dsh-quick-reply · 引用回复
给 DeepSeek Harness(下称 DSH)Web 界面用的一个小插件,两个入口,都只做「引用」:
- 选中屏幕上任何一段文字(助手的回答、左侧会话列表里的会话标题、右侧面板里的内容),鼠标旁边会冒出一张「引用回复」小卡片。点一下,就把这段文字变成 Markdown 引用块填进草稿,不自动发送。
- 在左侧会话列表里把鼠标移到某个会话行上,行末会出现一个引号按钮。点一下,切到那个会话,并把它最后一条已经说完的回答引用进草稿。
没有悬浮球、没有快捷键冲突。
当前版本 1.3.1,更新记录见 CHANGELOG.md。
它解决什么问题
长对话里想追问某一句时,常见做法是把那句话复制下来、粘进输入框、手动加 >、再补出处。四步,还容易粘错。
这个插件把它压成一步:选中 → 点「引用回复」→ 草稿里出现
> 你选中的那两行文字…(略) (引用自第7轮回答)
光标已经落在引用块下面那一行,直接写问题就行。
行为细节
- 选中范围:助手消息里只认「起点和终点都在同一条助手消息正文里」的选区——选中你自己的提问、工具调用卡片、思考过程、系统提示,按钮都不会出现,避免引错东西。聊天区以外(左侧会话列表、右侧面板)选中的文字也可以引用,那里没有「第几轮」的概念,所以只写
> 原文,不带出处标注。输入框里选中自己打的草稿不算引用。
- 侧栏文字本来选不中:宿主给会话行设了
user-select:none,插件把它放开成 user-select:text,才能拖选会话标题。单击行为不变(浏览器只有拖过一小段距离才算选中),行末的悬停按钮也不受影响。
- 卡片贴着鼠标出现:位置取鼠标松开的那一点(选区焦点,正拖反拖都对),卡片右缘与字尾齐平、缩在文字下方,视线不用跳。下边放不下就翻到字的上面;贴到窗口边缘时会夹进窗口,不会被切掉。滚动聊天或缩放窗口时重新定位,跟着文字走。
- 引用会自动截断:默认只留前 2 行 / 最多 120 字,超出部分用
…(略) 标记。原因是一整段回答塞进草稿会把输入框撑爆,也不方便接着写。原文仍然高亮着,想看全文回消息里看。
- 带上出处:引用块最后一行的末尾跟一个
(引用自第N轮回答),同一行,不另起一段。模型看到的只是纯文本、没有消息 ID,同一句话在历史里出现两次时它会分不清——轮次号给它一个可核对的坐标。这个 N 直接读宿主写在消息外层 div 上的 data-chat-turn,和界面右侧「轮次导航」显示的第几轮是同一个数字。在代码 / diff 面板里选中时没有「第几轮」,改成写 (引用自第79行)——行号是拿选区左边那一列数出来的,数不出来就干脆不写,不编假的。
- 光标落在引用块下一行:填完草稿后光标自动停在引用块下面的空行,直接写问题就行,不用再点一下。
- 原文高亮:用 CSS Custom Highlight API 标记选中的原文。不改宿主 DOM,所以不会和 React 的重渲染打架;浏览器不支持时静默跳过,功能不受影响,只是少了视觉提示。
- 引用过高亮一直留着:引用完那一段会「钉住」,之后你选别处、点别处、继续聊天都还亮着,方便回看自己引过哪些段。一个 Highlight 里装了多个 Range,所以「正在选的」和「已经引过的」可以同时亮。宿主重渲染后断开的 Range 会自动剔除;引用超过 50 段时丢掉最早的。页面刷新就清空。
- 把引用删掉,高亮跟着消失:判断依据是「这段字还在不在输入框里」,不是「你有没有按删除键」——整段删、撤销、发送后清空,都一样处理。逐字比较会把改标点、加空格误判成删除,所以只比字(汉字、字母、数字),标点空白忽略。
- 只填草稿,绝不发送:发不发、发什么,由你按回车决定。
- 保留已有草稿:如果输入框里已经写了东西,引用的内容会接在后面,前面空一行。
侧栏引用(会话列表里那个引号按钮)
- 引什么:那个会话最后一条已经说完的助手回答(跳过还在输出中的那一步,也跳过只有工具调用的步骤)。会话还没被回答过时,按钮点了不会有反应。
- 先切过去:点按钮会先打开那个会话,等它的历史加载完、最后一条回答出现,再把引用填进它的草稿。所以你会看到会话切换 + 草稿里出现引用块。
- 出处照旧:同样带
(引用自第N轮回答),光标同样落在引用块下一行。
- 为什么是「会话内引用」而不是跨会话引用:跨会话把别的会话的回答引到当前会话,需要插件去持有并读取另一个会话的历史,代价和出错面都大得多;而「点开这个会话、引用它自己的最后一条」用的还是同一条「当前会话」路径,简单可靠。这是刻意的取舍,不是没做完。
安装
前提
- DSH 桌面端(Electron)或
dsh web,且 Web 界面能正常打开。
- 插件通过 DSH 的插件管理器以本地包方式安装,不需要联网。
方式一:插件管理器(推荐)
在 DSH 里让 agent 执行:
plugin_manager install_bundle target = <本仓库的绝对路径>
装完后 重启 DSH 应用(宿主进程的模块缓存按 URL 记,重载不会重新求值),再刷新网页。
方式二:手动挂到 profile
把本目录放到任意位置,然后在 profile 的 package.json 里加一条依赖:
{
"dependencies": {
"@local/quick-reply": "link:<本仓库的绝对路径>"
}
}
并把 @local/quick-reply 追加到 bundles 列表末尾,重启 DSH。
验证装好了没有
刷新页面后:
- 选中任意一条助手回答里的文字。鼠标旁边出现「已选中第 N 轮的文字 · 引用回复」=选区那条装好了。
- 拖选左侧会话列表里的会话标题。卡片上写「已选中文字」(没有轮次号)=侧栏选区也装好了。
- 把鼠标移到左侧会话列表的任意一行上,行末多出一个引号按钮 = 侧栏那条装好了。
没出现的话按顺序查:
- 打开控制台,看有没有
@local/quick-reply 相关的加载错误;
- 在 DSH 里查插件列表,确认它是启用状态;
- 确认选中的确实是助手消息正文(不是工具输出、不是思考过程、不是你自己的提问);
- 侧栏按钮不出现时,确认那个会话行是不是被归档/空白会话(宿主对这类行不渲染动作按钮)。
卸载
插件管理器里禁用/移除,或从 profile 的 package.json 里删掉依赖与 bundles 条目,然后重启 DSH。
技术说明
挂在哪
用三个宿主官方槽位:
shell.overlay —— 窗口最上层、不随聊天滚动的一块浮层。卡片本体挂这里,位置由选区坐标算出来(position: fixed)。
conversation.input.dock —— 输入框卡片上方那一整行。这里只挂一个「什么都不渲染」的桥组件,用来取输入框的 inputActions、当前草稿、以及当前会话的聊天快照。
sidebar.workspaces.session.row.action —— 会话列表每行末尾的悬停按钮位(宿主自己的「归档」「置顶」也在这一排)。这里挂侧栏那个引号按钮。
为什么要分三处:shell.overlay 和侧栏行按钮都是 root 作用域,拿不到输入框的 inputActions,也读不到聊天快照;只有 conversation.input.dock 两样都拿得到。所以那两处只负责「告诉桥要干什么」,真正的取文字和填草稿都在桥里完成。
侧栏按钮为什么要切会话:桥是跟着「当前会话」挂载的,它只能读当前会话的聊天快照。所以点侧栏按钮先切过去,等新会话的历史加载完,桥自然就能读到那条回答了。好处是不需要跨会话 retain 别人的历史,坏处是用户会看到一次会话切换——这是刻意的取舍。
文件结构
QuickReply/
├── package.json # 包元数据 + dsh.client 声明(平台、注入依赖)
├── cordis.patch.yml # Cordis 加载补丁:insert 一个 id=quick-reply 的插件
├── index.js # Host 半边(空实现;本插件纯客户端,留入口给以后扩展)
├── client.js # 浏览器半边:全部逻辑都在这里
├── locale/zh.json # 插件列表里的标题与描述
├── locale/en.json
├── icon.svg
└── test/helpers.test.mjs # 纯函数单测(不启浏览器)
关键实现点
- 宿主插槽 props:dock 条目能拿到
inputActions(setDraft / submit 等)与当前草稿,所以填草稿走的是宿主自己的草稿接口,不是去模拟键盘。
- 为什么要缓存选区:点击按钮的
mousedown 会把页面选区清空,所以插件在 selectionchange 里先把「选中了哪段、属于第几轮、在屏幕哪个位置」记下来(用 requestAnimationFrame 合帧,拖选时不会疯狂重渲染),点按钮时用的是缓存。
- 怎么判断「这段文字能不能引」:看选区的起点和终点落在哪个容器里。两端都在同一条
[data-chat-flow-kind="assistant-step"] 里 → 能引,带轮次号;两端都不在聊天流里(侧栏、右侧面板)→ 也能引,但没轮次号;一头在里一头在外 → 跨区选择,不认。另外两端都在 [data-composer-card] 或 contenteditable 里(= 用户在选自己打的草稿)→ 不认。
- 位置在渲染后用
useLayoutEffect 算:卡片宽度要先量出来才能决定水平对齐(右缘贴字尾);layoutEffect 在浏览器绘制前跑完,用户看不到跳一下。位置没算好前先放 left:-9999px,避免在左上角闪一下。
- 锚点取选区焦点而不是
range 末尾:选区分方向,从下往上拖选时 range 的终点在文档里是上面那行。取 selection.focusNode / focusOffset 处收一个零宽 Range,才是鼠标松开的那一点。
- 按钮上
onMouseDown: preventDefault():同样是为了保住选区,避免卡片在按下的瞬间自己消失。
- 填完把光标送到末尾:
focus() 的默认落点不保证在末尾,所以用 Selection API 把 range 收拢到 contenteditable 的末尾。
- 样式自己插
<style> 标签:装进 profile 的普通插件拿不到动态插件沙箱里的 styles 服务,宿主自带的客户端插件也是自己插 style 标签的。颜色一律用 --dsw-alias-* 设计令牌,跟随主题。
- 侧栏按钮和桥之间靠一个小 store 通知:两者不在同一棵 React 树里,侧栏按钮只能把「要引用哪个会话」写进模块级的
pendingQuoteStore;桥订阅它,被通知后重渲染、等目标会话的最后一条回答出现再填草稿。用普通变量的话桥的依赖项没变、effect 不会重跑,点了就像没反应。
- 挑「最后一条回答」时跳过
status === 'running' 的步骤:那一步还在往外吐字,引一半会被截断,模型看到的是半句话。
useChat 缺失时用空壳 hook 顶上:React 要求每次渲染 hook 数量一致,props.useChat ? props.useChat(x) : null 会让两边 hook 数不同、整棵组件炸掉,所以空壳也要调一次 useSyncExternalStore。
- 不用
require 引 DSH 客户端包:插件只依赖宿主注入的 ctx、React,符合宿主对插件的要求。
开发
# 纯函数单测(不需要浏览器)
node test/helpers.test.mjs
# 语法检查
node --check client.js
node --check index.js
test/helpers.test.mjs 会用标记 // >>>PURE-HELPERS 从 client.js 里把纯函数抠出来跑,所以那几个函数里不能出现 DOM / React 调用。
已知限制
| 限制 | 说明 |
|---|
| 只引用助手正文 | 选中你自己的提问、工具输出、思考过程不会出现按钮。这是有意的。 |
| 侧栏只引「最后一条已结束的回答」 | 不提供「选第几条」的入口;想引中间某条,打开那个会话手动选中即可。 |
| 侧栏引用会切会话 | 点按钮会先打开那个会话。这是为了复用「当前会话」路径,避免跨会话读历史。 |
| 引用永远截断 | 目前没有「引用全文」入口。需要整段时手动选中复制。 |
| 依赖宿主 DOM 语义属性 | 选区判定依赖 [data-chat-flow-kind="assistant-step"] 和 [data-chat-turn]。宿主改版时插件最多失效(按钮不出现),不会弄坏聊天。 |
| 位置由 JS 算 | 卡片用 position: fixed 跟随选区坐标,不是宿主分配的布局空间。滚动/缩放时会重算,但如果宿主在浮层里加了 transform 容器,定位可能偏移。 |
| 没有配置项 | 截断行数/字数写死在 client.js 顶部(QUOTE_MAX_LINES / QUOTE_MAX_CHARS),改完需重新加载插件。 |
| 仅 Web 界面 | 宿主声明 platform: "web"。 |
许可
MIT