v0.3.0: 模型与设置(目录/API key/模型切换/系统提示词)
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
import { useRef } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { useAgent } from './hooks/useAgent'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { ChatView } from './components/ChatView'
|
||||
import { Composer } from './components/Composer'
|
||||
import { SettingsModal } from './components/SettingsModal'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const { state, sessions, send, stop, clearError, newSession, openSession, renameSession, deleteSession } = useAgent()
|
||||
const prefillRef = useRef<string | null>(null)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
const handlePickSuggestion = (text: string): void => {
|
||||
prefillRef.current = text
|
||||
@@ -26,6 +28,7 @@ export default function App(): JSX.Element {
|
||||
onOpenSession={openSession}
|
||||
onRenameSession={renameSession}
|
||||
onDeleteSession={deleteSession}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
/>
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<ChatView
|
||||
@@ -47,6 +50,7 @@ export default function App(): JSX.Element {
|
||||
prefillRef={prefillRef}
|
||||
/>
|
||||
</main>
|
||||
<SettingsModal open={settingsOpen} sessionState={state.sessionState} onClose={() => setSettingsOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { Check, Cpu, KeyRound, LogOut, Search, Sparkles, X } from 'lucide-react'
|
||||
import type { ModelCatalog, ProviderCatalogEntry, SessionState } from '@shared/types'
|
||||
|
||||
type Tab = 'model' | 'prompt'
|
||||
|
||||
function ProviderBadge({ provider }: { provider: ProviderCatalogEntry }): JSX.Element {
|
||||
if (provider.hasApiKey) {
|
||||
return <span className="rounded-full bg-emerald-950/70 px-1.5 py-0.5 text-[10px] text-emerald-300">API Key</span>
|
||||
}
|
||||
if (provider.isOAuth) {
|
||||
return <span className="rounded-full bg-sky-950/70 px-1.5 py-0.5 text-[10px] text-sky-300">OAuth</span>
|
||||
}
|
||||
return <span className="rounded-full bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">未配置</span>
|
||||
}
|
||||
|
||||
function formatContext(n: number): string {
|
||||
if (n >= 1000000) return `${Math.round(n / 1000000)}M`
|
||||
if (n >= 1000) return `${Math.round(n / 1000)}K`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
export function SettingsModal({
|
||||
open,
|
||||
sessionState,
|
||||
onClose
|
||||
}: {
|
||||
open: boolean
|
||||
sessionState: SessionState | null
|
||||
onClose: () => void
|
||||
}): JSX.Element | null {
|
||||
const [tab, setTab] = useState<Tab>('model')
|
||||
const [catalog, setCatalog] = useState<ModelCatalog | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [keyProvider, setKeyProvider] = useState<string | null>(null)
|
||||
const [keyValue, setKeyValue] = useState('')
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [promptDirty, setPromptDirty] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null)
|
||||
|
||||
const refreshCatalog = (): void => {
|
||||
setLoading(true)
|
||||
window.api
|
||||
.getModelCatalog()
|
||||
.then(setCatalog)
|
||||
.catch((err: unknown) =>
|
||||
setNotice({ kind: 'err', text: err instanceof Error ? err.message : String(err) })
|
||||
)
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
refreshCatalog()
|
||||
void window.api
|
||||
.getSettings()
|
||||
.then((s) => {
|
||||
setPrompt(s.systemPrompt)
|
||||
setPromptDirty(false)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
setNotice(null)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
const currentProvider = sessionState?.model?.provider
|
||||
const currentModel = sessionState?.model?.id
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!catalog) return []
|
||||
const q = search.trim().toLowerCase()
|
||||
return catalog.providers
|
||||
.map((p) => ({
|
||||
...p,
|
||||
models: q
|
||||
? p.models.filter(
|
||||
(m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q) || p.name.toLowerCase().includes(q)
|
||||
)
|
||||
: p.models
|
||||
}))
|
||||
.filter((p) => p.models.length > 0 || (!q && p.models.length === 0))
|
||||
}, [catalog, search])
|
||||
|
||||
const run = (label: string, fn: () => Promise<void>, after?: () => void): void => {
|
||||
setBusy(true)
|
||||
setNotice(null)
|
||||
fn()
|
||||
.then(() => {
|
||||
setNotice({ kind: 'ok', text: label })
|
||||
after?.()
|
||||
})
|
||||
.catch((err: unknown) => setNotice({ kind: 'err', text: err instanceof Error ? err.message : String(err) }))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={onClose}>
|
||||
<div
|
||||
className="flex h-[600px] w-[720px] max-w-[92vw] flex-col overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center gap-1 border-b border-zinc-800/80 px-5 py-3">
|
||||
<div className="mr-3 text-[14px] font-medium text-zinc-100">设置</div>
|
||||
{(
|
||||
[
|
||||
['model', '模型与密钥', Cpu],
|
||||
['prompt', '系统提示词', Sparkles]
|
||||
] as const
|
||||
).map(([id, label, Icon]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTab(id)}
|
||||
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[12.5px] transition-colors ${
|
||||
tab === id ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-500 hover:bg-zinc-800/50 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<Icon size={13} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ml-auto rounded-lg p-1.5 text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
{tab === 'model' ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-2 px-5 pb-2 pt-3">
|
||||
<div className="relative flex-1">
|
||||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-600" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索模型 / 服务商…"
|
||||
className="w-full rounded-lg border border-zinc-800 bg-zinc-900/60 py-1.5 pl-8 pr-3 text-[12.5px] text-zinc-200 outline-none placeholder:text-zinc-600 focus:border-zinc-700"
|
||||
/>
|
||||
</div>
|
||||
{loading && <span className="text-[11px] text-zinc-600">加载中…</span>}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 pb-4">
|
||||
{filtered.map((provider) => (
|
||||
<div key={provider.id}>
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<span className="text-[12px] font-medium text-zinc-300">{provider.name}</span>
|
||||
<ProviderBadge provider={provider} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setKeyProvider(provider.id)
|
||||
setKeyValue('')
|
||||
}}
|
||||
className="ml-auto flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300"
|
||||
>
|
||||
<KeyRound size={11} />
|
||||
{provider.hasApiKey ? '更换密钥' : '设置密钥'}
|
||||
</button>
|
||||
{(provider.hasApiKey || provider.isOAuth) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run('已移除凭据', () => window.api.logoutProvider(provider.id), refreshCatalog)
|
||||
}
|
||||
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-red-950/60 hover:text-red-300"
|
||||
>
|
||||
<LogOut size={11} />
|
||||
移除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{keyProvider === provider.id && (
|
||||
<div className="mb-2 flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-2">
|
||||
<input
|
||||
type="password"
|
||||
value={keyValue}
|
||||
onChange={(e) => setKeyValue(e.target.value)}
|
||||
placeholder="输入 API Key(保存到 ~/.pi/agent/auth.json,与 pi CLI 共享)"
|
||||
className="flex-1 rounded-md border border-zinc-800 bg-zinc-950 px-2.5 py-1.5 font-mono text-[12px] text-zinc-200 outline-none focus:border-emerald-800"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !keyValue.trim()}
|
||||
onClick={() =>
|
||||
run('密钥已保存', () => window.api.setApiKey(provider.id, keyValue.trim()), () => {
|
||||
setKeyProvider(null)
|
||||
setKeyValue('')
|
||||
refreshCatalog()
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-1 rounded-md bg-emerald-600 px-2.5 py-1.5 text-[12px] text-white transition-colors hover:bg-emerald-500 disabled:opacity-50"
|
||||
>
|
||||
<Check size={12} />
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setKeyProvider(null)
|
||||
setKeyValue('')
|
||||
}}
|
||||
className="rounded-md px-2 py-1.5 text-[12px] text-zinc-500 hover:bg-zinc-800"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{provider.models.map((m) => {
|
||||
const selected = provider.id === currentProvider && m.id === currentModel
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => run(`已切换到 ${m.name}`, () => window.api.setModel(provider.id, m.id))}
|
||||
className={`flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors ${
|
||||
selected ? 'bg-emerald-950/50 ring-1 ring-emerald-900/60' : 'hover:bg-zinc-800/50'
|
||||
}`}
|
||||
>
|
||||
{selected ? (
|
||||
<Check size={13} className="shrink-0 text-emerald-400" />
|
||||
) : (
|
||||
<span className="h-[13px] w-[13px] shrink-0 rounded-full border border-zinc-700" />
|
||||
)}
|
||||
<span className={`text-[12.5px] ${selected ? 'text-emerald-100' : 'text-zinc-300'}`}>
|
||||
{m.name}
|
||||
</span>
|
||||
<span className="font-mono text-[10.5px] text-zinc-600">{m.id}</span>
|
||||
<span className="ml-auto flex items-center gap-2 text-[10.5px] text-zinc-600">
|
||||
{m.reasoning && <span>推理</span>}
|
||||
<span>{formatContext(m.contextWindow)} ctx</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{provider.models.length === 0 && (
|
||||
<div className="px-2.5 py-2 text-[11.5px] text-zinc-600">配置密钥后显示可用模型</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="py-10 text-center text-[12.5px] text-zinc-600">没有匹配的模型</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col px-5 py-4">
|
||||
<div className="mb-2 text-[12px] leading-5 text-zinc-500">
|
||||
覆盖 pi 默认系统提示词。留空使用默认(面向编码的提示词)。保存后运行时重建,当前最近会话的历史会保留。
|
||||
</div>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => {
|
||||
setPrompt(e.target.value)
|
||||
setPromptDirty(true)
|
||||
}}
|
||||
spellCheck={false}
|
||||
placeholder={'例如:你是店铺运营助手,负责热点词分析、商品文案生成与上架流程编排…'}
|
||||
className="min-h-0 flex-1 resize-none rounded-xl border border-zinc-800 bg-zinc-900/60 p-3.5 font-mono text-[12.5px] leading-6 text-zinc-200 outline-none placeholder:text-zinc-700 focus:border-zinc-700"
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !promptDirty}
|
||||
onClick={() =>
|
||||
run('系统提示词已保存,运行时已重建', () => window.api.setSystemPrompt(prompt), () =>
|
||||
setPromptDirty(false)
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3.5 py-1.5 text-[12.5px] text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Check size={13} />
|
||||
保存并应用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 通知条 */}
|
||||
{notice && (
|
||||
<div
|
||||
className={`border-t px-5 py-2 text-[12px] ${
|
||||
notice.kind === 'ok'
|
||||
? 'border-emerald-900/50 bg-emerald-950/40 text-emerald-300'
|
||||
: 'border-red-900/50 bg-red-950/40 text-red-300'
|
||||
}`}
|
||||
>
|
||||
{notice.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { Check, Cpu, MessageSquare, Pencil, Store, Trash2, X } from 'lucide-react'
|
||||
import { Check, Cpu, MessageSquare, Pencil, Settings, Store, Trash2, X } from 'lucide-react'
|
||||
import type { SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
|
||||
|
||||
interface VersionInfo {
|
||||
@@ -105,7 +105,8 @@ export function Sidebar({
|
||||
onNewSession,
|
||||
onOpenSession,
|
||||
onRenameSession,
|
||||
onDeleteSession
|
||||
onDeleteSession,
|
||||
onOpenSettings
|
||||
}: {
|
||||
sessionState: SessionState | null
|
||||
stats: SessionStatsInfo | null
|
||||
@@ -115,6 +116,7 @@ export function Sidebar({
|
||||
onOpenSession: (path: string) => void
|
||||
onRenameSession: (name: string) => void
|
||||
onDeleteSession: (path: string) => void
|
||||
onOpenSettings: () => void
|
||||
}): JSX.Element {
|
||||
const [versions, setVersions] = useState<VersionInfo | null>(null)
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
@@ -293,6 +295,14 @@ export function Sidebar({
|
||||
<div className="text-[10.5px] text-zinc-600">{sessionState?.model ? `${sessionState.model.provider}` : '—'}</div>
|
||||
</div>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${connected ? 'bg-emerald-400' : 'bg-zinc-600'}`} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
title="设置(模型 / 密钥 / 系统提示词)"
|
||||
className="shrink-0 rounded-md p-1 text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
<Settings size={13} />
|
||||
</button>
|
||||
</div>
|
||||
{stats && (
|
||||
<div className="mt-2.5 flex items-center justify-between text-[10.5px] text-zinc-600">
|
||||
|
||||
@@ -123,6 +123,8 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
||||
switch (event.type) {
|
||||
case 'session_ready':
|
||||
return { ...state, ready: true, sessionState: event.state }
|
||||
case 'state':
|
||||
return { ...state, ready: true, sessionState: event.state }
|
||||
case 'session_error':
|
||||
return { ...state, sessionError: event.message }
|
||||
case 'session_switched':
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { AgentUiEvent, ModelInfo, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
|
||||
import type {
|
||||
AgentUiEvent,
|
||||
AppSettings,
|
||||
ModelCatalog,
|
||||
ModelInfo,
|
||||
SessionMeta,
|
||||
SessionState,
|
||||
SessionStatsInfo
|
||||
} from '@shared/types'
|
||||
|
||||
export type { AgentUiEvent, ModelInfo, SessionMeta, SessionState, SessionStatsInfo }
|
||||
export type { AgentUiEvent, AppSettings, ModelCatalog, ModelInfo, SessionMeta, SessionState, SessionStatsInfo }
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -15,6 +23,12 @@ declare global {
|
||||
openSession: (path: string) => Promise<void>
|
||||
renameSession: (name: string) => Promise<void>
|
||||
deleteSession: (path: string) => Promise<void>
|
||||
getModelCatalog: () => Promise<ModelCatalog>
|
||||
setModel: (providerId: string, modelId: string) => Promise<void>
|
||||
setApiKey: (providerId: string, apiKey: string) => Promise<void>
|
||||
logoutProvider: (providerId: string) => Promise<void>
|
||||
getSettings: () => Promise<AppSettings>
|
||||
setSystemPrompt: (text: string) => Promise<void>
|
||||
onEvent: (callback: (event: AgentUiEvent) => void) => () => void
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user