AI 聊天刷新后记录全丢?用浏览器 IndexedDB 给 AI Chat 加一层“离线记忆“
本文为作者原创首发于掘金现同步发布到 CSDN。内容整理自AI Mind项目的真实开发过程。GitHubhttps://github.com/HWYD/ai-mind对应代码版本v0.4.7线上体验https://ai.hwyblog.cloud/instant-mindAI Mind 是一个基于 Next.js 持续迭代的 AI Chat 项目项目从本地大模型聊天起步逐步扩展流式协议、工具调用、MCP、Skill Runtime 和 Agent 等能力。如果这篇文章或 AI Mind 项目对你有所帮助也欢迎到 GitHub 给项目点个 Star⭐这会是对我继续整理后续版本复盘很大的鼓励。先花两句话介绍一下 AI Mind 是什么。它是一个 Next.js 写的 AI Chat 项目但跟普通聊天框不太一样——它不只是你问 AI 答而是把一次对话拆成了多个可展示的环节AI 调用外部工具tool查资料、读取本地文件resource、触发预设的提示词模板prompt、执行一组稳定的任务模式Skill、甚至跑一个多步骤的自主任务Agent每一步的执行过程都会在界面上可视化展示出来。这些展示内容——工具返回的 JSON、Skill 的执行步骤、Agent 的决策路线图、AI 生成的文档产物artifact——构成了所谓的富 UI 聊天记录。问题来了这些富 UI 聊天记录刷新页面就全丢了。这不是加个 localStorage 缓存就能解决的。这些内容结构复杂、容量不小而且有些状态——比如 AI 还在流式输出中的半截话、Agent 暂停等待人工确认——不能也不该被持久化。更关键的是这套本地持久化不能破坏现有的架构边界。在 AI Mind 里服务端有两样东西各司其职一个是 Conversation Registry会话登记表负责记录有哪些会话、每个会话属于谁另一个是 ThreadState线程状态负责给 AI 提供最近聊了什么、之前做了哪些重要决策的短期记忆。这两样东西都不保存完整的聊天历史——会话登记表只管身份线程状态只管最近几轮对话的关键信息。本地快照只能管 UI 展示恢复不能偷偷升级为 AI 的上下文来源也不能绕过服务端的会话归属校验。这一版做的事情就是用浏览器 IndexedDB 保存最近会话的完整 UI 快照页面刷新后先从本地恢复展示再让服务端做权威校准。另外补上了桌面端和移动端统一的会话删除能力删一个会话会同时清理服务端 Registry、ThreadState 和本地快照。1. 三层数据各管各的不互相替代先交代几个概念后面会反复用到Conversation Registry服务端维护的会话登记表记录会话 ID、标题、归属最多保留 10 个最近会话。它是会话身份的权威来源。ThreadState服务端为 AI 运行时保存的短期上下文——bounded recent text、summary、pinned decisions。它不包含完整聊天历史也不包含富 UI 部件。本地快照浏览器 IndexedDB 里保存的完整用户可见 UI 展示记录是刷新后恢复聊天界面的唯一完整来源。这三者的关系用一张表说清楚数据对象存储位置权威职责不管什么本地完整 UI 快照浏览器 IndexedDB刷新后恢复完整聊天展示文本 富 UI 部件不管会话身份、不管 AI 上下文Server Conversation Registry服务端 PostgreSQL会话身份、归属、最近 10 个会话的保留不保存完整消息历史Server ThreadState服务端 PostgreSQLAI 运行时短期上下文不恢复完整富 UI 展示为什么不能合并成一套因为服务端不存完整聊天历史——ThreadState 只保留最近几轮对话的文本摘要bounded recent text不包含工具调用结果、Agent 执行轨迹、文档产物等富 UI 内容。如果只用服务端数据恢复刷新后只能看到最近几轮纯文本之前跑的 Agent 执行过程全丢了。反过来如果我把本地完整快照当模型上下文发给 AI等于把展示用的历史偷偷变成了 AI 的决策依据——这越界了。那本地快照和服务端 ThreadState 内容不一致怎么办答案很简单允许不一致允许继续发送。本地快照只决定 UI 展示服务端 ThreadState 决定 AI 上下文。两者不做静默合并、覆盖或补写。如果要求完全一致才允许发送等于把本地缓存变成了一条阻塞聊天主链的同步依赖——这比不一致本身还糟糕。2. 为什么选 IndexedDB不选 localStoragev0.4.7 之前AI Mind 已经在用localStorage保存 selected/draft hint——但那是简单的字符串标记跟富 UI 快照完全是两个量级的东西。一条工具调用结果可能包含几百行 JSON 输出一次 Agent 执行可能产生多个 Agent Step 展示块一份 artifact 可能是完整的 Markdown 文档。这些内容用localStorage存有三个硬伤容量通常只有 5-10MBsetItem()是同步 API 会卡 UI而且只支持字符串结构化数据全靠手写JSON.parse/stringify。IndexedDB 刚好把这三个问题都解决了异步 API、原生支持结构化对象、容量远大于 localStorage。浏览器重启后数据仍在符合跨重启保留的承诺。没引入任何 IndexedDB wrapper 依赖——直接用原生 API通过runStoreOperation()封装事务生命周期。数据库名固定为ai-mind-local-chat两个 Object Storeconversation-index会话索引存最近 10 个会话的 ID、标题、时间等元数据以及selectedConversationId和isDraft两个 UI hintconversation-snapshots会话快照按conversationId独立存储每条记录包含该会话的完整可恢复消息列表3. 不是所有消息都能存稳定快照的过滤规则这是 v0.4.7 最关键的一层过滤。AI Mind 前端有一个叫useChatStream的核心 Hook它负责管理当前会话的所有消息——每条消息是一个MindMessage对象里面包含多个part消息部件比如一段文本、一次工具调用结果、一个 Agent 执行步骤。但这个消息列表里混着很多不能持久化的东西——流式输出中的半成品、Agent 暂停等待人工确认的控制信号AgentInterrupt、线程内存状态提示thread-memory-status等。如果把这些也写进 IndexedDB刷新后恢复出来的是不可用的半截数据。stable-snapshot.ts稳定快照投影负责从消息列表中提取可安全恢复的子集。它的核心逻辑很简单——白名单 状态过滤// 只保留这 8 种 part 类型——对应聊天界面中用户能看到的各种展示块// agent-step: Agent 决策步骤 reasoning: AI 推理过程// tool: 工具调用结果 resource: 文件/资源预览// skill: Skill 执行展示 workflow-progress: 任务进度// text: 纯文本 prompt: 提示词模板constRECOVERABLE_PART_TYPES[agent-step,prompt,reasoning,resource,skill,text,tool,workflow-progress,]functionisRecoverablePart(part:MindMessagePart):boolean{// 状态必须是未设置或 completed——streaming/pending 的不要if(part.statuspart.status!completed)returnfalsereturnRECOVERABLE_PART_TYPES.includes(part.type)}projectRecoverableMessages()的完整过滤规则只保留 user 和 assistant 消息——system 消息、控制消息不进入快照只保留 status 为未设置或completed的消息——streaming、failed、aborted、pending 全过滤只保留白名单内的 part 类型——thread-memory-status、AgentInterrupt等控制部件不进入过滤掉不完整的 artifact——只保留已完成状态的 text artifact移除空消息——过滤后没有任何可恢复 part 的消息直接丢弃容量裁剪最多 120 条消息超出时从最旧的完整消息开始删除快照只在流式完成、删除问答完成、重新生成完成后提交。流式中、请求失败、用户中止时不提交当前回合保留上一份成功稳定快照。这个策略确保了一件事刷新后恢复出来的一定是之前已经完整看到过的内容。4. local-first 恢复先本地后服务端失败降级刷新页面后的完整恢复链路是三步走。这里先解释一个关键概念——bounded hydration服务端/api/chat/thread接口返回的会话恢复数据但它只包含最近几轮对话的纯文本不包含富 UI 部件。换句话说它不是一个完整的聊天记录备份而是一个AI 需要知道的最近上下文。[页面刷新] │ ├─ 1. 读取 IndexedDB │ ├─ 读 conversation-index → 立即渲染最近会话列表 │ └─ 读 conversation-snapshots[selectedId] → 立即渲染当前会话消息 │ ├─ 2. 请求 GET /api/chat/conversations │ ├─ 成功 → 用服务端列表替换本地索引清理不在列表中的旧会话 │ └─ 失败 → 保留本地数据进入只读缓存态 │ └─ 3. 请求 GET /api/chat/thread?conversationIdxxx ├─ 成功 本地有快照 → 保留本地展示服务端只做确认 ├─ 成功 本地无快照 → 用 bounded hydration 降级展示 ├─ 服务端返回ThreadState 暂不可用错误 本地有快照 → 只读缓存 └─ 失败 本地无快照 → 恢复失败保持空状态第 2 步的服务端权威校准值得单独说一下。use-conversation-sessions.ts会话列表状态管理在请求 registry 之前会记录一份本地索引基线// 请求前记录基线constbaseline{revision:localIndex.revision,conversationIds:localIndex.conversations.map(cc.id),}// 请求成功后用基线做权威替换awaitreconcileLocalConversationIndex(baseline,serverPayload)reconcileLocalConversationIndex()的行为服务端列表中的会话 → 更新本地索引元数据标题、时间不碰该会话的本地消息快照基线中不在服务端列表的会话 → 从本地索引硬删除并删除对应的本地消息快照请求发起后由其他标签页创建的新会话 → 保留不因本次权威替换而丢失请求失败/超时/无效 → 不做任何清理本地数据全部保留第 3 步的关键服务端返回的 bounded hydration 只包含有限条目的纯文本不包含富 UI 部件。如果本地已有完整快照服务端数据只用于确认这个会话仍然有效、可以继续发送。如果本地没有快照bounded hydration 作为降级展示但不会让用户误以为这就是全部聊天记录。5. 服务端只做了两个最小调整v0.4.7 不修改ai-mind/stream-core公开协议不新增 PostgreSQL 聊天历史业务表。服务端只动了两个地方。5.1 ThreadState 不可用时返回显式错误码而不是假装成功之前/api/chat/thread在 ThreadState 读取失败时返回的是restored: false的空成功响应。前端没法区分真的没有 bounded state和服务端暂不可用——这是两种完全不同的降级策略。现在改为返回 HTTP 503服务暂不可用加上CHAT_THREAD_HYDRATION_UNAVAILABLE这个明确的错误码。前端use-chat-stream.ts聊天流式交互核心 Hook收到这个错误时有本地快照就进入只读缓存态展示本地消息但禁止发送没有本地快照就直接恢复失败。5.2 新增 DELETE /api/chat/conversations会话删除不是只隐藏前端列表。DELETE /api/chat/conversations的完整流程[用户点击删除 → 确认弹窗 → 确认] │ ├─ 1. 服务端验证当前 browser session 对该 conversationId 的 ownership ├─ 2. 通过会话记忆存储chat-memory checkpointer删除 ThreadState 数据 ├─ 3. 从 Conversation Registry 中移除该会话 ├─ 4. 返回更新后的 registry payload含新的会话列表 fallback selected │ └─ 5. 客户端收到成功响应后 ├─ 从本地 IndexedDB 索引中删除该条目 └─ 硬删除该会话的本地消息快照这里有个重要的设计决策删除失败时不清理本地数据。如果服务端 Registry 删除成功但 ThreadState 删除失败客户端保留本地快照用户至少还能看到历史记录。反过来如果先清本地再删服务端服务端失败时用户就两头空了。deleteConversation()在conversation-registry.ts会话注册表服务中的实现顺序是先删 ThreadState 再删 Registry entry——因为这两步操作不在同一个数据库事务里无法保证要么都成功、要么都回滚所以优先保证 ThreadState 清理干净避免出现Registry 里没了但历史数据还在的残留状态。6. 服务端挂了怎么办只读缓存降级服务端不可用时本地快照不能变成可以继续聊天的假象。降级策略的核心是能看不能动。触发只读缓存的条件很简单——服务端 registry 请求失败但本地索引还在或者 thread hydration 返回ThreadState 暂不可用错误但本地快照存在。满足任一条件页面就进入只读态。只读态下发送消息、新建会话、切换会话、删除会话全部禁用。页面顶部会显示一条琥珀色提示明确告诉用户当前展示的是本地缓存尚未获得服务端确认旁边放一个重试连接服务端按钮。用户点重试或者直接刷新页面服务端恢复可用后就能回到正常状态。这个只读缓存的控制逻辑统一在instantmind-page.tsx聊天主页面组件里合并了useConversationSessions和useChatStream两个层级的降级信号保证不会出现列表可以切换但聊天区不能发送这种半吊子状态。7. 会话删除的交互细节conversation-row-actions.tsx会话行操作按钮用项目已有的 shadcn/ui 组件库React 生态里一套无头可访问组件的DropdownMenuAlertDialog组合实现了三点菜单 删除确认弹窗。桌面端 hover 或 focus 时显示三点按钮移动端因为没有 hover三点按钮始终可见。菜单里只有删除一项确认弹窗显示会话标题和警告文案取消和确认两个按钮。删除进行中按钮 disabled防止重复提交。删除失败时弹窗保留显示错误提示。删除成功后关闭弹窗客户端用服务端返回的 registry payload 做权威替换清理本地快照。删除当前会话时自动切换到服务端返回的 fallback 会话或空白 draft删除非当前会话时当前展示不变。8. 多标签页和容量边界v0.4.7 不承诺跨标签页实时同步但并发写入不能破坏数据一致性。不同会话的并发写入很简单不同conversationId的快照独立存储互不覆盖。共享索引的更新按conversationId合并元数据——标签页 A 更新会话 A 的元数据不会把标签页 B 刚写入的会话 B 元数据弄丢。同一会话的并发写入用 revision 乐观锁。每条快照写入时携带单调递增的 revision写入前比较当前已存储的 revision——旧版本不能覆盖新版本。不做消息级合并两个标签页各自对同一会话做了删除或重新生成不会尝试合并而是保留较新的稳定写入。容量方面单个快照最多 120 条消息超出时从最旧完整消息开始裁剪。本地存储配额耗尽时先裁剪旧消息重试仍失败就静默降级不影响聊天主链。9. 总结回头看 v0.4.7 做的最重要的三件事第一把数据边界分清楚了。本地快照管展示服务端 Registry 管身份服务端 ThreadState 管 AI 上下文。三层不交叉不合并不互相替代。后续加账号体系、加 PostgreSQL 完整历史、加跨设备同步时不需要回头拆这层的耦合。第二稳定才存不完整不存。流式中的半成品、失败的请求、pending 的 Agent 审核全都不进快照。刷新后恢复出来的一定是之前已经完整看到过的内容。第三服务端不可用时只读不假装可以继续聊。本地快照是增强体验不是替代服务端。只读缓存态下能回看历史但不能发送、不能切换、不能新建。代码改动集中在apps/webapp下的 11 个文件新增了local-chat-persistence/模块schema store stable-snapshot调整了useConversationSessions、useChatStream、instantmind-page和两个 API route。7 个 focused test suites 共 63 个测试通过TypeScript 类型检查、代码规范检查、Git 差异检查全部通过真实浏览器 smoke 覆盖了普通文本恢复、富 UI 恢复、多标签页隔离和删除链路。项目地址 GitHubhttps://github.com/HWYD/ai-mind 线上体验https://ai.hwyblog.cloud/instant-mind如果这篇文章或者 AI Mind 项目对你有所帮助也欢迎给项目点个 Star⭐。你的支持会是我持续更新这个系列、继续整理项目实现过程和设计复盘的很大动力。