v0.3.0: 模型与设置(目录/API key/模型切换/系统提示词)
This commit is contained in:
@@ -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)
|
||||
@@ -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', () => ({
|
||||
app: process.env.npm_package_version ?? '0.0.0',
|
||||
electron: process.versions.electron,
|
||||
|
||||
@@ -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<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 {
|
||||
this.listeners.add(fn)
|
||||
return () => this.listeners.delete(fn)
|
||||
@@ -75,7 +109,14 @@ export class AgentService {
|
||||
private async create(): Promise<AgentSessionRuntime> {
|
||||
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<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 {
|
||||
|
||||
@@ -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<void> => ipcRenderer.invoke('agent:send', text),
|
||||
@@ -13,6 +13,14 @@ const api = {
|
||||
openSession: (path: string): Promise<void> => ipcRenderer.invoke('sessions:open', path),
|
||||
renameSession: (name: string): Promise<void> => ipcRenderer.invoke('sessions:rename', name),
|
||||
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) => {
|
||||
const listener = (_e: Electron.IpcRendererEvent, event: AgentUiEvent): void => callback(event)
|
||||
ipcRenderer.on('agent:event', listener)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,34 @@ export interface SessionMeta {
|
||||
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 事件。
|
||||
* 已归一化为可结构化克隆的普通 JSON(message 原样透传,渲染层防御性读取)。
|
||||
@@ -43,6 +71,7 @@ export type AgentUiEvent =
|
||||
| { type: 'session_ready'; state: SessionState }
|
||||
| { type: 'session_error'; message: string }
|
||||
| { type: 'session_switched'; state: SessionState; messages: unknown[]; activeTools: Record<string, unknown> }
|
||||
| { type: 'state'; state: SessionState }
|
||||
| { type: 'agent_start' }
|
||||
| { type: 'agent_end' }
|
||||
| { type: 'agent_settled' }
|
||||
|
||||
Reference in New Issue
Block a user