当前位置: 首页 > news >正文

使用 IndexedDB 在客户端存储对话记录

让用户的对话历史永不丢失,即使断网、刷新页面、关掉浏览器,回来还能接着聊

你有没有遇到过这种情况:正在跟 AI 助手讨论一个复杂问题,聊了三十多轮,突然网络断了,页面一刷新,所有对话都没了?用户骂娘,你背锅。

传统聊天应用依赖后端存储,但智能体的对话往往很长、很频繁,每次切换会话都从后端拉取几十条消息,延迟高、流量大、体验差。如果能把这些对话存在用户的浏览器里,离线可用、秒开切换、还能减少后端压力,岂不美哉?

这就是 IndexedDB 的价值。

2026 年的今天,IndexedDB 已经非常成熟。几乎所有现代浏览器都支持,而且封装库(如 Dexie.js)让操作变得跟 MongoDB 一样简单。这篇文章,我会带你一步步在智能体前端中集成 IndexedDB,实现对话记录的本地存储、同步、离线可用。包含完整的代码、架构图和最佳实践。

一、为什么需要 IndexedDB,而不是 localStorage?

你可能用过localStorage存一些简单数据,但它有几个致命的限制:

  • 容量小:通常只有 5-10MB,存几十条对话就爆了。
  • 同步 API:会阻塞 UI 线程,大数据量时页面卡死。
  • 只能存字符串:结构化数据需要JSON.stringify,读取再parse,效率低。
  • 无法索引和查询:想“查询昨天所有的对话”做不到。

IndexedDB 是浏览器内置的 NoSQL 数据库:

  • 容量大:通常 50MB 起步,甚至可以申请几百 MB。
  • 异步 API:不阻塞 UI,适合存储大量数据。
  • 支持索引:可以按时间、会话 ID 高效查询。
  • 事务支持:保证数据一致性。

对于智能体前端,用户的对话历史可能积累到几百甚至上千条,每条消息又可能很长(AI 生成的报告动辄几千字)。IndexedDB 是唯一正确的选择。

下面是 IndexedDB 在前端存储架构中的位置:

二、Dexie.js:让 IndexedDB 变得简单

原生 IndexedDB API 非常繁琐(需要打开数据库、创建事务、处理游标…)。Dexie.js 是一个封装库,提供了类似 MongoDB 的语法,TypeScript 支持完美。

npminstalldexie

定义数据库 schema:

// lib/db.tsimportDexie,{Table}from'dexie';exportinterfaceDBConversation{id:string;title:string;createdAt:number;updatedAt:number;messageCount:number;lastMessage?:string;}exportinterfaceDBMessage{id:string;conversationId:string;role:'user'|'assistant';content:string;timestamp:number;attachments?:any[];// 可扩展}classAgentDatabaseextendsDexie{conversations!:Table<DBConversation,string>;messages!:Table<DBMessage,string>;constructor(){super('AgentDB');this.version(1).stores({conversations:'id, updatedAt, createdAt',messages:'id, conversationId, timestamp',});}}exportconstdb=newAgentDatabase();

三、封装 IndexedDB 操作服务

为了方便在 Zustand store 中调用,我们封装一个服务层:

// services/conversationService.tsimport{db}from'@/lib/db';import{DBConversation,DBMessage}from'@/lib/db';exportconstconversationService={// 获取所有会话(按更新时间倒序)asyncgetAllConversations():Promise<DBConversation[]>{returnawaitdb.conversations.orderBy('updatedAt').reverse().toArray();},// 创建新会话asynccreateConversation(conversation:DBConversation):Promise<void>{awaitdb.conversations.add(conversation);},// 获取会话的所有消息asyncgetMessages(conversationId:string):Promise<DBMessage[]>{returnawaitdb.messages.where('conversationId').equals(conversationId).sortBy('timestamp');},// 添加单条消息asyncaddMessage(message:DBMessage):Promise<void>{awaitdb.messages.add(message);// 同时更新会话的 messageCount 和 updatedAtawaitdb.conversations.update(message.conversationId,{messageCount:(awaitdb.messages.where('conversationId').equals(message.conversationId).count()),updatedAt:Date.now(),lastMessage:message.content.slice(0,50),});},// 更新会话标题asyncupdateConversationTitle(id:string,title:string):Promise<void>{awaitdb.conversations.update(id,{title});},// 删除会话及其所有消息(事务)asyncdeleteConversation(id:string):Promise<void>{awaitdb.transaction('rw',db.conversations,db.messages,async()=>{awaitdb.messages.where('conversationId').equals(id).delete();awaitdb.conversations.delete(id);});},// 同步后端数据到本地(合并策略)asyncsyncFromBackend(userId:string):Promise<void>{constresponse=awaitfetch(`/api/conversations?userId=${userId}`);constremoteConvs=awaitresponse.json();for(constconvofremoteConvs){// 如果本地没有或远端更新,则覆盖constlocal=awaitdb.conversations.get(conv.id);if(!local||conv.updatedAt>local.updatedAt){awaitdb.conversations.put(conv);// 同步消息constmsgsResp=awaitfetch(`/api/conversations/${conv.id}/messages`);constremoteMsgs=awaitmsgsResp.json();awaitdb.messages.bulkPut(remoteMsgs);}}},};

四、集成到 Zustand Store(优先本地,异步同步)

修改之前的conversationStore,让它先从 IndexedDB 读取,再后台与后端同步。

// stores/conversationStore.tsimport{create}from'zustand';import{conversationService}from'@/services/conversationService';import{db}from'@/lib/db';interfaceConversationState{conversations:DBConversation[];currentConversationId:string|null;messages:DBMessage[];isLoading:boolean;isHydrated:boolean;// 初始化:从 IndexedDB 加载hydrate:()=>Promise<void>;// 会话操作(先操作本地,再异步同步到后端)createConversation:()=>Promise<string>;switchConversation:(id:string)=>Promise<void>;deleteConversation:(id:string)=>Promise<void>;addMessage:(message:DBMessage)=>void;updateLastMessage:(content:string)=>void;// 后台同步syncToBackend:()=>Promise<void>;}exportconstuseConversationStore=create<ConversationState>((set,get)=>({conversations:[],currentConversationId:null,messages:[],isLoading:false,isHydrated:false,hydrate:async()=>{set({isLoading:true});constconvs=awaitconversationService.getAllConversations();// 如果有会话,默认选中最近的一个constcurrentId=convs[0]?.id||null;letmessages:DBMessage[]=[];if(currentId){messages=awaitconversationService.getMessages(currentId);}set({conversations:convs,currentConversationId:currentId,messages,isLoading:false,isHydrated:true,});// 后台同步远端(不阻塞UI)get().syncToBackend();},createConversation:async()=>{constnewId=crypto.randomUUID();constnewConv:DBConversation={id:newId,title:'新对话',createdAt:Date.now(),updatedAt:Date.now(),messageCount:0,};awaitconversationService.createConversation(newConv);set((state)=>({conversations:[newConv,...state.conversations],currentConversationId:newId,messages:[],}));// 可选:异步在后端创建fetch('/api/conversations',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(newConv),}).catch(console.error);returnnewId;},switchConversation:async(id:string)=>{if(id===get().currentConversationId)return;set({isLoading:true});constmessages=awaitconversationService.getMessages(id);set({currentConversationId:id,messages,isLoading:false,});},deleteConversation:async(id:string)=>{awaitconversationService.deleteConversation(id);set((state)=>{constnewConversations=state.conversations.filter(c=>c.id!==id);letnewCurrentId=state.currentConversationId;if(state.currentConversationId===id){newCurrentId=newConversations[0]?.id||null;}return{conversations:newConversations,currentConversationId:newCurrentId,messages:newCurrentId?state.messages:[],};});if(get().currentConversationId){awaitget().switchConversation(get().currentConversationId!);}// 异步通知后端删除fetch(`/api/conversations/${id}`,{method:'DELETE'}).catch(console.error);},addMessage:(message)=>{// 添加到本地 IndexedDBconversationService.addMessage(message).catch(console.error);set((state)=>({messages:[...state.messages,message],}));// 更新会话列表中的最后消息和计数set((state)=>({conversations:state.conversations.map(conv=>conv.id===message.conversationId?{...conv,lastMessage:message.content.slice(0,50),updatedAt:Date.now(),messageCount:conv.messageCount+1,}:conv),}));// 异步发送到后端(可选)fetch(`/api/conversations/${message.conversationId}/messages`,{method:'POST',body:JSON.stringify(message),headers:{'Content-Type':'application/json'},}).catch(console.error);},updateLastMessage:(content)=>{// 流式输出时更新最后一条 AI 消息set((state)=>{constlastIndex=state.messages.length-1;if(lastIndex<0)returnstate;constnewMessages=[...state.messages];newMessages[lastIndex]={...newMessages[lastIndex],content};return{messages:newMessages};});// 同时更新 IndexedDB 中对应的消息constlastMsg=get().messages[get().messages.length-1];if(lastMsg){db.messages.update(lastMsg.id,{content}).catch(console.error);}},syncToBackend:async()=>{// 将本地所有未同步的变更推送到后端// 这里简化:全量覆盖(实际可用 lastSyncTime 增量)constconvs=get().conversations;for(constconvofconvs){awaitfetch(`/api/conversations/${conv.id}`,{method:'PUT',body:JSON.stringify(conv),headers:{'Content-Type':'application/json'},}).catch(console.error);}},}));

五、在 App 启动时加载 IndexedDB 数据

修改主App.tsx,在应用挂载时调用hydrate

// App.tsx import { useEffect } from 'react'; import { useConversationStore } from './stores/conversationStore'; function App() { const hydrate = useConversationStore(state => state.hydrate); const isHydrated = useConversationStore(state => state.isHydrated); useEffect(() => { hydrate(); }, []); if (!isHydrated) { return <div className="flex items-center justify-center h-screen">加载会话中...</div>; } return <ChatInterface />; }

六、IndexedDB 数据迁移与版本升级

当你的数据结构发生变化(比如给消息增加attachments字段),需要升级数据库版本。Dexie 提供了版本升级钩子:

classAgentDatabaseextendsDexie{constructor(){super('AgentDB');this.version(1).stores({conversations:'id, updatedAt, createdAt',messages:'id, conversationId, timestamp',});this.version(2).stores({conversations:'id, updatedAt, createdAt',messages:'id, conversationId, timestamp, role',// 增加 role 索引}).upgrade(async(tx)=>{// 迁移旧数据:为每条消息补充 role 字段(如果缺失)constmessages=awaittx.table('messages').toArray();for(constmsgofmessages){if(!msg.role){awaittx.table('messages').update(msg.id,{role:'assistant'});}}});}}

七、限制本地存储容量与清理策略

用户可能积累几千条对话,IndexedDB 再大也有限。需要设置自动清理策略:只保留最近 100 条会话,或清理 30 天前的会话。

// services/cleanupService.tsimport{db}from'@/lib/db';exportasyncfunctioncleanOldConversations(daysToKeep=30,maxConversations=200){constcutoff=Date.now()-daysToKeep*24*3600*1000;constoldConvs=awaitdb.conversations.where('updatedAt').below(cutoff).toArray();for(constconvofoldConvs){awaitdb.transaction('rw',db.conversations,db.messages,async()=>{awaitdb.messages.where('conversationId').equals(conv.id).delete();awaitdb.conversations.delete(conv.id);});}constallConvs=awaitdb.conversations.orderBy('updatedAt').reverse().toArray();if(allConvs.length>maxConversations){consttoDelete=allConvs.slice(maxConversations);for(constconvoftoDelete){// 同上删除}}}

八、离线模式支持(可选)

结合 Service Worker,你甚至可以做到完全离线使用。用户断网时,所有对话仍可正常聊天(消息存在 IndexedDB),恢复网络后再自动同步到后端。

// 在 addMessage 中检测 navigator.onLineif(!navigator.onLine){// 标记消息为“待同步”message.syncStatus='pending';}

九、性能优化与注意事项

  • 批量操作:导入大量历史数据时使用bulkPut,比逐条add快一个数量级。
  • 索引选择:在messages表中对conversationIdtimestamp建立复合索引,加快查询。
  • 限制消息内容长度:如果 AI 回答特别长(几十万 token),考虑分块存储或提醒用户。
  • 清除 IndexedDB:提供“清除所有数据”按钮,方便用户重置。
  • 隐私注意:IndexedDB 数据只存在于当前浏览器,用户换设备无法访问。提示用户登录后云端备份。

十、完整架构图

下面是结合 IndexedDB 的会话管理最终架构:

用户每次操作(新建会话、发送消息、删除会话)都先更新 IndexedDB,立即响应用户(乐观更新)。同时,后台异步将变更推送到后端,实现多端同步和备份。App 启动时从 IndexedDB 加载,速度快到用户感觉不到网络延迟。

写在最后

使用 IndexedDB 存储对话记录,最大的价值不是“技术有多酷”,而是让用户在任何时候打开页面,对话都安然无恙地躺在那里。即便断网,也能继续跟 AI 交流。这种离线优先的体验,在移动端和弱网环境下尤其宝贵。

http://www.jsqmd.com/news/863083/

相关文章:

  • EC2 M3 Ultra Mac 实例实战:28 核 256GB 跑 12 路并行 Simulator 测试
  • GitHub中文界面插件架构解析与实战指南
  • 哥德巴赫猜想1+1基于平行素数对等腰梯形网格拓扑与素数渐近密度的大偶数满填充完备性证明
  • Appium环境搭建与元素定位实战:四层依赖与三层定位解析
  • AzurLaneAutoScript:基于图像识别与状态机的游戏自动化架构解析
  • iOS 27 语音控制获 AI 升级:自然语言操控 iPhone,Siri 革新终于有眉目
  • 2026年|面对AI检测,如何快速降低论文AIGC痕迹? - 降AI实验室
  • MCP 协议实战:用 50 行代码给本地大模型接上“工具手“,让 Ollama 也能干 Agent 的活
  • “爱能克服远距离......”
  • 桐乡汽车贴膜哪家好?口碑专业靠谱贴膜门店推荐(2026 本地实用指南) - GrowthUME
  • 3步解锁百度网盘全速下载:告别限速困扰的实用指南
  • GitHub中文界面本地化解决方案:技术架构与部署指南
  • 2026年赤峰市育婴师企业推荐排行-育婴师企业口碑排行-育婴师机构口碑排行 - 品牌推广大师
  • Wireshark深度追踪HTTP敏感数据实战方法论
  • 思科:速修复满分 Secure Workload 未授权 API 访问漏洞
  • 告别臃肿!G-Helper:华硕笔记本用户的终极轻量级控制神器
  • 2026行业内靠谱的屏幕贴合机设备厂家口碑排行 - 品牌排行榜
  • Unity UGUI Text性能优化:打字、阴影、渐变的底层原理与实战方案
  • Unity背包系统从零手戳:数据层逻辑层表现层分离实践
  • UE5 BaseInstallBundle.ini深度解析:安装包构建的元数据契约
  • Appium环境搭建实战手册:解决JDK、Android SDK与Node.js兼容性问题
  • 2026年诸暨市汽车贴膜门店合规资质深度测评:4家正规授权店实测对比,新国标下资质核验避坑指南与选型推荐 - GrowthUME
  • Markdown图文教程转PPT实战指南
  • Unity URP下高性能尾气与扬尘粒子系统实现
  • Wireshark实战:HTTP明文敏感数据追踪与识别
  • Selenium动作链原理与Go实战:模拟人类交互的底层机制
  • Unity粒子特效优化:GPU/CPU/内存三重性能攻坚指南
  • G-Helper终极指南:免费轻量级华硕笔记本控制中心完全解决方案
  • Unity翻书效果深度解析:从物理建模到工程落地
  • Unity载具特效实战:尾气与扬尘的物理建模与性能优化