dsh-mobile-plugin 0.5.9 → 0.5.11

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.
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 公开模型参数快照的查表层:模型名 → 上下文 / 输出上限 / 读图 / 思考档位。
5
+ *
6
+ * 自建渠道与中转站的 `/v1/models` 大多只回 id 和名字:上下文多大、单次能输出多少、能不能
7
+ * 看图、思考有几档,一个字都没有。这些数公开库里有,所以插件带一份快照(由
8
+ * `scripts/build-model-meta.mjs` 生成),查到就替用户填好,查不到就留空让人自己改。
9
+ *
10
+ * ## 名字怎么对
11
+ *
12
+ * 键是**折叠后的模型名**:小写、只留字母与数字(`foldKey`,与 `model-prices.cjs` 逐字一致)。
13
+ * 于是 `Claude-Sonnet-4.5`、`anthropic/claude-sonnet-4-5`、`claude_sonnet_4.5(中转)` 落到
14
+ * 同一个键上 —— 用户说的「大小写不一样、中间加符号、带括号」都是同一件事。
15
+ *
16
+ * 只做**精确折叠相等**:不猜后缀、不做模糊匹配、不维护别名表。`-20250929`、`:free` 这些
17
+ * 查不到就是查不到,让用户自己改,比猜错一个上下文上限好。
18
+ *
19
+ * ## 搜索
20
+ *
21
+ * 折叠后的子串匹配(键与公开名字都算),**完全相等的排最前**、其次是前缀命中,其余按名字
22
+ * 长短排 —— 用户多半是在抄一个 id,先让他看到那一条。
23
+ */
24
+
25
+ const { REV, ROWS } = require('./model-meta-table.cjs');
26
+
27
+ /** 面板给用户勾的四档;快照已经按这四个归一化过(`minimal` → `low`、`xhigh` → `high`)。 */
28
+ const MODEL_LEVELS = ['low', 'medium', 'high', 'max'];
29
+
30
+ /** 折叠:小写、只留字母与数字。必须与生成脚本的 `foldKey` 逐字一致。 */
31
+ function foldKey(value) {
32
+ if (typeof value !== 'string') return '';
33
+ const flat = value.trim().toLowerCase();
34
+ // 中转站常写成 `anthropic/claude-sonnet-4-5`:砍掉厂商前缀,剩下的还是官方那个名字。
35
+ const cut = flat.lastIndexOf('/');
36
+ const bare = cut < 0 ? flat : flat.slice(cut + 1);
37
+ return bare.replace(/[^\p{L}\p{N}]+/gu, '');
38
+ }
39
+
40
+ /**
41
+ * 末尾带括号注释的那种写法:`claude-sonnet-4-5(中转)`、`gpt-4o (free)`。
42
+ *
43
+ * 只剥**结尾**的方括号段,且只用来兜第二遍查表 —— 用户给模型起名时常在后面加一句自己的
44
+ * 备注,而括号里的东西通常不是模型的一部分。`-preview` / `:free` 这类真变体不剥(剥了就等于
45
+ * 把另一个模型的窗口塞进来)。
46
+ */
47
+ function withoutTrailingBrackets(value) {
48
+ if (typeof value !== 'string') return '';
49
+ let text = value.trim();
50
+ const tail = /\s*(?:\([^()]*\)|([^()]*)|\[[^\[\]]*\]|【[^【】]*】)\s*$/;
51
+ while (tail.test(text)) text = text.replace(tail, '');
52
+ return text;
53
+ }
54
+
55
+ /** 快照的修订号(内容哈希)与条目数:App 拿它判断缓存要不要重拉。 */
56
+ const revision = REV;
57
+ const size = Object.keys(ROWS).length;
58
+
59
+ /**
60
+ * 快照里的一行 → 对外的参数形状。
61
+ *
62
+ * `reasoning: []` 与「没有 reasoning 这个键」是两件事:前者是「查到了,它不思考」,
63
+ * 后者是「公开库里没这条」,界面上前者要能把四档勾掉、后者要留默认。
64
+ */
65
+ function metaOf(key, row) {
66
+ const meta = { id: key };
67
+ if (typeof row.n === 'string' && row.n !== '') meta.name = row.n;
68
+ if (Number.isInteger(row.c) && row.c > 0) meta.contextWindow = row.c;
69
+ if (Number.isInteger(row.m) && row.m > 0) meta.maxTokens = row.m;
70
+ if (row.i === 1) meta.vision = true;
71
+ if (Array.isArray(row.r)) meta.reasoning = row.r.filter((level) => MODEL_LEVELS.includes(level));
72
+ return meta;
73
+ }
74
+
75
+ /**
76
+ * 查一条公开参数。
77
+ *
78
+ * @param {string} id 模型 id,大小写随意,可带 `厂商/` 前缀与括号后缀。
79
+ * @returns {{id: string, name?: string, contextWindow?: number, maxTokens?: number, vision?: boolean, reasoning?: string[]}|undefined}
80
+ * 查不到回 `undefined`(「公开库里没有」),别和「查到了、但没这个字段」混。
81
+ */
82
+ function lookupModelMeta(id) {
83
+ const key = foldKey(id);
84
+ if (key !== '' && ROWS[key] !== undefined) return metaOf(key, ROWS[key]);
85
+ // 第二遍:把结尾那句括号备注剥掉再查一次。只有这一种容错,别再加模糊匹配。
86
+ const bare = foldKey(withoutTrailingBrackets(id));
87
+ if (bare === '' || bare === key || ROWS[bare] === undefined) return undefined;
88
+ return metaOf(bare, ROWS[bare]);
89
+ }
90
+
91
+ /**
92
+ * 按关键词搜一批。
93
+ *
94
+ * @param {string} query 用户敲的那几个字(大小写、空格、符号都不敏感)。
95
+ * @param {number} [limit] 最多回几条,缺省 20。
96
+ */
97
+ function searchModelMeta(query, limit = 20) {
98
+ const needle = foldKey(query);
99
+ const max = Number.isFinite(limit) && limit > 0 ? Math.min(Math.trunc(limit), 100) : 20;
100
+ if (needle === '') return [];
101
+ const hits = [];
102
+ for (const key of Object.keys(ROWS)) {
103
+ const row = ROWS[key];
104
+ const name = foldKey(row.n);
105
+ const inKey = key.indexOf(needle);
106
+ const inName = name.indexOf(needle);
107
+ if (inKey < 0 && inName < 0) continue;
108
+ // 完全相等 → 0,前缀 → 1,其余 → 2;同档按名字长短(更像同一个东西的排前面)。
109
+ const rank = key === needle || name === needle
110
+ ? 0
111
+ : (key.startsWith(needle) || name.startsWith(needle) ? 1 : 2);
112
+ hits.push({ rank, length: key.length, key });
113
+ if (hits.length > max * 20) break;
114
+ }
115
+ hits.sort((left, right) => (left.rank - right.rank) || (left.length - right.length) || left.key.localeCompare(right.key));
116
+ return hits.slice(0, max).map((hit) => metaOf(hit.key, ROWS[hit.key]));
117
+ }
118
+
119
+ /** 整份表(App 端缓存 + 本地搜索用)。 */
120
+ function fullModelMeta() {
121
+ return { revision, total: size, items: ROWS };
122
+ }
123
+
124
+ module.exports = {
125
+ MODEL_LEVELS,
126
+ foldKey,
127
+ lookupModelMeta,
128
+ withoutTrailingBrackets,
129
+ searchModelMeta,
130
+ fullModelMeta,
131
+ revision,
132
+ size,
133
+ };
@@ -34,6 +34,9 @@ const { workspaceSessionFlags, pendingInteractionOf } = require('./session-flags
34
34
  * 字段名与完整清单**逐字一致**(sessionId / title / running / completed / pendingInteraction /
35
35
  * lastTurnError),因为原生那边解析的是同一套字段:换名字就得同步改 Kotlin,而那段代码
36
36
  * 在 App 之外,改错一次是「后台永远不提醒」这种看不见的故障。
37
+ *
38
+ * 另有一个可选字段 `lastMessage`:只给可能要提醒的那几条带上一句「这一轮跑出了什么」,
39
+ * 供原生完成提醒的横幅正文用;拿不到预览就不出这个字段。
37
40
  */
38
41
  function monitorItem(summary, state, fact) {
39
42
  const running = summary.running === true;
@@ -134,6 +137,24 @@ async function buildMonitorState(ctx, deviceKey) {
134
137
  states.get(summary.sessionId),
135
138
  lastTurnOf(ctx, summary.sessionId),
136
139
  ));
140
+ // 可能要提醒的那几条补一句「这一轮跑出了什么」:只在完成 / 失败 / 待回话的条目上取,
141
+ // 走 sessionPreviewFor 的缓存;常态(全在跑 / 全闲且已读)一条 history 都不读。
142
+ // 必须在排序与算指纹**之前**补好,否则正文变了却算不出新 rev,手机不会被唤醒。
143
+ const summariesById = new Map(live.map((s) => [s.sessionId, s]));
144
+ await Promise.all(items.map(async (item) => {
145
+ if (item.completed !== true && !item.lastTurnError && !item.pendingInteraction) return;
146
+ const summary = summariesById.get(item.sessionId);
147
+ if (!summary) return;
148
+ try {
149
+ const last = (await sessionPreviewFor(ctx, summary)).lastMessage;
150
+ // 只有 assistant 的话值得进通知:用户自己说的在横幅里没有信息量。
151
+ if (last && last.role === 'assistant' && typeof last.text === 'string' && last.text !== '') {
152
+ item.lastMessage = { role: last.role, text: last.text.slice(0, 200) };
153
+ }
154
+ } catch {
155
+ // 拿不到预览就整个字段不出,老 App 会忽略它。
156
+ }
157
+ }));
137
158
  // 按 id 排序后再算指纹:宿主是按 updatedAt 排的,聊一句话顺序就换,指纹会跟着每轮都变,
138
159
  // 条件刷新等于白做。监控视图不关心顺序。
139
160
  items.sort((a, b) => (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0));
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  const { sendJson } = require('../shared/http-util');
17
+ const { unchangedListBody, revisionOf, stableRevision } = require('../shared/list-revision.js');
17
18
  const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/transport.js');
18
19
 
19
20
  const PLUGIN_ID_MAX_CHARS = 200; // entryId 长度上限
@@ -96,7 +97,10 @@ function statusOf(error) {
96
97
 
97
98
  /**
98
99
  * GET /api/mobile/v1/plugins
99
- * 成功:{ ok:true, items:[{ entryId, moduleName, enabled, fiberPhase }] }
100
+ * 成功:{ ok:true, items:[{ entryId, moduleName, enabled, fiberPhase }], revision }
101
+ *
102
+ * 带 `?rev=` 时走条件响应:清单没变就只回一句 `unchanged`。插件清单只在装 / 卸 / 启停 /
103
+ * 重启之后变,而手机每进一次「插件清单」页都要读它一遍。
100
104
  */
101
105
  async function handlePlugins(ctx, req, res) {
102
106
  const result = await callInventory(ctx);
@@ -109,7 +113,8 @@ async function handlePlugins(ctx, req, res) {
109
113
  items.push(entry);
110
114
  if (items.length >= PLUGIN_MAX_ITEMS) break;
111
115
  }
112
- sendJson(res, 200, { ok: true, items });
116
+ const body = { ok: true, items, revision: stableRevision(items) };
117
+ sendJson(res, 200, unchangedListBody(body, revisionOf(req)) || body);
113
118
  }
114
119
 
115
120
  module.exports = {
@@ -40,16 +40,17 @@ function createPushNotifier(deps) {
40
40
  if (fingerprint === lastSent && now - lastSentAt < FRAME_TTL_MS) return;
41
41
  lastSent = fingerprint;
42
42
  lastSentAt = now;
43
- const body = fact.status === 'failed'
44
- ? `出错了${fact.message ? `:${String(fact.message).slice(0, 60)}` : ''}`
45
- : '回复已完成';
43
+ // 文案只用跨语言的事实,不写死某一门语言:这条推送由系统直接显示,App 没机会再本地化
44
+ // (没有通知服务扩展),而这个列表在电脑上、读它的手机可能被设成十门语言里的任一门。
45
+ // 所以标题是会话名(用户自己起的),正文只给「成没成」一个符号。
46
+ const body = fact.status === 'failed' ? '✗' : '✓';
46
47
  void send('push', {
47
48
  kind: 'push',
48
49
  pushFrame: 'send',
49
50
  deviceId: '',
50
51
  // deviceId 留空 = 这台实例上全部已登记设备都推。completion 的按设备记账
51
52
  // 在 App 自己手里(本机已读水位),中转只存「实例 → 令牌」一级。
52
- title: title || '任务提醒',
53
+ title: title || String(sessionId).slice(0, 8),
53
54
  body,
54
55
  sessionId,
55
56
  kind: fact.status === 'failed' ? 'failed' : 'completed',
@@ -25,7 +25,7 @@ const pairingApproval = require('../relay/pairing-policy.js');
25
25
  const applyLedger = require('../relay/apply-ledger.cjs');
26
26
  const { probeInstanceId, verifyRelayRegistration } = require('../relay/verify.js');
27
27
  const { canListen } = require('../relay/port-allocator.cjs');
28
- const { lanCandidates, publicCandidates, ipv6Candidates } = require('../relay/lan-network.cjs');
28
+ const { lanCandidates, publicCandidates, ipv6Candidates, withManualIpv6Candidate } = require('../relay/lan-network.cjs');
29
29
  const { sendJson, readJsonBody } = require('../shared/http-util');
30
30
 
31
31
  /** 改配置的请求体上限:几个短字符串,4KB 很宽。 */
@@ -65,6 +65,7 @@ function configView(state) {
65
65
  pairingPolicy: pairingApproval.normalize(state.pairingPolicy),
66
66
  preferredLanAddress: typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : '',
67
67
  manualLanUrl: typeof state.manualLanUrl === 'string' ? state.manualLanUrl : '',
68
+ manualIpv6Url: typeof state.manualIpv6Url === 'string' ? state.manualIpv6Url : '',
68
69
  /** 注册密钥设没设(值本身不回)。 */
69
70
  registerSecretSet: typeof state.registerSecret === 'string' && state.registerSecret !== '',
70
71
  };
@@ -129,6 +130,14 @@ function configPatch(body) {
129
130
  }
130
131
  patch.manualLanUrl = body.manualLanUrl.trim().replace(/\/+$/, '');
131
132
  }
133
+ // 用户手填的 IPv6 直连地址:与上一条同一条口径(空串合法 = 清掉),格式由
134
+ // `relay/lan-network.cjs` 的 `manualIpv6Candidate` 在产候选那一步判。
135
+ if (body.manualIpv6Url !== undefined) {
136
+ if (typeof body.manualIpv6Url !== 'string' || body.manualIpv6Url.length > 200) {
137
+ return { ok: false, message: 'IPv6 直连地址必须是 200 字以内的字符串(空串表示清掉)' };
138
+ }
139
+ patch.manualIpv6Url = body.manualIpv6Url.trim().replace(/\/+$/, '');
140
+ }
132
141
  if (Object.keys(patch).length === 0) return { ok: false, message: '没有任何可改的字段' };
133
142
  return { ok: true, patch };
134
143
  }
@@ -237,9 +246,13 @@ function createRelayControlHandlers(deps) {
237
246
  adjusted: typeof getLanPortAdjusted === 'function' ? getLanPortAdjusted() === true : false,
238
247
  lan: lanCandidates(port),
239
248
  public: publicCandidates(port),
240
- ipv6: ipv6Candidates(port),
249
+ // 手填的 IPv6 直连同「优先用这条」无关,只缀在网卡候选最后当兜底:同一个 Wi-Fi 下
250
+ // 网卡地址快一个数量级,排最前就会让手机永远选中手填那条(与 `manualLanUrl` 同一个取舍)。
251
+ ipv6: withManualIpv6Candidate(ipv6Candidates(port), state.manualIpv6Url),
241
252
  /** 用户手填的那条直连地址(空串 = 没填)。与上面三份并列给出,界面不用拼。 */
242
253
  manual: typeof state.manualLanUrl === 'string' ? state.manualLanUrl : '',
254
+ /** 用户手填的那条 IPv6 直连地址(空串 = 没填),与 `manual` 并列。 */
255
+ manualIpv6: typeof state.manualIpv6Url === 'string' ? state.manualIpv6Url : '',
243
256
  };
244
257
  }
245
258
 
@@ -317,8 +330,8 @@ function createRelayControlHandlers(deps) {
317
330
  * · 不传 `stage` —— 老 App 的行为一字不差:先回 200 再让旁路去重配。隧道照旧「先登记
318
331
  * 迁移再重建」,只是结果只能靠重连后读状态(`/diagnostics` 里有失败台账)。
319
332
  *
320
- * 只改了不进 sidecar 的项(`preferredLanAddress` / `name` / `manualLanUrl`)时当场改完、
321
- * 回新配置,手机不用重连。
333
+ * 只改了不进 sidecar 的项(`preferredLanAddress` / `name` / `manualLanUrl` /
334
+ * `manualIpv6Url`)时当场改完、回新配置,手机不用重连。
322
335
  */
323
336
  async function handleRelayConfigSave(_ctx, req, res) {
324
337
  if (!wired) return unavailable(res);
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 正文水位探针:`GET /api/mobile/v1/session/head?sessionId=…`。
5
+ *
6
+ * 回答一个问题 —— **这条会话的正文在宿主日志里到哪个水位了**。App 进对话前先问一句,跟本机
7
+ * 缓存水位一比就知道要不要重新拉历史:几兆的正文换几十字节的答案。
8
+ *
9
+ * 这个答案的分量全在「一致」那一侧:答「一致」等于告诉手机「你手上那份就是最新,一条正文
10
+ * 请求都别发」,答错了用户就停在一份缺尾巴的正文上。所以它必须来自**日志**,不能来自镜像
11
+ * (2026-11-01 改的就是这一条):镜像是插件按轮重建的一份副本,它落后于日志(同一会话实测
12
+ * 差过 449 个 seq),而落后时给出的答案与「真的没有新内容」一模一样 —— 手机把本机那份当
13
+ * 最新铺上屏,偏偏那一页上没有第二条路会把尾巴补回来(空闲会话的兜底轮询只同步运行态,
14
+ * 后台那条给未打开会话补正文的路刻意跳过正开着的那条)。
15
+ *
16
+ * 代价照实说:每进一次对话,电脑这侧多读一小段日志尾巴(与列表给 20 个会话补「最后一条
17
+ * 消息」预览是同一条走法、同一个小窗口),换来的是「没变就零正文请求」这条口径仍然成立 ——
18
+ * 它比多传一份正文便宜几个数量级。
19
+ *
20
+ * 子代理没有水位:子会话正文刻意不进镜像,日志也要走 durable 地址才读得到,所以直接回
21
+ * `{seq:0, known:false}`,让 App 照旧整份拉(见 A3-14 与 A3-03 的子代理那一节)。
22
+ */
23
+
24
+ const { sendJson } = require('../shared/http-util');
25
+ const { envelopeValue } = require('./envelope.js');
26
+ const { callSessions } = require('./rpc.js');
27
+ const { deriveMessages } = require('./history-derive.js');
28
+
29
+ /**
30
+ * 读日志尾巴时一次要多少事件。
31
+ *
32
+ * 只为认出「最后一条消息的 seq」:一轮里事件不密(turn/start、assistant/message、tool/call、
33
+ * tool/result、step/end、usage 各一条),几十条足够把最后一条正文包进来。取 60 而不像列表
34
+ * 预览那样取 10:预览要的是那一条文字,这里要的是水位,尾巴里一条消息都没折出来就只能答
35
+ * 「不知道」,那样反而让手机白拉一整份历史。
36
+ */
37
+ const HEAD_TAIL_EVENTS = 60;
38
+
39
+ /**
40
+ * 现读一小段日志尾巴,算出**消息** seq(折叠出来的行里最大的那个)。
41
+ *
42
+ * 不能拿事件 seq:`turn/end` / `usage` 也会推高事件水位,拿它当答案会让手机每进一次对话都
43
+ * 判「本机落后」而白拉一发。也不能把答案缓存一小会儿:要看的正是「刚跑完那一拍」。
44
+ *
45
+ * 读失败、尾巴里一条消息都没有时如实回 `known:false`(让 App 退回整份拉取)—— 宁可多拉一次,
46
+ * 也不拿一个猜出来的水位当「本机就是最新」的依据。
47
+ */
48
+ async function sessionMessageHead(ctx, sessionId) {
49
+ let resp;
50
+ try {
51
+ resp = await callSessions(ctx, 'history', { sessionId, maxMessages: HEAD_TAIL_EVENTS });
52
+ } catch {
53
+ return { seq: 0, known: false };
54
+ }
55
+ const value = envelopeValue(resp);
56
+ const events = value && Array.isArray(value.events) ? value.events : [];
57
+ if (events.length === 0) return { seq: 0, known: false };
58
+ let seq = 0;
59
+ for (const item of deriveMessages(events, sessionId)) {
60
+ const candidate = item && item.seq;
61
+ if (Number.isSafeInteger(candidate) && candidate > seq) seq = candidate;
62
+ }
63
+ return seq > 0 ? { seq, known: true } : { seq: 0, known: false };
64
+ }
65
+
66
+ async function handleSessionHead(ctx, req, res) {
67
+ const url = new URL(req.url, 'http://x');
68
+ const sessionId = url.searchParams.get('sessionId');
69
+ if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
70
+ const parentSessionId = url.searchParams.get('parentSessionId');
71
+ if (parentSessionId !== null && parentSessionId !== '') {
72
+ return sendJson(res, 200, { ok: true, seq: 0, known: false });
73
+ }
74
+ const head = await sessionMessageHead(ctx, sessionId);
75
+ sendJson(res, 200, { ok: true, seq: head.seq, known: head.known });
76
+ }
77
+
78
+ module.exports = { HEAD_TAIL_EVENTS, sessionMessageHead, handleSessionHead };
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  const { sendJson } = require('../shared/http-util');
12
+ const { unchangedListBody, revisionOf, stableRevision } = require('../shared/list-revision.js');
12
13
  const { unwrap } = require('./envelope.js');
13
14
  const { callAgentPresets } = require('./rpc.js');
14
15
 
@@ -39,14 +40,17 @@ async function handleAgentPresets(ctx, req, res) {
39
40
  ...(item.broken === true ? { broken: true } : {}),
40
41
  });
41
42
  }
42
- sendJson(res, 200, {
43
+ const body = {
43
44
  ok: true,
44
45
  presets,
45
46
  // 与条目内的 isDefault / broken 同口径:宿主只该给布尔,非布尔一律当 false,
46
47
  // 不让 'yes' / 1 这种值被 App 当成「可以自己写预设」。
47
48
  authorable: value != null && value.authorable === true,
48
49
  hasDocument: value != null && value.hasDocument === true,
49
- });
50
+ };
51
+ // 指纹只看内容:`authorable` / `hasDocument` 变了也算变,它们是同一页上的两格状态。
52
+ const withRevision = { ...body, revision: stableRevision(body) };
53
+ sendJson(res, 200, unchangedListBody(withRevision, revisionOf(req)) || withRevision);
50
54
  }
51
55
 
52
56
  module.exports = { handleAgentPresets };
@@ -28,6 +28,7 @@ const { withIdempotency, requestIdOf } = require('./idempotency.js');
28
28
  const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/transport.js');
29
29
  const { recordSessionRead } = require('./session-last-turn.js');
30
30
  const { imageSizeOf } = require('./local-images.js');
31
+ const attachmentStore = require('./attachment-store.js');
31
32
 
32
33
  /**
33
34
  * `/send` 的请求体上限(**明文**字节,图片 base64)。
@@ -281,6 +282,10 @@ async function handleSessionAttachment(ctx, req, res) {
281
282
  || typeof body.attachmentId !== 'string' || !body.attachmentId) {
282
283
  return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, attachmentId }' } });
283
284
  }
285
+ // 直读宿主附件库(2026-10-31):登记过的 id 直接从本地盘流给手机,省掉「等宿主把整张
286
+ // 读成 base64 再返回」那一段(宿主主线程忙时手机会一个字节都收不到)。前提与回退见
287
+ // `attachment-store.js` 的 `streamIfRemembered`:不满足就往下走原来的宿主接口。
288
+ if (body.raw === true && await attachmentStore.streamIfRemembered(req, res, body.sessionId, body.attachmentId)) return;
284
289
  const { value, error } = unwrap(await callSessions(ctx, 'attachment', { sessionId: body.sessionId, attachmentId: body.attachmentId }));
285
290
  if (error) return sendJson(res, 400, { ok: false, error });
286
291
  const data = value && typeof value.data === 'string' ? value.data : '';
@@ -6,7 +6,8 @@
6
6
  * 这个文件一度是一千九百多行的单体,HTTP 路由、宿主 RPC、缓存、事件派生全混在一起;现在
7
7
  * 按域拆开:envelope(信封拆解)、rpc(窄接口封装)、session-cache(按 ctx 隔离的状态表)、
8
8
  * attachments、completion、session-models、session-search、session-send、session-snapshot、
9
- * session-stats、session-mirror、session-goal、session-presets、session-preview、session-list-shortcut。
9
+ * session-stats、session-mirror、session-goal、session-presets、session-preview、session-head、
10
+ * session-list-shortcut。
10
11
  *
11
12
  * 剩下的就是列表:GET /sessions(10 秒缓存 + fresh 绕过 + 完成态确认 + `?rev=` 条件刷新)、
12
13
  * 单会话摘要、正文水位探针、统计、新建会话、排队消息动作。本文件继续转出各子模块的导出,
@@ -17,8 +18,6 @@ const { sendJson, readJsonBody } = require('../shared/http-util');
17
18
  const { isFreshRequest } = require('../shared/fresh-request');
18
19
  const { listRevision, unchangedListBody } = require('../shared/list-revision.js');
19
20
  const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
20
- // 正文水位信号:session/head 探针直接读它的快照,**不**为了「看起来有值」去现读历史。
21
- const { sessionSignal } = require('../shared/session-signal.js');
22
21
  const { listShortcut, rememberListBuild } = require('./session-list-shortcut.js');
23
22
  const { withIdempotency, requestIdOf } = require('./idempotency.js');
24
23
  const { listTitle, deriveMessages, lastMessageOf, promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive.js');
@@ -46,38 +45,12 @@ const { workspaceSessionFlags, pendingInteractionOf } = require('./session-flags
46
45
  const { activeScheduleSessions } = require('./session-schedule-mark.js');
47
46
  // 预览(lastMessage 补全)与它自己的重读节流:只读日志、不发写,单列一个子模块。
48
47
  const { HISTORY_FOR_ENRICH, sessionPreviewFor } = require('./session-preview.js');
48
+ // 正文水位探针:进对话前问「宿主日志到哪个水位了」,现读一小段日志尾巴(见该文件头)。
49
+ const { handleSessionHead } = require('./session-head.js');
49
50
 
50
51
  const SESSIONS_PREVIEW_LIMIT = 20; // 仅为最近/运行中的会话补最后消息预览
51
52
  const SESSION_LIST_CACHE_MS = 10000; // 会话列表增料缓存(降低手机轮询对桌面的冲击)
52
53
 
53
- /**
54
- * GET /api/mobile/v1/session/head?sessionId=…&parentSessionId=…&mode=…(token)
55
- *
56
- * 「这条会话的正文到哪个水位了」——App 进对话前先问一句,跟本地缓存水位一比就知道要不要
57
- * 重新拉全量历史。几十字节换几兆。
58
- *
59
- * `seq` 取 `sessionSignal.peek(sessionId)`:镜像推送每轮都会 bump,语义正是「快照里真正
60
- * 装上的消息的最大 seq」,与 App 的缓存水位同一个 seq 空间。
61
- *
62
- * `known` = `seq > 0`。**信号里没有时如实回 `known:false`**(插件刚重启、还没轮到推这个
63
- * 会话),让 App 退回全量拉取;绝不为了「看起来有值」现读一次历史——那样这个探针本身
64
- * 就变成了它要省掉的那次请求。
65
- *
66
- * 子代理(带 parentSessionId)直接回 `{seq:0, known:false}`:镜像里刻意不存子会话正文
67
- * (见 dev-map A3-14 的「子会话的正文刻意不进镜像」),没有水位可言。
68
- */
69
- async function handleSessionHead(_ctx, req, res) {
70
- const url = new URL(req.url, 'http://x');
71
- const sessionId = url.searchParams.get('sessionId');
72
- if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
73
- const parentSessionId = url.searchParams.get('parentSessionId');
74
- if (parentSessionId !== null && parentSessionId !== '') {
75
- return sendJson(res, 200, { ok: true, seq: 0, known: false });
76
- }
77
- const seq = sessionSignal.peek(sessionId);
78
- sendJson(res, 200, { ok: true, seq, known: seq > 0 });
79
- }
80
-
81
54
  /** GET /api/mobile/v1/sessions(token) */
82
55
  async function handleSessions(ctx, req, res) {
83
56
  const deviceKey = canonicalDeviceKey(req);
@@ -29,6 +29,7 @@ const {
29
29
  waitForDrain,
30
30
  ssePadding,
31
31
  shedWithoutSeq,
32
+ flushFrameBatch,
32
33
  } = require('../shared/sse-write.js');
33
34
  const { resolveEventAttachments } = require('./sessions.js');
34
35
  const { createTextImageBudget } = require('./local-images.js');
@@ -197,6 +198,7 @@ function writeSse(res, payload, frame, options = {}) {
197
198
  // 协议 v2:每帧一条独立密文(§8.3(5))。封装器由开流那一处给 —— 它才知道这条流的
198
199
  // 请求 ts / nonce 与帧序号(AAD 要绑这三样)。
199
200
  sealFrame: typeof options.sealFrame === 'function' ? options.sealFrame : null,
201
+ batch: res.__dshFrameBatch === true,
200
202
  });
201
203
  }
202
204
 
@@ -392,6 +394,9 @@ async function handleStream(ctx, req, res) {
392
394
  if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
393
395
  // 子会话地址:App 打开子会话的流时带上父会话与模式(见 app/src/sse.ts 的
394
396
  // openSessionStream 传参)。缺这两个字段就是普通会话,与历史那条同一套判定。
397
+ // 合帧:只有 App 在流地址上带 `frameBatch=1` 才启用(老 App 不带,行为一字不变)。
398
+ // 合帧把一串 `assistant/chunk` 并成一帧,省掉逐帧的加密、写套接字与客户端逐帧解析。
399
+ const frameBatch = url.searchParams.get('frameBatch') === '1';
395
400
  const parentSessionId = url.searchParams.get('parentSessionId');
396
401
  const mode = url.searchParams.get('mode');
397
402
  const streamAddress = typeof parentSessionId === 'string' && parentSessionId
@@ -401,6 +406,8 @@ async function handleStream(ctx, req, res) {
401
406
  // **要在 writeHead 之前算**:`X-Dsh-Proto: 2` 只能在头发出去之前声明,写过头再
402
407
  // `setHeader` 是无效的(Node 会静默忽略),客户端就永远不知道帧是信封。
403
408
  const sealFrame = createFrameSealer(req);
409
+ // 这条连接要不要合帧:`writeSse` 是文件级函数,挂在响应对象上比穿一串参数稳。
410
+ if (frameBatch) res.__dshFrameBatch = true;
404
411
  const head = {
405
412
  'content-type': 'text/event-stream; charset=utf-8',
406
413
  'cache-control': 'no-cache',
@@ -628,6 +635,9 @@ async function handleStream(ctx, req, res) {
628
635
  // 明确告诉客户端「流到此为止,请带 lastSeq 重连」:它会走已验证可用的 replay
629
636
  // 路径补上缺口,而不是把静默断流当成正常状态。
630
637
  if (!ac.signal.aborted && !clientGone) {
638
+ // 收尾帧之前先把攒着的增量放出去:resync-required 自己会触发 flush,这一行是为了
639
+ // 客户端已经走了那条分支不至于把最后几帧留在缓冲里。
640
+ flushFrameBatch(res);
631
641
  writeSse(res, { type: 'session/resync-required', sessionId, oldestSeq: lastDeliveredSeq + 1, final: true }, null, { sealFrame });
632
642
  }
633
643
  res.end();