modelstage 0.5.24 → 0.6.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +2 -56
  3. package/bin/modelstage.mjs +48 -12
  4. package/config/models/live2d.assetbundls.json +1 -1
  5. package/config/models/live2d.azurlane.json +1 -1
  6. package/config/models/live2d.azurlane_bundle.json +1 -1
  7. package/config/models/live2d.azurlane_bundle.jsonl +2 -0
  8. package/config/models/live2d.azurlane_spine.json +1 -1
  9. package/config/models/live2d.bd2.json +1 -1
  10. package/config/models/live2d.bd2.jsonl +2 -0
  11. package/config/models/live2d.bluearchive.json +1 -1
  12. package/config/models/live2d.nikke.json +1 -1
  13. package/config/models/live2d.nikke.jsonl +2 -0
  14. package/config/models/live2d.nikke_2.json +1 -1
  15. package/config/models/live2d.npc.json +1 -1
  16. package/config/models/spine.assetbundls.json +1 -1
  17. package/config/models/spine.azurlane.json +1 -1
  18. package/config/models/spine.azurlane_bundle.json +1 -1
  19. package/config/models/spine.azurlane_bundle.jsonl +2 -0
  20. package/config/models/spine.azurlane_spine.json +1 -1
  21. package/config/models/spine.bd2.json +1 -1
  22. package/config/models/spine.bd2.jsonl +433 -0
  23. package/config/models/spine.bluearchive.json +1 -1
  24. package/config/models/spine.nikke.json +1 -1
  25. package/config/models/spine.nikke_2.json +1 -1
  26. package/config/models/spine.npc.json +1 -1
  27. package/package.json +8 -2
  28. package/src/server/assets/probe.mjs +1 -1
  29. package/src/server/core/config.mjs +2 -2
  30. package/src/server/core/log.mjs +1 -1
  31. package/src/server/data/catalog.mjs +11 -14
  32. package/src/server/data/live2d.mjs +139 -40
  33. package/src/server/data/manifest-cache.mjs +53 -10
  34. package/src/server/data/manifest-jsonl.mjs +264 -0
  35. package/src/server/data/manifest-stamp.mjs +52 -2
  36. package/src/server/data/scan-progress.mjs +305 -0
  37. package/src/server/data/scan-queue.mjs +99 -0
  38. package/src/server/data/spine.mjs +324 -106
  39. package/src/server/http/write.mjs +8 -1
  40. package/src/server/plugins/registry.mjs +1 -1
  41. package/src/server/routes/data.mjs +76 -17
  42. package/src/server/serve.mjs +71 -4
  43. package/src/server/unpack/plugin-store.mjs +1 -1
  44. package/src/server/unpack/runtime/exec.mjs +37 -8
  45. package/src/server/unpack/worker-client.mjs +23 -8
  46. package/src/shared/scan-limits.mjs +23 -0
  47. package/src/shared/type-labels.mjs +31 -0
  48. package/src/web/app/anim-list.js +1 -1
  49. package/src/web/app/backgrounds.js +4 -8
  50. package/src/web/app/dir-picker.js +3 -5
  51. package/src/web/app/memory-budget.js +0 -1
  52. package/src/web/app/memory-guard.js +0 -1
  53. package/src/web/app/model-actions.js +18 -15
  54. package/src/web/app/root-manager.js +2 -4
  55. package/src/web/app/transport-actions.js +1 -1
  56. package/src/web/app/ui-state.js +1 -1
  57. package/src/web/components.css +149 -0
  58. package/src/web/debug/race-probe.js +36 -13
  59. package/src/web/engine/textures.js +1 -2
  60. package/src/web/index.html +2 -0
  61. package/src/web/infra/api.js +10 -19
  62. package/src/web/infra/document.js +3 -3
  63. package/src/web/infra/plugin-host-unpack.mjs +81 -9
  64. package/src/web/infra/ready.js +177 -39
  65. package/src/web/live2d/bridge.js +1 -1
  66. package/src/web/live2d/runtime-adapter.js +2 -4
  67. package/src/web/panel/panel-settings.js +88 -3
  68. package/src/web/roster/events.js +159 -0
  69. package/src/web/roster/filter.js +4 -4
  70. package/src/web/roster/list.js +3 -3
  71. package/src/web/roster/load.js +440 -0
  72. package/src/web/roster/persist.js +64 -15
  73. package/src/web/roster/render.js +284 -0
  74. package/src/web/roster/reveal.js +4 -9
  75. package/src/web/roster/rows.js +35 -13
  76. package/src/web/roster/scan-stream.js +346 -0
  77. package/src/web/roster/snapshot.js +16 -8
  78. package/src/web/roster/store.js +292 -0
  79. package/src/web/roster/type-filter.js +97 -0
  80. package/src/web/roster/view.js +148 -544
  81. package/src/web/spine/canvas-runtime.js +4 -5
  82. package/src/web/spine/runtimes.js +1 -1
  83. package/tool/lib/check-perms.mjs +0 -114
  84. package/tool/lib/contract-checks.mjs +0 -582
  85. package/tool/lib/cubism-core.mjs +0 -122
  86. package/tool/lib/export-bundle.mjs +0 -328
  87. package/tool/lib/spine-runtime.mjs +0 -131
  88. package/tool/lib/test-lock.mjs +0 -178
  89. package/tool/lib/test-report.mjs +0 -133
@@ -17,6 +17,12 @@ import { live2dGuide } from '../../../tool/lib/runtime-guides.mjs';
17
17
  import { readRatioEnv } from '../core/env-num.mjs';
18
18
  // 插槽目录(S1-a):与校验同源,见 plugins/slot-catalog.mjs
19
19
  import { SLOT_CATALOG, CURRENT_STAGE } from '../plugins/slot-catalog.mjs';
20
+ // 扫描进度:单飞表 + NDJSON 帧写出(P0;设计 §4.6/§5/§6)
21
+ import { createScanRunner, streamScanFrames } from '../data/scan-progress.mjs';
22
+
23
+ // 扫描单飞表:**模块级一份**(不是每请求一份)—— 单飞的意义就是"同一时刻同一个根只扫一次"。
24
+ // P1 的 JSONL 存储会原样复用这张表(设计 §4.6)。
25
+ const scanRunner = createScanRunner();
20
26
 
21
27
  /** 资源根 / 插件 / 品牌 / 前端配置 */
22
28
  function apiBasics(ctx, res, p) {
@@ -69,7 +75,7 @@ function apiBasics(ctx, res, p) {
69
75
  }
70
76
 
71
77
  /** 目录 / 模型清单 / 名表 / 配置导出 / 目录浏览 */
72
- function apiData(ctx, res, p, url) {
78
+ async function apiData(ctx, res, p, url) {
73
79
  const { data, sendJson, browseDir } = ctx;
74
80
  if (p === '/api/tree') {
75
81
  const r = data.tree.listTree(url.searchParams.get('root') || '', url.searchParams.get('path') || '');
@@ -84,19 +90,70 @@ function apiData(ctx, res, p, url) {
84
90
  const force = url.searchParams.get('refresh') === '1';
85
91
  const cfg = data.config.readConfig().find((r) => r.id === rootId);
86
92
  if (!cfg) return sendJson(res, 400, { error: `未找到资源根: ${rootId}` });
87
- try {
88
- const isSpine = p === '/api/spine/models';
89
- const r = isSpine ? data.spine.getSpineModels(cfg, force) : data.live2d.getLive2DModels(cfg, force);
90
- return sendJson(res, 200, {
91
- root: { id: cfg.id, name: cfg.name },
92
- count: r.models.length,
93
- cached: r.cached,
94
- scannedAt: new Date(r.ts).toISOString(),
95
- models: r.models,
93
+ const isSpine = p === '/api/spine/models';
94
+ // 不带 stream=1:仍走原来的 sendJson —— 这段响应必须**逐字节不变**(验收 ①)
95
+ if (url.searchParams.get('stream') !== '1') {
96
+ try {
97
+ const r = isSpine ? await data.spine.getSpineModels(cfg, force) : await data.live2d.getLive2DModels(cfg, force);
98
+ return sendJson(res, 200, {
99
+ root: { id: cfg.id, name: cfg.name },
100
+ count: r.models.length,
101
+ cached: r.cached,
102
+ scannedAt: new Date(r.ts).toISOString(),
103
+ models: r.models,
104
+ });
105
+ } catch (e) {
106
+ return sendJson(res, 500, { error: `${p === '/api/spine/models' ? 'Spine 扫描失败' : 'Live2D 扫描失败'}: ${e.message}` });
107
+ }
108
+ }
109
+
110
+ // ── stream=1:NDJSON 逐帧写进度。模型数据仍走上面的 JSON 接口(P0 只发进度,见设计 §6.3)
111
+ const kind = isSpine ? 'spine' : 'live2d';
112
+ const startedAt = Date.now();
113
+ const joined = scanRunner.startOrJoin({
114
+ kind,
115
+ rootId: cfg.id,
116
+ // onModel:扫描每产出一条模型就转发成 model 帧 —— 这是"首模型早出现"的关键
117
+ run: (onProgress, onStart, onModel) => (isSpine
118
+ ? data.spine.getSpineModels(cfg, force, { onProgress, onStart, onModel })
119
+ : data.live2d.getLive2DModels(cfg, force, onProgress, onStart, onModel)),
120
+ });
121
+ // 加入已有扫描时,把当前快照立刻推给这个新连接(设计 §4.6:后续请求不必等下一次节流)
122
+ if (joined.joined && joined.progress) {
123
+ setImmediate(() => {
124
+ try { joined.emitter.emit('progress', joined.progress); } catch { /* 订阅者可能已走 */ }
96
125
  });
97
- } catch (e) {
98
- return sendJson(res, 500, { error: `${p === '/api/spine/models' ? 'Spine 扫描失败' : 'Live2D 扫描失败'}: ${e.message}` });
99
126
  }
127
+ // **不 await**:这是一条长连接(扫描结束时才 res.end()),调用方不该被它挂住。
128
+ // 若改成 await,handleData 就得变 async —— 而它的调用方写的是 `if (handleData(...))`,
129
+ // Promise 恒真会让**所有请求都被它认领**(这是个真实踩到过的坑,见本文件末尾注释)。
130
+ streamScanFrames({
131
+ res,
132
+ emitter: joined.emitter,
133
+ buildStartFrame: () => ({
134
+ type: 'start',
135
+ kind,
136
+ rootId: cfg.id,
137
+ rootPath: cfg.path,
138
+ startedAt: new Date(startedAt).toISOString(),
139
+ }),
140
+ buildDoneFrame: (result) => ({
141
+ type: 'done',
142
+ count: result && result.models ? result.models.length : 0,
143
+ cached: false,
144
+ elapsedMs: Date.now() - startedAt,
145
+ }),
146
+ buildCachedFrame: (result) => ({
147
+ type: 'cached',
148
+ count: result && result.models ? result.models.length : 0,
149
+ ts: result && result.ts,
150
+ }),
151
+ }).catch((e) => {
152
+ // 帧写出阶段出意外(例如 header 已发再抛):尽量收尾,别留下半开连接
153
+ try { res.end(); } catch { /* 已断开 */ }
154
+ if (ctx.log && ctx.log.warn) ctx.log.warn('扫描进度流写出异常', { root: cfg.id, err: (e && e.message) || e });
155
+ });
156
+ return true;
100
157
  }
101
158
  if (p === '/api/names') return apiNames(ctx, res, url);
102
159
  // /api/config/export 与 /api/config/import 同属配置组,见 routes/config.mjs(此处不重复实现)
@@ -137,7 +194,7 @@ function apiNames(ctx, res, url) {
137
194
  }
138
195
 
139
196
  /** 需要按需 import 的三条(catalog / bundle probe / model meta) */
140
- function apiAsync(ctx, req, res, p, url) {
197
+ async function apiAsync(ctx, req, res, p, url) {
141
198
  const { data, sendJson } = ctx;
142
199
  const done = (r) => sendJson(res, r.status, r.body);
143
200
  const fail = (label) => (e) => sendJson(res, 500, { ok: false, error: `${label}: ${e.message}` });
@@ -157,7 +214,7 @@ function apiAsync(ctx, req, res, p, url) {
157
214
  const known = data.config.readConfig().map((r) => r.id);
158
215
  return sendJson(res, 400, { ok: false, error: '缺少 root 参数(?root=<资源根 id>)', known });
159
216
  }
160
- return done(data.catalog.listCatalog(rootId));
217
+ return done(await data.catalog.listCatalog(rootId));
161
218
  }
162
219
  if (p === '/api/bundle/probe') {
163
220
  data.bundleProbe.probeBundle(url.searchParams.get('root') || '', url.searchParams.get('path') || '')
@@ -289,7 +346,7 @@ function serveLive2DLib(ctx, req, res, p) {
289
346
  * @param {object} ctx 需要 data/fs/path/sendJson/sendFile/resolveSafe/browseDir/toRootView/writeApi/readBody
290
347
  * @returns {boolean} 是否已处理
291
348
  */
292
- export function handleData(req, res, p, url, ctx) {
349
+ export async function handleData(req, res, p, url, ctx) {
293
350
  const { sendJson, readBody, writeApi } = ctx;
294
351
 
295
352
  // 资源根写操作(增 / 删 / 改名 / 启停):校验 → 备份 → 写 → 失效缓存
@@ -303,8 +360,10 @@ export function handleData(req, res, p, url, ctx) {
303
360
  return true;
304
361
  }
305
362
  if (apiBasics(ctx, res, p)) return true;
306
- if (apiData(ctx, res, p, url)) return true;
307
- if (apiAsync(ctx, req, res, p, url)) return true;
363
+ // await:apiData 内部要等扫描(async 化后),但**返回的仍是 boolean** ——
364
+ // 不能写成 `if (apiData(...))`(Promise 恒真会让所有 /api 请求都被它认领,上一轮踩过)
365
+ if (await apiData(ctx, res, p, url)) return true;
366
+ if (await apiAsync(ctx, req, res, p, url)) return true;
308
367
  if (serveModelFile(ctx, req, res, p, url)) return true;
309
368
  if (serveIcon(ctx, req, res, p)) return true;
310
369
  if (serveAvatar(ctx, req, res, p)) return true;
@@ -31,6 +31,8 @@ import { createLogger } from './core/log.mjs';
31
31
  import { createUnpackStore } from './unpack/plugin-store.mjs';
32
32
  import { createUnpackRoutes } from './unpack/routes.mjs';
33
33
  import { handleData } from './routes/data.mjs';
34
+ // 扫描并发上限与让路提示(P0 退化修复);serve 只负责把"在途请求数"同步进去
35
+ import { noteRequestStart, noteRequestEnd } from './data/scan-queue.mjs';
34
36
  import { handleConfig } from './routes/config.mjs';
35
37
  import { handleDiag } from './routes/diag.mjs';
36
38
  import { handleReady } from './routes/ready.mjs';
@@ -236,7 +238,54 @@ const ctx = {
236
238
  * @param {string} p
237
239
  * @param {URL} url
238
240
  */
239
- function dispatch(req, res, p, url) {
241
+ /**
242
+ * 版本号:读 package.json(它是版本号的唯一权威,与 bin 的 --version 同源)。
243
+ * 读不到时返回 'unknown' 而不是抛错 —— 探活端点不该因为包元信息缺失而 500。
244
+ */
245
+ function readVersion() {
246
+ try {
247
+ return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version || 'unknown';
248
+ } catch {
249
+ return 'unknown';
250
+ }
251
+ }
252
+
253
+ /**
254
+ * GET /api/health —— 探活端点(`web.sh status` 与外部监控用)。
255
+ *
256
+ * 为什么单独一条而不复用 /api/ready:ready 是"能否开始服务请求"的业务就绪信号
257
+ * (要扫资源根、可能 503),而 health 只回答"进程活着吗、什么版本、数据在哪"。
258
+ * 两者语义不同,混一起会让监控在"资源根还没扫完"时误判进程已死。
259
+ *
260
+ * 只暴露排障必需的粗粒度信息:版本 / 运行时长 / 数据根绝对路径 / 插件数。
261
+ * **不返回**配置内容、资源根清单、任何凭据或环境变量。
262
+ */
263
+ function handleHealth(req, res, p) {
264
+ if (p !== '/api/health') return false;
265
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
266
+ sendJson(res, 405, { ok: false, error: '仅支持 GET' });
267
+ return true;
268
+ }
269
+ // 插件数:直接问数据层(与 /api/plugins 同源)。
270
+ // 早先这里写的是 ctx.providers(),但本函数没有 ctx 参数 —— 异常被 catch 吞成 0,
271
+ // 结果 health 永远报 plugins:0(实测 5 个插件时也报 0)。探活字段报假数比不报更糟,故改为同源直取。
272
+ let pluginCount = 0;
273
+ try {
274
+ pluginCount = (data.plugins.readPlugins() || []).length;
275
+ } catch { /* 插件系统未就绪也算 0,探活不该因此失败 */ }
276
+ sendJson(res, 200, {
277
+ ok: true,
278
+ uptimeSec: Math.round(process.uptime()),
279
+ version: readVersion(),
280
+ dataRoot: data.paths.dataRoot,
281
+ plugins: pluginCount,
282
+ });
283
+ return true;
284
+ }
285
+
286
+ async function dispatch(req, res, p, url) {
287
+ // 探活放最前:它不该被其它路由的鉴权/副作用影响(也就不会因为那些逻辑出错而"连探活都挂")
288
+ if (handleHealth(req, res, p)) return true;
240
289
  if (unpackRoutes(req, res, p, url)) return true;
241
290
  if (handleDiag(req, res, p, url, ctx)) return true;
242
291
  if (handleReady(req, res, p, url, ctx)) return true;
@@ -247,7 +296,8 @@ function dispatch(req, res, p, url) {
247
296
  if (handleBackgrounds(req, res, p, ctx)) return true;
248
297
  // 条目状态(写读一体):必须在 handleData 之前认领,否则 /api/ 前缀兜底会当未知接口 404
249
298
  if (handleEntries(req, res, p, url, ctx)) return true;
250
- if (handleData(req, res, p, url, ctx)) return true;
299
+ // handleData 已 async 化(内部要等扫描):必须 await,否则 Promise 恒真会让所有请求都被它认领
300
+ if (await handleData(req, res, p, url, ctx)) return true;
251
301
  if (staticRoutes.serveAssets(req, res, p)) return true;
252
302
  if (res.headersSent) return true;
253
303
  if (p.startsWith('/api/')) {
@@ -259,25 +309,27 @@ function dispatch(req, res, p, url) {
259
309
  return true;
260
310
  }
261
311
 
262
- const server = http.createServer((req, res) => {
312
+ const server = http.createServer(async (req, res) => {
263
313
  const url = new URL(req.url, `http://localhost:${PORT}`);
264
314
  // 长连接(SSE)不计入活跃度:它一挂几分钟,计入就等于"服务永远忙"
265
315
  const longLived = url.pathname.includes('/stream');
266
316
  if (!longLived) {
267
317
  activity.inflight += 1;
318
+ noteRequestStart(); // 同一个事实同步给扫描调度(它据此让路)
268
319
  activity.lastRequestAt = Date.now();
269
320
  let counted = true;
270
321
  const done = () => {
271
322
  if (!counted) return; // close 与 finish 都会来,只减一次
272
323
  counted = false;
273
324
  activity.inflight -= 1;
325
+ noteRequestEnd(); // 必须与 noteRequestStart 成对(放在同一个 done 回调里)
274
326
  activity.lastRequestAt = Date.now();
275
327
  };
276
328
  res.on('finish', done);
277
329
  res.on('close', done);
278
330
  }
279
331
  try {
280
- dispatch(req, res, url.pathname, url);
332
+ await dispatch(req, res, url.pathname, url);
281
333
  } catch (e) {
282
334
  log.error('请求处理异常', { err: (e && e.stack) ? e.stack : String(e) });
283
335
  try {
@@ -302,6 +354,21 @@ server.listen(PORT, '0.0.0.0', () => {
302
354
  log.info('资源根已登记', { roots: data.config.readConfig().map((r) => `${r.id}=${r.name}`) });
303
355
  });
304
356
 
357
+ // 监听失败要**说人话**:不处理的话用户看到的是 EADDRINUSE 原始堆栈(一大坨,且不说下一步做什么)。
358
+ // 退出码用 2 而不是 1:1 留给"参数/配置错误"这类,端口占用是环境问题,靠退出码就能区分。
359
+ server.on('error', (e) => {
360
+ const code = (e && e.code) || '';
361
+ if (code === 'EADDRINUSE') {
362
+ log.error(`端口 ${PORT} 已被占用:用 --port <其他端口> 换一个,或先停掉占用该端口的进程`);
363
+ log.error(`排查提示:lsof -i :${PORT}(macOS/Linux)或 netstat -ano | findstr :${PORT}(Windows)`);
364
+ process.exit(2);
365
+ }
366
+ // 其它监听错误(EACCES 权限不足、EADDRNOTAVAIL 地址不可用…):保留原始信息,同样给出下一步
367
+ log.error(`监听端口 ${PORT} 失败:${(e && e.message) || e}`, { code });
368
+ log.error('可尝试:换一个端口(--port),或检查是否有权限监听该端口/该地址');
369
+ process.exit(2);
370
+ });
371
+
305
372
  /**
306
373
  * 收尾钩子:先把**常驻资源**放掉再退出。
307
374
  *
@@ -287,7 +287,7 @@ export function createUnpackStore(opts) {
287
287
 
288
288
  // 解包 worker 客户端:**常驻 1 个**(与既有串行队列一致,不并发解包)。
289
289
  // 硬超时 120s 只用于"防死循环把队列永久堵住",不是"约束合法解包" ——
290
- // 锚点包 azurlane_bundle/live2d/z46_2(解压 33.4MB 的 LZMA)实测墙钟 17.83s、unpackMs 9416ms,
290
+ // 实测大体积 LZMA 包也在 20s 内完成,
291
291
  // 120s 有 6.7 倍余量,正常解包一辈子不该触发它。
292
292
  // ⚠️ 别把这个 120s 与"客户端不设总时长上限"当成矛盾,两者防的不是一回事:
293
293
  // 这里防的是「worker 死循环把串行队列永久堵死」(服务端自保,与有没有人看无关);
@@ -162,6 +162,30 @@ function expandPlaceholder(value, opts = {}) {
162
162
  * @param {object} meta 插件清单
163
163
  * @returns {string} 解释器路径或命令名
164
164
  */
165
+ /** PATH 里的目录(空段过滤掉:空串会让 path.join 拼出当前目录,那是安全隐患) */
166
+ function pathDirs() {
167
+ return String(process.env.PATH || '').split(path.delimiter).filter(Boolean);
168
+ }
169
+
170
+ /**
171
+ * 按**名字**在 PATH 里找可执行文件,返回绝对路径;找不到返回 null。
172
+ *
173
+ * 为什么不能写一串"常见安装路径":那样换机器(或换 Node/Python 安装方式)就失效,
174
+ * 而且会让"这个解释器一定在 /usr/local/bin"变成隐含假设 —— 这正是要被消灭的形态。
175
+ * PATH 是运行时的权威,问它就行。
176
+ * @param {string} binaryName 不带路径的命令名(如 'python3.14')
177
+ * @returns {string|null}
178
+ */
179
+ function findInPath(binaryName) {
180
+ for (const dir of pathDirs()) {
181
+ const candidate = path.join(dir, binaryName);
182
+ try {
183
+ if (fs.existsSync(candidate)) return candidate;
184
+ } catch { /* 该 PATH 目录不可读:跳过,继续下一个 */ }
185
+ }
186
+ return null;
187
+ }
188
+
165
189
  let interpreterProbeCache = null;
166
190
  function resolveInterpreter(meta) {
167
191
  // ① 环境变量最优先(部署期覆盖,便于换机/换版本而不改清单)
@@ -171,14 +195,16 @@ function resolveInterpreter(meta) {
171
195
  const declared = meta && meta.interpreter ? String(meta.interpreter) : '';
172
196
  if (declared) return declared;
173
197
  if (interpreterProbeCache) return interpreterProbeCache;
174
- // 候选按"新到旧"排:新版本装的依赖不保证能在旧版本里 import
175
- for (const candidate of ['/usr/local/bin/python3.14', '/usr/local/bin/python3.10', '/usr/bin/python3']) {
176
- try {
177
- if (fs.existsSync(candidate)) {
178
- interpreterProbeCache = candidate;
179
- return candidate;
180
- }
181
- } catch { /* 继续下一个 */ }
198
+ // ③ 按名字在 PATH 里探(**不写死任何安装位置**)。
199
+ // 候选集合**严格沿用**改动前的那三个(python3.14 → python3.10 → python3),
200
+ // 按"新到旧"排:新版本装的依赖不保证能在旧版本里 import。
201
+ // 只扩大候选集合(比如补上 3.13/3.12/3.11)会改变"选中哪个解释器"这个结果,本批要求行为不变,故不加。
202
+ for (const binaryName of ['python3.14', 'python3.10', 'python3']) {
203
+ const found = findInPath(binaryName);
204
+ if (found) {
205
+ interpreterProbeCache = found;
206
+ return found;
207
+ }
182
208
  }
183
209
  // 都没探到 ⇒ 交给 PATH(spawn 会报 ENOENT,错误信息里带命令名,够定位)
184
210
  interpreterProbeCache = 'python3';
@@ -218,6 +244,9 @@ class ExecWorker {
218
244
  if (this.ready) return this.ready;
219
245
  this.ready = new Promise((resolve, reject) => {
220
246
  const interpreter = resolveInterpreter(this.meta);
247
+ // 记一行解释器路径:spawn 失败(ENOENT / 版本不对 / 依赖没装)时,日志里能直接看到
248
+ // "用哪个 python 起的"——否则只能从报错反推,多绕一圈。
249
+ if (this.log) this.log('info', `exec worker 使用解释器:${interpreter}`, { plugin: this.pluginId || null });
221
250
  const entryAbs = path.join(this.pluginDir, String(this.meta.entry || 'worker.py'));
222
251
  if (!fs.existsSync(entryAbs)) {
223
252
  this.ready = null;
@@ -11,7 +11,7 @@
11
11
  //
12
12
  // 硬超时语义(写死在注释里,避免被误读成"包太大被拒"):
13
13
  // 120s 只用于**防死循环把队列永久堵住**,不是"约束合法解包"。
14
- // 锚点包(azurlane_bundle/live2d/z46_2,解压 33.4MB 的 LZMA)实测墙钟 17.83s、unpackMs 9416ms,
14
+ // 实测大体积 LZMA 包也在 20s 内完成,
15
15
  // 120s 有 6.7 倍余量 —— 正常解包一辈子不该触发它。
16
16
 
17
17
  import fs from 'node:fs';
@@ -20,6 +20,12 @@ import { Worker } from 'node:worker_threads';
20
20
 
21
21
  const WORKER_URL = new URL('./unpack-worker.mjs', import.meta.url);
22
22
 
23
+ // worker 单次解包的默认超时。
24
+ // 语义:**worker 活着但不响应**的上限(卡住),不是"崩溃检测"——崩溃由 exit 事件立即结算。
25
+ // 为什么 120s:最大的包(几十 MB + LZMA 解压 + 贴图解码)实测在 30~60s 量级,
26
+ // 留一倍余量避免正常慢包被误杀;更长的等待意味着真卡住,继续等没有收益。
27
+ const DEFAULT_TIMEOUT_MS = 120_000;
28
+
23
29
  /**
24
30
  * @param {{
25
31
  * rootDir: string,
@@ -30,7 +36,10 @@ const WORKER_URL = new URL('./unpack-worker.mjs', import.meta.url);
30
36
  export function createUnpackWorkerClient(opts) {
31
37
  const rootDir = opts && opts.rootDir;
32
38
  if (!rootDir) throw new Error('createUnpackWorkerClient: rootDir 必填');
33
- const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : 120000;
39
+ // workerUrl 可注入:仅用于测试「worker 启动即崩溃」这条路径。
40
+ // 生产调用方不传 ⇒ 用模块级常量,行为与改动前逐字一致。
41
+ const workerUrl = (opts && opts.workerUrl) || WORKER_URL;
42
+ const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : DEFAULT_TIMEOUT_MS;
34
43
  const log = typeof opts.log === 'function' ? opts.log : () => {};
35
44
 
36
45
  /** @type {Worker|null} */
@@ -101,22 +110,28 @@ export function createUnpackWorkerClient(opts) {
101
110
 
102
111
  function ensureWorker() {
103
112
  if (worker) return worker;
104
- const w = new Worker(WORKER_URL, { workerData: { rootDir } });
113
+ const w = new Worker(workerUrl, { workerData: { rootDir } });
105
114
  w.on('message', onMessage);
106
115
  w.on('error', (e) => {
107
116
  // worker 自己抛未捕获异常 ⇒ 该次请求 reject、句柄置空、下次重建
108
117
  failCurrent('unpack worker crashed', (e && e.message) || e);
109
118
  worker = null;
110
119
  });
111
- w.on('exit', (code) => {
120
+ w.on('exit', (code, signal) => {
112
121
  // 只有"它仍是当前句柄"才算意外退出:超时/close 路径会先把 worker 置空再 terminate,
113
122
  // 所以这里判断句柄归属比维护一个全局 terminating 标志更硬 —— 不依赖 exit 与 finally 的先后。
114
123
  const wasCurrent = worker === w;
115
124
  if (wasCurrent) worker = null;
116
125
  if (!wasCurrent) return;
117
- if (code !== 0) {
118
- failCurrent('unpack worker exited unexpectedly', `exit code ${code}`);
119
- log('warn', `解包 worker 异常退出(code ${code}),已丢弃句柄,下次请求将重建`);
126
+ // 为什么 code === 0 也要让在途请求失败:worker 正常走完生命周期就不会再回消息了,
127
+ // 不 reject 的话这个请求要白等到 120s 超时,而且报出来的是"timeout"——
128
+ // 把"已退出"误报成"卡住",排查方向直接跑偏(这两类要分开)。
129
+ // 没有在途请求时 failCurrent 内部会早退,所以这里无条件调用是安全的。
130
+ const hadPending = Boolean(current);
131
+ failCurrent('unpack worker 已退出', `code=${code === null ? 'null' : code}, signal=${signal || 'none'}`);
132
+ // 日志只在"异常退出"或"有请求被牵连"时打,避免正常退出刷日志
133
+ if (code !== 0 || hadPending) {
134
+ log('warn', `解包 worker 已退出(code=${code === null ? 'null' : code}, signal=${signal || 'none'}),已丢弃句柄,下次请求将重建`);
120
135
  }
121
136
  });
122
137
  worker = w;
@@ -149,7 +164,7 @@ export function createUnpackWorkerClient(opts) {
149
164
  Promise.resolve(dead && dead.terminate && dead.terminate()).catch(() => undefined);
150
165
  // 清理该 fp 的残留临时目录(writeResult 的 .tmp-<fp>-* 命名规则)
151
166
  cleanupTmp(fp);
152
- reject(new Error(`unpack worker timeout after ${timeoutMs}ms(本次解包未完成,已终止并重建 worker)`));
167
+ reject(new Error(`unpack worker 无响应:超过 ${timeoutMs}ms 未返回(worker 仍在运行但卡住了,已终止并重建;与「worker 已退出」是两类问题)`));
153
168
  }, timeoutMs);
154
169
  current = { id, resolve, reject, timer, onProgress: typeof opts.onProgress === 'function' ? opts.onProgress : null };
155
170
  try {
@@ -0,0 +1,23 @@
1
+ // shared/scan-limits.mjs —— 扫描判死的阈值(**单一来源**)
2
+ //
3
+ // 为什么放 shared:前端要用它判"多久没进度就算疑似卡住",将来服务端/插件也可能要读同一个数。
4
+ // 两处各写一份数字,调参时必然漂移(一处改了另一处没改,表现为"前端说卡住、服务端还在跑")。
5
+ //
6
+ // 口径(设计 §7.3):
7
+ // · 这是**无进展**阈值,不是总时长阈值:大目录本来就慢,用总时长判死会误杀正常扫描。
8
+ // · 每次收到任意一帧(start / progress / done / cached / error)都会重置计时。
9
+ // · 触发后只改状态与文案,**不自动取消请求** —— 是否重试交给调用方决定。
10
+
11
+ /** 连续无帧多久 ⇒ 判"疑似卡住"(毫秒) */
12
+ export const SCAN_STALL_MS = 10000;
13
+
14
+ /**
15
+ * 解析可覆盖值(来自 `/api/web-config` 的 scanStallMs 或测试注入)。
16
+ * 非法/缺失一律回落默认:判死阈值必须始终有个可用数字,不能因为配置脏就变成 NaN。
17
+ * @param {unknown} raw
18
+ * @returns {number}
19
+ */
20
+ export function resolveScanStallMs(raw) {
21
+ const value = Number(raw);
22
+ return Number.isFinite(value) && value > 0 ? value : SCAN_STALL_MS;
23
+ }
@@ -0,0 +1,31 @@
1
+ // shared/type-labels.mjs —— 内容类型(kind)的显示名映射(**单一来源**)
2
+ //
3
+ // 谁用:前端名册的类型筛选胶囊、以及将来可能的插件/其它视图。
4
+ // 为什么放 shared 而不是各自写一份:显示名是"同一事实两处表达"的高危点——
5
+ // 前端写一套、插件再写一套,很快就会出现"同一个类型两处叫法不同"。
6
+ //
7
+ // 规则(设计口径):
8
+ // · 已知类型 → 可读名(下表)
9
+ // · 未知类型 → **原样返回 key**,不做大小写美化(避免"看起来认识其实不认识")
10
+
11
+ /** kind 为 null/空时的归并键(未解包容器条目走这一档,见设计稿 §5.1) */
12
+ export const UNKNOWN_TYPE_KEY = 'unknown';
13
+
14
+ /** 内置显示名:键是条目上的 kind 值(null 一律先归成 UNKNOWN_TYPE_KEY) */
15
+ export const TYPE_LABELS = Object.freeze({
16
+ spine: 'Spine',
17
+ live2d: 'Live2D',
18
+ [UNKNOWN_TYPE_KEY]: '待解包',
19
+ bundle: '资源包',
20
+ });
21
+
22
+ /**
23
+ * 取显示名:已知返回中文/品牌名,未知**原值返回**。
24
+ * @param {string} typeKey 类型键(如 'spine' / 'unknown' / 将来插件的自定义值)
25
+ * @returns {string}
26
+ */
27
+ export function typeLabel(typeKey) {
28
+ const key = String(typeKey == null ? '' : typeKey);
29
+ if (!key) return String(UNKNOWN_TYPE_KEY);
30
+ return Object.prototype.hasOwnProperty.call(TYPE_LABELS, key) ? TYPE_LABELS[key] : key;
31
+ }
@@ -125,7 +125,7 @@ export function syncTransportOptions() {
125
125
  * 为什么需要:动作是**模型属性**(设计 §1.9),但它的三个副本都是应用层状态,不会自己跟着模型走:
126
126
  * · `cycle.currentId` —— 循环推进用,名字命中新清单就会继续按旧项推进
127
127
  * · `state.transportMotion` —— 底栏下拉的副本,被 `currentAnimationId()` 优先读取
128
- * · 底栏下拉自身的值 —— 新模型若有同名动作(碧蓝航线 live2d 全都叫 idle/home/…),
128
+ * · 底栏下拉自身的值 —— 新模型若有同名动作,会被误当成"上次选的那个",
129
129
  * "保留同名项"的规则会让它原样停在上一个模型选的那条 ⇒ 新模型**真的播它**
130
130
  * 三者不清,就会出现"选了 home、切哪个模型都播 home"。
131
131
  */
@@ -42,16 +42,14 @@ export function describeFailure(body, status) {
42
42
  /**
43
43
  * 统一请求封装:解析 JSON、把失败翻成 {ok:false, reason, message}。
44
44
  * @param {string} url 目标地址
45
- * @param {object} [init] fetch 参数(传 signal 可覆盖兜底超时)
45
+ * @param {object} [init] fetch 参数(可传 signal 主动取消)
46
46
  * @returns {Promise<object>} 成功时为响应体(含 ok:true),失败时 {ok:false, reason, message}
47
47
  */
48
48
  async function request(url, init) {
49
49
  let res;
50
50
  try {
51
- // 兜底超时 8s:本文件四个调用点(列表/改名/删除/重置)都是读改写一份小 JSON,
52
- // 同源同类接口实测 10~58ms(/api/plugins 10ms、/api/config/export 58ms),留 100 倍余量。
53
- // 上传那处 body 最大 4MB(dataURL)⇒ 由调用方传更长的 signal 覆盖(见 uploadBackground)。
54
- res = await fetch(url, { ...(init || {}), signal: (init && init.signal) || AbortSignal.timeout(8000) });
51
+ // 不设时限:调用方要取消就传 signal。
52
+ res = await fetch(url, { ...(init || {}), ...(init && init.signal ? { signal: init.signal } : {}) });
55
53
  } catch (e) {
56
54
  return { ok: false, reason: 'network', message: `网络请求失败:${(e && e.message) || e}` };
57
55
  }
@@ -93,9 +91,7 @@ export function uploadBackground(file) {
93
91
  'x-filename': encodeURIComponent(file.name || ''),
94
92
  },
95
93
  body: file,
96
- // 上传单独覆盖成 15s:body 最大 4MB(MAX_BYTES),服务端要收满 + 落盘(CIFS 上写几 MB 可能数秒),
97
- // 兜底的 8s 对这一步太紧;其余三个调用点都是小 JSON,用兜底值即可。
98
- signal: AbortSignal.timeout(15000),
94
+ // 不设时限。
99
95
  });
100
96
  }
101
97
 
@@ -55,7 +55,7 @@ export function createDirPicker({ onPick, formatError, getLastError } = {}) {
55
55
  <div class="dir-picker-alert" data-role="alert" role="alert" hidden></div>
56
56
  <div class="dir-picker-foot">
57
57
  <span class="muted-hint">显示名称(同名会合并成一个游戏,用于归类原版/Mod)</span>
58
- <input type="text" data-role="name" placeholder="如:棕色尘埃2" aria-label="资源根显示名称" />
58
+ <input type="text" data-role="name" placeholder="显示名称" aria-label="资源根显示名称" />
59
59
  <button type="button" class="mini-btn is-accent" data-role="add">添加</button>
60
60
  </div>
61
61
  </div>`;
@@ -75,10 +75,8 @@ export function createDirPicker({ onPick, formatError, getLastError } = {}) {
75
75
  body.append(text('div', 'dir-picker-hint', '加载中…'));
76
76
  let data = null;
77
77
  try {
78
- // 幂等 GET(连点同一目录会被 api.js 去重)⇒ 走统一层;超时 30s 的依据:
79
- // 本机 CIFS(vers=3.02)实测 75 条目 5.5ms、241 条目 19ms,**未实测到慢例**,
80
- // 按项目既有「catalog 冷启动最坏 31s」(同属目录扫描链路)**类推**取 30s。
81
- data = await getJson(`/api/browse?path=${encodeURIComponent(currentPath)}`, { timeoutMs: 30000 });
78
+ // 幂等 GET(连点同一目录会被 api.js 去重)⇒ 走统一层;统一层不设时限,慢就等。
79
+ data = await getJson(`/api/browse?path=${encodeURIComponent(currentPath)}`);
82
80
  if (!data || data.error) throw new Error((data && data.error) || '服务端返回空目录数据');
83
81
  } catch (err) {
84
82
  body.replaceChildren(text('div', 'dir-picker-error',
@@ -92,7 +92,6 @@ async function fetchServerJudgement(sourceMb) {
92
92
  fetchCount += 1;
93
93
  const res = await fetch(`/api/unpack/capacity${qs}`, {
94
94
  headers: { Accept: 'application/json' },
95
- signal: AbortSignal.timeout(2000),
96
95
  });
97
96
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
98
97
  const j = await res.json();
@@ -161,7 +161,6 @@ export async function refreshMemoryReadout(target) {
161
161
  try {
162
162
  const res = await fetch(`/api/unpack/capacity${qs}`, {
163
163
  headers: { Accept: 'application/json' },
164
- signal: AbortSignal.timeout(2000),
165
164
  });
166
165
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
167
166
  const j = await res.json();
@@ -212,10 +212,10 @@ async function probeSkelVersion(url) {
212
212
  try {
213
213
  const vfs = getSharedVirtualFS();
214
214
  const real = (vfs && typeof vfs.resolve === 'function' ? vfs.resolve(url) : null) || url;
215
- // 归 **B 类(只加超时,不走 api.js)**:这里要的是二进制头,不是 JSON —— getJson 会 res.json() 解坏。
216
- // 注意 `.slice(0,32)` 是在**已拿到的 blob** 上切,不是 HTTP Range ⇒ 实际仍下载整个 .skel,
217
- // 所以超时按"整文件下载"给:20s(CIFS 上几 MB 骨架可能数秒,实测运行时脚本读取同量级)。
218
- const res = await fetch(real, { signal: AbortSignal.timeout(20000) });
215
+ // 不走 api.js:这里要的是二进制头,不是 JSON —— getJson 会 res.json() 解坏。
216
+ // 注意 `.slice(0,32)` 是在**已拿到的 blob** 上切,不是 HTTP Range ⇒ 实际仍下载整个 .skel。
217
+ // 不设时限。
218
+ const res = await fetch(real);
219
219
  if (!res || !res.ok) return null;
220
220
  // 读前 64 字节:3.8 格式是「hash 串 + 版本串」,两段加起来可能 40+ 字节(实测 1+27+1+7=36)
221
221
  const head = new Uint8Array(await (await res.blob()).slice(0, 64).arrayBuffer());
@@ -289,11 +289,11 @@ async function expandContainerEntry(src, name) {
289
289
  let fetchNote = '';
290
290
  try {
291
291
  if (fetchUrl) {
292
- // 归 **B 类(只加超时)**:这里必须保留 `cache: 'no-store'`(entry.json 是解包产物,
292
+ // 归 **B 类**:这里必须保留 `cache: 'no-store'`(entry.json 是解包产物,
293
293
  // 读到浏览器缓存会拿到上一版产物);而 getJson/fetchWithRetry 都不透传 cache 选项,
294
- // 走 api.js 会静默丢掉这个语义 ⇒ 宁可只补超时。
295
- // 8.5s 与 api.js 默认一致(entry.json 是小 JSON,实测同量级接口 10~58ms)。
296
- const res = await fetch(fetchUrl, { cache: 'no-store', signal: AbortSignal.timeout(8500) });
294
+ // 走 api.js 会静默丢掉这个语义。
295
+ // 不设时限。
296
+ const res = await fetch(fetchUrl, { cache: 'no-store' });
297
297
  if (res && res.ok) meta = await res.json();
298
298
  else fetchNote = `HTTP ${res && res.status}`;
299
299
  } else fetchNote = 'resolve 返回空';
@@ -494,8 +494,9 @@ function mountEntry(entry, name) {
494
494
  };
495
495
 
496
496
  // 唯一的装载通路:来源解析在 loadModel 内部完成,这里不再按 kind/source 分支
497
+ // **返回这个 promise**:冷启动要能等"首个模型真的出来"再放行加载队列。
497
498
  const loadPromise = loadModel(entry, name);
498
- loadPromise
499
+ return loadPromise
499
500
  .then((info) => {
500
501
  // null = 本次请求被排队/被更新的选择取代(Surface 的 last-wins 语义):
501
502
  // 不能当成功读结果字段,否则出现"旧面已释放 + 新面没装"的空白 + TypeError
@@ -571,7 +572,7 @@ function onSelectModel(model) {
571
572
 
572
573
  // 换模型 = 换内容:先把「当前动作」的副本清掉(动作是模型属性,见设计 §1.9)。
573
574
  // 必须在这里清而不是装载成功后清:装载失败/被拒时也不该残留上一个模型的动作名,
574
- // 否则新模型的清单里只要恰好有同名项(碧蓝航线 live2d 全都叫 idle/home/…)就会被继续播。
575
+ // 否则新模型的清单里只要恰好有同名项,就会被继续播。
575
576
  resetMotionState();
576
577
  // 无残缺(绝大多数情况):直接走统一装载入口
577
578
  return mountEntry(entry, name);
@@ -602,12 +603,14 @@ async function loadRoots(opts = {}) {
602
603
  onSelect: onSelectModel,
603
604
  });
604
605
  state.roster = roster;
605
- // 关闭自动恢复时,连"上次展开的节点"也不还原:保持空舞台、空展开
606
- await roster.mount(roots, { restoreExpanded: autoRestore });
606
+ // 关闭自动恢复时,连"上次展开的节点"也不还原:保持空舞台、空展开。
607
+ // mount 内部会做恢复并返回结果 —— 恢复只允许有一个所有者,不再在这里二次调用 restoreLast
608
+ // (两个入口会让"先恢复、后排队"的次序随时序漂移而失效)。
607
609
  state.autoRestore = autoRestore;
608
- state.restore = (autoRestore && !opts.skipRestore)
609
- ? await roster.restoreLast()
610
- : { restored: false, reason: opts.skipRestore ? 'roots-changed' : 'config-off' };
610
+ state.restore = await roster.mount(roots, {
611
+ restoreExpanded: autoRestore,
612
+ skipRestore: !(autoRestore && !opts.skipRestore),
613
+ });
611
614
  // 资源根管理(F4):首次建实例并渲染;之后每次刷新都用最新列表重画
612
615
  if (!state.rootManager) {
613
616
  state.rootManager = createRootManager({