- 人工智能
- 大模型
- AI 应用
- 交互助手
- 本地部署
【免费下载链接】cherry-studio
🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端
本篇技术指南基于 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.ts | SQLite 支撑的主题消息树操作与不变量 |
仓库早期曾规划过一套泛化适配器层与根包桶的目标架构文档,但这些 API 并未落地;当前参考文档只描述已实现的行为。因此在阅读本文时请以实际模块为基准,例如消息操作能力由MessageListActions.copyRichContent这类共享动作面提供,页面/窗口适配器再按需注入实现。
领域内还有两份独立的深度参考文档:
| 文档 | 覆盖内容 |
|---|---|
| Composer Rich Clipboard | 私有剪贴板格式:跨复制/粘贴保留 Composer 令牌、还原规则与所有权边界 |
| Message Tree | 主题消息树模型:邻接表、虚拟根、兄弟组、不变量、删除语义与消费方契约 |
主题消息树模型(Message Tree)
邻接表结构与核心列
一个主题(topic)的消息构成一棵树,以邻接表形式存储在message表中——每行通过parentId指向其父行。多模型响应(一次用户回合、N 条助手回复)表现为兄弟组(sibling groups):共享同一parentId且siblingsGroupId非零的行。
表结构定义见 src/main/data/db/schemas/message.ts,关键列语义:
| 列 | 含义 |
|---|---|
parentId | 父消息 id;仅虚拟根(virtual root)为NULL |
topicId | 所属主题(外键,ON DELETE CASCADE) |
role | user/assistant/system内容行,或root(虚拟根哨兵) |
siblingsGroupId | 0= 普通单分支;>0= 同一父节点下的多模型组成员 |
topic.activeNodeId | 当前选中的叶子——"我们在哪"指针,读取路径从它向上回溯 |
此外表结构还包含若干工程细节值得注意:data列以 JSON 存放 AI SDKUIMessage.parts;searchableText与ftsRowid由触发器维护并接入 FTS5 全文索引(trigram 分词),ftsRowid使用稳定整数而非隐式 rowid,避免表重建(如 VACUUM)导致索引失同步。
虚拟根(Virtual Root)
每个主题拥有且仅拥有一个无内容虚拟根:role = 'root'、parentId = NULL、data = { 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.create、TopicService.duplicate和TemporaryChatService持久化路径调用; - 迁移: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=true;MessageService.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 = true、clearTopicMessages、purgeByTopicIdsTx(主题删除)与topic外键级联都能作为单个无序删除保持正确的原因。cascade = false在删除节点之前先重挂子节点,级联便无事可做。(cascade = false删除首回合消息会把子节点重挂到虚拟根下——结构合法,它们成为首回合节点。)
设计教训:
SET NULL在message_topic_root_uniq下是错误的:它会在删除中途把集合内幸存的子节点parentId置空,瞬时产生第二个parentId = NULL行而违反索引(删除任何多模型主题时都可能触达崩溃)。PRAGMA defer_foreign_keys也无济于事——它推迟的是外键检查,而非外键动作。
消费方契约(Consumer Contract)
rootId是权威的首回合信号。getBranchMessages与getTree每页都返回rootId: string | null(主题虚拟根 id)和activeNodeId。一条消息是首回合当且仅当message.parentId === rootId——这是唯一可靠判断。不要用"父节点不在已加载列表"推断首回合(分支是分页的,根永不出现在响应中),也不要用 v1 的askId字段(与角色耦合,user 消息为undefined)。当rootId未知时,什么也不当首回合处理(fail-safe)。getPathRowsToNodeTx从节点向上走到虚拟根并排除根——展示的会话从第一条用户消息开始。getTree查找虚拟根(parentId IS NULL),从活动路径丢弃它,并把其子节点视为逻辑根。首回合节点在响应中保留真实父(虚拟根 id);虚拟根永不作为节点返回。因此TreeNode.parentId与SiblingsGroup.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 令牌,覆盖skill、file、command、knowledge、reference、quote、promptVariable七类内容。设计目标:
- 在 Cherry Studio 内部复制/粘贴时保留 Composer 令牌;
- 通过
text/plain与text/html保持 Cherry Studio 之外的常规剪贴板行为可用; - 绝不在任一载荷中暴露未消毒的令牌 JSON、可解析的 Composer 令牌元数据或本地文件路径;
- 当不存在 Composer 令牌片段时,保留既有富 HTML 复制(如 markdown 表格复制)。
富复制会写入三种载荷:
| 格式 | 用途 |
|---|---|
text/plain | 人类可读的回退文本 |
text/html | 无解析性 Composer 令牌元数据的人类可读 HTML |
web application/x-cherry-composer-fragment+json | Cherry 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)中。
还原上下文由两部分组成:
- 文件还原 handle 注册表(
fileRestorationRegistry):handle →{ sourceId, file, expiresAt },TTL 为 30 分钟(COMPOSER_CLIPBOARD_FILE_HANDLE_TTL_MS = 30 * 60 * 1000),过期即剪除; - 会话缓存:保存最近一次经异步剪贴板 API 写入的富复制片段,以其纯文本为键——粘贴该复制内容时无需读取系统剪贴板即可还原令牌。
此外,quote/promptVariable这类会原样还原 promptText的令牌受会话私有 nonce 保护(COMPOSER_CLIPBOARD_PROMPT_NONCE_TTL_MS同为 30 分钟):由于任何应用都能伪造剪贴板 MIME,一个短可见标签可能隐藏注入的 promptText 并在发送时静默到达模型;因此仅在片段携带本渲染进程写出的会话私有 nonce 时才信任其 promptText,否则降级为可见回退文本。
复制 → 粘贴完整流程
写入侧的核心是projectTokensOverText:把草稿或消息 parts 中的 token(按textOffset与index排序)投影为文本段 + 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做指纹识别 → 泄露来源标记。
接受的代价:来自消息复制的quote与promptVariable令牌在应用重启后或另一个 Cherry Studio 实例中丢失令牌身份;skill与knowledge令牌仍可通过纯文本标记还原(/marker/、#marker#)。
还原规则(Restore Rules)
skill与knowledge令牌仅通过当前表面的 resolver 还原——Chat 与 Agent 保持各自的令牌所有权边界;reference令牌以及无还原规则的私有令牌种类(如command)回退为可见文本;file令牌仅在私有载荷含 handle 且在当前渲染会话的还原上下文中可解析时还原;还原的文件按id:path去重;- 文件 handle 不是可信的剪贴板数据——它们只定位本渲染会话已持有的还原上下文;缺失、未知、过期、跨窗口、重启后或伪造的 handle 一律回退为可见文本;
- 从用户消息复制的 file 令牌,仅当消息文件 part 携带的 file token 源与文本 token 源精确匹配时才可还原;文件名、显示名、令牌标签永不用作回退身份;
- 路径派生的 file token id永不写入剪贴板;若当前渲染会话持有原文件元数据,令牌仍可通过 handle 还原;
quote与promptVariable从消毒后的 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 提供商的桌面客户端
相关推荐
Cherry Studio 聊天域技术参考:消息树模型与 Composer 富文本剪贴板深度解析
Cherry Studio 聊天域技术参考:消息树模型与 Composer 富文本剪贴板深度解析 Cherry Studio 的聊天域横跨渲染进程可复用模块、页
AI 应用大模型桌面应用本地部署RAGA2UI 消息类型完全参考:v0.8/v0.9 协议消息格式、数据模型与消息顺序实战指南
A2UI 消息类型完全参考:v0.8/v0.9 协议消息格式、数据模型与消息顺序实战指南 本文是基于开源仓库 A2UI(Agent to UI)官方参考文档 m
人工智能AI AgentAI 应用前端UI组件UFO 项目 AIP 消息协议完全参考:Pydantic 消息模型、关联机制与最佳实践
UFO 项目 AIP 消息协议完全参考:Pydantic 消息模型、关联机制与最佳实践 AIP(Agent Interaction Protocol)是 UFO
人工智能AI Agent自主智能体GUI 自动化Agent 编排多智能体RAG
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考