dsh-mobile-plugin 0.5.21 → 0.5.23

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.
Files changed (41) hide show
  1. package/README.md +3 -12
  2. package/assets/pets/README.md +9 -0
  3. package/assets/pets/deepy-whale/attention.gif +0 -0
  4. package/assets/pets/deepy-whale/done.gif +0 -0
  5. package/assets/pets/deepy-whale/error.gif +0 -0
  6. package/assets/pets/deepy-whale/idle.gif +0 -0
  7. package/assets/pets/deepy-whale/pack.json +54 -0
  8. package/assets/pets/deepy-whale/sleeping.gif +0 -0
  9. package/assets/pets/deepy-whale/working-3.gif +0 -0
  10. package/assets/pets/deepy-whale/working.gif +0 -0
  11. package/extensions/README.md +64 -0
  12. package/extensions/catalog.json +1 -0
  13. package/lib/impl.js +21 -1
  14. package/lib/mobile/approvals.js +22 -2
  15. package/lib/mobile/cli/acp.js +34 -20
  16. package/lib/mobile/cli/mobile.js +8 -1
  17. package/lib/mobile/cli/service.js +151 -10
  18. package/lib/mobile/extension-catalog.js +244 -0
  19. package/lib/mobile/live-steps.js +35 -3
  20. package/lib/mobile/local-images.js +166 -87
  21. package/lib/mobile/pet-tools.js +215 -0
  22. package/lib/mobile/pets.js +559 -0
  23. package/lib/mobile/session-local-image.js +9 -1
  24. package/lib/mobile/stats-collect.cjs +15 -7
  25. package/lib/mobile/stats-ledger.cjs +44 -5
  26. package/lib/mobile/stream.js +8 -5
  27. package/lib/mobile/wechat-tools.js +257 -0
  28. package/lib/mobile/wechat.js +489 -0
  29. package/lib/panel/index.html +10 -10
  30. package/lib/relay/pairing-payload.cjs +13 -5
  31. package/lib/relay/snapshot-auth.js +82 -0
  32. package/lib/relay/status.js +3 -0
  33. package/lib/routes/control-plane.js +65 -3
  34. package/lib/routes/mobile-api.js +54 -1
  35. package/lib/sidecar-facade-worker.cjs +8 -0
  36. package/lib/tailcat/host-process.cjs +270 -0
  37. package/lib/tailcat/ingress.cjs +98 -0
  38. package/lib/tailcat/session.cjs +264 -0
  39. package/lib/tailcat/wiring.cjs +96 -0
  40. package/lib/update-install.cjs +43 -0
  41. package/package.json +10 -1
@@ -0,0 +1,559 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 悬浮宠物的包目录(见 docs/pet-overlay.md §1):素材存在跑对话的这台电脑上,手机连着哪台
5
+ * 就看哪台的;换手机、清缓存都不丢。
6
+ *
7
+ * ## 两个根
8
+ *
9
+ * · 用户包:`<DSH_HOME>/storages/mobile-plugin/pets/<id>/`,可写、可删;
10
+ * · 内置包:插件包自己的 `assets/pets/<id>/`,只读、随发版走,删不掉。
11
+ *
12
+ * 用户目录里同 id 的包覆盖内置那份(`builtin` 回 false)—— 这就是「整体覆盖内置」的方式。
13
+ * 内置目录不存在是常态(素材由发版那边放),那时列表就是少几个包,不报错。
14
+ *
15
+ * ## 坏包静默跳过
16
+ *
17
+ * 少一个目录、pack.json 读不出、schema 不是 1、id 不合规矩、图片超限、没有能用的 idle,
18
+ * 一律当作「这个包不存在」,不抛、不让整张列表变红。取文件那条才逐个回错误码。
19
+ *
20
+ * ## 能力面
21
+ *
22
+ * 只认包目录内**单层**的 `.gif` / `.png`:id 与文件名都卡成不含斜杠的形状,取文件时再按
23
+ * 列表里的那一份核对,路径全由宿主拼,客户端给不了路径。没有「写任意文件」这条口子。
24
+ */
25
+
26
+ const fs = require('node:fs');
27
+ const fsp = require('node:fs/promises');
28
+ const path = require('node:path');
29
+
30
+ const { sendJson, readJsonBody } = require('../shared/http-util');
31
+ const { dshHome } = require('../shared/paths.js');
32
+
33
+ /** 包 id 也是目录名:只认这套形状,顺带把路径穿越挡在门外。 */
34
+ const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
35
+ /** 认得的状态键。表外的键原样忽略,以后加状态时老 App 还能用这份包。 */
36
+ const KNOWN_STATES = Object.freeze(['idle', 'working', 'attention', 'done', 'error', 'sleeping']);
37
+ /** 默认循环播的那几档(与 §1.1 一致)。 */
38
+ const DEFAULT_LOOP = Object.freeze(['idle', 'working', 'attention']);
39
+ /** 单张图上限:一张 GIF 一般几十到几百 KB,四 MB 是「明显不对劲」的那条线。 */
40
+ const MAX_FILE_BYTES = 4 * 1024 * 1024;
41
+ /** 整包上限:手机要整份拉下来,超了整包拒收。 */
42
+ const MAX_PACK_BYTES = 20 * 1024 * 1024;
43
+ const MAX_PACK_FILES = 60;
44
+ /** 档案文件名。 */
45
+ const PACK_FILE = 'pack.json';
46
+ /** 图片扩展名:GIF 自带帧就是一段动画,PNG 是一张静帧。 */
47
+ const IMAGE_EXT_RE = /\.(?:gif|png)$/i;
48
+
49
+ /** 用户包根。`DSH_MOBILE_PETS` 只给测试用。 */
50
+ function rootDir() {
51
+ return process.env.DSH_MOBILE_PETS || path.join(dshHome(), 'storages', 'mobile-plugin', 'pets');
52
+ }
53
+
54
+ /**
55
+ * 内置包根:从 lib/mobile/ 往上两级就是包根。不依赖 cwd、不建目录(随 npm 包发布)。
56
+ * `DSH_MOBILE_PETS_BUILTIN` 只给测试用。
57
+ */
58
+ function builtinRoot() {
59
+ return process.env.DSH_MOBILE_PETS_BUILTIN || path.join(__dirname, '..', '..', 'assets', 'pets');
60
+ }
61
+
62
+ /** 400 信封:与其余移动端接口同形,App 按 code 翻人话。 */
63
+ function fail(res, status, code, message) {
64
+ return sendJson(res, status, { ok: false, error: { code, message } });
65
+ }
66
+
67
+ function isRecord(value) {
68
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
69
+ }
70
+
71
+ function textOf(value, max) {
72
+ if (typeof value !== 'string') return '';
73
+ const trimmed = value.trim();
74
+ return trimmed.length > max ? trimmed.slice(0, max) : trimmed;
75
+ }
76
+
77
+ /** 可选文案:非空字符串才收,其余一律 null(页脚按 null 决定不画这一行)。 */
78
+ function textOrNull(value) {
79
+ const text = textOf(value, 200);
80
+ return text ? text : null;
81
+ }
82
+
83
+ function finiteOf(value) {
84
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
85
+ }
86
+
87
+ /**
88
+ * 包内图片名:单层、不带斜杠、不以点开头、扩展名只认 `.gif` / `.png`。
89
+ *
90
+ * 这一条同时是「路径穿越」的判据:`..` 段与绝对路径都拼不出这个形状。
91
+ */
92
+ function isImageName(name) {
93
+ if (typeof name !== 'string') return false;
94
+ if (name.length === 0 || name.length > 120) return false;
95
+ if (name.startsWith('.')) return false;
96
+ if (name.includes('/') || name.includes('\\')) return false;
97
+ return IMAGE_EXT_RE.test(name);
98
+ }
99
+
100
+ /** 按扩展名给媒体类型:只认 gif / png 两种,其余在上一步已经被挡掉了。 */
101
+ function mediaTypeOf(name) {
102
+ return /\.gif$/i.test(name) ? 'image/gif' : 'image/png';
103
+ }
104
+
105
+ /** `{w,h}` / `{x,baseline}` 这类小坐标:字段齐且都是有限数才收,缺一个就当没填。 */
106
+ function pairOf(value, keys) {
107
+ if (!isRecord(value)) return undefined;
108
+ const out = {};
109
+ for (const key of keys) {
110
+ const number = finiteOf(value[key]);
111
+ if (number === null) return undefined;
112
+ out[key] = number;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * 扫包目录里的图片:单层、名字合规矩、不点开头;超限当场返回 null(整包拒收)。
119
+ *
120
+ * 只认普通文件:子目录一律不看(§1 的形状就是一层)。
121
+ */
122
+ function scanImages(directory) {
123
+ let entries;
124
+ try {
125
+ entries = fs.readdirSync(directory, { withFileTypes: true });
126
+ } catch {
127
+ return null;
128
+ }
129
+ const files = [];
130
+ let total = 0;
131
+ for (const entry of entries) {
132
+ if (!entry.isFile() || entry.name.startsWith('.') || !isImageName(entry.name)) continue;
133
+ let size;
134
+ try {
135
+ size = fs.statSync(path.join(directory, entry.name)).size;
136
+ } catch {
137
+ continue;
138
+ }
139
+ if (size > MAX_FILE_BYTES) return null;
140
+ total += size;
141
+ files.push({ path: entry.name, size });
142
+ if (files.length > MAX_PACK_FILES || total > MAX_PACK_BYTES) return null;
143
+ }
144
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
145
+ return files;
146
+ }
147
+
148
+ /**
149
+ * 把一份 pack.json 收成内部形状。`entries` 是包里实际有的图片(`[{path, size}]`)。
150
+ *
151
+ * 返回 `{ pack, problems }`:列表那条路只认 `pack`(拿不到就是坏包,静默跳过),写那条路
152
+ * 把 `problems` 原样说给模型听 —— 同一套判据,两种说法。
153
+ */
154
+ function buildPack(raw, id, entries) {
155
+ const problems = [];
156
+ if (!isRecord(raw)) return { pack: null, problems: ['pack.json 不是一个对象。'] };
157
+
158
+ if (raw.schema !== 1) problems.push('schema 必须是 1。');
159
+ if (typeof raw.id !== 'string' || !ID_RE.test(raw.id.trim())) {
160
+ problems.push('id 不合规矩:只认 1-64 位小写字母、数字与连字符,且不以连字符开头。');
161
+ } else if (raw.id.trim() !== id) {
162
+ // id 同时是目录名:写的和所在的目录对不上,这份包到底叫哪个名字就说不清了。
163
+ problems.push(`pack.json 里的 id("${raw.id.trim()}")和目录名("${id}")对不上。`);
164
+ }
165
+ const name = textOf(raw.name, 80);
166
+ if (!name) problems.push('name 必填(界面上显示的就是它)。');
167
+
168
+ const present = new Set(entries.map((entry) => entry.path));
169
+
170
+ // states:认识的键才收,文件得真在包里;其余键(以后加的)原样忽略,不是错误。
171
+ const states = {};
172
+ if (!isRecord(raw.states)) {
173
+ problems.push('states 必填,而且至少要有一个 idle。');
174
+ } else {
175
+ for (const key of KNOWN_STATES) {
176
+ const entry = raw.states[key];
177
+ if (!isRecord(entry)) continue;
178
+ const file = textOf(entry.file, 120);
179
+ if (!isImageName(file) || !present.has(file)) continue;
180
+ states[key] = { file };
181
+ }
182
+ if (!states.idle) problems.push('states 里至少要有一个能用的 idle(file 得是包里真有的 .gif / .png)。');
183
+ }
184
+
185
+ // tiers.working:按「同时在跑几条」换图,min 从大到小匹配。
186
+ const tiers = {};
187
+ if (isRecord(raw.tiers) && Array.isArray(raw.tiers.working)) {
188
+ const rows = [];
189
+ for (const item of raw.tiers.working) {
190
+ if (!isRecord(item)) continue;
191
+ const min = finiteOf(item.min);
192
+ const file = textOf(item.file, 120);
193
+ if (min === null || min < 1) continue;
194
+ if (!isImageName(file) || !present.has(file)) continue;
195
+ rows.push({ min: Math.floor(min), file });
196
+ }
197
+ rows.sort((a, b) => b.min - a.min);
198
+ if (rows.length > 0) tiers.working = rows;
199
+ }
200
+
201
+ const loop = [];
202
+ if (Array.isArray(raw.loop)) {
203
+ for (const item of raw.loop) {
204
+ if (typeof item !== 'string' || !KNOWN_STATES.includes(item) || loop.includes(item)) continue;
205
+ loop.push(item);
206
+ }
207
+ }
208
+ const sleepAfterMs = finiteOf(raw.sleepAfterMs);
209
+
210
+ if (problems.length > 0) return { pack: null, problems };
211
+
212
+ return {
213
+ pack: {
214
+ id,
215
+ name,
216
+ author: textOrNull(raw.author),
217
+ license: textOrNull(raw.license),
218
+ canvas: pairOf(raw.canvas, ['w', 'h']),
219
+ anchor: pairOf(raw.anchor, ['x', 'baseline']),
220
+ states,
221
+ tiers,
222
+ loop: loop.length > 0 ? loop : [...DEFAULT_LOOP],
223
+ sleepAfterMs: sleepAfterMs !== null && sleepAfterMs > 0 ? sleepAfterMs : undefined,
224
+ size: entries.reduce((sum, entry) => sum + entry.size, 0),
225
+ files: entries,
226
+ },
227
+ problems: [],
228
+ };
229
+ }
230
+
231
+ /** 读一个包目录。不成立就 null(坏包静默跳过这条路的入口)。 */
232
+ function readPackAt(root, id, builtin) {
233
+ if (!ID_RE.test(id)) return null;
234
+ const directory = path.join(root, id);
235
+ let stats;
236
+ try {
237
+ stats = fs.statSync(directory);
238
+ } catch {
239
+ return null;
240
+ }
241
+ if (!stats.isDirectory()) return null;
242
+ let raw;
243
+ try {
244
+ raw = JSON.parse(fs.readFileSync(path.join(directory, PACK_FILE), 'utf8'));
245
+ } catch {
246
+ return null;
247
+ }
248
+ const entries = scanImages(directory);
249
+ if (!entries || entries.length === 0) return null;
250
+ const { pack } = buildPack(raw, id, entries);
251
+ if (!pack) return null;
252
+ return { ...pack, builtin, dir: directory };
253
+ }
254
+
255
+ /**
256
+ * 列包:内置在前、用户在后,同 id 的话用户那份顶掉内置那份(`builtin` 变 false)。
257
+ *
258
+ * 哪个目录不在都不算错:用户还没做过宠物的机器上,用户目录根本不存在;素材还没放进去的
259
+ * 构建里,内置目录也不存在 —— 两种都只是「列表里少几个包」。
260
+ */
261
+ function listPacks() {
262
+ const byId = new Map();
263
+ for (const id of dirNames(builtinRoot())) {
264
+ const pack = readPackAt(builtinRoot(), id, true);
265
+ if (pack) byId.set(pack.id, pack);
266
+ }
267
+ for (const id of dirNames(rootDir())) {
268
+ const pack = readPackAt(rootDir(), id, false);
269
+ if (pack) byId.set(pack.id, pack);
270
+ }
271
+ const packs = [...byId.values()];
272
+ packs.sort((a, b) => {
273
+ if (a.builtin !== b.builtin) return a.builtin ? -1 : 1;
274
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
275
+ });
276
+ return packs;
277
+ }
278
+
279
+ /** 目录下的一层子目录名。读不到就是空表。 */
280
+ function dirNames(root) {
281
+ let entries;
282
+ try {
283
+ entries = fs.readdirSync(root, { withFileTypes: true });
284
+ } catch {
285
+ return [];
286
+ }
287
+ return entries
288
+ .filter((entry) => entry.isDirectory() && ID_RE.test(entry.name))
289
+ .map((entry) => entry.name);
290
+ }
291
+
292
+ /** 只留列表要的形状:内部的 `dir` 不出去。 */
293
+ function publicPack(pack) {
294
+ return {
295
+ id: pack.id,
296
+ name: pack.name,
297
+ builtin: pack.builtin,
298
+ author: pack.author,
299
+ license: pack.license,
300
+ canvas: pack.canvas,
301
+ anchor: pack.anchor,
302
+ states: pack.states,
303
+ tiers: pack.tiers,
304
+ loop: pack.loop,
305
+ sleepAfterMs: pack.sleepAfterMs,
306
+ size: pack.size,
307
+ files: pack.files,
308
+ };
309
+ }
310
+
311
+ /** 找一个包:用户那份优先,其次内置。 */
312
+ function findPack(id) {
313
+ if (!ID_RE.test(id)) return null;
314
+ return readPackAt(rootDir(), id, false) || readPackAt(builtinRoot(), id, true);
315
+ }
316
+
317
+ /**
318
+ * 取一个文件。
319
+ *
320
+ * 判据是「这份包列出来的文件里有没有它」而不是拼路径 —— id 与文件名都已经是不含斜杠的
321
+ * 形状了,再加上这一步,读到的永远是列表里那一张。
322
+ */
323
+ function readPackFile(id, name) {
324
+ if (!ID_RE.test(id) || !isImageName(name)) return null;
325
+ const pack = findPack(id);
326
+ if (!pack) return null;
327
+ if (!pack.files.some((file) => file.path === name)) return null;
328
+ let bytes;
329
+ try {
330
+ bytes = fs.readFileSync(path.join(pack.dir, name));
331
+ } catch {
332
+ return null;
333
+ }
334
+ if (bytes.length === 0 || bytes.length > MAX_FILE_BYTES) return null;
335
+ return { bytes, mediaType: mediaTypeOf(name) };
336
+ }
337
+
338
+ /**
339
+ * 删一个**用户**包。内置包删不掉:它是随发版走的只读素材。
340
+ *
341
+ * 用户目录里没有同 id 的包、而内置里有,回 `forbidden` 而不是 not-found —— App 那边听到的是
342
+ * 「这是内置的」,不是一个看起来像出错的「不在了」。
343
+ */
344
+ async function deletePack(id) {
345
+ if (!ID_RE.test(id)) {
346
+ const error = new Error('宠物包 id 不合规矩。');
347
+ error.code = 'bad-request';
348
+ throw error;
349
+ }
350
+ const isDir = (directory) => {
351
+ try {
352
+ return fs.statSync(directory).isDirectory();
353
+ } catch {
354
+ return false;
355
+ }
356
+ };
357
+ if (isDir(path.join(rootDir(), id))) {
358
+ await fsp.rm(path.join(rootDir(), id), { recursive: true, force: true });
359
+ return { builtin: false };
360
+ }
361
+ if (isDir(path.join(builtinRoot(), id))) {
362
+ const error = new Error('这是内置的宠物,删不掉;想换成自己的一份就用同一个 id 写一个用户包。');
363
+ error.code = 'forbidden';
364
+ throw error;
365
+ }
366
+ const error = new Error('没有这个宠物包。');
367
+ error.code = 'not-found';
368
+ throw error;
369
+ }
370
+
371
+ /** 本机路径统一收一遍:绝对路径、`~/`、`file://`,相对地址按当前目录解析。 */
372
+ function resolveLocalPath(value) {
373
+ let text = String(value || '').trim();
374
+ if (!text) return null;
375
+ if (text.startsWith('<') && text.endsWith('>')) text = text.slice(1, -1).trim();
376
+ if (/^file:\/\//i.test(text)) {
377
+ try {
378
+ text = decodeURIComponent(text.slice(7));
379
+ } catch {
380
+ return null;
381
+ }
382
+ } else if (text === '~' || text.startsWith('~/')) {
383
+ text = path.join(require('node:os').homedir(), text.slice(1));
384
+ }
385
+ return path.isAbsolute(text) ? path.normalize(text) : path.resolve(process.cwd(), text);
386
+ }
387
+
388
+ /**
389
+ * 一项文件的字节:优先读电脑上的那个文件,其次才认 base64。
390
+ *
391
+ * 以 `filePath` 为主:让模型把几 MB 的 base64 写进工具参数里走不通(与公众号那条同一个
392
+ * 理由),它手里真有的是那张图落在盘上的位置。
393
+ */
394
+ async function bytesOfItem(item) {
395
+ const fromPath = textOf(item.filePath, 1024);
396
+ if (fromPath) {
397
+ const filePath = resolveLocalPath(fromPath);
398
+ if (!filePath) return null;
399
+ try {
400
+ const stat = await fsp.stat(filePath);
401
+ if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_FILE_BYTES) return null;
402
+ return await fsp.readFile(filePath);
403
+ } catch {
404
+ return null;
405
+ }
406
+ }
407
+ const data = typeof item.base64 === 'string' ? item.base64.trim() : '';
408
+ if (!data) return null;
409
+ let bytes;
410
+ try {
411
+ bytes = Buffer.from(data, 'base64');
412
+ } catch {
413
+ return null;
414
+ }
415
+ if (bytes.length === 0 || bytes.length > MAX_FILE_BYTES) return null;
416
+ return bytes;
417
+ }
418
+
419
+ /**
420
+ * 写一个用户包。
421
+ *
422
+ * 判据与列表那条是同一套(`buildPack`):先按「盘上已有的图 + 这次给的图」拼出完整的一份,
423
+ * 成立才落盘 —— 不成立就整包不动,不会把原来那份写坏。
424
+ *
425
+ * 覆盖内置 id 是允许的:那是用户明说要改,`overriddenBuiltin` 让工具那条路能告警一句。
426
+ */
427
+ async function writePack(input) {
428
+ const id = textOf(input && input.id, 64);
429
+ if (!ID_RE.test(id)) {
430
+ const error = new Error(`宠物包 id 不合规矩(收到 "${id || ''}"):只认 1-64 位小写字母、数字与连字符,且不以连字符开头。`);
431
+ error.code = 'bad-request';
432
+ throw error;
433
+ }
434
+ if (!isRecord(input.pack)) {
435
+ const error = new Error('pack 必填:整份 pack.json 的对象(至少要有 name 与 states.idle)。');
436
+ error.code = 'bad-request';
437
+ throw error;
438
+ }
439
+
440
+ const directory = path.join(rootDir(), id);
441
+ const existing = scanImages(directory) || [];
442
+ const entries = new Map(existing.map((entry) => [entry.path, entry.size]));
443
+ const incoming = [];
444
+ const rejected = [];
445
+ const items = Array.isArray(input.files) ? input.files : [];
446
+ for (const item of items) {
447
+ if (!isRecord(item)) continue;
448
+ const name = textOf(item.path, 120);
449
+ if (!isImageName(name)) {
450
+ rejected.push(name || '(没有文件名)');
451
+ continue;
452
+ }
453
+ const bytes = await bytesOfItem(item);
454
+ if (!bytes) {
455
+ rejected.push(name);
456
+ continue;
457
+ }
458
+ entries.set(name, bytes.length);
459
+ incoming.push({ path: name, bytes });
460
+ }
461
+
462
+ const scan = [...entries.entries()].map(([file, size]) => ({ path: file, size }));
463
+ if (scan.length > MAX_PACK_FILES) {
464
+ const error = new Error(`一个包最多 ${MAX_PACK_FILES} 个文件,现在会到 ${scan.length} 个。`);
465
+ error.code = 'too-large';
466
+ throw error;
467
+ }
468
+ const total = scan.reduce((sum, entry) => sum + entry.size, 0);
469
+ if (total > MAX_PACK_BYTES) {
470
+ const error = new Error(`整包上限 ${Math.round(MAX_PACK_BYTES / 1024 / 1024)}MB,现在会到 ${(total / 1024 / 1024).toFixed(1)}MB。`);
471
+ error.code = 'too-large';
472
+ throw error;
473
+ }
474
+
475
+ const manifest = { ...input.pack, schema: 1, id };
476
+ const built = buildPack(manifest, id, scan);
477
+ if (!built.pack) {
478
+ const error = new Error(`这份 pack.json 还不成立:${built.problems.join(' ')}`);
479
+ error.code = 'bad-pack';
480
+ throw error;
481
+ }
482
+
483
+ await fsp.mkdir(directory, { recursive: true });
484
+ for (const file of incoming) {
485
+ await fsp.writeFile(path.join(directory, file.path), file.bytes);
486
+ }
487
+ // 档案最后写、并且先写临时文件再改名:宿主随时可能被杀,半个 JSON 会让这个包从此不出现。
488
+ const tmp = path.join(directory, `${PACK_FILE}.tmp`);
489
+ await fsp.writeFile(tmp, JSON.stringify(manifest, null, 2), 'utf8');
490
+ await fsp.rename(tmp, path.join(directory, PACK_FILE));
491
+
492
+ const pack = readPackAt(rootDir(), id, false) || readPackAt(builtinRoot(), id, true);
493
+ return {
494
+ pack,
495
+ rejected,
496
+ written: incoming.map((file) => file.path),
497
+ // 内置里有同 id 的那份:覆盖是允许的(用户明说要改),只是工具那条路该说一句。
498
+ overriddenBuiltin: Boolean(readPackAt(builtinRoot(), id, true)),
499
+ };
500
+ }
501
+
502
+ // ── 路由 ─────────────────────────────────────────────────────────────────────
503
+
504
+ /** `GET /pet/packs` —— 这台电脑上现有的宠物包(内置 + 用户)。 */
505
+ async function handlePackList(_ctx, _req, res) {
506
+ sendJson(res, 200, { ok: true, packs: listPacks().map(publicPack) });
507
+ }
508
+
509
+ /**
510
+ * `GET /pet/pack/file?id=&path=` —— 取一张图(base64 JSON,与 `wechat/media` 同形)。
511
+ *
512
+ * 走 JSON 而不是直接回字节:App 取回来之后要交给原生落地播放,中间还得带鉴权头,形状与
513
+ * 公众号那条一致,也能复用同一套解包。
514
+ */
515
+ async function handlePackFile(_ctx, req, res) {
516
+ const url = new URL(req.url, 'http://x');
517
+ const id = (url.searchParams.get('id') || '').trim();
518
+ const name = (url.searchParams.get('path') || '').trim();
519
+ if (!ID_RE.test(id) || !isImageName(name)) return fail(res, 400, 'bad-request', '参数不对。');
520
+ const file = readPackFile(id, name);
521
+ if (!file) return fail(res, 404, 'file-not-found', '这个包里没有这张图。');
522
+ sendJson(res, 200, { ok: true, base64: file.bytes.toString('base64'), mediaType: file.mediaType });
523
+ }
524
+
525
+ /** `POST /pet/pack/delete` —— 删一个用户包;内置的回 forbidden。 */
526
+ async function handlePackDelete(_ctx, req, res) {
527
+ const body = await readJsonBody(req, 64 * 1024);
528
+ if (!isRecord(body)) return fail(res, 400, 'bad-request', '请求体不是对象。');
529
+ const id = textOf(body.id, 64);
530
+ try {
531
+ await deletePack(id);
532
+ } catch (error) {
533
+ const code = error && error.code;
534
+ if (code === 'bad-request') return fail(res, 400, 'bad-request', error.message);
535
+ if (code === 'forbidden') return fail(res, 403, 'forbidden', error.message);
536
+ if (code === 'not-found') return fail(res, 404, 'pack-not-found', error.message);
537
+ return fail(res, 500, 'delete-failed', '没能删掉,稍后重试。');
538
+ }
539
+ sendJson(res, 200, { ok: true, id });
540
+ }
541
+
542
+ module.exports = {
543
+ handlePackList,
544
+ handlePackFile,
545
+ handlePackDelete,
546
+ // 工具那一侧直接用这几个(不经过 HTTP)
547
+ listPacks,
548
+ readPackFile,
549
+ writePack,
550
+ deletePack,
551
+ rootDir,
552
+ builtinRoot,
553
+ isImageName,
554
+ ID_RE,
555
+ KNOWN_STATES,
556
+ MAX_FILE_BYTES,
557
+ MAX_PACK_BYTES,
558
+ MAX_PACK_FILES,
559
+ };
@@ -16,7 +16,7 @@
16
16
 
17
17
  const fs = require('node:fs/promises');
18
18
  const { sendJson, readJsonBody } = require('../shared/http-util');
19
- const { localImageOf } = require('./local-images.js');
19
+ const { localImageOf, MAX_IMAGE_BYTES } = require('./local-images.js');
20
20
 
21
21
  async function handleSessionLocalImage(ctx, req, res) {
22
22
  const body = await readJsonBody(req);
@@ -28,6 +28,14 @@ async function handleSessionLocalImage(ctx, req, res) {
28
28
  if (!entry) {
29
29
  return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '这张图没有登记,或登记已失效' } });
30
30
  }
31
+ // 超过单张上限的那张:登记过(编号在正文里),但字节不往回搬。回一个能读的 413,
32
+ // App 据此画「图片太大」的占位 —— 比在正文里留一串 markdown 源码有用得多。
33
+ if (entry.tooLarge) {
34
+ return sendJson(res, 413, {
35
+ ok: false,
36
+ error: { code: 'too-large', message: '这张图超过了单张上限,手机这边取不回来', details: { maxBytes: MAX_IMAGE_BYTES } },
37
+ });
38
+ }
31
39
  let data;
32
40
  try {
33
41
  data = await fs.readFile(entry.filePath);
@@ -223,7 +223,8 @@ async function readSessionModel(ctx, sessionId) {
223
223
  * 它那一份 —— 而统计页的成本是按模型桶折算的,缺一条就少一整块钱。
224
224
  *
225
225
  * 只做一件事:读一次宿主的当前模型(不碰投影、不碰水位),把这条会话的**累计量**复制进模型桶,
226
- * 与升级当天那次回填同一个口径(`record.model` 一旦有了,之后的增量就照常记)。读不到也把
226
+ * 与升级当天那次回填同一个口径(`record.model` 一旦有了,之后的增量就照常记)。按天明细跟着
227
+ * 这条记录自己那份 `daily` 摊开,不压在一天上(见 `applyModelBackfill`)。读不到也把
227
228
  * 尝试时间记下 —— 试过这件事本身要落盘,否则下一轮还会为同一条再问一次。
228
229
  *
229
230
  * @returns 账本有没有改动(改了就落盘)。
@@ -236,10 +237,11 @@ async function backfillModel(ctx, book, sessionId, record, at) {
236
237
  record.modelTriedAt = at;
237
238
  const model = await readSessionModel(ctx, sessionId);
238
239
  if (model) {
239
- ledger.applyModelUsage(book, {
240
+ ledger.applyModelBackfill(book, {
240
241
  model,
241
- day: ledger.dayKeyOf(num(record.lastAt) || at),
242
242
  usage: record.billed,
243
+ daily: record.daily,
244
+ at: num(record.lastAt) || at,
243
245
  });
244
246
  record.model = model;
245
247
  }
@@ -368,14 +370,20 @@ async function collectOnce(ctx, options = {}) {
368
370
  // 其余轮次一条都不读,否则定期采集每 5 分钟就给每条会话多一趟宿主调用。
369
371
  const needsModel = firstSight || hasDelta(delta) || Boolean(record && !record.model && billedOf(record.billed) > 0);
370
372
  const model = needsModel ? await readSessionModel(ctx, sessionId) : null;
373
+ // 这一笔增量记在哪个模型名下:读不到当下的选择时**沿用记录上已知的那个**。
374
+ // 不沿用的话,一条早就有模型的会话只要有一轮 `sessions.models` 读失败,那一截增量就
375
+ // 再也进不了模型卡与成本 —— 补模型那一步只服务「还没有模型」的记录,不会来接手。
376
+ // 首采仍然什么都不记(那时候根本没有已知模型,见 `stats-ledger.test.cjs` 那条)。
377
+ const attributed = model || (record ? record.model : null);
371
378
  // 老账本回填:这条会话在升级前记过累计量,但它没有模型维度,模型卡会是 0 —— 把它那条
372
- // 会话的累计量一次性复制进模型桶。日期与会话最后一次活动那天一致(同一条近似)。
379
+ // 会话的累计量一次性复制进模型桶,按天明细照它自己那份摊开(同一条近似)。
373
380
  // 首采不在这里做:首采的 delta 就是全量,已经跟着 applyDelta 记进模型桶了,再补一次会翻倍。
374
381
  if (model && record && !record.model && billedOf(record.billed) > 0 && !firstSight) {
375
- ledger.applyModelUsage(book, {
382
+ ledger.applyModelBackfill(book, {
376
383
  model,
377
- day: ledger.dayKeyOf(num(record.lastAt) || lastActiveAt),
378
384
  usage: record.billed,
385
+ daily: record.daily,
386
+ at: num(record.lastAt) || lastActiveAt,
379
387
  });
380
388
  touched = true;
381
389
  }
@@ -402,7 +410,7 @@ async function collectOnce(ctx, options = {}) {
402
410
  cwd,
403
411
  workspaceName,
404
412
  lastText,
405
- model,
413
+ model: attributed,
406
414
  });
407
415
  touched = true;
408
416
  }