Cherry Studio Composer Rich Clipboard 深度解析:消息表面与编辑器之间的 Token 无损复制粘贴机制
2026/9/13 12:17:22 网站建设 项目流程

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:它如何在复制/粘贴时保留skillfilecommandknowledgereferencequotepromptVariable等 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 内部复制粘贴时,保留skillfilecommandknowledgereferencequotepromptVariable的 Token 身份;
  • 通过text/plaintext/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+jsonCherry 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 字段在白名单内(typeextnameorigin_namesize),文件 Token 还会带一个handle。写入前会对 Token 做一次净化(sanitize),且净化与文件句柄注册只发生一次,即在createComposerClipboardFragment中完成(源码注释明确说明这一点,见 composerClipboard.ts)。写入的片段还受COMPOSER_CLIPBOARD_FRAGMENT_MAX_LENGTH = 250_000长度上限约束,读取时超过上限直接拒绝。

文件 Token:路径永不落盘,句柄只存活于会话内存

文件 Token 是隐私敏感度最高的类型。其写入 payload 只保留展示字段(文件名、扩展名、大小、类型),绝不携带本地路径或由路径派生的 id;可恢复的文件 Token 只携带一个不可猜测的句柄(handle),对应的文件元数据存放在当前渲染进程会话的内存恢复上下文中。

恢复上下文由两部分组成(见 composerClipboard.ts 中的实现):

  1. 文件恢复句柄注册表fileRestorationRegistry:一个Map<handle, { sourceId, file, expiresAt }>,句柄用 UUID 生成(createComposerFileTokenSourceId/createComposerSecureRandomId('composer-file'),见 composerFileTokenSource.ts),TTL 为COMPOSER_CLIPBOARD_FILE_HANDLE_TTL_MS = 30 * 60 * 1000(30 分钟),过期条目在注册与解析时惰性清除。
  2. 会话缓存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 一节):

  1. 用户复制选中的 Composer 内容或消息部件(message parts);
  2. 把「可见文本 + Composer Token」投影(project)为有序分段;
  3. 写入text/plain、安全的text/html
  4. 浏览器支持时写入私有 Web 自定义格式(含句柄),并把片段记录进会话缓存;
  5. 粘贴到ComposerSurface
    • 若粘贴事件数据中带私有片段 → 解析并恢复支持的 Token 与文件句柄;
    • 否则,若粘贴文本与会话缓存中的最近富复制匹配 → 从缓存恢复;
    • 否则 → 解析纯文本标记(/skill/#knowledge#)或直接插入纯文本;
  6. 更新编辑器内容,并将恢复出的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 编辑器复制/剪切ComposerSurfaceRuntimehandleComposerCopy(ComposerSurfaceRuntime.tsx)把当前选区序列化为草稿(draft),用createComposerRichClipboardContentFromDraft生成富内容,并同步合并 live Token 的文件路径(mergeLiveFileTokenPayload)。剪切执行同样的富复制后删除选区,因此剪切出的 Token 仍可恢复,而不是退化为默认的「剥离 Token 的剪贴板 HTML」。

片段生成的核心是投影函数:projectTokensOverTexttextOffset顺序把文本与 Token 交错切分为分段,Token 的 fallbackText 由getTokenFallbackText按类型决定:

Token 类型fallbackText
quotepromptText ?? description ?? label
promptVariablepromptText ?? description ?? label
skill/${marker}/(从skill:前缀 id 提取标记)
knowledge#${marker}#(从knowledge:前缀 id 提取标记)
其余(含filelink等)promptText ?? label

这解释了为什么应用重启后skill/knowledgeToken 仍可通过纯文本标记恢复——/pdf/#kb#本身就是可重解析的文本标记。

粘贴侧实现

ComposerSurfaceRuntimememoizedHandlePaste(ComposerSurfaceRuntime.tsx)中,粘贴优先级如下:

  1. 若光标位于提示词变量 Token 内,先把粘贴文本写入该变量值;
  2. 长文本粘贴(超过阈值LONG_TEXT_PASTE_THRESHOLD = 1500字符)交给文件处理路径;
  3. 优先解析私有片段:readComposerClipboardFragmentFromDataTransfer(event.clipboardData)(读取粘贴事件数据),失败则readComposerClipboardFragmentFromSessionCache(pastedText)(按纯文本命中会话缓存),两者都失败则继续降级;
  4. 片段解析成功 →getComposerClipboardPasteOverride生成编辑器内容与ComposerAttachment[],合并文件附件;
  5. 否则走getComposerPlainTextPasteOverride:识别链接、/skill/#knowledge#、提示词变量标记,或插入纯文本;
  6. 仍失败则回退到通用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做指纹标记会泄露来源标识。

该设计付出的代价是明确的:quotepromptVariableToken 在应用重启或另一个 Cherry Studio 实例中会失去 Token 身份(因为它们的恢复依赖会话内 nonce 或会话缓存),而skill/knowledgeToken 仍可通过纯文本标记恢复。

写入侧对应实现为writeComposerRichClipboardContent(composerClipboard.ts),其降级策略:

  1. 浏览器支持navigator.clipboard且存在ClipboardItem时:
    • ClipboardItem.supports(私有 MIME)为 true,写入text/plain+text/html+ 私有片段;写入成功后,若片段可被回读解析,则写入会话缓存;
    • 若自定义格式写入抛错,降级为仅text/plain+text/html
    • 若浏览器不支持私有 MIME,仅写text/plain+text/html
  2. ClipboardItem都不可用时,降级为navigator.clipboard.writeText(plainText)

每一次降级都会清空会话缓存sessionCachedRichClipboardWrite = null置于函数开头),测试clears the session cache when the private format is not supportedclears 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)getComposerClipboardPasteOverrideresolvePrivateClipboardToken返回 null 时插入 fallbackText
file仅当私有载荷带有能在当前会话恢复上下文中解析的句柄时恢复;恢复文件按id:path去重createComposerAttachmentFromComposerClipboardToken+mergeComposerClipboardFilesfileTokenSourceId:path键),见 composerClipboard.ts 与 ComposerSurfaceRuntime.tsx
quote/promptVariable从净化后的 Token 字段恢复(promptText ?? description ?? labelsanitizeComposerClipboardSegment中的 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:防伪造提示注入

quotepromptVariable(以及folderlinkreference)的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 textrestores 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 },而skilllinkfilefolderknowledgereferencequotepromptVariable均为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/plaintext/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),仅供参考

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

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

立即咨询