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/lib/client.js ADDED
@@ -0,0 +1,3912 @@
1
+ /**
2
+ * prts-terrarchive 浏览器宿主适配器(手写);莱茵资料馆单独编译并按需加载。
3
+ *
4
+ * 格式与 @deepseek-ai 官方 client 包的 lib/client.js 一致:
5
+ * window.__ModuleLoader__.load({ id, factory(require) { ... return module.exports } })
6
+ * react 由平台模块表注入。本插件只依赖 connection、slots 与 theme 服务(零 UI 库依赖,
7
+ * 组件用内联样式原生元素)。
8
+ *
9
+ * 注册:Settings → Plugins 里的「PRTS 语料」tab(settings.plugins.tab slot)。
10
+ * 数据:Host Connection 共享 Fetch 通道 /api/prts-corpus/rpc(Web / Electron)。
11
+ */
12
+ window.__ModuleLoader__.load({
13
+ id: 'prts-terrarchive',
14
+ factory: (require) => {
15
+ const module = { exports: {} }
16
+ const exports = module.exports
17
+ const React = require('react')
18
+ const { useState, useEffect, useCallback, useMemo, useRef, createElement: h, Fragment } = React
19
+
20
+ let callApi = null
21
+ let themeRuntime = null
22
+ let removeSkinTokens = null
23
+ let activeSkin = 'harness'
24
+ let clientContext = null
25
+ let disposeAicLayout = null
26
+ let disposeScene = null
27
+ let syncAicLayout = () => {}
28
+ let sceneSnapshotModel = null
29
+ let notifySceneSnapshot = () => {}
30
+ let applyGeneration = 0
31
+ let skinOperationEpoch = 0
32
+ let skinApplyEpoch = 0
33
+ // Host -> browser 的皮肤快照共享同一代次。启动配置、设置页完整刷新和
34
+ // 状态轮询无论从哪个入口发出,都只能由最后开始的请求提交到本地。
35
+ let hostSkinSyncGeneration = 0
36
+ const pendingSkinOperations = new Set()
37
+ let skinSelectionOwner = null
38
+ let clientAbortController = null
39
+ const NOOP_STYLESHEET_TRANSACTION = Object.freeze({ commit() {}, rollback() {} })
40
+ let prepareSkinStylesheet = async () => NOOP_STYLESHEET_TRANSACTION
41
+ let skinStylesheetOwner = null
42
+ const SKIN_SOURCE = 'prts-terrarchive:prts-agent-skin'
43
+
44
+ function isHostSettingsOpen(doc = document) {
45
+ return Boolean(doc.querySelector('[data-shortcut-modal="settings"], '
46
+ + '[data-slot="sidebar.settings"] [role="dialog"][aria-modal="true"], '
47
+ + '[role="dialog"][aria-modal="true"]:has([data-slot="settings.header"])'))
48
+ }
49
+
50
+ function openHostSettings(ctx = clientContext, doc = document) {
51
+ if (isHostSettingsOpen(doc)) return true
52
+ // DSH 0.1.3/0.1.5 keeps settings state inside its mounted React component.
53
+ // Clicking its own trigger preserves that instance (including hidden sidebars).
54
+ const trigger = doc.querySelector('[data-slot="sidebar.settings"] button[aria-haspopup="dialog"]')
55
+ if (trigger && !trigger.disabled) {
56
+ trigger.click()
57
+ return true
58
+ }
59
+ // DSH 0.1.7 replaces the trigger with settings.launcher (account menu).
60
+ // Its registered shell store's create() returns the SAME instance used by
61
+ // SettingsRoot and settings.open. Read the live winning entry on each click;
62
+ // retaining it would target a dead shell after a host/plugin reload.
63
+ const slots = ctx?.slots
64
+ const entries = slots?.entriesOfSlot?.('sidebar.settings') ?? slots?.entries?.('sidebar.settings') ?? []
65
+ const shell = entries[0]
66
+ if (!shell?.children?.['settings.launcher'] || typeof shell.store?.create !== 'function') return false
67
+ const instance = shell.store.create()
68
+ if (typeof instance?.actions?.open !== 'function') return false
69
+ instance.actions.open()
70
+ return true
71
+ }
72
+
73
+ /* ---- 终末地「开机场」加载屏:由当前 apply/切肤代次独占,旧回调不得操作新遮罩。 ---- */
74
+ const AIC_BOOT_KEY = 'prts.uiSkin'
75
+ let aicBootEl = null
76
+ let aicBootTimer = null
77
+ let aicBootWatchdog = null
78
+ let aicBootTimeouts = new Set()
79
+ let aicBootPct = 0
80
+ let aicBootEpoch = 0
81
+ let aicBootFinishing = false
82
+ let aicBootStyleOwned = false
83
+ let aicBootPreviousBackground
84
+ function aicBootClearTimers() {
85
+ if (aicBootTimer) { clearInterval(aicBootTimer); aicBootTimer = null }
86
+ if (aicBootWatchdog) { clearTimeout(aicBootWatchdog); aicBootWatchdog = null }
87
+ for (const timer of aicBootTimeouts) clearTimeout(timer)
88
+ aicBootTimeouts.clear()
89
+ }
90
+ function aicBootSchedule(callback, delay, token) {
91
+ const timer = setTimeout(() => {
92
+ aicBootTimeouts.delete(timer)
93
+ if (token === aicBootEpoch) callback()
94
+ }, delay)
95
+ aicBootTimeouts.add(timer)
96
+ return timer
97
+ }
98
+ function aicBootRestoreBackground() {
99
+ if (typeof document === 'undefined') return
100
+ if (aicBootPreviousBackground !== undefined) {
101
+ document.documentElement.style.background = aicBootPreviousBackground
102
+ aicBootPreviousBackground = undefined
103
+ }
104
+ }
105
+ function aicBootRestoreDocument() {
106
+ if (typeof document === 'undefined') return
107
+ aicBootRestoreBackground()
108
+ if (aicBootStyleOwned) document.getElementById('aic-boot-style')?.remove()
109
+ aicBootStyleOwned = false
110
+ }
111
+ function aicBootPaint(percent, message, token = aicBootEpoch) {
112
+ if (!aicBootEl || token !== aicBootEpoch) return
113
+ const failed = percent < 0
114
+ const fill = aicBootEl.querySelector('.aic-boot-fill')
115
+ const label = aicBootEl.querySelector('.aic-boot-msg')
116
+ const pct = aicBootEl.querySelector('.aic-boot-pct')
117
+ if (fill) fill.style.width = `${aicBootPct}%`
118
+ if (label) label.textContent = failed
119
+ ? `LOAD FAILED: ${message ?? 'UNKNOWN'}` : `${(message ?? 'LOADING').toUpperCase()}…`
120
+ if (pct) pct.textContent = failed ? 'ERROR' : `${aicBootPct}%`
121
+ }
122
+ function aicBootProgress(percent, message, token = aicBootEpoch) {
123
+ if (!aicBootEl || token !== aicBootEpoch) return
124
+ if (aicBootTimer) { clearInterval(aicBootTimer); aicBootTimer = null }
125
+ if (percent < 0) {
126
+ aicBootPaint(-1, message, token)
127
+ return
128
+ }
129
+ // 进度只进不退(假爬行与真实事件混跑时避免条子回跳)。
130
+ aicBootPct = Math.max(aicBootPct, Math.min(100, Math.round(percent)))
131
+ aicBootPaint(aicBootPct, message, token)
132
+ }
133
+ function aicBootAbort(forceRestore = activeSkin !== 'endfield-aic') {
134
+ aicBootEpoch += 1
135
+ aicBootClearTimers()
136
+ aicBootEl?.remove()
137
+ aicBootEl = null
138
+ aicBootFinishing = false
139
+ if (forceRestore) aicBootRestoreDocument()
140
+ }
141
+ function aicBootShow(message = 'INITIALIZING', restart = false, skin = 'endfield-aic') {
142
+ if (typeof document === 'undefined') return 0
143
+ if (aicBootEl && !restart && !aicBootFinishing) {
144
+ aicBootProgress(aicBootPct, message)
145
+ return aicBootEpoch
146
+ }
147
+ if (aicBootEl) aicBootAbort(false)
148
+ const token = ++aicBootEpoch
149
+ if (!document.getElementById('aic-boot-style')) {
150
+ const style = document.createElement('style')
151
+ style.id = 'aic-boot-style'
152
+ style.textContent = '.aic-boot{position:fixed;inset:0;z-index:99999;background:#0b0d10;display:flex;align-items:center;justify-content:center;transition:opacity .6s ease .15s,visibility .6s ease .15s}'
153
+ + '.aic-boot[data-skin="prts-agent"],.aic-boot[data-skin="harness"]{background:#f5f5f1;color:#202327}'
154
+ + '.aic-boot[data-skin="rhine-lab"]{background:#ebe8e2;color:#30362c}'
155
+ + '.aic-boot:not([data-skin="endfield-aic"]) .aic-boot-logo{color:inherit;letter-spacing:.12em}'
156
+ + '.aic-boot:not([data-skin="endfield-aic"]) :is(.aic-boot-sub,.aic-boot-line){color:#676b62}'
157
+ + '.aic-boot:not([data-skin="endfield-aic"]) .aic-boot-bar{background:#a5a99655}'
158
+ + '.aic-boot:not([data-skin="endfield-aic"]) .aic-boot-fill{background:currentColor;box-shadow:none}'
159
+ + '.aic-boot.done{opacity:0;visibility:hidden;pointer-events:none}'
160
+ + '.aic-boot-box{width:380px;max-width:82vw;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}'
161
+ + '.aic-boot-logo{font-size:44px;font-weight:900;letter-spacing:10px;color:#faff3f}'
162
+ + '.aic-boot-sub{font-size:10px;letter-spacing:2px;color:rgba(255,255,255,.4);margin:8px 0 26px}'
163
+ + '.aic-boot-bar{height:3px;background:rgba(255,255,255,.1);overflow:hidden}'
164
+ + '.aic-boot-fill{height:100%;width:0;background:#faff3f;box-shadow:0 0 8px rgba(250,255,63,.7);transition:width .3s ease}'
165
+ + '.aic-boot-line{display:flex;justify-content:space-between;margin-top:8px;font-size:10px;letter-spacing:1px;color:rgba(255,255,255,.5)}'
166
+ + '@media(prefers-reduced-motion:reduce){.aic-boot,.aic-boot-fill{transition:none!important}}'
167
+ document.head.appendChild(style)
168
+ aicBootStyleOwned = true
169
+ }
170
+ aicBootEl = document.createElement('div')
171
+ aicBootEl.className = 'aic-boot'
172
+ aicBootEl.dataset.skin = skin
173
+ const branding = skin === 'endfield-aic' ? ['AIC', 'ENDFIELD INDUSTRIES // TALOS-II SURVEY SYSTEM']
174
+ : skin === 'rhine-lab' ? ['RHINE LAB', 'SYNTHESIZE INFORMATION // ARCHIVE SYSTEM']
175
+ : skin === 'prts-agent' ? ['PRTS', 'TERRARCHIVE // RETRIEVAL SYSTEM']
176
+ : ['HARNESS', 'PREPARING WORKSPACE']
177
+ aicBootEl.innerHTML = `<div class="aic-boot-box"><div class="aic-boot-logo">${branding[0]}</div>`
178
+ + `<div class="aic-boot-sub">${branding[1]}</div>`
179
+ + '<div class="aic-boot-bar"><div class="aic-boot-fill"></div></div>'
180
+ + '<div class="aic-boot-line"><span class="aic-boot-msg"></span><span class="aic-boot-pct"></span></div></div>'
181
+ document.body.appendChild(aicBootEl)
182
+ if (aicBootPreviousBackground === undefined) {
183
+ aicBootPreviousBackground = document.documentElement.style.background
184
+ }
185
+ document.documentElement.style.background = skin === 'endfield-aic' ? '#0b0d10' : skin === 'rhine-lab' ? '#ebe8e2' : '#f5f5f1'
186
+ aicBootPct = 4
187
+ aicBootFinishing = false
188
+ aicBootPaint(aicBootPct, message, token)
189
+ // 真实进度(地图资源 / 插件装载事件)到来前缓慢爬行,避免停在 0%。
190
+ aicBootTimer = setInterval(() => {
191
+ if (!aicBootEl || token !== aicBootEpoch) return
192
+ aicBootPct = Math.min(90, aicBootPct + Math.random() * 3)
193
+ aicBootPaint(aicBootPct, 'LOADING', token)
194
+ }, 160)
195
+ // 看门狗:任何挂起(配置请求悬挂、地图脚本阻塞、席位未渲染)最多 30s 强制放行,绝不卡死界面。
196
+ aicBootWatchdog = setTimeout(() => {
197
+ if (!aicBootEl || token !== aicBootEpoch) return
198
+ aicBootProgress(-1, 'STARTUP TIMEOUT', token)
199
+ aicBootSchedule(() => aicBootDone(token, false), 2500, token)
200
+ }, 30000)
201
+ return token
202
+ }
203
+ function aicBootDone(token = aicBootEpoch, succeeded = true) {
204
+ if (!aicBootEl || token !== aicBootEpoch || aicBootFinishing) return
205
+ // Failed startup already paints an ERROR state. Do not overwrite it with a
206
+ // misleading success message while the overlay is fading out.
207
+ if (succeeded) aicBootProgress(100, 'SYSTEM ONLINE', token)
208
+ const el = aicBootEl
209
+ aicBootClearTimers()
210
+ aicBootFinishing = true
211
+ aicBootSchedule(() => {
212
+ el.classList.add('done')
213
+ aicBootSchedule(() => {
214
+ el.remove()
215
+ if (aicBootEl === el) aicBootEl = null
216
+ aicBootFinishing = false
217
+ if (aicBootStyleOwned) document.getElementById('aic-boot-style')?.remove()
218
+ aicBootStyleOwned = false
219
+ if (activeSkin !== 'endfield-aic') aicBootRestoreDocument()
220
+ }, 900, token)
221
+ }, 260, token)
222
+ }
223
+
224
+
225
+ // PRTS Agent 原版是“深色侧栏 + 亮色阅读区”的固定混合亮度,
226
+ // 不是 Harness 主题的一个普通亮/暗配色。因此两种 base scheme 投影到
227
+ // 同一套 canonical token;侧栏的反白文字由局部 CSS token 负责。
228
+ const canonical = (value) => ({ light: value, dark: value })
229
+ const PRTS_AGENT_TOKENS = Object.freeze({
230
+ '--dsw-alias-bg-base': canonical('rgba(255,255,255,.01)'),
231
+ '--dsw-alias-bg-layer-1': canonical('rgba(248,249,246,.9)'),
232
+ '--dsw-alias-bg-layer-2': canonical('rgba(255,255,255,.95)'),
233
+ '--dsw-alias-bg-layer-3': canonical('#ffffff'),
234
+ '--dsw-alias-bg-module-platform': canonical('rgba(239,239,235,.9)'),
235
+ '--dsw-alias-bg-multi-select': canonical('#f1f2ef'),
236
+ '--dsw-alias-bg-overlay': canonical('#f2f3f0'),
237
+ '--dsw-alias-border-l1': canonical('rgba(17,18,20,.075)'),
238
+ '--dsw-alias-border-l2': canonical('rgba(17,18,20,.15)'),
239
+ '--dsw-alias-border-l2-darkmode-thin': canonical('rgba(17,18,20,.11)'),
240
+ '--dsw-alias-border-l3': canonical('rgba(17,18,20,.2)'),
241
+ '--dsw-alias-border-l4': canonical('rgba(17,18,20,.28)'),
242
+ '--dsw-alias-brand-primary': canonical('#111214'),
243
+ '--dsw-alias-brand-text': canonical('#111214'),
244
+ '--dsw-alias-button-elevated-fill': canonical('rgba(255,255,255,.92)'),
245
+ '--dsw-alias-button-floating-fill': canonical('rgba(255,255,255,.9)'),
246
+ '--dsw-alias-button-floating-hover': canonical('#efefeb'),
247
+ '--dsw-alias-button-ghost-active-fill': canonical('rgba(17,18,20,.095)'),
248
+ '--dsw-alias-button-ghost-active-hover': canonical('rgba(17,18,20,.13)'),
249
+ '--dsw-alias-button-info-fill': canonical('#111214'),
250
+ '--dsw-alias-button-info-hover': canonical('#292b2f'),
251
+ '--dsw-alias-button-primary-dimmed': canonical('#e5e6e2'),
252
+ '--dsw-alias-button-primary-hover': canonical('#34373b'),
253
+ '--dsw-alias-interactive-bg-active': canonical('rgba(17,18,20,.095)'),
254
+ '--dsw-alias-interactive-bg-hover': canonical('rgba(17,18,20,.055)'),
255
+ '--dsw-alias-interactive-bg-hover-accent': canonical('rgba(40,105,216,.12)'),
256
+ '--dsw-alias-interactive-bg-hover-solid': canonical('#f1f1ed'),
257
+ '--dsw-alias-label-primary': canonical('#111214'),
258
+ '--dsw-alias-label-primary-dimmed': canonical('#34373b'),
259
+ '--dsw-alias-label-primary-foreground': canonical('#ffffff'),
260
+ '--dsw-alias-label-primary-inverted': canonical('#ffffff'),
261
+ '--dsw-alias-label-secondary': canonical('#34373b'),
262
+ '--dsw-alias-label-tertiary': canonical('#62666c'),
263
+ '--dsw-alias-label-caption': canonical('#858a91'),
264
+ '--dsw-alias-label-dimmed': canonical('#b4b7bb'),
265
+ '--dsw-alias-markdown-citation': canonical('#e4ecfb'),
266
+ '--dsw-alias-markdown-code-block': canonical('rgba(245,246,243,.9)'),
267
+ '--dsw-alias-markdown-code-block-banner': canonical('#efefeb'),
268
+ '--dsw-alias-markdown-inline-code': canonical('#efefeb'),
269
+ '--dsw-alias-scrollbar-bg-l1': canonical('rgba(127,130,132,.25)'),
270
+ '--dsw-alias-scrollbar-bg-l2': canonical('rgba(127,130,132,.32)'),
271
+ '--dsw-alias-scrollbar-hover-l1': canonical('rgba(98,102,108,.45)'),
272
+ '--dsw-alias-scrollbar-hover-l2': canonical('rgba(98,102,108,.55)'),
273
+ '--dsw-alias-state-business-primary': canonical('#2869d8'),
274
+ '--dsw-alias-state-business-tertiary': canonical('#e4ecfb'),
275
+ '--dsw-alias-state-error-primary': canonical('#f25a5a'),
276
+ '--dsw-alias-state-success-primary': canonical('#35ad64'),
277
+ '--dsw-alias-state-warn-primary': canonical('#e89a1c'),
278
+ '--dsw-specific-bubble': canonical('rgba(255,255,255,.82)'),
279
+ '--dsw-specific-bubble-highlight': canonical('#e4ecfb'),
280
+ '--dsw-specific-input-major': canonical('rgba(255,255,255,.9)'),
281
+ '--dsw-specific-menu': canonical('rgba(255,255,255,.97)'),
282
+ '--dsw-specific-selector': canonical('#f5f6f3'),
283
+ '--dsw-specific-sidebar-fill': canonical('transparent'),
284
+ '--dsw-specific-sidebar-nav-item-active': canonical('rgba(255,255,255,.14)'),
285
+ '--dsw-specific-sidebar-nav-item-hover': canonical('rgba(255,255,255,.08)'),
286
+ '--dsw-specific-tip': canonical('rgba(245,246,243,.94)'),
287
+ })
288
+
289
+ // 皮肤接管整套 --dsw-* token:终末地是固定暗色界面,而 Harness 主题仍是
290
+ // light 基线,漏掉的 token(如 bg-module-platform、button-floating-fill)
291
+ // 会保持近白填充,与近白文字叠成"白块"。因此对照 design-platform 全集补齐。
292
+ const ENDFIELD_AIC_TOKENS = Object.freeze({
293
+ '--dsw-alias-bg-base': canonical('#0b0d10'),
294
+ '--dsw-alias-bg-layer-1': canonical('rgba(12,15,17,.88)'),
295
+ '--dsw-alias-bg-layer-2': canonical('rgba(18,21,23,.94)'),
296
+ '--dsw-alias-bg-layer-3': canonical('#15191c'),
297
+ '--dsw-alias-bg-mask-1': canonical('rgba(0,0,0,.55)'),
298
+ '--dsw-alias-bg-mask-2': canonical('rgba(0,0,0,.28)'),
299
+ '--dsw-alias-bg-mask-3': canonical('rgba(0,0,0,.48)'),
300
+ '--dsw-alias-bg-module-platform': canonical('rgba(255,255,255,.07)'),
301
+ '--dsw-alias-bg-multi-select': canonical('rgba(255,255,255,.06)'),
302
+ '--dsw-alias-bg-overlay': canonical('rgba(16,19,22,.92)'),
303
+ '--dsw-alias-bg-skeleton': canonical('rgba(255,255,255,.07)'),
304
+ // 会话思考状态的 shimmer 直接引用品牌蓝 static token,覆写成酸性黄。
305
+ '--dsw-static-deepseek-500': canonical('#faff3f'),
306
+ '--dsw-static-deepseek-200': canonical('#fdffa3'),
307
+ '--dsw-alias-border-l1': canonical('rgba(255,255,255,.1)'),
308
+ '--dsw-alias-border-l2': canonical('rgba(255,255,255,.17)'),
309
+ '--dsw-alias-border-l2-darkmode-thin': canonical('rgba(255,255,255,.1)'),
310
+ '--dsw-alias-border-l3': canonical('rgba(250,255,63,.38)'),
311
+ '--dsw-alias-border-l4': canonical('rgba(255,255,255,.26)'),
312
+ '--dsw-alias-border-inverted': canonical('rgba(255,255,255,.06)'),
313
+ '--dsw-alias-border-inverted2': canonical('rgba(255,255,255,.08)'),
314
+ '--dsw-alias-brand-primary': canonical('#faff3f'),
315
+ '--dsw-alias-brand-primary-invert': canonical('#080a0c'),
316
+ '--dsw-alias-brand-primary-new-colorprimary-new-color': canonical('#faff3f'),
317
+ '--dsw-alias-brand-text': canonical('#faff3f'),
318
+ '--dsw-alias-button-contrast-fill': canonical('rgba(238,240,235,.85)'),
319
+ '--dsw-alias-button-elevated-fill': canonical('rgba(18,21,24,.95)'),
320
+ '--dsw-alias-button-floating-fill': canonical('rgba(14,17,20,.94)'),
321
+ '--dsw-alias-button-floating-hover': canonical('rgba(30,34,38,.96)'),
322
+ '--dsw-alias-button-ghost-active-border': canonical('rgba(250,255,63,.55)'),
323
+ '--dsw-alias-button-ghost-active-fill': canonical('rgba(250,255,63,.12)'),
324
+ '--dsw-alias-button-ghost-active-hover': canonical('rgba(250,255,63,.18)'),
325
+ '--dsw-alias-button-info-fill': canonical('#dce12f'),
326
+ '--dsw-alias-button-info-hover': canonical('#faff3f'),
327
+ '--dsw-alias-button-primary-dimmed': canonical('rgba(250,255,63,.35)'),
328
+ '--dsw-alias-button-primary-fill': canonical('#faff3f'),
329
+ '--dsw-alias-button-primary-hover': canonical('#fdff86'),
330
+ '--dsw-alias-button-tool-bar-fill': canonical('rgba(84,85,87,.5)'),
331
+ '--dsw-alias-button-tool-bar-fill-invisible': canonical('rgba(31,31,31,.36)'),
332
+ '--dsw-alias-button-tool-bar-hover': canonical('rgba(84,85,87,.6)'),
333
+ '--dsw-alias-interactive-bg-active': canonical('rgba(250,255,63,.12)'),
334
+ '--dsw-alias-interactive-bg-hover': canonical('rgba(255,255,255,.07)'),
335
+ '--dsw-alias-interactive-bg-hover-accent': canonical('rgba(250,255,63,.14)'),
336
+ '--dsw-alias-interactive-bg-hover-danger': canonical('rgba(255,103,103,.16)'),
337
+ '--dsw-alias-interactive-bg-hover-solid': canonical('rgba(28,32,36,.95)'),
338
+ '--dsw-alias-label-primary': canonical('#f2f3ef'),
339
+ '--dsw-alias-label-primary-bluish': canonical('#f2f3ef'),
340
+ '--dsw-alias-label-primary-dimmed': canonical('#d8d9d5'),
341
+ '--dsw-alias-label-primary-foreground': canonical('#080a0c'),
342
+ '--dsw-alias-label-primary-inverted': canonical('#101316'),
343
+ '--dsw-alias-label-secondary': canonical('rgba(242,243,239,.74)'),
344
+ '--dsw-alias-label-tertiary': canonical('rgba(242,243,239,.5)'),
345
+ '--dsw-alias-label-caption': canonical('rgba(242,243,239,.35)'),
346
+ '--dsw-alias-label-dimmed': canonical('rgba(242,243,239,.42)'),
347
+ '--dsw-alias-markdown-citation': canonical('rgba(250,255,63,.12)'),
348
+ '--dsw-alias-markdown-code-block': canonical('rgba(5,7,8,.78)'),
349
+ '--dsw-alias-markdown-code-block-banner': canonical('rgba(250,255,63,.08)'),
350
+ '--dsw-alias-markdown-code-segment-selected': canonical('rgba(255,255,255,.1)'),
351
+ '--dsw-alias-markdown-code-segment-unselected': canonical('rgba(255,255,255,.05)'),
352
+ '--dsw-alias-markdown-inline-code': canonical('rgba(250,255,63,.1)'),
353
+ '--dsw-alias-markdown-placeholder': canonical('rgba(255,255,255,.07)'),
354
+ '--dsw-alias-markdown-tag': canonical('rgba(255,255,255,.08)'),
355
+ '--dsw-alias-scrollbar-bg-l1': canonical('rgba(255,255,255,.12)'),
356
+ '--dsw-alias-scrollbar-bg-l2': canonical('rgba(255,255,255,.18)'),
357
+ '--dsw-alias-scrollbar-hover-l1': canonical('rgba(250,255,63,.42)'),
358
+ '--dsw-alias-scrollbar-hover-l2': canonical('rgba(250,255,63,.45)'),
359
+ '--dsw-alias-state-business-primary': canonical('#faff3f'),
360
+ '--dsw-alias-state-business-tertiary': canonical('rgba(250,255,63,.1)'),
361
+ '--dsw-alias-state-error-primary': canonical('#ff6767'),
362
+ '--dsw-alias-state-error-secondary': canonical('#ff8585'),
363
+ '--dsw-alias-state-success-primary': canonical('#b9e85b'),
364
+ '--dsw-alias-state-success-secondary': canonical('#b9e85b'),
365
+ '--dsw-alias-state-success-tertiary': canonical('rgba(185,232,91,.12)'),
366
+ '--dsw-alias-state-warn-label': canonical('#dce12f'),
367
+ '--dsw-alias-state-warn-primary': canonical('#faff3f'),
368
+ '--dsw-alias-state-warn-secondary': canonical('rgba(250,255,63,.75)'),
369
+ '--dsw-alias-state-warn-tertiary': canonical('rgba(250,255,63,.1)'),
370
+ '--dsw-alias-toast-bg': canonical('rgba(17,20,23,.98)'),
371
+ '--dsw-alias-tooltip-bg': canonical('rgba(17,20,23,.98)'),
372
+ '--dsw-specific-bubble': canonical('rgba(10,12,14,.74)'),
373
+ '--dsw-specific-bubble-highlight': canonical('rgba(250,255,63,.08)'),
374
+ '--dsw-specific-input-major': canonical('rgba(13,16,18,.94)'),
375
+ '--dsw-specific-login-input': canonical('rgba(255,255,255,.06)'),
376
+ '--dsw-specific-menu': canonical('rgba(10,12,14,.98)'),
377
+ '--dsw-specific-selector': canonical('rgba(255,255,255,.05)'),
378
+ '--dsw-specific-sidebar-fill': canonical('rgba(8,10,12,.96)'),
379
+ '--dsw-specific-sidebar-nav-item-active': canonical('rgba(250,255,63,.1)'),
380
+ '--dsw-specific-sidebar-nav-item-active-accent': canonical('rgba(250,255,63,.14)'),
381
+ '--dsw-specific-sidebar-nav-item-hover': canonical('rgba(255,255,255,.06)'),
382
+ '--dsw-specific-tip': canonical('rgba(10,12,14,.96)'),
383
+ })
384
+
385
+ // Match the host's settings and menus to the same ivory/olive workspace.
386
+ const RHINE_LAB_TOKENS = Object.freeze({
387
+ ...PRTS_AGENT_TOKENS,
388
+ '--dsw-alias-bg-base': canonical('#ebe8e2'),
389
+ '--dsw-alias-bg-layer-1': canonical('#e7e3d9'),
390
+ '--dsw-alias-bg-layer-2': canonical('#f3f0e8'),
391
+ '--dsw-alias-bg-layer-3': canonical('#faf8f1'),
392
+ '--dsw-alias-bg-mask-1': canonical('rgba(39,43,33,.32)'),
393
+ '--dsw-alias-brand-primary': canonical('#59634b'),
394
+ '--dsw-alias-brand-text': canonical('#48533d'),
395
+ '--dsw-alias-label-primary': canonical('#282e24'),
396
+ '--dsw-alias-label-secondary': canonical('#505747'),
397
+ '--dsw-alias-label-tertiary': canonical('#69705e'),
398
+ '--dsw-alias-label-caption': canonical('#717664'),
399
+ '--dsw-alias-button-info-fill': canonical('#59634b'),
400
+ '--dsw-alias-button-info-hover': canonical('#454e39'),
401
+ '--dsw-alias-button-elevated-fill': canonical('#f3f0e8'),
402
+ '--dsw-alias-button-floating-fill': canonical('#f3f0e8'),
403
+ '--dsw-alias-interactive-bg-active': canonical('rgba(99,112,77,.14)'),
404
+ '--dsw-alias-interactive-bg-hover': canonical('rgba(99,112,77,.08)'),
405
+ '--dsw-alias-state-business-primary': canonical('#59634b'),
406
+ '--dsw-alias-state-business-tertiary': canonical('#e3e6d7'),
407
+ '--dsw-specific-menu': canonical('#f3f0e8'),
408
+ '--dsw-specific-sidebar-fill': canonical('#e7e3d9'),
409
+ })
410
+
411
+ const normalizeSkin = (skin) => skin === 'prts-agent' || skin === 'endfield-aic' || skin === 'rhine-lab'
412
+ ? skin : 'harness'
413
+ const skinTokens = (skin) => skin === 'prts-agent' ? PRTS_AGENT_TOKENS
414
+ : skin === 'endfield-aic' ? ENDFIELD_AIC_TOKENS : skin === 'rhine-lab' ? RHINE_LAB_TOKENS : null
415
+ const writeSkinDataset = (skin) => {
416
+ if (typeof document !== 'undefined') {
417
+ if (skin === 'prts-agent') document.body.dataset.prtsSkin = 'agent'
418
+ else if (skin === 'endfield-aic') document.body.dataset.prtsSkin = 'endfield-aic'
419
+ else if (skin === 'rhine-lab') document.body.dataset.prtsSkin = 'rhine-lab'
420
+ else delete document.body.dataset.prtsSkin
421
+ }
422
+ for (const listener of rhineSkinListeners) listener(skin)
423
+ }
424
+ const persistSkin = (skin) => {
425
+ try {
426
+ if (typeof localStorage !== 'undefined') localStorage.setItem(AIC_BOOT_KEY, skin)
427
+ } catch { /* 隐私模式等无 localStorage 环境不影响已提交的皮肤 */ }
428
+ }
429
+ const setSkin = async (skin, options = {}) => {
430
+ const next = normalizeSkin(skin)
431
+ const operation = ++skinApplyEpoch
432
+ const assertCurrent = () => {
433
+ if (operation !== skinApplyEpoch) throw makeAbortError('皮肤切换已被新的操作取代')
434
+ options.assertCurrent?.()
435
+ }
436
+ const stylesheet = await prepareSkinStylesheet(next)
437
+ try {
438
+ assertCurrent()
439
+ } catch (error) {
440
+ stylesheet.rollback()
441
+ throw error
442
+ }
443
+ if (next === activeSkin) {
444
+ // 同值也要修复运行时挂载;slot owner HMR 或旧版半切换状态不能被短路。
445
+ try {
446
+ await options.beforeCommit?.({ previous: activeSkin, next })
447
+ assertCurrent()
448
+ syncScene()
449
+ syncAicLayout()
450
+ stylesheet.commit()
451
+ // AIC 的 boot 已完成时会刻意保留根节点黑底。轮询或裸 setSkin
452
+ // 离开 AIC 不会再经过 boot 淡出,因此在成功提交后独立恢复原值。
453
+ if (next !== 'endfield-aic') aicBootRestoreBackground()
454
+ persistSkin(next)
455
+ return next
456
+ } catch (error) {
457
+ stylesheet.rollback()
458
+ throw error
459
+ }
460
+ }
461
+ const previous = activeSkin
462
+ const previousDispose = removeSkinTokens
463
+ const nextTokens = skinTokens(next)
464
+ let nextDispose = null
465
+ let runtimeStarted = false
466
+ try {
467
+ // External persistence is the asynchronous prepare phase. Do it while
468
+ // activeSkin, tokens, scene and the committed stylesheet are untouched.
469
+ await options.beforeCommit?.({ previous, next })
470
+ assertCurrent()
471
+ runtimeStarted = true
472
+ // 同 source 的新层会原子替换旧层,旧 disposer 自动变成 no-op。
473
+ if (nextTokens && themeRuntime) {
474
+ nextDispose = themeRuntime.overrideTokens(SKIN_SOURCE, nextTokens)
475
+ }
476
+ activeSkin = next
477
+ writeSkinDataset(next)
478
+ syncScene()
479
+ syncAicLayout()
480
+ if (!nextDispose) previousDispose?.()
481
+ removeSkinTokens = nextDispose
482
+ // Runtime and CSS commit form one synchronous section, so cleanup/HMR
483
+ // cannot interleave and let an obsolete rollback overwrite a new owner.
484
+ stylesheet.commit()
485
+ if (next !== 'endfield-aic') aicBootRestoreBackground()
486
+ persistSkin(next)
487
+ return next
488
+ } catch (error) {
489
+ stylesheet.rollback()
490
+ if (!runtimeStarted) throw error
491
+ // 清掉新皮肤已经安装的 DOM/slot,再恢复旧 token、dataset 与运行时。
492
+ try {
493
+ activeSkin = 'harness'
494
+ syncScene()
495
+ syncAicLayout()
496
+ } catch { /* 尽最大努力回滚,保留原始异常 */ }
497
+ try { nextDispose?.() } catch (rollbackError) {
498
+ console.error('[prts-terrarchive] failed to dispose rejected skin tokens', rollbackError)
499
+ }
500
+ const previousTokens = skinTokens(previous)
501
+ try {
502
+ removeSkinTokens = previousTokens && themeRuntime
503
+ ? themeRuntime.overrideTokens(SKIN_SOURCE, previousTokens)
504
+ : previousDispose
505
+ } catch (rollbackError) {
506
+ removeSkinTokens = previousDispose
507
+ console.error('[prts-terrarchive] failed to restore previous skin tokens', rollbackError)
508
+ }
509
+ activeSkin = previous
510
+ writeSkinDataset(previous)
511
+ try { syncScene(); syncAicLayout() } catch { /* 保留原始异常 */ }
512
+ throw error
513
+ }
514
+ }
515
+
516
+ const PRTS_SEARCH_TOOLS = ['cloud_search', 'cloud_inspect', 'corpus_search', 'timeline_search']
517
+ const PRTS_SCENE_TOOLS = [...PRTS_SEARCH_TOOLS, 'corpus_read']
518
+ const emptyToolVisual = () => ({ state: 'standby', calls: 0, latest: null })
519
+ const compactSceneText = (value, fallback, limit = 32) => {
520
+ const text = String(value ?? '').trim().replace(/\s+/gu, ' ')
521
+ if (!text) return fallback
522
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text
523
+ }
524
+ const parseToolArguments = (raw) => {
525
+ if (raw && typeof raw === 'object') return raw
526
+ try { return JSON.parse(String(raw || '{}')) } catch { return {} }
527
+ }
528
+ const toolSnapshot = (root) => {
529
+ if (!root) return null
530
+ if ('kind' in root) return {
531
+ name: root.call?.name || '', argsRaw: root.call?.argsRaw || '{}',
532
+ state: root.isError ? 'error' : 'complete', time: root.time || 0,
533
+ }
534
+ return { name: root.name || '', argsRaw: root.argsRaw || '{}', state: 'active', time: root.time || 0 }
535
+ }
536
+ const visibleAssistantText = (blocks) => (Array.isArray(blocks) ? blocks : [])
537
+ .filter((block) => block?.kind === 'text')
538
+ .map((block) => block.text || '').join('').trim()
539
+
540
+ /** DSH Chat snapshot -> PRTS Agent 四阶段视觉模型。 */
541
+ const buildSceneSnapshotModel = (order, nodes) => {
542
+ const visuals = Object.fromEntries(PRTS_SCENE_TOOLS.map((name) => [name, emptyToolVisual()]))
543
+ const assistantSteps = []
544
+ for (let index = 0; index < (Array.isArray(order) ? order.length : 0); index += 1) {
545
+ const node = nodes?.get?.(order[index])
546
+ if (node?.kind === 'tool-call') {
547
+ const call = toolSnapshot(node.data?.root)
548
+ if (!call || !Object.hasOwn(visuals, call.name)) continue
549
+ const visual = visuals[call.name]
550
+ visual.calls += 1
551
+ visual.latest = { ...call, index, arguments: parseToolArguments(call.argsRaw) }
552
+ visual.state = call.state
553
+ } else if (node?.kind === 'assistant-step') {
554
+ assistantSteps.push({
555
+ index, status: node.data?.status || 'settled',
556
+ text: visibleAssistantText(node.data?.blocks),
557
+ })
558
+ }
559
+ }
560
+ const aggregate = (names) => {
561
+ const states = names.map((name) => visuals[name].state)
562
+ if (states.includes('active')) return 'active'
563
+ if (states.includes('error') && !states.includes('complete')) return 'error'
564
+ if (states.includes('complete')) return 'complete'
565
+ if (states.includes('error')) return 'error'
566
+ return 'standby'
567
+ }
568
+ const searchCalls = PRTS_SEARCH_TOOLS.flatMap((name) => {
569
+ const latest = visuals[name].latest
570
+ return latest ? [{ ...latest, name, calls: visuals[name].calls }] : []
571
+ }).sort((left, right) => left.index - right.index)
572
+ const latestQuery = searchCalls.at(-1) || null
573
+ const latestRead = visuals.corpus_read.latest
574
+ const hasToolCall = PRTS_SCENE_TOOLS.some((name) => visuals[name].calls > 0)
575
+ const assistantRunning = assistantSteps.some((step) => step.status === 'running')
576
+ const finalAnswer = [...assistantSteps].reverse().find((step) => step.text && step.status !== 'running')
577
+ const planState = hasToolCall ? 'complete' : assistantRunning ? 'active' : 'standby'
578
+ const recallState = aggregate(PRTS_SEARCH_TOOLS)
579
+ const readState = visuals.corpus_read.state
580
+ let verifyState = 'standby'
581
+ if (readState === 'error') verifyState = 'error'
582
+ else if (latestRead) {
583
+ const answerAfterRead = finalAnswer && finalAnswer.index > latestRead.index
584
+ verifyState = answerAfterRead ? 'complete' : 'active'
585
+ }
586
+
587
+ const queryArgs = latestQuery?.arguments || {}
588
+ const queryValue = queryArgs.query?.text || queryArgs.query || ''
589
+ const queryScopes = queryArgs.options?.channels || queryArgs.resource_types
590
+ || queryArgs.filters?.resource_types || []
591
+ const queryScope = Array.isArray(queryScopes) && queryScopes.length
592
+ ? queryScopes.join(' + ')
593
+ : latestQuery?.name?.startsWith('cloud_') ? `${queryArgs.depth || 'standard'} / cloud` : 'local corpus + timeline'
594
+ const readArgs = latestRead?.arguments || {}
595
+ const selection = readArgs.selection || readArgs
596
+ const sourceRange = selection.mode === 'range'
597
+ ? `L${selection.start_line || '?'}—L${selection.end_line || '?'}`
598
+ : readArgs.line || selection.line
599
+ ? `L${readArgs.line || selection.line} ± ${selection.before ?? 3}/${selection.after ?? 3}`
600
+ : selection.mode === 'document' ? 'DOCUMENT / PAGED' : '文档 · 行号 · 说话人'
601
+ const localCalls = visuals.corpus_search.calls + visuals.timeline_search.calls
602
+ const recallTitle = visuals.cloud_search.calls ? '云端召回候选'
603
+ : visuals.timeline_search.calls ? '检索事件时间线' : '召回候选片段'
604
+ const recallMeta = visuals.cloud_search.calls ? `云端检索 / ${visuals.cloud_search.calls} 次`
605
+ : localCalls ? `本地索引 / ${localCalls} 次` : '本地索引 / 待命'
606
+ const stateLabel = (state, labels) => labels[state] || labels.standby
607
+ const running = assistantRunning || PRTS_SCENE_TOOLS.some((name) => visuals[name].state === 'active')
608
+ const latestActivity = latestRead?.state === 'active' ? '正在核验原文'
609
+ : latestQuery?.state === 'active' ? '正在检索候选'
610
+ : verifyState === 'active' ? '正在核对来源与主张'
611
+ : hasToolCall ? '本地证据链' : '本地检索'
612
+
613
+ return {
614
+ visuals,
615
+ plan: { state: planState, title: '拆解问题意图', meta: stateLabel(planState, {
616
+ active: '正在解析实体 · 时间 · 因果', complete: '检索计划已生成',
617
+ error: '计划生成异常', standby: '实体 · 时间 · 因果',
618
+ }) },
619
+ recall: { state: recallState, title: recallTitle, meta: recallMeta },
620
+ read: { state: readState, title: '定位原文上下文', meta: sourceRange },
621
+ verify: { state: verifyState, title: '交叉核验证据', meta: stateLabel(verifyState, {
622
+ active: '正在核对来源与主张', complete: '证据核验完成',
623
+ error: '证据核验异常', standby: '等待已读原文',
624
+ }) },
625
+ query: {
626
+ state: latestQuery?.state || planState,
627
+ text: compactSceneText(queryValue, '等待用户问题'),
628
+ scope: compactSceneText(queryScope, 'local corpus'),
629
+ status: stateLabel(latestQuery?.state || planState, {
630
+ active: 'QUERYING RETRIEVAL SERVICE', complete: 'QUERY RESOLVED',
631
+ error: 'QUERY FAILED', standby: 'WAITING FOR AGENT',
632
+ }),
633
+ },
634
+ source: {
635
+ state: readState,
636
+ title: compactSceneText(readArgs.title || readArgs.locator?.display_title, '等待定位原文'),
637
+ range: sourceRange,
638
+ status: stateLabel(readState, {
639
+ active: 'READING SOURCE CONTEXT', complete: 'SOURCE CONTEXT READY',
640
+ error: 'SOURCE READ FAILED', standby: 'WAITING FOR SOURCE',
641
+ }),
642
+ },
643
+ stack: [
644
+ `01 cloud.search ×${visuals.cloud_search.calls}`,
645
+ `02 corpus.search ×${visuals.corpus_search.calls}`,
646
+ `03 source.read ×${visuals.corpus_read.calls}`,
647
+ ],
648
+ stackStates: [
649
+ visuals.cloud_search.state,
650
+ visuals.corpus_search.state,
651
+ visuals.corpus_read.state,
652
+ ],
653
+ ticker: latestActivity,
654
+ tickerState: running ? 'LIVE' : hasToolCall || finalAnswer ? 'DONE' : 'IDLE',
655
+ running,
656
+ }
657
+ }
658
+
659
+ const sceneSnapshotSignature = (snapshot) => (snapshot?.order || []).map((key) => {
660
+ const node = snapshot.nodes?.get?.(key)
661
+ if (node?.kind === 'tool-call') {
662
+ const call = toolSnapshot(node.data?.root)
663
+ return call ? `t:${call.name}:${call.state}:${call.argsRaw}` : 't:'
664
+ }
665
+ if (node?.kind === 'assistant-step') {
666
+ return `a:${node.data?.status}:${visibleAssistantText(node.data?.blocks).length}`
667
+ }
668
+ return node?.kind || ''
669
+ }).join('|')
670
+
671
+ const SCENE_HTML = `
672
+ <div class="hero-system-map">
673
+ <div class="prts-scene-grid"></div>
674
+ <div class="prts-scene-shard prts-shard-dark"></div>
675
+ <div class="prts-scene-shard prts-shard-glass"></div>
676
+ <div class="prts-scene-shard prts-shard-silver"></div>
677
+ <div class="prts-scan-orbit"><i></i><i></i><i></i><i></i><span>SEARCH<br>RADIUS</span></div>
678
+ <div class="prts-crosshair prts-crosshair-a"><i></i></div>
679
+ <div class="prts-crosshair prts-crosshair-b"><i></i></div>
680
+ <div class="prts-ambient-type">RETRIEVAL<br><strong>PROCESS</strong><small>PRTS / 04—16</small></div>
681
+ <div class="prts-trace-window prts-trace-query"><header><span>QUERY_DECOMPOSE</span><i></i></header><p><em>intent</em> <span>等待用户问题</span></p><p><em>scope</em> <span>local corpus</span></p><b><i></i><strong>WAITING FOR AGENT</strong></b></div>
682
+ <div class="prts-trace-window prts-trace-source"><header><span>SOURCE_READ</span><i></i></header><p><em>doc</em> <span>等待定位原文</span></p><p><em>range</em> <span>文档 · 行号 · 说话人</span></p><b><i></i><strong>WAITING FOR SOURCE</strong></b></div>
683
+ <div class="prts-trace-stack"><span data-state="standby">01&nbsp;&nbsp;cloud.search</span><span data-state="standby">02&nbsp;&nbsp;corpus.search</span><span data-state="standby">03&nbsp;&nbsp;source.read</span></div>
684
+ <div class="prts-system-node prts-node-plan"><i></i><small>01 · QUERY PLAN</small><b>拆解问题意图</b><span>等待计划生成</span></div>
685
+ <div class="prts-system-node prts-node-recall"><i></i><small>02 · RETRIEVAL</small><b>云端召回候选</b><span>本地 / 云端检索</span></div>
686
+ <div class="prts-system-node prts-node-read"><i></i><small>03 · SOURCE READ</small><b>定位原文上下文</b><span>DOCUMENT / PAGED</span></div>
687
+ <div class="prts-system-node prts-node-verify"><i></i><small>04 · VERIFY</small><b>交叉核验证据</b><span>证据链完成</span></div>
688
+ <div class="prts-system-caption"><span></span>LOCAL AGENT PROCESSOR <em>STANDBY</em></div>
689
+ <div class="prts-system-ticker"><b>AGENT TRACE</b><span>本地检索</span><i>IDLE</i></div>
690
+ <svg class="prts-cpu-assembly" viewBox="0 0 620 410" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
691
+ <defs><linearGradient id="prts-cpu-board" x1="185" y1="185" x2="433" y2="332" gradientUnits="userSpaceOnUse"><stop stop-color="#F8F8F5"/><stop offset=".52" stop-color="#E8E9E5"/><stop offset="1" stop-color="#FDFDFC"/></linearGradient><linearGradient id="prts-cpu-die" x1="257" y1="196" x2="365" y2="276" gradientUnits="userSpaceOnUse"><stop stop-color="#16181B"/><stop offset=".58" stop-color="#3B4047"/><stop offset="1" stop-color="#0E1012"/></linearGradient><linearGradient id="prts-cpu-lid" x1="250" y1="70" x2="404" y2="180" gradientUnits="userSpaceOnUse"><stop stop-color="#FFF"/><stop offset="1" stop-color="#D7D9D5"/></linearGradient><filter id="prts-cpu-shadow" x="120" y="120" width="390" height="250" filterUnits="userSpaceOnUse"><feDropShadow dx="0" dy="18" stdDeviation="18" flood-color="#16181B" flood-opacity=".14"/></filter><filter id="prts-lid-shadow" x="190" y="22" width="280" height="220" filterUnits="userSpaceOnUse"><feDropShadow dx="0" dy="12" stdDeviation="14" flood-color="#16181B" flood-opacity=".13"/></filter></defs>
692
+ <g class="prts-circuit-lines" stroke="#9DA19F"><path d="M20 255H135L178 227M36 284H145L185 260M63 318H165L205 292M600 242H485L446 221M584 277H473L435 253M552 314H452L415 286"/><circle cx="20" cy="255" r="3" fill="#9DA19F"/><circle cx="36" cy="284" r="3" fill="#9DA19F"/><circle cx="600" cy="242" r="3" fill="#9DA19F"/><circle cx="584" cy="277" r="3" fill="#9DA19F"/></g>
693
+ <g filter="url(#prts-cpu-shadow)"><path d="M176 204L305 147L447 215L315 299L176 231V204Z" fill="#CBCDCA"/><path d="M176 204L305 136L447 204L315 288L176 220V204Z" fill="url(#prts-cpu-board)" stroke="#B7BAB7"/><path d="M202 207L306 152L421 207L314 273L202 221V207Z" stroke="#C4C7C4"/><path d="M228 207L307 165L394 207L314 257L228 218V207Z" stroke="#D1D3D0"/><g class="prts-cpu-die-group"><path class="prts-cpu-die" d="M267 204L308 182L354 204L313 230L267 210V204Z" fill="url(#prts-cpu-die)" stroke="#0A0B0D"/><path d="M267 204L313 226L354 202" stroke="#777D83" opacity=".55"/><path class="prts-cpu-die-highlight" d="M278 201L308 186L342 202L312 221L278 207V201Z" fill="#68717B" opacity=".42"/></g></g>
694
+ <g class="prts-cpu-pins" stroke="#B4B7B4" stroke-width="2"><path d="M191 232L174 242M210 241L193 252M231 251L214 262M252 261L235 272M273 271L256 282M296 282L279 293M333 279L350 290M354 266L371 277M375 253L392 264M397 239L414 250M418 226L435 237"/></g>
695
+ <g class="prts-cpu-lid" filter="url(#prts-lid-shadow)"><path d="M228 112L313 68L404 112L317 166L228 122V112Z" fill="#C6C9C6"/><path d="M228 104L313 60L404 104L317 158L228 114V104Z" fill="url(#prts-cpu-lid)" stroke="#AEB2AF"/><path d="M251 104L314 72L381 105L316 145L251 114V104Z" stroke="#C1C4C1"/><path d="M299 100L314 92L330 100L314 110L299 103V100Z" fill="#17191C"/></g><g class="prts-assembly-guides" stroke="#8E9391" stroke-dasharray="4 7"><path d="M242 126L242 186M388 119L388 190M316 166V184"/></g>
696
+ </svg>
697
+ </div><div class="prts-empty-hero"><div class="prts-hero-identity"><div class="prts-hero-wordmark"><svg viewBox="0 0 1567 299" aria-label="PRTS"><g fill="#111214"><path d="M0 0H260C300 0 331 28 331 63V135C331 171 300 200 260 200H58C54 200 52 203 52 207V298H0V127H51V145C51 150 54 153 58 153H247C265 153 279 139 279 121V78C279 62 266 49 251 49H44Z"/><path d="M428 0H696C734 0 765 28 765 62V139C765 172 738 200 707 200H686L762 296L698 298L610 200H483C481 200 480 203 480 207V280L479 298H430V127H478V145C478 150 481 153 484 153H683C700 153 714 139 714 121V88C714 66 701 49 685 49H469Z"/><path d="M867 0H1163V49H1045C1043 49 1042 51 1042 53V262L1041 297L1029 298L991 263V53C991 51 989 49 987 49H867Z"/><path d="M1319 0H1554L1512 49H1333C1319 49 1308 61 1308 75V103C1308 116 1319 127 1332 127H1506C1541 127 1566 155 1566 189V244C1566 274 1542 298 1513 298H1310L1289 275C1289 272 1291 269 1293 267L1305 256C1308 253 1311 252 1315 252H1487C1504 252 1517 239 1517 222V199C1517 185 1507 174 1495 174H1315C1282 174 1256 147 1256 124V59C1256 26 1284 0 1319 0Z"/></g></svg><em>BETA</em></div><small><s>PRIMITIVE RHODES ISLAND TERMINAL SERVICE</s> // PRIES—???</small></div><h2>想从泰拉的故事里了解什么?</h2><p>我会先检索线索,再回到本地资料逐行核验,并把可复查的原文放在回答旁边。</p></div>
698
+ `
699
+ // Endfield AIC skin CSS lives in lib/skins/endfield-aic.css.
700
+
701
+ const installScene = () => {
702
+ if (typeof document === 'undefined') return () => {}
703
+ const scene = document.createElement('div')
704
+ scene.id = 'prts-agent-scene'
705
+ scene.className = 'prts-agent-scene'
706
+ // installScene 比 Conversation slot 更早挂载时先保持隐藏,避免在设置、
707
+ // 工作区等非会话页面闪出开机场景。
708
+ scene.dataset.page = 'hidden'
709
+ scene.setAttribute('aria-hidden', 'true')
710
+ scene.innerHTML = SCENE_HTML
711
+ document.body.prepend(scene)
712
+ const replayCpu = (forcePurple = false) => {
713
+ const cpu = scene.querySelector('.prts-cpu-assembly')
714
+ if (!cpu) return
715
+ const replacement = cpu.cloneNode(true)
716
+ replacement.classList.toggle('is-purple', forcePurple || Math.random() < .05)
717
+ cpu.replaceWith(replacement)
718
+ }
719
+ let cpuDebugBuffer = ''
720
+ const handleCpuDebugKeydown = (event) => {
721
+ const target = event.target
722
+ if (event.isComposing || event.ctrlKey || event.metaKey || event.altKey
723
+ || (target instanceof Element && target.closest('input, textarea, select, [contenteditable="true"], [contenteditable="plaintext-only"]'))) {
724
+ cpuDebugBuffer = ''
725
+ return
726
+ }
727
+ if (event.key.length !== 1) return
728
+ cpuDebugBuffer = `${cpuDebugBuffer}${event.key.toLowerCase()}`.slice(-9)
729
+ if (cpuDebugBuffer !== 'priestess') return
730
+ cpuDebugBuffer = ''
731
+ replayCpu(true)
732
+ }
733
+ globalThis.addEventListener('keydown', handleCpuDebugKeydown)
734
+ if (Math.random() < .05) scene.querySelector('.prts-cpu-assembly')?.classList.add('is-purple')
735
+ let frame = null
736
+ let heroExitTimer = null
737
+ let hasShownHero = false
738
+ const stateFor = (names) => {
739
+ const rows = names.flatMap((name) => [...document.querySelectorAll(`[data-tool="${name}"]`)])
740
+ if (rows.some((row) => row.dataset.state === 'running')) return 'active'
741
+ if (rows.some((row) => row.dataset.state === 'error')) return 'error'
742
+ if (rows.length) return 'complete'
743
+ return 'standby'
744
+ }
745
+ const setStage = (selector, value) => {
746
+ const element = scene.querySelector(selector)
747
+ if (!element || !value) return
748
+ element.dataset.state = value.state
749
+ const title = element.querySelector(':scope > b')
750
+ const meta = element.querySelector(':scope > span')
751
+ if (title) title.textContent = value.title
752
+ if (meta) meta.textContent = value.meta
753
+ }
754
+ const setTrace = (selector, value) => {
755
+ const element = scene.querySelector(selector)
756
+ if (!element || !value) return
757
+ element.dataset.state = value.state
758
+ const lines = element.querySelectorAll(':scope > p > span')
759
+ if (lines[0]) lines[0].textContent = value.text || value.title
760
+ if (lines[1]) lines[1].textContent = value.scope || value.range
761
+ const status = element.querySelector(':scope > b > strong')
762
+ if (status) status.textContent = value.status
763
+ }
764
+ const applySnapshotModel = (model, search, read) => {
765
+ if (!model) return
766
+ const resolved = {
767
+ ...model,
768
+ recall: { ...model.recall, state: search === 'active' ? 'active' : model.recall.state },
769
+ read: { ...model.read, state: read === 'active' ? 'active' : model.read.state },
770
+ query: { ...model.query,
771
+ state: search === 'active' ? 'active' : model.query.state,
772
+ status: search === 'active' ? 'QUERYING RETRIEVAL SERVICE' : model.query.status },
773
+ source: { ...model.source,
774
+ state: read === 'active' ? 'active' : model.source.state,
775
+ status: read === 'active' ? 'READING SOURCE CONTEXT' : model.source.status },
776
+ verify: { ...model.verify,
777
+ state: read === 'active' ? 'active' : model.verify.state,
778
+ meta: read === 'active' ? '正在核对来源与主张' : model.verify.meta },
779
+ }
780
+ setStage('.prts-node-plan', resolved.plan)
781
+ setStage('.prts-node-recall', resolved.recall)
782
+ setStage('.prts-node-read', resolved.read)
783
+ setStage('.prts-node-verify', resolved.verify)
784
+ setTrace('.prts-trace-query', resolved.query)
785
+ setTrace('.prts-trace-source', resolved.source)
786
+ const stack = scene.querySelectorAll('.prts-trace-stack > span')
787
+ resolved.stack.forEach((text, index) => {
788
+ if (!stack[index]) return
789
+ stack[index].textContent = text
790
+ stack[index].dataset.state = resolved.stackStates?.[index] || 'standby'
791
+ })
792
+ const caption = scene.querySelector('.prts-system-caption > em')
793
+ if (caption) caption.textContent = resolved.running ? 'ACTIVE' : resolved.tickerState
794
+ }
795
+ const sync = () => {
796
+ frame = null
797
+ const conversation = document.querySelector('[data-phase]:not(#prts-agent-scene)')
798
+ // 路由切走时 Conversation 会从 DOM 暂时卸载。它不代表新建了空白
799
+ // 会话,不能把 scene 重置成 hero;否则返回已有会话会重放整套退场
800
+ // 动画。这里只隐藏场景并保留阶段、计数与工具状态,等会话重新挂载。
801
+ if (!conversation) {
802
+ scene.dataset.page = 'hidden'
803
+ return
804
+ }
805
+ scene.dataset.page = 'conversation'
806
+ const conversationRect = conversation?.getBoundingClientRect?.()
807
+ if (conversationRect?.width > 0) {
808
+ const shellOverlay = document.querySelector('[data-shell-overlay]')
809
+ const shell = shellOverlay?.parentElement
810
+ const sidebarRect = shell?.querySelector(':scope > div:nth-of-type(1)')?.getBoundingClientRect?.()
811
+ const contentLeft = Math.max(conversationRect.left, sidebarRect?.right ?? 0)
812
+ const contentRightEdge = conversationRect.right
813
+ const contentCenter = contentLeft + (contentRightEdge - contentLeft) / 2
814
+ scene.style.setProperty('--agent-content-left', `${contentLeft}px`)
815
+ scene.style.setProperty('--agent-content-right', `${document.documentElement.clientWidth - contentRightEdge}px`)
816
+ scene.style.setProperty('--agent-content-center', `${contentCenter}px`)
817
+
818
+ // Harness 的 hero composer 在某些 shell 布局下仍以 viewport 为
819
+ // 横轴。测量实际卡片中心并补偿到侧栏右侧内容列中心;把累计值
820
+ // 写在 Conversation 上,后续侧栏拖宽/折叠不会来回振荡。
821
+ const composerSeat = conversation.querySelector('[data-composer-seat]')
822
+ const composerCard = conversation.querySelector('[data-composer-card]')
823
+ const composerSeatRect = composerSeat?.getBoundingClientRect?.()
824
+ const composerRect = composerCard?.getBoundingClientRect?.()
825
+ if (composerRect?.width > 0) {
826
+ const currentShift = Number.parseFloat(conversation.style.getPropertyValue('--prts-agent-composer-shift')) || 0
827
+ const actualCenter = composerRect.left + composerRect.width / 2
828
+ const nextShift = currentShift + contentCenter - actualCenter
829
+ if (Math.abs(nextShift - currentShift) > .5) {
830
+ conversation.style.setProperty('--prts-agent-composer-shift', `${nextShift}px`)
831
+ }
832
+ }
833
+
834
+ // 原版 CPU 在 fixed system-map,字标在其下方的 hero 流中。
835
+ // 当前场景是独立 overlay,因此用 SVG 的真实绘图 bbox 恢复两者
836
+ // 至少 34px 的视觉间距,而不是继续猜一个固定 top。
837
+ const cpu = scene.querySelector('.prts-cpu-assembly')
838
+ const wordmark = scene.querySelector('.prts-hero-wordmark')
839
+ const cpuRect = cpu?.getBoundingClientRect?.()
840
+ const cpuBox = typeof cpu?.getBBox === 'function' ? cpu.getBBox() : null
841
+ const wordmarkRect = wordmark?.getBoundingClientRect?.()
842
+ if (cpuRect?.height > 0 && cpuBox && wordmarkRect?.height > 0) {
843
+ const cpuArtBottom = cpuRect.top + ((cpuBox.y + cpuBox.height) / 410) * cpuRect.height
844
+ const currentHeroTop = Number.parseFloat(scene.style.getPropertyValue('--agent-hero-top')) || 371
845
+ const baseWordmarkTop = wordmarkRect.top - currentHeroTop + 371
846
+ const nextHeroTop = 371 + Math.max(0, 34 - (baseWordmarkTop - cpuArtBottom))
847
+ if (Math.abs(nextHeroTop - currentHeroTop) > .5) {
848
+ scene.style.setProperty('--agent-hero-top', `${nextHeroTop}px`)
849
+ }
850
+ }
851
+
852
+ // 欢迎文案和 Harness composer 属于两个独立定位层。垂直测量必须
853
+ // 覆盖整个 seat(含工作区/模式工具栏),不能只测白色输入卡。
854
+ // 文案下方至少留 44px,同时给整个 composer 底部保留 24px。
855
+ // 用“去掉当前位移后的基准 top”计算,避免重复同步累加。
856
+ const heroRect = scene.querySelector('.prts-empty-hero')?.getBoundingClientRect?.()
857
+ if (conversation.dataset.phase === 'hero' && composerSeatRect?.height > 0 && heroRect?.height > 0) {
858
+ const currentY = Number.parseFloat(
859
+ conversation.style.getPropertyValue('--prts-agent-composer-y'),
860
+ ) || 0
861
+ const baseComposerTop = composerSeatRect.top - currentY
862
+ const contentBottom = Math.min(document.documentElement.clientHeight, conversationRect.bottom)
863
+ const wantedY = Math.max(0, heroRect.bottom + 44 - baseComposerTop)
864
+ const maxY = Math.max(0, contentBottom - 24 - composerSeatRect.height - baseComposerTop)
865
+ const nextY = Math.min(wantedY, maxY)
866
+ if (Math.abs(nextY - currentY) > .5) {
867
+ conversation.style.setProperty('--prts-agent-composer-y', `${nextY}px`)
868
+ }
869
+ }
870
+ }
871
+ // 场景本身也会写 data-phase;必须只读取 Harness Conversation,
872
+ // 否则第一次写入 hero 后会永远读回自己,已有会话也不会退场。
873
+ const phase = conversation.dataset.phase ?? 'hero'
874
+ const search = stateFor(['cloud_search', 'corpus_search', 'timeline_search'])
875
+ const read = stateFor(['corpus_read'])
876
+ if (phase === 'hero') {
877
+ hasShownHero = true
878
+ if (heroExitTimer !== null) clearTimeout(heroExitTimer)
879
+ heroExitTimer = null
880
+ scene.dataset.phase = 'hero'
881
+ } else if (phase === 'active' && hasShownHero && scene.dataset.phase !== 'active') {
882
+ scene.dataset.phase = 'leaving'
883
+ if (heroExitTimer === null) {
884
+ const reduceMotion = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches
885
+ heroExitTimer = setTimeout(() => {
886
+ heroExitTimer = null
887
+ scene.dataset.phase = 'active'
888
+ }, reduceMotion ? 0 : 760)
889
+ }
890
+ } else if (scene.dataset.phase !== 'leaving') {
891
+ scene.dataset.phase = phase
892
+ }
893
+ scene.dataset.search = search
894
+ scene.dataset.read = read
895
+ applySnapshotModel(sceneSnapshotModel, search, read)
896
+ const snapshotRunning = sceneSnapshotModel?.running === true
897
+ scene.dataset.running = search === 'active' || read === 'active' || snapshotRunning ? 'true' : 'false'
898
+ const ticker = scene.querySelector('.prts-system-ticker')
899
+ const tickerLabel = ticker?.querySelector('span')
900
+ const tickerState = ticker?.querySelector('i')
901
+ if (tickerLabel) tickerLabel.textContent = read === 'active' ? '正在核验原文'
902
+ : search === 'active' ? '正在检索候选'
903
+ : sceneSnapshotModel?.ticker || (phase === 'active' ? '本地证据链' : '本地检索')
904
+ if (tickerState) tickerState.textContent = scene.dataset.running === 'true'
905
+ ? 'LIVE' : sceneSnapshotModel?.tickerState || (phase === 'active' ? 'DONE' : 'IDLE')
906
+ }
907
+ notifySceneSnapshot = () => schedule()
908
+ const schedule = () => { if (frame == null) frame = requestAnimationFrame(sync) }
909
+ globalThis.addEventListener('resize', schedule)
910
+ const observer = new MutationObserver((records) => {
911
+ // 更新场景自身的 dataset / ticker 也会产生 mutation;忽略这些回声,
912
+ // 避免每帧重新同步。侧栏折叠和拖宽仍由外部节点变更触发。
913
+ if (records.every((record) => record.target === scene || scene.contains(record.target))) return
914
+ schedule()
915
+ })
916
+ observer.observe(document.body, { subtree: true, childList: true, attributes: true,
917
+ attributeFilter: ['data-state', 'data-phase', 'data-tool', 'data-sidebar-collapsed', 'style'] })
918
+ sync()
919
+ return () => {
920
+ observer.disconnect()
921
+ globalThis.removeEventListener('keydown', handleCpuDebugKeydown)
922
+ globalThis.removeEventListener('resize', schedule)
923
+ if (heroExitTimer !== null) clearTimeout(heroExitTimer)
924
+ const conversation = document.querySelector('[data-phase]:not(#prts-agent-scene)')
925
+ conversation?.style.removeProperty('--prts-agent-composer-shift')
926
+ conversation?.style.removeProperty('--prts-agent-composer-y')
927
+ if (frame != null) cancelAnimationFrame(frame)
928
+ notifySceneSnapshot = () => {}
929
+ scene.remove()
930
+ }
931
+ }
932
+
933
+ // PRTS Agent 开机场(场景动画 + MutationObserver)只在对应皮肤下挂载;
934
+ // harness / 终末地皮肤不承担这份常驻 DOM 与监听开销。
935
+ const syncScene = () => {
936
+ if (activeSkin === 'prts-agent') {
937
+ if (!disposeScene) disposeScene = installScene()
938
+ } else if (disposeScene) {
939
+ disposeScene()
940
+ disposeScene = null
941
+ }
942
+ }
943
+
944
+ // 浏览器半边是预构建 classic script,不能直接 import CSS;Host 只暴露这两份
945
+ // 固定白名单资源。候选样式 load 成功后才交给 setSkin 做二阶段提交,旧样式在此
946
+ // 之前始终保留,避免 404、超时和快速切换留下“有皮肤状态、无皮肤 CSS”。
947
+ const SKIN_COMMON_STYLESHEET = Object.freeze({
948
+ id: 'common', href: '/api/prts-corpus/skins/common.css',
949
+ })
950
+ const SKIN_STYLESHEETS = Object.freeze({
951
+ 'prts-agent': '/api/prts-corpus/skins/prts-agent.css',
952
+ 'endfield-aic': '/api/prts-corpus/skins/endfield-aic.css',
953
+ 'rhine-lab': '/api/prts-corpus/skins/rhine-lab.css',
954
+ })
955
+ const SKIN_STYLESHEET_TIMEOUT_MS = 8000
956
+ const createSkinStylesheet = (id, href) => {
957
+ const link = document.createElement('link')
958
+ link.rel = 'stylesheet'
959
+ link.href = href
960
+ link.dataset.plugin = 'prts-terrarchive'
961
+ link.dataset.pluginCss = `${id}-skin`
962
+ return link
963
+ }
964
+ const installSkinStylesheets = () => {
965
+ // Defend against an overlapping/re-entrant apply that installs a replacement
966
+ // effect before the previous cleanup. Retire that owner now so its ready
967
+ // candidate/committed links cannot survive beside the replacement manager.
968
+ skinStylesheetOwner?.dispose?.()
969
+ const owner = {}
970
+ // This tiny fail-closed rule prevents a missing common.css from exposing
971
+ // the always-registered evidence UI as naked browser controls.
972
+ const commonSafetyStyle = document.createElement('style')
973
+ commonSafetyStyle.dataset.plugin = 'prts-terrarchive'
974
+ commonSafetyStyle.dataset.pluginCss = 'common-safety'
975
+ commonSafetyStyle.textContent = '.prts-header-badge,.prts-evidence-layer{display:none}'
976
+ document.head.appendChild(commonSafetyStyle)
977
+ let commonLink = null
978
+ let commonRequest = null
979
+ let committedLink = null
980
+ let committedSkin = 'harness'
981
+ let currentRequest = null
982
+ let disposed = false
983
+
984
+ const ensureCommon = () => {
985
+ if (disposed) return Promise.reject(makeAbortError('皮肤样式管理器已卸载'))
986
+ if (commonLink) return Promise.resolve(commonLink)
987
+ if (commonRequest) return commonRequest.promise
988
+ const candidate = createSkinStylesheet(
989
+ SKIN_COMMON_STYLESHEET.id, SKIN_COMMON_STYLESHEET.href)
990
+ let resolveReady
991
+ let rejectReady
992
+ const request = {
993
+ candidate, timer: null, finished: false,
994
+ promise: new Promise((resolve, reject) => {
995
+ resolveReady = resolve
996
+ rejectReady = reject
997
+ }),
998
+ finish(error) {
999
+ if (request.finished) return
1000
+ request.finished = true
1001
+ clearTimeout(request.timer)
1002
+ candidate.removeEventListener('load', onLoad)
1003
+ candidate.removeEventListener('error', onError)
1004
+ if (commonRequest === request) commonRequest = null
1005
+ if (error) {
1006
+ candidate.remove()
1007
+ rejectReady(error)
1008
+ return
1009
+ }
1010
+ commonLink = candidate
1011
+ commonSafetyStyle.remove()
1012
+ resolveReady(candidate)
1013
+ },
1014
+ }
1015
+ const onLoad = () => request.finish()
1016
+ const onError = () => {
1017
+ console.error('[prts-terrarchive] failed to load common stylesheet')
1018
+ request.finish(new Error('公共皮肤样式加载失败'))
1019
+ }
1020
+ candidate.addEventListener('load', onLoad, { once: true })
1021
+ candidate.addEventListener('error', onError, { once: true })
1022
+ request.timer = setTimeout(() => {
1023
+ request.finish(new Error('公共皮肤样式加载超时'))
1024
+ }, SKIN_STYLESHEET_TIMEOUT_MS)
1025
+ commonRequest = request
1026
+ document.head.appendChild(candidate)
1027
+ return request.promise
1028
+ }
1029
+
1030
+ const invalidateCurrent = (error = makeAbortError('皮肤样式加载已被取代')) => {
1031
+ const request = currentRequest
1032
+ if (!request) return
1033
+ currentRequest = null
1034
+ request.rollback(error)
1035
+ }
1036
+ const prepare = async (skin) => {
1037
+ const id = normalizeSkin(skin)
1038
+ invalidateCurrent()
1039
+ if (disposed) return Promise.reject(makeAbortError('皮肤样式管理器已卸载'))
1040
+ await ensureCommon()
1041
+ if (disposed) throw makeAbortError('皮肤样式管理器已卸载')
1042
+ // Even an already-resolved common promise yields a microtask. A newer
1043
+ // back-to-back switch may have installed its request in that gap.
1044
+ invalidateCurrent()
1045
+ const href = SKIN_STYLESHEETS[id]
1046
+ if (committedSkin === id) return Promise.resolve(NOOP_STYLESHEET_TRANSACTION)
1047
+ if (!href) {
1048
+ let finished = false
1049
+ return Promise.resolve({
1050
+ commit() {
1051
+ if (finished || disposed) return
1052
+ finished = true
1053
+ committedLink?.remove()
1054
+ committedLink = null
1055
+ committedSkin = 'harness'
1056
+ },
1057
+ rollback() { finished = true },
1058
+ })
1059
+ }
1060
+ const candidate = createSkinStylesheet(id, href)
1061
+ let resolveReady
1062
+ let rejectReady
1063
+ const request = {
1064
+ id, candidate, state: 'loading', finished: false, timer: null,
1065
+ promise: new Promise((resolve, reject) => {
1066
+ resolveReady = resolve
1067
+ rejectReady = reject
1068
+ }),
1069
+ cleanup() {
1070
+ clearTimeout(request.timer)
1071
+ candidate.removeEventListener('load', onLoad)
1072
+ candidate.removeEventListener('error', onError)
1073
+ },
1074
+ commit() {
1075
+ if (request.finished || request.state !== 'ready' || disposed
1076
+ || currentRequest !== request) {
1077
+ throw makeAbortError('皮肤样式事务已失效')
1078
+ }
1079
+ request.finished = true
1080
+ request.cleanup()
1081
+ if (currentRequest === request) currentRequest = null
1082
+ committedLink?.remove()
1083
+ committedLink = candidate
1084
+ committedSkin = id
1085
+ },
1086
+ rollback(error) {
1087
+ if (request.finished) return
1088
+ request.finished = true
1089
+ request.cleanup()
1090
+ candidate.remove()
1091
+ if (currentRequest === request) currentRequest = null
1092
+ if (request.state === 'loading') rejectReady(error)
1093
+ },
1094
+ }
1095
+ const onLoad = () => {
1096
+ if (currentRequest !== request || request.finished) return
1097
+ request.state = 'ready'
1098
+ request.cleanup()
1099
+ resolveReady(request)
1100
+ }
1101
+ const onError = () => {
1102
+ console.error(`[prts-terrarchive] failed to load ${id} skin stylesheet`)
1103
+ request.rollback(new Error(`${id} 皮肤样式加载失败`))
1104
+ }
1105
+ candidate.addEventListener('load', onLoad, { once: true })
1106
+ candidate.addEventListener('error', onError, { once: true })
1107
+ request.timer = setTimeout(() => {
1108
+ request.rollback(new Error(`${id} 皮肤样式加载超时`))
1109
+ }, SKIN_STYLESHEET_TIMEOUT_MS)
1110
+ currentRequest = request
1111
+ document.head.appendChild(candidate)
1112
+ return request.promise
1113
+ }
1114
+ const dispose = () => {
1115
+ if (disposed) return
1116
+ disposed = true
1117
+ invalidateCurrent(makeAbortError('皮肤样式管理器已卸载'))
1118
+ commonRequest?.finish(makeAbortError('皮肤样式管理器已卸载'))
1119
+ commonRequest = null
1120
+ commonLink?.remove()
1121
+ commonSafetyStyle.remove()
1122
+ committedLink?.remove()
1123
+ if (skinStylesheetOwner === owner) {
1124
+ skinApplyEpoch += 1
1125
+ skinStylesheetOwner = null
1126
+ prepareSkinStylesheet = async () => NOOP_STYLESHEET_TRANSACTION
1127
+ }
1128
+ }
1129
+ owner.prepare = prepare
1130
+ owner.dispose = dispose
1131
+ skinStylesheetOwner = owner
1132
+ prepareSkinStylesheet = prepare
1133
+ // Start fetching immediately; prepare() joins this request and treats any
1134
+ // failure as a transaction failure. A later switch retries with a new link.
1135
+ void ensureCommon().catch(() => {})
1136
+ return dispose
1137
+ }
1138
+
1139
+ const ENDPOINTS = Object.freeze({
1140
+ '/status': 'status', '/releases': 'releases', '/check-update': 'check-update',
1141
+ '/config:GET': 'config.get', '/config:PUT': 'config.update',
1142
+ '/download': 'download', '/activate': 'activate', '/delete': 'delete',
1143
+ })
1144
+ const jsonFetch = async (path, init = {}) => {
1145
+ if (!callApi) throw new Error('PRTS Connection 尚未就绪')
1146
+ const method = init.method ?? 'GET'
1147
+ const endpoint = ENDPOINTS[`${path}:${method}`] ?? ENDPOINTS[path]
1148
+ if (!endpoint) throw new Error(`未知 PRTS 操作 ${method} ${path}`)
1149
+ const payload = init.body ? JSON.parse(init.body) : {}
1150
+ return callApi(endpoint, payload, init.signal)
1151
+ }
1152
+ const makeAbortError = (message = '操作已取消') => {
1153
+ const error = new Error(message)
1154
+ error.name = 'AbortError'
1155
+ return error
1156
+ }
1157
+ const beginHostSkinSync = () => {
1158
+ hostSkinSyncGeneration += 1
1159
+ return hostSkinSyncGeneration
1160
+ }
1161
+ const assertHostSkinSyncActive = (generation) => {
1162
+ if (generation !== hostSkinSyncGeneration) {
1163
+ throw makeAbortError('Host 皮肤快照已被更新的请求取代')
1164
+ }
1165
+ }
1166
+ const assertApplyActive = (generation, signal) => {
1167
+ if (signal?.aborted || generation !== applyGeneration || !clientContext) throw makeAbortError()
1168
+ }
1169
+ const assertConfiguredSkinActive = (generation, signal, skinEpoch, hostSyncGeneration) => {
1170
+ assertApplyActive(generation, signal)
1171
+ if (skinEpoch !== skinOperationEpoch) throw makeAbortError('皮肤配置已被新的用户操作取代')
1172
+ assertHostSkinSyncActive(hostSyncGeneration)
1173
+ }
1174
+ const waitWithDeadline = (promise, { signal, timeout = 10000, label = '请求' } = {}) =>
1175
+ new Promise((resolve, reject) => {
1176
+ let settled = false
1177
+ const finish = (callback, value) => {
1178
+ if (settled) return
1179
+ settled = true
1180
+ clearTimeout(timer)
1181
+ signal?.removeEventListener('abort', onAbort)
1182
+ callback(value)
1183
+ }
1184
+ const onAbort = () => finish(reject, makeAbortError())
1185
+ const timer = setTimeout(() => finish(reject, new Error(`${label}超时`)), timeout)
1186
+ signal?.addEventListener('abort', onAbort, { once: true })
1187
+ if (signal?.aborted) { onAbort(); return }
1188
+ Promise.resolve(promise).then(
1189
+ (value) => finish(resolve, value),
1190
+ (error) => finish(reject, error),
1191
+ )
1192
+ })
1193
+ const writeSkinConfig = async (skin, {
1194
+ signal = clientAbortController?.signal, timeout = 10000,
1195
+ } = {}) => {
1196
+ if (signal?.aborted) throw makeAbortError('皮肤配置写入已取消')
1197
+ const controller = new AbortController()
1198
+ let timer = null
1199
+ let rejectControl
1200
+ const control = new Promise((_resolve, reject) => { rejectControl = reject })
1201
+ const cancel = (error) => {
1202
+ if (controller.signal.aborted) return
1203
+ controller.abort(error)
1204
+ rejectControl(error)
1205
+ }
1206
+ const onAbort = () => cancel(makeAbortError('皮肤配置写入已取消'))
1207
+ signal?.addEventListener('abort', onAbort, { once: true })
1208
+ timer = setTimeout(() => cancel(makeAbortError('皮肤配置写入超时')), timeout)
1209
+ try {
1210
+ return await Promise.race([
1211
+ jsonFetch('/config', {
1212
+ method: 'PUT', body: JSON.stringify({ patch: { uiSkin: normalizeSkin(skin) } }),
1213
+ signal: controller.signal,
1214
+ }),
1215
+ control,
1216
+ ])
1217
+ } finally {
1218
+ clearTimeout(timer)
1219
+ signal?.removeEventListener('abort', onAbort)
1220
+ }
1221
+ }
1222
+ const fetchWithDeadline = async (url, init, { signal, timeout = 8000 } = {}) => {
1223
+ const controller = new AbortController()
1224
+ const abort = () => controller.abort(signal?.reason)
1225
+ signal?.addEventListener('abort', abort, { once: true })
1226
+ if (signal?.aborted) abort()
1227
+ const timer = setTimeout(() => controller.abort(makeAbortError('配置请求超时')), timeout)
1228
+ try {
1229
+ return await fetch(url, { ...init, signal: controller.signal })
1230
+ } finally {
1231
+ clearTimeout(timer)
1232
+ signal?.removeEventListener('abort', abort)
1233
+ }
1234
+ }
1235
+ const activateConfiguredSkin = (skin, options = {}) => {
1236
+ const next = normalizeSkin(skin)
1237
+ if (next === 'endfield-aic' && !aicBootEl) aicBootShow('CONNECTING TERMINAL')
1238
+ return setSkin(next, options)
1239
+ }
1240
+ const loadConfiguredSkin = async ({ signal, generation, skinEpoch, hostSyncGeneration }) => {
1241
+ const assertCurrent = () => {
1242
+ assertConfiguredSkinActive(generation, signal, skinEpoch, hostSyncGeneration)
1243
+ }
1244
+ let resolved = null
1245
+ if (typeof fetch === 'function') {
1246
+ try {
1247
+ const response = await fetchWithDeadline('/api/prts-corpus/ui-skin.json', { cache: 'no-store' }, { signal })
1248
+ assertCurrent()
1249
+ if (response.ok) {
1250
+ const payload = await waitWithDeadline(response.json(), {
1251
+ signal, timeout: 3000, label: '皮肤配置解析',
1252
+ })
1253
+ assertCurrent()
1254
+ resolved = await activateConfiguredSkin(payload?.uiSkin, { assertCurrent })
1255
+ }
1256
+ } catch (error) {
1257
+ if (signal?.aborted || generation !== applyGeneration
1258
+ || skinEpoch !== skinOperationEpoch
1259
+ || hostSyncGeneration !== hostSkinSyncGeneration) throw makeAbortError()
1260
+ console.error('[prts-terrarchive] skin endpoint activation failed', error)
1261
+ }
1262
+ }
1263
+ if (resolved === null) {
1264
+ try {
1265
+ const status = await waitWithDeadline(jsonFetch('/status'), {
1266
+ signal, timeout: 10000, label: '皮肤配置 RPC',
1267
+ })
1268
+ assertCurrent()
1269
+ resolved = await activateConfiguredSkin(status?.config?.uiSkin, { assertCurrent })
1270
+ } catch (error) {
1271
+ if (error?.name === 'AbortError') throw error
1272
+ // 配置通道失败时立即撤下开机场,回退到当前界面,绝不能把 boot 屏留在原地。
1273
+ aicBootAbort()
1274
+ throw error
1275
+ }
1276
+ }
1277
+ assertCurrent()
1278
+ // setSkin 已在 stylesheet 提交后写入;这里用最终解析值再次校准启动缓存。
1279
+ try { if (resolved) localStorage.setItem(AIC_BOOT_KEY, resolved) } catch { /* 忽略 */ }
1280
+ if (resolved !== 'endfield-aic') aicBootAbort()
1281
+ return resolved
1282
+ }
1283
+
1284
+ /* ---- 证据抽取:优先走会话快照(不依赖视图 DOM),DOM 抓取仅作回退 ---- */
1285
+
1286
+ const rowText = (row) => String(row.textContent ?? '').replace(/\s+/g, ' ').trim()
1287
+ const DATA_VERSION_PATTERN = /^[0-9a-f]{64}$/
1288
+ const validDataVersion = (value) => typeof value === 'string'
1289
+ && DATA_VERSION_PATTERN.test(value)
1290
+
1291
+ /** 一条 corpus_read 模型投影 → 人类可读标题与引用范围。 */
1292
+ const readPresentation = (value) => {
1293
+ if (!value || value.kind !== 'prts-corpus-read-v1' || !value.locator
1294
+ || typeof value.locator !== 'object') return null
1295
+ const documentId = typeof value.locator.document_id === 'string'
1296
+ && value.locator.document_id.length <= 512 ? value.locator.document_id : ''
1297
+ const sourceRef = typeof value.locator.source_ref === 'string'
1298
+ && value.locator.source_ref.length <= 1024 ? value.locator.source_ref : ''
1299
+ const documentUid = typeof value.locator.document_uid === 'string'
1300
+ && /^doc_[A-Za-z0-9_-]{16}$/.test(value.locator.document_uid)
1301
+ ? value.locator.document_uid : ''
1302
+ if (!documentId && !sourceRef) return null
1303
+ const primary = {
1304
+ documentId, documentUid, sourceRef,
1305
+ // 只有契约中的 sha256 才能将原文绑定到确定版本。旧会话里的
1306
+ // 人类版本名或截断 hash 仅供展示,不能标记 VERIFIED。
1307
+ dataVersion: validDataVersion(value.data_version) ? value.data_version : '',
1308
+ title: typeof value.title === 'string' && value.title.length <= 512 ? value.title : '',
1309
+ lineStart: Number.isInteger(value.line_start) && value.line_start > 0 ? value.line_start : null,
1310
+ lineEnd: Number.isInteger(value.line_end) && value.line_end > 0 ? value.line_end : null,
1311
+ }
1312
+ const sources = Array.isArray(value.sources) ? value.sources.slice(0, 128).map((source) => {
1313
+ const sourceDocumentId = typeof source?.document_id === 'string'
1314
+ && source.document_id.length <= 512 ? source.document_id : ''
1315
+ if (!sourceDocumentId) return null
1316
+ const documentUid = typeof source.document_uid === 'string'
1317
+ && /^doc_[A-Za-z0-9_-]{16}$/.test(source.document_uid) ? source.document_uid : ''
1318
+ return { documentId: sourceDocumentId, documentUid, sourceRef: '', dataVersion: primary.dataVersion,
1319
+ title: typeof source.title === 'string' && source.title.length <= 512 ? source.title : '',
1320
+ lineStart: Number.isInteger(source.line_start) && source.line_start > 0 ? source.line_start : null,
1321
+ lineEnd: Number.isInteger(source.line_end) && source.line_end > 0 ? source.line_end : null }
1322
+ }).filter(Boolean) : []
1323
+ return { ...primary, sources }
1324
+ }
1325
+
1326
+ const rememberReadBlock = (byTitle, full, rawPresentation) => {
1327
+ const presentation = readPresentation(rawPresentation)
1328
+ const titleMatch = full.match(/#\s+(.+?)\s+(?:范围:|字段:)/u)
1329
+ const citationMatch = full.match(/引用:《([^》]+)》(?:(document_uid=(doc_[A-Za-z0-9_-]{16})))?(?:第\s*(\d+)(?:[-–](\d+))?\s*行|Wiki)/u)
1330
+ const projectedSources = presentation?.sources?.length ? presentation.sources : [presentation || {}]
1331
+ for (const source of projectedSources) {
1332
+ const title = String(source.title || presentation?.title || citationMatch?.[1]
1333
+ || titleMatch?.[1] || '').replace(/\s+/g, ' ').trim()
1334
+ if (!title) continue
1335
+ const lineStart = source.lineStart ?? (citationMatch?.[3] ? Number(citationMatch[3]) : null)
1336
+ const lineEnd = source.lineEnd ?? (citationMatch?.[4] ? Number(citationMatch[4]) : lineStart)
1337
+ const excerpt = presentation?.sources?.length > 1
1338
+ ? `《${title}》${lineStart ? `第 ${lineStart}${lineEnd && lineEnd !== lineStart ? `-${lineEnd}` : ''} 行` : '资料片段'}`
1339
+ : full.slice(0, 260)
1340
+ // 同一轮内可能因重试/热更新重读同名文档;按会话顺序保留最后一次,
1341
+ // 避免 first-write-wins 把旧 data_version 固化到证据卡。
1342
+ const documentUid = source.documentUid || citationMatch?.[2] || ''
1343
+ const key = documentUid ? `${title}\u0000${documentUid}` : title
1344
+ byTitle.set(key, { title, lineStart, lineEnd, excerpt, documentUid,
1345
+ documentId: source.documentId || '', sourceRef: source.sourceRef || '',
1346
+ dataVersion: source.dataVersion || presentation?.dataVersion || '' })
1347
+ }
1348
+ }
1349
+
1350
+ /**
1351
+ * 会话快照版(首选):tool-call 节点的 tool-result 带 renderRead 全文,
1352
+ * 最后一条含正文的 assistant-step 是引用解析对象。与当前挂载的是哪个
1353
+ * 会话视图(chat / trajectory)无关。
1354
+ */
1355
+ const collectSnapshotEvidence = (order, nodes) => {
1356
+ const byTitle = new Map()
1357
+ let lastAnswer = ''
1358
+ if (!Array.isArray(order) || !nodes) return { byTitle, lastAnswer }
1359
+ // Header 标注的是“本轮证据”:从最后一条 user 节点开始投影,不把
1360
+ // 上一轮的 corpus_read 在当前回答无引用时回退进来。无 user 的旧快照
1361
+ // 保持兼容,仍按完整 order 处理。
1362
+ let turnStart = 0
1363
+ for (let index = 0; index < order.length; index += 1) {
1364
+ const node = typeof nodes.get === 'function' ? nodes.get(order[index]) : null
1365
+ if (node?.kind === 'user') turnStart = index
1366
+ }
1367
+ for (const key of order.slice(turnStart)) {
1368
+ const node = typeof nodes.get === 'function' ? nodes.get(key) : null
1369
+ if (!node) continue
1370
+ if (node.kind === 'tool-call') {
1371
+ const root = node.data?.root
1372
+ if (root?.kind !== 'tool-result' || root.call?.name !== 'corpus_read') continue
1373
+ const full = contentText(root.content).replace(/\s+/g, ' ').trim()
1374
+ if (full.includes('引用:《')) rememberReadBlock(byTitle, full, root.meta)
1375
+ } else if (node.kind === 'assistant-step') {
1376
+ const text = assistantParts(node.data?.blocks).text
1377
+ if (text) lastAnswer = String(text).replace(/\s+/g, ' ').trim()
1378
+ }
1379
+ }
1380
+ return { byTitle, lastAnswer }
1381
+ }
1382
+
1383
+ // ChatNodeStore and order retain their identities for same-key updates. Use
1384
+ // the evidence inputs themselves as the subscription value, including text
1385
+ // and metadata changes that do not alter the scene's status/text length.
1386
+ const evidenceSnapshotSignature = (snapshot) => {
1387
+ const { byTitle, lastAnswer } = collectSnapshotEvidence(snapshot?.order, snapshot?.nodes)
1388
+ return JSON.stringify({ reads: [...byTitle], lastAnswer })
1389
+ }
1390
+
1391
+ /** DOM 版(回退):轨迹视图 tr[data-kind="tool"] 与默认会话视图 [data-tool="corpus_read"] 都要覆盖。 */
1392
+ const collectDomEvidence = () => {
1393
+ const byTitle = new Map()
1394
+ if (typeof document === 'undefined') return { byTitle, lastAnswer: '' }
1395
+ let lastAnswer = ''
1396
+ const flowRows = [...document.querySelectorAll('[data-chat-flow-kind]')]
1397
+ if (flowRows.length) {
1398
+ const turnStart = flowRows.findLastIndex((row) => row.dataset?.chatFlowKind === 'user')
1399
+ const currentRows = flowRows.slice(Math.max(0, turnStart))
1400
+ for (const row of currentRows) {
1401
+ const full = rowText(row)
1402
+ const isCorpusRead = row.matches?.('[data-tool="corpus_read"]')
1403
+ || row.querySelector?.('[data-tool="corpus_read"]') || full.includes('corpus_read')
1404
+ if (row.dataset?.chatFlowKind === 'tool-call' && isCorpusRead
1405
+ && full.includes('引用:《')) rememberReadBlock(byTitle, full)
1406
+ if (row.dataset?.chatFlowKind === 'assistant-step' && full
1407
+ && !full.includes('引用:《')) lastAnswer = full
1408
+ }
1409
+ return { byTitle, lastAnswer }
1410
+ }
1411
+ // 轨迹表回退:同样从最后一条 USER/用户消息开始。
1412
+ const rows = [...document.querySelectorAll('tr[data-kind]')]
1413
+ const turnStart = rows.findLastIndex((row) => row.dataset?.kind === 'message'
1414
+ && /USER|用户/i.test(row.querySelector('td[class*="event"]')?.textContent ?? ''))
1415
+ for (const row of rows.slice(Math.max(0, turnStart))) {
1416
+ const event = row.querySelector('td[class*="event"]')?.textContent ?? ''
1417
+ const full = rowText(row)
1418
+ if (row.dataset?.kind === 'tool' && full.includes('corpus_read')
1419
+ && full.includes('引用:《')) rememberReadBlock(byTitle, full)
1420
+ if (row.dataset?.kind === 'message' && /ASSISTANT/i.test(event)) lastAnswer = full
1421
+ }
1422
+ return { byTitle, lastAnswer }
1423
+ }
1424
+
1425
+ /** 由读取映射 + 最后一条回答计算证据卡:只取回答真正引用的主要参考资料。 */
1426
+ const buildEvidence = ({ byTitle, lastAnswer }) => {
1427
+ // 「标题 → 文档」解析:优先精确匹配完整展示标题;回答可能用简写(如《黄金三角》),
1428
+ // 仅在唯一命中且非泛指词时做包含匹配,避免《行动前》这类短词误命中多篇。
1429
+ const resolveTitle = (raw, uid = '') => {
1430
+ const title = String(raw ?? '').replace(/\s+/g, ' ').trim()
1431
+ const values = [...byTitle.values()]
1432
+ const exact = values.filter((item) => item.title === title
1433
+ && (!uid || item.documentUid === uid))
1434
+ if (exact.length === 1) return exact[0]
1435
+ if (title.length < 3) return null
1436
+ const candidates = values.filter((item) => item.title.length >= title.length
1437
+ && item.title.includes(title) && (!uid || item.documentUid === uid))
1438
+ if (candidates.length !== 1) return null
1439
+ return candidates[0]
1440
+ }
1441
+ const cited = new Map()
1442
+ // 行内可溯源引用:《标题》…第 N(-M)? 行。
1443
+ for (const m of lastAnswer.matchAll(/《([^》]+)》(?:(document_uid=(doc_[A-Za-z0-9_-]{16})))?[^。;\n]{0,40}?第\s*(\d+)(?:\s*[-—–~]\s*(\d+))?\s*行/g)) {
1444
+ const hit = resolveTitle(m[1], m[2] || '')
1445
+ if (!hit) continue
1446
+ const lineStart = Number(m[3])
1447
+ const lineEnd = m[4] ? Number(m[4]) : lineStart
1448
+ cited.set(`${hit.title}\u0000${hit.documentUid || hit.documentId || ''}`,
1449
+ { ...hit, lineStart, lineEnd })
1450
+ }
1451
+ // 其余《标题》引用(如「主要参考资料」清单),一并纳入。
1452
+ for (const m of lastAnswer.matchAll(/《([^》]+)》(?:(document_uid=(doc_[A-Za-z0-9_-]{16})))?/g)) {
1453
+ const hit = resolveTitle(m[1], m[2] || '')
1454
+ const key = hit ? `${hit.title}\u0000${hit.documentUid || hit.documentId || ''}` : ''
1455
+ if (hit && !cited.has(key)) cited.set(key, { ...hit, lineStart: hit.lineStart, lineEnd: hit.lineEnd })
1456
+ }
1457
+ // 回答里没有可映射引用时,仅回退到 collect* 已限定的本轮读取。
1458
+ const used = cited.size ? cited : new Map(byTitle)
1459
+ const seen = new Set()
1460
+ let index = 0
1461
+ return [...used.values()].map((item) => {
1462
+ index += 1
1463
+ const signature = [item.documentId, item.lineStart, item.title].join('|') || `source-${++index}`
1464
+ if (seen.has(signature)) return null
1465
+ seen.add(signature)
1466
+ return { id: `${index}-${signature.slice(0, 24)}`, title: item.title, kind: 'story',
1467
+ documentId: item.documentId || '', sourceRef: item.sourceRef || '', dataVersion: item.dataVersion || '',
1468
+ lineStart: item.lineStart, lineEnd: item.lineEnd,
1469
+ excerpt: item.excerpt || '已在回答中引用该资料,点开查看原文。' }
1470
+ }).filter(Boolean)
1471
+ }
1472
+
1473
+ const readEvidenceFromPage = () => buildEvidence(collectDomEvidence())
1474
+
1475
+ // 点开证据卡 → 读全文:复用与 corpus_read 工具一致的 read RPC。
1476
+ async function readEvidenceSource(item, cursor = '') {
1477
+ const locator = item.sourceRef ? { source_ref: item.sourceRef }
1478
+ : item.documentId ? { document_id: item.documentId } : null
1479
+ if (!locator) throw new Error('该证据缺少稳定的原文定位,请从当前会话重新读取')
1480
+ if (!validDataVersion(item.dataVersion)) {
1481
+ throw new Error('该证据没有完整的 64 位资料版本,为避免打开错版原文,请重新读取')
1482
+ }
1483
+ if (cursor && (typeof cursor !== 'string' || cursor.length > 65536)) {
1484
+ throw new Error('原文续页定位无效,请重新打开证据')
1485
+ }
1486
+ const result = await callApi('read', { locator,
1487
+ selection: { mode: 'document', ...(cursor ? { cursor } : {}) },
1488
+ data_version: item.dataVersion,
1489
+ max_lines: 500, max_chars: 100000 })
1490
+ if (!result?.ok || result?.error) throw new Error(result?.error?.message || '无法读取原文')
1491
+ if (result.response?.data_version !== item.dataVersion) {
1492
+ throw new Error('返回的原文版本与证据不匹配,已拒绝展示')
1493
+ }
1494
+ return result.response
1495
+ }
1496
+
1497
+ const evidencePage = (response) => {
1498
+ if (response?.status !== 'ok') throw new Error(response?.error?.message || '读取失败')
1499
+ const lines = Array.isArray(response?.content?.lines) ? response.content.lines.map((line) => ({
1500
+ line_number: line.line_number, speaker_raw: line.speaker_raw,
1501
+ text: line.text, source_ref: line.source_ref,
1502
+ })) : []
1503
+ const hasMore = response?.page?.has_more === true
1504
+ const rawCursor = response?.page?.next_cursor
1505
+ const nextCursor = hasMore && typeof rawCursor === 'string' && rawCursor.length <= 65536
1506
+ ? rawCursor : ''
1507
+ const total = Number.isInteger(response?.page?.total) && response.page.total >= 0
1508
+ ? response.page.total : null
1509
+ return { lines, hasMore, nextCursor, total }
1510
+ }
1511
+
1512
+ const mergeEvidenceLines = (current, incoming) => {
1513
+ const seen = new Set(current.map((line) => `${line.line_number}|${line.source_ref || ''}`))
1514
+ return current.concat(incoming.filter((line) => {
1515
+ const key = `${line.line_number}|${line.source_ref || ''}`
1516
+ if (seen.has(key)) return false
1517
+ seen.add(key)
1518
+ return true
1519
+ }))
1520
+ }
1521
+
1522
+ const MODAL_FOCUSABLE_SELECTOR = [
1523
+ 'button:not([disabled])', '[href]', 'input:not([disabled])', 'select:not([disabled])',
1524
+ 'textarea:not([disabled])', '[contenteditable="true"]', '[tabindex]:not([tabindex="-1"])',
1525
+ ].join(',')
1526
+ const resolveFocusTarget = (target) => {
1527
+ const value = typeof target === 'function' ? target() : target
1528
+ return value?.current ?? value
1529
+ }
1530
+ function installDialogFocusTrap(dialog, options = {}) {
1531
+ if (!dialog) return () => {}
1532
+ const ownerDocument = dialog.ownerDocument
1533
+ || (typeof document !== 'undefined' ? document : null)
1534
+ if (!ownerDocument) return () => {}
1535
+ const focusables = () => [...dialog.querySelectorAll(MODAL_FOCUSABLE_SELECTOR)]
1536
+ .filter((element) => element.isConnected !== false
1537
+ && element.getAttribute?.('aria-hidden') !== 'true'
1538
+ && !element.closest?.('[hidden],[inert],[aria-hidden="true"]')
1539
+ && (typeof element.getClientRects !== 'function' || element.getClientRects().length > 0)
1540
+ && ownerDocument.defaultView?.getComputedStyle?.(element)?.visibility !== 'hidden')
1541
+ const focus = (target) => {
1542
+ if (typeof target?.focus !== 'function' || target.isConnected === false) return false
1543
+ target.focus()
1544
+ return true
1545
+ }
1546
+ const initial = resolveFocusTarget(options.initialFocus)
1547
+ if (!focus(initial)) {
1548
+ const first = focusables()[0]
1549
+ if (!focus(first)) focus(dialog)
1550
+ }
1551
+ const onKeyDown = (event) => {
1552
+ if (event.defaultPrevented || options.isSuspended?.()) return
1553
+ if (event.key === 'Escape') {
1554
+ if (!options.onEscape) return
1555
+ event.preventDefault()
1556
+ options.onEscape()
1557
+ return
1558
+ }
1559
+ if (event.key !== 'Tab') return
1560
+ const items = focusables()
1561
+ if (!items.length) {
1562
+ event.preventDefault()
1563
+ focus(dialog)
1564
+ return
1565
+ }
1566
+ const first = items[0]
1567
+ const last = items[items.length - 1]
1568
+ if (!dialog.contains(ownerDocument.activeElement)) {
1569
+ event.preventDefault()
1570
+ focus(event.shiftKey ? last : first)
1571
+ } else if (event.shiftKey
1572
+ && (ownerDocument.activeElement === first || ownerDocument.activeElement === dialog)) {
1573
+ event.preventDefault()
1574
+ focus(last)
1575
+ } else if (!event.shiftKey && ownerDocument.activeElement === last) {
1576
+ event.preventDefault()
1577
+ focus(first)
1578
+ }
1579
+ }
1580
+ ownerDocument.addEventListener('keydown', onKeyDown)
1581
+ let active = true
1582
+ return () => {
1583
+ if (!active) return
1584
+ active = false
1585
+ ownerDocument.removeEventListener('keydown', onKeyDown)
1586
+ focus(resolveFocusTarget(options.returnFocus))
1587
+ }
1588
+ }
1589
+
1590
+ function SourceReader({ item, onClose, returnFocus }) {
1591
+ const [loading, setLoading] = useState(true)
1592
+ const [loadingMore, setLoadingMore] = useState(false)
1593
+ const [error, setError] = useState('')
1594
+ const [lines, setLines] = useState([])
1595
+ const [page, setPage] = useState({ hasMore: false, nextCursor: '', total: null })
1596
+ const generation = useRef(0)
1597
+ const sourceDialogRef = useRef(null)
1598
+ const onCloseRef = useRef(onClose)
1599
+ const returnFocusRef = useRef(returnFocus)
1600
+ onCloseRef.current = onClose
1601
+ returnFocusRef.current = returnFocus
1602
+ useEffect(() => {
1603
+ const currentGeneration = ++generation.current
1604
+ setLoading(true); setLoadingMore(false); setError(''); setLines([])
1605
+ setPage({ hasMore: false, nextCursor: '', total: null })
1606
+ readEvidenceSource(item).then((response) => {
1607
+ if (generation.current !== currentGeneration) return
1608
+ const first = evidencePage(response)
1609
+ setLines(first.lines)
1610
+ setPage({ hasMore: first.hasMore, nextCursor: first.nextCursor, total: first.total })
1611
+ if (first.hasMore && !first.nextCursor) {
1612
+ setError('原文仍有后续内容,但服务器未提供可用的续页定位。')
1613
+ }
1614
+ }).catch((err) => {
1615
+ if (generation.current === currentGeneration) setError(err.message || '读取失败')
1616
+ }).finally(() => {
1617
+ if (generation.current === currentGeneration) setLoading(false)
1618
+ })
1619
+ return () => {
1620
+ if (generation.current === currentGeneration) generation.current += 1
1621
+ }
1622
+ }, [item])
1623
+ useEffect(() => installDialogFocusTrap(sourceDialogRef.current, {
1624
+ onEscape: () => onCloseRef.current?.(),
1625
+ returnFocus: () => resolveFocusTarget(returnFocusRef.current),
1626
+ }), [item.id])
1627
+ const loadMore = async () => {
1628
+ if (loading || loadingMore || !page.hasMore || !page.nextCursor) return
1629
+ const cursor = page.nextCursor
1630
+ const currentGeneration = generation.current
1631
+ setLoadingMore(true); setError('')
1632
+ try {
1633
+ const next = evidencePage(await readEvidenceSource(item, cursor))
1634
+ if (generation.current !== currentGeneration) return
1635
+ setLines((current) => mergeEvidenceLines(current, next.lines))
1636
+ setPage({ hasMore: next.hasMore,
1637
+ // 服务器若误返原游标,立即停止,避免用户进入无限续页。
1638
+ nextCursor: next.nextCursor === cursor ? '' : next.nextCursor,
1639
+ total: next.total ?? page.total })
1640
+ if (next.hasMore && (!next.nextCursor || next.nextCursor === cursor)) {
1641
+ setError('原文仍有后续内容,但服务器未提供可用的续页定位。')
1642
+ }
1643
+ } catch (err) {
1644
+ if (generation.current === currentGeneration) setError(err.message || '续页读取失败')
1645
+ } finally {
1646
+ if (generation.current === currentGeneration) setLoadingMore(false)
1647
+ }
1648
+ }
1649
+ return h('div', { className: 'prts-evidence-layer' },
1650
+ h('button', { type: 'button', className: 'prts-evidence-scrim', onClick: onClose,
1651
+ 'aria-label': '关闭原文查看器' }),
1652
+ h('aside', { ref: sourceDialogRef, className: 'prts-source-reader', role: 'dialog',
1653
+ tabIndex: -1, 'aria-modal': 'true',
1654
+ 'aria-label': `原文:${item.title}` },
1655
+ h('header', null,
1656
+ h('div', null, h('h3', null, item.title),
1657
+ h('p', null, 'LOCAL CORPUS / SOURCE READER')),
1658
+ h('button', { type: 'button', className: 'prts-evidence-close', onClick: onClose,
1659
+ 'aria-label': '关闭' }, '×')),
1660
+ h('div', { className: 'prts-source-body' },
1661
+ loading && h('div', { className: 'prts-source-state' }, '正在读取原文…'),
1662
+ error && h('div', { className: 'prts-source-state prts-source-error' }, error),
1663
+ !loading && !error && lines.length === 0 && h('div', { className: 'prts-source-state' },
1664
+ '该资料没有可展示的正文。'),
1665
+ !loading && lines.length > 0 && h('div', { className: 'prts-source-page',
1666
+ role: 'status', 'aria-live': 'polite' }, page.hasMore
1667
+ ? `已加载 ${lines.length}${page.total != null ? ` / ${page.total}` : ''} 行,原文尚未完整`
1668
+ : `已加载全部 ${lines.length} 行`),
1669
+ !loading && lines.map((line) => h('p', {
1670
+ className: [
1671
+ 'prts-source-line', line.speaker_raw ? '' : 'narration',
1672
+ item.lineStart && line.line_number === item.lineStart ? 'active' : '',
1673
+ ].filter(Boolean).join(' '),
1674
+ key: `${line.line_number}-${line.source_ref || ''}`,
1675
+ },
1676
+ h('span', { className: 'prts-source-no' }, String(line.line_number)),
1677
+ line.speaker_raw && h('span', { className: 'prts-source-speaker' }, line.speaker_raw),
1678
+ line.text)),
1679
+ !loading && page.hasMore && page.nextCursor && h('button', { type: 'button',
1680
+ className: 'prts-source-more', onClick: loadMore, disabled: loadingMore },
1681
+ loadingMore ? '正在加载…' : '继续加载原文'))))
1682
+ }
1683
+
1684
+ function EvidenceControl({ useChat }) {
1685
+ const [open, setOpen] = useState(false)
1686
+ const [reader, setReader] = useState(null)
1687
+ const evidenceTriggerRef = useRef(null)
1688
+ const evidenceDrawerRef = useRef(null)
1689
+ const readerReturnFocusRef = useRef(null)
1690
+ const readerReturnIdRef = useRef('')
1691
+ // 首选会话快照(useChat 是 session 槽位的标准 prop,ui-chat 组合必给);
1692
+ // 拿不到时回退 DOM 抓取 + MutationObserver。
1693
+ const snapshotOrder = useChat ? useChat((snapshot) => snapshot?.order ?? []) : null
1694
+ const snapshotNodes = useChat ? useChat((snapshot) => snapshot?.nodes ?? null) : null
1695
+ const sceneRevision = useChat ? useChat(sceneSnapshotSignature) : ''
1696
+ const evidenceRevision = useChat ? useChat(evidenceSnapshotSignature) : ''
1697
+ const sceneModel = useMemo(() => (useChat
1698
+ ? buildSceneSnapshotModel(snapshotOrder, snapshotNodes)
1699
+ : null), [snapshotOrder, snapshotNodes, sceneRevision])
1700
+ const [domEvidence, setDomEvidence] = useState([])
1701
+ useEffect(() => {
1702
+ sceneSnapshotModel = sceneModel
1703
+ notifySceneSnapshot()
1704
+ return () => {
1705
+ if (sceneSnapshotModel !== sceneModel) return
1706
+ sceneSnapshotModel = null
1707
+ notifySceneSnapshot()
1708
+ }
1709
+ }, [sceneModel])
1710
+ useEffect(() => {
1711
+ if (useChat || typeof document === 'undefined') return undefined
1712
+ let timer = null
1713
+ const sync = () => {
1714
+ timer = null
1715
+ const next = readEvidenceFromPage()
1716
+ const signature = JSON.stringify(next)
1717
+ setDomEvidence((previous) => JSON.stringify(previous) === signature ? previous : next)
1718
+ }
1719
+ const schedule = () => {
1720
+ if (timer == null) timer = setTimeout(sync, 80)
1721
+ }
1722
+ const observer = new MutationObserver(schedule)
1723
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true,
1724
+ attributes: true, attributeFilter: ['data-state', 'data-tool'] })
1725
+ sync()
1726
+ return () => { observer.disconnect(); if (timer != null) clearTimeout(timer) }
1727
+ }, [])
1728
+ const evidence = useMemo(() => (useChat
1729
+ ? buildEvidence(collectSnapshotEvidence(snapshotOrder, snapshotNodes))
1730
+ : domEvidence), [snapshotOrder, snapshotNodes, evidenceRevision, domEvidence])
1731
+ useEffect(() => {
1732
+ if (!open || reader || typeof document === 'undefined') return undefined
1733
+ return installDialogFocusTrap(evidenceDrawerRef.current, {
1734
+ onEscape: () => setOpen(false),
1735
+ initialFocus: () => readerReturnFocusRef.current,
1736
+ returnFocus: () => evidenceTriggerRef.current,
1737
+ })
1738
+ }, [open, reader])
1739
+ return h(Fragment, null,
1740
+ h('button', { ref: evidenceTriggerRef, type: 'button', className: 'prts-header-badge',
1741
+ onClick: () => {
1742
+ readerReturnIdRef.current = ''
1743
+ readerReturnFocusRef.current = null
1744
+ setOpen(true)
1745
+ }, title: '查看本轮本地资料读取记录', 'aria-haspopup': 'dialog',
1746
+ 'aria-expanded': open },
1747
+ h('i'), '证据', h('b', null, String(evidence.length))),
1748
+ reader && h('div', { className: 'prts-evidence-layer' },
1749
+ h(SourceReader, { item: reader, onClose: () => setReader(null),
1750
+ returnFocus: () => readerReturnFocusRef.current })),
1751
+ open && !reader && h('div', { className: 'prts-evidence-layer' },
1752
+ h('button', { type: 'button', className: 'prts-evidence-scrim', onClick: () => setOpen(false),
1753
+ 'aria-label': '关闭证据面板' }),
1754
+ h('aside', { ref: evidenceDrawerRef, className: 'prts-evidence-drawer', role: 'dialog',
1755
+ tabIndex: -1, 'aria-modal': 'true',
1756
+ 'aria-label': '本轮证据' },
1757
+ h('header', null,
1758
+ h('div', null, h('h3', null, '本轮证据'), h('p', null, 'LOCAL CORPUS / SOURCE READ TRACE')),
1759
+ h('button', { type: 'button', className: 'prts-evidence-close', onClick: () => setOpen(false),
1760
+ 'aria-label': '关闭' }, '×')),
1761
+ h('div', { className: 'prts-evidence-list' }, evidence.length
1762
+ ? evidence.map((item, index) => {
1763
+ const hasLocator = Boolean(item.sourceRef || item.documentId)
1764
+ const readable = hasLocator && validDataVersion(item.dataVersion)
1765
+ return h('button', {
1766
+ type: 'button', className: 'prts-evidence-card', key: item.id,
1767
+ ref: (node) => {
1768
+ if (node && readerReturnIdRef.current === item.id) readerReturnFocusRef.current = node
1769
+ },
1770
+ 'data-index': String(index + 1).padStart(2, '0'), disabled: !readable,
1771
+ onClick: readable ? (event) => {
1772
+ readerReturnIdRef.current = item.id
1773
+ readerReturnFocusRef.current = event.currentTarget
1774
+ setReader(item)
1775
+ } : undefined,
1776
+ title: readable ? `点击查看原文(${item.documentId || item.sourceRef})`
1777
+ : hasLocator ? '旧会话未保存完整的 64 位资料版本,不能打开可能已换版的原文'
1778
+ : '旧会话未保存稳定定位,只能查看引用摘要',
1779
+ }, h('b', null, readable ? 'CORPUS_READ · VERIFIED' : 'CORPUS_READ · UNVERIFIED'),
1780
+ h('p', null, item.excerpt))
1781
+ })
1782
+ : h('div', { className: 'prts-evidence-empty' }, '本轮还没有读取本地原文。\n检索并读取资料后,证据卡会出现在这里。')))))
1783
+ }
1784
+
1785
+ const fmtBytes = (bytes) => {
1786
+ if (!Number.isFinite(bytes)) return '—'
1787
+ if (bytes < 1024) return `${bytes} B`
1788
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`
1789
+ if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`
1790
+ return `${(bytes / 1073741824).toFixed(2)} GB`
1791
+ }
1792
+ const shortVersion = (value) => (value ? `${String(value).slice(0, 12)}…` : '—')
1793
+ const fmtDate = (value) => {
1794
+ if (!value) return '—'
1795
+ const date = new Date(value)
1796
+ if (Number.isNaN(date.getTime())) return String(value)
1797
+ const pad = (n) => String(n).padStart(2, '0')
1798
+ return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
1799
+ }
1800
+
1801
+ /* ---- 内联样式 ---- */
1802
+ const S = {
1803
+ wrap: { display: 'flex', flexDirection: 'column', gap: '16px', fontSize: '13px' },
1804
+ card: { border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '10px',
1805
+ padding: '14px 16px', background: 'var(--dsw-alias-bg-layer-1)' },
1806
+ cardTitle: { fontWeight: 600, margin: '0 0 10px', fontSize: '13px' },
1807
+ row: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap', margin: '4px 0' },
1808
+ key: { opacity: 0.65, minWidth: '96px', flex: '0 0 auto' },
1809
+ val: { fontFamily: 'ui-monospace, monospace', wordBreak: 'break-all' },
1810
+ badge: { padding: '1px 8px', borderRadius: '999px', fontSize: '11px', flex: '0 0 auto',
1811
+ background: 'var(--dsw-alias-state-business-tertiary)', color: 'var(--dsw-alias-state-business-primary)' },
1812
+ badgeGray: { padding: '1px 8px', borderRadius: '999px', fontSize: '11px', flex: '0 0 auto',
1813
+ background: 'rgba(128,128,128,.18)', color: '#999' },
1814
+ badgeGreen: { padding: '1px 8px', borderRadius: '999px', fontSize: '11px', flex: '0 0 auto',
1815
+ background: 'var(--dsw-alias-state-success-tertiary)', color: 'var(--dsw-alias-state-success-primary)' },
1816
+ badgeRed: { padding: '1px 8px', borderRadius: '999px', fontSize: '11px', flex: '0 0 auto',
1817
+ background: 'var(--dsw-alias-interactive-bg-hover-danger)', color: 'var(--dsw-alias-state-error-primary)' },
1818
+ button: { padding: '5px 12px', borderRadius: '7px', border: '1px solid var(--dsw-alias-border-l2)',
1819
+ background: 'transparent', color: 'inherit', cursor: 'pointer', fontSize: '12px', flex: '0 0 auto' },
1820
+ buttonPrimary: { padding: '5px 12px', borderRadius: '7px', border: 'none',
1821
+ background: 'var(--dsw-alias-button-info-fill)', color: '#fff', cursor: 'pointer', fontSize: '12px', flex: '0 0 auto' },
1822
+ input: { boxSizing: 'border-box', padding: '4px 8px', borderRadius: '6px',
1823
+ border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-base)', color: 'inherit',
1824
+ fontSize: '12px', flex: '1 1 160px', minWidth: '120px', maxWidth: '100%' },
1825
+ select: { boxSizing: 'border-box', padding: '4px 8px', borderRadius: '6px',
1826
+ border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-base)', color: 'inherit',
1827
+ fontSize: '12px', flex: '0 1 auto', minWidth: '120px', maxWidth: '100%' },
1828
+ table: { width: '100%', borderCollapse: 'collapse', fontSize: '12.5px', tableLayout: 'fixed' },
1829
+ th: { textAlign: 'left', padding: '6px 8px', opacity: 0.6, fontWeight: 500,
1830
+ borderBottom: '1px solid rgba(128,128,128,.3)', whiteSpace: 'nowrap' },
1831
+ td: { padding: '6px 8px', borderBottom: '1px solid rgba(128,128,128,.16)', verticalAlign: 'top' },
1832
+ progressTrack: { height: '8px', borderRadius: '4px', background: 'rgba(128,128,128,.22)',
1833
+ overflow: 'hidden', flex: 1, minWidth: '180px' },
1834
+ progressFill: { height: '100%', background: 'var(--dsw-alias-state-business-primary)', transition: 'width .3s' },
1835
+ hint: { opacity: 0.6, fontSize: '12px' },
1836
+ datasetGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
1837
+ gap: '10px', marginTop: '14px' },
1838
+ datasetCard: { border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '11px',
1839
+ padding: '13px', background: 'var(--dsw-alias-bg-base)', transition: 'border-color .15s, box-shadow .15s' },
1840
+ datasetCardEnabled: { borderColor: 'var(--dsw-alias-state-business-primary)',
1841
+ boxShadow: '0 0 0 1px var(--dsw-alias-state-business-primary)' },
1842
+ buttonLink: { padding: '3px 0', border: 'none', background: 'transparent', color: 'inherit',
1843
+ opacity: 0.72, cursor: 'pointer', fontSize: '12px' },
1844
+ versionPanel: { marginTop: '12px', padding: '12px', borderRadius: '9px',
1845
+ background: 'rgba(128,128,128,.07)', border: '1px solid rgba(128,128,128,.18)' },
1846
+ versionRow: { display: 'flex', alignItems: 'center', gap: '8px', padding: '10px 0',
1847
+ borderBottom: '1px solid rgba(128,128,128,.16)' },
1848
+ grid2: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '6px 12px' },
1849
+ field: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap', margin: '6px 0' },
1850
+ fieldKey: { minWidth: '140px', opacity: 0.75, flex: '0 0 auto' },
1851
+ error: { color: 'var(--dsw-alias-state-error-primary)' },
1852
+ }
1853
+
1854
+ function SkinCard({ skin, onChanged }) {
1855
+ const [saving, setSaving] = useState(false)
1856
+ const [message, setMessage] = useState(null)
1857
+ const choose = async (next) => {
1858
+ // React props/state may still describe the previous render. The module
1859
+ // owner is the synchronous de-duplication lock; even clicking the item
1860
+ // that appears selected must be allowed to confirm Host config and
1861
+ // invalidate an unresolved startup/status snapshot.
1862
+ if (skinSelectionOwner) return
1863
+ const selectionOwner = {}
1864
+ skinSelectionOwner = selectionOwner
1865
+ beginHostSkinSync()
1866
+ const operationEpoch = ++skinOperationEpoch
1867
+ pendingSkinOperations.add(selectionOwner)
1868
+ setSaving(true)
1869
+ setMessage(null)
1870
+ const previous = activeSkin
1871
+ const normalizedNext = normalizeSkin(next)
1872
+ const changesRuntime = normalizedNext !== activeSkin
1873
+ // 切肤开机场:终末地等地图就绪后由 aicBootDone 撤下,其它皮肤短暂过渡后立即收尾。
1874
+ // 重申当前 Host 值不重启开机场;AIC runtime 已就绪时不会产生新的
1875
+ // map-ready 事件,若重启遮罩只能等待 watchdog。
1876
+ const bootToken = changesRuntime ? aicBootShow(normalizedNext === 'endfield-aic'
1877
+ ? 'REBUILDING INTERFACE' : 'SWITCHING SKIN', true, normalizedNext) : 0
1878
+ let hostCommitted = false
1879
+ try {
1880
+ // CSS 先 ready,Host 确认后再同步提交本地运行时、移除旧 CSS 与写缓存。
1881
+ await setSkin(next, { beforeCommit: async () => {
1882
+ await writeSkinConfig(next)
1883
+ hostCommitted = true
1884
+ } })
1885
+ if (operationEpoch !== skinOperationEpoch) return
1886
+ onChanged(normalizedNext)
1887
+ if (normalizedNext !== 'endfield-aic') {
1888
+ if (changesRuntime) aicBootDone(bootToken)
1889
+ // activeSkin 仍是 Harness 但迟到的启动快照可能已经展示 AIC
1890
+ // 遮罩。此时没有本次过渡可淡出,必须立即撤下旧 owner。
1891
+ else aicBootAbort(true)
1892
+ }
1893
+ } catch (error) {
1894
+ let reportedError = error
1895
+ // 只有 Host 已接受而本地最终提交失败时才需要补偿写;常见的 PUT
1896
+ // 失败已经由 setSkin 在旧 CSS 仍在时完成纯本地回滚。
1897
+ if (hostCommitted && operationEpoch === skinOperationEpoch) {
1898
+ try {
1899
+ await writeSkinConfig(previous)
1900
+ } catch (rollbackError) {
1901
+ console.error('[prts-terrarchive] failed to roll back host skin config', rollbackError)
1902
+ reportedError = new Error(`${error?.message ?? String(error)};Host 配置回滚失败:${rollbackError?.message ?? String(rollbackError)}`)
1903
+ }
1904
+ }
1905
+ if (operationEpoch === skinOperationEpoch) {
1906
+ aicBootAbort()
1907
+ setMessage(reportedError?.message ?? String(reportedError))
1908
+ }
1909
+ } finally {
1910
+ // 让选择期间发出的旧 status 请求失效;成功与回滚都以此处状态为准。
1911
+ pendingSkinOperations.delete(selectionOwner)
1912
+ if (skinOperationEpoch === operationEpoch) {
1913
+ skinOperationEpoch += 1
1914
+ setSaving(false)
1915
+ }
1916
+ if (skinSelectionOwner === selectionOwner) skinSelectionOwner = null
1917
+ }
1918
+ }
1919
+ const option = (id, title, description, swatchClass) => h('button', {
1920
+ type: 'button', className: 'prts-skin-option', 'aria-pressed': skin === id,
1921
+ disabled: saving, onClick: () => { void choose(id) },
1922
+ }, h('strong', null, title), h('small', null, description),
1923
+ h('span', { className: `prts-skin-swatch ${swatchClass}` }))
1924
+ return h('div', { style: S.card },
1925
+ h('h3', { style: S.cardTitle }, '界面皮肤'),
1926
+ h('div', { className: 'prts-skin-options' },
1927
+ option('harness', 'Harness 默认', '跟随 DeepSeek Harness 原生视觉', ''),
1928
+ option('prts-agent', 'PRTS Agent', '迁移自 PRTS.chat Agent 的冷白、石墨与蓝色强调', 'prts'),
1929
+ option('endfield-aic', 'AIC 终末地', '完整武陵地图终端布局与酸性黄工业界面', 'endfield'),
1930
+ option('rhine-lab', '莱茵生命资料馆', '三维档案阵列、检索过程与可摘录的资料台', 'rhine')),
1931
+ h('p', { className: 'prts-skin-note', style: message ? S.error : null },
1932
+ message || (saving ? '正在应用…' : '选择后立即生效,并保留当前的明亮 / 暗色偏好。')))
1933
+ }
1934
+
1935
+ /* ---- 资料库选择与按游戏展开的版本管理 ---- */
1936
+ function DatasetLibraryCard({ status, releases, activeRelease, enabledGames, savingGames,
1937
+ onEnabledGames, onRefresh, onCheckUpdate, checking, updateInfo,
1938
+ onActivate, onDelete, onDownload, downloading }) {
1939
+ const store = status?.store ?? {}
1940
+ const download = status?.download ?? {}
1941
+ const percent = download.filesTotal
1942
+ ? Math.round((download.filesDone / download.filesTotal) * 100) : null
1943
+ const [expandedGame, setExpandedGame] = useState(null)
1944
+ const games = [
1945
+ { id: 'arknights', title: '明日方舟', subtitle: '剧情、档案、Wiki 与实体资料' },
1946
+ { id: 'endfield', title: '明日方舟:终末地', subtitle: '剧情原文、档案库与自建 Wiki' },
1947
+ ]
1948
+ const activeDatasets = activeRelease?.datasets ?? {}
1949
+ const shared = activeDatasets.sharedPacks ?? []
1950
+ const sharedDocuments = shared.reduce((sum, item) => sum + (Number(item.documentCount) || 0), 0)
1951
+ const sharedCompressedSize = shared.reduce((sum, item) =>
1952
+ sum + (Number(item.compressedSize) || 0), 0)
1953
+ const toggle = (game) => {
1954
+ const selected = enabledGames.includes(game)
1955
+ ? enabledGames.filter((item) => item !== game)
1956
+ : [...enabledGames, game]
1957
+ if (!selected.length || savingGames) return
1958
+ void onEnabledGames(selected)
1959
+ }
1960
+ const versionTitle = (dataset, release) => {
1961
+ const knownTitles = { 'xuesong-youmeng': '雪凇幽梦' }
1962
+ const releaseName = dataset?.releaseTitle
1963
+ || knownTitles[dataset?.releaseName] || dataset?.releaseName
1964
+ const names = [releaseName, dataset?.gameVersion].filter(Boolean)
1965
+ return names.length ? names.join(' · ') : release.releaseId
1966
+ }
1967
+ const versionRows = expandedGame == null ? [] : (releases ?? [])
1968
+ .filter((release) => release.datasets?.[expandedGame]?.present)
1969
+ return h('div', { style: S.card },
1970
+ h('div', { style: { ...S.row, justifyContent: 'space-between' } },
1971
+ h('div', null,
1972
+ h('h3', { style: { ...S.cardTitle, marginBottom: '3px' } }, '资料库'),
1973
+ h('span', { style: S.hint }, '选择 Agent 可以检索的资料;至少启用一个。')),
1974
+ h('div', { style: S.row },
1975
+ h('button', { style: S.button, onClick: onRefresh }, '刷新'),
1976
+ h('button', { style: S.button, disabled: checking, onClick: onCheckUpdate },
1977
+ checking ? '检查中…' : '检查更新'),
1978
+ h('button', { style: S.buttonPrimary, disabled: downloading, onClick: () => onDownload() },
1979
+ downloading ? '下载中…' : '下载最新'))),
1980
+ store.installed === false && h('div', { style: { ...S.error, marginTop: '8px' } },
1981
+ `本地资料尚未就绪:${store.installationIssue || '未找到有效版本'}。便携版用户还应确认 ZIP 已完整解压。`),
1982
+ store.installed === true && !store.loaded && h('div', { style: { ...S.hint, marginTop: '8px' } },
1983
+ '已检测到语料,正在加载索引;首次启动可能需要一些时间。'),
1984
+ h('div', { style: S.datasetGrid }, ...games.map((game) => {
1985
+ const dataset = activeDatasets[game.id]
1986
+ const enabled = enabledGames.includes(game.id)
1987
+ return h('section', { key: game.id, style: {
1988
+ ...S.datasetCard,
1989
+ ...(enabled ? S.datasetCardEnabled : {}),
1990
+ ...(!dataset?.present ? { opacity: 0.72 } : {}),
1991
+ } },
1992
+ h('div', { style: { ...S.row, flexWrap: 'nowrap', alignItems: 'flex-start' } },
1993
+ h('input', { type: 'checkbox', checked: enabled, disabled: savingGames,
1994
+ 'aria-label': `启用${game.title}资料`, onChange: () => toggle(game.id),
1995
+ style: { width: '17px', height: '17px', marginTop: '2px', accentColor: 'var(--dsw-alias-state-business-primary)' } }),
1996
+ h('label', { style: { flex: 1, cursor: 'pointer' }, onClick: () => toggle(game.id) },
1997
+ h('strong', { style: { display: 'block', fontSize: '14px', marginBottom: '4px' } }, game.title),
1998
+ h('span', { style: S.hint }, game.subtitle))),
1999
+ h('div', { style: { ...S.row, marginTop: '12px' } },
2000
+ dataset?.present
2001
+ ? h(Fragment, null,
2002
+ h('span', { style: store.loaded ? S.badgeGreen : S.badgeGray }, store.loaded ? '已就绪' : '加载中'),
2003
+ h('span', null, `${dataset.documentCount.toLocaleString()} 篇`),
2004
+ h('span', { style: S.hint }, `${fmtBytes(dataset.compressedSize)} 专属包`))
2005
+ : h('span', { style: S.badgeGray }, '未安装')),
2006
+ h('div', { style: { ...S.row, justifyContent: 'space-between', marginTop: '8px' } },
2007
+ h('span', { style: S.hint }, dataset?.present
2008
+ ? versionTitle(dataset, activeRelease) : '没有可用版本'),
2009
+ h('button', { style: S.buttonLink, disabled: !dataset?.present,
2010
+ onClick: () => setExpandedGame(expandedGame === game.id ? null : game.id) },
2011
+ expandedGame === game.id ? '收起版本' : '查看版本')))
2012
+ })),
2013
+ sharedDocuments > 0 && h('div', { style: { ...S.hint, marginTop: '9px' } },
2014
+ `另含 ${sharedDocuments.toLocaleString()} 篇共享资料(${fmtBytes(sharedCompressedSize)}),两款游戏共同使用。`),
2015
+ updateInfo?.text && h('div', { style: { ...S.row, marginTop: '8px' } },
2016
+ h('span', { style: updateInfo.kind === 'error' ? S.error : updateInfo.kind === 'update' ? S.badgeGreen : S.badgeGray },
2017
+ updateInfo.text),
2018
+ updateInfo.kind === 'update' && updateInfo.remoteReleaseId
2019
+ && h('button', { style: S.buttonPrimary, onClick: updateInfo.onDownload }, '下载更新')),
2020
+ download.phase && download.phase !== 'idle'
2021
+ ? h('div', { style: { ...S.row, marginTop: '8px' } },
2022
+ h('span', { style: S.key }, download.active ? '下载中' : '下载状态'),
2023
+ h('span', { style: S.badge }, `${download.source ?? ''} · ${download.phase}`),
2024
+ percent !== null && h('div', { style: S.progressTrack },
2025
+ h('div', { style: { ...S.progressFill, width: `${percent}%` } })),
2026
+ h('span', { style: S.hint },
2027
+ `${download.filesDone}/${download.filesTotal ?? '?'} 文件 · ${fmtBytes(download.bytesDone)}`),
2028
+ download.error && h('span', { style: S.error }, download.error))
2029
+ : null,
2030
+ expandedGame && h('div', { style: S.versionPanel },
2031
+ h('div', { style: { ...S.row, justifyContent: 'space-between' } },
2032
+ h('strong', null, `${games.find((item) => item.id === expandedGame)?.title} · 已安装版本`),
2033
+ h('span', { style: S.hint }, '版本切换会同时切换联合资料包')),
2034
+ ...versionRows.map((release) => {
2035
+ const dataset = release.datasets[expandedGame]
2036
+ return h('div', { key: release.releaseId, style: S.versionRow },
2037
+ h('div', { style: { minWidth: 0, flex: 1 } },
2038
+ h('strong', { style: { display: 'block' } }, versionTitle(dataset, release)),
2039
+ h('span', { style: S.hint },
2040
+ `${release.releaseId} · ${dataset.documentCount.toLocaleString()} 篇 · ${fmtBytes(dataset.compressedSize)}`),
2041
+ h('span', { style: { ...S.hint, display: 'block' } },
2042
+ `数据 ${shortVersion(dataset.dataVersion)}${release.createdAt ? ` · ${fmtDate(release.createdAt)}` : ''}`)),
2043
+ release.active
2044
+ ? h('span', { style: S.badgeGreen }, '当前')
2045
+ : h(Fragment, null,
2046
+ h('button', { style: S.button, disabled: !release.complete,
2047
+ onClick: () => onActivate(release.releaseId) }, '切换'),
2048
+ h('button', { style: S.button, onClick: () => onDelete(release.releaseId) }, '删除')))
2049
+ }),
2050
+ versionRows.length === 0 && h('span', { style: S.hint }, '本地没有这个资料库的版本。')),
2051
+ h('div', { style: { ...S.hint, marginTop: '9px' } },
2052
+ '版本与哈希先由 PRTS.chat current 确认,文件再按镜像顺序下载;切换后索引自动重建。'))
2053
+ }
2054
+
2055
+ /* ---- 配置编辑 ---- */
2056
+ const NUMBER_FIELDS = [
2057
+ ['cacheShards', '分片缓存数'],
2058
+ ['cloudTimeoutMs', '云端超时(ms)'],
2059
+ ['cloudMaxResponseBytes', '云端响应上限(B)'],
2060
+ ]
2061
+
2062
+ function ConfigCard({ config, onSaved }) {
2063
+ const [draft, setDraft] = useState(null)
2064
+ const [saving, setSaving] = useState(false)
2065
+ const [message, setMessage] = useState(null)
2066
+ if (!config) return null
2067
+ const value = draft ?? {}
2068
+ const current = (key, fallback) => Object.hasOwn(value, key) ? value[key] : (config[key] ?? fallback)
2069
+ const set = (key, cast) => (event) => {
2070
+ const raw = event.target.type === 'checkbox' ? event.target.checked : event.target.value
2071
+ setDraft((previous) => ({ ...(previous ?? {}), [key]: cast ? cast(raw) : raw }))
2072
+ }
2073
+ const save = async () => {
2074
+ setSaving(true)
2075
+ setMessage(null)
2076
+ try {
2077
+ await jsonFetch('/config', { method: 'PUT', body: JSON.stringify({ patch: value }) })
2078
+ setDraft(null)
2079
+ setMessage('已保存并生效')
2080
+ onSaved()
2081
+ } catch (error) {
2082
+ setMessage(error.message)
2083
+ } finally {
2084
+ setSaving(false)
2085
+ }
2086
+ }
2087
+ return h('div', { style: S.card },
2088
+ h('h3', { style: S.cardTitle }, '插件配置'),
2089
+ h('div', { style: S.field },
2090
+ h('span', { style: S.fieldKey }, '云端工具'),
2091
+ h('input', { type: 'checkbox', checked: Boolean(current('cloudEnabled', false)),
2092
+ onChange: set('cloudEnabled') }),
2093
+ h('span', { style: S.hint }, 'cloud_search / cloud_inspect(保存后即时注册/注销)')),
2094
+ h('div', { style: S.field },
2095
+ h('span', { style: S.fieldKey }, '云端检索地址'),
2096
+ h('input', { style: S.input, value: String(current('cloudBaseUrl', '')),
2097
+ onChange: set('cloudBaseUrl') }),
2098
+ h('span', { style: { ...S.hint, flexBasis: '100%', marginLeft: '148px' } }, 'cloud_search / cloud_inspect 服务')),
2099
+ h('div', { style: S.field },
2100
+ h('span', { style: S.fieldKey }, '访问令牌'),
2101
+ h('input', { style: S.input, type: 'password',
2102
+ placeholder: config.hasCloudToken ? '已设置(不修改即保持)' : '留空使用匿名会话',
2103
+ value: String(current('cloudToken', '')), onChange: set('cloudToken') })),
2104
+ h('div', { style: S.grid2 },
2105
+ ...NUMBER_FIELDS.map(([key, label]) => h('div', { key, style: S.field },
2106
+ h('span', { style: S.fieldKey }, label),
2107
+ h('input', { style: { ...S.input, flex: '0 0 96px', width: '96px' }, type: 'number',
2108
+ value: String(current(key, '')), onChange: set(key, Number) })))),
2109
+ h('div', { style: S.field },
2110
+ h('span', { style: S.fieldKey }, '资料站点地址'),
2111
+ h('input', { style: S.input, value: String(current('downloadSiteBaseUrl', '')),
2112
+ onChange: set('downloadSiteBaseUrl') })),
2113
+ h('div', { style: S.field },
2114
+ h('span', { style: S.fieldKey }, '下载源顺序'),
2115
+ h('select', { style: S.select, value: current('downloadOrder', ['modelscope', 'site']).join(','),
2116
+ onChange: set('downloadOrder', (raw) => raw.split(',')) },
2117
+ h('option', { value: 'modelscope,site' }, 'ModelScope → PRTS.chat'),
2118
+ h('option', { value: 'site,modelscope' }, 'PRTS.chat → ModelScope'),
2119
+ h('option', { value: 'modelscope' }, '仅 ModelScope'),
2120
+ h('option', { value: 'site' }, '仅 PRTS.chat'))),
2121
+ h('div', { style: { ...S.row, marginTop: '8px' } },
2122
+ h('button', { style: S.buttonPrimary, disabled: !draft || saving, onClick: save },
2123
+ saving ? '保存中…' : '保存'),
2124
+ draft && h('button', { style: S.button, onClick: () => { setDraft(null) } }, '放弃'),
2125
+ message && h('span', { style: message.includes('已保存') ? S.badgeGreen : S.error }, message),
2126
+ h('span', { style: S.hint }, '持久化于 $DSH_HOME/prts-corpus.json,覆盖 patch 配置')))
2127
+ }
2128
+
2129
+ /* ---- 设置页主组件 ---- */
2130
+ const releaseStatusKey = (status) => JSON.stringify([
2131
+ status?.store?.releaseId ?? null, status?.store?.dataVersion ?? null,
2132
+ status?.store?.installed ?? null, status?.download?.finishedAt ?? null,
2133
+ ])
2134
+
2135
+ function PrtsSection() {
2136
+ const [status, setStatus] = useState(null)
2137
+ const [releaseList, setReleaseList] = useState(null)
2138
+ const [busy, setBusy] = useState(false)
2139
+ const [error, setError] = useState(null)
2140
+ const [checking, setChecking] = useState(false)
2141
+ const [savingGames, setSavingGames] = useState(false)
2142
+ const [updateInfo, setUpdateInfo] = useState(null)
2143
+ const [skin, setSkinState] = useState(() => activeSkin)
2144
+ const aliveRef = useRef(true)
2145
+ const statusInFlightRef = useRef(false)
2146
+ const hostSyncRef = useRef(0)
2147
+ const releaseStatusRef = useRef(null)
2148
+
2149
+ const beginStatusSync = useCallback(() => {
2150
+ const generation = beginHostSkinSync()
2151
+ hostSyncRef.current = generation
2152
+ return generation
2153
+ }, [])
2154
+
2155
+ const commitStatusSkin = useCallback(async (nextStatus, syncGeneration,
2156
+ observedSkinEpoch, repairSame = false) => {
2157
+ const assertCurrent = () => {
2158
+ if (!aliveRef.current || pendingSkinOperations.size !== 0
2159
+ || observedSkinEpoch !== skinOperationEpoch) throw makeAbortError()
2160
+ assertHostSkinSyncActive(syncGeneration)
2161
+ }
2162
+ assertCurrent()
2163
+ setStatus(nextStatus)
2164
+ const nextSkin = normalizeSkin(nextStatus?.config?.uiSkin)
2165
+ const committedSkin = nextSkin === activeSkin && !repairSame
2166
+ ? nextSkin : await setSkin(nextSkin, { assertCurrent })
2167
+ assertCurrent()
2168
+ setSkinState(committedSkin)
2169
+ // A stale startup AIC request may already have mounted its boot screen.
2170
+ // Host-driven non-AIC snapshots have no transition to finish it, so
2171
+ // retire that overlay only after this newest snapshot is committed.
2172
+ if (committedSkin !== 'endfield-aic') aicBootAbort()
2173
+ return committedSkin
2174
+ }, [])
2175
+
2176
+ const refreshStatus = useCallback(async () => {
2177
+ if (statusInFlightRef.current || pendingSkinOperations.size !== 0
2178
+ || (typeof document !== 'undefined' && document.hidden)) return
2179
+ const observedSkinEpoch = skinOperationEpoch
2180
+ const syncGeneration = beginStatusSync()
2181
+ statusInFlightRef.current = true
2182
+ try {
2183
+ const nextStatus = await jsonFetch('/status')
2184
+ await commitStatusSkin(nextStatus, syncGeneration, observedSkinEpoch, false)
2185
+ assertHostSkinSyncActive(syncGeneration)
2186
+ const nextReleaseStatus = releaseStatusKey(nextStatus)
2187
+ // Download completion and external activation invalidate the library
2188
+ // list. Ordinary status polls keep the last listing without rescanning
2189
+ // installed releases; a failed or superseded listing retries next poll.
2190
+ if (releaseStatusRef.current !== nextReleaseStatus) {
2191
+ const nextReleases = await jsonFetch('/releases')
2192
+ if (!aliveRef.current) throw makeAbortError()
2193
+ assertHostSkinSyncActive(syncGeneration)
2194
+ setReleaseList(nextReleases)
2195
+ releaseStatusRef.current = nextReleaseStatus
2196
+ }
2197
+ setError(null)
2198
+ } catch (caught) {
2199
+ if (aliveRef.current && syncGeneration === hostSkinSyncGeneration
2200
+ && caught?.name !== 'AbortError') setError(caught.message)
2201
+ } finally {
2202
+ statusInFlightRef.current = false
2203
+ }
2204
+ }, [beginStatusSync, commitStatusSkin])
2205
+
2206
+ const refresh = useCallback(async () => {
2207
+ const observedSkinEpoch = skinOperationEpoch
2208
+ const syncGeneration = beginStatusSync()
2209
+ try {
2210
+ const [nextStatus, nextReleases] = await Promise.all([
2211
+ jsonFetch('/status'), jsonFetch('/releases'),
2212
+ ])
2213
+ await commitStatusSkin(nextStatus, syncGeneration, observedSkinEpoch, true)
2214
+ assertHostSkinSyncActive(syncGeneration)
2215
+ setReleaseList(nextReleases)
2216
+ releaseStatusRef.current = releaseStatusKey(nextStatus)
2217
+ setError(null)
2218
+ } catch (caught) {
2219
+ if (aliveRef.current && syncGeneration === hostSkinSyncGeneration
2220
+ && caught?.name !== 'AbortError') setError(caught.message)
2221
+ }
2222
+ }, [beginStatusSync, commitStatusSkin])
2223
+
2224
+ useEffect(() => {
2225
+ aliveRef.current = true
2226
+ void refresh()
2227
+ const onVisibility = () => { if (!document.hidden) void refreshStatus() }
2228
+ const timer = setInterval(() => { void refreshStatus() }, 2000)
2229
+ document.addEventListener('visibilitychange', onVisibility)
2230
+ return () => {
2231
+ aliveRef.current = false
2232
+ if (hostSyncRef.current === hostSkinSyncGeneration) beginHostSkinSync()
2233
+ hostSyncRef.current = 0
2234
+ clearInterval(timer)
2235
+ document.removeEventListener('visibilitychange', onVisibility)
2236
+ }
2237
+ }, [refresh, refreshStatus])
2238
+
2239
+ const activeRelease = (releaseList?.releases ?? []).find((item) => item.active) ?? null
2240
+ const enabledGames = Array.isArray(status?.config?.enabledGames)
2241
+ ? status.config.enabledGames : ['arknights', 'endfield']
2242
+
2243
+ const updateEnabledGames = async (games) => {
2244
+ const ordered = ['arknights', 'endfield'].filter((game) => games.includes(game))
2245
+ if (!ordered.length) return
2246
+ setSavingGames(true)
2247
+ try {
2248
+ await jsonFetch('/config', { method: 'PUT', body: JSON.stringify({ patch: {
2249
+ enabledGames: ordered,
2250
+ // 同步旧版配置键,使降级到旧插件时仍保持用户选择。
2251
+ cloudGame: ordered.length === 2 ? 'all' : ordered[0],
2252
+ } }) })
2253
+ await refresh()
2254
+ } catch (caught) {
2255
+ setError(caught.message)
2256
+ } finally {
2257
+ setSavingGames(false)
2258
+ }
2259
+ }
2260
+
2261
+ const checkUpdate = async () => {
2262
+ setChecking(true)
2263
+ setUpdateInfo(null)
2264
+ try {
2265
+ const result = await jsonFetch('/check-update')
2266
+ if (result.updateAvailable && result.remote) {
2267
+ setUpdateInfo({ kind: 'update', remoteReleaseId: result.remote.releaseId,
2268
+ text: `发现新版本 ${result.remote.releaseId}(${result.remote.documentCount ?? '?'} 篇)` })
2269
+ } else if (!result.error && result.remote) {
2270
+ setUpdateInfo({ kind: 'latest', text: `已是最新版本(${result.remote.releaseId})` })
2271
+ } else {
2272
+ setUpdateInfo({ kind: 'error', text: result.error || '检查失败' })
2273
+ }
2274
+ } catch (caught) {
2275
+ setUpdateInfo({ kind: 'error', text: caught.message })
2276
+ } finally {
2277
+ setChecking(false)
2278
+ }
2279
+ }
2280
+
2281
+ const download = async () => {
2282
+ setBusy(true)
2283
+ try {
2284
+ await jsonFetch('/download', { method: 'POST', body: JSON.stringify({}) })
2285
+ if (updateInfo?.kind === 'update') setUpdateInfo(null)
2286
+ void refresh()
2287
+ } catch (caught) {
2288
+ setError(caught.message)
2289
+ } finally {
2290
+ setBusy(false)
2291
+ }
2292
+ }
2293
+ const activate = async (releaseId) => {
2294
+ if (!window.confirm(`切换激活版本到 ${releaseId}?索引将重建。`)) return
2295
+ try {
2296
+ await jsonFetch('/activate', { method: 'POST', body: JSON.stringify({ releaseId }) })
2297
+ void refresh()
2298
+ } catch (caught) { setError(caught.message) }
2299
+ }
2300
+ const remove = async (releaseId) => {
2301
+ if (!window.confirm(`删除本地版本 ${releaseId}?不可恢复。`)) return
2302
+ try {
2303
+ await jsonFetch('/delete', { method: 'POST', body: JSON.stringify({ releaseId }) })
2304
+ void refresh()
2305
+ } catch (caught) { setError(caught.message) }
2306
+ }
2307
+
2308
+ const statusUpdateInfo = updateInfo?.kind === 'update'
2309
+ ? { ...updateInfo, onDownload: download }
2310
+ : updateInfo
2311
+
2312
+ const onSkinChanged = useCallback((next) => {
2313
+ setSkinState(next)
2314
+ setStatus((previous) => previous ? { ...previous,
2315
+ config: { ...(previous.config ?? {}), uiSkin: next } } : previous)
2316
+ }, [])
2317
+
2318
+ return h('div', { style: S.wrap, className: 'prts-settings-wrap' },
2319
+ error && h('div', { style: { ...S.card, ...S.error } }, error),
2320
+ h(SkinCard, { skin, onChanged: onSkinChanged }),
2321
+ h(DatasetLibraryCard, { status, activeRelease,
2322
+ releases: releaseList?.releases ?? [], enabledGames, savingGames,
2323
+ onEnabledGames: updateEnabledGames, onRefresh: refresh,
2324
+ onCheckUpdate: checkUpdate, checking, updateInfo: statusUpdateInfo,
2325
+ onActivate: activate, onDelete: remove, onDownload: download,
2326
+ downloading: busy || Boolean(status?.download?.active),
2327
+ }),
2328
+ h(ConfigCard, { config: status?.config, onSaved: refresh }))
2329
+ }
2330
+
2331
+ const contentText = (content) => (Array.isArray(content) ? content : []).map((block) => {
2332
+ if (typeof block === 'string') return block
2333
+ if (typeof block?.text === 'string') return block.text
2334
+ if (block?.type === 'image' || block?.kind === 'image') return '[IMAGE_ATTACHMENT]'
2335
+ return ''
2336
+ }).filter(Boolean).join('\n')
2337
+ const assistantParts = (blocks) => {
2338
+ const parts = { text: [], reasoning: [] }
2339
+ for (const block of Array.isArray(blocks) ? blocks : []) {
2340
+ if (block?.kind === 'text' && block.text) parts.text.push(block.text)
2341
+ else if (block?.kind === 'reasoning' && block.text) parts.reasoning.push(block.text)
2342
+ }
2343
+ return { text: parts.text.join('\n'), reasoning: parts.reasoning.join('\n') }
2344
+ }
2345
+ const MAP_RUNTIME_ABI = 3
2346
+ let mapBundlePromise = null
2347
+ let cancelMapBundleLoad = null
2348
+ let loadedMapRuntime = null
2349
+ const validMapRuntime = (runtime) => runtime?.RUNTIME_ABI === MAP_RUNTIME_ABI
2350
+ && typeof runtime.createRegionMap === 'function'
2351
+ const ensureMapBundle = () => {
2352
+ if (validMapRuntime(globalThis.__PRTS_ENDFIELD_MAP__)) {
2353
+ loadedMapRuntime = globalThis.__PRTS_ENDFIELD_MAP__
2354
+ return Promise.resolve(loadedMapRuntime)
2355
+ }
2356
+ // 未标 ABI 的旧 bundle 不得跨 HMR 复用。
2357
+ if (globalThis.__PRTS_ENDFIELD_MAP__) delete globalThis.__PRTS_ENDFIELD_MAP__
2358
+ if (mapBundlePromise) return mapBundlePromise
2359
+ let currentPromise
2360
+ let cancelCurrentLoad = null
2361
+ const pending = new Promise((resolve, reject) => {
2362
+ const script = document.createElement('script')
2363
+ script.src = `/api/prts-corpus/endfield-map/map.js?abi=${MAP_RUNTIME_ABI}`
2364
+ script.async = true
2365
+ let settled = false
2366
+ let loadTimer = null
2367
+ const finish = (callback, value) => {
2368
+ if (settled) return
2369
+ settled = true
2370
+ if (loadTimer !== null) clearTimeout(loadTimer)
2371
+ script.onload = null
2372
+ script.onerror = null
2373
+ script.remove()
2374
+ callback(value)
2375
+ }
2376
+ cancelCurrentLoad = (error = makeAbortError('地图模块加载已取消')) => finish(reject, error)
2377
+ cancelMapBundleLoad = cancelCurrentLoad
2378
+ script.onload = () => {
2379
+ const runtime = globalThis.__PRTS_ENDFIELD_MAP__
2380
+ if (!validMapRuntime(runtime)) {
2381
+ if (globalThis.__PRTS_ENDFIELD_MAP__ === runtime) {
2382
+ delete globalThis.__PRTS_ENDFIELD_MAP__
2383
+ }
2384
+ loadedMapRuntime = null
2385
+ finish(reject, new Error(`地图模块 ABI 不兼容(需要 ${MAP_RUNTIME_ABI})`))
2386
+ return
2387
+ }
2388
+ loadedMapRuntime = runtime
2389
+ finish(resolve, runtime)
2390
+ }
2391
+ script.onerror = () => finish(reject, new Error('地图模块加载失败'))
2392
+ document.head.appendChild(script)
2393
+ // 订阅者超时只取消自己的等待;底层也必须有 watchdog,否则一次既不
2394
+ // load 也不 error 的 script 会把后续所有 AIC 重挂永久钉在同一 pending。
2395
+ loadTimer = setTimeout(() => {
2396
+ cancelCurrentLoad(new Error('地图模块加载超时'))
2397
+ }, 45000)
2398
+ })
2399
+ currentPromise = pending.finally(() => {
2400
+ if (mapBundlePromise === currentPromise) mapBundlePromise = null
2401
+ if (cancelMapBundleLoad === cancelCurrentLoad) cancelMapBundleLoad = null
2402
+ })
2403
+ mapBundlePromise = currentPromise
2404
+ return currentPromise
2405
+ }
2406
+ const loadMapBundle = (signal) => {
2407
+ if (signal?.aborted) return Promise.reject(makeAbortError('地图加载已取消'))
2408
+ // script 是同一插件实例内的共享加载任务。组件卸载只取消自己的等待,
2409
+ // 不移除共享 script;否则快速切肤/slot 重挂会让旧实例误伤新实例。
2410
+ return waitWithDeadline(ensureMapBundle(), {
2411
+ signal, timeout: 50000, label: '地图模块加载',
2412
+ })
2413
+ }
2414
+
2415
+ function AicMap({ focusPoint = .66 }) {
2416
+ const host = useRef(null)
2417
+ const mapRef = useRef(null)
2418
+ const focusPointRef = useRef(focusPoint)
2419
+ focusPointRef.current = focusPoint
2420
+ const [status, setStatus] = useState('MAP_ASSET_SYNC 0%')
2421
+ const [stats, setStats] = useState({ cx: 0, cy: 0, size: 0, fps: 0, meshes: 0, tris: 0 })
2422
+ const [positions, setPositions] = useState([])
2423
+ const [selected, setSelected] = useState(null)
2424
+ const [regions, setRegions] = useState([])
2425
+ useEffect(() => {
2426
+ let disposed = false
2427
+ const controller = new AbortController()
2428
+ const bootToken = aicBootEpoch
2429
+ // WebView2 进入托盘后 document.visibilityState 不一定立刻变化,桌面壳
2430
+ // 会额外发送 prts-shell-visibility。两条信号统一控制 Three.js 的 RAF,
2431
+ // 避免隐藏窗口仍持续渲染并让 renderer/GPU working set 不断增长。
2432
+ const pageIsActive = () => document.visibilityState !== 'hidden'
2433
+ && globalThis.__PRTS_SHELL_BACKGROUND__ !== true
2434
+ && !document.querySelector('[aria-modal="true"][role="dialog"]')
2435
+ const syncMapActivity = () => {
2436
+ const map = mapRef.current
2437
+ if (!map) return
2438
+ if (pageIsActive()) map.resume?.()
2439
+ else map.pause?.()
2440
+ }
2441
+ const syncModalState = () => {
2442
+ document.body.classList.toggle('aic-modal-open', isHostSettingsOpen())
2443
+ syncMapActivity()
2444
+ }
2445
+ document.addEventListener('visibilitychange', syncMapActivity)
2446
+ globalThis.addEventListener('prts-shell-visibility', syncMapActivity)
2447
+ // 设置弹窗覆盖 WebGL 时暂停 RAF。尤其在 macOS WebKit 中,全屏蒙层与
2448
+ // 持续运行的 Three.js 合成会显著放大 GPU 压力,表现为发光后卡死。
2449
+ const modalObserver = new MutationObserver(syncModalState)
2450
+ modalObserver.observe(document.body, { childList: true, subtree: true, attributes: true,
2451
+ attributeFilter: ['aria-modal', 'role'] })
2452
+ syncModalState()
2453
+ void loadMapBundle(controller.signal).then(async (runtime) => {
2454
+ if (disposed || !host.current) return
2455
+ setRegions(runtime.REGION_LIST ?? [])
2456
+ const map = await runtime.createRegionMap(host.current, {
2457
+ signal: controller.signal,
2458
+ focusPoint: focusPointRef.current,
2459
+ onProgress: (progress, message) => {
2460
+ if (disposed) return
2461
+ setStatus(progress < 0 ? `ERROR // ${message}` : `MAP_ASSET_SYNC ${progress}% // ${message}`)
2462
+ // 开机场加载屏由地图真实进度驱动;失败时停留片刻再撤下,露出页面内的错误状态。
2463
+ if (progress < 0) {
2464
+ aicBootProgress(-1, message, bootToken)
2465
+ aicBootSchedule(() => aicBootDone(bootToken, false), 2500, bootToken)
2466
+ } else aicBootProgress(progress, message, bootToken)
2467
+ },
2468
+ onStats: (value) => { if (!disposed) setStats(value) },
2469
+ onSelectLv: (value) => { if (!disposed) setSelected(value) },
2470
+ onRegionPositions: (value) => { if (!disposed) setPositions(value) },
2471
+ })
2472
+ if (disposed) map.dispose()
2473
+ else {
2474
+ // 装载期间响应式布局可能已变宽;应用最新值,不能丢掉那次更新。
2475
+ map.setFocusPoint?.(focusPointRef.current)
2476
+ mapRef.current = map
2477
+ syncMapActivity()
2478
+ setStatus('')
2479
+ aicBootDone(bootToken)
2480
+ }
2481
+ }).catch((error) => {
2482
+ if (disposed || error?.name === 'AbortError') return
2483
+ const message = error?.message || '地图加载失败'
2484
+ setStatus(`ERROR // ${message}`)
2485
+ aicBootProgress(-1, message, bootToken)
2486
+ aicBootSchedule(() => aicBootDone(bootToken, false), 2500, bootToken)
2487
+ })
2488
+ return () => {
2489
+ disposed = true
2490
+ controller.abort(makeAbortError('AIC 地图已卸载'))
2491
+ document.removeEventListener('visibilitychange', syncMapActivity)
2492
+ globalThis.removeEventListener('prts-shell-visibility', syncMapActivity)
2493
+ modalObserver.disconnect()
2494
+ document.body.classList.remove('aic-modal-open')
2495
+ mapRef.current?.dispose()
2496
+ mapRef.current = null
2497
+ }
2498
+ }, [])
2499
+ useEffect(() => {
2500
+ focusPointRef.current = focusPoint
2501
+ mapRef.current?.setFocusPoint?.(focusPoint)
2502
+ }, [focusPoint])
2503
+ const positionByLv = Object.fromEntries(positions.map((item) => [item.lv, item]))
2504
+ return h(Fragment, null,
2505
+ h('div', { ref: host, className: 'aic-map' }),
2506
+ status && h('div', { className: `aic-map-status${status.startsWith('ERROR') ? ' error' : ''}` }, status),
2507
+ ...regions.map((region) => {
2508
+ const point = positionByLv[region.lv]
2509
+ if (!point?.visible) return null
2510
+ return h('button', { key: region.lv, type: 'button',
2511
+ className: `aic-region-label${selected === region.lv ? ' selected' : ''}`,
2512
+ 'aria-pressed': selected === region.lv,
2513
+ 'aria-label': `查看区域:${region.name}(${region.lv.toUpperCase()})`,
2514
+ onClick: () => {
2515
+ mapRef.current?.setSelected?.(region.lv)
2516
+ mapRef.current?.focusLv?.(region.lv)
2517
+ setSelected(region.lv)
2518
+ },
2519
+ style: { left: `${point.x}px`,
2520
+ top: `${point.y}px`, transform: 'translate(-50%,-50%)' } },
2521
+ h('i'), region.name, h('small', null, region.lv.toUpperCase()))
2522
+ }),
2523
+ selected && (() => {
2524
+ const region = regions.find((item) => item.lv === selected)
2525
+ return region ? h('aside', { className: 'aic-region-card' },
2526
+ h('small', null, `REGION_INTEL // ${region.lv.toUpperCase()}`),
2527
+ h('h3', null, region.name), h('em', null, region.nameEn),
2528
+ h('p', null, region.description), h('b', null, region.sourceLabel)) : null
2529
+ })(),
2530
+ h('div', { className: 'aic-hud aic-hud-tl' },
2531
+ h('div', { className: 'aic-brand' }, h('i', { className: 'aic-brand-mark' }),
2532
+ h('b', { className: 'aic-brand-name' }, 'AIC'),
2533
+ h('span', { className: 'aic-brand-sub' }, 'ENDFIELD_INDUSTRIES // TALOS-II SURVEY')),
2534
+ h('div', { className: 'aic-coords' }, h('span', null, `VIEW ${stats.cx},${stats.cy}`),
2535
+ h('span', null, `ZOOM ${stats.size}`))),
2536
+ h('div', { className: 'aic-hud aic-hud-br' },
2537
+ 'DRAG/方向键 平移 // WHEEL/+/- 缩放 // TAB 区域按钮'),
2538
+ h('button', { type: 'button', className: 'aic-map-reset', onClick: () => mapRef.current?.resetView(),
2539
+ title: '重置地图视角' }, 'RESET_VIEW'))
2540
+ }
2541
+
2542
+ const aicViewportWidth = () => Math.max(1,
2543
+ globalThis.document?.documentElement?.clientWidth || globalThis.innerWidth || 1)
2544
+ const aicBandRange = (viewportWidth) => {
2545
+ if (viewportWidth <= 980) {
2546
+ const fixed = Math.min(720, viewportWidth)
2547
+ return { min: fixed, max: fixed, preferred: fixed }
2548
+ }
2549
+ const max = Math.max(320, Math.min(1200, viewportWidth - 320))
2550
+ const min = Math.min(520, max)
2551
+ const preferred = Math.max(min, Math.min(max, 960, Math.max(760, viewportWidth * .46),
2552
+ viewportWidth - 360))
2553
+ return { min, max, preferred }
2554
+ }
2555
+
2556
+ function AicRoot({ useSessions }) {
2557
+ // useSessions 由 ui-session 的 provideRoot 提供给 root 槽位;不含该包的
2558
+ // 组合下为 undefined——历史抽屉退化为空列表,新建对话仍走 sessions 服务。
2559
+ const sessions = useSessions?.((snapshot) => snapshot)
2560
+ ?? { ids: [], byId: {}, current: undefined }
2561
+ const [historyOpen, setHistoryOpen] = useState(false)
2562
+ const [clock, setClock] = useState(() => new Date())
2563
+ const [viewportWidth, setViewportWidth] = useState(aicViewportWidth)
2564
+ const bandRange = aicBandRange(viewportWidth)
2565
+ const defaultBandWidth = bandRange.preferred
2566
+ const [chatBandWidth, setChatBandWidth] = useState(defaultBandWidth)
2567
+ const [chatResizeActive, setChatResizeActive] = useState(false)
2568
+ const stopChatResizeRef = useRef(null)
2569
+ const historyButtonRef = useRef(null)
2570
+ const historyDrawerRef = useRef(null)
2571
+ useEffect(() => {
2572
+ let timer = null
2573
+ const syncClock = () => {
2574
+ if (timer !== null) clearInterval(timer)
2575
+ timer = null
2576
+ if (!document.hidden) {
2577
+ setClock(new Date())
2578
+ timer = setInterval(() => setClock(new Date()), 1000)
2579
+ }
2580
+ }
2581
+ document.addEventListener('visibilitychange', syncClock)
2582
+ syncClock()
2583
+ return () => {
2584
+ document.removeEventListener('visibilitychange', syncClock)
2585
+ if (timer !== null) clearInterval(timer)
2586
+ }
2587
+ }, [])
2588
+ useEffect(() => {
2589
+ const resize = () => {
2590
+ const width = aicViewportWidth()
2591
+ const range = aicBandRange(width)
2592
+ setViewportWidth(width)
2593
+ setChatBandWidth((currentWidth) => Math.max(range.min, Math.min(range.max, currentWidth)))
2594
+ }
2595
+ globalThis.addEventListener('resize', resize)
2596
+ globalThis.visualViewport?.addEventListener('resize', resize)
2597
+ resize()
2598
+ return () => {
2599
+ globalThis.removeEventListener('resize', resize)
2600
+ globalThis.visualViewport?.removeEventListener('resize', resize)
2601
+ }
2602
+ }, [])
2603
+ useEffect(() => {
2604
+ document.body.style.setProperty('--prts-aic-band-width', `${chatBandWidth}px`)
2605
+ return () => { document.body.style.removeProperty('--prts-aic-band-width') }
2606
+ }, [chatBandWidth])
2607
+ useEffect(() => () => { stopChatResizeRef.current?.() }, [])
2608
+ const current = sessions.current
2609
+ const currentSummary = current === undefined ? undefined : sessions.byId[current]
2610
+ const currentTitle = currentSummary?.title || (currentSummary?.blank ? '新会话' : 'AIC_TERMINAL')
2611
+ const sessionRows = useMemo(() => sessions.ids.map((id) => sessions.byId[id]).filter(Boolean),
2612
+ [sessions.ids, sessions.byId])
2613
+ useEffect(() => {
2614
+ if (!historyOpen) return undefined
2615
+ const drawer = historyDrawerRef.current
2616
+ const previousFocus = document.activeElement
2617
+ const focusables = () => [...(drawer?.querySelectorAll(
2618
+ 'button:not([disabled]),[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])',
2619
+ ) ?? [])]
2620
+ focusables()[0]?.focus()
2621
+ const onKeyDown = (event) => {
2622
+ if (event.key === 'Escape') {
2623
+ event.preventDefault()
2624
+ setHistoryOpen(false)
2625
+ return
2626
+ }
2627
+ if (event.key !== 'Tab') return
2628
+ const items = focusables()
2629
+ if (!items.length) { event.preventDefault(); return }
2630
+ const first = items[0]
2631
+ const last = items[items.length - 1]
2632
+ if (!drawer?.contains(document.activeElement)) {
2633
+ event.preventDefault()
2634
+ ;(event.shiftKey ? last : first).focus()
2635
+ } else if (event.shiftKey && document.activeElement === first) {
2636
+ event.preventDefault(); last.focus()
2637
+ } else if (!event.shiftKey && document.activeElement === last) {
2638
+ event.preventDefault(); first.focus()
2639
+ }
2640
+ }
2641
+ document.addEventListener('keydown', onKeyDown)
2642
+ return () => {
2643
+ document.removeEventListener('keydown', onKeyDown)
2644
+ if (typeof previousFocus?.focus === 'function' && previousFocus.isConnected) previousFocus.focus()
2645
+ else historyButtonRef.current?.focus()
2646
+ }
2647
+ }, [historyOpen])
2648
+ const newSession = () => {
2649
+ const options = currentSummary?.cwd ? { cwd: currentSummary.cwd } : {}
2650
+ void clientContext?.sessions?.create(options)
2651
+ .then((sessionId) => { clientContext?.sessions?.open(sessionId) })
2652
+ .catch((error) => console.error('[prts-terrarchive] failed to create session', error))
2653
+ }
2654
+ const openSettings = () => { openHostSettings() }
2655
+ const startChatResize = (event) => {
2656
+ if (viewportWidth <= 980) return
2657
+ event.preventDefault()
2658
+ stopChatResizeRef.current?.()
2659
+ setChatResizeActive(true)
2660
+ document.body.classList.add('aic-chat-resizing')
2661
+ const resize = (moveEvent) => {
2662
+ const range = aicBandRange(aicViewportWidth())
2663
+ setChatBandWidth(Math.max(range.min, Math.min(range.max, moveEvent.clientX)))
2664
+ }
2665
+ const stop = () => {
2666
+ setChatResizeActive(false)
2667
+ globalThis.removeEventListener('pointermove', resize)
2668
+ globalThis.removeEventListener('pointerup', stop)
2669
+ globalThis.removeEventListener('pointercancel', stop)
2670
+ document.body.classList.remove('aic-chat-resizing')
2671
+ stopChatResizeRef.current = null
2672
+ }
2673
+ stopChatResizeRef.current = stop
2674
+ globalThis.addEventListener('pointermove', resize)
2675
+ globalThis.addEventListener('pointerup', stop, { once: true })
2676
+ globalThis.addEventListener('pointercancel', stop, { once: true })
2677
+ }
2678
+ const resizeChatWithKeyboard = (event) => {
2679
+ if (viewportWidth <= 980) return
2680
+ let next = null
2681
+ if (event.key === 'ArrowLeft') next = chatBandWidth - 24
2682
+ else if (event.key === 'ArrowRight') next = chatBandWidth + 24
2683
+ else if (event.key === 'Home') next = bandRange.min
2684
+ else if (event.key === 'End') next = bandRange.max
2685
+ if (next === null) return
2686
+ event.preventDefault()
2687
+ setChatBandWidth(Math.max(bandRange.min, Math.min(bandRange.max, next)))
2688
+ }
2689
+ const focusPoint = Math.max(.55, Math.min(.86,
2690
+ .66 + (chatBandWidth - defaultBandWidth) / (Math.max(1, viewportWidth) * 2)))
2691
+ return h('main', { className: 'aic-root', style: {
2692
+ '--aic-band-width': `${chatBandWidth}px`,
2693
+ '--prts-aic-band-width': `${chatBandWidth}px`,
2694
+ } },
2695
+ h(AicMap, { focusPoint }),
2696
+ h('section', { className: 'aic-chat-band' },
2697
+ h('div', { className: 'aic-band-backdrop', 'aria-hidden': 'true' }),
2698
+ h('div', { className: 'aic-chat-resize', role: 'separator', tabIndex: 0,
2699
+ title: '拖动或使用左右方向键调整聊天区域宽度',
2700
+ 'aria-label': '调整聊天区域宽度', 'aria-orientation': 'vertical',
2701
+ 'aria-valuemin': Math.round(bandRange.min), 'aria-valuemax': Math.round(bandRange.max),
2702
+ 'aria-valuenow': Math.round(chatBandWidth), onKeyDown: resizeChatWithKeyboard,
2703
+ onPointerDown: startChatResize }),
2704
+ h('section', { className: 'aic-start-brand', 'aria-hidden': 'true' },
2705
+ h('div', { className: 'aic-start-wordmark' }, 'PRTS'),
2706
+ h('div', { className: 'aic-start-submark' },
2707
+ h('s', null, 'PRIMITIVE RHODES ISLAND TERMINAL SERVICE'), ' // PRIES—???'),
2708
+ h('h2', null, '想从泰拉的故事里了解什么?'),
2709
+ h('p', null, '我会先检索线索,再回到本地资料逐行核验,并把可复查的原文放在回答旁边。')),
2710
+ h('div', { className: 'aic-terminal-overlay' },
2711
+ h('div', { className: 'aic-terminal-name' }, h('i', { className: 'aic-terminal-dot' }),
2712
+ h('span', { title: currentTitle }, currentTitle),
2713
+ h('small', { className: 'aic-terminal-ver' }, 'V9.2 // WULING_NODE')),
2714
+ h('button', { type: 'button', className: 'aic-band-btn aic-settings-trigger',
2715
+ onClick: openSettings }, '设置'),
2716
+ h('button', { ref: historyButtonRef, type: 'button',
2717
+ className: `aic-band-btn${historyOpen ? ' on' : ''}`,
2718
+ 'aria-expanded': historyOpen, 'aria-controls': 'aic-session-archive',
2719
+ onClick: () => setHistoryOpen((value) => !value) }, '◷ 历史记录'),
2720
+ h('button', { type: 'button', className: 'aic-band-btn', onClick: newSession }, '+ 新建对话'))),
2721
+ chatResizeActive && h('div', { className: 'aic-chat-resize-line',
2722
+ style: { left: `${chatBandWidth}px` } }),
2723
+ h('div', { className: 'aic-hud aic-hud-tr' },
2724
+ h('span', { className: 'aic-clock' }, clock.toLocaleTimeString('zh-CN', { hour12: false }))),
2725
+ historyOpen && h(Fragment, null,
2726
+ h('div', { className: 'aic-drawer-scrim', 'aria-hidden': 'true',
2727
+ onPointerDown: (event) => { event.preventDefault(); setHistoryOpen(false) } }),
2728
+ h('aside', { ref: historyDrawerRef, id: 'aic-session-archive',
2729
+ className: 'aic-drawer', role: 'dialog', 'aria-modal': 'true', 'aria-label': '历史会话' },
2730
+ h('header', { className: 'aic-drawer-head' }, h('span', null, 'SESSION_ARCHIVE'),
2731
+ h('button', { type: 'button', className: 'aic-close', 'aria-label': '关闭历史会话',
2732
+ onClick: () => setHistoryOpen(false) }, '×')),
2733
+ h('button', { type: 'button', className: 'aic-band-btn', onClick: newSession }, '+ 新建对话'),
2734
+ h('div', { className: 'aic-session-list' }, sessionRows.length
2735
+ ? sessionRows.map((session) => h('button', { type: 'button', key: session.id,
2736
+ className: `aic-session${session.id === current ? ' active' : ''}`,
2737
+ 'aria-current': session.id === current ? 'page' : undefined,
2738
+ onClick: () => { clientContext?.sessions?.open(session.id); setHistoryOpen(false) } },
2739
+ h('b', null, session.title || (session.blank ? '新会话' : `会话_${String(session.id).slice(0, 6)}`)),
2740
+ h('small', null, `${session.blank ? 'STANDBY' : 'ARCHIVED'} // ${new Date(session.updatedAt).toLocaleString('zh-CN')}`)))
2741
+ : h('div', { className: 'aic-empty' }, 'NO SESSION RECORDS'))))
2742
+ )
2743
+ }
2744
+
2745
+ syncAicLayout = () => {
2746
+ if (!clientContext) return
2747
+ if (activeSkin !== 'endfield-aic') {
2748
+ if (disposeAicLayout) disposeAicLayout()
2749
+ disposeAicLayout = null
2750
+ return
2751
+ }
2752
+ if (disposeAicLayout) return
2753
+ const owner = clientContext
2754
+ // shell.overlay 由 ui-layout 的 root entry 动态声明。inject 会等待声明,
2755
+ // declaration collapse 时卸载,并在 owner HMR 重挂后自动重新注册。
2756
+ const disposeInjection = owner.slots.inject('shell.overlay', () => {
2757
+ if (activeSkin !== 'endfield-aic' || clientContext !== owner) return () => {}
2758
+ return owner.slots.register({
2759
+ name: 'shell.overlay', id: 'prts-aic-shell', order: -100,
2760
+ }, AicRoot)
2761
+ })
2762
+ disposeAicLayout = () => { disposeInjection() }
2763
+ }
2764
+
2765
+ /* Rhine archive: durable returned tool material, independent from camera state. */
2766
+ const rhineSkinListeners = new Set()
2767
+ const rhineCleanups = new Set()
2768
+ let rhineBundlePromise = null
2769
+ let rhineBundleOwner = null
2770
+ const RHINE_BASE = '/api/prts-corpus/rhine/'
2771
+ const rhineNode = (nodes, key) => typeof nodes?.get === 'function' ? nodes.get(key) : nodes?.[key]
2772
+ const rhineToolPresentation = (root) => {
2773
+ const supported = (meta) => meta?.kind === 'prts-archive-sources-v1' || meta?.kind === 'prts-corpus-read-v1'
2774
+ if (supported(root?.meta)) return root.meta
2775
+ if (root?.kind !== 'tool-result' || typeof root.parentCallId !== 'string'
2776
+ || !Array.isArray(root.content)) return null
2777
+ // PTC receipts are their own log-only block, never text to parse or show.
2778
+ // Bind all three identities before accepting a durable source projection.
2779
+ const receipt = root.content.find((block) => block?.type === 'prts-archive-receipt'
2780
+ && block.version === 1 && block.callId === root.callId
2781
+ && block.parentCallId === root.parentCallId && block.tool === root.call?.name
2782
+ && supported(block.meta) && Array.isArray(block.meta.sources)
2783
+ && block.meta.sources.length <= 128)
2784
+ return receipt?.meta || null
2785
+ }
2786
+ const mergeArchiveSources = (sources) => {
2787
+ const result = new Map()
2788
+ const rank = { found: 0, read: 1, cited: 2 }
2789
+ for (const item of sources) {
2790
+ if (!item || typeof item.title !== 'string' || !item.title.trim()) continue
2791
+ const version = validDataVersion(item.dataVersion) ? item.dataVersion : ''
2792
+ const identity = version && (item.documentId || item.documentUid)
2793
+ ? `${version}:${item.documentId || item.documentUid}` : item.id
2794
+ if (typeof identity !== 'string' || !identity) continue
2795
+ const incoming = { ...item, title: item.title.slice(0, 512),
2796
+ id: version && item.documentUid ? `document:${version}:${item.documentUid}` : item.id || identity,
2797
+ origin: ['cloud', 'web'].includes(item.origin) ? item.origin : 'local',
2798
+ state: Object.hasOwn(rank, item.state) ? item.state : 'found',
2799
+ excerpt: typeof item.excerpt === 'string' ? item.excerpt : '',
2800
+ dataVersion: version || undefined }
2801
+ const previous = result.get(identity)
2802
+ const ranges = [...(previous?.ranges || []), ...(incoming.ranges || []),
2803
+ ...(Number.isInteger(item.lineStart) && item.lineStart > 0
2804
+ ? [{ start: item.lineStart, end: item.lineEnd || item.lineStart }] : [])]
2805
+ .filter((r) => Number.isSafeInteger(r?.start) && Number.isSafeInteger(r?.end)
2806
+ && r.start > 0 && r.end >= r.start).sort((a, b) => a.start - b.start)
2807
+ const mergedRanges = []
2808
+ for (const range of ranges) {
2809
+ const last = mergedRanges.at(-1)
2810
+ if (last && range.start <= last.end + 1) last.end = Math.max(last.end, range.end)
2811
+ else mergedRanges.push({ start: range.start, end: range.end })
2812
+ }
2813
+ const readRanges = [...new Map([...(previous?.readRanges || []), ...(incoming.readRanges || [])]
2814
+ .filter((r) => Number.isSafeInteger(r?.start) && Number.isSafeInteger(r?.end) && r.start > 0 && r.end >= r.start)
2815
+ .map((r) => [`${r.start}:${r.end}`, { start: r.start, end: r.end }])).values()]
2816
+ result.set(identity, { ...previous, ...incoming,
2817
+ id: version && (incoming.documentUid || previous?.documentUid)
2818
+ ? `document:${version}:${incoming.documentUid || previous.documentUid}` : previous?.id || incoming.id,
2819
+ origin: previous?.origin === 'cloud' ? 'cloud' : incoming.origin,
2820
+ documentId: incoming.documentId || previous?.documentId,
2821
+ documentUid: incoming.documentUid || previous?.documentUid,
2822
+ sourceRef: incoming.sourceRef || previous?.sourceRef,
2823
+ kind: previous?.kind && ['corpus_read', 'reference', 'source'].includes(incoming.kind)
2824
+ ? previous.kind : incoming.kind || previous?.kind,
2825
+ state: previous && rank[previous.state] > rank[incoming.state] ? previous.state : incoming.state,
2826
+ agentRead: previous?.agentRead === true || incoming.agentRead === true
2827
+ || previous?.state === 'read' || incoming.state === 'read' || readRanges.length > 0,
2828
+ excerpt: incoming.excerpt || previous?.excerpt || '', ranges: mergedRanges,
2829
+ readRanges })
2830
+ }
2831
+ return [...result.values()]
2832
+ }
2833
+ // A title mention alone is not evidence of citation. Only explicit corpus
2834
+ // citation syntax or an exact source_ref can mark a returned source as cited.
2835
+ const rhineCitationIndex = sources => {
2836
+ const candidates = mergeArchiveSources(sources), titles = new Map(), refs = new Map()
2837
+ for (const source of candidates) {
2838
+ if (!titles.has(source.title)) titles.set(source.title, [])
2839
+ titles.get(source.title).push(source)
2840
+ if (source.sourceRef && typeof source.sourceRef === 'string') {
2841
+ if (!refs.has(source.sourceRef)) refs.set(source.sourceRef, [])
2842
+ refs.get(source.sourceRef).push(source)
2843
+ }
2844
+ }
2845
+ const exactRefs = []
2846
+ for (const [ref, hits] of refs) {
2847
+ if (hits.length !== 1) continue
2848
+ const escaped = ref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
2849
+ exactRefs.push([hits[0].id, new RegExp(`(?:^|[\\s\\x60("'=:])${escaped}(?=$|[\\s\\x60)"'\\],.;,。;])`, 'u')])
2850
+ }
2851
+ return { titles, exactRefs }
2852
+ }
2853
+ const citedArchiveIds = (sources, answer, wikiCitations, index = rhineCitationIndex(sources)) => {
2854
+ const cited = new Set()
2855
+ const matchesRange = (source, start, end) => (source.ranges || []).some((range) =>
2856
+ range.start <= start && range.end >= end)
2857
+ for (const match of answer.matchAll(/《([^》\n]+)》(?:(document_uid=(doc_[A-Za-z0-9_-]{16})))?\s*(?:第\s*(\d+)(?:\s*[-—–~]\s*(\d+))?\s*行|Wiki·([^\s。;,,;\n]+))/gu)) {
2858
+ const start = Number(match[3]), end = Number(match[4] || match[3])
2859
+ const hits = (index.titles.get(match[1]) || []).filter(source => !match[2] || source.documentUid === match[2])
2860
+ // UID alone cannot disambiguate two revisions of the same document.
2861
+ if (hits.length !== 1) continue
2862
+ const source = hits[0]
2863
+ if (match[5] ? wikiCitations?.get(source.id)?.has(match[5])
2864
+ : Number.isSafeInteger(start) && start > 0 && end >= start && matchesRange(source, start, end)) cited.add(source.id)
2865
+ }
2866
+ for (const [id, pattern] of index.exactRefs) if (pattern.test(answer)) cited.add(id)
2867
+ return cited
2868
+ }
2869
+ const isRhineWebTool = name => name === 'web_search' || name === 'web_fetch'
2870
+ const isRhineReadTool = name => name === 'corpus_read' || name === 'cloud_inspect' || name === 'web_fetch'
2871
+ const rhineWebUrl = value => {
2872
+ if (typeof value !== 'string') return ''
2873
+ try {
2874
+ const url = new URL(value)
2875
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return ''
2876
+ url.hash = ''
2877
+ return url.href
2878
+ } catch { return '' }
2879
+ }
2880
+ // Consume the web tools' durable structured metadata, never links guessed
2881
+ // from arbitrary tool output. Fetch bodies remain the exact returned text.
2882
+ const rhineWebSources = (name, args, meta, text, callId, prior) => {
2883
+ if (name === 'web_search') return (Array.isArray(meta?.sources) ? meta.sources : []).slice(0, 128).flatMap(item => {
2884
+ const url = rhineWebUrl(item?.url)
2885
+ if (!url) return []
2886
+ return [{ id: `web:${url}`, url, title: typeof item.title === 'string' && item.title.trim() ? item.title : url,
2887
+ kind: 'web_page', origin: 'web', state: 'found', callId,
2888
+ excerpt: typeof item.snippet === 'string' ? item.snippet : '' }]
2889
+ })
2890
+ const requested = rhineWebUrl(args.url), url = rhineWebUrl(meta?.url) || requested
2891
+ if (!url || !text.trim() || Number(meta?.statusCode) >= 400) return []
2892
+ const id = `web:${requested || url}`
2893
+ const previous = [...prior].reverse().find(source => source.id === id || source.origin === 'web' && rhineWebUrl(source.url) === url)
2894
+ const heading = text.match(/^#{1,2}\s+([^\n]+)$/mu)?.[1]?.trim()
2895
+ return [{ id: previous?.id || id, url, title: previous?.title || heading || url,
2896
+ kind: 'web_page', origin: 'web', state: 'read', agentRead: true, callId,
2897
+ excerpt: text.slice(0, 1600), content: text, contentTruncated: meta?.truncated === true }]
2898
+ }
2899
+ const rhinePhase = (snapshot) => {
2900
+ if (snapshot.searching) return isRhineReadTool(snapshot.tool) ? 'reading' : 'searching'
2901
+ if (snapshot.running) return 'synthesizing'
2902
+ if (snapshot.error) return 'error'
2903
+ return snapshot.answer ? 'complete' : 'idle'
2904
+ }
2905
+ // Detached input values detect same-length and in-place edits. Equal
2906
+ // subtrees stay shared without serializing the entire transcript per tick.
2907
+ const retainRhineValue = (value, previous) => {
2908
+ if (Object.is(value, previous)) return previous
2909
+ if (!value || typeof value !== 'object') return value
2910
+ const array = Array.isArray(value)
2911
+ const compatible = previous && typeof previous === 'object' && Array.isArray(previous) === array
2912
+ const keys = Object.keys(value)
2913
+ const previousKeys = compatible ? Object.keys(previous) : []
2914
+ let result = compatible ? undefined : array ? [] : {}
2915
+ const copy = () => result ||= array ? previous.slice() : { ...previous }
2916
+ for (const key of keys) {
2917
+ const next = retainRhineValue(value[key], compatible ? previous[key] : undefined)
2918
+ if (!compatible || !Object.hasOwn(previous, key) || !Object.is(next, previous[key])) {
2919
+ copy()
2920
+ if (key === '__proto__') Object.defineProperty(result, key, { value: next, enumerable: true, writable: true, configurable: true })
2921
+ else result[key] = next
2922
+ }
2923
+ }
2924
+ for (const key of previousKeys) if (!Object.hasOwn(value, key)) { copy(); delete result[key] }
2925
+ if (array && (!compatible || value.length !== previous.length)) { copy(); result.length = value.length }
2926
+ return result || previous
2927
+ }
2928
+ const rhineSnapshotEntries = (order, nodes) => {
2929
+ const entries = []
2930
+ for (const key of order || []) {
2931
+ const node = rhineNode(nodes, key)
2932
+ entries.push([key, node])
2933
+ if (node?.kind !== 'tool-call') continue
2934
+ const seen = new Set([node.data?.root])
2935
+ const queue = [...(node.data?.root?.subCalls || [])].reverse()
2936
+ let count = 0
2937
+ while (queue.length && count++ < 16384) {
2938
+ const child = queue.pop()
2939
+ if (!child || seen.has(child)) continue
2940
+ seen.add(child)
2941
+ entries.push([`${key}:${child.callId || count}`, { kind: 'tool-call', data: { root: child },
2942
+ location: node.location, anchorSeq: node.anchorSeq }])
2943
+ queue.push(...(child.subCalls || []).slice().reverse())
2944
+ }
2945
+ }
2946
+ return entries
2947
+ }
2948
+ const rhineTurnInput = turn => turn ? { turn: turn.turn, start: turn.start, end: turn.end } : undefined
2949
+ const rhineSnapshotInput = snapshot => {
2950
+ const entries = rhineSnapshotEntries(snapshot?.order, snapshot?.nodes).map(([key, node]) => {
2951
+ if (!node) return [key, node]
2952
+ const raw = node.data, root = raw?.root
2953
+ const data = { seq: raw?.seq, turn: raw?.turn }
2954
+ if (node.kind === 'assistant-step') {
2955
+ data.status = raw?.status
2956
+ data.blocks = [{ kind: 'text', text: visibleAssistantText(raw?.blocks) }]
2957
+ } else if (node.kind === 'user' || node.kind === 'steering') {
2958
+ data.content = (raw?.content || []).filter(block => block?.type === 'text')
2959
+ .map(block => ({ type: 'text', text: block.text }))
2960
+ } else if (node.kind === 'tool-call' && root) {
2961
+ data.root = { name: root.name, argsRaw: root.argsRaw,
2962
+ ...('kind' in root ? { kind: root.kind } : {}),
2963
+ call: root.call ? { name: root.call.name, argsRaw: root.call.argsRaw } : undefined,
2964
+ meta: root.meta, content: root.content, isError: root.isError, time: root.time,
2965
+ callTime: root.callTime, callId: root.callId, parentCallId: root.parentCallId,
2966
+ turn: root.turn, seq: root.seq }
2967
+ } else if (node.kind === 'turn-error' || node.kind === 'turn-max-tokens') data.message = raw?.message
2968
+ return [key, { kind: node.kind, data, anchorSeq: node.anchorSeq,
2969
+ location: { turn: rhineTurnInput(node.location?.turn) } }]
2970
+ })
2971
+ const turnNumber = snapshot?.timeline?.turnOrder?.at(-1)
2972
+ const turn = turnNumber === undefined ? undefined : snapshot?.timeline?.turns?.get(turnNumber)
2973
+ return { entries, turnNumber, turn: rhineTurnInput(turn) }
2974
+ }
2975
+ function createRhineProjectionCache(diagnostics) {
2976
+ const slots = new Map()
2977
+ let used = new Set()
2978
+ return {
2979
+ begin() { used = new Set() },
2980
+ end() { for (const key of slots.keys()) if (!used.has(key)) slots.delete(key) },
2981
+ array(key, value) {
2982
+ used.add(key)
2983
+ const previous = slots.get(key)?.value
2984
+ if (previous?.length === value.length && value.every((item, i) => Object.is(item, previous[i]))) return previous
2985
+ const next = value.slice()
2986
+ slots.set(key, { value: next })
2987
+ return next
2988
+ },
2989
+ retain(key, value) {
2990
+ used.add(key)
2991
+ const next = retainRhineValue(value, slots.get(key)?.value)
2992
+ slots.set(key, { value: next })
2993
+ return next
2994
+ },
2995
+ memo(key, deps, task, category) {
2996
+ used.add(key)
2997
+ const previous = slots.get(key)
2998
+ if (previous?.deps?.length === deps.length && deps.every((value, i) => Object.is(value, previous.deps[i]))) {
2999
+ diagnostics?.cacheEvent(category, true)
3000
+ return previous.value
3001
+ }
3002
+ diagnostics?.cacheEvent(category, false)
3003
+ const value = task()
3004
+ slots.set(key, { deps, value })
3005
+ return value
3006
+ },
3007
+ }
3008
+ }
3009
+ const buildRhineSnapshot = (order = [], nodes, sessionId = '', sessionRunning = false, timeline, cache) => {
3010
+ const memo = (key, deps, task, category) => cache ? cache.memo(key, deps, task, category) : task()
3011
+ const retain = (key, value) => cache ? cache.retain(key, value) : value
3012
+ const retainArray = (key, value) => cache ? cache.array(key, value) : value
3013
+ const sources = []
3014
+ const records = []
3015
+ const operations = []
3016
+ const toolCalls = []
3017
+ const operationSources = new Map()
3018
+ const entries = cache?.entries || rhineSnapshotEntries(order, nodes)
3019
+ const userEntries = entries.filter(([, node]) => node?.kind === 'user' || node?.kind === 'steering')
3020
+ const lastInput = userEntries.at(-1)
3021
+ const inputSeq = lastInput?.[1]?.data?.seq ?? lastInput?.[1]?.anchorSeq
3022
+ const turnNumber = timeline?.turnOrder?.at(-1)
3023
+ let currentTurn = turnNumber === undefined ? undefined : timeline?.turns?.get(turnNumber)
3024
+ if (!currentTurn) {
3025
+ currentTurn = [...entries].reverse().find(([, node]) => node?.location?.turn)?.[1]?.location?.turn
3026
+ }
3027
+ // A newly admitted message can precede its turn/start publication. Never
3028
+ // reuse the previous turn's successful end for that pending question.
3029
+ if (currentTurn?.end && Number.isFinite(inputSeq) && inputSeq > currentTurn.end.seq) currentTurn = undefined
3030
+ const investigationId = currentTurn ? `turn:${currentTurn.turn}`
3031
+ : lastInput ? `message:${lastInput[0]}` : entries.length ? `node:${entries.at(-1)[0]}` : ''
3032
+ const belongsToCurrentTurn = (node) => {
3033
+ if (!currentTurn) return true
3034
+ const owner = node?.location?.turn?.turn ?? node?.data?.turn ?? node?.data?.root?.turn
3035
+ if (owner !== undefined) return owner === currentTurn.turn
3036
+ const seq = node?.anchorSeq ?? node?.data?.seq ?? node?.data?.root?.seq
3037
+ return Number.isFinite(seq) && seq >= (currentTurn.start?.seq ?? Infinity)
3038
+ && seq <= (currentTurn.end?.seq ?? Infinity)
3039
+ }
3040
+ let turnSourceStart = 0
3041
+ const turnSources = []
3042
+ let searching = false
3043
+ let question = ''
3044
+ let query = ''
3045
+ let tool = ''
3046
+ let answer = ''
3047
+ let error = ''
3048
+ let interrupted = false
3049
+ let terminalError = false
3050
+ let assistantRunning = false
3051
+ let activeArgs = null
3052
+ const cited = new Set()
3053
+ const wikiCitations = new Map()
3054
+ const toolErrors = new Map()
3055
+ const citations = (key, material, text) => {
3056
+ if (!cache) return citedArchiveIds(material, text, wikiCitations)
3057
+ const input = retainArray('citation-input:' + key, material)
3058
+ const wiki = retain('wiki-input:' + key, [...wikiCitations].map(([id, values]) => [id, [...values]]))
3059
+ const candidates = memo('citation-index:' + key, [input], () => rhineCitationIndex(input), 'citation')
3060
+ return memo('citations:' + key, [candidates, wiki, text], () => citedArchiveIds(input, text, wikiCitations, candidates), 'citation')
3061
+ }
3062
+ for (const [key, node] of entries) {
3063
+ if (node?.kind === 'user' || node?.kind === 'steering') {
3064
+ const text = (node.data?.content || []).filter((block) => block?.type === 'text')
3065
+ .map((block) => block.text || '').join('\n').trim()
3066
+ question = text
3067
+ // New user input starts a new answer; keep the durable material rack.
3068
+ answer = ''; error = ''; assistantRunning = false; toolErrors.clear()
3069
+ interrupted = false; terminalError = false
3070
+ // With a real Turn location, steering belongs to the same ongoing
3071
+ // investigation. Older renderers fall back to the last input boundary.
3072
+ if (!currentTurn) { turnSourceStart = sources.length; operations.length = 0; toolCalls.length = 0; operationSources.clear() }
3073
+ if (node.kind === 'user' || !currentTurn) { searching = false; activeArgs = null }
3074
+ if (!searching) { query = ''; tool = '' }
3075
+ continue
3076
+ }
3077
+ if (node?.kind === 'assistant-step') {
3078
+ const text = visibleAssistantText(node.data?.blocks)
3079
+ const current = belongsToCurrentTurn(node)
3080
+ if (current) assistantRunning = node.data?.status === 'running'
3081
+ if (text) {
3082
+ if (current) answer = text
3083
+ for (const id of citations('assistant:' + key, sources, text)) cited.add(id)
3084
+ }
3085
+ if (current && node.data?.status === 'interrupted') { error = '调查已停止'; interrupted = true }
3086
+ continue
3087
+ }
3088
+ if (node?.kind === 'turn-error' || node?.kind === 'turn-max-tokens') {
3089
+ if (!belongsToCurrentTurn(node)) continue
3090
+ error = node.data?.message || (node.kind === 'turn-max-tokens' ? '回答达到输出上限,调查未完成' : '调查未完成')
3091
+ terminalError = true
3092
+ assistantRunning = false
3093
+ continue
3094
+ }
3095
+ if (node?.kind !== 'tool-call') continue
3096
+ const root = node.data?.root
3097
+ const projection = memo('tool:' + key, [root], () => {
3098
+ const call = toolSnapshot(root)
3099
+ const meta = rhineToolPresentation(root)
3100
+ if ((typeof meta?.error === 'string' && meta.error || call?.name === 'web_fetch' && Number(root?.meta?.statusCode) >= 400) && call) call.state = 'error'
3101
+ return { call, meta, args: parseToolArguments(call?.argsRaw),
3102
+ text: root?.kind === 'tool-result' ? contentText(root.content) : '' }
3103
+ }, 'tool')
3104
+ const { call, meta, args, text } = projection
3105
+ if (!call?.name) continue
3106
+ const locator = args.locator && typeof args.locator === 'object' ? args.locator : args
3107
+ const currentQuery = String(args.query || args.title || locator.display_title || locator.document_id
3108
+ || locator.document_uid || locator.source_ref || args.url
3109
+ || (Array.isArray(args.queries) ? args.queries.filter(item => typeof item === 'string').join(' · ') : '')
3110
+ || args.activity_name || args.collection_name || args.section || '')
3111
+ const current = belongsToCurrentTurn(node)
3112
+ records.push(memo('record:' + key, [call, currentQuery, text], () =>
3113
+ ({ id: String(key), tool: call.name, state: call.state, query: currentQuery, text }), 'tool'))
3114
+ if (current) {
3115
+ const startedAt = root.kind === 'tool-result' ? root.callTime : root.time
3116
+ const completedAt = root.kind === 'tool-result' ? root.time : undefined
3117
+ toolCalls.push({ id: String(key), tool: call.name, state: call.state,
3118
+ query: currentQuery.slice(0, 240),
3119
+ ...(Number.isFinite(startedAt) ? { startedAt } : {}),
3120
+ ...(Number.isFinite(completedAt) ? { completedAt } : {}) })
3121
+ }
3122
+ // Corpus and web material both enter the archive; unrelated tools remain in the HUD.
3123
+ if (!PRTS_SCENE_TOOLS.includes(call.name) && !isRhineWebTool(call.name)) continue
3124
+ if (current) {
3125
+ const requestedVersion = args.data_version || args.dataVersion || locator.data_version || locator.dataVersion
3126
+ const startedAt = root.kind === 'tool-result' ? root.callTime : root.time
3127
+ const completedAt = root.kind === 'tool-result' ? root.time : undefined
3128
+ operations.push({ id: String(key), tool: call.name, kind: isRhineReadTool(call.name) ? 'read' : 'search',
3129
+ state: call.state, sourceIds: [], ...(currentQuery ? { query: currentQuery } : {}),
3130
+ ...(call.name === 'web_fetch' && rhineWebUrl(args.url) ? { url: rhineWebUrl(args.url) } : {}),
3131
+ ...(typeof locator.document_id === 'string' && locator.document_id ? { documentId: locator.document_id } : {}),
3132
+ ...(typeof locator.document_uid === 'string' && locator.document_uid ? { documentUid: locator.document_uid } : {}),
3133
+ ...(typeof locator.source_ref === 'string' && locator.source_ref ? { sourceRef: locator.source_ref } : {}),
3134
+ ...(validDataVersion(requestedVersion) ? { dataVersion: requestedVersion } : {}),
3135
+ ...(Number.isFinite(startedAt) ? { startedAt } : {}),
3136
+ ...(Number.isFinite(completedAt) ? { completedAt } : {}) })
3137
+ }
3138
+ if (current && call.state === 'active' && !node.location?.turn?.end) {
3139
+ searching = true
3140
+ // PTC can run reads and searches together. Keep the reading operation
3141
+ // as the visual focus instead of letting a later search in tree order
3142
+ // hide its cassette animation or overwrite its document locator.
3143
+ if (!activeArgs || isRhineReadTool(call.name) || !isRhineReadTool(tool)) {
3144
+ query = currentQuery
3145
+ tool = call.name
3146
+ activeArgs = args
3147
+ }
3148
+ } else if (current && !searching) { query = currentQuery || query; tool = call.name }
3149
+ if (call.state === 'error') {
3150
+ if (current) toolErrors.set(call.name, meta?.error || text.slice(0, 600) || `${call.name} 执行失败`)
3151
+ continue
3152
+ }
3153
+ if (call.state !== 'complete') continue
3154
+ if (current) toolErrors.delete(call.name)
3155
+ const sourceStart = sources.length
3156
+ if (isRhineWebTool(call.name)) {
3157
+ const prior = call.name === 'web_fetch' ? retainArray('web-prior:' + key, sources.filter(source => source.origin === 'web')) : undefined
3158
+ sources.push(...memo('web-sources:' + key, [call.name, args, root.meta, text, prior],
3159
+ () => rhineWebSources(call.name, args, root.meta, text, String(key), prior || []), 'material'))
3160
+ }
3161
+ if (meta?.kind === 'prts-archive-sources-v1' && Array.isArray(meta.sources)) {
3162
+ sources.push(...memo('delivered:' + key, [meta.sources], () => meta.sources.slice(0, 128)
3163
+ .map(source => ({ ...source, callId: String(key) })), 'material'))
3164
+ }
3165
+ if (call.name === 'corpus_read') {
3166
+ const delivered = memo('read-sources:' + key, [meta, text], () => {
3167
+ const read = readPresentation(meta)
3168
+ const readSources = read ? read.sources.length ? read.sources : [read] : []
3169
+ const material = [], wiki = []
3170
+ for (const source of readSources) {
3171
+ const id = `document:${source.dataVersion}:${source.documentUid || source.documentId || source.sourceRef}`
3172
+ for (const citation of text.matchAll(/^引用:《([^》\n]+)》Wiki·([^\s\n]+)$/gmu)) {
3173
+ if (citation[1] === (source.title || read.title)) wiki.push([id, citation[2]])
3174
+ }
3175
+ material.push({ ...source, id,
3176
+ title: source.title || read.title || '原文资料', kind: 'corpus_read',
3177
+ origin: 'local', state: 'read', callId: String(key),
3178
+ sourceRef: source.sourceRef || (read.sources.length <= 1 ? read.sourceRef : ''),
3179
+ readRanges: source.lineStart && source.lineEnd ? [{ start: source.lineStart, end: source.lineEnd }] : [],
3180
+ excerpt: read.sources.length > 1
3181
+ ? `Agent 已读取《${source.title}》第 ${source.lineStart}–${source.lineEnd} 行;完整返回内容见检索记录。`
3182
+ : text.slice(0, 1600) })
3183
+ }
3184
+ return { material, wiki }
3185
+ }, 'material')
3186
+ sources.push(...delivered.material)
3187
+ for (const [id, field] of delivered.wiki) {
3188
+ if (!wikiCitations.has(id)) wikiCitations.set(id, new Set())
3189
+ wikiCitations.get(id).add(field)
3190
+ }
3191
+ }
3192
+ if (current) {
3193
+ const delivered = sources.slice(sourceStart)
3194
+ operationSources.set(String(key), delivered)
3195
+ if (currentTurn) turnSources.push(...delivered)
3196
+ }
3197
+ }
3198
+ const sourceInput = retainArray('source-input', sources)
3199
+ const mergedMaterials = memo('merge-materials', [sourceInput], () => mergeArchiveSources(sourceInput), 'material')
3200
+ const citedInput = retain('cited-input', [...cited])
3201
+ const materials = memo('materials', [mergedMaterials, citedInput], () => mergedMaterials.map(source => cited.has(source.id)
3202
+ ? { ...source, state: 'cited' } : source), 'material')
3203
+ error = error || [...toolErrors.values()].at(-1) || ''
3204
+ const end = currentTurn?.end
3205
+ const endKind = end?.type === 'turn/end' ? end.data?.reason?.kind : undefined
3206
+ if (interrupted || terminalError) { searching = false; assistantRunning = false }
3207
+ let outcome = terminalError ? 'error' : interrupted ? 'interrupted'
3208
+ : sessionRunning || searching || assistantRunning ? 'running' : 'unknown'
3209
+ if (endKind) {
3210
+ // A finished turn closes its tools too. A stale streamed node must not
3211
+ // keep animating a cancelled or completed investigation after its end.
3212
+ searching = false; assistantRunning = false
3213
+ if (endKind === 'completed') { outcome = 'completed'; error = '' }
3214
+ else if (endKind === 'aborted' || endKind === 'interrupted') { outcome = 'interrupted'; error = '调查已停止' }
3215
+ else if (endKind === 'error' || endKind === 'max-tokens' || endKind === 'blocked') {
3216
+ outcome = 'error'
3217
+ error = end.data.reason.error?.message || (endKind === 'max-tokens'
3218
+ ? '回答达到输出上限,调查未完成' : endKind === 'blocked' ? '调查被阻止,尚未完成' : error || '调查未完成')
3219
+ } else { outcome = 'unknown' }
3220
+ }
3221
+ // Keep one operation per invocation instead of inferring activity from a
3222
+ // source's highest-ever state. A second read of the same ranges is still
3223
+ // a new operation, even though it makes no change to the material rack.
3224
+ const sourceAliases = (source, includeRef = true) => {
3225
+ const version = validDataVersion(source.dataVersion) ? source.dataVersion : ''
3226
+ return [['id', source.id], ['uid', source.documentUid], ['document', source.documentId],
3227
+ ...(includeRef ? [['ref', source.sourceRef]] : [])]
3228
+ .flatMap(([kind, value]) => value ? [JSON.stringify([version, kind, value])] : [])
3229
+ }
3230
+ const materialIds = memo('material-aliases', [mergedMaterials], () => {
3231
+ const result = new Map()
3232
+ for (const source of mergedMaterials) {
3233
+ for (const alias of sourceAliases(source)) {
3234
+ if (!result.has(alias)) result.set(alias, new Set())
3235
+ result.get(alias).add(source.id)
3236
+ }
3237
+ }
3238
+ return result
3239
+ }, 'material')
3240
+ const deliveredMaterialId = (source) => {
3241
+ // A shared source_ref can cover several documents. Prefer the first
3242
+ // matching exact identity, never union a weaker alias into a receipt.
3243
+ const precise = Boolean(source.documentUid || source.documentId)
3244
+ for (const alias of sourceAliases(source, !precise)) {
3245
+ const matches = materialIds.get(alias)
3246
+ if (matches?.size) return matches.size === 1 ? [...matches] : []
3247
+ }
3248
+ return []
3249
+ }
3250
+ for (const call of toolCalls) {
3251
+ if (call.state === 'active' && (endKind || interrupted || terminalError)) {
3252
+ call.state = 'error'
3253
+ const index = records.findIndex(record => record.id === call.id)
3254
+ if (index >= 0) records[index] = { ...records[index], state: 'error' }
3255
+ }
3256
+ }
3257
+ for (const operation of operations) {
3258
+ if (operation.state === 'active' && (endKind || interrupted || terminalError)) operation.state = 'error'
3259
+ const delivered = (operationSources.get(operation.id) || []).filter(source => operation.kind !== 'read'
3260
+ || source.agentRead === true || source.state === 'read' || source.readRanges?.length > 0)
3261
+ operation.sourceIds = [...new Set(delivered.flatMap(deliveredMaterialId))]
3262
+ }
3263
+ // Report citations are recomputed from this answer only. Historical
3264
+ // citations still mark the cumulative rack, but never enter a new report.
3265
+ const reportIds = citations('report', materials, answer)
3266
+ const reportInput = retainArray('report-ids', [...reportIds])
3267
+ const reportSources = memo('report-sources', [materials, reportInput], () => materials.filter(source => reportIds.has(source.id))
3268
+ .map(source => ({ ...source, state: 'cited' })), 'material')
3269
+ const turnInput = retainArray('turn-input', currentTurn ? turnSources : sources.slice(turnSourceStart))
3270
+ const mergedTurn = memo('merge-turn', [turnInput], () => mergeArchiveSources(turnInput), 'material')
3271
+ const currentSources = memo('current-sources', [mergedTurn, reportInput], () => mergedTurn
3272
+ .map(source => reportIds.has(source.id) ? { ...source, state: 'cited' } : source), 'material')
3273
+ const activeLocator = activeArgs?.locator || activeArgs || {}
3274
+ let activeDocumentId = typeof activeLocator.document_id === 'string' ? activeLocator.document_id : ''
3275
+ const activeSourceRef = typeof activeLocator.source_ref === 'string' ? activeLocator.source_ref : ''
3276
+ const requestedVersion = activeArgs?.data_version || activeArgs?.dataVersion
3277
+ || activeLocator.data_version || activeLocator.dataVersion
3278
+ const activeDataVersion = validDataVersion(requestedVersion) ? requestedVersion : ''
3279
+ if (!activeDocumentId && isRhineReadTool(tool)) {
3280
+ const hits = materials.filter((source) => activeLocator.document_uid
3281
+ ? source.documentUid === activeLocator.document_uid
3282
+ : activeSourceRef ? source.sourceRef === activeSourceRef
3283
+ : (activeLocator.title || activeLocator.display_title)
3284
+ && source.title === (activeLocator.title || activeLocator.display_title))
3285
+ .filter((source) => !activeDataVersion || source.dataVersion === activeDataVersion)
3286
+ if (hits.length === 1) activeDocumentId = hits[0].documentId || ''
3287
+ }
3288
+ const snapshot = { sessionId, running: !endKind && !interrupted && !terminalError && Boolean(sessionRunning || searching || assistantRunning || toolCalls.some(call => call.state === 'active')), searching, question, query, tool,
3289
+ investigationId, outcome, turnSources: currentSources, reportSources,
3290
+ ...(outcome === 'completed' && Number.isFinite(end?.time) ? { completedAt: end.time } : {}),
3291
+ sources: materials, answer, records, operations, toolCalls, ...(error ? { error } : {}),
3292
+ ...(searching && activeDocumentId ? { activeDocumentId } : {}),
3293
+ ...(searching && activeSourceRef ? { activeSourceRef } : {}),
3294
+ ...(searching && activeDataVersion ? { activeDataVersion } : {}) }
3295
+ return retain('output', { ...snapshot, phase: rhinePhase(snapshot) })
3296
+ }
3297
+ // Each mounted controller owns bounded counters; no snapshot data is exported.
3298
+ function createRhineSnapshotDiagnostics() {
3299
+ let monitor
3300
+ const counts = Object.fromEntries(['host-snapshot-selector', 'host-snapshot-build',
3301
+ 'host-snapshot-serialize', 'host-snapshot-parse', 'host-snapshot-merge',
3302
+ 'host-snapshot-derive', 'host-snapshot-input'].map(kind => [kind, 0]))
3303
+ const cache = Object.fromEntries(['input', 'tool', 'material', 'citation', 'serialize', 'sourceMerge']
3304
+ .flatMap(kind => [[kind + 'Hits', 0], [kind + 'Misses', 0]]))
3305
+ return {
3306
+ cacheEvent(kind, hit) {
3307
+ const key = kind + (hit ? 'Hits' : 'Misses')
3308
+ if (Object.hasOwn(cache, key)) cache[key]++
3309
+ },
3310
+ measure(kind, task) {
3311
+ if (!Object.prototype.hasOwnProperty.call(counts, kind)) return task()
3312
+ counts[kind]++
3313
+ const done = monitor?.(kind)
3314
+ try { return task() } finally { done?.() }
3315
+ },
3316
+ setPerformanceMonitor(value) { monitor = value },
3317
+ performanceStats() { return { counts: { ...counts }, cache: { ...cache } } },
3318
+ }
3319
+ }
3320
+ const rhineSnapshotSignature = (snapshot, diagnostics) => {
3321
+ const measure = (kind, task) => diagnostics ? diagnostics.measure(kind, task) : task()
3322
+ return measure('host-snapshot-selector', () => {
3323
+ const value = measure('host-snapshot-build', () => buildRhineSnapshot(snapshot?.order, snapshot?.nodes, '', false, snapshot?.timeline))
3324
+ return measure('host-snapshot-serialize', () => JSON.stringify(value))
3325
+ })
3326
+ }
3327
+
3328
+ // Preserve the host's string selector contract, but serialize only changed
3329
+ // fields and let React consume the already-built detached value. A stale
3330
+ // concurrent render can still parse its own string, never a newer snapshot.
3331
+ function createRhineSnapshotSelector(diagnostics) {
3332
+ const measure = (kind, task) => diagnostics ? diagnostics.measure(kind, task) : task()
3333
+ const cache = createRhineProjectionCache(diagnostics)
3334
+ const fields = new Map()
3335
+ let input, value, serialized
3336
+ const select = snapshot => measure('host-snapshot-selector', () => {
3337
+ const nextInput = measure('host-snapshot-input', () => retainRhineValue(rhineSnapshotInput(snapshot), input))
3338
+ if (nextInput === input) {
3339
+ diagnostics?.cacheEvent('input', true)
3340
+ return serialized
3341
+ }
3342
+ diagnostics?.cacheEvent('input', false)
3343
+ cache.begin()
3344
+ cache.entries = nextInput.entries
3345
+ let next
3346
+ try {
3347
+ const timeline = { turnOrder: [nextInput.turnNumber], turns: new Map([[nextInput.turnNumber, nextInput.turn]]) }
3348
+ next = measure('host-snapshot-build', () => buildRhineSnapshot([], null, '', false, timeline, cache))
3349
+ } finally { cache.end(); cache.entries = undefined }
3350
+ const signature = measure('host-snapshot-serialize', () => {
3351
+ const parts = [], keys = new Set(Object.keys(next))
3352
+ for (const key of fields.keys()) if (!keys.has(key)) fields.delete(key)
3353
+ for (const [key, item] of Object.entries(next)) {
3354
+ let field = fields.get(key)
3355
+ const hit = Boolean(field && Object.is(field.value, item))
3356
+ diagnostics?.cacheEvent('serialize', hit)
3357
+ if (!hit) { field = { value: item, text: JSON.stringify(item) }; fields.set(key, field) }
3358
+ if (field.text !== undefined) parts.push(JSON.stringify(key) + ':' + field.text)
3359
+ }
3360
+ return '{' + parts.join(',') + '}'
3361
+ })
3362
+ input = nextInput; value = next; serialized = signature
3363
+ return serialized
3364
+ })
3365
+ select.read = signature => signature === serialized && value ? value
3366
+ : measure('host-snapshot-parse', () => signature ? JSON.parse(signature) : buildRhineSnapshot([], null))
3367
+ return select
3368
+ }
3369
+
3370
+ // Both resources are staged together and retired on HMR. Failed loads are retryable.
3371
+ const loadRhineBundle = () => {
3372
+ if (rhineBundlePromise) return rhineBundlePromise
3373
+ const owner = { elements: [], cancel: null }
3374
+ rhineBundleOwner = owner
3375
+ const load = (kind, path) => new Promise((resolve, reject) => {
3376
+ const element = document.createElement(kind === 'script' ? 'script' : 'link')
3377
+ if (kind === 'script') { element.src = RHINE_BASE + path; element.async = true }
3378
+ else { element.rel = 'stylesheet'; element.href = RHINE_BASE + path }
3379
+ element.dataset.plugin = 'prts-terrarchive'
3380
+ element.dataset.rhineResource = path
3381
+ owner.elements.push(element)
3382
+ const abort = () => finish(makeAbortError('资料馆资源加载已取消'))
3383
+ const timer = setTimeout(() => finish(new Error('资料馆资源加载超时,请重试')), 15000)
3384
+ const signal = clientAbortController?.signal
3385
+ const finish = (error) => {
3386
+ clearTimeout(timer)
3387
+ element.onload = null; element.onerror = null
3388
+ signal?.removeEventListener('abort', abort)
3389
+ if (error) { element.remove(); reject(error) } else resolve()
3390
+ }
3391
+ signal?.addEventListener('abort', abort, { once: true })
3392
+ element.onload = () => finish()
3393
+ element.onerror = () => finish(new Error(`资料馆资源加载失败:${path}`))
3394
+ if (signal?.aborted) abort()
3395
+ else document.head.appendChild(element)
3396
+ })
3397
+ rhineBundlePromise = Promise.all([load('style', 'rhine.css'), load('script', 'rhine.js')])
3398
+ .then(() => {
3399
+ const runtime = globalThis.__PRTS_RHINE__
3400
+ if (rhineBundleOwner !== owner) throw makeAbortError('资料馆已被新实例替换')
3401
+ if (runtime?.RUNTIME_ABI !== 1 || typeof runtime.mountRhineWorkbench !== 'function') {
3402
+ throw new Error('资料馆资源版本不兼容,请刷新页面')
3403
+ }
3404
+ return runtime
3405
+ }).catch((error) => {
3406
+ for (const element of owner.elements) element.remove()
3407
+ if (rhineBundleOwner === owner) { rhineBundleOwner = null; rhineBundlePromise = null }
3408
+ throw error
3409
+ })
3410
+ return rhineBundlePromise
3411
+ }
3412
+ const disposeRhine = () => {
3413
+ for (const cleanup of [...rhineCleanups]) cleanup()
3414
+ for (const element of rhineBundleOwner?.elements || []) element.remove()
3415
+ rhineBundleOwner = null
3416
+ rhineBundlePromise = null
3417
+ }
3418
+ async function submitRhineQuestion(ctx, sessionId, text, signal) {
3419
+ if (!text?.trim()) return
3420
+ const session = ctx?.sessions?.binding?.(sessionId)?.session
3421
+ if (!session?.prompt || !session?.beginSubmission) throw new Error('当前会话尚未连接,请返回对话后重试')
3422
+ const submission = session.beginSubmission({ mode: 'queue', text, attachments: [] })
3423
+ try {
3424
+ const result = await session.prompt([{ type: 'text', text }], 'queue', signal, submission.requestId)
3425
+ if (!result?.ok) throw new Error(result?.error?.message || 'Agent 未接受问题,请重试')
3426
+ } catch (error) { submission.abandon(); throw error }
3427
+ }
3428
+ // The workbench uses the same public session and model services as DSH's
3429
+ // sidebar/composer. Each controller is owned by one open archive session.
3430
+ function createRhineHostControls(ctx, sessionId) {
3431
+ const sessions = ctx.sessions
3432
+ const session = sessions.binding(sessionId)?.session
3433
+ const directory = ctx.modelDirectories?.directoryFor(sessionId)
3434
+ const listeners = new Set()
3435
+ let disposed = false
3436
+ let busy = false
3437
+ let loading = false
3438
+ let error = ''
3439
+ let modes = []
3440
+ let refreshTask = null
3441
+ let pendingCreated = null
3442
+ const cleanups = []
3443
+ let monitor
3444
+ let frame = null, timer = null
3445
+ const metrics = { notifications: 0, coalesced: 0, flushes: 0, stateReads: 0, emitted: 0, deduplicated: 0,
3446
+ sources: { sessions: 0, workspaces: 0, session: 0, models: 0 } }
3447
+ const cancelPending = () => {
3448
+ if (frame !== null) window.cancelAnimationFrame(frame)
3449
+ if (timer !== null) clearTimeout(timer)
3450
+ frame = timer = null
3451
+ }
3452
+ const modelKey = (selection) => selection ? JSON.stringify([selection.provider, selection.model]) : ''
3453
+ const workspaceState = () => ctx.workspaces?.list.getSnapshot() || { items: [], archivedSessionIds: [] }
3454
+ const workspaceOf = (id) => workspaceState().items.find(item => item.sessionIds?.includes(id))
3455
+ const summaryOf = () => sessions.list.getSnapshot().byId[sessionId]
3456
+ const modelOptions = () => (directory?.store.getSnapshot().groups || []).flatMap(group =>
3457
+ (group.models || []).map(model => ({
3458
+ id: modelKey({ provider: group.id, model: model.id }),
3459
+ name: `${model.name || model.id} · ${group.name || group.id}`,
3460
+ provider: group.id, model: model.id, reasoning: model.reasoning,
3461
+ })))
3462
+ const projectState = () => {
3463
+ const list = sessions.list.getSnapshot()
3464
+ const current = list.byId[sessionId]
3465
+ const lifecycle = session?.getSnapshot?.()
3466
+ const workspaces = workspaceState()
3467
+ const archived = new Set(workspaces.archivedSessionIds || [])
3468
+ const modelState = directory?.store.getSnapshot()
3469
+ const currentModel = modelState?.current
3470
+ const choices = modelOptions().map(({ id, name }) => ({ id, name }))
3471
+ // Catalog membership is advisory; an existing routable model may no
3472
+ // longer be advertised. Preserve its actual selection in the picker.
3473
+ if (currentModel && !choices.some(item => item.id === modelKey(currentModel))) {
3474
+ choices.unshift({ id: modelKey(currentModel), name: `${currentModel.model} · ${currentModel.provider}` })
3475
+ }
3476
+ const workspace = workspaceOf(sessionId)
3477
+ return {
3478
+ sessionId, title: current?.title || (current?.blank ? '新建对话' : current?.displayTitle) || '当前对话',
3479
+ workspace: workspace?.title || workspace?.path || current?.cwd || '',
3480
+ blank: Boolean(lifecycle?.blank ?? current?.blank),
3481
+ running: Boolean(lifecycle?.running ?? current?.running),
3482
+ busy: busy || modelState?.status === 'selecting', loading,
3483
+ canSubmit: modelState?.routable !== false && !lifecycle?.removed,
3484
+ error: error || (modelState?.routable === false ? '当前模型没有可用连接,请选择其他模型。' : modelState?.error || ''),
3485
+ mode: typeof current?.projectionValues?.agentPreset === 'string' ? current.projectionValues.agentPreset : '',
3486
+ modes, model: modelKey(currentModel), models: choices,
3487
+ sessions: list.ids.map(id => list.byId[id]).filter(item => item && !archived.has(item.id) && item.origin !== 'subagent')
3488
+ .map(item => {
3489
+ const owner = workspaces.items.find(workspace => workspace.sessionIds?.includes(item.id))
3490
+ return { id: item.id, title: item.title || (item.blank ? '新建对话' : item.displayTitle) || '未命名对话',
3491
+ workspace: owner?.title || owner?.path || item.cwd || '',
3492
+ running: Boolean(item.running), updatedAt: item.updatedAt || 0 }
3493
+ }),
3494
+ }
3495
+ }
3496
+ const getState = () => {
3497
+ metrics.stateReads++
3498
+ const done = monitor?.('host-state-project')
3499
+ try { return projectState() } finally { done?.() }
3500
+ }
3501
+ // The history UI displays a date, not per-token timestamp changes. Preserve
3502
+ // ordering, names and all actionable fields; never compare only references.
3503
+ const displaySignature = state => JSON.stringify({ ...state, sessions: state.sessions.map(item => ({
3504
+ ...item, updatedAt: Number.isFinite(item.updatedAt) && item.updatedAt > 0 ? new Date(item.updatedAt).toLocaleDateString('zh-CN') : '',
3505
+ })) })
3506
+ let publishedSignature = displaySignature(getState())
3507
+ const emit = () => {
3508
+ cancelPending()
3509
+ if (disposed) return
3510
+ metrics.flushes++
3511
+ const done = monitor?.('host-notify')
3512
+ try {
3513
+ const state = getState(), signature = displaySignature(state)
3514
+ if (signature === publishedSignature) { metrics.deduplicated++; return }
3515
+ publishedSignature = signature; metrics.emitted++
3516
+ for (const listener of listeners) listener(state)
3517
+ } finally { done?.() }
3518
+ }
3519
+ const requestEmit = source => {
3520
+ if (disposed) return
3521
+ metrics.notifications++; metrics.sources[source]++
3522
+ if (!listeners.size) return
3523
+ if (frame !== null || timer !== null) { metrics.coalesced++; return }
3524
+ if (typeof window.requestAnimationFrame === 'function') frame = window.requestAnimationFrame(emit)
3525
+ // RAF may be suspended in a background tab; current state still arrives.
3526
+ timer = setTimeout(emit, frame === null ? 16 : 100)
3527
+ }
3528
+ const assertActive = () => {
3529
+ if (disposed || clientContext !== ctx || sessions.list.getSnapshot().current !== sessionId) {
3530
+ throw makeAbortError('会话已切换,请在当前会话重试')
3531
+ }
3532
+ }
3533
+ const unwrap = (result, label) => {
3534
+ if (!result?.ok) throw new Error(result?.error?.message || `${label}失败`)
3535
+ return result.value
3536
+ }
3537
+ const run = async (action) => {
3538
+ assertActive()
3539
+ if (busy || directory?.store.getSnapshot().status === 'selecting') throw new Error('上一项设置正在保存,请稍候')
3540
+ busy = true; error = ''; emit()
3541
+ try { await action() }
3542
+ catch (caught) {
3543
+ if (!disposed && caught?.name !== 'AbortError') error = caught?.message || '操作失败,请重试'
3544
+ throw caught
3545
+ } finally { busy = false; emit() }
3546
+ }
3547
+ const refresh = () => {
3548
+ if (disposed) return Promise.resolve()
3549
+ if (refreshTask) return refreshTask
3550
+ loading = true; error = ''; emit()
3551
+ refreshTask = (async () => {
3552
+ const results = await Promise.allSettled([
3553
+ waitWithDeadline(ctx.remote.agentPresets.list(), { timeout: 12000, label: '模式列表' }),
3554
+ waitWithDeadline(sessions.refresh(), { timeout: 12000, label: '会话列表' }),
3555
+ directory ? waitWithDeadline(directory.load(), { timeout: 12000, label: '模型列表' }) : Promise.resolve(),
3556
+ ])
3557
+ if (disposed) return
3558
+ const roster = results[0]
3559
+ if (roster.status === 'fulfilled') {
3560
+ try {
3561
+ const value = unwrap(roster.value, '读取模式')
3562
+ modes = (value.presets || []).filter(item => !item.broken)
3563
+ .map(item => ({ id: item.id, name: item.name || item.id, description: item.description || '' }))
3564
+ } catch (caught) { error = caught.message }
3565
+ }
3566
+ const failures = results.filter(result => result.status === 'rejected')
3567
+ .map(result => result.reason?.message || '读取会话设置失败')
3568
+ if (failures.length) error = [error, ...failures].filter(Boolean).join(';')
3569
+ })().finally(() => { loading = false; refreshTask = null; emit() })
3570
+ return refreshTask
3571
+ }
3572
+ const requireMode = (id) => {
3573
+ if (!id || !modes.some(mode => mode.id === id)) throw new Error('该模式暂不可用,请刷新模式列表')
3574
+ }
3575
+ const createSession = (requestedMode) => run(async () => {
3576
+ const original = summaryOf()
3577
+ const mode = requestedMode || original?.projectionValues?.agentPreset
3578
+ if (requestedMode) requireMode(requestedMode)
3579
+ // Inherit the effective selection without making a new conversation
3580
+ // depend on the advisory model catalog being reachable. With no explicit
3581
+ // selection, the new session uses the same Host default.
3582
+ const model = directory?.store.getSnapshot().current || original?.projectionValues?.modelSelection?.next
3583
+ const workspace = workspaceOf(sessionId)
3584
+ const target = workspace ? { workspaceId: workspace.workspaceId }
3585
+ : original?.cwd ? { cwd: original.cwd } : {}
3586
+ const selection = model ? { provider: model.provider, model: model.model,
3587
+ ...(model.reasoningEffort === undefined ? {} : { reasoningEffort: model.reasoningEffort }) } : null
3588
+ const targetKey = JSON.stringify([target, mode || null, selection])
3589
+ if (pendingCreated) {
3590
+ const pendingSummary = sessions.list.getSnapshot().byId[pendingCreated.id]
3591
+ const pendingState = pendingSummary ? sessions.binding(pendingCreated.id)?.session.getSnapshot() : null
3592
+ if (pendingCreated.targetKey !== targetKey || !pendingSummary || !pendingSummary.blank
3593
+ || pendingSummary.running || pendingState?.blank === false || pendingState?.running
3594
+ || pendingState?.removed || workspaceState().archivedSessionIds.includes(pendingCreated.id)) {
3595
+ // This entry may now be somebody's active conversation. Forget the
3596
+ // retry handle without deleting or rewriting any existing session.
3597
+ pendingCreated = null
3598
+ }
3599
+ }
3600
+ if (!pendingCreated) {
3601
+ const id = await sessions.create(target)
3602
+ pendingCreated = { id, targetKey, mode, model: selection }
3603
+ }
3604
+ const pending = pendingCreated
3605
+ const id = pending.id
3606
+ try {
3607
+ assertActive()
3608
+ if (pending.mode) unwrap(await ctx.remote.agentPresets.select(id, pending.mode), '设置新对话模式')
3609
+ assertActive()
3610
+ if (pending.model) await ctx.modelDirectories.directoryFor(id).select({ ...pending.model })
3611
+ assertActive()
3612
+ sessions.open(id)
3613
+ if (pendingCreated === pending) pendingCreated = null
3614
+ } catch (caught) {
3615
+ // Keep the old conversation selected until the new one is fully
3616
+ // configured. Retrying the same selection reuses this blank entry.
3617
+ if (caught?.name !== 'AbortError') throw new Error(`新对话已创建,但尚未打开:${caught?.message || '配置失败'}。再次执行相同操作将重试此对话。`)
3618
+ throw caught
3619
+ }
3620
+ })
3621
+ for (const [source, store] of [['sessions', sessions.list], ['workspaces', ctx.workspaces?.list], ['session', session], ['models', directory?.store]]) {
3622
+ if (store?.subscribe) cleanups.push(store.subscribe(() => requestEmit(source)))
3623
+ }
3624
+ const controls = {
3625
+ getState, refresh, createSession,
3626
+ setPerformanceMonitor(value) { monitor = value },
3627
+ performanceStats() { return { ...metrics, sources: { ...metrics.sources }, pending: frame !== null || timer !== null } },
3628
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener) },
3629
+ openSession: (id) => run(async () => {
3630
+ if (!getState().sessions.some(item => item.id === id)) throw new Error('该对话已不可用,请刷新会话列表')
3631
+ sessions.open(id)
3632
+ }),
3633
+ selectMode: (id) => run(async () => {
3634
+ requireMode(id)
3635
+ const current = getState()
3636
+ if (!current.blank || current.running) throw new Error('当前对话已经开始,请使用所选模式新建对话')
3637
+ unwrap(await ctx.remote.agentPresets.select(sessionId, id), '切换模式')
3638
+ assertActive()
3639
+ await sessions.refresh()
3640
+ }),
3641
+ selectModel: (id) => run(async () => {
3642
+ if (!directory) throw new Error('模型目录尚未就绪')
3643
+ const choice = modelOptions().find(item => item.id === id)
3644
+ if (!choice) throw new Error('该模型暂不可用,请刷新模型列表')
3645
+ const current = directory.store.getSnapshot().current
3646
+ const effort = current?.reasoningEffort
3647
+ const reasoningEffort = choice.reasoning?.efforts?.some(item => item.id === effort) ? effort : choice.reasoning?.defaultEffort
3648
+ await directory.select({ provider: choice.provider, model: choice.model,
3649
+ ...(reasoningEffort ? { reasoningEffort } : {}) })
3650
+ }),
3651
+ cancel: () => run(async () => {
3652
+ if (!session?.cancel) throw new Error('当前会话尚未连接')
3653
+ unwrap(await session.cancel(), '停止当前任务')
3654
+ }),
3655
+ dispose() { disposed = true; cancelPending(); monitor = undefined; pendingCreated = null; for (const cleanup of cleanups) cleanup(); listeners.clear() },
3656
+ }
3657
+ return controls
3658
+ }
3659
+ function RhineControl({ useChat, useSession, sessionId }) {
3660
+ const [skin, setSkinValue] = useState(activeSkin)
3661
+ const [open, setOpen] = useState(activeSkin === 'rhine-lab')
3662
+ const trigger = useRef(null)
3663
+ const workbench = useRef(null)
3664
+ const previousSources = useRef({ sessionId, sources: [] })
3665
+ const snapshotDiagnostics = useMemo(() => createRhineSnapshotDiagnostics(), [sessionId])
3666
+ const snapshotSelector = useMemo(() => createRhineSnapshotSelector(snapshotDiagnostics), [snapshotDiagnostics])
3667
+ const serialized = useChat ? useChat(snapshotSelector) : ''
3668
+ const sessionState = useSession ? useSession((snapshot) => JSON.stringify({
3669
+ running: Boolean(snapshot.running), hasMoreHistory: Boolean(snapshot.hasMore),
3670
+ loadingHistory: Boolean(snapshot.loadingOlder), error: snapshot.lastAgentError
3671
+ || (snapshot.promptError?.op === 'send' ? snapshot.promptError.error?.message : '') || '',
3672
+ awaitingFirstTurn: Boolean(snapshot.awaitingFirstTurn || snapshot.pendingSubmissions?.some(item => item.placement === 'transcript')),
3673
+ })) : '{}'
3674
+ const snapshot = useMemo(() => snapshotDiagnostics.measure('host-snapshot-derive', () => {
3675
+ const next = snapshotSelector.read(serialized)
3676
+ const previous = previousSources.current
3677
+ const reuseSources = previous.sessionId === sessionId && previous.input === next.sources
3678
+ snapshotDiagnostics.cacheEvent('sourceMerge', reuseSources)
3679
+ const sources = reuseSources ? previous.sources : snapshotDiagnostics.measure('host-snapshot-merge',
3680
+ () => mergeArchiveSources([...(previous.sessionId === sessionId ? previous.sources : []), ...next.sources]))
3681
+ previousSources.current = { sessionId, sources, input: next.sources }
3682
+ const lifecycle = JSON.parse(sessionState)
3683
+ // Chat's durable turn/end outranks the coarser live running bit, whose
3684
+ // control frame can arrive later. A newly submitted first turn still
3685
+ // takes precedence while its durable user/turn nodes are on the way.
3686
+ const ended = ['completed', 'interrupted', 'error'].includes(next.outcome) && !next.running
3687
+ const running = Boolean(lifecycle.awaitingFirstTurn || !ended && (lifecycle.running || next.running))
3688
+ const error = ended ? next.error : lifecycle.error || next.error
3689
+ const outcome = running ? 'running' : ended ? next.outcome : lifecycle.error ? 'error' : next.outcome || 'unknown'
3690
+ const combined = { ...next, ...lifecycle, error,
3691
+ sessionId: sessionId || '', sources, running, outcome }
3692
+ return { ...combined, phase: rhinePhase(combined) }
3693
+ }), [serialized, sessionId, sessionState, snapshotDiagnostics, snapshotSelector])
3694
+ const latestSnapshot = useRef(snapshot)
3695
+ latestSnapshot.current = snapshot
3696
+ useEffect(() => {
3697
+ const listener = (value) => {
3698
+ setSkinValue(value)
3699
+ setOpen(value === 'rhine-lab')
3700
+ }
3701
+ rhineSkinListeners.add(listener)
3702
+ return () => rhineSkinListeners.delete(listener)
3703
+ }, [])
3704
+ useEffect(() => { workbench.current?.update(snapshot) }, [snapshot])
3705
+ useEffect(() => {
3706
+ if (!open || skin !== 'rhine-lab' || !sessionId) return undefined
3707
+ const ctx = clientContext
3708
+ const api = callApi
3709
+ const signal = clientAbortController?.signal
3710
+ const overlay = document.createElement('div')
3711
+ overlay.className = 'prts-rhine-overlay'
3712
+ overlay.setAttribute('role', 'dialog')
3713
+ overlay.setAttribute('aria-modal', 'true')
3714
+ overlay.setAttribute('aria-label', '莱茵生命资料馆')
3715
+ overlay.tabIndex = -1
3716
+ const status = document.createElement('div')
3717
+ status.className = 'prts-rhine-loading'
3718
+ const message = document.createElement('p')
3719
+ message.textContent = 'RHINE LAB / 正在打开资料馆'
3720
+ const close = document.createElement('button')
3721
+ close.textContent = '返回对话'
3722
+ close.onclick = () => setOpen(false)
3723
+ status.append(message, close); overlay.append(status)
3724
+ document.body.append(overlay)
3725
+ let disposed = false
3726
+ let instance = null
3727
+ let hostControls = null
3728
+ let removeFocusTrap = installDialogFocusTrap(overlay, {
3729
+ returnFocus: trigger, isSuspended: isHostSettingsOpen,
3730
+ })
3731
+ const visible = () => instance?.setActive(!document.hidden)
3732
+ document.addEventListener('visibilitychange', visible)
3733
+ const cleanup = () => {
3734
+ if (disposed) return
3735
+ disposed = true
3736
+ document.removeEventListener('visibilitychange', visible)
3737
+ removeFocusTrap()
3738
+ hostControls?.dispose()
3739
+ instance?.dispose()
3740
+ if (workbench.current === instance) workbench.current = null
3741
+ if (window.rhineWorkbench === instance) window.rhineWorkbench = null
3742
+ overlay.remove()
3743
+ rhineCleanups.delete(cleanup)
3744
+ }
3745
+ rhineCleanups.add(cleanup)
3746
+ void loadRhineBundle().then((runtime) => {
3747
+ if (disposed || clientContext !== ctx || signal?.aborted) return
3748
+ status.remove()
3749
+ hostControls = ctx.sessions?.list?.getSnapshot ? createRhineHostControls(ctx, sessionId) : null
3750
+ instance = runtime.mountRhineWorkbench(overlay, {
3751
+ assetBase: RHINE_BASE, snapshot: latestSnapshot.current, snapshotDiagnostics,
3752
+ ...(hostControls ? { host: hostControls } : {}),
3753
+ api: (endpoint, payload, requestSignal) => api(endpoint, payload, requestSignal || signal),
3754
+ askAgent: (text) => submitRhineQuestion(ctx, sessionId, text, signal),
3755
+ loadHistory: async () => {
3756
+ const session = ctx?.sessions?.binding?.(sessionId)?.session
3757
+ if (!session?.loadOlder) throw new Error('当前会话无法加载更早记录')
3758
+ await session.loadOlder()
3759
+ },
3760
+ close: () => setOpen(false),
3761
+ openSettings: () => openHostSettings(ctx),
3762
+ isSettingsOpen: isHostSettingsOpen,
3763
+ })
3764
+ workbench.current = instance
3765
+ // Expose the live workbench for diagnostics (bin/measure-rhine-console.js).
3766
+ window.rhineWorkbench = instance
3767
+ visible()
3768
+ removeFocusTrap()
3769
+ removeFocusTrap = installDialogFocusTrap(overlay, {
3770
+ returnFocus: trigger, isSuspended: isHostSettingsOpen,
3771
+ })
3772
+ }).catch((error) => {
3773
+ if (disposed) return
3774
+ if (!status.isConnected) overlay.append(status)
3775
+ message.textContent = error?.message || '资料馆加载失败,请关闭后重试'
3776
+ })
3777
+ return cleanup
3778
+ }, [open, skin, sessionId])
3779
+ if (skin !== 'rhine-lab') return null
3780
+ return h('button', { type: 'button', className: 'prts-rhine-trigger', ref: trigger,
3781
+ 'aria-label': '打开莱茵生命资料馆', onClick: () => setOpen(true) },
3782
+ '▥ 资料馆', snapshot.sources.length ? ` · ${snapshot.sources.length}` : '')
3783
+ }
3784
+
3785
+
3786
+ /* ---- 插件入口:Settings → Plugins 的「PRTS 语料」tab ---- */
3787
+ exports.__sceneStateForTest = { buildSceneSnapshotModel, sceneSnapshotSignature,
3788
+ collectSnapshotEvidence, buildEvidence, readEvidenceSource, installDialogFocusTrap }
3789
+ exports.__rhineStateForTest = { buildRhineSnapshot, mergeArchiveSources, rhineSnapshotSignature, submitRhineQuestion, RhineControl, createRhineHostControls, createRhineSnapshotDiagnostics, createRhineSnapshotSelector }
3790
+ exports.__skinStateForTest = {
3791
+ setSkin, writeSkinConfig, SkinCard, PrtsSection, openHostSettings, isHostSettingsOpen,
3792
+ finishAicBoot: () => aicBootDone(),
3793
+ getActiveSkin: () => activeSkin,
3794
+ getHostSkinSyncGeneration: () => hostSkinSyncGeneration,
3795
+ }
3796
+ // Remote namespaces are separate Cordis services. A new model directory
3797
+ // also resolves remote.session through this caller's tracked context.
3798
+ exports.inject = ['slots', 'connection', 'theme', 'sessions', 'remote', 'remote.agentPresets', 'remote.session', 'workspaces', 'modelDirectories']
3799
+
3800
+ exports.apply = (ctx) => {
3801
+ // A replacement apply must invalidate and cancel the previous owner's
3802
+ // asynchronous skin transaction before installing its own stylesheet owner.
3803
+ clientAbortController?.abort(makeAbortError('PRTS 浏览器插件已被新实例替换'))
3804
+ disposeRhine()
3805
+ skinApplyEpoch += 1
3806
+ skinOperationEpoch += 1
3807
+ beginHostSkinSync()
3808
+ pendingSkinOperations.clear()
3809
+ skinSelectionOwner = null
3810
+ // Retire runtime handles while they still point at the previous context.
3811
+ // Otherwise a truthy old AIC disposer makes syncAicLayout() skip mounting
3812
+ // the shell into the replacement slot owner, and old theme tokens can leak
3813
+ // when the new context exposes a different theme runtime.
3814
+ if (disposeAicLayout) disposeAicLayout()
3815
+ disposeAicLayout = null
3816
+ if (disposeScene) disposeScene()
3817
+ disposeScene = null
3818
+ if (removeSkinTokens) removeSkinTokens()
3819
+ removeSkinTokens = null
3820
+ activeSkin = 'harness'
3821
+ writeSkinDataset('harness')
3822
+ aicBootAbort(true)
3823
+ const generation = ++applyGeneration
3824
+ const controller = new AbortController()
3825
+ clientAbortController = controller
3826
+ clientContext = ctx
3827
+ themeRuntime = ctx.theme
3828
+ if (typeof document !== 'undefined') {
3829
+ ctx.effect(installSkinStylesheets, 'prts-corpus: skin stylesheets')
3830
+ }
3831
+ const instanceCallApi = async (endpoint, payload, signal = controller.signal) => {
3832
+ const response = await fetch('/api/prts-corpus/rpc', {
3833
+ method: 'POST',
3834
+ headers: { 'Content-Type': 'application/json' },
3835
+ credentials: 'same-origin',
3836
+ body: JSON.stringify({ endpoint, payload }),
3837
+ signal,
3838
+ })
3839
+ if (!response.ok) throw new Error(`PRTS 服务请求失败(HTTP ${response.status})`)
3840
+ const result = await response.json()
3841
+ if (typeof result?.ok !== 'boolean') throw new Error('PRTS 服务返回了无效响应')
3842
+ if (!result.ok) throw new Error(result.error?.message || 'PRTS 服务请求失败')
3843
+ return result.value
3844
+ }
3845
+ callApi = instanceCallApi
3846
+ ctx.effect(() => () => {
3847
+ controller.abort(makeAbortError('PRTS 浏览器插件已卸载'))
3848
+ if (generation !== applyGeneration) return
3849
+ disposeRhine()
3850
+ applyGeneration += 1
3851
+ skinOperationEpoch += 1
3852
+ skinApplyEpoch += 1
3853
+ beginHostSkinSync()
3854
+ if (disposeAicLayout) disposeAicLayout()
3855
+ disposeAicLayout = null
3856
+ if (disposeScene) disposeScene()
3857
+ disposeScene = null
3858
+ clientContext = null
3859
+ themeRuntime = null
3860
+ if (clientAbortController === controller) clientAbortController = null
3861
+ if (callApi === instanceCallApi) callApi = null
3862
+ if (removeSkinTokens) removeSkinTokens()
3863
+ removeSkinTokens = null
3864
+ activeSkin = 'harness'
3865
+ pendingSkinOperations.clear()
3866
+ skinSelectionOwner = null
3867
+ writeSkinDataset('harness')
3868
+ aicBootAbort(true)
3869
+ if (loadedMapRuntime && globalThis.__PRTS_ENDFIELD_MAP__ === loadedMapRuntime) {
3870
+ delete globalThis.__PRTS_ENDFIELD_MAP__
3871
+ }
3872
+ loadedMapRuntime = null
3873
+ cancelMapBundleLoad?.()
3874
+ cancelMapBundleLoad = null
3875
+ mapBundlePromise = null
3876
+ }, 'prts-corpus: skin cleanup')
3877
+ ctx.effect(() => ctx.slots.inject('settings.plugins.tab',
3878
+ () => ctx.slots.register(
3879
+ { name: 'settings.plugins.tab', id: 'prts-corpus', label: 'PRTS 语料', order: 60 },
3880
+ PrtsSection)), 'prts-corpus: settings plugins tab')
3881
+ ctx.effect(() => ctx.slots.inject('conversation.session.header.utilities',
3882
+ () => ctx.slots.register(
3883
+ { name: 'conversation.session.header.utilities', id: 'prts-evidence', order: 18 },
3884
+ EvidenceControl)), 'prts-corpus: evidence header control')
3885
+ // DSH omits the header utilities in a blank session. Keep the workbench
3886
+ // on the resident composer so it opens before the first prompt and keeps
3887
+ // the same instance when that session gains conversation content.
3888
+ ctx.effect(() => ctx.slots.inject('conversation.input.left',
3889
+ () => ctx.slots.register(
3890
+ { name: 'conversation.input.left', id: 'prts-rhine', order: 17 },
3891
+ RhineControl)), 'prts-corpus: rhine archive composer control')
3892
+ // 从 apply 内接管启动屏,使 DOM、计时器和背景都归当前 Cordis fiber 所有。
3893
+ try {
3894
+ if (typeof localStorage !== 'undefined' && localStorage.getItem(AIC_BOOT_KEY) === 'endfield-aic') {
3895
+ console.info('[prts-terrarchive] boot takeover: cached endfield skin, showing AIC loading screen')
3896
+ aicBootShow('CONNECTING TERMINAL')
3897
+ }
3898
+ } catch { /* 隐私模式等无 localStorage 环境:退回默认启动屏 */ }
3899
+ const configuredSkinEpoch = skinOperationEpoch
3900
+ const configuredHostSyncGeneration = beginHostSkinSync()
3901
+ void loadConfiguredSkin({ signal: controller.signal, generation,
3902
+ skinEpoch: configuredSkinEpoch,
3903
+ hostSyncGeneration: configuredHostSyncGeneration }).catch((error) => {
3904
+ if (error?.name === 'AbortError' || generation !== applyGeneration) return
3905
+ console.error('[prts-terrarchive] failed to activate configured skin', error)
3906
+ aicBootAbort(true)
3907
+ })
3908
+ }
3909
+
3910
+ return module.exports
3911
+ },
3912
+ })