dsh-quote-followup 0.2.5 → 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/lib/client.js +66 -42
- package/package.json +1 -1
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,6 +46,8 @@ window.__ModuleLoader__.load({
|
|
|
44
46
|
"button.title": "引用选中内容到输入框(可多次引用,发送前可编辑)",
|
|
45
47
|
"quote.header": "引用",
|
|
46
48
|
"conversation.role": "对话",
|
|
49
|
+
"conversation.role.user": "用户",
|
|
50
|
+
"conversation.role.assistant": "助手",
|
|
47
51
|
"quote.turn": "第 {n} 轮",
|
|
48
52
|
"quote.truncated": "已截断"
|
|
49
53
|
},
|
|
@@ -52,6 +56,8 @@ window.__ModuleLoader__.load({
|
|
|
52
56
|
"button.title": "Quote the selection into the composer (repeatable; editable before sending)",
|
|
53
57
|
"quote.header": "Quote",
|
|
54
58
|
"conversation.role": "conversation",
|
|
59
|
+
"conversation.role.user": "user",
|
|
60
|
+
"conversation.role.assistant": "assistant",
|
|
55
61
|
"quote.turn": "turn {n}",
|
|
56
62
|
"quote.truncated": "truncated"
|
|
57
63
|
}
|
|
@@ -66,6 +72,19 @@ window.__ModuleLoader__.load({
|
|
|
66
72
|
const element = elementOf(node);
|
|
67
73
|
return element === null ? null : element.closest(TRANSCRIPT_SELECTOR);
|
|
68
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;
|
|
69
88
|
/** The live selection when BOTH ends sit inside the same transcript. */
|
|
70
89
|
const selectionInTranscript = (selection) => {
|
|
71
90
|
if (selection === null || selection.isCollapsed || selection.rangeCount === 0)
|
|
@@ -81,9 +100,8 @@ window.__ModuleLoader__.load({
|
|
|
81
100
|
* looking for role markers (data attributes, then class-name hints).
|
|
82
101
|
* Null → the generic "对话" label; this is display-only metadata.
|
|
83
102
|
*/
|
|
84
|
-
const detectRole = (range, transcript) =>
|
|
85
|
-
|
|
86
|
-
while (element !== null && element !== transcript) {
|
|
103
|
+
const detectRole = (range, transcript) =>
|
|
104
|
+
walkAncestors(range.startContainer, transcript, (element) => {
|
|
87
105
|
const role = element.getAttribute?.("data-role") ?? element.getAttribute?.("data-message-role");
|
|
88
106
|
if (typeof role === "string" && role !== "") {
|
|
89
107
|
if (/user|human/i.test(role)) return "user";
|
|
@@ -92,11 +110,9 @@ window.__ModuleLoader__.load({
|
|
|
92
110
|
const cls = String(element.className ?? "");
|
|
93
111
|
if (/(^|[\s_-])user([\s_-]|$)/i.test(cls)) return "user";
|
|
94
112
|
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");
|
|
113
|
+
return undefined;
|
|
114
|
+
});
|
|
115
|
+
const roleLabel = (role, t) => role === "user" ? t("conversation.role.user") : role === "assistant" ? t("conversation.role.assistant") : t("conversation.role");
|
|
100
116
|
/**
|
|
101
117
|
* Conversation-turn provenance: DSH's chat renderer stamps every message
|
|
102
118
|
* row with `data-chat-turn` (a per-session monotonic ordinal). Resolving
|
|
@@ -104,19 +120,16 @@ window.__ModuleLoader__.load({
|
|
|
104
120
|
* host-side message-id API. Absent / non-numeric markers degrade to null
|
|
105
121
|
* and the serialized frame stays exactly as before.
|
|
106
122
|
*/
|
|
107
|
-
const detectTurn = (range, transcript) =>
|
|
108
|
-
|
|
109
|
-
while (element !== null && element !== transcript) {
|
|
123
|
+
const detectTurn = (range, transcript) =>
|
|
124
|
+
walkAncestors(range.startContainer, transcript, (element) => {
|
|
110
125
|
const raw = element.getAttribute?.("data-chat-turn");
|
|
111
126
|
if (typeof raw === "string" && raw !== "") {
|
|
112
127
|
const turn = Number(raw);
|
|
113
|
-
if (
|
|
128
|
+
if (isValidTurn(turn))
|
|
114
129
|
return turn;
|
|
115
130
|
}
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
return null;
|
|
119
|
-
};
|
|
131
|
+
return undefined;
|
|
132
|
+
});
|
|
120
133
|
/** Bound one quote before it becomes editor state. */
|
|
121
134
|
const quotePayload = (text, role, turn) => {
|
|
122
135
|
let body = String(text ?? "");
|
|
@@ -125,28 +138,32 @@ window.__ModuleLoader__.load({
|
|
|
125
138
|
body = body.slice(0, QUOTE_MAX_CHARS);
|
|
126
139
|
truncated = true;
|
|
127
140
|
}
|
|
128
|
-
return { text: body, role, truncated, turn:
|
|
141
|
+
return { text: body, role, truncated, turn: isValidTurn(turn) ? turn : null };
|
|
129
142
|
};
|
|
130
143
|
/** Model / clipboard projection. The composer shows a chip, not this frame. */
|
|
131
144
|
const quoteFrame = ({ text, role, truncated, turn }, t) => {
|
|
132
145
|
const lines = text.split("\n").map((line) => `> ${line}`.trimEnd());
|
|
133
146
|
const meta = [t("quote.header"), roleLabel(role, t)];
|
|
134
|
-
if (
|
|
147
|
+
if (isValidTurn(turn))
|
|
135
148
|
meta.push(String(t("quote.turn")).replace("{n}", String(turn)));
|
|
136
149
|
if (truncated)
|
|
137
150
|
meta.push(t("quote.truncated"));
|
|
138
151
|
return `> [${meta.join(" · ")}]\n${lines.join("\n")}\n\n`;
|
|
139
152
|
};
|
|
140
153
|
const decodeQuote = (ref) => {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
+
}
|
|
150
167
|
};
|
|
151
168
|
const quoteInsert = (payload, t) => {
|
|
152
169
|
const clipboardText = quoteFrame(payload, t);
|
|
@@ -162,17 +179,24 @@ window.__ModuleLoader__.load({
|
|
|
162
179
|
};
|
|
163
180
|
};
|
|
164
181
|
/** 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
|
-
|
|
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
|
+
};
|
|
176
200
|
const findComposer = () => {
|
|
177
201
|
for (const selector of COMPOSER_SELECTORS) {
|
|
178
202
|
const element = document.querySelector(selector);
|