dsh-mobile-plugin 0.1.100 → 0.2.1
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 +14 -5
- package/dsh-mobile-credentials/lib/credentials.js +405 -84
- package/lib/client.js +31 -8
- package/lib/http/routing.js +165 -9
- package/lib/impl.js +88 -9
- package/lib/index.js +3 -2
- package/lib/mirror/pusher.cjs +35 -4
- package/lib/mobile/approvals.js +62 -15
- package/lib/mobile/auth.js +46 -26
- package/lib/mobile/credential-upgrade.js +137 -0
- package/lib/mobile/entry-window.js +32 -0
- package/lib/mobile/events.js +14 -4
- package/lib/mobile/extensions.js +279 -0
- package/lib/mobile/history-derive.js +209 -11
- package/lib/mobile/host-bridge/event-synthesis.js +61 -10
- package/lib/mobile/pairing.js +150 -64
- package/lib/mobile/plugin-self.js +187 -0
- package/lib/mobile/push-notify-route.js +37 -0
- package/lib/mobile/push-notify.js +84 -0
- package/lib/mobile/questions.js +75 -10
- package/lib/mobile/relay-control.js +547 -0
- package/lib/mobile/session-cache.js +60 -2
- package/lib/mobile/session-file.js +133 -34
- package/lib/mobile/session-list-shortcut.js +38 -0
- package/lib/mobile/session-model-options.js +3 -1
- package/lib/mobile/session-models.js +15 -3
- package/lib/mobile/session-send.js +30 -27
- package/lib/mobile/session-snapshot.js +12 -12
- package/lib/mobile/sessions.js +20 -0
- package/lib/mobile/stats-collect.cjs +55 -12
- package/lib/mobile/stats-ledger.cjs +140 -21
- package/lib/mobile/stream-registry.js +73 -0
- package/lib/mobile/stream.js +382 -31
- package/lib/mobile/subagents.js +11 -2
- package/lib/mobile/uploads.js +91 -5
- package/lib/mobile/workspaces.js +39 -8
- package/lib/panel/index.html +11 -11
- package/lib/relay/core.js +28 -2
- package/lib/relay/lan-network.cjs +47 -0
- package/lib/relay/lan-proxy.js +283 -122
- package/lib/relay/protocol.js +4 -2
- package/lib/relay/tunnel.js +20 -14
- package/lib/routes/control-plane.js +139 -9
- package/lib/routes/mobile-api.js +92 -9
- package/lib/shared/e2ee-pair.js +91 -0
- package/lib/shared/e2ee-plain.js +79 -0
- package/lib/shared/e2ee-request.js +331 -0
- package/lib/shared/e2ee.js +173 -0
- package/lib/shared/http-util.js +27 -4
- package/lib/shared/list-signal.js +16 -0
- package/lib/shared/pair-throttle.js +150 -0
- package/lib/shared/sse-write.js +16 -5
- package/lib/shared/via-header.js +65 -0
- package/lib/shell/runtime.js +147 -42
- package/lib/shell/self-check-after-swap.js +93 -0
- package/lib/shell/updater.js +44 -3
- package/lib/sidecar-facade-worker.cjs +70 -6
- package/lib/update-install.cjs +13 -5
- package/package.json +3 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* 会话侧的按 ctx
|
|
4
|
+
* 会话侧的按 ctx 隔离状态:十三张表,一个入口。
|
|
5
5
|
*
|
|
6
6
|
* 这些表此前全是 sessions.js 的模块级 WeakMap。它们之所以必须按 ctx 隔离,是因为
|
|
7
7
|
* 它们的值属于**宿主进程状态**(某个会话正在跑、某个列表刚构建完、某条发送已经
|
|
@@ -39,9 +39,14 @@ const lastTurnFacts = new WeakMap();
|
|
|
39
39
|
// Prevent duplicate prompt calls when a mobile request is retried after a timeout.
|
|
40
40
|
const sendIdempotency = new WeakMap();
|
|
41
41
|
// 写操作(新建/改名/归档/分叉)的 requestId 重放表:同一次写在换通道重投后不再执行第二遍。
|
|
42
|
-
// 与 sendIdempotency 分开是因为键与生命周期不同(那边按 sessionId+clientMessageId,
|
|
42
|
+
// 与 sendIdempotency 分开是因为键与生命周期不同(那边按 sessionId+clientMessageId,
|
|
43
|
+
// 成功之后留到进程结束;这里是每次写各自的 requestId、只活在重投窗口里)。
|
|
43
44
|
const writeIdempotency = new WeakMap();
|
|
44
45
|
|
|
46
|
+
// 工作区列表的条件刷新短路:上次构建留下的 revision / 生成时刻 / 当时的信号代号。
|
|
47
|
+
// 与 sessionListResponseCache 同一类东西(列表刚构建完的那份是谁),只是按工作区算。
|
|
48
|
+
const workspaceShortcutStates = new WeakMap();
|
|
49
|
+
|
|
45
50
|
/** 设备维度:同一个 ctx 下多台手机各自一份,缺省用 'legacy'(未带 x-dsh-device 的调用)。 */
|
|
46
51
|
function canonicalDeviceKey(req, fallback = 'legacy') {
|
|
47
52
|
const value = req?.headers?.['x-dsh-device'];
|
|
@@ -224,6 +229,19 @@ function clearCompletionForAllDevices(ctx, sessionId, at = Date.now()) {
|
|
|
224
229
|
return cleared;
|
|
225
230
|
}
|
|
226
231
|
|
|
232
|
+
/**
|
|
233
|
+
* 工作区列表短路的记忆体:客户端 rev、上次生成时刻、当时的信号 revision、上次构建时刻。
|
|
234
|
+
* 与其它表同样按 ctx 隔离 —— 不同宿主的工作区快照互不相干。
|
|
235
|
+
*/
|
|
236
|
+
function workspaceShortcutStateOf(ctx) {
|
|
237
|
+
let state = workspaceShortcutStates.get(ctx);
|
|
238
|
+
if (!state) {
|
|
239
|
+
state = { lastRevision: '', lastGeneratedAt: 0, lastSignalRevision: '', lastBuiltAt: 0 };
|
|
240
|
+
workspaceShortcutStates.set(ctx, state);
|
|
241
|
+
}
|
|
242
|
+
return state;
|
|
243
|
+
}
|
|
244
|
+
|
|
227
245
|
function completionGenerationOf(ctx, deviceKey) {
|
|
228
246
|
let generations = completionGenerations.get(ctx);
|
|
229
247
|
if (!generations) {
|
|
@@ -248,6 +266,44 @@ function sendIdempotencyStateOf(ctx) {
|
|
|
248
266
|
return state;
|
|
249
267
|
}
|
|
250
268
|
|
|
269
|
+
/**
|
|
270
|
+
* 同一个投递 id 在一次会话里只真正跑一次:成功之后记录留到进程结束,失败立刻释放。
|
|
271
|
+
*
|
|
272
|
+
* 要防的是「请求超时、但那一发其实已经交给宿主了」这一档:App 会拿着同一个
|
|
273
|
+
* clientMessageId 再发一次(内核重试或用户手点重试),第二次必须拿到第一次的结果,
|
|
274
|
+
* 而不是让模型 / 子代理把同一句话再说一遍。
|
|
275
|
+
*
|
|
276
|
+
* **失败不占位**:`run` 回的信封不是成功(宿主明确拒绝,如 model-unavailable /
|
|
277
|
+
* agent-busy)就当场删掉记录 —— 一次瞬时错误不能被固化成「这个 id 永远只回这一句错」;
|
|
278
|
+
* 同理 `run` 抛错(传输层失败)也删。两条都必须是「真的再试一次」,否则活的路径只剩
|
|
279
|
+
* 用户重开 App。
|
|
280
|
+
*
|
|
281
|
+
* @param {object} ctx 插件上下文(状态按 ctx 隔离)
|
|
282
|
+
* @param {string} key 投递身份:主会话 `sessionId:clientMessageId`,子代理额外加前缀
|
|
283
|
+
* @param {() => Promise<object>} run 真正发一次,回宿主信封(`{ result: { ok, value | error } }`)
|
|
284
|
+
*/
|
|
285
|
+
function promptOnceFor(ctx, key, run) {
|
|
286
|
+
const state = sendIdempotencyStateOf(ctx);
|
|
287
|
+
const existing = state.get(key);
|
|
288
|
+
if (existing) return existing.promise;
|
|
289
|
+
|
|
290
|
+
const promise = Promise.resolve().then(run);
|
|
291
|
+
const record = { promise };
|
|
292
|
+
state.set(key, record);
|
|
293
|
+
const release = () => { if (state.get(key) === record) state.delete(key); };
|
|
294
|
+
promise.then(
|
|
295
|
+
(response) => {
|
|
296
|
+
// 留下的这条记录就是「这一发已经交给宿主了」的回执;宿主明确拒绝(信封 ok 不为真)
|
|
297
|
+
// 就当场释放 —— 一次业务错误不能被固化成「这个 id 永远只回这一句」。这里只看收没
|
|
298
|
+
// 收下,不拆错误信封(拆信封在 envelope.js,本文件是零依赖的叶子)。
|
|
299
|
+
const accepted = Boolean(response && response.result && response.result.ok === true);
|
|
300
|
+
if (!accepted) release();
|
|
301
|
+
},
|
|
302
|
+
release,
|
|
303
|
+
);
|
|
304
|
+
return promise;
|
|
305
|
+
}
|
|
306
|
+
|
|
251
307
|
/** 写操作幂等表(实现在 mobile/idempotency.js,这里只保证按 ctx 隔离)。 */
|
|
252
308
|
function writeIdempotencyStateOf(ctx) {
|
|
253
309
|
let state = writeIdempotency.get(ctx);
|
|
@@ -336,7 +392,9 @@ module.exports = {
|
|
|
336
392
|
markCompletionRead,
|
|
337
393
|
clearCompletionForAllDevices,
|
|
338
394
|
completionGenerationOf,
|
|
395
|
+
workspaceShortcutStateOf,
|
|
339
396
|
bumpCompletionGeneration,
|
|
340
397
|
sendIdempotencyStateOf,
|
|
398
|
+
promptOnceFor,
|
|
341
399
|
writeIdempotencyStateOf,
|
|
342
400
|
};
|
|
@@ -8,17 +8,24 @@
|
|
|
8
8
|
*
|
|
9
9
|
* 三条边界同时成立:
|
|
10
10
|
*
|
|
11
|
-
* 1.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* 1. **放行面 = 本会话工作区里的文件 ∪ 本会话 present 声明过的路径**(2026-10-25 用户拍板
|
|
12
|
+
* 的第二条,见 docs/decisions.md)。前者的判法是「解析后的落点在 cwd 之下」:相对路径
|
|
13
|
+
* 按 cwd 拼、绝对路径原样,然后整条路径必须在 cwd 里 —— 所以 `../x` 这种穿越落在
|
|
14
|
+
* cwd 之外,退回旧的那条白名单,不在声明里就 403。后者保持原样(`deliverables/presented`
|
|
15
|
+
* 里显式声明过的、**字符串全等**),它的用处是工作区之外的文件也能交付(绝对路径、
|
|
16
|
+
* cwd 变了的老会话)。
|
|
17
|
+
*
|
|
18
|
+
* **为什么敢放开工作区**:正文里模型写的 `[报告](docs/报告.md)` 就是这条路上的用户预期
|
|
19
|
+
* —— 手机上点一下能看,比「点了没反应」有意义得多;而这条会话的模型本来就能读同一个
|
|
20
|
+
* 工作区,手机与电脑是同一个使用者,放宽的是「同一份工作区能不能在手机上看」,不是
|
|
21
|
+
* 「能不能读这台电脑上的任意文件」(那仍然是 403,与不暴露 shell / 任意写入同一条边界)。
|
|
22
|
+
* 2. **相对路径按会话工作区解析**:cwd 取自会话列表摘要(与 /session 同一个来源)。没有
|
|
23
|
+
* cwd 的会话只能走 present 那条白名单,相对路径解析不出来就回 409。
|
|
17
24
|
* 3. **流式回写、不在插件进程里缓冲**:几十 MB 的录屏不该整包躺在内存里,与会话日志
|
|
18
25
|
* 导出同一个理由。
|
|
19
26
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
27
|
+
* 取不到时两种失败要分得开:两条口子都不认这个路径就回 403(`outside-workspace`),
|
|
28
|
+
* 认了、盘上没了回 404(`missing`)—— 前者是「这个路径不该被读」,后者是「文件被挪走了」。
|
|
22
29
|
*/
|
|
23
30
|
|
|
24
31
|
const fs = require('node:fs');
|
|
@@ -26,6 +33,7 @@ const { stat } = require('node:fs/promises');
|
|
|
26
33
|
const path = require('node:path');
|
|
27
34
|
const { pipeline } = require('node:stream/promises');
|
|
28
35
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
36
|
+
const { signedPath } = require('../shared/e2ee-request');
|
|
29
37
|
const { unwrap } = require('./envelope.js');
|
|
30
38
|
const { callSessions } = require('./rpc.js');
|
|
31
39
|
const { presentedPathsOf } = require('./history-derive.js');
|
|
@@ -97,6 +105,19 @@ async function declaredPathsOf(ctx, sessionId) {
|
|
|
97
105
|
return presentedPathsOf((value && value.events) || []);
|
|
98
106
|
}
|
|
99
107
|
|
|
108
|
+
/**
|
|
109
|
+
* 落点在不在工作区里。
|
|
110
|
+
*
|
|
111
|
+
* `path.relative` 出来是空的(就是 cwd 本身)或者以 `..` 开头(爬出去了)都算不在,
|
|
112
|
+
* 这样 `../x`、`a/../../x` 这些写法一并挡住 —— 判的是**解析后的真实落点**,不是输入字符串,
|
|
113
|
+
* 所以「看起来没写过 ..」最终指到外面的路径同样过不去。
|
|
114
|
+
*/
|
|
115
|
+
function isInsideWorkspace(cwd, target) {
|
|
116
|
+
if (!cwd) return false;
|
|
117
|
+
const rel = path.relative(path.resolve(cwd), target);
|
|
118
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
119
|
+
}
|
|
120
|
+
|
|
100
121
|
/** 会话的工作区目录(相对路径的解析基准)。取不到就回空串,由调用方决定怎么办。 */
|
|
101
122
|
async function cwdOf(ctx, sessionId) {
|
|
102
123
|
try {
|
|
@@ -124,34 +145,35 @@ async function handleSessionFile(ctx, req, res) {
|
|
|
124
145
|
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, path }' } });
|
|
125
146
|
}
|
|
126
147
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return sendJson(res, 502, {
|
|
132
|
-
ok: false,
|
|
133
|
-
error: { code: 'history-failed', message: String((error && error.message) || error) },
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
if (!declaredPaths.has(declared)) {
|
|
137
|
-
return sendJson(res, 403, {
|
|
138
|
-
ok: false,
|
|
139
|
-
error: { code: 'not-presented', message: '这个文件不在本会话的交付声明里' },
|
|
140
|
-
});
|
|
141
|
-
}
|
|
148
|
+
// 先把落点算出来:工作区那一侧判的是**解析后的真实路径**,不是输入字符串。
|
|
149
|
+
const cwd = await cwdOf(ctx, sessionId);
|
|
150
|
+
let target = path.isAbsolute(declared) ? path.resolve(declared) : '';
|
|
151
|
+
if (!target && cwd) target = path.resolve(cwd, declared);
|
|
142
152
|
|
|
143
|
-
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
// 工作区里的文件直接放行,一次历史都不读 —— 这是正文里点的链接走的那条路。
|
|
154
|
+
if (!target || !isInsideWorkspace(cwd, target)) {
|
|
155
|
+
// 工作区之外只剩交付声明这一条口子(绝对路径、或 cwd 变了的老会话)。
|
|
156
|
+
let declaredPaths;
|
|
157
|
+
try {
|
|
158
|
+
declaredPaths = await declaredPathsOf(ctx, sessionId);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return sendJson(res, 502, {
|
|
161
|
+
ok: false,
|
|
162
|
+
error: { code: 'history-failed', message: String((error && error.message) || error) },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (!declaredPaths.has(declared)) {
|
|
166
|
+
return sendJson(res, 403, {
|
|
167
|
+
ok: false,
|
|
168
|
+
error: { code: 'outside-workspace', message: '这个路径不在本会话的工作区里,手机上打不开' },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (!target) {
|
|
149
172
|
return sendJson(res, 409, {
|
|
150
173
|
ok: false,
|
|
151
174
|
error: { code: 'no-cwd', message: '这条会话没有工作区目录,没法解析相对路径' },
|
|
152
175
|
});
|
|
153
176
|
}
|
|
154
|
-
target = path.join(cwd, declared);
|
|
155
177
|
}
|
|
156
178
|
|
|
157
179
|
let info;
|
|
@@ -171,12 +193,33 @@ async function handleSessionFile(ctx, req, res) {
|
|
|
171
193
|
}
|
|
172
194
|
|
|
173
195
|
const name = path.basename(target);
|
|
174
|
-
|
|
196
|
+
const header = {
|
|
175
197
|
'content-type': mediaTypeOf(target),
|
|
176
|
-
'content-length': String(info.size),
|
|
177
198
|
'content-disposition': dispositionOf(name),
|
|
178
199
|
'cache-control': 'no-store',
|
|
179
|
-
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// 协议 v2:分块信封(§8.3(6))。
|
|
203
|
+
//
|
|
204
|
+
// 套一次性 AEAD 意味着宿主得先把整份读进内存 —— 单文件上限 512MB,那条路不能走。
|
|
205
|
+
// 所以改成固定分块流式写:宿主仍然边读边发,内存占用还是一块的大小;App 那边今天本来
|
|
206
|
+
// 就是整份读进 arrayBuffer(上限 200MB),按长度字段切块、逐块解即可,不需要流式解密栈。
|
|
207
|
+
const opened = req && req.__dshE2ee;
|
|
208
|
+
if (opened && opened.keys && opened.req) {
|
|
209
|
+
res.writeHead(200, {
|
|
210
|
+
...header,
|
|
211
|
+
'content-type': 'application/x-dsh-e2ee-chunks',
|
|
212
|
+
// 这份字节不是原文,`content-length` 得等写完才知道,改用 chunked。
|
|
213
|
+
'transfer-encoding': 'chunked',
|
|
214
|
+
'x-dsh-proto': '2',
|
|
215
|
+
// 真实类型单独给:App 解密之后要拿它决定怎么预览。
|
|
216
|
+
'x-dsh-content-type': header['content-type'],
|
|
217
|
+
});
|
|
218
|
+
await writeEncryptedFile(req, res, target, opened);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
res.writeHead(200, { ...header, 'content-length': String(info.size) });
|
|
180
223
|
if (info.size === 0) return res.end();
|
|
181
224
|
|
|
182
225
|
const stream = fs.createReadStream(target);
|
|
@@ -189,4 +232,60 @@ async function handleSessionFile(ctx, req, res) {
|
|
|
189
232
|
}
|
|
190
233
|
}
|
|
191
234
|
|
|
192
|
-
|
|
235
|
+
/**
|
|
236
|
+
* 按块加密写出去(§8.3(6))。
|
|
237
|
+
*
|
|
238
|
+
* 每块形状:`[4 字节大端长度][12 字节 nonce][密文 + 16 字节 tag]`,长度字段写的是
|
|
239
|
+
* **「密文 + tag」的长度**(不含 nonce 与长度字段本身)。块大小 64KB,最后一块可以更短。
|
|
240
|
+
*
|
|
241
|
+
* AAD 绑这条请求的路径、请求 nonce 与块序号 —— 块序号让中转不能把别的文件(或这一份的
|
|
242
|
+
* 别的块)的字节搬进来,也不能重排或截断而让 App 察觉不到。
|
|
243
|
+
*/
|
|
244
|
+
async function writeEncryptedFile(req, res, target, opened) {
|
|
245
|
+
const e2ee = require('../shared/e2ee');
|
|
246
|
+
const requestNonce = opened.req.nonce;
|
|
247
|
+
let seq = 0;
|
|
248
|
+
let pending = Buffer.alloc(0);
|
|
249
|
+
let closed = false;
|
|
250
|
+
res.on('close', () => { closed = true; });
|
|
251
|
+
// **整段都在 try 里**:调用点已经 `writeHead(200)` 过了,此后任何一步抛错都改不了状态码,
|
|
252
|
+
// 而 `sendJson` 再想写一个 500 只会撞上「头已发出」被静默吞掉 —— 响应就永远不结束,
|
|
253
|
+
// 手机上表现成「下载中」一直转、首字节等到超时。所以只能主动断开,让对端按截断处理。
|
|
254
|
+
try {
|
|
255
|
+
const path0 = signedPath(req);
|
|
256
|
+
const stream = fs.createReadStream(target, { highWaterMark: FILE_CHUNK_BYTES });
|
|
257
|
+
for await (const chunk of stream) {
|
|
258
|
+
if (closed) break;
|
|
259
|
+
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
|
|
260
|
+
while (pending.length >= FILE_CHUNK_BYTES) {
|
|
261
|
+
const block = pending.subarray(0, FILE_CHUNK_BYTES);
|
|
262
|
+
pending = pending.subarray(FILE_CHUNK_BYTES);
|
|
263
|
+
seq += 1;
|
|
264
|
+
writeBlock(res, e2ee, opened.keys.down, block, e2ee.fileAad(path0, requestNonce, seq));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// 尾块(可能不足一块);空文件也给一块,否则 App 拿不到「这是个空文件」的确认。
|
|
268
|
+
if (!closed) {
|
|
269
|
+
seq += 1;
|
|
270
|
+
writeBlock(res, e2ee, opened.keys.down, pending, e2ee.fileAad(path0, requestNonce, seq));
|
|
271
|
+
}
|
|
272
|
+
if (!closed) res.end();
|
|
273
|
+
} catch {
|
|
274
|
+
try { res.destroy(); } catch { /* 连接已关闭 */ }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 写一块:`[4 字节长度][nonce][密文+tag]`,长度写的是后两段之外的密文长度。 */
|
|
279
|
+
function writeBlock(res, e2ee, key, block, aad) {
|
|
280
|
+
const sealed = e2ee.seal(key, block, aad);
|
|
281
|
+
const buf = Buffer.from(sealed);
|
|
282
|
+
const cipherLen = buf.length - e2ee.NONCE_BYTES;
|
|
283
|
+
const head = Buffer.alloc(4);
|
|
284
|
+
head.writeUInt32BE(cipherLen, 0);
|
|
285
|
+
res.write(Buffer.concat([head, buf]));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 交付文件的分块大小。64KB:一块一次 AEAD,太小会让每块的 16+12 字节固定开销占比变高。 */
|
|
289
|
+
const FILE_CHUNK_BYTES = 64 * 1024;
|
|
290
|
+
|
|
291
|
+
module.exports = { handleSessionFile, SESSION_FILE_MAX_BYTES, FILE_CHUNK_BYTES, writeEncryptedFile };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `GET /sessions?rev=` 的条件刷新:上一次构建留下的「这份清单是谁」。
|
|
5
|
+
*
|
|
6
|
+
* 只服务短路判断,不拿它当数据源 —— `cache.value` 那条路仍然按原来的规则走。四样
|
|
7
|
+
* 一起对:客户端手上的 revision、这台设备的完成态代号、清单变更信号、上次构建时刻。
|
|
8
|
+
* 单拎出来是因为 sessions.js 只剩列表 handler 的骨架,这类「记东西 / 对东西」的纯
|
|
9
|
+
* 逻辑摆在 handler 中间会让状态和流程混在一起。
|
|
10
|
+
*
|
|
11
|
+
* 5 分钟上限只是兜底:`listSignal.watching()` 为假时根本不走短路,正常路径上信号
|
|
12
|
+
* 一涨就会被判失效,用不到这个上限。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { listSignal } = require('../shared/list-signal.js');
|
|
16
|
+
|
|
17
|
+
const LIST_SHORTCUT_MAX_AGE_MS = 5 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
/** 命中就回一份 `unchanged` 身体,否则 null(交回原路重建)。 */
|
|
20
|
+
function listShortcut(cache, rev, generation) {
|
|
21
|
+
if (!rev || typeof cache.lastRevision !== 'string' || cache.lastRevision !== rev) return null;
|
|
22
|
+
if (cache.lastGeneration !== generation) return null;
|
|
23
|
+
if (cache.lastSignalRevision !== listSignal.snapshot().revision) return null;
|
|
24
|
+
if (!(Date.now() - (cache.lastBuiltAt || 0) <= LIST_SHORTCUT_MAX_AGE_MS)) return null;
|
|
25
|
+
return { ok: true, revision: rev, generatedAt: cache.lastGeneratedAt };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 构建成功之后记下这一份:revision、生成时刻、当时的信号与完成态代号。 */
|
|
29
|
+
function rememberListBuild(cache, body, generation) {
|
|
30
|
+
if (!body || typeof body.revision !== 'string' || !body.revision) return;
|
|
31
|
+
cache.lastRevision = body.revision;
|
|
32
|
+
cache.lastGeneratedAt = body.generatedAt;
|
|
33
|
+
cache.lastGeneration = generation;
|
|
34
|
+
cache.lastSignalRevision = listSignal.snapshot().revision;
|
|
35
|
+
cache.lastBuiltAt = Date.now();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { listShortcut, rememberListBuild };
|
|
@@ -50,6 +50,8 @@ const OPTION_SOURCES = [
|
|
|
50
50
|
|
|
51
51
|
const MAX_ROWS = 12;
|
|
52
52
|
const MAX_CHOICES = 32;
|
|
53
|
+
/** 选项说明和模型描述一样,选择器里只画一行;正文没必要按 4KB 下发。 */
|
|
54
|
+
const MAX_CHOICE_DESCRIPTION = 200;
|
|
53
55
|
|
|
54
56
|
function sourceFor(provider) {
|
|
55
57
|
return OPTION_SOURCES.find((source) => source.provider === provider);
|
|
@@ -124,7 +126,7 @@ function safeModelOptions(value) {
|
|
|
124
126
|
.map((choice) => ({
|
|
125
127
|
id: choice.id.slice(0, 256),
|
|
126
128
|
name: choice.name.slice(0, 512),
|
|
127
|
-
...(typeof choice.description === 'string' ? { description: choice.description.slice(0,
|
|
129
|
+
...(typeof choice.description === 'string' ? { description: choice.description.slice(0, MAX_CHOICE_DESCRIPTION) } : {}),
|
|
128
130
|
})),
|
|
129
131
|
}));
|
|
130
132
|
}
|
|
@@ -22,6 +22,18 @@ function modelText(value, max) {
|
|
|
22
22
|
return typeof value === 'string' ? value.slice(0, max) : '';
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* 模型 / 推理档位描述的下发上限。
|
|
27
|
+
*
|
|
28
|
+
* 选择器里这两处都是 `numberOfLines={1}`,按屏宽也就显示二三十个字;宿主那边的描述
|
|
29
|
+
* 却有写到几千字的(能力说明、价格表都有)。整份目录每次启动、每次进对话都会传一遍,
|
|
30
|
+
* 留着 8KB 的上限等于给每个模型背一段永远读不到的正文。
|
|
31
|
+
*/
|
|
32
|
+
const MODEL_DESCRIPTION_MAX = 200;
|
|
33
|
+
const EFFORT_DESCRIPTION_MAX = 200;
|
|
34
|
+
/** 失败原因只在诊断里看,同样不需要整篇。 */
|
|
35
|
+
const MODEL_FAILURE_MESSAGE_MAX = 512;
|
|
36
|
+
|
|
25
37
|
function safeModelsValue(value) {
|
|
26
38
|
if (!value || typeof value !== 'object') return {};
|
|
27
39
|
const result = {};
|
|
@@ -63,7 +75,7 @@ function safeModelsValue(value) {
|
|
|
63
75
|
.map((model) => ({
|
|
64
76
|
id: modelText(model.id, 512),
|
|
65
77
|
name: modelText(model.name, 512),
|
|
66
|
-
...(typeof model.description === 'string' ? { description: model.description.slice(0,
|
|
78
|
+
...(typeof model.description === 'string' ? { description: model.description.slice(0, MODEL_DESCRIPTION_MAX) } : {}),
|
|
67
79
|
...(model.reasoning && typeof model.reasoning === 'object' && Array.isArray(model.reasoning.efforts)
|
|
68
80
|
? { reasoning: {
|
|
69
81
|
efforts: model.reasoning.efforts
|
|
@@ -72,7 +84,7 @@ function safeModelsValue(value) {
|
|
|
72
84
|
.map((effort) => ({
|
|
73
85
|
id: modelText(effort.id, 256),
|
|
74
86
|
name: modelText(effort.name, 512),
|
|
75
|
-
...(typeof effort.description === 'string' ? { description: effort.description.slice(0,
|
|
87
|
+
...(typeof effort.description === 'string' ? { description: effort.description.slice(0, EFFORT_DESCRIPTION_MAX) } : {}),
|
|
76
88
|
})),
|
|
77
89
|
...(typeof model.reasoning.defaultEffort === 'string' ? { defaultEffort: modelText(model.reasoning.defaultEffort, 256) } : {}),
|
|
78
90
|
} }
|
|
@@ -89,7 +101,7 @@ function safeModelsValue(value) {
|
|
|
89
101
|
if (Array.isArray(value.failures)) result.failures = value.failures.slice(0, 64).map((failure) => ({
|
|
90
102
|
...(typeof failure?.id === 'string' ? { id: modelText(failure.id, 256) } : {}),
|
|
91
103
|
...(typeof failure?.name === 'string' ? { name: modelText(failure.name, 512) } : {}),
|
|
92
|
-
...(typeof failure?.message === 'string' ? { message: modelText(failure.message,
|
|
104
|
+
...(typeof failure?.message === 'string' ? { message: modelText(failure.message, MODEL_FAILURE_MESSAGE_MAX) } : {}),
|
|
93
105
|
}));
|
|
94
106
|
if (Number.isSafeInteger(value.revision)) result.revision = value.revision;
|
|
95
107
|
return result;
|
|
@@ -3,21 +3,23 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 发消息、slash 命令、会话的读/停/改名/分叉。
|
|
5
5
|
*
|
|
6
|
-
* promptOnce
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* promptOnce 的去重是这里唯一非平凡的逻辑:手机端发消息时网络重试与用户连点会产生
|
|
7
|
+
* 同一个 clientMessageId 的多次请求,同一会话内它们共享同一个 prompt,而不是让模型跑
|
|
8
|
+
* 两遍。成功之后记录一直留着(不按时间窗过期):请求超时的那一发很可能已经交给宿主了,
|
|
9
|
+
* 用户隔几分钟再点一次重试,过期的窗口正好把同一条消息又发一遍。
|
|
10
|
+
* **只缓存成功**:失败必须能被重试,不能把一次瞬时错误固化成一条永远发不出去的消息。
|
|
10
11
|
*
|
|
11
12
|
* 文本块恰好一个且以 / 开头时派发为 slash 命令(commands.execute,不发给模型);
|
|
12
13
|
* 未知命令或网关未组合时回退普通消息,与桌面 miss 落入默认 sink 的行为一致。
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
17
|
+
const { inlineUploadedImageBlocks } = require('./uploads.js');
|
|
16
18
|
const { unwrap } = require('./envelope.js');
|
|
17
19
|
const { callSessions, mobilePromptRpcId } = require('./rpc.js');
|
|
18
20
|
const {
|
|
19
21
|
canonicalDeviceKey, invalidateSessionList, invalidateSessionModels,
|
|
20
|
-
markCompletionRead,
|
|
22
|
+
markCompletionRead, promptOnceFor,
|
|
21
23
|
} = require('./session-cache.js');
|
|
22
24
|
const { promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive.js');
|
|
23
25
|
const { withIdempotency, requestIdOf } = require('./idempotency.js');
|
|
@@ -25,7 +27,13 @@ const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/tran
|
|
|
25
27
|
const readReceipt = require('../read-receipt.cjs');
|
|
26
28
|
const { recordSessionRead } = require('./session-last-turn.js');
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
/**
|
|
31
|
+
* `/send` 的请求体上限(**明文**字节,图片 base64)。
|
|
32
|
+
*
|
|
33
|
+
* 原来是 8MB。协议 v2 的信封让线上变成 4/3,按线上长度直接卡等于砍掉四分之一额度,
|
|
34
|
+
* 所以按 §8.3(9) 抬到 10.7MB;线上那一侧另有 `MAX_SEALED_BODY` 兜着。
|
|
35
|
+
*/
|
|
36
|
+
const SEND_MAX_BODY = Math.ceil(8 * 1024 * 1024 * 4 / 3);
|
|
29
37
|
|
|
30
38
|
/** 仅当 content 恰好为一个文本块时返回其文本,否则 null(带图片/多块不回退命令路径)。 */
|
|
31
39
|
function singleTextOf(content) {
|
|
@@ -79,25 +87,9 @@ function promptOnce(ctx, clientMessageId, payload) {
|
|
|
79
87
|
// 因为实时帧里的图还被漏读,张数一对不上就彻底认领不了)。
|
|
80
88
|
const request = clientMessageId ? { ...payload, requestId: rpcId } : payload;
|
|
81
89
|
if (!clientMessageId) return callSessions(ctx, 'prompt', request, rpcId);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const existing = state.get(key);
|
|
86
|
-
if (existing && now - existing.createdAt < 60_000) return existing.promise;
|
|
87
|
-
if (existing) state.delete(key);
|
|
88
|
-
|
|
89
|
-
const promise = Promise.resolve(callSessions(ctx, 'prompt', request, rpcId));
|
|
90
|
-
// Keep the short-lived record so an in-flight/concurrent send with the same
|
|
91
|
-
// clientMessageId shares one prompt. Only cache a SUCCESS: a rejection must
|
|
92
|
-
// not poison later retries (a transient error should be retryable), so the
|
|
93
|
-
// record is released on failure.
|
|
94
|
-
const record = { createdAt: now, promise };
|
|
95
|
-
state.set(key, record);
|
|
96
|
-
promise.then(
|
|
97
|
-
() => { /* resolved stays cached until the 60s window expires */ },
|
|
98
|
-
() => { if (state.get(key) === record) state.delete(key); },
|
|
99
|
-
);
|
|
100
|
-
return promise;
|
|
90
|
+
// 同一个 clientMessageId 在本会话里只承诺一次:内核重试、以及隔了几分钟用户手点的
|
|
91
|
+
// 那次,都拿同一份结果(口径见 session-cache 的 `promptOnceFor`)。
|
|
92
|
+
return promptOnceFor(ctx, `${payload.sessionId}:${clientMessageId}`, () => callSessions(ctx, 'prompt', request, rpcId));
|
|
101
93
|
}
|
|
102
94
|
|
|
103
95
|
async function handleSend(ctx, req, res) {
|
|
@@ -130,13 +122,24 @@ async function handleSend(ctx, req, res) {
|
|
|
130
122
|
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'text 块内容为空' } });
|
|
131
123
|
}
|
|
132
124
|
} else if (block.type === 'image') {
|
|
133
|
-
|
|
134
|
-
|
|
125
|
+
// 两种形状都认:老客户端的 `data`(内联 base64),新客户端的 `uploadId`
|
|
126
|
+
// (先走分片上传,再由宿主自己读盘)。后者是为了绕开 `session/send` 那 8 秒预算 ——
|
|
127
|
+
// 4MB 照片内联上传线上接近 7MB,弱网必超时。
|
|
128
|
+
const inline = typeof block.data === 'string' && block.data.length > 0;
|
|
129
|
+
const uploaded = typeof block.uploadId === 'string' && block.uploadId.length > 0;
|
|
130
|
+
if (!IMAGE_TYPES.has(block.mediaType) || (!inline && !uploaded)) {
|
|
131
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'image 块需合法 mediaType 与 base64 data 或 uploadId' } });
|
|
135
132
|
}
|
|
136
133
|
} else {
|
|
137
134
|
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: `不支持的 content 块类型: ${block.type}` } });
|
|
138
135
|
}
|
|
139
136
|
}
|
|
137
|
+
// ── uploadId → 内联字节(实现在 uploads.js:字节本来就是那边落的盘)──
|
|
138
|
+
const uploadExpired = await inlineUploadedImageBlocks(content);
|
|
139
|
+
if (uploadExpired) {
|
|
140
|
+
return sendJson(res, 400, { ok: false, error: { code: 'upload-not-found', message: uploadExpired } });
|
|
141
|
+
}
|
|
142
|
+
|
|
140
143
|
// ── Slash 命令派发 ─────────────────────────────────────────────
|
|
141
144
|
// 已知命令:注册表执行(command/run + command/done 随事件流回传),响应标注 handledAs 供
|
|
142
145
|
// 客户端跳过「乐观 pending 气泡 + running 态」;未知命令/语法不符(execute 返回 undefined)
|
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
const { sendJson } = require('../shared/http-util');
|
|
16
16
|
const { unwrap } = require('./envelope.js');
|
|
17
17
|
const { callSessions } = require('./rpc.js');
|
|
18
|
-
const { deriveMessages,
|
|
18
|
+
const { deriveMessages, thinEntryMessages, toolResultPatchesFor } = require('./history-derive.js');
|
|
19
19
|
const { resolveEventAttachments } = require('./attachments.js');
|
|
20
20
|
const { createTextImageBudget } = require('./local-images.js');
|
|
21
|
+
const { entryWindowPayload } = require('./entry-window.js');
|
|
21
22
|
|
|
22
23
|
const MESSAGES_DEFAULT_LIMIT = 50; // /messages 默认条数
|
|
23
24
|
|
|
@@ -140,6 +141,9 @@ async function handleMessages(ctx, req, res) {
|
|
|
140
141
|
// 上游历史接口都不支持 afterSeq(只认 beforeSeq/maxMessages),故在插件内用较大的窗口
|
|
141
142
|
// 拉最近若干事件后再过滤 seq > afterSeq,覆盖常见轮询缺口;缺口超过窗口时要求客户端全量补齐。
|
|
142
143
|
if (afterSeq !== null) payload.maxMessages = Math.max(limit, 200);
|
|
144
|
+
// 入口那一发(不带游标)可以要求按内容裁:宿主按轮取整,过程块见 thinEntryMessages。
|
|
145
|
+
const wantThin = url.searchParams.get('thin') === '1' && beforeSeq === null && afterSeq === null;
|
|
146
|
+
if (wantThin) entryWindowPayload(payload, limit);
|
|
143
147
|
|
|
144
148
|
let value;
|
|
145
149
|
let error;
|
|
@@ -165,17 +169,13 @@ async function handleMessages(ctx, req, res) {
|
|
|
165
169
|
const budget = createTextImageBudget();
|
|
166
170
|
const resolvedEvents = await Promise.all(filtered.map((entry) => resolveEventAttachments(ctx, sessionId, entry.event || entry, { budget, referencesOnly: true }).then((event) => entry.event ? { ...entry, event } : event)));
|
|
167
171
|
const messages = deriveMessages(resolvedEvents);
|
|
168
|
-
// 工具结果不产生消息行,但会把**缓存里已有的**工具卡从 running 补成最终态。补丁必须
|
|
169
|
-
// 取自整个窗口(不只是 seq > afterSeq 的那一段):调用方游标可能已经越过结果 seq,
|
|
170
|
-
// 而工具卡还停在 running;把窗口内所有落定结果都回传,客户端按 callId 幂等打补丁。
|
|
171
|
-
const toolResults = [...buildToolResultMap(events).entries()].map(([callId, result]) => ({
|
|
172
|
-
callId,
|
|
173
|
-
state: result.isError ? (result.code === 'interrupted' ? 'stopped' : 'error') : 'ok',
|
|
174
|
-
...(result.output ? { output: result.output } : {}),
|
|
175
|
-
...(result.images && result.images.length ? { images: result.images } : {}),
|
|
176
|
-
...(result.diffs && result.diffs.length ? { diffs: result.diffs } : {}),
|
|
177
|
-
}));
|
|
178
172
|
const hasMore = !!(value && value.hasMore);
|
|
173
|
+
const items = wantThin ? thinEntryMessages(messages, { hasMore }) : messages;
|
|
174
|
+
// 工具结果不产生消息行,但会把**缓存里已有的**工具卡从 running 补成最终态。补丁取自
|
|
175
|
+
// 整个窗口(调用方游标可能已越过结果 seq);已随消息块下发的只回状态(见 toolResultPatchesFor)。
|
|
176
|
+
// 增量那一发(带 afterSeq)一律只回状态:调用方手上已有一份会话,整窗重发等于每 15 秒
|
|
177
|
+
// 把最近两百条的工具输出再传一次;入口裁剪那一发不带补丁(过程块已摘掉、无处可打)。
|
|
178
|
+
const toolResults = wantThin ? [] : toolResultPatchesFor(events, items, { bodies: afterSeq === null });
|
|
179
179
|
const seqs = events.map(eventSeqOf).filter((seq) => Number.isSafeInteger(seq) && seq >= 0);
|
|
180
180
|
const oldestSeq = seqs.length ? Math.min(...seqs) : null;
|
|
181
181
|
// A truncated history window is not a complete incremental answer. Keep the
|
|
@@ -186,7 +186,7 @@ async function handleMessages(ctx, req, res) {
|
|
|
186
186
|
: (seqs.length ? Math.max(...seqs) : 0);
|
|
187
187
|
sendJson(res, 200, {
|
|
188
188
|
ok: true,
|
|
189
|
-
items
|
|
189
|
+
items,
|
|
190
190
|
hasMore,
|
|
191
191
|
lastSeq,
|
|
192
192
|
...(oldestSeq !== null ? { oldestSeq } : {}),
|
package/lib/mobile/sessions.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* session-goal.js 目标条动作
|
|
22
22
|
* session-presets.js Agent 预设目录
|
|
23
23
|
* session-preview.js 列表行的 lastMessage 预览(按会话指纹复用 + 运行中会话的节流)
|
|
24
|
+
* session-list-shortcut.js `?rev=` 条件刷新记住的那份清单是谁
|
|
24
25
|
*
|
|
25
26
|
* 剩下的就是列表:GET /sessions(10 秒缓存 + fresh 绕过 + 完成态确认 + `?rev=` 条件刷新)、
|
|
26
27
|
* GET /session(单会话摘要)、GET /session/head(正文水位探针)、GET /session/stats、
|
|
@@ -31,8 +32,10 @@
|
|
|
31
32
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
32
33
|
const { isFreshRequest } = require('../shared/fresh-request');
|
|
33
34
|
const { listRevision, unchangedListBody } = require('../shared/list-revision.js');
|
|
35
|
+
const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
|
|
34
36
|
// 正文水位信号:session/head 探针直接读它的快照,**不**为了「看起来有值」去现读历史。
|
|
35
37
|
const { sessionSignal } = require('../shared/session-signal.js');
|
|
38
|
+
const { listShortcut, rememberListBuild } = require('./session-list-shortcut.js');
|
|
36
39
|
const { withIdempotency, requestIdOf } = require('./idempotency.js');
|
|
37
40
|
const { listTitle, deriveMessages, lastMessageOf, promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive.js');
|
|
38
41
|
const { unwrap } = require('./envelope.js');
|
|
@@ -131,6 +134,21 @@ async function handleSessions(ctx, req, res) {
|
|
|
131
134
|
return sendJson(res, status, unchanged || body);
|
|
132
135
|
};
|
|
133
136
|
|
|
137
|
+
// ── 构建前短路(`rev` 命中且清单确实没变)──
|
|
138
|
+
//
|
|
139
|
+
// 手机 15 秒问一次,而「构建这份列表」要几百毫秒(读宿主列表 + 折预览 + 算 revision)。
|
|
140
|
+
// 大多数轮次清单根本没变,答案就是几十字节的 `unchanged` —— 那就没必要先把它建出来。
|
|
141
|
+
// 判据三样一起看,缺一不可:
|
|
142
|
+
// ① 客户端手上的 revision 与上次构建出来的完全相同;
|
|
143
|
+
// ② 清单变更信号没有涨过(镜像推送调度器每 500ms 比一次内容哈希,见 list-signal);
|
|
144
|
+
// ③ 这台设备的完成态代号没变(completed / 失败态按设备算,信号里看不出来)。
|
|
145
|
+
// 只在**真有观众**(设备级 SSE 或轮询订阅着)时才信任信号:没有观众时推送调度器会
|
|
146
|
+
// 停止空转构建,信号不再前进,那时宁可老实重建。
|
|
147
|
+
if (!bypassCache && rev && sharedListSignal.watching()) {
|
|
148
|
+
const short = listShortcut(cache, rev, generation);
|
|
149
|
+
if (short) return respond(200, short);
|
|
150
|
+
}
|
|
151
|
+
|
|
134
152
|
if (!bypassCache && cache.value && now - cache.updatedAt < SESSION_LIST_CACHE_MS) {
|
|
135
153
|
return respond(200, cache.value);
|
|
136
154
|
}
|
|
@@ -160,6 +178,7 @@ async function handleSessions(ctx, req, res) {
|
|
|
160
178
|
cache.updatedAt = Date.now();
|
|
161
179
|
}
|
|
162
180
|
}
|
|
181
|
+
if (response.status === 200) rememberListBuild(cache, response.body, completionGenerationOf(ctx, deviceKey));
|
|
163
182
|
return respond(response.status, response.body);
|
|
164
183
|
} finally {
|
|
165
184
|
if (cache.promise === promise) cache.promise = null;
|
|
@@ -425,6 +444,7 @@ async function handleQueueUpdate(ctx, req, res) {
|
|
|
425
444
|
|
|
426
445
|
// 下面这一组从各子模块转出:它们此前全是 sessions.js 的实现,而 sessions.js 是
|
|
427
446
|
// mobile/ 下被引用最多的模块(其它 handler 与测试都从这里取)。换实现不改路径。
|
|
447
|
+
|
|
428
448
|
module.exports = {
|
|
429
449
|
// 本文件自己的
|
|
430
450
|
buildSessionsResponse,
|