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 +3 -0
- package/README.zh.md +3 -0
- package/lib/client.js +146 -61
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@ English | [中文](README.zh.md)
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/dsh-quote-followup)
|
|
6
6
|
[](LICENSE)
|
|
7
|
+
[](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
|
[](https://www.npmjs.com/package/dsh-quote-followup)
|
|
6
6
|
[](LICENSE)
|
|
7
|
+
[](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)
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
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
|
-
|
|
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 (
|
|
132
|
+
if (isValidTurn(turn))
|
|
114
133
|
return turn;
|
|
115
134
|
}
|
|
116
|
-
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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 -
|
|
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.
|
|
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",
|