dsh-quote-followup 0.2.5 → 0.2.7

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
@@ -4,6 +4,7 @@ English | [中文](README.zh.md)
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/dsh-quote-followup.svg)](https://www.npmjs.com/package/dsh-quote-followup)
6
6
  [![license](https://img.shields.io/npm/l/dsh-quote-followup.svg)](LICENSE)
7
+ [![DSH Market](https://raw.githubusercontent.com/2BingLing/dsh-market/master/assets/readme/badge-listed-en.svg)](https://dsh.market/)
7
8
 
8
9
  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.
9
10
 
@@ -58,6 +59,8 @@ npm test
58
59
 
59
60
  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.
60
61
 
62
+ An opt-in [assembled DSH Web regression](https://github.com/tr1v3r/dsh-quote-followup/blob/master/test/integration/README.md) runs the plugin through a pinned, built DSH checkout with Chromium. It covers existing drafts, repeated quoting, undo/redo, session isolation, prompt serialization, and Host-driven unavailable input.
63
+
61
64
  ## License
62
65
 
63
66
  MIT
package/README.zh.md CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/dsh-quote-followup.svg)](https://www.npmjs.com/package/dsh-quote-followup)
6
6
  [![license](https://img.shields.io/npm/l/dsh-quote-followup.svg)](LICENSE)
7
+ [![DSH Market](https://raw.githubusercontent.com/2BingLing/dsh-market/master/assets/readme/badge-listed-zh.svg)](https://dsh.market/)
7
8
 
8
9
  一个仅支持 Web 的 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:选中对话文本,将其追加到输入框,再进行针对性追问。
9
10
 
@@ -58,6 +59,8 @@ npm test
58
59
 
59
60
  回归测试覆盖精简的原生引用 chip、中英文 locale 切换、轮次溯源与降级、连续引用、已有草稿后的间距、codec 序列化、Firefox 文本降级路径,以及热替换后新版 client 接管旧按钮/单例状态。
60
61
 
62
+ 可选的[真实 DSH Web 集成回归](https://github.com/tr1v3r/dsh-quote-followup/blob/master/test/integration/README.md)通过固定版本、已构建的 DSH 和 Chromium 加载插件,验证已有草稿、连续引用、撤销重做、会话隔离、发送序列化及 Host 驱动的不可写输入状态。
63
+
61
64
  ## 许可证
62
65
 
63
66
  MIT
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,11 @@ 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.5";
38
+ const CLIENT_VERSION = "0.2.7";
39
+ /** Shared <style> element; versioned like the button so hot-swaps replace it. */
40
+ const STYLE_ID = "dsh-quote-followup-style";
41
+ const STYLE_VERSION_ATTR = "data-dsh-quote-followup-style-version";
42
+ const BUTTON_CLASS = "dsh-qf-btn";
37
43
  /** Codec owner for native DSH reference chips. */
38
44
  const QUOTE_SOURCE = "quote-followup";
39
45
  const LOCALE_NS = "quote-followup";
@@ -44,6 +50,8 @@ window.__ModuleLoader__.load({
44
50
  "button.title": "引用选中内容到输入框(可多次引用,发送前可编辑)",
45
51
  "quote.header": "引用",
46
52
  "conversation.role": "对话",
53
+ "conversation.role.user": "用户",
54
+ "conversation.role.assistant": "助手",
47
55
  "quote.turn": "第 {n} 轮",
48
56
  "quote.truncated": "已截断"
49
57
  },
@@ -52,6 +60,8 @@ window.__ModuleLoader__.load({
52
60
  "button.title": "Quote the selection into the composer (repeatable; editable before sending)",
53
61
  "quote.header": "Quote",
54
62
  "conversation.role": "conversation",
63
+ "conversation.role.user": "user",
64
+ "conversation.role.assistant": "assistant",
55
65
  "quote.turn": "turn {n}",
56
66
  "quote.truncated": "truncated"
57
67
  }
@@ -66,6 +76,19 @@ window.__ModuleLoader__.load({
66
76
  const element = elementOf(node);
67
77
  return element === null ? null : element.closest(TRANSCRIPT_SELECTOR);
68
78
  };
79
+ /** Walk from a range endpoint up to (but excluding) the transcript. */
80
+ const walkAncestors = (node, transcript, visit) => {
81
+ let element = elementOf(node);
82
+ while (element !== null && element !== transcript) {
83
+ const found = visit(element);
84
+ if (found !== undefined)
85
+ return found;
86
+ element = element.parentElement;
87
+ }
88
+ return null;
89
+ };
90
+ /** A turn marker is only meaningful as a non-negative safe integer. */
91
+ const isValidTurn = (turn) => Number.isSafeInteger(turn) && turn >= 0;
69
92
  /** The live selection when BOTH ends sit inside the same transcript. */
70
93
  const selectionInTranscript = (selection) => {
71
94
  if (selection === null || selection.isCollapsed || selection.rangeCount === 0)
@@ -81,9 +104,8 @@ window.__ModuleLoader__.load({
81
104
  * looking for role markers (data attributes, then class-name hints).
82
105
  * Null → the generic "对话" label; this is display-only metadata.
83
106
  */
84
- const detectRole = (range, transcript) => {
85
- let element = elementOf(range.startContainer);
86
- while (element !== null && element !== transcript) {
107
+ const detectRole = (range, transcript) =>
108
+ walkAncestors(range.startContainer, transcript, (element) => {
87
109
  const role = element.getAttribute?.("data-role") ?? element.getAttribute?.("data-message-role");
88
110
  if (typeof role === "string" && role !== "") {
89
111
  if (/user|human/i.test(role)) return "user";
@@ -92,11 +114,9 @@ window.__ModuleLoader__.load({
92
114
  const cls = String(element.className ?? "");
93
115
  if (/(^|[\s_-])user([\s_-]|$)/i.test(cls)) return "user";
94
116
  if (/(^|[\s_-])assistant([\s_-]|$)/i.test(cls)) return "assistant";
95
- element = element.parentElement;
96
- }
97
- return null;
98
- };
99
- const roleLabel = (role, t) => role === "user" ? "user" : role === "assistant" ? "assistant" : t("conversation.role");
117
+ return undefined;
118
+ });
119
+ const roleLabel = (role, t) => role === "user" ? t("conversation.role.user") : role === "assistant" ? t("conversation.role.assistant") : t("conversation.role");
100
120
  /**
101
121
  * Conversation-turn provenance: DSH's chat renderer stamps every message
102
122
  * row with `data-chat-turn` (a per-session monotonic ordinal). Resolving
@@ -104,19 +124,16 @@ window.__ModuleLoader__.load({
104
124
  * host-side message-id API. Absent / non-numeric markers degrade to null
105
125
  * and the serialized frame stays exactly as before.
106
126
  */
107
- const detectTurn = (range, transcript) => {
108
- let element = elementOf(range.startContainer);
109
- while (element !== null && element !== transcript) {
127
+ const detectTurn = (range, transcript) =>
128
+ walkAncestors(range.startContainer, transcript, (element) => {
110
129
  const raw = element.getAttribute?.("data-chat-turn");
111
130
  if (typeof raw === "string" && raw !== "") {
112
131
  const turn = Number(raw);
113
- if (Number.isSafeInteger(turn) && turn >= 0)
132
+ if (isValidTurn(turn))
114
133
  return turn;
115
134
  }
116
- element = element.parentElement;
117
- }
118
- return null;
119
- };
135
+ return undefined;
136
+ });
120
137
  /** Bound one quote before it becomes editor state. */
121
138
  const quotePayload = (text, role, turn) => {
122
139
  let body = String(text ?? "");
@@ -125,28 +142,32 @@ window.__ModuleLoader__.load({
125
142
  body = body.slice(0, QUOTE_MAX_CHARS);
126
143
  truncated = true;
127
144
  }
128
- return { text: body, role, truncated, turn: Number.isSafeInteger(turn) && turn >= 0 ? turn : null };
145
+ return { text: body, role, truncated, turn: isValidTurn(turn) ? turn : null };
129
146
  };
130
147
  /** Model / clipboard projection. The composer shows a chip, not this frame. */
131
148
  const quoteFrame = ({ text, role, truncated, turn }, t) => {
132
149
  const lines = text.split("\n").map((line) => `> ${line}`.trimEnd());
133
150
  const meta = [t("quote.header"), roleLabel(role, t)];
134
- if (Number.isSafeInteger(turn) && turn >= 0)
151
+ if (isValidTurn(turn))
135
152
  meta.push(String(t("quote.turn")).replace("{n}", String(turn)));
136
153
  if (truncated)
137
154
  meta.push(t("quote.truncated"));
138
155
  return `> [${meta.join(" · ")}]\n${lines.join("\n")}\n\n`;
139
156
  };
140
157
  const decodeQuote = (ref) => {
141
- const value = JSON.parse(ref);
142
- if (value === null || typeof value !== "object" || typeof value.text !== "string")
143
- throw new Error("invalid quote-followup reference");
144
- return {
145
- text: value.text,
146
- role: value.role === "user" || value.role === "assistant" ? value.role : null,
147
- truncated: value.truncated === true,
148
- turn: Number.isSafeInteger(value.turn) && value.turn >= 0 ? value.turn : null
149
- };
158
+ try {
159
+ const value = JSON.parse(ref);
160
+ if (value === null || typeof value !== "object" || typeof value.text !== "string")
161
+ return null;
162
+ return {
163
+ text: value.text,
164
+ role: value.role === "user" || value.role === "assistant" ? value.role : null,
165
+ truncated: value.truncated === true,
166
+ turn: isValidTurn(value.turn) ? value.turn : null
167
+ };
168
+ } catch {
169
+ return null;
170
+ }
150
171
  };
151
172
  const quoteInsert = (payload, t) => {
152
173
  const clipboardText = quoteFrame(payload, t);
@@ -162,17 +183,24 @@ window.__ModuleLoader__.load({
162
183
  };
163
184
  };
164
185
  /** Codec-only source: it owns quote chips but intentionally adds no @ candidates. */
165
- const makeQuoteSource = (t) => ({
166
- trigger: "@",
167
- name: QUOTE_SOURCE,
168
- order: 1000,
169
- candidates: () => Promise.resolve([]),
170
- onPick: () => void 0,
171
- codec: {
172
- clipboardText: (ref) => quoteFrame(decodeQuote(ref), t),
173
- serialize: (ref) => Promise.resolve(quoteFrame(decodeQuote(ref), t))
174
- }
175
- });
186
+ const makeQuoteSource = (t) => {
187
+ /** Project a chip ref to Markdown, degrading to "" for malformed refs. */
188
+ const project = (ref) => {
189
+ const payload = decodeQuote(ref);
190
+ return payload === null ? "" : quoteFrame(payload, t);
191
+ };
192
+ return {
193
+ trigger: "@",
194
+ name: QUOTE_SOURCE,
195
+ order: 1000,
196
+ candidates: () => Promise.resolve([]),
197
+ onPick: () => void 0,
198
+ codec: {
199
+ clipboardText: project,
200
+ serialize: (ref) => Promise.resolve(project(ref))
201
+ }
202
+ };
203
+ };
176
204
  const findComposer = () => {
177
205
  for (const selector of COMPOSER_SELECTORS) {
178
206
  const element = document.querySelector(selector);
@@ -334,7 +362,67 @@ window.__ModuleLoader__.load({
334
362
  let pendingQuote = null;
335
363
  let quoteSourceReady = false;
336
364
  let currentT = fallbackT;
365
+ /**
366
+ * Inject the button stylesheet once per client version. Visuals follow
367
+ * the dsh-annotation convention: a single idempotent <style> element
368
+ * built on DSH Web design tokens (--dsw-alias-* / --dsw-specific-*),
369
+ * so the pill tracks the active theme instead of hard-coded colors.
370
+ * The trailing fallbacks only engage when a token is renamed upstream.
371
+ * Without a real document.head (test fakes) the button still works,
372
+ * just unstyled.
373
+ */
374
+ const ensureStyles = () => {
375
+ const head = document.head;
376
+ if (head === null || typeof head.appendChild !== "function")
377
+ return;
378
+ const existing = document.getElementById(STYLE_ID);
379
+ if (existing !== null) {
380
+ if (existing.getAttribute(STYLE_VERSION_ATTR) === CLIENT_VERSION)
381
+ return;
382
+ existing.remove();
383
+ }
384
+ const style = document.createElement("style");
385
+ style.id = STYLE_ID;
386
+ style.setAttribute(STYLE_VERSION_ATTR, CLIENT_VERSION);
387
+ style.textContent = [
388
+ `.${BUTTON_CLASS} {`,
389
+ " position: fixed;",
390
+ " z-index: 2147483600;",
391
+ " display: inline-flex;",
392
+ " align-items: center;",
393
+ " gap: 6px;",
394
+ " height: 28px;",
395
+ " padding: 0 12px;",
396
+ " border: none;",
397
+ " border-radius: 14px;",
398
+ " background: var(--dsw-alias-button-primary-fill, rgba(56, 56, 62, .94));",
399
+ " color: var(--dsw-alias-label-primary-foreground, #f5f5f7);",
400
+ " font-family: var(--dsw-font-family, system-ui);",
401
+ " font-size: 12px;",
402
+ " line-height: 18px;",
403
+ " white-space: nowrap;",
404
+ " cursor: pointer;",
405
+ " box-shadow: var(--dsw-shadow-lv3, 0 2px 10px rgba(0, 0, 0, .28));",
406
+ " user-select: none;",
407
+ " animation: dsh-qf-pop .12s var(--ds-ease-in-out, ease);",
408
+ "}",
409
+ `.${BUTTON_CLASS}:hover:not(:disabled) {`,
410
+ " background: var(--dsw-alias-button-primary-hover, rgba(72, 72, 80, .96));",
411
+ "}",
412
+ `.${BUTTON_CLASS}:active { filter: brightness(.92); }`,
413
+ `.${BUTTON_CLASS}:focus-visible {`,
414
+ " outline: 2px solid var(--dsw-alias-text-accent, #4c9aff);",
415
+ " outline-offset: 1px;",
416
+ "}",
417
+ "@keyframes dsh-qf-pop {",
418
+ " from { opacity: 0; transform: translateY(3px); }",
419
+ " to { opacity: 1; transform: none; }",
420
+ "}"
421
+ ].join("\n");
422
+ head.appendChild(style);
423
+ };
337
424
  const ensureButton = () => {
425
+ ensureStyles();
338
426
  const existing = document.getElementById(BUTTON_ID);
339
427
  if (existing?.getAttribute(BUTTON_VERSION_ATTR) === CLIENT_VERSION)
340
428
  return existing;
@@ -343,23 +431,10 @@ window.__ModuleLoader__.load({
343
431
  button.id = BUTTON_ID;
344
432
  button.setAttribute(BUTTON_VERSION_ATTR, CLIENT_VERSION);
345
433
  button.type = "button";
434
+ button.className = BUTTON_CLASS;
346
435
  button.textContent = currentT("button.label");
347
436
  button.title = currentT("button.title");
348
- Object.assign(button.style, {
349
- position: "fixed",
350
- zIndex: "2147483600",
351
- padding: "3px 10px",
352
- fontSize: "12px",
353
- lineHeight: "20px",
354
- borderRadius: "6px",
355
- border: "1px solid rgba(140,140,150,.45)",
356
- background: "rgba(56,56,62,.94)",
357
- color: "#f5f5f7",
358
- cursor: "pointer",
359
- boxShadow: "0 2px 10px rgba(0,0,0,.28)",
360
- userSelect: "none",
361
- display: "none"
362
- });
437
+ button.style.display = "none";
363
438
  button.addEventListener("mousedown", (event) => {
364
439
  event.preventDefault();
365
440
  event.stopPropagation();
@@ -373,14 +448,21 @@ window.__ModuleLoader__.load({
373
448
  return button;
374
449
  };
375
450
  const placeButton = (button, rect) => {
376
- let x = rect.left + rect.width / 2 - 30;
377
- let y = rect.top - 28;
451
+ // Reveal before measuring: offsetWidth of a display:none element is 0,
452
+ // and the fixed-width guard below keeps fake DOMs (no layout) working.
453
+ button.style.display = "inline-flex";
454
+ const width = Number.isFinite(button.offsetWidth) && button.offsetWidth > 0
455
+ ? button.offsetWidth
456
+ : 64;
457
+ // Center on the selection; measurement guards keep fake/hidden
458
+ // elements (offsetWidth 0 / undefined) on the old fixed-width path.
459
+ let x = rect.left + rect.width / 2 - width / 2;
460
+ let y = rect.top - 34; // pill height (28px) + 6px gap
378
461
  if (y < 4)
379
462
  y = rect.bottom + 6;
380
- x = Math.max(4, Math.min(x, window.innerWidth - 70));
463
+ x = Math.max(4, Math.min(x, window.innerWidth - width - 4));
381
464
  button.style.left = `${Math.round(x)}px`;
382
465
  button.style.top = `${Math.round(y)}px`;
383
- button.style.display = "block";
384
466
  };
385
467
  const hideButton = () => {
386
468
  const button = document.getElementById(BUTTON_ID);
@@ -504,6 +586,9 @@ window.__ModuleLoader__.load({
504
586
  const button = document.getElementById(BUTTON_ID);
505
587
  if (button?.getAttribute(BUTTON_VERSION_ATTR) === CLIENT_VERSION)
506
588
  button.remove();
589
+ const style = document.getElementById(STYLE_ID);
590
+ if (style?.getAttribute(STYLE_VERSION_ATTR) === CLIENT_VERSION)
591
+ style.remove();
507
592
  if (globalThis[STATE] === state)
508
593
  delete globalThis[STATE];
509
594
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-quote-followup",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Quote selected Web conversation text into the DeepSeek Harness composer for targeted follow-up turns.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -11,7 +11,8 @@
11
11
  "follow-up"
12
12
  ],
13
13
  "scripts": {
14
- "test": "node --check lib/index.js && node --check lib/client.js && node test/web-repeat-harness.mjs"
14
+ "test": "node --check lib/index.js && node --check lib/client.js && node test/web-repeat-harness.mjs",
15
+ "test:assembled-web": "node test/run-assembled-web.mjs"
15
16
  },
16
17
  "type": "module",
17
18
  "main": "lib/index.js",