Cherry Studio Composer Rich Clipboard 深度解析:消息表面与编辑器之间的 Token 无损复制粘贴机制
【免费下载链接】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 中消息表面(Chat 对话、Agent 会话)与 Composer 输入框之间的复制粘贴链路,剖析一套私有剪贴板格式web application/x-cherry-composer-fragment+json:它如何在复制/粘贴时保留skill、file、command、knowledge、reference、quote、promptVariable等 Composer Token 的语义身份,同时保证text/plain/text/html对外部应用保持可用且不泄露本地路径。读完本文,你将理解该机制的目标、剪贴板载荷形状、同步粘贴流程、文件句柄与会话缓存的恢复规则、安全边界,以及如何用聚焦测试快速验证相关改动。
背景:为什么需要私有剪贴板格式
Cherry Studio 的 Composer 基于富文本编辑器(TipTap/ProseMirror)实现,用户输入内容由「文本 + 有序 Token 片段」构成,Token 覆盖技能(skill)、文件(file)、命令(command)、知识库(knowledge)、引用(reference)、引用块(quote)、提示词变量(promptVariable)等类型。当用户把一条包含这些 Token 的消息复制到剪贴板,再粘贴回另一个消息表面或 Composer 时,如果只走系统标准text/plain/text/html,Token 结构就会丢失、退化为普通文本——例如/pdf/技能标记会变成一段纯文本,而无法恢复成可执行的技能 Token。
docs/references/chat/composer-rich-clipboard.md定义的 Composer Rich Clipboard(富剪贴板)正是为了解决这个问题:在标准剪贴板格式之外,额外写入一个带版本号的私有 JSON 片段,完整保留有序的 text/token 分段;粘贴时优先解析私有片段还原 Token,私有片段缺失时则退而求其次,通过纯文本标记(如/skill/、#knowledge#)或直接插入纯文本完成降级。
该机制的目标可归纳为四点(见 composer-rich-clipboard.md):
- 在 Cherry Studio 内部复制粘贴时,保留
skill、file、command、knowledge、reference、quote、promptVariable的 Token 身份; - 通过
text/plain与text/html让剪贴板在 Cherry Studio 之外仍然可用; - 绝不在任何剪贴板载荷中暴露未经净化的 Token JSON、可解析的 Token 元数据或本地文件路径;
- 当剪贴板中没有 Composer Token 片段时,保留原有富 HTML 复制行为(如 Markdown 表格复制)。
剪贴板载荷形状:三路写入与私有片段结构
三种格式的分工
Rich Composer 复制时向系统剪贴板写入三种载荷(见 composer-rich-clipboard.md 的 Clipboard Shape 一节):
| 格式 | 用途 |
|---|---|
text/plain | 人类可读的回退文本,粘贴到任意应用都可用 |
text/html | 人类可读的 HTML,不含可被解析的 Composer Token 元数据 |
web application/x-cherry-composer-fragment+json | Cherry Studio 私有 Token 片段 |
第三个私有格式的 MIME 常量定义在 composerClipboard.ts:COMPOSER_CLIPBOARD_FRAGMENT_MIME = 'web application/x-cherry-composer-fragment+json'。
私有片段的内部结构
私有片段是带版本号的 JSON,version固定为 1(COMPOSER_CLIPBOARD_FRAGMENT_VERSION = 1),segments是有序数组,每个 segment 二选一:
{ type: 'text', text: string }:纯文本段;{ type: 'token', token: {...}, fallbackText: string }:Token 段,fallbackText是粘贴时无法恢复 Token 时的可见回退文本。
Token 的 payload 字段在白名单内(type、ext、name、origin_name、size),文件 Token 还会带一个handle。写入前会对 Token 做一次净化(sanitize),且净化与文件句柄注册只发生一次,即在createComposerClipboardFragment中完成(源码注释明确说明这一点,见 composerClipboard.ts)。写入的片段还受COMPOSER_CLIPBOARD_FRAGMENT_MAX_LENGTH = 250_000长度上限约束,读取时超过上限直接拒绝。
文件 Token:路径永不落盘,句柄只存活于会话内存
文件 Token 是隐私敏感度最高的类型。其写入 payload 只保留展示字段(文件名、扩展名、大小、类型),绝不携带本地路径或由路径派生的 id;可恢复的文件 Token 只携带一个不可猜测的句柄(handle),对应的文件元数据存放在当前渲染进程会话的内存恢复上下文中。
恢复上下文由两部分组成(见 composerClipboard.ts 中的实现):
- 文件恢复句柄注册表
fileRestorationRegistry:一个Map<handle, { sourceId, file, expiresAt }>,句柄用 UUID 生成(createComposerFileTokenSourceId/createComposerSecureRandomId('composer-file'),见 composerFileTokenSource.ts),TTL 为COMPOSER_CLIPBOARD_FILE_HANDLE_TTL_MS = 30 * 60 * 1000(30 分钟),过期条目在注册与解析时惰性清除。 - 会话缓存
sessionCachedRichClipboardWrite:保存最近一次通过异步剪贴板 API 写入的富复制片段,以其纯文本为键,使「粘贴该复制内容」无需读取系统剪贴板即可恢复 Token。
测试用例keeps the file path out of the private fragment while preserving a session restore handle(见 composerClipboard.test.ts)验证了这一点:片段文本中不含/Users/example/private/...路径,也不含providerMetadata,但写入时注册的句柄可在同一会话内还原出完整的FileMetadata。
完整流程:从复制到粘贴
文档给出的流程图可以概括为以下链路(原文 Mermaid 见 composer-rich-clipboard.md 的 Flow 一节):
- 用户复制选中的 Composer 内容或消息部件(message parts);
- 把「可见文本 + Composer Token」投影(project)为有序分段;
- 写入
text/plain、安全的text/html; - 浏览器支持时写入私有 Web 自定义格式(含句柄),并把片段记录进会话缓存;
- 粘贴到
ComposerSurface:- 若粘贴事件数据中带私有片段 → 解析并恢复支持的 Token 与文件句柄;
- 否则,若粘贴文本与会话缓存中的最近富复制匹配 → 从缓存恢复;
- 否则 → 解析纯文本标记(
/skill/、#knowledge#)或直接插入纯文本;
- 更新编辑器内容,并将恢复出的
FileMetadata合并进 Composer 文件列表(去重键为id:path)。
复制侧实现
复制分为两个入口:
消息表面复制:MessageListActions.copyRichContent是富剪贴板写入的共享动作表面。消息组件请求该能力,页面/窗口适配器提供实现。核心动作注册在 messageMenuBarActions.tsx:
registerCommand('message.copy', async ({ actions, mainTextContent, messageParts, setCopied, t }) => { const richContent = actions.copyRichContent ? createComposerRichClipboardContentFromParts(messageParts) : null if (richContent) { const plainText = removeTrailingDoubleSpaces(richContent.plainText.trimStart()) await actions.copyRichContent?.( { ...richContent, plainText }, { successMessage: t('message.copied') } ) } else { await actions.copyText?.(removeTrailingDoubleSpaces(mainTextContent.trimStart()), { successMessage: t('message.copied') }) } setCopied(true) })注意:存在copyRichContent能力时,普通复制路径的text/plain规范化(去尾部双空格、去前导空白)会被应用到富复制内容上,但私有片段保留原始文本,保证粘贴还原无损。
页面/窗口适配器(如 useMessagePlatformActions.ts)的默认实现直接调用writeComposerRichClipboardContent(content)并弹出成功提示。useMessageSelectionController(useMessageSelectionController.ts)则支持多选消息复制时合并生成一个片段。
Composer 编辑器复制/剪切:ComposerSurfaceRuntime的handleComposerCopy(ComposerSurfaceRuntime.tsx)把当前选区序列化为草稿(draft),用createComposerRichClipboardContentFromDraft生成富内容,并同步合并 live Token 的文件路径(mergeLiveFileTokenPayload)。剪切执行同样的富复制后删除选区,因此剪切出的 Token 仍可恢复,而不是退化为默认的「剥离 Token 的剪贴板 HTML」。
片段生成的核心是投影函数:projectTokensOverText按textOffset顺序把文本与 Token 交错切分为分段,Token 的 fallbackText 由getTokenFallbackText按类型决定:
| Token 类型 | fallbackText |
|---|---|
quote | promptText ?? description ?? label |
promptVariable | promptText ?? description ?? label |
skill | /${marker}/(从skill:前缀 id 提取标记) |
knowledge | #${marker}#(从knowledge:前缀 id 提取标记) |
其余(含file、link等) | promptText ?? label |
这解释了为什么应用重启后skill/knowledgeToken 仍可通过纯文本标记恢复——/pdf/与#kb#本身就是可重解析的文本标记。
粘贴侧实现
ComposerSurfaceRuntime的memoizedHandlePaste(ComposerSurfaceRuntime.tsx)中,粘贴优先级如下:
- 若光标位于提示词变量 Token 内,先把粘贴文本写入该变量值;
- 长文本粘贴(超过阈值
LONG_TEXT_PASTE_THRESHOLD = 1500字符)交给文件处理路径; - 优先解析私有片段:
readComposerClipboardFragmentFromDataTransfer(event.clipboardData)(读取粘贴事件数据),失败则readComposerClipboardFragmentFromSessionCache(pastedText)(按纯文本命中会话缓存),两者都失败则继续降级; - 片段解析成功 →
getComposerClipboardPasteOverride生成编辑器内容与ComposerAttachment[],合并文件附件; - 否则走
getComposerPlainTextPasteOverride:识别链接、/skill/、#knowledge#、提示词变量标记,或插入纯文本; - 仍失败则回退到通用
handlePaste。
getComposerClipboardPasteOverride(composerPaste.ts)遍历片段分段:文本段直接建纯文本内容,Token 段调用resolvePrivateClipboardToken按类型解析——skill/knowledge通过当前表面的 resolver 重新解析标记,file通过createComposerAttachmentFromComposerClipboardToken解析句柄,其余类型直接构造 Token。解析失败的分段一律降级为fallbackText纯文本。
同步粘贴设计:永不读取系统剪贴板
粘贴处理完全同步,绝不调用navigator.clipboard.read()。这是文档明确的设计约束,原因在于:
- 通过异步剪贴板 API 写入的私有片段在 paste 事件的
DataTransfer中不可见; - 因此
writeComposerRichClipboardContent会把写出的片段记录进会话缓存;当一次粘贴的纯文本与最近一次富复制完全一致(经过换行符规范化\r\n → \n)时,从缓存恢复,避免读取系统剪贴板。
文档还记录了被否决的替代方案:粘贴时读取系统剪贴板会让每一次外部粘贴都变成异步操作,且会读取无关的剪贴板数据;通过合成复制事件写入需要已废弃的execCommand;对text/html做指纹标记会泄露来源标识。
该设计付出的代价是明确的:quote与promptVariableToken 在应用重启或另一个 Cherry Studio 实例中会失去 Token 身份(因为它们的恢复依赖会话内 nonce 或会话缓存),而skill/knowledgeToken 仍可通过纯文本标记恢复。
写入侧对应实现为writeComposerRichClipboardContent(composerClipboard.ts),其降级策略:
- 浏览器支持
navigator.clipboard且存在ClipboardItem时:- 若
ClipboardItem.supports(私有 MIME)为 true,写入text/plain+text/html+ 私有片段;写入成功后,若片段可被回读解析,则写入会话缓存; - 若自定义格式写入抛错,降级为仅
text/plain+text/html; - 若浏览器不支持私有 MIME,仅写
text/plain+text/html;
- 若
- 连
ClipboardItem都不可用时,降级为navigator.clipboard.writeText(plainText)。
每一次降级都会清空会话缓存(sessionCachedRichClipboardWrite = null置于函数开头),测试clears the session cache when the private format is not supported与clears the session cache when writing the private format fails(composerClipboard.test.ts)分别验证了这两种场景。
恢复规则:每种 Token 的还原策略
文档定义的恢复规则,结合源码可以逐条对应:
| Token 类型 | 恢复策略 | 源码依据 |
|---|---|---|
skill/knowledge | 仅通过当前表面的 resolver 重新解析(保持 Chat 与 Agent 各自的 Token 归属边界) | resolvePrivateClipboardToken中调用resolveSkillMarker/resolveKnowledgeBaseMarker,见 composerPaste.ts |
reference及无恢复规则的类型(如command) | 回退为可见文本(fallbackText) | getComposerClipboardPasteOverride中resolvePrivateClipboardToken返回 null 时插入 fallbackText |
file | 仅当私有载荷带有能在当前会话恢复上下文中解析的句柄时恢复;恢复文件按id:path去重 | createComposerAttachmentFromComposerClipboardToken+mergeComposerClipboardFiles(fileTokenSourceId:path键),见 composerClipboard.ts 与 ComposerSurfaceRuntime.tsx |
quote/promptVariable | 从净化后的 Token 字段恢复(promptText ?? description ?? label) | sanitizeComposerClipboardSegment中的 nonce 校验,见 composerClipboard.ts |
| 不支持 / 不安全 / 无法解析的片段 | 一律回退可见文本 | 同上 |
文件句柄的安全语义
文件句柄不是受信任的剪贴板数据。它只是「定位本渲染进程会话中已有恢复上下文」的索引;缺失、未知、过期、跨窗口、重启后或伪造的句柄,一律回退为可见文本。测试覆盖了多种伪造场景:
does not reuse incoming file handles when writing private fragments:写入时忽略传入的伪造 handle,不为其注册(composerClipboard.test.ts);stops resolving file restoration handles after the handle TTL expires:句柄 30 分钟过期后返回 null(composerClipboard.test.ts);strips forged path payloads from private file fragments read from the clipboard:读取侧剥离伪造的path字段(composerClipboard.test.ts)。
另外两条值得强调的规则:
- 消息文件 Token 的来源匹配:从用户消息复制的文件 Token,只有当消息文件部件携带的
fileTokenSourceId与文本 Token 的源 id 完全一致时才可恢复;文件名、显示名、Token label 一律不作为回退身份。测试does not restore message file tokens by filename when source ids do not match验证了这一点(composerClipboard.test.ts)。 - 路径型 id 永不写入剪贴板:
isComposerFileTokenPathLike会把以file://、/、\、~开头或匹配^[A-Za-z]:[\\/]的 id 判定为不安全(composerFileTokenSource.ts),写入侧降级为 fallbackText 纯文本,读取侧同样拒绝伪造的路径型 id。测试downgrades file tokens with unsafe id系列覆盖了读写两侧(composerClipboard.test.ts)。
会话 nonce:防伪造提示注入
quote、promptVariable(以及folder、link、reference)的promptText会原样从片段恢复。由于任何应用都可以伪造私有 MIME,一个可见的短 label 背后可能隐藏着会在发送时悄悄注入给模型的提示文本。因此这类 Token 只有在片段携带「会话私有 nonce」证明是本渲染进程写入时才被信任,否则降级为可见回退文本。实现见 composerClipboard.ts:
COMPOSER_CLIPBOARD_PROMPT_NONCE_TTL_MS = 30 * 60 * 1000(30 分钟 TTL);registerTrustedPromptFragmentNonce在写入侧为含promptText的这类 Token 生成并记录 nonce,写入 JSON 顶层;isTrustedPromptFragmentNonce在读取侧校验;sanitizeComposerClipboardSegment对无 nonce 且携带promptText的这类 Token 直接降级为 fallbackText。
测试downgrades forged folder tokens without a session nonce to visible fallback text与restores folder tokens carrying a valid session nonce(composerClipboard.test.ts)成对验证了拒绝与放行两种路径。
能力边界与职责划分
模块职责
MessageListActions.copyRichContent:富剪贴板写入的共享动作表面。消息组件请求能力,页面/窗口适配器提供实现(如 homeMessageListAdapter.tsx 层面的能力接线)。composerClipboard.ts:唯一拥有私有片段解析、序列化、HTML 转义、恢复上下文(文件句柄注册表 + 会话缓存)与系统剪贴板写入辅助函数的模块。ComposerSurface:拥有编辑器 copy/cut/paste 事件处理,并把片段解析/投影委托给工具函数;剪切执行与复制相同的富复制后再删除选区。
特性边界
- 操作系统文件粘贴 / 拖放是独立流程,使用浏览器或 Electron 文件 API,不从私有 Composer 片段恢复文件。
- 文件恢复不重新读取文件、不重跑支持扩展名校验;后续发送/文件处理路径仍负责文件可用性。
- 文件部件的
mediaType推断不属于本特性;如需 MIME 归一化,应作为独立改动(文档明确要求保持分离)。 commandToken 默认不具备剪贴板能力:能力表COMPOSER_TOKEN_CAPABILITIES(composerTokenPolicy.ts)中command: { clipboard: false },而skill、link、file、folder、knowledge、reference、quote、promptVariable均为clipboard: true。这与恢复规则中「command属于无恢复规则类型、回退可见文本」的语义一致。
已知取舍
由于同步粘贴不读取系统剪贴板,且会话缓存只保留最近一次富复制:
- 应用重启或另一个 Cherry Studio 实例粘贴
quote/promptVariableToken 会丢失 Token 身份; skill/knowledge因纯文本标记(/x/、#x#)可跨会话恢复;file因句柄 TTL(30 分钟)且绑定当前渲染进程会话,跨窗口、重启后均不可恢复。
聚焦验证:用最小测试集迭代
文档建议在本地迭代时使用聚焦检查而非全量测试套件。原文命令如下(两条命令分别覆盖粘贴解析与复制动作):
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各测试文件的分工:
- composerClipboard.test.ts:核心单元测试,覆盖片段净化(路径不出现在片段中)、句柄 TTL 过期、伪造句柄/路径拒绝、nonce 信任、会话缓存命中与清空、多消息组投影、草稿 Token 投影、
promptVariable序列化、畸形片段拒绝(非法 JSON、版本号不符、未知 kind、超长片段)。 - ComposerSurface.test.tsx 与 composerPaste.test.ts:粘贴行为集成测试,验证私有片段解析、纯文本标记解析与降级路径。
- messageMenuBarActions.test.tsx:验证
message.copy命令在copyRichContent能力存在/缺失时的分支行为。 - useMessagePlatformActions.test.tsx 与 useMessageSelectionController.test.tsx:验证适配器实现与多选复制调用链。
总结
Cherry Studio 的 Composer Rich Clipboard 是一套以「安全优先、标准格式兼容」为核心的私有剪贴板协议:一次富复制同时产出text/plain、text/html与带版本的私有 JSON 片段;粘贴完全同步,通过会话缓存与文件句柄注册表在「不读取系统剪贴板」的前提下恢复 Token 身份;路径与可解析 Token 元数据被严格排除在剪贴板载荷之外,伪造片段通过 nonce 与来源 id 匹配被拦截。理解这份协议,可以帮助你在为消息表面或 Composer 增加新 Token 类型时,同步补齐能力表(composerTokenPolicy.ts)、片段净化与恢复规则,并借助聚焦测试快速验证行为是否符合安全预期。
【免费下载链接】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),仅供参考