v0.3.0: 模型与设置(目录/API key/模型切换/系统提示词)

This commit is contained in:
Rick Ye
2026-09-05 09:41:57 +08:00
parent 9984e2a427
commit fe70cb0f71
11 changed files with 631 additions and 8 deletions
+5 -1
View File
@@ -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':
+16 -2
View File
@@ -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
}
}