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
@@ -0,0 +1,2245 @@
1
+ import { manualSearchControls, mountManualSearch } from './manual-search';
2
+ import type { ReadingObject } from './reading-object';
3
+ import { WORKBENCH_QUALITY, createRhineScene } from './scene';
4
+ import { ArchiveNavigationLimiter } from './archive-navigation-limit';
5
+ import { brandHeading } from './original/brand';
6
+ import { captureReadingOrigin, restoreReadingOrigin, readingOriginLabel, type ReadingOrigin, type ReadingOriginKind } from './reading-origin';
7
+ import { ARCHIVE_LANES, SHELF_PAGE_SIZE, belongsOnShelf, archiveLane, mergeSourcesInOrder, sourceIdentity, sourceIndex } from './catalogue';
8
+ import { shelfSlot } from './shelf-layout';
9
+ import { viewportLayout } from './original/viewport-layout';
10
+ import { ModelViewer } from './original/model-viewer';
11
+ // TEMPORARY RHINE PROFILER
12
+ import { mountTemporaryPerformancePanel } from './temporary-performance-panel';
13
+ import { renderSourceMarkdown, renderReportMarkdown, type ReportHeading } from './report-markdown';
14
+ import type { EvidenceBoardTool } from './evidence-board-model';
15
+ import { mountEvidenceBoardPanel } from './evidence-board-panel';
16
+ import { mountInvestigationBoard, type InvestigationContext } from './investigation-board';
17
+ import { readerStartLine, resolveReadingSource } from './source-reading';
18
+ import { createArchiveRack } from './archive-rack';
19
+ import { boardPlaneTransform } from './board-plane-transform';
20
+ import { mountSceneEdgeNavigation } from './scene-edge-navigation';
21
+ import { SurfaceTransition } from './original/ui-transitions';
22
+ import { investigationActivity, latestReadFocus, ReadCardMotion } from './tool-activity';
23
+ import { SnapshotUpdateGate } from './snapshot-update-gate';
24
+ import { createRollingNumber, createRollingText } from '@kitlangton/rolling-number';
25
+ import '@kitlangton/rolling-number/styles.css';
26
+ import type { ArchiveSource, InvestigationSnapshot, RhineHostState, RhineLocation, RhineOptions, RhineScene, RhineWorkbench, BoardAnchorFrame } from './types';
27
+
28
+ type Location = RhineLocation;
29
+ type ReaderLine = { line_number: number; text: string; speaker_raw?: string; source_ref?: string };
30
+ type Extract = { id: string; source: ArchiveSource; text: string; start?: number; end?: number };
31
+ type Persisted = { sources: ArchiveSource[]; extracts: Extract[] };
32
+ const PAGE_SIZE = 6;
33
+ const MAX_SAVED = 150;
34
+ const MAX_EXTRACTS = 24;
35
+
36
+ function node<K extends keyof HTMLElementTagNameMap>(tag: K, className = '', text?: string): HTMLElementTagNameMap[K] {
37
+ const element = document.createElement(tag);
38
+ element.className = className;
39
+ if (text !== undefined) element.textContent = text;
40
+ return element;
41
+ }
42
+ function button(text: string, className: string, action: () => void, label?: string) {
43
+ const element = node('button', className, text);
44
+ element.type = 'button';
45
+ if (label) element.setAttribute('aria-label', label);
46
+ element.addEventListener('click', action);
47
+ return element;
48
+ }
49
+ function origin(source: ArchiveSource) { return source.origin === 'cloud' ? '云端' : source.origin === 'web' ? '外部来源' : '本地资料'; }
50
+ function kind(source: ArchiveSource) {
51
+ const labels: Record<string, string> = {
52
+ story: '剧情原文', original_story: '剧情原文', character_profile: '角色档案',
53
+ character_module: '模组文案', character_voice: '角色语音', character_bundle: '角色资料集',
54
+ character_story: '角色故事', operator_record: '干员密录', character_wiki: '角色 Wiki',
55
+ story_wiki: '剧情 Wiki', character_activity_wiki: '角色活动资料', reviewed_wiki: '审校 Wiki',
56
+ knowledge: '审校资料', wiki: 'Wiki', archive: '官方档案库', timeline: '时间线',
57
+ cloud: '云端资料', web: '外部资料', activity: '活动资料', character: '角色资料',
58
+ character_skin: '时装文案', terra_journey: '大地巡旅', entity_profile: '实体资料', reference: '引用资料',
59
+ };
60
+ return labels[source.kind] || '资料';
61
+ }
62
+ function state(source: ArchiveSource) { return source.state === 'cited' ? 'Agent 已引用' : source.state === 'read' ? 'Agent 已读' : '已找到'; }
63
+ function matchesSource(source: ArchiveSource, query: string) {
64
+ return !query || `${source.title} ${kind(source)} ${origin(source)} ${source.excerpt}`.toLocaleLowerCase().includes(query);
65
+ }
66
+ function updateSourceChoice(row: HTMLButtonElement, source: ArchiveSource, number: number, context: string) {
67
+ if (!row.children.length) row.append(node('span', 'rhine-choice-number'), node('span', 'rhine-choice-copy'), node('span', 'rhine-choice-mark', '↗'));
68
+ const copy = row.querySelector('.rhine-choice-copy')!;
69
+ if (!copy.children.length) copy.append(node('strong'), node('small'));
70
+ row.querySelector('.rhine-choice-number')!.textContent = String(number).padStart(3, '0');
71
+ copy.querySelector('strong')!.textContent = source.title;
72
+ copy.querySelector('small')!.textContent = `${context} · ${kind(source)} · ${state(source)}${source.saved ? ' · 已收藏' : ''}`;
73
+ row.dataset.state = source.state;
74
+ row.title = source.title;
75
+ row.setAttribute('aria-label', `选择 ${source.title},${context},${state(source)}`);
76
+ }
77
+ function sourceKey(source: ArchiveSource) { return sourceIdentity(source); }
78
+ function sameSource(a: ArchiveSource, b: ArchiveSource) {
79
+ return (a.dataVersion || '') === (b.dataVersion || '') && (a.id === b.id ||
80
+ (['documentId', 'documentUid', 'sourceRef'] as const).some(key => a[key] && a[key] === b[key]));
81
+ }
82
+ function hasLocator(source: ArchiveSource) { return Boolean(source.documentId || source.sourceRef); }
83
+ function plainError(error: unknown) { return error instanceof Error ? error.message : String(error || '请求未完成'); }
84
+
85
+ /** A DOM reading workspace over a separately scheduled Three.js archive scene. */
86
+ export function mountRhineWorkbench(host: HTMLElement, options: RhineOptions): RhineWorkbench {
87
+ let snapshot = options.snapshot;
88
+ const snapshotGate = new SnapshotUpdateGate(snapshot);
89
+ let activitySources: ArchiveSource[] | undefined;
90
+ let logSourcesSignature = '';
91
+ let logRecordsSignature = '';
92
+ let disposed = false;
93
+ let active = true;
94
+ let location: Location = new URLSearchParams(window.location.search).get('rhineView') === 'board' ? 'board' : 'archive';
95
+ let boardFullscreen = false;
96
+ let scene: RhineScene | undefined;
97
+ let synchronizingScene = false;
98
+ let viewer: ModelViewer | undefined;
99
+ let viewerFrame = 0;
100
+ let edgeNavigation: ReturnType<typeof mountSceneEdgeNavigation> | undefined;
101
+ // A single rendering profile; legacy per-browser quality choices no longer apply.
102
+ const quality = { ...WORKBENCH_QUALITY };
103
+ const navigationLimiter = new ArchiveNavigationLimiter();
104
+ let manualSources: ArchiveSource[] = [];
105
+ let extracts: Extract[] = [];
106
+ let sources: ArchiveSource[] = [];
107
+ let discoveredSources: ArchiveSource[] = [];
108
+ let results: ArchiveSource[] = [];
109
+ let resultsPage = 0;
110
+ let deskPage = 0;
111
+ let searchCursor: unknown;
112
+ let searchDataVersion: string | undefined;
113
+ let searchQuery = '';
114
+ let searchBusy = false;
115
+ let searchError = '';
116
+ let searchWarning = '';
117
+ let searchRequest: Record<string, unknown> = {};
118
+ let searchMode: 'local' | 'cloud' = 'local';
119
+ let searchController: AbortController | undefined;
120
+ let readController: AbortController | undefined;
121
+ let readerAnnotationSignature = '', readerChipsSignature = '';
122
+ let readerPositionFrame = 0;
123
+ let readerPosition: { row?: HTMLElement; source: ArchiveSource; controller?: AbortController; finish?: (succeeded: boolean, asynchronous?: boolean) => void } | undefined;
124
+ const readerMeasure = <T>(part: string, task: () => T, detail: Record<string, unknown> = {}) => {
125
+ const finish = performancePanel.capture.beginUiWork(`reader-${part}`, detail);
126
+ try { return task(); } finally { finish?.(); }
127
+ };
128
+ function cancelReaderPosition() {
129
+ cancelAnimationFrame(readerPositionFrame); readerPositionFrame = 0;
130
+ readerPosition?.finish?.(false, true); readerPosition = undefined;
131
+ }
132
+ function scheduleReaderPosition() {
133
+ if (!readerPosition || readerPositionFrame || disposed || !active || root.hidden || reader.hidden) return;
134
+ readerPositionFrame = requestAnimationFrame(() => {
135
+ readerPositionFrame = 0;
136
+ const pending = readerPosition;
137
+ if (!pending || disposed || pending.source !== selectedSource || pending.controller && (pending.controller !== readController || pending.controller.signal.aborted) || pending.row && !pending.row.isConnected) {
138
+ cancelReaderPosition(); return;
139
+ }
140
+ if (!active || root.hidden || reader.hidden) return;
141
+ // Read the complete geometry first. Correct for the panel's visual scale.
142
+ const top = readerMeasure('position-read', () => {
143
+ if (!pending.row) return 0;
144
+ const bodyRect = readerBody.getBoundingClientRect(), rowRect = pending.row.getBoundingClientRect();
145
+ const scale = bodyRect.height / (readerBody.offsetHeight || bodyRect.height || 1);
146
+ return Math.max(0, readerBody.scrollTop + (rowRect.top - bodyRect.top) / (scale || 1)
147
+ - readerBody.clientTop - Math.min(50, readerBody.clientHeight * 0.2));
148
+ });
149
+ readerMeasure('position-write', () => { readerBody.scrollTop = top; });
150
+ pending.finish?.(true, true); readerPosition = undefined;
151
+ });
152
+ }
153
+
154
+ let selectedSource: ArchiveSource | undefined;
155
+ let readerCursor: string | undefined;
156
+ let readerVersion: string | undefined;
157
+ let readerBusy = false;
158
+ let sourceSignature = '';
159
+ let toastTimeout: number | undefined;
160
+ let lastFocus: HTMLElement | null = null;
161
+ let extracting = false;
162
+ let readerReturn: ReadingOriginKind | null = null;
163
+ let readerOrigin: ReadingOrigin | null = null;
164
+ let originRestoreFrame = 0;
165
+ let reportTrigger: HTMLElement | null = null;
166
+ let reportText = '';
167
+ let reportSourceSignature = '';
168
+ let reportTocSignature = '';
169
+ let reportHeadings: ReportHeading[] = [];
170
+ let reportRenderTimer: number | undefined;
171
+ let reportPositionFrame = 0;
172
+ let reportScrollTop = 0;
173
+ let reportScrollPending: number | 'end' | undefined;
174
+ let reportHeadingNodes: { heading: ReportHeading; element: HTMLElement; link: HTMLButtonElement }[] = [];
175
+ let reportViewedKey = '';
176
+ let reportInvestigationKey = '';
177
+ let lastCompletedKey = '';
178
+ let hasRenderedStatus = false;
179
+ let completionTimer: number | undefined;
180
+ let visibleRecordCount = 20;
181
+ let archiveSources: ArchiveSource[] = [];
182
+ let archiveSelected: string | null = null;
183
+ let archiveLaneIndex = 0;
184
+ let shelfSelected: string | null = null;
185
+ let catalogueLoaded = false;
186
+ let resultFilter = -1;
187
+ let detailWasOpen = false;
188
+ let pendingDetailFocus: HTMLElement | null = null;
189
+ let detailVisibility = 0;
190
+ let navigationHint: { axis: 'row' | 'lane'; direction: number } | undefined;
191
+ let hostState: RhineHostState | undefined = options.host?.getState();
192
+ let hostActionBusy = false;
193
+ let hostActionError = '';
194
+ let hostRetry: (() => Promise<void>) | undefined;
195
+ let hostTrigger: HTMLElement | null = null;
196
+ let hostSessionId = hostState?.sessionId;
197
+ let hostMode = hostState?.mode;
198
+ let hostModel = hostState?.model;
199
+ let modeSignature = '';
200
+ let modelSignature = '';
201
+ let sessionsSignature = '';
202
+ let unsubscribeHost: (() => void) | undefined;
203
+ const uiUpdates = { status: 0, host: 0, selectionSkipped: 0, ticksCreated: 0, sourceMerges: 0, sourceMergeSkipped: 0, statusSkipped: 0 };
204
+ const archiveTickNodes = new Map<string, HTMLButtonElement>();
205
+ const shelfChoiceNodes = new Map<string, HTMLButtonElement>();
206
+ let archiveSelectionSignature = '';
207
+ let hostPaintSignature = '';
208
+ const columnMemory: (string | null)[] = ARCHIVE_LANES.map(() => null);
209
+ const recordNodes = new Map<string, HTMLDetailsElement>();
210
+ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
211
+
212
+ const root = node('div', 'rhine-workbench');
213
+ root.setAttribute('aria-label', '莱茵生命资料浏览器');
214
+ root.setAttribute('aria-busy', 'true');
215
+ root.dataset.sceneState = 'loading';
216
+ root.innerHTML = `
217
+ <div class="rhine-scene-loader" role="status" aria-live="polite"><div class="rhine-loader-mark" aria-hidden="true"><i></i><i></i><i></i><i></i></div><div class="rhine-loader-title">RHINE LAB <span>/</span> 资料馆</div><div class="rhine-loader-message">正在载入档案场景…</div><div class="rhine-loader-progress" aria-hidden="true"><span></span></div><button type="button" class="rhine-loader-skip">先使用阅读模式 ↗</button></div>
218
+ <div class="rhine-original-stage" data-mode="archive" data-boot="done">
219
+ <div id="three-scene" class="three-scene rhine-scene"></div>
220
+ <div class="scene-atmosphere archive-atmosphere"></div>
221
+ <header class="brand">${brandHeading}</header>
222
+ <nav class="system-nav" aria-label="系统导航">
223
+ <div class="rhine-session-controls" aria-label="对话操作" hidden><button type="button" class="rhine-session-new">+ 新建对话</button><button type="button" class="rhine-session-history" aria-label="切换历史会话" aria-haspopup="dialog">会话 ⌄</button></div>
224
+ <button type="button" class="rhine-nav-board" aria-label="前往证据板">EVIDENCE BOARD <span class="rhine-board-count">00</span></button>
225
+ <button class="rhine-nav-index" data-action="search" aria-label="档案索引"><span class="nav-glyph">⌕</span> ARCHIVE INDEX <span class="key">/</span></button>
226
+ <button class="rhine-nav-rack" aria-label="查看调查档案架">EVIDENCE RACK <span class="rhine-rack-count">00</span></button>
227
+ <button class="rhine-nav-extracts" data-action="saved" aria-label="查看收藏与摘录">+ 摘录 <span id="saved-count">00</span></button>
228
+ <button class="settings-button rhine-nav-log" data-action="settings" aria-label="调查记录"><span class="settings-glyph" aria-hidden="true">◷</span><span class="settings-label">调查记录</span></button>
229
+ <button type="button" class="rhine-nav-settings" aria-label="打开设置">设置 ↗</button>
230
+ </nav>
231
+ <div class="rhine-board-toolbar" role="group" aria-label="白板视图标签" hidden>
232
+ <span class="rhine-board-view-caption" aria-hidden="true">VIEW</span>
233
+ <div class="rhine-board-magnet"><button type="button" class="rhine-board-zoom-out" aria-label="缩小线索板">−</button><button type="button" class="rhine-board-reset" aria-label="复位线索板视图" title="点击恢复全貌">100%</button><button type="button" class="rhine-board-zoom-in" aria-label="放大线索板">+</button></div>
234
+ <div class="rhine-board-magnet"><button type="button" class="rhine-board-tools-fallback" hidden>工具</button><button type="button" class="rhine-board-fullscreen-toggle" aria-pressed="false" aria-label="全屏线索板" aria-controls="three-scene"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M7 3H3v4m10-4h4v4M3 13v4h4m10-4v4h-4"/></svg><span class="rhine-board-fullscreen-label">全屏</span></button></div>
235
+ </div>
236
+ <button type="button" class="rhine-board-chalk-hit" aria-label="粉笔盒:打开整理工具" title="点击粉笔盒,整理线索" aria-expanded="false" hidden></button>
237
+ <div class="rhine-board-tool-status" hidden><span role="status" aria-live="polite"></span><button type="button" class="rhine-board-tool-cancel">取消 ×</button></div>
238
+ <section id="archive-ui" class="archive-ui" aria-label="档案选择">
239
+ <div class="archive-callout rhine-investigation-brief">
240
+ <div class="eyebrow"><span class="rhine-case-label">INVESTIGATION / READY</span><span>/</span><span class="rhine-case-phase">等待调查</span></div>
241
+ <h1 class="rhine-case-question">从一个问题,开始调查。</h1>
242
+ <button type="button" class="rhine-case-operation" aria-label="查看 Agent 工作详情" aria-haspopup="dialog"><span class="rhine-operation-meter" aria-hidden="true"><i></i><i></i><i></i></span><span class="rhine-operation-copy"><strong class="rhine-case-tool" role="status" aria-live="polite">等待开始调查</strong><span class="rhine-case-operation-text">输入问题,或自由检索和翻阅原文</span></span><span class="rhine-operation-more">查看过程 <b>↗</b></span></button>
243
+ <div class="callout-rule"><i></i></div>
244
+ <div class="rhine-case-counts" aria-label="Agent 资料状态"><span>已查得 <b class="rhine-found-count">00</b></span><span>已读 <b class="rhine-read-count">00</b></span><span>已引用 <b class="rhine-cited-count">00</b></span></div>
245
+ <section class="rhine-tool-feed" aria-label="实时工具调用" hidden><div class="rhine-tool-feed-heading"><span>TOOL ACTIVITY / 调用记录</span><span class="rhine-tool-total"></span></div><div class="rhine-tool-feed-list"></div></section>
246
+ <button type="button" class="rhine-report-open" hidden><span class="rhine-report-open-copy"><small class="rhine-report-open-kicker">RESEARCH REPORT</small><strong>阅读调查报告</strong><span class="rhine-report-open-meta"></span></span><b aria-hidden="true">↗</b></button>
247
+ <div class="rhine-follow-controls"><span class="rhine-follow-status" role="status">跟随 Agent 查阅</span><button type="button" class="rhine-follow-toggle" aria-pressed="true">暂停跟随</button></div>
248
+ <div class="rhine-current-source"><div class="rhine-source-kicker"><span id="selected-id">S-<span id="selected-code">000</span></span><span id="archive-category">角色档案</span><span id="selected-clearance">SOURCE / READY</span></div><button class="file-title" data-action="open"><span id="selected-title">浏览资料库</span><span class="file-open">↗</span></button><p class="rhine-selected-excerpt">搜索原文,收藏线索,或把问题交给 Agent。</p><label class="rhine-evidence-target"><span>证据盒归属</span><select aria-label="证据盒归属" disabled><option>正在读取调查板…</option></select></label><div class="rhine-priority-actions"><button class="read-file" data-action="open">OPEN SOURCE <span>→</span></button><button type="button" class="rhine-array-stage">+ 放入重点证据盒</button></div></div>
249
+ </div>
250
+ <div class="archive-counter"><span class="tiny-label">SOURCE / SELECT</span><div><span id="selected-number">00</span><i>/</i><span class="count-total">00</span></div></div>
251
+ <button type="button" class="rhine-array-toggle" aria-controls="rhine-array-list" aria-expanded="false"><span aria-hidden="true">+</span> 展开资料列表 <small class="rhine-array-toggle-count"></small></button>
252
+ <div id="rhine-array-list" class="archive-navigation" aria-label="检索阵列资料选择" hidden inert>
253
+ <div class="rhine-choice-heading"><span>SOURCE DIRECTORY / 资料选择</span><button type="button" class="rhine-array-directory">资料索引 ↗</button><button type="button" class="rhine-array-read" data-action="open">读取所选 ↗</button><button type="button" class="rhine-array-collapse" aria-label="收起资料列表">收起 ×</button></div>
254
+ <div class="column-navigation"><button data-action="column-prev" aria-label="上一列">←</button><div><span id="column-number">COLLECTION <span id="column-index">01</span> / 05</span><strong id="column-name">角色档案</strong></div><button data-action="column-next" aria-label="下一列">→</button></div>
255
+ <div id="file-ticks" class="file-ticks" role="group" aria-label="当前分类的资料标题"></div>
256
+ <div class="rhine-choice-footer"><button data-action="prev" aria-label="上一页档案">← 上一页</button><span class="rhine-array-range"></span><button data-action="next" aria-label="下一页档案">下一页 →</button></div>
257
+ </div>
258
+ <div class="archive-hint"><kbd>←</kbd> <kbd>→</kbd> 切换列 <span>/</span> <kbd>↑</kbd> <kbd>↓</kbd> 前后档案 <span>/</span> <kbd>ENTER</kbd> 读取</div>
259
+ </section>
260
+ <section id="detail-ui" class="detail-ui" aria-label="档案内容" hidden>
261
+ <button class="back-button" data-action="back">← <span>ARCHIVE OVERVIEW</span><small>ESC</small></button>
262
+ <div class="object-caption"><span id="object-id">S-001</span><div>RESEARCH ARCHIVE</div><small>DOCUMENT / OPEN <span>↔</span></small><button class="viewer-open" data-action="inspect">360° 查看文档模型 <span>↗</span></button></div>
263
+ <article class="rhine-reader detail-content" aria-label="资料阅读器" hidden>
264
+ <div class="detail-kicker"><span>DOCUMENT / READING ROOM</span><button type="button" class="rhine-reader-back-report" hidden>← 返回报告</button><button type="button" class="rhine-close-reader" aria-label="关闭资料">CLOSE ×</button></div>
265
+ <h2 class="rhine-reader-title" tabindex="-1"></h2>
266
+ <div class="rhine-reader-meta detail-title-cn"></div><div class="detail-rule"></div>
267
+ <div class="rhine-reader-provenance"></div>
268
+ <label class="rhine-evidence-target"><span>证据盒归属</span><select aria-label="证据盒归属" disabled><option>正在读取调查板…</option></select></label><div class="rhine-reader-actions"><button type="button" class="rhine-read-save">+ 收藏到档案架</button><button type="button" class="rhine-read-pin">+ 放入重点证据盒</button><button type="button" class="rhine-extract-selection">摘录选中文字 ↗</button></div>
269
+ <div class="rhine-reader-ranges" aria-label="原文相关位置"></div>
270
+ <div class="rhine-reader-body" tabindex="0" aria-label="资料正文"></div>
271
+ <div class="rhine-reader-footer"><span class="rhine-reader-status" role="status"></span><button type="button" class="rhine-reader-more" hidden>继续读取 ↓</button></div>
272
+ </article>
273
+ </section>
274
+ <section class="rhine-desk-panel" aria-label="本次调查档案架" hidden>
275
+ <div class="tiny-label">EVIDENCE RACK / <span class="rhine-desk-count">00</span></div><h2>调查档案架</h2><p class="rhine-rack-intro">选择标题定位档案,抽取后阅读原文。</p><label class="rhine-shelf-search"><span aria-hidden="true">⌕</span><input class="rhine-shelf-filter" type="search" placeholder="在全部档案中筛选标题、内容…" aria-label="筛选全部档案"/><button type="button" class="rhine-shelf-clear" hidden aria-label="清除档案筛选">×</button></label><div class="rhine-rack-range" role="status"></div>
276
+ <div class="rhine-shelf-current"><span class="rhine-shelf-number">S-000</span><h3 class="rhine-shelf-title">等待第一份资料</h3><div class="rhine-shelf-meta"></div><p class="rhine-shelf-excerpt">Agent 实际读过或你主动收藏的资料,留在这里供回查。</p><label class="rhine-evidence-target"><span>证据盒归属</span><select aria-label="证据盒归属" disabled><option>正在读取调查板…</option></select></label><div class="rhine-priority-actions"><button type="button" class="rhine-shelf-open">抽取阅读 <span>↗</span></button><button type="button" class="rhine-shelf-stage">+ 放入重点证据盒</button></div></div>
277
+ <div class="rhine-shelf-navigation"><button type="button" class="rhine-shelf-prev" aria-label="翻阅上一份资料">←</button><div class="rhine-desk-list" role="group" aria-label="档案标题选择列表"></div><button type="button" class="rhine-shelf-next" aria-label="翻阅下一份资料">→</button></div><div class="rhine-desk-pagination"></div><button type="button" class="rhine-rack-directory">全部资料目录 <span>↗</span></button>
278
+ <div class="rhine-rack-legend"><span data-state="found">查得</span><span data-state="read">Agent 已读</span><span data-state="cited">已引用</span><span data-state="saved">手动收藏</span></div><button type="button" class="rhine-shelf-report" hidden>调查报告 <span>↗</span></button>
279
+ </section>
280
+ <button type="button" class="rhine-location-button"><span class="rhine-location-title">调查档案架 <b>→</b></span><span class="rhine-location-count">00</span></button>
281
+ <nav class="rhine-zone-nav" aria-label="资料馆场景区域">
282
+ <button type="button" data-zone="archive"><small>01</small><span>检索阵列</span></button>
283
+ <i aria-hidden="true"></i><button type="button" data-zone="board"><small>02</small><span>证据板</span></button>
284
+ <i aria-hidden="true"></i><button type="button" data-zone="desk"><small>03</small><span>档案收录架</span></button>
285
+ </nav>
286
+ <form class="rhine-agent-form" aria-label="向 Agent 提问">
287
+ <div class="rhine-agent-heading"><label for="rhine-agent-question">ASK THE ARCHIVE</label><div class="rhine-agent-settings" aria-label="调查配置" hidden><button type="button" class="rhine-session-mode" aria-haspopup="dialog"><small>MODE</small><span>选择模式</span><i aria-hidden="true">⌄</i></button><button type="button" class="rhine-session-model" aria-haspopup="dialog"><small>MODEL</small><span>选择模型</span><i aria-hidden="true">⌄</i></button></div><span class="rhine-agent-availability">AGENT / READY</span></div>
288
+ <div class="rhine-agent-field"><span aria-hidden="true">⌕</span><input id="rhine-agent-question" type="text" autocomplete="off" placeholder="输入问题,交给 Agent 调查…" maxlength="3000" aria-label="调查问题"/><button type="button" class="rhine-agent-cancel" hidden aria-label="停止当前任务">■ 停止</button><button type="submit" class="rhine-agent-submit" aria-label="提交调查问题">SEND <span>↗</span></button></div>
289
+ <div class="rhine-agent-board-context" title="查看或切换调查板不会改变 Agent 的工作板"></div><div class="rhine-agent-footer"><span class="rhine-agent-hint">候选资料在检索阵列;已读与收藏资料入架。</span><button type="button" class="rhine-manual-search">检索资料 <span>→</span></button></div>
290
+ <button type="button" class="rhine-session-alert" hidden aria-haspopup="dialog"><span>会话设置暂不可用</span><b>查看详情 ↗</b></button>
291
+ </form>
292
+ <section class="rhine-activity" aria-label="Agent 检索状态" hidden><span class="rhine-status-dot"></span><span class="rhine-status-title" role="status"></span><span class="rhine-activity-query"></span><span class="rhine-activity-detail"></span></section>
293
+ <div class="powered">POWERED BY <b>RHINE LAB</b><i></i></div>
294
+ <footer class="system-footer"><span><i class="status-light"></i> RESEARCH SESSION <span class="rhine-session-id"></span></span><span>PRTS / CORPUS CONNECTION <i>/</i> <span id="clock">00:00:00</span></span><button class="rhine-nav-close">返回对话 ↗</button><span class="rhine-performance" hidden></span></footer>
295
+ <section class="rhine-results modal-backdrop" aria-label="搜索结果" hidden>
296
+ <div class="terminal-modal rhine-index-modal"><div class="modal-top"><span>RHINE LAB / ARCHIVE DIRECTORY</span><button type="button" class="rhine-close-results" aria-label="关闭档案索引">CLOSE <span>×</span></button></div><h2>资料库检索<small>ARCHIVE INDEX</small></h2>
297
+ <form class="rhine-query-form">${manualSearchControls}<div class="search-field"><span>⌕</span><input id="rhine-query" type="search" autocomplete="off" placeholder="搜索人物、剧情、机构或一句原文" maxlength="3000" aria-label="搜索资料"/><button type="button" class="rhine-search-cancel" hidden>取消</button><button type="submit" class="rhine-search-submit">检索资料 ↵</button></div><div class="rhine-query-actions"><span class="rhine-query-hint">候选资料可直接阅读、收藏,或放入指定调查的证据盒。</span><button type="button" class="rhine-ask-agent">交给 Agent ↗</button></div></form>
298
+ <div class="category-filters"></div><label class="rhine-evidence-target"><span>证据盒归属</span><select aria-label="证据盒归属" disabled><option>正在读取调查板…</option></select></label><div class="result-header"><span>资料标题 / 内容预览</span><span>来源与操作</span></div><div class="rhine-result-summary" role="status"></div><div class="rhine-result-list search-results"></div><div class="rhine-result-pagination"></div><div class="modal-bottom"><span class="rhine-index-provenance">PRTS / CORPUS SEARCH</span><span class="rhine-search-connection">LOCAL / 本地资料</span></div>
299
+ </div>
300
+ </section>
301
+ <section class="rhine-basket modal-backdrop" aria-label="收藏与摘录" hidden><div class="terminal-modal rhine-basket-modal"><div class="modal-top"><span>RHINE LAB / RESEARCH EXTRACTS</span><button type="button" class="rhine-close-basket" aria-label="关闭收藏">CLOSE <span>×</span></button></div><h2>RESEARCH EXTRACTS<small>收藏与摘录 <span class="rhine-extract-count">00</span></small></h2><p class="rhine-panel-description">选取原文,保存线索和来源。摘录随你提交的调查问题一起发送。</p><div class="rhine-basket-list"></div><button type="button" class="rhine-send-extracts solid-button">将摘录交给 Agent ↗</button></div></section>
302
+ <section class="rhine-log modal-backdrop" aria-label="Agent 调查记录" hidden><div class="terminal-modal rhine-log-modal"><div class="modal-top"><span>RHINE LAB / INVESTIGATION RECORDS</span><button type="button" class="rhine-close-log" aria-label="关闭调查记录">CLOSE <span>×</span></button></div><h2>RESEARCH LOG<small>调查记录</small></h2><div class="rhine-log-status"></div><div class="rhine-log-content"><div class="rhine-log-sources"></div><div class="rhine-log-records-heading tiny-label">TOOL RECEIPTS / 工具调用记录</div><div class="rhine-log-records"></div><div class="rhine-log-answer"></div></div></div></section>
303
+ <section class="rhine-rack-index modal-backdrop" aria-label="调查资料目录" hidden><div class="terminal-modal rhine-rack-index-modal"><div class="modal-top"><span>RHINE LAB / EVIDENCE INDEX</span><button type="button" class="rhine-close-rack-index" aria-label="关闭调查资料目录">CLOSE <span>×</span></button></div><h2>档案架目录<small>Agent 已读与手动收藏</small></h2><label class="search-field"><span>⌕</span><input class="rhine-rack-filter" type="search" autocomplete="off" placeholder="筛选资料标题、来源或内容" aria-label="筛选档案架资料"/></label><label class="rhine-evidence-target"><span>证据盒归属</span><select aria-label="证据盒归属" disabled><option>正在读取调查板…</option></select></label><div class="rhine-rack-index-list"></div><div class="rhine-rack-index-count" role="status"></div></div></section>
304
+ <section class="rhine-report modal-backdrop" aria-label="Agent 调查报告" hidden><div class="terminal-modal rhine-report-modal"><div class="modal-top"><span>RHINE LAB / RESEARCH REPORT</span><button type="button" class="rhine-close-report" aria-label="关闭调查报告">返回调查 <span>×</span></button></div><header class="rhine-report-header"><div class="rhine-report-kicker tiny-label">RESEARCH REPORT</div><h2 class="rhine-report-title">调查报告</h2><div class="rhine-report-status" role="status"></div></header><div class="rhine-report-layout"><aside class="rhine-report-outline" aria-label="报告目录"><div class="tiny-label">CONTENTS / 目录</div><nav class="rhine-report-toc"></nav></aside><article class="rhine-report-body" tabindex="0" aria-label="Agent 回答"></article><aside class="rhine-report-references" aria-label="报告引用"><div class="rhine-report-sources"></div></aside></div><footer class="rhine-report-footer"><button type="button" class="rhine-report-rack">翻阅调查资料 <span>↗</span></button><span class="rhine-report-position"></span></footer></div></section>
305
+ <section class="rhine-session-panel modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="rhine-session-heading" hidden><div class="terminal-modal rhine-session-modal"><div class="modal-top"><span>RHINE LAB / SESSION CONTROL</span><button type="button" class="rhine-close-session" aria-label="关闭对话管理">CLOSE <span>×</span></button></div><h2 id="rhine-session-heading">RESEARCH SESSIONS<small>对话与模式</small></h2><p class="rhine-session-workspace"></p><div class="rhine-session-error" role="alert" hidden><p></p><button type="button" class="rhine-session-retry">重试 ↗</button></div><div class="rhine-session-content"><section class="rhine-session-config" aria-label="对话设置"><div class="rhine-session-mode-field"><label for="rhine-mode-select">AGENT MODE <span>调查模式</span></label><div class="rhine-session-select-row"><select id="rhine-mode-select" aria-describedby="rhine-mode-description rhine-mode-hint"></select><button type="button" class="rhine-mode-apply">使用此模式</button></div><p id="rhine-mode-description"></p><p id="rhine-mode-hint"></p></div><div class="rhine-session-model-field" hidden><label for="rhine-model-select">MODEL <span>当前对话模型</span></label><div class="rhine-session-select-row"><select id="rhine-model-select"></select><button type="button" class="rhine-model-apply">切换模型</button></div></div></section><section class="rhine-session-history-section" aria-label="历史会话"><div class="rhine-session-list-heading"><span>SESSION ARCHIVE / 历史会话</span><button type="button" class="rhine-session-refresh">刷新 ↻</button></div><div class="rhine-session-list" tabindex="-1"></div></section></div><div class="rhine-session-modal-footer"><span class="rhine-session-notice" role="status"></span><button type="button" class="rhine-session-create solid-button">+ 新建对话</button></div></div></section>
306
+ <button type="button" class="rhine-completion-notice" aria-label="调查已完成,阅读报告" hidden><span>✓ 调查完成</span><strong>阅读调查报告</strong><b aria-hidden="true">↗</b></button><div class="rhine-completion-announcement" role="status" aria-live="polite"></div><div class="rhine-toast toast" role="status" hidden></div>
307
+ </div>
308
+ `;
309
+ host.append(root);
310
+ function $<T extends HTMLElement = HTMLElement>(selector: string) { return root.querySelector<T>(selector)!; }
311
+ const input = $<HTMLInputElement>('#rhine-query');
312
+ const agentInput = $<HTMLInputElement>('#rhine-agent-question');
313
+ const reader = $('.rhine-reader');
314
+ const readerBody = $('.rhine-reader-body');
315
+ const resultsPanel = $('.rhine-results');
316
+ const basket = $('.rhine-basket');
317
+ const log = $('.rhine-log');
318
+ const report = $('.rhine-report');
319
+ const rackIndex = $('.rhine-rack-index');
320
+ const sessionPanel = $('.rhine-session-panel');
321
+ const modeSelect = $<HTMLSelectElement>('#rhine-mode-select');
322
+ const modelSelect = $<HTMLSelectElement>('#rhine-model-select');
323
+ const panels = [resultsPanel, basket, log, report, sessionPanel, rackIndex];
324
+ const stage = $('.rhine-original-stage');
325
+ const sceneLoader = $('.rhine-scene-loader');
326
+ let sceneStartupCancelled = false;
327
+ const finishSceneLoading = (state: 'ready' | 'unavailable') => {
328
+ root.dataset.sceneState = state;
329
+ root.setAttribute('aria-busy', 'false');
330
+ stage.inert = false;
331
+ sceneLoader.hidden = true;
332
+ };
333
+ stage.inert = true;
334
+ $('.rhine-loader-skip').addEventListener('click', () => {
335
+ sceneStartupCancelled = true;
336
+ scene?.dispose(); scene = undefined;
337
+ root.classList.add('rhine-scene-unavailable');
338
+ finishSceneLoading('unavailable');
339
+ });
340
+ const arrayList = $('.archive-navigation');
341
+ const arrayToggle = $('.rhine-array-toggle');
342
+ const arrayListTransition = new SurfaceTransition(arrayList, undefined, 220, 160);
343
+ let arrayListExpanded = false;
344
+ stage.dataset.arrayList = 'closed';
345
+ const boardToolbar = $('.rhine-board-toolbar');
346
+ const boardFullscreenButton = $<HTMLButtonElement>('.rhine-board-fullscreen-toggle');
347
+ const boardResetButton = $<HTMLButtonElement>('.rhine-board-reset');
348
+ const boardZoomOut = $<HTMLButtonElement>('.rhine-board-zoom-out');
349
+ const boardZoomIn = $<HTMLButtonElement>('.rhine-board-zoom-in');
350
+ const boardChalk = $<HTMLButtonElement>('.rhine-board-chalk-hit');
351
+ const boardToolsFallback = $<HTMLButtonElement>('.rhine-board-tools-fallback');
352
+ const boardToolStatus = $('.rhine-board-tool-status');
353
+ let boardFrame: BoardAnchorFrame | null = null;
354
+ let boardUiScale = 1;
355
+ let boardToolState: EvidenceBoardTool = { mode: 'select' };
356
+ let boardToolsOpen = false;
357
+ let boardEditorInset = 0;
358
+ let investigationReading: ReadingObject | null = null;
359
+ let investigationReturn = { fullscreen: false, kind: 'report' as 'report' | 'clue' | 'inbox' };
360
+ let evidenceTarget = 'auto';
361
+ let investigationContext: InvestigationContext = { sessionId: snapshot.sessionId, boards: [], selectedId: '', workingId: '', ready: false };
362
+ let contextSignature = '';
363
+ let investigation: ReturnType<typeof mountInvestigationBoard> | undefined;
364
+ const archiveRack = createArchiveRack({ api: options.api,
365
+ changed(value) { manualSources=value;persist();mergeSources();updateReaderSave(); }, failed: toast });
366
+ const boardPanel = mountEvidenceBoardPanel(stage, {
367
+ getCardBounds: id => scene?.getBoardCardBounds(id) || null,
368
+ managed: true,
369
+ sessionId: snapshot.sessionId,
370
+ onChange(cards) {
371
+ investigation?.userChange(cards);
372
+ $('.rhine-board-count').textContent = String(cards.length).padStart(2, '0');
373
+ },
374
+ onSelect(id) { scene?.selectBoardCard(id); },
375
+ onToolChange(tool) {
376
+ if (scene) scene.setBoardTool(tool);
377
+ else {
378
+ syncBoardToolState({ mode: 'select' });
379
+ if (tool.mode !== 'select') toast('白板正在载入,请稍候再选择工具。');
380
+ }
381
+ },
382
+ onToolsOpen(open) {
383
+ boardToolsOpen = open;
384
+ boardChalk.setAttribute('aria-expanded', String(open)); boardChalk.classList.toggle('is-open', open);
385
+ boardToolStatus.hidden = open || boardToolState.mode === 'select' || location !== 'board' || Boolean(selectedSource);
386
+ },
387
+ onViewportInset(fraction) {
388
+ boardEditorInset = fraction;
389
+ scene?.setBoardEditorInset(fraction);
390
+ },
391
+ onBrowse() { setLocation('archive'); openIndex(); },
392
+ });
393
+ investigation = mountInvestigationBoard(stage, {
394
+ sessionId: snapshot.sessionId, api: options.api, panel: boardPanel,
395
+ onRackRevision(revision) { void archiveRack.refresh(revision).catch(()=>{}); },
396
+ onCards(cards) { scene?.setBoardCards(cards); $('.rhine-board-count').textContent = String(Math.max(0, cards.length - 1)).padStart(2, '0'); },
397
+ openSource(source,inbox=false) { investigation?.holdReadingContext();investigationReturn={fullscreen:boardFullscreen,kind:inbox?'inbox':investigationReading?.kind||'report'};openSource(source,'investigation'); }, notify: toast,
398
+ onContext(value){investigationContext=value;renderInvestigationContext();},
399
+ onInbox(value,open){if(open)boardPanel.closeTools();scene?.setEvidenceInbox(value,open);},
400
+ askInbox:options.agentAvailable===false?undefined:async(boardId,title)=>{await options.askAgent(`请继续整理调查板「${title}」(board_id: ${boardId})的重点证据盒。先读取这块板和 inbox,resume 同一调查;核对待整理资料,尤其是我手动选入的材料,将有依据的内容整理为线索。不要仅为清空盒子而生成线索。`);},
401
+ onReading(value) { investigationReading=value;syncReadingObject(); },
402
+ });
403
+ edgeNavigation = mountSceneEdgeNavigation(stage, {
404
+ location: () => location,
405
+ available: () => active && !disposed && !selectedSource && !viewer?.isOpen && !boardFullscreen
406
+ && !panels.some(panel => !panel.hidden) && (location !== 'board' || boardToolState.mode === 'select'),
407
+ navigate: setLocation,
408
+ returnFocus: () => root.querySelector<HTMLElement>(`[data-zone="${location}"]`)?.focus({ preventScroll: true }),
409
+ });
410
+ // TEMPORARY RHINE PROFILER: no document text or queries are included in exports.
411
+ const performancePanel = mountTemporaryPerformancePanel(root, () => {
412
+ const stats = scene?.stats();
413
+ const switches = new URLSearchParams(window.location.search);
414
+ return { diagnosticRevision: 'pointer-reader-1', uiUpdates: { ...uiUpdates }, hostBridge: options.host?.performanceStats?.() ?? null, snapshotBridge: options.snapshotDiagnostics?.performanceStats?.() ?? null, userAgent: navigator.userAgent, viewport: { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio },
415
+ quality: { ...quality }, renderQuality: stats?.renderQuality, renderSurface: stats?.renderSurface,
416
+ location, viewerOpen: Boolean(viewer?.isOpen), sourceCount: sources.length, archiveSourceCount: archiveSources.length,
417
+ heroLoaded: stats?.heroLoaded, preparation: stats?.preparation,
418
+ programPreparation: stats?.programPreparation, shelfInterior: stats?.shelfInterior, camera: stats?.camera,
419
+ navigation: stats?.navigation ?? navigationLimiter.stats(),
420
+ comparison: Object.fromEntries(['rhineArray', 'rhineCull', 'rhineLod', 'rhineOcclusion', 'rhineShell', 'rhineDetails', 'rhineShelf'].map(key => { const value = switches.get(key); return [key, value === null ? null : ['geometry', 'baked', 'off', 'on'].includes(value) ? value : 'other']; })),
421
+ };
422
+ }, () => ({ location, viewerOpen: Boolean(viewer?.isOpen), sourceCount: sources.length, archiveSourceCount: archiveSources.length,
423
+ detailOpen: Boolean(selectedSource), modalOpen: panels.some(panel => !panel.hidden), boardFullscreen,
424
+ searchBusy, readerBusy, resultCount: results.length, answerCharacters: snapshot.answer?.length || 0,
425
+ recordCount: snapshot.records?.length || 0, investigationRunning: Boolean(snapshot.running),
426
+ investigationSearching: Boolean(snapshot.searching), reportRenderPending: reportRenderTimer !== undefined,
427
+ uiUpdates: { ...uiUpdates }, hostBridge: options.host?.performanceStats?.() ?? null, snapshotBridge: options.snapshotDiagnostics?.performanceStats?.() ?? null }));
428
+ options.host?.setPerformanceMonitor?.(kind => performancePanel.capture.beginUiWork(kind));
429
+ options.snapshotDiagnostics?.setPerformanceMonitor?.(kind => performancePanel.capture.beginUiWork(kind));
430
+ const monitoredApi: typeof options.api = async (endpoint, payload, signal) => {
431
+ const done = performancePanel.capture.beginLoad(endpoint === 'archive.search' ? 'api:archive.search' : endpoint === 'read' ? 'api:read' : 'api:other');
432
+ try {
433
+ const result = await options.api(endpoint, payload, signal);
434
+ done(signal?.aborted ? 'aborted' : result?.ok === false || result?.error ? 'error' : 'complete');
435
+ return result;
436
+ } catch (error) { done(signal?.aborted ? 'aborted' : 'error'); throw error; }
437
+ };
438
+ const manualSearch = mountManualSearch($<HTMLFormElement>('.rhine-query-form'), monitoredApi, () => {
439
+ searchController?.abort(); searchController = undefined; searchBusy = false;
440
+ searchMode = manualSearch.mode; results = []; resultsPage = 0; resultFilter = -1;
441
+ searchCursor = undefined; searchDataVersion = undefined; searchQuery = ''; searchError = ''; searchWarning = '';
442
+ catalogueLoaded = false; renderResults(); renderStatus();
443
+ });
444
+ const detailTransition = new SurfaceTransition($('#detail-ui'), undefined, 180, 180);
445
+ const modalTransitions = new Map(panels.map(panel => [panel, new SurfaceTransition(panel, panel.querySelector<HTMLElement>('.terminal-modal')!, 300, 200)]));
446
+ const coarse = window.matchMedia('(pointer: coarse)');
447
+ const motion = { duration: 460, motionBlur: true, animated: !reducedMotion.matches };
448
+ const counters = {
449
+ file: createRollingNumber($('#selected-number'), { ...motion, locales: 'en-US', value: 0, format: { minimumIntegerDigits: 2, useGrouping: false } }),
450
+ column: createRollingNumber($('#column-index'), { ...motion, locales: 'en-US', value: 1, format: { minimumIntegerDigits: 2, useGrouping: false } }),
451
+ code: createRollingNumber($('#selected-code'), { ...motion, locales: 'en-US', value: 0, format: { minimumIntegerDigits: 3, useGrouping: false } }),
452
+ };
453
+ const titles = {
454
+ column: createRollingText($('#column-name'), { ...motion, text: ARCHIVE_LANES[0], transition: 'direct', stagger: 'none' }),
455
+ category: createRollingText($('#archive-category'), { ...motion, text: ARCHIVE_LANES[0], transition: 'direct', stagger: 'none' }),
456
+ clearance: createRollingText($('#selected-clearance'), { ...motion, text: 'SOURCE / READY', transition: 'direct', stagger: 'none' }),
457
+ };
458
+ const sourceMotion = new ReadCardMotion();
459
+ let agentReadFocus: ReturnType<typeof latestReadFocus>;
460
+ let followAgent = true;
461
+ const unseenReads = new Set<string>();
462
+ const observedReads = new Set<string>();
463
+ let activityTurn = '';
464
+ let activityInitialized = false;
465
+ let displayedArchiveSource: ArchiveSource | undefined;
466
+ let toolFeedSignature = '';
467
+ let previousLayout = '';
468
+ function fit() {
469
+ if (disposed) return;
470
+ const { width, height, scale, kind } = viewportLayout(root.clientWidth, root.clientHeight, coarse.matches, false);
471
+ stage.style.width = `${width}px`;
472
+ stage.style.height = `${height}px`;
473
+ stage.style.transform = `translate(-50%, -50%) scale(${scale})`;
474
+ stage.dataset.layout = kind;
475
+ stage.dataset.touch = String(coarse.matches);
476
+ stage.style.setProperty('--stage-scale', String(scale));
477
+ boardUiScale = scale;
478
+ scheduleReportPosition();
479
+ stage.style.setProperty('--board-ui-scale', String(1 / scale));
480
+ positionBoardControls();
481
+ root.style.setProperty('--scale', String(scale));
482
+ const visual = window.visualViewport;
483
+ const stageTop = (root.clientHeight - height * scale) / 2;
484
+ stage.style.setProperty('--modal-top', `${Math.max(0, (visual?.offsetTop || 0) - stageTop) / scale}px`);
485
+ stage.style.setProperty('--modal-height', `${Math.min(height, (visual?.height || root.clientHeight) / scale)}px`);
486
+ // CSS scaling and DPR changes do not resize the scene's layout box, so its
487
+ // ResizeObserver alone cannot keep the drawing buffer at display resolution.
488
+ const layoutKey = JSON.stringify([width, height, scale, kind, devicePixelRatio]);
489
+ if (layoutKey !== previousLayout) {
490
+ previousLayout = layoutKey;
491
+ scene?.resize();
492
+ viewer?.resize();
493
+ }
494
+ }
495
+ const resizeObserver = new ResizeObserver(fit);
496
+ resizeObserver.observe(root);
497
+ // Moving to a display with another pixel density need not fire window resize.
498
+ // Re-arm the query against the new density after each change.
499
+ let densityQuery = window.matchMedia(`(resolution: ${devicePixelRatio}dppx)`);
500
+ function densityChanged() {
501
+ if (disposed) return;
502
+ densityQuery.removeEventListener('change', densityChanged);
503
+ densityQuery = window.matchMedia(`(resolution: ${devicePixelRatio}dppx)`);
504
+ densityQuery.addEventListener('change', densityChanged);
505
+ fit();
506
+ }
507
+ densityQuery.addEventListener('change', densityChanged);
508
+ window.addEventListener('resize', fit);
509
+ window.visualViewport?.addEventListener('resize', fit);
510
+ window.visualViewport?.addEventListener('scroll', fit);
511
+ coarse.addEventListener('change', fit);
512
+ fit();
513
+ function syncBoardToolState(tool: EvidenceBoardTool) {
514
+ boardToolState = tool;
515
+ boardPanel.setToolState(tool);
516
+ const text = tool.mode === 'place' ? `点击白板空白处放下${{ note: '短便签', source: '资料卡', question: '疑问签', tag: '关键词标签' }[tool.template]} · Esc 取消`
517
+ : tool.mode === 'connect' ? tool.fromId ? '再点一条线索,连起红绳 · Esc 取消' : '点选第一条线索 · Esc 取消' : '';
518
+ boardToolStatus.querySelector('span')!.textContent = text;
519
+ boardToolStatus.hidden = boardToolsOpen || !text || location !== 'board' || Boolean(selectedSource);
520
+ edgeNavigation?.sync();
521
+ }
522
+ function positionBoardControls() {
523
+ if (!boardFrame || disposed) return;
524
+ const frame = boardFrame, unit = 1 / boardUiScale, margin = 16 * unit;
525
+ const attachedTransform = frame.controls.every(point => point.visible)
526
+ ? boardPlaneTransform(frame.controls, 320, 45) : null;
527
+ boardToolbar.classList.toggle('is-docked', !attachedTransform);
528
+ if (attachedTransform) {
529
+ boardToolbar.style.left = '0'; boardToolbar.style.top = '0';
530
+ boardToolbar.style.transform = attachedTransform;
531
+ } else {
532
+ // Only an offscreen board needs a screen-facing recovery control.
533
+ const x = Math.max(margin, Math.min(frame.width - 240 * unit - margin, frame.corner.x - 240 * unit));
534
+ const y = Math.max(margin, Math.min(frame.height - 40 * unit - margin, frame.corner.y));
535
+ boardToolbar.style.left = `${x}px`; boardToolbar.style.top = `${y}px`;
536
+ boardToolbar.style.transform = `scale(${unit})`;
537
+ }
538
+ const chalkVisible = frame.chalk.visible && frame.chalk.x > 24 * unit && frame.chalk.x < frame.width - 24 * unit
539
+ && frame.chalk.y > 24 * unit && frame.chalk.y < frame.height - 24 * unit;
540
+ boardChalk.style.left = `${frame.chalk.x - 24 * unit}px`; boardChalk.style.top = `${frame.chalk.y - 24 * unit}px`;
541
+ boardChalk.hidden = boardToolbar.hidden || !chalkVisible;
542
+ boardToolsFallback.hidden = chalkVisible;
543
+ boardResetButton.textContent = `${Math.round(frame.zoom * 100)}%`;
544
+ boardZoomOut.disabled = !scene || frame.zoom <= 1.0001;
545
+ boardZoomIn.disabled = !scene || frame.zoom >= 2.9999;
546
+ }
547
+ function boardToolPosition() {
548
+ return boardFrame ? { x: boardFrame.chalk.x, y: boardFrame.chalk.y } : undefined;
549
+ }
550
+ function syncBoardToolbar() {
551
+ const unavailable = root.classList.contains('rhine-scene-unavailable');
552
+ boardToolbar.hidden = location !== 'board' || Boolean(selectedSource) || unavailable || !boardFrame?.visible;
553
+ boardChalk.hidden = boardToolbar.hidden;
554
+ boardToolStatus.hidden = boardToolsOpen || boardToolbar.hidden || boardToolState.mode === 'select';
555
+ if (boardToolbar.hidden && document.activeElement instanceof HTMLElement && (boardToolbar.contains(document.activeElement) || boardChalk === document.activeElement)) document.activeElement.blur();
556
+ boardFullscreenButton.disabled = boardResetButton.disabled = !scene || unavailable;
557
+ boardFullscreenButton.setAttribute('aria-pressed', String(boardFullscreen));
558
+ boardFullscreenButton.setAttribute('aria-label', boardFullscreen ? '退出全屏线索板' : '全屏线索板');
559
+ boardFullscreenButton.title = boardFullscreen ? '退出全屏 · Esc' : '全屏线索板';
560
+ $('.rhine-board-fullscreen-label').textContent = boardFullscreen ? '退出' : '全屏';
561
+ positionBoardControls();
562
+ }
563
+ function setBoardFullscreen(value: boolean) {
564
+ const next = value && location === 'board' && Boolean(scene) && !root.classList.contains('rhine-scene-unavailable');
565
+ if (boardFullscreen === next) { syncBoardToolbar(); return; }
566
+ boardFullscreen = next;
567
+ root.classList.toggle('rhine-board-fullscreen', next);
568
+ fit();
569
+ scene?.setBoardFullscreen(next);
570
+ syncBoardToolbar();
571
+ edgeNavigation?.sync();
572
+ }
573
+ boardFullscreenButton.addEventListener('click', () => setBoardFullscreen(!boardFullscreen));
574
+ boardResetButton.addEventListener('click', () => scene?.resetBoardView());
575
+ boardZoomOut.addEventListener('click', () => scene?.zoomBoard(-1));
576
+ boardZoomIn.addEventListener('click', () => scene?.zoomBoard(1));
577
+ const toggleBoardTools = () => boardPanel.toggleTools(boardToolPosition());
578
+ boardChalk.addEventListener('click', toggleBoardTools);
579
+ boardToolsFallback.addEventListener('click', toggleBoardTools);
580
+ $('.rhine-board-tool-cancel').addEventListener('click', () => { scene?.setBoardTool({ mode: 'select' }); syncBoardToolState({ mode: 'select' }); });
581
+ function clockTick() { if (active && !document.hidden) $('#clock').textContent = new Date().toLocaleTimeString('en-GB', { hour12: false }); }
582
+ const clockTimer = window.setInterval(clockTick, 1000);
583
+ clockTick();
584
+ function setArrayListExpanded(expanded: boolean, immediate = false, restoreFocus = false) {
585
+ arrayListExpanded = expanded;
586
+ arrayList.inert = !expanded;
587
+ stage.dataset.arrayList = expanded ? 'open' : 'closed';
588
+ arrayToggle.hidden = expanded;
589
+ arrayToggle.setAttribute('aria-expanded', String(expanded));
590
+ if (expanded) {
591
+ arrayListTransition.show(immediate || reducedMotion.matches);
592
+ const chosen = archiveSelected && archiveTickNodes.get(archiveSelected);
593
+ if (chosen) revealChoice($('#file-ticks'), chosen);
594
+ $('.rhine-array-collapse').focus({ preventScroll: true });
595
+ } else {
596
+ arrayListTransition.hide(immediate || reducedMotion.matches);
597
+ if (restoreFocus) arrayToggle.focus({ preventScroll: true });
598
+ }
599
+ }
600
+ function syncMode() {
601
+ const detail = Boolean(selectedSource);
602
+ if (detail && arrayListExpanded) setArrayListExpanded(false, true);
603
+ stage.dataset.mode = detail ? 'detail' : 'archive';
604
+ if (detail && !detailWasOpen) { detailTransition.show(reducedMotion.matches); paintDetail(detailVisibility); }
605
+ else if (!detail && detailWasOpen) detailTransition.hide(reducedMotion.matches, () => {
606
+ if (selectedSource) return;
607
+ reader.hidden = true;
608
+ });
609
+ detailWasOpen = detail;
610
+ $('#archive-ui').hidden = detail || location !== 'archive';
611
+ $('#archive-ui').inert = detail || location !== 'archive';
612
+ if (detail) reader.hidden = false;
613
+ $('.rhine-agent-form').hidden = detail || location === 'board';
614
+ $('.rhine-location-button').hidden = detail || location === 'board';
615
+ $('.rhine-zone-nav').hidden = detail;
616
+ boardPanel.setActive(!detail && location === 'board');
617
+ investigation?.setActive(!detail && location === 'board');
618
+ syncBoardToolbar();
619
+ for (const tab of root.querySelectorAll<HTMLElement>('[data-zone]')) tab.setAttribute('aria-pressed', String(tab.dataset.zone === location));
620
+ $('.rhine-nav-board').setAttribute('aria-pressed', String(location === 'board'));
621
+ $('.rhine-desk-panel').hidden = detail || location !== 'desk';
622
+ scene?.setDetail(detail);
623
+ if (reducedMotion.matches || !scene) paintDetail(detail ? 1 : 0);
624
+ syncModalState();
625
+ }
626
+ function paintDetail(visibility: number) {
627
+ detailVisibility = visibility;
628
+ stage.style.setProperty('--detail-shade', String(visibility));
629
+ if (selectedSource) {
630
+ const content = reader;
631
+ content.style.opacity = String(visibility);
632
+ content.style.transform = `translateY(${(1 - visibility) * 18}px)`;
633
+ content.inert = visibility < 0.1;
634
+ if (pendingDetailFocus && visibility >= 0.1 && !panels.some(panel => !panel.hidden) && !viewer?.isOpen) {
635
+ readerMeasure('focus', () => pendingDetailFocus!.focus({ preventScroll: true })); pendingDetailFocus = null;
636
+ }
637
+ }
638
+ }
639
+ function syncReadingObject() {
640
+ const value=investigationReading||(!report.hidden?{title:$('.rhine-report-title').textContent||'调查报告',code:'RESEARCH / LIVE',kind:'report' as const}:null);
641
+ root.classList.toggle('has-reading-object',!!value);scene?.setReadingObject(value);
642
+ }
643
+ function syncModalState() {
644
+ syncReadingObject();
645
+ const modal = panels.find(panel => !panel.hidden);
646
+ if (modal) scene?.setBoardTool({ mode: 'select' });
647
+ for (const child of [...stage.children]) {
648
+ if (child instanceof HTMLElement) child.inert = Boolean(modal && child !== modal && !child.classList.contains('rhine-toast'));
649
+ }
650
+ if (!modal) $('#archive-ui').inert = stage.dataset.mode !== 'archive' || location !== 'archive';
651
+ syncCompletionNotice();
652
+ edgeNavigation?.sync();
653
+ }
654
+ function updateArchiveSelection(id: string | null = archiveSelected, lane = archiveLaneIndex, animate = true) {
655
+ return performancePanel.capture.measureWork('archive-selection', () => updateArchiveSelectionBody(id, lane, animate));
656
+ }
657
+ function updateArchiveSelectionBody(id: string | null, lane: number, animate: boolean) {
658
+ const selected = archiveSources.find(item => item.id === id);
659
+ archiveSelected = selected?.id || null;
660
+ archiveLaneIndex = selected ? archiveLane(selected) : lane;
661
+ const files = archiveSources.filter(item => archiveLane(item) === archiveLaneIndex);
662
+ const index = selected ? files.findIndex(item => item.id === selected.id) : -1;
663
+ if (selected) columnMemory[archiveLaneIndex] = selected.id;
664
+ const following = followAgent ? agentReadFocus : undefined;
665
+ const source = following ? following.source : selected;
666
+ displayedArchiveSource = source; // Keep the current source object even when its visible fields are unchanged.
667
+ const animated = animate && !reducedMotion.matches && !selectedSource;
668
+ const direction = navigationHint ? navigationHint.direction > 0 ? 'up' : 'down' : 'auto';
669
+ const rowDirection = navigationHint?.axis === 'row' ? direction : 'auto';
670
+ const laneDirection = navigationHint?.axis === 'lane' ? direction : 'auto';
671
+ navigationHint = undefined;
672
+ const title = source?.title || (files.length ? '选择一份资料' : '等待新的线索');
673
+ const category = source ? kind(source) : ARCHIVE_LANES[archiveLaneIndex];
674
+ const clearance = following ? following.operation.state === 'active' ? 'Agent 正在查阅' : following.operation.state === 'error' ? '查阅未完成' : 'Agent 已查阅' : source ? source.saved ? '已收藏 · ' + state(source) : state(source) : 'COLLECTION / EMPTY';
675
+ const code = source ? sourceNumber(source) : 0;
676
+ const excerpt = following ? `${following.operation.tool} · ${following.operation.state === 'active' ? '正在读取这份原文' : following.operation.state === 'error' ? '本次查阅未完成' : '本次查阅已返回'},点击可核对内容。` : source?.excerpt || (source ? '打开原文,继续阅读完整内容。' : '可从索引检索这一类资料,或交给 Agent 寻找线索。');
677
+ const first = Math.floor(Math.max(0, index) / 8) * 8;
678
+ const visible = files.slice(first, first + 8);
679
+ const signature = JSON.stringify([archiveSelected, archiveLaneIndex, index, title, category, clearance, code,
680
+ excerpt, Boolean(source), files.length, visible.map(item => [item.id, item.title, item.state, item.saved])]);
681
+ if (signature === archiveSelectionSignature) { uiUpdates.selectionSkipped++; return; }
682
+ archiveSelectionSignature = signature;
683
+ $('#selected-title').textContent = title;
684
+ titles.column.update({ text: ARCHIVE_LANES[archiveLaneIndex], animated });
685
+ titles.category.update({ text: category, animated });
686
+ titles.clearance.update({ text: clearance, animated });
687
+ counters.code.update({ value: code, animated, direction });
688
+ counters.file.update({ value: index + 1, animated, direction: rowDirection });
689
+ counters.column.update({ value: archiveLaneIndex + 1, animated, direction: laneDirection });
690
+ $('.count-total').textContent = String(files.length).padStart(2, '0');
691
+ $('.rhine-selected-excerpt').textContent = excerpt;
692
+ $('.rhine-current-source').dataset.empty = String(!source);
693
+ $<HTMLButtonElement>('.rhine-array-stage').disabled=!source;
694
+ const read = $('.read-file'), label = source ? 'OPEN SOURCE ' : 'ARCHIVE INDEX ';
695
+ if (read.firstChild?.textContent !== label) {
696
+ read.replaceChildren(document.createTextNode(label), node('span', '', '→'));
697
+ }
698
+ const ticks = $('#file-ticks'), ids = new Set(visible.map(item => item.id));
699
+ const retainFocus = ticks.contains(document.activeElement);
700
+ const priorSelected = ticks.dataset.selected;
701
+ ticks.dataset.selected = archiveSelected || '';
702
+ for (const [key, tick] of archiveTickNodes) if (!ids.has(key)) { tick.remove(); archiveTickNodes.delete(key); }
703
+ visible.forEach((item, position) => {
704
+ let tick = archiveTickNodes.get(item.id);
705
+ if (!tick) {
706
+ tick = button('', 'rhine-source-choice', () => selectArchiveSource(item.id), item.title);
707
+ tick.dataset.sourceId = item.id; archiveTickNodes.set(item.id, tick); uiUpdates.ticksCreated++;
708
+ }
709
+ tick.classList.toggle('selected', item.id === archiveSelected);
710
+ tick.setAttribute('aria-pressed', String(item.id === archiveSelected));
711
+ updateSourceChoice(tick, item, sourceNumber(item), origin(item));
712
+ if (ticks.children[position] !== tick) ticks.insertBefore(tick, ticks.children[position] || null);
713
+ });
714
+ ticks.querySelector('.rhine-empty')?.remove();
715
+ if (!files.length) ticks.append(node('p', 'rhine-empty', '此分类还没有资料,可打开资料索引检索。'));
716
+ $('.rhine-array-toggle-count').textContent = `${ARCHIVE_LANES[archiveLaneIndex]} · ${files.length}`;
717
+ $('.rhine-array-range').textContent = files.length ? `${first + 1}–${Math.min(first + 8, files.length)} / ${files.length} 份` : '暂无资料';
718
+ const chosen = archiveSelected && archiveTickNodes.get(archiveSelected);
719
+ if (chosen && location === 'archive') {
720
+ revealChoice(ticks, chosen);
721
+ if (retainFocus && priorSelected !== archiveSelected) chosen.focus({ preventScroll: true });
722
+ }
723
+ $<HTMLButtonElement>('[data-action="prev"]').disabled = first === 0;
724
+ $<HTMLButtonElement>('[data-action="next"]').disabled = first + 8 >= files.length;
725
+ $<HTMLButtonElement>('.rhine-array-read').disabled = !source;
726
+ }
727
+ function syncArchiveSources() {
728
+ activitySources = undefined;
729
+ archiveSources = mergeSourcesInOrder(archiveSources, [...results, ...discoveredSources]);
730
+ scene?.setArchiveSources(archiveSources);
731
+ const current = archiveSources.find(item => item.id === archiveSelected);
732
+ const next = current || archiveSources.find(item => archiveLane(item) === archiveLaneIndex) || archiveSources[0];
733
+ if (next && !current && location === 'archive' && !selectedSource) scene?.selectArchiveSource(next.id, false);
734
+ updateArchiveSelection(next?.id || null, next ? archiveLane(next) : archiveLaneIndex, false);
735
+ }
736
+ function renderFollowMode() {
737
+ $('.rhine-follow-status').textContent = followAgent ? '跟随 Agent 查阅' : `手动浏览${unseenReads.size ? ` · 新查阅 ${unseenReads.size} 次` : ' · 自动跟随已暂停'}`;
738
+ $('.rhine-follow-toggle').textContent = followAgent ? '暂停跟随' : '恢复跟随 ↗';
739
+ $('.rhine-follow-toggle').setAttribute('aria-pressed', String(followAgent));
740
+ }
741
+ function setFollowAgent(value: boolean) {
742
+ followAgent = value; scene?.setFollowAgent(value);
743
+ if (value) { unseenReads.clear(); if (agentReadFocus && !selectedSource) scene?.selectArchiveSource(agentReadFocus.source.id, false); }
744
+ renderFollowMode();
745
+ }
746
+ function selectArchiveSource(id: string) {
747
+ if (selectedSource) return;
748
+ setFollowAgent(false);
749
+ updateArchiveSelection();
750
+ if (scene) { scene.browseArchiveSource(id); return; }
751
+ if (id === archiveSelected || !navigationLimiter.tryAccept(performance.now())) return;
752
+ updateArchiveSelection(id);
753
+ }
754
+ function sourceNumber(source: ArchiveSource) {
755
+ const index = sourceIndex(archiveSources, source);
756
+ return (index >= 0 ? index : sourceIndex(sources, source)) + 1;
757
+ }
758
+ function navigateArchivePage(direction: number) {
759
+ const files = archiveSources.filter(item => archiveLane(item) === archiveLaneIndex);
760
+ const index = files.findIndex(item => item.id === archiveSelected);
761
+ const target = (Math.floor(Math.max(0, index) / 8) + direction) * 8;
762
+ if (files[target]) selectArchiveSource(files[target].id);
763
+ }
764
+ function navigateArchive(axis: 'row' | 'lane', direction: number) {
765
+ if (selectedSource) return;
766
+ setFollowAgent(false);
767
+ updateArchiveSelection();
768
+ navigationHint = { axis, direction };
769
+ if (scene) { scene.navigate(axis, direction); navigationHint = undefined; return; }
770
+ if (!navigationLimiter.tryAccept(performance.now())) { navigationHint = undefined; return; }
771
+ const lane = axis === 'lane' ? (archiveLaneIndex + direction + ARCHIVE_LANES.length) % ARCHIVE_LANES.length : archiveLaneIndex;
772
+ const files = archiveSources.filter(item => archiveLane(item) === lane);
773
+ const index = files.findIndex(item => item.id === archiveSelected);
774
+ const id = axis === 'row' ? files[(index + direction + files.length) % files.length]?.id
775
+ : files.find(item => item.id === columnMemory[lane])?.id || files[0]?.id;
776
+ updateArchiveSelection(id || null, lane);
777
+ }
778
+ function openCurrentArchive() {
779
+ const source = displayedArchiveSource || archiveSources.find(item => item.id === archiveSelected);
780
+ if (source) void openSource(source); else openIndex();
781
+ }
782
+ function closeModals(finished?: () => void, immediate = false) {
783
+ const visible = panels.filter(panel => !panel.hidden);
784
+ if (!visible.length) { syncModalState(); finished?.(); return; }
785
+ let remaining = visible.length;
786
+ for (const panel of visible) modalTransitions.get(panel)!.hide(immediate || reducedMotion.matches, () => {
787
+ syncModalState();
788
+ if (--remaining === 0) finished?.();
789
+ });
790
+ }
791
+ function openIndex() {
792
+ showPanel(resultsPanel); renderResults(); input.focus(); void manualSearch.refresh();
793
+ if (!catalogueLoaded && !searchBusy && manualSearch.mode === 'local') void search(false, true);
794
+ }
795
+ function renderViewer(time: number) {
796
+ viewerFrame = 0;
797
+ if (disposed || !active || document.hidden || !viewer?.isOpen) return;
798
+ viewer.update(time / 1000, time);
799
+ viewerFrame = requestAnimationFrame(renderViewer);
800
+ }
801
+ const viewerVisibility = () => {
802
+ if (document.hidden) { cancelAnimationFrame(viewerFrame); viewerFrame = 0; }
803
+ else if (!disposed && active && viewer?.isOpen && !viewerFrame) viewerFrame = requestAnimationFrame(renderViewer);
804
+ };
805
+ document.addEventListener('visibilitychange', viewerVisibility);
806
+ function openModelViewer() {
807
+ if (!scene) { toast('档案模型正在载入,请稍后再试。'); return; }
808
+ try {
809
+ if (!viewer) viewer = new ModelViewer(stage, () => {
810
+ if (disposed) return;
811
+ cancelAnimationFrame(viewerFrame); viewerFrame = 0;
812
+ scene?.setActive(active && !document.hidden);
813
+ edgeNavigation?.sync();
814
+ }, undefined, performancePanel.capture);
815
+ viewer.setQuality(quality);
816
+ scene.finishDecryption();
817
+ if (!selectedSource) return;
818
+ const provider = scene;
819
+ viewer.open(selectedSource.id, selectedSource.title, () => provider.createAssemblyModel(), reducedMotion.matches);
820
+ edgeNavigation?.sync();
821
+ scene.setActive(false);
822
+ if (!viewerFrame) viewerFrame = requestAnimationFrame(renderViewer);
823
+ } catch (error) { toast(`模型查看器暂时无法打开:${plainError(error)}`); }
824
+ }
825
+
826
+ function toast(message: string) {
827
+ if (disposed) return;
828
+ const element = $('.rhine-toast');
829
+ element.textContent = message;
830
+ element.hidden = false;
831
+ clearTimeout(toastTimeout);
832
+ toastTimeout = window.setTimeout(() => { element.hidden = true; }, 4500);
833
+ }
834
+ function storageKey() { return `prts-rhine-desk:v1:${encodeURIComponent(snapshot.sessionId || 'default')}`; }
835
+ function loadSaved() {
836
+ manualSources = [];
837
+ extracts = [];
838
+ try {
839
+ const saved: Persisted = JSON.parse(localStorage.getItem(storageKey()) || '{}');
840
+ manualSources = Array.isArray(saved.sources) ? saved.sources.filter(s => s && typeof s.id === 'string' && typeof s.title === 'string').slice(-MAX_SAVED).map(source => ({ ...source, saved: true })) : [];
841
+ extracts = Array.isArray(saved.extracts) ? saved.extracts.filter(e => e && typeof e.text === 'string' && e.source && typeof e.source.id === 'string').slice(-MAX_EXTRACTS) : [];
842
+ } catch { /* The workspace remains fully usable without browser persistence. */ }
843
+ void archiveRack.setSession(snapshot.sessionId,manualSources).catch(()=>{});
844
+ }
845
+ function persist() {
846
+ try { localStorage.setItem(storageKey(), JSON.stringify({ sources: manualSources.slice(-MAX_SAVED), extracts: extracts.slice(-MAX_EXTRACTS) })); }
847
+ catch { toast('浏览器存储不可用,本次打开期间仍可翻阅资料。'); }
848
+ }
849
+ function closeReader(restoreFocus = true) {
850
+ const origin = restoreFocus ? readerOrigin : null;
851
+ readerOrigin = null; readerReturn = null;
852
+ cancelAnimationFrame(originRestoreFrame);
853
+ $('.rhine-reader-back-report').hidden = true;
854
+ const wasOpen = Boolean(selectedSource);
855
+ if (!wasOpen) reader.hidden = true;
856
+ cancelReaderPosition(); readController?.abort(); readController = undefined;
857
+ readerBusy = false; selectedSource = undefined; pendingDetailFocus = null;
858
+ scene?.select(null); root.classList.remove('has-reader'); syncMode();
859
+ if (origin && origin.sessionId === snapshot.sessionId) {
860
+ if (location !== origin.location) setLocation(origin.location);
861
+ if (origin.kind === 'investigation') {
862
+ setBoardFullscreen(investigationReturn.fullscreen);
863
+ investigation?.resumeReading(investigationReturn.kind);
864
+ } else if (origin.kind === 'report') openReport(true);
865
+ else {
866
+ const panel = {search: resultsPanel, rack: rackIndex, basket, log}[origin.kind as 'search'|'rack'|'basket'|'log'];
867
+ if (panel) showPanel(panel);
868
+ }
869
+ if (origin.arrayExpanded && origin.location === 'archive') setArrayListExpanded(true, true);
870
+ originRestoreFrame = requestAnimationFrame(() => {
871
+ originRestoreFrame = 0;
872
+ if (!disposed && !selectedSource && origin.sessionId === snapshot.sessionId) restoreReadingOrigin(root, origin);
873
+ });
874
+ } else if (restoreFocus && lastFocus?.isConnected && !lastFocus.closest('[hidden],[inert]')) lastFocus.focus({preventScroll:true});
875
+ }
876
+ function showPanel(panel: HTMLElement) {
877
+ for (const other of panels) if (other !== panel && !other.hidden) modalTransitions.get(other)!.hide(true);
878
+ closeReader(false);
879
+ modalTransitions.get(panel)!.show(reducedMotion.matches);
880
+ syncModalState();
881
+ }
882
+ function hostBlocked() { return hostActionBusy || Boolean(hostState?.busy || hostState?.loading); }
883
+ function submissionBlocked() { return options.agentAvailable === false || extracting || hostBlocked() || hostState?.canSubmit === false; }
884
+ function renderHostChoice() {
885
+ if (!hostState) return;
886
+ const canChangeMode = hostState.blank && !hostState.running;
887
+ const mode = hostState.modes.find(item => item.id === modeSelect.value);
888
+ $('#rhine-mode-description').textContent = mode?.description || (hostState.loading ? '正在读取可用模式…' : hostState.modes.length ? '' : '暂未读取到可用模式,请刷新后重试。');
889
+ $('#rhine-mode-hint').textContent = canChangeMode ? '当前是空白对话,可在提问前选择模式。' : '当前对话已开始。选择其他模式会新建对话,已有调查记录保留在历史会话中。';
890
+ const apply = $<HTMLButtonElement>('.rhine-mode-apply');
891
+ apply.textContent = canChangeMode ? modeSelect.value === hostState.mode ? '当前模式' : '使用此模式' : '用此模式新建对话';
892
+ apply.disabled = hostBlocked() || extracting || !mode || Boolean(canChangeMode && modeSelect.value === hostState.mode);
893
+ $<HTMLButtonElement>('.rhine-model-apply').disabled = hostBlocked() || extracting || !hostState.models.some(item => item.id === modelSelect.value) || modelSelect.value === hostState.model;
894
+ const modeName = mode?.name || '';
895
+ $('.rhine-session-create').textContent = hostState.blank ? '+ 另开新对话' : '+ 新建对话';
896
+ $('.rhine-session-create').title = modeName ? `用“${modeName}”新建对话` : '新建对话';
897
+ }
898
+ const hostSessionNodes = new Map<string, HTMLButtonElement>();
899
+ function renderHostControls() { return performancePanel.capture.measureWork('host-controls', renderHostControlsBody); }
900
+ function renderHostControlsBody() {
901
+ const signature = JSON.stringify([hostState, hostActionBusy, hostActionError, extracting]);
902
+ if (signature === hostPaintSignature) return;
903
+ hostPaintSignature = signature;
904
+ $('.rhine-session-controls').hidden = !options.host;
905
+ $('.rhine-agent-settings').hidden = !options.host;
906
+ root.classList.toggle('has-host-controls', Boolean(options.host));
907
+ if (!hostState) return;
908
+ const busy = hostBlocked() || extracting;
909
+ const modeChanged = hostMode !== hostState.mode;
910
+ const modelChanged = hostModel !== hostState.model;
911
+ const sessionChanged = hostSessionId !== hostState.sessionId;
912
+ if (sessionChanged) { hostActionError = ''; hostRetry = undefined; }
913
+ $('.rhine-session-history').title = `当前会话:${hostState.title || '新对话'} · 切换历史会话`;
914
+ const currentMode = hostState.modes.find(item => item.id === hostState!.mode)?.name || hostState.mode || '选择模式';
915
+ const currentModel = hostState.models.find(item => item.id === hostState!.model)?.name || '选择模型';
916
+ $('.rhine-session-mode span').textContent = currentMode;
917
+ $('.rhine-session-mode').title = `调查模式:${currentMode}`;
918
+ $('.rhine-session-mode').setAttribute('aria-label', `选择调查模式,当前:${currentMode}`);
919
+ $('.rhine-session-model span').textContent = currentModel;
920
+ $('.rhine-session-model').title = `当前模型:${currentModel}`;
921
+ $('.rhine-session-model').setAttribute('aria-label', `选择模型,当前:${currentModel}`);
922
+ $('.rhine-session-workspace').textContent = hostState.workspace ? `当前工作区 · ${hostState.workspace}` : '当前工作区尚未选择';
923
+ $('.rhine-session-notice').textContent = busy ? hostState.loading && !hostActionBusy ? '正在读取对话设置…' : '正在更新对话,请稍候…' : hostState.blank ? '空白对话 · 设置模式后即可开始调查' : '切换会话后,可继续该会话的调查。';
924
+ const error = hostActionError || hostState.error || '';
925
+ $('.rhine-session-error').hidden = !error;
926
+ $('.rhine-session-error p').textContent = error;
927
+ $('.rhine-session-alert').hidden = !error;
928
+ $('.rhine-session-alert').title = error;
929
+ $('.rhine-session-controls').setAttribute('aria-busy', String(busy));
930
+ $('.rhine-agent-settings').setAttribute('aria-busy', String(busy));
931
+ sessionPanel.setAttribute('aria-busy', String(busy));
932
+ const nextModeSignature = JSON.stringify(hostState.modes);
933
+ if (nextModeSignature !== modeSignature) {
934
+ const draft = modeSelect.value;
935
+ modeSelect.replaceChildren(...hostState.modes.map(item => { const option = node('option', '', item.name); option.value = item.id; return option; }));
936
+ modeSelect.value = !sessionChanged && !modeChanged && hostState.modes.some(item => item.id === draft) ? draft : hostState.mode;
937
+ modeSignature = nextModeSignature;
938
+ } else if (sessionChanged || modeChanged) modeSelect.value = hostState.mode;
939
+ const nextModelSignature = JSON.stringify(hostState.models);
940
+ if (nextModelSignature !== modelSignature) {
941
+ const draft = modelSelect.value;
942
+ modelSelect.replaceChildren(...hostState.models.map(item => { const option = node('option', '', item.name); option.value = item.id; return option; }));
943
+ modelSelect.value = !sessionChanged && !modelChanged && hostState.models.some(item => item.id === draft) ? draft : hostState.model;
944
+ modelSignature = nextModelSignature;
945
+ } else if (sessionChanged || modelChanged) modelSelect.value = hostState.model;
946
+ hostSessionId = hostState.sessionId; hostMode = hostState.mode; hostModel = hostState.model;
947
+ $('.rhine-session-model-field').hidden = !hostState.models.length;
948
+ modeSelect.disabled = busy || !hostState.modes.length;
949
+ modelSelect.disabled = busy || !hostState.models.length;
950
+ for (const control of root.querySelectorAll<HTMLButtonElement>('.rhine-session-mode,.rhine-session-model,.rhine-session-new,.rhine-session-history,.rhine-session-create,.rhine-session-refresh,.rhine-session-retry')) control.disabled = busy;
951
+ const nextSessionsSignature = JSON.stringify([hostState.sessionId, hostState.loading, hostState.sessions]);
952
+ if (nextSessionsSignature !== sessionsSignature) {
953
+ const list = $('.rhine-session-list');
954
+ const ids = new Set(hostState.sessions.map(item => item.id));
955
+ for (const [id, entry] of hostSessionNodes) if (!ids.has(id)) { entry.remove(); hostSessionNodes.delete(id); }
956
+ list.querySelector('.rhine-session-empty')?.remove();
957
+ hostState.sessions.forEach((item, index) => {
958
+ let entry = hostSessionNodes.get(item.id);
959
+ if (!entry) {
960
+ entry = button('', 'rhine-session-item', () => { if (options.host) void runHostAction(() => options.host!.openSession(item.id), true); });
961
+ entry.append(node('strong'), node('span', 'rhine-session-item-meta'));
962
+ hostSessionNodes.set(item.id, entry);
963
+ }
964
+ entry.querySelector('strong')!.textContent = item.title || '未命名对话';
965
+ const date = new Date(item.updatedAt);
966
+ entry.querySelector('span')!.textContent = [item.id === hostState!.sessionId ? '当前会话' : '', item.running ? '调查进行中' : '', item.workspace, Number.isFinite(date.getTime()) && item.updatedAt > 0 ? date.toLocaleDateString('zh-CN') : ''].filter(Boolean).join(' · ');
967
+ entry.setAttribute('aria-current', item.id === hostState!.sessionId ? 'true' : 'false');
968
+ entry.title = [item.title, item.workspace].filter(Boolean).join(' · ');
969
+ if (list.children[index] !== entry) list.insertBefore(entry, list.children[index] || null);
970
+ });
971
+ if (!hostState.sessions.length) list.append(node('p', 'rhine-session-empty', hostState.loading ? '正在读取历史会话…' : '暂无历史会话。可以新建对话开始调查。'));
972
+ sessionsSignature = nextSessionsSignature;
973
+ }
974
+ for (const [id, entry] of hostSessionNodes) entry.disabled = busy || id === hostState.sessionId;
975
+ const cancel = $<HTMLButtonElement>('.rhine-agent-cancel');
976
+ cancel.hidden = !hostState.running;
977
+ cancel.disabled = busy;
978
+ renderHostChoice();
979
+ }
980
+ async function runHostAction(action: () => Promise<void>, closeOnSuccess = false) {
981
+ if (!options.host || hostBlocked() || extracting) return;
982
+ hostActionBusy = true; hostActionError = ''; hostRetry = undefined;
983
+ renderAgentControls();
984
+ try {
985
+ await action();
986
+ if (disposed) return;
987
+ if (closeOnSuccess) closeModals(() => agentInput.focus());
988
+ } catch (error) {
989
+ if (disposed) return;
990
+ hostActionError = plainError(error);
991
+ hostRetry = () => runHostAction(action, closeOnSuccess);
992
+ toast(hostActionError);
993
+ } finally {
994
+ if (!disposed) {
995
+ hostActionBusy = false;
996
+ hostState = options.host.getState();
997
+ renderAgentControls();
998
+ }
999
+ }
1000
+ }
1001
+ function openHostPanel(trigger: HTMLElement, focus: 'mode' | 'model' | 'history' = 'mode') {
1002
+ if (!options.host) return;
1003
+ hostTrigger = trigger;
1004
+ showPanel(sessionPanel); renderHostControls();
1005
+ const focusChoice = () => {
1006
+ const choice = focus === 'history' ? $('.rhine-session-list') : focus === 'model' ? modelSelect : modeSelect;
1007
+ const target = choice.matches(':disabled') || choice.closest('[hidden]') ? $('.rhine-close-session') : choice;
1008
+ target.focus();
1009
+ target.scrollIntoView({ block: 'nearest' });
1010
+ };
1011
+ if (!hostBlocked() && !hostActionError && !hostState?.error) {
1012
+ $('.rhine-close-session').focus();
1013
+ void runHostAction(() => options.host!.refresh()).then(() => {
1014
+ if (!disposed && !sessionPanel.hidden && hostTrigger === trigger && document.activeElement === $('.rhine-close-session')) focusChoice();
1015
+ });
1016
+ } else focusChoice();
1017
+ }
1018
+ function setLocation(next: Location) {
1019
+ if (next !== location) setArrayListExpanded(false, true);
1020
+ if (next !== 'board') setBoardFullscreen(false);
1021
+ location = next;
1022
+ root.dataset.location = next;
1023
+ $('.rhine-location-title').replaceChildren(document.createTextNode(next === 'desk' ? '检索阵列 ' : '调查档案架 '), node('b', '', next === 'desk' ? '←' : '→'));
1024
+ $('.rhine-location-button').setAttribute('aria-label', next === 'desk' ? '镜头移回检索阵列' : `镜头右移到调查档案架,已有 ${sources.length} 份资料`);
1025
+ closeModals(undefined, true);
1026
+ closeReader(false);
1027
+ scene?.setLocation(next);
1028
+ syncMode(); renderStatus();
1029
+ }
1030
+ function renderInvestigationContext() {
1031
+ const context=investigationContext;
1032
+ const signature=JSON.stringify([context,evidenceTarget]);
1033
+ if(signature===contextSignature)return;
1034
+ contextSignature=signature;
1035
+ const working=context.boards.find(board=>board.id===context.workingId);
1036
+ const fallback=context.boards.find(board=>board.id===context.selectedId);
1037
+ const autoTitle=working ? `工作板 · ${working.title}` : fallback ? `当前板 · ${fallback.title}` : '首次放入时新建调查板';
1038
+ for(const select of root.querySelectorAll<HTMLSelectElement>('.rhine-evidence-target select')){
1039
+ select.replaceChildren();
1040
+ const addOption=(value:string,label:string)=>{const option=node('option','',label) as HTMLOptionElement;option.value=value;select.append(option);};
1041
+ addOption('auto',context.ready?autoTitle:'正在读取调查板…');
1042
+ for(const board of context.boards)addOption(board.id,board.title);
1043
+ if(evidenceTarget!=='auto'&&!context.boards.some(board=>board.id===evidenceTarget))addOption(evidenceTarget,'目标调查板不可用 · 请重新选择');
1044
+ select.value=evidenceTarget;select.disabled=!context.ready;
1045
+ select.title=select.selectedOptions[0]?.textContent||'';
1046
+ }
1047
+ $('.rhine-agent-board-context').textContent=working?`Agent 工作板:${working.title}`:'新建或延续调查,由 Agent 判断';
1048
+ }
1049
+ function pinToBoard(source: ArchiveSource) {
1050
+ if(!investigationContext.ready){toast('正在读取调查板,请稍候再放入证据盒。');return;}
1051
+ const target=evidenceTarget==='auto'?(investigationContext.workingId||investigationContext.selectedId):evidenceTarget;
1052
+ if(target&&!investigationContext.boards.some(board=>board.id===target)){toast('目标调查板已不可用,请重新选择证据盒归属。');return;}
1053
+ void investigation?.stageSource(source,target).catch(error => toast(error?.message || '资料尚未放入证据盒'));
1054
+ }
1055
+ function mergeSources(animate = true) { return performancePanel.capture.measureWork('mergeSources', () => mergeSourcesBody(animate)); }
1056
+ function mergeSourcesBody(animate: boolean) {
1057
+ uiUpdates.sourceMerges++;
1058
+ activitySources = undefined;
1059
+ discoveredSources = mergeSourcesInOrder(discoveredSources, [...snapshot.sources || [], ...manualSources]);
1060
+ sources = mergeSourcesInOrder(sources, discoveredSources.filter(belongsOnShelf));
1061
+ const signature = JSON.stringify(sources);
1062
+ scene?.setInvestigation(snapshot);
1063
+ syncArchiveSources();
1064
+ if (signature !== sourceSignature) {
1065
+ sourceSignature = signature;
1066
+ scene?.setSources(sources, animate);
1067
+ renderDesk();
1068
+ if (selectedSource) { updateReaderAnnotations(); updateReaderSave(); }
1069
+ if (!log.hidden) renderLog();
1070
+ if (!rackIndex.hidden) renderRackIndex();
1071
+ }
1072
+ for (const selector of ['.rhine-desk-count', '.rhine-location-count', '.rhine-rack-count']) $(selector).textContent = String(sources.length).padStart(2, '0');
1073
+ $('.rhine-location-button').setAttribute('aria-label', location === 'desk' ? '镜头移回检索阵列' : `镜头右移到调查档案架,已有 ${sources.length} 份资料`);
1074
+ }
1075
+ function addToDesk(source: ArchiveSource, notify = true) {
1076
+ const session=snapshot.sessionId;
1077
+ void archiveRack.save(source).then(()=>{
1078
+ if(!disposed&&session===snapshot.sessionId&&notify)toast('资料已收入档案架,Agent 下次推理时会收到查看提醒。');
1079
+ }).catch(()=>{});
1080
+ }
1081
+ function updateReaderSave() {
1082
+ const saved = selectedSource && sources.some(s => sameSource(s, selectedSource!) && s.saved);
1083
+ const saveButton = $<HTMLButtonElement>('.rhine-read-save');
1084
+ const pending = selectedSource && archiveRack.isPending(selectedSource);
1085
+ saveButton.textContent = pending ? '↻ 重试同步到档案架' : saved ? '✓ 已收藏到档案架' : '+ 收藏到档案架';
1086
+ saveButton.disabled = Boolean(saved && !pending);
1087
+ }
1088
+ function pagination(target: HTMLElement, page: number, count: number, change: (page: number) => void) {
1089
+ target.replaceChildren();
1090
+ const pages = Math.max(1, Math.ceil(count / PAGE_SIZE));
1091
+ if (pages <= 1) return;
1092
+ const previous = button('←', 'rhine-page-button', () => change(page - 1), '上一页');
1093
+ const next = button('→', 'rhine-page-button', () => change(page + 1), '下一页');
1094
+ previous.disabled = page <= 0;
1095
+ next.disabled = page >= pages - 1;
1096
+ target.append(previous, node('span', '', `${String(page + 1).padStart(2, '0')} / ${String(pages).padStart(2, '0')}`), next);
1097
+ }
1098
+ function focusShelf(id: string | null, page = deskPage) {
1099
+ shelfSelected = id;
1100
+ deskPage = page;
1101
+ renderDesk();
1102
+ }
1103
+ function setShelfPage(page: number) {
1104
+ setFollowAgent(false);
1105
+ closeReader(false);
1106
+ deskPage = Math.max(0, Math.min(page, Math.max(0, Math.ceil(sources.length / SHELF_PAGE_SIZE) - 1)));
1107
+ shelfSelected = sources[deskPage * SHELF_PAGE_SIZE]?.id || null;
1108
+ scene?.setShelfPage(deskPage);
1109
+ renderDesk();
1110
+ }
1111
+ function shelfQuery() { return $<HTMLInputElement>('.rhine-shelf-filter').value.trim().toLocaleLowerCase(); }
1112
+ function chooseShelfSource(id: string) {
1113
+ setFollowAgent(false);
1114
+ const target = sources.findIndex(source => source.id === id);
1115
+ if (target < 0) return;
1116
+ const current = sources.findIndex(source => source.id === shelfSelected);
1117
+ if (scene) scene.browseShelf(target - Math.max(0, current));
1118
+ else focusShelf(id, Math.floor(target / SHELF_PAGE_SIZE));
1119
+ }
1120
+ function revealChoice(list: HTMLElement, row: HTMLElement) {
1121
+ if (!list.getClientRects().length) return;
1122
+ const bounds = list.getBoundingClientRect(), selected = row.getBoundingClientRect();
1123
+ // Scroll only this list; scrollIntoView can move the scaled scene and modal ancestors.
1124
+ const scale = bounds.height / list.clientHeight || 1;
1125
+ if (selected.top < bounds.top) list.scrollTop -= (bounds.top - selected.top) / scale;
1126
+ else if (selected.bottom > bounds.bottom) list.scrollTop += (selected.bottom - bounds.bottom) / scale;
1127
+ }
1128
+ function browseShelf(direction: number) {
1129
+ const query = shelfQuery();
1130
+ const choices = query ? sources.filter(source => matchesSource(source, query)) : sources;
1131
+ if (!choices.length) return;
1132
+ const index = choices.findIndex(source => source.id === shelfSelected);
1133
+ const next = index < 0 ? direction < 0 ? choices.length - 1 : 0 : (index + direction + choices.length) % choices.length;
1134
+ chooseShelfSource(choices[next].id);
1135
+ }
1136
+ function renderDesk() { return performancePanel.capture.measureWork('renderDesk', renderDeskBody); }
1137
+ function renderDeskBody() {
1138
+ const list = $('.rhine-desk-list');
1139
+ const query = shelfQuery();
1140
+ const previousSelected = list.dataset.selected;
1141
+ const retainFocus = list.contains(document.activeElement);
1142
+ deskPage = Math.min(deskPage, Math.max(0, Math.ceil(sources.length / SHELF_PAGE_SIZE) - 1));
1143
+ const pageSources = sources.slice(deskPage * SHELF_PAGE_SIZE, (deskPage + 1) * SHELF_PAGE_SIZE);
1144
+ const visible = query ? sources.filter(source => matchesSource(source, query)) : pageSources;
1145
+ $('.rhine-rack-range').textContent = query ? `全部档案 · 匹配 ${visible.length} / ${sources.length} 份` : sources.length ? `本架 ${deskPage * SHELF_PAGE_SIZE + 1}–${Math.min(sources.length, (deskPage + 1) * SHELF_PAGE_SIZE)} / 共 ${sources.length} 份资料` : 'Agent 读过或手动收藏后入架';
1146
+ $('.rhine-shelf-clear').hidden = !query;
1147
+ $('.rhine-rack-directory').firstChild!.textContent = `全部资料目录 · ${sources.length} `;
1148
+ if (!pageSources.some(source => source.id === shelfSelected)) shelfSelected = pageSources[0]?.id || null;
1149
+ const focused = sources.find(source => source.id === shelfSelected);
1150
+ $('.rhine-shelf-number').textContent = focused ? `已选 S-${String(sourceNumber(focused)).padStart(3, '0')} / ${shelfSlot(sources.indexOf(focused)).level === 0 ? '下层' : '上层'}` : 'NO SOURCES YET';
1151
+ $('.rhine-shelf-title').textContent = focused?.title || '留一个位置,给下一条线索。';
1152
+ $('.rhine-shelf-meta').textContent = focused ? `${kind(focused)} / ${origin(focused)} / ${state(focused)}${focused.saved ? ' / 已收藏' : ''}` : '';
1153
+ $('.rhine-shelf-excerpt').textContent = focused?.excerpt || (focused ? '抽取档案,查看完整原文和 Agent 实际读过的段落。' : 'Agent 实际读过或你主动收藏的资料,留在这里供回查。');
1154
+ $<HTMLButtonElement>('.rhine-shelf-open').disabled = !focused;
1155
+ $<HTMLButtonElement>('.rhine-shelf-stage').disabled = !focused;
1156
+ for (const selector of ['.rhine-shelf-prev', '.rhine-shelf-next']) $<HTMLButtonElement>(selector).disabled = visible.length < 2;
1157
+ const ids = new Set(visible.map(source => source.id));
1158
+ for (const [id, row] of shelfChoiceNodes) if (!ids.has(id)) { row.remove(); shelfChoiceNodes.delete(id); }
1159
+ list.querySelector('.rhine-empty')?.remove();
1160
+ visible.forEach((source, position) => {
1161
+ const index = sources.indexOf(source), level = shelfSlot(index).level === 0 ? '下层' : '上层';
1162
+ let slot = shelfChoiceNodes.get(source.id);
1163
+ if (!slot) {
1164
+ slot = button('', 'rhine-desk-item rhine-source-choice', () => chooseShelfSource(source.id));
1165
+ slot.dataset.sourceId = source.id;
1166
+ shelfChoiceNodes.set(source.id, slot);
1167
+ }
1168
+ updateSourceChoice(slot, source, sourceNumber(source), `${query ? `第 ${Math.floor(index / SHELF_PAGE_SIZE) + 1} 架 · ` : ''}${level}`);
1169
+ slot.dataset.level = String(shelfSlot(index).level);
1170
+ slot.dataset.saved = String(Boolean(source.saved));
1171
+ slot.classList.toggle('is-selected', source.id === shelfSelected);
1172
+ slot.setAttribute('aria-pressed', String(source.id === shelfSelected));
1173
+ if (list.children[position] !== slot) list.insertBefore(slot, list.children[position] || null);
1174
+ });
1175
+ if (!visible.length) list.append(node('p', 'rhine-empty', query ? '没有匹配的档案,试试更短的标题或清除筛选。' : 'Agent 实际读过或你主动收藏的资料,会按入架顺序显示在这里。'));
1176
+ list.dataset.selected = shelfSelected || '';
1177
+ if (previousSelected !== list.dataset.selected && location === 'desk') {
1178
+ const chosen = shelfSelected && shelfChoiceNodes.get(shelfSelected);
1179
+ if (chosen) { revealChoice(list, chosen); if (retainFocus) chosen.focus({ preventScroll: true }); }
1180
+ }
1181
+ const controls = $('.rhine-desk-pagination');
1182
+ controls.hidden = Boolean(query);
1183
+ const pages = Math.max(1, Math.ceil(sources.length / SHELF_PAGE_SIZE));
1184
+ if (!controls.children.length) controls.append(
1185
+ button('← 上一架', 'rhine-page-button', () => setShelfPage(deskPage - 1), '上一架资料'),
1186
+ node('span'), button('下一架 →', 'rhine-page-button', () => setShelfPage(deskPage + 1), '下一架资料'));
1187
+ (controls.firstElementChild as HTMLButtonElement).disabled = deskPage === 0;
1188
+ (controls.lastElementChild as HTMLButtonElement).disabled = deskPage === pages - 1;
1189
+ controls.querySelector('span')!.textContent = `${String(deskPage + 1).padStart(2, '0')} / ${String(pages).padStart(2, '0')}`;
1190
+ $('.rhine-shelf-report').hidden = !snapshot.answer;
1191
+ }
1192
+ const rackNodes = new Map<string, HTMLButtonElement>();
1193
+ function renderRackIndex() { return performancePanel.capture.measureWork('renderRackIndex', renderRackIndexBody); }
1194
+ function renderRackIndexBody() {
1195
+ const query = $<HTMLInputElement>('.rhine-rack-filter').value.trim().toLocaleLowerCase();
1196
+ const matches = sources.filter(source => matchesSource(source, query));
1197
+ const list = $('.rhine-rack-index-list');
1198
+ const visibleIds = new Set(matches.map(source => source.id));
1199
+ for (const [id, row] of rackNodes) if (!visibleIds.has(id)) { row.remove(); rackNodes.delete(id); }
1200
+ list.querySelector('.rhine-empty')?.remove();
1201
+ matches.forEach((source, index) => {
1202
+ let row = rackNodes.get(source.id);
1203
+ if (!row) {
1204
+ row = button('', 'rhine-rack-index-item', () => { void openSource(sources.find(item => item.id === source.id) || source, 'rack'); });
1205
+ row.append(node('small'), node('strong'), node('span'));
1206
+ row.dataset.sourceId = source.id;
1207
+ rackNodes.set(source.id, row);
1208
+ }
1209
+ row.querySelector('small')!.textContent = `S-${String(sourceNumber(source)).padStart(3, '0')}`;
1210
+ row.querySelector('strong')!.textContent = source.title;
1211
+ row.querySelector('span')!.textContent = `${kind(source)} · ${origin(source)} · ${state(source)} ↗`;
1212
+ if (list.children[index] !== row) list.insertBefore(row, list.children[index] || null);
1213
+ });
1214
+ if (!matches.length) list.append(node('p', 'rhine-empty', sources.length ? '没有匹配的资料,换一个关键词试试。' : 'Agent 已读或你手动收藏的资料,会在这里按入架顺序显示。'));
1215
+ $('.rhine-rack-index-count').textContent = `显示 ${matches.length} / ${sources.length} 份资料 · 点击标题打开原文`;
1216
+ }
1217
+ function openRackIndex() {
1218
+ showPanel(rackIndex); renderRackIndex(); $('.rhine-rack-filter').focus();
1219
+ }
1220
+ function renderResults() { return performancePanel.capture.measureWork('renderResults', renderResultsBody); }
1221
+ function renderResultsBody() {
1222
+ $('.rhine-index-provenance').textContent = searchMode === 'cloud' ? 'PRTS / CLOUD RETRIEVAL' : 'PRTS / LOCAL CORPUS';
1223
+ $('.rhine-search-connection').textContent = searchBusy ? 'RETRIEVING / 检索中' : searchMode === 'cloud' ? 'CLOUD / 云端资料' : 'LOCAL / 本地资料';
1224
+ $('.rhine-search-cancel').hidden = !searchBusy;
1225
+ $<HTMLButtonElement>('.rhine-search-submit').disabled = searchBusy;
1226
+ $<HTMLButtonElement>('.rhine-search-submit').textContent = searchBusy ? '正在检索…' : '检索资料 ↵';
1227
+ const filters = $('.category-filters');
1228
+ filters.replaceChildren();
1229
+ ['全部资料', ...ARCHIVE_LANES].forEach((label, index) => filters.append(button(label, resultFilter === index - 1 ? 'active' : '', () => { resultFilter = index - 1; resultsPage = 0; renderResults(); })));
1230
+ const matching = resultFilter < 0 ? results : results.filter(source => archiveLane(source) === resultFilter);
1231
+ resultsPage = Math.min(resultsPage, Math.max(0, Math.ceil(matching.length / PAGE_SIZE) - 1));
1232
+ const list = $('.rhine-result-list');
1233
+ list.replaceChildren();
1234
+ const queryLabel = searchQuery ? `「${searchQuery}」` : '资料目录';
1235
+ $('.rhine-result-summary').textContent = searchBusy ? `正在检索${queryLabel}…` : searchError || `${searchMode === 'cloud' ? '云端' : '本地'} · ${queryLabel} · 已载入 ${results.length} 份资料${searchWarning ? ` · ${searchWarning}` : ''}`;
1236
+ if (!matching.length && !searchBusy) list.append(node('p', 'rhine-empty', searchError ? '调整设置后可重新检索。' : searchMode === 'cloud' && !searchQuery ? '输入问题,再点击检索资料。云端检索由你手动开始。' : '没有找到匹配资料。试试人物名称、活动名称,或更短的关键词。'));
1237
+ matching.slice(resultsPage * PAGE_SIZE, (resultsPage + 1) * PAGE_SIZE).forEach((source, index) => {
1238
+ const row = node('article', 'rhine-result-item');
1239
+ const open = button('', 'rhine-result-open', () => { void openSource(source); });
1240
+ open.dataset.sourceId = source.id;
1241
+ open.append(node('span', 'rhine-result-number', `${String(resultsPage * PAGE_SIZE + index + 1).padStart(3, '0')} / ${kind(source)}`), node('strong', '', source.title), node('p', '', (source.excerpt || '打开查看原文').slice(0, 200)));
1242
+ const details = node('div', 'rhine-result-meta');
1243
+ details.append(node('span', '', `${origin(source)}${hasLocator(source) ? ' · 可读取原文' : ' · 返回片段'}`));
1244
+ details.append(button('+ 收入档案架', '', () => addToDesk(source)));
1245
+ details.append(button('+ 放入重点证据盒', 'rhine-result-pin', () => pinToBoard(source)));
1246
+ row.append(open, details);
1247
+ list.append(row);
1248
+ });
1249
+ const controls = $('.rhine-result-pagination');
1250
+ pagination(controls, resultsPage, matching.length, page => { resultsPage = page; renderResults(); });
1251
+ if (searchCursor) {
1252
+ const more = button(searchBusy ? '正在载入…' : '载入更多资料 ↓', 'rhine-load-more', () => { void search(true); });
1253
+ more.disabled = searchBusy;
1254
+ controls.append(more);
1255
+ }
1256
+ }
1257
+ async function search(more = false, reveal = true) {
1258
+ if(reveal)setFollowAgent(false);
1259
+ const query = more ? searchQuery : input.value.trim();
1260
+ let request: Record<string, unknown>;
1261
+ try { request = more ? searchRequest : manualSearch.request(query); }
1262
+ catch (error) { searchError = plainError(error); renderResults(); input.focus(); return; }
1263
+ searchController?.abort();
1264
+ const controller = new AbortController();
1265
+ searchController = controller;
1266
+ searchBusy = true;
1267
+ searchError = ''; searchWarning = '';
1268
+ searchMode = manualSearch.mode; searchRequest = request;
1269
+ searchQuery = query;
1270
+ if (!more) { results = []; resultsPage = 0; resultFilter = -1; searchCursor = undefined; searchDataVersion = undefined; }
1271
+ if (reveal) showPanel(resultsPanel);
1272
+ syncModalState();
1273
+ renderResults();
1274
+ renderStatus();
1275
+ try {
1276
+ const response = await monitoredApi(searchMode === 'cloud' ? 'archive.cloud-search' : 'archive.search', { ...request, ...(more && searchCursor ? { after: searchCursor, data_version: searchDataVersion } : {}) }, controller.signal);
1277
+ if (disposed || controller !== searchController || controller.signal.aborted) return;
1278
+ if (response?.ok === false || response?.error) throw new Error(response?.error?.message || response?.error || '资料检索未完成');
1279
+ const data = response?.sources ? response : response?.response?.sources ? response.response : response?.data || response;
1280
+ searchWarning = (data?.warnings || []).map((item: any) => typeof item === 'string' ? item : item.message || '').filter(Boolean).join(';');
1281
+ const incoming: ArchiveSource[] = Array.isArray(data?.sources) ? data.sources : [];
1282
+ const previousCount = results.length;
1283
+ const unique = new Map((more ? results : []).map(s => [sourceKey(s), s]));
1284
+ for (const source of incoming) unique.set(sourceKey(source), { ...source, dataVersion: source.dataVersion || data.data_version });
1285
+ results = [...unique.values()];
1286
+ catalogueLoaded = true;
1287
+ if (!more && reveal) $<HTMLDetailsElement>('.rhine-search-advanced').open = false;
1288
+ syncArchiveSources();
1289
+ if (more) resultsPage = Math.floor(previousCount / PAGE_SIZE);
1290
+ searchCursor = data?.page?.has_more ? data.page.next_after || data.page.next_cursor || data.page.after : undefined;
1291
+ searchDataVersion = data?.data_version || searchDataVersion;
1292
+ } catch (error) {
1293
+ if (!disposed && controller === searchController && !controller.signal.aborted) {
1294
+ searchError = `检索未完成:${plainError(error)}`;
1295
+ toast(`检索未完成:${plainError(error)}`);
1296
+ }
1297
+ } finally {
1298
+ if (!disposed && controller === searchController) {
1299
+ searchBusy = false;
1300
+ renderResults();
1301
+ renderStatus();
1302
+ }
1303
+ }
1304
+ }
1305
+ let readerLines: ReaderLine[] = [];
1306
+ let returnedReader: { text: string; status: string; label: string } | undefined;
1307
+ function renderReturnedSource(source: ArchiveSource) {
1308
+ const page = source.origin === 'web' && typeof source.content === 'string';
1309
+ const text = page ? source.content! : source.excerpt || '本条来源未返回文本片段。';
1310
+ const label = page ? '网页返回内容' : source.origin === 'web' ? '联网检索摘要' : source.origin === 'cloud' ? '云端返回片段' : '来源返回片段';
1311
+ const status = page ? source.contentTruncated ? '网页内容已被工具截断,以下为本次实际返回的部分。' : '以下为 Agent 本次收到的网页内容。'
1312
+ : source.origin === 'web' ? '当前显示返回摘要;网页正文将在抓取成功后提供。' : '当前提供检索返回的片段。';
1313
+ if (returnedReader?.text === text && returnedReader.status === status && returnedReader.label === label) return;
1314
+ returnedReader = { text, status, label };
1315
+ const body=node('div','rhine-excerpt-text rhine-markdown');renderReportMarkdown(body,text);
1316
+ readerBody.replaceChildren(node('div', 'rhine-excerpt-label', label), body);
1317
+ $('.rhine-reader-status').textContent = status;
1318
+ }
1319
+ function openSource(source: ArchiveSource, returnTo: ReadingOriginKind | null = null) {
1320
+ cancelAnimationFrame(originRestoreFrame); setFollowAgent(false);
1321
+ const kind = returnTo || (!resultsPanel.hidden ? 'search' : !rackIndex.hidden ? 'rack' : !report.hidden ? 'report' : !basket.hidden ? 'basket' : !log.hidden ? 'log' : location);
1322
+ const origin = readerOrigin || captureReadingOrigin(root, { kind, location, sessionId: snapshot.sessionId, sourceId: source.id, arrayExpanded: arrayListExpanded });
1323
+ return readerMeasure('open', () => openSourceBody(source, origin));
1324
+ }
1325
+ async function openSourceBody(source: ArchiveSource, returnOrigin: ReadingOrigin) {
1326
+ if (disposed || returnOrigin.sessionId !== snapshot.sessionId) return;
1327
+ if (panels.some(panel => !panel.hidden)) { closeModals(() => { void openSourceBody(source, returnOrigin); }); return; }
1328
+ if (location === 'board') setLocation('archive');
1329
+ source = resolveReadingSource(source, sources.find(item => sameSource(item, source)) || archiveSources.find(item => sameSource(item, source)));
1330
+ lastFocus = returnOrigin.focus;
1331
+ cancelReaderPosition(); readerAnnotationSignature = ''; readerChipsSignature = '';
1332
+ readController?.abort(); selectedSource = source;
1333
+ readerOrigin = returnOrigin; readerReturn = returnOrigin.kind;
1334
+ const label = readingOriginLabel(returnOrigin.kind, investigationReturn.kind);
1335
+ $('.rhine-reader-back-report').hidden = false;
1336
+ $('.rhine-reader-back-report').textContent = `${label} ↗`;
1337
+ $('.rhine-close-reader').setAttribute('aria-label', label);
1338
+ $('[data-action="back"] span').textContent = label;
1339
+ $('[data-action="back"]').setAttribute('aria-label', `${label}(Esc)`);
1340
+ readerCursor = undefined;
1341
+ readerVersion = source.dataVersion;
1342
+ readerBusy = false;
1343
+ for (const panel of panels) panel.hidden = true;
1344
+ reader.hidden = false;
1345
+ $('#object-id').textContent = `S-${String(Math.max(1, sourceNumber(source))).padStart(3, '0')}`;
1346
+ root.classList.add('has-reader');
1347
+ if (location === 'archive') scene?.selectArchiveSource(archiveSources.find(item => sameSource(item, source))?.id || source.id);
1348
+ syncMode();
1349
+ $('.rhine-reader-title').textContent = source.title;
1350
+ $('.rhine-reader-meta').textContent = `${kind(source)} / ${origin(source)} / ${state(source)}`;
1351
+ const provenance = $('.rhine-reader-provenance');
1352
+ provenance.replaceChildren();
1353
+ const identity = source.documentId || source.sourceRef || source.documentUid;
1354
+ if (identity) provenance.append(node('span', '', identity));
1355
+ if (source.dataVersion) provenance.append(node('span', '', `版本 ${source.dataVersion}`));
1356
+ if (source.url) {
1357
+ try {
1358
+ const url = new URL(source.url);
1359
+ if (url.protocol === 'https:' || url.protocol === 'http:') {
1360
+ const link = node('a', '', '访问原始来源 ↗');
1361
+ link.href = url.href; link.target = '_blank'; link.rel = 'noopener noreferrer'; provenance.append(link);
1362
+ }
1363
+ } catch { /* A malformed source URL does not affect reading its returned text. */ }
1364
+ }
1365
+ updateReaderSave();
1366
+ readerBody.replaceChildren();
1367
+ returnedReader = undefined; readerLines = [];
1368
+ // The emptied scroll container resets naturally; avoid forcing its layout here.
1369
+ $('.rhine-reader-status').textContent = '';
1370
+ $('.rhine-reader-more').hidden = true;
1371
+ scene?.select(source.id);
1372
+ renderDesk();
1373
+ updateReaderAnnotations();
1374
+ pendingDetailFocus = $('.rhine-reader-title');
1375
+ if (reducedMotion.matches || !scene) { pendingDetailFocus.focus({ preventScroll: true }); pendingDetailFocus = null; }
1376
+ if (!hasLocator(source)) {
1377
+ renderReturnedSource(source);
1378
+ readerPosition = { source }; scheduleReaderPosition();
1379
+ return;
1380
+ }
1381
+ await loadReader(false, readerStartLine(source));
1382
+ }
1383
+ function updateReaderAnnotations(addedRows?: readonly HTMLElement[]) {
1384
+ const selected = selectedSource;
1385
+ if (!selected) return;
1386
+ readerMeasure('annotations', () => {
1387
+ const source = resolveReadingSource(selected, sources.find(s => sameSource(s, selected)));
1388
+ if (!hasLocator(source)) renderReturnedSource(source);
1389
+ const meta = $('.rhine-reader-meta'), label = `${kind(source)} / ${origin(source)} / ${state(source)}`;
1390
+ if (meta.textContent !== label) meta.textContent = label;
1391
+ const chips = $('.rhine-reader-ranges');
1392
+ const ranges = source.readRanges || [];
1393
+ const hits = source.readingRange ? [source.readingRange] : source.ranges || (source.lineStart ? [{ start: source.lineStart, end: source.lineEnd || source.lineStart }] : []);
1394
+ const signature = JSON.stringify([ranges, hits]);
1395
+ const chipsSignature = `${hasLocator(source)}:${signature}`;
1396
+ if (chipsSignature !== readerChipsSignature) {
1397
+ const fragment = document.createDocumentFragment();
1398
+ const jump = (start: number) => {
1399
+ cancelReaderPosition();
1400
+ const row = [...readerBody.querySelectorAll<HTMLElement>('.rhine-reader-line')].find(row=>Number(row.dataset.line)<=start&&Number(row.dataset.lineEnd||row.dataset.line)>=start);
1401
+ if (row) { row.scrollIntoView({ block: 'center', behavior: reducedMotion.matches ? 'instant' : 'smooth' }); return; }
1402
+ readController?.abort(); readerBusy = false; readerCursor = undefined;
1403
+ void loadReader(false, Math.max(1, start - 3));
1404
+ };
1405
+ if (hasLocator(source)) fragment.append(button('从头阅读', '', () => jump(1)));
1406
+ if (source.readingRange) fragment.append(button(`引用位置 L${source.readingRange.start}–${source.readingRange.end}`, '', () => jump(source.readingRange!.start)));
1407
+ ranges.slice(0, 12).forEach(range => fragment.append(button(`Agent 已读 L${range.start}–${range.end}`, 'rhine-read-range', () => jump(range.start))));
1408
+ if (!ranges.length && !source.readingRange) hits.slice(0, 8).forEach(range => fragment.append(button(`相关位置 L${range.start}–${range.end}`, '', () => jump(range.start))));
1409
+ chips.replaceChildren(fragment); readerChipsSignature = chipsSignature;
1410
+ }
1411
+ // Unchanged receipts only annotate new rows; preserve existing DOM and selection.
1412
+ const rows = signature === readerAnnotationSignature ? addedRows || []
1413
+ : [...readerBody.querySelectorAll<HTMLElement>('.rhine-reader-line'), ...(addedRows?.filter(row => !row.isConnected) || [])];
1414
+ for (const row of rows) {
1415
+ const line = Number(row.dataset.line), end = Number(row.dataset.lineEnd || row.dataset.line);
1416
+ row.classList.toggle('is-agent-read', ranges.some(range => end >= range.start && line <= range.end));
1417
+ row.classList.toggle('is-hit', hits.some(range => end >= range.start && line <= range.end));
1418
+ }
1419
+ readerAnnotationSignature = signature;
1420
+ });
1421
+ }
1422
+ async function loadReader(more: boolean, startLine = 1) {
1423
+ const source = selectedSource;
1424
+ if (!source || readerBusy || !hasLocator(source)) return;
1425
+ if (!more) cancelReaderPosition();
1426
+ readController?.abort();
1427
+ const controller = new AbortController();
1428
+ readController = controller;
1429
+ readerBusy = true;
1430
+ const moreButton = $<HTMLButtonElement>('.rhine-reader-more');
1431
+ moreButton.disabled = true;
1432
+ $('.rhine-reader-status').textContent = more ? '正在读取下一段…' : '正在读取原文…';
1433
+ try {
1434
+ const payload = {
1435
+ locator: source.documentId ? { document_id: source.documentId } : { source_ref: source.sourceRef },
1436
+ ...(readerVersion ? { data_version: readerVersion } : {}),
1437
+ selection: { mode: 'document', ...(more && readerCursor ? { cursor: readerCursor } : startLine > 1 ? { start_line: startLine } : {}) },
1438
+ max_lines: 150, max_chars: 24000,
1439
+ };
1440
+ const result = await monitoredApi('read', payload, controller.signal);
1441
+ if (disposed || controller !== readController || source !== selectedSource || controller.signal.aborted) return;
1442
+ if (result?.ok === false || result?.error) throw new Error(result?.error?.message || result?.error || '资料暂时无法读取');
1443
+ const data = result?.response || result;
1444
+ const lines: ReaderLine[] = data?.content?.lines || [];
1445
+ if (!Array.isArray(lines)) throw new Error('资料响应格式不正确');
1446
+ readerVersion = data?.data_version || readerVersion;
1447
+ const finishPresent = performancePanel.capture.beginWork('reader-present');
1448
+ try {
1449
+ readerMeasure('dom', () => {
1450
+ readerLines = more ? [...readerLines,...lines] : lines;
1451
+ const built = readerMeasure('build', () => renderSourceMarkdown(readerLines));
1452
+ updateReaderAnnotations(built);
1453
+ const rows = readerMeasure('body-patch', () => {
1454
+ // Preserve settled blocks and selections when another page is appended.
1455
+ built.forEach((row,index)=>{
1456
+ const previous=readerBody.children[index];
1457
+ if(!previous)readerBody.append(row);
1458
+ else if(!previous.isEqualNode(row))readerBody.replaceChild(row,previous);
1459
+ });
1460
+ while(readerBody.children.length>built.length)readerBody.lastElementChild!.remove();
1461
+ return [...readerBody.querySelectorAll<HTMLElement>('.rhine-reader-line')];
1462
+ });
1463
+ readerCursor = data?.page?.has_more ? data.page.next_cursor : undefined;
1464
+ moreButton.hidden = !readerCursor;
1465
+ const firstLine = readerBody.querySelector<HTMLElement>('.rhine-reader-line')?.dataset.line || '1';
1466
+ $('.rhine-reader-status').textContent = lines.length ? `L${firstLine}–${lines[lines.length - 1].line_number}${readerCursor ? ' · 下方继续读取' : ' · 已至文末'}` : '此范围没有可显示的正文。';
1467
+ const hit = !more && rows.find(row => row.classList.contains('is-hit'));
1468
+ if (!more) {
1469
+ readerPosition = { row: hit || undefined, source, controller, finish: finishPresent };
1470
+ scheduleReaderPosition();
1471
+ } else finishPresent?.(true, true);
1472
+ }, { lines: lines.length, append: more });
1473
+ } catch (error) { finishPresent?.(false, true); throw error; }
1474
+ } catch (error) {
1475
+ if (!disposed && controller === readController && !controller.signal.aborted) {
1476
+ $('.rhine-reader-status').textContent = `读取未完成:${plainError(error)}`;
1477
+ if (!more && source.excerpt) { const body=node('div','rhine-excerpt-text rhine-markdown');renderReportMarkdown(body,source.excerpt);readerBody.replaceChildren(node('div', 'rhine-excerpt-label', '原文读取失败 · 以下为检索返回片段'),body); }
1478
+ const retry = button('重试读取 ↻', 'rhine-retry', () => { retry.remove(); void loadReader(more, startLine); });
1479
+ readerBody.append(retry);
1480
+ }
1481
+ } finally {
1482
+ if (!disposed && controller === readController) { readerBusy = false; moreButton.disabled = false; }
1483
+ }
1484
+ }
1485
+ function extractSelection() {
1486
+ const selection = window.getSelection();
1487
+ const source = selectedSource;
1488
+ if (!selection || !selection.rangeCount || !source || !readerBody.contains(selection.anchorNode) || !readerBody.contains(selection.focusNode)) { toast('先在资料正文中选中一段文字,再点击摘录。'); return; }
1489
+ const range = selection.getRangeAt(0);
1490
+ const text = selection.toString().trim();
1491
+ if (!text) { toast('先在资料正文中选中一段文字。'); return; }
1492
+ if (text.length > 12000) { toast('单条摘录最多 12,000 字,请缩小选取范围。'); return; }
1493
+ const rows = [...readerBody.querySelectorAll<HTMLElement>('.rhine-reader-line')].filter(row => range.intersectsNode(row));
1494
+ if (extracts.some(item => sameSource(item.source, source) && item.text === text)) { toast('这段文字已经保存在摘录中。'); return; }
1495
+ if (extracts.length >= MAX_EXTRACTS) { toast(`最多保存 ${MAX_EXTRACTS} 条摘录,请先移除一些。`); return; }
1496
+ extracts.push({ id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, source: { ...source, dataVersion: readerVersion || source.dataVersion }, text, ...(rows.length ? { start: Number(rows[0].dataset.line), end: Number(rows[rows.length - 1].dataset.lineEnd || rows[rows.length - 1].dataset.line) } : {}) });
1497
+ addToDesk(source, false);
1498
+ persist(); renderBasket(); renderStatus();
1499
+ toast('摘录已保存,包含资料来源和可用的行号。');
1500
+ }
1501
+ function renderBasket() {
1502
+ $('.rhine-extract-count').textContent = String(extracts.length).padStart(2, '0');
1503
+ $('#saved-count').textContent = String(extracts.length).padStart(2, '0');
1504
+ $('.rhine-nav-extracts').setAttribute('aria-label', `查看收藏与 ${extracts.length} 条摘录`);
1505
+ $('.rhine-query-hint').textContent = extracts.length ? `本次提问将附上 ${extracts.length} 条摘录。` : '候选资料可直接阅读、收藏,或放入指定调查的证据盒。';
1506
+ const list = $('.rhine-basket-list');
1507
+ list.replaceChildren();
1508
+ if (!extracts.length) list.append(node('p', 'rhine-empty', '还没有摘录。打开一份资料,选中文字后点击“摘录选中文字”。'));
1509
+ extracts.forEach(extract => {
1510
+ const card = node('article', 'rhine-extract-card');
1511
+ const heading = node('div', 'rhine-extract-heading');
1512
+ const sourceLink=button(extract.source.title, 'rhine-extract-title', () => { void openSource(extract.source); });
1513
+ sourceLink.dataset.sourceId=extract.source.id;heading.append(sourceLink);
1514
+ heading.append(button('×', 'rhine-icon-button', () => { extracts = extracts.filter(e => e.id !== extract.id); persist(); renderBasket(); }, `移除摘录:${extract.source.title}`));
1515
+ card.append(heading, node('small', '', `${origin(extract.source)}${extract.start ? ` · L${extract.start}–${extract.end}` : ' · 返回片段'}`), node('blockquote', '', extract.text));
1516
+ list.append(card);
1517
+ });
1518
+ $<HTMLButtonElement>('.rhine-send-extracts').disabled = submissionBlocked() || !extracts.length;
1519
+ }
1520
+ function evidenceContext() {
1521
+ if (!extracts.length) return '';
1522
+ const blocks: string[] = [];
1523
+ for (const extract of extracts) {
1524
+ blocks.push(`[资料:${extract.source.title}]\n来源:${extract.source.documentId || extract.source.sourceRef || extract.source.url || extract.source.id}${extract.source.dataVersion ? `\n版本:${extract.source.dataVersion}` : ''}${extract.start ? `\n行号:${extract.start}–${extract.end}` : '\n范围:检索返回片段'}\n${extract.text}`);
1525
+ }
1526
+ return `\n\n以下是我从调查档案架选取的参考摘录。请将摘录作为资料,保留引用来源:\n\n${blocks.join('\n\n---\n\n')}`;
1527
+ }
1528
+ async function askAgent(extractsOnly = false) {
1529
+ if (options.agentAvailable === false) { toast('请在 Harness 会话中使用 Agent;本地预览可以直接检索和翻阅资料。'); return; }
1530
+ if (hostBlocked()) { toast('对话设置正在更新,请稍候再提问。'); return; }
1531
+ if (hostState?.canSubmit === false) { toast(hostState.error || '请先在对话设置中选择可用模型。'); return; }
1532
+ const query = agentInput.value.trim() || (extractsOnly && extracts.length ? '请根据这些摘录继续调查,整理其中的重要事实与联系,并列出引用来源。' : '');
1533
+ if (!query) { agentInput.focus(); toast('输入想调查的问题。'); return; }
1534
+ if (extracts.reduce((sum, extract) => sum + extract.text.length, 0) > 22000) { toast('摘录总量超过 22,000 字,请移除部分摘录后再发送。已保存的摘录会完整保留。'); return; }
1535
+ if (extracting) return;
1536
+ const submissionSession = snapshot.sessionId;
1537
+ extracting = true;
1538
+ $<HTMLButtonElement>('.rhine-agent-submit').disabled = true;
1539
+ $<HTMLButtonElement>('.rhine-ask-agent').disabled = true;
1540
+ renderBasket();
1541
+ renderStatus();
1542
+ try {
1543
+ await options.askAgent(query + evidenceContext());
1544
+ if (disposed || snapshot.sessionId !== submissionSession) return;
1545
+ agentInput.value = '';
1546
+ basket.hidden = true;
1547
+ resultsPanel.hidden = true;
1548
+ syncModalState();
1549
+ toast('调查已交给 Agent,候选资料进入检索阵列,Agent 读过后入架。');
1550
+ } catch (error) { if (!disposed && snapshot.sessionId === submissionSession) toast(`未能开始调查:${plainError(error)}`); }
1551
+ finally { if (!disposed && snapshot.sessionId === submissionSession) { extracting = false; $<HTMLButtonElement>('.rhine-agent-submit').disabled = false; $<HTMLButtonElement>('.rhine-ask-agent').disabled = false; renderBasket(); renderStatus(); } }
1552
+ }
1553
+ function renderLog() { return performancePanel.capture.measureWork('renderLog', renderLogBody); }
1554
+ function renderLogBody() {
1555
+ $('.rhine-log-status').textContent = snapshot.error || (snapshot.running ? '调查进行中。新资料进入检索阵列,Agent 读过后归入档案架。' : snapshot.query ? `本次调查:${snapshot.query}` : '尚未开始 Agent 调查。');
1556
+ const agentSources = snapshot.sources || [];
1557
+ const signature = JSON.stringify([snapshot.sessionId, agentSources]);
1558
+ if (signature !== logSourcesSignature) {
1559
+ logSourcesSignature = signature;
1560
+ const list = $('.rhine-log-sources');
1561
+ list.replaceChildren();
1562
+ agentSources.slice(-40).forEach(source => {
1563
+ const row = button('', 'rhine-log-source', () => { void openSource(source); });
1564
+ row.dataset.sourceId=source.id;
1565
+ row.append(node('span', '', state(source)), node('strong', '', source.title));
1566
+ list.append(row);
1567
+ });
1568
+ if (agentSources.length > 40) list.prepend(node('p', 'rhine-panel-description', `显示最近 40 份资料;完整的 ${agentSources.length} 份候选资料可在检索阵列翻阅。`));
1569
+ }
1570
+ renderRecords();
1571
+ const answer = $('.rhine-log-answer');
1572
+ if (answer.textContent !== (snapshot.answer || '')) answer.textContent = snapshot.answer || '';
1573
+ }
1574
+ function renderRecords() {
1575
+ const signature = JSON.stringify([snapshot.sessionId, snapshot.records, snapshot.hasMoreHistory, snapshot.loadingHistory, visibleRecordCount]);
1576
+ if (signature === logRecordsSignature) return;
1577
+ logRecordsSignature = signature;
1578
+ const container = $('.rhine-log-records');
1579
+ const records = snapshot.records || [];
1580
+ $('.rhine-log-records-heading').hidden = !records.length && !snapshot.hasMoreHistory;
1581
+ container.querySelector('.rhine-load-history')?.remove();
1582
+ if (!records.length) container.replaceChildren();
1583
+ const shown = records.slice(-visibleRecordCount);
1584
+ const activeIds = new Set(shown.map(record => record.id));
1585
+ for (const [id, element] of recordNodes) {
1586
+ if (!activeIds.has(id)) { element.remove(); recordNodes.delete(id); }
1587
+ }
1588
+ container.querySelector('.rhine-more-records')?.remove();
1589
+ if (shown.length < records.length) {
1590
+ container.prepend(button(`展开更早记录(共 ${records.length} 次) ↑`, 'rhine-more-records', () => { visibleRecordCount += 20; renderRecords(); }));
1591
+ }
1592
+ shown.forEach(record => {
1593
+ let details = recordNodes.get(record.id);
1594
+ if (!details) {
1595
+ details = node('details', 'rhine-log-record');
1596
+ const summary = node('summary', '');
1597
+ summary.append(node('span', 'rhine-record-state'), node('strong', 'rhine-record-tool'), node('span', 'rhine-record-query'));
1598
+ details.append(summary);
1599
+ details.addEventListener('toggle', () => {
1600
+ const current = (snapshot.records || []).find(item => item.id === record.id);
1601
+ if (!details!.open) { details!.querySelector('pre')?.remove(); return; }
1602
+ if (!details!.querySelector('pre')) details!.append(node('pre', 'rhine-record-text', current?.text || '等待工具返回内容…'));
1603
+ });
1604
+ recordNodes.set(record.id, details);
1605
+ container.append(details);
1606
+ }
1607
+ details.querySelector('.rhine-record-state')!.textContent = record.state === 'active' ? '进行中' : record.state === 'error' ? '未完成' : '已返回';
1608
+ details.querySelector('.rhine-record-tool')!.textContent = record.tool;
1609
+ details.querySelector('.rhine-record-query')!.textContent = record.query || '';
1610
+ const text = details.querySelector('pre');
1611
+ if (text && text.textContent !== record.text) text.textContent = record.text || '等待工具返回内容…';
1612
+ });
1613
+ if (snapshot.hasMoreHistory && options.loadHistory) {
1614
+ const history = button(snapshot.loadingHistory ? '正在载入更早的会话…' : '载入更早的会话记录 ↑', 'rhine-load-history', async () => {
1615
+ history.disabled = true;
1616
+ try { await options.loadHistory?.(); }
1617
+ catch (error) { if (!disposed) toast(`历史记录读取未完成:${plainError(error)}`); }
1618
+ finally { if (!disposed && history.isConnected) history.disabled = false; }
1619
+ });
1620
+ history.disabled = Boolean(snapshot.loadingHistory);
1621
+ container.prepend(history);
1622
+ }
1623
+ }
1624
+ function investigationKey() { return `${snapshot.sessionId}:${snapshot.investigationId || snapshot.question || ''}`; }
1625
+ function investigationTitle() { return (snapshot.question || '').split('\n\n以下是我从调查档案架选取的参考摘录。')[0].trim(); }
1626
+ function investigationComplete() { return !snapshot.running && snapshot.outcome === 'completed'; }
1627
+ function currentInvestigationSources() { return snapshot.turnSources || snapshot.sources || []; }
1628
+ function syncCompletionNotice() {
1629
+ $('.rhine-completion-notice').hidden = !investigationComplete() || !snapshot.answer || reportViewedKey === investigationKey()
1630
+ || panels.some(panel => !panel.hidden) || location !== 'desk' && !selectedSource;
1631
+ }
1632
+ function reportMeasure<T>(kind: string, work: () => T): T { return performancePanel.capture.measureWork(kind, work); }
1633
+ function reportTextContent(element: HTMLElement, text: string) { if (element.textContent !== text) element.textContent = text; }
1634
+ function scheduleReportPosition() {
1635
+ if (reportPositionFrame || disposed || !active || report.hidden) return;
1636
+ reportPositionFrame = requestAnimationFrame(() => { reportPositionFrame = 0; updateReportPosition(); });
1637
+ }
1638
+ function updateReportPosition() {
1639
+ if (disposed || !active || report.hidden) return;
1640
+ const body = $('.rhine-report-body');
1641
+ if (reportScrollPending !== undefined) {
1642
+ const next = reportScrollPending === 'end' ? reportMeasure('report-scroll-read', () => body.scrollHeight) : reportScrollPending;
1643
+ reportScrollPending = undefined;
1644
+ reportMeasure('report-scroll-write', () => { body.scrollTop = next; });
1645
+ }
1646
+ // Read the complete geometry snapshot before changing any text/attributes.
1647
+ const state = reportMeasure('report-position-read', () => {
1648
+ const rect = body.getBoundingClientRect(), scale = rect.width / body.offsetWidth || 1;
1649
+ const scrollable = body.scrollHeight - body.clientHeight, top = rect.top;
1650
+ reportScrollTop = body.scrollTop;
1651
+ const percent = scrollable > 2 ? Math.min(100, Math.round(reportScrollTop / scrollable * 100)) : 100;
1652
+ let activeHeading = reportHeadings[0]?.id || '';
1653
+ for (const item of reportHeadingNodes) if (item.element.getBoundingClientRect().top <= top + 100 * scale) activeHeading = item.heading.id;
1654
+ return { percent, activeHeading };
1655
+ });
1656
+ reportMeasure('report-position-write', () => {
1657
+ reportTextContent($('.rhine-report-position'), snapshot.running ? '回答仍在更新 · 可先阅读已生成内容' : state.percent >= 99 ? '已至文末' : `阅读位置 ${state.percent}%`);
1658
+ for (const { heading, link } of reportHeadingNodes) {
1659
+ const value = String(heading.id === state.activeHeading);
1660
+ if (link.getAttribute('aria-current') !== value) link.setAttribute('aria-current', value);
1661
+ }
1662
+ });
1663
+ }
1664
+ function renderReport() { return reportMeasure('renderReport', renderReportBody); }
1665
+ function renderReportBody() {
1666
+ clearTimeout(reportRenderTimer); reportRenderTimer = undefined;
1667
+ const changedInvestigation = reportInvestigationKey !== investigationKey(), body = $('.rhine-report-body');
1668
+ // A hidden report is prepared without layout reads. Visible updates read once before all DOM writes.
1669
+ const previous = reportMeasure('report-scroll-read', () => {
1670
+ if (changedInvestigation) return { top: 0, following: false };
1671
+ if (!active || report.hidden) return { top: reportScrollTop, following: false };
1672
+ const top = body.scrollTop;
1673
+ return { top, following: Boolean(reportText) && snapshot.running && body.scrollHeight - body.clientHeight - top < 56 };
1674
+ });
1675
+ if (changedInvestigation) {
1676
+ reportInvestigationKey = investigationKey(); reportScrollTop = 0;
1677
+ reportText = ''; reportSourceSignature = ''; reportTocSignature = '';
1678
+ }
1679
+ reportMeasure('report-header', () => {
1680
+ reportTextContent($('.rhine-report-title'), investigationTitle() || '调查报告');
1681
+ const completed = investigationComplete();
1682
+ reportTextContent($('.rhine-report-kicker'), completed ? 'INVESTIGATION COMPLETE / 调查完成' : snapshot.running ? 'LIVE REPORT / 正在生成' : 'RESEARCH RECORD / 调查记录');
1683
+ reportTextContent($('.rhine-report-status'), snapshot.error || (snapshot.running ? '内容随回答更新。你可以先阅读,或点右侧引用核对原文。' : completed ? '本轮调查已完成 · 点击目录跳转章节,点击引用查阅原文。' : snapshot.outcome === 'interrupted' ? '本轮调查已停止 · 以下保留停止前已返回的内容。' : '已返回的回答 · 来源可在右侧逐份查阅。'));
1684
+ });
1685
+ const text = snapshot.answer || '等待 Agent 返回调查内容…';
1686
+ if (reportText !== text) {
1687
+ reportHeadings = renderReportMarkdown(body, text, reportMeasure); reportText = text;
1688
+ reportScrollPending = previous.following ? 'end' : previous.top;
1689
+ reportMeasure('report-toc', () => {
1690
+ const signature = JSON.stringify(reportHeadings);
1691
+ if (signature !== reportTocSignature) {
1692
+ reportTocSignature = signature;
1693
+ const fragment = document.createDocumentFragment();
1694
+ reportHeadings.forEach((heading, index) => {
1695
+ const link = button('', 'rhine-report-toc-link', () => {
1696
+ const target = body.querySelector<HTMLElement>(`#${heading.id}`);
1697
+ if (target) {
1698
+ const bodyRect = body.getBoundingClientRect(), scale = bodyRect.width / body.offsetWidth || 1;
1699
+ const top = body.scrollTop + (target.getBoundingClientRect().top - bodyRect.top) / scale - 16;
1700
+ body.scrollTo({ top, behavior: reducedMotion.matches ? 'instant' : 'smooth' });
1701
+ target.tabIndex = -1; target.focus({ preventScroll: true });
1702
+ }
1703
+ });
1704
+ link.dataset.heading = heading.id; link.dataset.level = String(heading.level);
1705
+ link.append(node('small', '', String(index + 1).padStart(2, '0')), node('span', '', heading.label));
1706
+ fragment.append(link);
1707
+ });
1708
+ if (!reportHeadings.length) fragment.append(node('span', 'rhine-report-toc-empty', '正文'));
1709
+ $('.rhine-report-toc').replaceChildren(fragment);
1710
+ }
1711
+ // A streamed heading can replace its node without changing the TOC signature.
1712
+ reportHeadingNodes = reportHeadings.flatMap(heading => {
1713
+ const element = body.querySelector<HTMLElement>(`#${heading.id}`);
1714
+ const link = root.querySelector<HTMLButtonElement>(`.rhine-report-toc-link[data-heading="${heading.id}"]`);
1715
+ return element && link ? [{ heading, element, link }] : [];
1716
+ });
1717
+ });
1718
+ }
1719
+ reportMeasure('report-references', () => {
1720
+ const references = $('.rhine-report-sources');
1721
+ const cited = snapshot.reportSources || currentInvestigationSources().filter(source => source.state === 'cited');
1722
+ const signature = JSON.stringify(cited.map(source => [source.id, source.title, source.origin, source.state]));
1723
+ if (signature === reportSourceSignature) return;
1724
+ reportSourceSignature = signature;
1725
+ const fragment = document.createDocumentFragment();
1726
+ fragment.append(node('div', 'tiny-label', `REFERENCES / 引用 ${String(cited.length).padStart(2, '0')}`));
1727
+ cited.forEach((source, index) => {
1728
+ const reference = button('', 'rhine-report-source', () => { void openSource(source, 'report'); });
1729
+ reference.dataset.sourceId=source.id;
1730
+ reference.append(node('small', '', String(index + 1).padStart(2, '0')), node('strong', '', source.title), node('span', '', `${origin(source)} · 查看原文 ↗`));
1731
+ fragment.append(reference);
1732
+ });
1733
+ if (!cited.length) fragment.append(node('p', 'rhine-panel-description', snapshot.running ? '回答生成后,已引用的原文会列在这里。' : '本轮回答没有附带可定位的资料引用。'));
1734
+ references.replaceChildren(fragment);
1735
+ });
1736
+ // Preparing a hidden completed answer does not mark it as read.
1737
+ if (active && !report.hidden && investigationComplete()) reportViewedKey = investigationKey();
1738
+ scheduleReportPosition(); syncCompletionNotice();
1739
+ }
1740
+ function scheduleReport() {
1741
+ if (reportRenderTimer !== undefined) return;
1742
+ if (report.hidden && (snapshot.running || !snapshot.answer || reportText === snapshot.answer && reportInvestigationKey === investigationKey())) return;
1743
+ reportRenderTimer = window.setTimeout(() => { reportRenderTimer = undefined; if (!disposed) renderReport(); }, report.hidden || snapshot.running ? 180 : 0);
1744
+ }
1745
+ function openReport(returning = false) { return reportMeasure('report-open', () => openReportBody(returning)); }
1746
+ function openReportBody(returning: boolean) {
1747
+ if (!returning && document.activeElement instanceof HTMLElement && !report.contains(document.activeElement) && !readerReturn && !reader.contains(document.activeElement)) reportTrigger = document.activeElement;
1748
+ // Populate while hidden so parsing/patching cannot repeatedly flush visible report layout.
1749
+ renderReport(); reportMeasure('report-show', () => showPanel(report));
1750
+ if (investigationComplete()) reportViewedKey = investigationKey();
1751
+ syncCompletionNotice(); scheduleReportPosition(); reportMeasure('report-focus', () => $('.rhine-close-report').focus({ preventScroll: true }));
1752
+ }
1753
+ function panelReturnFocus(panel: HTMLElement) {
1754
+ if (panel === sessionPanel) return hostTrigger?.isConnected ? hostTrigger : $('.rhine-session-history');
1755
+ if (panel === rackIndex) return $('.rhine-rack-directory');
1756
+ if (panel === report) return reportTrigger?.isConnected && !reportTrigger.closest('[hidden]') ? reportTrigger : location === 'desk' ? $('.rhine-shelf-report') : $('.rhine-report-open');
1757
+ return $('.rhine-nav-index');
1758
+ }
1759
+ let logTrigger: HTMLElement | null = null;
1760
+ function openLog(trigger: HTMLElement, callId?: string) {
1761
+ logTrigger = trigger;
1762
+ showPanel(log); renderLog(); $('.rhine-close-log').focus();
1763
+ if (callId) {
1764
+ const index = (snapshot.records || []).findIndex(record => record.id === callId);
1765
+ if (index >= 0) {
1766
+ visibleRecordCount = Math.max(visibleRecordCount, snapshot.records!.length - index);
1767
+ renderRecords();
1768
+ const entry = recordNodes.get(callId);
1769
+ if (entry) { entry.open = true; entry.scrollIntoView({ block: 'nearest' }); }
1770
+ }
1771
+ }
1772
+ }
1773
+ function syncToolActivity() { return performancePanel.capture.measureWork('tool-activity', syncToolActivityBody); }
1774
+ function syncToolActivityBody() {
1775
+ const turn = `${snapshot.sessionId}:${snapshot.investigationId || ''}`;
1776
+ if (activityTurn !== turn) {
1777
+ activityTurn = turn; agentReadFocus = undefined;
1778
+ sourceMotion.dispose();
1779
+ }
1780
+ const next = latestReadFocus(snapshot, activitySources ??= mergeSourcesInOrder(snapshot.sources || [], archiveSources));
1781
+ const changed = Boolean(next && next.key !== agentReadFocus?.key);
1782
+ for(const operation of snapshot.operations||[]){
1783
+ if(operation.kind!=='read')continue;
1784
+ const key=`${turn}:${operation.id}`;
1785
+ if(!observedReads.has(key)&&!followAgent&&activityInitialized)unseenReads.add(key);
1786
+ observedReads.add(key);
1787
+ }
1788
+ if (next) agentReadFocus = next;
1789
+ else if (agentReadFocus) {
1790
+ const operation = snapshot.operations?.find(call => call.id === agentReadFocus?.operation.id);
1791
+ if (operation) agentReadFocus = { ...agentReadFocus, operation };
1792
+ }
1793
+ updateArchiveSelection(archiveSelected, archiveLaneIndex, false);
1794
+ renderFollowMode();
1795
+ if (followAgent && changed && next?.operation.state !== 'error' && activityInitialized && snapshot.running && active && !document.hidden && !selectedSource)
1796
+ sourceMotion.reveal($('.rhine-current-source'), reducedMotion.matches);
1797
+ activityInitialized = true;
1798
+ const activity = investigationActivity(snapshot);
1799
+ $('.rhine-tool-feed').hidden = !snapshot.running;
1800
+ $('.rhine-tool-total').textContent = activity.active.length ? `${activity.active.length} 项进行中` : `${activity.calls.length} 次调用`;
1801
+ const signature = JSON.stringify(activity.recent.map(call => [call.id, call.tool, call.state, call.query]));
1802
+ if (signature !== toolFeedSignature) {
1803
+ toolFeedSignature = signature;
1804
+ const list = $('.rhine-tool-feed-list');
1805
+ list.replaceChildren();
1806
+ if (!activity.recent.length) list.append(node('p', 'rhine-tool-wait', '等待 Agent 发起工具调用…'));
1807
+ for (const call of activity.recent) {
1808
+ const row = button('', 'rhine-tool-row', () => openLog(row, call.id));
1809
+ row.dataset.state = call.state;
1810
+ row.append(node('span', 'rhine-tool-state', call.state === 'active' ? '调用中' : call.state === 'error' ? '失败' : '已返回'),
1811
+ node('strong', 'rhine-tool-name', call.tool), node('span', 'rhine-tool-query', call.query || '查看调用详情'), node('span', 'rhine-tool-arrow', '↗'));
1812
+ list.append(row);
1813
+ }
1814
+ }
1815
+ return activity;
1816
+ }
1817
+ function renderStatus() { return performancePanel.capture.measureWork('renderStatus', renderStatusBody); }
1818
+ function renderStatusBody() {
1819
+ uiUpdates.status++;
1820
+ const activity = syncToolActivity();
1821
+ const searching = searchBusy || snapshot.searching;
1822
+ const running = searching || snapshot.running || extracting;
1823
+ const phase = snapshot.phase || (snapshot.searching ? ['corpus_read', 'cloud_inspect', 'web_fetch'].includes(snapshot.tool) ? 'reading' : 'searching' : snapshot.running ? 'synthesizing' : snapshot.error ? 'error' : snapshot.answer ? 'complete' : 'idle');
1824
+ const phases = { idle: '等待调查', searching: '检索资料', reading: '阅读原文', synthesizing: '整理线索', complete: '调查完成', error: '调查未完成' };
1825
+ const received = mergeSourcesInOrder([], currentInvestigationSources());
1826
+ const reads = received.filter(source => source.agentRead === true || source.state === 'read' || Boolean(source.readRanges?.length)).length;
1827
+ const citations = (snapshot.reportSources || received.filter(source => source.state === 'cited')).length;
1828
+ const completed = investigationComplete();
1829
+ const current = [...received, ...archiveSources].find(source => (!snapshot.activeDataVersion || source.dataVersion === snapshot.activeDataVersion) && (snapshot.activeDocumentId && source.documentId === snapshot.activeDocumentId || snapshot.activeSourceRef && source.sourceRef === snapshot.activeSourceRef));
1830
+ const manualSearch = searchBusy && !snapshot.running;
1831
+ const submitting = extracting && !snapshot.running;
1832
+ const operation = $('.rhine-case-operation');
1833
+ const operationLabel = submitting ? '正在提交问题' : manualSearch ? '正在检索资料库' : activity.label;
1834
+ const operationDetail = submitting ? '问题正在交给 Agent' : manualSearch ? searchQuery || '正在载入资料目录'
1835
+ : snapshot.running ? activity.detail
1836
+ : phase === 'reading' ? current?.title || '正在读取原文,完整返回内容可在过程记录中查看'
1837
+ : phase === 'searching' ? snapshot.query || '正在查找相关资料,结果会持续送入档案架'
1838
+ : phase === 'synthesizing' ? received.length ? `已收集 ${received.length} 份资料,正在整理回答` : '正在处理你的问题,回答生成后可直接阅读'
1839
+ : phase === 'complete' ? received.length ? `已查得 ${received.length} 份 · 已读 ${reads} 份 · 引用 ${citations} 份` : '回答已生成,可阅读下方回复或查看过程'
1840
+ : phase === 'error' ? snapshot.error || '本轮调查已停止,点此查看记录'
1841
+ : '输入问题,或自由检索和翻阅原文';
1842
+ root.classList.toggle('is-searching', searching);
1843
+ root.classList.toggle('is-running', running);
1844
+ root.dataset.phase = phase;
1845
+ scene?.setSearching(searchBusy);
1846
+ $('.rhine-case-label').textContent = completed ? 'INVESTIGATION COMPLETE' : snapshot.question || snapshot.query ? 'ACTIVE INVESTIGATION' : 'INVESTIGATION / READY';
1847
+ $('.rhine-case-phase').textContent = submitting ? '正在提交' : manualSearch ? '检索资料库' : phase === 'complete' && !completed ? '回答已返回' : snapshot.outcome === 'interrupted' ? '调查已停止' : snapshot.running ? activity.current ? '工具调用中' : '等待下一步' : phases[phase];
1848
+ $('.rhine-case-question').textContent = investigationTitle() || (snapshot.running ? snapshot.query : '') || (snapshot.answer ? '本次调查已归档。' : '从一个问题,开始调查。');
1849
+ $('.rhine-case-tool').textContent = operationLabel;
1850
+ $('.rhine-case-operation-text').textContent = operationDetail;
1851
+ const calling = Boolean(activity.current) || manualSearch;
1852
+ operation.classList.toggle('is-active', calling);
1853
+ operation.classList.toggle('is-waiting', !calling && (snapshot.running || submitting));
1854
+ operation.dataset.state = submitting ? 'submitting' : manualSearch ? 'searching' : phase;
1855
+ operation.title = `${operationLabel} · ${operationDetail}`;
1856
+ $('.rhine-found-count').textContent = String(received.length).padStart(2, '0');
1857
+ $('.rhine-read-count').textContent = String(reads).padStart(2, '0');
1858
+ $('.rhine-cited-count').textContent = String(citations).padStart(2, '0');
1859
+ $('.rhine-report-open').hidden = !activity.showReport;
1860
+ $('.rhine-shelf-report').hidden = !activity.showReport;
1861
+ const reportEntry = $('.rhine-report-open');
1862
+ reportEntry.dataset.state = completed ? 'complete' : snapshot.running ? 'running' : snapshot.outcome === 'interrupted' || snapshot.error ? 'error' : 'returned';
1863
+ $('.rhine-report-open-kicker').textContent = completed ? '✓ 调查完成 / REPORT READY' : snapshot.running ? 'LIVE REPORT / 正在生成' : snapshot.outcome === 'interrupted' ? '调查已停止 / 已返回内容' : 'RESEARCH REPORT / 已返回回答';
1864
+ $('.rhine-report-open strong').textContent = completed ? '阅读调查报告' : '查看已返回的回答';
1865
+ $('.rhine-report-open-meta').textContent = `本轮查得 ${received.length} 份资料 · ${citations} 份引用${completed ? ' · 随时核对原文' : ''}`;
1866
+ if (completed && snapshot.answer && lastCompletedKey !== investigationKey()) {
1867
+ lastCompletedKey = investigationKey();
1868
+ if (hasRenderedStatus) {
1869
+ for (const selector of ['.rhine-report-open', '.rhine-completion-notice']) $(selector).classList.add('is-new');
1870
+ $('.rhine-completion-announcement').textContent = '本轮调查已完成。调查报告已就绪,可以打开阅读并查阅引用原文。';
1871
+ clearTimeout(completionTimer);
1872
+ completionTimer = window.setTimeout(() => { if (!disposed) for (const selector of ['.rhine-report-open', '.rhine-completion-notice']) $(selector).classList.remove('is-new'); }, 6000);
1873
+ }
1874
+ }
1875
+ if (!completed) for (const selector of ['.rhine-report-open', '.rhine-completion-notice']) $(selector).classList.remove('is-new');
1876
+ hasRenderedStatus = true;
1877
+ syncCompletionNotice();
1878
+ $('.rhine-activity').hidden = location !== 'desk' || !running && !snapshot.error;
1879
+ $('.rhine-status-title').textContent = searching ? 'RETRIEVING ARCHIVES' : snapshot.running ? 'INVESTIGATION IN PROGRESS' : 'INVESTIGATION / PAUSED';
1880
+ $('.rhine-activity-query').textContent = snapshot.question || snapshot.query || '';
1881
+ $('.rhine-activity-detail').textContent = snapshot.error || `新资料持续入架 · 已收到 ${received.length} 份`;
1882
+ $('.rhine-session-id').textContent = (snapshot.sessionId || '').slice(0, 8).toUpperCase();
1883
+ renderAgentControls();
1884
+ refreshSnapshotPanels();
1885
+ }
1886
+ function refreshSnapshotPanels() {
1887
+ if (!log.hidden) renderLog();
1888
+ scheduleReport();
1889
+ }
1890
+
1891
+ function renderAgentControls() { return performancePanel.capture.measureWork('agent-controls', () => {
1892
+ uiUpdates.host++;
1893
+ const available = options.agentAvailable !== false;
1894
+ $('.rhine-agent-availability').textContent = !available ? '在会话中启用' : hostBlocked() ? 'SESSION / UPDATING' : hostState?.canSubmit === false ? '请选择可用模型' : snapshot.running ? 'AGENT / WORKING' : 'AGENT / READY';
1895
+ $('.rhine-agent-hint').textContent = !available ? '本地预览可检索与翻阅;在 Harness 会话中向 Agent 提问。' : hostBlocked() ? '对话设置正在更新,输入的问题会保留。' : hostState?.canSubmit === false ? hostState.error || '打开上方对话设置,选择可用模型后开始调查。' : extracts.length ? `提问时会附上 ${extracts.length} 条摘录。` : snapshot.running ? '可以继续提问,问题会排队交给 Agent。' : '资料归入右侧档案架,可边查边读。';
1896
+ $<HTMLButtonElement>('.rhine-agent-submit').disabled = submissionBlocked();
1897
+ $<HTMLButtonElement>('.rhine-ask-agent').disabled = submissionBlocked();
1898
+ $<HTMLButtonElement>('.rhine-send-extracts').disabled = submissionBlocked() || !extracts.length;
1899
+ renderHostControls();
1900
+ }); }
1901
+
1902
+ $('.rhine-nav-index').addEventListener('click', openIndex);
1903
+ $('.rhine-nav-board').addEventListener('click', () => setLocation('board'));
1904
+ for (const tab of root.querySelectorAll<HTMLButtonElement>('[data-zone]')) tab.addEventListener('click', () => setLocation(tab.dataset.zone as Location));
1905
+ $('.rhine-nav-rack').addEventListener('click', () => setLocation(location === 'desk' ? 'archive' : 'desk'));
1906
+ $('.rhine-manual-search').addEventListener('click', () => { if (agentInput.value.trim()) input.value = agentInput.value.trim(); openIndex(); if (input.value.trim() !== searchQuery) void search(); });
1907
+ $('.rhine-report-open').addEventListener('click', () => openReport());
1908
+ $('.rhine-completion-notice').addEventListener('click', () => openReport());
1909
+ $('.rhine-report-rack').addEventListener('click', () => setLocation('desk'));
1910
+ $('.rhine-rack-directory').addEventListener('click', openRackIndex);
1911
+ $('.rhine-rack-filter').addEventListener('input', renderRackIndex);
1912
+ $('.rhine-shelf-filter').addEventListener('input', () => { $('.rhine-desk-list').scrollTop = 0; renderDesk(); });
1913
+ $('.rhine-shelf-clear').addEventListener('click', () => { $<HTMLInputElement>('.rhine-shelf-filter').value = ''; renderDesk(); $('.rhine-shelf-filter').focus(); });
1914
+ $('.rhine-array-directory').addEventListener('click', openIndex);
1915
+ arrayToggle.addEventListener('click', () => setArrayListExpanded(true));
1916
+ $('.rhine-array-collapse').addEventListener('click', () => setArrayListExpanded(false, false, true));
1917
+ $('.rhine-close-rack-index').addEventListener('click', () => closeModals(() => $('.rhine-rack-directory').focus()));
1918
+ $('.rhine-reader-back-report').addEventListener('click', () => closeReader());
1919
+ $('.rhine-report-body').addEventListener('scroll', scheduleReportPosition, { passive: true });
1920
+ for (const event of ['wheel', 'touchstart', 'pointerdown', 'keydown']) readerBody.addEventListener(event, cancelReaderPosition, { passive: true });
1921
+ $('.rhine-report-body').addEventListener('click', event => {
1922
+ const link = (event.target as HTMLElement).closest<HTMLAnchorElement>('a');
1923
+ if (!link) return;
1924
+ const href = link.getAttribute('href') || '';
1925
+ if (href.startsWith('#')) {
1926
+ event.preventDefault();
1927
+ let label = href.slice(1);
1928
+ try { label = decodeURIComponent(label); } catch { /* An incomplete streamed anchor remains plain navigation. */ }
1929
+ const heading = reportHeadings.find(item => `#${item.id}` === href || item.label === label);
1930
+ if (heading) root.querySelector<HTMLButtonElement>(`.rhine-report-toc-link[data-heading="${heading.id}"]`)?.click();
1931
+ return;
1932
+ }
1933
+ const source = archiveSources.find(item => item.url === href || item.sourceRef === href || item.documentId === href);
1934
+ if (source) { event.preventDefault(); void openSource(source, 'report'); }
1935
+ });
1936
+ $('.rhine-case-operation').addEventListener('click', () => openLog($('.rhine-case-operation')));
1937
+ $('.rhine-shelf-report').addEventListener('click', () => openReport());
1938
+ $('.rhine-close-report').addEventListener('click', () => closeModals(() => panelReturnFocus(report).focus()));
1939
+ $('.rhine-shelf-prev').addEventListener('click', () => browseShelf(-1));
1940
+ $('.rhine-shelf-next').addEventListener('click', () => browseShelf(1));
1941
+ for(const select of root.querySelectorAll<HTMLSelectElement>('.rhine-evidence-target select'))select.addEventListener('change',()=>{
1942
+ evidenceTarget=select.value;renderInvestigationContext();
1943
+ });
1944
+ $('.rhine-array-stage').addEventListener('click',()=>{const source=displayedArchiveSource;if(source)pinToBoard(source);});
1945
+ $('.rhine-follow-toggle').addEventListener('click',()=>{setFollowAgent(!followAgent);updateArchiveSelection(archiveSelected,archiveLaneIndex,false);});
1946
+ $('.rhine-shelf-stage').addEventListener('click',()=>{const source=sources.find(item=>item.id===shelfSelected);if(source)pinToBoard(source);});
1947
+ $('.rhine-shelf-open').addEventListener('click', () => { const source = sources.find(item => item.id === shelfSelected); if (source) void openSource(source); });
1948
+ $('.rhine-agent-form').addEventListener('submit', event => { event.preventDefault(); void askAgent(); });
1949
+ $('.rhine-session-mode').addEventListener('click', () => openHostPanel($('.rhine-session-mode')));
1950
+ $('.rhine-session-model').addEventListener('click', () => openHostPanel($('.rhine-session-model'), 'model'));
1951
+ $('.rhine-session-history').addEventListener('click', () => openHostPanel($('.rhine-session-history'), 'history'));
1952
+ $('.rhine-session-alert').addEventListener('click', () => openHostPanel($('.rhine-session-alert')));
1953
+ $('.rhine-session-new').addEventListener('click', () => { if (options.host) void runHostAction(() => options.host!.createSession(), true); });
1954
+ $('.rhine-close-session').addEventListener('click', () => closeModals(() => (hostTrigger?.isConnected ? hostTrigger : $('.rhine-session-history')).focus()));
1955
+ $('.rhine-session-refresh').addEventListener('click', () => { if (options.host) void runHostAction(() => options.host!.refresh()); });
1956
+ $('.rhine-session-retry').addEventListener('click', () => { if (hostRetry) void hostRetry(); else if (options.host) void runHostAction(() => options.host!.refresh()); });
1957
+ $('.rhine-session-create').addEventListener('click', () => { const mode = modeSelect.value || undefined; if (options.host) void runHostAction(() => options.host!.createSession(mode), true); });
1958
+ $('.rhine-mode-apply').addEventListener('click', () => {
1959
+ const mode = modeSelect.value;
1960
+ if (options.host && hostState?.modes.some(item => item.id === mode)) {
1961
+ const blank = hostState.blank && !hostState.running;
1962
+ void runHostAction(() => blank ? options.host!.selectMode(mode) : options.host!.createSession(mode), !blank);
1963
+ }
1964
+ });
1965
+ $('.rhine-model-apply').addEventListener('click', () => { const model = modelSelect.value; if (options.host && hostState?.models.some(item => item.id === model)) void runHostAction(() => options.host!.selectModel(model)); });
1966
+ $('.rhine-agent-cancel').addEventListener('click', () => { if (options.host && hostState?.running) void runHostAction(() => options.host!.cancel()); });
1967
+ modeSelect.addEventListener('change', renderHostChoice);
1968
+ modelSelect.addEventListener('change', renderHostChoice);
1969
+ $('.rhine-nav-close').addEventListener('click', options.close);
1970
+ $('.rhine-nav-extracts').addEventListener('click', () => { const open = basket.hidden; if (open) { showPanel(basket); renderBasket(); $('.rhine-close-basket').focus(); } else closeModals(); });
1971
+ $('.rhine-nav-log').addEventListener('click', () => { if (log.hidden) openLog($('.rhine-nav-log')); else closeModals(); });
1972
+ $('.rhine-nav-settings').addEventListener('click', () => { if (!options.openSettings?.()) toast('当前宿主没有可用的设置入口。'); });
1973
+ $('.rhine-location-button').addEventListener('click', () => setLocation(location === 'archive' ? 'desk' : 'archive'));
1974
+ $('.rhine-close-results').addEventListener('click', () => closeModals(() => $('.rhine-nav-index').focus()));
1975
+ $('.rhine-close-reader').addEventListener('click', () => closeReader());
1976
+ $('.rhine-close-basket').addEventListener('click', () => closeModals(() => $('.rhine-nav-extracts').focus()));
1977
+ $('.rhine-close-log').addEventListener('click', () => closeModals(() => (logTrigger || $('.rhine-nav-log')).focus()));
1978
+ $('[data-action="prev"]').addEventListener('click', () => navigateArchivePage(-1));
1979
+ $('[data-action="next"]').addEventListener('click', () => navigateArchivePage(1));
1980
+ $('[data-action="column-prev"]').addEventListener('click', () => navigateArchive('lane', -1));
1981
+ $('[data-action="column-next"]').addEventListener('click', () => navigateArchive('lane', 1));
1982
+ for (const open of root.querySelectorAll<HTMLElement>('[data-action="open"]')) open.addEventListener('click', openCurrentArchive);
1983
+ $('[data-action="back"]').addEventListener('click', () => closeReader());
1984
+ $('[data-action="inspect"]').addEventListener('click', openModelViewer);
1985
+ for (const panel of panels) panel.addEventListener('click', event => { if (event.target === panel) closeModals(() => panelReturnFocus(panel).focus()); });
1986
+ $('.rhine-read-save').addEventListener('click', () => { if (selectedSource) addToDesk(selectedSource); });
1987
+ $('.rhine-read-pin').addEventListener('click', () => { if (selectedSource) pinToBoard(selectedSource); });
1988
+ $('.rhine-extract-selection').addEventListener('mousedown', event => event.preventDefault());
1989
+ $('.rhine-extract-selection').addEventListener('click', extractSelection);
1990
+ $('.rhine-reader-more').addEventListener('click', () => { void loadReader(true); });
1991
+ $('.rhine-search-cancel').addEventListener('click', () => { searchController?.abort(); searchController = undefined; searchBusy = false; searchError = '检索已取消,可修改条件后重试。'; renderResults(); renderStatus(); });
1992
+ $('.rhine-query-form').addEventListener('submit', event => { event.preventDefault(); void search(); });
1993
+ $('.rhine-ask-agent').addEventListener('click', () => { agentInput.value = input.value; void askAgent(); });
1994
+ $('.rhine-send-extracts').addEventListener('click', () => { void askAgent(true); });
1995
+ const keydown = (event: KeyboardEvent) => {
1996
+ if (!active || disposed || viewer?.isOpen || event.defaultPrevented || options.isSettingsOpen?.()) return;
1997
+ const target = event.target instanceof HTMLElement ? event.target : null;
1998
+ const editing = target && (target.matches('input, textarea, select') || target.isContentEditable);
1999
+ const modal = panels.find(panel => !panel.hidden);
2000
+ if (event.key === 'Tab' && modal) {
2001
+ const focusable = [...modal.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),select:not(:disabled),a[href],summary,[tabindex="0"]')].filter(element => element.getClientRects().length && !element.closest('[hidden],[inert]'));
2002
+ const first = focusable[0], last = focusable[focusable.length - 1];
2003
+ if (first && (event.shiftKey && (document.activeElement === first || !modal.contains(document.activeElement)) || !event.shiftKey && (document.activeElement === last || !modal.contains(document.activeElement)))) {
2004
+ event.preventDefault(); (event.shiftKey ? last : first).focus();
2005
+ }
2006
+ return;
2007
+ }
2008
+ if (event.key === '/' && !editing && !event.metaKey && !event.ctrlKey && !event.altKey) { event.preventDefault(); openIndex(); return; }
2009
+ if (event.key === 'Escape') {
2010
+ if (event.isComposing) return;
2011
+ if (modal) { event.preventDefault(); closeModals(() => panelReturnFocus(modal).focus()); }
2012
+ else if (selectedSource) { event.preventDefault(); closeReader(); }
2013
+ else if (arrayListExpanded) { event.preventDefault(); setArrayListExpanded(false, false, true); }
2014
+ else if (location === 'board' && scene?.cancelBoardGesture()) { event.preventDefault(); }
2015
+ else if (location === 'board' && target?.closest('.rhine-evidence-tools') && boardPanel.handleEscape()) { event.preventDefault(); }
2016
+ else if (editing) { event.preventDefault(); target?.blur(); }
2017
+ else if (location === 'board' && boardPanel.handleEscape()) { event.preventDefault(); }
2018
+ else if (boardFullscreen) { event.preventDefault(); setBoardFullscreen(false); boardFullscreenButton.focus({ preventScroll: true }); }
2019
+ else if (location !== 'archive') { event.preventDefault(); setLocation('archive'); }
2020
+ return;
2021
+ }
2022
+ if (event.key === 'Enter' && (event.ctrlKey || event.metaKey) && event.target === input) { event.preventDefault(); agentInput.value = input.value; void askAgent(); return; }
2023
+ if (editing || modal || selectedSource || event.isComposing) return;
2024
+ if (location === 'board') {
2025
+ if (boardPanel.handleShortcut(event)) event.preventDefault();
2026
+ return;
2027
+ }
2028
+ if (location === 'desk') {
2029
+ if (['ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown'].includes(event.key)) { event.preventDefault(); browseShelf(event.key === 'ArrowLeft' || event.key === 'ArrowUp' ? -1 : 1); }
2030
+ if (event.key === 'Enter' && (!target?.matches('button,a,summary') || target.classList.contains('rhine-desk-item'))) {
2031
+ const id = target?.dataset.sourceId;
2032
+ if (id && id !== shelfSelected) { event.preventDefault(); chooseShelfSource(id); return; }
2033
+ const source = sources.find(item => item.id === shelfSelected);
2034
+ if (source) { event.preventDefault(); void openSource(source); }
2035
+ }
2036
+ return;
2037
+ }
2038
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); navigateArchive('lane', event.key === 'ArrowLeft' ? -1 : 1); }
2039
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); navigateArchive('row', event.key === 'ArrowUp' ? -1 : 1); }
2040
+ if (event.key === 'Enter' && (!target?.matches('button,input,a,summary') || target?.dataset.sourceId)) { event.preventDefault(); const id = target?.dataset.sourceId; if (id && id !== archiveSelected) selectArchiveSource(id); else openCurrentArchive(); }
2041
+ };
2042
+ const readingKeydown = (event:KeyboardEvent) => {
2043
+ if(!active||disposed||root.hidden||viewer?.isOpen||event.defaultPrevented||options.isSettingsOpen?.())return;
2044
+ if(investigation?.handleKeydown(event))event.stopImmediatePropagation();
2045
+ };
2046
+ document.addEventListener('keydown', readingKeydown, true);
2047
+ document.addEventListener('keydown', keydown);
2048
+ const motionChange = () => { scene?.setReducedMotion(reducedMotion.matches); stage.classList.toggle('reduce-motion', reducedMotion.matches); for (const control of [...Object.values(counters), ...Object.values(titles)]) { control.update({ animated: !reducedMotion.matches }); if (reducedMotion.matches) control.finish(); } if (reducedMotion.matches) { arrayListTransition.finish(); detailTransition.finish(); for (const transition of modalTransitions.values()) transition.finish(); } };
2049
+ reducedMotion.addEventListener('change', motionChange);
2050
+ loadSaved();
2051
+ mergeSources(false);
2052
+ renderBasket();
2053
+ renderDesk();
2054
+ renderStatus();
2055
+ unsubscribeHost = options.host?.subscribe(next => { if (!disposed) { hostState = next; renderAgentControls(); } });
2056
+ if (options.host) void runHostAction(() => options.host!.refresh());
2057
+ updateArchiveSelection(null, 0, false);
2058
+ syncMode();
2059
+ stage.classList.toggle('reduce-motion', reducedMotion.matches);
2060
+ root.dataset.location = location;
2061
+ const initialCatalogue = search(false, false);
2062
+
2063
+ // Keep the moment loading began as the history baseline. Fast reads that
2064
+ // finish while the GLB loads are new activity, not historical playback.
2065
+ const sceneInitialSnapshot = snapshot;
2066
+ const sceneInitialSources = [...sources];
2067
+ // Give the opaque loader a paint before synchronous WebGL setup begins.
2068
+ void new Promise<void>(resolve => requestAnimationFrame(() => setTimeout(resolve, 0)))
2069
+ .then(() => {
2070
+ if (disposed || sceneStartupCancelled) return null;
2071
+ return createRhineScene($('.rhine-scene'), {
2072
+ assetBase: options.assetBase,
2073
+ navigationLimiter,
2074
+ performanceProbe: performancePanel.capture,
2075
+ activitySince: Date.now(),
2076
+ reducedMotion: reducedMotion.matches,
2077
+ onSelect: id => { const source = sources.find(s => s.id === id); if (source) void openSource(source); },
2078
+ onArchiveSourceSelect: (id, lane, userInitiated) => {
2079
+ if (disposed || synchronizingScene) return;
2080
+ if (userInitiated) setFollowAgent(false);
2081
+ updateArchiveSelection(id, lane);
2082
+ },
2083
+ onArchiveSourceOpen: id => { const source = archiveSources.find(item => item.id === id); if (!disposed && source) void openSource(source); },
2084
+ onShelfSelect: (id, page) => { if (!disposed) focusShelf(id, page); },
2085
+ onInboxAnchor:value=>investigation?.setInboxAnchor(value),
2086
+ onLocationRequest: next => { setFollowAgent(false); setLocation(next); },
2087
+ onInboxOpen:()=>investigation?.openInbox(),
2088
+ onBoardSelect: (id, openEditor) => { if (!disposed) investigation?.select(id, openEditor); },
2089
+ onBoardMove: (id, x, y) => { if (!disposed) boardPanel.moveCard(id, x, y); },
2090
+ onBoardResize: (id, scale, x, y) => { if (!disposed) boardPanel.resizeCard(id, scale, x, y); },
2091
+ onBoardAnchors: frame => { if (!disposed) { boardFrame = frame; investigation?.setBoardFrame(frame); syncBoardToolbar(); } },
2092
+ onBoardToolRequest: () => { if (!disposed) boardPanel.toggleTools(boardToolPosition()); },
2093
+ onBoardToolChange: tool => { if (!disposed) syncBoardToolState(tool); },
2094
+ onBoardPlace: (template, position) => { if (!disposed) boardPanel.placeCard(template, position); },
2095
+ onBoardConnect: (from, to) => { if (!disposed) boardPanel.connectCards(from, to); },
2096
+ onDetailFrame: visibility => { if (!disposed) paintDetail(visibility); },
2097
+ onError: message => { if (!disposed) { root.classList.add('rhine-scene-unavailable'); setBoardFullscreen(false); $('.rhine-performance').textContent = 'ARCHIVE READING MODE'; toast(message); } },
2098
+ });
2099
+ }).then(async value => {
2100
+ if (!value) return;
2101
+ if (disposed || sceneStartupCancelled) { value.dispose(); return; }
2102
+ scene = value;
2103
+ await initialCatalogue;
2104
+ if (disposed || sceneStartupCancelled) return;
2105
+ $('.rhine-loader-message').textContent = '正在准备模型与画面…';
2106
+ scene.setFollowAgent(followAgent);
2107
+ syncReadingObject();
2108
+ scene.setBoardEditorInset(boardEditorInset);
2109
+ scene.setBoardCards(investigation?.getCards() || []);
2110
+ scene.setEvidenceInbox(investigation?.getInboxState()||{boardId:'',title:'',count:0,titles:[]},false);
2111
+ scene.selectBoardCard(boardPanel.stats().selected);
2112
+ synchronizingScene = true;
2113
+ try {
2114
+ const sameSession = sceneInitialSnapshot.sessionId === snapshot.sessionId;
2115
+ scene.setActive(active);
2116
+ scene.setInvestigation(sameSession ? sceneInitialSnapshot : snapshot);
2117
+ scene.setSources(sameSession ? sceneInitialSources : sources, false);
2118
+ scene.setArchiveSources(archiveSources);
2119
+ scene.setInvestigation(snapshot);
2120
+ if (sameSession) scene.setSources(sources, true);
2121
+ scene.setLocation(location);
2122
+ scene.setBoardFullscreen(boardFullscreen);
2123
+ scene.setSearching(searchBusy);
2124
+ if (archiveSelected) scene.selectArchiveSource(archiveSelected, false);
2125
+ scene.setShelfPage(deskPage);
2126
+ scene.setDetail(Boolean(selectedSource));
2127
+ if (selectedSource) scene.select(selectedSource.id);
2128
+ scene.setQuality(quality);
2129
+ } finally { synchronizingScene = false; syncBoardToolbar(); }
2130
+ await scene.whenReady();
2131
+ if (!disposed && !sceneStartupCancelled) finishSceneLoading('ready');
2132
+ }).catch(() => {
2133
+ if (!disposed && !sceneStartupCancelled) {
2134
+ scene?.dispose(); scene = undefined;
2135
+ finishSceneLoading('unavailable');
2136
+ root.classList.add('rhine-scene-unavailable');
2137
+ setBoardFullscreen(false);
2138
+ $('.rhine-performance').textContent = 'ARCHIVE READING MODE';
2139
+ toast('此浏览器暂时无法显示 3D 场景,仍可搜索、阅读与摘录资料。');
2140
+ }
2141
+ });
2142
+
2143
+ return {
2144
+ update(next: InvestigationSnapshot) {
2145
+ if (disposed) return;
2146
+ const finishWork = performancePanel.capture.beginUiWork('snapshot-update', {
2147
+ sourceCount: next.sources?.length || 0, answerCharacters: next.answer?.length || 0,
2148
+ recordCount: next.records?.length || 0, running: Boolean(next.running), searching: Boolean(next.searching) });
2149
+ try {
2150
+ const changes = performancePanel.capture.measureWork('snapshot-classify', () => snapshotGate.read(next));
2151
+ const changedSession = changes.sessionChanged;
2152
+ snapshot = next;
2153
+ if (changedSession) {
2154
+ setBoardFullscreen(false);
2155
+ evidenceTarget='auto';contextSignature='';
2156
+ boardPanel.closeTools();
2157
+ void investigation?.setSession(next.sessionId);
2158
+ boardPanel.setSession(next.sessionId);
2159
+ searchController?.abort();
2160
+ searchController = undefined;
2161
+ searchBusy = false;
2162
+ for (const [panel, transition] of modalTransitions) {
2163
+ transition.dispose();
2164
+ panel.hidden = true;
2165
+ panel.dataset.transition = 'closed';
2166
+ }
2167
+ closeReader(false);
2168
+ setArrayListExpanded(false, true);
2169
+ results = []; resultsPage = 0; deskPage = 0; searchCursor = undefined; searchDataVersion = undefined;
2170
+ sources = []; discoveredSources = []; archiveSources = []; archiveSelected = null; shelfSelected = null;
2171
+ followAgent = true; unseenReads.clear(); observedReads.clear(); scene?.setFollowAgent(true);
2172
+ archiveLaneIndex = 0; columnMemory.fill(null); catalogueLoaded = false;
2173
+ sourceSignature = ''; input.value = ''; agentInput.value = ''; extracting = false;
2174
+ readerBody.replaceChildren(); $('.rhine-reader-title').textContent = '';
2175
+ $('.rhine-reader-provenance').replaceChildren(); $('.rhine-reader-ranges').replaceChildren();
2176
+ $('.rhine-report-body').textContent = ''; $('.rhine-report-sources').replaceChildren();
2177
+ $('.rhine-report-toc').replaceChildren();
2178
+ reportText = ''; reportSourceSignature = ''; reportTocSignature = ''; reportHeadings = [];
2179
+ reportHeadingNodes = []; reportScrollTop = 0; reportScrollPending = undefined;
2180
+ cancelAnimationFrame(reportPositionFrame); reportPositionFrame = 0;
2181
+ reportInvestigationKey = ''; reportViewedKey = ''; lastCompletedKey = ''; hasRenderedStatus = false; reportTrigger = null;
2182
+ clearTimeout(reportRenderTimer); reportRenderTimer = undefined;
2183
+ clearTimeout(completionTimer);
2184
+ $('.rhine-completion-announcement').textContent = '';
2185
+ shelfChoiceNodes.clear(); $('.rhine-desk-list').replaceChildren(); $<HTMLInputElement>('.rhine-shelf-filter').value = '';
2186
+ rackNodes.clear(); $('.rhine-rack-index-list').replaceChildren(); $<HTMLInputElement>('.rhine-rack-filter').value = '';
2187
+ $('.rhine-log-answer').textContent = ''; $('.rhine-log-sources').replaceChildren();
2188
+ searchQuery = ''; searchError = ''; searchWarning = ''; manualSearch.resetMode(); searchMode = 'local';
2189
+ scene?.setInvestigation(snapshot); scene?.setSources([], false); scene?.setArchiveSources([]);
2190
+ setLocation('archive'); updateArchiveSelection(null, 0, false); renderDesk();
2191
+ void search(false, false);
2192
+ visibleRecordCount = 20; recordNodes.clear(); $('.rhine-log-records').replaceChildren();
2193
+ logSourcesSignature = logRecordsSignature = ''; activitySources = undefined;
2194
+ loadSaved(); renderBasket();
2195
+ syncModalState();
2196
+ }
2197
+ if (changes.sourcesChanged || changedSession) mergeSources(!changedSession);
2198
+ else {
2199
+ uiUpdates.sourceMergeSkipped++;
2200
+ if (changes.statusChanged) scene?.setInvestigation(snapshot);
2201
+ }
2202
+ if (changes.statusChanged || changedSession) renderStatus();
2203
+ else { uiUpdates.statusSkipped++; refreshSnapshotPanels(); }
2204
+ } finally { finishWork?.(); }
2205
+ },
2206
+ setActive(next: boolean) { active = next; if (!next) { cancelAnimationFrame(readerPositionFrame); readerPositionFrame = 0; } edgeNavigation?.sync(); performancePanel.setActive(next); scene?.setActive(next && !viewer?.isOpen); root.hidden = !next; if (next) { scheduleReportPosition(); scheduleReaderPosition(); } else { cancelAnimationFrame(reportPositionFrame); reportPositionFrame = 0; } if (next && viewer?.isOpen && !viewerFrame) viewerFrame = requestAnimationFrame(renderViewer); else if (!next) { cancelAnimationFrame(viewerFrame); viewerFrame = 0; } },
2207
+ stats() { return { uiUpdates: { ...uiUpdates }, hostBridge: options.host?.performanceStats?.() ?? null, snapshotBridge: options.snapshotDiagnostics?.performanceStats?.() ?? null, ...scene?.stats(), board: boardPanel.stats(), investigations: investigation?.stats(), boardFullscreen, navigation: navigationLimiter.stats(), quality: { ...quality }, sourceCount: sources.length, candidateCount: archiveSources.length, followAgent, unseenReads: unseenReads.size, displayedSourceId: displayedArchiveSource?.id, manualSourceCount: manualSources.length, extractCount: extracts.length, location, readerOpen: !reader.hidden, searchBusy, resultCount: results.length, reportOpen: !report.hidden, shelfSelected, deskPage, viewerOpen: Boolean(viewer?.isOpen), archiveIndex: archiveSelected }; },
2208
+ dispose() {
2209
+ manualSearch.dispose();
2210
+ if (disposed) return;
2211
+ disposed = true;
2212
+ archiveRack.dispose();
2213
+ edgeNavigation?.dispose();
2214
+ options.host?.setPerformanceMonitor?.();
2215
+ options.snapshotDiagnostics?.setPerformanceMonitor?.();
2216
+ performancePanel.dispose();
2217
+ investigation?.dispose();
2218
+ boardPanel.dispose();
2219
+ unsubscribeHost?.();
2220
+ cancelReaderPosition();
2221
+ cancelAnimationFrame(originRestoreFrame);
2222
+ searchController?.abort(); readController?.abort();
2223
+ clearTimeout(toastTimeout);
2224
+ clearTimeout(reportRenderTimer); clearTimeout(completionTimer); cancelAnimationFrame(reportPositionFrame);
2225
+ clearInterval(clockTimer);
2226
+ cancelAnimationFrame(viewerFrame);
2227
+ document.removeEventListener('visibilitychange', viewerVisibility);
2228
+ viewer?.dispose();
2229
+ resizeObserver.disconnect();
2230
+ densityQuery.removeEventListener('change', densityChanged);
2231
+ window.removeEventListener('resize', fit);
2232
+ window.visualViewport?.removeEventListener('resize', fit);
2233
+ window.visualViewport?.removeEventListener('scroll', fit);
2234
+ coarse.removeEventListener('change', fit);
2235
+ for (const control of [...Object.values(counters), ...Object.values(titles)]) control.destroy();
2236
+ sourceMotion.dispose();
2237
+ arrayListTransition.dispose();
2238
+ detailTransition.dispose(); for (const transition of modalTransitions.values()) transition.dispose();
2239
+ document.removeEventListener('keydown', readingKeydown, true);
2240
+ document.removeEventListener('keydown', keydown);
2241
+ reducedMotion.removeEventListener('change', motionChange);
2242
+ scene?.dispose(); root.remove();
2243
+ },
2244
+ };
2245
+ }