modelstage 0.0.0-stage → 0.5.28

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 (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -2
  3. package/THIRD_PARTY_NOTICES.md +90 -0
  4. package/bin/modelstage.mjs +137 -0
  5. package/config/diag-upload.example.json +7 -0
  6. package/config/models/live2d.assetbundls.json +1 -0
  7. package/config/models/live2d.azurlane.json +1 -0
  8. package/config/models/live2d.azurlane_bundle.json +1 -0
  9. package/config/models/live2d.azurlane_spine.json +1 -0
  10. package/config/models/live2d.bd2.json +1 -0
  11. package/config/models/live2d.bluearchive.json +1 -0
  12. package/config/models/live2d.live2d.json +1 -0
  13. package/config/models/live2d.nikke.json +1 -0
  14. package/config/models/live2d.nikke_2.json +1 -0
  15. package/config/models/live2d.npc.json +1 -0
  16. package/config/models/spine.assetbundls.json +1 -0
  17. package/config/models/spine.azurlane.json +1 -0
  18. package/config/models/spine.azurlane_bundle.json +1 -0
  19. package/config/models/spine.azurlane_spine.json +1 -0
  20. package/config/models/spine.bd2.json +1 -0
  21. package/config/models/spine.bluearchive.json +1 -0
  22. package/config/models/spine.live2d.json +1 -0
  23. package/config/models/spine.nikke.json +1 -0
  24. package/config/models/spine.nikke_2.json +1 -0
  25. package/config/models/spine.npc.json +1 -0
  26. package/config/names/NIKKE.json +1058 -0
  27. package/config/names/README.md +47 -0
  28. package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
  29. package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
  30. package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
  31. package/config/plugins.json +13 -0
  32. package/config/resources.example.json +40 -0
  33. package/package.json +63 -4
  34. package/plugins/unity-live2d-bundle/README.md +98 -0
  35. package/plugins/unity-live2d-bundle/index.js +891 -0
  36. package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
  37. package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
  38. package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
  39. package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
  40. package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
  41. package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
  42. package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
  43. package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
  44. package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  45. package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
  46. package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
  47. package/plugins/unity-live2d-bundle/lib/png.js +424 -0
  48. package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
  49. package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
  50. package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
  51. package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
  52. package/plugins/unity-live2d-bundle/plugin.json +81 -0
  53. package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
  54. package/plugins/unity-spine-bundle/index.js +535 -0
  55. package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
  56. package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
  57. package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
  58. package/plugins/unity-spine-bundle/lib/errors.js +79 -0
  59. package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
  60. package/plugins/unity-spine-bundle/lib/extract.js +242 -0
  61. package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  62. package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
  63. package/plugins/unity-spine-bundle/lib/png.js +424 -0
  64. package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
  65. package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
  66. package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
  67. package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
  68. package/plugins/unity-spine-bundle/plugin.json +75 -0
  69. package/src/server/README.md +69 -0
  70. package/src/server/assets/browse.mjs +56 -0
  71. package/src/server/assets/bundle-probe.mjs +94 -0
  72. package/src/server/assets/probe.mjs +184 -0
  73. package/src/server/core/config.mjs +173 -0
  74. package/src/server/core/env-num.mjs +60 -0
  75. package/src/server/core/env.mjs +49 -0
  76. package/src/server/core/fs-portable.mjs +37 -0
  77. package/src/server/core/log.mjs +107 -0
  78. package/src/server/core/paths.mjs +44 -0
  79. package/src/server/data/catalog.mjs +1091 -0
  80. package/src/server/data/entry-states.mjs +499 -0
  81. package/src/server/data/live2d.mjs +272 -0
  82. package/src/server/data/manifest-cache.mjs +58 -0
  83. package/src/server/data/manifest-stamp.mjs +135 -0
  84. package/src/server/data/model-meta.mjs +114 -0
  85. package/src/server/data/names.mjs +106 -0
  86. package/src/server/data/shape-probe-worker.mjs +33 -0
  87. package/src/server/data/shape-probe.mjs +626 -0
  88. package/src/server/data/spine.mjs +336 -0
  89. package/src/server/data/tree.mjs +126 -0
  90. package/src/server/http/atomic-json.mjs +145 -0
  91. package/src/server/http/background-solids.mjs +270 -0
  92. package/src/server/http/backgrounds.mjs +388 -0
  93. package/src/server/http/diag-upload.mjs +264 -0
  94. package/src/server/http/diag.mjs +173 -0
  95. package/src/server/http/ready.mjs +167 -0
  96. package/src/server/http/static.mjs +312 -0
  97. package/src/server/http/write.mjs +654 -0
  98. package/src/server/index.mjs +114 -0
  99. package/src/server/plugins/builtin-providers.json +30 -0
  100. package/src/server/plugins/registry.mjs +650 -0
  101. package/src/server/plugins/slot-catalog.mjs +286 -0
  102. package/src/server/routes/background-solids.mjs +56 -0
  103. package/src/server/routes/backgrounds.mjs +77 -0
  104. package/src/server/routes/config.mjs +38 -0
  105. package/src/server/routes/data.mjs +313 -0
  106. package/src/server/routes/diag.mjs +37 -0
  107. package/src/server/routes/entries.mjs +170 -0
  108. package/src/server/routes/ready.mjs +122 -0
  109. package/src/server/serve.mjs +396 -0
  110. package/src/server/unpack/capacity.mjs +217 -0
  111. package/src/server/unpack/mem-probe.mjs +27 -0
  112. package/src/server/unpack/plugin-runner.mjs +529 -0
  113. package/src/server/unpack/plugin-store.mjs +736 -0
  114. package/src/server/unpack/probe-store.mjs +73 -0
  115. package/src/server/unpack/routes.mjs +359 -0
  116. package/src/server/unpack/runtime/exec.mjs +558 -0
  117. package/src/server/unpack/runtime/index.mjs +141 -0
  118. package/src/server/unpack/runtime/node-vm.mjs +111 -0
  119. package/src/server/unpack/unpack-worker.mjs +105 -0
  120. package/src/server/unpack/worker-client.mjs +184 -0
  121. package/src/server/unpack/write-result.mjs +84 -0
  122. package/src/shared/bundle-shape.mjs +83 -0
  123. package/src/shared/entry-reasons.mjs +119 -0
  124. package/src/shared/names.mjs +45 -0
  125. package/src/shared/type-labels.mjs +31 -0
  126. package/src/web/README.md +193 -0
  127. package/src/web/app/anim-list.js +222 -0
  128. package/src/web/app/background-solids.js +150 -0
  129. package/src/web/app/backgrounds.js +152 -0
  130. package/src/web/app/camera-log.js +28 -0
  131. package/src/web/app/context.js +72 -0
  132. package/src/web/app/dir-picker.js +242 -0
  133. package/src/web/app/entry-report.js +71 -0
  134. package/src/web/app/memory-bars.js +91 -0
  135. package/src/web/app/memory-budget.js +228 -0
  136. package/src/web/app/memory-guard.js +204 -0
  137. package/src/web/app/model-actions.js +682 -0
  138. package/src/web/app/renderer-setting.js +70 -0
  139. package/src/web/app/root-manager.js +254 -0
  140. package/src/web/app/status.js +138 -0
  141. package/src/web/app/topbar-actions.js +357 -0
  142. package/src/web/app/transport-actions.js +297 -0
  143. package/src/web/app/ui-state.js +559 -0
  144. package/src/web/app/url-params.js +171 -0
  145. package/src/web/app/view-settings.js +186 -0
  146. package/src/web/components.css +524 -0
  147. package/src/web/debug/diag-hud.js +95 -0
  148. package/src/web/debug/drag.js +369 -0
  149. package/src/web/debug/log.js +320 -0
  150. package/src/web/debug/model-cmp.js +292 -0
  151. package/src/web/debug/panel.css +474 -0
  152. package/src/web/debug/panel.js +712 -0
  153. package/src/web/debug/race-probe.js +82 -0
  154. package/src/web/debug/upload.js +130 -0
  155. package/src/web/engine/adapters/atlas.js +47 -0
  156. package/src/web/engine/adapters/index.js +22 -0
  157. package/src/web/engine/adapters/renderer.js +223 -0
  158. package/src/web/engine/adapters/skeleton.js +105 -0
  159. package/src/web/engine/adapters/texture.js +64 -0
  160. package/src/web/engine/animation.js +181 -0
  161. package/src/web/engine/appearance.js +136 -0
  162. package/src/web/engine/camera-adapter.js +212 -0
  163. package/src/web/engine/effect-chain.js +138 -0
  164. package/src/web/engine/engine-state.js +120 -0
  165. package/src/web/engine/filters.js +224 -0
  166. package/src/web/engine/fit.js +197 -0
  167. package/src/web/engine/frame-gate.js +33 -0
  168. package/src/web/engine/gl-driver.js +56 -0
  169. package/src/web/engine/gl.js +77 -0
  170. package/src/web/engine/index.js +20 -0
  171. package/src/web/engine/loader.js +55 -0
  172. package/src/web/engine/model-info.js +26 -0
  173. package/src/web/engine/pipeline.js +200 -0
  174. package/src/web/engine/player.js +427 -0
  175. package/src/web/engine/pma/blend.js +76 -0
  176. package/src/web/engine/pma/detect.js +118 -0
  177. package/src/web/engine/pma/index.js +8 -0
  178. package/src/web/engine/pma/resolve.js +112 -0
  179. package/src/web/engine/pma/upload.js +127 -0
  180. package/src/web/engine/pma.js +5 -0
  181. package/src/web/engine/render-frame.js +93 -0
  182. package/src/web/engine/runtime-capabilities.js +209 -0
  183. package/src/web/engine/runtime.js +37 -0
  184. package/src/web/engine/skins.js +165 -0
  185. package/src/web/engine/textures.js +174 -0
  186. package/src/web/index.html +117 -0
  187. package/src/web/infra/api.js +531 -0
  188. package/src/web/infra/diag.js +509 -0
  189. package/src/web/infra/document.js +269 -0
  190. package/src/web/infra/dom.js +31 -0
  191. package/src/web/infra/events.mjs +27 -0
  192. package/src/web/infra/log.js +153 -0
  193. package/src/web/infra/mem-stats.js +112 -0
  194. package/src/web/infra/plugin-host-registration.mjs +285 -0
  195. package/src/web/infra/plugin-host-report.mjs +32 -0
  196. package/src/web/infra/plugin-host-unload.mjs +79 -0
  197. package/src/web/infra/plugin-host-unpack.mjs +899 -0
  198. package/src/web/infra/plugin-host-warmup.mjs +180 -0
  199. package/src/web/infra/plugin-host.js +628 -0
  200. package/src/web/infra/ready.js +461 -0
  201. package/src/web/infra/schedule.js +51 -0
  202. package/src/web/infra/shortcuts.js +129 -0
  203. package/src/web/infra/slot-registry.js +402 -0
  204. package/src/web/infra/vfs.js +247 -0
  205. package/src/web/live2d/bridge.js +400 -0
  206. package/src/web/live2d/model.js +321 -0
  207. package/src/web/live2d/overlay.js +142 -0
  208. package/src/web/live2d/pixi-app.js +111 -0
  209. package/src/web/live2d/playback-probe.js +92 -0
  210. package/src/web/live2d/probe-geometry.js +329 -0
  211. package/src/web/live2d/probe.js +119 -0
  212. package/src/web/live2d/release.js +66 -0
  213. package/src/web/live2d/runtime-adapter.js +289 -0
  214. package/src/web/live2d/stage-ctx.js +152 -0
  215. package/src/web/live2d/stage.js +204 -0
  216. package/src/web/live2d/vfs-settings.js +174 -0
  217. package/src/web/live2d/view.js +89 -0
  218. package/src/web/main.js +629 -0
  219. package/src/web/panel/background-list.js +373 -0
  220. package/src/web/panel/bg-source.js +291 -0
  221. package/src/web/panel/color-picker.css +199 -0
  222. package/src/web/panel/color-picker.js +278 -0
  223. package/src/web/panel/dialog.css +116 -0
  224. package/src/web/panel/dialog.js +173 -0
  225. package/src/web/panel/dom.js +35 -0
  226. package/src/web/panel/icons.js +58 -0
  227. package/src/web/panel/memory-bar.js +195 -0
  228. package/src/web/panel/panel-settings.js +1263 -0
  229. package/src/web/panel/panel.css +1304 -0
  230. package/src/web/panel/render-choice.js +104 -0
  231. package/src/web/panel/schema.js +784 -0
  232. package/src/web/panel/settings-io.js +291 -0
  233. package/src/web/panel/shell-controls.js +274 -0
  234. package/src/web/panel/slider.js +210 -0
  235. package/src/web/panel/toolbar-schema.js +111 -0
  236. package/src/web/panel/toolbar.js +195 -0
  237. package/src/web/panel/transport-picker.js +375 -0
  238. package/src/web/panel/transport.js +321 -0
  239. package/src/web/panel/view.js +553 -0
  240. package/src/web/plugin/unpack-worker.js +185 -0
  241. package/src/web/render/camera/constants.js +60 -0
  242. package/src/web/render/camera/index.js +123 -0
  243. package/src/web/render/camera/spine.js +124 -0
  244. package/src/web/render/capture.js +30 -0
  245. package/src/web/render/interaction.js +527 -0
  246. package/src/web/render/live2d.js +627 -0
  247. package/src/web/render/playback.js +47 -0
  248. package/src/web/render/reset-view.js +29 -0
  249. package/src/web/render/spine.js +386 -0
  250. package/src/web/render/surface-switch.js +176 -0
  251. package/src/web/render/surface.js +571 -0
  252. package/src/web/render/surfaces.js +70 -0
  253. package/src/web/roster/filter.js +31 -0
  254. package/src/web/roster/list.js +276 -0
  255. package/src/web/roster/persist.js +92 -0
  256. package/src/web/roster/reveal.js +77 -0
  257. package/src/web/roster/rows.js +248 -0
  258. package/src/web/roster/snapshot.js +39 -0
  259. package/src/web/roster/type-filter.js +97 -0
  260. package/src/web/roster/view.js +761 -0
  261. package/src/web/spine/canvas-api-groups.js +267 -0
  262. package/src/web/spine/canvas-api.js +97 -0
  263. package/src/web/spine/canvas-document.js +80 -0
  264. package/src/web/spine/canvas-engine.js +193 -0
  265. package/src/web/spine/canvas-entry.js +28 -0
  266. package/src/web/spine/canvas-pixels.js +36 -0
  267. package/src/web/spine/canvas-runtime.js +127 -0
  268. package/src/web/spine/canvas-view.js +108 -0
  269. package/src/web/spine/canvas.js +7 -0
  270. package/src/web/spine/runtime-hint.js +73 -0
  271. package/src/web/spine/runtimes.js +212 -0
  272. package/src/web/style.css +493 -0
  273. package/tool/lib/bundle-probe-node.mjs +121 -0
  274. package/tool/lib/paths.mjs +20 -0
  275. package/tool/lib/runtime-guides.mjs +65 -0
  276. package/tool/lib/runtime-package.mjs +403 -0
  277. package/tool/lib/spine-meta.mjs +227 -0
  278. package/tool/lib/spine-motions.mjs +104 -0
  279. package/tool/parse-jsonc.mjs +86 -0
  280. package/tool/resolve-live2d-runtimes.mjs +276 -0
  281. package/tool/spine-motion-rules.json +101 -0
@@ -0,0 +1,242 @@
1
+ // app/dir-picker.js —— 资源根「选择目录」的服务端目录浏览器
2
+ //
3
+ // 为什么必须有它:模型在**服务端**磁盘上,浏览器拿不到绝对路径(File System Access API 给的是
4
+ // 客户端沙箱句柄),只能让服务端列目录。只能走服务端列目录(`/api/browse`):浏览器拿不到任意路径的目录列表。
5
+ //
6
+ // 交互// 点「选择目录」→ 弹出面板
7
+ // 顶部:标题 + 关闭
8
+ // 中部:当前路径(含子目录数 / 含多少 .skel)、「↑ 上级目录」、「选择此目录」、📁 子目录列表
9
+ // 底部:显示名称(可空,同名会合并成一个游戏)+「添加」
10
+ //
11
+ // 面板关掉就销毁,不常驻 DOM(不留隐藏节点)。
12
+
13
+ /** 打开时的默认起点:上次用过的目录优先,否则从根开始让用户自己走 */
14
+ const LAST_DIR_KEY = 'modelstage.dirpicker.last';
15
+
16
+ // 目录列举走统一 HTTP 层(api.js):拿重试 + 去重 + 统一的 TimeoutError 归类,别裸 fetch
17
+ import { getJson } from '../infra/api.js';
18
+
19
+ /**
20
+ * 创建目录选择器。
21
+ * @param {{onPick: (payload: {path: string, name: string}) => Promise<boolean>|boolean, formatError?: Function}} deps
22
+ * onPick 返回 true 表示添加成功(面板会自行关闭)
23
+ */
24
+ export function createDirPicker({ onPick, formatError, getLastError } = {}) {
25
+ let panel = null; // 遮罩层(含面板)
26
+ let currentPath = ''; // 当前浏览到的目录
27
+ let pickedPath = ''; // 用户点过"选择此目录"的目录(空 = 还没选)
28
+ let busy = false;
29
+
30
+ const q = (sel) => (panel ? panel.querySelector(sel) : null);
31
+
32
+ /** 读上次目录(浏览器存储不可用时回落空串) */
33
+ function lastDir() {
34
+ try { return localStorage.getItem(LAST_DIR_KEY) || ''; } catch { return ''; }
35
+ }
36
+
37
+ function rememberDir(path) {
38
+ try { localStorage.setItem(LAST_DIR_KEY, path); } catch { /* 存不了就算了 */ }
39
+ }
40
+
41
+ function ensureShell() {
42
+ if (panel) return panel;
43
+ panel = document.createElement('div');
44
+ panel.id = 'dir-picker';
45
+ panel.className = 'dir-picker';
46
+ panel.innerHTML = `
47
+ <div class="dir-picker-box" role="dialog" aria-modal="true" aria-label="添加资源根">
48
+ <div class="dir-picker-head">
49
+ <strong>添加资源根</strong>
50
+ <span class="dir-picker-spacer"></span>
51
+ <button type="button" class="mini-btn" data-role="close">关闭</button>
52
+ </div>
53
+ <div class="dir-picker-body" data-role="body"></div>
54
+ <!-- 失败原因显示在对话框**自己身上**:它弹在屏幕中央,左栏那行提示在用户背后等于没有 -->
55
+ <div class="dir-picker-alert" data-role="alert" role="alert" hidden></div>
56
+ <div class="dir-picker-foot">
57
+ <span class="muted-hint">显示名称(同名会合并成一个游戏,用于归类原版/Mod)</span>
58
+ <input type="text" data-role="name" placeholder="如:棕色尘埃2" aria-label="资源根显示名称" />
59
+ <button type="button" class="mini-btn is-accent" data-role="add">添加</button>
60
+ </div>
61
+ </div>`;
62
+ document.body.append(panel);
63
+ // 点遮罩关闭(点面板内部不关)
64
+ panel.addEventListener('click', (e) => { if (e.target === panel) close(); });
65
+ q('[data-role="close"]').addEventListener('click', close);
66
+ q('[data-role="add"]').addEventListener('click', doAdd);
67
+ return panel;
68
+ }
69
+
70
+ /** 渲染当前目录:路径信息 / 上级 / 选择此目录 / 子目录列表 */
71
+ async function renderBody() {
72
+ const body = q('[data-role="body"]');
73
+ if (!body) return;
74
+ body.replaceChildren();
75
+ body.append(text('div', 'dir-picker-hint', '加载中…'));
76
+ let data = null;
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 });
82
+ if (!data || data.error) throw new Error((data && data.error) || '服务端返回空目录数据');
83
+ } catch (err) {
84
+ body.replaceChildren(text('div', 'dir-picker-error',
85
+ `读取失败:${typeof formatError === 'function' ? formatError(err) : (err && err.message) || err}`));
86
+ return;
87
+ }
88
+ currentPath = data.path || currentPath;
89
+ const hint = data.hint || {};
90
+ const info = text('div', 'dir-picker-hint', '');
91
+ info.append('当前:');
92
+ const code = document.createElement('code');
93
+ code.textContent = currentPath;
94
+ info.append(code);
95
+ info.append(` 子目录 ${hint.subDirCount || 0} 个${hint.skelHere ? `,含 ${hint.skelHere} 个 .skel` : ''}`);
96
+ body.replaceChildren(info);
97
+
98
+ // ↑ 上级目录
99
+ if (data.parent) body.append(navBtn(`↑ 上级目录`, data.parent));
100
+
101
+ // 选择此目录(已选过就变成"✓ 已选择",再点仍是选中当前)
102
+ const pick = document.createElement('button');
103
+ pick.type = 'button';
104
+ // 已选中态用描边强调(与「选择目录」「添加」同一套语言),不再用实心高饱和
105
+ pick.className = pickedPath === currentPath ? 'mini-btn is-accent' : 'mini-btn';
106
+ pick.dataset.pick = currentPath;
107
+ pick.textContent = pickedPath === currentPath ? '✓ 已选择此目录' : '选择此目录';
108
+ pick.addEventListener('click', () => { pickedPath = currentPath; renderBody(); });
109
+ const pickRow = text('div', 'dir-picker-pick', '');
110
+ pickRow.append(pick);
111
+ body.append(pickRow);
112
+
113
+ // 子目录列表
114
+ const dirs = Array.isArray(data.dirs) ? data.dirs : [];
115
+ if (!dirs.length) {
116
+ body.append(text('div', 'dir-picker-hint', '(无子目录)'));
117
+ return;
118
+ }
119
+ const list = text('div', 'dir-picker-list', '');
120
+ for (const d of dirs) {
121
+ const btn = navBtn(`📁 ${d.name}`, d.path);
122
+ btn.dataset.dirName = d.name;
123
+ list.append(btn);
124
+ }
125
+ body.append(list);
126
+ }
127
+
128
+ function text(tag, className, content) {
129
+ const n = document.createElement(tag);
130
+ if (className) n.className = className;
131
+ if (content) n.textContent = content;
132
+ return n;
133
+ }
134
+
135
+ function navBtn(label, target) {
136
+ const btn = document.createElement('button');
137
+ btn.type = 'button';
138
+ btn.className = 'mini-btn dir-picker-nav';
139
+ btn.dataset.nav = target;
140
+ btn.textContent = label;
141
+ btn.addEventListener('click', () => {
142
+ // 换目标 ⇒ 之前的「选择此目录」作废。
143
+ // 不清的后果(实测过的 bug):在 A 点过选择、再导航到 B 点添加,提交的却是 A,
144
+ // 服务端按路径判重正确返回 409「该目录已登记为资源根」——
145
+ // 用户看到的是"我明明没登记 B,凭什么说已登记",且「✓ 已选择」还挂在 A 上。
146
+ pickedPath = '';
147
+ currentPath = target;
148
+ renderBody();
149
+ });
150
+ return btn;
151
+ }
152
+
153
+ /**
154
+ * 添加:**只提交当前浏览的目录**。
155
+ *
156
+ * 为什么不再用 `pickedPath || currentPath`:pickedPath 是"点过选择此目录"留下的,
157
+ * 一旦导航没清(历史 bug),它就会是**上一次选过的目录** ⇒ 提交一个与当前视图无关的目标
158
+ * ⇒ 服务端按路径判重正确返回 409,而用户完全看不出为什么。
159
+ * 现在 navBtn 已经"导航即清除",pickedPath 只可能是空或等于 currentPath;
160
+ * 这里再兜一层:提交值永远来自 currentPath,两者不一致时不可能发生错位。
161
+ */
162
+ async function doAdd() {
163
+ if (busy) return;
164
+ const path = currentPath;
165
+ const nameInput = q('[data-role="name"]');
166
+ const name = nameInput ? nameInput.value.trim() : '';
167
+ if (!path) return;
168
+ clearAlert();
169
+ busy = true;
170
+ try {
171
+ const res = typeof onPick === 'function' ? await onPick({ path, name }) : false;
172
+ // onPick 可能返回布尔(既有实现)或 { ok, error }(更细的实现)——两种都认
173
+ const ok = res && typeof res === 'object' ? res.ok === true : res === true;
174
+ if (ok) { rememberDir(path); close(); return; }
175
+ // 失败:**对话框不关**,把原因写在底部输入行上方,用户改完就能重试
176
+ showAlert(pickErrorText(res));
177
+ } finally {
178
+ busy = false;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * 取失败原因文本。
184
+ * 优先用 onPick 直接给回来的 error({ ok:false, error });
185
+ * 否则问 getLastError(root-manager 会把上一次失败文案记下来)。
186
+ * @param {unknown} res onPick 的返回值
187
+ * @returns {string}
188
+ */
189
+ function pickErrorText(res) {
190
+ if (res && typeof res === 'object' && typeof res.error === 'string' && res.error) return res.error;
191
+ try {
192
+ if (typeof getLastError === 'function') {
193
+ const t = getLastError();
194
+ if (typeof t === 'string' && t) return t;
195
+ }
196
+ } catch { /* 取不到就退回通用文案 */ }
197
+ return '添加失败,请检查目录路径与名称后重试';
198
+ }
199
+
200
+ /** 把失败原因显示在对话框内 */
201
+ function showAlert(text) {
202
+ const box = q('[data-role="alert"]');
203
+ if (!box) return;
204
+ box.textContent = String(text || '');
205
+ box.hidden = !box.textContent;
206
+ }
207
+
208
+ /** 收起错误行(重试时先清掉,避免旧错误杵在那儿) */
209
+ function clearAlert() {
210
+ const box = q('[data-role="alert"]');
211
+ if (!box) return;
212
+ box.textContent = '';
213
+ box.hidden = true;
214
+ }
215
+
216
+ /** 打开面板(默认从上次目录或 / 开始) */
217
+ function open(startPath) {
218
+ ensureShell();
219
+ panel.hidden = false;
220
+ pickedPath = '';
221
+ currentPath = startPath || lastDir() || '/';
222
+ const nameInput = q('[data-role="name"]');
223
+ if (nameInput) nameInput.value = '';
224
+ renderBody();
225
+ return panel;
226
+ }
227
+
228
+ /** 关闭并销毁(不留隐藏容器) */
229
+ function close() {
230
+ if (!panel) return;
231
+ panel.remove();
232
+ panel = null;
233
+ }
234
+
235
+ return {
236
+ open, close,
237
+ /** 只读:当前浏览路径 / 已选路径(断言用) */
238
+ get state() { return { open: !!panel, currentPath, pickedPath }; },
239
+ /** 断言用:直接设定路径后重绘(等价于用户一路点进来) */
240
+ goto(path) { currentPath = path; return panel ? renderBody() : null; },
241
+ };
242
+ }
@@ -0,0 +1,71 @@
1
+ // app/entry-report.js —— 装载结果上报(契约《条目状态与展示归属》§三 的前端侧)
2
+ //
3
+ // 为什么要有这一层:装载成功/失败都必须写进服务端的条目状态,并且**立刻**把那一行画对。
4
+ // 服务端的 POST /api/entries/result 是"写读一体"的:响应里就带回重算后的
5
+ // display/support/loadResult。所以这里一次请求同时完成两件事,不需要再拉 /api/catalog
6
+ // (那是秒级扫描,为一行颜色付这个代价是浪费,而且刚解包时它未必已经能看到)。
7
+ //
8
+ // 旁路原则(契约 INV-D3):上报失败**绝不能**影响装载结果 —— 所有异常在这里吞掉只记日志。
9
+ import { postJson } from '../infra/api.js';
10
+ import { stageLog } from './status.js';
11
+ import { state } from './context.js';
12
+
13
+ /**
14
+ * 条目的状态键:与 VFS 前缀同构,`${rootId}/${rel}`。
15
+ *
16
+ * 为什么不用条目 id:容器 id 是 `bundle-<rootId>-<rel 把 / 换成 _>` 派生的,**不可逆**,
17
+ * 不同 rel 可能撞成同一个键;而 rootId/rel 是解包与存储都天然认得的形态。
18
+ * @param {object} model 名册条目
19
+ * @returns {string} 空串 = 这个条目没有可用的键(调用方应跳过上报)
20
+ */
21
+ export function entryStateKey(model) {
22
+ const rootId = String((model && model.rootId) || '');
23
+ const rel = String((model && (model.rel || model.srcDir || model.name)) || '');
24
+ if (!rootId || !rel) return '';
25
+ return `${rootId}/${rel}`;
26
+ }
27
+
28
+ /**
29
+ * 上报一次装载结果,并用响应就地更新名册那一行。
30
+ *
31
+ * @param {object} model 名册条目
32
+ * @param {{result: 'ok'|'failed', reason?: string|null, message?: string|null,
33
+ * provider?: string|null}} outcome 结果(reason 用契约码,message 是人话)
34
+ * @returns {Promise<object|null>} 服务端重算后的三字段;失败/未上报返回 null
35
+ */
36
+ export async function reportEntryResult(model, outcome) {
37
+ const key = entryStateKey(model);
38
+ if (!key) return null;
39
+ const body = {
40
+ key,
41
+ result: outcome.result === 'ok' ? 'ok' : 'failed',
42
+ reason: outcome.reason || null,
43
+ message: outcome.message || null,
44
+ provider: outcome.provider || null,
45
+ size: Number.isFinite(model && model.size) ? Number(model.size) : undefined,
46
+ source: 'client',
47
+ };
48
+ try {
49
+ const res = await postJson('/api/entries/result', body, { retries: 0 });
50
+ const computed = res && res.entry ? res.entry : null;
51
+ if (computed) {
52
+ // 必须走名册的写回入口,不能只刷 DOM:
53
+ // 传进来的 model 是 normalizeEntry 的**副本**(`{ ...entry }`),
54
+ // 改它不会影响名册渲染用的那个对象 ⇒ 下一次 render 就读回旧值,
55
+ // 表现为"当场变了色、一切换/搜索就掉回中性色"(实测的老 bug)。
56
+ // setEntryState 按 id 找到名册持有的原对象、写回三字段,再重画那一行。
57
+ const roster = state.roster;
58
+ const id = String((model && (model.id || model.name)) || '');
59
+ try {
60
+ if (roster && typeof roster.setEntryState === 'function' && id) roster.setEntryState(id, computed);
61
+ } catch { /* 名册刷新失败不影响装载,也不影响已写入的服务端状态 */ }
62
+ }
63
+ return computed;
64
+ } catch (e) {
65
+ // 旁路:上报失败只记一条 info,绝不升级成装载失败
66
+ try {
67
+ stageLog('info', `装载结果未上报(${(e && e.message) || e}):${key}`, { key, result: body.result });
68
+ } catch { /* 日志失败也不影响 */ }
69
+ return null;
70
+ }
71
+ }
@@ -0,0 +1,91 @@
1
+ // app/memory-bars.js —— 内存三色条的两个挂载点(舞台顶部 / 设置面板)统一管理
2
+ //
3
+ // 为什么要这一层:面板会重建、舞台容器常在,两处各有自己的生命周期。
4
+ // 组件本身(panel/memory-bar.js)只管"画一条";谁该在什么时候拉数据、要不要显示,
5
+ // 集中在这么一个小模块里,免得散落到 model-actions / panel-settings / shell-controls 各处。
6
+ //
7
+ // 刷新时机(事件驱动,无定时器):
8
+ // · 模型加载成功后 → refreshStage()
9
+ // · 面板重建(展开设置时) → refreshPanel()
10
+ // · 「显示状态信息」开关 → setStageVisible()(关掉时舞台那条跟着消失,与状态行同策略)
11
+
12
+ import { mountMemoryBar } from '../panel/memory-bar.js';
13
+
14
+ /** 舞台顶部那条(容器在 index.html 里) */
15
+ const stageBar = mountMemoryBar('#mem-bar-stage');
16
+
17
+ /** 设置面板里那条(容器由 schema 的 ctl-memory-bar 提供) */
18
+ const panelBar = mountMemoryBar('#right-panel [data-ctl="ctl-memory-bar"]');
19
+
20
+ /** 舞台那条是否允许显示(跟「显示状态信息」开关走,默认开) */
21
+ let stageVisible = true;
22
+
23
+ /**
24
+ * 刷新舞台顶部那条(模型加载成功后调用)。
25
+ * @returns {Promise<boolean>} 是否显示成功
26
+ */
27
+ export async function refreshStage() {
28
+ if (!stageVisible) { stageBar.hide(); return false; }
29
+ return stageBar.refresh();
30
+ }
31
+
32
+ /**
33
+ * 刷新设置面板那条(面板重建后调用)。
34
+ * @returns {Promise<boolean>}
35
+ */
36
+ export async function refreshPanel() {
37
+ return panelBar.refresh();
38
+ }
39
+
40
+ /**
41
+ * 「显示状态信息」开关联动:关掉时舞台那条立即隐藏(不拉数据、不留空壳)。
42
+ * @param {boolean} on
43
+ */
44
+ export function setStageVisible(on) {
45
+ stageVisible = on !== false;
46
+ if (!stageVisible) { stageBar.hide(); return; }
47
+ // 打开时补拉一次(此刻可能还没有数据)
48
+ refreshStage().catch(() => { /* 失败已由组件内部记 warn 并隐藏 */ });
49
+ }
50
+
51
+ /**
52
+ * 绑定「展开设置面板 → 拉一次」(文档 §五)。
53
+ * 面板重建时也会调 refreshPanel,但"点开设置组"不重建面板 ⇒ 单独绑点击。
54
+ * 幂等:只绑一次。
55
+ * @returns {boolean} 本次是否新绑定
56
+ */
57
+ let bound = false;
58
+ export function bindPanelBarRefresh() {
59
+ if (bound) return false;
60
+ bound = true;
61
+ document.addEventListener('click', (ev) => {
62
+ const trigger = ev.target && typeof ev.target.closest === 'function'
63
+ ? ev.target.closest('#right-panel .acc-trigger')
64
+ : null;
65
+ if (!trigger) return;
66
+ const label = (trigger.textContent || '').trim();
67
+ // 只关心「设置」组:其它组与内存无关,不必拉数据
68
+ if (!label.includes('设置')) return;
69
+ refreshPanel().catch(() => { /* 失败已由组件记 warn 并隐藏 */ });
70
+ }, true);
71
+ return true;
72
+ }
73
+
74
+ /** 供断言:两处当前显示的数值(无数据返回 null) */
75
+ export function memoryBarSnapshot() {
76
+ const read = (sel) => {
77
+ const bar = document.querySelector(sel);
78
+ if (!bar || bar.hidden) return null;
79
+ return {
80
+ aria: bar.getAttribute('aria-label'),
81
+ text: (bar.querySelector('.mem-bar-text') || {}).textContent || '',
82
+ pct: {
83
+ server: bar.dataset.pctServer, other: bar.dataset.pctOther, avail: bar.dataset.pctAvail,
84
+ },
85
+ };
86
+ };
87
+ return {
88
+ stage: read('#mem-bar-stage .mem-bar'),
89
+ panel: read('#right-panel [data-ctl="ctl-memory-bar"] .mem-bar'),
90
+ };
91
+ }
@@ -0,0 +1,228 @@
1
+ // app/memory-budget.js —— 内存判据的**唯一决策入口**(策略层)
2
+ //
3
+ // 为什么要有这一层:项目里曾经同时存在三处"防内存打爆"的判断,各写各的
4
+ // ① capacity.mjs 用「可用内存 × 0.5 vs 预估峰值」——有权威翻译器、受开关控、文案带数字
5
+ // ② memory-guard.js 用「JS 堆 85%」——受开关控
6
+ // ③ plugin-host.js 用「裸包字节 > 512MB 硬编码」——不受开关控、文案丢数字、更不看内存余量
7
+ // 同一个包在 8GB 机器上没事、在 2GB 机器上必炸,第③处却一视同仁地拦。
8
+ // 所以这里把"能不能做"收成一个函数,谁要判断都问它。
9
+ //
10
+ // 🔴 铁律:**前端不自己算倍数**。
11
+ // 「包字节 → 预估峰值」的权威实现只有服务端 capacity.mjs 的 estimatePeakMb(三档实测上包络),
12
+ // 前端自己写 bytes*20 之类的倍数 = 又造一个新口径,正是本任务要消灭的东西。
13
+ //
14
+ // 数据源分工(不许越界):
15
+ // · 服务端内存:只能从 GET /api/unpack/capacity 拿(浏览器里读不到系统内存)
16
+ // · 前端堆占用:只能从浏览器 performance.memory 拿(服务端读不到)
17
+
18
+ import { memStats } from '../infra/mem-stats.js';
19
+ import { stageLog } from './status.js';
20
+
21
+ /**
22
+ * 前端堆占用阈值(路径 B / C 与 assess 共用**同一个**值)。
23
+ * 为什么放在这里:判据口径只允许有一份,memory-guard 从这里 import ——
24
+ * 两边各写一个 0.85 就是新一轮"同一事实两处表达",迟早漂移。
25
+ *
26
+ * 缺省 0.85;服务端可用 **MODELSTAGE_ADMIT_HEAP_RATIO** 覆盖,
27
+ * 经 `GET /api/web-config` 的 `admitHeapRatio` 下发到前端(前端读不到 env)。
28
+ * 这里用 `let` 而不是 const:下发值要在启动后覆盖缺省,而 import 方拿到的是**活绑定**,
29
+ * memory-guard 里那些 `h.pct > HEAP_RATIO` 无需改动就会看到新值。
30
+ */
31
+ export let HEAP_RATIO = 0.85;
32
+ /** 缺省值(下发值缺失/非法时回落到它;也是文档里写的那个数) */
33
+ export const DEFAULT_HEAP_RATIO = 0.85;
34
+
35
+ /**
36
+ * 应用服务端下发的准入配置(幂等;非法值回落缺省并 warn 一次)。
37
+ * @param {{admitHeapRatio?: number}} cfg /api/web-config 的响应
38
+ * @returns {{ratio: number, applied: boolean}}
39
+ */
40
+ export function applyAdmitConfig(cfg) {
41
+ if (!cfg || cfg.admitHeapRatio === undefined || cfg.admitHeapRatio === null) {
42
+ return { ratio: HEAP_RATIO, applied: false };
43
+ }
44
+ const v = Number(cfg.admitHeapRatio);
45
+ const ok = Number.isFinite(v) && v > 0 && v <= 1;
46
+ if (!ok) {
47
+ // 不静默:下发值非法时说明白用的是哪个数,否则"为什么阈值没变"只能靠猜
48
+ try { stageLog('warn', `准入堆比例下发值非法(${JSON.stringify(cfg.admitHeapRatio)})—— 继续用 ${HEAP_RATIO}`); } catch { /* 日志失败不影响判据 */ }
49
+ return { ratio: HEAP_RATIO, applied: false };
50
+ }
51
+ HEAP_RATIO = v;
52
+ try { stageLog('info', `准入堆比例已按服务端下发调整:${v}`); } catch { /* 同上 */ }
53
+ return { ratio: HEAP_RATIO, applied: true };
54
+ }
55
+
56
+ /** 服务端判据缓存时长:同一个包在短时间内反复判断不该每次都打服务端 */
57
+ const CAP_TTL_MS = 5000;
58
+
59
+ /** 开关状态镜像(由 memory-guard.setMemoryGuard 同步过来,避免两层互相 import 成环) */
60
+ let guardEnabled = true;
61
+
62
+ /** 缓存:key = 包 MB(取整)/ 'machine' → { at, value } */
63
+ const capCache = new Map();
64
+
65
+ /** 实际发起过的服务端请求次数(验收"缓存生效"用) */
66
+ let fetchCount = 0;
67
+
68
+ /**
69
+ * 同步"内存保护开关"状态。memory-guard.setMemoryGuard 里会调它。
70
+ * @param {boolean} on
71
+ */
72
+ export function setBudgetGuardEnabled(on) {
73
+ guardEnabled = on !== false;
74
+ }
75
+
76
+ /** @returns {boolean} 当前是否受开关约束 */
77
+ export function isBudgetGuardEnabled() {
78
+ return guardEnabled;
79
+ }
80
+
81
+ /**
82
+ * 取服务端判据(带 5 秒缓存)。
83
+ * @param {number} sourceMb 包大小(MB);0/缺省 ⇒ 只问机器级信息
84
+ * @returns {Promise<object|null>} 拿不到返回 null(调用方按"这一路不可用"处理)
85
+ */
86
+ async function fetchServerJudgement(sourceMb) {
87
+ const key = sourceMb > 0 ? String(Math.round(sourceMb)) : 'machine';
88
+ const hit = capCache.get(key);
89
+ if (hit && Date.now() - hit.at < CAP_TTL_MS) return hit.value;
90
+ const qs = sourceMb > 0 ? `?sourceMb=${encodeURIComponent(sourceMb)}` : '';
91
+ try {
92
+ fetchCount += 1;
93
+ const res = await fetch(`/api/unpack/capacity${qs}`, {
94
+ headers: { Accept: 'application/json' },
95
+ signal: AbortSignal.timeout(2000),
96
+ });
97
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
98
+ const j = await res.json();
99
+ capCache.set(key, { at: Date.now(), value: j });
100
+ return j;
101
+ } catch (err) {
102
+ stageLog('warn', `内存判据取不到服务端数据:${String((err && err.message) || err)}`, { sourceMb });
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /** 读前端堆占用(不可用返回 null,不瞎估) */
108
+ function heapStats() {
109
+ try {
110
+ const s = memStats();
111
+ if (s.jsHeapPct === null || s.jsHeapPct === undefined) return null;
112
+ return { pct: s.jsHeapPct, usedMB: s.jsHeapMB, limitMB: s.jsHeapLimitMB };
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /** 百分比显示:不足 1% 写成 <1%(写 0% 会让人以为没数据) */
119
+ function pctText(pct) {
120
+ return pct < 0.01 ? '<1' : String(Math.round(pct * 100));
121
+ }
122
+
123
+ /**
124
+ * 统一决策:这次操作能不能做。
125
+ *
126
+ * 判据取**更严格的一路**(任一路不通过即不通过);两路都拿不到 ⇒ **放行 + 记日志**
127
+ * —— 拿不到真值时不假装保护,否则会变成"在某些浏览器上永远加载不了"。
128
+ *
129
+ * @param {object} opts
130
+ * @param {number} [opts.bytesHint] 已知的包字节数(能算出预估峰值)
131
+ * @param {'web-load'|'web-unpack'|'web-long-op'} [opts.where] 调用场景(只影响文案与默认策略)
132
+ * @param {string} [opts.label] 对象名(写进文案,让用户知道是哪一步被拦)
133
+ * @returns {Promise<{ok:boolean, reason:string, numbers:object, message:string}>}
134
+ * numbers: { pkgBytes, peakEstMb, budgetMb, availMb, heapPct, usedMB, limitMB }
135
+ */
136
+ export async function assess(opts = {}) {
137
+ const where = opts.where || 'web-load';
138
+ const label = String(opts.label || '').trim();
139
+ const bytes = Number(opts.bytesHint) > 0 ? Number(opts.bytesHint) : 0;
140
+ const numbers = {
141
+ pkgBytes: bytes || null,
142
+ peakEstMb: null,
143
+ budgetMb: null,
144
+ availMb: null,
145
+ heapPct: null,
146
+ usedMB: null,
147
+ limitMB: null,
148
+ };
149
+
150
+ if (!guardEnabled) {
151
+ // 开关关闭:**连绝对兜底也不拦**(用户明示要试)
152
+ return { ok: true, reason: 'guard-off', numbers, message: '' };
153
+ }
154
+
155
+ // 路 A|服务端:包字节 → 预估峰值 / 预算 / 可用
156
+ const srv = await fetchServerJudgement(bytes > 0 ? bytes / 1024 / 1024 : 0);
157
+ const heap = heapStats();
158
+ let serverJudged = false;
159
+ let serverFails = false;
160
+ let serverDetail = '';
161
+ if (srv && Number.isFinite(srv.peakEstMb) && Number.isFinite(srv.budgetMb)) {
162
+ serverJudged = true;
163
+ numbers.peakEstMb = srv.peakEstMb;
164
+ numbers.budgetMb = srv.budgetMb;
165
+ numbers.availMb = Number.isFinite(srv.memAvailableMb) ? srv.memAvailableMb : null;
166
+ if (srv.peakEstMb > srv.budgetMb) {
167
+ serverFails = true;
168
+ serverDetail = `预估峰值 ${srv.peakEstMb}MB 超过可用预算 ${srv.budgetMb}MB(当前可用 ${numbers.availMb}MB × 0.5)`;
169
+ }
170
+ }
171
+ if (heap) {
172
+ numbers.heapPct = heap.pct;
173
+ numbers.usedMB = heap.usedMB;
174
+ numbers.limitMB = heap.limitMB;
175
+ }
176
+
177
+ // 两路都拿不到 ⇒ 放行 + 记日志(不假装保护)
178
+ if (!serverJudged && !heap) {
179
+ stageLog('warn', `内存判据两路都不可用,放行(${where}${label ? ` · ${label}` : ''})`, { where, bytes });
180
+ return { ok: true, reason: 'unjudgeable', numbers, message: '' };
181
+ }
182
+
183
+ const heapFails = !!(heap && heap.pct > HEAP_RATIO);
184
+ if (serverFails || heapFails) {
185
+ return {
186
+ ok: false,
187
+ reason: serverFails ? 'insufficient-memory' : 'heap-high',
188
+ numbers,
189
+ message: buildMessage({ where, label, serverFails, heapFails, serverDetail, numbers }),
190
+ };
191
+ }
192
+ return { ok: true, reason: 'ok', numbers, message: '' };
193
+ }
194
+
195
+ /**
196
+ * 统一文案模板(三处同一套格式):`无法<动作>「<对象>」:<判据含真实数字>。<能怎么办>`
197
+ *
198
+ * 为什么必须带数字:只说"内存不足/文件过大",用户无法判断该关标签页、拆包还是换机器。
199
+ * @returns {string}
200
+ */
201
+ export function buildMessage({ where, label, serverFails, heapFails, serverDetail, numbers }) {
202
+ const obj = label ? `「${label}」` : '';
203
+ const verb = where === 'web-unpack' ? '解包' : (where === 'web-long-op' ? '继续' : '加载');
204
+ const parts = [];
205
+ if (serverFails && serverDetail) parts.push(serverDetail);
206
+ if (heapFails && numbers.heapPct !== null) {
207
+ parts.push(`内存占用较高(${numbers.usedMB} MB / ${numbers.limitMB} MB,${pctText(numbers.heapPct)}%)`);
208
+ }
209
+ const why = parts.join(';');
210
+ const advice = where === 'web-unpack'
211
+ ? '可先关闭其他程序,或改用更小的包。'
212
+ : (where === 'web-long-op'
213
+ ? '已完成的文件保留,可稍后重试剩余部分。'
214
+ : '请关闭其他标签页后重试。');
215
+ return `无法${verb}${obj}:${why}。${advice}`;
216
+ }
217
+
218
+ /** 验收用:已发起的服务端判据请求次数 */
219
+ export function budgetStats() {
220
+ return { fetchCount, cachedKeys: [...capCache.keys()] };
221
+ }
222
+
223
+ /** 供测试重置 */
224
+ export function __resetMemoryBudgetForTest() {
225
+ capCache.clear();
226
+ fetchCount = 0;
227
+ guardEnabled = true;
228
+ }