prts-terrarchive 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (287) hide show
  1. package/GAME_ASSETS.md +26 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +274 -0
  4. package/README.md +423 -0
  5. package/THIRD_PARTY_NOTICES.md +61 -0
  6. package/bin/bake-rhine-details.mjs +34 -0
  7. package/bin/install.js +273 -0
  8. package/bin/pack-map-assets.mjs +110 -0
  9. package/bin/preview-rhine.mjs +71 -0
  10. package/bin/rhine-detail-baker.ts +84 -0
  11. package/contracts/cloud_tools_v1.schema.json +243 -0
  12. package/contracts/corpus_i18n_v1.schema.json +179 -0
  13. package/contracts/corpus_tools_v1.schema.json +1801 -0
  14. package/cordis.patch.yml +32 -0
  15. package/docs/endfield-official-i18n.md +103 -0
  16. package/lib/client.js +3912 -0
  17. package/lib/endfield-map/map.js.br +0 -0
  18. package/lib/endfield-map/map.js.gz +0 -0
  19. package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.br +0 -0
  20. package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.gz +0 -0
  21. package/lib/endfield-map/resources/map-01616f534aaaeca1.png +0 -0
  22. package/lib/endfield-map/resources/map-1562e0dff4be3d23.png +0 -0
  23. package/lib/endfield-map/resources/map-160fe040f080f6a3.png +0 -0
  24. package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.br +0 -0
  25. package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.gz +0 -0
  26. package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.br +0 -0
  27. package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.gz +0 -0
  28. package/lib/endfield-map/resources/map-1d30baaaef3e29cc.png +0 -0
  29. package/lib/endfield-map/resources/map-21a61386af79cd12.json.br +0 -0
  30. package/lib/endfield-map/resources/map-21a61386af79cd12.json.gz +0 -0
  31. package/lib/endfield-map/resources/map-24b920b8dab49b44.json.br +0 -0
  32. package/lib/endfield-map/resources/map-24b920b8dab49b44.json.gz +0 -0
  33. package/lib/endfield-map/resources/map-26caf8d71247abb7.png +0 -0
  34. package/lib/endfield-map/resources/map-2729942c89545e73.json.br +0 -0
  35. package/lib/endfield-map/resources/map-2729942c89545e73.json.gz +0 -0
  36. package/lib/endfield-map/resources/map-29063a9802f67014.json.br +0 -0
  37. package/lib/endfield-map/resources/map-29063a9802f67014.json.gz +0 -0
  38. package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.br +0 -0
  39. package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.gz +0 -0
  40. package/lib/endfield-map/resources/map-2f98a873da81425e.json.br +0 -0
  41. package/lib/endfield-map/resources/map-2f98a873da81425e.json.gz +0 -0
  42. package/lib/endfield-map/resources/map-3ab978ae44190f80.json.br +0 -0
  43. package/lib/endfield-map/resources/map-3ab978ae44190f80.json.gz +0 -0
  44. package/lib/endfield-map/resources/map-3b62c80d14208902.json.br +0 -0
  45. package/lib/endfield-map/resources/map-3b62c80d14208902.json.gz +0 -0
  46. package/lib/endfield-map/resources/map-3d486e887ae121ac.png +0 -0
  47. package/lib/endfield-map/resources/map-418dc4865608fce5.png +0 -0
  48. package/lib/endfield-map/resources/map-4366b84607335f76.json.br +0 -0
  49. package/lib/endfield-map/resources/map-4366b84607335f76.json.gz +0 -0
  50. package/lib/endfield-map/resources/map-4371259d3ab7aac7.png +0 -0
  51. package/lib/endfield-map/resources/map-48dcc0fd55e13f7f.png +0 -0
  52. package/lib/endfield-map/resources/map-4aabda0e41197570.json.br +0 -0
  53. package/lib/endfield-map/resources/map-4aabda0e41197570.json.gz +0 -0
  54. package/lib/endfield-map/resources/map-505f5687b287043c.json.br +0 -0
  55. package/lib/endfield-map/resources/map-505f5687b287043c.json.gz +0 -0
  56. package/lib/endfield-map/resources/map-5177e9d1d46f47e2.png +0 -0
  57. package/lib/endfield-map/resources/map-51baeb59b7f8c28d.png +0 -0
  58. package/lib/endfield-map/resources/map-549561b46e621874.png +0 -0
  59. package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.br +0 -0
  60. package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.gz +0 -0
  61. package/lib/endfield-map/resources/map-5a6963909423b674.png +0 -0
  62. package/lib/endfield-map/resources/map-5a9c533c0552d7b7.png +0 -0
  63. package/lib/endfield-map/resources/map-5d20ae9ffa9a02b0.png +0 -0
  64. package/lib/endfield-map/resources/map-626c8847393f60bf.png +0 -0
  65. package/lib/endfield-map/resources/map-63d43cbec3bed3b8.png +0 -0
  66. package/lib/endfield-map/resources/map-661b6ec56e722574.png +0 -0
  67. package/lib/endfield-map/resources/map-6ddd384eca190828.png +0 -0
  68. package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.br +0 -0
  69. package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.gz +0 -0
  70. package/lib/endfield-map/resources/map-738ebaa002c33cfc.png +0 -0
  71. package/lib/endfield-map/resources/map-758c13be6085ec07.json.br +0 -0
  72. package/lib/endfield-map/resources/map-758c13be6085ec07.json.gz +0 -0
  73. package/lib/endfield-map/resources/map-78929f26d6844509.png +0 -0
  74. package/lib/endfield-map/resources/map-7f09c142950a0f74.png +0 -0
  75. package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.br +0 -0
  76. package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.gz +0 -0
  77. package/lib/endfield-map/resources/map-881343926a568f27.json.br +0 -0
  78. package/lib/endfield-map/resources/map-881343926a568f27.json.gz +0 -0
  79. package/lib/endfield-map/resources/map-8d1ade3f01d952b0.png +0 -0
  80. package/lib/endfield-map/resources/map-8fef3495bb7ed60f.png +0 -0
  81. package/lib/endfield-map/resources/map-907a7ecf8a0ce352.png +0 -0
  82. package/lib/endfield-map/resources/map-9113d5951c4db903.json.br +0 -0
  83. package/lib/endfield-map/resources/map-9113d5951c4db903.json.gz +0 -0
  84. package/lib/endfield-map/resources/map-94ad6634436224e3.json.br +0 -0
  85. package/lib/endfield-map/resources/map-94ad6634436224e3.json.gz +0 -0
  86. package/lib/endfield-map/resources/map-99722e61da5f3e53.png +0 -0
  87. package/lib/endfield-map/resources/map-9be9596cc059abe3.png +0 -0
  88. package/lib/endfield-map/resources/map-a4db9a4763e47fbf.png +0 -0
  89. package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.br +0 -0
  90. package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.gz +0 -0
  91. package/lib/endfield-map/resources/map-ab35d06875ee418f.png +0 -0
  92. package/lib/endfield-map/resources/map-ac51bb3e7623cd8b.png +0 -0
  93. package/lib/endfield-map/resources/map-ac830a66296bc83a.png +0 -0
  94. package/lib/endfield-map/resources/map-b3fd04209718e55e.json.br +0 -0
  95. package/lib/endfield-map/resources/map-b3fd04209718e55e.json.gz +0 -0
  96. package/lib/endfield-map/resources/map-b8164f1af7c5d895.png +0 -0
  97. package/lib/endfield-map/resources/map-b8cde93b965384d8.json.br +0 -0
  98. package/lib/endfield-map/resources/map-b8cde93b965384d8.json.gz +0 -0
  99. package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.br +0 -0
  100. package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.gz +0 -0
  101. package/lib/endfield-map/resources/map-bfc58277d5ba649d.png +0 -0
  102. package/lib/endfield-map/resources/map-c013f82048c6891a.png +0 -0
  103. package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.br +0 -0
  104. package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.gz +0 -0
  105. package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.br +0 -0
  106. package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.gz +0 -0
  107. package/lib/endfield-map/resources/map-c607f8b4c8ad8967.png +0 -0
  108. package/lib/endfield-map/resources/map-c6f20276b8781326.json.br +0 -0
  109. package/lib/endfield-map/resources/map-c6f20276b8781326.json.gz +0 -0
  110. package/lib/endfield-map/resources/map-d51adf5952af1e8e.png +0 -0
  111. package/lib/endfield-map/resources/map-d695eface22f8b74.json.br +0 -0
  112. package/lib/endfield-map/resources/map-d695eface22f8b74.json.gz +0 -0
  113. package/lib/endfield-map/resources/map-db11d394456a7465.png +0 -0
  114. package/lib/endfield-map/resources/map-dcc4d218581e3dc6.png +0 -0
  115. package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.br +0 -0
  116. package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.gz +0 -0
  117. package/lib/endfield-map/resources/map-defe5db7dabf748a.png +0 -0
  118. package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.br +0 -0
  119. package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.gz +0 -0
  120. package/lib/endfield-map/resources/map-e25ee9af6e493500.png +0 -0
  121. package/lib/endfield-map/resources/map-e3824082cd6f747d.json.br +0 -0
  122. package/lib/endfield-map/resources/map-e3824082cd6f747d.json.gz +0 -0
  123. package/lib/endfield-map/resources/map-e694a91e630b1bd6.png +0 -0
  124. package/lib/endfield-map/resources/map-eaa93af79d12a87d.png +0 -0
  125. package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.br +0 -0
  126. package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.gz +0 -0
  127. package/lib/endfield-map/resources/map-ede421a329dc78a4.png +0 -0
  128. package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.br +0 -0
  129. package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.gz +0 -0
  130. package/lib/endfield-map/resources/map-fb5f4a59a181452c.png +0 -0
  131. package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.br +0 -0
  132. package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.gz +0 -0
  133. package/lib/rhine/assets/archive-assembly.glb +0 -0
  134. package/lib/rhine/assets/archive-cassette.glb +0 -0
  135. package/lib/rhine/fonts/MiSans-Bold.woff2 +0 -0
  136. package/lib/rhine/fonts/MiSans-Demibold.woff2 +0 -0
  137. package/lib/rhine/fonts/MiSans-Light.woff2 +0 -0
  138. package/lib/rhine/fonts/MiSans-Regular.woff2 +0 -0
  139. package/lib/rhine/fonts/MiSans-license.pdf +0 -0
  140. package/lib/rhine/fonts/NOTICE.txt +6 -0
  141. package/lib/rhine/licenses/RhineLabUI-MIT.txt +21 -0
  142. package/lib/rhine/licenses/entities-BSD-2-Clause.txt +11 -0
  143. package/lib/rhine/licenses/linkify-it-MIT.txt +22 -0
  144. package/lib/rhine/licenses/markdown-it-MIT.txt +22 -0
  145. package/lib/rhine/licenses/mdurl-MIT.txt +45 -0
  146. package/lib/rhine/licenses/punycode.js-MIT.txt +20 -0
  147. package/lib/rhine/licenses/rolling-number-MIT.txt +21 -0
  148. package/lib/rhine/licenses/three-MIT.txt +21 -0
  149. package/lib/rhine/licenses/uc.micro-MIT.txt +20 -0
  150. package/lib/rhine/rhine.css +1 -0
  151. package/lib/rhine/rhine.js +5106 -0
  152. package/lib/skins/common.css +31 -0
  153. package/lib/skins/endfield-aic.css +141 -0
  154. package/lib/skins/prts-agent.css +136 -0
  155. package/lib/skins/rhine-lab.css +70 -0
  156. package/package.json +112 -0
  157. package/presets/definition.js +26 -0
  158. package/presets/prts/agent.cordis.yml +25 -0
  159. package/presets/prts/preset.yml +3 -0
  160. package/presets/register.js +86 -0
  161. package/resources/entity-routing.json +21 -0
  162. package/skills/prts-investigation/SKILL.md +121 -0
  163. package/skills/prts-retrieval/SKILL.md +35 -0
  164. package/skills/prts-retrieval/references/cloud-tools.md +13 -0
  165. package/skills/prts-retrieval/references/diagnostics.md +15 -0
  166. package/skills/prts-retrieval/references/local-tools.md +24 -0
  167. package/skills/prts-retrieval/references/module-arknights.md +23 -0
  168. package/skills/prts-retrieval/references/module-dual.md +15 -0
  169. package/skills/prts-retrieval/references/module-endfield.md +21 -0
  170. package/skills/prts-retrieval/references/retrieval-process.md +44 -0
  171. package/skills/prts-retrieval/references/retrieval-recipes-dual.md +34 -0
  172. package/skills/prts-retrieval/references/retrieval-recipes-endfield.md +37 -0
  173. package/skills/prts-retrieval/references/retrieval-recipes.md +39 -0
  174. package/skills/prts-retrieval/references/tools-runtime.md +48 -0
  175. package/skills/prts-retrieval/references/wiki-schema.md +38 -0
  176. package/src/archive-presentation.js +287 -0
  177. package/src/cloud-projection.js +124 -0
  178. package/src/cloud.js +498 -0
  179. package/src/entity-recognizer.js +311 -0
  180. package/src/entity-routing.js +82 -0
  181. package/src/evidence-state.js +332 -0
  182. package/src/i18n.js +365 -0
  183. package/src/index.js +1261 -0
  184. package/src/installer.js +1362 -0
  185. package/src/investigation-review.js +87 -0
  186. package/src/investigation-store.js +579 -0
  187. package/src/investigation-tools.js +113 -0
  188. package/src/localization-format.js +23 -0
  189. package/src/manual-search.js +103 -0
  190. package/src/read.js +1097 -0
  191. package/src/release-compatibility.js +73 -0
  192. package/src/release-watch.js +40 -0
  193. package/src/search-projection.js +139 -0
  194. package/src/search.js +1522 -0
  195. package/src/skill.js +98 -0
  196. package/src/source-map.js +348 -0
  197. package/src/state.js +314 -0
  198. package/src/store.js +1569 -0
  199. package/src/timeline.js +386 -0
  200. package/src/ui.js +1353 -0
  201. package/src/wiki.js +111 -0
  202. package/tsconfig.rhine.json +15 -0
  203. package/ui/rhine/archive-navigation-limit.ts +31 -0
  204. package/ui/rhine/archive-rack.ts +83 -0
  205. package/ui/rhine/assets/evidence-observatory.webp +0 -0
  206. package/ui/rhine/board-plane-transform.ts +24 -0
  207. package/ui/rhine/catalogue.ts +72 -0
  208. package/ui/rhine/deferred-preparation.ts +146 -0
  209. package/ui/rhine/evidence-board-controls.css +22 -0
  210. package/ui/rhine/evidence-board-history.ts +73 -0
  211. package/ui/rhine/evidence-board-model.ts +189 -0
  212. package/ui/rhine/evidence-board-panel.ts +762 -0
  213. package/ui/rhine/evidence-board-scene.ts +548 -0
  214. package/ui/rhine/evidence-board.css +191 -0
  215. package/ui/rhine/evidence-card-art.ts +470 -0
  216. package/ui/rhine/evidence-card-style.ts +17 -0
  217. package/ui/rhine/evidence-inbox-scene.ts +43 -0
  218. package/ui/rhine/evidence-inbox.css +29 -0
  219. package/ui/rhine/evidence-inbox.ts +104 -0
  220. package/ui/rhine/index.ts +12 -0
  221. package/ui/rhine/interaction.css +20 -0
  222. package/ui/rhine/investigation-board-plane.ts +5 -0
  223. package/ui/rhine/investigation-board.css +34 -0
  224. package/ui/rhine/investigation-board.ts +348 -0
  225. package/ui/rhine/investigation-edits.ts +46 -0
  226. package/ui/rhine/investigation-layout.ts +29 -0
  227. package/ui/rhine/label-prefetch.ts +34 -0
  228. package/ui/rhine/looping-title.ts +40 -0
  229. package/ui/rhine/manual-search.css +41 -0
  230. package/ui/rhine/manual-search.ts +89 -0
  231. package/ui/rhine/original/appearance.ts +180 -0
  232. package/ui/rhine/original/archive-label.ts +84 -0
  233. package/ui/rhine/original/archive-lighting.ts +39 -0
  234. package/ui/rhine/original/archive-loop.ts +82 -0
  235. package/ui/rhine/original/archive-refill.ts +64 -0
  236. package/ui/rhine/original/array-detail-data.ts +2 -0
  237. package/ui/rhine/original/array-detail.ts +41 -0
  238. package/ui/rhine/original/array-interior.ts +411 -0
  239. package/ui/rhine/original/array-occlusion.ts +166 -0
  240. package/ui/rhine/original/array-shell.ts +38 -0
  241. package/ui/rhine/original/array-visibility.ts +309 -0
  242. package/ui/rhine/original/asset-url.ts +4 -0
  243. package/ui/rhine/original/brand.ts +12 -0
  244. package/ui/rhine/original/cassette-lod.ts +81 -0
  245. package/ui/rhine/original/data.ts +21 -0
  246. package/ui/rhine/original/decryption.ts +185 -0
  247. package/ui/rhine/original/detail-signature.ts +18 -0
  248. package/ui/rhine/original/glass-reveal.ts +45 -0
  249. package/ui/rhine/original/internal-optics.ts +70 -0
  250. package/ui/rhine/original/label-renderer.ts +119 -0
  251. package/ui/rhine/original/label-textures.ts +117 -0
  252. package/ui/rhine/original/model-viewer.ts +658 -0
  253. package/ui/rhine/original/motion.ts +116 -0
  254. package/ui/rhine/original/opaque-backfaces.ts +64 -0
  255. package/ui/rhine/original/program-preparation.ts +111 -0
  256. package/ui/rhine/original/quality-renderer.ts +97 -0
  257. package/ui/rhine/original/quality-settings.ts +111 -0
  258. package/ui/rhine/original/render-quality.ts +163 -0
  259. package/ui/rhine/original/scene.ts +2051 -0
  260. package/ui/rhine/original/shelf-interior.ts +142 -0
  261. package/ui/rhine/original/ui-transitions.ts +111 -0
  262. package/ui/rhine/original/viewer-camera.ts +92 -0
  263. package/ui/rhine/original/viewport-layout.ts +41 -0
  264. package/ui/rhine/original-ui.css +2334 -0
  265. package/ui/rhine/readability.css +46 -0
  266. package/ui/rhine/reading-object.ts +96 -0
  267. package/ui/rhine/reading-origin.ts +28 -0
  268. package/ui/rhine/reading-room.css +55 -0
  269. package/ui/rhine/report-markdown.ts +98 -0
  270. package/ui/rhine/scene-edge-navigation.ts +84 -0
  271. package/ui/rhine/scene.ts +1726 -0
  272. package/ui/rhine/shelf-layout.ts +38 -0
  273. package/ui/rhine/snapshot-update-gate.ts +22 -0
  274. package/ui/rhine/source-reading.ts +15 -0
  275. package/ui/rhine/source-selection.css +164 -0
  276. package/ui/rhine/temporary-performance-observers.ts +163 -0
  277. package/ui/rhine/temporary-performance-panel.ts +124 -0
  278. package/ui/rhine/temporary-performance.css +19 -0
  279. package/ui/rhine/temporary-performance.ts +552 -0
  280. package/ui/rhine/temporary-render-diagnostics.ts +105 -0
  281. package/ui/rhine/tool-activity.ts +69 -0
  282. package/ui/rhine/types.ts +219 -0
  283. package/ui/rhine/visible-scene-hit.ts +13 -0
  284. package/ui/rhine/workbench.css +818 -0
  285. package/ui/rhine/workbench.ts +2245 -0
  286. package/ui/rhine/workspace-travel.ts +32 -0
  287. package/vite.rhine.config.ts +19 -0
package/src/wiki.js ADDED
@@ -0,0 +1,111 @@
1
+ /** Wiki 文档角色与标签字段的确定性解析。资料仍保留原始标签文本;这里仅提供检索语义层。 */
2
+
3
+ export const WIKI_SECTION_VALUES = Object.freeze([
4
+ '简要介绍', '相关角色', '详细介绍', '剧情高光', '战斗表现', '相关活动', 'trivia', '角色点评',
5
+ '剧情总结', '关键人物', '角色剧情概括',
6
+ '所有相关的活动剧情总结', '相关剧情总结', '相关剧情高光', '相关trivia', '相关角色总结',
7
+ ])
8
+
9
+ const WIKI_SECTION_SET = new Set(WIKI_SECTION_VALUES)
10
+ const WIKI_CONTAINER_SECTIONS = new Set(['所有相关的活动剧情总结'])
11
+ const OPEN_TAG = /^<([^>/]+)>$/u
12
+ const CLOSE_TAG = /^<\/([^>]+)>$/u
13
+
14
+ export function wikiDocumentRole(document = {}) {
15
+ if (document.document_type !== 'knowledge' || document.document_kind !== 'wiki') return ''
16
+ const explicit = String(document.wiki_role || '').trim()
17
+ if (['story', 'character', 'character_activity', 'other'].includes(explicit)) return explicit
18
+ const path = String(document.path || '')
19
+ if (path.startsWith('stories/')) return 'story'
20
+ if (path.startsWith('char_v3/prompt_')) return 'character_activity'
21
+ if (path.startsWith('char_v3/') && document.character_name) return 'character'
22
+ return 'other'
23
+ }
24
+
25
+ export function wikiCharacterName(record = {}) {
26
+ const metadata = String(record.document?.character_name || '').trim()
27
+ if (metadata) return metadata
28
+ // 新版 character_activity 记录直接携带 character_name;旧版未拆分资料包
29
+ // 仍需从 prompt 首行的“名称:角色名”恢复,以保证升级前后都可检索。
30
+ for (const line of record.lines || []) {
31
+ const match = /^名称[::]\s*(.+)$/u.exec(String(line.text || '').trim())
32
+ if (match?.[1]) return match[1].trim()
33
+ }
34
+ return ''
35
+ }
36
+
37
+ export function wikiActivityName(document = {}) {
38
+ const metadata = String(document.activity_name || '').trim()
39
+ if (metadata) return metadata
40
+ const role = wikiDocumentRole(document)
41
+ return role === 'story' || role === 'character_activity'
42
+ ? String(document.display_title || '').trim() : ''
43
+ }
44
+
45
+ /** 旧版角色活动 Wiki 将多个活动放在同一篇文档,以活动名称标签划分正文。 */
46
+ export function wikiActivityRanges(record = {}) {
47
+ const ranges = []
48
+ const lines = record.lines || []
49
+ let current = null
50
+ for (let index = 0; index < lines.length; index += 1) {
51
+ const text = String(lines[index]?.text || '').trim()
52
+ const marker = /^<活动名称>\s*(.+?)\s*<\/活动名称>$/u.exec(text)
53
+ if (marker) {
54
+ if (current) current.end_line = index
55
+ current = { name: marker[1].trim(), start_line: index + 1, end_line: lines.length }
56
+ ranges.push(current)
57
+ } else if (current && text === '</相关内容>') {
58
+ current.end_line = index + 1
59
+ current = null
60
+ } else if (current && text === '</所有相关的活动剧情总结>') {
61
+ current.end_line = index
62
+ current = null
63
+ }
64
+ }
65
+ return ranges
66
+ }
67
+
68
+ /** 返回标签内部的 1-based 闭区间;标签行本身不包含在范围内。 */
69
+ export function wikiSectionRanges(record = {}, requested = []) {
70
+ const wanted = new Set((requested || []).map((item) => String(item || '').trim()).filter(Boolean))
71
+ const ranges = []
72
+ const lines = record.lines || []
73
+ for (let markerIndex = 0; markerIndex < lines.length; markerIndex += 1) {
74
+ const opened = OPEN_TAG.exec(String(lines[markerIndex]?.text || '').trim())
75
+ const name = opened?.[1]
76
+ if (!name || !WIKI_SECTION_SET.has(name)
77
+ || (wanted.size && !wanted.has(name))) continue
78
+
79
+ // 角色×活动辅助 Wiki 的子字段是半结构化的:它们没有各自的闭合标签,
80
+ // 而由下一个字段标签或外层 </相关内容> 隐式结束。容器字段仍优先寻找同名闭合。
81
+ let boundaryIndex = lines.length
82
+ for (let index = markerIndex + 1; index < lines.length; index += 1) {
83
+ const text = String(lines[index]?.text || '').trim()
84
+ const closed = CLOSE_TAG.exec(text)
85
+ if (closed?.[1] === name) {
86
+ boundaryIndex = index
87
+ break
88
+ }
89
+ if (WIKI_CONTAINER_SECTIONS.has(name)) continue
90
+ const nextOpen = OPEN_TAG.exec(text)
91
+ if ((nextOpen && WIKI_SECTION_SET.has(nextOpen[1])) || closed) {
92
+ boundaryIndex = index
93
+ break
94
+ }
95
+ }
96
+ const startLine = markerIndex + 2
97
+ const endLine = boundaryIndex
98
+ if (endLine < startLine) continue
99
+ ranges.push({ name, start_line: startLine, end_line: endLine,
100
+ marker_start_line: markerIndex + 1, marker_end_line: boundaryIndex + 1 })
101
+ }
102
+ return ranges.sort((left, right) => left.start_line - right.start_line
103
+ || left.end_line - right.end_line)
104
+ }
105
+
106
+ export function wikiSectionAt(ranges, lineNumber) {
107
+ const containing = (ranges || []).filter((range) =>
108
+ range.start_line <= lineNumber && lineNumber <= range.end_line)
109
+ return containing.sort((left, right) =>
110
+ (left.end_line - left.start_line) - (right.end_line - right.start_line))[0] || null
111
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
+ "moduleResolution": "Bundler",
7
+ "resolveJsonModule": true,
8
+ "types": ["vite/client"],
9
+ "strict": true,
10
+ "skipLibCheck": true,
11
+ "noEmit": true,
12
+ "allowImportingTsExtensions": true
13
+ },
14
+ "include": ["ui/rhine/**/*.ts"]
15
+ }
@@ -0,0 +1,31 @@
1
+ export const ARCHIVE_NAVIGATION_INTERVAL_MS = 350;
2
+ export const MAX_RETURNING_ARCHIVE_FILES = 3;
3
+
4
+ /** One shared budget for manual row/lane/point navigation; rejected input is not queued. */
5
+ export class ArchiveNavigationLimiter {
6
+ private lastAccepted = -Infinity;
7
+ private accepted = 0;
8
+ private rateLimited = 0;
9
+ private busyLimited = 0;
10
+
11
+ tryAccept(now: number, returningFiles = 0): boolean {
12
+ if (now - this.lastAccepted < ARCHIVE_NAVIGATION_INTERVAL_MS) {
13
+ this.rateLimited++;
14
+ return false;
15
+ }
16
+ // Wall-clock throttling alone cannot bound copies when rendering/animation
17
+ // slows down. Wait for existing returns to settle instead of adding more.
18
+ if (returningFiles >= MAX_RETURNING_ARCHIVE_FILES) {
19
+ this.busyLimited++;
20
+ return false;
21
+ }
22
+ this.lastAccepted = now;
23
+ this.accepted++;
24
+ return true;
25
+ }
26
+
27
+ stats() {
28
+ return { intervalMs: ARCHIVE_NAVIGATION_INTERVAL_MS, maxReturningFiles: MAX_RETURNING_ARCHIVE_FILES,
29
+ accepted: this.accepted, rateLimited: this.rateLimited, busyLimited: this.busyLimited, queued: 0 };
30
+ }
31
+ }
@@ -0,0 +1,83 @@
1
+ import { mergeSourcesInOrder, sourceIdentity } from './catalogue.ts';
2
+ import type { ArchiveSource } from './types';
3
+
4
+ type Options = {
5
+ api(endpoint: string, payload: any, signal?: AbortSignal): Promise<any>;
6
+ changed(sources: ArchiveSource[]): void;
7
+ failed(message: string): void;
8
+ };
9
+
10
+ /** Session-owned bookmarks, with a browser copy retained until synchronization succeeds. */
11
+ export function createArchiveRack(options: Options) {
12
+ let sessionId = '', generation = new AbortController(), queue = Promise.resolve();
13
+ let sources: ArchiveSource[] = [], pending = new Map<string, ArchiveSource>(), revision = -1;
14
+ let requestedRevision = -1, disposed = false;
15
+ const publish = () => options.changed(structuredClone(sources));
16
+ const current = (g: AbortController) => !disposed && g === generation;
17
+ function run(operation: (send: (endpoint: string, payload: any) => Promise<any>, g: AbortController) => Promise<void>) {
18
+ const g = generation, session = sessionId;
19
+ const send = async (endpoint: string, payload: any) => {
20
+ if (!current(g)) throw new DOMException('会话已切换', 'AbortError');
21
+ const result = await options.api(endpoint, { session_id: session, ...payload }, g.signal);
22
+ if (result?.ok === false || result?.error) throw new Error(result?.error?.message || result?.error || '档案架同步失败');
23
+ return result;
24
+ };
25
+ const task = queue.then(async () => {
26
+ if (!current(g)) throw new DOMException('会话已切换', 'AbortError');
27
+ await operation(send, g);
28
+ if (!current(g)) throw new DOMException('会话已切换', 'AbortError');
29
+ });
30
+ queue = task.catch(error => {
31
+ if (current(g) && error?.name !== 'AbortError') options.failed('档案架尚未同步给 Agent,资料已保留在此浏览器;可再次点击收藏重试。');
32
+ });
33
+ return task;
34
+ }
35
+ async function load(send: (endpoint: string, payload: any) => Promise<any>, g: AbortController) {
36
+ const remote: ArchiveSource[] = []; let cursor: number | null = 0, seen = revision;
37
+ while (cursor !== null && current(g)) {
38
+ const result = await send('investigation.get', { section: 'rack', saved_only: true, limit: 128, cursor });
39
+ for (const source of result.sources || []) remote.push({ ...source, id: source.sourceId || source.id, saved: true,
40
+ ...(source.agentRead ? { readRanges: source.ranges } : {}) });
41
+ cursor = result.nextCursor ?? null; seen = result.rack_revision;
42
+ }
43
+ if (!current(g)) return;
44
+ sources = mergeSourcesInOrder(sources, [...remote, ...pending.values()]);
45
+ revision = seen; publish();
46
+ }
47
+ return {
48
+ setSession(id: string, cached: ArchiveSource[]) {
49
+ generation.abort(); generation = new AbortController(); sessionId = id; queue = Promise.resolve();
50
+ sources = structuredClone(cached); pending = new Map(cached.map(s => [sourceIdentity(s), s])); revision = -1; requestedRevision = -1;
51
+ const initial = [...pending.values()];
52
+ return run(async (send, g) => {
53
+ if (initial.length) {
54
+ await send('investigation.rack', { action: 'import', sources: initial });
55
+ if (!current(g)) return;
56
+ for (const source of initial) if (pending.get(sourceIdentity(source)) === source) pending.delete(sourceIdentity(source));
57
+ }
58
+ await load(send, g);
59
+ });
60
+ },
61
+ refresh(nextRevision: number) {
62
+ if (!sessionId || disposed || nextRevision <= revision || nextRevision === requestedRevision) return Promise.resolve();
63
+ requestedRevision = nextRevision;
64
+ const g = generation;
65
+ return run(load).catch(error => { if (current(g)) requestedRevision = -1; throw error; });
66
+ },
67
+ save(source: ArchiveSource) {
68
+ const item = { ...structuredClone(source), saved: true }, key = sourceIdentity(item);
69
+ pending.set(key, item); sources = mergeSourcesInOrder(sources, [item]); publish();
70
+ return run(async (send, g) => {
71
+ const result = await send('investigation.rack', { action: 'add', mutation_id: crypto.randomUUID(), sources: [item] });
72
+ if (!current(g)) return;
73
+ if (pending.get(key) === item) pending.delete(key);
74
+ // A receipt can race a bookmark created elsewhere. Read the complete rack
75
+ // before treating this revision as locally synchronized.
76
+ if (result.rack_revision !== revision) await load(send, g);
77
+ else publish();
78
+ });
79
+ },
80
+ isPending(source: ArchiveSource) { return pending.has(sourceIdentity(source)); },
81
+ dispose() { disposed = true; generation.abort(); },
82
+ };
83
+ }
@@ -0,0 +1,24 @@
1
+ type Point = { x: number; y: number };
2
+
3
+ /** Map a DOM rectangle exactly onto the projected corners of a planar label. */
4
+ export function boardPlaneTransform(points: readonly Point[], width: number, height: number): string | null {
5
+ const [p0, p1, p2, p3] = points;
6
+ if (points.length !== 4 || !p3 || !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0
7
+ || points.some(p => !Number.isFinite(p.x) || !Number.isFinite(p.y))) return null;
8
+ let orientation = 0;
9
+ for (let index = 0; index < 4; index++) {
10
+ const a = points[index], b = points[(index + 1) % 4], c = points[(index + 2) % 4];
11
+ const cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x);
12
+ if (!Number.isFinite(cross) || Math.abs(cross) < 1e-6 || orientation && Math.sign(cross) !== orientation) return null;
13
+ orientation = Math.sign(cross);
14
+ }
15
+ const dx1 = p1.x - p2.x, dx2 = p3.x - p2.x, dx3 = p0.x - p1.x + p2.x - p3.x;
16
+ const dy1 = p1.y - p2.y, dy2 = p3.y - p2.y, dy3 = p0.y - p1.y + p2.y - p3.y;
17
+ const determinant = dx1 * dy2 - dx2 * dy1;
18
+ if (Math.abs(determinant) < 1e-6) return null;
19
+ const g = (dx3 * dy2 - dx2 * dy3) / determinant;
20
+ const h = (dx1 * dy3 - dx3 * dy1) / determinant;
21
+ const a = p1.x - p0.x + g * p1.x, b = p3.x - p0.x + h * p3.x;
22
+ const d = p1.y - p0.y + g * p1.y, e = p3.y - p0.y + h * p3.y;
23
+ return `matrix3d(${a / width},${d / width},0,${g / width},${b / height},${e / height},0,${h / height},0,0,1,0,${p0.x},${p0.y},0,1)`;
24
+ }
@@ -0,0 +1,72 @@
1
+ import type { ArchiveSource } from './types';
2
+
3
+ export const ARCHIVE_LANES = ['角色档案', '剧情记录', '世界资料', '时间线', '其他资料'] as const;
4
+ export { SHELF_PAGE_SIZE } from './shelf-layout.ts';
5
+
6
+ export function archiveLane(source: ArchiveSource): number {
7
+ if (/timeline/.test(source.kind)) return 3;
8
+ if (/character|operator|voice|module|skin/.test(source.kind)) return 0;
9
+ if (/story|activity|original/.test(source.kind)) return 1;
10
+ if (/entity|world|wiki|organization|faction|item|enemy/.test(source.kind)) return 2;
11
+ return 4;
12
+ }
13
+
14
+ /** Citation and discovery alone do not establish that the Agent read a source. */
15
+ export function belongsOnShelf(source: ArchiveSource): boolean {
16
+ return Boolean(source.saved || source.agentRead || source.state === 'read' || source.readRanges?.length);
17
+ }
18
+
19
+ /** Resolve the same version and any known locator, independent of shelf membership. */
20
+ export function sourceIndex(catalogue: ArchiveSource[], source: ArchiveSource): number {
21
+ return catalogue.findIndex(item => (item.dataVersion || '') === (source.dataVersion || '') &&
22
+ (item.id === source.id || (['documentId', 'documentUid', 'sourceRef'] as const).some(key => source[key] && source[key] === item[key])));
23
+ }
24
+
25
+ export function sourceIdentity(source: ArchiveSource): string {
26
+ // `id` survives enrichment with a local locator. Merging below resolves
27
+ // alternate IDs before the scene uses this stable, version-bound identity.
28
+ return `${source.dataVersion || ''}:${source.id}`;
29
+ }
30
+
31
+ /** Preserve shelf order and explicit read/citation evidence when results recur. */
32
+ export function mergeSourcesInOrder(previous: ArchiveSource[], incoming: ArchiveSource[]): ArchiveSource[] {
33
+ const rank = { found: 0, read: 1, cited: 2 };
34
+ const sources: (ArchiveSource | null)[] = [];
35
+ const aliases = new Map<string, number>();
36
+ const keys = (source: ArchiveSource) => [
37
+ ['id', source.id], ['document', source.documentId],
38
+ ['uid', source.documentUid], ['ref', source.sourceRef],
39
+ ].flatMap(([type, value]) => value ? [JSON.stringify([source.dataVersion || '', type, value])] : []);
40
+ const combine = (old: ArchiveSource, source: ArchiveSource): ArchiveSource => ({ ...old, ...source,
41
+ id: old.id,
42
+ kind: ['corpus_read', 'reference', 'source'].includes(source.kind) ? old.kind : source.kind,
43
+ state: rank[old.state] > rank[source.state] ? old.state : source.state,
44
+ agentRead: Boolean(old.agentRead || source.agentRead || old.state === 'read' || source.state === 'read'
45
+ || old.readRanges?.length || source.readRanges?.length),
46
+ saved: old.saved || source.saved,
47
+ origin: old.origin === 'cloud' ? 'cloud' : source.origin,
48
+ excerpt: source.excerpt || old.excerpt,
49
+ documentId: source.documentId || old.documentId,
50
+ documentUid: source.documentUid || old.documentUid,
51
+ sourceRef: source.sourceRef || old.sourceRef,
52
+ ranges: [...new Map([...(old.ranges || []), ...(source.ranges || [])].map(r => [`${r.start}:${r.end}`, r])).values()],
53
+ readRanges: [...new Map([...(old.readRanges || []), ...(source.readRanges || [])].map(r => [`${r.start}:${r.end}`, r])).values()],
54
+ });
55
+ for (const source of [...previous, ...incoming]) {
56
+ const matches = [...new Set(keys(source).flatMap(key => aliases.has(key) ? [aliases.get(key)!] : []))].sort((a, b) => a - b);
57
+ const index = matches[0] ?? sources.length;
58
+ let merged = sources[index] || null;
59
+ // A newly delivered locator can join two earlier aliases. Keep the first
60
+ // shelf position and retain every range before updating it with new data.
61
+ for (const duplicate of matches.slice(1)) {
62
+ if (sources[duplicate]) merged = merged ? combine(merged, sources[duplicate]!) : sources[duplicate];
63
+ sources[duplicate] = null;
64
+ for (const [key, value] of aliases) if (value === duplicate) aliases.set(key, index);
65
+ }
66
+ merged = merged ? combine(merged, source) : { ...source,
67
+ agentRead: Boolean(source.agentRead || source.state === 'read' || source.readRanges?.length) };
68
+ sources[index] = merged;
69
+ for (const key of [...keys(source), ...keys(merged)]) aliases.set(key, index);
70
+ }
71
+ return sources.filter((source): source is ArchiveSource => source !== null);
72
+ }
@@ -0,0 +1,146 @@
1
+ export interface PreparationScheduler {
2
+ requestAnimationFrame(callback: FrameRequestCallback): number;
3
+ cancelAnimationFrame(handle: number): void;
4
+ setTimeout(callback: () => void, delay: number): number;
5
+ clearTimeout(handle: number): void;
6
+ }
7
+
8
+ export interface DeferredPreparationOptions {
9
+ scheduler?: PreparationScheduler;
10
+ onError?: (error: unknown, key: string) => void;
11
+ /** Optional diagnostics. Receives no source keys; failures cannot interrupt the queue. */
12
+ monitorTask?: () => ((stage: 'sync' | 'settled', succeeded: boolean) => void) | undefined;
13
+ }
14
+
15
+ type PreparationTask = () => void | Promise<void>;
16
+ type PendingTask = { task: PreparationTask; priority: number };
17
+
18
+ /** Prepare at most one object after each animation-frame opportunity. */
19
+ export class DeferredPreparation {
20
+ private readonly scheduler: PreparationScheduler;
21
+ private readonly onError: (error: unknown, key: string) => void;
22
+ private readonly monitorTask: DeferredPreparationOptions['monitorTask'];
23
+ private readonly pending = new Map<string, PendingTask>();
24
+ private active = true;
25
+ private disposed = false;
26
+ private running = false;
27
+ private completed = 0;
28
+ private frame: number | undefined;
29
+ private timer: number | undefined;
30
+ private generation = 0;
31
+
32
+ constructor(options: DeferredPreparationOptions = {}) {
33
+ this.monitorTask = options.monitorTask;
34
+ this.scheduler = options.scheduler ?? {
35
+ requestAnimationFrame: callback => globalThis.requestAnimationFrame(callback),
36
+ cancelAnimationFrame: handle => globalThis.cancelAnimationFrame(handle),
37
+ setTimeout: (callback, delay) => globalThis.setTimeout(callback, delay),
38
+ clearTimeout: handle => globalThis.clearTimeout(handle),
39
+ };
40
+ this.onError = options.onError ?? ((error, key) => {
41
+ console.error(`Archive preparation failed: ${key}`, error);
42
+ });
43
+ }
44
+
45
+ /** Higher priority runs first. Replacing a pending key preserves FIFO order. */
46
+ enqueue(key: string, task: PreparationTask, priority = 0) {
47
+ if (this.disposed) return;
48
+ this.pending.set(key, { task, priority: Number.isFinite(priority) ? priority : 0 });
49
+ this.schedule();
50
+ }
51
+
52
+ /** Running work owns its cleanup; cancellation only removes pending work. */
53
+ cancel(key: string) {
54
+ this.pending.delete(key);
55
+ if (this.pending.size === 0) this.cancelScheduled();
56
+ }
57
+
58
+ setActive(active: boolean) {
59
+ if (this.disposed || this.active === active) return;
60
+ this.active = active;
61
+ if (active) this.schedule();
62
+ else this.cancelScheduled();
63
+ }
64
+
65
+ dispose() {
66
+ if (this.disposed) return;
67
+ this.disposed = true;
68
+ this.pending.clear();
69
+ this.cancelScheduled();
70
+ }
71
+
72
+ stats() {
73
+ return { pending: this.pending.size, running: this.running, completed: this.completed };
74
+ }
75
+
76
+ private cancelScheduled() {
77
+ this.generation++;
78
+ if (this.frame !== undefined) this.scheduler.cancelAnimationFrame(this.frame);
79
+ if (this.timer !== undefined) this.scheduler.clearTimeout(this.timer);
80
+ this.frame = undefined;
81
+ this.timer = undefined;
82
+ }
83
+
84
+ private schedule() {
85
+ if (this.disposed || !this.active || this.running || this.pending.size === 0 ||
86
+ this.frame !== undefined || this.timer !== undefined) return;
87
+ const generation = this.generation;
88
+ this.frame = this.scheduler.requestAnimationFrame(() => {
89
+ if (generation !== this.generation) return;
90
+ this.frame = undefined;
91
+ if (this.disposed || !this.active) return;
92
+ // Work in a subsequent task, not inside the scene's animation callback.
93
+ this.timer = this.scheduler.setTimeout(() => {
94
+ if (generation !== this.generation) return;
95
+ this.timer = undefined;
96
+ this.runNext();
97
+ }, 0);
98
+ });
99
+ }
100
+
101
+ private runNext() {
102
+ if (this.disposed || !this.active || this.running) return;
103
+ let selectedKey: string | undefined;
104
+ let selected: PendingTask | undefined;
105
+ for (const [key, pending] of this.pending) {
106
+ if (!selected || pending.priority > selected.priority) {
107
+ selected = pending;
108
+ selectedKey = key;
109
+ }
110
+ }
111
+ if (!selected || selectedKey === undefined) return;
112
+ const key = selectedKey;
113
+ this.pending.delete(key);
114
+ this.running = true;
115
+ let monitor: ReturnType<NonNullable<DeferredPreparationOptions['monitorTask']>>;
116
+ try { monitor = this.monitorTask?.(); } catch { /* diagnostics must not affect preparation */ }
117
+ const report = (stage: 'sync' | 'settled', succeeded: boolean) => { try { monitor?.(stage, succeeded); } catch { /* isolated */ } };
118
+ let result: void | Promise<void>;
119
+ try {
120
+ result = selected.task();
121
+ } catch (error) {
122
+ report('sync', false); report('settled', false);
123
+ this.settle(key, false, error);
124
+ return;
125
+ }
126
+ report('sync', true);
127
+ Promise.resolve(result).then(
128
+ () => { report('settled', true); this.settle(key, true); },
129
+ error => { report('settled', false); this.settle(key, false, error); },
130
+ );
131
+ }
132
+
133
+ private settle(key: string, succeeded: boolean, error?: unknown) {
134
+ this.running = false;
135
+ if (succeeded) this.completed++;
136
+ else {
137
+ try { this.onError(error, key); }
138
+ catch (handlerError) {
139
+ // A reporting callback must not reject the task's settlement promise
140
+ // or leave the remaining preparation queue stalled.
141
+ console.error("Archive preparation error handler failed.", handlerError);
142
+ }
143
+ }
144
+ this.schedule();
145
+ }
146
+ }
@@ -0,0 +1,22 @@
1
+ /* The view label is projected as a plane, just like the board's RHINE nameplate. */
2
+ .rhine-workbench .rhine-board-toolbar{position:absolute;z-index:9;display:flex;align-items:center;justify-content:space-between;gap:8px;box-sizing:border-box;width:320px;height:45px;padding:0 12px 0 15px;right:auto;bottom:auto;transform-origin:0 0;transition:none;font-family:MiSans,sans-serif;pointer-events:auto;white-space:nowrap;color:#625b50;background:#e8e2d8;border:1px solid #b5ab9b66;border-left:3px solid #b0a28b;box-shadow:0 1.5px 0 #786e5c33;backface-visibility:hidden}
3
+ .rhine-workbench .rhine-board-view-caption{font-size:8px;letter-spacing:1.4px;color:#958978;user-select:none}
4
+ .rhine-workbench .rhine-board-magnet{display:flex;height:100%;align-items:center;gap:0}
5
+ .rhine-workbench .rhine-board-magnet:last-child{border-left:1px solid #b5ab9b66;padding-left:9px}
6
+ .rhine-workbench .rhine-board-toolbar button{display:inline-flex;align-items:center;justify-content:center;gap:8px;box-sizing:border-box;height:100%;min-width:32px;padding:0 6px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:13px;cursor:pointer}
7
+ .rhine-workbench .rhine-board-toolbar button:hover{color:#5d4632;background:#b9a79620}
8
+ .rhine-workbench .rhine-board-toolbar button:disabled{opacity:.3;cursor:default}
9
+ .rhine-workbench .rhine-board-toolbar button:focus-visible,.rhine-workbench .rhine-board-chalk-hit:focus-visible{outline:2px solid #a38558;outline-offset:2px}
10
+ .rhine-workbench .rhine-board-toolbar .rhine-board-reset{min-width:49px;font-variant-numeric:tabular-nums;font-size:12px;color:#736856}
11
+ .rhine-workbench .rhine-board-toolbar .rhine-board-zoom-out,.rhine-workbench .rhine-board-toolbar .rhine-board-zoom-in{font-size:17px;font-weight:300}
12
+ .rhine-workbench .rhine-board-fullscreen-toggle svg{width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:1.2}
13
+ .rhine-workbench .rhine-board-toolbar.is-docked{width:240px;height:40px;padding-inline:8px;gap:2px;background:#ede8de}
14
+ .rhine-workbench .rhine-board-toolbar.is-docked .rhine-board-view-caption{display:none}
15
+ .rhine-workbench .rhine-board-toolbar.is-docked button{font-size:11px;padding-inline:5px;gap:5px}
16
+ /* Invisible keyboard/touch target over the real object; no floating badge or box. */
17
+ .rhine-workbench .rhine-board-chalk-hit{position:absolute;z-index:8;width:48px;height:48px;transform:scale(var(--board-ui-scale,1));transform-origin:0 0;padding:0;border:0;background:transparent;cursor:pointer;touch-action:manipulation}
18
+ .rhine-workbench .rhine-board-tool-status{position:absolute;z-index:14;left:50%;bottom:calc(90px * var(--board-ui-scale,1));display:flex;align-items:center;gap:14px;max-width:calc(90% / var(--board-ui-scale,1));padding:9px 13px;background:#f4eee2;border:1px solid #c6b49b;color:#7b674c;transform:translateX(-50%) scale(var(--board-ui-scale,1));transform-origin:50% 100%;font:11px/1.6 MiSans,sans-serif;box-shadow:0 4px 14px #77624712}
19
+ .rhine-workbench .rhine-board-tool-cancel{flex-shrink:0;border:0;background:none;color:#986d4c;font:inherit;cursor:pointer;padding:4px}
20
+ .rhine-workbench.rhine-board-fullscreen .rhine-board-tool-status{bottom:calc(30px * var(--board-ui-scale,1))}
21
+ .rhine-workbench [data-layout=portrait] .rhine-board-tool-status{max-width:calc(92% / var(--board-ui-scale,1));bottom:115px;gap:8px;font-size:10px}
22
+ .rhine-workbench.rhine-board-fullscreen [data-layout=portrait] .rhine-board-tool-status{bottom:24px}
@@ -0,0 +1,73 @@
1
+ import type { EvidenceCard } from './evidence-board-model';
2
+
3
+ export type EvidenceSnapshot = {
4
+ cards: EvidenceCard[];
5
+ selected: string | null;
6
+ promotions: [string, string][];
7
+ };
8
+ type Entry = { before: EvidenceSnapshot; after: EvidenceSnapshot; label: string; group?: string; time: number };
9
+ const copy = (state: EvidenceSnapshot): EvidenceSnapshot => ({
10
+ cards: state.cards.map(card => ({ ...card, ...(card.position ? { position: { ...card.position } } : {}),
11
+ ...(card.links ? { links: [...card.links] } : {}) })),
12
+ selected: state.selected, promotions: state.promotions.map(([from, to]) => [from, to]),
13
+ });
14
+ const sameCards = (a: EvidenceSnapshot, b: EvidenceSnapshot) => JSON.stringify(a.cards) === JSON.stringify(b.cards);
15
+
16
+ /** Session-local history. Selection-only changes never occupy an undo step. */
17
+ export function createEvidenceHistory(limit = 40) {
18
+ const past: Entry[] = [], future: Entry[] = [];
19
+ let mergeOpen = false;
20
+ return {
21
+ record(before: EvidenceSnapshot, after: EvidenceSnapshot, label: string, group?: string, time = Date.now()) {
22
+ if (sameCards(before, after)) return false;
23
+ const last = past.at(-1);
24
+ if (mergeOpen && group && last?.group === group && time - last.time < 1500 && sameCards(last.after, before)) {
25
+ last.after = copy(after); last.time = time;
26
+ if (sameCards(last.before, last.after)) past.pop();
27
+ } else {
28
+ past.push({ before: copy(before), after: copy(after), label, group, time });
29
+ if (past.length > limit) past.shift();
30
+ }
31
+ future.length = 0; mergeOpen = Boolean(group); return true;
32
+ },
33
+ undo() {
34
+ mergeOpen = false;
35
+ const entry = past.pop(); if (!entry) return null;
36
+ future.push(entry); return { state: copy(entry.before), from: copy(entry.after), label: entry.label };
37
+ },
38
+ redo() {
39
+ mergeOpen = false;
40
+ const entry = future.pop(); if (!entry) return null;
41
+ past.push(entry); return { state: copy(entry.after), from: copy(entry.before), label: entry.label };
42
+ },
43
+ remapIds(mapping: Record<string, string>, revisions: Record<string, { content_revision: number; layout_revision: number }> = {}) {
44
+ for (const entry of [...past, ...future]) for (const state of [entry.before, entry.after]) {
45
+ state.cards = state.cards.map(card => {
46
+ const id = mapping[card.id] || card.id, revision = id !== card.id ? revisions[id] : undefined;
47
+ return { ...card, id, ...(revision ? { contentRevision: revision.content_revision, layoutRevision: revision.layout_revision } : {}), links: card.links?.map(id => mapping[id] || id) };
48
+ });
49
+ if (state.selected) state.selected = mapping[state.selected] || state.selected;
50
+ }
51
+ },
52
+ seal() { mergeOpen = false; },
53
+ clear() { past.length = 0; future.length = 0; mergeOpen = false; },
54
+ stats() { return { undoCount: past.length, redoCount: future.length, undoLabel: past.at(-1)?.label,
55
+ redoLabel: future.at(-1)?.label }; },
56
+ };
57
+ }
58
+
59
+ /** Apply only this user's inverse fields; retain later agent additions and edits. */
60
+ export function rebaseEvidenceHistory(current: EvidenceCard[], from: EvidenceCard[], to: EvidenceCard[]) {
61
+ const result = structuredClone(current);
62
+ const keys = ['title','body','summary','kind','stage','position','rotation','scale','links'] as const;
63
+ const equal = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
64
+ for (const old of from) {
65
+ const desired = to.find(card => card.id === old.id), index = result.findIndex(card => card.id === old.id);
66
+ if (index < 0) continue;
67
+ if (!desired) { if (keys.every(key => equal(result[index][key], old[key]))) result.splice(index, 1); continue; }
68
+ const next = result[index] as Record<string, unknown>;
69
+ for (const key of keys) if (!equal(old[key], desired[key]) && equal(next[key], old[key])) next[key] = structuredClone(desired[key]);
70
+ }
71
+ for (const desired of to) if (!from.some(card => card.id === desired.id) && !result.some(card => card.id === desired.id)) result.push(structuredClone(desired));
72
+ return result;
73
+ }