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
+76
View File
@@ -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)
+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/函数/循环引用) */
+16 -17
View File
@@ -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<void>
steer: (text: string) => Promise<void>
stop: () => Promise<void>
getState: () => Promise<SessionState>
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<void> => ipcRenderer.invoke('agent:send', text),
steer: (text: string): Promise<void> => ipcRenderer.invoke('agent:steer', text),
stop: (): Promise<void> => ipcRenderer.invoke('agent:stop'),
getState: (): Promise<SessionState> => ipcRenderer.invoke('agent:state'),
versions: (): Promise<{ app: string; electron: string; node: string; chrome: string }> =>
ipcRenderer.invoke('app:versions'),
listSessions: (): Promise<SessionMeta[]> => ipcRenderer.invoke('sessions:list'),
newSession: (): Promise<void> => ipcRenderer.invoke('sessions:new'),
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),
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)
+11 -2
View File
@@ -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<string | null>(null)
const handlePickSuggestion = (text: string): void => {
@@ -17,7 +17,16 @@ export default function App(): JSX.Element {
return (
<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">
<ChatView
messages={state.messages}
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import type { JSX } from 'react'
import { Cpu, MessageSquarePlus, Store } from 'lucide-react'
import type { SessionState, SessionStatsInfo } from '@shared/types'
import { Check, Cpu, MessageSquare, Pencil, Store, Trash2, X } from 'lucide-react'
import type { SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
interface VersionInfo {
app: string
@@ -21,16 +21,106 @@ function formatTokens(n: number): string {
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({
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<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)
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 (
<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 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>
{/* 新会话(v0.2 提供) */}
{/* 新会话 */}
<div className="px-3">
<button
type="button"
disabled
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-400 transition-colors hover:border-zinc-700 disabled:cursor-not-allowed disabled:opacity-60"
onClick={onNewSession}
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"
>
<MessageSquarePlus size={15} />
<span className="text-[15px] leading-none text-zinc-500">+</span>
新会话
</button>
</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="rounded-xl border border-dashed border-zinc-800/80 px-3 py-4 text-center text-[12px] leading-5 text-zinc-600">
会话列表与历史恢复
<br />
<span className="text-zinc-700">v0.2 上线</span>
<div className="min-h-0 flex-1 space-y-0.5 overflow-y-auto pb-2">
{sessions.length === 0 ? (
<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>
) : (
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>
{/* 重命名对话框 */}
{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
+157 -27
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'
import type { AgentUiEvent, SessionState, SessionStatsInfo } from '@shared/types'
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
import type { AgentUiEvent, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
export interface ToolState {
toolName: string
@@ -64,6 +64,47 @@ function readRole(message: unknown): string {
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 {
if (action.type === 'optimistic-user') {
return {
@@ -84,6 +125,19 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
return { ...state, ready: true, sessionState: event.state }
case 'session_error':
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':
return { ...state, streaming: true, error: null, retry: null }
case 'agent_end':
@@ -151,7 +205,10 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
case 'tool_update': {
const tools = { ...state.tools }
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 }
}
case 'tool_end': {
@@ -167,13 +224,20 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
case 'queue_update':
return { ...state, queue: { steering: event.steering, followUp: event.followUp } }
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':
return { ...state, error: event.message }
case 'session_info':
return state.sessionState
? { ...state, sessionState: { ...state.sessionState, sessionName: event.name } }
: state
case 'session_info': {
if (!state.sessionState) return state
return {
...state,
sessionState: { ...state.sessionState, sessionName: event.name }
}
}
case 'stats':
return { ...state, stats: event.stats }
default:
@@ -183,45 +247,111 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
export interface UseAgent {
state: AgentUiState
sessions: SessionMeta[]
send: (text: string) => void
stop: () => void
clearError: () => void
refreshSessions: () => void
newSession: () => void
openSession: (path: string) => void
renameSession: (name: string) => void
deleteSession: (path: string) => void
}
export function useAgent(): UseAgent {
const [state, dispatch] = useReducer(reducer, initialState)
const stateRef = useRef(state)
stateRef.current = state
const [sessions, setSessions] = useState<SessionMeta[]>([])
useEffect(() => {
const unsubscribe = window.api.onEvent((event) => {
dispatch({ type: 'event', event })
// 会话集合可能在后台变化(新建/删除/重命名),切换后刷新列表
if (event.type === 'session_switched') {
void window.api
.listSessions()
.then(setSessions)
.catch(() => undefined)
}
})
return unsubscribe
}, [])
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((err: unknown) => {
dispatch({
type: 'event',
event: { type: 'error', message: err instanceof Error ? err.message : String(err) }
})
const refreshSessions = useCallback((): void => {
void window.api
.listSessions()
.then(setSessions)
.catch(() => undefined)
}, [])
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(() => {
window.api.stop().catch((err: unknown) => {
dispatch({
type: 'event',
event: { type: 'error', message: err instanceof Error ? err.message : String(err) }
})
})
}, [])
window.api.stop().catch(dispatchError)
}, [dispatchError])
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]
)
}
+7 -2
View File
@@ -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 {
interface Window {
@@ -10,6 +10,11 @@ declare global {
stop: () => Promise<void>
getState: () => Promise<SessionState>
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
}
}
+12
View File
@@ -24,6 +24,17 @@ export interface SessionStatsInfo {
contextPercent?: number
}
/** 会话列表项(SessionManager.list 的映射) */
export interface SessionMeta {
id: string
path: string
name?: string
firstMessage: string
created: string
modified: string
messageCount: number
}
/**
* 转发到渲染进程的 agent 事件。
* 已归一化为可结构化克隆的普通 JSON(message 原样透传,渲染层防御性读取)。
@@ -31,6 +42,7 @@ export interface SessionStatsInfo {
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: 'agent_start' }
| { type: 'agent_end' }
| { type: 'agent_settled' }