Cherry Studio 聊天域核心参考:消息树模型与 Composer 富剪贴板协议
2026/9/20 8:36:57 网站建设 项目流程
  • 人工智能
  • 大模型
  • AI 应用
  • 交互助手
  • 本地部署

【免费下载链接】cherry-studio

🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端

项目地址:https://gitcode.com/CherryHQ/cherry-studio
点击查看免费下载

本篇技术指南基于 CherryHQ/cherry-studio 仓库的 docs/references/chat/README.md 展开,系统梳理聊天域(chat domain)的两大核心机制:主进程 SQLite 支撑的主题消息树模型message表邻接表结构、虚拟根、兄弟组与删除语义),以及渲染进程Composer 富剪贴板(私有片段格式、令牌还原规则与安全边界)。读完本文,你将掌握 Cherry Studio 消息如何以树形持久化、activeNodeId如何驱动分支读取、复制/粘贴如何无损保留 skill/file/quote 等 Composer 令牌,以及这些机制的源码落点与验证方式。

聊天域模块全景:所有权地图

Cherry Studio 的聊天域没有单一的@renderer/components/chat根桶(barrel),也不存在泛化的components/chat/adapters/目录;消费方直接导入拥有该能力的模块。领域所有权划分如下:

路径职责
src/renderer/components/chat/messages/共享的消息列表契约、渲染、操作、工具、markdown、流式与列表行为
src/renderer/components/chat/actions/通用操作描述符/注册表,以及当前主题与会话的操作集
src/renderer/components/chat/{resourceList,shell,panes,flow}/共享资源导航、会话外壳、辅助面板与主题树可视化(flow canvas)
src/renderer/components/composer/共享 Composer 表面及其 Chat/Agent 变体
src/renderer/pages/{home,agents}/messages/页面拥有的投影层:把业务状态投影为共享消息列表契约
src/main/data/services/MessageService.tsSQLite 支撑的主题消息树操作与不变量

仓库早期曾规划过一套泛化适配器层与根包桶的目标架构文档,但这些 API 并未落地;当前参考文档只描述已实现的行为。因此在阅读本文时请以实际模块为基准,例如消息操作能力由MessageListActions.copyRichContent这类共享动作面提供,页面/窗口适配器再按需注入实现。

领域内还有两份独立的深度参考文档:

文档覆盖内容
Composer Rich Clipboard私有剪贴板格式:跨复制/粘贴保留 Composer 令牌、还原规则与所有权边界
Message Tree主题消息树模型:邻接表、虚拟根、兄弟组、不变量、删除语义与消费方契约

主题消息树模型(Message Tree)

邻接表结构与核心列

一个主题(topic)的消息构成一棵树,以邻接表形式存储在message表中——每行通过parentId指向其父行。多模型响应(一次用户回合、N 条助手回复)表现为兄弟组(sibling groups):共享同一parentIdsiblingsGroupId非零的行。

表结构定义见 src/main/data/db/schemas/message.ts,关键列语义:

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

此外表结构还包含若干工程细节值得注意:data列以 JSON 存放 AI SDKUIMessage.partssearchableTextftsRowid由触发器维护并接入 FTS5 全文索引(trigram 分词),ftsRowid使用稳定整数而非隐式 rowid,避免表重建(如 VACUUM)导致索引失同步。

虚拟根(Virtual Root)

每个主题拥有且仅拥有一个无内容虚拟根role = 'root'parentId = NULLdata = { parts: [] }。所有真实消息都挂在它之下。第一个用户回合及其重发是共享父节点下的普通兄弟——因此"重发第一条消息"在结构上与任何兄弟创建完全一致,不存在多个物理根:

root (role='root', parentId=NULL, 无内容,永不渲染) ├─ user "v1" ┐ ├─ user "v2" ├─ 同一个 siblingsGroup —— "重发首条消息" = 一个普通兄弟 └─ user "v3" ┘ └─ assistant → user → assistant → …

专用role = 'root'使该行自标识:按角色过滤的内容查询(WHERE role = 'system'等)天然排除它,无需parentId IS NOT NULL附加条件。role = 'root'parentId IS NULL是等价的;parentId IS NULL仍是有索引的根查找键

虚拟根在创建主题的同一事务里即时创建,因此每个主题从出生起就有根。写入方仅有两处:

  • 运行时:MessageService.createRootMessageTx(tx, topicId) —— 由TopicService.createTopicService.duplicateTemporaryChatService持久化路径调用;
  • 迁移:ChatMigrator 为每个主题内联构建同一行,并把旧 v1 物理根重挂到它之上,使迁移后的主题与新建主题结构一致。

消息创建路径从不创建根,而是通过getRootMessageIdTx(tx, topicId)读取(源码)——缺失即抛错,因为根缺失意味着主题创建路径出错,属于必须大声暴露的 bug,而非可粉饰的边界情况。

持久化的等待输入分支(awaiting-input branches)

在助手消息之下开启新分支,通过POST /messages/:id/branches持久化空的成功role = 'user'叶子

  • 叶子助手会获得两个子节点,这样首次预留才构成真实分支;已有子节点的助手只新增一个节点;
  • 同一助手下的多个空预留是有意的分支点,而非重复数据;
  • 等待输入状态由结构推导,不存储任何草稿标记;
  • 会话列表隐藏空的成功 user 行,而getTree将空 user 叶子投影为isAwaitingInput供 flow canvas 渲染。

源码实现见 MessageService.reserveBranch(anchorId, activate):它校验锚点必须是assistant行,hasChild决定插入 1 还是 2 行,并默认把新预留设为活动节点。空闲预留成为主题活动节点;直播流期间渲染进程发送activate: false,使创建预留不会移动活动流路径。若用户后续选中该预留并在主题仍直播时输入,排队载荷会捕获预留 id 并等待主题转为空闲——它无法被手动引入正在进行的回合。

下一次提交时,Composer 复用空行的 id 而非新建 user 行,并走常规submit-message流程。MessageService.createUserMessageWithPlaceholders(mode = 'fill-reserved') 会校验目标仍是无回复的空成功 user 叶子,然后在同一事务内填充它并创建助手占位行;主进程的直播守卫在任何写入前拒绝预留分支提交,从而关闭渲染进程的时序竞争。

数据层不变量(DB 级强制)

以下不变量由数据库结构而非服务层约定强制,定义见 message 表 schema:

不变量强制方式
每个主题恰好一个(存活)虚拟根message_topic_root_uniq——(topic_id)上的部分UNIQUE索引,WHERE parent_id IS NULL AND deleted_at IS NULL,插入时拒绝第二个存活根
每条内容消息都有非空父DB CHECKmessage_root_parent_check((role = 'root') = (parent_id IS NULL))—— 内容行(role != 'root')带空父在存储层即被拒绝
role = 'root'parentId IS NULL同一个message_root_parent_checkCHECK 约束;createRootMessageTx(运行时)/ChatMigrator(迁移)是根行唯二写入方,但该双条件本身由结构强制
activeNodeId永不为虚拟根空主题为NULL,否则必为内容消息;读取路径从活动路径中丢弃根
等待输入分支 = 空的成功 user 叶子reserveBranch按锚点是否为叶子决定建 2 行或 1 行;createUserMessageWithPlaceholders(mode = 'fill-reserved')重新校验所选叶子并原子填充
删除等待输入节点不得误删已被填充的消息Canvas 请求DELETE /messages/:id?awaitingInputOnly=trueMessageService.delete在删除前重新校验空 parts、成功状态、user 角色且无存活子节点
虚拟根只能随主题删除而删除delete()硬拒绝它(见下),主题外键ON DELETE CASCADE是唯一删除路径

message_topic_root_uniq同时为根查找提供 O(1) 支撑(WHERE topic_id=? AND parent_id IS NULL),并以deleted_at IS NULL限定作用域,避免未来根被软删后与新根冲突。

删除语义(Delete Semantics)

删除行为矩阵:

目标行为
虚拟根拒绝INVALID_OPERATION),无论是否cascade。删除它会使首回合子节点孤儿化(违反唯一索引)或留下无根主题
内容消息,cascade = false活动路径上的分组助手回复按默认父策略:子节点转给同组下一条存活回复(末尾取前一条,按创建时间再按 ID 排序);否则子节点重挂到被删节点的父节点。删除分组上下文回复时清除后代上下文锚点(即使没有兄弟剩余)。保留活动后代;若被删节点本身是活动的,则选后继者或回退到父节点。子节点携带其siblingsGroupId(相对旧父),每个不同的非零被移动组都会重基到目标位置已有组之上的新 id——不会并入目标处的无关组
内容消息,cascade = true删除消息及其整个子树
"清空所有消息"clearTopicMessages(topicId)DELETE /topics/:topicId/messages)—— 一条语句删除主题全部非根行并清空activeNodeId;无内容虚拟根保留。这是旧"删根清主题"(现已被拒绝)的结构性替代

自引用外键(parentId → message.id)为ON DELETE CASCADE:删除节点即一条语句移除整棵子树——无需叶子优先排序,也无需SET NULL制造冲突的parentId = NULL行。这正是cascade = trueclearTopicMessagespurgeByTopicIdsTx(主题删除)与topic外键级联都能作为单个无序删除保持正确的原因。cascade = false在删除节点之前先重挂子节点,级联便无事可做。(cascade = false删除首回合消息会把子节点重挂到虚拟根下——结构合法,它们成为首回合节点。)

设计教训SET NULLmessage_topic_root_uniq下是错误的:它会在删除中途把集合内幸存的子节点parentId置空,瞬时产生第二个parentId = NULL行而违反索引(删除任何多模型主题时都可能触达崩溃)。PRAGMA defer_foreign_keys也无济于事——它推迟的是外键检查,而非外键动作

消费方契约(Consumer Contract)

  • rootId是权威的首回合信号。getBranchMessagesgetTree每页都返回rootId: string | null(主题虚拟根 id)和activeNodeId。一条消息是首回合当且仅当message.parentId === rootId——这是唯一可靠判断。不要用"父节点不在已加载列表"推断首回合(分支是分页的,根永不出现在响应中),也不要用 v1 的askId字段(与角色耦合,user 消息为undefined)。当rootId未知时,什么也不当首回合处理(fail-safe)。
  • getPathRowsToNodeTx从节点向上走到虚拟根并排除根——展示的会话从第一条用户消息开始。
  • getTree查找虚拟根(parentId IS NULL),从活动路径丢弃它,并把其子节点视为逻辑根。首回合节点在响应中保留真实父(虚拟根 id);虚拟根永不作为节点返回。因此TreeNode.parentIdSiblingsGroup.parentId是非空string
  • Flow canvas(见 src/renderer/components/chat/flow/TopicMessageFlowCanvas.tsx)跳过父节点未渲染的边——首回合挂靠的虚拟根不是节点——因此首回合仍以图根形式渲染。持久化的等待输入分支保持为真实可选的树节点。
  • 按角色查询内容无需特殊处理根:根是role = 'root',构造上即被排除。

相关延伸阅读:Database Patterns、DataApi in Main。

Composer 富剪贴板协议(Composer Rich Clipboard)

设计目标与三格式写入

私有剪贴板格式用于用户在 Cherry Studio 消息表面与 Composer 之间复制/粘贴时保留 Composer 令牌,覆盖skillfilecommandknowledgereferencequotepromptVariable七类内容。设计目标:

  • 在 Cherry Studio 内部复制/粘贴时保留 Composer 令牌;
  • 通过text/plaintext/html保持 Cherry Studio 之外的常规剪贴板行为可用;
  • 绝不在任一载荷中暴露未消毒的令牌 JSON、可解析的 Composer 令牌元数据或本地文件路径;
  • 当不存在 Composer 令牌片段时,保留既有富 HTML 复制(如 markdown 表格复制)。

富复制会写入三种载荷:

格式用途
text/plain人类可读的回退文本
text/html无解析性 Composer 令牌元数据的人类可读 HTML
web application/x-cherry-composer-fragment+jsonCherry Studio 私有令牌片段

MIME 常量定义于 src/renderer/utils/message/composerClipboard.ts:COMPOSER_CLIPBOARD_FRAGMENT_MIME = 'web application/x-cherry-composer-fragment+json'

私有片段结构与消毒

私有片段是带版本的 JSON,由有序的 text/token 段组成:

interface ComposerClipboardFragment { version: 1 segments: ComposerClipboardSegment[] // { type: 'text', text } | { type: 'token', token, fallbackText } }

片段在写入前经过一次且仅一次的消毒(createComposerClipboardFragment):token 需有合法 id、kind、label;file token 的 id 若形似路径(hasUnsafeComposerClipboardFileTokenId)则被拒绝。文件 token 载荷永不携带本地路径或路径派生的 id;可还原的文件 token 只携带不可猜测的 handle加显示字段,对应文件元数据保存在当前渲染会话的内存还原上下文(restoration context)中。

还原上下文由两部分组成:

  1. 文件还原 handle 注册表fileRestorationRegistry):handle →{ sourceId, file, expiresAt },TTL 为 30 分钟(COMPOSER_CLIPBOARD_FILE_HANDLE_TTL_MS = 30 * 60 * 1000),过期即剪除;
  2. 会话缓存:保存最近一次经异步剪贴板 API 写入的富复制片段,以其纯文本为键——粘贴该复制内容时无需读取系统剪贴板即可还原令牌。

此外,quote/promptVariable这类会原样还原 promptText的令牌受会话私有 nonce 保护(COMPOSER_CLIPBOARD_PROMPT_NONCE_TTL_MS同为 30 分钟):由于任何应用都能伪造剪贴板 MIME,一个短可见标签可能隐藏注入的 promptText 并在发送时静默到达模型;因此仅在片段携带本渲染进程写出的会话私有 nonce 时才信任其 promptText,否则降级为可见回退文本。

复制 → 粘贴完整流程

写入侧的核心是projectTokensOverText:把草稿或消息 parts 中的 token(按textOffsetindex排序)投影为文本段 + token 段,同时生成纯文本;getTokenFallbackText为每类 token 生成回退文本——例如 skill 用/marker/纯文本标记、knowledge 用#marker#标记、quote/promptVariable 优先用 promptText。之后createComposerRichClipboardContentFromProjection组装三格式载荷;writeComposerClipboardData把载荷写入DataTransfer(同步 paste 事件),writeComposerRichClipboardContent则走异步navigator.clipboard.write

同步粘贴与设计取舍

粘贴处理完全同步,永不调用navigator.clipboard.read()。经异步剪贴板 API 写入的片段不会出现在paste 事件的DataTransfer中,因此writeComposerRichClipboardContent会把写入片段记录到会话缓存;粘贴时若纯文本与最近一次富复制匹配(先做行尾归一化\r\n → \n,兼容 Windows 剪贴板往返),则从缓存还原。备选方案均被否决:

  • 粘贴时读取系统剪贴板 → 使每次外部粘贴变异步,且会读取无关剪贴板数据;
  • 通过合成 copy 事件写入 → 需要已废弃的execCommand
  • text/html做指纹识别 → 泄露来源标记。

接受的代价:来自消息复制的quotepromptVariable令牌在应用重启后或另一个 Cherry Studio 实例中丢失令牌身份skillknowledge令牌仍可通过纯文本标记还原(/marker/#marker#)。

还原规则(Restore Rules)

  • skillknowledge令牌仅通过当前表面的 resolver 还原——Chat 与 Agent 保持各自的令牌所有权边界;
  • reference令牌以及无还原规则的私有令牌种类(如command)回退为可见文本;
  • file令牌仅在私有载荷含 handle 且在当前渲染会话的还原上下文中可解析时还原;还原的文件按id:path去重;
  • 文件 handle 不是可信的剪贴板数据——它们只定位本渲染会话已持有的还原上下文;缺失、未知、过期、跨窗口、重启后或伪造的 handle 一律回退为可见文本;
  • 从用户消息复制的 file 令牌,仅当消息文件 part 携带的 file token 源与文本 token 源精确匹配时才可还原;文件名、显示名、令牌标签永不用作回退身份;
  • 路径派生的 file token id永不写入剪贴板;若当前渲染会话持有原文件元数据,令牌仍可通过 handle 还原;
  • quotepromptVariable从消毒后的 token 字段还原(受 nonce 保护,见上文);
  • 不支持、不安全或无法解析的令牌段回退为可见文本;
  • 片段只从 paste 事件剪贴板数据或会话缓存还原;粘贴永不读取系统剪贴板;
  • 若浏览器无法写入私有自定义格式,剪贴板写入回退为text/html+text/plain,再在ClipboardItem不可用时回退纯文本;每次此类回退都会清空会话缓存

所有权边界(Boundaries)

  • MessageListActions.copyRichContent是富剪贴板写入的共享动作面:消息组件请求该能力,页面/窗口适配器提供实现;
  • composerClipboard.ts 拥有私有片段解析、序列化、HTML 转义、还原上下文(文件还原 handle 与会话缓存)以及系统剪贴板写入辅助函数;
  • ComposerSurface拥有编辑器 copy/cut/paste 事件处理,并把片段解析/投影委托给工具函数。Cut 执行与复制相同的富复制后删除选区——因此被剪切令牌保持可还原,而不是退化为默认的去令牌 HTML 剪贴板;
  • 普通 OS 文件粘贴与拖放是独立流程,使用浏览器或 Electron 文件 API,不会从私有 Composer 片段还原文件;
  • 文件还原不会重读文件或重跑支持扩展名检查;后续发送/文件处理路径仍负责文件可用性;
  • 文件 part 的mediaType推断不属于本特性;如需 MIME 归一化,请保持该改动独立。

令牌能力表:剪贴板支持由单一契约派生

哪些令牌种类支持剪贴板、哪些种类的 promptText 可被信任,并非散落的硬编码,而是由 src/renderer/utils/composerTokenPolicy.ts 中的单一能力表COMPOSER_TOKEN_CAPABILITIES派生:

令牌种类剪贴板剪贴板 promptText
skill
link
file
folder
command
knowledge
reference
quote
promptVariable

ComposerClipboardTokenKind = ComposerTokenKindWithCapability<'clipboard'>即由此表推导,测试见 composerTokenPolicy.test.ts(例如command不具剪贴板能力、unknown一律拒绝)。

聚焦验证:本地迭代命令

富剪贴板与消息树涉及大量渲染进程与主进程代码,文档建议本地迭代时使用聚焦检查而非全量测试套件:

# 富剪贴板核心:ComposerSurface 粘贴事件 + 片段解析/序列化工具 pnpm test:renderer src/renderer/components/composer/__tests__/ComposerSurface.test.tsx src/renderer/utils/message/__tests__/composerClipboard.test.ts # 富剪贴板动作面:菜单栏动作、选区、平台动作钩子、选区控制器 pnpm test:renderer src/renderer/components/chat/messages/frame/__tests__/messageMenuBarActions.test.tsx src/renderer/components/chat/messages/utils/__tests__/messageSelection.test.ts src/renderer/components/chat/messages/hooks/__tests__/useMessagePlatformActions.test.tsx src/renderer/components/chat/messages/hooks/__tests__/useMessageSelectionController.test.tsx

测试文件均已在仓库确认存在(如 ComposerSurface.test.tsx、composerClipboard.test.ts、messageMenuBarActions.test.tsx 等)。消息树侧的完整覆盖还包括 MessageService 相关的服务层测试与 message schema 中的 CHECK/UNIQUE 约束测试。

小结

Cherry Studio 的聊天域把"业务状态"与"渲染契约"清晰分层:主进程 MessageService.ts 以邻接表 + 虚拟根 + 兄弟组维护主题消息树,并用 DB CHECK 与部分唯一索引把树的不变量下沉到存储层;渲染进程以 composerClipboard.ts 为核心实现了一套不读取系统剪贴板不泄露路径与可解析元数据的富剪贴板协议,配合单一令牌能力表与 30 分钟 TTL 的会话还原上下文,在内部无损还原令牌、对外保持普通剪贴板行为。两条主线分别由 Message Tree 与 Composer Rich Clipboard 两份文档详细记载,本文是其面向工程实践的索引与源码级印证。

  • 人工智能
  • 大模型
  • AI 应用
  • 交互助手
  • 本地部署

【免费下载链接】cherry-studio

🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端

项目地址:https://gitcode.com/CherryHQ/cherry-studio
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询