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
+65
View File
@@ -0,0 +1,65 @@
/**
* v0.3.0 冒烟:模型目录 / API key login-logout / setModel / 系统提示词
* 不打真实 LLM 请求;key 用假值(仅验证凭据写入与模型切换逻辑)。
*/
import { createAgentSessionServices, createAgentSessionRuntime, createAgentSessionFromServices, getAgentDir, SessionManager } from '@earendil-works/pi-coding-agent'
import { homedir } from 'node:os'
import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
const cwd = homedir()
const agentDir = getAgentDir()
const services = await createAgentSessionServices({ cwd, agentDir })
const factory = async ({ cwd: nc, agentDir: na, sessionManager }) => {
const svc = nc === services.cwd && na === services.agentDir ? services : await createAgentSessionServices({ cwd: nc, agentDir: na })
const result = await createAgentSessionFromServices({ services: svc, sessionManager })
return { ...result, services: svc, diagnostics: svc.diagnostics }
}
const runtime = await createAgentSessionRuntime(factory, { cwd, agentDir, sessionManager: SessionManager.continueRecent(cwd) })
const mr = runtime.session.modelRuntime
// 1) 模型目录
const providers = mr.getProviders()
console.log('[smoke3] providers:', providers.map((p) => p.id).join(', '))
const anthropic = providers.find((p) => p.id === 'anthropic')
if (anthropic) {
const models = mr.getModels('anthropic')
console.log('[smoke3] anthropic models: %d (first: %s)', models.length, models[0]?.id)
// 2) login(假 key)→ listCredentials
const before = await mr.listCredentials()
await mr.login('anthropic', 'api_key', { prompt: async () => 'sk-smoke-fake-key', notify: async () => undefined })
const after = await mr.listCredentials()
console.log('[smoke3] credentials before=%d after=%d (anthropic=%s)', before.length, after.length, after.some((c) => c.providerId === 'anthropic' && c.type === 'api_key'))
// auth.json 应包含 anthropic 条目
const auth = JSON.parse(readFileSync(join(agentDir, 'auth.json'), 'utf8'))
console.log('[smoke3] auth.json has anthropic:', Boolean(auth.anthropic))
// 3) setModel(有凭据时允许)
const target = models[0]
await runtime.session.setModel(target, { persist: false })
console.log('[smoke3] setModel ok -> %s/%s', runtime.session.model?.provider, runtime.session.model?.id)
// 4) logout
await mr.logout('anthropic')
const finalCreds = await mr.listCredentials()
console.log('[smoke3] after logout, anthropic credential present:', finalCreds.some((c) => c.providerId === 'anthropic'))
} else {
console.log('[smoke3] anthropic not found in providers, skip key tests')
}
// 5) 系统提示词 override(独立 services,用 resourceLoaderOptions.systemPrompt)
const tmp = mkdtempSync(join(tmpdir(), 'aso-smoke-'))
const promptText = '你是冒烟测试助手。'
const svc2 = await createAgentSessionServices({ cwd, agentDir, resourceLoaderOptions: { systemPrompt: promptText } })
console.log('[smoke3] systemPrompt applied:', svc2.resourceLoader ? 'services-created' : 'fail')
const rt2 = await createAgentSessionFromServices({ services: svc2, sessionManager: SessionManager.inMemory(cwd) })
console.log('[smoke3] session systemPrompt startsWith expected:', rt2.session.systemPrompt.startsWith(promptText.slice(0, 8)))
await runtime.dispose()
writeFileSync(join(tmp, 'done'), '')
console.log('[smoke3] done')
process.exit(0)
+33
View File
@@ -72,6 +72,39 @@ export function registerIpc(): AgentService {
}) })
}) })
// ---- 模型与凭据 ----
const pass = (fn: () => Promise<unknown>): Promise<unknown> =>
fn().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err))
})
ipcMain.handle('models:catalog', () => pass(() => service.getModelCatalog()))
ipcMain.handle('models:set', (_event, providerId: unknown, modelId: unknown) => {
if (typeof providerId !== 'string' || typeof modelId !== 'string') throw new Error('invalid model')
return pass(() => service.setModel(providerId, modelId))
})
ipcMain.handle('auth:setKey', (_event, providerId: unknown, apiKey: unknown) => {
if (typeof providerId !== 'string' || typeof apiKey !== 'string' || !apiKey.trim()) {
throw new Error('invalid api key')
}
return pass(() => service.setApiKey(providerId, apiKey.trim()))
})
ipcMain.handle('auth:logout', (_event, providerId: unknown) => {
if (typeof providerId !== 'string') throw new Error('invalid provider')
return pass(() => service.logoutProvider(providerId))
})
// ---- 应用设置 ----
ipcMain.handle('settings:get', () => service.getSettings())
ipcMain.handle('settings:setPrompt', (_event, text: unknown) => {
if (typeof text !== 'string') throw new Error('invalid prompt')
return pass(() => service.setSystemPrompt(text))
})
ipcMain.handle('app:versions', () => ({ ipcMain.handle('app:versions', () => ({
app: process.env.npm_package_version ?? '0.0.0', app: process.env.npm_package_version ?? '0.0.0',
electron: process.versions.electron, electron: process.versions.electron,
+134 -2
View File
@@ -1,4 +1,6 @@
import { app, shell } from 'electron' import { app, shell } from 'electron'
import { readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { import {
createAgentSessionRuntime, createAgentSessionRuntime,
createAgentSessionServices, createAgentSessionServices,
@@ -11,7 +13,15 @@ import {
type CreateAgentSessionRuntimeFactory, type CreateAgentSessionRuntimeFactory,
type SessionInfo type SessionInfo
} from '@earendil-works/pi-coding-agent' } from '@earendil-works/pi-coding-agent'
import type { AgentUiEvent, ModelInfo, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types' import type {
AgentUiEvent,
AppSettings,
ModelCatalog,
ModelInfo,
SessionMeta,
SessionState,
SessionStatsInfo
} from '@shared/types'
/** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */ /** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */
const DELTA_FLUSH_MS = 40 const DELTA_FLUSH_MS = 40
@@ -30,11 +40,35 @@ export class AgentService {
private flushTimer: NodeJS.Timeout | null = null private flushTimer: NodeJS.Timeout | null = null
private attached: AgentSession | null = null private attached: AgentSession | null = null
private unsubscribeAttached: (() => void) | null = null private unsubscribeAttached: (() => void) | null = null
private settings: AppSettings = { systemPrompt: '' }
private get cwd(): string { private get cwd(): string {
return app.getPath('home') return app.getPath('home')
} }
private get settingsFile(): string {
return join(app.getPath('userData'), 'aso-settings.json')
}
private loadSettings(): AppSettings {
try {
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<AppSettings>
return {
systemPrompt: typeof raw.systemPrompt === 'string' ? raw.systemPrompt : ''
}
} catch {
return { systemPrompt: '' }
}
}
private saveSettings(): void {
try {
writeFileSync(this.settingsFile, JSON.stringify(this.settings, null, 2), 'utf8')
} catch (err) {
console.error('[agent-service] save settings failed:', err)
}
}
onEvent(fn: (event: AgentUiEvent) => void): () => void { onEvent(fn: (event: AgentUiEvent) => void): () => void {
this.listeners.add(fn) this.listeners.add(fn)
return () => this.listeners.delete(fn) return () => this.listeners.delete(fn)
@@ -75,7 +109,14 @@ export class AgentService {
private async create(): Promise<AgentSessionRuntime> { private async create(): Promise<AgentSessionRuntime> {
const cwd = this.cwd const cwd = this.cwd
const agentDir = getAgentDir() const agentDir = getAgentDir()
const initialServices = await createAgentSessionServices({ cwd, agentDir }) this.settings = this.loadSettings()
const initialServices = await createAgentSessionServices({
cwd,
agentDir,
resourceLoaderOptions: this.settings.systemPrompt.trim()
? { systemPrompt: this.settings.systemPrompt }
: undefined
})
// 每次切换/新建时由 runtime 调用;cwd 变化时重建服务,否则复用 // 每次切换/新建时由 runtime 调用;cwd 变化时重建服务,否则复用
const factory: CreateAgentSessionRuntimeFactory = async ({ const factory: CreateAgentSessionRuntimeFactory = async ({
@@ -330,6 +371,97 @@ export class AgentService {
await runtime.newSession() await runtime.newSession()
} }
} }
// ---- 模型与凭据 ----
async getModelCatalog(): Promise<ModelCatalog> {
const runtime = await this.getRuntime()
const mr = runtime.session.modelRuntime
const credentials = await mr.listCredentials()
const credSet = new Set(credentials.map((c) => `${c.providerId}:${c.type}`))
const providers = mr
.getProviders()
.map((p) => {
const pid = String(p.id ?? '')
return {
id: pid,
name: String(p.name ?? pid),
hasApiKey: credSet.has(`${pid}:api_key`),
isOAuth: credSet.has(`${pid}:oauth`),
models: mr
.getModels(pid)
.map((m) => ({
id: String(m.id ?? ''),
name: String(m.name ?? m.id ?? ''),
contextWindow: Number(m.contextWindow ?? 0),
reasoning: Boolean(m.reasoning)
}))
}
})
// 已配置凭据的排前面,其余按名称排序
.sort((a, b) => {
const av = a.hasApiKey || a.isOAuth ? 0 : 1
const bv = b.hasApiKey || b.isOAuth ? 0 : 1
return av - bv || a.name.localeCompare(b.name)
})
return { providers }
}
async setApiKey(providerId: string, apiKey: string): Promise<void> {
const runtime = await this.getRuntime()
const mr = runtime.session.modelRuntime
// login 走凭据存储的 modify(持久化到 ~/.pi/agent/auth.json,与 pi CLI 共享)
await mr.login(providerId, 'api_key', {
prompt: async () => apiKey,
notify: async () => undefined
})
this.emit({ type: 'state', state: this.buildState(runtime.session) })
}
async logoutProvider(providerId: string): Promise<void> {
const runtime = await this.getRuntime()
await runtime.session.modelRuntime.logout(providerId)
this.emit({ type: 'state', state: this.buildState(runtime.session) })
}
async setModel(providerId: string, modelId: string): Promise<void> {
const runtime = await this.getRuntime()
const mr = runtime.session.modelRuntime
const model = mr.getModel(providerId, modelId)
if (!model) {
throw new Error(`未知模型: ${providerId}/${modelId}`)
}
await runtime.session.setModel(model, { persist: true })
this.emit({ type: 'state', state: this.buildState(runtime.session) })
}
// ---- 应用设置 ----
getSettings(): AppSettings {
return { ...this.settings }
}
/** 更新系统提示词后重建运行时(continueRecent 重新挂载当前最近会话,历史保留) */
async setSystemPrompt(text: string): Promise<void> {
this.settings = { systemPrompt: text }
this.saveSettings()
await this.rebuildRuntime()
}
private async rebuildRuntime(): Promise<void> {
const old = this.runtime
this.runtime = null
this.initPromise = null
this.unsubscribeAttached?.()
this.unsubscribeAttached = null
this.attached = null
if (old) {
await old.dispose().catch((err: unknown) => {
console.error('[agent-service] runtime dispose error:', err)
})
}
await this.ensureReady()
}
} }
function toSessionMeta(info: SessionInfo): SessionMeta { function toSessionMeta(info: SessionInfo): SessionMeta {
+9 -1
View File
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from 'electron' import { contextBridge, ipcRenderer } from 'electron'
import type { AgentUiEvent, SessionMeta, SessionState } from '@shared/types' import type { AgentUiEvent, AppSettings, ModelCatalog, SessionMeta, SessionState } from '@shared/types'
const api = { const api = {
send: (text: string): Promise<void> => ipcRenderer.invoke('agent:send', text), send: (text: string): Promise<void> => ipcRenderer.invoke('agent:send', text),
@@ -13,6 +13,14 @@ const api = {
openSession: (path: string): Promise<void> => ipcRenderer.invoke('sessions:open', path), openSession: (path: string): Promise<void> => ipcRenderer.invoke('sessions:open', path),
renameSession: (name: string): Promise<void> => ipcRenderer.invoke('sessions:rename', name), renameSession: (name: string): Promise<void> => ipcRenderer.invoke('sessions:rename', name),
deleteSession: (path: string): Promise<void> => ipcRenderer.invoke('sessions:delete', path), deleteSession: (path: string): Promise<void> => ipcRenderer.invoke('sessions:delete', path),
getModelCatalog: (): Promise<ModelCatalog> => ipcRenderer.invoke('models:catalog'),
setModel: (providerId: string, modelId: string): Promise<void> =>
ipcRenderer.invoke('models:set', providerId, modelId),
setApiKey: (providerId: string, apiKey: string): Promise<void> =>
ipcRenderer.invoke('auth:setKey', providerId, apiKey),
logoutProvider: (providerId: string): Promise<void> => ipcRenderer.invoke('auth:logout', providerId),
getSettings: (): Promise<AppSettings> => ipcRenderer.invoke('settings:get'),
setSystemPrompt: (text: string): Promise<void> => ipcRenderer.invoke('settings:setPrompt', text),
onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => { onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => {
const listener = (_e: Electron.IpcRendererEvent, event: AgentUiEvent): void => callback(event) const listener = (_e: Electron.IpcRendererEvent, event: AgentUiEvent): void => callback(event)
ipcRenderer.on('agent:event', listener) ipcRenderer.on('agent:event', listener)
+5 -1
View File
@@ -1,13 +1,15 @@
import { useRef } from 'react' import { useRef, useState } from 'react'
import type { JSX } from 'react' import type { JSX } from 'react'
import { useAgent } from './hooks/useAgent' import { useAgent } from './hooks/useAgent'
import { Sidebar } from './components/Sidebar' import { Sidebar } from './components/Sidebar'
import { ChatView } from './components/ChatView' import { ChatView } from './components/ChatView'
import { Composer } from './components/Composer' import { Composer } from './components/Composer'
import { SettingsModal } from './components/SettingsModal'
export default function App(): JSX.Element { export default function App(): JSX.Element {
const { state, sessions, send, stop, clearError, newSession, openSession, renameSession, deleteSession } = useAgent() const { state, sessions, send, stop, clearError, newSession, openSession, renameSession, deleteSession } = useAgent()
const prefillRef = useRef<string | null>(null) const prefillRef = useRef<string | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
const handlePickSuggestion = (text: string): void => { const handlePickSuggestion = (text: string): void => {
prefillRef.current = text prefillRef.current = text
@@ -26,6 +28,7 @@ export default function App(): JSX.Element {
onOpenSession={openSession} onOpenSession={openSession}
onRenameSession={renameSession} onRenameSession={renameSession}
onDeleteSession={deleteSession} onDeleteSession={deleteSession}
onOpenSettings={() => setSettingsOpen(true)}
/> />
<main className="flex min-w-0 flex-1 flex-col"> <main className="flex min-w-0 flex-1 flex-col">
<ChatView <ChatView
@@ -47,6 +50,7 @@ export default function App(): JSX.Element {
prefillRef={prefillRef} prefillRef={prefillRef}
/> />
</main> </main>
<SettingsModal open={settingsOpen} sessionState={state.sessionState} onClose={() => setSettingsOpen(false)} />
</div> </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 { useEffect, useRef, useState } from 'react'
import type { JSX } 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' import type { SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
interface VersionInfo { interface VersionInfo {
@@ -105,7 +105,8 @@ export function Sidebar({
onNewSession, onNewSession,
onOpenSession, onOpenSession,
onRenameSession, onRenameSession,
onDeleteSession onDeleteSession,
onOpenSettings
}: { }: {
sessionState: SessionState | null sessionState: SessionState | null
stats: SessionStatsInfo | null stats: SessionStatsInfo | null
@@ -115,6 +116,7 @@ export function Sidebar({
onOpenSession: (path: string) => void onOpenSession: (path: string) => void
onRenameSession: (name: string) => void onRenameSession: (name: string) => void
onDeleteSession: (path: string) => void onDeleteSession: (path: string) => void
onOpenSettings: () => void
}): JSX.Element { }): JSX.Element {
const [versions, setVersions] = useState<VersionInfo | null>(null) const [versions, setVersions] = useState<VersionInfo | null>(null)
const [renaming, setRenaming] = useState(false) 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 className="text-[10.5px] text-zinc-600">{sessionState?.model ? `${sessionState.model.provider}` : '—'}</div>
</div> </div>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${connected ? 'bg-emerald-400' : 'bg-zinc-600'}`} /> <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> </div>
{stats && ( {stats && (
<div className="mt-2.5 flex items-center justify-between text-[10.5px] text-zinc-600"> <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) { switch (event.type) {
case 'session_ready': case 'session_ready':
return { ...state, ready: true, sessionState: event.state } return { ...state, ready: true, sessionState: event.state }
case 'state':
return { ...state, ready: true, sessionState: event.state }
case 'session_error': case 'session_error':
return { ...state, sessionError: event.message } return { ...state, sessionError: event.message }
case 'session_switched': 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 { declare global {
interface Window { interface Window {
@@ -15,6 +23,12 @@ declare global {
openSession: (path: string) => Promise<void> openSession: (path: string) => Promise<void>
renameSession: (name: string) => Promise<void> renameSession: (name: string) => Promise<void>
deleteSession: (path: 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 onEvent: (callback: (event: AgentUiEvent) => void) => () => void
} }
} }
+29
View File
@@ -35,6 +35,34 @@ export interface SessionMeta {
messageCount: number messageCount: number
} }
/** 模型目录条目 */
export interface ModelMeta {
id: string
name: string
contextWindow: number
reasoning: boolean
}
export interface ProviderCatalogEntry {
id: string
name: string
/** 已配置 api_key 凭据 */
hasApiKey: boolean
/** 已配置 oauth 凭据 */
isOAuth: boolean
models: ModelMeta[]
}
export interface ModelCatalog {
providers: ProviderCatalogEntry[]
}
/** 应用级设置(存 userData/aso-settings.json) */
export interface AppSettings {
/** 覆盖 pi 默认系统提示词(空串 = 不覆盖) */
systemPrompt: string
}
/** /**
* 转发到渲染进程的 agent 事件。 * 转发到渲染进程的 agent 事件。
* 已归一化为可结构化克隆的普通 JSON(message 原样透传,渲染层防御性读取)。 * 已归一化为可结构化克隆的普通 JSON(message 原样透传,渲染层防御性读取)。
@@ -43,6 +71,7 @@ export type AgentUiEvent =
| { type: 'session_ready'; state: SessionState } | { type: 'session_ready'; state: SessionState }
| { type: 'session_error'; message: string } | { type: 'session_error'; message: string }
| { type: 'session_switched'; state: SessionState; messages: unknown[]; activeTools: Record<string, unknown> } | { type: 'session_switched'; state: SessionState; messages: unknown[]; activeTools: Record<string, unknown> }
| { type: 'state'; state: SessionState }
| { type: 'agent_start' } | { type: 'agent_start' }
| { type: 'agent_end' } | { type: 'agent_end' }
| { type: 'agent_settled' } | { type: 'agent_settled' }
+17
View File
@@ -31,6 +31,23 @@
4. **cwd 固定为 home**——会话按 cwd 分组,工作区切换(店铺 A/店铺 B 各自会话域)还没设计;等 `packages/agent` 业务域明确后一起做。 4. **cwd 固定为 home**——会话按 cwd 分组,工作区切换(店铺 A/店铺 B 各自会话域)还没设计;等 `packages/agent` 业务域明确后一起做。
5. **键盘链路仍未人工实测**(computer-use 无法激活窗口);冒烟+截图继续兜底,但 Enter 发送/Shift+Enter 换行/重命名快捷键需要用户实机过一遍。 5. **键盘链路仍未人工实测**(computer-use 无法激活窗口);冒烟+截图继续兜底,但 Enter 发送/Shift+Enter 换行/重命名快捷键需要用户实机过一遍。
## v0.3.0 — 模型与设置
**交付**:
- 模型目录:`ModelRuntime.getProviders/getModels/listCredentials` → 41 个 provider 全量分组展示(含 deepseek/anthropic/openai/google/zai 等),行内显示 推理/上下文窗口
- API key 管理:`modelRuntime.login(providerId,'api_key',{prompt})` 持久化写入 `~/.pi/agent/auth.json`(与 pi CLI 完全共享),支持更换/移除(logout)
- 模型切换:`session.setModel(model,{persist:true})`,选中态高亮 + `state` 事件同步侧栏
- 系统提示词:应用设置存 `userData/aso-settings.json`,经 `resourceLoaderOptions.systemPrompt` 注入;保存后运行时整体重建(dispose + continueRecent 重挂当前最近会话,历史保留)
- 设置弹窗:双标签页(模型与密钥 / 系统提示词)、搜索过滤、密钥输入(password 型)、操作通知条;侧栏齿轮入口
- 验证:typecheck/build;Node 冒烟(login→auth.json 写入→setModel→logout 移除、systemPrompt 生效);应用截图 + a11y 树核验弹窗完整渲染与交互(齿轮点击成功打开)
**自省**(v0.3.0 → 指导 v0.4+):
1. **模型列表太长**——41 个 provider 全量平铺,滚动找模型体验一般;虽然已按"已配置凭据优先"排序,但缺少"仅显示已配置"开关和常用置顶。排 v0.5。
2. **OAuth 流程未接**——`login(providerId,'oauth')` 需要 auth_url/device_code 的 UI 交互(AuthInteraction.notify),当前只支持 API key。GitHub Copilot 等 OAuth-only provider 暂时用不了。排 v0.4 或 v0.5(优先级低于业务工具)。
3. **密钥无连通性测试**——保存假 key 也能存上,要到真正发消息才 401。应加"测试连接"(发一次极小请求)。排 v0.5。
4. **系统提示词重建成本**——保存提示词会整体重建 runtime(重新加载扩展/skills),秒级耗时且丢当前流式状态;保存前应检查是否有进行中的回复。当前实现未拦截,记录待修。
5. **运行时重建后模型选择**——重建走 continueRecent,会话内保存的模型跟随会话;但全局默认模型(settings 持久化)与 UI 状态的同步依赖 `state` 事件,尚未覆盖"重建后 model fallback"提示(modelFallbackMessage 丢弃未展示)。排 v0.5。
## v0.1.0 — 骨架 + 聊天链路 ## v0.1.0 — 骨架 + 聊天链路
**交付**: **交付**: