v0.2.0: 会话管理(列表/新建/切换恢复/重命名/回收站删除)
This commit is contained in:
@@ -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)
|
||||||
@@ -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', () => ({
|
ipcMain.handle('app:versions', () => ({
|
||||||
app: process.env.npm_package_version ?? '0.0.0',
|
app: process.env.npm_package_version ?? '0.0.0',
|
||||||
electron: process.versions.electron,
|
electron: process.versions.electron,
|
||||||
|
|||||||
@@ -1,27 +1,39 @@
|
|||||||
import { app } from 'electron'
|
import { app, shell } from 'electron'
|
||||||
import {
|
import {
|
||||||
createAgentSession,
|
createAgentSessionRuntime,
|
||||||
|
createAgentSessionServices,
|
||||||
|
createAgentSessionFromServices,
|
||||||
|
getAgentDir,
|
||||||
|
SessionManager,
|
||||||
type AgentSession,
|
type AgentSession,
|
||||||
type AgentSessionEvent,
|
type AgentSessionEvent,
|
||||||
type CreateAgentSessionResult
|
type AgentSessionRuntime,
|
||||||
|
type CreateAgentSessionRuntimeFactory,
|
||||||
|
type SessionInfo
|
||||||
} from '@earendil-works/pi-coding-agent'
|
} 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 快照节流经验取略低值 */
|
/** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */
|
||||||
const DELTA_FLUSH_MS = 40
|
const DELTA_FLUSH_MS = 40
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* pi AgentSession 的主进程封装:
|
* pi 会话运行时的主进程封装:
|
||||||
* - 懒初始化会话(默认复用 ~/.pi 的配置、凭据与会话持久化)
|
* - 基于 AgentSessionRuntime 支持会话新建/切换(自动重挂事件订阅)
|
||||||
* - 订阅 pi 事件流,归一化为 AgentUiEvent 转发给渲染层
|
* - 复用 ~/.pi 的配置、凭据与会话持久化(sessions/<encoded-cwd>/)
|
||||||
* - 提供 prompt / abort / 状态与统计查询
|
* - 归一化 pi 事件为 AgentUiEvent 转发渲染层
|
||||||
*/
|
*/
|
||||||
export class AgentService {
|
export class AgentService {
|
||||||
private result: CreateAgentSessionResult | null = null
|
private runtime: AgentSessionRuntime | null = null
|
||||||
private initPromise: Promise<CreateAgentSessionResult> | null = null
|
private initPromise: Promise<AgentSessionRuntime> | null = null
|
||||||
private listeners = new Set<(event: AgentUiEvent) => void>()
|
private listeners = new Set<(event: AgentUiEvent) => void>()
|
||||||
private deltaQueue: AgentUiEvent[] = []
|
private deltaQueue: AgentUiEvent[] = []
|
||||||
private flushTimer: NodeJS.Timeout | null = null
|
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 {
|
onEvent(fn: (event: AgentUiEvent) => void): () => void {
|
||||||
this.listeners.add(fn)
|
this.listeners.add(fn)
|
||||||
@@ -50,7 +62,7 @@ export class AgentService {
|
|||||||
}, DELTA_FLUSH_MS)
|
}, DELTA_FLUSH_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSession(): Promise<CreateAgentSessionResult> {
|
private getRuntime(): Promise<AgentSessionRuntime> {
|
||||||
if (!this.initPromise) {
|
if (!this.initPromise) {
|
||||||
this.initPromise = this.create().catch((err) => {
|
this.initPromise = this.create().catch((err) => {
|
||||||
this.initPromise = null
|
this.initPromise = null
|
||||||
@@ -60,21 +72,83 @@ export class AgentService {
|
|||||||
return this.initPromise
|
return this.initPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
private async create(): Promise<CreateAgentSessionResult> {
|
private async create(): Promise<AgentSessionRuntime> {
|
||||||
const result = await createAgentSession({
|
const cwd = this.cwd
|
||||||
cwd: app.getPath('home')
|
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))
|
this.attach(runtime.session)
|
||||||
return result
|
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> {
|
async ensureReady(): Promise<SessionState> {
|
||||||
try {
|
try {
|
||||||
const { session } = await this.getSession()
|
const runtime = await this.getRuntime()
|
||||||
const state = this.buildState(session)
|
this.emitSwitched(runtime.session)
|
||||||
this.emit({ type: 'session_ready', state })
|
return this.buildState(runtime.session)
|
||||||
return state
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
this.emit({ type: 'session_error', message })
|
this.emit({ type: 'session_error', message })
|
||||||
@@ -174,9 +248,9 @@ export class AgentService {
|
|||||||
|
|
||||||
private async emitStats(): Promise<void> {
|
private async emitStats(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { session } = await this.getSession()
|
const runtime = await this.getRuntime()
|
||||||
const stats = session.getSessionStats()
|
const stats = runtime.session.getSessionStats()
|
||||||
const usage = session.getContextUsage()
|
const usage = runtime.session.getContextUsage()
|
||||||
const info: SessionStatsInfo = {
|
const info: SessionStatsInfo = {
|
||||||
tokens: { ...stats.tokens },
|
tokens: { ...stats.tokens },
|
||||||
cost: stats.cost,
|
cost: stats.cost,
|
||||||
@@ -189,12 +263,13 @@ export class AgentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getState(): Promise<SessionState> {
|
async getState(): Promise<SessionState> {
|
||||||
const { session } = await this.getSession()
|
const runtime = await this.getRuntime()
|
||||||
return this.buildState(session)
|
return this.buildState(runtime.session)
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(text: string): Promise<void> {
|
async send(text: string): Promise<void> {
|
||||||
const { session } = await this.getSession()
|
const runtime = await this.getRuntime()
|
||||||
|
const session = runtime.session
|
||||||
if (session.isStreaming) {
|
if (session.isStreaming) {
|
||||||
await session.prompt(text, { streamingBehavior: 'followUp' })
|
await session.prompt(text, { streamingBehavior: 'followUp' })
|
||||||
} else {
|
} else {
|
||||||
@@ -203,15 +278,74 @@ export class AgentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async steer(text: string): Promise<void> {
|
async steer(text: string): Promise<void> {
|
||||||
const { session } = await this.getSession()
|
const runtime = await this.getRuntime()
|
||||||
await session.steer(text)
|
await runtime.session.steer(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
if (!this.result && !this.initPromise) return
|
if (!this.runtime && !this.initPromise) return
|
||||||
const { session } = await this.getSession()
|
const runtime = await this.getRuntime()
|
||||||
await session.abort()
|
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/函数/循环引用) */
|
/** 事件消息对象转为可结构化克隆的普通对象(剥离 undefined/函数/循环引用) */
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron'
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
import type { AgentUiEvent, SessionState } from '@shared/types'
|
import type { AgentUiEvent, SessionMeta, SessionState } from '@shared/types'
|
||||||
|
|
||||||
export interface DesktopApi {
|
const api = {
|
||||||
send: (text: string) => Promise<void>
|
send: (text: string): Promise<void> => ipcRenderer.invoke('agent:send', text),
|
||||||
steer: (text: string) => Promise<void>
|
steer: (text: string): Promise<void> => ipcRenderer.invoke('agent:steer', text),
|
||||||
stop: () => Promise<void>
|
stop: (): Promise<void> => ipcRenderer.invoke('agent:stop'),
|
||||||
getState: () => Promise<SessionState>
|
getState: (): Promise<SessionState> => ipcRenderer.invoke('agent:state'),
|
||||||
versions: () => Promise<{ app: string; electron: string; node: string; chrome: string }>
|
versions: (): Promise<{ app: string; electron: string; node: string; chrome: string }> =>
|
||||||
onEvent: (callback: (event: AgentUiEvent) => void) => () => void
|
ipcRenderer.invoke('app:versions'),
|
||||||
}
|
listSessions: (): Promise<SessionMeta[]> => ipcRenderer.invoke('sessions:list'),
|
||||||
|
newSession: (): Promise<void> => ipcRenderer.invoke('sessions:new'),
|
||||||
const api: DesktopApi = {
|
openSession: (path: string): Promise<void> => ipcRenderer.invoke('sessions:open', path),
|
||||||
send: (text) => ipcRenderer.invoke('agent:send', text),
|
renameSession: (name: string): Promise<void> => ipcRenderer.invoke('sessions:rename', name),
|
||||||
steer: (text) => ipcRenderer.invoke('agent:steer', text),
|
deleteSession: (path: string): Promise<void> => ipcRenderer.invoke('sessions:delete', path),
|
||||||
stop: () => ipcRenderer.invoke('agent:stop'),
|
onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => {
|
||||||
getState: () => ipcRenderer.invoke('agent:state'),
|
|
||||||
versions: () => ipcRenderer.invoke('app:versions'),
|
|
||||||
onEvent: (callback) => {
|
|
||||||
const listener = (_e: Electron.IpcRendererEvent, event: AgentUiEvent): void => callback(event)
|
const listener = (_e: Electron.IpcRendererEvent, event: AgentUiEvent): void => callback(event)
|
||||||
ipcRenderer.on('agent:event', listener)
|
ipcRenderer.on('agent:event', listener)
|
||||||
return () => {
|
return () => {
|
||||||
@@ -25,4 +22,6 @@ const api: DesktopApi = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DesktopApi = typeof api
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('api', api)
|
contextBridge.exposeInMainWorld('api', api)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ChatView } from './components/ChatView'
|
|||||||
import { Composer } from './components/Composer'
|
import { Composer } from './components/Composer'
|
||||||
|
|
||||||
export default function App(): JSX.Element {
|
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<string | null>(null)
|
const prefillRef = useRef<string | null>(null)
|
||||||
|
|
||||||
const handlePickSuggestion = (text: string): void => {
|
const handlePickSuggestion = (text: string): void => {
|
||||||
@@ -17,7 +17,16 @@ export default function App(): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full bg-zinc-950 text-zinc-100 antialiased">
|
<div className="flex h-full w-full bg-zinc-950 text-zinc-100 antialiased">
|
||||||
<Sidebar sessionState={state.sessionState} stats={state.stats} connected={state.ready} />
|
<Sidebar
|
||||||
|
sessionState={state.sessionState}
|
||||||
|
stats={state.stats}
|
||||||
|
connected={state.ready}
|
||||||
|
sessions={sessions}
|
||||||
|
onNewSession={newSession}
|
||||||
|
onOpenSession={openSession}
|
||||||
|
onRenameSession={renameSession}
|
||||||
|
onDeleteSession={deleteSession}
|
||||||
|
/>
|
||||||
<main className="flex min-w-0 flex-1 flex-col">
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
<ChatView
|
<ChatView
|
||||||
messages={state.messages}
|
messages={state.messages}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { JSX } from 'react'
|
import type { JSX } from 'react'
|
||||||
import { Cpu, MessageSquarePlus, Store } from 'lucide-react'
|
import { Check, Cpu, MessageSquare, Pencil, Store, Trash2, X } from 'lucide-react'
|
||||||
import type { SessionState, SessionStatsInfo } from '@shared/types'
|
import type { SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
|
||||||
|
|
||||||
interface VersionInfo {
|
interface VersionInfo {
|
||||||
app: string
|
app: string
|
||||||
@@ -21,16 +21,106 @@ function formatTokens(n: number): string {
|
|||||||
return String(n)
|
return String(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatModified(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
const now = new Date()
|
||||||
|
const sameDay = d.toDateString() === now.toDateString()
|
||||||
|
if (sameDay) {
|
||||||
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
const yesterday = new Date(now.getTime() - 86400000)
|
||||||
|
if (d.toDateString() === yesterday.toDateString()) return '昨天'
|
||||||
|
if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||||
|
return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionTitle(s: SessionMeta): string {
|
||||||
|
if (s.name?.trim()) return s.name.trim()
|
||||||
|
const first = s.firstMessage?.trim()
|
||||||
|
if (first) return first.length > 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 (
|
||||||
|
<div
|
||||||
|
className={`group relative flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 transition-colors ${
|
||||||
|
active ? 'bg-zinc-800/80' : 'hover:bg-zinc-800/40'
|
||||||
|
}`}
|
||||||
|
onClick={onOpen}
|
||||||
|
>
|
||||||
|
{active && <span className="absolute left-0 top-1/2 h-4 w-0.5 -translate-y-1/2 rounded-full bg-emerald-400" />}
|
||||||
|
<MessageSquare size={13} className={`shrink-0 ${active ? 'text-emerald-400' : 'text-zinc-600'}`} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className={`truncate text-[12.5px] ${active ? 'text-zinc-100' : 'text-zinc-400'}`}>{sessionTitle(session)}</div>
|
||||||
|
<div className="text-[10.5px] text-zinc-600">
|
||||||
|
{formatModified(session.modified)} · {session.messageCount} 条
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="重命名"
|
||||||
|
className="rounded p-1 text-zinc-500 transition-colors hover:bg-zinc-700 hover:text-zinc-200"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRename()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="删除(移入回收站)"
|
||||||
|
className="rounded p-1 text-zinc-500 transition-colors hover:bg-red-950/60 hover:text-red-300"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDelete()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
sessionState,
|
sessionState,
|
||||||
stats,
|
stats,
|
||||||
connected
|
connected,
|
||||||
|
sessions,
|
||||||
|
onNewSession,
|
||||||
|
onOpenSession,
|
||||||
|
onRenameSession,
|
||||||
|
onDeleteSession
|
||||||
}: {
|
}: {
|
||||||
sessionState: SessionState | null
|
sessionState: SessionState | null
|
||||||
stats: SessionStatsInfo | null
|
stats: SessionStatsInfo | null
|
||||||
connected: boolean
|
connected: boolean
|
||||||
|
sessions: SessionMeta[]
|
||||||
|
onNewSession: () => void
|
||||||
|
onOpenSession: (path: string) => void
|
||||||
|
onRenameSession: (name: string) => void
|
||||||
|
onDeleteSession: (path: string) => void
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const [versions, setVersions] = useState<VersionInfo | null>(null)
|
const [versions, setVersions] = useState<VersionInfo | null>(null)
|
||||||
|
const [renaming, setRenaming] = useState(false)
|
||||||
|
const [renameValue, setRenameValue] = useState('')
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||||
|
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||||
const entranceRef = useRef<HTMLDivElement>(null)
|
const entranceRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
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 (
|
return (
|
||||||
<aside ref={entranceRef} className="flex w-[260px] shrink-0 flex-col border-r border-zinc-800/80 bg-zinc-950">
|
<aside ref={entranceRef} className="relative flex w-[260px] shrink-0 flex-col border-r border-zinc-800/80 bg-zinc-950">
|
||||||
{/* 品牌 */}
|
{/* 品牌 */}
|
||||||
<div className="flex items-center gap-2.5 px-4 pb-4 pt-5">
|
<div className="flex items-center gap-2.5 px-4 pb-4 pt-5">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-950/60 ring-1 ring-emerald-900/60">
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-950/60 ring-1 ring-emerald-900/60">
|
||||||
@@ -69,29 +171,114 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 新会话(v0.2 提供) */}
|
{/* 新会话 */}
|
||||||
<div className="px-3">
|
<div className="px-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled
|
onClick={onNewSession}
|
||||||
title="会话管理将在 v0.2 提供"
|
className="flex w-full items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/40 px-3 py-2 text-[13px] text-zinc-300 transition-colors hover:border-emerald-900 hover:bg-emerald-950/30 hover:text-emerald-200"
|
||||||
className="flex w-full items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/40 px-3 py-2 text-[13px] text-zinc-400 transition-colors hover:border-zinc-700 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
>
|
||||||
<MessageSquarePlus size={15} />
|
<span className="text-[15px] leading-none text-zinc-500">+</span>
|
||||||
新会话
|
新会话
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 会话列表占位 */}
|
{/* 会话列表 */}
|
||||||
<div className="mt-4 flex-1 overflow-y-auto px-3">
|
<div className="mt-4 flex min-h-0 flex-1 flex-col px-3">
|
||||||
<div className="px-2 pb-2 text-[11px] font-medium uppercase tracking-wider text-zinc-600">会话</div>
|
<div className="px-2 pb-2 text-[11px] font-medium uppercase tracking-wider text-zinc-600">会话</div>
|
||||||
<div className="rounded-xl border border-dashed border-zinc-800/80 px-3 py-4 text-center text-[12px] leading-5 text-zinc-600">
|
<div className="min-h-0 flex-1 space-y-0.5 overflow-y-auto pb-2">
|
||||||
会话列表与历史恢复
|
{sessions.length === 0 ? (
|
||||||
<br />
|
<div className="rounded-xl border border-dashed border-zinc-800/80 px-3 py-4 text-center text-[12px] leading-5 text-zinc-600">
|
||||||
<span className="text-zinc-700">v0.2 上线</span>
|
暂无历史会话
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sessions.map((s) => (
|
||||||
|
<SessionRow
|
||||||
|
key={s.id}
|
||||||
|
session={s}
|
||||||
|
active={s.id === activeSessionId}
|
||||||
|
onOpen={() => onOpenSession(s.path)}
|
||||||
|
onRename={() => {
|
||||||
|
setRenameValue(sessionTitle(s))
|
||||||
|
setRenaming(true)
|
||||||
|
}}
|
||||||
|
onDelete={() => setConfirmDelete(s.path)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 重命名对话框 */}
|
||||||
|
{renaming && (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="w-[220px] rounded-xl border border-zinc-800 bg-zinc-900 p-3 shadow-2xl">
|
||||||
|
<div className="mb-2 text-[12.5px] font-medium text-zinc-200">重命名当前会话</div>
|
||||||
|
<input
|
||||||
|
ref={renameInputRef}
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') submitRename()
|
||||||
|
if (e.key === 'Escape') setRenaming(false)
|
||||||
|
}}
|
||||||
|
className="mb-2.5 w-full rounded-lg border border-zinc-700 bg-zinc-950 px-2.5 py-1.5 text-[12.5px] text-zinc-100 outline-none focus:border-emerald-700"
|
||||||
|
placeholder="会话名称"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRenaming(false)}
|
||||||
|
className="flex items-center gap-1 rounded-lg px-2 py-1 text-[12px] text-zinc-400 transition-colors hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submitRename}
|
||||||
|
className="flex items-center gap-1 rounded-lg bg-emerald-600 px-2 py-1 text-[12px] text-white transition-colors hover:bg-emerald-500"
|
||||||
|
>
|
||||||
|
<Check size={12} />
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 删除确认 */}
|
||||||
|
{confirmDelete && (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="w-[230px] rounded-xl border border-zinc-800 bg-zinc-900 p-3.5 shadow-2xl">
|
||||||
|
<div className="mb-1 flex items-center gap-2 text-[12.5px] font-medium text-zinc-200">
|
||||||
|
<Trash2 size={13} className="text-red-400" />
|
||||||
|
删除这个会话?
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 text-[11.5px] leading-5 text-zinc-500">会话文件将移入系统回收站,可随时恢复。</div>
|
||||||
|
<div className="flex justify-end gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(null)}
|
||||||
|
className="rounded-lg px-2 py-1 text-[12px] text-zinc-400 transition-colors hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onDeleteSession(confirmDelete)
|
||||||
|
setConfirmDelete(null)
|
||||||
|
}}
|
||||||
|
className="rounded-lg bg-red-600 px-2 py-1 text-[12px] text-white transition-colors hover:bg-red-500"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 底部状态 */}
|
{/* 底部状态 */}
|
||||||
<div className="border-t border-zinc-800/80 px-4 py-3.5">
|
<div className="border-t border-zinc-800/80 px-4 py-3.5">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
|
||||||
import type { AgentUiEvent, SessionState, SessionStatsInfo } from '@shared/types'
|
import type { AgentUiEvent, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
|
||||||
|
|
||||||
export interface ToolState {
|
export interface ToolState {
|
||||||
toolName: string
|
toolName: string
|
||||||
@@ -64,6 +64,47 @@ function readRole(message: unknown): string {
|
|||||||
return 'unknown'
|
return 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 历史消息(会话切换/恢复时)映射为 UI 消息 */
|
||||||
|
function historyToMessages(messages: unknown[]): UiMessage[] {
|
||||||
|
const result: UiMessage[] = []
|
||||||
|
for (const raw of messages) {
|
||||||
|
const role = readRole(raw)
|
||||||
|
if (role === 'toolResult') continue
|
||||||
|
if (role === 'user' || role === 'assistant') {
|
||||||
|
result.push({ key: nextKey('h'), role, raw })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从历史消息重建工具状态(assistant 的 toolCall 块 + toolResult 消息) */
|
||||||
|
function historyToTools(messages: unknown[]): Record<string, ToolState> {
|
||||||
|
const tools: Record<string, ToolState> = {}
|
||||||
|
for (const message of messages) {
|
||||||
|
if (!message || typeof message !== 'object') continue
|
||||||
|
const m = message as { role?: string; content?: unknown }
|
||||||
|
if (m.role === 'assistant' && Array.isArray(m.content)) {
|
||||||
|
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: block.arguments, status: 'done' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const message of messages) {
|
||||||
|
if (!message || typeof message !== 'object') continue
|
||||||
|
const m = message as { role?: string; toolCallId?: unknown; toolName?: unknown; isError?: unknown }
|
||||||
|
if (m.role === 'toolResult' && typeof m.toolCallId === 'string') {
|
||||||
|
tools[m.toolCallId] = {
|
||||||
|
...(tools[m.toolCallId] ?? { toolName: m.toolName ?? 'tool' }),
|
||||||
|
result: message,
|
||||||
|
status: m.isError ? 'error' : 'done'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tools
|
||||||
|
}
|
||||||
|
|
||||||
function reducer(state: AgentUiState, action: Action): AgentUiState {
|
function reducer(state: AgentUiState, action: Action): AgentUiState {
|
||||||
if (action.type === 'optimistic-user') {
|
if (action.type === 'optimistic-user') {
|
||||||
return {
|
return {
|
||||||
@@ -84,6 +125,19 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
|||||||
return { ...state, ready: true, sessionState: event.state }
|
return { ...state, ready: true, sessionState: event.state }
|
||||||
case 'session_error':
|
case 'session_error':
|
||||||
return { ...state, sessionError: event.message }
|
return { ...state, sessionError: event.message }
|
||||||
|
case 'session_switched':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
ready: true,
|
||||||
|
error: null,
|
||||||
|
retry: null,
|
||||||
|
streaming: false,
|
||||||
|
queue: { steering: [], followUp: [] },
|
||||||
|
stats: null,
|
||||||
|
sessionState: event.state,
|
||||||
|
messages: historyToMessages(event.messages),
|
||||||
|
tools: historyToTools(event.messages)
|
||||||
|
}
|
||||||
case 'agent_start':
|
case 'agent_start':
|
||||||
return { ...state, streaming: true, error: null, retry: null }
|
return { ...state, streaming: true, error: null, retry: null }
|
||||||
case 'agent_end':
|
case 'agent_end':
|
||||||
@@ -151,7 +205,10 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
|||||||
case 'tool_update': {
|
case 'tool_update': {
|
||||||
const tools = { ...state.tools }
|
const tools = { ...state.tools }
|
||||||
const prev = tools[event.toolCallId]
|
const prev = tools[event.toolCallId]
|
||||||
tools[event.toolCallId] = { ...(prev ?? { toolName: event.toolName, status: 'running' as const }), partialResult: event.partialResult }
|
tools[event.toolCallId] = {
|
||||||
|
...(prev ?? { toolName: event.toolName, status: 'running' as const }),
|
||||||
|
partialResult: event.partialResult
|
||||||
|
}
|
||||||
return { ...state, tools }
|
return { ...state, tools }
|
||||||
}
|
}
|
||||||
case 'tool_end': {
|
case 'tool_end': {
|
||||||
@@ -167,13 +224,20 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
|||||||
case 'queue_update':
|
case 'queue_update':
|
||||||
return { ...state, queue: { steering: event.steering, followUp: event.followUp } }
|
return { ...state, queue: { steering: event.steering, followUp: event.followUp } }
|
||||||
case 'retry':
|
case 'retry':
|
||||||
return { ...state, retry: { attempt: event.attempt, maxAttempts: event.maxAttempts, errorMessage: event.errorMessage }, streaming: true }
|
return {
|
||||||
|
...state,
|
||||||
|
retry: { attempt: event.attempt, maxAttempts: event.maxAttempts, errorMessage: event.errorMessage },
|
||||||
|
streaming: true
|
||||||
|
}
|
||||||
case 'error':
|
case 'error':
|
||||||
return { ...state, error: event.message }
|
return { ...state, error: event.message }
|
||||||
case 'session_info':
|
case 'session_info': {
|
||||||
return state.sessionState
|
if (!state.sessionState) return state
|
||||||
? { ...state, sessionState: { ...state.sessionState, sessionName: event.name } }
|
return {
|
||||||
: state
|
...state,
|
||||||
|
sessionState: { ...state.sessionState, sessionName: event.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
case 'stats':
|
case 'stats':
|
||||||
return { ...state, stats: event.stats }
|
return { ...state, stats: event.stats }
|
||||||
default:
|
default:
|
||||||
@@ -183,45 +247,111 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
|||||||
|
|
||||||
export interface UseAgent {
|
export interface UseAgent {
|
||||||
state: AgentUiState
|
state: AgentUiState
|
||||||
|
sessions: SessionMeta[]
|
||||||
send: (text: string) => void
|
send: (text: string) => void
|
||||||
stop: () => void
|
stop: () => void
|
||||||
clearError: () => void
|
clearError: () => void
|
||||||
|
refreshSessions: () => void
|
||||||
|
newSession: () => void
|
||||||
|
openSession: (path: string) => void
|
||||||
|
renameSession: (name: string) => void
|
||||||
|
deleteSession: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAgent(): UseAgent {
|
export function useAgent(): UseAgent {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState)
|
const [state, dispatch] = useReducer(reducer, initialState)
|
||||||
const stateRef = useRef(state)
|
const [sessions, setSessions] = useState<SessionMeta[]>([])
|
||||||
stateRef.current = state
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = window.api.onEvent((event) => {
|
const unsubscribe = window.api.onEvent((event) => {
|
||||||
dispatch({ type: 'event', event })
|
dispatch({ type: 'event', event })
|
||||||
|
// 会话集合可能在后台变化(新建/删除/重命名),切换后刷新列表
|
||||||
|
if (event.type === 'session_switched') {
|
||||||
|
void window.api
|
||||||
|
.listSessions()
|
||||||
|
.then(setSessions)
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const send = useCallback((text: string) => {
|
const refreshSessions = useCallback((): void => {
|
||||||
const trimmed = text.trim()
|
void window.api
|
||||||
if (!trimmed) return
|
.listSessions()
|
||||||
dispatch({ type: 'optimistic-user', key: nextKey('u'), text: trimmed })
|
.then(setSessions)
|
||||||
window.api.send(trimmed).catch((err: unknown) => {
|
.catch(() => undefined)
|
||||||
dispatch({
|
}, [])
|
||||||
type: 'event',
|
|
||||||
event: { type: 'error', message: err instanceof Error ? err.message : String(err) }
|
useEffect(() => {
|
||||||
})
|
refreshSessions()
|
||||||
|
}, [refreshSessions])
|
||||||
|
|
||||||
|
const dispatchError = useCallback((err: unknown): void => {
|
||||||
|
dispatch({
|
||||||
|
type: 'event',
|
||||||
|
event: { type: 'error', message: err instanceof Error ? err.message : String(err) }
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const send = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
dispatch({ type: 'optimistic-user', key: nextKey('u'), text: trimmed })
|
||||||
|
window.api.send(trimmed).catch(dispatchError)
|
||||||
|
},
|
||||||
|
[dispatchError]
|
||||||
|
)
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
window.api.stop().catch((err: unknown) => {
|
window.api.stop().catch(dispatchError)
|
||||||
dispatch({
|
}, [dispatchError])
|
||||||
type: 'event',
|
|
||||||
event: { type: 'error', message: err instanceof Error ? err.message : String(err) }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const clearError = useCallback(() => dispatch({ type: 'clear-error' }), [])
|
const clearError = useCallback(() => dispatch({ type: 'clear-error' }), [])
|
||||||
|
|
||||||
return useMemo(() => ({ state, send, stop, clearError }), [state, send, stop, clearError])
|
const newSession = useCallback((): void => {
|
||||||
|
window.api.newSession().catch(dispatchError)
|
||||||
|
}, [dispatchError])
|
||||||
|
|
||||||
|
const openSession = useCallback(
|
||||||
|
(path: string): void => {
|
||||||
|
window.api.openSession(path).catch(dispatchError)
|
||||||
|
},
|
||||||
|
[dispatchError]
|
||||||
|
)
|
||||||
|
|
||||||
|
const renameSession = useCallback(
|
||||||
|
(name: string): void => {
|
||||||
|
window.api.renameSession(name).catch(dispatchError)
|
||||||
|
},
|
||||||
|
[dispatchError]
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteSession = useCallback(
|
||||||
|
(path: string): void => {
|
||||||
|
window.api
|
||||||
|
.deleteSession(path)
|
||||||
|
.then(() => window.api.listSessions())
|
||||||
|
.then(setSessions)
|
||||||
|
.catch(dispatchError)
|
||||||
|
},
|
||||||
|
[dispatchError]
|
||||||
|
)
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
sessions,
|
||||||
|
send,
|
||||||
|
stop,
|
||||||
|
clearError,
|
||||||
|
refreshSessions,
|
||||||
|
newSession,
|
||||||
|
openSession,
|
||||||
|
renameSession,
|
||||||
|
deleteSession
|
||||||
|
}),
|
||||||
|
[state, sessions, send, stop, clearError, refreshSessions, newSession, openSession, renameSession, deleteSession]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { AgentUiEvent, ModelInfo, SessionState, SessionStatsInfo } from '@shared/types'
|
import type { AgentUiEvent, ModelInfo, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
|
||||||
|
|
||||||
export type { AgentUiEvent, ModelInfo, SessionState, SessionStatsInfo }
|
export type { AgentUiEvent, ModelInfo, SessionMeta, SessionState, SessionStatsInfo }
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -10,6 +10,11 @@ declare global {
|
|||||||
stop: () => Promise<void>
|
stop: () => Promise<void>
|
||||||
getState: () => Promise<SessionState>
|
getState: () => Promise<SessionState>
|
||||||
versions: () => Promise<{ app: string; electron: string; node: string; chrome: string }>
|
versions: () => Promise<{ app: string; electron: string; node: string; chrome: string }>
|
||||||
|
listSessions: () => Promise<SessionMeta[]>
|
||||||
|
newSession: () => Promise<void>
|
||||||
|
openSession: (path: string) => Promise<void>
|
||||||
|
renameSession: (name: string) => Promise<void>
|
||||||
|
deleteSession: (path: string) => Promise<void>
|
||||||
onEvent: (callback: (event: AgentUiEvent) => void) => () => void
|
onEvent: (callback: (event: AgentUiEvent) => void) => () => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ export interface SessionStatsInfo {
|
|||||||
contextPercent?: number
|
contextPercent?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 会话列表项(SessionManager.list 的映射) */
|
||||||
|
export interface SessionMeta {
|
||||||
|
id: string
|
||||||
|
path: string
|
||||||
|
name?: string
|
||||||
|
firstMessage: string
|
||||||
|
created: string
|
||||||
|
modified: string
|
||||||
|
messageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转发到渲染进程的 agent 事件。
|
* 转发到渲染进程的 agent 事件。
|
||||||
* 已归一化为可结构化克隆的普通 JSON(message 原样透传,渲染层防御性读取)。
|
* 已归一化为可结构化克隆的普通 JSON(message 原样透传,渲染层防御性读取)。
|
||||||
@@ -31,6 +42,7 @@ export interface SessionStatsInfo {
|
|||||||
export type AgentUiEvent =
|
export type AgentUiEvent =
|
||||||
| { type: 'session_ready'; state: SessionState }
|
| { type: 'session_ready'; state: SessionState }
|
||||||
| { type: 'session_error'; message: string }
|
| { type: 'session_error'; message: string }
|
||||||
|
| { type: 'session_switched'; state: SessionState; messages: unknown[]; activeTools: Record<string, unknown> }
|
||||||
| { type: 'agent_start' }
|
| { type: 'agent_start' }
|
||||||
| { type: 'agent_end' }
|
| { type: 'agent_end' }
|
||||||
| { type: 'agent_settled' }
|
| { type: 'agent_settled' }
|
||||||
|
|||||||
@@ -15,6 +15,22 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v0.2.0 — 会话管理
|
||||||
|
|
||||||
|
**交付**:
|
||||||
|
- 主进程 `AgentService` 升级为 `AgentSessionRuntime` 模式:`newSession` / `switchSession`(自动重挂事件订阅 + 通知渲染层)、factory 按 cwd 复用/重建 services
|
||||||
|
- 会话列表 `SessionManager.list`(名称/首消息/条数/修改时间)、重命名(`setSessionName`)、删除(`shell.trashItem` 移入回收站,可恢复)
|
||||||
|
- 渲染层:侧栏真实会话列表(选中高亮 + 绿条指示、悬停操作:重命名/删除)、重命名对话框、删除确认、历史消息与工具状态重建(`session_switched` 事件)
|
||||||
|
- 启动自动 `continueRecent` 恢复最近会话
|
||||||
|
- 验证:typecheck/build 通过;Node 冒烟(list/new/switch 恢复 2 条历史/rename 可见于 list);应用截图核验(列表选中态、历史气泡渲染正确)
|
||||||
|
|
||||||
|
**自省**(v0.2.0 → 指导 v0.3+):
|
||||||
|
1. **pi 的落盘约定**——只有出现 assistant 消息会话文件才真正写入(避免空会话残留)。由此:纯手动模式(不调 LLM)产生的会话不会自动出现在列表里,将来 D2 双模式统一时要在手动路径补 assistant 侧记录(`sendCustomMessage` 或直接 appendMessage)。
|
||||||
|
2. **重命名仅限当前会话**——`setSessionName` 作用于活跃会话;列表项上的重命名按钮对非活跃会话实际是"切过去再改名"或干脆隐藏,当前实现有误导。v0.3 修正:非活跃项隐藏重命名按钮(或先 open 再 rename)。
|
||||||
|
3. **会话列表不实时**——列表在加载和切换后刷新,别的窗口/CLI 新建的会话不会自动出现;缺一个"刷新"入口或文件监听。排 v0.5。
|
||||||
|
4. **cwd 固定为 home**——会话按 cwd 分组,工作区切换(店铺 A/店铺 B 各自会话域)还没设计;等 `packages/agent` 业务域明确后一起做。
|
||||||
|
5. **键盘链路仍未人工实测**(computer-use 无法激活窗口);冒烟+截图继续兜底,但 Enter 发送/Shift+Enter 换行/重命名快捷键需要用户实机过一遍。
|
||||||
|
|
||||||
## v0.1.0 — 骨架 + 聊天链路
|
## v0.1.0 — 骨架 + 聊天链路
|
||||||
|
|
||||||
**交付**:
|
**交付**:
|
||||||
|
|||||||
Reference in New Issue
Block a user