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.
Files changed (2) hide show
  1. package/lib/client.js +66 -42
  2. 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), 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,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.5";
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
- let element = elementOf(range.startContainer);
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
- element = element.parentElement;
96
- }
97
- return null;
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
- let element = elementOf(range.startContainer);
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 (Number.isSafeInteger(turn) && turn >= 0)
128
+ if (isValidTurn(turn))
114
129
  return turn;
115
130
  }
116
- element = element.parentElement;
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: Number.isSafeInteger(turn) && turn >= 0 ? turn : null };
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 (Number.isSafeInteger(turn) && turn >= 0)
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
- 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
- };
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
- 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
- });
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);
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.6",
4
4
  "description": "Quote selected Web conversation text into the DeepSeek Harness composer for targeted follow-up turns.",
5
5
  "keywords": [
6
6
  "dsh",