Cherry Studio 消息树模型深度解析:邻接表、虚拟根节点与分支删除语义
【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio
导读
本文是 Cherry Studio 聊天会话(Topic)中消息树模型(Message Tree)的权威技术参考。它讲解了一个主题(Topic)下的消息在 SQLite 中如何以邻接表(adjacency list)形式组织、哪些数据库级不变量(invariants)保证树结构合法、虚拟根节点(virtual root)如何让"重发首条消息""多模型并列回复"等操作在结构上与普通分支完全一致,以及删除语义(cascade/ 重挂载 / 整主题清空)的底层原理。读完本文,你将掌握 Cherry Studio 消息表message的完整列语义、siblingsGroupId分组机制、awaiting-input 保留分支的工作流,以及 flow 画布等消费端依赖的rootId契约,可直接用于阅读 message.ts 与 MessageService.ts 的源码,或在此基础上扩展新的读写路径。
范围说明:本文只覆盖主题聊天消息(
message表)。Agent 会话消息(agent_session_message)是独立的扁平模型,不在本文范围内。
结构:一棵以parentId为指针的邻接表树
一个主题的消息构成一棵树,存储形式为邻接表——每一行通过parentId指向其父行。多模型响应(一次用户输入、N 条助手回复)表现为兄弟组(sibling groups):即共享同一个parentId且siblingsGroupId非零的多行。
核心列语义如下表:
| 列 | 含义 |
|---|---|
parentId | 父消息 id。仅虚拟根节点(见下节)为NULL。 |
topicId | 所属主题(外键,ON DELETE CASCADE)。 |
role | user/assistant/system内容行,或root(虚拟根哨兵行)。 |
siblingsGroupId | 0= 普通单分支;>0= 同一父节点下某个多模型组的成员。 |
topic.activeNodeId | 当前选中的叶子——"我们当前在哪"的指针,读路径从它向上回溯。 |
在 message.ts 中可以看到这张表的完整定义。除了文档列出的核心字段,源码还给出了若干与树模型配合的附加列与索引,值得一并了解:
data(text({ mode: 'json' })):以 JSON 存储 AI SDKUIMessage.parts内容;role = 'root'的虚拟根行内容是空对象{ parts: [] }。searchableText:由触发器填充的纯文本列(非 SQLite GENERATED 列),供 FTS5 全文检索使用;在 message.ts 中定义了message_fts虚拟表与message_ai/message_ad/message_au三个同步触发器。status:pending/success/error/paused,由message_status_check约束。modelId/messageSnapshot/stats:模型外键、创建时模型快照与 token 用量统计。compactionSummary:持久化压缩标记(滚动摘要),只读使用,绝不作为树节点喂给模型。
与树结构直接相关的数据库级约束和索引包括:
message_parent_id_idx(parentId索引)、message_topic_created_idx(topicId, createdAt索引)。message_topic_root_uniq:部分唯一索引(topic_id) WHERE parent_id IS NULL AND deleted_at IS NULL——保证每主题至多一个"存活"的根行,同时让根查找WHERE topic_id=? AND parent_id IS NULL达到 O(1)。message_root_parent_check:((role = 'root') = (parent_id IS NULL))的 CHECK 约束——role='root'与parentId IS NULL互为充要条件。message_role_check:role IN ('user', 'assistant', 'system', 'root')。
虚拟根节点:每主题一棵自识别的"无内容根"
每个主题恰好拥有一个无内容的虚拟根节点:role = 'root'、parentId = NULL、data = { parts: [] }。所有真实消息都挂在它之下。首条用户消息及其"重发"版本只是这个共享父节点下的普通兄弟——因此"重发首条消息"在结构上与创建任何其他兄弟完全一致,不存在多个物理根。示意如下:
root (role='root', parentId=NULL, 无内容, 永不渲染) ├─ user "v1" ┐ ├─ user "v2" ├─ 同一个 siblingsGroup —— "重发首条消息" = 一个普通兄弟 └─ user "v3" ┘ └─ assistant → user → assistant → …虚拟根行是自我标识(self-identifying)的:role = 'root'让所有按角色过滤的内容查询(如WHERE role = 'system')天然排除它,无需额外追加parentId IS NOT NULL条件。role = 'root'与parentId IS NULL等价,而parentId IS NULL仍然是索引化的根查找键(即message_topic_root_uniq索引)。
根节点的创建与写入者
虚拟根节点是急切(eagerly)创建的——在创建主题的同一个事务里完成,因此每个主题从诞生起就有根。从源码看,写入者共有两类:
- 运行时:
MessageService.createRootMessageTx(tx, topicId)(MessageService.ts),插入一行{ topicId, parentId: null, role: 'root', data: { parts: [] }, status: 'success', siblingsGroupId: 0 }。它由三条主题创建路径调用:TopicService.create(TopicService.ts)、TopicService.duplicate(TopicService.ts)与TemporaryChatService的 persist(TemporaryChatService.ts)。 - 迁移:
ChatMigrator在迁移中为每个主题内联创建同样的行,并把 v1 时代的物理根重挂到它之下,使迁移后的主题与新建主题形态一致。
消息创建路径绝不创建根——它们通过getRootMessageIdTx(tx, topicId)读取根(MessageService.ts)。该方法在根缺失时抛出异常:根缺失是"主题创建路径漏调createRootMessageTx"的响亮 bug,绝不静默掩盖。测试 MessageService.test.ts 还验证了同一主题第二次调用createRootMessageTx会违反message_topic_root_uniq唯一索引——单根不变量在存储层被强制执行。
根对内容消息的父解析
MessageService.create(MessageService.ts)对parentId有三种解析策略:
dto.parentId === undefined:自动解析——以topic.activeNodeId为权威的"当前在哪"标记追加;空主题(无 active 节点)则把首轮消息挂到虚拟根下(resolvedParentId = topic.activeNodeId ?? this.getRootMessageIdTx(tx, topicId))。dto.parentId === null:显式首轮消息——挂到主题的虚拟根下。dto.parentId为字符串:校验父消息存在且属于同一主题(跨主题父引用不是受支持的形态)。
createUserMessageWithPlaceholders的mode: 'create'分支(MessageService.ts)遵循同样规则:首轮消息一律以getRootMessageIdTx的返回值为父。
持久化的 awaiting-input 分支
在助手消息下方开启新分支时,客户端通过POST /messages/:id/branches持久化空的role = 'user'成功叶子,而不是在渲染层临时标记"草稿"。规则是:
- 叶子助手会获得两个子节点,这样首次预留就能形成真实分支;
- 已有子节点的助手只新增一个节点;
- 同一助手下方多个空预留是有意的分支点,不是重复数据。
"等待输入(awaiting-input)"状态完全由这个结构推导——不存储任何草稿标记。会话列表会隐藏空的成功 user 行,而getTree会把空的 user 叶子投影为isAwaitingInput供 flow 画布使用。
与活动流的交互
空闲的预留会变成主题的 active 节点。但在直播流(live stream)期间,渲染层发送activate: false,因此创建预留不会移动当前正在播放的流路径。如果用户之后选中该预留、在主题仍处于直播状态时输入内容,排队的载荷会捕获该预留 id,并等待主题变为空闲后才真正写入——它不能被手动引导进正在运行的 turn 中。
提交与填充
下一次 composer 提交会使用这个空行的 id(而不是再创建一条 user 行),并走标准的submit-message流程。MessageService.createUserMessageWithPlaceholders的mode: 'fill-reserved'分支(MessageService.ts)会重新校验目标仍是"空的、成功的、无回复的 user 叶子",然后在一个事务里原子地填充它并创建助手占位符。主进程的直播流守卫会在任何写入之前拒绝针对已预留分支的提交,从而关闭渲染层的时序竞争。
isAwaitingInputLeafTx(MessageService.ts)的实现给出了"awaiting-input 叶子"的精确定义:内容是空 user turn(role = 'user'、status = 'success'、parts为空)且没有任何存活子节点。
不变量(Invariants)
下表汇总了消息树模型必须始终成立的约束及其强制者:
| 不变量 | 强制方式 |
|---|---|
| 每主题恰好一个(存活)虚拟根 | message_topic_root_uniq——(topic_id) WHERE parent_id IS NULL AND deleted_at IS NULL部分唯一索引,插入第二个存活根即被拒绝。 |
| 每条内容消息都有非空父 | DB CHECKmessage_root_parent_check((role = 'root') = (parent_id IS NULL))——内容行(role != 'root')若父为空,在存储层就被拒绝,而不是靠约定。首轮内容消息的parentId = <虚拟根>。 |
role = 'root'⇔parentId IS NULL | 同一个DB CHECKmessage_root_parent_check。createRootMessageTx(运行时)/ChatMigrator(迁移)是根行的唯一写入者,但该双条件本身由结构强制。 |
activeNodeId永不为虚拟根 | 空主题为NULL,否则必为内容消息;读路径会把根从 active path 中剔除。 |
| awaiting-input 分支必须是"空的成功 user 叶子" | MessageService.reserveBranch对叶子锚点创建两条不同行,否则创建一条;createUserMessageWithPlaceholders(mode = 'fill-reserved')重新校验选中的叶子并原子地填充它与其助手占位符。 |
| 删除 awaiting-input 节点绝不能删掉"期间已被填充"的消息 | 画布请求DELETE /messages/:id?awaitingInputOnly=true;MessageService.delete在删除前重新校验空 parts、成功状态、user 角色与无存活子节点。 |
| 虚拟根只能通过删除主题来移除 | delete()硬拒绝它(见下);主题外键ON DELETE CASCADE是唯一能移除它的路径。 |
其中message_root_parent_check的威力在于:"内容必有父"与"根 ⇔ 无父"是数据库约束,不是服务层的纪律。MessageService.update也据此提供了更友好的错误信息:虚拟根不能被重挂(会丢失 null 父、导致主题无根),内容消息不能被移动到parentId = null(会产生第二行 null 父、违反唯一索引),见 MessageService.ts。
删除语义(Delete Semantics)
删除逻辑集中在MessageService.delete(MessageService.ts),按目标分四种行为:
| 目标 | 行为 |
|---|---|
| 虚拟根 | 拒绝(INVALID_OPERATION),无论cascade为何值。删除它要么让首轮子节点成为孤儿(违反唯一索引),要么留下一个无根主题。 |
内容消息,cascade = false | 若被删的是 active 路径上的分组助手回复且使用默认 parent 策略,则把子节点转移给同组的下一条存活回复(末尾时取前一条),按创建时间再按 id 排序;否则把子节点重挂到被删节点的父节点。删除分组上下文回复时清空后代上下文锚点(即使没有兄弟剩余也清)。保留 active 后代;若被删节点本身是 active,则选择后继者或回退到父节点。子节点携带其siblingsGroupId(相对旧父),因此每个非零移动组都会被**重基(rebased)**到目标位置已有任何组之上的新 id——绝不会合并进目标处无关的组。 |
内容消息,cascade = true | 删除该消息及其整棵子树。 |
| "清空全部消息" | clearTopicMessages(topicId)(DELETE /topics/:topicId/messages)——一条语句删除主题的所有非根行并清空activeNodeId;无内容的虚拟根保留。这是旧的"删除根来清空主题"(现已拒绝)的结构性替代。 |
自引用外键与 CASCADE 的正确性根源
message.parentId → message.id的自引用外键是ON DELETE CASCADE(message.ts)。删除一个节点即一条语句删除整棵子树——无需叶子优先排序,也没有SET NULL来制造冲突的parentId = NULL行。这正是cascade = true、clearTopicMessages、purgeByTopicIdsTx(主题删除)和topic外键级联全部可以退化为"单次无序删除"而依然正确的原因。而cascade = false会在删除节点之前重挂其子节点,因此级联触发时已无子节点可删。(删除首轮消息时cascade = false会把子节点重挂到虚拟根上——结构上合法,它们成为新的首轮节点。)
文档特别强调了一个历史教训:
SET NULL在message_topic_root_uniq下是积极错误的:它会在删除中途把集合内幸存的子节点parentId置空,瞬态地制造第二行parentId = NULL,违反唯一索引(删除任意多模型主题时都可能触发崩溃)。PRAGMA defer_foreign_keys也无济于事——它推迟的是外键检查,而不是动作本身。
删除实现中的细节印证
delete开头同时检查message.role === 'root' || message.parentId === null(MessageService.ts),双保险地拒绝根删除。awaitingInputOnly参数(MessageService.ts)在删除前调用isAwaitingInputLeafTx重新校验,防止误删已被填充的消息。isContextReply判定(MessageService.ts):只有 active 路径上的分组助手回复才允许把续接权交给同组兄弟;后继者按"下一条,末尾回绕取前一条"的展示顺序解析。reparentChildrenTx(MessageService.ts)实现了组重基:以{child.parentId}:{child.siblingsGroupId}为源组键,为目标父下每个被移动的非零组分配全新递增 id(nextGroupId = max(0, 目标已有组, 移动组) + 1起步),确保移动组不与目标处已有组冲突。getDescendantIdsTx(MessageService.ts)使用递归 CTE 在单条查询中取得全部后代,供cascade = true与响应计数使用。clearTopicMessages(MessageService.ts)先经getRootMessageIdTx定位根,删除topic_id = ? AND id != root的所有行,再清空activeNodeId。
消费端契约(Consumer Contract)
以下是读路径与 flow 画布依赖的核心契约:
rootId是权威的"首轮"信号。getBranchMessages与getTree在每一页返回rootId: string | null(主题虚拟根的 id),同时返回activeNodeId。一条消息是首轮当且仅当message.parentId === rootId——这是唯一可靠的判断。不要用"父不在已加载列表中"推断首轮(分支是分页的、根永远不在响应中),也不要用 v1 的askId字段(它与角色耦合,对 user 消息为undefined)。当rootId未知时,一律把任何消息都当作非首轮处理(fail-safe)。getPathRowsToNodeTx(MessageService.ts)从节点向上走到虚拟根并排除根——展示的对话从第一条 user 消息开始。实现用递归 CTE 收集祖先 id,再经 ORM 取全行以保证 camelCase 映射,最后把根行切掉(chain[0]?.parentId === null ? chain.slice(1) : chain)。getTree(MessageService.ts)定位虚拟根(parentId IS NULL),把它从 active path 剔除,并把它的子节点当作逻辑根。首轮节点在响应中保留真实父(虚拟根 id);虚拟根永不作为节点返回。因此TreeNode.parentId与SiblingsGroup.parentId是非空string。若调用方显式传入rootId === virtualRootId,getTree会抛INVALID_OPERATION(虚拟根不可渲染为树节点)。- Flow 画布跳过父节点不是已渲染节点的边——首轮消息挂在虚拟根下但根从不渲染——因此首轮仍作为图的根渲染。持久化的 awaiting-input 分支仍是真实可选中的树节点。
- 基于角色的内容查询无需特殊的根处理:根是
role = 'root',天然被排除。
与相邻模块的关系
消息树模型不是孤立设计,它与 Cherry Studio 的数据层架构深度咬合:
- 递归 CTE(取祖先、取后代、取子树)是消息树读路径的核心手段,相关 ORM-for-rows 模式与数据库模式约定参见 database-patterns.md。
- 所有写路径在
withWriteTx事务中串行化执行(fts_rowid的MAX+1分配、activeNodeId 更新都依赖这一串行化),事务提交后通过notifyDataApiDataChange发布/topics/:topicId/messages、/topics/:topicId/tree等端点的读模型变更,供渲染层订阅刷新;主进程侧的数据 API 总览见 contenteditable="false">【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考