跳到主要内容

聊天与存储 — 数据模型、对话编排、本地优先持久化

本章讲什么: 前两章的「粒子重组」和「Beam 输出」最终都落在同一个数据模型上:DMessage。本章讲清 big-AGI 怎么把一条消息建模成片段数组、怎么用 ConversationHandler 编排一个对话(含 Beam 与叠加层)、以及怎么用 Zustand + IndexedDB 做本地优先持久化。

1. 它要解决的小问题(零基础)

现代 AI 消息不只是文本:一条 assistant 回复可能同时含「推理过程 + 正文 + 一张图 + 一次工具调用 + 几条引用」。用一个 content: string 字段根本装不下。

而且 big-AGI 是本地优先的:对话不在云端,而在你浏览器里,离线也能看历史。这要求一套能离线存、能版本迁移的本地存储。

本章回答两件事:

  1. 一条消息怎么建模,才能装下这么多异构内容?(答:片段数组)
  2. 状态怎么存,才能本地优先、刷新不丢?(答:Zustand persist + IndexedDB)

2. 顶层全景:从片段到落盘

DMessage(一条消息)
├─ id / role('user'|'assistant'|'system') / created / updated
├─ pendingIncomplete? (流式进行中标志)
├─ generator (谁生成的:aix / named / …,含 metrics、upstreamHandle)
└─ fragments: DMessageFragment[] ◄── 核心:异构内容的数组
├─ content 片段: 文本 / 推理 / 图片引用 / 工具调用 / 工具响应 / 错误
├─ attachment 片段: 用户附件
└─ void 片段: 占位符 / 模型注解(不进 prompt 的旁注)

│ 多条 DMessage 组成一个 Conversation

┌──────────────────────────────────────┐
│ ConversationHandler(每对话一个编排器) │
│ · 持有 BeamStore(见第 2 章) │
│ · 持有 PerChatOverlayStore(叠加层) │
│ · messageAppend / beamInvoke │
└──────────────────┬─────────────────────┘
▼ 写入全局存储
┌──────────────────────────────────────┐
│ Zustand stores │
│ store-chats ──persist──► IndexedDB │ (对话/消息,大)
│ store-llms ──persist──► localStorage │ (模型配置)
│ store-ux-labs ─persist──► localStorage │ (偏好/labs)
└──────────────────────────────────────┘

3. 核心原理(逐个机制)

3.1 DMessage:片段数组,而非单一文本

它要解决的小问题: 一条消息要装下异构、可增长(流式)、可编辑的多种内容。

思路: DMessage.fragments 是一个 DMessageFragment[],每个片段是带判别字段的联合。顶层先分三类(chat.fragments.ts:21-24):

片段类(ft)装什么
DMessageContentFragment进入 prompt 的实质内容:文本、推理、图片引用、工具调用/响应、错误
DMessageAttachmentFragment用户附件(文档/图片等)
DMessageVoidFragment不进 prompt 的旁注:占位符、模型注解

每个片段内部再用 part 区分具体类型。这就是为什么第 1 章的 ContentReassembler 能把粒子干净落位——粒子类型和片段 part 类型是对应的(文本粒子 t → 文本 part,图片粒子 ii → 图片 part,工具粒子 fci → 工具调用 part)。

关键细节:void 片段的妙用。 流式时先放一个占位符 void 片段(createPlaceholderVoidFragment),内容到了再替换;干净结束时 finalizeReassembly 把占位符清掉(第 1 章 3.6)。「占位符是一种片段」让「加载中」状态和真内容用同一套渲染管线,UI 不用特判。

关键细节:结构共享。 第 1 章提过 LL 累加器的契约:fragments 每次更新替换引用、绝不原地改(aix.client.ts:797-805)。这正是 Zustand + React 的最佳实践——靠引用比较检测变化,避免深比较。

3.2 ConversationHandler:每对话一个编排器

它要解决的小问题: 一个对话不只是「消息列表」,还挂着 Beam、临时态(ephemerals)、Composer 草稿、叠加层。这些得有个东西统一管,且每个对话/窗格各一份

思路: ConversationHandler(chat-overlay/ConversationHandler.ts:35)是一个 class,每个对话一个实例。它构造时就造好自己的 Beam store 和叠加层 store:

// 真实源码节选(简化)ConversationHandler.ts:41-45
this.beamStore = createBeamVanillaStore(); // 这个对话专属的 Beam
this.beamStore.subscribe((state, prevState) => { ... }); // 监听 Beam 变化

它提供消息编排方法:messageAppend(:158)、messageAppendAssistantPlaceholder(:150,流式前先放占位),以及触发多模型融合的 beamInvoke(:252)。注意:普通聊天的 AIX 触发不在这个类里——它由 chat 编辑器 src/apps/chat/editors/chat-persona.tsaixChatGenerateContent_DMessage_FromConversation 发起(见第 1 章主线),ConversationHandler 只负责把消息编排进对话。Beam 的融合结果通过 beamInvoke 里的 onBeamSuccess 回调回到这里,写回对话(:255-285,内部走 messageEdit/messageAppend)。

精华:叠加(overlay)架构。 「对话本身的数据(消息)」存全局 store(要持久化),而「这个对话的临时 UI 态(Beam、草稿、临时消息)」存每对话的叠加 store(PerChatOverlayStore,chat-overlay/store-perchat_vanilla.ts)。持久数据和瞬态 UI 分离,前者落盘、后者随用随弃。

3.3 本地优先:Zustand persist + IndexedDB

它要解决的小问题: 对话可能很大(含图片 base64),要离线、刷新不丢,还要能随数据结构升级而迁移。

思路: 全局状态用 Zustand,通过 persist 中间件落盘,但按数据特性分两种介质(见 CLAUDE.md 的 Storage 节):

存储介质为什么
store-chats(对话/消息)IndexedDB(单 key-val cell)数据大,localStorage 装不下
store-llms(模型配置)localStorage小、读取频繁
store-ux-labs(偏好)localStorage小、同步读

关键细节:createIDBPersistStorage() 做 IndexedDB 持久化;版本化迁移在 rehydration 时修复/升级旧数据;partialize/merge 控制哪些字段落盘(比如 genAbortController 这种运行态绝不能存)。这套机制让数据结构演进时,老用户的本地对话能平滑升级。

关键细节:多窗格状态隔离。 因为 Beam store 和叠加 store 都是每对话一实例(vanilla store),big-AGI 能同时开多个窗格、各自独立对话而互不串扰。这是「per-instance store」与「全局 store」分层的直接收益。

4. 巧妙之处(可借鉴)

  • 消息 = 片段数组的判别联合:异构内容(文本/推理/图/工具/错误/占位)统一建模,和粒子类型一一对应。(chat.fragments.ts:21)
  • 占位符是一种 void 片段:加载态与真内容共用渲染管线。(createPlaceholderVoidFragment)
  • 结构共享(替换不改):契合 Zustand/React 的引用比较。(aix.client.ts:797)
  • 叠加架构:持久对话数据 vs 瞬态 UI 态分离,各用各的 store。(chat-overlay/)
  • 按数据特性分介质:大对话进 IndexedDB,小配置进 localStorage。(CLAUDE.md Storage 节)
  • 每对话一实例的 vanilla store:多窗格天然隔离。(ConversationHandler.ts:41)

5. 边界与局限

  • 本地优先 = 数据在浏览器:换设备不自动同步(云同步是 Pro 增量层,在 dev 分支)。
  • IndexedDB 单 cell 持久化:整块读写,超大历史可能有序列化成本(故有 partialize 控制)。
  • API key 仍在 localStorage:CLAUDE.md 安全节自承「想搬走但暂时如此」。
  • 迁移逻辑是关键路径:结构变更若迁移没写好,会在 rehydration 时影响老数据(故 console 有迁移日志可查)。

6. 横向对比

本章是前两章的「落点」:

  • 01-aix-streaming.md:ContentReassembler 重组出的 DMessageFragment[] 就是本章的片段模型;粒子类型与 part 类型一一对应。
  • 02-beam-multimodel.md:每条 ray、每个 fusion 的输出都是 DMessage,经 beamInvokeonBeamSuccess 回调落进对话存储。

相比把消息建模成纯文本 + 富文本 markdown 的简单聊天应用,big-AGI 的片段模型更重,但这正是它能统一承载「推理过程 / 多模态 / 工具调用 / 多模型融合」的根基。

7. 代码地图(导航索引)

主题文件关键符号
消息结构src/common/stores/chat/chat.message.tsDMessageDMessageRolecreateDMessageEmpty
片段模型(核心联合)src/common/stores/chat/chat.fragments.tsDMessageFragmentDMessageContentFragmentDMessageVoidFragmentcreatePlaceholderVoidFragmentisContentFragment
对话编排器src/common/chat-overlay/ConversationHandler.tsConversationHandlermessageAppendmessageAppendAssistantPlaceholderbeamInvokegetBeamStore
对话管理src/common/chat-overlay/ConversationsManager.tsConversationsManager
每对话叠加态src/common/chat-overlay/store-perchat_vanilla.tsPerChatOverlayStore(草稿/临时/变体切片)
对话/消息全局存储src/common/stores/chat/store-chats.ts对话与消息(IndexedDB 持久化)
模型配置存储src/common/stores/llms/store-llms.tsfindLLMOrThrow
项目级架构说明CLAUDE.mdStorage System / State Management 节