- 人工智能
- AI 应用
- 桌面应用
【免费下载链接】open-codesign
Open-source Claude Design alternative. One-click import your Claude Code / Codex API key. Prompt → prototype / slides / PDF. Multi-model (Claude, GPT, Gemini, Kimi, GLM, Ollama). BYOK, local-first, MIT.
导读
本文基于仓库内的 session_history_restore_plan.md 计划文档,完整还原 open-codesign 桌面端(Electron)一次会话历史持久化链路修复的根因分析与四步实施方案。该计划针对window.codesign.chat.*在 v0.2 阶段遗留的 TODO stub——聊天记录既不落盘也无法重载的问题,通过主进程 IPC 通道恢复、preload 桥接、快照种子与工具状态更新回归覆盖四个步骤完成重建。读完本文,你将掌握 open-codesign 主进程 / preload / 渲染进程三层之间聊天数据的完整调用链、JSONL 会话文件的存储与回放机制,以及如何验证这套恢复方案的正确性。
一、问题根因:v0.2 遗留的 TODO stub
计划文档开门见山地给出了问题的根因:
window.codesign.chat.*在apps/desktop/src/preload/index.ts中是 v0.2 的 TODO stub。它返回空列表、在内存中完成 append,因此渲染器(renderer)的聊天记录从未被持久化,也无法在重启后重新加载。
这段话描述了三个具体症状,可以对照源码逐一理解:
chat.list返回空列表:界面每次启动读取聊天历史时得到的都是[],历史对话"凭空消失";chat.append只在内存中追加:消息仅在当前进程存活期间可见,进程退出即丢失;- 聊天记录既不持久化也不重载:没有落盘通道,自然也没有恢复路径,用户无法接续上一次会话。
修复前,preload 层chat对象是一段"占位实现";修复后,它变成了一组真正指向主进程 IPC 通道的桥接方法(详见下文第四节)。
二、恢复方案总览:四步重建链路
计划文档给出了完整的实施清单(当前状态均为已完成):
| 步骤 | 内容 | 状态 |
|---|---|---|
| 1 | 在主进程中使用现有的聊天消息辅助函数(chat message helpers)恢复持久化的聊天 IPC 通道 | ✅ 已完成 |
| 2 | 将 preload 中的聊天方法指向这些 IPC 通道 | ✅ 已完成 |
| 3 | 为 append/list、快照种子(snapshot seeding)与工具状态更新(tool status updates)补充 IPC 回归测试覆盖 | ✅ 已完成 |
| 4 | 运行聚焦的桌面端主进程测试 | ✅ 已完成 |
这套方案的核心思想是不新造轮子:主进程侧本就存在一套围绕SessionManager的会话聊天辅助函数(位于 apps/desktop/src/main/session-chat.ts),只是缺少暴露给渲染层的 IPC 通道。恢复工作因此变成"接线"而非"重写"——把已有能力通过 IPC 暴露出去,再让 preload 指向它。
三、第一步:主进程恢复持久化聊天 IPC 通道
恢复后的 IPC 通道注册在 apps/desktop/src/main/snapshots-ipc.ts(第 1601–1625 行),共四条:
ipcMain.handle('chat:v1:list', (_e, raw): ChatMessageRow[] => { const designId = parseDesignIdPayload(raw, 'chat:v1:list'); return runDb('chat:list', () => listSessionChatMessages(chatStoreOptions(db), designId)); }); ipcMain.handle('chat:v1:append', (_e, raw): ChatMessageRow => { const input = parseChatAppendInput(raw); return runDb('chat:append', () => appendSessionChatMessage(chatStoreOptions(db), input)); }); ipcMain.handle('chat:v1:seed-from-snapshots', (_e, raw): { inserted: number } => { const designId = parseDesignIdPayload(raw, 'chat:v1:seed-from-snapshots'); return runDb('chat:seed-from-snapshots', () => ({ inserted: seedSessionChatFromSnapshots(chatStoreOptions(db), designId), })); }); ipcMain.handle('chat:v1:update-tool-status', (_e, raw): { ok: true } => { const input = parseToolStatusInput(raw); runDb('chat:update-tool-status', () => appendSessionToolStatus(chatStoreOptions(db), input)); return { ok: true }; });四条通道的职责划分非常清晰:
chat:v1:list:读取某个 design 的全部聊天记录,返回ChatMessageRow[];chat:v1:append:追加一条聊天消息,返回持久化后的完整行(含id、seq、createdAt);chat:v1:seed-from-snapshots:首次打开既有 design 时,用快照中的 prompt 反向填充聊天历史,返回插入条数;chat:v1:update-tool-status:更新某条工具调用(tool_call)消息的执行状态,返回{ ok: true }。
底层真正干活的是 session-chat.ts 中导出的辅助函数——listSessionChatMessages、appendSessionChatMessage、seedSessionChatFromSnapshots、appendSessionToolStatus。每个 handler 都经过runDb包裹,配合CodesignError与IPC_BAD_INPUT/IPC_NOT_FOUND等错误码(见 packages/shared/src/error-codes.ts),保证异常路径可控。
3.1 存储层:JSONL 会话文件
session-chat.ts的存储模型是每个 design 一个 JSONL 文件:
function sessionFileForDesign(sessionDir: string, designId: string): string { const safeId = designId.replace(/[^A-Za-z0-9_-]/g, '_'); return path.join(sessionDir, `${safeId}.jsonl`); }- designId 会被清洗为只含
A-Za-z0-9_-的安全文件名,避免路径注入; - 会话文件放在
db.sessionDir下,主进程通过SessionChatStoreOptions({ db, sessionDir })统一传入; - 写入前
mkdirSync(..., { recursive: true })确保目录存在,文件为JSON.stringify的逐行追加(writeFileSync全量重写)。
3.2 消息条目类型与 schema 版本
每条写入会话文件的自定义条目(custom entry)都带type: 'custom'与customType,由常量标识:
| 常量 | customType 值 | 用途 |
|---|---|---|
CHAT_MESSAGE_CUSTOM_TYPE | open-codesign.chat.message | 聊天消息本体 |
CHAT_TOOL_STATUS_CUSTOM_TYPE | open-codesign.chat.tool_status | 工具调用状态更新 |
COMMENT_CUSTOM_TYPE | open-codesign.comment.v1 | 评论事件(add/update/remove/mark-applied) |
CONTEXT_BRIEF_CUSTOM_TYPE | open-codesign.context.brief.v1 | 设计简报 |
RUN_PREFERENCES_CUSTOM_TYPE | open-codesign.context.run_preferences.v1 | 运行偏好 |
ACTIVE_MESSAGE_CUSTOM_TYPE | open-codesign.active-message.v1 | 运行中的活跃消息 |
所有持久化结构均携带schemaVersion: 1,例如存储的聊天消息:
interface StoredChatMessage { schemaVersion: 1; id: number; seq: number; kind: ChatMessageKind; payload: unknown; snapshotId: string | null; }回放时通过parseStoredMessage、parseStatusUpdate、parseCommentEvent等解析函数做严格校验(schemaVersion必须为 1、字段类型逐一检查),遇到畸形条目会抛出IPC_DB_ERROR,而不是静默吞掉数据。
3.3 追加消息:seq 与活动时间戳
appendSessionChatMessage的实现要点:
const seq = listSessionChatMessages(opts, input.designId).length; const stored: StoredChatMessage = { schemaVersion: 1, id: seq, seq, kind: input.kind, payload: input.payload ?? {}, snapshotId: input.snapshotId ?? null, };seq取自当前消息总数,保证同一会话内消息序号单调递增且稳定,这是后面工具状态按seq回写的前提;- 默认会调用
touchDesignActivity(db, designId, createdAt)更新设计活跃时间(快照种子等批量回填场景通过{ touchActivity: false }关闭,避免污染排序)。
四、第二步:preload 聊天方法指向 IPC 通道
恢复后的 preload 桥接位于 apps/desktop/src/preload/index.ts(第 865–898 行),window.codesign.chat从 TODO stub 变成了四个真实调用ipcRenderer.invoke的方法:
chat: { list: (designId: string) => ipcRenderer.invoke('chat:v1:list', { schemaVersion: 1, designId }) as Promise<ChatMessageRow[]>, append: (input: ChatAppendInput) => ipcRenderer.invoke('chat:v1:append', { schemaVersion: 1, ...input }) as Promise<ChatMessageRow>, seedFromSnapshots: (designId: string) => ipcRenderer.invoke('chat:v1:seed-from-snapshots', { schemaVersion: 1, designId }) as Promise<{ inserted: number }>, updateToolStatus: (input: { designId; seq; status: 'done' | 'error'; result?; durationMs?; errorMessage? }) => ipcRenderer.invoke('chat:v1:update-tool-status', { schemaVersion: 1, ...input }) as Promise<{ ok: true }>, onAgentEvent: (cb: (event: AgentStreamEvent) => void) => { ... }, }注意所有 IPC 载荷都携带schemaVersion: 1,与主进程 handler 中的parseDesignIdPayload/requireSchemaV1校验对应;同时onAgentEvent订阅了agent:event:v1通道,用于接收主进程推送的实时 Agent 事件(见第五节)。
渲染进程侧的消费端同样印证了这条链路:在 apps/desktop/src/renderer/src/store/slices/chat.ts 中,打开设计时会先window.codesign.chat.seedFromSnapshots(designId)再window.codesign.chat.list(designId)加载历史,追加消息则走window.codesign.chat.append(input)——这正是计划中"渲染器聊天行从未被持久化或重载"问题被修复后的实际调用形态。
五、快照种子:让既有设计的历史"复活"
对于 v0.2 之前创建、从未写入过会话文件的既有设计,直接chat:list只能得到空数组。seedSessionChatFromSnapshots(session-chat.ts 第 690–724 行)解决这个问题:
export function seedSessionChatFromSnapshots(opts, designId): number { if (listSessionChatMessages(opts, designId).length > 0) return 0; // 已有历史则跳过 const snapshots = listSnapshots(opts.db, designId).slice().reverse(); for (const snapshot of snapshots) { if (snapshot.prompt 非空) { appendSessionChatMessage(opts, { designId, kind: 'user', payload: { text: snapshot.prompt } }, { touchActivity: false }); } appendSessionChatMessage(opts, { designId, kind: 'artifact_delivered', payload: { createdAt: snapshot.createdAt }, snapshotId: snapshot.id, }, { touchActivity: false }); } return inserted; }关键行为:
- 幂等:会话文件已存在聊天记录时直接返回 0,绝不重复播种;
- 从快照反推历史:每个快照的
prompt回填为一条user消息,并追加一条带snapshotId的artifact_delivered消息(标记交付了哪个快照产物); - 时间顺序:快照按时间倒序取、再
reverse()成正序写入,保证聊天记录的自然顺序; - 不触碰活动时间:批量回填使用
touchActivity: false,避免把所有设计"顶"到活跃列表最前。
六、实时事件投影:run 事件到聊天行的持续写入
持久化不只发生在用户手动 append 时。Agent 运行过程中的流式事件同样需要落到会话文件中,这一职责由 apps/desktop/src/main/run-event-chat.ts 的projectRunEventToChat承担,其注释点明设计原则:"日志(journal)是权威来源;回放用于修复日志与聊天写入之间的崩溃间隙。"
事件 → 聊天行的映射规则:
| Agent 事件 | 生成的聊天行 | 说明 |
|---|---|---|
turn_end | assistant_text | 写入finalText,携带runId与runEventKey(generationId:seq) |
tool_call_start | tool_call | 记录toolName、args、toolCallId、command、status |
tool_call_result | tool_call状态更新 | 按runId + toolCallId定位原行,appendSessionToolStatus回写done/error、result、durationMs |
error | error消息 | 记录message与code |
run_settled | 结算所有running工具 + 补写assistant_text/artifact_delivered | completed→done,否则 →error |
去重与幂等是这套投影机制的关键:每条追加都带唯一runEventKey,append前先检查rows.some((row) => payload(row)['runEventKey'] === eventKey),重复事件不会产生重复行;工具结果按seq应用,且通过runResultEventKey保证"已提交的结果不会被运行结算推断的错误覆盖"(见applyStatusUpdate中删除error/errorMessage的逻辑)。
主进程在 apps/desktop/src/main/ipc/generate.ts(第 333–381 行)的publishEvent中完成"日志先写、投影随后、事件再推送给渲染器"的编排:事件先journal.append持久化,再projectRunEventToChat投影到聊天存储,最后target.send('agent:event:v1', durable)推送给窗口。此外codesign:v1:recover-runshandler(同文件第 463–511 行)会读取 journal 中全部历史事件并重放投影,实现启动时的崩溃恢复。
七、工具状态更新的落地细节
chat:v1:update-tool-status对应的appendSessionToolStatus(session-chat.ts 第 599–629 行)有几个值得注意的实现细节:
- 先定位再更新:按
seq找到原始消息,若其kind !== 'tool_call'直接返回,绝不写入孤儿状态; - 结果压缩:工具结果先经
compactToolResultForHistory(toolName, result)压缩(见 apps/desktop/src/main/ipc/tool-log.ts),避免把超大结果原样塞进 JSONL; - 回放时合并:
replayEntries在重建聊天行时按seq找到对应tool_call行并applyStatusUpdate合并状态字段,因此读取出来的行始终是"已应用最新状态"的最终形态; - 错误字段规范化:
errorMessage会被展开为{ error: { message } }与平铺errorMessage两种形态,供渲染层不同组件消费。
八、回归测试覆盖与验证
计划第三步要求为 append/list、快照种子、工具状态更新补充 IPC 回归测试,仓库中的测试文件可以逐一对应:
- apps/desktop/src/main/session-chat.test.ts:直接覆盖
appendSessionChatMessage、listSessionChatMessages、seedSessionChatFromSnapshots等存储函数——例如种子测试断言插入数为 2(1 条 user prompt + 1 条 artifact_delivered),且getDesign(...).updatedAt在touchActivity: false下保持不变; - apps/desktop/src/main/run-event-chat.test.ts:覆盖
projectRunEventToChat的事件投影规则,包括"忽略瞬态与未入日志的事件"等边界; - apps/desktop/src/renderer/src/store.chat-continuity.test.ts:以
api.chat.seedFromSnapshots/api.chat.list的 mock 验证渲染器在重载/聚焦时的续聊行为; - apps/desktop/src/renderer/src/hooks/useAgentStream.completion.test.ts:验证
chat.append/chat.list在完成事件下的调用次数与窗口聚焦后的重新拉取。
验证方式(第四步)即运行桌面端主进程的聚焦测试。仓库使用 pnpm + vitest(配置见 apps/desktop/vitest.config.ts),典型命令为:
# 仓库根目录(pnpm workspace) pnpm --filter @open-codesign/desktop test -- session-chat run-event-chat或单独跑某个文件:
pnpm --filter @open-codesign/desktop vitest run apps/desktop/src/main/session-chat.test.ts若要在本地复现整个链路,可先pnpm install,再运行pnpm --filter @open-codesign/desktop dev(dev 入口见 apps/desktop/scripts/dev.cjs)启动 Electron 应用,创建/打开一个 design 后重启应用,即可验证聊天历史被从<sessionDir>/<safeId>.jsonl中恢复。
九、小结
从这份计划文档可以提炼出 open-codesign 桌面端会话历史持久化的完整链路:
渲染进程 store/slices/chat.ts │ window.codesign.chat.{list,append,seedFromSnapshots,updateToolStatus} ▼ preload 层 index.ts(IPC 桥接,携带 schemaVersion: 1) │ ipcRenderer.invoke('chat:v1:*') ▼ 主进程 snapshots-ipc.ts(IPC handler + 输入校验 + runDb) │ ▼ session-chat.ts(SessionManager + JSONL 文件 + 回放/校验) ▲ │ projectRunEventToChat(run-event-chat.ts) │ RunJournal 事件流(ipc/generate.ts publishEvent / recover-runs)这条链路的修复价值在于:聊天记录从"进程内存中的易失数据"升级为"以 JSONL 落盘、可由快照种子恢复、可被运行日志回放修复的持久数据",同时通过schemaVersion版本化、seq序号、runEventKey去重等手段保证读取结果的确定性与幂等性。对于需要在 Electron 桌面应用中实现"会话级聊天持久化 + 崩溃恢复 + 历史回填"的开发者,这份计划与实现提供了一个结构清晰、可逐层验证的参考范式。
- 人工智能
- AI 应用
- 桌面应用
【免费下载链接】open-codesign
Open-source Claude Design alternative. One-click import your Claude Code / Codex API key. Prompt → prototype / slides / PDF. Multi-model (Claude, GPT, Gemini, Kimi, GLM, Ollama). BYOK, local-first, MIT.
相关推荐
open-codesign v0.2 Agentic Design Loop 架构解析:从 JSONL 会话存储到 15 工具 Harness 的完整实施指南
open codesign v0.2 Agentic Design Loop 架构解析:从 JSONL 会话存储到 15 工具 Harness 的完整实施指南
人工智能AI 应用桌面应用Open-LLM-VTuber聊天记录管理完全指南:持久化存储与历史对话切换
Open LLM VTuber聊天记录管理完全指南:持久化存储与历史对话切换 Open LLM VTuber是一个开源的AI虚拟主播项目,支持通过语音与大型语言
AI 应用大模型语音数字人交互助手本地部署kotaemon会话存储:历史记录持久化方案
kotaemon会话存储:历史记录持久化方案 概述 在RAG(Retrieval Augmented Generation)应用中,会话历史记录的持久化存储是确
人工智能大模型RAG向量数据库后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考