dsh-plugin-quote
Verified@walkerxie/dsh-plugin-quote · v0.2.2 · MIT · Web UI
Select text in the DeepSeek Harness Web conversation and append it to the composer as a markdown blockquote.
Install
dsh plugin add @walkerxie/dsh-plugin-quote Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
dsh-plugin-quote
一个 DeepSeek Harness 的 Web 插件。在对话里选中一段文字,点浮现出来的按钮,那段文字就以 Markdown 引用块落进输入框。
我的问题
> 你选中的段落
> 按行保留换行
没有任何新东西进入模型:引用就是普通用户文本,和你手打进去完全一样。
环境要求
- DeepSeek Harness,且使用 Web profile(
dsh web)。headless/sdk/acp没有输入框,插件在那里不起作用。 - 在 dsh
0.1.5-rc.1上构建与验证。Harness 的插件 API 是 pre-stable 的,见兼容性。
安装
这个包是一个 bundle(组合包):package.json 里声明了 dsh.bundle.patch,指向 cordis.patch.yml,由那份 patch 插入插件需要的那一行。所以有两条路,第一条不用手改任何文件。
在桌面版里装。 打开插件管理器的「添加插件」对话框,输入包名:
@walkerxie/dsh-plugin-quote
对话框在安装任何东西之前会先问注册表这个 spec 指向什么,没有组合包声明的包会被直接拒绝(not-a-bundle)——这正是这条路上不需要手写那一行的原因。包名、GitHub 地址、本地目录路径都可以;指向 lib/index.js 的路径不行,因为要读到 manifest,才能拿到 dsh.client 声明和 ./client bundle。
或者自己装,再自己加那一行:
npm install @walkerxie/dsh-plugin-quote
它的 patch 跟着包一起走,所以选中该包的 profile 会自己把那一行捡起来。若想手动选中,在你 Web profile 的 patch 文件(~/.dsh/profiles/web/cordis.patch.yml)里加一行:
- insert:
- id: ui-quote
name: '@walkerxie/dsh-plugin-quote'
若要从源码检出运行——开发插件本身时更方便——构建后把该行指向 host 半边:
git clone https://github.com/Xieweikang123/dsh-plugin-quote.git
cd dsh-plugin-quote
npm install
npm run build
- insert:
- id: ui-quote
name: 'file:///你的绝对路径/dsh-plugin-quote/lib/index.js'
最后这种写法是一条普通的 Loader 行,而不是装在 profile 里的组合包,所以它指向文件而不是目录——这是这里唯一不经过 package.json 的路径。
profile 的 patch 是热重载的,刷新页面即可。如果按钮没出现,重启一次 dsh web,让 Loader 在启动时把新行读进去。
卸载
删掉那段 - insert:(或者给该行加 disabled: true),刷新即可。插件不拥有任何别的东西:没有配置、没有会话数据、没有自己的文件。
使用
- 在消息里选中文字。输入框内部的选区永远不会给出按钮——那是你在编辑自己的草稿。
- 点 引用选中文字(英文
Quote selection)。它出现在整个选中区域的右上方:横向贴着选区自己的右边缘,纵向在选区上方一点(选区贴到消息区顶部时会落到下方)。为什么不贴"最后一行"——见下面的实现要点。 - 选区以引用块形式追加进草稿,控件消失,焦点交给输入框、光标落在草稿末尾。接着打字就行。
按 Escape 可以关闭控件,且不改动你的选区。
控件在屏幕上的时候,Ctrl+Shift+C 会把一份 JSON 读数复制到剪贴板:原始 rect 列表、它们的并集、选区两端点、周边标记、视口,以及控件自己的 rect。它是给"打不开控制台"的页面准备的,描述的是同一时刻——提 bug 时粘这份 JSON,而不是描述一张截图。
实现要点
- 一个条目,没有 host 行为。 插件只注册一个
conversation.input.overlay条目,host 半边是空的apply()。它不添加工具、不加提示词段落、不写会话事件。 - 选区追踪。 一个文档级来源监听
selectionchange、指针与按键手势、滚动与缩放,只在快照确实变化时发布。只有落在对话记录内的选区才可引用。 - 锚点只从选中的「文字」量取,绝不用
Range.getClientRects()的原始列表。 那个方法返回的不是选区的矩形。Blink 从两处拼出来:每个被部分选中的文字节点的行盒,以及每个被 range 完整包含的元素的 border-box。后一种根本不是高亮的一部分,正是它搞坏了定位。选 agent 回复的最后一段就是典型:鼠标只能落到段落下方的空白和操作栏上,于是端点停在某个容器元素上,它后面的所有兄弟节点都被"完整包含"。这时 range 会报告操作栏上 28×28 的按钮盒和一整列宽的块盒——实测把锚点的 bottom 从 548 顶到 599,按钮于是翻到选区上方。修法是遍历与 range 相交的文字节点(TreeWalker+range.intersectsNode),逐个量它自己那一段,这也是选区工具条的既有做法:元素 border-box 从源头就进不来。落在控件内的矩形(button、[role=button]、表单元素)会被跳过,因为拖进操作栏时端点会落进控件的文字标签——「用量 423K tok」就是个文字节点。更早的版本挂在"最后一行"上,而最后一行无法从 rect 列表里可靠地还原:在 Web shell 里实测的一次选中,7 个 rect 按文档顺序返回,最宽的那个(185px,右边缘 x=1078)出现在最上面,而视觉上最低的一条线被切成 28px、28px、15px 三个碎片。取"最后一个 rect"会锚到一个碎片、控件落到段落中间。文字 rect 的并集没有顺序可猜错,而且对一行、折行段落、跨多个块、带行内 chip 都精确成立。 - 定位默认在选区上方,且限制在消息区内。 选区上方那一行是你已经读过的,下方那一行是你正在读的——而控件会盖住它所在的那一侧。上方还能避开你刚选中的文字:拖选结束时鼠标停在选区底边,放下方正好压在你盯着的那一行上。上方放不下时改放下方。两个边界都重要:原来的做法夹到视口底边,控件停在输入框上方、离它引用的文字十万八千里;夹到
innerWidth又会让它贴到窗口最右边。现在的区域是对话记录自己的盒子,并在输入框卡片的上边缘截断。 - 锚点跟随布局,而不只是选区。 一个
ResizeObserver盯着对话记录和输入框卡片:草稿换行、面板缩放、虚拟列表回收行——这三件事都不触发选区事件。拿着旧 rect 定位,正是"选中最后一段后按钮压在输入框上"的成因。 - 坐标系由插件自己拥有。 控件渲染进一个由插件创建的零尺寸宿主,它是
<html>的直接子节点,并用position: fixed钉在视口原点上。其中的按钮是position: absolute,因此它的坐标就是真正的视口坐标——与getBoundingClientRect()报告选区时所用的空间一致。宿主之上的任何东西都无法重新解释这些坐标:祖先上的position、transform、filter、perspective、contain都会改变fixed的解析基准,而文档元素没有祖先。宿主在首次渲染时就创建(而不是在 effect 里),所以第一帧就已经落在正确的空间里。 - 写入草稿。 控件调用 composer 自己的公开
inputActions.setDraft()。它自己不读取也不持有任何状态——草稿仍然存在它原本该在的地方。 - 焦点交接。
setDraft会把编辑器的选区留在草稿末尾,但不移动 DOM 焦点,所以控件自己聚焦 composer 的编辑器,并把光标收拢到末尾。要聚焦的卡片取自控件自己的 slot 座位,而不是在文档里搜出来的:按钮现在不在卡片里了,否则一条已结束消息的编辑器会先被找到。 - 对比度固定,不跟随主题。 控件的底色与文字硬编码,而不是取自主题令牌。它浮在并不属于它的对话文字之上,必须在其背后的任何内容上都保持可读;跟随主题的一对颜色会随宿主主题翻转,可能落成浅底浅字或深底深字。底色用的是宿主自己的近黑(
#1b1b1c,即它的neutral-bluish-900),让这个芯片属于产品自己的色板;引号图标用品牌蓝;描边是固定的白色发丝边而不是主题令牌——在深色对话记录上,这圈边才是它和背景的分界。阴影同理固定。
兼容性
DeepSeek Harness 的插件 API 是 pre-stable 的:下面这些东西都不是兼容性承诺,Harness 的一次升级就可能让它静默失效——引用照落,或者按钮干脆不再出现。
本插件依赖:
| 依赖对象 | 性质 |
|---|---|
由 @deepseek-ai/dsh-client-ui-conversation 声明的 conversation.input.overlay slot |
slot 名称 |
每个 session 作用域 slot 都会收到的 useInput 与 inputActions |
框架契约 |
ctx.slots 与 ctx.locale 两个服务 |
框架契约 |
[data-conversation-scroll]、[data-composer-card]、[data-composer-input] 三个 DOM 标记 |
不是契约——内部标记 |
react-dom 的 createPortal 与一个可 portal 的文档根 |
框架契约 |
前三项失效是响的;DOM 标记失效是静默的,而焦点交接是一处记录在案的工作区绕行做法,不是 API。
已知限制
- 控件会盖住相邻的那一行(默认是上方那行,上方放不下时是下方那行),所以按钮会压在已经显示的内容上。另一个选择——预留布局空间——会让对话记录在每次选区变化时重排。
- 只能追加。 composer 的公开 API 提供整段替换,没有"在光标处插入"的动词,所以引用总是落在草稿末尾。
- 仅纯文本。 引用带围栏的代码块会丢掉围栏,引用表格会丢掉单元格分隔,因为浏览器返回的是渲染后的文字。
- 只覆盖对话记录。 trajectory 与 waterfall 视图是另外的界面,其中的选区不提供任何操作。
- 只有一个动作。 这是一个引用按钮,不是选区工具栏。复制、搜索与批注属于拥有那些决策的界面。
开发
npm install
npm test # 单元测试 + 真实 Cordis 上下文的注册测试
npm run typecheck
npm run build # lib/index.js(host 半边)+ lib/client.js(浏览器半边)
npm run sync # 构建,然后把包复制进已安装的 Web profile
npm run sync 是为了绕开一个坑:profile 的 cordis.patch.yml 写的是包名,所以 Web shell 加载的是 profile node_modules 里的 lib/client.js,不是这个 checkout 里的。只跑 npm run build 页面看不到任何变化。npm run sync 把构建产物复制过去(默认 ~/.dsh/profiles/web,可用 $DSH_PROFILE_DIR 或参数覆盖)。
它连 manifest 和 cordis.patch.yml 一起复制,因为装进去的那份也必须是组合包——只有 manifest 而没有 dsh.bundle.patch 声明,会把这个已安装的包降级成普通依赖,它的 dsh.client 声明就永远不会被扫描到。
lib/client.js 不是普通 bundle:Web shell 通过闭包工厂契约挂载插件,所以构建会把浏览器半边包成 window.__ModuleLoader__.load({ id, factory }),并让 React 等平台模块走注入的 require 而不是内联进来。整个构建就是 scripts/build.mjs,前面挂了一个很小的 CSS Modules 编译器。
CSS Modules 编译器
那个编译器里有两个细节是承重的,而且出错时都是静默的:
- 类名映射必须是纯字符串。 lightningcss 把每个导出报成
{ name, composes, isReferenced },把这个对象交给className会渲染出[object Object],没有任何选择器能匹配它——整个文件的规则全部失效。构建负责把记录压平成名字。 - 样式标签以内容哈希为键。 HMR 会在活着的页面里重新执行模块工厂,所以只以路径为键会命中上一次的标签、跳过注入新样式。
tests/build.client.spec.ts 针对构建产物断言这两点,因为它们在源码层面都看不出来。
真机探针
选区几何是真实布局的事实,jsdom 不建模,所以有意思的 bug 只存在于浏览器里。tools/ 下有三个探针,都通过 CDP 驱动真实 Chrome 打到运行中的应用(http://127.0.0.1:3080/),把 JSON 写到仓库根目录(已 git-ignore):
probe-real-drag.mjs—— 用真实鼠标拖拽(Input.dispatchMouseEvent)划过 agent 回复的最后一段,打印 range 的原始 rect、文字 rect、以及控件自己的 rect。这是唯一能复现该 bug 的探针:程序化setStart/setEnd永远不会产生真实拖拽才会有的元素 border-box。dump-live-page.mjs—— 遍历真实会话里每个可选文字节点,比对控件位置与定位公式,标出任何不一致。用来做跨大量选区的回归检查。probe-quote-geometry.mjs—— 自建合成对话记录并加载真实 bundle,用于隔离测量控件。
docs/debugging-notes.md 记录了这些探针是为了找什么。一句话版:Range.getClientRects() 不是"选区的矩形"——它还会报告每个被 range 完整包含的元素的 border-box,而"拖过段落末尾"恰好产生这种 range,定位问题因此拖了很久。
许可证
MIT