From fe70cb0f7165339c3d9162fbebfb32f37b377102 Mon Sep 17 00:00:00 2001 From: Rick Ye Date: Sat, 5 Sep 2026 09:41:57 +0800 Subject: [PATCH] =?UTF-8?q?v0.3.0:=20=E6=A8=A1=E5=9E=8B=E4=B8=8E=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=EF=BC=88=E7=9B=AE=E5=BD=95/API=20key/=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=88=87=E6=8D=A2/=E7=B3=BB=E7=BB=9F=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/scripts/smoke-models.mjs | 65 ++++ apps/desktop/src/main/ipc.ts | 33 ++ apps/desktop/src/main/pi/agent-service.ts | 136 +++++++- apps/desktop/src/preload/index.ts | 10 +- apps/desktop/src/renderer/src/App.tsx | 6 +- .../renderer/src/components/SettingsModal.tsx | 309 ++++++++++++++++++ .../src/renderer/src/components/Sidebar.tsx | 14 +- .../src/renderer/src/hooks/useAgent.ts | 2 + apps/desktop/src/renderer/src/lib/api.ts | 18 +- apps/desktop/src/shared/types.ts | 29 ++ docs/roadmap.md | 17 + 11 files changed, 631 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/scripts/smoke-models.mjs create mode 100644 apps/desktop/src/renderer/src/components/SettingsModal.tsx diff --git a/apps/desktop/scripts/smoke-models.mjs b/apps/desktop/scripts/smoke-models.mjs new file mode 100644 index 0000000..3466310 --- /dev/null +++ b/apps/desktop/scripts/smoke-models.mjs @@ -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) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index f89bcc0..5b87f69 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -72,6 +72,39 @@ export function registerIpc(): AgentService { }) }) + // ---- 模型与凭据 ---- + const pass = (fn: () => Promise): Promise => + 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', () => ({ app: process.env.npm_package_version ?? '0.0.0', electron: process.versions.electron, diff --git a/apps/desktop/src/main/pi/agent-service.ts b/apps/desktop/src/main/pi/agent-service.ts index f444fd5..f252d49 100644 --- a/apps/desktop/src/main/pi/agent-service.ts +++ b/apps/desktop/src/main/pi/agent-service.ts @@ -1,4 +1,6 @@ import { app, shell } from 'electron' +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' import { createAgentSessionRuntime, createAgentSessionServices, @@ -11,7 +13,15 @@ import { type CreateAgentSessionRuntimeFactory, type SessionInfo } 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 快照节流经验取略低值 */ const DELTA_FLUSH_MS = 40 @@ -30,11 +40,35 @@ export class AgentService { private flushTimer: NodeJS.Timeout | null = null private attached: AgentSession | null = null private unsubscribeAttached: (() => void) | null = null + private settings: AppSettings = { systemPrompt: '' } private get cwd(): string { 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 + 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 { this.listeners.add(fn) return () => this.listeners.delete(fn) @@ -75,7 +109,14 @@ export class AgentService { private async create(): Promise { const cwd = this.cwd 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 变化时重建服务,否则复用 const factory: CreateAgentSessionRuntimeFactory = async ({ @@ -330,6 +371,97 @@ export class AgentService { await runtime.newSession() } } + + // ---- 模型与凭据 ---- + + async getModelCatalog(): Promise { + 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 { + 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 { + 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 { + 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 { + this.settings = { systemPrompt: text } + this.saveSettings() + await this.rebuildRuntime() + } + + private async rebuildRuntime(): Promise { + 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 { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index a41dee4..7ab8d1d 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -1,5 +1,5 @@ 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 = { send: (text: string): Promise => ipcRenderer.invoke('agent:send', text), @@ -13,6 +13,14 @@ const api = { openSession: (path: string): Promise => ipcRenderer.invoke('sessions:open', path), renameSession: (name: string): Promise => ipcRenderer.invoke('sessions:rename', name), deleteSession: (path: string): Promise => ipcRenderer.invoke('sessions:delete', path), + getModelCatalog: (): Promise => ipcRenderer.invoke('models:catalog'), + setModel: (providerId: string, modelId: string): Promise => + ipcRenderer.invoke('models:set', providerId, modelId), + setApiKey: (providerId: string, apiKey: string): Promise => + ipcRenderer.invoke('auth:setKey', providerId, apiKey), + logoutProvider: (providerId: string): Promise => ipcRenderer.invoke('auth:logout', providerId), + getSettings: (): Promise => ipcRenderer.invoke('settings:get'), + setSystemPrompt: (text: string): Promise => ipcRenderer.invoke('settings:setPrompt', text), onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => { const listener = (_e: Electron.IpcRendererEvent, event: AgentUiEvent): void => callback(event) ipcRenderer.on('agent:event', listener) diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 83fa576..a48d004 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -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(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)} />
+ setSettingsOpen(false)} /> ) } diff --git a/apps/desktop/src/renderer/src/components/SettingsModal.tsx b/apps/desktop/src/renderer/src/components/SettingsModal.tsx new file mode 100644 index 0000000..aef6dae --- /dev/null +++ b/apps/desktop/src/renderer/src/components/SettingsModal.tsx @@ -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 API Key + } + if (provider.isOAuth) { + return OAuth + } + return 未配置 +} + +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('model') + const [catalog, setCatalog] = useState(null) + const [loading, setLoading] = useState(false) + const [search, setSearch] = useState('') + const [keyProvider, setKeyProvider] = useState(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, 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 ( +
+
e.stopPropagation()} + > + {/* 头部 */} +
+
设置
+ {( + [ + ['model', '模型与密钥', Cpu], + ['prompt', '系统提示词', Sparkles] + ] as const + ).map(([id, label, Icon]) => ( + + ))} + +
+ + {/* 内容 */} + {tab === 'model' ? ( +
+
+
+ + 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" + /> +
+ {loading && 加载中…} +
+
+ {filtered.map((provider) => ( +
+
+ {provider.name} + + + {(provider.hasApiKey || provider.isOAuth) && ( + + )} +
+ + {keyProvider === provider.id && ( +
+ 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" + /> + + +
+ )} + +
+ {provider.models.map((m) => { + const selected = provider.id === currentProvider && m.id === currentModel + return ( + + ) + })} + {provider.models.length === 0 && ( +
配置密钥后显示可用模型
+ )} +
+
+ ))} + {!loading && filtered.length === 0 && ( +
没有匹配的模型
+ )} +
+
+ ) : ( +
+
+ 覆盖 pi 默认系统提示词。留空使用默认(面向编码的提示词)。保存后运行时重建,当前最近会话的历史会保留。 +
+