dsh-quote-followup 0.2.3 → 0.2.5

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,20 +2,41 @@
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
19
  - Append a native DSH conversation-reference chip without replacing the existing draft.
11
20
  - Reuse the same atomic `ReferenceChipNode`, conversation icon, and business color as `@file` / `@session`.
21
+ - Keep each chip compact by showing only the excerpt; the bubble icon already conveys that it is a quote.
22
+ - Follow the active DSH locale for the floating action and serialized quote frame.
23
+ - Attach the conversation turn number to the serialized frame when the selection comes from a DSH chat row, so follow-ups like "revisit turn 3" stay resolvable. Selections without a turn marker keep the provenance-free frame.
12
24
  - Quote multiple excerpts; on send, the plugin codec expands each chip into a model-readable Markdown blockquote.
13
25
  - Fall back to a plain-text quote when the host lacks native chip support.
14
26
 
15
27
  TUI is intentionally unsupported.
16
28
 
29
+ ## Scope
30
+
31
+ This is a Web client-side extension. The composer and send path are owned by the browser client: chips exist only in the Lexical editor, and the model only ever sees the Markdown blockquotes the codec expands at send time — never the chips themselves. No host-side plugin surface is involved.
32
+
33
+ Chips are text-only by design. They carry the excerpt, a display-only role hint, the conversation turn ordinal when it is available, and a truncation flag — no global session-message references — so they stay valid across compaction folds and session rotation.
34
+
35
+
17
36
  ## Install
18
37
 
38
+ Requires DSH `>=0.1.2-rc.1`.
39
+
19
40
  ```bash
20
41
  dsh plugin --profile web add dsh-quote-followup
21
42
  ```
@@ -35,7 +56,7 @@ Add `dsh-quote-followup` to the Web profile's `dsh.profile.bundles`, then restar
35
56
  npm test
36
57
  ```
37
58
 
38
- 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.
59
+ The regression harness covers compact native quote chips, locale switching, turn-number provenance with graceful degradation, 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.
39
60
 
40
61
  ## License
41
62
 
package/README.zh.md CHANGED
@@ -2,20 +2,41 @@
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
19
  - 以 DSH 原生对话引用 chip 追加到现有草稿,不覆盖已输入内容。
11
20
  - chip 使用与 `@文件` / `@对话` 相同的 `ReferenceChipNode`、对话图标和业务色,可整体删除。
21
+ - chip 只展示摘录正文,引用语义由气泡图标表达,减少重复标签。
22
+ - 浮动按钮和发送时的引用框架跟随 DSH 当前语言。
23
+ - 选中内容来自 DSH 聊天行时,序列化引用会附带对话轮次编号,模型可定位"第 3 轮引用的内容";无轮次标记时保持原有框架。
12
24
  - 可连续引用多段内容;发送时由插件 codec 将各 chip 展开为模型可读的 Markdown 引用块。
13
25
  - 旧版 DSH 缺少原生 chip 能力时,自动降级为纯文本引用。
14
26
 
15
27
  本插件不再适配 TUI。
16
28
 
29
+ ## 边界
30
+
31
+ 这是一个 Web 客户端侧扩展。composer 和发送路径由浏览器 client 拥有:chip 只存在于 Lexical 编辑器中,模型只会看到发送时 codec 展开的 Markdown 引用块,永远不会看到 chip 本身,不涉及任何 host 侧插件接口。
32
+
33
+ chip 刻意只存文本:包含摘录正文、仅供显示的角色提示、可用的对话轮次编号和截断标记,不含全局 session-message 引用,因此在 compaction 折叠与会话轮转后依然有效。
34
+
35
+
17
36
  ## 安装
18
37
 
38
+ 要求 DSH `>=0.1.2-rc.1`。
39
+
19
40
  ```bash
20
41
  dsh plugin --profile web add dsh-quote-followup
21
42
  ```
@@ -35,7 +56,7 @@ dsh plugin --profile web add dsh-quote-followup
35
56
  npm test
36
57
  ```
37
58
 
38
- 回归测试覆盖原生引用 chip、连续引用、已有草稿后的间距、codec 序列化、Firefox 文本降级路径,以及热替换后新版 client 接管旧按钮/单例状态。
59
+ 回归测试覆盖精简的原生引用 chip、中英文 locale 切换、轮次溯源与降级、连续引用、已有草稿后的间距、codec 序列化、Firefox 文本降级路径,以及热替换后新版 client 接管旧按钮/单例状态。
39
60
 
40
61
  ## 许可证
41
62
 
package/lib/client.js CHANGED
@@ -33,9 +33,30 @@ 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.3";
36
+ const CLIENT_VERSION = "0.2.5";
37
37
  /** Codec owner for native DSH reference chips. */
38
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.turn": "第 {n} 轮",
48
+ "quote.truncated": "已截断"
49
+ },
50
+ en: {
51
+ "button.label": "❐ Quote",
52
+ "button.title": "Quote the selection into the composer (repeatable; editable before sending)",
53
+ "quote.header": "Quote",
54
+ "conversation.role": "conversation",
55
+ "quote.turn": "turn {n}",
56
+ "quote.truncated": "truncated"
57
+ }
58
+ };
59
+ const fallbackT = (key) => LOCALE_DICT.en[key] ?? key;
39
60
  /** Per-quote character cap for a bounded composer insertion. */
40
61
  const QUOTE_MAX_CHARS = 1600;
41
62
  //#endregion
@@ -75,21 +96,46 @@ window.__ModuleLoader__.load({
75
96
  }
76
97
  return null;
77
98
  };
78
- const roleLabel = (role) => role === "user" ? "user" : role === "assistant" ? "assistant" : "对话";
99
+ const roleLabel = (role, t) => role === "user" ? "user" : role === "assistant" ? "assistant" : t("conversation.role");
100
+ /**
101
+ * Conversation-turn provenance: DSH's chat renderer stamps every message
102
+ * row with `data-chat-turn` (a per-session monotonic ordinal). Resolving
103
+ * it lets the model address "what you quoted in turn N" without any
104
+ * host-side message-id API. Absent / non-numeric markers degrade to null
105
+ * and the serialized frame stays exactly as before.
106
+ */
107
+ const detectTurn = (range, transcript) => {
108
+ let element = elementOf(range.startContainer);
109
+ while (element !== null && element !== transcript) {
110
+ const raw = element.getAttribute?.("data-chat-turn");
111
+ if (typeof raw === "string" && raw !== "") {
112
+ const turn = Number(raw);
113
+ if (Number.isSafeInteger(turn) && turn >= 0)
114
+ return turn;
115
+ }
116
+ element = element.parentElement;
117
+ }
118
+ return null;
119
+ };
79
120
  /** Bound one quote before it becomes editor state. */
80
- const quotePayload = (text, role) => {
121
+ const quotePayload = (text, role, turn) => {
81
122
  let body = String(text ?? "");
82
123
  let truncated = false;
83
124
  if (body.length > QUOTE_MAX_CHARS) {
84
125
  body = body.slice(0, QUOTE_MAX_CHARS);
85
126
  truncated = true;
86
127
  }
87
- return { text: body, role, truncated };
128
+ return { text: body, role, truncated, turn: Number.isSafeInteger(turn) && turn >= 0 ? turn : null };
88
129
  };
89
130
  /** Model / clipboard projection. The composer shows a chip, not this frame. */
90
- const quoteFrame = ({ text, role, truncated }) => {
131
+ const quoteFrame = ({ text, role, truncated, turn }, t) => {
91
132
  const lines = text.split("\n").map((line) => `> ${line}`.trimEnd());
92
- return `> [引用 · ${roleLabel(role)}${truncated ? ",已截断" : ""}]\n${lines.join("\n")}\n\n`;
133
+ const meta = [t("quote.header"), roleLabel(role, t)];
134
+ if (Number.isSafeInteger(turn) && turn >= 0)
135
+ meta.push(String(t("quote.turn")).replace("{n}", String(turn)));
136
+ if (truncated)
137
+ meta.push(t("quote.truncated"));
138
+ return `> [${meta.join(" · ")}]\n${lines.join("\n")}\n\n`;
93
139
  };
94
140
  const decodeQuote = (ref) => {
95
141
  const value = JSON.parse(ref);
@@ -98,32 +144,35 @@ window.__ModuleLoader__.load({
98
144
  return {
99
145
  text: value.text,
100
146
  role: value.role === "user" || value.role === "assistant" ? value.role : null,
101
- truncated: value.truncated === true
147
+ truncated: value.truncated === true,
148
+ turn: Number.isSafeInteger(value.turn) && value.turn >= 0 ? value.turn : null
102
149
  };
103
150
  };
104
- const quoteInsert = (payload) => {
105
- const clipboardText = quoteFrame(payload);
151
+ const quoteInsert = (payload, t) => {
152
+ const clipboardText = quoteFrame(payload, t);
106
153
  const preview = payload.text.replace(/\s+/g, " ").trim();
107
154
  return {
108
155
  source: QUOTE_SOURCE,
109
156
  ref: JSON.stringify(payload),
110
- label: `引用 · ${roleLabel(payload.role)}: ${preview}${payload.truncated ? "…" : ""}`,
157
+ // The bubble icon carries the quote meaning; keep only the excerpt
158
+ // visible. Role/truncation stay in the serialized Markdown frame.
159
+ label: preview,
111
160
  appearance: "session",
112
161
  clipboardText
113
162
  };
114
163
  };
115
164
  /** Codec-only source: it owns quote chips but intentionally adds no @ candidates. */
116
- const QUOTE_TRIGGER_SOURCE = {
165
+ const makeQuoteSource = (t) => ({
117
166
  trigger: "@",
118
167
  name: QUOTE_SOURCE,
119
168
  order: 1000,
120
169
  candidates: () => Promise.resolve([]),
121
170
  onPick: () => void 0,
122
171
  codec: {
123
- clipboardText: (ref) => quoteFrame(decodeQuote(ref)),
124
- serialize: (ref) => Promise.resolve(quoteFrame(decodeQuote(ref)))
172
+ clipboardText: (ref) => quoteFrame(decodeQuote(ref), t),
173
+ serialize: (ref) => Promise.resolve(quoteFrame(decodeQuote(ref), t))
125
174
  }
126
- };
175
+ });
127
176
  const findComposer = () => {
128
177
  for (const selector of COMPOSER_SELECTORS) {
129
178
  const element = document.querySelector(selector);
@@ -138,7 +187,7 @@ window.__ModuleLoader__.load({
138
187
  * The node class is already registered on the live composer; no second
139
188
  * Lexical or React runtime is loaded by this plugin.
140
189
  */
141
- const appendQuoteChip = (element, payload) => {
190
+ const appendQuoteChip = (element, payload, t) => {
142
191
  const editor = element.__lexicalEditor;
143
192
  const registry = editor?._nodes;
144
193
  const ChipNode = registry?.get?.("reference-chip")?.klass;
@@ -162,7 +211,7 @@ window.__ModuleLoader__.load({
162
211
  const tail = typeof block.getTextContent === "function" ? block.getTextContent().slice(-1) : "";
163
212
  if (tail !== "" && !/\s/u.test(tail))
164
213
  nodes.push(new TextNode(" "));
165
- nodes.push(new ChipNode(quoteInsert(payload)));
214
+ nodes.push(new ChipNode(quoteInsert(payload, t)));
166
215
  const trailing = new TextNode(" ");
167
216
  nodes.push(trailing);
168
217
  block.append(...nodes);
@@ -284,6 +333,7 @@ window.__ModuleLoader__.load({
284
333
  //#region floating button
285
334
  let pendingQuote = null;
286
335
  let quoteSourceReady = false;
336
+ let currentT = fallbackT;
287
337
  const ensureButton = () => {
288
338
  const existing = document.getElementById(BUTTON_ID);
289
339
  if (existing?.getAttribute(BUTTON_VERSION_ATTR) === CLIENT_VERSION)
@@ -293,8 +343,8 @@ window.__ModuleLoader__.load({
293
343
  button.id = BUTTON_ID;
294
344
  button.setAttribute(BUTTON_VERSION_ATTR, CLIENT_VERSION);
295
345
  button.type = "button";
296
- button.textContent = "❐ 引用";
297
- button.title = "引用选中内容到输入框(可多次引用,发送前可编辑)";
346
+ button.textContent = currentT("button.label");
347
+ button.title = currentT("button.title");
298
348
  Object.assign(button.style, {
299
349
  position: "fixed",
300
350
  zIndex: "2147483600",
@@ -356,7 +406,11 @@ window.__ModuleLoader__.load({
356
406
  hideButton();
357
407
  return;
358
408
  }
359
- pendingQuote = { text: text.trim(), role: detectRole(selection.getRangeAt(0), transcript) };
409
+ pendingQuote = {
410
+ text: text.trim(),
411
+ role: detectRole(selection.getRangeAt(0), transcript),
412
+ turn: detectTurn(selection.getRangeAt(0), transcript)
413
+ };
360
414
  placeButton(ensureButton(), rect);
361
415
  };
362
416
  const onQuoteClick = () => {
@@ -373,9 +427,9 @@ window.__ModuleLoader__.load({
373
427
  // Clear the transcript range BEFORE focusing the composer. Clearing after
374
428
  // insertion destroys Lexical's caret and makes the next quote unreliable.
375
429
  document.getSelection()?.removeAllRanges();
376
- const payload = quotePayload(quote.text, quote.role);
377
- if (!(quoteSourceReady && appendQuoteChip(composer, payload)))
378
- appendToComposer(composer, quoteFrame(payload));
430
+ const payload = quotePayload(quote.text, quote.role, quote.turn);
431
+ if (!(quoteSourceReady && appendQuoteChip(composer, payload, currentT)))
432
+ appendToComposer(composer, quoteFrame(payload, currentT));
379
433
  };
380
434
  //#endregion
381
435
  //#region apply
@@ -387,10 +441,34 @@ window.__ModuleLoader__.load({
387
441
  if (typeof previous?.dispose === "function")
388
442
  previous.dispose();
389
443
  let unregisterSource = null;
444
+ let unregisterLocale = null;
445
+ let unsubscribeLocale = null;
446
+ const refreshButtonCopy = () => {
447
+ const button = document.getElementById(BUTTON_ID);
448
+ if (button !== null) {
449
+ button.textContent = currentT("button.label");
450
+ button.title = currentT("button.title");
451
+ }
452
+ };
453
+ try {
454
+ const locale = typeof ctx?.get === "function" ? ctx.get("locale") : null;
455
+ if (typeof locale?.register === "function" && typeof locale?.bind === "function") {
456
+ unregisterLocale = locale.register(LOCALE_NS, LOCALE_DICT);
457
+ currentT = locale.bind(LOCALE_NS);
458
+ if (typeof locale.subscribe === "function") {
459
+ unsubscribeLocale = locale.subscribe(refreshButtonCopy);
460
+ }
461
+ } else {
462
+ currentT = fallbackT;
463
+ }
464
+ } catch (error) {
465
+ console.warn("[dsh-quote-followup] locale unavailable; using English copy", error);
466
+ currentT = fallbackT;
467
+ }
390
468
  try {
391
469
  const inputTriggers = typeof ctx?.get === "function" ? ctx.get("inputTriggers") : null;
392
470
  if (typeof inputTriggers?.registerSource === "function") {
393
- unregisterSource = inputTriggers.registerSource(QUOTE_TRIGGER_SOURCE);
471
+ unregisterSource = inputTriggers.registerSource(makeQuoteSource(currentT));
394
472
  quoteSourceReady = true;
395
473
  }
396
474
  } catch (error) {
@@ -412,6 +490,11 @@ window.__ModuleLoader__.load({
412
490
  return;
413
491
  disposed = true;
414
492
  quoteSourceReady = false;
493
+ if (typeof unsubscribeLocale === "function")
494
+ unsubscribeLocale();
495
+ if (typeof unregisterLocale === "function")
496
+ unregisterLocale();
497
+ currentT = fallbackT;
415
498
  if (typeof unregisterSource === "function")
416
499
  unregisterSource();
417
500
  document.removeEventListener("selectionchange", onSelectionChange);
@@ -437,7 +520,7 @@ window.__ModuleLoader__.load({
437
520
  ctx.effect(() => cleanup, "dsh-quote-followup: selection quote ui");
438
521
  }
439
522
  //#endregion
440
- exports.inject = ["inputTriggers"];
523
+ exports.inject = ["inputTriggers", "locale"];
441
524
  exports.apply = apply;
442
525
  return module.exports;
443
526
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-quote-followup",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
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
  ],