AI 聊天刷新后记录全丢?用浏览器本地存储给 AI Chat 加一层"离线记忆"

本文基于 AI Mind 项目的真实实现整理。

GitHub:github.com/HWYD/ai-min...

对应代码版本:v0.4.7

线上链接:ai.hwyblog.cloud/instant-min...

AI Mind 是一个持续迭代中的 Next.js AI Chat 项目。从最基础的本地聊天开始,逐步加入流式协议、工具调用、MCP、Skill 和 Agent 能力。

如果对这个项目感兴趣,或者这篇文章对你有一点帮助,也欢迎顺手到 GitHub 帮 AI Mind 点个 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 服务端 PostgreSQL AI 运行时短期上下文 不恢复完整富 UI 展示

为什么不能合并成一套?因为服务端不存完整聊天历史------ThreadState 只保留最近几轮对话的文本摘要(bounded recent text),不包含工具调用结果、Agent 执行轨迹、文档产物等富 UI 内容。如果只用服务端数据恢复,刷新后只能看到最近几轮纯文本,之前跑的 Agent 执行过程全丢了。反过来,如果我把本地完整快照当模型上下文发给 AI,等于把展示用的历史偷偷变成了 AI 的决策依据------这越界了。

那本地快照和服务端 ThreadState 内容不一致怎么办?答案很简单:允许不一致,允许继续发送。 本地快照只决定 UI 展示,服务端 ThreadState 决定 AI 上下文。两者不做静默合并、覆盖或补写。如果要求完全一致才允许发送,等于把本地缓存变成了一条阻塞聊天主链的同步依赖------这比不一致本身还糟糕。


2. 为什么选 IndexedDB,不选 localStorage

v0.4.7 之前,AI Mind 已经在用 localStorage 保存 selected/draft hint------但那是简单的字符串标记,跟富 UI 快照完全是两个量级的东西。

一条工具调用结果可能包含几百行 JSON 输出,一次 Agent 执行可能产生多个 Agent Step 展示块,一份 artifact 可能是完整的 Markdown 文档。这些内容用 localStorage 存有三个硬伤:容量通常只有 5-10MB,setItem() 是同步 API 会卡 UI,而且只支持字符串,结构化数据全靠手写 JSON.parse/stringify

IndexedDB 刚好把这三个问题都解决了:异步 API、原生支持结构化对象、容量远大于 localStorage。浏览器重启后数据仍在,符合跨重启保留的承诺。

没引入任何 IndexedDB wrapper 依赖------直接用原生 API,通过 runStoreOperation() 封装事务生命周期。数据库名固定为 ai-mind-local-chat,两个 Object Store:

  • conversation-index :会话索引,存最近 10 个会话的 ID、标题、时间等元数据,以及 selectedConversationIdisDraft 两个 UI hint
  • conversation-snapshots :会话快照,按 conversationId 独立存储,每条记录包含该会话的完整可恢复消息列表

3. 不是所有消息都能存:稳定快照的过滤规则

这是 v0.4.7 最关键的一层过滤。AI Mind 前端有一个叫 useChatStream 的核心 Hook,它负责管理当前会话的所有消息------每条消息是一个 MindMessage 对象,里面包含多个 part(消息部件),比如一段文本、一次工具调用结果、一个 Agent 执行步骤。但这个消息列表里混着很多不能持久化的东西------流式输出中的半成品、Agent 暂停等待人工确认的控制信号(AgentInterrupt)、线程内存状态提示(thread-memory-status)等。如果把这些也写进 IndexedDB,刷新后恢复出来的是不可用的半截数据。

stable-snapshot.ts(稳定快照投影)负责从消息列表中提取"可安全恢复"的子集。它的核心逻辑很简单------白名单 + 状态过滤:

typescript 复制代码
// 只保留这 8 种 part 类型------对应聊天界面中用户能看到的各种展示块
// agent-step: Agent 决策步骤    reasoning: AI 推理过程
// tool: 工具调用结果            resource: 文件/资源预览
// skill: Skill 执行展示        workflow-progress: 任务进度
// text: 纯文本                 prompt: 提示词模板
const RECOVERABLE_PART_TYPES = [
  'agent-step', 'prompt', 'reasoning', 'resource',
  'skill', 'text', 'tool', 'workflow-progress',
]

function isRecoverablePart(part: MindMessagePart): boolean {
  // 状态必须是未设置或 completed------streaming/pending 的不要
  if (part.status && part.status !== 'completed') return false
  return RECOVERABLE_PART_TYPES.includes(part.type)
}

projectRecoverableMessages() 的完整过滤规则:

  1. 只保留 user 和 assistant 消息------system 消息、控制消息不进入快照
  2. 只保留 status 为未设置或 completed 的消息------streaming、failed、aborted、pending 全过滤
  3. 只保留白名单内的 part 类型------thread-memory-statusAgentInterrupt 等控制部件不进入
  4. 过滤掉不完整的 artifact------只保留已完成状态的 text artifact
  5. 移除空消息------过滤后没有任何可恢复 part 的消息直接丢弃
  6. 容量裁剪:最多 120 条消息,超出时从最旧的完整消息开始删除

快照只在流式完成、删除问答完成、重新生成完成后提交。流式中、请求失败、用户中止时,不提交当前回合,保留上一份成功稳定快照。这个策略确保了一件事:刷新后恢复出来的,一定是"之前已经完整看到过的内容"。


4. local-first 恢复:先本地,后服务端,失败降级

刷新页面后的完整恢复链路是三步走。这里先解释一个关键概念------bounded hydration :服务端 /api/chat/thread 接口返回的"会话恢复数据",但它只包含最近几轮对话的纯文本,不包含富 UI 部件。换句话说,它不是一个完整的聊天记录备份,而是一个"AI 需要知道的最近上下文"。

ini 复制代码
[页面刷新]
    │
    ├─ 1. 读取 IndexedDB
    │   ├─ 读 conversation-index → 立即渲染最近会话列表
    │   └─ 读 conversation-snapshots[selectedId] → 立即渲染当前会话消息
    │
    ├─ 2. 请求 GET /api/chat/conversations
    │   ├─ 成功 → 用服务端列表替换本地索引,清理不在列表中的旧会话
    │   └─ 失败 → 保留本地数据,进入只读缓存态
    │
    └─ 3. 请求 GET /api/chat/thread?conversationId=xxx
        ├─ 成功 + 本地有快照 → 保留本地展示,服务端只做确认
        ├─ 成功 + 本地无快照 → 用 bounded hydration 降级展示
        ├─ 服务端返回"ThreadState 暂不可用"错误 + 本地有快照 → 只读缓存
        └─ 失败 + 本地无快照 → 恢复失败,保持空状态

第 2 步的"服务端权威校准"值得单独说一下。use-conversation-sessions.ts(会话列表状态管理)在请求 registry 之前会记录一份本地索引基线:

typescript 复制代码
// 请求前记录基线
const baseline = {
  revision: localIndex.revision,
  conversationIds: localIndex.conversations.map(c => c.id),
}

// 请求成功后,用基线做权威替换
await reconcileLocalConversationIndex(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 的完整流程:

css 复制代码
[用户点击删除 → 确认弹窗 → 确认]
    │
    ├─ 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(聊天主页面组件)里,合并了 useConversationSessionsuseChatStream 两个层级的降级信号,保证不会出现"列表可以切换但聊天区不能发送"这种半吊子状态。


7. 会话删除的交互细节

conversation-row-actions.tsx(会话行操作按钮)用项目已有的 shadcn/ui 组件库(React 生态里一套无头可访问组件)的 DropdownMenu + AlertDialog 组合,实现了三点菜单 + 删除确认弹窗。

桌面端 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),调整了 useConversationSessionsuseChatStreaminstantmind-page 和两个 API route。7 个 focused test suites 共 63 个测试通过,TypeScript 类型检查、代码规范检查、Git 差异检查全部通过,真实浏览器 smoke 覆盖了普通文本恢复、富 UI 恢复、多标签页隔离和删除链路。


项目地址

👉 GitHub:github.com/HWYD/ai-min...

👉 线上体验:ai.hwyblog.cloud/instant-min...

如果这篇文章或者 AI Mind 项目对你有所帮助,也欢迎顺手帮项目点个 Star⭐。这个支持对我来说很重要,也会让我更有动力继续整理后续版本的实现过程、设计取舍和踩坑复盘。

相关推荐
梦想的旅途21 小时前
企业微信API 实现企业微信消息的自动发送?
java·服务器·前端
何时梦醒1 小时前
🤖 RAG 文档分割器(Splitter)深度学习笔记 —— 从零搭建知识库文档处理流水线
前端·javascript·人工智能
ss2731 小时前
Vue 完整版本发展史时间线(2013–2026)|版本迭代里程碑+核心特性对照表
前端·javascript·vue.js
刘棕霆1 小时前
企业测试造数为什么这么难?我把熟手经验做成了一个可交付的造数 Skill
aigc·agent·测试
武子康1 小时前
HunyuanVideo 全家族选型:原版 13B / I2V / Avatar / Foley / 1.5 8.3B 怎么分工
人工智能·llm·aigc
亿元程序员1 小时前
为什么大家都那么喜欢用Shader?
前端
Codebee1 小时前
OODER Studio设计工具导入体系深度解析
前端
weedsfly1 小时前
前端开发中的命令模式——从点餐退单到异步任务调度
前端·javascript·面试