dsh-quote-followup 0.2.2 → 0.2.4

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 CHANGED
@@ -2,19 +2,31 @@
2
2
 
3
3
  English | [中文](README.zh.md)
4
4
 
5
+ [![npm version](https://img.shields.io/npm/v/dsh-quote-followup.svg)](https://www.npmjs.com/package/dsh-quote-followup)
6
+ [![license](https://img.shields.io/npm/l/dsh-quote-followup.svg)](LICENSE)
7
+
5
8
  A Web-only [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin for quoting selected conversation text into the composer and asking a focused follow-up.
6
9
 
10
+ <p align="center">
11
+ <img src="docs/assets/quote-followup-demo.gif" width="960" alt="Select conversation excerpts, insert native DSH quote chips, and write a focused follow-up">
12
+ </p>
13
+
14
+ <p align="center"><strong>Select. Quote. Follow up.</strong></p>
15
+
7
16
  ## Features
8
17
 
9
18
  - Select text inside the Web conversation transcript to reveal a floating **Quote** button.
10
- - Append the selection as a Markdown quote without replacing the existing draft.
11
- - Repeat the action to collect multiple excerpts before sending.
12
- - Dispatch DSH's live Lexical `PASTE_COMMAND` directly, keeping the editor model and rendered composer in sync across Chromium and Firefox.
19
+ - Append a native DSH conversation-reference chip without replacing the existing draft.
20
+ - Reuse the same atomic `ReferenceChipNode`, conversation icon, and business color as `@file` / `@session`.
21
+ - Quote multiple excerpts; on send, the plugin codec expands each chip into a model-readable Markdown blockquote.
22
+ - Fall back to a plain-text quote when the host lacks native chip support.
13
23
 
14
24
  TUI is intentionally unsupported.
15
25
 
16
26
  ## Install
17
27
 
28
+ Requires DSH `>=0.1.2-rc.1`.
29
+
18
30
  ```bash
19
31
  dsh plugin --profile web add dsh-quote-followup
20
32
  ```
@@ -26,7 +38,7 @@ Add `dsh-quote-followup` to the Web profile's `dsh.profile.bundles`, then restar
26
38
  1. Select text in a conversation message.
27
39
  2. Click **❐ Quote**.
28
40
  3. Select and quote more excerpts if needed.
29
- 4. Type the follow-up below the inserted quotes and send.
41
+ 4. Type the follow-up after the inserted chips and send.
30
42
 
31
43
  ## Development
32
44
 
@@ -34,7 +46,7 @@ Add `dsh-quote-followup` to the Web profile's `dsh.profile.bundles`, then restar
34
46
  npm test
35
47
  ```
36
48
 
37
- The regression harness covers repeated quotes, Firefox rejecting constructor-injected `clipboardData`, Lexical reconciliation of raw DOM fallbacks, and takeover from a stale client button/singleton after a hot swap.
49
+ The regression harness covers native quote chips, repeated quoting, spacing after an existing draft, codec serialization, the Firefox text fallback, and current-client takeover of stale singleton/button state after a hot swap.
38
50
 
39
51
  ## License
40
52
 
package/README.zh.md CHANGED
@@ -2,19 +2,31 @@
2
2
 
3
3
  [English](README.md) | 中文
4
4
 
5
+ [![npm version](https://img.shields.io/npm/v/dsh-quote-followup.svg)](https://www.npmjs.com/package/dsh-quote-followup)
6
+ [![license](https://img.shields.io/npm/l/dsh-quote-followup.svg)](LICENSE)
7
+
5
8
  一个仅支持 Web 的 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:选中对话文本,将其追加到输入框,再进行针对性追问。
6
9
 
10
+ <p align="center">
11
+ <img src="docs/assets/quote-followup-demo.gif" width="960" alt="划选对话片段,插入 DSH 原生引用 chip,再输入针对性追问">
12
+ </p>
13
+
14
+ <p align="center"><strong>划选、引用、继续追问。</strong></p>
15
+
7
16
  ## 功能
8
17
 
9
18
  - 在 Web 对话区选中文本后显示浮动的 **❐ 引用** 按钮。
10
- - 以 Markdown 引用块追加到现有草稿,不覆盖已输入内容。
11
- - 可连续引用多段内容,再统一编辑并发送。
12
- - 直接派发 DSH 当前 Lexical editor `PASTE_COMMAND`,在 Chromium 与 Firefox 中同步更新编辑器模型和 DOM。
19
+ - 以 DSH 原生对话引用 chip 追加到现有草稿,不覆盖已输入内容。
20
+ - chip 使用与 `@文件` / `@对话` 相同的 `ReferenceChipNode`、对话图标和业务色,可整体删除。
21
+ - 可连续引用多段内容;发送时由插件 codec 将各 chip 展开为模型可读的 Markdown 引用块。
22
+ - 旧版 DSH 缺少原生 chip 能力时,自动降级为纯文本引用。
13
23
 
14
24
  本插件不再适配 TUI。
15
25
 
16
26
  ## 安装
17
27
 
28
+ 要求 DSH `>=0.1.2-rc.1`。
29
+
18
30
  ```bash
19
31
  dsh plugin --profile web add dsh-quote-followup
20
32
  ```
@@ -26,7 +38,7 @@ dsh plugin --profile web add dsh-quote-followup
26
38
  1. 在对话消息中划选任意片段。
27
39
  2. 点击 **❐ 引用**。
28
40
  3. 如需补充,可继续选中并引用其他片段。
29
- 4. 在引用块下输入追问并发送。
41
+ 4. 在引用 chip 后输入追问并发送。
30
42
 
31
43
  ## 开发验证
32
44
 
@@ -34,7 +46,7 @@ dsh plugin --profile web add dsh-quote-followup
34
46
  npm test
35
47
  ```
36
48
 
37
- 回归测试覆盖连续引用、Firefox 丢弃构造参数中的 `clipboardData`、Lexical 回滚裸 DOM fallback,以及热替换后新版 client 接管旧按钮/单例状态。
49
+ 回归测试覆盖原生引用 chip、连续引用、已有草稿后的间距、codec 序列化、Firefox 文本降级路径,以及热替换后新版 client 接管旧按钮/单例状态。
38
50
 
39
51
  ## 许可证
40
52
 
package/lib/client.js CHANGED
@@ -33,7 +33,28 @@ window.__ModuleLoader__.load({
33
33
  ];
34
34
  const BUTTON_ID = "dsh-quote-followup-btn";
35
35
  const BUTTON_VERSION_ATTR = "data-dsh-quote-followup-version";
36
- const CLIENT_VERSION = "0.2.2";
36
+ const CLIENT_VERSION = "0.2.4";
37
+ /** Codec owner for native DSH reference chips. */
38
+ const QUOTE_SOURCE = "quote-followup";
39
+ const LOCALE_NS = "quote-followup";
40
+ /** UI + serialized frame copy; the active DSH locale picks the language. */
41
+ const LOCALE_DICT = {
42
+ zh: {
43
+ "button.label": "❐ 引用",
44
+ "button.title": "引用选中内容到输入框(可多次引用,发送前可编辑)",
45
+ "quote.header": "引用",
46
+ "conversation.role": "对话",
47
+ "quote.truncated": ",已截断"
48
+ },
49
+ en: {
50
+ "button.label": "❐ Quote",
51
+ "button.title": "Quote the selection into the composer (repeatable; editable before sending)",
52
+ "quote.header": "Quote",
53
+ "conversation.role": "conversation",
54
+ "quote.truncated": ", truncated"
55
+ }
56
+ };
57
+ const fallbackT = (key) => LOCALE_DICT.en[key] ?? key;
37
58
  /** Per-quote character cap for a bounded composer insertion. */
38
59
  const QUOTE_MAX_CHARS = 1600;
39
60
  //#endregion
@@ -73,18 +94,57 @@ window.__ModuleLoader__.load({
73
94
  }
74
95
  return null;
75
96
  };
76
- /** Markdown blockquote frame for one quoted fragment. */
77
- const quoteFrame = (text, role) => {
97
+ const roleLabel = (role, t) => role === "user" ? "user" : role === "assistant" ? "assistant" : t("conversation.role");
98
+ /** Bound one quote before it becomes editor state. */
99
+ const quotePayload = (text, role) => {
78
100
  let body = String(text ?? "");
79
101
  let truncated = false;
80
102
  if (body.length > QUOTE_MAX_CHARS) {
81
103
  body = body.slice(0, QUOTE_MAX_CHARS);
82
104
  truncated = true;
83
105
  }
84
- const who = role === "user" ? "user" : role === "assistant" ? "assistant" : "对话";
85
- const lines = body.split("\n").map((line) => `> ${line}`.trimEnd());
86
- return `> [引用 · ${who}${truncated ? ",已截断" : ""}]\n${lines.join("\n")}\n\n`;
106
+ return { text: body, role, truncated };
107
+ };
108
+ /** Model / clipboard projection. The composer shows a chip, not this frame. */
109
+ const quoteFrame = ({ text, role, truncated }, t) => {
110
+ const lines = text.split("\n").map((line) => `> ${line}`.trimEnd());
111
+ return `> [${t("quote.header")} · ${roleLabel(role, t)}${truncated ? t("quote.truncated") : ""}]\n${lines.join("\n")}\n\n`;
112
+ };
113
+ const decodeQuote = (ref) => {
114
+ const value = JSON.parse(ref);
115
+ if (value === null || typeof value !== "object" || typeof value.text !== "string")
116
+ throw new Error("invalid quote-followup reference");
117
+ return {
118
+ text: value.text,
119
+ role: value.role === "user" || value.role === "assistant" ? value.role : null,
120
+ truncated: value.truncated === true
121
+ };
122
+ };
123
+ const quoteInsert = (payload, t) => {
124
+ const clipboardText = quoteFrame(payload, t);
125
+ const preview = payload.text.replace(/\s+/g, " ").trim();
126
+ return {
127
+ source: QUOTE_SOURCE,
128
+ ref: JSON.stringify(payload),
129
+ // The bubble icon carries the quote meaning; keep only the excerpt
130
+ // visible. Role/truncation stay in the serialized Markdown frame.
131
+ label: preview,
132
+ appearance: "session",
133
+ clipboardText
134
+ };
87
135
  };
136
+ /** Codec-only source: it owns quote chips but intentionally adds no @ candidates. */
137
+ const makeQuoteSource = (t) => ({
138
+ trigger: "@",
139
+ name: QUOTE_SOURCE,
140
+ order: 1000,
141
+ candidates: () => Promise.resolve([]),
142
+ onPick: () => void 0,
143
+ codec: {
144
+ clipboardText: (ref) => quoteFrame(decodeQuote(ref), t),
145
+ serialize: (ref) => Promise.resolve(quoteFrame(decodeQuote(ref), t))
146
+ }
147
+ });
88
148
  const findComposer = () => {
89
149
  for (const selector of COMPOSER_SELECTORS) {
90
150
  const element = document.querySelector(selector);
@@ -93,6 +153,52 @@ window.__ModuleLoader__.load({
93
153
  }
94
154
  return null;
95
155
  };
156
+ /**
157
+ * Insert through DSH's registered ReferenceChipNode so the quote uses the
158
+ * exact same atomic editor entity and visual treatment as @file/@session.
159
+ * The node class is already registered on the live composer; no second
160
+ * Lexical or React runtime is loaded by this plugin.
161
+ */
162
+ const appendQuoteChip = (element, payload, t) => {
163
+ const editor = element.__lexicalEditor;
164
+ const registry = editor?._nodes;
165
+ const ChipNode = registry?.get?.("reference-chip")?.klass;
166
+ const TextNode = registry?.get?.("text")?.klass;
167
+ const ParagraphNode = registry?.get?.("paragraph")?.klass;
168
+ if (typeof editor?.update !== "function" || typeof ChipNode !== "function" || typeof TextNode !== "function" || typeof ParagraphNode !== "function")
169
+ return false;
170
+ try {
171
+ let inserted = false;
172
+ editor.update(() => {
173
+ const state = editor._pendingEditorState ?? editor._editorState;
174
+ const root = state?._nodeMap?.get?.("root");
175
+ if (root === void 0 || root === null || typeof root.append !== "function")
176
+ return;
177
+ let block = typeof root.getLastChild === "function" ? root.getLastChild() : null;
178
+ if (block === null || typeof block.append !== "function") {
179
+ block = new ParagraphNode();
180
+ root.append(block);
181
+ }
182
+ const nodes = [];
183
+ const tail = typeof block.getTextContent === "function" ? block.getTextContent().slice(-1) : "";
184
+ if (tail !== "" && !/\s/u.test(tail))
185
+ nodes.push(new TextNode(" "));
186
+ nodes.push(new ChipNode(quoteInsert(payload, t)));
187
+ const trailing = new TextNode(" ");
188
+ nodes.push(trailing);
189
+ block.append(...nodes);
190
+ if (typeof trailing.selectEnd === "function")
191
+ trailing.selectEnd();
192
+ inserted = true;
193
+ }, { discrete: true });
194
+ if (inserted)
195
+ element.focus();
196
+ return inserted;
197
+ } catch (error) {
198
+ console.warn("[dsh-quote-followup] native reference chip failed; using text fallback", error);
199
+ return false;
200
+ }
201
+ };
96
202
  /**
97
203
  * Use the live Lexical command table when DSH exposes its editor on the root.
98
204
  * This bypasses browser security differences around synthetic clipboardData
@@ -198,6 +304,8 @@ window.__ModuleLoader__.load({
198
304
  //#endregion
199
305
  //#region floating button
200
306
  let pendingQuote = null;
307
+ let quoteSourceReady = false;
308
+ let currentT = fallbackT;
201
309
  const ensureButton = () => {
202
310
  const existing = document.getElementById(BUTTON_ID);
203
311
  if (existing?.getAttribute(BUTTON_VERSION_ATTR) === CLIENT_VERSION)
@@ -207,8 +315,8 @@ window.__ModuleLoader__.load({
207
315
  button.id = BUTTON_ID;
208
316
  button.setAttribute(BUTTON_VERSION_ATTR, CLIENT_VERSION);
209
317
  button.type = "button";
210
- button.textContent = "❐ 引用";
211
- button.title = "引用选中内容到输入框(可多次引用,发送前可编辑)";
318
+ button.textContent = currentT("button.label");
319
+ button.title = currentT("button.title");
212
320
  Object.assign(button.style, {
213
321
  position: "fixed",
214
322
  zIndex: "2147483600",
@@ -287,7 +395,9 @@ window.__ModuleLoader__.load({
287
395
  // Clear the transcript range BEFORE focusing the composer. Clearing after
288
396
  // insertion destroys Lexical's caret and makes the next quote unreliable.
289
397
  document.getSelection()?.removeAllRanges();
290
- appendToComposer(composer, quoteFrame(quote.text, quote.role));
398
+ const payload = quotePayload(quote.text, quote.role);
399
+ if (!(quoteSourceReady && appendQuoteChip(composer, payload, currentT)))
400
+ appendToComposer(composer, quoteFrame(payload, currentT));
291
401
  };
292
402
  //#endregion
293
403
  //#region apply
@@ -298,6 +408,40 @@ window.__ModuleLoader__.load({
298
408
  return;
299
409
  if (typeof previous?.dispose === "function")
300
410
  previous.dispose();
411
+ let unregisterSource = null;
412
+ let unregisterLocale = null;
413
+ let unsubscribeLocale = null;
414
+ const refreshButtonCopy = () => {
415
+ const button = document.getElementById(BUTTON_ID);
416
+ if (button !== null) {
417
+ button.textContent = currentT("button.label");
418
+ button.title = currentT("button.title");
419
+ }
420
+ };
421
+ try {
422
+ const locale = typeof ctx?.get === "function" ? ctx.get("locale") : null;
423
+ if (typeof locale?.register === "function" && typeof locale?.bind === "function") {
424
+ unregisterLocale = locale.register(LOCALE_NS, LOCALE_DICT);
425
+ currentT = locale.bind(LOCALE_NS);
426
+ if (typeof locale.subscribe === "function") {
427
+ unsubscribeLocale = locale.subscribe(refreshButtonCopy);
428
+ }
429
+ } else {
430
+ currentT = fallbackT;
431
+ }
432
+ } catch (error) {
433
+ console.warn("[dsh-quote-followup] locale unavailable; using English copy", error);
434
+ currentT = fallbackT;
435
+ }
436
+ try {
437
+ const inputTriggers = typeof ctx?.get === "function" ? ctx.get("inputTriggers") : null;
438
+ if (typeof inputTriggers?.registerSource === "function") {
439
+ unregisterSource = inputTriggers.registerSource(makeQuoteSource(currentT));
440
+ quoteSourceReady = true;
441
+ }
442
+ } catch (error) {
443
+ console.warn("[dsh-quote-followup] native reference codec unavailable; using text fallback", error);
444
+ }
301
445
  const onScroll = () => hideButton();
302
446
  const onKeyDown = (event) => {
303
447
  if (event.key === "Escape")
@@ -313,6 +457,14 @@ window.__ModuleLoader__.load({
313
457
  if (disposed)
314
458
  return;
315
459
  disposed = true;
460
+ quoteSourceReady = false;
461
+ if (typeof unsubscribeLocale === "function")
462
+ unsubscribeLocale();
463
+ if (typeof unregisterLocale === "function")
464
+ unregisterLocale();
465
+ currentT = fallbackT;
466
+ if (typeof unregisterSource === "function")
467
+ unregisterSource();
316
468
  document.removeEventListener("selectionchange", onSelectionChange);
317
469
  window.removeEventListener("scroll", onScroll, true);
318
470
  document.removeEventListener("keydown", onKeyDown, true);
@@ -336,7 +488,7 @@ window.__ModuleLoader__.load({
336
488
  ctx.effect(() => cleanup, "dsh-quote-followup: selection quote ui");
337
489
  }
338
490
  //#endregion
339
- exports.inject = [];
491
+ exports.inject = ["inputTriggers", "locale"];
340
492
  exports.apply = apply;
341
493
  return module.exports;
342
494
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-quote-followup",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Quote selected Web conversation text into the DeepSeek Harness composer for targeted follow-up turns.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -28,6 +28,7 @@
28
28
  "files": [
29
29
  "lib",
30
30
  "cordis.patch.yml",
31
+ "docs",
31
32
  "README.md",
32
33
  "README.zh.md"
33
34
  ],
@@ -44,13 +45,15 @@
44
45
  },
45
46
  "dsh": {
46
47
  "engines": {
47
- "dsh": ">=0.1.1-rc.1"
48
+ "dsh": ">=0.1.2-rc.1"
48
49
  },
49
50
  "bundle": {
50
51
  "patch": "./cordis.patch.yml"
51
52
  },
52
53
  "client": {
53
- "inject": [],
54
+ "inject": [
55
+ "@deepseek-ai/dsh-client-ui-input-trigger"
56
+ ],
54
57
  "platform": "web"
55
58
  }
56
59
  }