From 9984e2a427bf424452326cdb8f6ef4f9790d9684 Mon Sep 17 00:00:00 2001 From: Rick Ye Date: Sat, 5 Sep 2026 09:35:30 +0800 Subject: [PATCH] =?UTF-8?q?v0.2.0:=20=E4=BC=9A=E8=AF=9D=E7=AE=A1=E7=90=86?= =?UTF-8?q?=EF=BC=88=E5=88=97=E8=A1=A8/=E6=96=B0=E5=BB=BA/=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E6=81=A2=E5=A4=8D/=E9=87=8D=E5=91=BD=E5=90=8D/?= =?UTF-8?q?=E5=9B=9E=E6=94=B6=E7=AB=99=E5=88=A0=E9=99=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/scripts/smoke-sessions.mjs | 76 ++++++ apps/desktop/src/main/ipc.ts | 34 +++ apps/desktop/src/main/pi/agent-service.ts | 198 +++++++++++++--- apps/desktop/src/preload/index.ts | 33 ++- apps/desktop/src/renderer/src/App.tsx | 13 +- .../src/renderer/src/components/Sidebar.tsx | 217 ++++++++++++++++-- .../src/renderer/src/hooks/useAgent.ts | 184 ++++++++++++--- apps/desktop/src/renderer/src/lib/api.ts | 9 +- apps/desktop/src/shared/types.ts | 12 + docs/roadmap.md | 16 ++ 10 files changed, 697 insertions(+), 95 deletions(-) create mode 100644 apps/desktop/scripts/smoke-sessions.mjs diff --git a/apps/desktop/scripts/smoke-sessions.mjs b/apps/desktop/scripts/smoke-sessions.mjs new file mode 100644 index 0000000..ad353ad --- /dev/null +++ b/apps/desktop/scripts/smoke-sessions.mjs @@ -0,0 +1,76 @@ +/** + * pi SDK 会话管理冒烟测试(v0.2.0) + * 验证:runtime 创建、list、newSession、switchSession(历史恢复)、rename、删除(回收站)。 + */ +import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessionFromServices, getAgentDir, SessionManager } from '@earendil-works/pi-coding-agent' +import { homedir } from 'node:os' +import { join } from 'node:path' + +const cwd = homedir() +const agentDir = getAgentDir() + +console.log('[smoke2] listing sessions (cwd=%s)...', cwd) +const before = await SessionManager.list(cwd) +console.log('[smoke2] found %d sessions', before.length) + +const initialServices = await createAgentSessionServices({ cwd, agentDir }) +const factory = async ({ cwd: nextCwd, agentDir: nextAgentDir, sessionManager, sessionStartEvent }) => { + const services = + nextCwd === initialServices.cwd && nextAgentDir === initialServices.agentDir + ? initialServices + : await createAgentSessionServices({ cwd: nextCwd, agentDir: nextAgentDir }) + const result = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent }) + return { ...result, services, diagnostics: services.diagnostics } +} + +const sessionManager = SessionManager.create(cwd) +const runtime = await createAgentSessionRuntime(factory, { cwd, agentDir, sessionManager }) +let rebindCount = 0 +runtime.setRebindSession(async () => { + rebindCount += 1 +}) +console.log('[smoke2] runtime ready, session=%s', runtime.session.sessionId) + +// 重命名 +runtime.session.setSessionName('smoke-测试会话') +console.log('[smoke2] renamed to: %s', runtime.session.sessionName) + +// 会话里追加用户+助手消息(直接写会话文件,不触发 LLM) +// pi 约定:只有出现 assistant 消息后才会真正落盘(避免空会话残留文件) +runtime.session.sessionManager.appendMessage({ role: 'user', content: '这是一条冒烟测试消息', timestamp: Date.now() }) +runtime.session.sessionManager.appendMessage({ + role: 'assistant', + content: [{ type: 'text', text: '冒烟回复(不经过 LLM)' }], + api: 'openai-completions', + provider: 'smoke', + model: 'smoke-model', + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0 }, + stopReason: 'stop', + timestamp: Date.now() +}) +console.log('[smoke2] messages in session:', runtime.session.messages.length) + +const listAfter = await SessionManager.list(cwd) +const mine = listAfter.find((s) => s.name === 'smoke-测试会话' || s.firstMessage.includes('冒烟测试')) +console.log('[smoke2] session visible in list: %s (total %d)', Boolean(mine), listAfter.length) +if (mine) { + console.log('[smoke2] meta: name=%s firstMessage=%s count=%d', mine.name, JSON.stringify(mine.firstMessage.slice(0, 30)), mine.messageCount) + + // 新会话(切换) + await runtime.newSession() + console.log('[smoke2] newSession ok, rebind=%d, new id=%s, messages=%d', rebindCount, runtime.session.sessionId, runtime.session.messages.length) + + // 切回 + await runtime.switchSession(mine.path) + console.log( + '[smoke2] switch back ok, rebind=%d, id=%s, messages restored=%d, name=%s', + rebindCount, + runtime.session.sessionId, + runtime.session.messages.length, + runtime.session.sessionName ?? '(none)' + ) +} + +await runtime.dispose() +console.log('[smoke2] done') +process.exit(0) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 1f2d4de..f89bcc0 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -38,6 +38,40 @@ export function registerIpc(): AgentService { }) ) + // ---- 会话管理 ---- + ipcMain.handle('sessions:list', () => + service.listSessions().catch((err: unknown) => { + throw new Error(err instanceof Error ? err.message : String(err)) + }) + ) + + ipcMain.handle('sessions:new', () => + service.newSession().catch((err: unknown) => { + throw new Error(err instanceof Error ? err.message : String(err)) + }) + ) + + ipcMain.handle('sessions:open', (_event, path: unknown) => { + if (typeof path !== 'string' || !path.trim()) throw new Error('invalid session path') + return service.openSession(path).catch((err: unknown) => { + throw new Error(err instanceof Error ? err.message : String(err)) + }) + }) + + ipcMain.handle('sessions:rename', (_event, name: unknown) => { + if (typeof name !== 'string' || !name.trim()) throw new Error('invalid session name') + return service.renameSession(name.trim()).catch((err: unknown) => { + throw new Error(err instanceof Error ? err.message : String(err)) + }) + }) + + ipcMain.handle('sessions:delete', (_event, path: unknown) => { + if (typeof path !== 'string' || !path.trim()) throw new Error('invalid session path') + return service.deleteSession(path).catch((err: unknown) => { + throw new Error(err instanceof Error ? err.message : String(err)) + }) + }) + 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 d352cf4..f444fd5 100644 --- a/apps/desktop/src/main/pi/agent-service.ts +++ b/apps/desktop/src/main/pi/agent-service.ts @@ -1,27 +1,39 @@ -import { app } from 'electron' +import { app, shell } from 'electron' import { - createAgentSession, + createAgentSessionRuntime, + createAgentSessionServices, + createAgentSessionFromServices, + getAgentDir, + SessionManager, type AgentSession, type AgentSessionEvent, - type CreateAgentSessionResult + type AgentSessionRuntime, + type CreateAgentSessionRuntimeFactory, + type SessionInfo } from '@earendil-works/pi-coding-agent' -import type { AgentUiEvent, ModelInfo, SessionState, SessionStatsInfo } from '@shared/types' +import type { AgentUiEvent, ModelInfo, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types' /** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */ const DELTA_FLUSH_MS = 40 /** - * pi AgentSession 的主进程封装: - * - 懒初始化会话(默认复用 ~/.pi 的配置、凭据与会话持久化) - * - 订阅 pi 事件流,归一化为 AgentUiEvent 转发给渲染层 - * - 提供 prompt / abort / 状态与统计查询 + * pi 会话运行时的主进程封装: + * - 基于 AgentSessionRuntime 支持会话新建/切换(自动重挂事件订阅) + * - 复用 ~/.pi 的配置、凭据与会话持久化(sessions//) + * - 归一化 pi 事件为 AgentUiEvent 转发渲染层 */ export class AgentService { - private result: CreateAgentSessionResult | null = null - private initPromise: Promise | null = null + private runtime: AgentSessionRuntime | null = null + private initPromise: Promise | null = null private listeners = new Set<(event: AgentUiEvent) => void>() private deltaQueue: AgentUiEvent[] = [] private flushTimer: NodeJS.Timeout | null = null + private attached: AgentSession | null = null + private unsubscribeAttached: (() => void) | null = null + + private get cwd(): string { + return app.getPath('home') + } onEvent(fn: (event: AgentUiEvent) => void): () => void { this.listeners.add(fn) @@ -50,7 +62,7 @@ export class AgentService { }, DELTA_FLUSH_MS) } - private getSession(): Promise { + private getRuntime(): Promise { if (!this.initPromise) { this.initPromise = this.create().catch((err) => { this.initPromise = null @@ -60,21 +72,83 @@ export class AgentService { return this.initPromise } - private async create(): Promise { - const result = await createAgentSession({ - cwd: app.getPath('home') + private async create(): Promise { + const cwd = this.cwd + const agentDir = getAgentDir() + const initialServices = await createAgentSessionServices({ cwd, agentDir }) + + // 每次切换/新建时由 runtime 调用;cwd 变化时重建服务,否则复用 + const factory: CreateAgentSessionRuntimeFactory = async ({ + cwd: nextCwd, + agentDir: nextAgentDir, + sessionManager, + sessionStartEvent + }) => { + const services = + nextCwd === initialServices.cwd && nextAgentDir === initialServices.agentDir + ? initialServices + : await createAgentSessionServices({ cwd: nextCwd, agentDir: nextAgentDir }) + const result = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent }) + return { ...result, services, diagnostics: services.diagnostics } + } + + const sessionManager = SessionManager.continueRecent(cwd) + const runtime = await createAgentSessionRuntime(factory, { cwd, agentDir, sessionManager }) + // 会话被替换(new/switch/fork)后重挂事件订阅并通知渲染层 + runtime.setRebindSession(async (session) => { + this.attach(session) + this.emitSwitched(session) }) - result.session.subscribe((event) => this.handleEvent(event)) - return result + this.attach(runtime.session) + return runtime + } + + private attach(session: AgentSession): void { + if (this.attached === session) return + this.unsubscribeAttached?.() + this.attached = session + this.unsubscribeAttached = session.subscribe((event) => this.handleEvent(event)) + } + + private emitSwitched(session: AgentSession): void { + const state = this.buildState(session) + this.emit({ + type: 'session_switched', + state, + messages: session.messages.map((m) => toCloneable(m)), + activeTools: this.collectToolStates(session) + }) + } + + /** 从历史消息中恢复工具状态(用于切换会话后 ToolCard 的参数/结果展示) */ + private collectToolStates(session: AgentSession): Record { + const tools: Record = {} + for (const message of session.messages) { + const m = message as { role?: string; content?: unknown; toolCallId?: unknown; toolName?: unknown; isError?: unknown } + if (m.role !== 'assistant' || !Array.isArray(m.content)) continue + for (const block of m.content as Array<{ type?: string; id?: string; name?: string; arguments?: unknown }>) { + if (block.type === 'toolCall' && block.id) { + tools[block.id] = { toolName: block.name ?? 'tool', args: toCloneable(block.arguments), status: 'done' } + } + } + } + for (const message of session.messages) { + const m = message as { role?: string; toolCallId?: unknown; toolName?: unknown; content?: unknown; isError?: unknown } + if (m.role !== 'toolResult' || typeof m.toolCallId !== 'string') continue + tools[m.toolCallId] = { + ...((tools[m.toolCallId] as Record) ?? { toolName: m.toolName ?? 'tool' }), + result: toCloneable(m), + status: m.isError ? 'error' : 'done' + } + } + return tools } - /** 初始化完成事件由调用方(ipc 层)在 ready 后主动拉取状态 */ async ensureReady(): Promise { try { - const { session } = await this.getSession() - const state = this.buildState(session) - this.emit({ type: 'session_ready', state }) - return state + const runtime = await this.getRuntime() + this.emitSwitched(runtime.session) + return this.buildState(runtime.session) } catch (err) { const message = err instanceof Error ? err.message : String(err) this.emit({ type: 'session_error', message }) @@ -174,9 +248,9 @@ export class AgentService { private async emitStats(): Promise { try { - const { session } = await this.getSession() - const stats = session.getSessionStats() - const usage = session.getContextUsage() + const runtime = await this.getRuntime() + const stats = runtime.session.getSessionStats() + const usage = runtime.session.getContextUsage() const info: SessionStatsInfo = { tokens: { ...stats.tokens }, cost: stats.cost, @@ -189,12 +263,13 @@ export class AgentService { } async getState(): Promise { - const { session } = await this.getSession() - return this.buildState(session) + const runtime = await this.getRuntime() + return this.buildState(runtime.session) } async send(text: string): Promise { - const { session } = await this.getSession() + const runtime = await this.getRuntime() + const session = runtime.session if (session.isStreaming) { await session.prompt(text, { streamingBehavior: 'followUp' }) } else { @@ -203,15 +278,74 @@ export class AgentService { } async steer(text: string): Promise { - const { session } = await this.getSession() - await session.steer(text) + const runtime = await this.getRuntime() + await runtime.session.steer(text) } async stop(): Promise { - if (!this.result && !this.initPromise) return - const { session } = await this.getSession() - await session.abort() + if (!this.runtime && !this.initPromise) return + const runtime = await this.getRuntime() + await runtime.session.abort() } + + // ---- 会话管理 ---- + + async listSessions(): Promise { + const infos = await SessionManager.list(this.cwd) + return infos + .sort((a, b) => b.modified.getTime() - a.modified.getTime()) + .map((info) => toSessionMeta(info)) + } + + async newSession(): Promise { + const runtime = await this.getRuntime() + if (runtime.session.isStreaming) { + await runtime.session.abort() + } + await runtime.newSession() + // rebind 回调负责发 session_switched + } + + async openSession(path: string): Promise { + const runtime = await this.getRuntime() + if (runtime.session.isStreaming) { + await runtime.session.abort() + } + await runtime.switchSession(path) + } + + async renameSession(name: string): Promise { + const runtime = await this.getRuntime() + runtime.session.setSessionName(name) + this.emit({ type: 'session_info', name }) + } + + async deleteSession(path: string): Promise { + const runtime = await this.getRuntime() + const active = runtime.session.sessionFile + await shell.trashItem(path).catch((err: unknown) => { + throw new Error(`移入回收站失败: ${err instanceof Error ? err.message : String(err)}`) + }) + if (active && samePath(active, path)) { + await runtime.newSession() + } + } +} + +function toSessionMeta(info: SessionInfo): SessionMeta { + return { + id: info.id, + path: info.path, + name: info.name, + firstMessage: info.firstMessage, + created: info.created.toISOString(), + modified: info.modified.toISOString(), + messageCount: info.messageCount + } +} + +function samePath(a: string, b: string): boolean { + return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase() } /** 事件消息对象转为可结构化克隆的普通对象(剥离 undefined/函数/循环引用) */ diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index c657293..a41dee4 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -1,22 +1,19 @@ import { contextBridge, ipcRenderer } from 'electron' -import type { AgentUiEvent, SessionState } from '@shared/types' +import type { AgentUiEvent, SessionMeta, SessionState } from '@shared/types' -export interface DesktopApi { - send: (text: string) => Promise - steer: (text: string) => Promise - stop: () => Promise - getState: () => Promise - versions: () => Promise<{ app: string; electron: string; node: string; chrome: string }> - onEvent: (callback: (event: AgentUiEvent) => void) => () => void -} - -const api: DesktopApi = { - send: (text) => ipcRenderer.invoke('agent:send', text), - steer: (text) => ipcRenderer.invoke('agent:steer', text), - stop: () => ipcRenderer.invoke('agent:stop'), - getState: () => ipcRenderer.invoke('agent:state'), - versions: () => ipcRenderer.invoke('app:versions'), - onEvent: (callback) => { +const api = { + send: (text: string): Promise => ipcRenderer.invoke('agent:send', text), + steer: (text: string): Promise => ipcRenderer.invoke('agent:steer', text), + stop: (): Promise => ipcRenderer.invoke('agent:stop'), + getState: (): Promise => ipcRenderer.invoke('agent:state'), + versions: (): Promise<{ app: string; electron: string; node: string; chrome: string }> => + ipcRenderer.invoke('app:versions'), + listSessions: (): Promise => ipcRenderer.invoke('sessions:list'), + newSession: (): Promise => ipcRenderer.invoke('sessions:new'), + 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), + onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => { const listener = (_e: Electron.IpcRendererEvent, event: AgentUiEvent): void => callback(event) ipcRenderer.on('agent:event', listener) return () => { @@ -25,4 +22,6 @@ const api: DesktopApi = { } } +export type DesktopApi = typeof api + contextBridge.exposeInMainWorld('api', api) diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 43e764e..83fa576 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -6,7 +6,7 @@ import { ChatView } from './components/ChatView' import { Composer } from './components/Composer' export default function App(): JSX.Element { - const { state, send, stop, clearError } = useAgent() + const { state, sessions, send, stop, clearError, newSession, openSession, renameSession, deleteSession } = useAgent() const prefillRef = useRef(null) const handlePickSuggestion = (text: string): void => { @@ -17,7 +17,16 @@ export default function App(): JSX.Element { return (
- +
36 ? `${first.slice(0, 36)}…` : first + return '(空会话)' +} + +function SessionRow({ + session, + active, + onOpen, + onRename, + onDelete +}: { + session: SessionMeta + active: boolean + onOpen: () => void + onRename: () => void + onDelete: () => void +}): JSX.Element { + return ( +
+ {active && } + +
+
{sessionTitle(session)}
+
+ {formatModified(session.modified)} · {session.messageCount} 条 +
+
+
+ + +
+
+ ) +} + export function Sidebar({ sessionState, stats, - connected + connected, + sessions, + onNewSession, + onOpenSession, + onRenameSession, + onDeleteSession }: { sessionState: SessionState | null stats: SessionStatsInfo | null connected: boolean + sessions: SessionMeta[] + onNewSession: () => void + onOpenSession: (path: string) => void + onRenameSession: (name: string) => void + onDeleteSession: (path: string) => void }): JSX.Element { const [versions, setVersions] = useState(null) + const [renaming, setRenaming] = useState(false) + const [renameValue, setRenameValue] = useState('') + const [confirmDelete, setConfirmDelete] = useState(null) + const renameInputRef = useRef(null) const entranceRef = useRef(null) useEffect(() => { @@ -56,8 +146,20 @@ export function Sidebar({ } }, []) + useEffect(() => { + if (renaming) renameInputRef.current?.focus() + }, [renaming]) + + const submitRename = (): void => { + const name = renameValue.trim() + if (name) onRenameSession(name) + setRenaming(false) + } + + const activeSessionId = sessionState?.sessionId + return ( -