dsh-quote-comment 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # dsh-quote-comment
2
+
3
+ 在 DSH 会话里**用鼠标选中任意内容 → 选区旁浮出「添加评论」按钮 → 点击即把选中文本作为一枚原子引用 chip 插入聊天输入框**,光标落在 chip 之后,直接接着写你的评论。
4
+
5
+ chip 上只显示一行摘要(悬停看全文),发送时由本插件的 codec 展开成 Markdown 引用(每行 `> `)——**输入框里干净,模型收到的是完整的被引内容**。
6
+
7
+ 这就是「快速引用选中内容与模型交流」:不用手打 `> `,也不用复制粘贴。
8
+
9
+ ## 交互细节
10
+
11
+ | 动作 | 行为 |
12
+ | --- | --- |
13
+ | 在会话正文里选中文字 | 选区右下方浮出「添加评论」胶囊按钮(悬停显示选区预览) |
14
+ | 点击按钮 | 选中文本作为引用 chip 插入输入框(chip 显示 40 字摘要),光标落在 chip 之后;按钮消失、选区清空、焦点回到输入框 |
15
+ | 发送消息 | chip 按 `codec.serialize` 展开成 Markdown 引用块(每行 `> `,空行写成 `>`,末尾一空行) |
16
+ | 滚动 / 缩放窗口 | 按钮跟随选区位置 |
17
+ | 按 Esc | 收起按钮 |
18
+ | 在输入框内选中文字 | 不出现按钮(只有会话正文里的选中才算) |
19
+ | 插入通道 | ① 官方引用通道 `actx.bail("slash/input-insert-reference")` → ② 官方 `inputActions.insertText(text, captureInsertion())` → ③ 合成 `paste` 事件 → ④ 写入剪贴板并提示「已复制,请粘贴」 |
20
+
21
+ ## 实现要点
22
+
23
+ - 纯客户端插件:宿主半身 `lib/index.js` 只打印一行日志,全部行为在 `lib/client.js`(无构建步骤,直接就是浏览器可加载的 UMD 包装,`window.__ModuleLoader__.load({ id: "dsh-quote-comment" })`)。
24
+ - 浮层挂在官方 `shell.overlay` 槽位(`{ kind: "list", scope: "root" }`,由 `@deepseek-ai/dsh-client-ui-layout` 的 AppFrame 渲染,z-index 20 的 `pointer-events:none` 全屏层,直接子元素恢复 `pointer-events:auto`),不占用会话排版、不需要会话作用域。
25
+ - **引用 chip 走官方引用通道**(与官方 `@` 引用同一条路):`captureInsertion()` 取 span → 在会话作用域上 `actx.bail(actx, "slash/input-insert-reference", { reference, span })`(会话作用域经 `sessions.binding(sessionId).ctx` 取,`sessionId` 来自 `uiSession.current.getSnapshot().key`)→ ui-conversation 的 `SessionInputShell.insertReference` 把它落成 Lexical 的 `reference-chip` 节点(`data-composer-chip="quote-comment"`),并自动补一个尾随空格。
26
+ - **chip 只带 `source` 名字,序列化靠插件自己注册的属主**:`ctx.get("inputTriggers").registerSource({ trigger: "#", name: "quote-comment", candidates: () => [], codec: { clipboardText, serialize } })`,提交消息时 inputTriggers 按名字找到本插件、调用 `codec.serialize(ref)` 把 `ref`(原始选中文本)展开成引用块。**没有这个属主,发送会被 `no serializer for reference source "quote-comment"` 挡住**,所以注册失败时(服务缺失/名字被占)自动退回纯文本路径。
27
+ - 兜底写输入框走官方 `inputActions`(ui-conversation 经 `uiSession` 服务以 `props.inputActions` 暴露):`insertText(text, captureInsertion())`。输入框是 Lexical 编辑器,`document.execCommand("insertText")` 对它不可靠,所以再兜底用合成 `paste` 事件。
28
+ - 会话正文判定用官方 DOM 锚点:`[data-chat-flow]` / `[data-chat-flow-key]` / `[data-chat-anchor-key]` / `[data-conversation-scroll]`,并排除 `[data-composer-seat]`(输入区)与插件自己的 `[data-quote-comment-root]`。
29
+
30
+ ## 安装到 desktop profile
31
+
32
+ profile 目录:`%USERPROFILE%\.dsh\profiles\desktop`(本机 `C:\Users\wangj\.dsh\profiles\desktop`)。
33
+
34
+ ```powershell
35
+ $src = 'C:\Users\wangj\Documents\deepseek-harness\default-workspace\dsh-quote-comment'
36
+ $profile = "$env:USERPROFILE\.dsh\profiles\desktop"
37
+
38
+ # 1) 把源码目录链接进 profile 的 node_modules(junction,改动即时生效,不用重新拷贝)
39
+ New-Item -ItemType Junction -Path "$profile\node_modules\dsh-quote-comment" -Target $src
40
+ ```
41
+
42
+ 2. 在 `$profile\package.json` 里登记两处(插件生效必须同时出现):
43
+
44
+ ```json
45
+ {
46
+ "dependencies": {
47
+ "dsh-quote-comment": "link:C:/Users/wangj/Documents/deepseek-harness/default-workspace/dsh-quote-comment"
48
+ },
49
+ "dsh": {
50
+ "profile": {
51
+ "bundles": [
52
+ "@deepseek-ai/dsh-base",
53
+ "@deepseek-ai/dsh-web-app",
54
+ "@deepseek-ai/dsh-experimental-agent-team-profile",
55
+ "dshmarket",
56
+ "billion-context",
57
+ "@vectorize-io/hindsight-coding-agents",
58
+ "dsh-cost-meter",
59
+ "dsh-quote-comment"
60
+ ]
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ 3. 重启 DSH(客户端 bundle 在启动时组图,改完要重启;重启后刷新页面)。
67
+
68
+ 卸载:把 `bundles` 与 `dependencies` 里的 `dsh-quote-comment` 去掉,删掉 `node_modules\dsh-quote-comment` 这个 junction,重启即可。
69
+
70
+ ## 自测
71
+
72
+ ```powershell
73
+ & "C:\Users\wangj\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe" test\smoke.mjs
74
+ ```
75
+
76
+ `test/smoke.mjs` 用迷你 DOM + 迷你 React 在 Node 里驱动 `lib/client.js`,覆盖 49 项断言:模块契约、选区浮出按钮、定位、**官方引用 chip 通道(属主注册、reference 载荷、codec 序列化、摘要截断)**、无 chip 服务时回退 `inputActions.insertText`、多行引用格式、输入区选中不触发、`paste` 兜底、剪贴板兜底。
77
+
78
+ `test/install-check.mjs` 另做 16 项安装自检(profile 登记、加载器清单契约、bundle 形态、宿主半身)。
79
+
80
+ ## 重启后验收(30 秒)
81
+
82
+ 1. 退出并重开 DeepSeek Harness(客户端 bundle 只在启动时组图,`rev` 由内容哈希推导,改代码后重启即换新 URL,不会吃到旧缓存),刷新页面。
83
+ 2. 在任意会话的模型回答里**用鼠标选中一段文字** → 选区右下方出现「添加评论」胶囊按钮。
84
+ 3. 点它 → 输入框里出现一枚**引用 chip**(气泡图标 + 选中内容摘要),光标在 chip 之后,接着写你的评论即可;发送后模型看到的是完整的 `> ` 引用块。
85
+
86
+ 想确认插件是否真的加载了,可在 DevTools Console 里跑:
87
+
88
+ ```js
89
+ document.querySelectorAll('[data-quote-comment-root]').length // 1 = 浮层已挂载
90
+ document.querySelector('[data-composer-chip="quote-comment"]') !== null // true = chip 插入成功
91
+ ```
92
+
93
+ 若按钮不出现,按顺序排查:插件是否在 profile 的 `dependencies` + `dsh.profile.bundles` 两处都登记(跑 `node test/install-check.mjs`);重启是否真的发生(宿主日志里应有 `[dsh-quote-comment] host half loaded`);选中的是否在会话正文而非输入框内。
94
+
95
+ 若出现的是 `> 选中内容` 这样的纯文本而不是 chip,说明官方引用通道没走通(回退链生效):此时功能仍可用,但可以检查 `ctx.get("inputTriggers")` / `ctx.get("sessions")` 是否改名。
96
+
97
+ ## 已知边界
98
+
99
+ - 只处理鼠标选区(`selectionchange` / `mouseup`);键盘跨消息选中同样覆盖(走 `selectionchange`)。
100
+ - chip 的 `appearance` 用官方三种图标里唯一的对话图标 `session`(另两种是 `file` / `folder`);`ref` 存的是原始选中文本,所以 chip 是「文本引用」而不是「会话引用」,点击 chip 不会打开别的会话(官方引用源才提供 `openReference`)。
101
+ - 引用块用 Markdown `> `,与模型交流时语义明确,也不会触发输入框的 `/` 命令菜单。
102
+ - 插件注册的属主触发字符是 `#` 且不提供补全候选:输入 `#` 不会弹出额外菜单项。若日后与别的插件撞名(`(trigger,name)` 唯一),插件会自动改用纯文本路径而不会报错。
103
+ - 若 DSH 后续改了 `shell.overlay` 槽位名、`inputActions` 方法名、`slash/input-insert-reference` 事件名或输入框锚点,需要同步更新 `lib/client.js` 顶部的选择器/常量与 `resolveInputActions()`。
@@ -0,0 +1,4 @@
1
+ # dsh bundle patch:把本插件插入 profile 的层叠栈(生成一条 Loader 条目)。
2
+ - insert:
3
+ - id: dsh-quote-comment
4
+ name: 'dsh-quote-comment'
package/lib/client.js ADDED
@@ -0,0 +1,560 @@
1
+ /**
2
+ * dsh-quote-comment —— 浏览器侧半身。
3
+ *
4
+ * 交互:在会话正文里用鼠标选中一段文本 → 选区右下角浮出「添加评论」按钮 →
5
+ * 点击后把选中文本作为一个**原子引用 chip** 插进聊天输入框(chip 上只显示一行摘要,
6
+ * 鼠标悬停看全文),光标落在 chip 之后,用户接着写自己的评论。
7
+ * 发送时 chip 由本插件的 codec 展开成 Markdown 引用(每行前缀 `> `),
8
+ * 也就是说输入框里干净,模型收到的是完整的被引内容。
9
+ *
10
+ * 实现要点:
11
+ * - 通过 `dsh.client` 声明由 client-modules 组图,本文件是自执行 UMD 包装,
12
+ * 模块 id 必须等于包名。
13
+ * - 浮层挂在官方 `shell.overlay` 槽位(`{ kind: "list", scope: "root" }`,
14
+ * ui-layout 的 AppFrame 渲染,容器 `position:absolute; inset:0; pointer-events:none`,
15
+ * 其直接子元素恢复 `pointer-events:auto`,故按钮自身可点)。
16
+ * - chip 走官方引用通道(与官方 `@` 引用同一条路):
17
+ * `captureInsertion()` 取 span → 在 session scope 上
18
+ * `actx.bail(actx, "slash/input-insert-reference", { reference, span })`,
19
+ * 由 ui-conversation 的 `SessionInputShell.insertReference` 落成 `reference-chip` 节点。
20
+ * chip 只带 `source` 名字,提交时 inputTriggers 按名字找属主的 codec 序列化成模型文本,
21
+ * 所以本插件必须在 `inputTriggers.registerSource` 里注册同名属主(见 `ensureSource`),
22
+ * 否则发送会被 `no serializer for reference source "quote-comment"` 挡住。
23
+ * - 输入框是 Lexical:`document.execCommand` 不可靠,故 chip 失败时逐级兜底
24
+ * `inputActions.insertText` → 合成 paste 事件 → 剪贴板。
25
+ */
26
+
27
+ window.__ModuleLoader__.load({ id: "dsh-quote-comment", factory: (require) => {
28
+ var module = { exports: {} };
29
+ var exports = module.exports;
30
+
31
+ "use strict";
32
+
33
+ var React = require("react");
34
+
35
+ /** 会话正文区锚点(由官方 dsh-client-ui-chat / ui-conversation 渲染)。 */
36
+ var TRANSCRIPT_SELECTOR = "[data-chat-flow],[data-chat-flow-key],[data-chat-anchor-key],[data-conversation-scroll]";
37
+ /** 输入区(输入框里的选中不该出现按钮)与本插件自己的浮层。 */
38
+ var EXCLUDED_SELECTOR = "[data-composer-seat],[data-quote-comment-root]";
39
+ var STYLE_TAG_ID = "dsh-quote-comment/style.css";
40
+ var OWN_UI_ATTR = "data-quote-comment-root";
41
+ /** 本插件在 inputTriggers 里注册的引用属主名(chip 的 `source` 就是这个名字)。 */
42
+ var SOURCE_NAME = "quote-comment";
43
+ /** 属主的触发字符;本插件不提供补全菜单,用一个不和官方 `/`、`@` 抢的字符。 */
44
+ var TRIGGER_CHAR = "#";
45
+ /** chip 图标种类:官方只认 session/file/folder,对话场景用 session(气泡)。 */
46
+ var CHIP_APPEARANCE = "session";
47
+ /** chip 单行摘要最多显示多少字符。 */
48
+ var LABEL_MAX = 40;
49
+
50
+ var CSS = [
51
+ ".dqc-root{position:fixed;z-index:30;pointer-events:auto;transform:translateX(-50%);}",
52
+ ".dqc-btn{display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;",
53
+ "border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.35));",
54
+ "background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#1f1f1f);",
55
+ "font-family:inherit;font-size:13px;line-height:18px;white-space:nowrap;cursor:pointer;",
56
+ "box-shadow:0 6px 18px rgba(0,0,0,.18);transition:background-color .12s ease,transform .12s ease;}",
57
+ ".dqc-btn:hover{background:var(--dsw-alias-interactive-bg-hover-solid,rgba(127,127,127,.14));}",
58
+ ".dqc-btn:active{transform:scale(.97);}",
59
+ ".dqc-btn svg{width:14px;height:14px;flex:none;}",
60
+ ".dqc-btn[data-status=copied],.dqc-btn[data-status=failed]{opacity:.9;}"
61
+ ].join("");
62
+
63
+ /** 元素节点归一:文本节点取 parentElement。 */
64
+ function elementOf(node) {
65
+ if (node === null || node === undefined) return null;
66
+ if (node.nodeType === 1) return node;
67
+ return node.parentElement || null;
68
+ }
69
+
70
+ /** 选区是否落在会话正文里(且不在输入区 / 不在本插件自己的 UI 上)。 */
71
+ function insideTranscript(node) {
72
+ var element = elementOf(node);
73
+ if (element === null || typeof element.closest !== "function") return false;
74
+ if (element.closest(EXCLUDED_SELECTOR) !== null) return false;
75
+ return element.closest(TRANSCRIPT_SELECTOR) !== null;
76
+ }
77
+
78
+ /** 事件目标是否属于本插件自己的浮层。 */
79
+ function ownUi(node) {
80
+ var element = elementOf(node);
81
+ if (element === null || typeof element.closest !== "function") return false;
82
+ return element.closest("[" + OWN_UI_ATTR + "]") !== null;
83
+ }
84
+
85
+ /** 选区最后一个可见行的视口矩形(用于给按钮定位)。 */
86
+ function lastRect(range) {
87
+ var rects = typeof range.getClientRects === "function" ? range.getClientRects() : null;
88
+ if (rects !== null) {
89
+ for (var index = rects.length - 1; index >= 0; index -= 1) {
90
+ var candidate = rects[index];
91
+ if (candidate && (candidate.width > 0 || candidate.height > 0)) return candidate;
92
+ }
93
+ }
94
+ var single = typeof range.getBoundingClientRect === "function" ? range.getBoundingClientRect() : null;
95
+ if (single && (single.width > 0 || single.height > 0)) return single;
96
+ return null;
97
+ }
98
+
99
+ /** 读取当前选区;不是「会话正文里的非折叠选中」时返回 null。 */
100
+ function readSelection() {
101
+ var selection = window.getSelection();
102
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
103
+ var text = selection.toString();
104
+ if (text.trim() === "") return null;
105
+ var range = selection.getRangeAt(0);
106
+ if (!insideTranscript(range.startContainer) && !insideTranscript(range.endContainer)) return null;
107
+ var rect = lastRect(range);
108
+ if (rect === null) return null;
109
+ var half = 62;
110
+ var x = rect.left + rect.width / 2;
111
+ x = Math.min(Math.max(x, half + 8), Math.max(half + 8, window.innerWidth - half - 8));
112
+ var y = rect.bottom + 10;
113
+ if (y > window.innerHeight - 44) y = Math.max(10, rect.top - 40);
114
+ return { text: text, x: Math.round(x), y: Math.round(y) };
115
+ }
116
+
117
+ /** 把选中文本变成输入框里的引用块:每行 `> ` 前缀,尾部留一空行给用户写评论。 */
118
+ function quoteOf(raw) {
119
+ var lines = String(raw).replace(/\r\n?/g, "\n").split("\n");
120
+ while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
121
+ while (lines.length > 0 && lines[0].trim() === "") lines.shift();
122
+ var body = lines
123
+ .map(function (line) {
124
+ var trimmed = line.replace(/[ \t]+$/, "");
125
+ return trimmed.trim() === "" ? ">" : "> " + trimmed;
126
+ })
127
+ .join("\n");
128
+ return body + "\n\n";
129
+ }
130
+
131
+ /** 压平空白并按需截断(chip 摘要 / 按钮 title 共用)。 */
132
+ function excerptOf(raw, max) {
133
+ var flat = String(raw).replace(/\s+/g, " ").trim();
134
+ return flat.length > max ? flat.slice(0, max) + "…" : flat;
135
+ }
136
+
137
+ /** 按钮 title 里显示的选区预览(压平空白)。 */
138
+ function previewOf(raw) {
139
+ return excerptOf(raw, 120);
140
+ }
141
+
142
+ /** 引用块里第一行非空文本(兜底路径用它判断插入是否真的发生)。 */
143
+ function firstLineOf(block) {
144
+ var lines = String(block).split("\n");
145
+ for (var index = 0; index < lines.length; index += 1) {
146
+ var line = lines[index].replace(/^>\s?/, "").trim();
147
+ if (line !== "") return line;
148
+ }
149
+ return "";
150
+ }
151
+
152
+ /** 输入框承载元素(Lexical 的 contenteditable)。 */
153
+ function composerHost() {
154
+ var seat = document.querySelector("[data-composer-seat]");
155
+ if (seat === null) return null;
156
+ return seat.querySelector('[contenteditable="true"]') || seat.querySelector("textarea");
157
+ }
158
+
159
+ /** 输入框当前文本(仅用于兜底路径的成功判定)。 */
160
+ function composerText() {
161
+ var host = composerHost();
162
+ if (host === null) return "";
163
+ if (typeof host.value === "string") return host.value;
164
+ return host.textContent || "";
165
+ }
166
+
167
+ /** 把焦点交回输入框(insertText 已把光标落在插入文本之后)。 */
168
+ function focusComposer() {
169
+ var host = composerHost();
170
+ if (host === null || typeof host.focus !== "function") return;
171
+ try {
172
+ host.focus({ preventScroll: true });
173
+ } catch (error) {
174
+ try {
175
+ host.focus();
176
+ } catch (ignored) {
177
+ /* 忽略 */
178
+ }
179
+ }
180
+ }
181
+
182
+ function installStyle() {
183
+ if (typeof document === "undefined") return;
184
+ if (document.querySelector("style[data-plugin-css=" + JSON.stringify(STYLE_TAG_ID) + "]") !== null) return;
185
+ var tag = document.createElement("style");
186
+ tag.dataset.plugin = "dsh-quote-comment";
187
+ tag.dataset.pluginCss = STYLE_TAG_ID;
188
+ tag.textContent = CSS;
189
+ document.head.append(tag);
190
+ }
191
+
192
+ function removeStyle() {
193
+ if (typeof document === "undefined") return;
194
+ var tag = document.querySelector("style[data-plugin-css=" + JSON.stringify(STYLE_TAG_ID) + "]");
195
+ if (tag !== null && tag.parentNode !== null) tag.parentNode.removeChild(tag);
196
+ }
197
+
198
+ /** 「添加评论」的图标(气泡 + 加号)。 */
199
+ function iconNode() {
200
+ return React.createElement(
201
+ "svg",
202
+ { viewBox: "0 0 16 16", "aria-hidden": "true", focusable: "false" },
203
+ React.createElement("path", {
204
+ d: "M2.5 3.1h11a.9.9 0 0 1 .9.9v5.6a.9.9 0 0 1-.9.9H8.2l-3 2.2a.4.4 0 0 1-.6-.3v-1.9H2.5a.9.9 0 0 1-.9-.9V4a.9.9 0 0 1 .9-.9Z",
205
+ fill: "none",
206
+ stroke: "currentColor",
207
+ strokeWidth: "1.2",
208
+ strokeLinejoin: "round"
209
+ }),
210
+ React.createElement("path", { d: "M8 5.1v3M6.5 6.6h3", fill: "none", stroke: "currentColor", strokeWidth: "1.2", strokeLinecap: "round" })
211
+ );
212
+ }
213
+
214
+ /**
215
+ * 装载客户端半身。
216
+ * @param ctx - 客户端 Cordis 上下文。硬依赖只有 `slots`(`exports.inject`);
217
+ * `inputTriggers` / `sessions` / `uiSession` 都在用时惰性 `ctx.get()` 解析,
218
+ * 拿不到就自动退回纯文本路径,不会因此加载失败。
219
+ */
220
+ function apply(ctx) {
221
+ installStyle();
222
+ ctx.effect(function () {
223
+ return function () {
224
+ removeStyle();
225
+ };
226
+ }, "dsh-quote-comment: styles");
227
+
228
+ /** 引用属主状态:ready = 已注册;broken = (trigger,name) 被占或字符非法,此后只走纯文本路径。 */
229
+ var sourceReady = false;
230
+ var sourceBroken = false;
231
+ var sourceDisposer = null;
232
+
233
+ /** uiSession 主视图 binding 的快照(`{ key, ctx, hooks, props }`,key 即 sessionId)。 */
234
+ function uiSessionSnapshot() {
235
+ try {
236
+ var service = typeof ctx.get === "function" ? ctx.get("uiSession") : ctx.uiSession;
237
+ if (!service || !service.current || typeof service.current.getSnapshot !== "function") return null;
238
+ return service.current.getSnapshot() || null;
239
+ } catch (error) {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ /** 取当前会话的输入框 action 面:uiSession → current binding → props.inputActions。 */
245
+ function resolveInputActions() {
246
+ var snapshot = uiSessionSnapshot();
247
+ var actions = snapshot && snapshot.props ? snapshot.props.inputActions : undefined;
248
+ return actions || null;
249
+ }
250
+
251
+ /** 当前主视图会话 id(chip 落笔要知道把事件发到哪个 session scope)。 */
252
+ function currentSession() {
253
+ var snapshot = uiSessionSnapshot();
254
+ if (snapshot === null) return null;
255
+ var id = snapshot.key;
256
+ if (id === undefined || id === null || id === "") id = snapshot.props ? snapshot.props.sessionId : null;
257
+ if (id === undefined || id === null || id === "") return null;
258
+ return { id: String(id), ctx: snapshot.ctx || null };
259
+ }
260
+
261
+ /** 会话作用域 ctx:与官方 ui-commands 一样经 `sessions.binding(sessionId).ctx` 取。 */
262
+ function sessionScope(session) {
263
+ var sessions = typeof ctx.get === "function" ? ctx.get("sessions") : ctx.sessions;
264
+ if (sessions && typeof sessions.binding === "function") {
265
+ try {
266
+ var binding = sessions.binding(session.id);
267
+ if (binding && binding.ctx && typeof binding.ctx.bail === "function") return binding.ctx;
268
+ } catch (error) {
269
+ /* 落到快照里的 ctx */
270
+ }
271
+ }
272
+ if (session.ctx && typeof session.ctx.bail === "function") return session.ctx;
273
+ return null;
274
+ }
275
+
276
+ /**
277
+ * 注册本插件自己的引用属主(inputTriggers source)。
278
+ * chip 只带 source 名字,提交时 inputTriggers 按名字找 codec 还原成模型可见文本;
279
+ * 没有属主就会被 `no serializer for reference source "quote-comment"` 挡住发送。
280
+ * 服务或名字不可用时返回 false → 调用方退回纯文本路径(不注册也能用)。
281
+ */
282
+ function ensureSource() {
283
+ if (sourceReady) return true;
284
+ if (sourceBroken) return false;
285
+ var triggers = typeof ctx.get === "function" ? ctx.get("inputTriggers") : ctx.inputTriggers;
286
+ if (!triggers || typeof triggers.registerSource !== "function") return false;
287
+ try {
288
+ var disposer = triggers.registerSource({
289
+ trigger: TRIGGER_CHAR,
290
+ name: SOURCE_NAME,
291
+ showGroupTitle: false,
292
+ candidates: function () {
293
+ return [];
294
+ },
295
+ codec: {
296
+ clipboardText: function (ref) {
297
+ return quoteOf(ref);
298
+ },
299
+ serialize: function (ref) {
300
+ return Promise.resolve(quoteOf(ref));
301
+ }
302
+ }
303
+ });
304
+ sourceDisposer = typeof disposer === "function" ? disposer : null;
305
+ sourceReady = true;
306
+ return true;
307
+ } catch (error) {
308
+ sourceBroken = true;
309
+ return false;
310
+ }
311
+ }
312
+
313
+ ctx.effect(function () {
314
+ ensureSource();
315
+ return function () {
316
+ if (sourceDisposer !== null) {
317
+ try {
318
+ sourceDisposer();
319
+ } catch (error) {
320
+ /* 忽略 */
321
+ }
322
+ sourceDisposer = null;
323
+ }
324
+ sourceReady = false;
325
+ };
326
+ }, "dsh-quote-comment: reference source");
327
+
328
+ /**
329
+ * 官方引用通道:把选中文本作为原子 chip 插进输入框。
330
+ * span 由 inputActions.captureInsertion() 给出,事件由 session scope 的 bail 派发;
331
+ * ui-conversation 的 `slash/input-insert-reference` 监听器返回 true 才算落笔成功。
332
+ */
333
+ function insertThroughReference(rawText) {
334
+ if (!ensureSource()) return false;
335
+ var session = currentSession();
336
+ if (session === null) return false;
337
+ var actx = sessionScope(session);
338
+ if (actx === null) return false;
339
+ var actions = resolveInputActions();
340
+ if (!actions || typeof actions.captureInsertion !== "function") return false;
341
+ try {
342
+ var span = actions.captureInsertion();
343
+ if (span === null || span === undefined) return false;
344
+ var reference = {
345
+ source: SOURCE_NAME,
346
+ ref: String(rawText),
347
+ label: excerptOf(rawText, LABEL_MAX),
348
+ appearance: CHIP_APPEARANCE,
349
+ clipboardText: quoteOf(rawText)
350
+ };
351
+ return actx.bail(actx, "slash/input-insert-reference", { reference: reference, span: span }) === true;
352
+ } catch (error) {
353
+ return false;
354
+ }
355
+ }
356
+
357
+ /** 官方路径:captureInsertion() 取 span,insertText(text, span) 落笔。 */
358
+ function insertThroughActions(text) {
359
+ var actions = resolveInputActions();
360
+ if (!actions || typeof actions.captureInsertion !== "function" || typeof actions.insertText !== "function") return false;
361
+ for (var attempt = 0; attempt < 2; attempt += 1) {
362
+ try {
363
+ var span = actions.captureInsertion();
364
+ if (actions.insertText(text, span) === true) return true;
365
+ } catch (error) {
366
+ return false;
367
+ }
368
+ }
369
+ return false;
370
+ }
371
+
372
+ /** 兜底路径:合成 paste 事件交给 Lexical 自己处理,之后读文本判定是否成功。 */
373
+ function insertThroughPaste(text) {
374
+ var host = composerHost();
375
+ if (host === null) return false;
376
+ var marker = firstLineOf(text);
377
+ try {
378
+ host.focus();
379
+ var data = new DataTransfer();
380
+ data.setData("text/plain", text);
381
+ host.dispatchEvent(new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true }));
382
+ } catch (error) {
383
+ return false;
384
+ }
385
+ return marker !== "" && composerText().indexOf(marker) !== -1;
386
+ }
387
+
388
+ function copyToClipboard(text) {
389
+ try {
390
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
391
+ var pending = navigator.clipboard.writeText(text);
392
+ if (pending && typeof pending.catch === "function") pending.catch(function () {});
393
+ return true;
394
+ }
395
+ } catch (error) {
396
+ /* 忽略:走失败提示 */
397
+ }
398
+ return false;
399
+ }
400
+
401
+ /** 交付选中文本:引用 chip → 官方 action → 合成 paste → 剪贴板,返回结果状态。 */
402
+ function deliver(rawText) {
403
+ var text = quoteOf(rawText);
404
+ if (text.trim() === "") return "failed";
405
+ if (insertThroughReference(rawText)) return "inserted";
406
+ if (insertThroughActions(text)) return "inserted";
407
+ if (insertThroughPaste(text)) return "inserted";
408
+ if (copyToClipboard(text)) return "copied";
409
+ return "failed";
410
+ }
411
+
412
+ function Overlay() {
413
+ var activeState = React.useState(null);
414
+ var active = activeState[0];
415
+ var setActive = activeState[1];
416
+ var statusState = React.useState(null);
417
+ var status = statusState[0];
418
+ var setStatus = statusState[1];
419
+ var latest = React.useRef(null);
420
+ var hideTimer = React.useRef(null);
421
+
422
+ React.useEffect(function () {
423
+ var dragging = false;
424
+ var frame = 0;
425
+
426
+ function push(candidate, clearStatus) {
427
+ latest.current = candidate;
428
+ setActive(candidate);
429
+ if (clearStatus !== false) setStatus(null);
430
+ }
431
+
432
+ function schedule(clearStatus) {
433
+ if (frame !== 0) return;
434
+ frame = window.requestAnimationFrame(function () {
435
+ frame = 0;
436
+ push(readSelection(), clearStatus);
437
+ });
438
+ }
439
+
440
+ function onSelectionChange() {
441
+ if (dragging) return;
442
+ schedule(true);
443
+ }
444
+
445
+ function onMouseDown(event) {
446
+ if (ownUi(event.target)) return;
447
+ dragging = true;
448
+ push(null);
449
+ }
450
+
451
+ function onMouseUp(event) {
452
+ dragging = false;
453
+ if (ownUi(event.target)) return;
454
+ schedule(true);
455
+ }
456
+
457
+ function onScroll() {
458
+ if (latest.current !== null) schedule(false);
459
+ }
460
+
461
+ function onResize() {
462
+ if (latest.current !== null) schedule(false);
463
+ }
464
+
465
+ function onKeyDown(event) {
466
+ if (event.key === "Escape") push(null);
467
+ }
468
+
469
+ document.addEventListener("selectionchange", onSelectionChange);
470
+ document.addEventListener("mousedown", onMouseDown, true);
471
+ document.addEventListener("mouseup", onMouseUp, true);
472
+ document.addEventListener("keydown", onKeyDown, true);
473
+ window.addEventListener("scroll", onScroll, true);
474
+ window.addEventListener("resize", onResize);
475
+
476
+ return function () {
477
+ document.removeEventListener("selectionchange", onSelectionChange);
478
+ document.removeEventListener("mousedown", onMouseDown, true);
479
+ document.removeEventListener("mouseup", onMouseUp, true);
480
+ document.removeEventListener("keydown", onKeyDown, true);
481
+ window.removeEventListener("scroll", onScroll, true);
482
+ window.removeEventListener("resize", onResize);
483
+ if (frame !== 0) window.cancelAnimationFrame(frame);
484
+ if (hideTimer.current !== null) {
485
+ window.clearTimeout(hideTimer.current);
486
+ hideTimer.current = null;
487
+ }
488
+ };
489
+ }, []);
490
+
491
+ function onPick(event) {
492
+ if (event) {
493
+ event.preventDefault();
494
+ event.stopPropagation();
495
+ }
496
+ var candidate = latest.current;
497
+ if (candidate === null) return;
498
+ var outcome = deliver(candidate.text);
499
+ if (outcome === "inserted") {
500
+ latest.current = null;
501
+ setActive(null);
502
+ setStatus(null);
503
+ try {
504
+ var selection = window.getSelection();
505
+ if (selection && typeof selection.removeAllRanges === "function") selection.removeAllRanges();
506
+ } catch (error) {
507
+ /* 忽略 */
508
+ }
509
+ focusComposer();
510
+ return;
511
+ }
512
+ setStatus(outcome);
513
+ if (hideTimer.current !== null) window.clearTimeout(hideTimer.current);
514
+ hideTimer.current = window.setTimeout(function () {
515
+ hideTimer.current = null;
516
+ latest.current = null;
517
+ setActive(null);
518
+ setStatus(null);
519
+ }, 1800);
520
+ }
521
+
522
+ if (active === null) return null;
523
+
524
+ var label = status === "copied" ? "已复制,请粘贴" : status === "failed" ? "插入失败,请手动复制" : "添加评论";
525
+
526
+ return React.createElement(
527
+ "div",
528
+ {
529
+ className: "dqc-root",
530
+ "data-quote-comment-root": "true",
531
+ style: { top: active.y + "px", left: active.x + "px" }
532
+ },
533
+ React.createElement(
534
+ "button",
535
+ {
536
+ type: "button",
537
+ className: "dqc-btn",
538
+ "data-status": status === null ? undefined : status,
539
+ title: previewOf(active.text),
540
+ onMouseDown: function (event) {
541
+ event.preventDefault();
542
+ },
543
+ onClick: onPick
544
+ },
545
+ iconNode(),
546
+ React.createElement("span", null, label)
547
+ )
548
+ );
549
+ }
550
+
551
+ ctx.slots.inject("shell.overlay", function () {
552
+ return ctx.slots.register({ name: "shell.overlay", id: "quote-comment", order: 100 }, Overlay);
553
+ });
554
+ }
555
+
556
+ exports.inject = ["slots"];
557
+ exports.apply = apply;
558
+
559
+ return module.exports;
560
+ }});
package/lib/index.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 宿主侧(Node)半身。
3
+ *
4
+ * 本插件的全部行为都在浏览器里:`lib/client.js` 通过 `dsh.client` 声明被
5
+ * client-modules 组进 `window.__DSH_BOOT__` 图并单独服务。宿主半身存在只是
6
+ * 为了让 profile 的层叠栈里有一条可加载的 Loader 条目——client-modules 只服务
7
+ * “有活跃 Loader 条目”的包的客户端 bundle。
8
+ */
9
+
10
+ export const name = 'dsh-quote-comment';
11
+
12
+ /**
13
+ * 装载宿主半身:不做任何事,只在日志里留一行便于确认条目已挂载。
14
+ * @param ctx - 宿主 Cordis 上下文。
15
+ */
16
+ export function apply(ctx) {
17
+ ctx.logger?.info?.('[dsh-quote-comment] host half loaded (client UI only)');
18
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "dsh-quote-comment",
3
+ "version": "0.1.0",
4
+ "description": "选中会话内容即浮出「添加评论」按钮,点击把选中文本作为引用插入聊天输入框。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "README.md",
15
+ "dsh.bundle.patch.yml"
16
+ ],
17
+ "keywords": [
18
+ "deepseek",
19
+ "harness",
20
+ "dsh",
21
+ "dsh-plugin",
22
+ "quote",
23
+ "comment",
24
+ "selection"
25
+ ],
26
+ "license": "MIT",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "dsh": {
31
+ "bundle": {
32
+ "patch": "./dsh.bundle.patch.yml"
33
+ },
34
+ "client": {
35
+ "platform": "web",
36
+ "immediately": true
37
+ }
38
+ }
39
+ }