dsh-quote-followup 0.2.4 → 0.2.6

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
@@ -18,11 +18,21 @@ A Web-only [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) p
18
18
  - Select text inside the Web conversation transcript to reveal a floating **Quote** button.
19
19
  - Append a native DSH conversation-reference chip without replacing the existing draft.
20
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.
21
24
  - Quote multiple excerpts; on send, the plugin codec expands each chip into a model-readable Markdown blockquote.
22
25
  - Fall back to a plain-text quote when the host lacks native chip support.
23
26
 
24
27
  TUI is intentionally unsupported.
25
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
+
26
36
  ## Install
27
37
 
28
38
  Requires DSH `>=0.1.2-rc.1`.
@@ -46,7 +56,7 @@ Add `dsh-quote-followup` to the Web profile's `dsh.profile.bundles`, then restar
46
56
  npm test
47
57
  ```
48
58
 
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.
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.
50
60
 
51
61
  ## License
52
62
 
package/README.zh.md CHANGED
@@ -18,11 +18,21 @@
18
18
  - 在 Web 对话区选中文本后显示浮动的 **❐ 引用** 按钮。
19
19
  - 以 DSH 原生对话引用 chip 追加到现有草稿,不覆盖已输入内容。
20
20
  - chip 使用与 `@文件` / `@对话` 相同的 `ReferenceChipNode`、对话图标和业务色,可整体删除。
21
+ - chip 只展示摘录正文,引用语义由气泡图标表达,减少重复标签。
22
+ - 浮动按钮和发送时的引用框架跟随 DSH 当前语言。
23
+ - 选中内容来自 DSH 聊天行时,序列化引用会附带对话轮次编号,模型可定位"第 3 轮引用的内容";无轮次标记时保持原有框架。
21
24
  - 可连续引用多段内容;发送时由插件 codec 将各 chip 展开为模型可读的 Markdown 引用块。
22
25
  - 旧版 DSH 缺少原生 chip 能力时,自动降级为纯文本引用。
23
26
 
24
27
  本插件不再适配 TUI。
25
28
 
29
+ ## 边界
30
+
31
+ 这是一个 Web 客户端侧扩展。composer 和发送路径由浏览器 client 拥有:chip 只存在于 Lexical 编辑器中,模型只会看到发送时 codec 展开的 Markdown 引用块,永远不会看到 chip 本身,不涉及任何 host 侧插件接口。
32
+
33
+ chip 刻意只存文本:包含摘录正文、仅供显示的角色提示、可用的对话轮次编号和截断标记,不含全局 session-message 引用,因此在 compaction 折叠与会话轮转后依然有效。
34
+
35
+
26
36
  ## 安装
27
37
 
28
38
  要求 DSH `>=0.1.2-rc.1`。
@@ -46,7 +56,7 @@ dsh plugin --profile web add dsh-quote-followup
46
56
  npm test
47
57
  ```
48
58
 
49
- 回归测试覆盖原生引用 chip、连续引用、已有草稿后的间距、codec 序列化、Firefox 文本降级路径,以及热替换后新版 client 接管旧按钮/单例状态。
59
+ 回归测试覆盖精简的原生引用 chip、中英文 locale 切换、轮次溯源与降级、连续引用、已有草稿后的间距、codec 序列化、Firefox 文本降级路径,以及热替换后新版 client 接管旧按钮/单例状态。
50
60
 
51
61
  ## 许可证
52
62
 
Binary file
package/lib/client.js CHANGED
@@ -7,9 +7,11 @@
7
7
  * can edit before sending — a visible, targeted follow-up with zero hidden
8
8
  * state and zero host-side privileges.
9
9
  *
10
- * Pure DOM (no React), so the module carries no platform dependencies:
11
- * `inject: []`. Mounted by the web client runtime through the package's
12
- * `dsh.client` declaration (`platform: "web"`).
10
+ * Pure DOM (no React). The only injected host services are DSH's
11
+ * `inputTriggers` codec registry and `locale` (both optional the module
12
+ * degrades to English text fallbacks when either is absent). Mounted by the
13
+ * web client runtime through the package's `dsh.client` declaration
14
+ * (`platform: "web"`).
13
15
  *
14
16
  * @module dsh-quote-followup/client
15
17
  */
@@ -33,7 +35,7 @@ window.__ModuleLoader__.load({
33
35
  ];
34
36
  const BUTTON_ID = "dsh-quote-followup-btn";
35
37
  const BUTTON_VERSION_ATTR = "data-dsh-quote-followup-version";
36
- const CLIENT_VERSION = "0.2.4";
38
+ const CLIENT_VERSION = "0.2.6";
37
39
  /** Codec owner for native DSH reference chips. */
38
40
  const QUOTE_SOURCE = "quote-followup";
39
41
  const LOCALE_NS = "quote-followup";
@@ -44,14 +46,20 @@ window.__ModuleLoader__.load({
44
46
  "button.title": "引用选中内容到输入框(可多次引用,发送前可编辑)",
45
47
  "quote.header": "引用",
46
48
  "conversation.role": "对话",
47
- "quote.truncated": ",已截断"
49
+ "conversation.role.user": "用户",
50
+ "conversation.role.assistant": "助手",
51
+ "quote.turn": "第 {n} 轮",
52
+ "quote.truncated": "已截断"
48
53
  },
49
54
  en: {
50
55
  "button.label": "❐ Quote",
51
56
  "button.title": "Quote the selection into the composer (repeatable; editable before sending)",
52
57
  "quote.header": "Quote",
53
58
  "conversation.role": "conversation",
54
- "quote.truncated": ", truncated"
59
+ "conversation.role.user": "user",
60
+ "conversation.role.assistant": "assistant",
61
+ "quote.turn": "turn {n}",
62
+ "quote.truncated": "truncated"
55
63
  }
56
64
  };
57
65
  const fallbackT = (key) => LOCALE_DICT.en[key] ?? key;
@@ -64,6 +72,19 @@ window.__ModuleLoader__.load({
64
72
  const element = elementOf(node);
65
73
  return element === null ? null : element.closest(TRANSCRIPT_SELECTOR);
66
74
  };
75
+ /** Walk from a range endpoint up to (but excluding) the transcript. */
76
+ const walkAncestors = (node, transcript, visit) => {
77
+ let element = elementOf(node);
78
+ while (element !== null && element !== transcript) {
79
+ const found = visit(element);
80
+ if (found !== undefined)
81
+ return found;
82
+ element = element.parentElement;
83
+ }
84
+ return null;
85
+ };
86
+ /** A turn marker is only meaningful as a non-negative safe integer. */
87
+ const isValidTurn = (turn) => Number.isSafeInteger(turn) && turn >= 0;
67
88
  /** The live selection when BOTH ends sit inside the same transcript. */
68
89
  const selectionInTranscript = (selection) => {
69
90
  if (selection === null || selection.isCollapsed || selection.rangeCount === 0)
@@ -79,9 +100,8 @@ window.__ModuleLoader__.load({
79
100
  * looking for role markers (data attributes, then class-name hints).
80
101
  * Null → the generic "对话" label; this is display-only metadata.
81
102
  */
82
- const detectRole = (range, transcript) => {
83
- let element = elementOf(range.startContainer);
84
- while (element !== null && element !== transcript) {
103
+ const detectRole = (range, transcript) =>
104
+ walkAncestors(range.startContainer, transcript, (element) => {
85
105
  const role = element.getAttribute?.("data-role") ?? element.getAttribute?.("data-message-role");
86
106
  if (typeof role === "string" && role !== "") {
87
107
  if (/user|human/i.test(role)) return "user";
@@ -90,35 +110,60 @@ window.__ModuleLoader__.load({
90
110
  const cls = String(element.className ?? "");
91
111
  if (/(^|[\s_-])user([\s_-]|$)/i.test(cls)) return "user";
92
112
  if (/(^|[\s_-])assistant([\s_-]|$)/i.test(cls)) return "assistant";
93
- element = element.parentElement;
94
- }
95
- return null;
96
- };
97
- const roleLabel = (role, t) => role === "user" ? "user" : role === "assistant" ? "assistant" : t("conversation.role");
113
+ return undefined;
114
+ });
115
+ const roleLabel = (role, t) => role === "user" ? t("conversation.role.user") : role === "assistant" ? t("conversation.role.assistant") : t("conversation.role");
116
+ /**
117
+ * Conversation-turn provenance: DSH's chat renderer stamps every message
118
+ * row with `data-chat-turn` (a per-session monotonic ordinal). Resolving
119
+ * it lets the model address "what you quoted in turn N" without any
120
+ * host-side message-id API. Absent / non-numeric markers degrade to null
121
+ * and the serialized frame stays exactly as before.
122
+ */
123
+ const detectTurn = (range, transcript) =>
124
+ walkAncestors(range.startContainer, transcript, (element) => {
125
+ const raw = element.getAttribute?.("data-chat-turn");
126
+ if (typeof raw === "string" && raw !== "") {
127
+ const turn = Number(raw);
128
+ if (isValidTurn(turn))
129
+ return turn;
130
+ }
131
+ return undefined;
132
+ });
98
133
  /** Bound one quote before it becomes editor state. */
99
- const quotePayload = (text, role) => {
134
+ const quotePayload = (text, role, turn) => {
100
135
  let body = String(text ?? "");
101
136
  let truncated = false;
102
137
  if (body.length > QUOTE_MAX_CHARS) {
103
138
  body = body.slice(0, QUOTE_MAX_CHARS);
104
139
  truncated = true;
105
140
  }
106
- return { text: body, role, truncated };
141
+ return { text: body, role, truncated, turn: isValidTurn(turn) ? turn : null };
107
142
  };
108
143
  /** Model / clipboard projection. The composer shows a chip, not this frame. */
109
- const quoteFrame = ({ text, role, truncated }, t) => {
144
+ const quoteFrame = ({ text, role, truncated, turn }, t) => {
110
145
  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`;
146
+ const meta = [t("quote.header"), roleLabel(role, t)];
147
+ if (isValidTurn(turn))
148
+ meta.push(String(t("quote.turn")).replace("{n}", String(turn)));
149
+ if (truncated)
150
+ meta.push(t("quote.truncated"));
151
+ return `> [${meta.join(" · ")}]\n${lines.join("\n")}\n\n`;
112
152
  };
113
153
  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
- };
154
+ try {
155
+ const value = JSON.parse(ref);
156
+ if (value === null || typeof value !== "object" || typeof value.text !== "string")
157
+ return null;
158
+ return {
159
+ text: value.text,
160
+ role: value.role === "user" || value.role === "assistant" ? value.role : null,
161
+ truncated: value.truncated === true,
162
+ turn: isValidTurn(value.turn) ? value.turn : null
163
+ };
164
+ } catch {
165
+ return null;
166
+ }
122
167
  };
123
168
  const quoteInsert = (payload, t) => {
124
169
  const clipboardText = quoteFrame(payload, t);
@@ -134,17 +179,24 @@ window.__ModuleLoader__.load({
134
179
  };
135
180
  };
136
181
  /** 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
- });
182
+ const makeQuoteSource = (t) => {
183
+ /** Project a chip ref to Markdown, degrading to "" for malformed refs. */
184
+ const project = (ref) => {
185
+ const payload = decodeQuote(ref);
186
+ return payload === null ? "" : quoteFrame(payload, t);
187
+ };
188
+ return {
189
+ trigger: "@",
190
+ name: QUOTE_SOURCE,
191
+ order: 1000,
192
+ candidates: () => Promise.resolve([]),
193
+ onPick: () => void 0,
194
+ codec: {
195
+ clipboardText: project,
196
+ serialize: (ref) => Promise.resolve(project(ref))
197
+ }
198
+ };
199
+ };
148
200
  const findComposer = () => {
149
201
  for (const selector of COMPOSER_SELECTORS) {
150
202
  const element = document.querySelector(selector);
@@ -378,7 +430,11 @@ window.__ModuleLoader__.load({
378
430
  hideButton();
379
431
  return;
380
432
  }
381
- pendingQuote = { text: text.trim(), role: detectRole(selection.getRangeAt(0), transcript) };
433
+ pendingQuote = {
434
+ text: text.trim(),
435
+ role: detectRole(selection.getRangeAt(0), transcript),
436
+ turn: detectTurn(selection.getRangeAt(0), transcript)
437
+ };
382
438
  placeButton(ensureButton(), rect);
383
439
  };
384
440
  const onQuoteClick = () => {
@@ -395,7 +451,7 @@ window.__ModuleLoader__.load({
395
451
  // Clear the transcript range BEFORE focusing the composer. Clearing after
396
452
  // insertion destroys Lexical's caret and makes the next quote unreliable.
397
453
  document.getSelection()?.removeAllRanges();
398
- const payload = quotePayload(quote.text, quote.role);
454
+ const payload = quotePayload(quote.text, quote.role, quote.turn);
399
455
  if (!(quoteSourceReady && appendQuoteChip(composer, payload, currentT)))
400
456
  appendToComposer(composer, quoteFrame(payload, currentT));
401
457
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-quote-followup",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Quote selected Web conversation text into the DeepSeek Harness composer for targeted follow-up turns.",
5
5
  "keywords": [
6
6
  "dsh",