dsh-mobile-plugin 0.1.75 → 0.1.76

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',
@@ -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 目标条动作
@@ -44,10 +44,8 @@ const { updateCompletionStates, confirmCompletionFromHistory } = require('./comp
44
44
  const { buildMessagesSnapshot, buildStatsSnapshot, statsProjections, handleMessages } = require('./session-snapshot.js');
45
45
  const { safeModelsValue, 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');
@@ -487,6 +485,7 @@ module.exports = {
487
485
  handleSend,
488
486
  handleCommands,
489
487
  handleSessionRead,
488
+ handleSessionAttachment,
490
489
  handleStop,
491
490
  handleRename,
492
491
  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
  }