/** * 会话与消息持久化。 * * 存储:userData/sessions.json(单文件,MVP 规模足够) * 真实业务后续应换成 SQLite;此处刻意把接口收敛成 Store 形态,便于替换实现。 */ import { app } from 'electron' import { promises as fs } from 'node:fs' import { join } from 'node:path' import { randomUUID } from 'node:crypto' import type { ChatMessage, SessionMeta } from '@shared/types' import { applyAssistantEvent } from '@shared/message-parts' import type { SessionContext } from '@shared/workspaces' import { migrateImportedWorkBuddyMessages, type WorkBuddyImportProvenance } from '@shared/workbuddy-import' import { agentMode, agentModeContextPatch } from './agent/mode-policy' interface SessionRecord extends SessionMeta { messages: ChatMessage[] pendingNativeBranchId?: string importProvenance?: WorkBuddyImportProvenance } interface StoreShape { sessions: SessionRecord[] } let cache: StoreShape | null = null let loadPromise: Promise | null = null let persistQueue: Promise = Promise.resolve() function filePath(): string { return join(app.getPath('userData'), 'sessions.json') } async function load(): Promise { if (cache) return cache if (!loadPromise) { loadPromise = (async () => { try { const raw = await fs.readFile(filePath(), 'utf8') const parsed = JSON.parse(raw) as StoreShape cache = { sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [] } let recovered = false for (const session of cache.sessions) { const migrated = migrateImportedWorkBuddyMessages(session.messages ?? [], session.importProvenance) if (migrated.changed) { session.messages = migrated.messages; recovered = true } session.messages = (session.messages ?? []).map((message) => { if (message.status !== 'streaming') return message recovered = true return { ...applyAssistantEvent(message, { runId: message.requestId ?? '', type: 'cancelled' }), interrupted: true } }) } if (recovered) await persist() } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { cache = null; throw error } cache = { sessions: [] } } return cache as StoreShape })() } const pending = loadPromise try { return await pending } finally { if (loadPromise === pending) loadPromise = null } } async function persist(): Promise { if (!cache) return const snapshot = JSON.stringify(cache, null, 2) const userData = app.getPath('userData') const file = filePath() const temporaryFile = join(userData, `.sessions-${randomUUID()}.tmp`) const write = persistQueue.then(async () => { await fs.mkdir(userData, { recursive: true }) try { await fs.writeFile(temporaryFile, snapshot, 'utf8') await fs.rename(temporaryFile, file) } catch (error) { await fs.rm(temporaryFile, { force: true }).catch(() => undefined) throw error } }) // A failed write reaches its caller but does not reject subsequent writes. persistQueue = write.catch(() => undefined) await write } export async function listSessions(): Promise { const store = await load() return [...store.sessions] // 置顶任务优先(最近置顶在前),其余按更新时间倒序。 .sort((a, b) => Number(b.pinnedAt ?? 0) - Number(a.pinnedAt ?? 0) || b.updatedAt - a.updatedAt) .map(({ id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, pinnedAt, archivedAt }) => ({ id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, pinnedAt, archivedAt })) } /** 侧栏可见任务:默认排除已归档;`includeArchived` 供归档视图使用。 */ export async function listVisibleSessions(includeArchived = false): Promise { const sessions = await listSessions() return includeArchived ? sessions : sessions.filter((session) => session.archivedAt === undefined) } export async function setSessionPinned(sessionId: string, pinned: boolean): Promise { const store = await load() const session = store.sessions.find((item) => item.id === sessionId) if (!session) throw new Error('会话不存在') const previous = session.pinnedAt session.pinnedAt = pinned ? Date.now() : undefined try { await persist() } catch (error) { session.pinnedAt = previous; throw error } return meta(session) } export async function setSessionArchived(sessionId: string, archived: boolean): Promise { const store = await load() const session = store.sessions.find((item) => item.id === sessionId) if (!session) throw new Error('会话不存在') const previous = session.archivedAt session.archivedAt = archived ? Date.now() : undefined try { await persist() } catch (error) { session.archivedAt = previous; throw error } return meta(session) } function meta(session: SessionRecord): SessionMeta { const { id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, pinnedAt, archivedAt } = session return { id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, pinnedAt, archivedAt } } export async function createSession(title = '新对话', context: SessionContext = {}): Promise { context = agentModeContextPatch({}, context) const store = await load() const now = Date.now() const record: SessionRecord = { ...context, id: randomUUID(), title, createdAt: now, updatedAt: now, messages: [] } store.sessions.push(record) await persist() return { ...context, id: record.id, title: record.title, createdAt: now, updatedAt: now } } export async function getSessionContext(id: string): Promise { const session = (await load()).sessions.find((item) => item.id === id) if (!session) throw new Error('会话不存在') return { workspaceId: session.workspaceId, projectId: session.projectId, cwd: session.cwd, modelId: session.modelId, expertId: session.expertId, agentMode: agentMode(session.agentMode), thinkingLevel: session.thinkingLevel } } export async function updateSessionContext(id: string, patch: SessionContext): Promise { const session = (await load()).sessions.find((item) => item.id === id) if (!session) throw new Error('会话不存在') const previous = { ...session } Object.assign(session, agentModeContextPatch(session, patch)) try { await persist() } catch (error) { for (const key of Object.keys(session)) if (!Object.hasOwn(previous, key)) delete (session as unknown as Record)[key] Object.assign(session, previous); throw error } const { id: sessionId, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level } = session return { id: sessionId, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level } } export async function getMessages(sessionId: string): Promise { const store = await load() return store.sessions.find((s) => s.id === sessionId)?.messages ?? [] } export async function getPendingNativeBranch(sessionId: string): Promise { return (await load()).sessions.find((session) => session.id === sessionId)?.pendingNativeBranchId } export async function setPendingNativeBranch(sessionId: string, entryId: string | undefined): Promise { const session = (await load()).sessions.find((item) => item.id === sessionId) if (!session) throw new Error('会话不存在') const previous = session.pendingNativeBranchId session.pendingNativeBranchId = entryId try { await persist() } catch (error) { session.pendingNativeBranchId = previous; throw error } } export async function appendMessage(sessionId: string, message: ChatMessage): Promise { const store = await load() const session = store.sessions.find((s) => s.id === sessionId) if (!session) return session.messages.push(message) session.updatedAt = Date.now() await persist() } export async function patchMessage( sessionId: string, messageId: string, patch: Partial, options: { touchSession?: boolean } = {} ): Promise { const store = await load() const session = store.sessions.find((s) => s.id === sessionId) if (!session) return const message = session.messages.find((m) => m.id === messageId) if (!message) return Object.assign(message, patch) if(options.touchSession!==false)session.updatedAt = Date.now() await persist() } export async function renameSession(sessionId: string, title: string): Promise { const store = await load() const session = store.sessions.find((s) => s.id === sessionId) if (!session) return session.title = title.trim() || session.title session.updatedAt = Date.now() await persist() } export async function deleteSession(sessionId: string): Promise { const store = await load() store.sessions = store.sessions.filter((s) => s.id !== sessionId) await persist() } /** Keep the abandoned branch on disk before replacing the active conversation. */ export async function replaceMessages(sessionId: string, messages: ChatMessage[], reason: string, options?: { pendingNativeBranchId?: string }): Promise { const store = await load() const session = store.sessions.find((item) => item.id === sessionId) if (!session) throw new Error('会话不存在') const directory = join(app.getPath('userData'), 'session-branches', encodeURIComponent(sessionId)) await fs.mkdir(directory, { recursive: true }) await fs.writeFile(join(directory, `${Date.now()}-${randomUUID()}.json`), JSON.stringify({ reason, session }, null, 2), 'utf8') const previous = session.messages const updatedAt = session.updatedAt const previousBranch = session.pendingNativeBranchId session.messages = messages if (options) session.pendingNativeBranchId = options.pendingNativeBranchId session.updatedAt = Date.now() try { await persist() } catch (error) { session.messages = previous; session.updatedAt = updatedAt; session.pendingNativeBranchId = previousBranch; throw error } } /** A turn and its selected native branch are one atomic session-store mutation. */ export async function startTurn(sessionId: string, user: ChatMessage, assistant: ChatMessage, prefix: ChatMessage[], reason?: string, nativeBranchId?: string): Promise { const store = await load() const session = store.sessions.find((item) => item.id === sessionId) if (!session) throw new Error('会话不存在') if (reason) { const directory = join(app.getPath('userData'), 'session-branches', encodeURIComponent(sessionId)) await fs.mkdir(directory, { recursive: true }) await fs.writeFile(join(directory, `${Date.now()}-${randomUUID()}.json`), JSON.stringify({ reason, session }, null, 2), 'utf8') } const previous = { messages: session.messages, title: session.title, updatedAt: session.updatedAt, pendingNativeBranchId: session.pendingNativeBranchId } session.messages = [...prefix, user, assistant] if (!prefix.length) session.title = deriveTitle(user.content) if (nativeBranchId) session.pendingNativeBranchId = nativeBranchId session.updatedAt = Date.now() try { await persist() } catch (error) { Object.assign(session, previous); throw error } } /** 用首条用户消息生成标题 */ export function deriveTitle(text: string): string { const flat = text.replace(/\s+/g, ' ').trim() return flat.length > 24 ? `${flat.slice(0, 24)}…` : flat || '新对话' }