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,509 @@
1
+ import { createLogger } from './log.js';
2
+
3
+ // diag.js —— 前端内置诊断探针(window.__DIAG__)
4
+ //
5
+ // 目的:把"出问题时临时注入 console 探针"变成页面自带的能力 —— 前端自证、AI/工具直接读文件取证,
6
+ // 不再依赖"浏览器会话还开着"这个前提。
7
+ //
8
+ // 采集面(只收错误 / 警告 / 关键事件,**不做每帧采样**,避免探针本身变成性能问题):
9
+ // · window.onerror(含资源加载失败,capture 阶段,覆盖 img/script/link/video/audio)
10
+ // · unhandledrejection
11
+ // · 包装 console.error / warn / info:幂等、保留原引用、内部日志不自激递归
12
+ // · 关键事件:业务代码主动调用 __DIAG__.event(name, data)
13
+ //
14
+ // 出口:内存环形缓冲(默认 500 条)+ 批量 POST /api/diag 落盘(data/diag.jsonl);
15
+ // 落盘失败静默降级为仅内存 —— 探针永远不能影响页面本身。面板显示留到 M6。
16
+ //
17
+ // API 名称与语义保持稳定(同一套检查代码可在不同页面复用,不需要为改名改脚本))。
18
+ // 结构:本文件按职责拆成若干小函数(每函数 ≤50 行,便于审计与维护),installDiag 只做编排。
19
+
20
+ /** 配置默认值(页面可在 installDiag 之前设置 window.__DIAG_CONFIG__ 覆盖) */
21
+ const DEFAULT_CONFIG = {
22
+ capacity: 500,
23
+ flushEveryMs: 1500,
24
+ flushBatchMax: 40,
25
+ strMax: 200,
26
+ arrMax: 8,
27
+ interactionMinMs: 500,
28
+ };
29
+
30
+ /** 框架告警结构化:Cubism 的 MotionManager 动作装载失败(Live2D 接入后才有,M5 用) */
31
+ const MOTION_FAIL_RE = /\[MotionManager\]\s*Failed to load motion/i;
32
+
33
+ /** 资源标签:这些元素的 error 事件代表"资源加载失败" */
34
+ const RESOURCE_TAGS = ['IMG', 'SCRIPT', 'LINK', 'VIDEO', 'AUDIO'];
35
+
36
+ // ---------------------------------------------------------------- 适配层(统一日志工厂)
37
+ //
38
+ // 探针仍然按自己的形状采集与落盘(/api/diag → data/diag.jsonl 那条链路不动),
39
+ // 但**每一条事件同时送一份进 infra/log.js 的工厂**。这样"面板展示"和"上传"都能
40
+ // 只认工厂这一份数据源,不会出现"面板有、上传缺"的半套日志。
41
+ // 事件名归一:探针历史命名是 `category/event` 混 `a.b`,统一成点分(如 window/error → window.error),
42
+ // 因为工厂的 debug 级要求点分事件名——机器要按前缀分组过滤。
43
+ const logDiag = createLogger('diag');
44
+
45
+ /** 把 `a/b.c` 这类名字归一成点分的 `a.b.c`(工厂 debug 级要求点分) */
46
+ function dottedName(raw) {
47
+ const parts = String(raw || '').split(/[/.]/).filter(Boolean);
48
+ const name = parts.join('.');
49
+ return /^[a-z]/.test(name) ? name : `event.${name || 'unknown'}`;
50
+ }
51
+
52
+ /**
53
+ * 把一条探针事件镜像进统一工厂。
54
+ * 级别语义:debug 走事件名 + 结构化 detail(机器读);其余三档用事件名当人话 + detail 当上下文。
55
+ * @param {string} level debug/info/warn/error
56
+ * @param {string} event 原始事件名
57
+ * @param {object} data 已摘要过的结构化数据
58
+ */
59
+ function mirrorEventToFactory(level, event, data) {
60
+ try {
61
+ const detail = data && typeof data === 'object' ? data : undefined;
62
+ if (level === 'debug') logDiag.debug(dottedName(event), detail || {});
63
+ else if (level === 'warn') logDiag.warn(String(event || '事件'), detail);
64
+ else if (level === 'error') logDiag.error(String(event || '事件'), detail);
65
+ else logDiag.info(String(event || '事件'), detail);
66
+ } catch { /* 适配层异常不得影响探针采集 */ }
67
+ }
68
+
69
+ // ---------------------------------------------------------------- 状态
70
+
71
+ /** 建立探针状态(缓冲、待落盘队列、计时器、配置) */
72
+ function createState(override) {
73
+ const config = Object.assign({}, DEFAULT_CONFIG, window.__DIAG_CONFIG__ || {}, override || {});
74
+ return {
75
+ config,
76
+ t0: Date.now(),
77
+ buf: [], // 环形缓冲
78
+ pending: [], // 待落盘队列
79
+ flushTimer: null,
80
+ internal: false, // 防自激:探针内部产生的 console 调用不再被采集
81
+ lastInteraction: 0,
82
+ sinks: new Set(), // 订阅者(面板等):push 时同步分发
83
+ seq: 0, // 条目自增序号:面板"补历史 + 订阅"两条路会拿到同一条,用它去重
84
+ // 页面实例 id:每次加载都不同。服务端按它分组,GET /api/diag?sid=… 只回该实例的条目,
85
+ // 这样"API 条数 == 面板条数"不会被上一轮加载的旧条目污染。
86
+ sid: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
87
+ };
88
+ }
89
+
90
+ // ---------------------------------------------------------------- 值摘要
91
+
92
+ /** 值摘要:任何大对象都不能原样塞进缓冲(否则内存会被日志吃掉) */
93
+ function summarize(v, depth, st) {
94
+ const d = depth || 0;
95
+ const cfg = st.config;
96
+ try {
97
+ if (v === null || v === undefined) return v;
98
+ const t = typeof v;
99
+ if (t === 'string') {
100
+ return v.length > cfg.strMax ? `${v.slice(0, cfg.strMax)}…(+${v.length - cfg.strMax})` : v;
101
+ }
102
+ if (t === 'number' || t === 'boolean') return v;
103
+ if (t === 'function') return `[Function ${v.name || 'anonymous'}]`;
104
+ if (t === 'symbol' || t === 'bigint') return String(v);
105
+ if (ArrayBuffer.isView(v)) return { bytes: v.byteLength, kind: v.constructor ? v.constructor.name : 'TypedArray' };
106
+ if (v instanceof ArrayBuffer) return { bytes: v.byteLength, kind: 'ArrayBuffer' };
107
+ if (d >= 3) return Array.isArray(v) ? `[Array(${v.length})]` : `{${Object.keys(v).slice(0, 5).join(',')}…}`;
108
+ if (Array.isArray(v)) {
109
+ const head = v.slice(0, cfg.arrMax).map((x) => summarize(x, d + 1, st));
110
+ return v.length > cfg.arrMax ? head.concat([`…(+${v.length - cfg.arrMax})`]) : head;
111
+ }
112
+ if (v instanceof Error) return summarizeError(v, d, st);
113
+ return summarizeObject(v, d, st);
114
+ } catch {
115
+ return '[unserializable]';
116
+ }
117
+ }
118
+
119
+ /** Error 只留名称/消息/前几行栈 */
120
+ function summarizeError(err, d, st) {
121
+ return {
122
+ name: err.name,
123
+ message: summarize(err.message, d + 1, st),
124
+ stack: err.stack ? summarize(String(err.stack).split('\n').slice(0, 4).join(' | '), d + 1, st) : null,
125
+ };
126
+ }
127
+
128
+ /** 普通对象只保留浅层可读字段,避免循环引用与深树 */
129
+ function summarizeObject(v, d, st) {
130
+ const out = {};
131
+ const keys = Object.keys(v).slice(0, 16);
132
+ for (const k of keys) {
133
+ const val = v[k];
134
+ if (typeof val === 'object' && val !== null && d + 1 >= 3) {
135
+ out[k] = Array.isArray(val) ? `[Array(${val.length})]` : '[Object]';
136
+ continue;
137
+ }
138
+ out[k] = summarize(val, d + 1, st);
139
+ }
140
+ const extra = Object.keys(v).length - keys.length;
141
+ if (extra > 0) out['…'] = `(+${extra} 键)`;
142
+ return out;
143
+ }
144
+
145
+ /** 把 console 参数拼成一行文本(对象先摘要再 JSON,避免 [object Object]) */
146
+ function textOf(args, st) {
147
+ try {
148
+ return args.map((a) => {
149
+ if (typeof a === 'string') return a;
150
+ try { return JSON.stringify(summarize(a, 0, st)); } catch { return String(a); }
151
+ }).join(' ');
152
+ } catch {
153
+ return '[args]';
154
+ }
155
+ }
156
+
157
+ // ---------------------------------------------------------------- 缓冲与落盘
158
+
159
+ /** 入队 + 淘汰最旧(环形缓冲)+ 触发落盘 */
160
+ function push(st, level, src, event, data, extra) {
161
+ const e = Object.assign({
162
+ id: (st.seq += 1),
163
+ t: new Date().toISOString(),
164
+ tRel: Date.now() - st.t0,
165
+ level,
166
+ src,
167
+ event,
168
+ data: summarize(data, 0, st),
169
+ model: null,
170
+ plugin: null,
171
+ }, extra || {});
172
+ st.buf.push(e);
173
+ if (st.buf.length > st.config.capacity) st.buf.splice(0, st.buf.length - st.config.capacity);
174
+ st.pending.push(e);
175
+ // 极端情况下丢最旧,避免 pending 无限增长(服务端不可用时尤其重要)
176
+ if (st.pending.length > 400) st.pending.splice(0, st.pending.length - 400);
177
+ scheduleFlush(st);
178
+ notifySinks(st, e);
179
+ // 适配层:同一条事件也进统一工厂(面板/上传的唯一数据源)
180
+ mirrorEventToFactory(level, event, e.data);
181
+ return e;
182
+ }
183
+
184
+ /**
185
+ * 把条目分发给订阅者(面板转发用)。
186
+ * 为什么要 try 逐个兜住:订阅者是 UI 代码,出错不能反过来影响采集。
187
+ * 传出去的是**浅拷贝**:订阅者改了字段不会污染环形缓冲。
188
+ */
189
+ function notifySinks(st, entry) {
190
+ for (const fn of st.sinks) {
191
+ try { fn({ ...entry }); } catch { /* 单个订阅者异常不影响其它订阅者 */ }
192
+ }
193
+ }
194
+
195
+ /** 合并 1.5s 内的条目成一批,避免每条一个请求 */
196
+ function scheduleFlush(st) {
197
+ if (st.flushTimer) return;
198
+ st.flushTimer = setTimeout(() => { st.flushTimer = null; flush(st); }, st.config.flushEveryMs);
199
+ }
200
+
201
+ /** 落盘:只追加、失败静默(探针不能因为服务端不可用而影响页面) */
202
+ function flush(st) {
203
+ if (!st.pending.length) return;
204
+ const batch = st.pending.splice(0, st.config.flushBatchMax);
205
+ try {
206
+ st.internal = true;
207
+ // **刻意不加超时信号**(不是漏了):这是探针自己的上报(st.internal = true 表示"自报"),
208
+ // 用的是 keepalive —— 它要的正是"页面要走了也尽量送达",与"到点就掐断"直接冲突。
209
+ // 失败已被 .catch 吞掉、不影响页面;加了 AbortSignal.timeout 只会在卸载时多出 AbortError 噪音。
210
+ fetch('/api/diag', {
211
+ method: 'POST',
212
+ headers: { 'Content-Type': 'application/json' },
213
+ body: JSON.stringify({ entries: batch, sid: st.sid }),
214
+ keepalive: true,
215
+ }).catch(() => {}).finally(() => { st.internal = false; });
216
+ } catch {
217
+ st.internal = false;
218
+ }
219
+ }
220
+
221
+ // ---------------------------------------------------------------- 采集:全局错误
222
+
223
+ /** 取资源元素上的 URL(不同标签字段不同) */
224
+ function resourceUrl(el) {
225
+ return el.currentSrc || el.src || el.href || '';
226
+ }
227
+
228
+ /** window.onerror + unhandledrejection + 资源加载失败(capture 才能收到资源的 error) */
229
+ function captureWindowErrors(st) {
230
+ window.addEventListener('error', (ev) => {
231
+ try {
232
+ const tg = ev && ev.target;
233
+ if (tg && tg !== window && RESOURCE_TAGS.includes(tg.tagName)) {
234
+ push(st, 'error', 'resource', 'resource.fail', { tag: tg.tagName, url: resourceUrl(tg) });
235
+ return;
236
+ }
237
+ push(st, 'error', 'window', 'error', {
238
+ message: ev && ev.message,
239
+ source: ev && ev.filename,
240
+ line: ev && ev.lineno,
241
+ col: ev && ev.colno,
242
+ stack: ev && ev.error && ev.error.stack ? String(ev.error.stack).split('\n').slice(0, 5).join(' | ') : null,
243
+ });
244
+ } catch { /* 采集失败不能影响页面 */ }
245
+ }, true);
246
+
247
+ window.addEventListener('unhandledrejection', (ev) => {
248
+ try {
249
+ const r = ev && ev.reason;
250
+ push(st, 'error', 'window', 'rejection', {
251
+ reason: r instanceof Error
252
+ ? { name: r.name, message: r.message, stack: r.stack ? String(r.stack).split('\n').slice(0, 5).join(' | ') : null }
253
+ : r,
254
+ });
255
+ } catch { /* 同上 */ }
256
+ });
257
+ }
258
+
259
+ // ---------------------------------------------------------------- 采集:console
260
+
261
+ /** 包装 console.error/warn/info:幂等、保留原引用、内部日志不自激 */
262
+ function wrapConsole(st) {
263
+ const origin = { error: console.error, warn: console.warn, info: console.info };
264
+
265
+ /** 单条 console 调用的采集(motion.fail 单独结构化,其余按 console 记一条) */
266
+ function capture(level, text) {
267
+ if (level === 'warn' && MOTION_FAIL_RE.test(text)) {
268
+ const m = text.match(/Failed to load motion[:\s]*([^\s]+)/i);
269
+ push(st, 'error', 'motion', 'motion.fail', {
270
+ motion: m ? m[1] : null,
271
+ url: (text.match(/https?:\/\/[^\s)]+/) || [null])[0],
272
+ raw: text,
273
+ });
274
+ return;
275
+ }
276
+ push(st, level, 'console', 'console', { text });
277
+ }
278
+
279
+ const wrap = (level) => function wrapped(...args) {
280
+ if (!st.internal) {
281
+ try { capture(level, textOf(args, st)); } catch { /* 采集失败不能影响原日志 */ }
282
+ }
283
+ return origin[level].apply(console, args);
284
+ };
285
+
286
+ try {
287
+ console.error = wrap('error');
288
+ console.warn = wrap('warn');
289
+ console.info = wrap('info');
290
+ } catch { /* 某些环境 console 只读:放弃包装,其余采集照旧 */ }
291
+ }
292
+
293
+ // ---------------------------------------------------------------- 脱敏
294
+
295
+ /** 敏感 query 键:命中就把值替换成 ***(导出、落盘、面板、API 同源处理) */
296
+ const SENSITIVE_KEYS = /^(token|access_token|refresh_token|id_token|api_?key|apikey|secret|password|passwd|pwd|auth|authorization|session|sessionid|sid|code|sign|signature|sig)$/i;
297
+
298
+ /**
299
+ * URL 脱敏:去掉 user:pass@ 与敏感 query 值,保留路径与其余参数。
300
+ * 为什么要做:日志会进面板、进 API、进磁盘;把 token 抄进去等于把凭据留在截图里。
301
+ * @param {string} raw
302
+ * @returns {string}
303
+ */
304
+ export function sanitizeUrl(raw) {
305
+ const text = String(raw == null ? '' : raw);
306
+ if (!text) return text;
307
+ let out = text;
308
+ try {
309
+ const u = new URL(text, typeof location !== 'undefined' ? location.href : 'http://x/');
310
+ if (u.username || u.password) { u.username = '***'; u.password = ''; }
311
+ for (const key of [...u.searchParams.keys()]) {
312
+ if (SENSITIVE_KEYS.test(key)) u.searchParams.set(key, '***');
313
+ }
314
+ out = u.toString();
315
+ // 相对地址保持相对(避免把 http://host 前缀写进日志)
316
+ if (/^https?:\/\//i.test(text) === false && out.startsWith(u.origin)) out = out.slice(u.origin.length) || '/';
317
+ } catch {
318
+ out = text.replace(/([?&][^=&]*(?:token|key|secret|password|code|sign)[^=&]*=)[^&]*/gi, '$1***');
319
+ }
320
+ return out;
321
+ }
322
+
323
+ /** 请求体脱敏:只保留键名,值一律 ***(请求体里最可能藏凭据) */
324
+ function sanitizeBodyKeys(body) {
325
+ if (!body || typeof body !== 'object') return null;
326
+ try {
327
+ const out = {};
328
+ for (const k of Object.keys(body).slice(0, 20)) out[k] = SENSITIVE_KEYS.test(k) ? '***' : '[omitted]';
329
+ return out;
330
+ } catch {
331
+ return null;
332
+ }
333
+ }
334
+
335
+ // ---------------------------------------------------------------- 采集:网络
336
+
337
+ /** 诊断自身的上报请求不能被采集(否则失败时会自我放大) */
338
+ const SELF_URL_RE = /^\/api\/diag(?:[?#]|$)/;
339
+
340
+ /** 记一条网络失败(状态码或异常) */
341
+ function pushNet(st, method, url, status, err) {
342
+ const safe = sanitizeUrl(url);
343
+ if (status >= 400) {
344
+ push(st, 'warn', 'net', 'http.error', { method, status, url: safe });
345
+ } else if (err) {
346
+ push(st, 'error', 'net', 'net.fail', { method, url: safe, error: String(err && err.message || err).slice(0, 200) });
347
+ }
348
+ }
349
+
350
+ /** 采集网络失败:包装 fetch 与 XHR(只记 ≥400 与请求失败,不记成功请求) */
351
+ function captureNetwork(st) {
352
+ if (typeof window === 'undefined') return;
353
+ // fetch:保留原引用,异常/状态码都记一条
354
+ if (typeof window.fetch === 'function' && !window.fetch.__diagWrapped) {
355
+ const origin = window.fetch;
356
+ const wrapped = function diagFetch(input, init) {
357
+ const method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
358
+ const url = typeof input === 'string' ? input : String((input && input.url) || '');
359
+ return origin.apply(this, arguments).then((res) => {
360
+ try {
361
+ if (!st.internal && !SELF_URL_RE.test(url) && res && res.status >= 400) {
362
+ pushNet(st, method, url, res.status, null);
363
+ }
364
+ } catch { /* 忽略 */ }
365
+ return res;
366
+ }, (err) => {
367
+ try { if (!SELF_URL_RE.test(url)) pushNet(st, method, url, 0, err); } catch { /* 忽略 */ }
368
+ throw err;
369
+ });
370
+ };
371
+ wrapped.__diagWrapped = true;
372
+ try { window.fetch = wrapped; } catch { /* 只读环境放弃 */ }
373
+ }
374
+ // XHR:只挂 loadend(status 0 表示网络层失败)
375
+ const XHR = window.XMLHttpRequest;
376
+ if (XHR && XHR.prototype && !XHR.prototype.__diagWrapped) {
377
+ const open = XHR.prototype.open;
378
+ const send = XHR.prototype.send;
379
+ try {
380
+ XHR.prototype.open = function diagOpen(method, url) {
381
+ try { this.__diagMeta = { method: String(method || 'GET').toUpperCase(), url: String(url || '') }; } catch { /* 忽略 */ }
382
+ return open.apply(this, arguments);
383
+ };
384
+ XHR.prototype.send = function diagSend() {
385
+ try {
386
+ const meta = this.__diagMeta;
387
+ if (meta && !SELF_URL_RE.test(meta.url)) {
388
+ this.addEventListener('loadend', () => {
389
+ try {
390
+ const stt = Number(this.status) || 0;
391
+ if (stt >= 400 || stt === 0) pushNet(st, meta.method, meta.url, stt, stt === 0 ? new Error('network error') : null);
392
+ } catch { /* 忽略 */ }
393
+ });
394
+ }
395
+ } catch { /* 忽略 */ }
396
+ return send.apply(this, arguments);
397
+ };
398
+ XHR.prototype.__diagWrapped = true;
399
+ } catch { /* 只读环境放弃 */ }
400
+ }
401
+ }
402
+
403
+ // ---------------------------------------------------------------- 采集:交互
404
+
405
+ /** 交互低频采样:只记"发生了什么",不记每帧(滚轮/按下各按阈值节流) */
406
+ function captureInteractions(st) {
407
+ const throttled = (fn) => (ev) => {
408
+ const now = Date.now();
409
+ if (now - st.lastInteraction < st.config.interactionMinMs) return;
410
+ st.lastInteraction = now;
411
+ fn(ev);
412
+ };
413
+
414
+ window.addEventListener('wheel', throttled((ev) => {
415
+ push(st, 'debug', 'stage', 'interaction', { kind: 'wheel', deltaY: ev.deltaY, ctrl: ev.ctrlKey });
416
+ }), { passive: true });
417
+
418
+ window.addEventListener('pointerdown', throttled((ev) => {
419
+ push(st, 'debug', 'stage', 'interaction', { kind: 'pointerdown', x: ev.clientX, y: ev.clientY, button: ev.button });
420
+ }), { passive: true });
421
+
422
+ // 页面隐藏时补一次落盘(关标签/切后台时最后一批不至于丢)
423
+ window.addEventListener('pagehide', () => { try { flush(st); } catch { /* 忽略 */ } });
424
+ }
425
+
426
+ // ---------------------------------------------------------------- 对外接口
427
+
428
+ /** 组装 window.__DIAG__(API 名称稳定,供外部脚本与测试复用) */
429
+ function createApi(st) {
430
+ return {
431
+ __installed: true,
432
+ /** 本页会话 id(GET /api/diag?sid=… 用它取"当前这一轮"的条目) */
433
+ get sid() { return st.sid; },
434
+ config: st.config,
435
+ /** 业务主动上报事件(如 model.mount / stage.load) */
436
+ event(event, data, extra) {
437
+ return push(st, (extra && extra.level) || 'info', (extra && extra.src) || 'app', event, data, extra);
438
+ },
439
+ /**
440
+ * 注册订阅者:每产生一条事件就同步收到(面板转发用)。
441
+ * 同一函数重复注册只生效一次(Set 语义);返回取消订阅函数。
442
+ * @param {(entry: object) => void} fn
443
+ * @returns {() => void}
444
+ */
445
+ addSink(fn) {
446
+ if (typeof fn !== 'function') return () => {};
447
+ st.sinks.add(fn);
448
+ return () => { st.sinks.delete(fn); };
449
+ },
450
+ removeSink(fn) { return st.sinks.delete(fn); },
451
+ /**
452
+ * 从服务端读回**当前这一轮**(本页实例)的条目。
453
+ *
454
+ * 为什么必须带 sid:GET /api/diag 不带 sid 时后端只能兜底"最近活跃会话",
455
+ * 多标签页/重载过的场景会读到**上一轮**的数据,排查时极易误判。
456
+ * 返回里带 sidSource(query/latest/none)——拿到 latest 就说明本页还没上报过或已过期。
457
+ * @param {{limit?: number}} [options]
458
+ * @returns {Promise<object>} 与 GET /api/diag 同结构
459
+ */
460
+ async readServer(options = {}) {
461
+ const qs = new URLSearchParams();
462
+ qs.set('sid', st.sid);
463
+ if (options.limit) qs.set('limit', String(options.limit));
464
+ // 带超时:这是诊断链路,服务端卡住时不能把调用方的 await 一起拖死(也不该为此起重试风暴)
465
+ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
466
+ ? AbortSignal.timeout(5000)
467
+ : undefined;
468
+ const res = await fetch(`/api/diag?${qs.toString()}`, { headers: { Accept: 'application/json' }, signal });
469
+ if (!res.ok) throw new Error(`GET /api/diag 失败:HTTP ${res.status}`);
470
+ return res.json();
471
+ },
472
+ dump() { return st.buf.slice(); },
473
+ errors() { return st.buf.filter((e) => e.level === 'error'); },
474
+ warnings() { return st.buf.filter((e) => e.level === 'warn'); },
475
+ timeline() { return st.buf.map((e) => `${e.t} [${e.level}] ${e.src}${e.event ? `/${e.event}` : ''}`); },
476
+ tail(n) { return st.buf.slice(-(Number(n) || 20)); },
477
+ clear() { st.buf.length = 0; st.pending = []; return true; },
478
+ size() {
479
+ return {
480
+ buffered: st.buf.length,
481
+ capacity: st.config.capacity,
482
+ pendingFlush: st.pending.length,
483
+ sinceMs: Date.now() - st.t0,
484
+ };
485
+ },
486
+ flushNow() { flush(st); return true; },
487
+ };
488
+ }
489
+
490
+ /**
491
+ * 安装探针(幂等:已安装则直接返回现有实例)。
492
+ * @param {object} [override] 配置覆盖(优先级最高)
493
+ * @returns {object} window.__DIAG__ 实例
494
+ */
495
+ export function installDiag(override) {
496
+ // 已注入过就直接返回:可能被重复调用(热重载、手工注入),必须幂等
497
+ if (window.__DIAG__ && window.__DIAG__.__installed) return window.__DIAG__;
498
+
499
+ const st = createState(override);
500
+ captureWindowErrors(st);
501
+ wrapConsole(st);
502
+ captureInteractions(st);
503
+ captureNetwork(st);
504
+
505
+ const api = createApi(st);
506
+ window.__DIAG__ = api;
507
+ push(st, 'info', 'diag', 'diag.ready', { capacity: st.config.capacity, href: location.href });
508
+ return api;
509
+ }