v0.2.0: 会话管理(列表/新建/切换恢复/重命名/回收站删除)

This commit is contained in:
Rick Ye
2026-09-05 09:35:30 +08:00
parent 17110ee5ab
commit 9984e2a427
10 changed files with 697 additions and 95 deletions
+34
View File
@@ -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,
+166 -32
View File
@@ -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/<encoded-cwd>/)
* - 归一化 pi 事件为 AgentUiEvent 转发渲染层
*/
export class AgentService {
private result: CreateAgentSessionResult | null = null
private initPromise: Promise<CreateAgentSessionResult> | null = null
private runtime: AgentSessionRuntime | null = null
private initPromise: Promise<AgentSessionRuntime> | 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<CreateAgentSessionResult> {
private getRuntime(): Promise<AgentSessionRuntime> {
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<CreateAgentSessionResult> {
const result = await createAgentSession({
cwd: app.getPath('home')
private async create(): Promise<AgentSessionRuntime> {
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<string, unknown> {
const tools: Record<string, unknown> = {}
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<string, unknown>) ?? { toolName: m.toolName ?? 'tool' }),
result: toCloneable(m),
status: m.isError ? 'error' : 'done'
}
}
return tools
}
/** 初始化完成事件由调用方(ipc 层)在 ready 后主动拉取状态 */
async ensureReady(): Promise<SessionState> {
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<void> {
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<SessionState> {
const { session } = await this.getSession()
return this.buildState(session)
const runtime = await this.getRuntime()
return this.buildState(runtime.session)
}
async send(text: string): Promise<void> {
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<void> {
const { session } = await this.getSession()
await session.steer(text)
const runtime = await this.getRuntime()
await runtime.session.steer(text)
}
async stop(): Promise<void> {
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<SessionMeta[]> {
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<void> {
const runtime = await this.getRuntime()
if (runtime.session.isStreaming) {
await runtime.session.abort()
}
await runtime.newSession()
// rebind 回调负责发 session_switched
}
async openSession(path: string): Promise<void> {
const runtime = await this.getRuntime()
if (runtime.session.isStreaming) {
await runtime.session.abort()
}
await runtime.switchSession(path)
}
async renameSession(name: string): Promise<void> {
const runtime = await this.getRuntime()
runtime.session.setSessionName(name)
this.emit({ type: 'session_info', name })
}
async deleteSession(path: string): Promise<void> {
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/函数/循环引用) */