prts-terrarchive 0.2.0

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 (287) hide show
  1. package/GAME_ASSETS.md +26 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +274 -0
  4. package/README.md +423 -0
  5. package/THIRD_PARTY_NOTICES.md +61 -0
  6. package/bin/bake-rhine-details.mjs +34 -0
  7. package/bin/install.js +273 -0
  8. package/bin/pack-map-assets.mjs +110 -0
  9. package/bin/preview-rhine.mjs +71 -0
  10. package/bin/rhine-detail-baker.ts +84 -0
  11. package/contracts/cloud_tools_v1.schema.json +243 -0
  12. package/contracts/corpus_i18n_v1.schema.json +179 -0
  13. package/contracts/corpus_tools_v1.schema.json +1801 -0
  14. package/cordis.patch.yml +32 -0
  15. package/docs/endfield-official-i18n.md +103 -0
  16. package/lib/client.js +3912 -0
  17. package/lib/endfield-map/map.js.br +0 -0
  18. package/lib/endfield-map/map.js.gz +0 -0
  19. package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.br +0 -0
  20. package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.gz +0 -0
  21. package/lib/endfield-map/resources/map-01616f534aaaeca1.png +0 -0
  22. package/lib/endfield-map/resources/map-1562e0dff4be3d23.png +0 -0
  23. package/lib/endfield-map/resources/map-160fe040f080f6a3.png +0 -0
  24. package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.br +0 -0
  25. package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.gz +0 -0
  26. package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.br +0 -0
  27. package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.gz +0 -0
  28. package/lib/endfield-map/resources/map-1d30baaaef3e29cc.png +0 -0
  29. package/lib/endfield-map/resources/map-21a61386af79cd12.json.br +0 -0
  30. package/lib/endfield-map/resources/map-21a61386af79cd12.json.gz +0 -0
  31. package/lib/endfield-map/resources/map-24b920b8dab49b44.json.br +0 -0
  32. package/lib/endfield-map/resources/map-24b920b8dab49b44.json.gz +0 -0
  33. package/lib/endfield-map/resources/map-26caf8d71247abb7.png +0 -0
  34. package/lib/endfield-map/resources/map-2729942c89545e73.json.br +0 -0
  35. package/lib/endfield-map/resources/map-2729942c89545e73.json.gz +0 -0
  36. package/lib/endfield-map/resources/map-29063a9802f67014.json.br +0 -0
  37. package/lib/endfield-map/resources/map-29063a9802f67014.json.gz +0 -0
  38. package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.br +0 -0
  39. package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.gz +0 -0
  40. package/lib/endfield-map/resources/map-2f98a873da81425e.json.br +0 -0
  41. package/lib/endfield-map/resources/map-2f98a873da81425e.json.gz +0 -0
  42. package/lib/endfield-map/resources/map-3ab978ae44190f80.json.br +0 -0
  43. package/lib/endfield-map/resources/map-3ab978ae44190f80.json.gz +0 -0
  44. package/lib/endfield-map/resources/map-3b62c80d14208902.json.br +0 -0
  45. package/lib/endfield-map/resources/map-3b62c80d14208902.json.gz +0 -0
  46. package/lib/endfield-map/resources/map-3d486e887ae121ac.png +0 -0
  47. package/lib/endfield-map/resources/map-418dc4865608fce5.png +0 -0
  48. package/lib/endfield-map/resources/map-4366b84607335f76.json.br +0 -0
  49. package/lib/endfield-map/resources/map-4366b84607335f76.json.gz +0 -0
  50. package/lib/endfield-map/resources/map-4371259d3ab7aac7.png +0 -0
  51. package/lib/endfield-map/resources/map-48dcc0fd55e13f7f.png +0 -0
  52. package/lib/endfield-map/resources/map-4aabda0e41197570.json.br +0 -0
  53. package/lib/endfield-map/resources/map-4aabda0e41197570.json.gz +0 -0
  54. package/lib/endfield-map/resources/map-505f5687b287043c.json.br +0 -0
  55. package/lib/endfield-map/resources/map-505f5687b287043c.json.gz +0 -0
  56. package/lib/endfield-map/resources/map-5177e9d1d46f47e2.png +0 -0
  57. package/lib/endfield-map/resources/map-51baeb59b7f8c28d.png +0 -0
  58. package/lib/endfield-map/resources/map-549561b46e621874.png +0 -0
  59. package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.br +0 -0
  60. package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.gz +0 -0
  61. package/lib/endfield-map/resources/map-5a6963909423b674.png +0 -0
  62. package/lib/endfield-map/resources/map-5a9c533c0552d7b7.png +0 -0
  63. package/lib/endfield-map/resources/map-5d20ae9ffa9a02b0.png +0 -0
  64. package/lib/endfield-map/resources/map-626c8847393f60bf.png +0 -0
  65. package/lib/endfield-map/resources/map-63d43cbec3bed3b8.png +0 -0
  66. package/lib/endfield-map/resources/map-661b6ec56e722574.png +0 -0
  67. package/lib/endfield-map/resources/map-6ddd384eca190828.png +0 -0
  68. package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.br +0 -0
  69. package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.gz +0 -0
  70. package/lib/endfield-map/resources/map-738ebaa002c33cfc.png +0 -0
  71. package/lib/endfield-map/resources/map-758c13be6085ec07.json.br +0 -0
  72. package/lib/endfield-map/resources/map-758c13be6085ec07.json.gz +0 -0
  73. package/lib/endfield-map/resources/map-78929f26d6844509.png +0 -0
  74. package/lib/endfield-map/resources/map-7f09c142950a0f74.png +0 -0
  75. package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.br +0 -0
  76. package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.gz +0 -0
  77. package/lib/endfield-map/resources/map-881343926a568f27.json.br +0 -0
  78. package/lib/endfield-map/resources/map-881343926a568f27.json.gz +0 -0
  79. package/lib/endfield-map/resources/map-8d1ade3f01d952b0.png +0 -0
  80. package/lib/endfield-map/resources/map-8fef3495bb7ed60f.png +0 -0
  81. package/lib/endfield-map/resources/map-907a7ecf8a0ce352.png +0 -0
  82. package/lib/endfield-map/resources/map-9113d5951c4db903.json.br +0 -0
  83. package/lib/endfield-map/resources/map-9113d5951c4db903.json.gz +0 -0
  84. package/lib/endfield-map/resources/map-94ad6634436224e3.json.br +0 -0
  85. package/lib/endfield-map/resources/map-94ad6634436224e3.json.gz +0 -0
  86. package/lib/endfield-map/resources/map-99722e61da5f3e53.png +0 -0
  87. package/lib/endfield-map/resources/map-9be9596cc059abe3.png +0 -0
  88. package/lib/endfield-map/resources/map-a4db9a4763e47fbf.png +0 -0
  89. package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.br +0 -0
  90. package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.gz +0 -0
  91. package/lib/endfield-map/resources/map-ab35d06875ee418f.png +0 -0
  92. package/lib/endfield-map/resources/map-ac51bb3e7623cd8b.png +0 -0
  93. package/lib/endfield-map/resources/map-ac830a66296bc83a.png +0 -0
  94. package/lib/endfield-map/resources/map-b3fd04209718e55e.json.br +0 -0
  95. package/lib/endfield-map/resources/map-b3fd04209718e55e.json.gz +0 -0
  96. package/lib/endfield-map/resources/map-b8164f1af7c5d895.png +0 -0
  97. package/lib/endfield-map/resources/map-b8cde93b965384d8.json.br +0 -0
  98. package/lib/endfield-map/resources/map-b8cde93b965384d8.json.gz +0 -0
  99. package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.br +0 -0
  100. package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.gz +0 -0
  101. package/lib/endfield-map/resources/map-bfc58277d5ba649d.png +0 -0
  102. package/lib/endfield-map/resources/map-c013f82048c6891a.png +0 -0
  103. package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.br +0 -0
  104. package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.gz +0 -0
  105. package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.br +0 -0
  106. package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.gz +0 -0
  107. package/lib/endfield-map/resources/map-c607f8b4c8ad8967.png +0 -0
  108. package/lib/endfield-map/resources/map-c6f20276b8781326.json.br +0 -0
  109. package/lib/endfield-map/resources/map-c6f20276b8781326.json.gz +0 -0
  110. package/lib/endfield-map/resources/map-d51adf5952af1e8e.png +0 -0
  111. package/lib/endfield-map/resources/map-d695eface22f8b74.json.br +0 -0
  112. package/lib/endfield-map/resources/map-d695eface22f8b74.json.gz +0 -0
  113. package/lib/endfield-map/resources/map-db11d394456a7465.png +0 -0
  114. package/lib/endfield-map/resources/map-dcc4d218581e3dc6.png +0 -0
  115. package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.br +0 -0
  116. package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.gz +0 -0
  117. package/lib/endfield-map/resources/map-defe5db7dabf748a.png +0 -0
  118. package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.br +0 -0
  119. package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.gz +0 -0
  120. package/lib/endfield-map/resources/map-e25ee9af6e493500.png +0 -0
  121. package/lib/endfield-map/resources/map-e3824082cd6f747d.json.br +0 -0
  122. package/lib/endfield-map/resources/map-e3824082cd6f747d.json.gz +0 -0
  123. package/lib/endfield-map/resources/map-e694a91e630b1bd6.png +0 -0
  124. package/lib/endfield-map/resources/map-eaa93af79d12a87d.png +0 -0
  125. package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.br +0 -0
  126. package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.gz +0 -0
  127. package/lib/endfield-map/resources/map-ede421a329dc78a4.png +0 -0
  128. package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.br +0 -0
  129. package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.gz +0 -0
  130. package/lib/endfield-map/resources/map-fb5f4a59a181452c.png +0 -0
  131. package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.br +0 -0
  132. package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.gz +0 -0
  133. package/lib/rhine/assets/archive-assembly.glb +0 -0
  134. package/lib/rhine/assets/archive-cassette.glb +0 -0
  135. package/lib/rhine/fonts/MiSans-Bold.woff2 +0 -0
  136. package/lib/rhine/fonts/MiSans-Demibold.woff2 +0 -0
  137. package/lib/rhine/fonts/MiSans-Light.woff2 +0 -0
  138. package/lib/rhine/fonts/MiSans-Regular.woff2 +0 -0
  139. package/lib/rhine/fonts/MiSans-license.pdf +0 -0
  140. package/lib/rhine/fonts/NOTICE.txt +6 -0
  141. package/lib/rhine/licenses/RhineLabUI-MIT.txt +21 -0
  142. package/lib/rhine/licenses/entities-BSD-2-Clause.txt +11 -0
  143. package/lib/rhine/licenses/linkify-it-MIT.txt +22 -0
  144. package/lib/rhine/licenses/markdown-it-MIT.txt +22 -0
  145. package/lib/rhine/licenses/mdurl-MIT.txt +45 -0
  146. package/lib/rhine/licenses/punycode.js-MIT.txt +20 -0
  147. package/lib/rhine/licenses/rolling-number-MIT.txt +21 -0
  148. package/lib/rhine/licenses/three-MIT.txt +21 -0
  149. package/lib/rhine/licenses/uc.micro-MIT.txt +20 -0
  150. package/lib/rhine/rhine.css +1 -0
  151. package/lib/rhine/rhine.js +5106 -0
  152. package/lib/skins/common.css +31 -0
  153. package/lib/skins/endfield-aic.css +141 -0
  154. package/lib/skins/prts-agent.css +136 -0
  155. package/lib/skins/rhine-lab.css +70 -0
  156. package/package.json +112 -0
  157. package/presets/definition.js +26 -0
  158. package/presets/prts/agent.cordis.yml +25 -0
  159. package/presets/prts/preset.yml +3 -0
  160. package/presets/register.js +86 -0
  161. package/resources/entity-routing.json +21 -0
  162. package/skills/prts-investigation/SKILL.md +121 -0
  163. package/skills/prts-retrieval/SKILL.md +35 -0
  164. package/skills/prts-retrieval/references/cloud-tools.md +13 -0
  165. package/skills/prts-retrieval/references/diagnostics.md +15 -0
  166. package/skills/prts-retrieval/references/local-tools.md +24 -0
  167. package/skills/prts-retrieval/references/module-arknights.md +23 -0
  168. package/skills/prts-retrieval/references/module-dual.md +15 -0
  169. package/skills/prts-retrieval/references/module-endfield.md +21 -0
  170. package/skills/prts-retrieval/references/retrieval-process.md +44 -0
  171. package/skills/prts-retrieval/references/retrieval-recipes-dual.md +34 -0
  172. package/skills/prts-retrieval/references/retrieval-recipes-endfield.md +37 -0
  173. package/skills/prts-retrieval/references/retrieval-recipes.md +39 -0
  174. package/skills/prts-retrieval/references/tools-runtime.md +48 -0
  175. package/skills/prts-retrieval/references/wiki-schema.md +38 -0
  176. package/src/archive-presentation.js +287 -0
  177. package/src/cloud-projection.js +124 -0
  178. package/src/cloud.js +498 -0
  179. package/src/entity-recognizer.js +311 -0
  180. package/src/entity-routing.js +82 -0
  181. package/src/evidence-state.js +332 -0
  182. package/src/i18n.js +365 -0
  183. package/src/index.js +1261 -0
  184. package/src/installer.js +1362 -0
  185. package/src/investigation-review.js +87 -0
  186. package/src/investigation-store.js +579 -0
  187. package/src/investigation-tools.js +113 -0
  188. package/src/localization-format.js +23 -0
  189. package/src/manual-search.js +103 -0
  190. package/src/read.js +1097 -0
  191. package/src/release-compatibility.js +73 -0
  192. package/src/release-watch.js +40 -0
  193. package/src/search-projection.js +139 -0
  194. package/src/search.js +1522 -0
  195. package/src/skill.js +98 -0
  196. package/src/source-map.js +348 -0
  197. package/src/state.js +314 -0
  198. package/src/store.js +1569 -0
  199. package/src/timeline.js +386 -0
  200. package/src/ui.js +1353 -0
  201. package/src/wiki.js +111 -0
  202. package/tsconfig.rhine.json +15 -0
  203. package/ui/rhine/archive-navigation-limit.ts +31 -0
  204. package/ui/rhine/archive-rack.ts +83 -0
  205. package/ui/rhine/assets/evidence-observatory.webp +0 -0
  206. package/ui/rhine/board-plane-transform.ts +24 -0
  207. package/ui/rhine/catalogue.ts +72 -0
  208. package/ui/rhine/deferred-preparation.ts +146 -0
  209. package/ui/rhine/evidence-board-controls.css +22 -0
  210. package/ui/rhine/evidence-board-history.ts +73 -0
  211. package/ui/rhine/evidence-board-model.ts +189 -0
  212. package/ui/rhine/evidence-board-panel.ts +762 -0
  213. package/ui/rhine/evidence-board-scene.ts +548 -0
  214. package/ui/rhine/evidence-board.css +191 -0
  215. package/ui/rhine/evidence-card-art.ts +470 -0
  216. package/ui/rhine/evidence-card-style.ts +17 -0
  217. package/ui/rhine/evidence-inbox-scene.ts +43 -0
  218. package/ui/rhine/evidence-inbox.css +29 -0
  219. package/ui/rhine/evidence-inbox.ts +104 -0
  220. package/ui/rhine/index.ts +12 -0
  221. package/ui/rhine/interaction.css +20 -0
  222. package/ui/rhine/investigation-board-plane.ts +5 -0
  223. package/ui/rhine/investigation-board.css +34 -0
  224. package/ui/rhine/investigation-board.ts +348 -0
  225. package/ui/rhine/investigation-edits.ts +46 -0
  226. package/ui/rhine/investigation-layout.ts +29 -0
  227. package/ui/rhine/label-prefetch.ts +34 -0
  228. package/ui/rhine/looping-title.ts +40 -0
  229. package/ui/rhine/manual-search.css +41 -0
  230. package/ui/rhine/manual-search.ts +89 -0
  231. package/ui/rhine/original/appearance.ts +180 -0
  232. package/ui/rhine/original/archive-label.ts +84 -0
  233. package/ui/rhine/original/archive-lighting.ts +39 -0
  234. package/ui/rhine/original/archive-loop.ts +82 -0
  235. package/ui/rhine/original/archive-refill.ts +64 -0
  236. package/ui/rhine/original/array-detail-data.ts +2 -0
  237. package/ui/rhine/original/array-detail.ts +41 -0
  238. package/ui/rhine/original/array-interior.ts +411 -0
  239. package/ui/rhine/original/array-occlusion.ts +166 -0
  240. package/ui/rhine/original/array-shell.ts +38 -0
  241. package/ui/rhine/original/array-visibility.ts +309 -0
  242. package/ui/rhine/original/asset-url.ts +4 -0
  243. package/ui/rhine/original/brand.ts +12 -0
  244. package/ui/rhine/original/cassette-lod.ts +81 -0
  245. package/ui/rhine/original/data.ts +21 -0
  246. package/ui/rhine/original/decryption.ts +185 -0
  247. package/ui/rhine/original/detail-signature.ts +18 -0
  248. package/ui/rhine/original/glass-reveal.ts +45 -0
  249. package/ui/rhine/original/internal-optics.ts +70 -0
  250. package/ui/rhine/original/label-renderer.ts +119 -0
  251. package/ui/rhine/original/label-textures.ts +117 -0
  252. package/ui/rhine/original/model-viewer.ts +658 -0
  253. package/ui/rhine/original/motion.ts +116 -0
  254. package/ui/rhine/original/opaque-backfaces.ts +64 -0
  255. package/ui/rhine/original/program-preparation.ts +111 -0
  256. package/ui/rhine/original/quality-renderer.ts +97 -0
  257. package/ui/rhine/original/quality-settings.ts +111 -0
  258. package/ui/rhine/original/render-quality.ts +163 -0
  259. package/ui/rhine/original/scene.ts +2051 -0
  260. package/ui/rhine/original/shelf-interior.ts +142 -0
  261. package/ui/rhine/original/ui-transitions.ts +111 -0
  262. package/ui/rhine/original/viewer-camera.ts +92 -0
  263. package/ui/rhine/original/viewport-layout.ts +41 -0
  264. package/ui/rhine/original-ui.css +2334 -0
  265. package/ui/rhine/readability.css +46 -0
  266. package/ui/rhine/reading-object.ts +96 -0
  267. package/ui/rhine/reading-origin.ts +28 -0
  268. package/ui/rhine/reading-room.css +55 -0
  269. package/ui/rhine/report-markdown.ts +98 -0
  270. package/ui/rhine/scene-edge-navigation.ts +84 -0
  271. package/ui/rhine/scene.ts +1726 -0
  272. package/ui/rhine/shelf-layout.ts +38 -0
  273. package/ui/rhine/snapshot-update-gate.ts +22 -0
  274. package/ui/rhine/source-reading.ts +15 -0
  275. package/ui/rhine/source-selection.css +164 -0
  276. package/ui/rhine/temporary-performance-observers.ts +163 -0
  277. package/ui/rhine/temporary-performance-panel.ts +124 -0
  278. package/ui/rhine/temporary-performance.css +19 -0
  279. package/ui/rhine/temporary-performance.ts +552 -0
  280. package/ui/rhine/temporary-render-diagnostics.ts +105 -0
  281. package/ui/rhine/tool-activity.ts +69 -0
  282. package/ui/rhine/types.ts +219 -0
  283. package/ui/rhine/visible-scene-hit.ts +13 -0
  284. package/ui/rhine/workbench.css +818 -0
  285. package/ui/rhine/workbench.ts +2245 -0
  286. package/ui/rhine/workspace-travel.ts +32 -0
  287. package/vite.rhine.config.ts +19 -0
package/src/ui.js ADDED
@@ -0,0 +1,1353 @@
1
+ /**
2
+ * 设置界面的 Host 半边:通过 Connection 的共享 Fetch 通道
3
+ * 挂载资料管理与静态资源,供 Web 和 Electron 设置 tab 调用。
4
+ *
5
+ * 纯路由逻辑抽成 buildApi()(方法+路径+体 → {status, json}),
6
+ * 宿主传输负责认证;/api/prts-corpus/rpc 接受 {endpoint,payload} JSON。
7
+ *
8
+ * 业务内部路由一览:
9
+ * GET /api/prts-corpus/status 当前版本/文档数/下载进度/生效配置(脱敏)
10
+ * GET /api/prts-corpus/releases 本地已装 release 清单(大小/版本/是否激活/需解压)
11
+ * GET /api/prts-corpus/check-update 联网检查站点是否有更新版本(本地/远程对比)
12
+ * POST /api/prts-corpus/download 触发下载 { releaseId? };省略则使用 PRTS.chat current
13
+ * POST /api/prts-corpus/activate 切换激活版本 { releaseId }(热重载 store)
14
+ * POST /api/prts-corpus/delete 删除非当前版本 { releaseId }
15
+ * GET /api/prts-corpus/config 生效配置 + 用户层(脱敏)
16
+ * PUT /api/prts-corpus/config 写配置补丁(立即生效,cloud 工具热重建)
17
+ */
18
+ import { lstat, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
19
+ import { readdirSync } from 'node:fs'
20
+ import { randomBytes } from 'node:crypto'
21
+ import { extname, join, normalize, resolve, sep } from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
23
+ import { promisify } from 'node:util'
24
+ import { brotliDecompress, gunzip } from 'node:zlib'
25
+ import { ensureCorpusRelease, missingEnabledGamePacks, RELEASE_ID_PATTERN,
26
+ readCurrentReleasePointer, resolveTrustedCurrentRelease, validateLocalRelease,
27
+ withReleaseMutationLock } from './installer.js'
28
+ import { redactConfig } from './state.js'
29
+ import { executeRead } from './read.js'
30
+ import { executeSearch } from './search.js'
31
+ import { assertCorpusVersion, corpusVersionSnapshot, documentGame } from './store.js'
32
+ import { localArchivePresentation } from './archive-presentation.js'
33
+ import { createManualCloudSearch } from './manual-search.js'
34
+
35
+ const MAX_BODY_BYTES = 1024 * 1024
36
+ const SKIN_STYLES_ROOT = resolve(fileURLToPath(new URL('../lib/skins/', import.meta.url)))
37
+ const SKIN_STYLESHEETS = Object.freeze({
38
+ 'common.css': join(SKIN_STYLES_ROOT, 'common.css'),
39
+ 'prts-agent.css': join(SKIN_STYLES_ROOT, 'prts-agent.css'),
40
+ 'endfield-aic.css': join(SKIN_STYLES_ROOT, 'endfield-aic.css'),
41
+ 'rhine-lab.css': join(SKIN_STYLES_ROOT, 'rhine-lab.css'),
42
+ })
43
+ const RHINE_ROOT = resolve(fileURLToPath(new URL('../lib/rhine/', import.meta.url)))
44
+ const RHINE_ASSETS = Object.freeze({
45
+ 'rhine.js': 'text/javascript; charset=utf-8',
46
+ 'rhine.css': 'text/css; charset=utf-8',
47
+ 'assets/archive-cassette.glb': 'model/gltf-binary',
48
+ 'assets/archive-assembly.glb': 'model/gltf-binary',
49
+ 'fonts/MiSans-Light.woff2': 'font/woff2',
50
+ 'fonts/MiSans-Regular.woff2': 'font/woff2',
51
+ 'fonts/MiSans-Demibold.woff2': 'font/woff2',
52
+ 'fonts/MiSans-Bold.woff2': 'font/woff2',
53
+ 'fonts/MiSans-license.pdf': 'application/pdf',
54
+ 'fonts/NOTICE.txt': 'text/plain; charset=utf-8',
55
+ })
56
+ const ARCHIVE_CATALOG_TYPES = Object.freeze(['story', 'character_bundle', 'character_wiki',
57
+ 'story_wiki', 'character_activity_wiki', 'terra_journey', 'entity_profile'])
58
+ const ENDFIELD_MAP_ROOT = resolve(fileURLToPath(new URL('../lib/endfield-map/', import.meta.url)))
59
+ const ENDFIELD_MAP_MIME = Object.freeze({
60
+ '.js': 'text/javascript; charset=utf-8',
61
+ '.json': 'application/json; charset=utf-8',
62
+ '.png': 'image/png',
63
+ })
64
+ const brotliDecompressAsync = promisify(brotliDecompress)
65
+ const gunzipAsync = promisify(gunzip)
66
+ const MAX_MAP_COMPRESSED_BYTES = 8 * 1024 * 1024
67
+ const MAX_MAP_TEXT_BYTES = 16 * 1024 * 1024
68
+ const MAX_MAP_BINARY_BYTES = 32 * 1024 * 1024
69
+ const MAX_IDENTITY_CACHE_BYTES = 24 * 1024 * 1024
70
+ const MAX_MAP_REQUESTS = 32
71
+ const MAP_RESPONSE_TIMEOUT_MS = 60_000
72
+ const MAX_IDENTITY_WAITERS = 32
73
+ const MAX_IDENTITY_REQUESTS = 64
74
+ const ENDFIELD_MAP_ASSETS = (() => {
75
+ const assets = new Set(['map.js'])
76
+ // HTTP 只接受打包时实际存在的逻辑资源名。去掉 .br/.gz 物理后缀后
77
+ // 建表,防止攻击者用随机 .json 路径制造无界的解压 miss 任务。
78
+ for (const name of readdirSync(join(ENDFIELD_MAP_ROOT, 'resources'))) {
79
+ const logical = name.replace(/\.(?:br|gz)$/u, '')
80
+ if (/^map-[0-9a-f]{16}\.(?:json|png)$/u.test(logical)) {
81
+ assets.add(`resources/${logical}`)
82
+ }
83
+ }
84
+ return assets
85
+ })()
86
+ const identityCache = new Map()
87
+ const identityInflight = new Map()
88
+ let identityCacheBytes = 0
89
+ let activeIdentityDecompressions = 0
90
+ let activeIdentityRequests = 0
91
+ let activeMapRequests = 0
92
+ const identityWaiters = []
93
+
94
+ /** Serve only packaged, fixed-name stylesheets; caller input never becomes a filesystem path. */
95
+ async function serveSkinStylesheet(req, res, fileName) {
96
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
97
+ res.writeHead(405, { allow: 'GET, HEAD' })
98
+ res.end()
99
+ return
100
+ }
101
+ const target = SKIN_STYLESHEETS[fileName]
102
+ if (!target) {
103
+ res.writeHead(404)
104
+ res.end()
105
+ return
106
+ }
107
+ try {
108
+ const body = await readFile(target)
109
+ res.writeHead(200, {
110
+ 'content-type': 'text/css; charset=utf-8',
111
+ 'content-length': body.length,
112
+ 'cache-control': 'no-cache',
113
+ 'x-content-type-options': 'nosniff',
114
+ 'cross-origin-resource-policy': 'same-origin',
115
+ })
116
+ res.end(req.method === 'HEAD' ? undefined : body)
117
+ } catch (error) {
118
+ res.writeHead(error?.code === 'ENOENT' ? 404 : 500)
119
+ res.end()
120
+ }
121
+ }
122
+
123
+ const identityFault = (code, message) => Object.assign(new Error(message), { code })
124
+
125
+ function assertIdentityRequestActive(signal) {
126
+ if (signal?.aborted) throw identityFault('CANCELLED', '地图资源请求已取消')
127
+ }
128
+
129
+ function acquireIdentityRequest() {
130
+ if (activeIdentityRequests >= MAX_IDENTITY_REQUESTS) {
131
+ throw identityFault('MAP_BUSY', '地图资源解压请求过多')
132
+ }
133
+ activeIdentityRequests += 1
134
+ let released = false
135
+ return () => {
136
+ if (released) return
137
+ released = true
138
+ activeIdentityRequests -= 1
139
+ }
140
+ }
141
+
142
+ function acquireMapRequest() {
143
+ if (activeMapRequests >= MAX_MAP_REQUESTS) {
144
+ throw identityFault('MAP_BUSY', '地图静态资源请求过多')
145
+ }
146
+ activeMapRequests += 1
147
+ let released = false
148
+ return () => {
149
+ if (released) return
150
+ released = true
151
+ activeMapRequests -= 1
152
+ }
153
+ }
154
+
155
+ /**
156
+ * 地图资源会把数 MiB 的 Buffer 交给 ServerResponse。处理函数返回并不代表
157
+ * Buffer 已被 socket 消费,因此总请求槽必须持有到 finish/close;否则不读取
158
+ * 响应的慢客户端可不断累积待发送 Buffer。close/aborted 同时取消尚在排队的
159
+ * identity 解压任务。
160
+ */
161
+ function trackMapResponse(req, res, releaseRequest) {
162
+ const controller = new AbortController()
163
+ const hasResponseLifecycle = typeof res.once === 'function'
164
+ let workSettled = false
165
+ let responseSettled = false
166
+ let released = false
167
+ const responseTimer = setTimeout(() => {
168
+ if (!controller.signal.aborted) controller.abort()
169
+ responseSettled = true
170
+ res.destroy?.()
171
+ releaseIfSettled()
172
+ }, MAP_RESPONSE_TIMEOUT_MS)
173
+ responseTimer.unref?.()
174
+ const cleanup = () => {
175
+ clearTimeout(responseTimer)
176
+ req.removeListener?.('aborted', onDisconnect)
177
+ res.removeListener?.('finish', onFinish)
178
+ res.removeListener?.('close', onDisconnect)
179
+ }
180
+ const releaseIfSettled = () => {
181
+ if (released) return
182
+ if (!workSettled || !responseSettled) return
183
+ released = true
184
+ cleanup()
185
+ releaseRequest()
186
+ }
187
+ const onDisconnect = () => {
188
+ if (!controller.signal.aborted) controller.abort()
189
+ responseSettled = true
190
+ releaseIfSettled()
191
+ }
192
+ const onFinish = () => {
193
+ responseSettled = true
194
+ releaseIfSettled()
195
+ }
196
+ req.once?.('aborted', onDisconnect)
197
+ res.once?.('finish', onFinish)
198
+ res.once?.('close', onDisconnect)
199
+ if (req.aborted || res.destroyed) onDisconnect()
200
+ return {
201
+ signal: controller.signal,
202
+ complete(responseHandedOff) {
203
+ workSettled = true
204
+ if (!responseHandedOff) {
205
+ if (!controller.signal.aborted) controller.abort()
206
+ responseSettled = true
207
+ } else if (!hasResponseLifecycle) {
208
+ // 单测或非 Node 兼容宿主可能没有 EventEmitter 接口;这种情况下
209
+ // 无法观察 socket 生命周期,只能在 end() 后视为发送完成。
210
+ responseSettled = true
211
+ }
212
+ releaseIfSettled()
213
+ },
214
+ }
215
+ }
216
+
217
+ async function withIdentityDecompressionSlot(operation, { signal } = {}) {
218
+ assertIdentityRequestActive(signal)
219
+ if (activeIdentityDecompressions < 2) activeIdentityDecompressions += 1
220
+ else {
221
+ if (identityWaiters.length >= MAX_IDENTITY_WAITERS) {
222
+ throw identityFault('MAP_BUSY', '地图资源解压队列已满')
223
+ }
224
+ await new Promise((resolveSlot, rejectSlot) => {
225
+ const waiter = { take: null }
226
+ const onAbort = () => {
227
+ const index = identityWaiters.indexOf(waiter)
228
+ if (index >= 0) identityWaiters.splice(index, 1)
229
+ rejectSlot(identityFault('CANCELLED', '地图资源请求已取消'))
230
+ }
231
+ waiter.take = () => {
232
+ signal?.removeEventListener?.('abort', onAbort)
233
+ resolveSlot()
234
+ }
235
+ signal?.addEventListener?.('abort', onAbort, { once: true })
236
+ identityWaiters.push(waiter)
237
+ })
238
+ }
239
+ try {
240
+ assertIdentityRequestActive(signal)
241
+ return await operation()
242
+ } finally {
243
+ const next = identityWaiters.shift()
244
+ if (next) next.take()
245
+ else activeIdentityDecompressions -= 1
246
+ }
247
+ }
248
+
249
+ async function readBoundedStaticFile(path, maximum, { signal } = {}) {
250
+ assertIdentityRequestActive(signal)
251
+ const info = await lstat(path)
252
+ assertIdentityRequestActive(signal)
253
+ if (!info.isFile() || info.isSymbolicLink() || info.size > maximum) {
254
+ throw new Error('地图资源类型或大小非法')
255
+ }
256
+ let bytes
257
+ try {
258
+ bytes = await readFile(path, signal ? { signal } : undefined)
259
+ } catch (error) {
260
+ if (signal?.aborted && (error?.name === 'AbortError' || error?.code === 'ABORT_ERR')) {
261
+ throw identityFault('CANCELLED', '地图资源请求已取消')
262
+ }
263
+ throw error
264
+ }
265
+ assertIdentityRequestActive(signal)
266
+ if (bytes.length > maximum) throw new Error('地图资源超过大小上限')
267
+ return bytes
268
+ }
269
+
270
+ /** Fixed assets reject symlinks in every component below the packaged root. */
271
+ async function assertRhineAssetPath(target, signal) {
272
+ let path = RHINE_ROOT
273
+ for (const component of ['', ...target.slice(RHINE_ROOT.length + 1).split(sep)]) {
274
+ assertIdentityRequestActive(signal)
275
+ if (component) path = join(path, component)
276
+ const info = await lstat(path)
277
+ if (info.isSymbolicLink() || (path !== target && !info.isDirectory())) {
278
+ throw Object.assign(new Error('资料馆资源不存在'), { code: 'ENOENT' })
279
+ }
280
+ }
281
+ }
282
+
283
+ async function serveRhineAsset(req, res, relative) {
284
+ if (!Object.hasOwn(RHINE_ASSETS, relative)) { res.writeHead(404); res.end(); return }
285
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
286
+ res.writeHead(405, { allow: 'GET, HEAD' }); res.end(); return
287
+ }
288
+ let release
289
+ try { release = acquireMapRequest() }
290
+ catch { res.writeHead(503, { 'retry-after': '1' }); res.end(); return }
291
+ const response = trackMapResponse(req, res, release)
292
+ let handedOff = false
293
+ try {
294
+ const target = join(RHINE_ROOT, relative)
295
+ await assertRhineAssetPath(target, response.signal)
296
+ const bytes = await readBoundedStaticFile(target, MAX_MAP_BINARY_BYTES, { signal: response.signal })
297
+ assertIdentityRequestActive(response.signal)
298
+ res.writeHead(200, { 'content-type': RHINE_ASSETS[relative], 'content-length': bytes.length,
299
+ 'cache-control': 'no-cache', 'x-content-type-options': 'nosniff',
300
+ 'cross-origin-resource-policy': 'same-origin' })
301
+ res.end(req.method === 'HEAD' ? undefined : bytes)
302
+ handedOff = true
303
+ } catch (error) {
304
+ if (!response.signal.aborted) {
305
+ res.writeHead(['ENOENT', 'EISDIR', 'ENOTDIR'].includes(error?.code) ? 404 : 500)
306
+ res.end(); handedOff = true
307
+ }
308
+ } finally { response.complete(handedOff) }
309
+ }
310
+
311
+ function acceptedEncodingQuality(header, encoding) {
312
+ const raw = String(header ?? '').trim()
313
+ if (!raw) return encoding === 'identity' ? 1 : 0
314
+ const qualities = new Map()
315
+ for (const part of raw.split(',')) {
316
+ const [namePart, ...parameters] = part.trim().split(';')
317
+ const name = namePart.trim().toLowerCase()
318
+ if (!name) continue
319
+ let quality = 1
320
+ const q = parameters.map((entry) => entry.trim())
321
+ .find((entry) => entry.toLowerCase().startsWith('q='))
322
+ if (q) {
323
+ const parsed = Number(q.slice(2))
324
+ quality = Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : 0
325
+ }
326
+ qualities.set(name, quality)
327
+ }
328
+ if (qualities.has(encoding)) return qualities.get(encoding)
329
+ if (encoding === 'identity') return qualities.get('*') === 0 ? 0 : 1
330
+ return qualities.get('*') ?? 0
331
+ }
332
+
333
+ async function readIdentityTextAsset(target, { signal } = {}) {
334
+ assertIdentityRequestActive(signal)
335
+ try {
336
+ return await readBoundedStaticFile(target, MAX_MAP_TEXT_BYTES, { signal })
337
+ } catch (error) {
338
+ if (error?.code !== 'ENOENT') throw error
339
+ }
340
+ assertIdentityRequestActive(signal)
341
+ const cached = identityCache.get(target)
342
+ if (cached) {
343
+ identityCache.delete(target)
344
+ identityCache.set(target, cached)
345
+ return cached
346
+ }
347
+ if (identityInflight.has(target)) {
348
+ const bytes = await identityInflight.get(target)
349
+ assertIdentityRequestActive(signal)
350
+ return bytes
351
+ }
352
+ // 共享 operation 不绑定创建它的首个 HTTP 请求。每个调用者只取消自己的
353
+ // 等待;否则首个客户端断连会把随后加入的正常客户端一并取消。
354
+ const operation = withIdentityDecompressionSlot(async () => {
355
+ for (const [extension, decompress] of [['.br', brotliDecompressAsync], ['.gz', gunzipAsync]]) {
356
+ try {
357
+ const compressed = await readBoundedStaticFile(`${target}${extension}`,
358
+ MAX_MAP_COMPRESSED_BYTES)
359
+ return await decompress(compressed, { maxOutputLength: MAX_MAP_TEXT_BYTES })
360
+ } catch (error) {
361
+ if (error?.code !== 'ENOENT') throw error
362
+ }
363
+ }
364
+ throw Object.assign(new Error('地图资源不存在'), { code: 'ENOENT' })
365
+ }).then((bytes) => {
366
+ if (bytes.length <= MAX_IDENTITY_CACHE_BYTES) {
367
+ identityCache.set(target, bytes)
368
+ identityCacheBytes += bytes.length
369
+ while (identityCacheBytes > MAX_IDENTITY_CACHE_BYTES && identityCache.size) {
370
+ const oldest = identityCache.keys().next().value
371
+ const removed = identityCache.get(oldest)
372
+ identityCache.delete(oldest)
373
+ identityCacheBytes -= removed.length
374
+ }
375
+ }
376
+ return bytes
377
+ }).finally(() => { identityInflight.delete(target) })
378
+ identityInflight.set(target, operation)
379
+ const bytes = await operation
380
+ assertIdentityRequestActive(signal)
381
+ return bytes
382
+ }
383
+
384
+ /**
385
+ * 静态回传地图资源。文本类(.js/.json)在包内以 .br/.gz 预压缩副本存放
386
+ * (bin/pack-map-assets.mjs,与 endfield.prts.chat 同一压缩方法):按请求
387
+ * Accept-Encoding 直接回传对应编码 + Content-Encoding 头,浏览器透明解压,
388
+ * 服务端零解压开销。开发态存在明文原件时自动回退。
389
+ */
390
+ async function serveEndfieldMapAsset(req, res, routePrefix) {
391
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
392
+ res.writeHead(405)
393
+ res.end()
394
+ return
395
+ }
396
+ let pathname
397
+ try {
398
+ pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://dsh.invalid').pathname)
399
+ } catch {
400
+ // WHATWG URL 会原样保留非法百分号序列(如 %E4%A6),decodeURIComponent
401
+ // 对其抛 URIError;这是畸形请求而非服务器故障,按 400 结束。
402
+ res.writeHead(400)
403
+ res.end()
404
+ return
405
+ }
406
+ const relative = pathname.slice(routePrefix.length).replace(/^\/+/, '')
407
+ if (!ENDFIELD_MAP_ASSETS.has(relative)) {
408
+ res.writeHead(404)
409
+ res.end()
410
+ return
411
+ }
412
+ let releaseMapRequest
413
+ try {
414
+ releaseMapRequest = acquireMapRequest()
415
+ } catch (error) {
416
+ res.writeHead(503, { 'retry-after': '1' })
417
+ res.end()
418
+ return
419
+ }
420
+ const response = trackMapResponse(req, res, releaseMapRequest)
421
+ let handedOff = false
422
+ const endResponse = (status, headers, body) => {
423
+ res.writeHead(status, headers)
424
+ res.end(body)
425
+ handedOff = true
426
+ }
427
+ try {
428
+ if (response.signal.aborted) return
429
+ const target = resolve(normalize(join(ENDFIELD_MAP_ROOT, relative)))
430
+ if (target !== ENDFIELD_MAP_ROOT && !target.startsWith(ENDFIELD_MAP_ROOT + sep)) {
431
+ endResponse(403)
432
+ return
433
+ }
434
+ const textAsset = /\.(js|json)$/.test(target)
435
+ const accept = req.headers?.['accept-encoding']
436
+ const candidates = textAsset
437
+ ? [
438
+ { encoding: 'br', extension: '.br', quality: acceptedEncodingQuality(accept, 'br'), priority: 3 },
439
+ { encoding: 'gzip', extension: '.gz', quality: acceptedEncodingQuality(accept, 'gzip'), priority: 2 },
440
+ { encoding: 'identity', extension: '', quality: acceptedEncodingQuality(accept, 'identity'), priority: 1 },
441
+ ].filter((candidate) => candidate.quality > 0)
442
+ .sort((left, right) => right.quality - left.quality || right.priority - left.priority)
443
+ : [{ encoding: 'identity', extension: '', quality: 1, priority: 1 }]
444
+ if (!candidates.length) {
445
+ endResponse(406, { vary: 'accept-encoding' })
446
+ return
447
+ }
448
+ for (const candidate of candidates) {
449
+ let releaseIdentityRequest = null
450
+ try {
451
+ let body
452
+ if (candidate.encoding === 'identity' && textAsset) {
453
+ releaseIdentityRequest = acquireIdentityRequest()
454
+ body = await readIdentityTextAsset(target, { signal: response.signal })
455
+ } else {
456
+ body = await readBoundedStaticFile(`${target}${candidate.extension}`,
457
+ textAsset ? MAX_MAP_COMPRESSED_BYTES : MAX_MAP_BINARY_BYTES,
458
+ { signal: response.signal })
459
+ }
460
+ assertIdentityRequestActive(response.signal)
461
+ endResponse(200, {
462
+ 'content-type': ENDFIELD_MAP_MIME[extname(target)] ?? 'application/octet-stream',
463
+ ...(candidate.encoding === 'identity' ? {} : { 'content-encoding': candidate.encoding }),
464
+ 'cache-control': target.endsWith('map.js') ? 'no-cache' : 'public, max-age=31536000, immutable',
465
+ ...(textAsset ? { vary: 'accept-encoding' } : {}),
466
+ }, req.method === 'HEAD' ? undefined : body)
467
+ return
468
+ } catch (error) {
469
+ if (error?.code === 'CANCELLED') return
470
+ if (error?.code === 'MAP_BUSY') {
471
+ endResponse(503, { 'retry-after': '1', ...(textAsset ? { vary: 'accept-encoding' } : {}) })
472
+ return
473
+ }
474
+ if (error?.code !== 'ENOENT' && error?.code !== 'EISDIR' && error?.code !== 'ENOTDIR') throw error
475
+ } finally {
476
+ releaseIdentityRequest?.()
477
+ }
478
+ }
479
+ endResponse(404)
480
+ } finally {
481
+ response.complete(handedOff)
482
+ }
483
+ }
484
+
485
+ /** Read the shared Fetch channel's JSON body without buffering an unbounded upload. */
486
+ async function readFetchJson(request) {
487
+ const cancelBody = () => {
488
+ return request.body?.cancel().catch(() => { /* The carrier may already have closed its upload. */ })
489
+ }
490
+ if (request.signal.aborted) {
491
+ await cancelBody()
492
+ assertIdentityRequestActive(request.signal)
493
+ }
494
+ if (request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') {
495
+ await cancelBody()
496
+ throw new ApiError(415, '请求体必须为 application/json')
497
+ }
498
+ if (Number(request.headers.get('content-length')) > MAX_BODY_BYTES) {
499
+ await cancelBody()
500
+ throw new ApiError(413, '请求体过大')
501
+ }
502
+ const reader = request.body?.getReader()
503
+ if (!reader) throw new ApiError(400, '请求体必须是 JSON')
504
+ const abort = () => {
505
+ void reader.cancel().catch(() => { /* The carrier may already have closed its upload. */ })
506
+ }
507
+ request.signal.addEventListener('abort', abort, { once: true })
508
+ const chunks = []
509
+ let length = 0
510
+ try {
511
+ assertIdentityRequestActive(request.signal)
512
+ while (true) {
513
+ const { done, value } = await reader.read()
514
+ assertIdentityRequestActive(request.signal)
515
+ if (done) break
516
+ length += value.byteLength
517
+ if (length > MAX_BODY_BYTES) {
518
+ abort()
519
+ throw new ApiError(413, '请求体过大')
520
+ }
521
+ chunks.push(value)
522
+ }
523
+ try { return JSON.parse(Buffer.concat(chunks, length).toString('utf8')) }
524
+ catch { throw new ApiError(400, '请求体必须是 JSON') }
525
+ } finally {
526
+ request.signal.removeEventListener('abort', abort)
527
+ reader.releaseLock()
528
+ }
529
+ }
530
+
531
+ /** Keep a static-response slot until its body is drained, cancelled, or expires. */
532
+ async function fetchStaticAsset(request, target, { signal: lifetimeSignal, textAsset, mime, cacheControl,
533
+ rhineAsset = false }) {
534
+ let releaseRequest
535
+ try { releaseRequest = acquireMapRequest() }
536
+ catch { return new Response(null, { status: 503, headers: { 'retry-after': '1' } }) }
537
+ const controller = new AbortController()
538
+ const sourceSignal = AbortSignal.any([request.signal, lifetimeSignal])
539
+ let bytes = null
540
+ let bodyController = null
541
+ let workSettled = false
542
+ let responseSettled = false
543
+ let released = false
544
+ const release = () => {
545
+ if (released || !workSettled || !responseSettled) return
546
+ released = true
547
+ bytes = null
548
+ clearTimeout(timer)
549
+ sourceSignal.removeEventListener('abort', abort)
550
+ releaseRequest()
551
+ }
552
+ const abort = () => {
553
+ if (!controller.signal.aborted) controller.abort()
554
+ if (!responseSettled) {
555
+ responseSettled = true
556
+ bodyController?.error(identityFault('CANCELLED', '资源请求已取消'))
557
+ }
558
+ bytes = null
559
+ release()
560
+ }
561
+ const timer = setTimeout(abort, MAP_RESPONSE_TIMEOUT_MS)
562
+ timer.unref?.()
563
+ sourceSignal.addEventListener('abort', abort, { once: true })
564
+ if (sourceSignal.aborted) abort()
565
+ try {
566
+ assertIdentityRequestActive(controller.signal)
567
+ if (rhineAsset) await assertRhineAssetPath(target, controller.signal)
568
+ let releaseIdentityRequest
569
+ try {
570
+ if (textAsset) {
571
+ releaseIdentityRequest = acquireIdentityRequest()
572
+ bytes = await readIdentityTextAsset(target, { signal: controller.signal })
573
+ } else {
574
+ bytes = await readBoundedStaticFile(target, MAX_MAP_BINARY_BYTES, { signal: controller.signal })
575
+ }
576
+ } finally { releaseIdentityRequest?.() }
577
+ assertIdentityRequestActive(controller.signal)
578
+ const headers = {
579
+ 'content-type': mime,
580
+ 'content-length': String(bytes.length),
581
+ 'cache-control': cacheControl,
582
+ 'x-content-type-options': 'nosniff',
583
+ 'cross-origin-resource-policy': 'same-origin',
584
+ }
585
+ if (request.method === 'HEAD') {
586
+ responseSettled = true
587
+ return new Response(null, { headers })
588
+ }
589
+ let offset = 0
590
+ const body = new ReadableStream({
591
+ start(value) { bodyController = value },
592
+ pull(value) {
593
+ if (offset === bytes.length) {
594
+ responseSettled = true
595
+ value.close()
596
+ release()
597
+ return
598
+ }
599
+ const end = Math.min(offset + 64 * 1024, bytes.length)
600
+ value.enqueue(bytes.subarray(offset, end))
601
+ offset = end
602
+ },
603
+ cancel() { responseSettled = true; release() },
604
+ }, { highWaterMark: 0 })
605
+ return new Response(body, { headers })
606
+ } catch (error) {
607
+ responseSettled = true
608
+ if (controller.signal.aborted || error?.code === 'CANCELLED') return new Response(null, { status: 499 })
609
+ if (error?.code === 'MAP_BUSY') return new Response(null, { status: 503, headers: { 'retry-after': '1' } })
610
+ if (['ENOENT', 'EISDIR', 'ENOTDIR'].includes(error?.code)) return new Response(null, { status: 404 })
611
+ throw error
612
+ } finally {
613
+ workSettled = true
614
+ release()
615
+ }
616
+ }
617
+
618
+ class ApiError extends Error {
619
+ constructor(status, message) {
620
+ super(message)
621
+ this.status = status
622
+ }
623
+ }
624
+
625
+ const releaseDirSize = async (dir) => {
626
+ let total = 0
627
+ const walk = async (path) => {
628
+ for (const entry of await readdir(path, { withFileTypes: true })) {
629
+ const child = join(path, entry.name)
630
+ if (entry.isDirectory()) await walk(child)
631
+ else if (entry.isFile()) total += (await stat(child)).size
632
+ }
633
+ }
634
+ await walk(dir)
635
+ return total
636
+ }
637
+
638
+ const emptyDataset = (game) => ({
639
+ game, present: false, documentCount: 0, compressedSize: 0, packs: [],
640
+ releaseName: null, releaseTitle: null, gameVersion: null, sourceVersion: null, dataVersion: null,
641
+ })
642
+
643
+ /** 把联合 release 中的 pack 汇总成用户可理解的两个游戏资料库。 */
644
+ async function readReleaseDatasets(dir, manifest, packManifests = null) {
645
+ const datasets = {
646
+ arknights: emptyDataset('arknights'),
647
+ endfield: emptyDataset('endfield'),
648
+ }
649
+ const sharedPacks = []
650
+ for (const pack of Array.isArray(manifest?.packs) ? manifest.packs : []) {
651
+ const manifestPath = String(pack?.manifest_path ?? '')
652
+ if (!manifestPath || manifestPath.includes('..') || manifestPath.startsWith('/')) continue
653
+ let detail = packManifests?.get(String(pack?.pack_id ?? ''))
654
+ if (!detail) {
655
+ try { detail = JSON.parse(await readFile(join(dir, manifestPath), 'utf8')) } catch { continue }
656
+ }
657
+ const packId = String(detail.pack_id ?? pack.pack_id ?? '')
658
+ const game = detail.game === 'arknights' || detail.game === 'endfield'
659
+ ? detail.game
660
+ : packId === 'official_game' ? 'arknights'
661
+ : packId.startsWith('endfield_') ? 'endfield' : null
662
+ const summary = {
663
+ packId,
664
+ authority: detail.authority ?? pack.authority ?? null,
665
+ documentCount: detail.document_count ?? pack.document_count ?? 0,
666
+ compressedSize: detail.compressed_size ?? pack.compressed_size ?? 0,
667
+ releaseName: detail.release_id ?? null,
668
+ releaseTitle: detail.release_title ?? detail.release_name ?? null,
669
+ gameVersion: detail.game_version ?? null,
670
+ sourceVersion: detail.source_version ?? null,
671
+ dataVersion: detail.data_version ?? pack.data_version ?? null,
672
+ }
673
+ if (!game) { sharedPacks.push(summary); continue }
674
+ const target = datasets[game]
675
+ target.present = true
676
+ target.documentCount += Number(summary.documentCount) || 0
677
+ target.compressedSize += Number(summary.compressedSize) || 0
678
+ target.packs.push(summary)
679
+ // 官方游戏导出的版本信息优先于 reviewed knowledge 的构建哈希。
680
+ if (summary.authority === 'official' || String(summary.authority).includes('official_game')) {
681
+ target.releaseName = summary.releaseName ?? target.releaseName
682
+ target.releaseTitle = summary.releaseTitle ?? target.releaseTitle
683
+ target.gameVersion = summary.gameVersion ?? target.gameVersion
684
+ target.sourceVersion = summary.sourceVersion ?? target.sourceVersion
685
+ target.dataVersion = summary.dataVersion ?? target.dataVersion
686
+ } else {
687
+ target.releaseName ??= summary.releaseName
688
+ target.releaseTitle ??= summary.releaseTitle
689
+ target.gameVersion ??= summary.gameVersion
690
+ target.sourceVersion ??= summary.sourceVersion
691
+ target.dataVersion ??= summary.dataVersion
692
+ }
693
+ }
694
+ return { ...datasets, sharedPacks }
695
+ }
696
+
697
+ async function readLocalReleases(shared, sizeCache = new Map()) {
698
+ const releases = []
699
+ let activeId = null
700
+ try {
701
+ activeId = (await readCurrentReleasePointer(shared.releasesDir)).release_id
702
+ } catch { /* 尚无激活版本 */ }
703
+ let entries = []
704
+ try {
705
+ entries = await readdir(shared.releasesDir, { withFileTypes: true })
706
+ } catch { return { activeId, releases } }
707
+ for (const entry of entries) {
708
+ if (!entry.isDirectory() || !RELEASE_ID_PATTERN.test(entry.name)) continue
709
+ const dir = join(shared.releasesDir, entry.name)
710
+ let manifest = null
711
+ let packManifests = null
712
+ try {
713
+ const validated = await validateLocalRelease(shared.releasesDir, entry.name, { details: true })
714
+ manifest = validated.manifest
715
+ packManifests = validated.packManifests
716
+ } catch { /* 半成品/外来目录:仍列出,标记不完整 */ }
717
+ let sizeBytes = sizeCache.get(entry.name)
718
+ if (sizeBytes === undefined) {
719
+ sizeBytes = await releaseDirSize(dir).catch(() => 0)
720
+ if (manifest) sizeCache.set(entry.name, sizeBytes)
721
+ }
722
+ releases.push({
723
+ releaseId: entry.name,
724
+ active: entry.name === activeId,
725
+ complete: Boolean(manifest),
726
+ dataVersion: manifest?.data_version ?? null,
727
+ documentCount: manifest?.document_count ?? null,
728
+ compressedSize: manifest?.compressed_size ?? null,
729
+ createdAt: manifest?.created_at ?? null,
730
+ sizeBytes,
731
+ needsExtract: true, // 本地分片以 .jsonl.gz 存储,打开时需解压
732
+ datasets: manifest ? await readReleaseDatasets(dir, manifest, packManifests) : null,
733
+ })
734
+ }
735
+ releases.sort((left, right) => String(right.createdAt ?? '').localeCompare(String(left.createdAt ?? '')))
736
+ return { activeId, releases }
737
+ }
738
+
739
+ /**
740
+ * 检查是否有更新版本。PRTS.chat current 是最新 release 与内容哈希的信任锚;
741
+ * ModelScope 只承载被该摘要约束的文件字节,不参与版本选择。
742
+ * @param {ReturnType<import('./state.js').createSharedState>} shared
743
+ * @param {{ fetchImpl?: typeof fetch }} [env]
744
+ */
745
+ async function checkForUpdate(shared, env = {}, sizeCache) {
746
+ const fetchImpl = env.fetchImpl ?? fetch
747
+ const manualCloudSearch = createManualCloudSearch(shared, { fetchImpl })
748
+ const local = await readLocalReleases(shared, sizeCache)
749
+ const localRelease = local.releases.find((release) => release.active)
750
+ const localInfo = {
751
+ releaseId: local.activeId ?? null,
752
+ dataVersion: localRelease?.dataVersion ?? null,
753
+ documentCount: localRelease?.documentCount ?? null,
754
+ sizeBytes: localRelease?.sizeBytes ?? null,
755
+ }
756
+ try {
757
+ const trusted = await resolveTrustedCurrentRelease({ fetchImpl,
758
+ signal: AbortSignal.timeout(20_000) })
759
+ const remote = {
760
+ releaseId: trusted.releaseId,
761
+ dataVersion: trusted.dataVersion,
762
+ minimumAgentVersion: trusted.minimumAgentVersion,
763
+ documentCount: trusted.documentCount,
764
+ compressedSize: trusted.compressedSize,
765
+ uncompressedSize: trusted.uncompressedSize,
766
+ createdAt: null,
767
+ }
768
+ const updateAvailable = local.activeId !== trusted.releaseId
769
+ || (localInfo.dataVersion != null && localInfo.dataVersion !== trusted.dataVersion)
770
+ return { source: 'site', local: localInfo, remote, updateAvailable }
771
+ } catch (error) {
772
+ return { source: null, local: localInfo, remote: null, updateAvailable: false,
773
+ error: `检查更新失败:${error?.message ?? error}` }
774
+ }
775
+ }
776
+
777
+ /**
778
+ * 构建纯 API 核心。
779
+ * @param {ReturnType<import('./state.js').createSharedState>} shared
780
+ * @param {{ logger?: object }} [env]
781
+ */
782
+ export function buildApi(shared, env = {}) {
783
+ const sizeCache = new Map()
784
+ const fetchImpl = env.fetchImpl ?? fetch
785
+ const manualCloudSearch = createManualCloudSearch(shared, { fetchImpl })
786
+ let releaseMutation = Promise.resolve()
787
+ const withReleaseMutation = (operation) => {
788
+ const running = releaseMutation.then(operation)
789
+ releaseMutation = running.catch(() => {})
790
+ return running
791
+ }
792
+ const startDownload = async ({ releaseId }) => {
793
+ const progress = shared.download
794
+ if (progress.active) throw new ApiError(409, '已有下载任务在进行中')
795
+ // 目标解析包含最长数十秒的网络请求;下载槽位必须在首个 await 之前同步
796
+ // 占用,否则两次并发 POST /download 都能通过 409 检查并发起双任务。
797
+ progress.active = true
798
+ progress.phase = 'listing'
799
+ progress.source = null
800
+ progress.releaseId = null
801
+ progress.filesDone = 0
802
+ progress.filesTotal = null
803
+ progress.bytesDone = 0
804
+ progress.error = null
805
+ progress.finishedAt = null
806
+ shared.notifyRuntime()
807
+ const releaseSlot = () => {
808
+ progress.active = false
809
+ progress.phase = 'idle'
810
+ progress.finishedAt = new Date().toISOString()
811
+ shared.notifyRuntime()
812
+ }
813
+ let target
814
+ let trustedCurrent = null
815
+ try {
816
+ const requested = releaseId ? String(releaseId) : null
817
+ if (requested && !RELEASE_ID_PATTERN.test(requested)) throw new ApiError(400, 'releaseId 非法')
818
+ try {
819
+ // 下载与更新每次都重新取 current,并将这一个对象原样传到
820
+ // installer;不在两次请求间重新选版,也不允许过期 UI 指定旧版。
821
+ trustedCurrent = await resolveTrustedCurrentRelease({ fetchImpl,
822
+ signal: AbortSignal.timeout(20_000) })
823
+ } catch (error) {
824
+ throw new ApiError(502, `无法从 PRTS.chat 获取可信最新版本:${error?.message ?? error}`)
825
+ }
826
+ if (requested && requested !== trustedCurrent.releaseId) {
827
+ throw new ApiError(409, `最新版本已变为 ${trustedCurrent.releaseId},请重新检查更新`)
828
+ }
829
+ target = trustedCurrent.releaseId
830
+ } catch (error) {
831
+ releaseSlot()
832
+ throw error
833
+ }
834
+ progress.releaseId = target
835
+ shared.notifyRuntime()
836
+
837
+ // 后台执行;结果写回 shared.download,界面轮询 status 跟踪
838
+ void (async () => {
839
+ try {
840
+ const config = shared.effective()
841
+ const result = await ensureCorpusRelease({
842
+ releasesDir: shared.releasesDir,
843
+ releaseId: target,
844
+ ...(trustedCurrent ? { trustedCurrent } : {}),
845
+ order: config.downloadOrder,
846
+ siteBaseUrl: config.downloadSiteBaseUrl,
847
+ requireRelease: true,
848
+ fetchImpl,
849
+ logger: env.logger,
850
+ onProgress: (update) => {
851
+ progress.phase = update.phase
852
+ progress.source = update.source
853
+ progress.filesDone = update.filesDone
854
+ progress.filesTotal = update.filesTotal
855
+ progress.bytesDone = update.bytesDone
856
+ },
857
+ })
858
+ progress.phase = result.status === 'present' ? 'present' : 'done'
859
+ sizeCache.delete(target)
860
+ // 新版本就绪 → 热重载 store(若当前激活版本变了)
861
+ if (shared.store) {
862
+ try {
863
+ const pointer = await readCurrentReleasePointer(shared.releasesDir)
864
+ if (pointer.release_id !== shared.store.releaseId) {
865
+ shared.store.reset()
866
+ }
867
+ } catch { /* current.json 异常时保持现状 */ }
868
+ }
869
+ } catch (error) {
870
+ progress.phase = 'error'
871
+ progress.error = `${error?.code ?? 'ERROR'}: ${error?.message ?? error}`
872
+ env.logger?.warn?.(`prts-corpus: 下载失败 ${progress.error}`)
873
+ } finally {
874
+ progress.active = false
875
+ progress.finishedAt = new Date().toISOString()
876
+ shared.notifyRuntime()
877
+ }
878
+ })()
879
+ return { started: true, releaseId: target }
880
+ }
881
+
882
+ /** 原始路由分发:ApiError/配置校验错误转为响应,其余向上抛给 HTTP 层。 */
883
+ const routeCall = async (method, pathname, body, { signal } = {}) => {
884
+ const route = pathname.replace(/^\/api\/prts-corpus\/?/, '').split('?')[0]
885
+ if (method === 'POST' && route.startsWith('investigation/')) {
886
+ const service = shared.investigations
887
+ if (!service) throw new ApiError(503, '调查板存储尚未就绪')
888
+ const { session_id, ...args } = body || {}
889
+ const action = route.slice('investigation/'.length)
890
+ let value
891
+ if (action === 'get') value = await service.read(session_id, args)
892
+ else if (action === 'watch') value = await service.watch(session_id, Number(args.after) || 0, signal)
893
+ else if (action === 'inbox') value = await service.editInbox(session_id, args, signal)
894
+ else if (action === 'rack') value = await service.saveRack(session_id, args, signal)
895
+ else if (action === 'edit') value = await service.edit(session_id, args, signal)
896
+ else if (action === 'import') value = await service.importLegacy(session_id, args, signal)
897
+ else if (action === 'create') value = await service.createUserBoard(session_id, args, signal)
898
+ else throw new ApiError(404, '未知调查板操作')
899
+ return { status: 200, json: value }
900
+ }
901
+ if (method === 'GET' && route === 'archive/options') {
902
+ const config = shared.effective()
903
+ return { status: 200, json: { games: config.enabledGames, cloud_enabled: Boolean(config.cloudEnabled && config.cloudBaseUrl) } }
904
+ }
905
+ if (method === 'POST' && route === 'archive/cloud-search') return manualCloudSearch(body, { signal })
906
+ if (method === 'POST' && route === 'archive/search') {
907
+ assertIdentityRequestActive(signal)
908
+ const store = shared.store
909
+ if (!store) throw new ApiError(409, '本地资料尚未就绪')
910
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
911
+ throw new ApiError(400, '搜索参数必须是对象')
912
+ }
913
+ try { await store.ready() }
914
+ catch { throw new ApiError(409, '本地资料尚未就绪,请在设置中安装资料包') }
915
+ assertIdentityRequestActive(signal)
916
+ const snapshot = corpusVersionSnapshot(store)
917
+ const dataVersion = store.dataVersion
918
+ if (body.data_version !== undefined && body.data_version !== dataVersion) {
919
+ throw new ApiError(409, '资料版本已变更,请重新搜索')
920
+ }
921
+ const enabledGames = shared.effective().enabledGames
922
+ let request = body
923
+ if (body.cursor == null) {
924
+ if (body.games !== undefined && (!Array.isArray(body.games) || !body.games.length
925
+ || body.games.some((game) => !enabledGames.includes(game)))) {
926
+ throw new ApiError(400, '请求的游戏资料库当前未启用或 games 格式不合法')
927
+ }
928
+ request = { ...body, games: body.games || enabledGames }
929
+ if (!String(body.query || '').trim() && body.resource_types === undefined) {
930
+ request.resource_types = [...ARCHIVE_CATALOG_TYPES]
931
+ }
932
+ }
933
+ const result = await executeSearch(store, request, { signal, allowedGames: enabledGames })
934
+ assertIdentityRequestActive(signal)
935
+ assertCorpusVersion(store, snapshot)
936
+ if (result?.error) {
937
+ if (result.error.code === 'CANCELLED') throw identityFault('CANCELLED', '资料检索已取消')
938
+ return { status: /VERSION|CURSOR_POLICY/u.test(result.error.code) ? 409 : 400,
939
+ json: { error: result.error.message, code: result.error.code } }
940
+ }
941
+ // A settings change during a search cannot expose newly disabled material.
942
+ const currentGames = shared.effective().enabledGames
943
+ if ((result.documents || []).some((item) => !currentGames.includes(item.game))) {
944
+ throw new ApiError(409, '资料库范围已变更,请重新搜索')
945
+ }
946
+ const metadata = localArchivePresentation(store, result, dataVersion)
947
+ return { status: 200, json: { sources: metadata.sources, page: result.page,
948
+ data_version: dataVersion, ...(result.warnings ? { warnings: result.warnings } : {}) } }
949
+ }
950
+ // 「点开证据卡 → 读全文」:用与工具一致的 executeRead 拉取目标原文/实体资料。
951
+ // 仅接受 source_ref / document_id 定位,复用同一套契约与数据版本校验。
952
+ if (method === 'POST' && route === 'read') {
953
+ const store = shared.store
954
+ if (!store?.loaded) throw new ApiError(409, '本地资料尚未就绪')
955
+ const locator = body?.locator
956
+ const selection = body?.selection ?? { mode: 'document' }
957
+ if (!locator || typeof locator !== 'object') throw new ApiError(400, '缺少定位器')
958
+ const hasReadLocator = Boolean(locator.source_ref || locator.document_id)
959
+ const hasActivityLocator = Boolean(locator.activity_id || locator.activity_name)
960
+ const hasCollectionLocator = Boolean(locator.collection_name)
961
+ const streamMode = selection.mode === 'activity' || selection.mode === 'collection'
962
+ const hasExpectedLocator = selection.mode === 'activity' ? hasActivityLocator
963
+ : selection.mode === 'collection' ? hasCollectionLocator : hasReadLocator
964
+ if (!hasExpectedLocator) {
965
+ throw new ApiError(400, streamMode
966
+ ? `${selection.mode} 定位缺少对应的活动/集合名称`
967
+ : '定位器需提供 source_ref / document_id')
968
+ }
969
+ const enabledGames = shared.effective().enabledGames
970
+ if (streamMode) {
971
+ let documents
972
+ try {
973
+ documents = selection.mode === 'activity'
974
+ ? store.activityStoryDocuments({ activityId: locator.activity_id,
975
+ activityName: locator.activity_name })
976
+ : store.endfieldCollectionDocuments({ collectionName: locator.collection_name,
977
+ contentTypes: selection.content_types ?? [] })
978
+ } catch (error) {
979
+ if (error?.code === 'DOCUMENT_AMBIGUOUS') {
980
+ throw new ApiError(400, error.message)
981
+ }
982
+ throw error
983
+ }
984
+ if (documents.some((item) => !enabledGames.includes(documentGame(item.document)))) {
985
+ throw new ApiError(400, '该活动或集合所属游戏资料当前未启用')
986
+ }
987
+ } else {
988
+ let documentId = String(locator.document_id || '')
989
+ if (!documentId && locator.source_ref) {
990
+ const sourceRef = String(locator.source_ref)
991
+ const marker = sourceRef.lastIndexOf(':L')
992
+ if (marker > 0) documentId = store.getDocumentIdByPrefix(sourceRef.slice(0, marker)) || ''
993
+ }
994
+ const metadata = documentId ? store.documents.get(documentId)?.document : null
995
+ if (metadata && !enabledGames.includes(documentGame(metadata))) {
996
+ throw new ApiError(400, '该文档所属游戏资料当前未启用')
997
+ }
998
+ }
999
+ // 限制值夹到契约允许的范围,避免客户端传入超限值导致 executeRead 直接报错。
1000
+ const clampInt = (value, min, max, fallback) => {
1001
+ const n = Number(value)
1002
+ if (!Number.isFinite(n)) return fallback
1003
+ return Math.min(max, Math.max(min, Math.round(n)))
1004
+ }
1005
+ const expected = {
1006
+ intent_id: `evidence-${Date.now().toString(36)}`,
1007
+ ...(body?.data_version ? { expected_data_version: String(body.data_version) } : {}),
1008
+ locator,
1009
+ selection,
1010
+ limits: {
1011
+ max_lines: clampInt(body?.max_lines, 1, 500, 500),
1012
+ max_chars: clampInt(body?.max_chars, 100, 100000, 100000),
1013
+ },
1014
+ }
1015
+ const result = await executeRead(store, expected, { logger: env.logger, signal })
1016
+ assertIdentityRequestActive(signal)
1017
+ if (result.status !== 'ok') {
1018
+ return { status: 200, json: { ok: false, error: result.error } }
1019
+ }
1020
+ return { status: 200, json: { ok: true, response: result } }
1021
+ }
1022
+ if (method === 'GET' && route === 'status') {
1023
+ const store = shared.store
1024
+ const ready = Boolean(store?.loaded)
1025
+ const config = shared.effective()
1026
+ let installed = false
1027
+ let installationIssue = null
1028
+ try {
1029
+ const pointer = await readCurrentReleasePointer(shared.releasesDir)
1030
+ const releaseId = String(pointer.release_id || '')
1031
+ const manifest = await validateLocalRelease(shared.releasesDir, releaseId)
1032
+ const missingGames = missingEnabledGamePacks(manifest, config.enabledGames)
1033
+ installed = Boolean((!pointer.data_version || pointer.data_version === manifest.data_version)
1034
+ && missingGames.length === 0)
1035
+ if (missingGames.length) installationIssue = `当前版本缺少已启用模块:${missingGames.map((game) =>
1036
+ game === 'endfield' ? '终末地' : '明日方舟').join('、')}`
1037
+ else if (!installed) installationIssue = 'current.json 或 release-manifest.json 内容无效'
1038
+ } catch (error) {
1039
+ installationIssue = error?.code === 'ENOENT'
1040
+ ? '未找到本地语料;请下载资料或检查资料目录配置'
1041
+ : `无法读取本地语料配置:${error?.message ?? error}`
1042
+ }
1043
+ let storeInfo = { loaded: ready, installed, installationIssue,
1044
+ releaseId: null, dataVersion: null, documentCount: null, packCount: null }
1045
+ if (store && ready && store.releaseId) {
1046
+ storeInfo = { ...storeInfo, loaded: true,
1047
+ releaseId: store.releaseId, dataVersion: store.dataVersion,
1048
+ documentCount: store.documents.size, packCount: store.packs.size }
1049
+ }
1050
+ return { status: 200, json: { store: storeInfo, download: { ...shared.download }, config: redactConfig(config) } }
1051
+ }
1052
+ if (method === 'GET' && route === 'releases') {
1053
+ const { activeId, releases } = await readLocalReleases(shared, sizeCache)
1054
+ return { status: 200, json: { activeId, releases } }
1055
+ }
1056
+ if (method === 'GET' && route === 'check-update') {
1057
+ return { status: 200, json: await checkForUpdate(shared, env, sizeCache) }
1058
+ }
1059
+ if (method === 'GET' && route === 'config') {
1060
+ return { status: 200, json: { config: redactConfig(shared.effective(), shared.userLayer()), defaultsPresent: true } }
1061
+ }
1062
+ if (method === 'PUT' && route === 'config') {
1063
+ const effective = await shared.saveConfig(body?.patch ?? body, { signal })
1064
+ return { status: 200, json: { config: redactConfig(effective, shared.userLayer()) } }
1065
+ }
1066
+ if (method === 'POST' && route === 'download') {
1067
+ return withReleaseMutation(async () => ({
1068
+ status: 202, json: await startDownload(body ?? {}),
1069
+ }))
1070
+ }
1071
+ if (method === 'POST' && route === 'activate') {
1072
+ const releaseId = String(body?.releaseId ?? '')
1073
+ if (!RELEASE_ID_PATTERN.test(releaseId)) throw new ApiError(400, 'releaseId 非法')
1074
+ return withReleaseMutation(async () => {
1075
+ if (shared.download.active) {
1076
+ throw new ApiError(409, '资料下载期间不能切换激活版本')
1077
+ }
1078
+ try {
1079
+ return await withReleaseMutationLock(shared.releasesDir, async () => {
1080
+ let manifest
1081
+ try {
1082
+ manifest = await validateLocalRelease(shared.releasesDir, releaseId, { verifyHashes: true })
1083
+ } catch (error) {
1084
+ throw new ApiError(400, `版本不完整,无法激活:${error?.message ?? error}`)
1085
+ }
1086
+ const pointerTemp = join(shared.releasesDir, `current.json.${randomBytes(6).toString('hex')}.tmp`)
1087
+ await writeFile(pointerTemp, JSON.stringify({
1088
+ release_id: releaseId, data_version: manifest.data_version,
1089
+ channel: 'manual', public_download: true, schema_version: 1,
1090
+ activated_at: new Date().toISOString(),
1091
+ }))
1092
+ await rename(pointerTemp, join(shared.releasesDir, 'current.json'))
1093
+ shared.store?.reset()
1094
+ shared.notifyRuntime()
1095
+ env.logger?.info?.(`prts-corpus: 激活版本切换为 ${releaseId}`)
1096
+ return { status: 200, json: { activated: releaseId } }
1097
+ })
1098
+ } catch (error) {
1099
+ if (error?.code === 'DOWNLOAD_BUSY') throw new ApiError(409, error.message)
1100
+ throw error
1101
+ }
1102
+ })
1103
+ }
1104
+ if (method === 'POST' && route === 'delete') {
1105
+ const releaseId = String(body?.releaseId ?? '')
1106
+ if (!RELEASE_ID_PATTERN.test(releaseId)) throw new ApiError(400, 'releaseId 非法')
1107
+ return withReleaseMutation(async () => {
1108
+ try {
1109
+ return await withReleaseMutationLock(shared.releasesDir, async () => {
1110
+ let activeId = null
1111
+ try {
1112
+ activeId = (await readCurrentReleasePointer(shared.releasesDir)).release_id
1113
+ } catch { /* 无激活 */ }
1114
+ if (releaseId === activeId) throw new ApiError(409, '不能删除当前激活版本')
1115
+ if (shared.download.active && shared.download.releaseId === releaseId) {
1116
+ throw new ApiError(409, '该版本正在下载')
1117
+ }
1118
+ try {
1119
+ await validateLocalRelease(shared.releasesDir, releaseId)
1120
+ } catch (error) {
1121
+ throw new ApiError(400, `目录不是可确认的完整资料版本,拒绝递归删除:${error?.message ?? error}`)
1122
+ }
1123
+ await rm(join(shared.releasesDir, releaseId), { recursive: true, force: true })
1124
+ sizeCache.delete(releaseId)
1125
+ return { status: 200, json: { deleted: releaseId } }
1126
+ })
1127
+ } catch (error) {
1128
+ if (error?.code === 'DOWNLOAD_BUSY') throw new ApiError(409, error.message)
1129
+ throw error
1130
+ }
1131
+ })
1132
+ }
1133
+ throw new ApiError(404, `未知路由 ${method} ${pathname}`)
1134
+ }
1135
+
1136
+ return {
1137
+ async call(method, pathname, body, options = {}) {
1138
+ try {
1139
+ return await routeCall(method, pathname, body, options)
1140
+ } catch (error) {
1141
+ if (error?.code?.startsWith('INVESTIGATION_') || error?.code === 'INVALID_INVESTIGATION') return { status: error.code === 'INVESTIGATION_CONFLICT' ? 409 : 400, json: { error: error.message, code: error.code } }
1142
+ if (error?.name === 'ZodError') return { status: 400, json: { error: '调查内容格式或长度不正确', code: 'INVALID_INVESTIGATION' } }
1143
+ if (error instanceof ApiError) return { status: error.status, json: { error: error.message } }
1144
+ if (error?.code === 'INVALID_CONFIG') return { status: 400, json: { error: error.message } }
1145
+ if (error?.code === 'PACKAGE_VERSION_MISMATCH') return { status: 409,
1146
+ json: { error: error.message, code: error.code } }
1147
+ throw error
1148
+ }
1149
+ },
1150
+ }
1151
+ }
1152
+
1153
+ /**
1154
+ * 通过 Connection 的共享 Fetch 通道挂 UI API 与资源,支持 Web 和 Electron。
1155
+ * 认证由宿主传输负责;WebServer 存在时另挂旧版 RPC 与资源路径。
1156
+ * @param {import('@deepseek-ai/cordis').Context} ctx
1157
+ * @param {ReturnType<import('./state.js').createSharedState>} shared
1158
+ */
1159
+ export function applyUi(ctx, shared) {
1160
+ const connection = ctx.get?.('connection') ?? ctx.connection
1161
+ if (!connection) return false
1162
+ const api = buildApi(shared, { logger: ctx.logger })
1163
+ const endpoints = Object.freeze({
1164
+ status: ['GET', '/api/prts-corpus/status'],
1165
+ releases: ['GET', '/api/prts-corpus/releases'],
1166
+ 'check-update': ['GET', '/api/prts-corpus/check-update'],
1167
+ 'config.get': ['GET', '/api/prts-corpus/config'],
1168
+ 'config.update': ['PUT', '/api/prts-corpus/config'],
1169
+ download: ['POST', '/api/prts-corpus/download'],
1170
+ activate: ['POST', '/api/prts-corpus/activate'],
1171
+ delete: ['POST', '/api/prts-corpus/delete'],
1172
+ read: ['POST', '/api/prts-corpus/read'],
1173
+ 'archive.search': ['POST', '/api/prts-corpus/archive/search'],
1174
+ 'archive.cloud-search': ['POST', '/api/prts-corpus/archive/cloud-search'],
1175
+ 'archive.options': ['GET', '/api/prts-corpus/archive/options'],
1176
+ 'investigation.get': ['POST', '/api/prts-corpus/investigation/get'],
1177
+ 'investigation.watch': ['POST', '/api/prts-corpus/investigation/watch'],
1178
+ 'investigation.inbox': ['POST', '/api/prts-corpus/investigation/inbox'],
1179
+ 'investigation.rack': ['POST', '/api/prts-corpus/investigation/rack'],
1180
+ 'investigation.edit': ['POST', '/api/prts-corpus/investigation/edit'],
1181
+ 'investigation.import': ['POST', '/api/prts-corpus/investigation/import'],
1182
+ 'investigation.create': ['POST', '/api/prts-corpus/investigation/create'],
1183
+ })
1184
+ const call = async (endpoint, payload, signal) => {
1185
+ const route = Object.hasOwn(endpoints, endpoint) ? endpoints[endpoint] : null
1186
+ if (!route) {
1187
+ return { ok: false, error: { code: 'not-found', message: `未知 PRTS RPC 端点 ${endpoint}`, details: {} } }
1188
+ }
1189
+ if (Buffer.byteLength(JSON.stringify(payload ?? {})) > MAX_BODY_BYTES) {
1190
+ return { ok: false, error: { code: 'bad-request', message: '请求体过大', details: {} } }
1191
+ }
1192
+ try {
1193
+ const result = await api.call(route[0], route[1], payload ?? {}, { signal })
1194
+ if (result.status >= 400) {
1195
+ return { ok: false, error: { code: result.status === 409 ? 'conflict' : 'bad-request',
1196
+ message: result.json.error ?? `PRTS API ${result.status}`, details: { status: result.status } } }
1197
+ }
1198
+ return { ok: true, value: result.json }
1199
+ } catch (error) {
1200
+ if (signal?.aborted || error?.code === 'CANCELLED' || error?.name === 'AbortError') {
1201
+ return { ok: false, error: { code: 'cancelled', message: 'PRTS 请求已取消', details: {} } }
1202
+ }
1203
+ // 不把原始 error.message 回传浏览器(ENOENT 等会携带宿主绝对路径);
1204
+ // 细节写宿主日志即可。
1205
+ ctx.logger?.warn?.(`prts-corpus RPC ${endpoint} 失败:${error?.stack ?? error}`)
1206
+ return { ok: false, error: { code: 'internal-error',
1207
+ message: 'PRTS 内部错误,详情见宿主日志', details: {} } }
1208
+ }
1209
+ }
1210
+ const lifetime = new AbortController()
1211
+ ctx.effect(() => () => lifetime.abort(), 'prts-corpus: Fetch requests')
1212
+ connection.fetch.register({
1213
+ path: '/api/prts-corpus/rpc', methods: ['POST'], requestBody: 'streaming',
1214
+ async fetch(request) {
1215
+ const signal = AbortSignal.any([request.signal, lifetime.signal])
1216
+ try {
1217
+ const body = await readFetchJson(new Request(request, { signal }))
1218
+ if (!body || typeof body !== 'object' || Array.isArray(body) || typeof body.endpoint !== 'string') {
1219
+ throw new ApiError(400, '请求需包含 endpoint 字符串和 payload')
1220
+ }
1221
+ return Response.json(await call(body.endpoint, body.payload, signal))
1222
+ } catch (error) {
1223
+ if (signal.aborted || error?.code === 'CANCELLED') {
1224
+ return Response.json({ ok: false, error: { code: 'cancelled', message: 'PRTS 请求已取消', details: {} } })
1225
+ }
1226
+ if (error?.code?.startsWith('INVESTIGATION_') || error?.code === 'INVALID_INVESTIGATION') return { status: error.code === 'INVESTIGATION_CONFLICT' ? 409 : 400, json: { error: error.message, code: error.code } }
1227
+ if (error?.name === 'ZodError') return { status: 400, json: { error: '调查内容格式或长度不正确', code: 'INVALID_INVESTIGATION' } }
1228
+ if (error instanceof ApiError) {
1229
+ return Response.json({ ok: false, error: { code: 'bad-request', message: error.message, details: {} } },
1230
+ { status: error.status })
1231
+ }
1232
+ throw error
1233
+ }
1234
+ },
1235
+ })
1236
+ connection.fetch.register({
1237
+ path: '/api/prts-corpus/archive/search', methods: ['POST'], requestBody: 'streaming',
1238
+ async fetch(request) {
1239
+ const signal = AbortSignal.any([request.signal, lifetime.signal])
1240
+ try {
1241
+ const body = await readFetchJson(new Request(request, { signal }))
1242
+ const result = await api.call('POST', '/api/prts-corpus/archive/search', body, { signal })
1243
+ return Response.json(result.json, { status: result.status })
1244
+ } catch (error) {
1245
+ if (signal.aborted || error?.code === 'CANCELLED') return new Response(null, { status: 499 })
1246
+ if (error?.code?.startsWith('INVESTIGATION_') || error?.code === 'INVALID_INVESTIGATION') return { status: error.code === 'INVESTIGATION_CONFLICT' ? 409 : 400, json: { error: error.message, code: error.code } }
1247
+ if (error?.name === 'ZodError') return { status: 400, json: { error: '调查内容格式或长度不正确', code: 'INVALID_INVESTIGATION' } }
1248
+ if (error instanceof ApiError) return Response.json({ error: error.message }, { status: error.status })
1249
+ ctx.logger?.warn?.(`prts-corpus archive.search 失败:${error?.stack ?? error}`)
1250
+ return Response.json({ error: '资料检索失败,详情见宿主日志' }, { status: 500 })
1251
+ }
1252
+ },
1253
+ })
1254
+ connection.fetch.register({
1255
+ path: '/api/prts-corpus/ui-skin.json', methods: ['GET', 'HEAD'], requestBody: 'buffered',
1256
+ async fetch(request) {
1257
+ const response = Response.json({ uiSkin: shared.effective().uiSkin }, { headers: { 'cache-control': 'no-store' } })
1258
+ return request.method === 'HEAD' ? new Response(null, { headers: response.headers }) : response
1259
+ },
1260
+ })
1261
+ for (const [name, target] of Object.entries(SKIN_STYLESHEETS)) {
1262
+ connection.fetch.register({
1263
+ path: `/api/prts-corpus/skins/${name}`, methods: ['GET', 'HEAD'], requestBody: 'buffered',
1264
+ fetch: (request) => fetchStaticAsset(request, target, { signal: lifetime.signal, textAsset: true,
1265
+ mime: 'text/css; charset=utf-8', cacheControl: 'no-cache' }),
1266
+ })
1267
+ }
1268
+ for (const relative of ENDFIELD_MAP_ASSETS) {
1269
+ const target = join(ENDFIELD_MAP_ROOT, relative)
1270
+ connection.fetch.register({
1271
+ path: `/api/prts-corpus/endfield-map/${relative}`, methods: ['GET', 'HEAD'], requestBody: 'buffered',
1272
+ fetch: (request) => fetchStaticAsset(request, target, { signal: lifetime.signal,
1273
+ textAsset: /\.(js|json)$/.test(target),
1274
+ mime: ENDFIELD_MAP_MIME[extname(target)],
1275
+ cacheControl: relative === 'map.js' ? 'no-cache' : 'public, max-age=31536000, immutable' }),
1276
+ })
1277
+ }
1278
+ for (const [relative, mime] of Object.entries(RHINE_ASSETS)) {
1279
+ connection.fetch.register({
1280
+ path: `/api/prts-corpus/rhine/${relative}`, methods: ['GET', 'HEAD'], requestBody: 'buffered',
1281
+ fetch: (request) => fetchStaticAsset(request, join(RHINE_ROOT, relative), {
1282
+ signal: lifetime.signal, textAsset: false, rhineAsset: true, mime, cacheControl: 'no-cache' }),
1283
+ })
1284
+ }
1285
+ ctx.inject(['connection', 'webServer'], (webCtx) => {
1286
+ // Older Portable clients use this RPC channel and the unprefixed asset paths.
1287
+ applyWebAssets(webCtx, shared)
1288
+ try {
1289
+ webCtx.connection.rpc.handle('/prts-corpus', call, { authority: 'loopback' })
1290
+ } catch (error) {
1291
+ // Some carrier-neutral Connection providers cannot resolve the legacy channel's Web dependency.
1292
+ if (error?.message !== 'cannot get property "webServer" without inject') throw error
1293
+ webCtx.logger?.info?.('prts-corpus: 宿主旧 RPC 通道不可用,设置与资源使用共享 Fetch 通道')
1294
+ }
1295
+ })
1296
+ ctx.logger?.info?.('prts-corpus: settings and assets mounted on /api/prts-corpus')
1297
+ return true
1298
+ }
1299
+
1300
+ function applyWebAssets(ctx, shared) {
1301
+ const webServer = ctx.webServer
1302
+ ctx.effect(() => {
1303
+ const disposeSkin = webServer.register({
1304
+ kind: 'exact', path: '/prts-corpus/ui-skin.json',
1305
+ handler: (req, res) => {
1306
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
1307
+ res.writeHead(405); res.end(); return
1308
+ }
1309
+ const body = Buffer.from(JSON.stringify({ uiSkin: shared.effective().uiSkin }))
1310
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1311
+ res.end(req.method === 'HEAD' ? undefined : body)
1312
+ },
1313
+ })
1314
+ const disposeAgentSkin = webServer.register({
1315
+ kind: 'exact', path: '/prts-corpus/skins/prts-agent.css',
1316
+ handler: (req, res) => serveSkinStylesheet(req, res, 'prts-agent.css'),
1317
+ })
1318
+ const disposeCommonSkin = webServer.register({
1319
+ kind: 'exact', path: '/prts-corpus/skins/common.css',
1320
+ handler: (req, res) => serveSkinStylesheet(req, res, 'common.css'),
1321
+ })
1322
+ const disposeAicSkin = webServer.register({
1323
+ kind: 'exact', path: '/prts-corpus/skins/endfield-aic.css',
1324
+ handler: (req, res) => serveSkinStylesheet(req, res, 'endfield-aic.css'),
1325
+ })
1326
+ const disposeRhineSkin = webServer.register({
1327
+ kind: 'exact', path: '/prts-corpus/skins/rhine-lab.css',
1328
+ handler: (req, res) => serveSkinStylesheet(req, res, 'rhine-lab.css'),
1329
+ })
1330
+ const disposeRhineAssets = Object.keys(RHINE_ASSETS).map((relative) => webServer.register({
1331
+ kind: 'exact', path: `/prts-corpus/rhine/${relative}`,
1332
+ handler: (req, res) => serveRhineAsset(req, res, relative),
1333
+ }))
1334
+ const disposeBundle = webServer.register({
1335
+ kind: 'prefix', path: '/prts-corpus/endfield-map',
1336
+ handler: (req, res) => serveEndfieldMapAsset(req, res, '/prts-corpus/endfield-map'),
1337
+ })
1338
+ const disposeResources = webServer.register({
1339
+ kind: 'prefix', path: '/webmap3d/resources',
1340
+ handler: (req, res) => serveEndfieldMapAsset(req, res, '/webmap3d'),
1341
+ })
1342
+ return () => {
1343
+ disposeResources()
1344
+ disposeBundle()
1345
+ for (const dispose of disposeRhineAssets) dispose()
1346
+ disposeRhineSkin()
1347
+ disposeAicSkin()
1348
+ disposeCommonSkin()
1349
+ disposeAgentSkin()
1350
+ disposeSkin()
1351
+ }
1352
+ }, 'prts-corpus: skin and Endfield map assets')
1353
+ }