modelstage 0.5.28 → 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 (78) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +2 -63
  3. package/config/models/live2d.assetbundls.json +1 -1
  4. package/config/models/live2d.azurlane.json +1 -1
  5. package/config/models/live2d.azurlane_bundle.json +1 -1
  6. package/config/models/live2d.azurlane_bundle.jsonl +2 -0
  7. package/config/models/live2d.azurlane_spine.json +1 -1
  8. package/config/models/live2d.bd2.json +1 -1
  9. package/config/models/live2d.bd2.jsonl +2 -0
  10. package/config/models/live2d.bluearchive.json +1 -1
  11. package/config/models/live2d.nikke.json +1 -1
  12. package/config/models/live2d.nikke.jsonl +2 -0
  13. package/config/models/live2d.nikke_2.json +1 -1
  14. package/config/models/live2d.npc.json +1 -1
  15. package/config/models/spine.assetbundls.json +1 -1
  16. package/config/models/spine.azurlane.json +1 -1
  17. package/config/models/spine.azurlane_bundle.json +1 -1
  18. package/config/models/spine.azurlane_bundle.jsonl +2 -0
  19. package/config/models/spine.azurlane_spine.json +1 -1
  20. package/config/models/spine.bd2.json +1 -1
  21. package/config/models/spine.bd2.jsonl +433 -0
  22. package/config/models/spine.bluearchive.json +1 -1
  23. package/config/models/spine.nikke.json +1 -1
  24. package/config/models/spine.nikke_2.json +1 -1
  25. package/config/models/spine.npc.json +1 -1
  26. package/package.json +2 -1
  27. package/src/server/assets/probe.mjs +1 -1
  28. package/src/server/core/config.mjs +2 -2
  29. package/src/server/core/log.mjs +1 -1
  30. package/src/server/data/catalog.mjs +11 -14
  31. package/src/server/data/live2d.mjs +139 -40
  32. package/src/server/data/manifest-cache.mjs +53 -10
  33. package/src/server/data/manifest-jsonl.mjs +264 -0
  34. package/src/server/data/manifest-stamp.mjs +52 -2
  35. package/src/server/data/scan-progress.mjs +305 -0
  36. package/src/server/data/scan-queue.mjs +99 -0
  37. package/src/server/data/spine.mjs +324 -106
  38. package/src/server/plugins/registry.mjs +1 -1
  39. package/src/server/routes/data.mjs +76 -17
  40. package/src/server/serve.mjs +9 -4
  41. package/src/server/unpack/plugin-store.mjs +1 -1
  42. package/src/server/unpack/runtime/exec.mjs +37 -8
  43. package/src/server/unpack/worker-client.mjs +23 -8
  44. package/src/shared/scan-limits.mjs +23 -0
  45. package/src/web/app/anim-list.js +1 -1
  46. package/src/web/app/backgrounds.js +4 -8
  47. package/src/web/app/dir-picker.js +3 -5
  48. package/src/web/app/memory-budget.js +0 -1
  49. package/src/web/app/memory-guard.js +0 -1
  50. package/src/web/app/model-actions.js +18 -15
  51. package/src/web/app/root-manager.js +2 -4
  52. package/src/web/app/transport-actions.js +1 -1
  53. package/src/web/app/ui-state.js +1 -1
  54. package/src/web/components.css +90 -0
  55. package/src/web/debug/race-probe.js +36 -13
  56. package/src/web/engine/textures.js +1 -2
  57. package/src/web/infra/api.js +10 -19
  58. package/src/web/infra/document.js +3 -3
  59. package/src/web/infra/plugin-host-unpack.mjs +81 -9
  60. package/src/web/infra/ready.js +177 -39
  61. package/src/web/live2d/bridge.js +1 -1
  62. package/src/web/live2d/runtime-adapter.js +2 -4
  63. package/src/web/panel/panel-settings.js +88 -3
  64. package/src/web/roster/events.js +159 -0
  65. package/src/web/roster/filter.js +4 -4
  66. package/src/web/roster/list.js +3 -3
  67. package/src/web/roster/load.js +440 -0
  68. package/src/web/roster/persist.js +46 -37
  69. package/src/web/roster/render.js +284 -0
  70. package/src/web/roster/reveal.js +4 -9
  71. package/src/web/roster/rows.js +35 -13
  72. package/src/web/roster/scan-stream.js +346 -0
  73. package/src/web/roster/snapshot.js +14 -8
  74. package/src/web/roster/store.js +292 -0
  75. package/src/web/roster/type-filter.js +5 -5
  76. package/src/web/roster/view.js +147 -634
  77. package/src/web/spine/canvas-runtime.js +4 -5
  78. package/src/web/spine/runtimes.js +1 -1
@@ -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';
@@ -281,7 +283,7 @@ function handleHealth(req, res, p) {
281
283
  return true;
282
284
  }
283
285
 
284
- function dispatch(req, res, p, url) {
286
+ async function dispatch(req, res, p, url) {
285
287
  // 探活放最前:它不该被其它路由的鉴权/副作用影响(也就不会因为那些逻辑出错而"连探活都挂")
286
288
  if (handleHealth(req, res, p)) return true;
287
289
  if (unpackRoutes(req, res, p, url)) return true;
@@ -294,7 +296,8 @@ function dispatch(req, res, p, url) {
294
296
  if (handleBackgrounds(req, res, p, ctx)) return true;
295
297
  // 条目状态(写读一体):必须在 handleData 之前认领,否则 /api/ 前缀兜底会当未知接口 404
296
298
  if (handleEntries(req, res, p, url, ctx)) return true;
297
- 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;
298
301
  if (staticRoutes.serveAssets(req, res, p)) return true;
299
302
  if (res.headersSent) return true;
300
303
  if (p.startsWith('/api/')) {
@@ -306,25 +309,27 @@ function dispatch(req, res, p, url) {
306
309
  return true;
307
310
  }
308
311
 
309
- const server = http.createServer((req, res) => {
312
+ const server = http.createServer(async (req, res) => {
310
313
  const url = new URL(req.url, `http://localhost:${PORT}`);
311
314
  // 长连接(SSE)不计入活跃度:它一挂几分钟,计入就等于"服务永远忙"
312
315
  const longLived = url.pathname.includes('/stream');
313
316
  if (!longLived) {
314
317
  activity.inflight += 1;
318
+ noteRequestStart(); // 同一个事实同步给扫描调度(它据此让路)
315
319
  activity.lastRequestAt = Date.now();
316
320
  let counted = true;
317
321
  const done = () => {
318
322
  if (!counted) return; // close 与 finish 都会来,只减一次
319
323
  counted = false;
320
324
  activity.inflight -= 1;
325
+ noteRequestEnd(); // 必须与 noteRequestStart 成对(放在同一个 done 回调里)
321
326
  activity.lastRequestAt = Date.now();
322
327
  };
323
328
  res.on('finish', done);
324
329
  res.on('close', done);
325
330
  }
326
331
  try {
327
- dispatch(req, res, url.pathname, url);
332
+ await dispatch(req, res, url.pathname, url);
328
333
  } catch (e) {
329
334
  log.error('请求处理异常', { err: (e && e.stack) ? e.stack : String(e) });
330
335
  try {
@@ -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
+ }
@@ -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({
@@ -50,10 +50,8 @@ export function createRootManager({ els, onChanged, formatError } = {}) {
50
50
  method: 'POST',
51
51
  headers: { 'content-type': 'application/json' },
52
52
  body: JSON.stringify(body),
53
- // 写操作:**不加去重、不自动重试**(重复写会建两个根),只补超时信号防"永久转圈"。
54
- // 8s 依据:服务端只改一份 resources.json + 落盘备份(backupConfigFile),正常 <1s,
55
- // 留 8 倍余量覆盖 CIFS 落盘抖动。不用 api.js:它的默认重试会重发写请求。
56
- signal: AbortSignal.timeout(8000),
53
+ // 写操作:**不加去重、不自动重试**(重复写会建两个根)。
54
+ // 不设时限。
57
55
  });
58
56
  let data = null;
59
57
  try { data = await res.json(); } catch { data = null; }
@@ -129,7 +129,7 @@ async function stepVariant(dir, peersArg) {
129
129
  * 翻模型(**按左栏树从上到下的顺序**,末尾回绕)。
130
130
  *
131
131
  * 池子必须是树,不能是某个接口的清单:
132
- * 树的「游戏」节点是**同名根合并**出来的(例如「碧蓝航线」= azurlane + azurlane_spine + azurlane_bundle),
132
+ * 树的「游戏」节点是**同名根合并**出来的,
133
133
  * 按单个 rootId 取清单会漏掉同名的其它根 ⇒ 表现为"只能在 live2d 之间翻、翻不到 spine"(实测过的现场)。
134
134
  * @param {number} dir -1 上一个 / +1 下一个
135
135
  * @param {object[]} [flatArg] 已取到的树序序列
@@ -283,7 +283,7 @@ export function setDebugCollapsed(collapsed) {
283
283
  try { saveViewSettings({ debug: next }); return true; } catch { return false; }
284
284
  }
285
285
 
286
- // ---------------------------------------------------------------- 名册键代理(结构不动)
286
+ // ---------------------------------------------------------------- 名册键代理(键名不动)
287
287
  /**
288
288
  * 右栏背景「二级列表」当前展开的组('solid' | 'custom' | '')。
289
289
  *