dsh-mobile-plugin 0.1.92 → 0.1.93

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 CHANGED
@@ -7,6 +7,7 @@ AI-HUB 手机端插件,为 DSH 提供移动 API、远程连接和局域网代
7
7
  ## 下载 AI-HUB 手机 App
8
8
 
9
9
  - [Android APK](https://app.wnluo.com/public/apps/14)
10
+ - [Android APK(GitHub 渠道)](https://github.com/WNLUO/dsh-mobile-plugin/releases/latest/download/ai-hub-android.apk);[历史版本](https://github.com/WNLUO/dsh-mobile-plugin/releases)
10
11
  - [App Store](https://apps.apple.com/us/app/ai-hub/id6821681438)(若暂时无法打开,可能是 App 仍在等待苹果审核通过。)
11
12
  - [TestFlight 测试版](https://testflight.apple.com/join/4fn5PCMD)
12
13
 
@@ -104,6 +105,7 @@ npm test
104
105
  - npm 包的 `repository` 字段用于关联插件列表条目;修改后需再次发布,注册表信息才会更新。
105
106
  - 插件列表收录通过向 awesome-dsh-plugin 提交 `data/plugins/<owner>__<repo>.yml` 完成。描述中的数字和 API 名称应能由代码核实;列表要求 `description.en`、`category: remote`,包需声明 `dsh.bundle`,仓库需有 `dsh-plugin` topic。
106
107
  - 仓库创建满一天的检查会自动重跑;截图可通过 `screenshots.json` 提供,也可由市场从 README 查找。
108
+ - GitHub 的 release 页放手机 App 的 APK(下载入口见开头那一节):资产名固定用 `ai-hub-android.apk`,并且发成正式版 —— 草稿与预发布都不会成为「最新」,README 里那条直连会随之断掉。插件自身走 npm 发布,不在本仓库发 release。
107
109
  - 隐私政策:[PRIVACY.md](PRIVACY.md)。技术支持与常见问题:[SUPPORT.md](SUPPORT.md)。
108
110
 
109
111
  ### 发布新版本
@@ -162,9 +162,12 @@
162
162
  searchTimeoutMs: 60000
163
163
  # Without this row the mobile host's tool catalog has no `ask_user_question`,
164
164
  # so the App's question dock could never appear: the model simply reports that
165
- # the tool is unavailable. `@deepseek-ai/dsh-host-apiproxy` already registers
166
- # the `ctx.userQuestions` provider that bridges `ask()` to the mux
167
- # `question/requested` frame, and the plugin's `lib/mobile/questions.js`
168
- # answers it — only the tool row was missing.
165
+ # the tool is unavailable. The plugin's `lib/mobile/questions.js` listens on
166
+ # the `user-questions/request` hook (0.2.x shape) and answers it — only the
167
+ # tool row was missing.
168
+ #
169
+ # The package must be pinned to the runtime's own generation (0.2.0-rc.2):
170
+ # its peerDependencies track the runtime release exactly, and the loader
171
+ # disables the whole row — rather than degrade — when one does not match.
169
172
  - id: tool-ask-user
170
173
  name: '@deepseek-ai/dsh-tool-ask-user'
package/lib/impl.js CHANGED
@@ -24,7 +24,7 @@ const { authorized } = require('./mobile/auth.js')
24
24
  const { createSidecarLifecycle } = require('./sidecar-lifecycle.cjs')
25
25
  const { createMirrorPusher } = require('./mirror/pusher.cjs')
26
26
  const { randomBytes } = require('node:crypto')
27
- const { lanUrls, advertisedCandidates, orderCandidates } = require('./relay/lan-network.cjs')
27
+ const { lanUrls, advertisedCandidates, orderCandidates, ipv6Candidates } = require('./relay/lan-network.cjs')
28
28
  const { resolvePort } = require('./relay/port-allocator.cjs')
29
29
  const { createPreviewService } = require('./relay/preview.js')
30
30
  // 本机设备凭据库:/api/relay/revoke-all 必须同时吊销局域网直连的 dshc_ 凭据,
@@ -177,6 +177,9 @@ function pairingPayload(state, lanPort) {
177
177
  // 候选列表随二维码一起带走:App 配对后即使换了网段,也能按这份列表
178
178
  // 依次重试而不用先回到公网中转去问。
179
179
  lanCandidates: candidates.map(candidate => candidate.url).filter(Boolean),
180
+ // IPv6 直连候选(理想情况下是手机**不在同一个 Wi-Fi 里**时唯一能拨到的地址):不参与上面
181
+ // 那份的排序,顺序由 App 侧拿它和公网中转实测比出来;直连监听没开就没有这条通道。
182
+ ipv6Candidates: state.lanEnabled ? ipv6Candidates(port).map(candidate => candidate.url).filter(Boolean) : [],
180
183
  }
181
184
  return {
182
185
  payload,
@@ -413,6 +416,8 @@ function apply(ctx) {
413
416
  getSidecarSnapshot: sidecarLifecycle.getSnapshot,
414
417
  // 移动端路由表那边叫它「局域网候选」,取的是同一份私网优先的清单。
415
418
  safeLanCandidates: advertisedCandidates,
419
+ // IPv6 直连候选单独一份:App 拿它单开一条通道、和公网中转比速度,不掺进上面那份的排序。
420
+ safeIpv6Candidates: ipv6Candidates,
416
421
  lanPortInUse: advertisedLanPort, // LAN 候选用实绑端口
417
422
  // 配对那条路由自己要签凭据:core 是签 token 的实现,pairingApproval 决定这台机器
418
423
  // 收不收新设备('deny' 时一律拒)。两者本文件都已有,透传给移动端路由。
@@ -1,16 +1,24 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 图片归一:会话历史存的是 `{type:'image', attachment}` 而不是内联字节,
5
- * 手机端拿到后要经 session attachment RPC 把图片取回来。
4
+ * 图片归一:会话历史存的是 `{type:'image', attachment}` 而不是内联字节。
6
5
  *
7
6
  * 宿主事件里图片可能出现在四处:message/content、inserted 上下文项、
8
7
  * assistant chunk 的 block,以及更深的嵌套 content。全部在这里归一,
9
- * 这样历史与 SSE 暴露给手机端的内联图片形状是同一个。
8
+ * 这样历史与 SSE 暴露给手机端的图片形状是同一个。
9
+ *
10
+ * **默认(引用模式 `opts.referencesOnly`)不取字节**:图片块原样留着 `attachment`
11
+ * 引用,由 history-derive 投影成 `{mediaType, name?, attachmentId}`,手机端要画的时候
12
+ * 按 attachmentId 单张去取(`/session/attachment`)。此前这里把每张图都换成内联 base64,
13
+ * 一页 200 条带十几张截图的响应接近 10MB,每次进会话都要重传一遍。
14
+ *
15
+ * 只有显式传 `referencesOnly: false` 时才回到「经 session attachment RPC 取回字节」的
16
+ * 老行为;取不回字节的块打 `dataUnavailable` 标(语义不变,见 dataUnavailableBlock)。
10
17
  *
11
18
  * 另外一类图片在正文里:`![](/abs/path.png)` 这种指向**宿主本机文件**的写法
12
19
  * (本机绝对路径、file://、~)在手机上取不到字节,也会被 App 当成非 http 图片降级成字面
13
20
  * markdown。这里顺手按同一形状内联成 data URI(实现与预算见 local-images.js)。
21
+ * 这条与附件引用是两件事:它走的是正文里的路径、没有 attachmentId,预算与行为保持原样。
14
22
  */
15
23
 
16
24
  const { unwrap } = require('./envelope.js');
@@ -19,12 +27,16 @@ const { inlineTextImages, createTextImageBudget } = require('./local-images.js')
19
27
 
20
28
  /**
21
29
  * Resolve durable image references before exposing messages to mobile clients.
22
- * Session history stores `{type:'image', attachment}` rather than inline bytes;
23
- * the mobile client resolves these through the session attachment RPC.
30
+ *
31
+ * Session history stores `{type:'image', attachment}` rather than inline bytes.
32
+ * By default (`referencesOnly`) the block is left as a reference and the mobile
33
+ * client fetches the bytes per attachment (`/session/attachment`); no host RPC
34
+ * happens on this path at all. Pass `referencesOnly: false` for the legacy
35
+ * inline-bytes shape.
24
36
  *
25
37
  * Host events can carry content in several places: message/content, inserted
26
38
  * context items, or an assistant/chunk block. Keep this normalization here so
27
- * history and SSE expose the same inline-image shape to mobile clients.
39
+ * history and SSE expose the same image shape to mobile clients.
28
40
  *
29
41
  * Text blocks additionally get local-file image syntax inlined as data URIs
30
42
  * (see local-images.js); `opts.budget` is a shared per-response budget so one
@@ -47,6 +59,9 @@ async function resolveEventAttachments(ctx, sessionId, event, opts = {}) {
47
59
  if (!event || !event.data || typeof event.data !== 'object') return event;
48
60
  const data = event.data;
49
61
  const budget = opts.budget || createTextImageBudget();
62
+ // 引用模式(默认):图片块只留 attachment 引用,**一次宿主 RPC 都不发**。
63
+ // 只有显式 referencesOnly:false 才走下面那条把字节取回来的老路。
64
+ const referencesOnly = opts.referencesOnly !== false;
50
65
  let nextData = data;
51
66
  let eventChanged = false;
52
67
 
@@ -59,6 +74,8 @@ async function resolveEventAttachments(ctx, sessionId, event, opts = {}) {
59
74
  if (block.type === 'image' && !block.data && block.attachment) {
60
75
  const attachmentId = block.attachment.attachmentId;
61
76
  if (typeof attachmentId !== 'string' || !attachmentId) return block;
77
+ // 引用模式到此为止:块原样返回,字节由手机端按 attachmentId 单张去取。
78
+ if (referencesOnly) return block;
62
79
  let result;
63
80
  try {
64
81
  result = unwrap(await callSessions(ctx, 'attachment', { sessionId, attachmentId }));
@@ -99,6 +99,46 @@ function toolResultParts(msg) {
99
99
  };
100
100
  }
101
101
 
102
+ /**
103
+ * 图片块 → 移动端图片条目(`ChatMessage.images[]` / 工具块的 `images[]` 同一个形状)。
104
+ *
105
+ * 两条互斥的出口:
106
+ * - 块上有 `attachmentId`:下发**引用** `{ mediaType, name?, attachmentId }`,**不带 data**。
107
+ * 字节由手机端要画的时候按 attachmentId 单张去取(`/session/attachment`),
108
+ * 历史响应因此不再背着几十张截图的 base64。
109
+ * - 块里本来就只有内联 `data`、没有 attachmentId:照旧下发 `{ mediaType, data, name? }`。
110
+ * 这条兜底不能删——老日志、工具结果里存在这种直接带字节的图片块。
111
+ *
112
+ * 两条都要求 mediaType 可读(块上没有就退到 attachment 上的),否则 App 认不出来、
113
+ * 也没有可显示的动作,返回 null 让调用方丢掉它。
114
+ */
115
+ function imageEntryOf(block) {
116
+ if (!block || typeof block !== 'object') return null;
117
+ const attachment = block.attachment && typeof block.attachment === 'object' ? block.attachment : {};
118
+ const attachmentId = typeof attachment.attachmentId === 'string' ? attachment.attachmentId : '';
119
+ const mediaType = typeof block.mediaType === 'string' && block.mediaType
120
+ ? block.mediaType
121
+ : (typeof attachment.mediaType === 'string' ? attachment.mediaType : '');
122
+ if (!mediaType) return null;
123
+ const name = typeof block.name === 'string'
124
+ ? block.name
125
+ : (typeof attachment.name === 'string' ? attachment.name : undefined);
126
+ if (attachmentId) return { mediaType, ...(name !== undefined ? { name } : {}), attachmentId };
127
+ if (typeof block.data === 'string' && block.data) return { mediaType, data: block.data, ...(name !== undefined ? { name } : {}) };
128
+ return null;
129
+ }
130
+
131
+ /** 把 tool/result 的图片块收成移动端图片条目(两种出口见 imageEntryOf)。 */
132
+ function toolResultImages(parts) {
133
+ const out = [];
134
+ for (const part of parts) {
135
+ if (!part || typeof part !== 'object' || part.type !== 'image') continue;
136
+ const entry = imageEntryOf(part);
137
+ if (entry) out.push(entry);
138
+ }
139
+ return out;
140
+ }
141
+
102
142
  /** 预扫描 tool/result 事件,构建 callId → {isError, code} 映射(对接桌面 toolRowModel 状态派生)。 */
103
143
  function buildToolResultMap(events) {
104
144
  const map = new Map();
@@ -115,10 +155,7 @@ function buildToolResultMap(events) {
115
155
  if (part && typeof part === 'object' && part.type === 'image') return '';
116
156
  try { return JSON.stringify(part); } catch { return ''; }
117
157
  }).filter(Boolean).join('');
118
- const images = parts
119
- .filter((part) => part && typeof part === 'object' && part.type === 'image'
120
- && typeof part.data === 'string' && typeof part.mediaType === 'string')
121
- .map((part) => ({ mediaType: part.mediaType, data: part.data, ...(typeof part.name === 'string' ? { name: part.name } : {}) }));
158
+ const images = toolResultImages(parts);
122
159
  const diffs = normalizeResultDiffs(e.data.meta);
123
160
  map.set(src.callId, { isError, code, ...(output ? { output } : {}), ...(images.length ? { images } : {}), ...(diffs ? { diffs } : {}) });
124
161
  }
@@ -142,8 +179,11 @@ function parseBlocks(blocks, toolResults) {
142
179
  // The image attachment resolver marked this block as unavailable. Keep it out of
143
180
  // visible text; pushMessage projects its retry metadata onto unavailableImages.
144
181
  continue;
145
- } else if (b.type === 'image' && typeof b.data === 'string' && typeof b.mediaType === 'string') {
146
- out.push({ type: 'image', mediaType: b.mediaType, data: b.data, name: typeof b.name === 'string' ? b.name : undefined });
182
+ } else if (b.type === 'image') {
183
+ // 有 attachmentId 走引用、只有内联 data 的走兜底(见 imageEntryOf)。两者都认不出
184
+ // 就整块丢掉——与旧行为一致,绝不能把它兜底成一段 JSON 文本。
185
+ const entry = imageEntryOf(b);
186
+ if (entry) out.push({ type: 'image', ...entry });
147
187
  } else if (b.type === 'tool-call') {
148
188
  // tool-call 块字段为 id/name/arguments(与 harness toAssistantBlock 同口径)。
149
189
  const callId = b.id != null ? String(b.id) : undefined;
@@ -336,10 +376,16 @@ function deriveMessages(events) {
336
376
  return rest;
337
377
  });
338
378
  const text = blocksToText(blocks);
379
+ // 图片条目统一投影:引用模式带 attachmentId 不带 data,内联兜底带 data 不带 attachmentId。
339
380
  const images = [
340
381
  ...blocks.filter((b) => b.type === 'image'),
341
382
  ...toolImages,
342
- ].map(({ mediaType, data, name }) => ({ mediaType, data, name }));
383
+ ].map(({ mediaType, data, name, attachmentId }) => ({
384
+ mediaType,
385
+ ...(typeof name === 'string' ? { name } : {}),
386
+ ...(typeof attachmentId === 'string' && attachmentId ? { attachmentId } : {}),
387
+ ...(typeof attachmentId === 'string' && attachmentId ? {} : { data }),
388
+ }));
343
389
  const unavailableImages = (Array.isArray(msg.content) ? msg.content : [])
344
390
  .filter((block) => block && block.type === 'image' && block.dataUnavailable === true)
345
391
  .map((block) => {
@@ -86,10 +86,11 @@ async function modelCatalog(ctx, payload, signal) {
86
86
  : null)
87
87
  : null;
88
88
  const effective = sessionSelection && sessionSelection.provider && sessionSelection.model ? sessionSelection : current;
89
- const routable = Array.isArray(catalog.routableProviders) ? catalog.routableProviders : [];
89
+ const routableProviders = Array.isArray(catalog.routableProviders) ? catalog.routableProviders : [];
90
90
  return ok({
91
91
  ...(effective ? { current: effective } : {}),
92
- routable: Boolean(effective && routable.includes(effective.provider)),
92
+ routable: Boolean(effective && routableProviders.includes(effective.provider)),
93
+ routableProviders,
93
94
  groups: Array.isArray(catalog.groups) ? catalog.groups : [],
94
95
  });
95
96
  }
@@ -33,6 +33,12 @@ function safeModelsValue(value) {
33
33
  }
34
34
  }
35
35
  if (typeof value.routable === 'boolean') result.routable = value.routable;
36
+ if (Array.isArray(value.routableProviders)) {
37
+ result.routableProviders = [...new Set(value.routableProviders
38
+ .filter((provider) => typeof provider === 'string' && provider.length > 0)
39
+ .slice(0, 128)
40
+ .map((provider) => provider.slice(0, 256)))];
41
+ }
36
42
  if (Array.isArray(value.groups)) {
37
43
  result.groups = value.groups
38
44
  .filter((group) => group && typeof group.id === 'string' && typeof group.name === 'string' && Array.isArray(group.models))
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 会话列表的「最后一条消息」预览:为最近 / 运行中的会话补一次 history 折叠。
5
+ *
6
+ * 会话列表的标题与行数据都来自宿主 `sessions.list` 摘要,只有 `lastMessage` 要读日志,
7
+ * 属于重活,所以只对预览子集做(见 sessions.js 的 SESSIONS_PREVIEW_LIMIT)。
8
+ *
9
+ * 同一份预览按会话指纹复用:运行中的会话 `updatedAt` 几乎每轮都在变,指纹于是每轮都变,
10
+ * 10 秒的列表缓存一到期就要为每个运行中会话重读一次 history;一轮生成里手机侧 15s
11
+ * 轮询会反复撞上「指纹变了但预览内容其实没变」的中间态,所以另加一个更短的窗口。
12
+ * 非运行中的会话仍走指纹判定(它们的 updatedAt 不变,指纹不变,本来就不会重读)。
13
+ */
14
+
15
+ const { callSessions } = require('./rpc.js');
16
+ const { unwrap } = require('./envelope.js');
17
+ const { deriveMessages, lastMessageOf } = require('./history-derive.js');
18
+ const {
19
+ sessionPreviewStateOf, sessionPreviewInFlightStateOf, sessionPreviewClockOf,
20
+ } = require('./session-cache.js');
21
+
22
+ /** 补预览时拉取的消息条数(低到足够取最后一条预览,避免每次重活)。 */
23
+ const HISTORY_FOR_ENRICH = 10;
24
+ /** 运行中会话的预览重读下限。 */
25
+ const RUNNING_PREVIEW_MIN_INTERVAL_MS = 3000;
26
+
27
+ function sessionPreviewFingerprint(summary) {
28
+ return `${summary.updatedAt || 0}:${summary.running ? 'running' : 'idle'}:${summary.blank ? 'blank' : 'content'}`;
29
+ }
30
+
31
+ /** Read one preview at most once per unchanged session revision. */
32
+ async function sessionPreviewFor(ctx, summary) {
33
+ const sessionId = summary.sessionId;
34
+ const fingerprint = sessionPreviewFingerprint(summary);
35
+ const cached = sessionPreviewStateOf(ctx).get(sessionId);
36
+ if (cached?.fingerprint === fingerprint) return { events: [], lastMessage: cached.lastMessage };
37
+
38
+ const inFlight = sessionPreviewInFlightStateOf(ctx);
39
+ const existing = inFlight.get(sessionId);
40
+ if (existing?.fingerprint === fingerprint) return existing.promise;
41
+
42
+ // 运行中会话的节流(见 RUNNING_PREVIEW_MIN_INTERVAL_MS):窗口内复用上一次预览,
43
+ // 不为「updatedAt 又动了一下」再读一遍日志。返回空 events 与 cache-hit 分支一致
44
+ // ——完成态确认只在真正读回历史时进行,本轮不复用它的边沿判定。
45
+ const clock = sessionPreviewClockOf(ctx);
46
+ if (cached && summary.running && Date.now() - (clock.get(sessionId) || 0) < RUNNING_PREVIEW_MIN_INTERVAL_MS) {
47
+ return { events: [], lastMessage: cached.lastMessage };
48
+ }
49
+ clock.set(sessionId, Date.now());
50
+
51
+ const promise = Promise.resolve(callSessions(ctx, 'history', {
52
+ sessionId,
53
+ maxMessages: HISTORY_FOR_ENRICH,
54
+ })).then((response) => {
55
+ const h = unwrap(response);
56
+ if (h.error) return { events: [], lastMessage: null };
57
+ const events = (h.value && h.value.events) || [];
58
+ const messages = deriveMessages(events);
59
+ const last = lastMessageOf(messages);
60
+ const lastMessage = last ? { role: last.role, text: last.text.slice(0, 200), at: last.time } : null;
61
+ sessionPreviewStateOf(ctx).set(sessionId, { fingerprint, lastMessage });
62
+ return { events, lastMessage };
63
+ }).finally(() => {
64
+ if (inFlight.get(sessionId)?.promise === promise) inFlight.delete(sessionId);
65
+ });
66
+ inFlight.set(sessionId, { fingerprint, promise });
67
+ return promise;
68
+ }
69
+
70
+ module.exports = { HISTORY_FOR_ENRICH, RUNNING_PREVIEW_MIN_INTERVAL_MS, sessionPreviewFingerprint, sessionPreviewFor };
@@ -71,7 +71,13 @@ async function executeCommand(ctx, sessionId, line) {
71
71
  */
72
72
  function promptOnce(ctx, clientMessageId, payload) {
73
73
  const rpcId = mobilePromptRpcId(clientMessageId);
74
- if (!clientMessageId) return callSessions(ctx, 'prompt', payload, rpcId);
74
+ // 把这一发认回去的身份一起送进宿主的 prompt 请求(`request.requestId`,见
75
+ // host-bridge/endpoints.js)。宿主要是不认识它,user 消息的 `source.rpcId` 就是那个
76
+ // 端点自己生成的 `mobile-prompt-<uuid>` —— App 那边 `clientMessageId` 永远对不上,
77
+ // 只能靠「同文本 + 同图数」猜着认领乐观行(2026-10-20 真机:自己发的图一直「发送中」,
78
+ // 因为实时帧里的图还被漏读,张数一对不上就彻底认领不了)。
79
+ const request = clientMessageId ? { ...payload, requestId: rpcId } : payload;
80
+ if (!clientMessageId) return callSessions(ctx, 'prompt', request, rpcId);
75
81
  const state = sendIdempotencyStateOf(ctx);
76
82
  const key = `${payload.sessionId}:${clientMessageId}`;
77
83
  const now = Date.now();
@@ -79,7 +85,7 @@ function promptOnce(ctx, clientMessageId, payload) {
79
85
  if (existing && now - existing.createdAt < 60_000) return existing.promise;
80
86
  if (existing) state.delete(key);
81
87
 
82
- const promise = Promise.resolve(callSessions(ctx, 'prompt', payload, rpcId));
88
+ const promise = Promise.resolve(callSessions(ctx, 'prompt', request, rpcId));
83
89
  // Keep the short-lived record so an in-flight/concurrent send with the same
84
90
  // clientMessageId shares one prompt. Only cache a SUCCESS: a rejection must
85
91
  // not poison later retries (a transient error should be retryable), so the
@@ -55,8 +55,10 @@ async function buildMessagesSnapshot(ctx, sessionId, limit = MESSAGES_DEFAULT_LI
55
55
  const { value, error } = unwrap(await callSessions(ctx, 'history', { sessionId, maxMessages: limit }));
56
56
  if (error) return { ok: false, error };
57
57
  const events = (value && value.events) || [];
58
+ // 引用模式:图片只下发 attachmentId,不取字节(见 attachments.js)。正文里本机图片的
59
+ // 内联仍走 budget,与之前一致。
58
60
  const budget = createTextImageBudget();
59
- const resolvedEvents = await Promise.all(events.map((entry) => resolveEventAttachments(ctx, sessionId, entry.event || entry, { budget }).then((event) => entry.event ? { ...entry, event } : event)));
61
+ const resolvedEvents = await Promise.all(events.map((entry) => resolveEventAttachments(ctx, sessionId, entry.event || entry, { budget, referencesOnly: true }).then((event) => entry.event ? { ...entry, event } : event)));
60
62
  const messages = deriveMessages(resolvedEvents);
61
63
  const lastSeq = events.length ? (eventSeqOf(events[events.length - 1]) ?? 0) : 0;
62
64
  const hasMore = !!(value && value.hasMore);
@@ -169,7 +171,7 @@ async function handleMessages(ctx, req, res) {
169
171
  // 一页历史共用一个内联图片预算:正文里的本机图片越多,越靠后的那些保持字面 markdown,
170
172
  // 而不是让单次响应无限膨胀(见 local-images.js 的三级预算)。
171
173
  const budget = createTextImageBudget();
172
- const resolvedEvents = await Promise.all(filtered.map((entry) => resolveEventAttachments(ctx, sessionId, entry.event || entry, { budget }).then((event) => entry.event ? { ...entry, event } : event)));
174
+ const resolvedEvents = await Promise.all(filtered.map((entry) => resolveEventAttachments(ctx, sessionId, entry.event || entry, { budget, referencesOnly: true }).then((event) => entry.event ? { ...entry, event } : event)));
173
175
  const messages = deriveMessages(resolvedEvents);
174
176
  const hasMore = !!(value && value.hasMore);
175
177
  const seqs = events.map(eventSeqOf).filter((seq) => Number.isSafeInteger(seq) && seq >= 0);
@@ -20,22 +20,25 @@
20
20
  * session-mirror.js 镜像整帧构建
21
21
  * session-goal.js 目标条动作
22
22
  * session-presets.js Agent 预设目录
23
+ * session-preview.js 列表行的 lastMessage 预览(按会话指纹复用 + 运行中会话的节流)
23
24
  *
24
- * 剩下的就是列表:GET /sessions(10 秒缓存 + fresh 绕过 + 完成态确认)、GET /session、
25
- * GET /session/stats、新建会话、排队消息动作。**本文件继续从各子模块转出导出**,
25
+ * 剩下的就是列表:GET /sessions(10 秒缓存 + fresh 绕过 + 完成态确认 + `?rev=` 条件刷新)、
26
+ * GET /session(单会话摘要)、GET /session/head(正文水位探针)、GET /session/stats、
27
+ * 新建会话、排队消息动作。**本文件继续从各子模块转出导出**,
26
28
  * 所以 lib/mobile 下所有 import 路径一行都不用改。
27
29
  */
28
30
 
29
31
  const { sendJson, readJsonBody } = require('../shared/http-util');
30
32
  const { isFreshRequest } = require('../shared/fresh-request');
31
- const { listRevision } = require('../shared/list-revision.js');
33
+ const { listRevision, unchangedListBody } = require('../shared/list-revision.js');
34
+ // 正文水位信号:session/head 探针直接读它的快照,**不**为了「看起来有值」去现读历史。
35
+ const { sessionSignal } = require('../shared/session-signal.js');
32
36
  const { withIdempotency, requestIdOf } = require('./idempotency.js');
33
37
  const { listTitle, deriveMessages, lastMessageOf, promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive.js');
34
38
  const { unwrap } = require('./envelope.js');
35
39
  const { callSessions } = require('./rpc.js');
36
40
  const {
37
41
  canonicalDeviceKey, sessionListResponseState, invalidateSessionList, deletedSessionIdsOf,
38
- sessionPreviewStateOf, sessionPreviewInFlightStateOf, sessionPreviewClockOf,
39
42
  completionGenerationOf, bumpCompletionGeneration,
40
43
  completionStateOf, sessionModelsStateOf, invalidateSessionModels, sendIdempotencyStateOf,
41
44
  } = require('./session-cache.js');
@@ -49,22 +52,13 @@ const { promptOnce, handleSend, handleCommands, handleSessionRead, handleSession
49
52
  const { handleSessionSearch } = require('./session-search.js');
50
53
  const { handleAgentPresets } = require('./session-presets.js');
51
54
  const { handleGoalAction } = require('./session-goal.js');
55
+ // 预览(lastMessage 补全)与它自己的重读节流:只读日志、不发写,单列一个子模块。
56
+ const { HISTORY_FOR_ENRICH, sessionPreviewFor } = require('./session-preview.js');
52
57
  const approvals = require('./approvals.js');
53
58
  const questions = require('./questions.js');
54
59
 
55
- const HISTORY_FOR_ENRICH = 10; // 补标题/预览时拉取的消息条数(低到足够取最后一条预览,避免每次重活)
56
60
  const SESSIONS_PREVIEW_LIMIT = 20; // 仅为最近/运行中的会话补最后消息预览
57
61
  const SESSION_LIST_CACHE_MS = 10000; // 会话列表增料缓存(降低手机轮询对桌面的冲击)
58
- /**
59
- * 运行中会话的预览重读下限。
60
- *
61
- * 运行中的会话 updatedAt 几乎每轮都在变,`sessionPreviewFingerprint` 于是每轮都变,
62
- * 10 秒的列表缓存一到期就要为每个运行中会话重读一次 history(HISTORY_FOR_ENRICH 条)。
63
- * 一轮生成里手机侧 15s 轮询会反复撞上「指纹变了但预览内容其实没变」的中间态。
64
- * 这里给运行中会话单独加一个更短的窗口:窗口内复用上一次预览,不重读日志。
65
- * 非运行中的会话仍走指纹判定(它们的 updatedAt 不变,指纹不变,本来就不会重读)。
66
- */
67
- const RUNNING_PREVIEW_MIN_INTERVAL_MS = 3000;
68
62
 
69
63
  /**
70
64
  * 宿主的两个**会话集合**(同一次 `workspace.list` 快照里拿到):
@@ -96,47 +90,32 @@ async function workspaceSessionFlags(ctx) {
96
90
  }
97
91
  }
98
92
 
99
- function sessionPreviewFingerprint(summary) {
100
- return `${summary.updatedAt || 0}:${summary.running ? 'running' : 'idle'}:${summary.blank ? 'blank' : 'content'}`;
101
- }
102
-
103
- /** Read one preview at most once per unchanged session revision. */
104
- async function sessionPreviewFor(ctx, summary) {
105
- const sessionId = summary.sessionId;
106
- const fingerprint = sessionPreviewFingerprint(summary);
107
- const cached = sessionPreviewStateOf(ctx).get(sessionId);
108
- if (cached?.fingerprint === fingerprint) return { events: [], lastMessage: cached.lastMessage };
109
-
110
- const inFlight = sessionPreviewInFlightStateOf(ctx);
111
- const existing = inFlight.get(sessionId);
112
- if (existing?.fingerprint === fingerprint) return existing.promise;
113
-
114
- // 运行中会话的节流(见 RUNNING_PREVIEW_MIN_INTERVAL_MS):窗口内复用上一次预览,
115
- // 不为「updatedAt 又动了一下」再读一遍日志。返回空 events 与 cache-hit 分支一致
116
- // ——完成态确认只在真正读回历史时进行,本轮不复用它的边沿判定。
117
- const clock = sessionPreviewClockOf(ctx);
118
- if (cached && summary.running && Date.now() - (clock.get(sessionId) || 0) < RUNNING_PREVIEW_MIN_INTERVAL_MS) {
119
- return { events: [], lastMessage: cached.lastMessage };
93
+ /**
94
+ * GET /api/mobile/v1/session/head?sessionId=…&parentSessionId=…&mode=…(token)
95
+ *
96
+ * 「这条会话的正文到哪个水位了」——App 进对话前先问一句,跟本地缓存水位一比就知道要不要
97
+ * 重新拉全量历史。几十字节换几兆。
98
+ *
99
+ * `seq` 取 `sessionSignal.peek(sessionId)`:镜像推送每轮都会 bump,语义正是「快照里真正
100
+ * 装上的消息的最大 seq」,与 App 的缓存水位同一个 seq 空间。
101
+ *
102
+ * `known` = `seq > 0`。**信号里没有时如实回 `known:false`**(插件刚重启、还没轮到推这个
103
+ * 会话),让 App 退回全量拉取;绝不为了「看起来有值」现读一次历史——那样这个探针本身
104
+ * 就变成了它要省掉的那次请求。
105
+ *
106
+ * 子代理(带 parentSessionId)直接回 `{seq:0, known:false}`:镜像里刻意不存子会话正文
107
+ * (见 dev-map A3-14 的「子会话的正文刻意不进镜像」),没有水位可言。
108
+ */
109
+ async function handleSessionHead(_ctx, req, res) {
110
+ const url = new URL(req.url, 'http://x');
111
+ const sessionId = url.searchParams.get('sessionId');
112
+ if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
113
+ const parentSessionId = url.searchParams.get('parentSessionId');
114
+ if (parentSessionId !== null && parentSessionId !== '') {
115
+ return sendJson(res, 200, { ok: true, seq: 0, known: false });
120
116
  }
121
- clock.set(sessionId, Date.now());
122
-
123
- const promise = Promise.resolve(callSessions(ctx, 'history', {
124
- sessionId,
125
- maxMessages: HISTORY_FOR_ENRICH,
126
- })).then((response) => {
127
- const h = unwrap(response);
128
- if (h.error) return { events: [], lastMessage: null };
129
- const events = (h.value && h.value.events) || [];
130
- const messages = deriveMessages(events);
131
- const last = lastMessageOf(messages);
132
- const lastMessage = last ? { role: last.role, text: last.text.slice(0, 200), at: last.time } : null;
133
- sessionPreviewStateOf(ctx).set(sessionId, { fingerprint, lastMessage });
134
- return { events, lastMessage };
135
- }).finally(() => {
136
- if (inFlight.get(sessionId)?.promise === promise) inFlight.delete(sessionId);
137
- });
138
- inFlight.set(sessionId, { fingerprint, promise });
139
- return promise;
117
+ const seq = sessionSignal.peek(sessionId);
118
+ sendJson(res, 200, { ok: true, seq, known: seq > 0 });
140
119
  }
141
120
 
142
121
  /** GET /api/mobile/v1/sessions(token) */
@@ -153,6 +132,10 @@ async function handleSessions(ctx, req, res) {
153
132
  // 与 fresh 的区别:它只绕开这层缓存,不需要作废在途构建,也不 bump 朝代号(没有写入)。
154
133
  const url = new URL(req.url, 'http://x');
155
134
  const archivedOnly = url.searchParams.get('archived') === '1';
135
+ // 条件刷新(?rev=<上次的 revision>):内容没变就只回一句 unchanged,不带 items。
136
+ // fresh=1(写后刷新必须拿到真数据)与 archived=1(归档子集不是同一份内容)一律不走。
137
+ const rev = (url.searchParams.get('rev') || '').trim();
138
+ const revEligible = !wantFresh && !archivedOnly;
156
139
  // 只有新客户端显式带分页参数时才切片;旧客户端不带参数仍拿到完整归档列表。
157
140
  const hasPaging = archivedOnly && (url.searchParams.has('offset') || url.searchParams.has('limit'));
158
141
  const parsedOffset = Number(url.searchParams.get('offset') ?? 0);
@@ -168,13 +151,21 @@ async function handleSessions(ctx, req, res) {
168
151
  cache.updatedAt = 0;
169
152
  generation = bumpCompletionGeneration(ctx, deviceKey);
170
153
  }
154
+ // 条件刷新的转换点:拿到**最终要回的 body**之后才判,且只在上面那两条短路条件
155
+ // (rev 缺失/为空、fresh=1、archived=1)都不成立时才认。unchanged 响应本身不写缓存、
156
+ // 也不进在途构建的合流判定——它只是同一份内容的一句话说清。
157
+ const respond = (status, body) => {
158
+ const unchanged = status === 200 && revEligible ? unchangedListBody(body, rev) : null;
159
+ return sendJson(res, status, unchanged || body);
160
+ };
161
+
171
162
  if (!bypassCache && cache.value && now - cache.updatedAt < SESSION_LIST_CACHE_MS) {
172
- return sendJson(res, 200, cache.value);
163
+ return respond(200, cache.value);
173
164
  }
174
165
 
175
166
  if (!bypassCache && cache.promise) {
176
167
  const response = await cache.promise;
177
- return sendJson(res, response.status, response.body);
168
+ return respond(response.status, response.body);
178
169
  }
179
170
 
180
171
  // 变更信号(镜像推送调度器先看见清单变了)会调 dropSessionListCache 丢掉这层缓存,
@@ -197,7 +188,7 @@ async function handleSessions(ctx, req, res) {
197
188
  cache.updatedAt = Date.now();
198
189
  }
199
190
  }
200
- return sendJson(res, response.status, response.body);
191
+ return respond(response.status, response.body);
201
192
  } finally {
202
193
  if (cache.promise === promise) cache.promise = null;
203
194
  }
@@ -455,6 +446,7 @@ module.exports = {
455
446
  SESSIONS_PREVIEW_LIMIT,
456
447
  handleSessions,
457
448
  handleSession,
449
+ handleSessionHead,
458
450
  handleStats,
459
451
  handleNew,
460
452
  handleQueueUpdate,