v0.2.0: 会话管理(列表/新建/切换恢复/重命名/回收站删除)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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/函数/循环引用) */
|
||||
|
||||
Reference in New Issue
Block a user