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 +22 -1
- package/README.zh.md +22 -1
- package/docs/assets/quote-followup-demo.gif +0 -0
- package/docs/assets/quote-followup-demo.webm +0 -0
- package/lib/client.js +107 -24
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -2,20 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
English | [中文](README.zh.md)
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/dsh-quote-followup)
|
|
6
|
+
[](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
|
+
[](https://www.npmjs.com/package/dsh-quote-followup)
|
|
6
|
+
[](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
|
-
|
|
59
|
+
回归测试覆盖精简的原生引用 chip、中英文 locale 切换、轮次溯源与降级、连续引用、已有草稿后的间距、codec 序列化、Firefox 文本降级路径,以及热替换后新版 client 接管旧按钮/单例状态。
|
|
39
60
|
|
|
40
61
|
## 许可证
|
|
41
62
|
|
|
Binary file
|
|
Binary file
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
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(
|
|
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
|
+
"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
|
],
|