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/search.js ADDED
@@ -0,0 +1,1522 @@
1
+ /** grep 风格 corpus_search:复用资料包索引,公开结果只使用自然标题与行号。 */
2
+ import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
3
+ import { deflateRawSync, inflateRawSync } from 'node:zlib'
4
+ import { activityMatches, aliasesFor } from './timeline.js'
5
+ import { assertCorpusVersion, corpusVersionSnapshot,
6
+ DOCUMENT_ORDERING_VERSION, documentGame, documentUid, naturalDocumentTitle } from './store.js'
7
+ import { projectSearch } from './search-projection.js'
8
+ import { wikiActivityName, wikiActivityRanges, wikiCharacterName, wikiDocumentRole,
9
+ wikiSectionAt, wikiSectionRanges } from './wiki.js'
10
+
11
+ export const SEARCH_CONTRACT_VERSION = 'prts-corpus-tools-v1'
12
+
13
+ const SEARCH_ERROR_CODES = new Set(['INVALID_REQUEST', 'PACKAGE_NOT_INSTALLED',
14
+ 'PACKAGE_VERSION_MISMATCH', 'INDEX_UNAVAILABLE', 'INDEX_CORRUPT', 'DOCUMENT_NOT_FOUND',
15
+ 'SOURCE_REF_INVALID', 'LINE_RANGE_INVALID', 'CURSOR_INVALID', 'CURSOR_VERSION_MISMATCH',
16
+ 'CURSOR_POLICY_MISMATCH', 'PAGE_ANCHOR_INVALID', 'PAGE_ANCHOR_MISMATCH',
17
+ 'PAGE_ANCHOR_NOT_FOUND', 'PAGE_ANCHOR_VERSION_MISMATCH', 'REGEX_REJECTED', 'TIMEOUT',
18
+ 'BUDGET_EXCEEDED', 'CANCELLED'])
19
+
20
+ function publicSearchError(error) {
21
+ const candidate = String(error?.code || '')
22
+ const code = SEARCH_ERROR_CODES.has(candidate) ? candidate : 'INTERNAL_ERROR'
23
+ return {
24
+ code,
25
+ message: code === 'INTERNAL_ERROR'
26
+ ? '本地语料搜索失败;详情请查看 DSH Host 日志' : (error?.message || String(error)),
27
+ retryable: code === 'INTERNAL_ERROR' ? false : (error?.retryable ?? false),
28
+ }
29
+ }
30
+
31
+ const PAGE_DOCUMENTS = 12
32
+ const MAX_PASSAGES_PER_DOCUMENT = 3
33
+ const PASSAGE_CLUSTER_GAP = 2
34
+ const RANK_POOL_CAP = 500
35
+ const SHORT_LITERAL_RANK_POOL_CAP = 128
36
+ const SIMPLE_LITERAL_MATCH_CAP_PER_DOCUMENT = 24
37
+ const SCAN_DOCUMENTS_PER_PAGE = 256
38
+ // 冷缓存下短字面量需要并发解压本地 JSONL 分片;8 秒会把正常检索误判为
39
+ // 超时。保留扫描上限和结果池上限,并把协作式时间预算提高到 15 秒。
40
+ const SEARCH_TIMEOUT_MS = 15000
41
+ const PREVIEW_OPTIONS = Object.freeze({ before_lines: 1, after_lines: 1,
42
+ max_chars_per_line: 2000, max_total_chars: 12000 })
43
+ const MATCHING_POLICY_VERSION = 1
44
+ const SEARCH_POLICY_FINGERPRINT = createHash('sha256').update(JSON.stringify({
45
+ pageDocuments: PAGE_DOCUMENTS,
46
+ scanDocumentsPerPage: SCAN_DOCUMENTS_PER_PAGE,
47
+ passagesPerDocument: MAX_PASSAGES_PER_DOCUMENT,
48
+ passageClusterGap: PASSAGE_CLUSTER_GAP,
49
+ simpleLiteralMatchCapPerDocument: SIMPLE_LITERAL_MATCH_CAP_PER_DOCUMENT,
50
+ preview: PREVIEW_OPTIONS,
51
+ matchingPolicyVersion: MATCHING_POLICY_VERSION,
52
+ })).digest('base64url').slice(0, 16)
53
+ const FILTER_LIMIT = 16
54
+ const DATA_VERSION_PATTERN = /^[0-9a-f]{64}$/
55
+ /** 过滤单项最长 512 码点:与上述项数上限共同保证压缩游标可被重新解码。 */
56
+ const FILTER_ITEM_LIMIT = 512
57
+ /**
58
+ * 游标的物理上限:decodeCursor 的长度检查与解压输出上限必须容纳
59
+ * FILTER_LIMIT × FILTER_ITEM_LIMIT 的最坏合法请求(CJK 直存约 3 字节/字符,
60
+ * 再计 JSON 键与转义余量),否则自己签发的游标会翻页失败。
61
+ */
62
+ const CURSOR_MAX_LENGTH = 65_536
63
+ const CURSOR_MAX_INFLATED = 262_144
64
+ const ENTITY_QUERY_MAX_DISTANCE = 256
65
+ const PROFILE_CATEGORIES = new Set(['干员档案', '招聘合同', '潜能与信物'])
66
+ const FILTER_FIELDS = {
67
+ story_names: 'story_name',
68
+ }
69
+ const CURSOR_FILTER_KEYS = Object.freeze({
70
+ resource_types: 'r', character_names: 'c', story_names: 's', activity_names: 'a',
71
+ entity_names: 'e', speakers: 'p', wiki_sections: 'w', games: 'g',
72
+ content_types: 't', collection_names: 'n',
73
+ })
74
+
75
+ function assertSearchActive(signal, deadline, phase = '本地语料搜索') {
76
+ if (signal?.aborted) throw Object.assign(new Error('搜索已取消'), { code: 'CANCELLED' })
77
+ if (Number.isFinite(deadline) && Date.now() >= deadline) {
78
+ throw Object.assign(new Error(`${phase}超时`), { code: 'TIMEOUT', retryable: true })
79
+ }
80
+ }
81
+
82
+ function normalizeText(value) {
83
+ return String(value ?? '').normalize('NFKC').replace(/\s+/gu, ' ').trim()
84
+ }
85
+
86
+ function ngramsFor(value) {
87
+ const chars = [...normalizeText(value).toLowerCase()]
88
+ if (!chars.length) return []
89
+ const size = Math.min(3, chars.length)
90
+ return [...new Set(Array.from({ length: chars.length - size + 1 }, (_, index) =>
91
+ chars.slice(index, index + size).join('')))]
92
+ }
93
+
94
+ function resourceMatches(document, requested) {
95
+ if (!requested.length) return true
96
+ const explicit = String(document.resource_type || '')
97
+ const type = String(document.document_type || '')
98
+ const kind = String(document.document_kind || '')
99
+ const category = String(document.document_category || '')
100
+ const wikiRole = wikiDocumentRole(document)
101
+ return requested.some((resource) => {
102
+ // v2 documents carry a narrow resource_type, but callers may deliberately
103
+ // request a semantic union. Exact-match short-circuiting here used to make
104
+ // character_bundle/reviewed_wiki/wiki silently exclude every Endfield v2
105
+ // document.
106
+ if (resource === explicit) return true
107
+ if (resource === 'story' && explicit === 'original_story') return true
108
+ if (resource === 'character_bundle'
109
+ && ['character_profile', 'character_module', 'character_voice',
110
+ 'operator_record'].includes(explicit)) return true
111
+ if (resource === 'reviewed_wiki'
112
+ && ['character_wiki', 'story_wiki', 'character_activity_wiki',
113
+ 'knowledge'].includes(explicit)) return true
114
+ if (resource === 'wiki'
115
+ && ['character_wiki', 'story_wiki', 'character_activity_wiki',
116
+ 'knowledge'].includes(explicit)) return true
117
+ // v2 common vocabulary over legacy Arknights v1 metadata.
118
+ if (resource === 'original_story') return type === 'story' && category !== 'memory' && kind !== 'synopsis'
119
+ if (resource === 'character_story') return type === 'story' && category === 'memory'
120
+ if (resource === 'archive') return type === 'knowledge' && kind === 'official_archive'
121
+ if (resource === 'knowledge') return type === 'knowledge' && kind === 'wiki'
122
+ if (resource === 'wiki') return type === 'knowledge' && kind === 'wiki'
123
+ if (resource === 'timeline') return type === 'reference'
124
+ if (resource === 'story') return type === 'story' && category !== 'memory' && kind !== 'synopsis'
125
+ if (resource === 'operator_record') return type === 'story' && category === 'memory'
126
+ if (resource === 'character_profile') return type === 'character' && PROFILE_CATEGORIES.has(category)
127
+ if (resource === 'character_module') return type === 'character' && category === '模组文案'
128
+ if (resource === 'character_voice') return type === 'character' && category === '干员语音'
129
+ if (resource === 'character_skin') return type === 'character' && category === '时装文案'
130
+ if (resource === 'character_bundle') return (type === 'character'
131
+ && (PROFILE_CATEGORIES.has(category) || category === '模组文案' || category === '干员语音'))
132
+ || (type === 'story' && category === 'memory')
133
+ if (resource === 'character_wiki') return type === 'knowledge' && kind === 'wiki'
134
+ && wikiRole === 'character'
135
+ if (resource === 'story_wiki') return type === 'knowledge' && kind === 'wiki'
136
+ && wikiDocumentRole(document) === 'story'
137
+ if (resource === 'character_activity_wiki') return type === 'knowledge' && kind === 'wiki'
138
+ && wikiDocumentRole(document) === 'character_activity'
139
+ if (resource === 'reviewed_wiki') return type === 'knowledge' && kind === 'wiki'
140
+ if (resource === 'terra_journey') return type === 'knowledge' && kind === 'terra_journey'
141
+ if (resource === 'entity_profile') return type === 'entity'
142
+ return resource === 'reference' && type === 'reference'
143
+ })
144
+ }
145
+
146
+ /**
147
+ * 线性正则子集。
148
+ *
149
+ * JavaScript RegExp 没有可抢占的单次匹配超时;仅靠识别少数“嵌套量词”无法
150
+ * 阻止 (a|aa)+$ 一类 ReDoS。因此这里只接受没有分支和可变量词的子集:
151
+ * 字面量、锚点、点号、字符类、转义字符,以及上限很小的固定次数 {n}。
152
+ * 该语法没有输入相关的回溯分支,最坏执行时间随文本和 pattern 线性增长。
153
+ */
154
+ function safeRegex(pattern) {
155
+ const rejected = (reason) => Object.assign(new Error(
156
+ `正则表达式超出安全线性子集(${reason});仅允许字面量、^/$、点号、字符类、转义和固定次数 {n}`,
157
+ ), { code: 'REGEX_REJECTED' })
158
+ if (pattern.length > 256) throw rejected('长度超过 256')
159
+ let inClass = false
160
+ let canRepeat = false
161
+ for (let index = 0; index < pattern.length; index += 1) {
162
+ const character = pattern[index]
163
+ if (character === '\\') {
164
+ const escaped = pattern[index + 1]
165
+ if (escaped === undefined) break // 交给 RegExp 编译器报告不完整转义
166
+ if (/[1-9]/.test(escaped) || escaped === 'k') throw rejected('不允许反向引用')
167
+ if ((escaped === 'p' || escaped === 'P') && pattern[index + 2] === '{') {
168
+ throw rejected('不允许 Unicode 属性转义')
169
+ }
170
+ if (escaped === 'u' && pattern[index + 2] === '{') {
171
+ throw rejected('不允许带花括号的 Unicode 转义')
172
+ }
173
+ index += 1
174
+ if (!inClass) canRepeat = escaped !== 'b' && escaped !== 'B'
175
+ continue
176
+ }
177
+ if (inClass) {
178
+ if (character === ']') {
179
+ inClass = false
180
+ canRepeat = true
181
+ }
182
+ continue
183
+ }
184
+ if (character === '[') {
185
+ inClass = true
186
+ canRepeat = false
187
+ continue
188
+ }
189
+ if ('()|*+?'.includes(character)) {
190
+ throw rejected(`不允许 ${character}`)
191
+ }
192
+ if (character === '{') {
193
+ if (!canRepeat) throw rejected('固定次数前缺少可重复字符')
194
+ const fixed = /^\{([0-9]{1,2})\}/.exec(pattern.slice(index))
195
+ if (!fixed) throw rejected('只允许 {n},不允许范围或开放式量词')
196
+ const count = Number(fixed[1])
197
+ if (count > 64) throw rejected('固定次数不能超过 64')
198
+ index += fixed[0].length - 1
199
+ canRepeat = false
200
+ continue
201
+ }
202
+ if (character === '}') throw rejected('未转义的 }')
203
+ canRepeat = character !== '^' && character !== '$'
204
+ }
205
+ try {
206
+ return new RegExp(pattern, 'u')
207
+ } catch (error) {
208
+ throw Object.assign(new Error(`正则表达式无法编译: ${error.message}`), { code: 'REGEX_REJECTED' })
209
+ }
210
+ }
211
+
212
+ function normalizedRequest(raw = {}) {
213
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
214
+ throw Object.assign(new Error('搜索参数必须是对象'), { code: 'INVALID_REQUEST' })
215
+ }
216
+ if (raw.cursor != null) {
217
+ if (typeof raw.cursor !== 'string' || raw.cursor.length < 1
218
+ || raw.cursor.length > CURSOR_MAX_LENGTH) {
219
+ throw Object.assign(new Error(`cursor 必须是 1..${CURSOR_MAX_LENGTH} 字符的字符串`),
220
+ { code: 'INVALID_REQUEST' })
221
+ }
222
+ return { cursor: raw.cursor }
223
+ }
224
+ let after = null
225
+ if (raw.after != null) {
226
+ if (!raw.after || typeof raw.after !== 'object' || Array.isArray(raw.after)) {
227
+ throw Object.assign(new Error('after 必须包含 data_version、resource_type、title 和 position'),
228
+ { code: 'INVALID_REQUEST' })
229
+ }
230
+ const dataVersion = raw.after.data_version
231
+ const resourceType = normalizeText(raw.after.resource_type)
232
+ const title = normalizeText(raw.after.title)
233
+ const position = raw.after.position
234
+ if (typeof dataVersion !== 'string' || !DATA_VERSION_PATTERN.test(dataVersion)
235
+ || !resourceType || !title || !Number.isSafeInteger(position) || position < 0
236
+ || position > 10_000_000
237
+ || Object.keys(raw.after).some((key) => ![
238
+ 'data_version', 'resource_type', 'title', 'position',
239
+ ].includes(key))) {
240
+ throw Object.assign(new Error(
241
+ 'after 必须包含且只能包含 64 位小写 data_version、resource_type、title 和 0..10000000 的安全整数 position'),
242
+ { code: 'INVALID_REQUEST' })
243
+ }
244
+ after = { data_version: dataVersion, resource_type: resourceType, title, position }
245
+ }
246
+ const query = normalizeText(raw.query)
247
+ if ([...query].length > 512) throw Object.assign(new Error('literal query 最多 512 个字符'),
248
+ { code: 'INVALID_REQUEST' })
249
+ const filters = {}
250
+ for (const field of ['resource_types', 'character_names', 'story_names', 'activity_names',
251
+ 'entity_names', 'speakers', 'wiki_sections', 'games', 'content_types', 'collection_names']) {
252
+ if (raw[field] !== undefined && (!Array.isArray(raw[field]) || !raw[field].length)) {
253
+ throw Object.assign(new Error(`${field} 必须是非空数组`), { code: 'INVALID_REQUEST' })
254
+ }
255
+ filters[field] = [...new Set((raw[field] || []).map(normalizeText))]
256
+ if (filters[field].some((value) => !value)) {
257
+ throw Object.assign(new Error(`${field} 不能包含空字符串`), { code: 'INVALID_REQUEST' })
258
+ }
259
+ if (filters[field].length > FILTER_LIMIT) {
260
+ throw Object.assign(new Error(`${field} 最多 ${FILTER_LIMIT} 项`), { code: 'INVALID_REQUEST' })
261
+ }
262
+ // 单项长度也设上限:合法请求的压缩游标必须能被 decodeCursor 的
263
+ // 长度/解压上限重新解码,否则首次搜索成功、翻页却 CURSOR_INVALID。
264
+ if (filters[field].some((value) => [...value].length > FILTER_ITEM_LIMIT)) {
265
+ throw Object.assign(new Error(`${field} 单项最长 ${FILTER_ITEM_LIMIT} 个字符`), { code: 'INVALID_REQUEST' })
266
+ }
267
+ }
268
+ if (filters.games.some((value) => !['arknights', 'endfield'].includes(value.toLocaleLowerCase()))) {
269
+ throw Object.assign(new Error('games 仅支持 arknights / endfield'), { code: 'INVALID_REQUEST' })
270
+ }
271
+ filters.games = filters.games.map((value) => value.toLocaleLowerCase())
272
+ if (!query && !Object.values(filters).some((items) => items.length)) {
273
+ throw Object.assign(new Error('请提供 query 或至少一个资料/人物/篇章/说话人过滤条件'), { code: 'INVALID_REQUEST' })
274
+ }
275
+ const matchMode = raw.match_mode ?? 'literal'
276
+ if (!['literal', 'regex'].includes(matchMode)) {
277
+ throw Object.assign(new Error('match_mode 仅支持 literal / regex'), { code: 'INVALID_REQUEST' })
278
+ }
279
+ if (matchMode === 'regex') {
280
+ if (!query) throw Object.assign(new Error('regex 模式必须提供 query'), { code: 'INVALID_REQUEST' })
281
+ safeRegex(query)
282
+ }
283
+ const contextTerms = raw.context_terms === undefined ? [] : raw.context_terms
284
+ if (!Array.isArray(contextTerms) || contextTerms.length > 8 || contextTerms.some((item) => !normalizeText(item))) {
285
+ throw Object.assign(new Error('context_terms 必须是最多 8 项的非空字符串数组'), { code: 'INVALID_REQUEST' })
286
+ }
287
+ if (contextTerms.some((item) => [...normalizeText(item)].length > FILTER_ITEM_LIMIT)) {
288
+ throw Object.assign(new Error(`context_terms 单项最长 ${FILTER_ITEM_LIMIT} 个字符`), { code: 'INVALID_REQUEST' })
289
+ }
290
+ if (contextTerms.length && !query && !filters.speakers.length && !filters.entity_names.length) {
291
+ throw Object.assign(new Error('context_terms 需要 query、speakers 或 entity_names 作为主条件'),
292
+ { code: 'INVALID_REQUEST' })
293
+ }
294
+ if (!query && filters.wiki_sections.length > 1) {
295
+ throw Object.assign(new Error('无 query 的完整字段查询一次只能选择一个 wiki_sections 值'),
296
+ { code: 'INVALID_REQUEST' })
297
+ }
298
+ return {
299
+ query, filters, match_mode: matchMode,
300
+ context_terms: [...new Set(contextTerms.map(normalizeText))],
301
+ after,
302
+ }
303
+ }
304
+
305
+ function withoutAfter(request) {
306
+ const { after: _after, ...searchRequest } = request
307
+ return searchRequest
308
+ }
309
+
310
+ async function checkpointAfterTitle(store, after, request, { signal, deadline } = {}) {
311
+ assertSearchActive(signal, deadline, '分页锚点校验')
312
+ if (after.data_version !== store.dataVersion) {
313
+ throw Object.assign(new Error('分页锚点绑定到另一个资料版本,请重新搜索'),
314
+ { code: 'PAGE_ANCHOR_VERSION_MISMATCH', retryable: false })
315
+ }
316
+ const regex = request.match_mode === 'regex'
317
+ ? new RegExp(safeRegex(request.query).source, 'iu') : null
318
+ const candidates = await candidateDocumentIds(store, request, regex, { signal, deadline })
319
+ assertSearchActive(signal, deadline, '分页锚点校验')
320
+ const documentId = candidates[after.position]
321
+ const location = documentId ? store.documents.get(documentId) : null
322
+ if (!location) throw Object.assign(new Error('找不到分页锚点,请重新搜索'),
323
+ { code: 'PAGE_ANCHOR_NOT_FOUND' })
324
+ if (publicResourceType(location.document) !== after.resource_type
325
+ || normalizeText(naturalDocumentTitle(location.document)) !== normalizeText(after.title)) {
326
+ throw Object.assign(new Error('分页锚点与当前资料版本不匹配,请重新搜索'),
327
+ { code: 'PAGE_ANCHOR_MISMATCH' })
328
+ }
329
+ return { nextCandidateIndex: after.position + 1, matchedDocumentsSoFar: 0,
330
+ matchedCountKnown: false }
331
+ }
332
+
333
+ async function exposeTitleContinuation(store, result, { signal, deadline } = {}) {
334
+ if (result?.error || !result?.page) return result
335
+ assertSearchActive(signal, deadline, '分页锚点生成')
336
+ const { next_cursor: internalCursor, ...page } = result.page
337
+ let nextAfter = null
338
+ if (internalCursor) {
339
+ const decoded = await decodeCursor(store, internalCursor)
340
+ assertSearchActive(signal, deadline, '分页锚点生成')
341
+ // Legacy offsets index a relevance-ranked pool, not the ordered candidate
342
+ // list. Preserve that continuation rather than inventing a scan anchor.
343
+ if (decoded.kind === 'legacy') {
344
+ return { ...result, page: { ...page, next_after: null, next_cursor: internalCursor } }
345
+ }
346
+ const regex = decoded.request.match_mode === 'regex'
347
+ ? new RegExp(safeRegex(decoded.request.query).source, 'iu') : null
348
+ const candidates = await candidateDocumentIds(store, decoded.request, regex, { signal, deadline })
349
+ assertSearchActive(signal, deadline, '分页锚点生成')
350
+ const anchorOrdinal = decoded.nextCandidateIndex - 1
351
+ const documentId = candidates[anchorOrdinal]
352
+ const document = documentId ? store.documents.get(documentId)?.document : null
353
+ if (!document) throw Object.assign(new Error('无法生成下一页的标题锚点'),
354
+ { code: 'PAGE_ANCHOR_INVALID' })
355
+ nextAfter = { data_version: store.dataVersion, resource_type: publicResourceType(document),
356
+ title: naturalDocumentTitle(document), position: anchorOrdinal }
357
+ }
358
+ return { ...result, page: { ...page, next_after: nextAfter } }
359
+ }
360
+
361
+ function cursorVersionTag(dataVersion) {
362
+ return createHash('sha256').update(String(dataVersion)).digest().subarray(0, 12).toString('base64url')
363
+ }
364
+
365
+ function compactCursorRequest(request) {
366
+ const filters = {}
367
+ for (const [field, key] of Object.entries(CURSOR_FILTER_KEYS)) {
368
+ if (request.filters[field]?.length) filters[key] = request.filters[field]
369
+ }
370
+ return [request.query || '', Object.keys(filters).length ? filters : 0,
371
+ request.match_mode === 'regex' ? 'r' : 0,
372
+ request.context_terms?.length ? request.context_terms : 0]
373
+ }
374
+
375
+ function expandCursorRequest(compact) {
376
+ if (!Array.isArray(compact) || compact.length !== 4) return null
377
+ const [query, encodedFilters, mode, contextTerms] = compact
378
+ if (typeof query !== 'string' || (encodedFilters !== 0
379
+ && (!encodedFilters || typeof encodedFilters !== 'object' || Array.isArray(encodedFilters)))
380
+ || ![0, 'r'].includes(mode) || (contextTerms !== 0 && !Array.isArray(contextTerms))) return null
381
+ const filters = Object.fromEntries(Object.keys(CURSOR_FILTER_KEYS).map((field) => [field, []]))
382
+ if (encodedFilters !== 0) {
383
+ const reverse = Object.fromEntries(Object.entries(CURSOR_FILTER_KEYS).map(([field, key]) => [key, field]))
384
+ for (const [key, values] of Object.entries(encodedFilters)) {
385
+ const field = reverse[key]
386
+ if (!field || !Array.isArray(values)) return null
387
+ filters[field] = values
388
+ }
389
+ }
390
+ return { query, filters, match_mode: mode === 'r' ? 'regex' : 'literal',
391
+ context_terms: contextTerms === 0 ? [] : contextTerms }
392
+ }
393
+
394
+ function validCursorSignature(body, received, secret, bytes = 32) {
395
+ const expected = createHmac('sha256', secret).update(body).digest().subarray(0, bytes)
396
+ const encoded = String(received || '')
397
+ let actual
398
+ try { actual = Buffer.from(encoded, 'base64url') } catch { actual = Buffer.alloc(0) }
399
+ return encoded === actual.toString('base64url')
400
+ && actual.length === expected.length && timingSafeEqual(actual, expected)
401
+ }
402
+
403
+ async function encodeOffsetCursor(store, request, offset) {
404
+ const payload = [3, cursorVersionTag(store.dataVersion), offset, compactCursorRequest(request)]
405
+ const compressed = deflateRawSync(Buffer.from(JSON.stringify(payload)), { level: 9 })
406
+ const encoded = compressed.toString('base64url')
407
+ const body = `s3.${encoded}`
408
+ const secret = await store.getOrCreateCursorSecret()
409
+ const signature = createHmac('sha256', secret).update(body).digest().subarray(0, 16).toString('base64url')
410
+ return `${body}.${signature}`
411
+ }
412
+
413
+ async function encodeScanCursor(store, request, nextCandidateIndex, matchedDocumentsSoFar) {
414
+ const payload = [4, cursorVersionTag(store.dataVersion), DOCUMENT_ORDERING_VERSION,
415
+ SEARCH_POLICY_FINGERPRINT, nextCandidateIndex, matchedDocumentsSoFar,
416
+ compactCursorRequest(request)]
417
+ const compressed = deflateRawSync(Buffer.from(JSON.stringify(payload)), { level: 9 })
418
+ const encoded = compressed.toString('base64url')
419
+ const body = `s4.${encoded}`
420
+ const secret = await store.getOrCreateCursorSecret()
421
+ const signature = createHmac('sha256', secret).update(body).digest()
422
+ .subarray(0, 16).toString('base64url')
423
+ return `${body}.${signature}`
424
+ }
425
+
426
+ async function decodeCursor(store, cursor) {
427
+ if (String(cursor).length > CURSOR_MAX_LENGTH) {
428
+ throw Object.assign(new Error('cursor 超过长度上限'), { code: 'CURSOR_INVALID' })
429
+ }
430
+ const secret = await store.getOrCreateCursorSecret()
431
+ const parts = String(cursor).split('.')
432
+ if (parts.length === 3 && parts[0] === 's4') {
433
+ const body = `${parts[0]}.${parts[1]}`
434
+ if (!validCursorSignature(body, parts[2], secret, 16)) {
435
+ throw Object.assign(new Error('cursor 签名无效'), { code: 'CURSOR_INVALID' })
436
+ }
437
+ let value
438
+ try {
439
+ value = JSON.parse(inflateRawSync(Buffer.from(parts[1], 'base64url'),
440
+ { maxOutputLength: CURSOR_MAX_INFLATED }).toString('utf8'))
441
+ } catch {
442
+ throw Object.assign(new Error('cursor 无法解析'), { code: 'CURSOR_INVALID' })
443
+ }
444
+ const request = Array.isArray(value) ? expandCursorRequest(value[6]) : null
445
+ if (value?.[0] !== 4 || !request || !Number.isInteger(value[4]) || value[4] < 0
446
+ || !Number.isInteger(value[5]) || value[5] < 0) {
447
+ throw Object.assign(new Error('cursor 内容无效'), { code: 'CURSOR_INVALID' })
448
+ }
449
+ if (value[1] !== cursorVersionTag(store.dataVersion)) {
450
+ throw Object.assign(new Error('cursor 绑定到另一个资料版本'),
451
+ { code: 'CURSOR_VERSION_MISMATCH' })
452
+ }
453
+ if (value[2] !== DOCUMENT_ORDERING_VERSION || value[3] !== SEARCH_POLICY_FINGERPRINT) {
454
+ throw Object.assign(new Error('cursor 绑定的排序或搜索策略已经变化,请重新搜索'),
455
+ { code: 'CURSOR_POLICY_MISMATCH' })
456
+ }
457
+ return { kind: 'scan', request, nextCandidateIndex: value[4],
458
+ matchedDocumentsSoFar: value[5] }
459
+ }
460
+ if (parts.length === 3 && parts[0] === 's3') {
461
+ const body = `${parts[0]}.${parts[1]}`
462
+ if (!validCursorSignature(body, parts[2], secret, 16)) {
463
+ throw Object.assign(new Error('cursor 签名无效'), { code: 'CURSOR_INVALID' })
464
+ }
465
+ let value
466
+ try {
467
+ value = JSON.parse(inflateRawSync(Buffer.from(parts[1], 'base64url'),
468
+ { maxOutputLength: CURSOR_MAX_INFLATED }).toString('utf8'))
469
+ } catch {
470
+ throw Object.assign(new Error('cursor 无法解析'), { code: 'CURSOR_INVALID' })
471
+ }
472
+ const request = Array.isArray(value) ? expandCursorRequest(value[3]) : null
473
+ if (value?.[0] !== 3 || value[1] !== cursorVersionTag(store.dataVersion)
474
+ || !Number.isInteger(value[2]) || value[2] < 0 || !request) {
475
+ throw Object.assign(new Error(value?.[1] !== cursorVersionTag(store.dataVersion)
476
+ ? 'cursor 绑定到另一个资料版本' : 'cursor 内容无效'),
477
+ { code: value?.[1] !== cursorVersionTag(store.dataVersion)
478
+ ? 'CURSOR_VERSION_MISMATCH' : 'CURSOR_INVALID' })
479
+ }
480
+ return { kind: 'legacy', request, offset: value[2] }
481
+ }
482
+
483
+ // v2 长游标兼容:已发给模型或持久化在旧会话中的 cursor 仍可继续分页。
484
+ const [body, received] = parts
485
+ if (parts.length !== 2 || !validCursorSignature(body || '', received, secret)) {
486
+ throw Object.assign(new Error('cursor 签名无效'), { code: 'CURSOR_INVALID' })
487
+ }
488
+ let value
489
+ try { value = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) } catch {
490
+ throw Object.assign(new Error('cursor 无法解析'), { code: 'CURSOR_INVALID' })
491
+ }
492
+ if (value.data_version !== store.dataVersion) {
493
+ throw Object.assign(new Error('cursor 绑定到另一个资料版本'), { code: 'CURSOR_VERSION_MISMATCH' })
494
+ }
495
+ if (value.v !== 2 || value.tool !== 'corpus_search' || !value.request
496
+ || !Number.isInteger(value.offset) || value.offset < 0) {
497
+ throw Object.assign(new Error('cursor 内容无效'), { code: 'CURSOR_INVALID' })
498
+ }
499
+ return { kind: 'legacy', request: value.request, offset: value.offset }
500
+ }
501
+
502
+ function documentMatches(document, speakers, filters) {
503
+ if (filters.games?.length && !filters.games.includes(documentGame(document))) return false
504
+ if (!resourceMatches(document, filters.resource_types)) return false
505
+ if (filters.content_types?.length
506
+ && !filters.content_types.includes(publicContentType(document))) return false
507
+ if (filters.collection_names?.length
508
+ && !filters.collection_names.includes(publicCollectionName(document))) return false
509
+ for (const [filter, field] of Object.entries(FILTER_FIELDS)) {
510
+ if (filters[filter].length && !filters[filter].includes(normalizeText(document[field]))) return false
511
+ }
512
+ const wikiRole = wikiDocumentRole(document)
513
+ if (filters.character_names.length
514
+ && (wikiRole !== 'character_activity' || normalizeText(document.character_name))
515
+ && !filters.character_names.includes(normalizeText(document.character_name))) return false
516
+ if (filters.activity_names.length && !needsWikiActivityHydration(document)) {
517
+ const activity = { ...document, activity_name: wikiActivityName(document)
518
+ || normalizeText(document.activity_name) }
519
+ const exact = wikiRole === 'story' || wikiRole === 'character_activity'
520
+ if (!filters.activity_names.some((name) => activityMatches(activity, name, { exact }))) return false
521
+ }
522
+ if (filters.wiki_sections.length && !wikiRole) return false
523
+ return !filters.speakers.length || filters.speakers.some((speaker) => speakers.includes(speaker))
524
+ }
525
+
526
+ function needsWikiActivityHydration(document) {
527
+ return wikiDocumentRole(document) === 'character_activity'
528
+ && !normalizeText(document.activity_name)
529
+ }
530
+
531
+ /** null 表示不限定活动行;旧包必须在加载正文后按活动块精确过滤。 */
532
+ function requestedWikiActivityRanges(record, filters) {
533
+ if (!filters.activity_names.length || !needsWikiActivityHydration(record.document)) return null
534
+ return wikiActivityRanges(record).filter((range) => filters.activity_names.some((name) =>
535
+ activityMatches({ activity_name: range.name }, name, { exact: true })))
536
+ }
537
+
538
+ /**
539
+ * 将已由轻量元数据过滤过的文档范围投影为 pack 范围。旧测试替身或第三方
540
+ * Store 若没有暴露 packId,则返回 null,保留全库查询语义,避免假阴性。
541
+ */
542
+ function packIdsForDocumentIds(store, documentIds) {
543
+ if (!documentIds.length) return []
544
+ const packIds = new Set()
545
+ for (const documentId of documentIds) {
546
+ const packId = store.documents.get(documentId)?.packId
547
+ if (!packId) return null
548
+ packIds.add(packId)
549
+ }
550
+ return [...packIds]
551
+ }
552
+
553
+ function documentScopeForFilters(store, filters) {
554
+ const documentIds = []
555
+ for (const [documentId, item] of store.documents) {
556
+ if (documentMatches(item.document, item.speakers ?? [], filters)) documentIds.push(documentId)
557
+ }
558
+ return { documentIds, packIds: packIdsForDocumentIds(store, documentIds) }
559
+ }
560
+
561
+ function hydratedRecordMatches(record, filters) {
562
+ if (filters.character_names.length && wikiDocumentRole(record.document) === 'character_activity'
563
+ && !normalizeText(record.document.character_name)
564
+ && !filters.character_names.includes(normalizeText(wikiCharacterName(record)))) return false
565
+ return true
566
+ }
567
+
568
+ function lineContent(line) {
569
+ const text = normalizeText(line.text)
570
+ const prefix = `${normalizeText(line.speaker_raw)}:`
571
+ return line.line_type === 'dialogue' && prefix !== ':' && text.startsWith(prefix)
572
+ ? text.slice(prefix.length).trimStart() : text
573
+ }
574
+
575
+ function matchesText(text, query, mode, regex) {
576
+ if (!query) return true
577
+ if (mode === 'regex') return regex.test(normalizeText(text))
578
+ const haystack = normalizeText(text).toLowerCase()
579
+ const needle = query.toLowerCase()
580
+ return haystack.includes(needle)
581
+ }
582
+
583
+ function aliasNearQuery(text, alias, queryRange) {
584
+ const haystack = normalizeText(text)
585
+ if (!queryRange) return haystack.includes(alias)
586
+ let offset = haystack.indexOf(alias)
587
+ while (offset >= 0) {
588
+ const end = offset + alias.length
589
+ const distance = end < queryRange.start ? queryRange.start - end
590
+ : offset > queryRange.end ? offset - queryRange.end : 0
591
+ if (distance <= ENTITY_QUERY_MAX_DISTANCE) return true
592
+ offset = haystack.indexOf(alias, offset + 1)
593
+ }
594
+ return false
595
+ }
596
+
597
+ function textAliases(entity) {
598
+ const aliases = [...new Set(entity.aliases.map(normalizeText).filter(Boolean))]
599
+ const longer = aliases.filter((alias) => [...alias].length > 1)
600
+ return longer.length ? longer : aliases
601
+ }
602
+
603
+ function entityOccurrence(record, line, entityGroups, queryPresent, queryRange = null) {
604
+ if (!entityGroups.length) return null
605
+ for (const stored of line.entity_occurrences ?? []) {
606
+ const entity = entityGroups.find((item) => item.canonical === stored.canonical_name
607
+ || item.aliases.includes(stored.raw_name))
608
+ if (!entity) continue
609
+ const rawName = String(stored.raw_name || '')
610
+ if (queryPresent && stored.evidence_kind !== 'speaker'
611
+ && !aliasNearQuery(line.text, rawName, queryRange)) continue
612
+ return {
613
+ entity_id: String(stored.entity_id || ''), canonical_entity: entity.canonical,
614
+ matched_alias: rawName, presence_status: String(stored.presence_status || ''),
615
+ evidence_kind: String(stored.evidence_kind || ''), occurrence_id: String(stored.occurrence_id || ''),
616
+ confidence: Number(stored.confidence || 0),
617
+ ...(stored.ambiguity_candidates?.length
618
+ ? { ambiguity_candidates: stored.ambiguity_candidates.map((item) => String(item)) } : {}),
619
+ }
620
+ }
621
+ for (const entity of entityGroups) {
622
+ const speakerAlias = entity.aliases.find((alias) => line.speaker_raw === alias)
623
+ if (speakerAlias) return { canonical_entity: entity.canonical, matched_alias: speakerAlias,
624
+ presence_status: 'explicit', evidence_kind: 'speaker' }
625
+ const textAlias = textAliases(entity).find((alias) =>
626
+ aliasNearQuery(line.text, alias, queryPresent ? queryRange : null))
627
+ if (textAlias) return { canonical_entity: entity.canonical, matched_alias: textAlias,
628
+ presence_status: 'mentioned', evidence_kind: 'text_mention' }
629
+ if (!queryPresent && entity.aliases.includes(String(record.document.character_name || ''))
630
+ && line.line_number === 1) {
631
+ return { canonical_entity: entity.canonical,
632
+ matched_alias: String(record.document.character_name || entity.canonical),
633
+ presence_status: 'explicit', evidence_kind: 'metadata_link' }
634
+ }
635
+ }
636
+ return null
637
+ }
638
+
639
+ function lineMatch(record, index, request, regex, entityGroups, bounds = null) {
640
+ const line = record.lines[index]
641
+ if (request.filters.speakers.length && !request.filters.speakers.includes(line.speaker_raw)) return null
642
+ const content = lineContent(line)
643
+ if (!matchesText(content, request.query, request.match_mode, regex)) return null
644
+ let start = null
645
+ let end = null
646
+ if (request.query) {
647
+ if (regex) {
648
+ const match = new RegExp(regex.source, regex.flags).exec(normalizeText(content))
649
+ if (match) { start = match.index; end = match.index + match[0].length }
650
+ } else {
651
+ const haystack = normalizeText(content).toLowerCase()
652
+ const needle = request.query.toLowerCase()
653
+ start = haystack.indexOf(needle)
654
+ // 区间终点必须用归一化后 needle 的长度:toLocaleLowerCase 可能改变长度
655
+ // (如土耳其语 İ → i̇),用原 query 长度会让实体邻近度判定错位。
656
+ end = start + needle.length
657
+ }
658
+ }
659
+ const occurrence = entityOccurrence(record, line, entityGroups, Boolean(request.query),
660
+ start === null ? null : { start, end })
661
+ if (entityGroups.length && !occurrence) return null
662
+ if (!request.context_terms.length) return { occurrence, start, end }
663
+ const nearby = record.lines.slice(Math.max((bounds?.start_line || 1) - 1, index - 3),
664
+ Math.min(bounds?.end_line || record.lines.length, index + 4))
665
+ const constraintLines = []
666
+ for (const term of request.context_terms) {
667
+ const found = nearby.find((item) => normalizeText(item.text).toLowerCase()
668
+ .includes(term.toLowerCase()))
669
+ if (!found) return null
670
+ constraintLines.push(found.line_number)
671
+ }
672
+ return { occurrence, start, end, constraint_lines: [...new Set(constraintLines)] }
673
+ }
674
+
675
+ function relevanceScore(match, request, field) {
676
+ if (field === 'title') return match.exact ? 1 : 0.96
677
+ let score = !request.query ? 0.5 : request.match_mode === 'regex' ? 0.7 : 1
678
+ if (Number(match.start) > 0) score -= Math.min(0.2, Number(match.start) / 200)
679
+ if (match.occurrence?.evidence_kind === 'speaker') score += 0.3
680
+ else if (match.occurrence?.presence_status === 'explicit') score += 0.2
681
+ else if (match.occurrence?.presence_status === 'mentioned') score += 0.05
682
+ return Math.max(0, Math.min(1, Math.round(score * 1000) / 1000))
683
+ }
684
+
685
+ function queryableEntities(record, maximum = 24) {
686
+ const entities = new Map()
687
+ const remember = (value) => {
688
+ const name = String(value.canonical_name || value.canonical_entity || '').trim()
689
+ const entityId = String(value.entity_id || '').trim()
690
+ if (!name && !entityId) return
691
+ const key = entityId || `name:${name}`
692
+ const existing = entities.get(key) || { entity_id: entityId, name,
693
+ entity_type: String(value.entity_type || ''), presence_statuses: [] }
694
+ const status = String(value.presence_status || '')
695
+ if (status && !existing.presence_statuses.includes(status)) existing.presence_statuses.push(status)
696
+ entities.set(key, existing)
697
+ }
698
+ for (const line of record.lines ?? []) for (const occurrence of line.entity_occurrences ?? []) remember(occurrence)
699
+ if (record.document.character_name) remember({ canonical_name: record.document.character_name,
700
+ entity_id: record.document.char_id || '', entity_type: 'character', presence_status: 'explicit' })
701
+ const all = [...entities.values()]
702
+ return { total: all.length, shown: all.slice(0, maximum), truncated: all.length > maximum }
703
+ }
704
+
705
+ function clusterPassages(candidates, forcedTruncated = false) {
706
+ const clusters = []
707
+ for (const candidate of candidates) {
708
+ const constraintLines = candidate.match?.constraint_lines || []
709
+ const candidateStart = Math.min(candidate.line.line_number, ...constraintLines)
710
+ const candidateEnd = Math.max(candidate.line.line_number, ...constraintLines)
711
+ const previous = clusters.at(-1)
712
+ if (previous && candidateStart - previous.end <= PASSAGE_CLUSTER_GAP
713
+ && previous.best.activity_range === candidate.activity_range
714
+ && previous.best.wiki_section === candidate.wiki_section) {
715
+ previous.end = Math.max(previous.end, candidateEnd)
716
+ previous.candidates.push(candidate)
717
+ if (candidate.score > previous.best.score) previous.best = candidate
718
+ } else clusters.push({ start: candidateStart, end: candidateEnd, candidates: [candidate], best: candidate })
719
+ }
720
+ const ranked = clusters.sort((left, right) => right.best.score - left.best.score
721
+ || left.start - right.start)
722
+ return ranked.slice(0, MAX_PASSAGES_PER_DOCUMENT).map((cluster) => ({ ...cluster.best,
723
+ passage_start: cluster.start, passage_end: cluster.end,
724
+ passage_match_count: cluster.candidates.length,
725
+ match_lines: [...new Set(cluster.candidates.map((item) => item.line.line_number))],
726
+ constraint_lines: [...new Set(cluster.candidates.flatMap((item) => item.match?.constraint_lines || []))],
727
+ document_passages_truncated: forcedTruncated || ranked.length > MAX_PASSAGES_PER_DOCUMENT }))
728
+ }
729
+
730
+ function searchableTitleText(document) {
731
+ return [document.display_title, document.story_name, document.activity_name,
732
+ document.character_name, document.story_code, document.part_label,
733
+ document.collection_name, document.mission_title, document.story_key]
734
+ .map((item) => normalizeText(item)).filter(Boolean).join('\n')
735
+ }
736
+
737
+ function isExactOfficialArchiveTitle(document, request) {
738
+ if (!request.query || publicResourceType(document) !== 'archive') return false
739
+ const query = normalizeText(request.query).toLowerCase()
740
+ return [document.display_title, document.story_name]
741
+ .some((value) => normalizeText(value).toLowerCase() === query)
742
+ }
743
+
744
+ function lineAllowed(line, filters) {
745
+ return !filters.speakers.length || filters.speakers.includes(line.speaker_raw)
746
+ }
747
+
748
+ function readableAnchor(record, filters, ranges = null) {
749
+ const eligible = (line) => normalizeText(line.text) && lineAllowed(line, filters)
750
+ && (!ranges || wikiSectionAt(ranges, line.line_number))
751
+ return record.lines.find((line) => line.line_type === 'dialogue' && eligible(line))
752
+ || record.lines.find(eligible) || null
753
+ }
754
+
755
+ function publicResourceType(document) {
756
+ if (document.resource_type) return String(document.resource_type)
757
+ const type = String(document.document_type || '')
758
+ const kind = String(document.document_kind || '')
759
+ const category = String(document.document_category || '')
760
+ if (type === 'story') return category === 'memory' ? 'operator_record' : 'story'
761
+ if (type === 'character') {
762
+ if (category === '模组文案') return 'character_module'
763
+ if (category === '干员语音') return 'character_voice'
764
+ if (category === '时装文案') return 'character_skin'
765
+ return 'character_profile'
766
+ }
767
+ if (type === 'knowledge' && kind === 'wiki') {
768
+ const role = wikiDocumentRole(document)
769
+ return role === 'story' ? 'story_wiki'
770
+ : role === 'character_activity' ? 'character_activity_wiki' : 'character_wiki'
771
+ }
772
+ if (type === 'knowledge' && kind === 'terra_journey') return 'terra_journey'
773
+ if (type === 'entity') return 'entity_profile'
774
+ return 'reference'
775
+ }
776
+
777
+ function publicContentType(document) {
778
+ if (document.content_type) return normalizeText(document.content_type)
779
+ const kind = normalizeText(document.kind || document.document_kind).toLocaleLowerCase()
780
+ const mapping = {
781
+ dlg: 'dialogue', story: 'dialogue', cutscene: 'cutscene', radio: 'radio',
782
+ remotecomm: 'remote_comm', black: 'black_screen', env: 'environment_talk',
783
+ sns: 'sns_chat', topic: 'sns_topic', wiki: 'knowledge', reference: 'knowledge',
784
+ }
785
+ return mapping[kind] || (document.document_type === 'story' ? 'dialogue' : 'knowledge')
786
+ }
787
+
788
+ function publicCollectionName(document) {
789
+ return normalizeText(document.collection_name || document.mission_title
790
+ || document.activity_name || document.story_name)
791
+ }
792
+
793
+ function boundedSummaryText(value, maximum) {
794
+ const text = normalizeText(value)
795
+ if (!text || maximum <= 0) return { text: '', truncated: Boolean(text) }
796
+ const characters = [...text]
797
+ return characters.length <= maximum
798
+ ? { text, truncated: false }
799
+ : { text: `${characters.slice(0, Math.max(0, maximum - 1)).join('')}…`, truncated: true }
800
+ }
801
+
802
+ function publicEntitySummary(record, maximum = 2400) {
803
+ const entity = record.entity || {}
804
+ const attributes = entity.attributes || {}
805
+ const canonicalName = normalizeText(entity.canonical_name || record.document.display_title)
806
+ const description = boundedSummaryText(attributes.description || entity.description, Math.min(800, maximum))
807
+ const remaining = Math.max(0, maximum - [...description.text].length)
808
+ const history = boundedSummaryText(attributes.history_summary || entity.history_summary,
809
+ Math.min(1600, remaining))
810
+ return {
811
+ canonical_name: canonicalName,
812
+ ...(description.text ? { description: description.text } : {}),
813
+ ...(history.text && history.text !== description.text ? { history_summary: history.text } : {}),
814
+ truncated: description.truncated || history.truncated,
815
+ citation: `《${naturalDocumentTitle(record.document)}》`,
816
+ }
817
+ }
818
+
819
+ function evidenceKind(document, field, occurrence) {
820
+ const resource = publicResourceType(document)
821
+ if (field === 'catalog' || field === 'title') return 'catalog'
822
+ if (occurrence?.evidence_kind === 'metadata_link') return 'entity_projection'
823
+ if (resource.endsWith('_wiki')) return 'wiki_curated'
824
+ if (resource === 'story' || resource === 'original_story' || resource === 'operator_record') {
825
+ return 'official_canonical'
826
+ }
827
+ if (resource === 'archive' || resource.startsWith('character_')) return 'official_structured'
828
+ return 'wiki_curated'
829
+ }
830
+
831
+ function matchKind(item, request) {
832
+ if (item.field === 'title') return 'title'
833
+ if (item.field === 'catalog') return 'catalog'
834
+ if (item.wiki_section) return 'section'
835
+ if (item.match?.occurrence?.evidence_kind === 'speaker') return 'entity_speaker'
836
+ if (item.match?.occurrence?.evidence_kind === 'text_mention') return 'entity_mention'
837
+ if (item.match?.occurrence) return 'entity_projection'
838
+ if (!request.query && request.filters.speakers.length) return 'speaker'
839
+ return request.match_mode === 'regex' ? 'regex' : 'literal'
840
+ }
841
+
842
+ function excerpt(record, item, bounds = null) {
843
+ const start = Math.max(bounds?.start_line || 1, item.passage_start - PREVIEW_OPTIONS.before_lines)
844
+ const end = Math.min(bounds?.end_line || record.lines.length,
845
+ item.passage_end + PREVIEW_OPTIONS.after_lines)
846
+ let truncated = false
847
+ const lines = record.lines.slice(start - 1, end).map((line) => {
848
+ const raw = String(lineContent(line) || '')
849
+ const text = [...raw].length > PREVIEW_OPTIONS.max_chars_per_line
850
+ ? `${[...raw].slice(0, PREVIEW_OPTIONS.max_chars_per_line).join('')}…` : raw
851
+ if (text !== raw) truncated = true
852
+ return { line: line.line_number,
853
+ role: item.match_lines?.includes(line.line_number) ? 'match'
854
+ : item.constraint_lines?.includes(line.line_number) ? 'constraint' : 'context',
855
+ line_type: line.line_type ?? '', speaker: line.speaker_raw ?? '', text,
856
+ truncated: text !== raw }
857
+ })
858
+ return { lines, characters: lines.reduce((total, line) => total + line.text.length, 0), truncated }
859
+ }
860
+
861
+ async function executeLegacySearch(store, request, offset,
862
+ { signal, requestId = null, deadline = Date.now() + SEARCH_TIMEOUT_MS } = {}) {
863
+ const started = Date.now()
864
+ const resolvedRequestId = String(requestId || `req-${createHash('sha256')
865
+ .update(`${started}:${Math.random()}`).digest('hex').slice(0, 16)}`)
866
+ try {
867
+ assertSearchActive(signal, deadline)
868
+ await store.ready()
869
+ assertSearchActive(signal, deadline)
870
+ const entityAliasGroups = await aliasesFor(store, request.filters.entity_names)
871
+ assertSearchActive(signal, deadline)
872
+ // regex 模式的 query 是模式文本而非字面量,跳过 trigram 预过滤(与浏览器一致)。
873
+ // 资料包缺失 search-index 时同样跳过倒排:此时倒排空结果不代表正文零命中,
874
+ // 不能据此把搜索降级成"仅标题兜底"。
875
+ const regex = request.match_mode === 'regex'
876
+ ? new RegExp(safeRegex(request.query).source, 'iu') : null
877
+ const queryNgrams = ngramsFor(request.query)
878
+ const rankPoolCap = request.query && !regex && queryNgrams.length
879
+ && [...queryNgrams[0]].length < 3
880
+ ? SHORT_LITERAL_RANK_POOL_CAP : RANK_POOL_CAP
881
+ const legacyScope = documentScopeForFilters(store, request.filters)
882
+ const indexedLookup = store.findDocumentsByNgrams || store.findDocumentsByTrigrams
883
+ let indexed = request.query && !regex && queryNgrams.length && indexedLookup
884
+ ? await indexedLookup.call(store, queryNgrams,
885
+ { signal, deadline, packIds: legacyScope.packIds }) : null
886
+ if (indexed === null && request.query && !regex && queryNgrams.length
887
+ && [...queryNgrams[0]].length < 3) {
888
+ indexed = await store.findDocumentsByShortLiteral?.(request.query,
889
+ { signal, deadline, packIds: legacyScope.packIds }) ?? null
890
+ }
891
+ assertSearchActive(signal, deadline, '本地语料搜索初始化')
892
+ const hasLineScope = request.filters.speakers.length || request.filters.entity_names.length
893
+ || request.filters.wiki_sections.length || request.context_terms.length
894
+ const titleIds = []
895
+ if (request.query && !hasLineScope) {
896
+ let checked = 0
897
+ for (const [id, item] of store.documents) {
898
+ if ((checked++ & 255) === 0) assertSearchActive(signal, deadline, '候选标题发现')
899
+ if (documentMatches(item.document, [], request.filters)
900
+ && matchesText(searchableTitleText(item.document), request.query,
901
+ request.match_mode, regex)) titleIds.push(id)
902
+ }
903
+ }
904
+ // 正文倒排与标题候选取并集;索引不可用时回退全量扫描。
905
+ const documentIds = indexed !== null ? [...new Set([...indexed, ...titleIds])] : null
906
+ const pool = []
907
+ for await (const record of store.iterateDocuments({
908
+ documentIds,
909
+ predicate: (document, speakers) => documentMatches(document, speakers, request.filters),
910
+ })) {
911
+ assertSearchActive(signal, deadline)
912
+ if (store.isPreferredNaturalDocument?.(record.document.document_id) === false) continue
913
+ const group = collectDocumentGroup(record, request, regex, entityAliasGroups, deadline, signal)
914
+ if (group) pool.push(...group.items)
915
+ if (pool.length >= rankPoolCap) break
916
+ }
917
+ pool.sort((left, right) => right.score - left.score
918
+ || String(left.record.document.collection_id || '').localeCompare(
919
+ String(right.record.document.collection_id || ''), 'zh-CN', { numeric: true }))
920
+ const resultKind = request.query ? 'text_matches'
921
+ : request.filters.speakers.length || request.filters.entity_names.length
922
+ ? 'structured_matches' : request.filters.wiki_sections.length
923
+ ? 'complete_sections' : 'documents'
924
+ const grouped = new Map()
925
+ for (const item of pool) {
926
+ const key = item.record.document.document_id
927
+ const group = grouped.get(key) || { record: item.record, score: item.score, items: [] }
928
+ group.score = Math.max(group.score, item.score)
929
+ group.items.push(item)
930
+ grouped.set(key, group)
931
+ }
932
+ const allDocuments = [...grouped.values()].sort((left, right) => right.score - left.score
933
+ || String(left.record.document.collection_id || '').localeCompare(
934
+ String(right.record.document.collection_id || ''), 'zh-CN', { numeric: true }))
935
+ const pageGroups = allDocuments.slice(offset, offset + PAGE_DOCUMENTS)
936
+ const documents = []
937
+ const reasons = new Set()
938
+ let returnedChars = 0
939
+ for (const group of pageGroups) {
940
+ assertSearchActive(signal, deadline)
941
+ if (documents.length && returnedChars >= PREVIEW_OPTIONS.max_total_chars) {
942
+ reasons.add('output_chars')
943
+ break
944
+ }
945
+ const metadata = group.record.document
946
+ const title = naturalDocumentTitle(metadata)
947
+ const result = {
948
+ game: documentGame(metadata), title, resource_type: publicResourceType(metadata),
949
+ content_type: publicContentType(metadata),
950
+ ...(store.requiresDocumentUid?.(metadata.document_id)
951
+ ? { document_uid: documentUid(metadata.document_id) } : {}),
952
+ ...(publicCollectionName(metadata) ? { collection_name: publicCollectionName(metadata) } : {}),
953
+ ...(metadata.activity_name ? { activity_name: metadata.activity_name } : {}),
954
+ ...(metadata.character_name ? { character_name: metadata.character_name } : {}),
955
+ matches: [], matches_truncated: group.items.some((item) => item.document_passages_truncated),
956
+ }
957
+ if (result.matches_truncated) reasons.add('document_passages')
958
+ if (resultKind !== 'documents' && result.resource_type === 'entity_profile') {
959
+ const remaining = Math.max(0, PREVIEW_OPTIONS.max_total_chars - returnedChars)
960
+ result.entity_summary = publicEntitySummary(group.record, Math.min(2400, remaining))
961
+ result.matches_truncated = false
962
+ returnedChars += [...(result.entity_summary.description || '')].length
963
+ + [...(result.entity_summary.history_summary || '')].length
964
+ if (result.entity_summary.truncated) reasons.add('entity_summary')
965
+ } else if (resultKind === 'complete_sections') {
966
+ const section = group.items[0]?.wiki_section
967
+ const lines = sectionPreviewLines(group)
968
+ const blocks = []
969
+ for (const line of lines) {
970
+ const text = String(line.text || '')
971
+ // 与 v4 路径一致按码点计数,避免含增补平面字符的行在两条路径下预算不一致
972
+ const width = [...text].length
973
+ if (returnedChars + width > PREVIEW_OPTIONS.max_total_chars) {
974
+ reasons.add('section_content')
975
+ break
976
+ }
977
+ blocks.push({ type: 'text', text })
978
+ returnedChars += width
979
+ }
980
+ result.section_content = { section: section?.name || request.filters.wiki_sections[0],
981
+ completeness: blocks.length === lines.length ? 'complete' : 'partial', blocks,
982
+ citation: `《${title}》Wiki·${section?.name || request.filters.wiki_sections[0]}` }
983
+ } else if (resultKind !== 'documents') {
984
+ for (const item of group.items.slice(0, MAX_PASSAGES_PER_DOCUMENT)) {
985
+ const titleOnly = item.field === 'title'
986
+ const shown = titleOnly ? { lines: [], characters: 0, truncated: false }
987
+ : excerpt(group.record, item, item.wiki_section || item.activity_range)
988
+ if (result.matches.length && returnedChars + shown.characters > PREVIEW_OPTIONS.max_total_chars) {
989
+ result.matches_truncated = true
990
+ reasons.add('output_chars')
991
+ reasons.add('document_passages')
992
+ break
993
+ }
994
+ if (shown.truncated) reasons.add('line_chars')
995
+ const lineStart = item.passage_start
996
+ const lineEnd = item.passage_end
997
+ const sectionName = item.wiki_section?.name
998
+ result.matches.push({
999
+ ...(sectionName || titleOnly ? {} : { line_start: lineStart, line_end: lineEnd }),
1000
+ match_kind: matchKind(item, request),
1001
+ evidence_kind: evidenceKind(metadata, item.field, item.match?.occurrence),
1002
+ excerpt: shown.lines,
1003
+ citation: titleOnly ? `《${title}》`
1004
+ : sectionName ? `《${title}》Wiki·${sectionName}`
1005
+ : `《${title}》第 ${lineStart === lineEnd ? lineStart : `${lineStart}-${lineEnd}`} 行`,
1006
+ })
1007
+ returnedChars += shown.characters
1008
+ }
1009
+ }
1010
+ documents.push(result)
1011
+ }
1012
+ const hasMore = offset + documents.length < allDocuments.length
1013
+ if (hasMore) reasons.add('more_documents')
1014
+ if (pool.length >= rankPoolCap) reasons.add('document_passages')
1015
+ assertSearchActive(signal, deadline)
1016
+ const nextCursor = hasMore ? await encodeOffsetCursor(store, request, offset + documents.length) : null
1017
+ assertSearchActive(signal, deadline, '分页游标生成')
1018
+ return {
1019
+ result_kind: resultKind, documents,
1020
+ page: { returned_documents: documents.length,
1021
+ total_relation: 'unknown', has_more: hasMore, exhausted: false,
1022
+ next_cursor: nextCursor },
1023
+ truncated: true,
1024
+ truncation_reasons: [...new Set([...reasons, 'legacy_pool_incomplete'])],
1025
+ }
1026
+ } catch (error) {
1027
+ return { contract_version: SEARCH_CONTRACT_VERSION, status: 'error', request_id: resolvedRequestId,
1028
+ data_version: store.dataVersion ?? null,
1029
+ error: publicSearchError(error) }
1030
+ }
1031
+ }
1032
+
1033
+ function resultKindFor(request) {
1034
+ if (request.query) return 'text_matches'
1035
+ if (request.filters.speakers.length || request.filters.entity_names.length) {
1036
+ return 'structured_matches'
1037
+ }
1038
+ return request.filters.wiki_sections.length ? 'complete_sections' : 'documents'
1039
+ }
1040
+
1041
+ function lineScopeFor(request) {
1042
+ return Boolean(request.filters.speakers.length || request.filters.entity_names.length
1043
+ || request.filters.wiki_sections.length || request.context_terms.length)
1044
+ }
1045
+
1046
+ async function candidateDocumentIds(store, request, regex, { signal, deadline } = {}) {
1047
+ assertSearchActive(signal, deadline, '候选文档发现')
1048
+ const hasLineScope = lineScopeFor(request)
1049
+ // 结构化过滤只依赖初始化时保留的轻量 metadata,可以先安全缩小范围;
1050
+ // hydratedRecordMatches 仍在读取正文后复核 character_activity Wiki 等特殊项。
1051
+ const scopedIds = []
1052
+ const orderedIds = store.orderedDocumentIds()
1053
+ for (let index = 0; index < orderedIds.length; index += 1) {
1054
+ if ((index & 255) === 0) assertSearchActive(signal, deadline, '候选文档发现')
1055
+ const documentId = orderedIds[index]
1056
+ const item = store.documents.get(documentId)
1057
+ if (item && documentMatches(item.document, item.speakers, request.filters)
1058
+ && store.isPreferredNaturalDocument?.(documentId) !== false) scopedIds.push(documentId)
1059
+ }
1060
+ const scoped = new Set(scopedIds)
1061
+ const scopedPackIds = packIdsForDocumentIds(store, scopedIds)
1062
+ const titleIds = []
1063
+ if (request.query && !hasLineScope) {
1064
+ for (let index = 0; index < scopedIds.length; index += 1) {
1065
+ if ((index & 255) === 0) assertSearchActive(signal, deadline, '候选标题发现')
1066
+ const id = scopedIds[index]
1067
+ if (matchesText(searchableTitleText(store.documents.get(id).document),
1068
+ request.query, request.match_mode, regex)) titleIds.push(id)
1069
+ }
1070
+ }
1071
+ const prioritizeOfficialTitle = (ids) => {
1072
+ const exact = []
1073
+ for (let index = 0; index < ids.length; index += 1) {
1074
+ if ((index & 255) === 0) assertSearchActive(signal, deadline, '候选文档排序')
1075
+ const id = ids[index]
1076
+ if (isExactOfficialArchiveTitle(store.documents.get(id)?.document || {}, request)) exact.push(id)
1077
+ }
1078
+ if (!exact.length) return interleaveGameCandidates(store, ids, request, { signal, deadline })
1079
+ const selected = new Set(exact)
1080
+ const remaining = []
1081
+ for (let index = 0; index < ids.length; index += 1) {
1082
+ if ((index & 255) === 0) assertSearchActive(signal, deadline, '候选文档排序')
1083
+ if (!selected.has(ids[index])) remaining.push(ids[index])
1084
+ }
1085
+ return [...exact, ...interleaveGameCandidates(store, remaining, request, { signal, deadline })]
1086
+ }
1087
+ if (!request.query || regex) return prioritizeOfficialTitle(scopedIds)
1088
+ const queryNgrams = ngramsFor(request.query)
1089
+ // v2 包的 unigram/bigram 直接服务 1—2 字查询;旧包仍复用无损分片预筛。
1090
+ const indexedLookup = store.findDocumentsByNgrams || store.findDocumentsByTrigrams
1091
+ let indexed = queryNgrams.length && indexedLookup
1092
+ ? await indexedLookup.call(store, queryNgrams,
1093
+ { signal, deadline, packIds: scopedPackIds }) : null
1094
+ if (indexed === null && queryNgrams.length && [...queryNgrams[0]].length < 3) {
1095
+ indexed = await store.findDocumentsByShortLiteral?.(request.query,
1096
+ { signal, deadline, packIds: scopedPackIds }) ?? null
1097
+ }
1098
+ assertSearchActive(signal, deadline, '候选文档发现')
1099
+ if (indexed === null) return prioritizeOfficialTitle(scopedIds)
1100
+ return prioritizeOfficialTitle(
1101
+ store.orderedDocumentIds([...new Set([...indexed.filter((id) => scoped.has(id)), ...titleIds])]))
1102
+ }
1103
+
1104
+ function interleaveGameCandidates(store, documentIds, request, { signal, deadline } = {}) {
1105
+ const requested = request.filters.games?.length
1106
+ ? request.filters.games : ['arknights', 'endfield']
1107
+ if (requested.length < 2) return documentIds
1108
+ const queues = new Map(requested.map((game) => [game, []]))
1109
+ const other = []
1110
+ for (let index = 0; index < documentIds.length; index += 1) {
1111
+ if ((index & 255) === 0) assertSearchActive(signal, deadline, '候选文档排序')
1112
+ const documentId = documentIds[index]
1113
+ const game = documentGame(store.documents.get(documentId)?.document || {})
1114
+ const queue = queues.get(game)
1115
+ if (queue) queue.push(documentId)
1116
+ else other.push(documentId)
1117
+ }
1118
+ const nonempty = [...queues.values()].filter((queue) => queue.length)
1119
+ if (nonempty.length < 2) return documentIds
1120
+ const result = []
1121
+ const maximum = Math.max(...nonempty.map((queue) => queue.length))
1122
+ for (let index = 0; index < maximum; index += 1) {
1123
+ if ((index & 255) === 0) assertSearchActive(signal, deadline, '候选文档排序')
1124
+ for (const game of requested) {
1125
+ const documentId = queues.get(game)?.[index]
1126
+ if (documentId) result.push(documentId)
1127
+ }
1128
+ }
1129
+ return result.concat(other)
1130
+ }
1131
+
1132
+ function collectDocumentGroup(record, request, regex, entityAliasGroups, deadline, signal = null) {
1133
+ if (!hydratedRecordMatches(record, request.filters)) return null
1134
+ const metadata = record.document
1135
+ const activityRanges = requestedWikiActivityRanges(record, request.filters)
1136
+ if (activityRanges && !activityRanges.length) return null
1137
+ let sectionRanges = request.filters.wiki_sections.length
1138
+ ? wikiSectionRanges(record, request.filters.wiki_sections) : []
1139
+ if (activityRanges) {
1140
+ // 容器字段可能跨越全部活动;取交集后仅返回本次选中的活动正文。
1141
+ sectionRanges = sectionRanges.flatMap((section) => activityRanges.map((activity) => ({
1142
+ ...section, start_line: Math.max(section.start_line, activity.start_line),
1143
+ end_line: Math.min(section.end_line, activity.end_line),
1144
+ })).filter((section) => section.start_line <= section.end_line))
1145
+ }
1146
+ if (request.filters.wiki_sections.length && !sectionRanges.length) return null
1147
+ const hasLineScope = lineScopeFor(request) || Boolean(activityRanges)
1148
+ const catalogMode = !request.query && !request.filters.speakers.length && !entityAliasGroups.length
1149
+ const items = []
1150
+ const titleText = searchableTitleText(metadata)
1151
+ if (request.query && !hasLineScope
1152
+ && matchesText(titleText, request.query, request.match_mode, regex)) {
1153
+ const firstSection = sectionRanges[0] || null
1154
+ const anchor = firstSection
1155
+ ? record.lines.slice(firstSection.start_line - 1, firstSection.end_line)
1156
+ .find((line) => normalizeText(line.text))
1157
+ : readableAnchor(record, request.filters)
1158
+ if (anchor) {
1159
+ const exact = normalizeText(metadata.story_name) === request.query
1160
+ || normalizeText(metadata.display_title) === request.query
1161
+ items.push({ record, line: anchor, score: relevanceScore({ exact }, request, 'title'),
1162
+ field: 'title', passage_start: anchor.line_number, passage_end: anchor.line_number,
1163
+ passage_match_count: 1, match: { occurrence: null, start: null, end: null },
1164
+ ...(firstSection ? { wiki_section: firstSection } : {}) })
1165
+ }
1166
+ }
1167
+ if (catalogMode) {
1168
+ if (sectionRanges.length) {
1169
+ for (const section of sectionRanges) {
1170
+ const anchor = record.lines.slice(section.start_line - 1, section.end_line)
1171
+ .find((line) => normalizeText(line.text))
1172
+ if (anchor) items.push({ record, line: anchor, score: 1, field: 'wiki_section',
1173
+ passage_start: section.start_line, passage_end: section.end_line,
1174
+ passage_match_count: 1, match: { occurrence: null, start: null, end: null },
1175
+ wiki_section: section })
1176
+ }
1177
+ } else {
1178
+ const anchor = readableAnchor(record, request.filters, activityRanges)
1179
+ if (anchor) items.push({ record, line: anchor, score: 1, field: 'catalog',
1180
+ passage_start: anchor.line_number, passage_end: anchor.line_number,
1181
+ passage_match_count: 1, match: { occurrence: null, start: null, end: null } })
1182
+ }
1183
+ } else {
1184
+ const documentMatches = []
1185
+ let documentMatchesTruncated = false
1186
+ for (let index = 0; index < record.lines.length; index += 1) {
1187
+ if ((index & 255) === 0) {
1188
+ // 与 legacy 路径对齐:逐行扫描既响应外部取消,也受时间预算约束。
1189
+ if (signal?.aborted) throw Object.assign(new Error('搜索已取消'), { code: 'CANCELLED' })
1190
+ if (Date.now() > deadline) {
1191
+ throw Object.assign(new Error('本地语料搜索超时'), { code: 'TIMEOUT', retryable: true })
1192
+ }
1193
+ }
1194
+ const activityRange = activityRanges
1195
+ ? wikiSectionAt(activityRanges, record.lines[index].line_number) : null
1196
+ if (activityRanges && !activityRange) continue
1197
+ const wikiSection = sectionRanges.length
1198
+ ? wikiSectionAt(sectionRanges, record.lines[index].line_number) : null
1199
+ if (sectionRanges.length && !wikiSection) continue
1200
+ const match = lineMatch(record, index, request, regex, entityAliasGroups, activityRange)
1201
+ if (!match) continue
1202
+ documentMatches.push({ record, line: record.lines[index],
1203
+ score: relevanceScore(match, request, 'content'), field: request.query ? 'content'
1204
+ : match.occurrence ? 'entity' : 'speaker_raw', match,
1205
+ ...(wikiSection ? { wiki_section: wikiSection } : {}),
1206
+ ...(activityRange ? { activity_range: activityRange } : {}) })
1207
+ if (!hasLineScope && !regex
1208
+ && documentMatches.length >= SIMPLE_LITERAL_MATCH_CAP_PER_DOCUMENT) {
1209
+ documentMatchesTruncated = true
1210
+ break
1211
+ }
1212
+ }
1213
+ items.push(...clusterPassages(documentMatches, documentMatchesTruncated))
1214
+ }
1215
+ if (!items.length) return null
1216
+ items.sort((left, right) => (left.field === 'title') - (right.field === 'title')
1217
+ || right.score - left.score || left.passage_start - right.passage_start)
1218
+ return { record, score: Math.max(...items.map((item) => item.score)), items }
1219
+ }
1220
+
1221
+ function previewLinesCost(lines) {
1222
+ return lines.reduce((total, line) => total + [...String(line.text || '')].length, 0)
1223
+ }
1224
+
1225
+ /**
1226
+ * measureDocument 与 buildPublicDocument 共用同一份命中预览:此前两条路径
1227
+ * 各自构建 excerpt(切片 + NFKC 归一化 + 逐行截断),对同一候选文档做双倍
1228
+ * 字符串处理;首次计算后缓存在 item/group 上,构建阶段直接复用。
1229
+ */
1230
+ function previewExcerpt(group, item) {
1231
+ if (!item.preview) item.preview = excerpt(group.record, item, item.wiki_section || item.activity_range)
1232
+ return item.preview
1233
+ }
1234
+
1235
+ /** complete_sections 的字段行列表;measure 与 build 共用,避免重复 slice+filter。 */
1236
+ function sectionPreviewLines(group) {
1237
+ if (!group.sectionLines) {
1238
+ const sections = group.items.map((item) => item.wiki_section).filter(Boolean)
1239
+ group.sectionLines = group.record.lines.filter((line) => normalizeText(line.text)
1240
+ && sections.some((section) => section.start_line <= line.line_number
1241
+ && line.line_number <= section.end_line))
1242
+ }
1243
+ return group.sectionLines
1244
+ }
1245
+
1246
+ function fitLinesToBudget(lines, budget) {
1247
+ const fitted = lines.map((line) => ({ ...line }))
1248
+ while (previewLinesCost(fitted) > budget && fitted.some((line) => line.role === 'context')) {
1249
+ const lastContext = fitted.findLastIndex((line) => line.role === 'context')
1250
+ fitted.splice(lastContext, 1)
1251
+ }
1252
+ if (previewLinesCost(fitted) <= budget) return fitted
1253
+ const target = fitted.find((line) => line.role === 'match') || fitted[0]
1254
+ if (!target) return []
1255
+ const otherCost = previewLinesCost(fitted) - [...String(target.text || '')].length
1256
+ const allowed = Math.max(80, budget - otherCost)
1257
+ if ([...String(target.text || '')].length > allowed) {
1258
+ target.text = `${[...String(target.text || '')].slice(0, Math.max(1, allowed - 1)).join('')}…`
1259
+ target.truncated = true
1260
+ }
1261
+ return fitted
1262
+ }
1263
+
1264
+ function measureDocument(group, resultKind, request) {
1265
+ if (resultKind !== 'documents' && publicResourceType(group.record.document) === 'entity_profile') {
1266
+ group.entitySummary ??= publicEntitySummary(group.record, 2400)
1267
+ return [...String(group.entitySummary.description || '')].length
1268
+ + [...String(group.entitySummary.history_summary || '')].length
1269
+ }
1270
+ if (resultKind === 'complete_sections') {
1271
+ return sectionPreviewLines(group)
1272
+ .reduce((total, line) => total + [...String(line.text || '')].length, 0)
1273
+ }
1274
+ if (resultKind === 'documents') return 0
1275
+ return group.items.slice(0, MAX_PASSAGES_PER_DOCUMENT).reduce((total, item) => {
1276
+ if (item.field === 'title') return total
1277
+ // 与 buildPublicDocument 的 previewLinesCost 同口径(码点计数),
1278
+ // 避免 UTF-16 估宽导致预算判定与实际构建不一致。
1279
+ return total + previewLinesCost(previewExcerpt(group, item).lines)
1280
+ }, 0)
1281
+ }
1282
+
1283
+ function buildPublicDocument(store, group, resultKind, request, budget = Infinity) {
1284
+ const metadata = group.record.document
1285
+ const title = naturalDocumentTitle(metadata)
1286
+ const result = {
1287
+ game: documentGame(metadata), title, resource_type: publicResourceType(metadata),
1288
+ content_type: publicContentType(metadata),
1289
+ ...(store.requiresDocumentUid?.(metadata.document_id)
1290
+ ? { document_uid: documentUid(metadata.document_id) } : {}),
1291
+ ...(publicCollectionName(metadata) ? { collection_name: publicCollectionName(metadata) } : {}),
1292
+ ...(metadata.activity_name ? { activity_name: metadata.activity_name } : {}),
1293
+ ...(metadata.character_name ? { character_name: metadata.character_name } : {}),
1294
+ matches: [], matches_truncated: group.items.some((item) => item.document_passages_truncated),
1295
+ }
1296
+ const reasons = new Set()
1297
+ let characters = 0
1298
+ if (result.matches_truncated) reasons.add('document_passages')
1299
+ if (resultKind !== 'documents' && result.resource_type === 'entity_profile') {
1300
+ const cap = Math.min(2400, budget)
1301
+ result.entity_summary = cap === 2400 && group.entitySummary
1302
+ ? group.entitySummary : publicEntitySummary(group.record, cap)
1303
+ result.matches_truncated = false
1304
+ characters = [...String(result.entity_summary.description || '')].length
1305
+ + [...String(result.entity_summary.history_summary || '')].length
1306
+ if (result.entity_summary.truncated) reasons.add('entity_summary')
1307
+ } else if (resultKind === 'complete_sections') {
1308
+ const lines = sectionPreviewLines(group)
1309
+ const blocks = []
1310
+ for (const line of lines) {
1311
+ const text = String(line.text || '')
1312
+ if (characters + [...text].length > budget) {
1313
+ if (!blocks.length && budget > 0) {
1314
+ blocks.push({ type: 'text', text: `${[...text].slice(0, Math.max(1, budget - 1)).join('')}…` })
1315
+ characters = Math.min([...text].length, budget)
1316
+ }
1317
+ reasons.add('section_content')
1318
+ break
1319
+ }
1320
+ blocks.push({ type: 'text', text })
1321
+ characters += [...text].length
1322
+ }
1323
+ const sectionName = group.items[0]?.wiki_section?.name || request.filters.wiki_sections[0]
1324
+ result.section_content = { section: sectionName,
1325
+ completeness: !reasons.has('section_content') && blocks.length === lines.length
1326
+ ? 'complete' : 'partial', blocks,
1327
+ citation: `《${title}》Wiki·${sectionName}` }
1328
+ } else if (resultKind !== 'documents') {
1329
+ for (const item of group.items.slice(0, MAX_PASSAGES_PER_DOCUMENT)) {
1330
+ const titleOnly = item.field === 'title'
1331
+ const shown = titleOnly ? { lines: [], characters: 0, truncated: false }
1332
+ : previewExcerpt(group, item)
1333
+ if (result.matches.length && characters + shown.characters > budget) {
1334
+ result.matches_truncated = true
1335
+ reasons.add('output_chars')
1336
+ reasons.add('document_passages')
1337
+ break
1338
+ }
1339
+ const available = Math.max(0, budget - characters)
1340
+ const shownLines = shown.characters > available
1341
+ ? fitLinesToBudget(shown.lines, available) : shown.lines
1342
+ const shownCharacters = previewLinesCost(shownLines)
1343
+ if (shown.truncated || shownCharacters < shown.characters) reasons.add('line_chars')
1344
+ const lineStart = item.passage_start
1345
+ const lineEnd = item.passage_end
1346
+ const sectionName = item.wiki_section?.name
1347
+ result.matches.push({
1348
+ ...(sectionName || titleOnly ? {} : { line_start: lineStart, line_end: lineEnd }),
1349
+ match_kind: matchKind(item, request),
1350
+ evidence_kind: evidenceKind(metadata, item.field, item.match?.occurrence),
1351
+ excerpt: shownLines,
1352
+ citation: titleOnly ? `《${title}》`
1353
+ : sectionName ? `《${title}》Wiki·${sectionName}`
1354
+ : `《${title}》第 ${lineStart === lineEnd ? lineStart : `${lineStart}-${lineEnd}`} 行`,
1355
+ })
1356
+ characters += shownCharacters
1357
+ }
1358
+ }
1359
+ return { document: result, characters, reasons }
1360
+ }
1361
+
1362
+ async function executeScanSearch(store, request, checkpoint,
1363
+ { signal, requestId = null, deadline = Date.now() + SEARCH_TIMEOUT_MS } = {}) {
1364
+ const started = Date.now()
1365
+ const resolvedRequestId = String(requestId || `req-${createHash('sha256')
1366
+ .update(`${started}:${Math.random()}`).digest('hex').slice(0, 16)}`)
1367
+ try {
1368
+ assertSearchActive(signal, deadline)
1369
+ const regex = request.match_mode === 'regex'
1370
+ ? new RegExp(safeRegex(request.query).source, 'iu') : null
1371
+ const entityAliasGroups = await aliasesFor(store, request.filters.entity_names)
1372
+ assertSearchActive(signal, deadline)
1373
+ const candidateIds = await candidateDocumentIds(store, request, regex, { signal, deadline })
1374
+ assertSearchActive(signal, deadline, '本地语料搜索初始化')
1375
+ const resultKind = resultKindFor(request)
1376
+ const documents = []
1377
+ const reasons = new Set()
1378
+ let characters = 0
1379
+ let scanned = 0
1380
+ let matchedDocuments = checkpoint.matchedDocumentsSoFar
1381
+ let nextCandidateIndex = checkpoint.nextCandidateIndex
1382
+ let stopped = false
1383
+ for (let candidateIndex = 0; candidateIndex < candidateIds.length; candidateIndex += 1) {
1384
+ if ((candidateIndex & 255) === 0) assertSearchActive(signal, deadline)
1385
+ const documentId = candidateIds[candidateIndex]
1386
+ if (candidateIndex < checkpoint.nextCandidateIndex) continue
1387
+ if (documents.length >= PAGE_DOCUMENTS || scanned >= SCAN_DOCUMENTS_PER_PAGE) {
1388
+ nextCandidateIndex = candidateIndex
1389
+ stopped = true
1390
+ break
1391
+ }
1392
+ assertSearchActive(signal, deadline)
1393
+ const location = store.documents.get(documentId)
1394
+ scanned += 1
1395
+ if (!location || !documentMatches(location.document, location.speakers, request.filters)
1396
+ || store.isPreferredNaturalDocument?.(documentId) === false) {
1397
+ nextCandidateIndex = candidateIndex + 1
1398
+ continue
1399
+ }
1400
+ const found = await store.getDocument(documentId)
1401
+ assertSearchActive(signal, deadline)
1402
+ if (!found) {
1403
+ nextCandidateIndex = candidateIndex + 1
1404
+ continue
1405
+ }
1406
+ const group = collectDocumentGroup(found.record, request, regex, entityAliasGroups, deadline, signal)
1407
+ if (!group) {
1408
+ nextCandidateIndex = candidateIndex + 1
1409
+ continue
1410
+ }
1411
+ const remaining = Math.max(0, PREVIEW_OPTIONS.max_total_chars - characters)
1412
+ const measured = measureDocument(group, resultKind, request)
1413
+ if (documents.length && measured > remaining) {
1414
+ nextCandidateIndex = candidateIndex
1415
+ reasons.add('output_chars')
1416
+ stopped = true
1417
+ break
1418
+ }
1419
+ const built = buildPublicDocument(store, group, resultKind, request,
1420
+ documents.length ? remaining : PREVIEW_OPTIONS.max_total_chars)
1421
+ documents.push(built.document)
1422
+ characters += built.characters
1423
+ for (const reason of built.reasons) reasons.add(reason)
1424
+ matchedDocuments += 1
1425
+ nextCandidateIndex = candidateIndex + 1
1426
+ }
1427
+ const exhausted = !stopped
1428
+ const countKnown = checkpoint.matchedCountKnown !== false
1429
+ if (!exhausted) reasons.add('scan_incomplete')
1430
+ const nextCursor = exhausted ? null
1431
+ : await encodeScanCursor(store, request, nextCandidateIndex, matchedDocuments)
1432
+ assertSearchActive(signal, deadline, '分页游标生成')
1433
+ return {
1434
+ result_kind: resultKind,
1435
+ documents,
1436
+ page: {
1437
+ returned_documents: documents.length,
1438
+ ...(exhausted && countKnown ? { total_documents: matchedDocuments } : {}),
1439
+ total_relation: exhausted && countKnown ? 'eq' : 'unknown',
1440
+ has_more: !exhausted,
1441
+ exhausted,
1442
+ next_cursor: nextCursor,
1443
+ },
1444
+ truncated: reasons.size > 0,
1445
+ truncation_reasons: [...reasons],
1446
+ }
1447
+ } catch (error) {
1448
+ return { contract_version: SEARCH_CONTRACT_VERSION, status: 'error', request_id: resolvedRequestId,
1449
+ data_version: store.dataVersion ?? null,
1450
+ error: publicSearchError(error) }
1451
+ }
1452
+ }
1453
+
1454
+ export async function executeSearch(store, raw,
1455
+ { signal, requestId = null, allowedGames = ['arknights', 'endfield'] } = {}) {
1456
+ let deadline = Infinity
1457
+ let snapshot = null
1458
+ try {
1459
+ assertSearchActive(signal, deadline)
1460
+ // 首次打开真实资料包需要建立全局轻量索引,这是 Store 就绪阶段,
1461
+ // 不应吃掉本次检索的 15s 预算。就绪后只创建一个绝对 deadline,
1462
+ // 短词预筛、主扫描和分页锚点全部共用它。
1463
+ await store.ready()
1464
+ snapshot = corpusVersionSnapshot(store)
1465
+ deadline = Date.now() + SEARCH_TIMEOUT_MS
1466
+ assertSearchActive(signal, deadline)
1467
+ const normalized = normalizedRequest(raw)
1468
+ const attachCorpusWarnings = (value, request) => {
1469
+ assertCorpusVersion(store, snapshot)
1470
+ if (value?.status === 'error' || !(store.packs instanceof Map)) return value
1471
+ const games = request.filters.games?.length
1472
+ ? request.filters.games : ['arknights', 'endfield']
1473
+ const packByGame = { arknights: 'official_game', endfield: 'endfield_official_game' }
1474
+ const warnings = games.filter((game) => !store.packs.has(packByGame[game])).map((game) => ({
1475
+ code: 'CORPUS_GAME_NOT_INSTALLED', game,
1476
+ message: `${game === 'endfield' ? '终末地' : '明日方舟'}本地语料未安装,本次只检索其余已安装资料。`,
1477
+ }))
1478
+ return warnings.length ? { ...value, warnings } : value
1479
+ }
1480
+ if (normalized.cursor) {
1481
+ assertSearchActive(signal, deadline, '分页游标校验')
1482
+ const decoded = await decodeCursor(store, normalized.cursor)
1483
+ assertSearchActive(signal, deadline, '分页游标校验')
1484
+ const requestedGames = decoded.request.filters?.games?.length
1485
+ ? decoded.request.filters.games : ['arknights', 'endfield']
1486
+ if (requestedGames.some((game) => !allowedGames.includes(game))) {
1487
+ throw Object.assign(new Error('该游标包含当前未启用的游戏资料,请重新搜索'),
1488
+ { code: 'CURSOR_POLICY_MISMATCH', retryable: false })
1489
+ }
1490
+ if (decoded.kind === 'legacy') {
1491
+ return attachCorpusWarnings(await exposeTitleContinuation(store,
1492
+ await executeLegacySearch(store, decoded.request, decoded.offset,
1493
+ { signal, requestId, deadline }), { signal, deadline }),
1494
+ decoded.request)
1495
+ }
1496
+ return attachCorpusWarnings(await exposeTitleContinuation(store, await executeScanSearch(store, decoded.request, {
1497
+ nextCandidateIndex: decoded.nextCandidateIndex,
1498
+ matchedDocumentsSoFar: decoded.matchedDocumentsSoFar,
1499
+ matchedCountKnown: true,
1500
+ }, { signal, requestId, deadline }), { signal, deadline }), decoded.request)
1501
+ }
1502
+ const request = withoutAfter(normalized)
1503
+ const checkpoint = normalized.after
1504
+ ? await checkpointAfterTitle(store, normalized.after, request, { signal, deadline })
1505
+ : { nextCandidateIndex: 0, matchedDocumentsSoFar: 0, matchedCountKnown: true }
1506
+ return attachCorpusWarnings(await exposeTitleContinuation(store,
1507
+ await executeScanSearch(store, request, checkpoint,
1508
+ { signal, requestId, deadline }), { signal, deadline }), request)
1509
+ } catch (error) {
1510
+ try { if (snapshot) assertCorpusVersion(store, snapshot) } catch (changed) { error = changed }
1511
+ return { contract_version: SEARCH_CONTRACT_VERSION, status: 'error',
1512
+ request_id: String(requestId || ''), data_version: store.dataVersion ?? null,
1513
+ error: publicSearchError(error) }
1514
+ }
1515
+ }
1516
+
1517
+ export function renderSearch(args, value) {
1518
+ if (value?.error) return [{ type: 'text', text: `[corpus_search:error] ${value.error.code}: ${value.error.message}` }]
1519
+ return [{ type: 'text', text: projectSearch(value, {
1520
+ query: normalizeText(args?.query),
1521
+ }) }]
1522
+ }