欢迎光临
我们一直在努力

AI 聊天刷新后记录全丢?用浏览器 IndexedDB 给 AI Chat 加一层“离线记忆“

本文为作者原创,首发于掘金,现同步发布到 CSDN。 内容整理自 AI Mind 项目的真实开发过程。 GitHub:https://github.com/HWYD/ai-mind 对应代码版本:v0.4.7 线上体验:https://ai.hwyblog.cloud/instant-mind

AI 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 服务端 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、标题、时间等元数据,以及 selectedConversationId 和 isDraft 两个 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(稳定快照投影)负责从消息列表中提取"可安全恢复"的子集。它的核心逻辑很简单——白名单 + 状态过滤:

// 只保留这 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() 的完整过滤规则:

  • 只保留 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?conversationId=xxx
    ├─ 成功 + 本地有快照 → 保留本地展示,服务端只做确认
    ├─ 成功 + 本地无快照 → 用 bounded hydration 降级展示
    ├─ 服务端返回"ThreadState 暂不可用"错误 + 本地有快照 → 只读缓存
    └─ 失败 + 本地无快照 → 恢复失败,保持空状态

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

    // 请求前记录基线
    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 的完整流程:

    [用户点击删除 → 确认弹窗 → 确认]

    ├─ 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 生态里一套无头可访问组件)的 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),调整了 useConversationSessions、useChatStream、instantmind-page 和两个 API route。7 个 focused test suites 共 63 个测试通过,TypeScript 类型检查、代码规范检查、Git 差异检查全部通过,真实浏览器 smoke 覆盖了普通文本恢复、富 UI 恢复、多标签页隔离和删除链路。


    项目地址

    👉 GitHub:https://github.com/HWYD/ai-mind 👉 线上体验:https://ai.hwyblog.cloud/instant-mind

    如果这篇文章或者 AI Mind 项目对你有所帮助,也欢迎给项目点个 Star⭐。你的支持会是我持续更新这个系列、继续整理项目实现过程和设计复盘的很大动力。

    赞(0)
    未经允许不得转载:171主机测评 » AI 聊天刷新后记录全丢?用浏览器 IndexedDB 给 AI Chat 加一层“离线记忆“
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址