dsh-mobile-plugin 0.5.1 → 0.5.3

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.
@@ -4,7 +4,8 @@
4
4
  * 移动端「Agent 预设管理」API:把桌面端设置页的预设管理分区补齐到手机上。
5
5
  *
6
6
  * 手机端只保留管理分区(app 的 PresetManager):桌面那边设置里的一行与新建会话芯片已下线,
7
- * 手机一律吃宿主默认档。三条路由都只转发宿主的 agentPreset 域:read / copy / remove。
7
+ * 手机一律吃宿主默认档。四条路由:read / copy / remove 转发宿主的 agentPreset 域;default
8
+ * 写宿主 settings 里存「默认档」的那一格(见 handleAgentPresetDefault)。
8
9
  *
9
10
  * 语义按宿主原文透传:copy 是整份复制,标识符成为目录名且事后无法更改;remove 只对 user
10
11
  * 预设有效(随附预设回 agent-preset-read-only)。宿主的失败对象是 code + details,按 code
@@ -143,10 +144,73 @@ async function handleAgentPresetRemove(ctx, req, res) {
143
144
  sendJson(res, 200, { ok: true, agentPreset: id.value });
144
145
  }
145
146
 
147
+ /** 宿主 settings 里存「默认预设」的那一格:桌面端「设为默认」写的就是它。 */
148
+ const PRESET_SETTINGS_NS = 'agent-preset-registry';
149
+ const PRESET_DEFAULT_FIELD = 'selectedDefault';
150
+
151
+ /**
152
+ * POST /api/mobile/v1/agent-presets/default(token){ agentPreset } → { ok, agentPreset }
153
+ *
154
+ * 「哪一档是新会话的默认档」不是预设自己的属性:宿主注册表从 settings 的
155
+ * `agent-preset-registry.selectedDefault` 读(没写过就退回部署时那个 default),桌面端那颗
156
+ * 「设为默认」写的就是这一格。手机此前没有这个入口,新会话只能吃部署时那一档。
157
+ *
158
+ * 先按名单确认这一档真在,再写:写一个不存在的 id 不会报错,但之后每次新建会话都会被宿主
159
+ * 拒(agent-preset-not-found),那时用户已经不知道是自己哪一步弄坏的。
160
+ */
161
+ async function handleAgentPresetDefault(ctx, req, res) {
162
+ const body = await readJsonBody(req, PRESET_MAX_BODY);
163
+ const id = normalizeId(body && body.agentPreset);
164
+ if (!id.value) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: id.problem } });
165
+
166
+ const listed = unwrap(await callAgentPresets(ctx, 'list', {}));
167
+ if (listed.error) return sendJson(res, statusOf(listed.error.code), { ok: false, error: failure(listed.error) });
168
+ const rows = listed.value && Array.isArray(listed.value.presets) ? listed.value.presets : [];
169
+ const row = rows.find((item) => item && item.id === id.value);
170
+ if (!row) {
171
+ return sendJson(res, 404, {
172
+ ok: false,
173
+ error: {
174
+ code: 'agent-preset-not-found',
175
+ message: presetMessage('agent-preset-not-found'),
176
+ details: { agentPreset: id.value, available: rows.map((item) => item && item.id).filter(Boolean) },
177
+ },
178
+ });
179
+ }
180
+ // 已经是默认档就不再写:settings.mutate 一次就推进一次 revision,白写等于让别处正在
181
+ // 改设置的人撞上冲突。
182
+ if (row.isDefault === true) return sendJson(res, 200, { ok: true, agentPreset: id.value });
183
+
184
+ const settings = ctx.apiProxy && ctx.apiProxy.settings;
185
+ if (!settings || typeof settings.mutate !== 'function') {
186
+ return sendJson(res, 400, {
187
+ ok: false,
188
+ error: { code: 'unsupported', message: '这台宿主没有设置写入接口,改不了默认预设' },
189
+ });
190
+ }
191
+ const { error } = unwrap(await settings.mutate({
192
+ rpcId: 'mobile',
193
+ method: 'settings.mutate',
194
+ payload: { ns: PRESET_SETTINGS_NS, ops: [{ op: 'set', path: [PRESET_DEFAULT_FIELD], value: id.value }] },
195
+ }));
196
+ if (error) {
197
+ const hostMessage = error && typeof error.message === 'string' ? error.message : '';
198
+ return sendJson(res, 400, {
199
+ ok: false,
200
+ error: {
201
+ code: error && typeof error.code === 'string' && error.code ? error.code : 'internal',
202
+ message: hostMessage ? `默认预设没写进去:${hostMessage}` : '默认预设没写进去',
203
+ },
204
+ });
205
+ }
206
+ sendJson(res, 200, { ok: true, agentPreset: id.value });
207
+ }
208
+
146
209
  module.exports = {
147
210
  handleAgentPresetRead,
148
211
  handleAgentPresetCopy,
149
212
  handleAgentPresetRemove,
213
+ handleAgentPresetDefault,
150
214
  // 下面这些是给守卫测试直接驱动的纯函数(与 references.js 同做法)
151
215
  normalizeId,
152
216
  normalizeName,
@@ -159,4 +223,6 @@ module.exports = {
159
223
  PRESET_ID_MAX_CHARS,
160
224
  PRESET_NAME_MAX_CHARS,
161
225
  PRESET_BODY_MAX_CHARS,
226
+ PRESET_SETTINGS_NS,
227
+ PRESET_DEFAULT_FIELD,
162
228
  };
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 手机端调试轨迹的落盘口(`POST /api/mobile/v1/debug/trace`)。
5
+ *
6
+ * 为什么要有它:滑动手感出问题时,现场在手机上(偏移轨迹、每一行的实际高度、图片什么时候
7
+ * 才到),而能看这些东西的人在这台电脑上。让人把几万行日志抄进聊天不现实,所以 App 边录边把
8
+ * 一行行 JSONL 打到这里,插件落进 `<DSH_HOME>/mobile-debug/<capture>.jsonl`,维护者在这台
9
+ * 电脑上直接读文件。
10
+ *
11
+ * 三条边界,别放宽:
12
+ * - **不是任意文件写入**:文件名只由 `capture` 生成,且只认 `[A-Za-z0-9._-]`。构造一个别的
13
+ * capture 只会在这一个目录里多一份空文件,碰不到用户的任何东西;
14
+ * - **只追加**:一次请求往 `<capture>.jsonl` 末尾续一段,从不覆盖、从不删别的文件;
15
+ * - **封顶**:一份轨迹 8MB 之后停写(回 `capped: true`,App 收到就停手),目录里最多留 10 份,
16
+ * 第 11 份进来时删掉最旧那份。录音不会把盘写满。
17
+ *
18
+ * 与 `session/file`、`session/attachment` 那些读口方向相反:这条只写、写的位置固定,回给手机
19
+ * 的只有文件名与字节数,不回路径。
20
+ */
21
+
22
+ const fs = require('node:fs');
23
+ const fsp = require('node:fs/promises');
24
+ const path = require('node:path');
25
+
26
+ const { dshHome } = require('../shared/paths');
27
+ const { sendJson, readJsonBody } = require('../shared/http-util');
28
+
29
+ /** 一份轨迹的字节上限。真到这一步说明录得太久,该停了。 */
30
+ const MAX_CAPTURE_BYTES = 8 * 1024 * 1024;
31
+ /** 目录里最多留几份(按 mtime,新的来、旧的走)。 */
32
+ const MAX_CAPTURES = 10;
33
+ /** 一次请求最多带多少行;App 那边按 40KB 切批,正常远小于这个数。 */
34
+ const MAX_LINES_PER_BATCH = 4000;
35
+ /** 一次请求最多多少字节(`readJsonBody` 本身还有 344KB 的封顶,这里再收一道)。 */
36
+ const MAX_BATCH_BYTES = 512 * 1024;
37
+ /** 轨迹名(也就是文件名):只认这几类字符,别的一律 400。 */
38
+ const CAPTURE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
39
+
40
+ /** 轨迹目录。按调用时解析,测试可注入 `DSH_HOME`。 */
41
+ function traceDir() {
42
+ return path.join(dshHome(), 'mobile-debug');
43
+ }
44
+
45
+ /** 某一份轨迹的落盘路径(`capture` 已经过白名单校验)。 */
46
+ function capturePath(capture) {
47
+ return path.join(traceDir(), `${capture}.jsonl`);
48
+ }
49
+
50
+ /**
51
+ * 目录里留最近 `MAX_CAPTURES` 份。
52
+ *
53
+ * 按 mtime 而不是文件名:轨迹名由 App 带时间戳生成,但同一秒的两份、或者用户手改过的名字都
54
+ * 不该让清理算错。读不动 stat 的(正在被删)直接跳过,清理失败不该让这次上传失败。
55
+ */
56
+ async function pruneCaptures() {
57
+ let names;
58
+ try {
59
+ names = await fsp.readdir(traceDir());
60
+ } catch {
61
+ return;
62
+ }
63
+ const files = [];
64
+ for (const name of names) {
65
+ if (!name.endsWith('.jsonl')) continue;
66
+ const full = path.join(traceDir(), name);
67
+ try {
68
+ const info = await fsp.stat(full);
69
+ if (info.isFile()) files.push({ name, full, mtime: info.mtimeMs });
70
+ } catch { /* 刚被删掉 / 读不动:跳过 */ }
71
+ }
72
+ if (files.length <= MAX_CAPTURES) return;
73
+ files.sort((a, b) => a.mtime - b.mtime);
74
+ for (const file of files.slice(0, files.length - MAX_CAPTURES)) {
75
+ try {
76
+ await fsp.unlink(file.full);
77
+ } catch { /* 删不掉就留着,下次再说 */ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * 续写一段轨迹。
83
+ *
84
+ * `lines` 是已经序列化好的 JSON 行(App 侧拼的),这里只做「拼成一段文本 + 追加」。一行都
85
+ * 不合法的批次按空处理:回 ok、写 0 字节,别让手机那头因为一次形状不对就整段录不上。
86
+ */
87
+ async function handleDebugTrace(_ctx, req, res) {
88
+ const body = await readJsonBody(req);
89
+ const capture = body && typeof body.capture === 'string' ? body.capture : '';
90
+ if (!CAPTURE_RE.test(capture)) {
91
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { capture },且只认字母数字与 ._-' } });
92
+ }
93
+ const raw = body && Array.isArray(body.lines) ? body.lines : null;
94
+ if (!raw) {
95
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { lines: string[] }' } });
96
+ }
97
+ if (raw.length > MAX_LINES_PER_BATCH) {
98
+ return sendJson(res, 413, { ok: false, error: { code: 'too-many-lines', message: `一次最多 ${MAX_LINES_PER_BATCH} 行` } });
99
+ }
100
+ const lines = raw.filter((line) => typeof line === 'string' && line.length > 0);
101
+ if (lines.length === 0) {
102
+ return sendJson(res, 200, { ok: true, file: `${capture}.jsonl`, bytes: 0, lines: 0, capped: false });
103
+ }
104
+ const text = `${lines.join('\n')}\n`;
105
+ const bytes = Buffer.byteLength(text, 'utf8');
106
+ if (bytes > MAX_BATCH_BYTES) {
107
+ return sendJson(res, 413, { ok: false, error: { code: 'too-large', message: `一次最多 ${MAX_BATCH_BYTES} 字节` } });
108
+ }
109
+
110
+ const file = capturePath(capture);
111
+ try {
112
+ await fsp.mkdir(traceDir(), { recursive: true });
113
+ } catch (error) {
114
+ return sendJson(res, 500, { ok: false, error: { code: 'io', message: `建目录失败:${error && error.message ? error.message : ''}` } });
115
+ }
116
+
117
+ let existing = 0;
118
+ let created = false;
119
+ try {
120
+ existing = (await fsp.stat(file)).size;
121
+ } catch {
122
+ created = true;
123
+ }
124
+
125
+ if (existing >= MAX_CAPTURE_BYTES) {
126
+ // 已经写满:回 capped 让 App 停手。文件留着,人要看的正是前面那一段。
127
+ return sendJson(res, 200, { ok: true, file: `${capture}.jsonl`, bytes: existing, lines: 0, capped: true });
128
+ }
129
+ // 快写满时**按整行留**:切在字节中间会让最后一行变成半截 JSON,读的人只会看到一行坏数据。
130
+ const room = MAX_CAPTURE_BYTES - existing;
131
+ let payload = text;
132
+ if (bytes > room) {
133
+ const kept = [];
134
+ let used = 0;
135
+ for (const line of lines) {
136
+ const size = Buffer.byteLength(line, 'utf8') + 1;
137
+ if (used + size > room) break;
138
+ kept.push(line);
139
+ used += size;
140
+ }
141
+ payload = kept.length > 0 ? `${kept.join('\n')}\n` : '';
142
+ }
143
+ try {
144
+ await fsp.appendFile(file, payload);
145
+ } catch (error) {
146
+ return sendJson(res, 500, { ok: false, error: { code: 'io', message: `写盘失败:${error && error.message ? error.message : ''}` } });
147
+ }
148
+ // 清理放在写完之后:这一份刚建出来的文件按 mtime 是最新的,留下来的正是它。
149
+ if (created) await pruneCaptures();
150
+ let total = existing;
151
+ try {
152
+ total = (await fsp.stat(file)).size;
153
+ } catch { /* 写成功但 stat 失败:回一个估计值,不影响 App */ }
154
+ sendJson(res, 200, {
155
+ ok: true,
156
+ file: `${capture}.jsonl`,
157
+ bytes: total,
158
+ lines: payload === text ? lines.length : (payload ? payload.split('\n').length - 1 : 0),
159
+ // 「这一段被切了」或「已经顶到上限」都算满:App 收到就停手,别继续往一条死路上发。
160
+ capped: payload !== text || total >= MAX_CAPTURE_BYTES,
161
+ });
162
+ }
163
+
164
+ /** 同步读一份轨迹(维护者在电脑上直接看文件;这里只给测试与将来的面板用)。 */
165
+ function readCapture(capture) {
166
+ if (!CAPTURE_RE.test(capture)) return null;
167
+ try {
168
+ return fs.readFileSync(capturePath(capture), 'utf8');
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ module.exports = {
175
+ handleDebugTrace,
176
+ readCapture,
177
+ traceDir,
178
+ MAX_CAPTURE_BYTES,
179
+ MAX_CAPTURES,
180
+ };
@@ -45,6 +45,9 @@ function normalizeProfile(result) {
45
45
  status: 'ready',
46
46
  name: textOrNull(profile.name),
47
47
  contact: textOrNull(profile.contact),
48
+ // 头像是资料的一部分(和名字、联系方式同级),手机拿去当 <Image> 的 uri 用;
49
+ // 账号 ID、token、attempt 仍然不出这一层。
50
+ avatarUrl: textOrNull(profile.avatarUrl),
48
51
  };
49
52
  }
50
53
 
@@ -15,9 +15,14 @@
15
15
  * 2000 不是随手取的上限:本机 1420 个真实回合量下来,最长一轮 576 条消息、超过 500 的
16
16
  * 有 3 个、超过 200 的有 18 个。它只是**上界**,宿主在跨过 `minTurns` 且遇到 turn/start
17
17
  * 时就会停,所以正常情况下读的仍是那几十条;只有碰上超长回合才会真往上读到 2000。
18
+ *
19
+ * `minTurns` 取 1,**不是 6**:进对话要的是「尾部这一屏」,不是「六轮上下文」。
20
+ * 取 6 的时候,一轮 40 步的会话会把窗口撑到一百多条,手机侧要铺十几屏才追得到底 ——
21
+ * 用户看到的是「进来之后内容一路往下滑」。窗口大小是这条链的总闸:窗口小,
22
+ * 「一屏就是终态」才成立;更早的内容由「加载更早」按需补。
18
23
  */
19
24
  const ENTRY_THIN_MAX_MESSAGES = 2000;
20
- const ENTRY_THIN_MIN_TURNS = 6;
25
+ const ENTRY_THIN_MIN_TURNS = 1;
21
26
 
22
27
  /** 把入口窗口的取数参数写进宿主请求体(就地修改并返回,调用方只关心副作用)。 */
23
28
  function entryWindowPayload(payload, limit) {
@@ -12,6 +12,30 @@
12
12
  */
13
13
 
14
14
  const { cutAssistantBlock, cutToolResultEvent, createToolNameIndex } = require('./wire-cut.js');
15
+ const { rememberToolResults, toolResultsFor } = require('./tool-result-cache.js');
16
+
17
+ /** 每一轮出过的图(工具结果里的图片块),轮号 → 图。`lookback` 也扫:它才装得下更早那一页。 */
18
+ function collectTurnImages(entries, lookbackEntries) {
19
+ const byTurn = new Map();
20
+ for (const list of [entries, lookbackEntries]) {
21
+ for (const entry of list || []) {
22
+ const e = entry && entry.event ? entry.event : entry;
23
+ if (!e || e.type !== 'tool/result' || !e.data || !e.data.message) continue;
24
+ const turn = typeof e.data.turn === 'number' ? e.data.turn : undefined;
25
+ if (turn === undefined) continue;
26
+ const { parts } = toolResultParts(cutToolResultEvent(e, '').data.message);
27
+ const images = toolResultImages(parts);
28
+ if (images.length === 0) continue;
29
+ const bucket = byTurn.get(turn) || [];
30
+ for (const image of images) {
31
+ if (bucket.some((kept) => kept.attachmentId && kept.attachmentId === image.attachmentId)) continue;
32
+ bucket.push(image);
33
+ }
34
+ byTurn.set(turn, bucket);
35
+ }
36
+ }
37
+ return byTurn;
38
+ }
15
39
  const {
16
40
  reasoningStepSummary,
17
41
  toolStepSummary,
@@ -61,12 +85,14 @@ function toolResultParts(msg) {
61
85
  * 图片块 → 移动端图片条目(`ChatMessage.images[]` / 工具块的 `images[]` 同一个形状)。
62
86
  *
63
87
  * 两条互斥的出口:
64
- * - 块上有 `attachmentId`:下发**引用** `{ mediaType, name?, attachmentId }`,**不带 data**。
65
- * 字节由手机端要画的时候按 attachmentId 单张去取(`/session/attachment`),
88
+ * - 块上有 `attachmentId`:下发**引用** `{ mediaType, name?, width?, height?, attachmentId }`,
89
+ * **不带 data**。字节由手机端要画的时候按 attachmentId 单张去取(`/session/attachment`),
66
90
  * 历史响应因此不再背着几十张截图的 base64。
67
- * - 块里本来就只有内联 `data`、没有 attachmentId:照旧下发 `{ mediaType, data, name? }`。
91
+ * - 块里本来就只有内联 `data`、没有 attachmentId:照旧下发 `{ mediaType, data, name?, width?, height? }`。
68
92
  * 这条兜底不能删——老日志、工具结果里存在这种直接带字节的图片块。
69
93
  *
94
+ * 宽高两格见 `imageDimsOf`:那是「手机能不能在挂载那一帧就占好位置」的前提。
95
+ *
70
96
  * 两条都要求 mediaType 可读(块上没有就退到 attachment 上的),否则 App 认不出来、
71
97
  * 也没有可显示的动作,返回 null 让调用方丢掉它。
72
98
  */
@@ -81,11 +107,28 @@ function imageEntryOf(block) {
81
107
  const name = typeof block.name === 'string'
82
108
  ? block.name
83
109
  : (typeof attachment.name === 'string' ? attachment.name : undefined);
84
- if (attachmentId) return { mediaType, ...(name !== undefined ? { name } : {}), attachmentId };
85
- if (typeof block.data === 'string' && block.data) return { mediaType, data: block.data, ...(name !== undefined ? { name } : {}) };
110
+ const dims = imageDimsOf(block, attachment);
111
+ if (attachmentId) return { mediaType, ...(name !== undefined ? { name } : {}), ...dims, attachmentId };
112
+ if (typeof block.data === 'string' && block.data) return { mediaType, data: block.data, ...(name !== undefined ? { name } : {}), ...dims };
86
113
  return null;
87
114
  }
88
115
 
116
+ /**
117
+ * 图片的原始宽高 —— 宿主的附件里本来就有(`attachment.width` / `attachment.height`)。
118
+ *
119
+ * **不许省掉这一格。** 手机侧要靠它把图片行的高度在**挂载那一帧就占好**:没有宽高,那一行
120
+ * 只能先按 240 方盒占位、等图解码完 onLoad 再改成真比例;而行高在挂载之后变高,正是对话页
121
+ * 「先落到底、再被顶回去」的来源(贴底列表上方一变高,屏幕上的内容就往下退一段)。
122
+ * 宽高非法(缺失 / 非正数)时整格不出现,调用方仍按旧兜底盒处理。
123
+ */
124
+ function imageDimsOf(block, attachment) {
125
+ const pick = (value) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined);
126
+ const width = pick(block.width) ?? pick(attachment.width);
127
+ const height = pick(block.height) ?? pick(attachment.height);
128
+ if (width === undefined || height === undefined) return {};
129
+ return { width, height };
130
+ }
131
+
89
132
  /** 把 tool/result 的图片块收成移动端图片条目(两种出口见 imageEntryOf)。 */
90
133
  function toolResultImages(parts) {
91
134
  const out = [];
@@ -324,10 +367,23 @@ function presentedPathsOf(events) {
324
367
  * - command:command/run 发出行,command/done 原地升级。
325
368
  * 结构化项与消息按 seq 升序混排;旧载荷字段缺失时跳过对应项,绝不抛错。
326
369
  */
327
- function deriveMessages(events) {
370
+ function deriveMessages(events, sessionId, lookbackEvents) {
328
371
  const out = [];
329
372
  // 预扫描 tool/result 事件(同批次事件内,结果可能在调用块之前/之后出现)。
330
- const toolResults = buildToolResultMap(events);
373
+ // `lookbackEvents` 是这一页**再往前**的一小段:倒着分页时工具结果常常落在比消息更早的位置
374
+ // (一轮里先跑工具、最后才写正文),只看这一页就看不出这一轮出过哪些图。
375
+ const toolResults = buildToolResultMap(lookbackEvents && lookbackEvents.length ? events.concat(lookbackEvents) : events);
376
+ // 倒着分页时,一页的边界可能正好切在「工具调用」与「它的结果」之间:这一页只有调用,
377
+ // 结果在更新的一页(本机早就拿到过)。把那些记在进程里的结果并进来,图就跟着消息一起
378
+ // 下发,手机侧那一行在挂载那一帧就是终高(见 tool-result-cache.js)。
379
+ const remembered = toolResultsFor(sessionId);
380
+ if (remembered) {
381
+ for (const [callId, result] of remembered) {
382
+ if (!toolResults.has(callId)) toolResults.set(callId, result);
383
+ }
384
+ }
385
+ // 这一页看到的结果记下来,供更早的页用(同一进程里,后到的页是更早的那几页)。
386
+ rememberToolResults(sessionId, toolResults);
331
387
  // 预扫描 compaction/summary:检查点 user/message 与 summary 事件在日志里的先后不固定,
332
388
  // 用 compactionId → summary 数据把 counts 补全到同一压缩行上。
333
389
  const summaries = new Map();
@@ -505,12 +561,17 @@ function deriveMessages(events) {
505
561
  });
506
562
  const text = blocksToText(blocks);
507
563
  // 图片条目统一投影:引用模式带 attachmentId 不带 data,内联兜底带 data 不带 attachmentId。
564
+ //
565
+ // **宽高必须一起带上**(`imageDimsOf` 的口径):手机侧靠它在挂载那一帧就把图片行的高度占好,
566
+ // 少这两格就只能先占 240 方盒、等解码完再改,而贴底列表上方一变高,屏幕上看到的内容就
567
+ // 往下退一段 —— 这正是「先落到底、再被顶回去」那一跳。
508
568
  const images = [
509
569
  ...blocks.filter((b) => b.type === 'image'),
510
570
  ...toolImages,
511
- ].map(({ mediaType, data, name, attachmentId }) => ({
571
+ ].map(({ mediaType, data, name, attachmentId, width, height }) => ({
512
572
  mediaType,
513
573
  ...(typeof name === 'string' ? { name } : {}),
574
+ ...(Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 ? { width, height } : {}),
514
575
  ...(typeof attachmentId === 'string' && attachmentId ? { attachmentId } : {}),
515
576
  ...(typeof attachmentId === 'string' && attachmentId ? {} : { data }),
516
577
  }));
@@ -568,6 +629,16 @@ function deriveMessages(events) {
568
629
  if (item.role === 'user') lastOpener = item;
569
630
  };
570
631
 
632
+ /**
633
+ * 这次压缩由哪条 slash 命令触发(`compaction/*` 上的 `sourceCommandId`)。
634
+ *
635
+ * 手动 `/compact` 时压缩行与命令行是同一件事的两半(桌面 `CompactionCommandCard` 只画一个
636
+ * `CompactionItem`),靠这个 id 配对,见出循环后的「认领」。
637
+ */
638
+ const commandIdOf = (data) => (data && typeof data.sourceCommandId === 'string' && data.sourceCommandId !== ''
639
+ ? data.sourceCommandId
640
+ : null);
641
+
571
642
  // 放入一行 compaction:同一 compactionId 只保留一行,检查点行原地替换 start 行(最终按 seq 排序归位)。
572
643
  const placeCompaction = (item) => {
573
644
  let index = compactionAt.get(item.id);
@@ -611,6 +682,9 @@ function deriveMessages(events) {
611
682
  : (msg.id != null ? String(msg.id) : null);
612
683
  if (id === null) continue;
613
684
  const s = summaries.get(id);
685
+ // 触发命令的 id 在检查点自己的 source 上(桌面 `compactSource` 读的就是它)。只认
686
+ // `compaction/*` 的 data 时,检查点先到 / 那些事件在窗口外就配不上命令行。
687
+ const commandId = typeof src.sourceCommandId === 'string' && src.sourceCommandId !== '' ? src.sourceCommandId : null;
614
688
  placeCompaction({
615
689
  id,
616
690
  kind: 'compaction',
@@ -618,6 +692,7 @@ function deriveMessages(events) {
618
692
  summary: s ? compactionSummaryText(s.summary) : null,
619
693
  shadowedItemCount: s ? shadowedCount(s.shadowedSeqs) : null,
620
694
  shadowedTokenCount: s ? shadowedTokens(s.shadowedTokenCount) : null,
695
+ ...(commandId ? { compactionCommandId: commandId } : {}),
621
696
  seq,
622
697
  time,
623
698
  });
@@ -655,6 +730,7 @@ function deriveMessages(events) {
655
730
  // 行已由检查点发出(done)时跳过,避免重复行。同一轮里已有没落定的行就**原地顶替**
656
731
  // (换 id/状态,位置留在第一次尝试那一处)—— 否则一次跑几十步就是几十条行。
657
732
  const idx = pendingCompactionAt(e.data.turn, e.data.compactionId);
733
+ const commandId = commandIdOf(e.data);
658
734
  if (idx < 0) {
659
735
  placeCompaction({
660
736
  id: e.data.compactionId,
@@ -666,6 +742,7 @@ function deriveMessages(events) {
666
742
  seq,
667
743
  time,
668
744
  ...(typeof e.data.turn === 'number' ? { turn: e.data.turn } : {}),
745
+ ...(commandId ? { compactionCommandId: commandId } : {}),
669
746
  });
670
747
  } else {
671
748
  const existing = out[idx];
@@ -675,6 +752,7 @@ function deriveMessages(events) {
675
752
  state: 'running',
676
753
  error: null,
677
754
  ...(typeof e.data.turn === 'number' ? { turn: e.data.turn } : {}),
755
+ ...(commandId ? { compactionCommandId: commandId } : {}),
678
756
  };
679
757
  compactionAt.set(e.data.compactionId, idx);
680
758
  }
@@ -683,6 +761,7 @@ function deriveMessages(events) {
683
761
  // 带原因。这条以前整条丢掉,于是被拒绝的压缩永远停在「正在压缩…」,失败一次都看不见。
684
762
  const error = typeof e.data.error === 'string' && e.data.error !== '' ? e.data.error : null;
685
763
  const idx = pendingCompactionAt(e.data.turn, e.data.compactionId);
764
+ const commandId = commandIdOf(e.data);
686
765
  if (idx < 0) {
687
766
  placeCompaction({
688
767
  id: e.data.compactionId,
@@ -695,6 +774,7 @@ function deriveMessages(events) {
695
774
  seq,
696
775
  time,
697
776
  ...(typeof e.data.turn === 'number' ? { turn: e.data.turn } : {}),
777
+ ...(commandId ? { compactionCommandId: commandId } : {}),
698
778
  });
699
779
  } else {
700
780
  const existing = out[idx];
@@ -703,6 +783,7 @@ function deriveMessages(events) {
703
783
  id: e.data.compactionId,
704
784
  state: error ? 'failed' : 'done',
705
785
  error,
786
+ ...(commandId ? { compactionCommandId: commandId } : {}),
706
787
  };
707
788
  compactionAt.set(e.data.compactionId, idx);
708
789
  }
@@ -710,12 +791,14 @@ function deriveMessages(events) {
710
791
  // 已有同 compactionId 的行(start 或检查点发出)→ 升级为 done 并补 counts;否则新增 done 行。
711
792
  const id = e.data.compactionId;
712
793
  const existing = compactionAt.has(id) ? out[compactionAt.get(id)] : null;
794
+ const commandId = commandIdOf(e.data);
713
795
  if (existing) {
714
796
  existing.state = 'done';
715
797
  existing.error = null;
716
798
  existing.summary = compactionSummaryText(e.data.summary);
717
799
  existing.shadowedItemCount = shadowedCount(e.data.shadowedSeqs);
718
800
  existing.shadowedTokenCount = shadowedTokens(e.data.shadowedTokenCount);
801
+ if (commandId) existing.compactionCommandId = commandId;
719
802
  } else {
720
803
  placeCompaction({
721
804
  id,
@@ -726,6 +809,7 @@ function deriveMessages(events) {
726
809
  shadowedTokenCount: shadowedTokens(e.data.shadowedTokenCount),
727
810
  seq,
728
811
  time,
812
+ ...(commandId ? { compactionCommandId: commandId } : {}),
729
813
  });
730
814
  }
731
815
  } else if (e.type === 'llm/retry' && e.data && typeof e.data.retryId === 'string' && e.data.retryId !== '' && typeof e.data.retry === 'number') {
@@ -783,7 +867,9 @@ function deriveMessages(events) {
783
867
  } else if (e.type === 'command/done' && e.data && typeof e.data.commandId === 'string' && e.data.commandId !== '') {
784
868
  // 升级同 commandId 行(name 保 run 的);done 先到(罕见)则按 done 内容建行。
785
869
  const id = e.data.commandId;
786
- const done = { kind: 'command', status: 'done', seq, time };
870
+ // `id` 必须落在行上:done 先到(增量窗口从命令行尾巴开始时就是这种)时这条行是新建的,
871
+ // 没有 id 的话 App 按 id 合并就配不上那半截 run 行 —— 屏幕上会多出一行「命令 · …」。
872
+ const done = { id, kind: 'command', status: 'done', seq, time };
787
873
  if (e.data.kind === 'error' || e.data.kind === 'success') done.resultKind = e.data.kind;
788
874
  if (typeof e.data.text === 'string' && e.data.text !== '') done.text = e.data.text;
789
875
  const existing = commandAt.has(id) ? out[commandAt.get(id)] : null;
@@ -825,6 +911,24 @@ function deriveMessages(events) {
825
911
  };
826
912
  }
827
913
  }
914
+ /**
915
+ * 这一轮出过的图也按轮回填到**最后一条正文**上。
916
+ *
917
+ * 倒着翻页时,工具结果(图在这里)的 seq 往往比这条正文更早 —— 那一页后到,正文先上屏,
918
+ * 于是这一行先按「没图」的高度排好,等下一页把图带回来才长高;它在用户头顶上,一长高就把
919
+ * 正看的内容推走。`lookbackEvents` 里已经能看到这一轮的结果,就先挂到正文那条上(与 App
920
+ * `chatRenderRows` 的 settleImages 同口径),行在挂载那一帧就是终高。
921
+ */
922
+ if (producedItems.length > 0) {
923
+ const byTurn = collectTurnImages(events, lookbackEvents);
924
+ for (const [turn, images] of byTurn) {
925
+ // 这一轮已经有消息带图(工具调用那条就在这一页里):别重复挂,App 那边也会去重。
926
+ if (producedItems.some((entry) => entry.turn === turn && (entry.item.images?.length ?? 0) > 0)) continue;
927
+ const carriers = producedItems.filter((entry) => entry.turn === turn && entry.item.role !== 'user' && entry.item.text);
928
+ const last = carriers[carriers.length - 1];
929
+ if (last) last.item.images = images;
930
+ }
931
+ }
828
932
  // 产出清单按轮回填:这一轮所有 write / edit 的并集,挂到这一轮每条 assistant 消息上。
829
933
  // 挂「整轮」而不是「从写那一刻起」,与桌面 `selectProducedFiles(owner)` 同口径;正文里
830
934
  // 提到这一轮写的文件就能解析成可点(App 侧按文件名或整条路径唯一命中才画,见
@@ -835,6 +939,41 @@ function deriveMessages(events) {
835
939
  if (bucket && bucket.size > 0) item.producedFiles = [...bucket];
836
940
  }
837
941
  }
942
+ // 手动 `/compact` 在桌面上是**一件事**:`CompactionCommandCard` 只画一个 `CompactionItem`
943
+ // (标题取命令名,摘要位放条数与 token 数,条数不齐时放命令结果原文),命令行那块根本不出现。
944
+ // 宿主的 command/run·done 与压缩检查点由 `sourceCommandId` 连在一起,所以这里让压缩行认领
945
+ // 命令行:压缩行继承命令名,命令行整条不发给手机 —— 否则屏幕上会多出一行「命令 ·
946
+ // Compacted N history items」(名字在增量窗口里丢了就掉成「命令」,摘要是宿主 en locale 的
947
+ // 英文原句,手机不本地化)。
948
+ const commandNames = new Map();
949
+ for (const item of out) {
950
+ if (!item || item.kind !== 'command') continue;
951
+ const entry = {};
952
+ if (typeof item.name === 'string' && item.name !== '') entry.name = item.name;
953
+ if (typeof item.text === 'string' && item.text !== '') entry.text = item.text;
954
+ if (entry.name !== undefined || entry.text !== undefined) commandNames.set(item.id, entry);
955
+ }
956
+ const claimedCommands = new Set();
957
+ for (const item of out) {
958
+ if (!item || item.kind !== 'compaction') continue;
959
+ const commandId = item.compactionCommandId;
960
+ if (typeof commandId !== 'string' || commandId === '') continue;
961
+ const paired = commandNames.get(commandId);
962
+ // 标题取命令名;命令行那半截不在窗口里也写 compact —— 只有 /compact 能触发压缩,桌面
963
+ // `fallbackState` 同样把名字兜成 'compact',否则这一行会掉回「上下文已压缩」。
964
+ item.compactionTitle = paired && paired.name !== undefined ? paired.name : 'compact';
965
+ // 条数不齐时摘要位用命令结果原文顶上(桌面 `fallbackSummary: node.outcome?.text`)。
966
+ if (paired && paired.text !== undefined && (item.shadowedItemCount == null || item.shadowedTokenCount == null)) {
967
+ item.compactionFallbackSummary = paired.text;
968
+ }
969
+ claimedCommands.add(commandId);
970
+ }
971
+ if (claimedCommands.size > 0) {
972
+ for (let index = out.length - 1; index >= 0; index -= 1) {
973
+ const item = out[index];
974
+ if (item && item.kind === 'command' && claimedCommands.has(item.id)) out.splice(index, 1);
975
+ }
976
+ }
838
977
  // 压缩行带一个空正文:直播侧的同款行本来就带 `text: ''`(App 的 `structuralItems`
839
978
  // 一条不落),历史这边少这一格时,没跟上判空修复的旧安装包会在这条行上直接读正文、
840
979
  // 当场抛错、整页崩掉(2026-10-27 真机)。标题与摘要照旧按 kind 走,不读这个字段;