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
@@ -0,0 +1,264 @@
1
+ // data/manifest-jsonl.mjs —— 模型清单的 JSONL 存储(P1)
2
+ //
3
+ // 为什么换格式:旧实现把整份清单 `JSON.stringify` 后一次写盘,于是
4
+ // ① 扫描期间**一条都写不出去**(必须等全部扫完)—— 中途被杀就全丢,用户重来一次还是几十秒;
5
+ // ② 大根的清单是单个巨大字符串,解析时要整份 materialize 到内存;
6
+ // ③ 想"边扫边写"根本没法做(JSON 不是流式可写的格式)。
7
+ // JSONL 每行一个独立对象:可以逐条 append、可以逐行解析、坏一行不影响其它行。
8
+ //
9
+ // 三个必须理解的约束(每条都对应一个实测踩过的坑,详见设计 §4.3):
10
+ // ① 句柄声明在**能执行到的外层**:曾经把 fd 声明在 try 内,catch/finally 里访问不到 ⇒ 从未关闭 ⇒
11
+ // 遍历大目录后句柄耗尽,且 rename 时目标仍有未关闭读句柄 ⇒ CIFS 上 EACCES
12
+ // ② rename 前必须已关句柄,且**不用 copyFile 绕开**(覆盖已存在文件在 CIFS 上 EPERM,且丢原子性)
13
+ // ③ 打开一次 + writeSync 多次,不要每条 open/close(431 条模型 = 431 次 open 是不可接受的开销)
14
+ //
15
+ // 行格式(设计 §4.2):
16
+ // 首行 meta {"_":"meta","rootId","ts","generator","dirStamps","complete":false}
17
+ // 数据行 {"t":"m","k":"<模型 id>","v":{…}}
18
+ // 末行 complete {"t":"meta","k":"complete","v":{"count":N,"ts":T}} ← last-wins
19
+ // 同一 key 重复出现 ⇒ 后写覆盖先写(重扫时不必先删旧行)
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+
24
+ /** 同一进程内的 tmp 序号:保证"每次扫描的 tmp 唯一" */
25
+ let tmpSeq = 0;
26
+
27
+ /** 坏行占比超过这个阈值 ⇒ 判整体损坏(设计 §4.4)。千分之一:少量截断尾巴能容忍,成片坏说明文件本身废了。 */
28
+ const BAD_LINE_RATIO_LIMIT = 0.01;
29
+
30
+ /**
31
+ * 打开一个清单写入器。
32
+ *
33
+ * @param {string} base 不带扩展名的路径(如 `…/config/models/spine.<rootId>`)
34
+ * @param {object} meta 首行要写的元信息:{rootId, ts, generator, dirStamps}
35
+ * (**不含 kind**:清单是可分发数据,渲染器类型由读取方按来源判断)
36
+ * @returns {{
37
+ * append: (model: object) => void,
38
+ * finish: (count: number) => {bytes: number, file: string},
39
+ * abort: () => void,
40
+ * bytesWritten: number,
41
+ * tmpFile: string,
42
+ * finalFile: string,
43
+ * }}
44
+ */
45
+ export function openManifestWriter(base, meta) {
46
+ // tmp 文件名**每次扫描唯一**:<base>.<pid>.<seq>.jsonl.tmp
47
+ // 为什么不能固定名:并发场景下(另一个根的 refresh、或任何一次 invalidate)会清理"看起来像残留"的 tmp,
48
+ // 固定名的话它会把**正在写**的那份删掉 ⇒ finish 时 rename ENOENT(实测踩到:
49
+ // `rename 'spine.<rootId>.jsonl.tmp' -> 'spine.<rootId>.jsonl'` ENOENT)。
50
+ // 唯一名让"两个扫描互不干扰",也让"清理残留"能安全地只删老的。
51
+ const tmpFile = `${base}.${process.pid}.${(tmpSeq += 1)}.jsonl.tmp`;
52
+ const finalFile = `${base}.jsonl`;
53
+ fs.mkdirSync(path.dirname(base), { recursive: true });
54
+
55
+ // ① 自己这个唯一名理论上不该已存在(seq 单调递增);存在说明进程内序号被复用(极罕见),覆盖即可。
56
+ try {
57
+ if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile);
58
+ } catch { /* 删不掉也无所谓:下面 'w' 会截断它 */ }
59
+
60
+ // ② 句柄声明在**外层**(见文件头约束①):finish/abort 都要能访问到
61
+ let fd = null;
62
+ let closed = false;
63
+ let bytesWritten = 0;
64
+
65
+ function writeLine(obj) {
66
+ const line = `${JSON.stringify(obj)}\n`;
67
+ fs.writeSync(fd, line);
68
+ bytesWritten += Buffer.byteLength(line);
69
+ }
70
+
71
+ fd = fs.openSync(tmpFile, 'w');
72
+ // 首行 meta:complete:false 起始 —— 读侧看到 false 就知道"这份还没写完"
73
+ // meta 行**不写 kind**:清单是可分发的数据,不应该带"这是哪种渲染器"的标签;
74
+ // 前端需要的类型由 catalog 读取时按来源盖章(值域仍是 'spine' / 'live2d')。
75
+ writeLine({
76
+ _: 'meta',
77
+ rootId: meta.rootId,
78
+ ts: meta.ts,
79
+ generator: meta.generator,
80
+ dirStamps: meta.dirStamps,
81
+ complete: false,
82
+ });
83
+
84
+ function close() {
85
+ if (closed) return;
86
+ closed = true;
87
+ try {
88
+ if (fd !== null) fs.closeSync(fd);
89
+ } catch { /* 已关闭/句柄失效:忽略 */ }
90
+ fd = null;
91
+ }
92
+
93
+ return {
94
+ /**
95
+ * 追加一条模型。**同步写**:单条几十~几百字节,一次 writeSync 是微秒级,
96
+ * 不会像目录遍历那样长时间占住事件循环(进度帧仍能正常发出)。
97
+ * @param {object} model 与旧格式 models[i] **逐字段一致**
98
+ */
99
+ append(model) {
100
+ if (closed) return;
101
+ const key = model && model.id ? String(model.id) : '';
102
+ writeLine({ t: 'm', k: key, v: model });
103
+ },
104
+ /**
105
+ * 收尾:写 complete 行 → 关句柄 → rename 覆盖正式文件。
106
+ * @param {number} count 最终模型数(读侧据此做一致性自检)
107
+ * @returns {{bytes: number, file: string}}
108
+ */
109
+ finish(count) {
110
+ if (closed) return { bytes: bytesWritten, file: finalFile };
111
+ writeLine({ t: 'meta', k: 'complete', v: { count, ts: Date.now() } });
112
+ const bytes = bytesWritten;
113
+ close();
114
+ // ② rename 前已关句柄(见约束②)
115
+ // tmp 不在 ⇒ 明确说是"被并发清理删掉了"而不是抛一个裸 ENOENT 让人猜
116
+ if (!fs.existsSync(tmpFile)) {
117
+ throw new Error(
118
+ `清单 tmp 在收尾前消失:${tmpFile}`
119
+ + `(可能被并发的失效清理删掉了;正式文件 ${path.basename(finalFile)} 未受影响,下次请求会重建)`,
120
+ );
121
+ }
122
+ fs.renameSync(tmpFile, finalFile);
123
+ return { bytes, file: finalFile };
124
+ },
125
+ /** 失败:只关句柄,**不动正式文件**(它保持上一次的完整版本);自己的 tmp 留着供排查 */
126
+ abort() {
127
+ close();
128
+ },
129
+ get bytesWritten() { return bytesWritten; },
130
+ tmpFile,
131
+ finalFile,
132
+ };
133
+ }
134
+
135
+ /** 当前优先的存储格式:默认 json(原行为);env MODELSTAGE_MANIFEST_FORMAT=jsonl 才用 JSONL */
136
+ export function preferredManifestFormat() {
137
+ return String(process.env.MODELSTAGE_MANIFEST_FORMAT || 'json').toLowerCase() === 'jsonl' ? 'jsonl' : 'json';
138
+ }
139
+
140
+ /** JSONL 单文件解析(文件必须存在) */
141
+ function readJsonlFile(jsonlFile) {
142
+ let text = '';
143
+ let bytes = 0;
144
+ try {
145
+ text = fs.readFileSync(jsonlFile, 'utf8');
146
+ bytes = Buffer.byteLength(text);
147
+ } catch (e) {
148
+ return { format: 'jsonl', meta: null, models: [], complete: false, badLines: 0, bytes: 0, error: e.message };
149
+ }
150
+ const lines = text.split('\n');
151
+ let metaRow = null;
152
+ /** @type {Map<string, object>} key → model(last-wins 靠 Map.set 天然实现) */
153
+ const byKey = new Map();
154
+ let badLines = 0;
155
+ let totalLines = 0;
156
+ let completeRow = null;
157
+
158
+ for (const raw of lines) {
159
+ const line = raw.trim();
160
+ if (!line) continue;
161
+ totalLines += 1;
162
+ let obj;
163
+ try {
164
+ obj = JSON.parse(line);
165
+ } catch {
166
+ badLines += 1; // 坏行跳过(进程被杀时常留下半行)—— 不影响其它行
167
+ continue;
168
+ }
169
+ if (obj && obj._ === 'meta') { metaRow = obj; continue; }
170
+ if (obj && obj.t === 'meta' && obj.k === 'complete') { completeRow = obj; continue; }
171
+ if (obj && obj.t === 'm') {
172
+ // last-wins:同 key 后写覆盖先写(重扫追加时不需先删旧行)
173
+ byKey.set(String(obj.k), obj.v);
174
+ continue;
175
+ }
176
+ badLines += 1; // 结构不认识的行也计坏行
177
+ }
178
+
179
+ // 坏行过多 ⇒ 判损坏(设计 §4.4)。少量坏行(截断尾巴)可容忍。
180
+ const tooManyBad = totalLines > 0 && badLines / totalLines > BAD_LINE_RATIO_LIMIT;
181
+ // 认头条件(**不再有版本字段**):`_ === 'meta'` 且带 rootId / generator / dirStamps。
182
+ // 缺任一 ⇒ 判不完整、走重建(宁可重扫,不拿来历不明的结构当结果)。
183
+ const meta = (metaRow && metaRow._ === 'meta' && metaRow.rootId
184
+ && metaRow.generator !== undefined && metaRow.dirStamps !== undefined)
185
+ ? {
186
+ rootId: metaRow.rootId,
187
+ ts: metaRow.ts,
188
+ generator: metaRow.generator,
189
+ dirStamps: metaRow.dirStamps,
190
+ complete: true, // 头部自洽;整体完整性由下面的三条件判定
191
+ }
192
+ : null;
193
+
194
+ // complete 判定 = **三条件**:meta 头存在 + complete 末行存在 + 末行 count === 实际条数
195
+ const countMatches = completeRow && completeRow.v && Number(completeRow.v.count) === byKey.size;
196
+ const complete = Boolean(meta && countMatches && !tooManyBad);
197
+
198
+ return {
199
+ format: 'jsonl',
200
+ meta,
201
+ models: [...byKey.values()],
202
+ complete,
203
+ badLines,
204
+ bytes,
205
+ ...(tooManyBad ? { error: `坏行过多(${badLines}/${totalLines}),判为损坏` } : {}),
206
+ };
207
+ }
208
+
209
+ /** 单个历史 JSON 清单解析(文件必须存在):**视为 complete=true**(历史文件都是完整写出的) */
210
+ function readJsonFile(jsonFile) {
211
+ try {
212
+ const text = fs.readFileSync(jsonFile, 'utf8');
213
+ const payload = JSON.parse(text);
214
+ return {
215
+ format: 'json',
216
+ meta: {
217
+ rootId: payload.rootId || null,
218
+ ts: payload.ts || null,
219
+ generator: payload.generator || null,
220
+ dirStamps: payload.dirStamps || null,
221
+ complete: true,
222
+ },
223
+ models: Array.isArray(payload.models) ? payload.models : [],
224
+ complete: true,
225
+ badLines: 0,
226
+ bytes: Buffer.byteLength(text),
227
+ };
228
+ } catch (e) {
229
+ return { format: 'json', meta: null, models: [], complete: false, badLines: 0, bytes: 0, error: e.message };
230
+ }
231
+ }
232
+
233
+ /**
234
+ * 读清单:先按**当前优先格式**读,缺失再回落另一种。
235
+ *
236
+ * 默认优先 `.json`(保持原有行为);`MODELSTAGE_MANIFEST_FORMAT=jsonl` 时优先 `.jsonl`。
237
+ * 两种格式都能读,切换格式不需要迁移历史文件。
238
+ *
239
+ * @param {string} base 不带扩展名的路径
240
+ * @returns {{
241
+ * format: 'jsonl'|'json'|'none',
242
+ * meta: object|null,
243
+ * models: object[],
244
+ * complete: boolean,
245
+ * badLines: number,
246
+ * bytes: number,
247
+ * error?: string,
248
+ * }}
249
+ */
250
+ export function readManifest(base) {
251
+ const jsonlFile = `${base}.jsonl`;
252
+ const jsonFile = `${base}.json`;
253
+ const order = preferredManifestFormat() === 'jsonl' ? ['jsonl', 'json'] : ['json', 'jsonl'];
254
+ for (const fmt of order) {
255
+ if (fmt === 'jsonl') {
256
+ if (fs.existsSync(jsonlFile)) return readJsonlFile(jsonlFile);
257
+ continue;
258
+ }
259
+ if (fs.existsSync(jsonFile)) return readJsonFile(jsonFile);
260
+ }
261
+ return { format: 'none', meta: null, models: [], complete: false, badLines: 0, bytes: 0 };
262
+ }
263
+
264
+ export { BAD_LINE_RATIO_LIMIT };
@@ -99,7 +99,57 @@ const GENERATOR_FILES = [
99
99
  let generatorCache = { sig: null, value: '0' };
100
100
 
101
101
  /**
102
- * 生成者指纹:解析器文件内容哈希的前 8 位。
102
+ * 去掉注释与多余空白后再参与哈希。
103
+ *
104
+ * 为什么不能直接哈希整份文件:指纹一变就**全部清单作废、所有根重扫**(每个根几十秒)。
105
+ * 而注释、缩进、换行都属于"改字不改行为"的编辑,不该有这种代价。
106
+ * 所以这里按语法位置剥掉 `//` 与 `/* *​/`(跳过字符串/模板字面量里的同形字符),再压掉空白,
107
+ * 只让**真正会改变解析结果的改动**影响指纹。
108
+ * @param {string} text
109
+ * @returns {string}
110
+ */
111
+ function stripCommentsAndSpace(text) {
112
+ let out = '';
113
+ let i = 0;
114
+ const n = text.length;
115
+ while (i < n) {
116
+ const ch = text[i];
117
+ const next = text[i + 1];
118
+ // 行注释
119
+ if (ch === '/' && next === '/') {
120
+ while (i < n && text[i] !== '\n') i += 1;
121
+ continue;
122
+ }
123
+ // 块注释
124
+ if (ch === '/' && next === '*') {
125
+ i += 2;
126
+ while (i < n && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
127
+ i += 2;
128
+ continue;
129
+ }
130
+ // 字符串 / 模板字面量:整段原样保留(里面的 // 不是注释)
131
+ if (ch === '"' || ch === "'" || ch === '`') {
132
+ const quote = ch;
133
+ out += ch;
134
+ i += 1;
135
+ while (i < n) {
136
+ const c = text[i];
137
+ out += c;
138
+ if (c === '\\') { out += text[i + 1] || ''; i += 2; continue; }
139
+ i += 1;
140
+ if (c === quote) break;
141
+ }
142
+ continue;
143
+ }
144
+ out += ch;
145
+ i += 1;
146
+ }
147
+ // 压空白:换行也统一成单空格,避免"重排代码"改指纹
148
+ return out.replace(/\s+/g, ' ').trim();
149
+ }
150
+
151
+ /**
152
+ * 生成者指纹:解析器文件**去掉注释与空白后**内容哈希的前 8 位。
103
153
  * @param {string} [root] 项目根(缺省按本文件位置推,不写死绝对路径)
104
154
  * @returns {string} 8 位十六进制(一个文件都读不到时为 '0')
105
155
  */
@@ -125,7 +175,7 @@ export function generatorFingerprint(root = PROJECT_ROOT) {
125
175
  if (!ok) continue;
126
176
  try {
127
177
  h.update(rel);
128
- h.update(fs.readFileSync(path.join(root, rel)));
178
+ h.update(stripCommentsAndSpace(fs.readFileSync(path.join(root, rel), 'utf8')));
129
179
  used += 1;
130
180
  } catch { /* 单个文件读不了不影响其它文件 */ }
131
181
  }
@@ -0,0 +1,305 @@
1
+ // data/scan-progress.mjs —— 扫描进度的单飞表 + NDJSON 帧写出(P0)
2
+ //
3
+ // 解决两个独立问题,放在一起是因为它们共享同一份"扫描生命周期":
4
+ // ① **单飞**:同一个资源的并发请求不该触发多次扫描(用户狂点刷新 / 两个标签页同时开)
5
+ // —— 设计 §4.6。这张表 P1 的 JSONL 存储会原样复用。
6
+ // ② **进度可见**:一次扫描在 CIFS 上可能要几十秒,没有进度用户只能猜"是不是卡死了"
7
+ // —— 设计 §5/§6。P0 只发**进度**,模型数据仍走原 JSON 接口(不在这里塞数据行)。
8
+ //
9
+ // 为什么不复用项目的 SSE(/api/ready/stream):
10
+ // 扫描是**一次性、单向、有明确终点**的。SSE 自带重连语义,前端一重连就会重复触发扫描
11
+ // (即便有单飞也会多一次订阅与快照),反而制造问题。NDJSON 读 fetch().body.getReader() 即可,
12
+ // 断线就是断线,不会自己重来。
13
+
14
+ import { EventEmitter } from 'node:events';
15
+
16
+ /** 进度节流的三个触发条件(设计 §5.1:三者先到先发) */
17
+ const PROGRESS_EVERY_MODELS = 50;
18
+ const PROGRESS_HEARTBEAT_MS = 2000;
19
+ /** 扫描结束后 emitter 的保留期:让"迟到的订阅者"还能拿到 done 帧(设计 §4.6) */
20
+ const EMITTER_RETAIN_MS = 5000;
21
+
22
+ /**
23
+ * 造一个进度节流器。walk 里调用 `bump(...)`,它自己决定这一条要不要真的发出去。
24
+ *
25
+ * 为什么要节流:一个大根有几万个目录,每条都发帧会把连接打满、前端也跟不上;
26
+ * 但**不能只按条数节流** —— 遇到一个"空目录树"可能几十秒不新增模型,那样前端会以为卡死。
27
+ * 所以心跳是独立的一条:即使 scanned 没变,每 2s 也要发一帧证明"还活着"。
28
+ *
29
+ * @param {object} opts
30
+ * @param {(snap: object) => void} opts.onProgress 真正发帧的回调
31
+ * @param {string} opts.kind 'spine' | 'live2d'
32
+ * @param {string} opts.rootId 资源根 id
33
+ * @param {() => number} [opts.now] 取时间(测试可注入)
34
+ * @returns {{bump: (p: {scanned: number, dirs: number, currentPath: string}) => void, flush: () => void, snapshot: () => object}}
35
+ */
36
+ export function createProgressThrottle({ onProgress, kind, rootId, now = Date.now }) {
37
+ const startedAt = now();
38
+ let lastSentAt = 0;
39
+ let lastScannedSent = 0;
40
+ let latest = { scanned: 0, dirs: 0, currentPath: '' };
41
+
42
+ function buildSnapshot() {
43
+ return {
44
+ kind,
45
+ rootId,
46
+ scanned: latest.scanned,
47
+ dirs: latest.dirs,
48
+ currentPath: latest.currentPath,
49
+ elapsedMs: now() - startedAt,
50
+ phase: 'scan',
51
+ };
52
+ }
53
+
54
+ function send() {
55
+ lastSentAt = now();
56
+ lastScannedSent = latest.scanned;
57
+ onProgress(buildSnapshot());
58
+ }
59
+
60
+ /**
61
+ * walk 里每完成一个目录 / 每累计若干模型调用一次。
62
+ * @param {{scanned: number, dirs: number, currentPath: string, dirCompleted?: boolean}} p
63
+ */
64
+ function bump(p) {
65
+ latest = { scanned: p.scanned, dirs: p.dirs, currentPath: p.currentPath || latest.currentPath };
66
+ // 三者先到先发:目录完成 / 每 50 条 / 心跳到期
67
+ const byDir = p.dirCompleted === true;
68
+ const byCount = latest.scanned - lastScannedSent >= PROGRESS_EVERY_MODELS;
69
+ const byHeartbeat = now() - lastSentAt >= PROGRESS_HEARTBEAT_MS;
70
+ if (byDir || byCount || byHeartbeat) send();
71
+ }
72
+
73
+ /** 扫描结束前补发最后一帧(保证前端拿到最终计数,而不是停在某个中间值) */
74
+ function flush() {
75
+ if (latest.scanned !== lastScannedSent) send();
76
+ }
77
+
78
+ return { bump, flush, snapshot: buildSnapshot };
79
+ }
80
+
81
+ /**
82
+ * 扫描单飞表。
83
+ *
84
+ * 语义(设计 §4.6):
85
+ * · 第一个请求创建任务并广播事件
86
+ * · 后续请求**不新建任务**,挂到同一 emitter,并立刻收到一条 progress 快照
87
+ * · force=1 时若已有扫描在跑 ⇒ 复用(用户在 UI 上重复点重试不该变成双重扫描)
88
+ * · 任务结束 5s 后从表里摘掉(保留期给迟到订阅者)
89
+ */
90
+ export function createScanRunner() {
91
+ /** @type {Map<string, {emitter: EventEmitter, progress: object|null, done: boolean, startedAt: number}>} */
92
+ const inflight = new Map();
93
+
94
+ function keyOf(kind, rootId) {
95
+ return `${kind}:${rootId}`;
96
+ }
97
+
98
+ /**
99
+ * 加入或启动一次扫描。
100
+ * @param {object} p
101
+ * @param {string} p.kind
102
+ * @param {string} p.rootId
103
+ * @param {(onProgress: (snap: object) => void, onStart: () => void) => Promise<object>} p.run
104
+ * 真正的扫描函数;它拿到的 onProgress 会转发到 emitter
105
+ * @returns {{emitter: EventEmitter, progress: object|null, joined: boolean}}
106
+ */
107
+ function startOrJoin({ kind, rootId, run }) {
108
+ const key = keyOf(kind, rootId);
109
+ const existing = inflight.get(key);
110
+ if (existing && !existing.done) {
111
+ // 加入已有扫描:立刻给一条当前快照,前端不必等下一次节流触发
112
+ return { emitter: existing.emitter, progress: existing.progress, joined: true };
113
+ }
114
+
115
+ const emitter = new EventEmitter();
116
+ // 订阅者可能随时来去;默认上限 10 会在大并发时告警,调高避免噪音(真正泄漏由结尾 destroy 兜住)
117
+ emitter.setMaxListeners(64);
118
+ const entry = { emitter, progress: null, done: false, startedAt: Date.now() };
119
+ inflight.set(key, entry);
120
+
121
+ Promise.resolve()
122
+ .then(() => run(
123
+ (snap) => {
124
+ entry.progress = snap;
125
+ emitter.emit('progress', snap);
126
+ },
127
+ // onStart:让扫描函数在"确认要扫、但还没开始走目录"时就能把首帧推出去
128
+ () => emitter.emit('start'),
129
+ // onModel:每产出一条模型就广播(真正的"首模型早出现"靠这一条)
130
+ (model) => emitter.emit('model', model),
131
+ ))
132
+ .then((result) => {
133
+ entry.done = true;
134
+ emitter.emit('done', result);
135
+ })
136
+ .catch((error) => {
137
+ entry.done = true;
138
+ emitter.emit('error', error);
139
+ })
140
+ .finally(() => {
141
+ // 保留期:让"扫描刚结束才连上来"的订阅者也能拿到 done(否则它会永远等不到结束帧)
142
+ setTimeout(() => {
143
+ if (inflight.get(key) === entry) inflight.delete(key);
144
+ emitter.removeAllListeners();
145
+ }, EMITTER_RETAIN_MS).unref?.();
146
+ });
147
+
148
+ return { emitter, progress: null, joined: false };
149
+ }
150
+
151
+ /** 仅供测试/诊断:当前在跑的扫描 key 列表 */
152
+ function activeKeys() {
153
+ return [...inflight.keys()].filter((k) => !inflight.get(k).done);
154
+ }
155
+
156
+ return { startOrJoin, activeKeys, keyOf };
157
+ }
158
+
159
+ /**
160
+ * 把一次扫描的生命周期写成 NDJSON 帧(设计 §6.2/6.3/6.4)。
161
+ *
162
+ * 三件事按这个顺序做,且都**不能抛错把连接搞崩**:
163
+ * · 写头(Content-Type: application/x-ndjson)
164
+ * · 先发 start(或 cached),再转发 progress,最后 done/error
165
+ * · 客户端断开 ⇒ 停止写帧,但**不取消扫描**(扫描继续跑完并落盘,下次请求就能命中缓存)
166
+ *
167
+ * @param {object} p
168
+ * @param {import('node:http').ServerResponse} p.res
169
+ * @param {object} p.startFrame 首帧(start 或 cached)
170
+ * @param {EventEmitter} p.emitter 扫描事件的 emitter
171
+ * @param {() => object} [p.buildDoneFrame] 把 run 的结果转成 done 帧
172
+ * @returns {Promise<void>} 连接结束(正常写完或客户端断开)时 resolve
173
+ */
174
+ export function streamScanFrames({ res, buildStartFrame, emitter, buildDoneFrame, buildCachedFrame }) {
175
+ res.writeHead(200, {
176
+ 'Content-Type': 'application/x-ndjson',
177
+ // 进度流不能被任何中间层缓存,否则第二个用户会看到第一个人的旧进度
178
+ 'Cache-Control': 'no-store',
179
+ // nginx 一类反代默认会缓冲响应体,缓冲后前端就看不到"逐帧到达"了 —— 显式关掉
180
+ 'X-Accel-Buffering': 'no',
181
+ });
182
+
183
+ let clientGone = false;
184
+ let settled = false;
185
+
186
+ function writeFrame(frame) {
187
+ if (clientGone) return;
188
+ try {
189
+ res.write(`${JSON.stringify(frame)}\n`);
190
+ } catch {
191
+ // 写到已断开的 socket 会抛(EPIPE)——按"客户端走了"处理,静默停写
192
+ clientGone = true;
193
+ }
194
+ }
195
+
196
+ function finish(finalFrame) {
197
+ if (settled) return;
198
+ flushModels();
199
+ settled = true;
200
+ if (finalFrame) writeFrame(finalFrame);
201
+ if (!clientGone) {
202
+ try { res.end(); } catch { /* 已断开:忽略 */ }
203
+ }
204
+ }
205
+
206
+ // 客户端断开:只标记并清理监听,**不动扫描**
207
+ res.on('close', () => {
208
+ clientGone = true;
209
+ if (modelFlushTimer) { clearTimeout(modelFlushTimer); modelFlushTimer = null; }
210
+ modelBuffer = []; // 断开后不再攒(扫描继续,但没人收)
211
+ emitter.removeListener('start', onStart);
212
+ emitter.removeListener('progress', onProgress);
213
+ emitter.removeListener('model', onModel);
214
+ emitter.removeListener('done', onDone);
215
+ emitter.removeListener('error', onError);
216
+ // 扫描本身由单飞表驱动,与这条连接无关 ⇒ 这里什么都不做就是"扫描继续"
217
+ });
218
+
219
+ // start 帧**不在开头预发**:因为"缓存命中"时根本没有扫描发生,先发 start 再发 cached 会自相矛盾
220
+ // (设计 §5.3 要求"start 必有 progress 跟随")。改为在**第一条 progress 到来时**补发 ——
221
+ // 那时扫描一定真的在跑。
222
+ let startSent = false;
223
+
224
+ // ── model 帧的批量 flush ──
225
+ // 为什么不每条都 res.write:一帧 ~200~500 字节,431 条就是 431 次 write;
226
+ // 而 res.write 每次都有 JS↔C++ 往返与 socket 缓冲开销。攒一小批再写,次数降到 1/20。
227
+ // 为什么还要 200ms 上限:只有"攒够 20 条"的话,扫到一个小目录(3 条)后如果 5 秒没新模型,
228
+ // 首模型就要再等 5 秒 —— 计时器保证"最多晚 200ms 就发出去"。
229
+ const MODEL_FLUSH_COUNT = 20;
230
+ const MODEL_FLUSH_MS = 200;
231
+ let modelBuffer = [];
232
+ let modelFlushTimer = null;
233
+ let modelFramesSent = 0;
234
+
235
+ function flushModels() {
236
+ if (modelFlushTimer) { clearTimeout(modelFlushTimer); modelFlushTimer = null; }
237
+ if (!modelBuffer.length) return;
238
+ const batch = modelBuffer;
239
+ modelBuffer = [];
240
+ for (const model of batch) {
241
+ writeFrame({ type: 'model', model });
242
+ modelFramesSent += 1;
243
+ }
244
+ }
245
+
246
+ function onModel(model) {
247
+ if (clientGone) return;
248
+ modelBuffer.push(model);
249
+ if (modelBuffer.length >= MODEL_FLUSH_COUNT) {
250
+ flushModels();
251
+ return;
252
+ }
253
+ if (!modelFlushTimer) modelFlushTimer = setTimeout(flushModels, MODEL_FLUSH_MS);
254
+ }
255
+
256
+ // start 的**首选**触发点:调用方在"确认缓存未命中、马 上要开始扫描"时主动 emit('start')。
257
+ // 为什么不能只靠"第一条 progress 才发":扫描前还有一步 `indexRootFiles`(建全根索引)是同步的,
258
+ // 建全根索引这一步在实测中占了数秒 —— 那样首帧仍然要等数秒,达不到"<2s 可见"的目标。
259
+ function onStart() {
260
+ if (startSent) return;
261
+ startSent = true;
262
+ if (buildStartFrame) writeFrame(buildStartFrame());
263
+ }
264
+ function onProgress(snap) {
265
+ if (!startSent) {
266
+ startSent = true;
267
+ if (buildStartFrame) writeFrame(buildStartFrame());
268
+ }
269
+ writeFrame({ type: 'progress', ...snap });
270
+ }
271
+ function onDone(result) {
272
+ flushModels(); // 收尾前把缓冲里的模型全部发出(否则最后 <20 条会丢)
273
+ // 缓存命中:一条 cached 帧后结束(不逐条发,也不发 start/progress)
274
+ if (result && result.cached) {
275
+ finish(buildCachedFrame ? buildCachedFrame(result) : { type: 'cached', count: result.models ? result.models.length : 0, ts: result.ts });
276
+ return;
277
+ }
278
+ if (!startSent) {
279
+ startSent = true;
280
+ if (buildStartFrame) writeFrame(buildStartFrame());
281
+ }
282
+ finish(buildDoneFrame ? buildDoneFrame(result) : { type: 'done', count: result && result.models ? result.models.length : 0, cached: false });
283
+ }
284
+ function onError(error) {
285
+ flushModels();
286
+ finish({
287
+ type: 'error',
288
+ message: (error && error.message) || String(error),
289
+ // 出错时把"扫到哪了"一并带上,便于用户/我们自己判断是卡在某个路径
290
+ scanned: null,
291
+ currentPath: null,
292
+ });
293
+ }
294
+
295
+ emitter.on('start', onStart);
296
+ emitter.on('progress', onProgress);
297
+ emitter.on('model', onModel);
298
+ emitter.on('done', onDone);
299
+ emitter.on('error', onError);
300
+
301
+ return new Promise((resolve) => {
302
+ res.on('close', resolve);
303
+ res.on('finish', resolve);
304
+ });
305
+ }