dsh-quote-shot 0.1.1 → 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.
- package/README.md +56 -6
- package/client.js +566 -4
- package/index.js +192 -22
- package/package.json +1 -1
- package/session-reader.js +94 -17
- 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
|
### 链路
|
|
@@ -128,7 +168,7 @@ ctx.tools.register({
|
|
|
128
168
|
})
|
|
129
169
|
```
|
|
130
170
|
|
|
131
|
-
###
|
|
171
|
+
### 引用功能本身的九个坑
|
|
132
172
|
|
|
133
173
|
| # | 坑 | 后果 | 教训 |
|
|
134
174
|
|---|---|---|---|
|
|
@@ -139,6 +179,8 @@ ctx.tools.register({
|
|
|
139
179
|
| 5 | 「第一个够长就返回」 | 抓到路径碎片(87 字) | 取**最外层**候选,不是最近的 |
|
|
140
180
|
| 6 | 用 `useInput` 的 `phase` 判断发送 | 那个字段不存在 | **未验证的 API 假设 = 建在沙子上** |
|
|
141
181
|
| 7 | **`innerText ?? textContent`** | **0.2.2 后引用全空** | 见下,这是最隐蔽的一个 |
|
|
182
|
+
| 8 | 拿「最后一个 live agent」当调用者 | 后台 subagent 一活跃就 `not-found` | **live agent ≠ 调用者**;后台 reviewer 也是活 agent |
|
|
183
|
+
| 9 | 只认 `session-<uuid>` 会话目录 | 大部分会话读不到 | DSH 两种目录命名并存,裸 uuid 也要试 |
|
|
142
184
|
|
|
143
185
|
### ⚠️ 坑 7 展开:`innerText` 对未渲染元素返回 `""`,不是 `null`
|
|
144
186
|
|
|
@@ -220,11 +262,19 @@ quote_read(messageId) → { found, messageId, text }
|
|
|
220
262
|
- 会话日志是**多帧 zstd**(每次追加一帧),普通解压只出第一帧,必须扫 magic `28 B5 2F FD` 逐帧解开
|
|
221
263
|
- 消息 id 位置:`user/message → data.id`,`assistant/message → data.message.id`
|
|
222
264
|
- 同一 id 可能跨多条事件(流式分片),文本要**累加**而非覆盖
|
|
223
|
-
-
|
|
224
|
-
-
|
|
265
|
+
- 按**文件路径**缓存索引(同一日志可能被两种目录名指向),文件增长时才重建
|
|
266
|
+
- 会话目录有两种命名:`session-<uuid>` 与裸 `<uuid>`,**两种都要试**
|
|
267
|
+
- 工具拿不到 sessionId(`exec` 只带 `signal`),所以按三级定位:① 客户端点击时上报的 `messageId → sessionId`;② 遍历**全部** live agent;③ 扫最近 25 个会话日志
|
|
225
268
|
|
|
226
269
|
**已验证**:`quote_read('eb3fc73f-...')` 返回 113 字符原文,与日志里的 `len=113` 一致。
|
|
227
270
|
|
|
271
|
+
**回归验证**:同一个 message id(`02dc5192-...`)在修复前返回 `not-found` 且日志无记录;修复后日志为
|
|
272
|
+
|
|
273
|
+
```
|
|
274
|
+
hint message=02dc5192-... session=session-1db3d041-...
|
|
275
|
+
quote_read session=session-1db3d041-... message=02dc5192-... len=894 via=hint
|
|
276
|
+
```
|
|
277
|
+
|
|
228
278
|
## 开发
|
|
229
279
|
|
|
230
280
|
```sh
|
|
@@ -291,10 +341,10 @@ node install.mjs # 安装 / 刷新
|
|
|
291
341
|
| **裸标记触发** | ❌ **0 次**(设计缺陷) |
|
|
292
342
|
| **显式指令标记触发** | ✅ 调用并读到 2318 字符 |
|
|
293
343
|
|
|
294
|
-
|
|
344
|
+
**风险与边界**:
|
|
295
345
|
|
|
296
346
|
1. **模型可能不调**——这次是因为提问直接指向"引用的内容",工具是唯一手段。如果引用只是背景("按上面说的改一下"),模型可能凭印象作答而跳过工具。
|
|
297
|
-
2.
|
|
347
|
+
2. **极冷的会话仍可能读不到**——三级定位覆盖「点击时上报的会话」「存活会话」「最近 25 个日志」。如果引用的是一条更早、更冷会话里的消息,扫盘窗口没覆盖到,仍会 `not-found`(此时日志会写明 `MISS` 和试过哪些会话)。
|
|
298
348
|
|
|
299
349
|
---
|
|
300
350
|
|
|
@@ -326,7 +376,7 @@ node install.mjs # 安装 / 刷新
|
|
|
326
376
|
|
|
327
377
|
- **草稿多一行标记**:`[引用消息 xxx]` 会出现在输入框里,发送后成为消息正文的一部分。
|
|
328
378
|
- **模型可能不调工具**:见上,引用可能被忽略。
|
|
329
|
-
-
|
|
379
|
+
- **极冷的会话读不到**:定位覆盖点击时上报的会话、存活会话、最近 25 个日志;更早且未在窗口内的会话会 `not-found`。
|
|
330
380
|
- **抓取的是「当时的 DOM」**:卡片预览是点击瞬间的快照。
|
|
331
381
|
- **引用后不打字直接发送**:卡片不会自动收起(检测不到发送)。
|
|
332
382
|
- **排版丢失**:DOM 提取只取 `innerText`,代码块/表格会变成纯文本。
|
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');
|
|
@@ -42,6 +44,8 @@ window.__ModuleLoader__.load({
|
|
|
42
44
|
const CARD_PREVIEW_CHARS = 60;
|
|
43
45
|
/** Host route that performs the injection. */
|
|
44
46
|
const INJECT_ROUTE = '/quote-shot/inject';
|
|
47
|
+
/** Host route where the click's session is reported for `quote_read`. */
|
|
48
|
+
const HINT_ROUTE = '/quote-shot/hint';
|
|
45
49
|
|
|
46
50
|
//#region quote state
|
|
47
51
|
/**
|
|
@@ -112,6 +116,31 @@ window.__ModuleLoader__.load({
|
|
|
112
116
|
/* ignored on purpose */
|
|
113
117
|
}
|
|
114
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Tell the Host which session this quote id belongs to.
|
|
121
|
+
*
|
|
122
|
+
* `quote_read` runs later with no session context, and the live-agent
|
|
123
|
+
* registry cannot be trusted to point at the caller (background subagents
|
|
124
|
+
* are live agents too). The click is the one moment the id and the session
|
|
125
|
+
* are both known, so it is recorded here.
|
|
126
|
+
*
|
|
127
|
+
* Fire-and-forget: a lost hint only costs the Host a slower fallback lookup.
|
|
128
|
+
*
|
|
129
|
+
* @param messageId - the quoted message id.
|
|
130
|
+
* @param sessionId - the session the button was clicked in.
|
|
131
|
+
*/
|
|
132
|
+
function postHint(messageId, sessionId) {
|
|
133
|
+
try {
|
|
134
|
+
fetch(HINT_ROUTE, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
credentials: 'same-origin',
|
|
137
|
+
headers: { 'content-type': 'application/json' },
|
|
138
|
+
body: JSON.stringify({ messageId, sessionId })
|
|
139
|
+
}).catch(() => {});
|
|
140
|
+
} catch {
|
|
141
|
+
/* ignored on purpose */
|
|
142
|
+
}
|
|
143
|
+
}
|
|
115
144
|
//#endregion
|
|
116
145
|
|
|
117
146
|
/**
|
|
@@ -128,6 +157,281 @@ window.__ModuleLoader__.load({
|
|
|
128
157
|
* @param anchor - the clicked button element.
|
|
129
158
|
* @returns the text, or an empty string when nothing usable was found.
|
|
130
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
|
+
|
|
131
435
|
function readQuoteText(anchor) {
|
|
132
436
|
try {
|
|
133
437
|
const selection = window.getSelection();
|
|
@@ -404,6 +708,10 @@ window.__ModuleLoader__.load({
|
|
|
404
708
|
}
|
|
405
709
|
|
|
406
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]'));
|
|
407
715
|
if (text.length === 0) {
|
|
408
716
|
// Ship the DOM shape along with the failure: message markup is not a
|
|
409
717
|
// contract, so the extraction strategy cannot be fixed from a bare
|
|
@@ -427,20 +735,58 @@ window.__ModuleLoader__.load({
|
|
|
427
735
|
// A bare `[引用消息 <id>]` failed in real use: the model decided it did
|
|
428
736
|
// not need the content and answered from memory (measured 0 calls across
|
|
429
737
|
// 3 quotes). Quoting means "look at this", so the marker now says so.
|
|
430
|
-
const marker =
|
|
738
|
+
const marker = buildMarker(messageId);
|
|
431
739
|
setBusy(true);
|
|
432
740
|
let inserted = false;
|
|
741
|
+
let usedChip = false;
|
|
433
742
|
try {
|
|
434
|
-
inserted =
|
|
435
|
-
|
|
743
|
+
inserted = insertQuoteChip(messageId, sessionId, text);
|
|
744
|
+
usedChip = inserted;
|
|
745
|
+
} catch (error) {
|
|
746
|
+
postLog('chip-insert-failed', { messageId, detail: String((error && error.message) || error) });
|
|
436
747
|
inserted = false;
|
|
437
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
|
+
}
|
|
438
759
|
setBusy(false);
|
|
439
760
|
if (inserted !== true) {
|
|
440
761
|
report('insert-failed', { messageId });
|
|
441
762
|
return;
|
|
442
763
|
}
|
|
443
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
|
+
|
|
786
|
+
// Record the id -> session pair before the marker leaves the client, so
|
|
787
|
+
// `quote_read` can resolve the owner instead of guessing a live agent.
|
|
788
|
+
postHint(messageId, sessionId);
|
|
789
|
+
|
|
444
790
|
replaceQuote(sessionId, {
|
|
445
791
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
446
792
|
preview: previewOf(text),
|
|
@@ -672,14 +1018,229 @@ window.__ModuleLoader__.load({
|
|
|
672
1018
|
);
|
|
673
1019
|
}
|
|
674
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
|
+
|
|
675
1205
|
/**
|
|
676
1206
|
* Register the dictionaries, the message action and the composer cards.
|
|
677
1207
|
*
|
|
678
1208
|
* @param ctx - the client plugin context.
|
|
679
1209
|
*/
|
|
680
1210
|
function apply(ctx) {
|
|
1211
|
+
pluginCtx = ctx;
|
|
681
1212
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'quote-shot: dictionaries');
|
|
682
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
|
+
|
|
683
1244
|
ctx.slots.inject('conversation.chat.assistant-actions', () =>
|
|
684
1245
|
ctx.slots.register(
|
|
685
1246
|
{
|
|
@@ -711,9 +1272,10 @@ window.__ModuleLoader__.load({
|
|
|
711
1272
|
}
|
|
712
1273
|
|
|
713
1274
|
exports.apply = apply;
|
|
714
|
-
exports.inject = ['slots', 'locale'];
|
|
1275
|
+
exports.inject = ['slots', 'locale', 'uiConversation', 'conversation', 'sessions', 'inputTriggers'];
|
|
715
1276
|
exports.QuoteActions = QuoteActions;
|
|
716
1277
|
exports.QuoteCard = QuoteCard;
|
|
1278
|
+
exports.SentQuoteCard = SentQuoteCard;
|
|
717
1279
|
exports.readQuoteText = readQuoteText;
|
|
718
1280
|
exports.previewOf = previewOf;
|
|
719
1281
|
exports.injectQuote = injectQuote;
|
package/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import { appendFileSync } from 'node:fs';
|
|
|
16
16
|
import { homedir } from 'node:os';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
|
|
19
|
-
import { readMessageById } from './session-reader.js';
|
|
19
|
+
import { readMessageById, readMessageFromFile, listRecentSessionFiles } from './session-reader.js';
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Diagnostic log path.
|
|
@@ -55,7 +55,44 @@ export const inject = ['webServer', 'agents', 'tools'];
|
|
|
55
55
|
/** Route owned by this plugin. */
|
|
56
56
|
const ROUTE = '/quote-shot/inject';
|
|
57
57
|
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Route where the client reports which session a quote id belongs to.
|
|
60
|
+
*
|
|
61
|
+
* This is the one piece of knowledge the Host cannot recover reliably: the
|
|
62
|
+
* click happens in a known session, but `quote_read` runs later with no session
|
|
63
|
+
* context at all. Remembering the click's sessionId turns "guess which live
|
|
64
|
+
* agent is calling" into a direct lookup.
|
|
65
|
+
*/
|
|
66
|
+
const HINT_ROUTE = '/quote-shot/hint';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* messageId -> sessionId, filled by the client when a quote is placed.
|
|
70
|
+
*
|
|
71
|
+
* Bounded: a long session can quote many messages, and only recent quotes are
|
|
72
|
+
* plausibly still unread. Oldest entries fall out first (Map preserves order).
|
|
73
|
+
*/
|
|
74
|
+
const quoteHints = new Map();
|
|
75
|
+
const MAX_HINTS = 200;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Remember which session a quoted message belongs to.
|
|
79
|
+
*
|
|
80
|
+
* @param messageId - quoted message id.
|
|
81
|
+
* @param sessionId - session the quote button was clicked in.
|
|
82
|
+
*/
|
|
83
|
+
function rememberHint(messageId, sessionId) {
|
|
84
|
+
if (messageId.length === 0 || sessionId.length === 0) return;
|
|
85
|
+
quoteHints.delete(messageId);
|
|
86
|
+
quoteHints.set(messageId, sessionId);
|
|
87
|
+
if (quoteHints.size > MAX_HINTS) {
|
|
88
|
+
const oldest = quoteHints.keys().next().value;
|
|
89
|
+
quoteHints.delete(oldest);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Longest accepted quote body, so one call cannot flood the context.
|
|
95
|
+
*/
|
|
59
96
|
const MAX_TEXT_CHARS = 20_000;
|
|
60
97
|
/** Longest accepted summary line shown in the collapsed row. */
|
|
61
98
|
const MAX_SUMMARY_CHARS = 200;
|
|
@@ -111,6 +148,76 @@ function buildSummary(text) {
|
|
|
111
148
|
*
|
|
112
149
|
* @param ctx - the Host plugin context.
|
|
113
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
|
+
|
|
114
221
|
export function apply(ctx) {
|
|
115
222
|
ctx.effect(
|
|
116
223
|
() =>
|
|
@@ -232,6 +339,86 @@ export function apply(ctx) {
|
|
|
232
339
|
'quote-shot: client log route'
|
|
233
340
|
);
|
|
234
341
|
|
|
342
|
+
// Client hint sink: which session the quote was placed in.
|
|
343
|
+
//
|
|
344
|
+
// Fire-and-forget from the client. A missed hint only costs a slower lookup
|
|
345
|
+
// (the registry and the on-disk scan still run), so failures are silent.
|
|
346
|
+
ctx.effect(
|
|
347
|
+
() =>
|
|
348
|
+
ctx.webServer.register({
|
|
349
|
+
kind: 'exact',
|
|
350
|
+
path: HINT_ROUTE,
|
|
351
|
+
handler: async (request, response) => {
|
|
352
|
+
const reply = (status, body) => {
|
|
353
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
354
|
+
response.end(JSON.stringify(body));
|
|
355
|
+
};
|
|
356
|
+
if (request.method !== 'POST') {
|
|
357
|
+
response.writeHead(405, { allow: 'POST' });
|
|
358
|
+
response.end();
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const body = await readJson(request);
|
|
362
|
+
if (body === null) {
|
|
363
|
+
reply(400, { ok: false, code: 'bad-json' });
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const messageId = typeof body.messageId === 'string' ? body.messageId.trim() : '';
|
|
367
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
368
|
+
if (messageId.length === 0 || sessionId.length === 0) {
|
|
369
|
+
reply(400, { ok: false, code: 'missing-fields' });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
rememberHint(messageId, sessionId);
|
|
373
|
+
trace(`hint message=${messageId} session=${sessionId}`);
|
|
374
|
+
reply(200, { ok: true });
|
|
375
|
+
}
|
|
376
|
+
}),
|
|
377
|
+
'quote-shot: hint route'
|
|
378
|
+
);
|
|
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
|
+
|
|
235
422
|
// The `quote_read` tool: read a message's full text by id.
|
|
236
423
|
//
|
|
237
424
|
// This is what makes "quote by reference" possible — the composer carries only
|
|
@@ -294,26 +481,9 @@ export function apply(ctx) {
|
|
|
294
481
|
async execute(args) {
|
|
295
482
|
const messageId = typeof args?.messageId === 'string' ? args.messageId.trim() : '';
|
|
296
483
|
if (messageId.length === 0) return { found: false, messageId: '', error: 'missing-messageId' };
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
live = ctx.agents.list();
|
|
301
|
-
} catch {
|
|
302
|
-
live = [];
|
|
303
|
-
}
|
|
304
|
-
if (live.length === 0) return { found: false, messageId, error: 'no-active-conversation' };
|
|
305
|
-
|
|
306
|
-
// Most recently registered live agent is the one issuing this call.
|
|
307
|
-
const owner = live[live.length - 1];
|
|
308
|
-
const sessionId = owner?.session?.id;
|
|
309
|
-
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
310
|
-
return { found: false, messageId, error: 'no-session-id' };
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
const text = readMessageById(sessionId, messageId);
|
|
314
|
-
if (text === null) return { found: false, messageId, error: 'not-found' };
|
|
315
|
-
trace(`quote_read session=${sessionId} message=${messageId} len=${text.length}`);
|
|
316
|
-
return { found: true, messageId, text };
|
|
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 };
|
|
317
487
|
}
|
|
318
488
|
});
|
|
319
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.
|
|
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/session-reader.js
CHANGED
|
@@ -25,9 +25,29 @@ const SESSIONS_ROOT = join(homedir(), '.dsh', 'sessions');
|
|
|
25
25
|
/** Zstd frame magic number. */
|
|
26
26
|
const MAGIC = [0x28, 0xb5, 0x2f, 0xfd];
|
|
27
27
|
|
|
28
|
-
/** Per-
|
|
28
|
+
/** Per-file cache: absolute log path -> { mtimeMs, index }. */
|
|
29
29
|
const cache = new Map();
|
|
30
30
|
|
|
31
|
+
/** Log file name inside a session directory. */
|
|
32
|
+
const LOG_NAME = 'session.v4.jsonl.zstd';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Directory names one session log may live under.
|
|
36
|
+
*
|
|
37
|
+
* DSH is not consistent here: sessions created by the desktop UI are stored as
|
|
38
|
+
* `session-<uuid>`, while subagent sessions and older logs use a bare `<uuid>`.
|
|
39
|
+
* On this machine that was 12 prefixed vs 161 bare logs in ONE workspace slug,
|
|
40
|
+
* so matching only the prefixed form silently missed almost every session.
|
|
41
|
+
*
|
|
42
|
+
* @param sessionId - bare session uuid, with or without the `session-` prefix.
|
|
43
|
+
* @returns candidate directory names, most likely first.
|
|
44
|
+
*/
|
|
45
|
+
function sessionDirCandidates(sessionId) {
|
|
46
|
+
const bare = String(sessionId).replace(/^session-/, '');
|
|
47
|
+
if (bare.length === 0) return [];
|
|
48
|
+
return [`session-${bare}`, bare];
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
/**
|
|
32
52
|
* Locate the durable log file for one session.
|
|
33
53
|
*
|
|
@@ -38,21 +58,63 @@ const cache = new Map();
|
|
|
38
58
|
* @returns absolute path, or null when no log exists.
|
|
39
59
|
*/
|
|
40
60
|
export function findSessionFile(sessionId) {
|
|
41
|
-
|
|
42
|
-
if (bare.length === 0 || !existsSync(SESSIONS_ROOT)) return null;
|
|
61
|
+
if (!existsSync(SESSIONS_ROOT)) return null;
|
|
43
62
|
let slugs;
|
|
44
63
|
try {
|
|
45
64
|
slugs = readdirSync(SESSIONS_ROOT);
|
|
46
65
|
} catch {
|
|
47
66
|
return null;
|
|
48
67
|
}
|
|
49
|
-
for (const
|
|
50
|
-
|
|
51
|
-
|
|
68
|
+
for (const dir of sessionDirCandidates(sessionId)) {
|
|
69
|
+
for (const slug of slugs) {
|
|
70
|
+
const file = join(SESSIONS_ROOT, slug, dir, LOG_NAME);
|
|
71
|
+
if (existsSync(file)) return file;
|
|
72
|
+
}
|
|
52
73
|
}
|
|
53
74
|
return null;
|
|
54
75
|
}
|
|
55
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Every session log under the sessions root, newest first.
|
|
79
|
+
*
|
|
80
|
+
* Used as the last-resort lookup: a quoted message can be older than the set of
|
|
81
|
+
* live agents (DSH restarted, or the conversation was reopened from history), so
|
|
82
|
+
* the owning session has to be found on disk rather than in the registry.
|
|
83
|
+
*
|
|
84
|
+
* @param limit - how many of the most recently touched logs to return.
|
|
85
|
+
* @returns `{ sessionId, file, mtimeMs }` entries, newest first.
|
|
86
|
+
*/
|
|
87
|
+
export function listRecentSessionFiles(limit = 25) {
|
|
88
|
+
if (!existsSync(SESSIONS_ROOT)) return [];
|
|
89
|
+
const found = [];
|
|
90
|
+
let slugs;
|
|
91
|
+
try {
|
|
92
|
+
slugs = readdirSync(SESSIONS_ROOT);
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
for (const slug of slugs) {
|
|
97
|
+
let dirs;
|
|
98
|
+
try {
|
|
99
|
+
dirs = readdirSync(join(SESSIONS_ROOT, slug));
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const dir of dirs) {
|
|
104
|
+
const file = join(SESSIONS_ROOT, slug, dir, LOG_NAME);
|
|
105
|
+
let stat;
|
|
106
|
+
try {
|
|
107
|
+
stat = statSync(file);
|
|
108
|
+
} catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
found.push({ sessionId: dir.replace(/^session-/, ''), file, mtimeMs: stat.mtimeMs });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
found.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
|
115
|
+
return found.slice(0, Math.max(1, limit));
|
|
116
|
+
}
|
|
117
|
+
|
|
56
118
|
/**
|
|
57
119
|
* Decompress every zstd frame in a multi-frame buffer.
|
|
58
120
|
*
|
|
@@ -122,24 +184,25 @@ function textOf(data) {
|
|
|
122
184
|
}
|
|
123
185
|
|
|
124
186
|
/**
|
|
125
|
-
* Build (or reuse) the id → text index for one
|
|
187
|
+
* Build (or reuse) the id → text index for one log file.
|
|
126
188
|
*
|
|
127
189
|
* A single message id can appear across several events (streaming parts), so
|
|
128
190
|
* text is accumulated per id rather than overwritten.
|
|
129
191
|
*
|
|
130
|
-
*
|
|
192
|
+
* Cached by FILE PATH, not session id: the same log can be reached through a
|
|
193
|
+
* prefixed and a bare directory name, and keying by id would build it twice.
|
|
194
|
+
*
|
|
195
|
+
* @param file - absolute log path.
|
|
131
196
|
* @returns the index, or null when the log cannot be read.
|
|
132
197
|
*/
|
|
133
|
-
function
|
|
134
|
-
const file = findSessionFile(sessionId);
|
|
135
|
-
if (file === null) return null;
|
|
198
|
+
function indexForFile(file) {
|
|
136
199
|
let mtimeMs;
|
|
137
200
|
try {
|
|
138
201
|
mtimeMs = statSync(file).mtimeMs;
|
|
139
202
|
} catch {
|
|
140
203
|
return null;
|
|
141
204
|
}
|
|
142
|
-
const cached = cache.get(
|
|
205
|
+
const cached = cache.get(file);
|
|
143
206
|
if (cached !== undefined && cached.mtimeMs === mtimeMs) return cached.index;
|
|
144
207
|
|
|
145
208
|
let raw;
|
|
@@ -166,12 +229,26 @@ function indexFor(sessionId) {
|
|
|
166
229
|
const previous = index.get(id);
|
|
167
230
|
index.set(id, previous === undefined ? body : `${previous}\n${body}`);
|
|
168
231
|
}
|
|
169
|
-
cache.set(
|
|
232
|
+
cache.set(file, { mtimeMs, index });
|
|
170
233
|
return index;
|
|
171
234
|
}
|
|
172
235
|
|
|
173
236
|
/**
|
|
174
|
-
* Read one message's
|
|
237
|
+
* Read one message's text out of a specific log file.
|
|
238
|
+
*
|
|
239
|
+
* @param file - absolute log path.
|
|
240
|
+
* @param messageId - the message id.
|
|
241
|
+
* @returns the text, or null when the id is not in that log.
|
|
242
|
+
*/
|
|
243
|
+
export function readMessageFromFile(file, messageId) {
|
|
244
|
+
if (typeof file !== 'string' || typeof messageId !== 'string') return null;
|
|
245
|
+
const index = indexForFile(file);
|
|
246
|
+
if (index === null) return null;
|
|
247
|
+
return index.get(messageId) ?? null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Read one message's full text by id, from its owning session.
|
|
175
252
|
*
|
|
176
253
|
* @param sessionId - owning session (bare uuid or `session-`-prefixed).
|
|
177
254
|
* @param messageId - the message id.
|
|
@@ -179,9 +256,9 @@ function indexFor(sessionId) {
|
|
|
179
256
|
*/
|
|
180
257
|
export function readMessageById(sessionId, messageId) {
|
|
181
258
|
if (typeof sessionId !== 'string' || typeof messageId !== 'string') return null;
|
|
182
|
-
const
|
|
183
|
-
if (
|
|
184
|
-
return
|
|
259
|
+
const file = findSessionFile(sessionId);
|
|
260
|
+
if (file === null) return null;
|
|
261
|
+
return readMessageFromFile(file, messageId);
|
|
185
262
|
}
|
|
186
263
|
|
|
187
264
|
/**
|
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('
|
|
270
|
-
check(
|
|
271
|
-
|
|
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);
|