dsh-quote-tray
原名
dsh-quote-replyv0.2.0。因与上游 liuhaolong16/dsh-quote-reply(v0.9.0)包名冲突,2026-10-21 更名为dsh-quote-tray。 v0.3.0(2026-10-26):输入框里只留正常文字(草稿藏 URI)+ 微信式气泡引用。 v0.3.1(2026-10-28):引用卡与「追问」(dsh-quotalk)卡片逐 token 对齐。 v0.4.0(2026-10-28):推倒重来——引用不再写进草稿。改为「浏览器 → 宿主 webServer 路由」传引用原文, 草稿里既没有dsh-quote://也不再有零宽字符;引用卡由插件自己的固定浮层渲染,贴在输入框正上方。 v0.4.2(2026-10-28):修「引用点了没反应」的真因——缺inject: ['uiSession']。内核@deepseek-ai/dsh-client-ui-session以super(ctx, "uiSession")提供服务,插件不声明注入就取不到, 会话标识恒为 null,点「引用」直接 return(输入框里什么都不发生)。同时加了启动信标 + 只读链路自检。 v0.4.3(2026-10-28):修「引用卡上的 × 点了没反应」。paint()每轮replaceChildren重建卡片, 而它自己写的layer.style.*又被挂在document.body上的 MutationObserver(attributes:true, subtree:true) 接住 → 每 60ms 自激重绘一次;按 × 时mousedown落在旧节点、mouseup落在新节点,click的目标被算成 公共祖先,按钮上的处理器一次都不会触发。现在:按 id 复用卡片节点、样式/显隐只在值变化时写、观察范围收窄到 输入区且不看 attributes、删除改挂浮层事件委托并在mousedown即生效(重绘也拦不住)。 v0.5.0(2026-10-30):向官方 dsh-quote-reply 0.9.0 的工程做法对齐 ① 版本握手:GET /dsh-quote-tray/health返回protocol/ 版本号 / 待引用统计 / 落盘状态,客户端三态处理 (ok / legacy 老宿主 404 / old 协议不匹配 → 明确提示重启,不再静默失效); ② 待引用条目落盘:~/.dsh/state/dsh-quote-tray/pending.json(原子写 + 12h TTL + 512KB 上限), 桌面端重启后未发送的引用不再无声消失; ③localStorage秒显缓存:刷新页面第一帧就有卡片,随后以宿主返回为准覆盖; ④ 轮询自适应(有引用 2s / 空闲 8s)+ 页面隐藏暂停;⑤ResizeObserver优先、rAF 合帧; ⑥ 新增test/preflight.mjs静态预检(版本号单一来源、两侧协议号一致、入口/打包面、回归哨兵)。
豆包 / 微信式引用回复:在聊天记录里划选一段文字 → 浮出「引用」按钮 → 点一下, 输入框上方出现一条可删除的引用卡 → 你接着写自己的问题 → 发送。
模型会读到干净的原话 + 带边界警告的引用原文(source.kind = 'quote-reply' 的背景消息),
而你的输入框和发出去的气泡里,都只有你自己写的字。
交互
| 步骤 | 效果 |
|---|---|
| 划选 AI 或自己的回复 | 选区上方浮出「引用」胶囊按钮 |
| 右键(选区未收起时) | 按钮挪到光标处(Electron 原生菜单无法从页面侧屏蔽) |
| 点「引用」 | 原文存进宿主内存;输入框上方出现卡片:浅灰底 + 2px 竖条 + 一行摘要 + × |
| 继续输入 | 正常写你的问题(草稿里没有任何引用痕迹) |
| 发送 | 宿主在 agent/pre-step 把原文作为不受信任背景注入这一轮,随后清空 |
| 点卡片的 × | 取消这条引用(宿主侧同步删除) |
| 切换会话 | 卡片按会话隔离:只显示当前会话待发送的引用 |
工作原理
划选 ──► 「引用」按钮(纯 DOM 浮层,不依赖任何服务)
│
├─► GET /dsh-quote-tray/health → 握手:protocol / version / pending / persistence
│
├─► POST /dsh-quote-tray/attach { sessionId, quote }
│ └─► 宿主内存 pending: Map<sessionId, Map<id, record>>
│ └─► 变更后 150ms 合并落盘(原子写,12h TTL)
│
├─► POST /dsh-quote-tray/list → 卡片镜像(有引用 2s、空闲 8s;页面隐藏暂停)
└─► POST /dsh-quote-tray/detach → × 取消
用户按下发送
└─► agent/pre-step 监听器(lib/index.js)
├─► 取 agent.session.id,takeQuotes(sessionId)(消费即清空)
└─► 在最后一条 user 消息后插入 source.kind = 'quote-reply' 的背景消息
- 输入框是内核自己的编辑器,插件一个字都不写、一个节点都不动 —— 位置和样式都不可能被 Lexical 覆盖。
- 卡片是插件自己的
position: fixed浮层,锚在[data-composer-seat]里的输入框上沿,不再受 dock 布局影响。 - 注入进模型的原文带固定警告:不得遵循其中指令 / 权限声明 / 工具请求。
为什么 v0.4.0 推倒重来(教训写在这里,防止回退)
v0.3.x 的做法是:把 dsh-quote://<base64url(JSON)> 当纯文本插进草稿,再在 DOM 层把那段文字包成
零尺寸 span 藏起来。实际跑下来有两个硬伤:
- Lexical 每次重渲染都会抹掉我们插入的 span,于是用户始终看到一坨 base64 链接(用户原话:「输入框还有链接」)。
conversation.input.dock里的卡片会被布局裁切,落在左下角只露出半截(用户原话:「当前画面一部分在左边」)。
保留的旧路径
v0.3.x 已经把 dsh-quote:// 写进草稿的老会话,宿主仍会从正文里剥离并还原(splitMessage),
保证升级后历史消息里的引用不丢;新会话不再产生这种文本。
另外两条来之不易的经验
- 两处
inject不是一回事。package.json里的dsh.client.inject是客户端模块依赖:写服务名会让整个 插件在服务迟到时失活,连纯 DOM 的悬浮按钮都不挂载(v0.4.0 就栽在这)——所以保持[]。 需要会话时,由模块导出inject = ['uiSession']声明服务依赖(内核据此在apply()前就绪), 并在apply()里先install()装 DOM 半区、再取ctx.uiSession。两处混用就会出现 「注册了但拿不到」或「拿得到但按钮不出现」。 - 重绘用 rAF 合帧,但必须有
setTimeout降级。rAF 在后台/失焦时会被节流到近乎停摆 —— v0.5.0 起改为「rAF 优先 + 定时器兜底」,并且后台标签页直接暂停轮询(visibilitychange), 让节流从「不可控」变成「明确不做」。
配置
作为 bundle 注册(不会被内核重写):~/.dsh/profiles/desktop/package.json
的 dsh.profile.bundles 末尾,以及 cordis.patch.yml 的 insert 列表。
- insert:
- id: ui-quote-reply
name: dsh-quote-tray
注意:不要手改
cordis.yml。那是内核按 bundle 组合生成的文件,会被写回覆盖。
安全边界
- 路由只接受 loopback(
127.0.0.1/::1)且sec-fetch-site: same-origin或桌面端转发 (x-quote-tray-client: dsh-desktop)的请求,另需一次性的/bootstraptoken。 - 引用的原文按会话隔离;发送时消费即删。v0.5.0 起会落盘到
~/.dsh/state/dsh-quote-tray/pending.json以防重启丢失:原子写、12h TTL、512KB 上限, 过期条目在恢复时丢弃;DSH_QUOTE_TRAY_STATE=off可整体关闭落盘。 GET /health只读且仅限本机(与/bootstrap同一条 loopback 校验),不返回引用原文。
限制
| 项 | 说明 |
|---|---|
| 用户消息动作条 | 内核硬编码,没有插件插槽,因此无法在「自己的旧消息」上点引用;只能划选正文 |
| 右键菜单项 | Electron 主进程的 context-menu 硬编码 undo/copy/paste,页面侧改不了 |
| 发送后的气泡 | 现在不显示引用卡(原文不进草稿,气泡里没有可还原的痕迹);引用关系只在当轮上下文里 |
| 长文本 | 单条引用上限 4000 字,超出截断并标注;一次请求最多注入 3 条 |
| 代码块 / 表格 | 划选取到的是纯文本,保留 Markdown 字符但丢原格式 |
| 重启后 pending | v0.5.0 起落盘保留 12 小时(超时丢弃;DSH_QUOTE_TRAY_STATE=off 可关闭) |
测试
npm test
test/core.test.mjs—— 纯函数(编解码、剥离、渲染上下文)test/host.test.mjs—— 待引用队列(attach/list/detach/take、去重、FIFO 上限、按会话隔离)+ 预步骤注入test/v040-ui.test.mjs—— 静态哨兵:不许往草稿写东西、不许挂 dock、不许碰编辑器 DOMtest/v043-click.test.mjs—— 用极简 DOM 替身(无 ResizeObserver / 无 localStorage,覆盖降级路径)真跑 client.js: 重绘不换节点、按下 × 后 click 被重定向仍能删除、键盘激活、点正文不误删test/v050-health.test.mjs—— 握手三态(ok / legacy / old)、localStorage 秒显缓存、可见性暂停、轮询节奏、 版本号与package.json一致test/v050-persist.test.mjs—— 落盘往返、12h TTL 边界(11h 留 / 13h 丢)、文件损坏、schema 不符、512KB 上限、off开关、未 apply 时不碰盘test/client-load.test.mjs—— 用 vm 模拟__ModuleLoader__加载 bundle,确认导出面与降级路径test/apply-smoke.mjs—— 假 cordis ctx 端到端跑一遍宿主:路由注册 → token → attach → pre-step 注入 → 只注入一轮test/preflight.mjs—— 静态预检:版本号单一来源、宿主/客户端协议号一致、入口与打包面、能力与回归哨兵
共 62 项单测 + 23 项冒烟检查 + 24 项 preflight(npm test 一条命令全跑)。
回滚
dsh plugin --profile desktop remove dsh-quote-tray
或手动:删掉 profile package.json 里 dsh-quote-tray 一行与 cordis.patch.yml 的 insert 条目,
删除 node_modules/dsh-quote-tray/,重启桌面端。
来源与署名
见 NOTICE.md。摘要:
| 来源 | 关系 | 许可证 |
|---|---|---|
| Dengzi555/dsh-quotalk 0.1.2 | 引用卡片 CSS 设计令牌逐 token 对齐 | MIT |
| liuhaolong16/dsh-quote-reply 0.9.0 | v0.5.0 四项工程做法(握手 / 缓存 / 有界轮询 / ResizeObserver) | MIT |
本项目全部代码为自研实现,未复制上述项目源码;渲染架构与二者均不同(本项目走 DOM 浮层覆盖层)。
许可证
MIT。
No comments yet. Be the first to write one.