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 +11 -1
- package/README.zh.md +11 -1
- package/docs/assets/quote-followup-demo.gif +0 -0
- package/docs/assets/quote-followup-demo.webm +0 -0
- package/lib/client.js +95 -39
- package/package.json +1 -1
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
|
-
|
|
59
|
+
回归测试覆盖精简的原生引用 chip、中英文 locale 切换、轮次溯源与降级、连续引用、已有草稿后的间距、codec 序列化、Firefox 文本降级路径,以及热替换后新版 client 接管旧按钮/单例状态。
|
|
50
60
|
|
|
51
61
|
## 许可证
|
|
52
62
|
|
|
Binary file
|
|
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)
|
|
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,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.
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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 = {
|
|
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
|
};
|