Cherry Studio 消息树模型深度解析:邻接表、虚拟根节点与分支删除语义
2026/9/13 9:15:50 网站建设 项目流程

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):即共享同一个parentIdsiblingsGroupId非零的多行。

核心列语义如下表:

含义
parentId父消息 id。虚拟根节点(见下节)为NULL
topicId所属主题(外键,ON DELETE CASCADE)。
roleuser/assistant/system内容行,或root(虚拟根哨兵行)。
siblingsGroupId0= 普通单分支;>0= 同一父节点下某个多模型组的成员。
topic.activeNodeId当前选中的叶子——"我们当前在哪"的指针,读路径从它向上回溯。

在 message.ts 中可以看到这张表的完整定义。除了文档列出的核心字段,源码还给出了若干与树模型配合的附加列与索引,值得一并了解:

  • datatext({ mode: 'json' })):以 JSON 存储 AI SDKUIMessage.parts内容;role = 'root'的虚拟根行内容是空对象{ parts: [] }
  • searchableText:由触发器填充的纯文本列(非 SQLite GENERATED 列),供 FTS5 全文检索使用;在 message.ts 中定义了message_fts虚拟表与message_ai/message_ad/message_au三个同步触发器。
  • statuspending/success/error/paused,由message_status_check约束。
  • modelId/messageSnapshot/stats:模型外键、创建时模型快照与 token 用量统计。
  • compactionSummary:持久化压缩标记(滚动摘要),只读使用,绝不作为树节点喂给模型。

与树结构直接相关的数据库级约束和索引包括:

  • message_parent_id_idxparentId索引)、message_topic_created_idxtopicId, 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_checkrole IN ('user', 'assistant', 'system', 'root')

虚拟根节点:每主题一棵自识别的"无内容根"

每个主题恰好拥有一个无内容的虚拟根节点role = 'root'parentId = NULLdata = { 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为字符串:校验父消息存在且属于同一主题(跨主题父引用不是受支持的形态)。

createUserMessageWithPlaceholdersmode: '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.createUserMessageWithPlaceholdersmode: '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_checkcreateRootMessageTx(运行时)/ChatMigrator(迁移)是根行的唯一写入者,但该双条件本身由结构强制。
activeNodeId永不为虚拟根空主题为NULL,否则必为内容消息;读路径会把根从 active path 中剔除。
awaiting-input 分支必须是"空的成功 user 叶子"MessageService.reserveBranch对叶子锚点创建两条不同行,否则创建一条;createUserMessageWithPlaceholders(mode = 'fill-reserved')重新校验选中的叶子并原子地填充它与其助手占位符。
删除 awaiting-input 节点绝不能删掉"期间已被填充"的消息画布请求DELETE /messages/:id?awaitingInputOnly=trueMessageService.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 = trueclearTopicMessagespurgeByTopicIdsTx(主题删除)和topic外键级联全部可以退化为"单次无序删除"而依然正确的原因。而cascade = false会在删除节点之前重挂其子节点,因此级联触发时已无子节点可删。(删除首轮消息时cascade = false会把子节点重挂到虚拟根上——结构上合法,它们成为新的首轮节点。)

文档特别强调了一个历史教训:

SET NULLmessage_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是权威的"首轮"信号。getBranchMessagesgetTree在每一页返回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.parentIdSiblingsGroup.parentId是非空string。若调用方显式传入rootId === virtualRootIdgetTree会抛INVALID_OPERATION(虚拟根不可渲染为树节点)。
  • Flow 画布跳过父节点不是已渲染节点的边——首轮消息挂在虚拟根下但根从不渲染——因此首轮仍作为图的根渲染。持久化的 awaiting-input 分支仍是真实可选中的树节点。
  • 基于角色的内容查询无需特殊的根处理:根是role = 'root',天然被排除。

与相邻模块的关系

消息树模型不是孤立设计,它与 Cherry Studio 的数据层架构深度咬合:

  • 递归 CTE(取祖先、取后代、取子树)是消息树读路径的核心手段,相关 ORM-for-rows 模式与数据库模式约定参见 database-patterns.md。
  • 所有写路径在withWriteTx事务中串行化执行(fts_rowidMAX+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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询