dsh-quote-shot 0.1.2 → 0.2.0

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 (5) hide show
  1. package/README.md +40 -0
  2. package/client.js +535 -4
  3. package/index.js +115 -68
  4. package/package.json +1 -1
  5. package/test.mjs +35 -4
package/README.md CHANGED
@@ -55,6 +55,46 @@ node install.mjs # 链接进 profile 并写入 dsh.profile.bundles
55
55
 
56
56
  ---
57
57
 
58
+ ## 输入框里是一枚 chip,不是一行指令
59
+
60
+ 点引用按钮后,输入框里出现的是紧凑标签,而不是原来那行长文本:
61
+
62
+ ```
63
+ 〔 引用 · 好,我测试一下,我看了这次改动… 〕
64
+ ```
65
+
66
+ **这是纯外观改动。** chip 的 `label` 显示被引用内容的开头,但底层 `clipboardText` 和序列化结果仍是原来那行标记,所以:
67
+
68
+ - 模型收到的内容不变
69
+ - `quote_read` 行为不变
70
+ - context 成本不变(仍然只传 id)
71
+
72
+ chip 用 DSH 原生的 `inputTriggers.registerSource` 注册。**DSH 的输入框无法装饰纯文本**——要让一段内容显示成标签,只能让它成为编辑器认识的「引用」对象,这是唯一的路。
73
+
74
+ 草稿 API 不可用时会自动退回纯文本插入,引用照常能用。
75
+
76
+ ## 发送后:标记渲染成卡片
77
+
78
+ 发送之后,那行 `[引用消息 <id>——…]` 会在气泡里变成一张卡片:
79
+
80
+ ```
81
+ ┌ “ 引用 1a2b3c4d 展开
82
+ └ (点「展开」后显示被引用消息的正文,最高 220px 可滚动)
83
+ ```
84
+
85
+ - 卡片总是排在你自己说的话**之前**:标记插入后会被移到草稿顶端
86
+ - **点卡片主体 = 瞬间跳回原消息**,并在那一块下方画一条梭形横线(两端淡出,约 1.6 秒后消失)
87
+ - **点「展开」= 取正文**(`POST /quote-shot/message`),不预取——否则一屏十几条引用就是十几个请求
88
+ - 取正文和 `quote_read` 工具共用同一套三级查找(`resolveMessageText`),不会出现两边行为不一致
89
+
90
+ **跳转靠点击引用时记下的 DOM 锚点**(`data-chat-anchor-key`,存在 localStorage,最多 200 条)——DSH 的消息 DOM 上没有 message id,只有这个锚点。跳转是**瞬间定位**(不是平滑滚动):跳几千上万像素时,滚动动画耗时比测量窗口还长,测到的会是滚动前的坐标。
91
+
92
+ **滚出加载区的消息不在 DOM 里**(列表虚拟化),这时卡片提示「原消息不在当前视图」,不会假装滚动。刷新页面后锚点仍在,仍可跳转。
93
+
94
+ **标记是一条梭形横线,不是一个框**:外层描边会把「引用按钮所在的那一块」框住——那通常是这一轮的文件变更卡片,而不是消息本身。横线只回答「是这一带」,且用固定定位浮层绘制,不往宿主节点上写任何 class 或 style。
95
+
96
+ **这只是「人眼看到什么」的改变**:模型收到的仍然是那行标记,仍然需要调用 `quote_read` 才能读到正文。卡片不省 token,也不改变模型行为。
97
+
58
98
  ## 实现
59
99
 
60
100
  ### 链路
package/client.js CHANGED
@@ -15,6 +15,8 @@ window.__ModuleLoader__.load({
15
15
  factory: (require) => {
16
16
  var module = { exports: {} };
17
17
  var exports = module.exports;
18
+ /** Plugin context, for the components that need services outside a slot. */
19
+ let pluginCtx = null;
18
20
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
19
21
 
20
22
  const react = require('react');
@@ -155,6 +157,281 @@ window.__ModuleLoader__.load({
155
157
  * @param anchor - the clicked button element.
156
158
  * @returns the text, or an empty string when nothing usable was found.
157
159
  */
160
+ /**
161
+ * Where a quoted message was on screen, keyed by message id.
162
+ *
163
+ * DSH's message DOM carries no message id — only `data-chat-anchor-key` — so
164
+ * a message id alone cannot find its row. The one moment the row IS known is
165
+ * the quote click, because the button lives inside it; the row's anchor is
166
+ * recorded then and replayed later.
167
+ *
168
+ * Global rather than per-session: ids are UUIDs so they cannot collide, and
169
+ * the sent-message card has no session id available.
170
+ */
171
+ const ANCHOR_STORE = 'dsh-quote-shot:anchors';
172
+ const ANCHOR_LIMIT = 200;
173
+
174
+ /**
175
+ * Chip source name.
176
+ *
177
+ * DSH renders a composer chip when the draft carries a reference of this
178
+ * source. The chip exists only for APPEARANCE: its label is a short tag, its
179
+ * clipboardText and serialised form are the same marker we have always sent,
180
+ * so the model and `quote_read` see exactly what they saw before.
181
+ */
182
+ const CHIP_SOURCE = 'quote-shot';
183
+
184
+ /**
185
+ * The marker that travels with the message.
186
+ *
187
+ * @param messageId - quoted message id.
188
+ * @returns the instruction line.
189
+ */
190
+ function buildMarker(messageId) {
191
+ return `[引用消息 ${messageId}——请先调用 quote_read 读取全文,再回答]`;
192
+ }
193
+
194
+ /**
195
+ * Insert one quote chip at the very top of the draft.
196
+ *
197
+ * Done as a single `setDraft` rather than insert-then-reorder: a chip has an
198
+ * offset, so moving text after the fact would desynchronise it.
199
+ *
200
+ * @param messageId - quoted message id.
201
+ * @param sessionId - the session owning the composer.
202
+ * @param preview - the quoted text, used for the chip's label.
203
+ * @returns true when the draft was rewritten.
204
+ */
205
+ function insertQuoteChip(messageId, sessionId, preview) {
206
+ const actx = pluginCtx?.sessions?.scope?.(sessionId);
207
+ const input = actx === undefined || actx === null ? undefined : pluginCtx?.conversation?.input?.for?.(actx);
208
+ const snapshot = input?.draftSnapshot;
209
+ if (snapshot === undefined || snapshot === null || typeof snapshot.text !== 'string') return false;
210
+
211
+ const marker = buildMarker(messageId);
212
+ const added = `${marker}\n`;
213
+ // An id alone tells the reader nothing; the opening words do.
214
+ const flat = String(preview ?? '').replace(/\s+/g, ' ').trim();
215
+ const label =
216
+ flat.length === 0
217
+ ? `引用 ${messageId.slice(0, 8)}`
218
+ : `引用 · ${flat.slice(0, 28)}${flat.length > 28 ? '…' : ''}`;
219
+ const chip = {
220
+ source: CHIP_SOURCE,
221
+ ref: messageId,
222
+ offset: 0,
223
+ length: marker.length,
224
+ label,
225
+ clipboardText: marker
226
+ };
227
+ const references = (Array.isArray(snapshot.references) ? snapshot.references : [])
228
+ .map((reference) => ({ ...reference, offset: reference.offset + added.length }));
229
+ references.push(chip);
230
+ references.sort((a, b) => a.offset - b.offset);
231
+
232
+ input.setDraft({ text: added + snapshot.text, references });
233
+ return true;
234
+ }
235
+
236
+ /**
237
+ * Remember which DOM row a message occupies.
238
+ *
239
+ * Stores the row anchor AND its turn id. The row is NOT a whole message: a
240
+ * reply is several rows (text, tool cards, file-change card) and the quote
241
+ * button lives in the LAST one, so highlighting just that row framed the
242
+ * file card instead of the answer. The turn id lets the jump widen to the
243
+ * whole exchange.
244
+ *
245
+ * @param messageId - quoted message id.
246
+ * @param row - the message row element.
247
+ */
248
+ function rememberAnchor(messageId, row) {
249
+ // Diagnose FIRST: the previous version returned before this ran whenever
250
+ // `closest` missed, which is exactly the case worth seeing. Records the
251
+ // whole ancestor chain (tag, class head, data-* attrs) so the real wrapper
252
+ // can be identified from outside.
253
+ try {
254
+ const chain = [];
255
+ let node = row;
256
+ for (let depth = 0; depth < 10 && node !== null && node !== undefined; depth += 1) {
257
+ const bits = [];
258
+ for (const attr of node.attributes ?? []) {
259
+ if (!attr.name.startsWith('data-')) continue;
260
+ bits.push(`${attr.name}=${String(attr.value).slice(0, 20)}`);
261
+ if (bits.length >= 3) break;
262
+ }
263
+ const cls = String(node.className ?? '').split(/\s+/).filter(Boolean)[0] ?? '';
264
+ chain.push(`${node.tagName?.toLowerCase()}.${cls.slice(0, 22)}[${bits.join(',')}]`);
265
+ node = node.parentElement;
266
+ }
267
+ postLog('anchor-chain', { rowFound: row !== null && row !== undefined, chain });
268
+ } catch {
269
+ /* diagnostics never break the click */
270
+ }
271
+
272
+ const key = row?.getAttribute?.('data-chat-anchor-key');
273
+ if (typeof key !== 'string' || key.length === 0) return;
274
+ const turn = row?.getAttribute?.('data-chat-turn') ?? null;
275
+ try {
276
+ const all = JSON.parse(localStorage.getItem(ANCHOR_STORE) ?? '{}');
277
+ delete all[messageId];
278
+ all[messageId] = { key, turn };
279
+ const ids = Object.keys(all);
280
+ for (const stale of ids.slice(0, Math.max(0, ids.length - ANCHOR_LIMIT))) delete all[stale];
281
+ localStorage.setItem(ANCHOR_STORE, JSON.stringify(all));
282
+ } catch {
283
+ /* storage is best-effort; jumping just degrades */
284
+ }
285
+ }
286
+
287
+ /**
288
+ * The recorded row anchor for a message.
289
+ *
290
+ * Tolerates the older plain-string format.
291
+ *
292
+ * @param messageId - quoted message id.
293
+ * @returns `{ key, turn }`, or null.
294
+ */
295
+ function anchorFor(messageId) {
296
+ try {
297
+ const all = JSON.parse(localStorage.getItem(ANCHOR_STORE) ?? '{}');
298
+ const value = all[messageId];
299
+ if (typeof value === 'string') return { key: value, turn: null };
300
+ if (value !== null && typeof value === 'object' && typeof value.key === 'string') return value;
301
+ return null;
302
+ } catch {
303
+ return null;
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Scroll to a quoted message and flash it.
309
+ *
310
+ * Highlights the whole turn when the turn id is known, otherwise the row
311
+ * itself. Messages the virtualised list has not rendered are not in the DOM
312
+ * at all; there is no honest way to jump to them, so this reports "not on
313
+ * screen" instead of inventing a scroll.
314
+ *
315
+ * @param messageId - quoted message id.
316
+ * @returns true when something was found and scrolled to.
317
+ */
318
+ /** The flash line currently on screen, so a second jump replaces it. */
319
+ let flashLine = null;
320
+
321
+ /**
322
+ * Draw a spindle line under a row for a moment.
323
+ *
324
+ * A full outline around the row was wrong: it framed whichever block the
325
+ * quote button happens to live in (often a file-change card). A thin line
326
+ * marks the position without claiming a boundary, and it is drawn as a
327
+ * fixed-position overlay so no host node is touched.
328
+ *
329
+ * @param row - the row to underline.
330
+ */
331
+ function flashRow(row) {
332
+ if (flashLine !== null) {
333
+ flashLine.remove();
334
+ flashLine = null;
335
+ }
336
+
337
+ const paint = () => {
338
+ const rect = row.getBoundingClientRect();
339
+ postLog('flash-paint', {
340
+ top: Math.round(rect.top),
341
+ bottom: Math.round(rect.bottom),
342
+ width: Math.round(rect.width),
343
+ height: Math.round(rect.height),
344
+ viewport: window.innerHeight
345
+ });
346
+ if (rect.width === 0 || rect.height === 0) return;
347
+
348
+ // Keep the line inside the viewport: a tall row centred by scrollIntoView
349
+ // can have its bottom below the fold, and a line drawn there is invisible.
350
+ const maxY = window.innerHeight - 14;
351
+ const y = Math.max(12, Math.min(rect.bottom, maxY));
352
+ const line = document.createElement('div');
353
+ line.style.cssText = [
354
+ 'position:fixed',
355
+ 'pointer-events:none',
356
+ 'z-index:2147483000',
357
+ `left:${rect.left + rect.width * 0.06}px`,
358
+ `width:${rect.width * 0.88}px`,
359
+ // Lifted off the row's bottom edge: sitting flush against it made the
360
+ // line read as the divider of the NEXT message.
361
+ `top:${y - 23}px`,
362
+ 'height:3px',
363
+ 'border-radius:2px',
364
+ 'background:linear-gradient(90deg,transparent 0%,#4d6bfe 18%,#4d6bfe 82%,transparent 100%)',
365
+ 'box-shadow:0 0 8px rgba(77,107,254,0.45)',
366
+ 'opacity:0',
367
+ 'transition:opacity 220ms ease'
368
+ ].join(';');
369
+ document.body.appendChild(line);
370
+ flashLine = line;
371
+ requestAnimationFrame(() => {
372
+ line.style.opacity = '1';
373
+ });
374
+ setTimeout(() => {
375
+ line.style.opacity = '0';
376
+ }, 1600);
377
+ setTimeout(() => {
378
+ line.remove();
379
+ if (flashLine === line) flashLine = null;
380
+ }, 2000);
381
+ };
382
+
383
+ // Wait until the row is actually INSIDE the viewport before measuring.
384
+ // Position stability alone is not enough: a scroll that has not started
385
+ // yet is perfectly stable, and measuring then draws the line off-screen.
386
+ let frames = 0;
387
+ const settle = () => {
388
+ frames += 1;
389
+ const rect = row.getBoundingClientRect();
390
+ const inView = rect.bottom > 8 && rect.top < window.innerHeight - 8;
391
+ if (inView && frames >= 2) {
392
+ paint();
393
+ return;
394
+ }
395
+ if (frames > 60) {
396
+ paint();
397
+ return;
398
+ }
399
+ requestAnimationFrame(settle);
400
+ };
401
+ requestAnimationFrame(settle);
402
+ }
403
+
404
+ function jumpToMessage(messageId) {
405
+ const record = anchorFor(messageId);
406
+ if (record === null) {
407
+ postLog('jump-miss', { messageId, reason: 'no-anchor' });
408
+ return false;
409
+ }
410
+
411
+ // Back to the behaviour that demonstrably worked: find the recorded row
412
+ // verbatim. The turn-prefix widening is gone until the row it lands on is
413
+ // understood; a broken jump is worse than an imprecise one.
414
+ let row = null;
415
+ try {
416
+ row = document.querySelector(`[data-chat-anchor-key="${CSS.escape(record.key)}"]`);
417
+ } catch (error) {
418
+ postLog('jump-miss', { messageId, reason: 'selector-error', detail: String((error && error.message) || error) });
419
+ return false;
420
+ }
421
+ if (row === null) {
422
+ postLog('jump-miss', { messageId, reason: 'row-gone', key: record.key });
423
+ return false;
424
+ }
425
+
426
+ // `behavior: 'auto'` on purpose. Smooth scrolling a jump of tens of
427
+ // thousands of pixels takes longer than the measuring window, and the line
428
+ // was then painted at a stale position (measured top: -19816).
429
+ row.scrollIntoView({ behavior: 'auto', block: 'center' });
430
+ flashRow(row);
431
+ postLog('jump-ok', { messageId, key: record.key });
432
+ return true;
433
+ }
434
+
158
435
  function readQuoteText(anchor) {
159
436
  try {
160
437
  const selection = window.getSelection();
@@ -431,6 +708,10 @@ window.__ModuleLoader__.load({
431
708
  }
432
709
 
433
710
  const text = readQuoteText(event.currentTarget);
711
+ // Record which row this message occupies: the message DOM carries no
712
+ // message id, and the sent card needs a way back. Done here because the
713
+ // button sits inside that row.
714
+ rememberAnchor(messageId, event.currentTarget.closest('[data-chat-anchor-key]'));
434
715
  if (text.length === 0) {
435
716
  // Ship the DOM shape along with the failure: message markup is not a
436
717
  // contract, so the extraction strategy cannot be fixed from a bare
@@ -454,20 +735,54 @@ window.__ModuleLoader__.load({
454
735
  // A bare `[引用消息 <id>]` failed in real use: the model decided it did
455
736
  // not need the content and answered from memory (measured 0 calls across
456
737
  // 3 quotes). Quoting means "look at this", so the marker now says so.
457
- const marker = `[引用消息 ${messageId}——请先调用 quote_read 读取全文,再回答]`;
738
+ const marker = buildMarker(messageId);
458
739
  setBusy(true);
459
740
  let inserted = false;
741
+ let usedChip = false;
460
742
  try {
461
- inserted = actions.insertText(`${marker}\n`, actions.captureInsertion());
462
- } catch {
743
+ inserted = insertQuoteChip(messageId, sessionId, text);
744
+ usedChip = inserted;
745
+ } catch (error) {
746
+ postLog('chip-insert-failed', { messageId, detail: String((error && error.message) || error) });
463
747
  inserted = false;
464
748
  }
749
+ if (!inserted) {
750
+ // Fallback: plain text. The chip is nicer presentation, not a
751
+ // requirement — quoting must keep working even if the draft API
752
+ // behaves differently on another DSH build.
753
+ try {
754
+ inserted = actions.insertText(`${marker}\n`, actions.captureInsertion());
755
+ } catch {
756
+ inserted = false;
757
+ }
758
+ }
465
759
  setBusy(false);
466
760
  if (inserted !== true) {
467
761
  report('insert-failed', { messageId });
468
762
  return;
469
763
  }
470
764
 
765
+ // Only the plain-text fallback needs reordering; a chip is written at
766
+ // offset 0 directly, and shifting text afterwards would corrupt it.
767
+ if (!usedChip) {
768
+ try {
769
+ const actx = pluginCtx?.sessions?.scope?.(sessionId);
770
+ const input = actx === undefined || actx === null ? undefined : pluginCtx?.conversation?.input?.for?.(actx);
771
+ const snapshot = input?.draftSnapshot;
772
+ const noChips = Array.isArray(snapshot?.references) && snapshot.references.length === 0;
773
+ if (noChips && typeof snapshot.text === 'string') {
774
+ const index = snapshot.text.lastIndexOf(marker);
775
+ const before = index > 0 ? snapshot.text.slice(0, index).replace(/\s+$/, '') : '';
776
+ if (index > 0 && before.length > 0) {
777
+ const after = snapshot.text.slice(index + marker.length);
778
+ input.setDraft({ text: `${marker}\n${before}${after}`, references: [] });
779
+ }
780
+ }
781
+ } catch {
782
+ report('reorder-skipped', { messageId });
783
+ }
784
+ }
785
+
471
786
  // Record the id -> session pair before the marker leaves the client, so
472
787
  // `quote_read` can resolve the owner instead of guessing a live agent.
473
788
  postHint(messageId, sessionId);
@@ -703,14 +1018,229 @@ window.__ModuleLoader__.load({
703
1018
  );
704
1019
  }
705
1020
 
1021
+ /**
1022
+ * The one-line marker this plugin puts in the composer.
1023
+ *
1024
+ * Group 1 is the message id. Kept in sync with the string built in
1025
+ * QuoteActions; the quote em-dash is part of the contract.
1026
+ */
1027
+ const QUOTE_MARK = /\[引用消息\s+([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\s*——[^\]]*\]/g;
1028
+
1029
+ /** Rule id for sent bubbles; must not collide with DSH's own user-text rules. */
1030
+ const CARD_NODE = 'dsh-quote-shot-card';
1031
+
1032
+ /**
1033
+ * userText rule: find one quote marker at or after `from`.
1034
+ *
1035
+ * DSH runs these rules over the text of a SENT user message and lets the
1036
+ * plugin replace the matched span with a React node, which is how a plain
1037
+ * `[引用消息 …]` turns into a card in the bubble.
1038
+ */
1039
+ const quoteRule = {
1040
+ id: CARD_NODE,
1041
+ order: 15,
1042
+ match(text, from) {
1043
+ QUOTE_MARK.lastIndex = from;
1044
+ const m = QUOTE_MARK.exec(text);
1045
+ if (m === null) return undefined;
1046
+ return {
1047
+ id: CARD_NODE,
1048
+ start: m.index,
1049
+ end: m.index + m[0].length,
1050
+ data: { messageId: m[1] }
1051
+ };
1052
+ }
1053
+ };
1054
+
1055
+ /**
1056
+ * The card a sent quote renders as.
1057
+ *
1058
+ * It shows nothing about the quoted text until opened: the marker carries
1059
+ * only an id, and prefetching the body for every card would fire a request
1060
+ * per quote on every render. Opening asks the Host for the text once and
1061
+ * caches it on the component.
1062
+ *
1063
+ * @param props - `node` from the rule (`data.messageId`, `data.text`/`note`).
1064
+ * @returns the card element.
1065
+ */
1066
+ function SentQuoteCard(props) {
1067
+ const node = props.node ?? {};
1068
+ const messageId = String(node?.data?.messageId ?? '');
1069
+ const [open, setOpen] = react.useState(false);
1070
+ const [body, setBody] = react.useState(null);
1071
+ const [state, setState] = react.useState('idle');
1072
+ const [notice, setNotice] = react.useState(null);
1073
+
1074
+ const toggle = async () => {
1075
+ if (open) {
1076
+ setOpen(false);
1077
+ return;
1078
+ }
1079
+ setOpen(true);
1080
+ if (body !== null || state === 'loading') return;
1081
+ setState('loading');
1082
+ try {
1083
+ const response = await fetch('/quote-shot/message', {
1084
+ method: 'POST',
1085
+ credentials: 'same-origin',
1086
+ headers: { 'content-type': 'application/json' },
1087
+ body: JSON.stringify({ messageId })
1088
+ });
1089
+ const payload = await response.json();
1090
+ if (payload.ok === true) {
1091
+ setBody(String(payload.text ?? ''));
1092
+ setState('done');
1093
+ } else {
1094
+ setBody('(找不到这条消息的正文)');
1095
+ setState('missing');
1096
+ }
1097
+ } catch {
1098
+ setBody('(读取失败)');
1099
+ setState('error');
1100
+ }
1101
+ };
1102
+
1103
+ const shortId = messageId.slice(0, 8);
1104
+
1105
+ const jump = () => {
1106
+ if (jumpToMessage(messageId)) return;
1107
+ // The row is gone: the virtualised list dropped it, or the page reloaded
1108
+ // before any anchor was recorded for this id.
1109
+ setNotice('原消息不在当前视图');
1110
+ setTimeout(() => setNotice(null), 2200);
1111
+ };
1112
+
1113
+ const header = h(
1114
+ 'span',
1115
+ {
1116
+ role: 'button',
1117
+ tabIndex: 0,
1118
+ onClick: jump,
1119
+ onKeyDown: (event) => {
1120
+ if (event.key === 'Enter' || event.key === ' ') {
1121
+ event.preventDefault();
1122
+ jump();
1123
+ }
1124
+ },
1125
+ title: '点击回到原消息',
1126
+ style: {
1127
+ display: 'flex',
1128
+ alignItems: 'center',
1129
+ gap: '6px',
1130
+ padding: '3px 8px',
1131
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
1132
+ cursor: 'pointer',
1133
+ fontSize: '12px',
1134
+ lineHeight: '16px',
1135
+ color: 'color-mix(in srgb, currentColor 58%, transparent)'
1136
+ }
1137
+ },
1138
+ h('span', { 'aria-hidden': 'true' }, '\u275D'),
1139
+ h('span', null, `引用 ${shortId}`),
1140
+ notice !== null ? h('span', { style: { opacity: 0.85 } }, notice) : null,
1141
+ h(
1142
+ 'span',
1143
+ {
1144
+ role: 'button',
1145
+ tabIndex: 0,
1146
+ onClick: (event) => {
1147
+ event.stopPropagation();
1148
+ toggle();
1149
+ },
1150
+ onKeyDown: (event) => {
1151
+ if (event.key === 'Enter' || event.key === ' ') {
1152
+ event.preventDefault();
1153
+ event.stopPropagation();
1154
+ toggle();
1155
+ }
1156
+ },
1157
+ style: { marginLeft: 'auto', opacity: 0.7, cursor: 'pointer' }
1158
+ },
1159
+ open ? '收起' : state === 'loading' ? '读取中…' : '展开'
1160
+ )
1161
+ );
1162
+
1163
+ const children = [header];
1164
+
1165
+ if (open) {
1166
+ children.push(
1167
+ h(
1168
+ 'span',
1169
+ {
1170
+ key: 'body',
1171
+ style: {
1172
+ display: 'block',
1173
+ marginTop: '6px',
1174
+ padding: '0 8px 4px',
1175
+ maxHeight: '220px',
1176
+ overflowY: 'auto',
1177
+ whiteSpace: 'pre-wrap',
1178
+ overflowWrap: 'anywhere',
1179
+ fontSize: '13px',
1180
+ lineHeight: '1.55',
1181
+ color: 'color-mix(in srgb, currentColor 80%, transparent)'
1182
+ }
1183
+ },
1184
+ state === 'loading' ? '…' : body ?? ''
1185
+ )
1186
+ );
1187
+ }
1188
+
1189
+ return h(
1190
+ 'span',
1191
+ {
1192
+ style: {
1193
+ display: 'block',
1194
+ margin: '2px 0 6px',
1195
+ borderLeft: '2px solid color-mix(in srgb, currentColor 35%, transparent)',
1196
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
1197
+ background: 'color-mix(in srgb, currentColor 6%, transparent)',
1198
+ userSelect: 'text'
1199
+ }
1200
+ },
1201
+ ...children
1202
+ );
1203
+ }
1204
+
706
1205
  /**
707
1206
  * Register the dictionaries, the message action and the composer cards.
708
1207
  *
709
1208
  * @param ctx - the client plugin context.
710
1209
  */
711
1210
  function apply(ctx) {
1211
+ pluginCtx = ctx;
712
1212
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'quote-shot: dictionaries');
713
1213
 
1214
+ // The composer chip. It never offers completion candidates — it exists so
1215
+ // the draft can carry a reference this plugin owns, which DSH draws as a
1216
+ // compact tag instead of the raw instruction line.
1217
+ ctx.effect(
1218
+ () =>
1219
+ ctx.inputTriggers.registerSource({
1220
+ trigger: '@',
1221
+ name: CHIP_SOURCE,
1222
+ order: 99,
1223
+ showGroupTitle: false,
1224
+ candidates: () => Promise.resolve([]),
1225
+ onPick: () => undefined,
1226
+ openReference: () => undefined,
1227
+ codec: {
1228
+ clipboardText: (ref) => buildMarker(String(ref)),
1229
+ serialize: (ref) => Promise.resolve(buildMarker(String(ref)))
1230
+ }
1231
+ }),
1232
+ 'quote-shot: chip source'
1233
+ );
1234
+
1235
+ // Sent bubble: the marker becomes a card instead of a run of grey text.
1236
+ ctx.effect(() => ctx.uiConversation.userText.register(quoteRule), 'quote-shot: user-text rule');
1237
+ ctx.slots.inject('conversation.user.text.node', () =>
1238
+ ctx.slots.register(
1239
+ { name: 'conversation.user.text.node', key: CARD_NODE, priority: 100 },
1240
+ SentQuoteCard
1241
+ )
1242
+ );
1243
+
714
1244
  ctx.slots.inject('conversation.chat.assistant-actions', () =>
715
1245
  ctx.slots.register(
716
1246
  {
@@ -742,9 +1272,10 @@ window.__ModuleLoader__.load({
742
1272
  }
743
1273
 
744
1274
  exports.apply = apply;
745
- exports.inject = ['slots', 'locale'];
1275
+ exports.inject = ['slots', 'locale', 'uiConversation', 'conversation', 'sessions', 'inputTriggers'];
746
1276
  exports.QuoteActions = QuoteActions;
747
1277
  exports.QuoteCard = QuoteCard;
1278
+ exports.SentQuoteCard = SentQuoteCard;
748
1279
  exports.readQuoteText = readQuoteText;
749
1280
  exports.previewOf = previewOf;
750
1281
  exports.injectQuote = injectQuote;
package/index.js CHANGED
@@ -148,6 +148,76 @@ function buildSummary(text) {
148
148
  *
149
149
  * @param ctx - the Host plugin context.
150
150
  */
151
+ /**
152
+ * Locate one message's full text, in three tiers.
153
+ *
154
+ * Shared by the `quote_read` tool (the model) and `/quote-shot/message` (the
155
+ * client, which draws the sent-message card). Both need the same answer, so the
156
+ * search lives here instead of being duplicated.
157
+ *
158
+ * @param ctx - the Host plugin context (for the live-agent registry).
159
+ * @param messageId - the message id to find.
160
+ * @returns `{ text, via, sessionId }`, or null when nothing matched.
161
+ */
162
+ function resolveMessageText(ctx, messageId) {
163
+ const startedAt = Date.now();
164
+ const tried = [];
165
+
166
+ // 1. The session the client reported when the quote was placed. This is the
167
+ // exact answer, and the only step that works when the caller is not the
168
+ // session that owns the message.
169
+ const hinted = quoteHints.get(messageId);
170
+ if (typeof hinted === 'string' && hinted.length > 0) {
171
+ tried.push(hinted);
172
+ const text = readMessageById(hinted, messageId);
173
+ if (text !== null) {
174
+ trace(`lookup session=${hinted} message=${messageId} len=${text.length} via=hint`);
175
+ return { text, via: 'hint', sessionId: hinted };
176
+ }
177
+ }
178
+
179
+ // 2. Every live agent, newest first. Background subagents are live agents
180
+ // too, so "the last one" is not a safe guess.
181
+ let live = [];
182
+ try {
183
+ live = ctx.agents.list();
184
+ } catch {
185
+ live = [];
186
+ }
187
+ for (let index = live.length - 1; index >= 0; index -= 1) {
188
+ const sessionId = live[index]?.session?.id;
189
+ if (typeof sessionId !== 'string' || sessionId.length === 0) continue;
190
+ if (tried.includes(sessionId)) continue;
191
+ tried.push(sessionId);
192
+ const text = readMessageById(sessionId, messageId);
193
+ if (text !== null) {
194
+ trace(`lookup session=${sessionId} message=${messageId} len=${text.length} via=live`);
195
+ return { text, via: 'live', sessionId };
196
+ }
197
+ }
198
+
199
+ // 3. On-disk scan of the most recently used logs: covers a restarted DSH or a
200
+ // conversation that is no longer live. Message ids are UUIDs.
201
+ let sources = [];
202
+ try {
203
+ sources = listRecentSessionFiles(25);
204
+ } catch {
205
+ sources = [];
206
+ }
207
+ for (const source of sources) {
208
+ const text = readMessageFromFile(source.file, messageId);
209
+ if (text !== null) {
210
+ trace(`lookup session=${source.sessionId} message=${messageId} len=${text.length} via=scan`);
211
+ return { text, via: 'scan', sessionId: source.sessionId };
212
+ }
213
+ }
214
+
215
+ // Failure MUST be traced. Silently returning "not found" is exactly how the
216
+ // earlier bug stayed invisible.
217
+ trace(`lookup MISS message=${messageId} tried=[${tried.join(', ')}] scanned=${sources.length} ms=${Date.now() - startedAt}`);
218
+ return null;
219
+ }
220
+
151
221
  export function apply(ctx) {
152
222
  ctx.effect(
153
223
  () =>
@@ -307,6 +377,48 @@ export function apply(ctx) {
307
377
  'quote-shot: hint route'
308
378
  );
309
379
 
380
+ // Client-side message text lookup, for the sent-message card.
381
+ //
382
+ // The card shows the quoted body when opened, but the renderer cannot call a
383
+ // model tool. This exposes the same three-tier lookup over HTTP so the card
384
+ // does not have to duplicate any of it.
385
+ ctx.effect(
386
+ () =>
387
+ ctx.webServer.register({
388
+ kind: 'exact',
389
+ path: '/quote-shot/message',
390
+ handler: async (request, response) => {
391
+ const reply = (status, body) => {
392
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
393
+ response.end(JSON.stringify(body));
394
+ };
395
+ if (request.method !== 'POST') {
396
+ response.writeHead(405, { allow: 'POST' });
397
+ response.end();
398
+ return;
399
+ }
400
+ const body = await readJson(request);
401
+ if (body === null) {
402
+ reply(400, { ok: false, code: 'bad-json' });
403
+ return;
404
+ }
405
+ const messageId = typeof body.messageId === 'string' ? body.messageId.trim() : '';
406
+ if (messageId.length === 0) {
407
+ reply(400, { ok: false, code: 'missing-messageId' });
408
+ return;
409
+ }
410
+ const found = resolveMessageText(ctx, messageId);
411
+ if (found === null) {
412
+ reply(200, { ok: false, code: 'not-found' });
413
+ return;
414
+ }
415
+ trace(`card message=${messageId} len=${found.text.length} via=${found.via}`);
416
+ reply(200, { ok: true, text: found.text });
417
+ }
418
+ }),
419
+ 'quote-shot: message route'
420
+ );
421
+
310
422
  // The `quote_read` tool: read a message's full text by id.
311
423
  //
312
424
  // This is what makes "quote by reference" possible — the composer carries only
@@ -369,74 +481,9 @@ export function apply(ctx) {
369
481
  async execute(args) {
370
482
  const messageId = typeof args?.messageId === 'string' ? args.messageId.trim() : '';
371
483
  if (messageId.length === 0) return { found: false, messageId: '', error: 'missing-messageId' };
372
-
373
- const startedAt = Date.now();
374
- const tried = [];
375
-
376
- // 1. The session the client reported when the quote was placed.
377
- // This is the exact answer, and it is the only step that works
378
- // when the caller is not the session that owns the message.
379
- const hinted = quoteHints.get(messageId);
380
- if (typeof hinted === 'string' && hinted.length > 0) {
381
- tried.push(hinted);
382
- const text = readMessageById(hinted, messageId);
383
- if (text !== null) {
384
- trace(`quote_read session=${hinted} message=${messageId} len=${text.length} via=hint`);
385
- return { found: true, messageId, text };
386
- }
387
- }
388
-
389
- // 2. Every live agent, newest first.
390
- //
391
- // The previous version used ONLY the last registered agent. That
392
- // is wrong: Mnemon's idle checkpoint reviewer runs as a live
393
- // subagent, so "the last live agent" can be a background reviewer
394
- // session that has never seen the quoted message. Iterating all of
395
- // them costs one Map lookup per agent and removes the guess.
396
- let live = [];
397
- try {
398
- live = ctx.agents.list();
399
- } catch {
400
- live = [];
401
- }
402
- for (let index = live.length - 1; index >= 0; index -= 1) {
403
- const sessionId = live[index]?.session?.id;
404
- if (typeof sessionId !== 'string' || sessionId.length === 0) continue;
405
- if (tried.includes(sessionId)) continue;
406
- tried.push(sessionId);
407
- const text = readMessageById(sessionId, messageId);
408
- if (text !== null) {
409
- trace(`quote_read session=${sessionId} message=${messageId} len=${text.length} via=live`);
410
- return { found: true, messageId, text };
411
- }
412
- }
413
-
414
- // 3. On-disk scan of the most recently used logs.
415
- //
416
- // Covers the case no registry can: DSH restarted, or the quoted
417
- // message belongs to a conversation that is no longer live. Message
418
- // ids are UUIDs, so a hit is unambiguous.
419
- let sources = [];
420
- try {
421
- sources = listRecentSessionFiles(25);
422
- } catch {
423
- sources = [];
424
- }
425
- for (const source of sources) {
426
- const text = readMessageFromFile(source.file, messageId);
427
- if (text !== null) {
428
- trace(`quote_read session=${source.sessionId} message=${messageId} len=${text.length} via=scan`);
429
- return { found: true, messageId, text };
430
- }
431
- }
432
-
433
- // Failure MUST be traced. Silently returning `not-found` is exactly
434
- // how this bug stayed invisible: the UI showed a message it could not
435
- // find, and the log had nothing.
436
- trace(
437
- `quote_read MISS message=${messageId} tried=[${tried.join(', ')}] scanned=${sources.length} ms=${Date.now() - startedAt}`
438
- );
439
- return { found: false, messageId, error: 'not-found' };
484
+ const found = resolveMessageText(ctx, messageId);
485
+ if (found === null) return { found: false, messageId, error: 'not-found' };
486
+ return { found: true, messageId, text: found.text };
440
487
  }
441
488
  });
442
489
  trace('tool quote_read registered');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-quote-shot",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Quote a whole DSH message by reference: the composer carries only a message id, and the model reads the full text on demand through a quote_read tool instead of pasting it into the prompt.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/test.mjs CHANGED
@@ -242,6 +242,8 @@ console.log('\napply() wiring');
242
242
  const localeRegs = [];
243
243
  const slotInjections = [];
244
244
  const seats = [];
245
+ const sources = [];
246
+ const rules = [];
245
247
  const ctx = {
246
248
  effect(fn) {
247
249
  return fn();
@@ -261,14 +263,43 @@ console.log('\napply() wiring');
261
263
  register(spec, component) {
262
264
  return { spec, component };
263
265
  }
264
- }
266
+ },
267
+ // Services added by the card / chip work. The mock has to model them, or
268
+ // apply() throws on the first dereference and every later check is noise.
269
+ inputTriggers: {
270
+ registerSource(source) {
271
+ sources.push(source);
272
+ return () => {};
273
+ }
274
+ },
275
+ uiConversation: {
276
+ userText: {
277
+ register(rule) {
278
+ rules.push(rule);
279
+ return () => {};
280
+ }
281
+ }
282
+ },
283
+ conversation: { input: { for: () => undefined } },
284
+ sessions: { scope: () => null }
265
285
  };
266
286
  api.apply(ctx);
267
287
  check('registers the dictionaries', localeRegs.length === 1 && localeRegs[0].namespace === 'quoteShot', JSON.stringify(localeRegs.map((r) => r.namespace)));
268
288
  check('dictionaries carry zh and en', Boolean(localeRegs[0]?.dictionaries?.zh && localeRegs[0]?.dictionaries?.en));
269
- check('injects into two slots', slotInjections.length === 2, slotInjections.join(','));
270
- check('first seat is the message action', slotInjections[0] === 'conversation.chat.assistant-actions', slotInjections[0]);
271
- check('second seat is the composer dock', slotInjections[1] === 'conversation.input.dock', slotInjections[1]);
289
+ check('registers the chip source', sources.length === 1 && sources[0].name === 'quote-shot', JSON.stringify(sources.map((s) => s.name)));
290
+ check(
291
+ 'chip codec covers clipboard and serialize',
292
+ typeof sources[0]?.codec?.clipboardText === 'function' && typeof sources[0]?.codec?.serialize === 'function'
293
+ );
294
+ check(
295
+ 'chip serialises to the same marker',
296
+ sources[0]?.codec?.clipboardText('abcdefgh-0000-0000-0000-000000000000')?.startsWith('[引用消息 abcdefgh-') === true
297
+ );
298
+ check('registers the user-text rule', rules.length === 1 && rules[0].id === 'dsh-quote-shot-card', String(rules[0]?.id));
299
+ check('injects into three slots', slotInjections.length === 3, slotInjections.join(','));
300
+ check('seat order: user text, message action, composer dock', slotInjections[0] === 'conversation.user.text.node', slotInjections[0]);
301
+ check('second seat is the message action', slotInjections[1] === 'conversation.chat.assistant-actions', slotInjections[1]);
302
+ check('third seat is the composer dock', slotInjections[2] === 'conversation.input.dock', slotInjections[2]);
272
303
  const actionSeat = seats.find((s) => s?.spec?.id === 'quote');
273
304
  const cardSeat = seats.find((s) => s?.spec?.id === 'quote-card');
274
305
  check('registers the action component', actionSeat?.component === api.QuoteActions);