dsh-mobile-plugin 0.1.75 → 0.1.77

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
@@ -4,6 +4,12 @@
4
4
 
5
5
  AI-HUB 手机端插件,为 DSH 提供移动 API、远程连接和局域网代理。插件也可作为无头 Cordis Host 独立运行,不依赖桌面外壳或 Electron。
6
6
 
7
+ ## 下载 AI-HUB 手机 App
8
+
9
+ - [Android APK](https://app.wnluo.com/public/apps/14)
10
+ - [App Store](https://apps.apple.com/us/app/ai-hub/id6821681438)(若暂时无法打开,可能是 App 仍在等待苹果审核通过。)
11
+ - [TestFlight 测试版](https://testflight.apple.com/join/4fn5PCMD)
12
+
7
13
  ## 功能
8
14
 
9
15
  - 通过 relay v2 隧道连接手机 App,并支持局域网代理。
@@ -30,6 +30,19 @@ const { inlineTextImages, createTextImageBudget } = require('./local-images.js')
30
30
  * (see local-images.js); `opts.budget` is a shared per-response budget so one
31
31
  * paged history response or mirror snapshot stays bounded.
32
32
  */
33
+ /**
34
+ * 取不到字节的图片块:原样留着,只加一个标记。
35
+ *
36
+ * 这里**绝不能抛,也绝不能返回 null**:resolveBlock 的结果会直接摊进整段历史响应或
37
+ * 一整帧 SSE,一个 null/异常会让整页消息一起挂掉(见 dev-map A3-33 的刻意设计)。
38
+ * 加标记是因为「附件取不到」和「这条消息本来就没有这张图」在 App 侧长得一样(都只有
39
+ * attachment 没有 data),不加标记就只能跟着静默消失——用户既不知道有过一张图,
40
+ * 也没有任何重试入口。
41
+ */
42
+ function dataUnavailableBlock(block) {
43
+ return { ...block, dataUnavailable: true };
44
+ }
45
+
33
46
  async function resolveEventAttachments(ctx, sessionId, event, opts = {}) {
34
47
  if (!event || !event.data || typeof event.data !== 'object') return event;
35
48
  const data = event.data;
@@ -50,9 +63,9 @@ async function resolveEventAttachments(ctx, sessionId, event, opts = {}) {
50
63
  try {
51
64
  result = unwrap(await callSessions(ctx, 'attachment', { sessionId, attachmentId }));
52
65
  } catch {
53
- return block;
66
+ return dataUnavailableBlock(block);
54
67
  }
55
- if (result.error || !result.value || typeof result.value.data !== 'string') return block;
68
+ if (result.error || !result.value || typeof result.value.data !== 'string') return dataUnavailableBlock(block);
56
69
  const attachment = result.value.attachment || block.attachment;
57
70
  return {
58
71
  type: 'image',
@@ -198,26 +198,6 @@ function retryFailure(failure) {
198
198
  return out.code !== undefined || out.message !== undefined ? out : null;
199
199
  }
200
200
 
201
- /** 上下文行的 label 提取(与桌面 contextProvenance 同口径;取不到为 null)。 */
202
- function contextLabelOf(source) {
203
- const kind = source.kind;
204
- if (kind === 'session-reference') {
205
- for (const ref of Array.isArray(source.references) ? source.references : []) {
206
- if (ref && typeof ref.label === 'string' && ref.label) return ref.label;
207
- }
208
- return null;
209
- }
210
- if (kind === 'agent-instructions') {
211
- for (const change of Array.isArray(source.changes) ? source.changes : []) {
212
- if (change && typeof change.path === 'string' && change.path) return change.path;
213
- }
214
- return null;
215
- }
216
- if (kind === 'plugin') return typeof source.plugin === 'string' && source.plugin ? source.plugin : null;
217
- if (kind === 'skill-invocation') return typeof source.name === 'string' && source.name ? source.name : null;
218
- return typeof kind === 'string' ? kind : null;
219
- }
220
-
221
201
  /** 列表/概览的 lastMessage 预览只取有 role 的消息项(结构化行不参与预览)。 */
222
202
  function lastMessageOf(messages) {
223
203
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -234,7 +214,6 @@ function lastMessageOf(messages) {
234
214
  * 渲染成 compaction 行(id=compactionId,state='done'),并用同 compactionId 的 compaction/summary 补 counts;
235
215
  * - retry:{ kind:'retry', id: retryId, seq, time, status:'scheduled'|'started', retry, maxRetries, delayMs?, failure? };
236
216
  * - turn-error / max-tokens:{ kind:'turn-error', id:'turn-<turn>', message, code? } / { kind:'max-tokens', id:'turn-<turn>' };
237
- * - context:{ kind:'context', id:'ctx-<msgId>', contextRole:'inject'|'recall', contextLabel, text };
238
217
  * - command:{ kind:'command', id:commandId, name, status:'running'|'done', resultKind?, text? }
239
218
  * (command/run 发出行,command/done 原地升级:name 保 run 的,resultKind/text 取 done 的)。
240
219
  * 结构化项与消息按事件 seq 升序混排返回;旧载荷字段缺失时跳过对应项,绝不抛错。
@@ -388,18 +367,8 @@ function deriveMessages(events) {
388
367
  continue;
389
368
  }
390
369
  if (src && typeof src === 'object' && src.kind !== 'user') {
391
- // 上下文注入/召回(session-reference→召回,其余 kind→注入);无文本或无可唯一 id 则跳过该项。
392
- const text = blocksToText(parseBlocks(msg.content));
393
- if (text === '' || msg.id == null) continue;
394
- out.push({
395
- id: `ctx-${String(msg.id)}`,
396
- kind: 'context',
397
- contextRole: src.kind === 'session-reference' ? 'recall' : 'inject',
398
- contextLabel: contextLabelOf(src),
399
- text,
400
- seq,
401
- time,
402
- });
370
+ // 上下文注入/召回(session-reference→召回,其余 kind→注入):桌面 `isVisibleChatNode`
371
+ // 只放行携带 tool-addition/tool-removal 的上下文节点,这类提示一律不显示 —— 不下发。
403
372
  continue;
404
373
  }
405
374
  // 普通用户消息(source 缺失或 kind==='user')沿用原折叠。
@@ -601,7 +570,6 @@ module.exports = {
601
570
  buildToolResultMap,
602
571
  parseBlocks,
603
572
  blocksToText,
604
- contextLabelOf,
605
573
  lastMessageOf,
606
574
  MOBILE_PROMPT_RPC_PREFIX,
607
575
  clientMessageIdFromSource,
@@ -30,15 +30,55 @@ async function* tolerate(frames, what, ctx) {
30
30
  }
31
31
  }
32
32
 
33
- /** 单会话事件流:follow 的 live 事件(跳过 snapshot,历史走 session.history)。 */
34
- async function* sessionEventFrames(ctx, sessionId, signal) {
35
- const frames = await openStream(ctx, 'session', 'follow', {
36
- request: { address: { kind: 'session', sessionId }, maxMessages: 1 },
37
- }, signal);
38
- for await (const frame of tolerate(frames, `session/${sessionId}`, ctx)) {
39
- if (!frame || frame.type !== 'event' || !frame.event) continue;
33
+ /**
34
+ * follow 流的一帧 → 0..n 个 mux 帧(事件原样转发;实时增量通知合成 assistant/chunk)。
35
+ *
36
+ * 0.2.0 的宿主把逐块流式增量从「会话事件」里拿了出去:正文/推理的每个 delta 只以 follow 流的
37
+ * `assistant-stream` 通知下发,而且**要显式传 `assistantStream: true` 才发**,事件流里只剩
38
+ * 「一步落定」的 `assistant/message`。不订阅通知的话,正文与运行中明细都只能在一步结束时整块
39
+ * 出现(真机故障 2026-10-21:回复整段蹦出来、运行中那一行没有实时明细)。
40
+ *
41
+ * 合成事件用的是移动协议早已认识的 `assistant/chunk` 形状,但**不带 seq** —— 它没有持久化
42
+ * 事件的身份,不该参与客户端的 seq 序门、去重与续传水位(App 侧对无 seq 帧一律放行),
43
+ * turn/step 由最近一次 start 通知补上。设备级推送只看 turn/start、turn/end、session/title
44
+ * (`lib/mobile/events.js` 的 LIST_EVENT_INNER),合成增量到不了那条通道,不会形成推送风暴。
45
+ *
46
+ * `state` 是这条流的记账({ turn, step, streaming }),由调用方持有 —— 一帧的处理要看
47
+ * 「之前收到过什么」。snapshot 帧不需要处理:历史走 session.history。
48
+ */
49
+ function* followFramePayloads(frame, sessionId, state) {
50
+ if (!frame) return;
51
+ if (frame.type === 'event') {
52
+ if (!frame.event) return;
53
+ // 增量只交付一次:宿主若同时把 chunk 也当成事件发,以通知为准(通知先到)。
54
+ if (state.streaming && frame.event.type === 'assistant/chunk') return;
40
55
  yield { payload: { type: 'session/event', sessionId, event: frame.event } };
56
+ return;
57
+ }
58
+ const notice = frame.type === 'assistant-stream' ? frame.frame : null;
59
+ if (!notice) return;
60
+ if (notice.type === 'start') {
61
+ if (Number.isSafeInteger(notice.turn)) state.turn = notice.turn;
62
+ if (Number.isSafeInteger(notice.step)) state.step = notice.step;
63
+ return;
41
64
  }
65
+ if (notice.type !== 'chunk' || !notice.chunk || typeof notice.chunk !== 'object') return;
66
+ // 块序号在通知帧上,chunk 自己也带一份;自己没带就补上(App 侧按 chunk.index 拼草稿)。
67
+ const chunk = typeof notice.chunk.index === 'number' || typeof notice.index !== 'number'
68
+ ? notice.chunk
69
+ : { ...notice.chunk, index: notice.index };
70
+ state.streaming = true;
71
+ yield {
72
+ payload: {
73
+ type: 'session/event',
74
+ sessionId,
75
+ event: {
76
+ type: 'assistant/chunk',
77
+ time: Number.isFinite(notice.time) ? notice.time : Date.now(),
78
+ data: { turn: state.turn, step: state.step, chunk },
79
+ },
80
+ },
81
+ };
42
82
  }
43
83
 
44
84
  /** 后台任务整帧。宿主 job/list 是流,首帧即全量 rows,之后按变化推。 */
@@ -174,20 +214,23 @@ function muxFrames(ctx, request, signal) {
174
214
  }
175
215
  const control = openStream(ctx, 'session', 'control', {}, signal).then((frames) => tolerate(frames, 'session/control', ctx));
176
216
  const jobs = openStream(ctx, 'job', 'list', { request: { sessionId } }, signal).then((frames) => tolerate(frames, `job/${sessionId}`, ctx));
217
+ // assistantStream: 逐块增量只走这条流的 assistant-stream 通知(见 followFramePayloads)。
177
218
  const follow = openStream(ctx, 'session', 'follow', {
178
- request: { address: { kind: 'session', sessionId }, maxMessages: 1 },
219
+ request: { address: { kind: 'session', sessionId }, maxMessages: 1, assistantStream: true },
179
220
  }, signal).then((frames) => tolerate(frames, `session/${sessionId}`, ctx));
180
221
  const projections = projectionState();
181
- const sources = await Promise.all([control, jobs, follow].map((promise) => promise.then(async function* wrap(iterable) {
222
+ const followState = { turn: null, step: null, streaming: false };
223
+ const sources = await Promise.all([['control', control], ['jobs', jobs], ['follow', follow]].map(([source, promise]) => promise.then(async function* wrap(iterable) {
182
224
  for await (const frame of iterable) {
183
- if (frame && frame.type === 'rows') {
225
+ if (source === 'follow') {
226
+ // follow 一帧可能产出 0..n 个 mux 帧(事件原样 + 增量通知合成 chunk 事件)。
227
+ yield* followFramePayloads(frame, sessionId, followState);
228
+ } else if (frame && frame.type === 'rows') {
184
229
  yield { payload: { type: 'session/jobs', sessionId, jobs: Array.isArray(frame.jobs) ? frame.jobs : [] } };
185
230
  } else if (frame && frame.type === 'baseline') {
186
231
  yield* applyBaseline(frame, projections, sessionId, ctx);
187
232
  } else if (frame && frame.type === 'projection') {
188
233
  yield* applyUpdate(frame, projections, sessionId, ctx);
189
- } else if (frame && frame.type === 'event' && frame.event) {
190
- yield { payload: { type: 'session/event', sessionId, event: frame.event } };
191
234
  }
192
235
  }
193
236
  }).catch((error) => {
@@ -275,4 +275,33 @@ async function handleFork(ctx, req, res) {
275
275
  sendJson(res, outcome.status, outcome.body);
276
276
  }
277
277
 
278
- module.exports = { singleTextOf, executeCommand, promptOnce, handleSend, handleCommands, handleSessionRead, handleStop, handleRename, handleFork };
278
+ /**
279
+ * POST /api/mobile/v1/session/attachment(token){sessionId, attachmentId}
280
+ * → {ok, mediaType, data, name?}
281
+ *
282
+ * 单张图片附件重取。App 在「认得出这张图、但上一次没取回字节」时给一个重试按钮,
283
+ * 按 attachmentId 只补这一张。不复用 /session/messages 重拉历史:那条按游标重装整个
284
+ * 消息列表,会把用户已经向上翻出去的更早几页丢掉。
285
+ */
286
+ async function handleSessionAttachment(ctx, req, res) {
287
+ const body = await readJsonBody(req);
288
+ if (!body || typeof body.sessionId !== 'string' || !body.sessionId
289
+ || typeof body.attachmentId !== 'string' || !body.attachmentId) {
290
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, attachmentId }' } });
291
+ }
292
+ const { value, error } = unwrap(await callSessions(ctx, 'attachment', { sessionId: body.sessionId, attachmentId: body.attachmentId }));
293
+ if (error) return sendJson(res, 400, { ok: false, error });
294
+ const data = value && typeof value.data === 'string' ? value.data : '';
295
+ // 附件已被会话清理掉时宿主回的是空字节:这不是「稍后再试就好」,如实回 404,
296
+ // 让 App 那边把重试按钮的失败说清楚,而不是转圈到超时。
297
+ if (!data) return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '附件已不可读' } });
298
+ const attachment = (value && value.attachment) || {};
299
+ sendJson(res, 200, {
300
+ ok: true,
301
+ mediaType: typeof attachment.mediaType === 'string' && attachment.mediaType ? attachment.mediaType : 'image/png',
302
+ data,
303
+ ...(typeof attachment.name === 'string' ? { name: attachment.name } : {}),
304
+ });
305
+ }
306
+
307
+ module.exports = { singleTextOf, executeCommand, promptOnce, handleSend, handleCommands, handleSessionRead, handleSessionAttachment, handleStop, handleRename, handleFork };
@@ -15,7 +15,7 @@
15
15
  * completion.js 完成提醒状态机
16
16
  * session-models.js 模型目录与切模型
17
17
  * session-search.js 正文检索
18
- * session-send.js 发消息 / slash 命令 / 读 / 停 / 改名 / 分叉
18
+ * session-send.js 发消息 / slash 命令 / 读 / 附件重取 / 停 / 改名 / 分叉
19
19
  * session-snapshot.js 消息与统计快照(镜像与分页共用)
20
20
  * session-mirror.js 镜像整帧构建
21
21
  * session-goal.js 目标条动作
@@ -42,12 +42,10 @@ const {
42
42
  const { resolveEventAttachments } = require('./attachments.js');
43
43
  const { updateCompletionStates, confirmCompletionFromHistory } = require('./completion.js');
44
44
  const { buildMessagesSnapshot, buildStatsSnapshot, statsProjections, handleMessages } = require('./session-snapshot.js');
45
- const { safeModelsValue, buildModelsSnapshot, handleModels, handleSelectModel } = require('./session-models.js');
45
+ const { buildModelsSnapshot, handleModels, handleSelectModel } = require('./session-models.js');
46
46
  const { buildSingleSessionMirror, buildMirrorWarmSnapshots } = require('./session-mirror.js');
47
- const {
48
- promptOnce, handleSend, handleCommands, handleSessionRead,
49
- handleStop, handleRename, handleFork,
50
- } = require('./session-send.js');
47
+ const { promptOnce, handleSend, handleCommands, handleSessionRead, handleSessionAttachment, handleStop,
48
+ handleRename, handleFork } = require('./session-send.js');
51
49
  const { handleSessionSearch } = require('./session-search.js');
52
50
  const { handleAgentPresets } = require('./session-presets.js');
53
51
  const { handleGoalAction } = require('./session-goal.js');
@@ -406,12 +404,6 @@ async function handleNew(ctx, req, res) {
406
404
  if (!sessionId) {
407
405
  return { status: 500, body: { ok: false, error: { code: 'internal', message: '新建会话未返回 sessionId' } } };
408
406
  }
409
- // 创建响应携带宿主当前选择;APP 可立即显示,完整目录仍可后台刷新。
410
- let models;
411
- try {
412
- const result = unwrap(await callSessions(ctx, 'models', { sessionId }));
413
- if (!result.error) models = safeModelsValue(result.value);
414
- } catch { /* 模型目录不可用时仍允许创建会话 */ }
415
407
  // 新建的会话要立刻出现在列表里,否则手机端最长 10 秒看不到它。
416
408
  invalidateSessionList(ctx);
417
409
  return {
@@ -421,7 +413,6 @@ async function handleNew(ctx, req, res) {
421
413
  sessionId,
422
414
  cwd: payload.cwd || null,
423
415
  ...(value && typeof value.agentPreset === 'string' ? { agentPreset: value.agentPreset } : {}),
424
- ...(models?.current ? { models } : {}),
425
416
  },
426
417
  };
427
418
  });
@@ -487,6 +478,7 @@ module.exports = {
487
478
  handleSend,
488
479
  handleCommands,
489
480
  handleSessionRead,
481
+ handleSessionAttachment,
490
482
  handleStop,
491
483
  handleRename,
492
484
  handleFork,
@@ -0,0 +1,220 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 移动端文件分片上传:把手机分享进来的文件落到宿主机固定收件目录。
5
+ *
6
+ * 四步:begin(登记文件名,拿 uploadId 与片大小)→ chunk(按 index 顺序追加)→
7
+ * finish(落盘成 `<收件根>/<日期>/<文件名>`,重名自动加后缀)→ abort(放弃)。
8
+ *
9
+ * 为什么分片,而不是一次 POST 传完:
10
+ * - 一次传完会把「能传多大的文件」变成一条运维配置 —— 中转的
11
+ * `MAX_BODY_BYTES`(dsh-mobile-server/lib/config.js)默认 50MB,插件自己的
12
+ * readJsonBody 默认只有 256KB。分片后单请求只有 1MB,与这些上限无关。
13
+ * - 手机侧网络不稳,分片能只重传坏掉的那一片,而不是整个文件。
14
+ *
15
+ * 为什么片内容走 base64 而不是原始字节:隧道协议本身支持原始字节
16
+ * (lib/relay/protocol.js 的 httpBody 帧),但 App 侧没有任何「发二进制 body」的
17
+ * 先例,RN 的 fetch 对 ArrayBuffer body 的支持也随版本变;图片通道本来就以 base64
18
+ * 走 JSON,这里沿用同一条确定能用的路。代价是 33% 膨胀(单片 1MB → 约 1.37MB),
19
+ * 仍远低于任何一层上限。
20
+ *
21
+ * 顺序与幂等:chunk 只接受「下一片」,重复送达同一片按重传处理(回当前 received,
22
+ * 不再追加),跳号直接 409 —— 客户端因此不需要在重试前先问进度。
23
+ *
24
+ * 落盘根目录固定 `~/.dsh/mobile-inbox`(收件专目录,与工作区解耦,工作区被删也不
25
+ * 影响已收到的文件)。`DSH_MOBILE_INBOX` 可覆盖,只给测试用。
26
+ */
27
+
28
+ const fsp = require('node:fs/promises');
29
+ const os = require('node:os');
30
+ const path = require('node:path');
31
+ const crypto = require('node:crypto');
32
+
33
+ const { sendJson, readJsonBody } = require('../shared/http-util');
34
+
35
+ // 片大小 256KB(base64 后约 341KB):客户端一次请求的硬预算是 8s(`app/src/api/core.ts`
36
+ // 的 REQUEST_TIMEOUT_MS),1MB 片在慢速上行网络上会撞超时;片越小越稳,代价只是多几次往返。
37
+ const CHUNK_BYTES = 256 * 1024;
38
+ /** 单片请求体上限:1MB base64 后约 1.37MB,留出信封与转义余量。 */
39
+ const CHUNK_MAX_BODY = 4 * 1024 * 1024;
40
+ /** 文件名上限(按字符算,避开各文件系统的字节上限差异)。 */
41
+ const NAME_MAX_CHARS = 120;
42
+ /** 暂存残片的最长寿命:手机上传中断后没人来 abort 的清理依据。 */
43
+ const STAGING_TTL_MS = 24 * 60 * 60 * 1000;
44
+ /** 分片必须是标准 base64:Node 的 Buffer.from 会静默丢弃非法字符,光靠解码判不出坏片。 */
45
+ const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
46
+
47
+ /** 进行中的上传:uploadId → { name, stagingPath, received, bytes }。 */
48
+ const uploads = new Map();
49
+
50
+ /** 收件根目录。每次取用都重算,好让测试用环境变量分派。 */
51
+ function inboxRoot() {
52
+ const override = process.env.DSH_MOBILE_INBOX;
53
+ return override ? path.resolve(override) : path.join(os.homedir(), '.dsh', 'mobile-inbox');
54
+ }
55
+
56
+ /**
57
+ * 清成一个能安全当作文件名的名字:去掉路径成分、文件系统保留字符与控制字符,
58
+ * 压掉连续空白,掐掉前导点(避免生成隐藏文件),并限长(保住扩展名)。
59
+ */
60
+ function sanitizeName(raw) {
61
+ const base = path.basename(String(raw == null ? '' : raw).replace(/\\/g, '/'));
62
+ const cleaned = base
63
+ .replace(/[/\\:*?"<>|\u0000-\u001f\u007f]/g, '_')
64
+ .replace(/\s+/g, ' ')
65
+ .trim()
66
+ .replace(/^\.+/, '')
67
+ .trim();
68
+ if (!cleaned) return '未命名文件';
69
+ if (cleaned.length <= NAME_MAX_CHARS) return cleaned;
70
+ const ext = path.extname(cleaned).slice(0, 16);
71
+ return cleaned.slice(0, NAME_MAX_CHARS - ext.length) + ext;
72
+ }
73
+
74
+ /** 本地日期 yyyy-mm-dd(收件目录按天分堆,`os` 本地时区更贴近「今天收到的」)。 */
75
+ function today() {
76
+ const now = new Date();
77
+ const pad = (n) => String(n).padStart(2, '0');
78
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
79
+ }
80
+
81
+ /** 在 dir 下给 name 找一个不冲突的落点(`名字-1.ext`、`名字-2.ext`…)。 */
82
+ async function uniquePath(dir, name) {
83
+ const ext = path.extname(name);
84
+ const stem = name.slice(0, name.length - ext.length) || '未命名文件';
85
+ for (let i = 0; i < 200; i += 1) {
86
+ const candidate = path.join(dir, i === 0 ? name : `${stem}-${i}${ext}`);
87
+ try {
88
+ await fsp.access(candidate);
89
+ } catch {
90
+ return candidate;
91
+ }
92
+ }
93
+ return path.join(dir, `${stem}-${Date.now()}${ext}`);
94
+ }
95
+
96
+ /** 清掉过期残片;尽力而为,失败不影响本次上传。 */
97
+ async function sweepStaging(stagingDir) {
98
+ let entries;
99
+ try {
100
+ entries = await fsp.readdir(stagingDir, { withFileTypes: true });
101
+ } catch {
102
+ return;
103
+ }
104
+ const cutoff = Date.now() - STAGING_TTL_MS;
105
+ await Promise.all(entries.map(async (entry) => {
106
+ if (!entry.isFile() || !entry.name.endsWith('.part')) return;
107
+ const target = path.join(stagingDir, entry.name);
108
+ try {
109
+ const stat = await fsp.stat(target);
110
+ if (stat.mtimeMs < cutoff) await fsp.unlink(target);
111
+ } catch { /* 已被别人清掉 */ }
112
+ }));
113
+ }
114
+
115
+ function fail(res, status, code, message) {
116
+ return sendJson(res, status, { ok: false, error: { code, message } });
117
+ }
118
+
119
+ /** POST /api/mobile/v1/upload/begin → { ok, uploadId, chunkSize }。 */
120
+ async function handleUploadBegin(_ctx, req, res) {
121
+ const body = await readJsonBody(req);
122
+ if (!body || typeof body.name !== 'string' || !body.name.trim()) {
123
+ return fail(res, 400, 'bad-request', '需要 { name }');
124
+ }
125
+ const stagingDir = path.join(inboxRoot(), '.staging');
126
+ try {
127
+ await fsp.mkdir(stagingDir, { recursive: true });
128
+ } catch (error) {
129
+ return fail(res, 500, 'inbox-unavailable', `收件目录不可用:${error.message}`);
130
+ }
131
+ void sweepStaging(stagingDir);
132
+
133
+ const uploadId = crypto.randomUUID();
134
+ const stagingPath = path.join(stagingDir, `${uploadId}.part`);
135
+ try {
136
+ await fsp.writeFile(stagingPath, Buffer.alloc(0));
137
+ } catch (error) {
138
+ return fail(res, 500, 'inbox-unavailable', `无法建立暂存文件:${error.message}`);
139
+ }
140
+ uploads.set(uploadId, {
141
+ name: sanitizeName(body.name),
142
+ stagingPath,
143
+ received: 0,
144
+ bytes: 0,
145
+ });
146
+ sendJson(res, 200, { ok: true, uploadId, chunkSize: CHUNK_BYTES });
147
+ }
148
+
149
+ /** POST /api/mobile/v1/upload/chunk { uploadId, index, data } → { ok, received }。 */
150
+ async function handleUploadChunk(_ctx, req, res) {
151
+ const body = await readJsonBody(req, CHUNK_MAX_BODY);
152
+ if (!body) return fail(res, 413, 'too-large', '分片过大或不是合法 JSON');
153
+
154
+ const job = uploads.get(String(body.uploadId == null ? '' : body.uploadId));
155
+ if (!job) return fail(res, 404, 'unknown-upload', '上传会话不存在或已结束');
156
+
157
+ const index = Number(body.index);
158
+ if (!Number.isInteger(index) || index < 0) return fail(res, 400, 'bad-request', 'index 必须是非负整数');
159
+ if (typeof body.data !== 'string') return fail(res, 400, 'bad-request', 'data 必须是 base64 字符串');
160
+ if (index < job.received) return sendJson(res, 200, { ok: true, received: job.received });
161
+ if (index > job.received) {
162
+ return fail(res, 409, 'out-of-order', `期望分片 ${job.received},收到 ${index}`);
163
+ }
164
+
165
+ const chunk = Buffer.from(body.data, 'base64');
166
+ if (!BASE64_RE.test(body.data) || body.data.length % 4 !== 0) {
167
+ return fail(res, 400, 'bad-request', 'data 不是合法 base64');
168
+ }
169
+ try {
170
+ await fsp.appendFile(job.stagingPath, chunk);
171
+ } catch (error) {
172
+ uploads.delete(String(body.uploadId));
173
+ return fail(res, 500, 'inbox-unavailable', `写入暂存文件失败:${error.message}`);
174
+ }
175
+ job.received += 1;
176
+ job.bytes += chunk.length;
177
+ sendJson(res, 200, { ok: true, received: job.received });
178
+ }
179
+
180
+ /** POST /api/mobile/v1/upload/finish { uploadId } → { ok, path, name, bytes }。 */
181
+ async function handleUploadFinish(_ctx, req, res) {
182
+ const body = await readJsonBody(req);
183
+ const uploadId = String((body && body.uploadId) || '');
184
+ const job = uploads.get(uploadId);
185
+ if (!job) return fail(res, 404, 'unknown-upload', '上传会话不存在或已结束');
186
+ uploads.delete(uploadId);
187
+
188
+ const dir = path.join(inboxRoot(), today());
189
+ try {
190
+ await fsp.mkdir(dir, { recursive: true });
191
+ const target = await uniquePath(dir, job.name);
192
+ await fsp.rename(job.stagingPath, target);
193
+ sendJson(res, 200, { ok: true, path: target, name: path.basename(target), bytes: job.bytes });
194
+ } catch (error) {
195
+ try { await fsp.unlink(job.stagingPath); } catch { /* 已经不在了 */ }
196
+ return fail(res, 500, 'inbox-unavailable', `落盘失败:${error.message}`);
197
+ }
198
+ }
199
+
200
+ /** POST /api/mobile/v1/upload/abort { uploadId } → { ok }。 */
201
+ async function handleUploadAbort(_ctx, req, res) {
202
+ const body = await readJsonBody(req);
203
+ const uploadId = String((body && body.uploadId) || '');
204
+ const job = uploads.get(uploadId);
205
+ if (job) {
206
+ uploads.delete(uploadId);
207
+ try { await fsp.unlink(job.stagingPath); } catch { /* 已经不在了 */ }
208
+ }
209
+ sendJson(res, 200, { ok: true });
210
+ }
211
+
212
+ module.exports = {
213
+ handleUploadBegin,
214
+ handleUploadChunk,
215
+ handleUploadFinish,
216
+ handleUploadAbort,
217
+ sanitizeName,
218
+ inboxRoot,
219
+ CHUNK_BYTES,
220
+ };
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  const { sendJson, readJsonBody } = require('../shared/http-util');
18
- const { listRevision } = require('../shared/list-revision.js');
18
+ const { stableRevision } = require('../shared/list-revision.js');
19
19
  // 会话列表缓存归 sessions.js 管。归档/移动会话会改变列表行本身,必须让那边立刻失效,
20
20
  // 否则手机端按完「归档会话」,那一行最长 10 秒还留在侧栏里,看着像没归档。
21
21
  const { invalidateSessionList } = require('./sessions.js');
@@ -74,7 +74,8 @@ async function buildWorkspacesBody(ctx) {
74
74
  // App 把 generatedAt + revision 存进快照用于展示「数据时间」,emptyConfirmed
75
75
  // 让「用户真的删光了工作区」不必再靠连续两次空去猜。
76
76
  generatedAt: Date.now(),
77
- revision: listRevision(items),
77
+ // 归档集合也是工作区快照语义的一部分;仅它变化时也必须让镜像推送判为新版本。
78
+ revision: stableRevision({ items, archivedSessionIds }),
78
79
  emptyConfirmed: items.length === 0,
79
80
  };
80
81
  }