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.
- package/GAME_ASSETS.md +26 -0
- package/LICENSE +21 -0
- package/README.en.md +274 -0
- package/README.md +423 -0
- package/THIRD_PARTY_NOTICES.md +61 -0
- package/bin/bake-rhine-details.mjs +34 -0
- package/bin/install.js +273 -0
- package/bin/pack-map-assets.mjs +110 -0
- package/bin/preview-rhine.mjs +71 -0
- package/bin/rhine-detail-baker.ts +84 -0
- package/contracts/cloud_tools_v1.schema.json +243 -0
- package/contracts/corpus_i18n_v1.schema.json +179 -0
- package/contracts/corpus_tools_v1.schema.json +1801 -0
- package/cordis.patch.yml +32 -0
- package/docs/endfield-official-i18n.md +103 -0
- package/lib/client.js +3912 -0
- package/lib/endfield-map/map.js.br +0 -0
- package/lib/endfield-map/map.js.gz +0 -0
- package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.br +0 -0
- package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.gz +0 -0
- package/lib/endfield-map/resources/map-01616f534aaaeca1.png +0 -0
- package/lib/endfield-map/resources/map-1562e0dff4be3d23.png +0 -0
- package/lib/endfield-map/resources/map-160fe040f080f6a3.png +0 -0
- package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.br +0 -0
- package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.gz +0 -0
- package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.br +0 -0
- package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.gz +0 -0
- package/lib/endfield-map/resources/map-1d30baaaef3e29cc.png +0 -0
- package/lib/endfield-map/resources/map-21a61386af79cd12.json.br +0 -0
- package/lib/endfield-map/resources/map-21a61386af79cd12.json.gz +0 -0
- package/lib/endfield-map/resources/map-24b920b8dab49b44.json.br +0 -0
- package/lib/endfield-map/resources/map-24b920b8dab49b44.json.gz +0 -0
- package/lib/endfield-map/resources/map-26caf8d71247abb7.png +0 -0
- package/lib/endfield-map/resources/map-2729942c89545e73.json.br +0 -0
- package/lib/endfield-map/resources/map-2729942c89545e73.json.gz +0 -0
- package/lib/endfield-map/resources/map-29063a9802f67014.json.br +0 -0
- package/lib/endfield-map/resources/map-29063a9802f67014.json.gz +0 -0
- package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.br +0 -0
- package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.gz +0 -0
- package/lib/endfield-map/resources/map-2f98a873da81425e.json.br +0 -0
- package/lib/endfield-map/resources/map-2f98a873da81425e.json.gz +0 -0
- package/lib/endfield-map/resources/map-3ab978ae44190f80.json.br +0 -0
- package/lib/endfield-map/resources/map-3ab978ae44190f80.json.gz +0 -0
- package/lib/endfield-map/resources/map-3b62c80d14208902.json.br +0 -0
- package/lib/endfield-map/resources/map-3b62c80d14208902.json.gz +0 -0
- package/lib/endfield-map/resources/map-3d486e887ae121ac.png +0 -0
- package/lib/endfield-map/resources/map-418dc4865608fce5.png +0 -0
- package/lib/endfield-map/resources/map-4366b84607335f76.json.br +0 -0
- package/lib/endfield-map/resources/map-4366b84607335f76.json.gz +0 -0
- package/lib/endfield-map/resources/map-4371259d3ab7aac7.png +0 -0
- package/lib/endfield-map/resources/map-48dcc0fd55e13f7f.png +0 -0
- package/lib/endfield-map/resources/map-4aabda0e41197570.json.br +0 -0
- package/lib/endfield-map/resources/map-4aabda0e41197570.json.gz +0 -0
- package/lib/endfield-map/resources/map-505f5687b287043c.json.br +0 -0
- package/lib/endfield-map/resources/map-505f5687b287043c.json.gz +0 -0
- package/lib/endfield-map/resources/map-5177e9d1d46f47e2.png +0 -0
- package/lib/endfield-map/resources/map-51baeb59b7f8c28d.png +0 -0
- package/lib/endfield-map/resources/map-549561b46e621874.png +0 -0
- package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.br +0 -0
- package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.gz +0 -0
- package/lib/endfield-map/resources/map-5a6963909423b674.png +0 -0
- package/lib/endfield-map/resources/map-5a9c533c0552d7b7.png +0 -0
- package/lib/endfield-map/resources/map-5d20ae9ffa9a02b0.png +0 -0
- package/lib/endfield-map/resources/map-626c8847393f60bf.png +0 -0
- package/lib/endfield-map/resources/map-63d43cbec3bed3b8.png +0 -0
- package/lib/endfield-map/resources/map-661b6ec56e722574.png +0 -0
- package/lib/endfield-map/resources/map-6ddd384eca190828.png +0 -0
- package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.br +0 -0
- package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.gz +0 -0
- package/lib/endfield-map/resources/map-738ebaa002c33cfc.png +0 -0
- package/lib/endfield-map/resources/map-758c13be6085ec07.json.br +0 -0
- package/lib/endfield-map/resources/map-758c13be6085ec07.json.gz +0 -0
- package/lib/endfield-map/resources/map-78929f26d6844509.png +0 -0
- package/lib/endfield-map/resources/map-7f09c142950a0f74.png +0 -0
- package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.br +0 -0
- package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.gz +0 -0
- package/lib/endfield-map/resources/map-881343926a568f27.json.br +0 -0
- package/lib/endfield-map/resources/map-881343926a568f27.json.gz +0 -0
- package/lib/endfield-map/resources/map-8d1ade3f01d952b0.png +0 -0
- package/lib/endfield-map/resources/map-8fef3495bb7ed60f.png +0 -0
- package/lib/endfield-map/resources/map-907a7ecf8a0ce352.png +0 -0
- package/lib/endfield-map/resources/map-9113d5951c4db903.json.br +0 -0
- package/lib/endfield-map/resources/map-9113d5951c4db903.json.gz +0 -0
- package/lib/endfield-map/resources/map-94ad6634436224e3.json.br +0 -0
- package/lib/endfield-map/resources/map-94ad6634436224e3.json.gz +0 -0
- package/lib/endfield-map/resources/map-99722e61da5f3e53.png +0 -0
- package/lib/endfield-map/resources/map-9be9596cc059abe3.png +0 -0
- package/lib/endfield-map/resources/map-a4db9a4763e47fbf.png +0 -0
- package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.br +0 -0
- package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.gz +0 -0
- package/lib/endfield-map/resources/map-ab35d06875ee418f.png +0 -0
- package/lib/endfield-map/resources/map-ac51bb3e7623cd8b.png +0 -0
- package/lib/endfield-map/resources/map-ac830a66296bc83a.png +0 -0
- package/lib/endfield-map/resources/map-b3fd04209718e55e.json.br +0 -0
- package/lib/endfield-map/resources/map-b3fd04209718e55e.json.gz +0 -0
- package/lib/endfield-map/resources/map-b8164f1af7c5d895.png +0 -0
- package/lib/endfield-map/resources/map-b8cde93b965384d8.json.br +0 -0
- package/lib/endfield-map/resources/map-b8cde93b965384d8.json.gz +0 -0
- package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.br +0 -0
- package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.gz +0 -0
- package/lib/endfield-map/resources/map-bfc58277d5ba649d.png +0 -0
- package/lib/endfield-map/resources/map-c013f82048c6891a.png +0 -0
- package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.br +0 -0
- package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.gz +0 -0
- package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.br +0 -0
- package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.gz +0 -0
- package/lib/endfield-map/resources/map-c607f8b4c8ad8967.png +0 -0
- package/lib/endfield-map/resources/map-c6f20276b8781326.json.br +0 -0
- package/lib/endfield-map/resources/map-c6f20276b8781326.json.gz +0 -0
- package/lib/endfield-map/resources/map-d51adf5952af1e8e.png +0 -0
- package/lib/endfield-map/resources/map-d695eface22f8b74.json.br +0 -0
- package/lib/endfield-map/resources/map-d695eface22f8b74.json.gz +0 -0
- package/lib/endfield-map/resources/map-db11d394456a7465.png +0 -0
- package/lib/endfield-map/resources/map-dcc4d218581e3dc6.png +0 -0
- package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.br +0 -0
- package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.gz +0 -0
- package/lib/endfield-map/resources/map-defe5db7dabf748a.png +0 -0
- package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.br +0 -0
- package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.gz +0 -0
- package/lib/endfield-map/resources/map-e25ee9af6e493500.png +0 -0
- package/lib/endfield-map/resources/map-e3824082cd6f747d.json.br +0 -0
- package/lib/endfield-map/resources/map-e3824082cd6f747d.json.gz +0 -0
- package/lib/endfield-map/resources/map-e694a91e630b1bd6.png +0 -0
- package/lib/endfield-map/resources/map-eaa93af79d12a87d.png +0 -0
- package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.br +0 -0
- package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.gz +0 -0
- package/lib/endfield-map/resources/map-ede421a329dc78a4.png +0 -0
- package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.br +0 -0
- package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.gz +0 -0
- package/lib/endfield-map/resources/map-fb5f4a59a181452c.png +0 -0
- package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.br +0 -0
- package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.gz +0 -0
- package/lib/rhine/assets/archive-assembly.glb +0 -0
- package/lib/rhine/assets/archive-cassette.glb +0 -0
- package/lib/rhine/fonts/MiSans-Bold.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Demibold.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Light.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Regular.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-license.pdf +0 -0
- package/lib/rhine/fonts/NOTICE.txt +6 -0
- package/lib/rhine/licenses/RhineLabUI-MIT.txt +21 -0
- package/lib/rhine/licenses/entities-BSD-2-Clause.txt +11 -0
- package/lib/rhine/licenses/linkify-it-MIT.txt +22 -0
- package/lib/rhine/licenses/markdown-it-MIT.txt +22 -0
- package/lib/rhine/licenses/mdurl-MIT.txt +45 -0
- package/lib/rhine/licenses/punycode.js-MIT.txt +20 -0
- package/lib/rhine/licenses/rolling-number-MIT.txt +21 -0
- package/lib/rhine/licenses/three-MIT.txt +21 -0
- package/lib/rhine/licenses/uc.micro-MIT.txt +20 -0
- package/lib/rhine/rhine.css +1 -0
- package/lib/rhine/rhine.js +5106 -0
- package/lib/skins/common.css +31 -0
- package/lib/skins/endfield-aic.css +141 -0
- package/lib/skins/prts-agent.css +136 -0
- package/lib/skins/rhine-lab.css +70 -0
- package/package.json +112 -0
- package/presets/definition.js +26 -0
- package/presets/prts/agent.cordis.yml +25 -0
- package/presets/prts/preset.yml +3 -0
- package/presets/register.js +86 -0
- package/resources/entity-routing.json +21 -0
- package/skills/prts-investigation/SKILL.md +121 -0
- package/skills/prts-retrieval/SKILL.md +35 -0
- package/skills/prts-retrieval/references/cloud-tools.md +13 -0
- package/skills/prts-retrieval/references/diagnostics.md +15 -0
- package/skills/prts-retrieval/references/local-tools.md +24 -0
- package/skills/prts-retrieval/references/module-arknights.md +23 -0
- package/skills/prts-retrieval/references/module-dual.md +15 -0
- package/skills/prts-retrieval/references/module-endfield.md +21 -0
- package/skills/prts-retrieval/references/retrieval-process.md +44 -0
- package/skills/prts-retrieval/references/retrieval-recipes-dual.md +34 -0
- package/skills/prts-retrieval/references/retrieval-recipes-endfield.md +37 -0
- package/skills/prts-retrieval/references/retrieval-recipes.md +39 -0
- package/skills/prts-retrieval/references/tools-runtime.md +48 -0
- package/skills/prts-retrieval/references/wiki-schema.md +38 -0
- package/src/archive-presentation.js +287 -0
- package/src/cloud-projection.js +124 -0
- package/src/cloud.js +498 -0
- package/src/entity-recognizer.js +311 -0
- package/src/entity-routing.js +82 -0
- package/src/evidence-state.js +332 -0
- package/src/i18n.js +365 -0
- package/src/index.js +1261 -0
- package/src/installer.js +1362 -0
- package/src/investigation-review.js +87 -0
- package/src/investigation-store.js +579 -0
- package/src/investigation-tools.js +113 -0
- package/src/localization-format.js +23 -0
- package/src/manual-search.js +103 -0
- package/src/read.js +1097 -0
- package/src/release-compatibility.js +73 -0
- package/src/release-watch.js +40 -0
- package/src/search-projection.js +139 -0
- package/src/search.js +1522 -0
- package/src/skill.js +98 -0
- package/src/source-map.js +348 -0
- package/src/state.js +314 -0
- package/src/store.js +1569 -0
- package/src/timeline.js +386 -0
- package/src/ui.js +1353 -0
- package/src/wiki.js +111 -0
- package/tsconfig.rhine.json +15 -0
- package/ui/rhine/archive-navigation-limit.ts +31 -0
- package/ui/rhine/archive-rack.ts +83 -0
- package/ui/rhine/assets/evidence-observatory.webp +0 -0
- package/ui/rhine/board-plane-transform.ts +24 -0
- package/ui/rhine/catalogue.ts +72 -0
- package/ui/rhine/deferred-preparation.ts +146 -0
- package/ui/rhine/evidence-board-controls.css +22 -0
- package/ui/rhine/evidence-board-history.ts +73 -0
- package/ui/rhine/evidence-board-model.ts +189 -0
- package/ui/rhine/evidence-board-panel.ts +762 -0
- package/ui/rhine/evidence-board-scene.ts +548 -0
- package/ui/rhine/evidence-board.css +191 -0
- package/ui/rhine/evidence-card-art.ts +470 -0
- package/ui/rhine/evidence-card-style.ts +17 -0
- package/ui/rhine/evidence-inbox-scene.ts +43 -0
- package/ui/rhine/evidence-inbox.css +29 -0
- package/ui/rhine/evidence-inbox.ts +104 -0
- package/ui/rhine/index.ts +12 -0
- package/ui/rhine/interaction.css +20 -0
- package/ui/rhine/investigation-board-plane.ts +5 -0
- package/ui/rhine/investigation-board.css +34 -0
- package/ui/rhine/investigation-board.ts +348 -0
- package/ui/rhine/investigation-edits.ts +46 -0
- package/ui/rhine/investigation-layout.ts +29 -0
- package/ui/rhine/label-prefetch.ts +34 -0
- package/ui/rhine/looping-title.ts +40 -0
- package/ui/rhine/manual-search.css +41 -0
- package/ui/rhine/manual-search.ts +89 -0
- package/ui/rhine/original/appearance.ts +180 -0
- package/ui/rhine/original/archive-label.ts +84 -0
- package/ui/rhine/original/archive-lighting.ts +39 -0
- package/ui/rhine/original/archive-loop.ts +82 -0
- package/ui/rhine/original/archive-refill.ts +64 -0
- package/ui/rhine/original/array-detail-data.ts +2 -0
- package/ui/rhine/original/array-detail.ts +41 -0
- package/ui/rhine/original/array-interior.ts +411 -0
- package/ui/rhine/original/array-occlusion.ts +166 -0
- package/ui/rhine/original/array-shell.ts +38 -0
- package/ui/rhine/original/array-visibility.ts +309 -0
- package/ui/rhine/original/asset-url.ts +4 -0
- package/ui/rhine/original/brand.ts +12 -0
- package/ui/rhine/original/cassette-lod.ts +81 -0
- package/ui/rhine/original/data.ts +21 -0
- package/ui/rhine/original/decryption.ts +185 -0
- package/ui/rhine/original/detail-signature.ts +18 -0
- package/ui/rhine/original/glass-reveal.ts +45 -0
- package/ui/rhine/original/internal-optics.ts +70 -0
- package/ui/rhine/original/label-renderer.ts +119 -0
- package/ui/rhine/original/label-textures.ts +117 -0
- package/ui/rhine/original/model-viewer.ts +658 -0
- package/ui/rhine/original/motion.ts +116 -0
- package/ui/rhine/original/opaque-backfaces.ts +64 -0
- package/ui/rhine/original/program-preparation.ts +111 -0
- package/ui/rhine/original/quality-renderer.ts +97 -0
- package/ui/rhine/original/quality-settings.ts +111 -0
- package/ui/rhine/original/render-quality.ts +163 -0
- package/ui/rhine/original/scene.ts +2051 -0
- package/ui/rhine/original/shelf-interior.ts +142 -0
- package/ui/rhine/original/ui-transitions.ts +111 -0
- package/ui/rhine/original/viewer-camera.ts +92 -0
- package/ui/rhine/original/viewport-layout.ts +41 -0
- package/ui/rhine/original-ui.css +2334 -0
- package/ui/rhine/readability.css +46 -0
- package/ui/rhine/reading-object.ts +96 -0
- package/ui/rhine/reading-origin.ts +28 -0
- package/ui/rhine/reading-room.css +55 -0
- package/ui/rhine/report-markdown.ts +98 -0
- package/ui/rhine/scene-edge-navigation.ts +84 -0
- package/ui/rhine/scene.ts +1726 -0
- package/ui/rhine/shelf-layout.ts +38 -0
- package/ui/rhine/snapshot-update-gate.ts +22 -0
- package/ui/rhine/source-reading.ts +15 -0
- package/ui/rhine/source-selection.css +164 -0
- package/ui/rhine/temporary-performance-observers.ts +163 -0
- package/ui/rhine/temporary-performance-panel.ts +124 -0
- package/ui/rhine/temporary-performance.css +19 -0
- package/ui/rhine/temporary-performance.ts +552 -0
- package/ui/rhine/temporary-render-diagnostics.ts +105 -0
- package/ui/rhine/tool-activity.ts +69 -0
- package/ui/rhine/types.ts +219 -0
- package/ui/rhine/visible-scene-hit.ts +13 -0
- package/ui/rhine/workbench.css +818 -0
- package/ui/rhine/workbench.ts +2245 -0
- package/ui/rhine/workspace-travel.ts +32 -0
- package/vite.rhine.config.ts +19 -0
package/src/read.js
ADDED
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* corpus_read 契约实现(corpus_tools_v1.schema.json,prts-corpus-tools-v1)。
|
|
3
|
+
*
|
|
4
|
+
* 与浏览器端 agent/browser/src/corpus-executor.js 语义对齐:
|
|
5
|
+
* - 单篇 locator + around/range/document,或活动/任务合集 + 连续位置分页
|
|
6
|
+
* - source_ref 内嵌行号在 around 模式下即中心行;document_id + around 必须显式给 center_line;
|
|
7
|
+
* display_title + around 的 center_line 可选(执行时缺失则报 LINE_RANGE_INVALID)
|
|
8
|
+
* - 全文行完整性校验(INDEX_CORRUPT)
|
|
9
|
+
* - expected_data_version 版本绑定(PACKAGE_VERSION_MISMATCH)
|
|
10
|
+
* - document 模式 HMAC 游标分页(插件生命周期内有效)
|
|
11
|
+
* - story 文档只返回请求的原文;剧情总结与时间线必须显式检索
|
|
12
|
+
*/
|
|
13
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
14
|
+
import { assertCorpusVersion, corpusVersionSnapshot, computeLinesIntegrity,
|
|
15
|
+
documentGame, documentUid, naturalDocumentTitle,
|
|
16
|
+
operatorRecordSegment, publicCharacterMaterial, publicStoryPart,
|
|
17
|
+
publicStoryStageCode } from './store.js'
|
|
18
|
+
import { WIKI_SECTION_VALUES, wikiSectionRanges } from './wiki.js'
|
|
19
|
+
|
|
20
|
+
export const CONTRACT_VERSION = 'prts-corpus-tools-v1'
|
|
21
|
+
|
|
22
|
+
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
23
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/
|
|
24
|
+
const SOURCE_REF_PATTERN =
|
|
25
|
+
/^(?:(?:official_game:(?:story:[^:]+|character:[^:]+:[^:]+)|client_data:(?:reviewed_wiki|terra_journey|entities|references):[0-9a-f]{24})|prts:(?:arknights|endfield):[A-Za-z0-9._:%/-]+):L([1-9][0-9]*)$/
|
|
26
|
+
|
|
27
|
+
export const END_FIELD_STORY_CONTENT_TYPES = Object.freeze([
|
|
28
|
+
'dialogue', 'cutscene', 'radio', 'remote_comm', 'black_screen',
|
|
29
|
+
'environment_talk', 'sns_topic', 'sns_chat', 'narration',
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
/** 游标签名密钥:插件实例生命周期内有效(重启即失效,符合契约的游标不透明语义)。 */
|
|
33
|
+
const CURSOR_SECRET = randomBytes(32)
|
|
34
|
+
|
|
35
|
+
/** 契约错误:映射为 errorResponse(status=error)。 */
|
|
36
|
+
export class ContractError extends Error {
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} code toolError.code 枚举值
|
|
39
|
+
* @param {string} message
|
|
40
|
+
* @param {{ retryable?: boolean }} [options]
|
|
41
|
+
*/
|
|
42
|
+
constructor(code, message, options = {}) {
|
|
43
|
+
super(message)
|
|
44
|
+
this.code = code
|
|
45
|
+
this.retryable = options.retryable ?? false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const estimateTokens = (value) => Math.ceil(
|
|
50
|
+
(typeof value === 'number' ? value : String(value ?? '').length) / 2.5,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
/** ---- 游标(document 模式分页) ---- */
|
|
54
|
+
|
|
55
|
+
function encodeCursor(payload) {
|
|
56
|
+
const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
|
|
57
|
+
const sig = createHmac('sha256', CURSOR_SECRET).update(body).digest('base64url')
|
|
58
|
+
return `${body}.${sig}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function decodeCursor(cursor) {
|
|
62
|
+
const dot = cursor.lastIndexOf('.')
|
|
63
|
+
if (dot <= 0) throw new ContractError('CURSOR_INVALID', 'malformed cursor')
|
|
64
|
+
const [body, sig] = [cursor.slice(0, dot), cursor.slice(dot + 1)]
|
|
65
|
+
// 与 search.js 的游标校验同口径:常数时间比较,长度不等先拒。
|
|
66
|
+
const expected = createHmac('sha256', CURSOR_SECRET).update(body).digest()
|
|
67
|
+
const received = Buffer.from(sig, 'base64url')
|
|
68
|
+
if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
|
|
69
|
+
throw new ContractError('CURSOR_INVALID', 'cursor signature mismatch')
|
|
70
|
+
}
|
|
71
|
+
let payload
|
|
72
|
+
try {
|
|
73
|
+
payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
|
|
74
|
+
} catch {
|
|
75
|
+
throw new ContractError('CURSOR_INVALID', 'cursor payload is not JSON')
|
|
76
|
+
}
|
|
77
|
+
if (typeof payload !== 'object' || payload === null || typeof payload.v !== 'number') {
|
|
78
|
+
throw new ContractError('CURSOR_INVALID', 'cursor payload has unknown shape')
|
|
79
|
+
}
|
|
80
|
+
return payload
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 模型只回传 cursor;在工具边界内部恢复文档 locator 与 document 模式。 */
|
|
84
|
+
export function readContractFromCursor(cursor) {
|
|
85
|
+
const payload = decodeCursor(String(cursor || ''))
|
|
86
|
+
if (payload.v !== 1 || typeof payload.document_id !== 'string' || !payload.document_id) {
|
|
87
|
+
throw new ContractError('CURSOR_INVALID', 'cursor 不是单篇文档读取游标')
|
|
88
|
+
}
|
|
89
|
+
if (payload.max_lines !== undefined && !Number.isInteger(payload.max_lines)) {
|
|
90
|
+
throw new ContractError('CURSOR_INVALID', 'cursor max_lines 无效')
|
|
91
|
+
}
|
|
92
|
+
if (payload.max_chars !== undefined && !Number.isInteger(payload.max_chars)) {
|
|
93
|
+
throw new ContractError('CURSOR_INVALID', 'cursor max_chars 无效')
|
|
94
|
+
}
|
|
95
|
+
return { locator: { document_id: payload.document_id },
|
|
96
|
+
selection: { mode: 'document', cursor: String(cursor) },
|
|
97
|
+
limits: { ...(payload.max_lines !== undefined ? { max_lines: payload.max_lines } : {}),
|
|
98
|
+
...(payload.max_chars !== undefined ? { max_chars: payload.max_chars } : {}) } }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** ---- 参数校验(跨字段规则由代码执行;DSL 只保证基础形态) ---- */
|
|
102
|
+
|
|
103
|
+
function requireInt(value, { min, max, field }) {
|
|
104
|
+
if (!Number.isInteger(value)) throw new ContractError('INVALID_REQUEST', `${field} must be an integer`)
|
|
105
|
+
if (value < min || value > max) {
|
|
106
|
+
throw new ContractError('INVALID_REQUEST', `${field} must be within [${min}, ${max}]`)
|
|
107
|
+
}
|
|
108
|
+
return value
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function requireIdString(value, field) {
|
|
112
|
+
if (typeof value !== 'string' || !ID_PATTERN.test(value)) {
|
|
113
|
+
throw new ContractError('INVALID_REQUEST', `${field} must match ${ID_PATTERN}`)
|
|
114
|
+
}
|
|
115
|
+
return value
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 校验并归一化 corpus_read 请求(填默认值,产出 normalized_request)。
|
|
120
|
+
* @throws {ContractError}
|
|
121
|
+
*/
|
|
122
|
+
export function normalizeReadRequest(raw) {
|
|
123
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
124
|
+
throw new ContractError('INVALID_REQUEST', 'request must be an object')
|
|
125
|
+
}
|
|
126
|
+
const { intent_id: intentId, request_id: requestIdInput, expected_data_version: expectedVersion } = raw
|
|
127
|
+
|
|
128
|
+
const intentIdChecked = requireIdString(intentId, 'intent_id')
|
|
129
|
+
const requestId = requestIdInput === undefined
|
|
130
|
+
? `req-${randomBytes(8).toString('hex')}`
|
|
131
|
+
: requireIdString(requestIdInput, 'request_id')
|
|
132
|
+
if (expectedVersion !== undefined && !SHA256_PATTERN.test(expectedVersion)) {
|
|
133
|
+
throw new ContractError('INVALID_REQUEST', 'expected_data_version must be a lowercase sha256 hex string')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// locator:单篇文档、明日方舟活动或终末地集合恰好一个
|
|
137
|
+
const locatorRaw = raw.locator
|
|
138
|
+
if (typeof locatorRaw !== 'object' || locatorRaw === null) {
|
|
139
|
+
throw new ContractError('INVALID_REQUEST', 'locator must be an object')
|
|
140
|
+
}
|
|
141
|
+
const hasSourceRef = locatorRaw.source_ref !== undefined
|
|
142
|
+
const hasDocumentId = locatorRaw.document_id !== undefined
|
|
143
|
+
const hasDocumentUid = locatorRaw.document_uid !== undefined
|
|
144
|
+
const hasDisplayTitle = locatorRaw.display_title !== undefined
|
|
145
|
+
const hasActivityId = locatorRaw.activity_id !== undefined
|
|
146
|
+
const hasActivityName = locatorRaw.activity_name !== undefined
|
|
147
|
+
const hasCollectionName = locatorRaw.collection_name !== undefined
|
|
148
|
+
const hasStreamLocator = hasActivityId || hasActivityName || hasCollectionName
|
|
149
|
+
const locatorCount = Number(hasSourceRef) + Number(hasDocumentId) + Number(hasDocumentUid) + Number(hasDisplayTitle)
|
|
150
|
+
+ Number(hasActivityId) + Number(hasActivityName) + Number(hasCollectionName)
|
|
151
|
+
if (locatorCount !== 1) {
|
|
152
|
+
throw new ContractError('INVALID_REQUEST',
|
|
153
|
+
'locator must contain exactly one of source_ref / document_id / document_uid / display_title / activity_id / activity_name / collection_name')
|
|
154
|
+
}
|
|
155
|
+
let locator
|
|
156
|
+
let refLine = null
|
|
157
|
+
if (hasSourceRef) {
|
|
158
|
+
const sourceRef = locatorRaw.source_ref
|
|
159
|
+
if (typeof sourceRef !== 'string' || sourceRef.length > 1024) {
|
|
160
|
+
throw new ContractError('SOURCE_REF_INVALID', 'source_ref must be a string of at most 1024 chars')
|
|
161
|
+
}
|
|
162
|
+
const match = SOURCE_REF_PATTERN.exec(sourceRef)
|
|
163
|
+
if (!match) throw new ContractError('SOURCE_REF_INVALID', `source_ref does not match contract pattern: ${sourceRef}`)
|
|
164
|
+
refLine = Number.parseInt(match[1], 10)
|
|
165
|
+
locator = { source_ref: sourceRef }
|
|
166
|
+
} else if (hasDocumentId) {
|
|
167
|
+
const documentId = locatorRaw.document_id
|
|
168
|
+
if (typeof documentId !== 'string' || documentId.length < 1 || documentId.length > 512 || !/\S/.test(documentId)) {
|
|
169
|
+
throw new ContractError('INVALID_REQUEST', 'document_id must be a non-empty identifier')
|
|
170
|
+
}
|
|
171
|
+
locator = { document_id: documentId }
|
|
172
|
+
} else if (hasDocumentUid) {
|
|
173
|
+
const uid = String(locatorRaw.document_uid || '').trim()
|
|
174
|
+
if (!/^doc_[A-Za-z0-9_-]{16}$/u.test(uid)) {
|
|
175
|
+
throw new ContractError('INVALID_REQUEST', 'document_uid must be a public doc_ locator')
|
|
176
|
+
}
|
|
177
|
+
locator = { document_uid: uid }
|
|
178
|
+
} else if (hasDisplayTitle) {
|
|
179
|
+
const displayTitle = locatorRaw.display_title
|
|
180
|
+
if (typeof displayTitle !== 'string' || displayTitle.length < 1 || displayTitle.length > 512 || !/\S/.test(displayTitle)) {
|
|
181
|
+
throw new ContractError('INVALID_REQUEST', 'display_title must be a non-empty title of at most 512 chars')
|
|
182
|
+
}
|
|
183
|
+
locator = { display_title: displayTitle }
|
|
184
|
+
} else if (hasActivityId) {
|
|
185
|
+
const activityId = String(locatorRaw.activity_id).trim()
|
|
186
|
+
if (!activityId || activityId.length > 512) {
|
|
187
|
+
throw new ContractError('INVALID_REQUEST', 'activity_id must be a non-empty identifier of at most 512 chars')
|
|
188
|
+
}
|
|
189
|
+
locator = { activity_id: activityId }
|
|
190
|
+
} else if (hasActivityName) {
|
|
191
|
+
const activityName = String(locatorRaw.activity_name).trim()
|
|
192
|
+
if (!activityName || activityName.length > 512) {
|
|
193
|
+
throw new ContractError('INVALID_REQUEST', 'activity_name must be a non-empty title of at most 512 chars')
|
|
194
|
+
}
|
|
195
|
+
locator = { activity_name: activityName }
|
|
196
|
+
} else {
|
|
197
|
+
const collectionName = String(locatorRaw.collection_name).trim()
|
|
198
|
+
if (!collectionName || collectionName.length > 512) {
|
|
199
|
+
throw new ContractError('INVALID_REQUEST', 'collection_name must be a non-empty title of at most 512 chars')
|
|
200
|
+
}
|
|
201
|
+
locator = { collection_name: collectionName }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// selection:around / range / document / section / activity / collection
|
|
205
|
+
const selectionRaw = raw.selection
|
|
206
|
+
if (typeof selectionRaw !== 'object' || selectionRaw === null) {
|
|
207
|
+
throw new ContractError('INVALID_REQUEST', 'selection must be an object')
|
|
208
|
+
}
|
|
209
|
+
const mode = selectionRaw.mode
|
|
210
|
+
let selection
|
|
211
|
+
if (mode === 'around') {
|
|
212
|
+
if (hasStreamLocator) throw new ContractError('INVALID_REQUEST', 'around mode requires a document locator')
|
|
213
|
+
if (hasSourceRef) {
|
|
214
|
+
if (selectionRaw.center_line !== undefined) {
|
|
215
|
+
throw new ContractError('INVALID_REQUEST', 'around with source_ref locator must not set center_line')
|
|
216
|
+
}
|
|
217
|
+
selection = {
|
|
218
|
+
mode: 'around',
|
|
219
|
+
before_lines: selectionRaw.before_lines ?? 3,
|
|
220
|
+
after_lines: selectionRaw.after_lines ?? 3,
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
// document_id:center_line 必填;display_title:center_line 可选(执行时缺失报错)
|
|
224
|
+
if ((hasDocumentId || hasDocumentUid) && selectionRaw.center_line === undefined) {
|
|
225
|
+
throw new ContractError('INVALID_REQUEST', 'around with document_id/document_uid locator requires center_line')
|
|
226
|
+
}
|
|
227
|
+
if (selectionRaw.center_line !== undefined) {
|
|
228
|
+
requireInt(selectionRaw.center_line, { min: 1, max: 1e9, field: 'center_line' })
|
|
229
|
+
}
|
|
230
|
+
selection = {
|
|
231
|
+
mode: 'around',
|
|
232
|
+
...(selectionRaw.center_line !== undefined ? { center_line: selectionRaw.center_line } : {}),
|
|
233
|
+
before_lines: selectionRaw.before_lines ?? 3,
|
|
234
|
+
after_lines: selectionRaw.after_lines ?? 3,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
requireInt(selection.before_lines, { min: 0, max: 100, field: 'before_lines' })
|
|
238
|
+
requireInt(selection.after_lines, { min: 0, max: 100, field: 'after_lines' })
|
|
239
|
+
} else if (mode === 'range') {
|
|
240
|
+
if (hasStreamLocator) throw new ContractError('INVALID_REQUEST', 'range mode requires a document locator')
|
|
241
|
+
const startLine = requireInt(selectionRaw.start_line, { min: 1, max: 1e9, field: 'start_line' })
|
|
242
|
+
const endLine = requireInt(selectionRaw.end_line, { min: 1, max: 1e9, field: 'end_line' })
|
|
243
|
+
if (endLine < startLine) throw new ContractError('LINE_RANGE_INVALID', 'end_line must be >= start_line')
|
|
244
|
+
selection = { mode: 'range', start_line: startLine, end_line: endLine }
|
|
245
|
+
} else if (mode === 'document') {
|
|
246
|
+
if (hasStreamLocator) throw new ContractError('INVALID_REQUEST', 'document mode requires a document locator')
|
|
247
|
+
const startLine = selectionRaw.start_line === undefined ? 1
|
|
248
|
+
: requireInt(selectionRaw.start_line, { min: 1, max: 1e9, field: 'start_line' })
|
|
249
|
+
if (selectionRaw.cursor != null && selectionRaw.start_line !== undefined) {
|
|
250
|
+
throw new ContractError('INVALID_REQUEST', 'document mode cannot combine cursor with start_line')
|
|
251
|
+
}
|
|
252
|
+
selection = { mode: 'document', cursor: selectionRaw.cursor ?? null, start_line: startLine }
|
|
253
|
+
} else if (mode === 'section') {
|
|
254
|
+
if (hasStreamLocator) {
|
|
255
|
+
throw new ContractError('INVALID_REQUEST', 'section mode requires a document locator')
|
|
256
|
+
}
|
|
257
|
+
const section = String(selectionRaw.section || '').trim()
|
|
258
|
+
if (!WIKI_SECTION_VALUES.includes(section)) {
|
|
259
|
+
throw new ContractError('INVALID_REQUEST', 'section must be a supported Wiki field')
|
|
260
|
+
}
|
|
261
|
+
selection = { mode: 'section', section }
|
|
262
|
+
} else if (mode === 'activity' || mode === 'collection') {
|
|
263
|
+
const expectedLocator = mode === 'activity'
|
|
264
|
+
? hasActivityId || hasActivityName || hasDocumentUid
|
|
265
|
+
: hasCollectionName || hasDocumentUid
|
|
266
|
+
if (!expectedLocator) {
|
|
267
|
+
throw new ContractError('INVALID_REQUEST',
|
|
268
|
+
`${mode} mode requires ${mode === 'activity' ? 'activity_id or activity_name' : 'collection_name'} locator`)
|
|
269
|
+
}
|
|
270
|
+
if (selectionRaw.cursor != null && selectionRaw.start_position !== undefined) {
|
|
271
|
+
throw new ContractError('INVALID_REQUEST', `${mode} mode cannot combine cursor with start_position`)
|
|
272
|
+
}
|
|
273
|
+
const startPosition = selectionRaw.start_position === undefined ? 1
|
|
274
|
+
: requireInt(selectionRaw.start_position, { min: 1, max: 1e9, field: 'start_position' })
|
|
275
|
+
let contentTypes = []
|
|
276
|
+
if (selectionRaw.content_types !== undefined) {
|
|
277
|
+
if (mode !== 'collection' || !Array.isArray(selectionRaw.content_types)
|
|
278
|
+
|| selectionRaw.content_types.length > END_FIELD_STORY_CONTENT_TYPES.length
|
|
279
|
+
|| selectionRaw.content_types.some((item) => !END_FIELD_STORY_CONTENT_TYPES.includes(item))) {
|
|
280
|
+
throw new ContractError('INVALID_REQUEST',
|
|
281
|
+
'content_types is only available in collection mode and must contain supported original-story types')
|
|
282
|
+
}
|
|
283
|
+
contentTypes = [...new Set(selectionRaw.content_types)]
|
|
284
|
+
}
|
|
285
|
+
selection = { mode, cursor: selectionRaw.cursor ?? null, start_position: startPosition,
|
|
286
|
+
...(contentTypes.length ? { content_types: contentTypes } : {}) }
|
|
287
|
+
} else {
|
|
288
|
+
throw new ContractError('INVALID_REQUEST',
|
|
289
|
+
'selection.mode must be around | range | document | section | activity | collection')
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const format = raw.format === undefined ? 'lines' : raw.format
|
|
293
|
+
if (format !== 'lines' && format !== 'plain_text') {
|
|
294
|
+
throw new ContractError('INVALID_REQUEST', 'format must be lines | plain_text')
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const includeAdjacent = raw.include_adjacent_documents === undefined ? true : raw.include_adjacent_documents
|
|
298
|
+
if (typeof includeAdjacent !== 'boolean') {
|
|
299
|
+
throw new ContractError('INVALID_REQUEST', 'include_adjacent_documents must be a boolean')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const limitsRaw = raw.limits ?? {}
|
|
303
|
+
if (typeof limitsRaw !== 'object' || limitsRaw === null) {
|
|
304
|
+
throw new ContractError('INVALID_REQUEST', 'limits must be an object')
|
|
305
|
+
}
|
|
306
|
+
const limits = {
|
|
307
|
+
max_lines: limitsRaw.max_lines ?? 100,
|
|
308
|
+
max_chars: limitsRaw.max_chars ?? 12000,
|
|
309
|
+
}
|
|
310
|
+
requireInt(limits.max_lines, { min: 1, max: 500, field: 'limits.max_lines' })
|
|
311
|
+
requireInt(limits.max_chars, { min: 100, max: 100000, field: 'limits.max_chars' })
|
|
312
|
+
|
|
313
|
+
const normalized = {
|
|
314
|
+
intent_id: intentIdChecked,
|
|
315
|
+
request_id: requestId,
|
|
316
|
+
locator,
|
|
317
|
+
selection,
|
|
318
|
+
format,
|
|
319
|
+
include_adjacent_documents: includeAdjacent,
|
|
320
|
+
limits,
|
|
321
|
+
}
|
|
322
|
+
if (expectedVersion !== undefined) normalized.expected_data_version = expectedVersion
|
|
323
|
+
return { normalized, refLine }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** ---- 文档摘要投影 ---- */
|
|
327
|
+
|
|
328
|
+
const SUMMARY_FIELDS = [
|
|
329
|
+
'game', 'resource_type', 'content_type', 'collection_name', 'collection_type',
|
|
330
|
+
'document_id', 'document_type', 'document_category', 'document_kind', 'display_title',
|
|
331
|
+
'collection_id', 'activity_id', 'activity_name', 'source_story_id', 'story_id', 'story_code',
|
|
332
|
+
'story_name', 'part_type', 'part_label', 'char_id', 'character_name', 'path', 'text_sha256',
|
|
333
|
+
'line_count', 'sequence_index', 'sequence_source', 'sequence_confidence',
|
|
334
|
+
'previous_document_id', 'next_document_id', 'source_ref_prefix', 'entity_id',
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
function toDocumentSummary(documentRecord) {
|
|
338
|
+
const summary = { document_uid: documentUid(documentRecord.document_id),
|
|
339
|
+
game: documentGame(documentRecord) }
|
|
340
|
+
// 与浏览器 executor.summary() 一致:固定摘要字段缺失时输出空字符串,
|
|
341
|
+
// 不能把 undefined 带过 Harness 的 lossless-JSON 工具边界。
|
|
342
|
+
for (const field of SUMMARY_FIELDS) summary[field] = documentRecord[field] ?? ''
|
|
343
|
+
return summary
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** 由完整 record 构建文档摘要:实体文档的 entity_id 位于 record.entity 而非 document 上。 */
|
|
347
|
+
function recordSummary(record) {
|
|
348
|
+
const summary = toDocumentSummary(record.document)
|
|
349
|
+
if (record.document?.document_type === 'entity' && record.entity?.entity_id) {
|
|
350
|
+
summary.entity_id = String(record.entity.entity_id)
|
|
351
|
+
}
|
|
352
|
+
return summary
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** ---- 执行 ---- */
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 执行 corpus_read。
|
|
359
|
+
* @param {import('./store.js').CorpusStore} store
|
|
360
|
+
* @param {object} rawArgs 模型原始参数
|
|
361
|
+
* @param {{ signal?: AbortSignal }} runtime
|
|
362
|
+
* @returns {Promise<object>} 契约响应(ok / error)
|
|
363
|
+
*/
|
|
364
|
+
export async function executeRead(store, rawArgs, runtime) {
|
|
365
|
+
const startedAt = Date.now()
|
|
366
|
+
let snapshot = null
|
|
367
|
+
try {
|
|
368
|
+
await store.ready()
|
|
369
|
+
snapshot = corpusVersionSnapshot(store)
|
|
370
|
+
const { normalized, refLine } = normalizeReadRequest(rawArgs)
|
|
371
|
+
if (runtime.signal?.aborted) throw new ContractError('CANCELLED', 'aborted before execution')
|
|
372
|
+
|
|
373
|
+
if (normalized.expected_data_version !== undefined && normalized.expected_data_version !== store.dataVersion) {
|
|
374
|
+
throw new ContractError('PACKAGE_VERSION_MISMATCH',
|
|
375
|
+
`expected data_version ${normalized.expected_data_version} but active release is ${store.dataVersion}`,
|
|
376
|
+
{ retryable: true })
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 合集通读:枚举活动/任务的全部官方剧情,按顺序跨文档读取与分页。
|
|
380
|
+
if (normalized.selection.mode === 'activity' || normalized.selection.mode === 'collection') {
|
|
381
|
+
const response = await executeStoryStreamRead(store, normalized, { runtime, startedAt })
|
|
382
|
+
assertCorpusVersion(store, snapshot)
|
|
383
|
+
return response
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 定位文档
|
|
387
|
+
let found
|
|
388
|
+
if (normalized.locator.document_id !== undefined) {
|
|
389
|
+
found = await store.getDocument(normalized.locator.document_id)
|
|
390
|
+
if (found === null) throw new ContractError('DOCUMENT_NOT_FOUND', `document not found: ${normalized.locator.document_id}`)
|
|
391
|
+
} else if (normalized.locator.document_uid !== undefined) {
|
|
392
|
+
found = await store.getDocumentByUid(normalized.locator.document_uid)
|
|
393
|
+
if (found === null) {
|
|
394
|
+
throw new ContractError('DOCUMENT_NOT_FOUND',
|
|
395
|
+
`本地资料包中找不到 document_uid=${normalized.locator.document_uid}`)
|
|
396
|
+
}
|
|
397
|
+
} else if (normalized.locator.display_title !== undefined) {
|
|
398
|
+
try {
|
|
399
|
+
found = await store.getDocumentByTitle(normalized.locator.display_title)
|
|
400
|
+
} catch (error) {
|
|
401
|
+
if (error?.code === 'DOCUMENT_AMBIGUOUS') {
|
|
402
|
+
throw new ContractError('DOCUMENT_AMBIGUOUS', error.message)
|
|
403
|
+
}
|
|
404
|
+
throw error
|
|
405
|
+
}
|
|
406
|
+
if (found === null) {
|
|
407
|
+
throw new ContractError('DOCUMENT_NOT_FOUND', '本地资料包中找不到该完整标题对应的文档')
|
|
408
|
+
}
|
|
409
|
+
} else {
|
|
410
|
+
const sourceRef = normalized.locator.source_ref
|
|
411
|
+
const prefix = sourceRef.slice(0, sourceRef.lastIndexOf(':L'))
|
|
412
|
+
const documentId = store.getDocumentIdByPrefix(prefix)
|
|
413
|
+
if (documentId === null) throw new ContractError('DOCUMENT_NOT_FOUND', `unknown source_ref prefix: ${prefix}`)
|
|
414
|
+
found = await store.getDocument(documentId)
|
|
415
|
+
if (found === null) throw new ContractError('DOCUMENT_NOT_FOUND', `document not found: ${documentId}`)
|
|
416
|
+
}
|
|
417
|
+
assertCorpusVersion(store, snapshot)
|
|
418
|
+
// GameData 把 [uc]info/ 一行式简介排在 obt/ 对话正文之前;document 模式
|
|
419
|
+
// 命中简介时优先换成可读全文(与浏览器执行器 readableStoryRecord 一致)。
|
|
420
|
+
let record = found.record
|
|
421
|
+
if (normalized.selection.mode === 'document') {
|
|
422
|
+
const sourceStoryId = String(record.document.source_story_id || '')
|
|
423
|
+
if (record.document.document_kind === 'synopsis' && sourceStoryId.startsWith('[uc]info/')) {
|
|
424
|
+
const fullStory = await store.getDocumentBySourceStoryId(sourceStoryId.slice('[uc]info/'.length))
|
|
425
|
+
if (fullStory?.record?.document?.document_kind === 'story') record = fullStory.record
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const packId = found.packId
|
|
430
|
+
const packManifest = store.packs.get(packId)
|
|
431
|
+
const document = record.document
|
|
432
|
+
const documentId = document.document_id
|
|
433
|
+
const lineCount = document.line_count
|
|
434
|
+
|
|
435
|
+
// 行完整性(全文):行文本 \n 连接后 sha256 与 local_integrity 比对
|
|
436
|
+
const actualIntegrity = computeLinesIntegrity(record.lines)
|
|
437
|
+
const expectedIntegrity = record.local_integrity?.sha256
|
|
438
|
+
const integrityVerified = expectedIntegrity === actualIntegrity
|
|
439
|
+
if (!integrityVerified) {
|
|
440
|
+
throw new ContractError('INDEX_CORRUPT', `integrity mismatch for ${documentId}: expected ${expectedIntegrity}, got ${actualIntegrity}`)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (runtime.signal?.aborted) throw new ContractError('CANCELLED', 'aborted after integrity check')
|
|
444
|
+
|
|
445
|
+
// 解析选区 → [startLine, endLine]
|
|
446
|
+
const { selection } = normalized
|
|
447
|
+
let startLine
|
|
448
|
+
let endLine
|
|
449
|
+
let cursorNextLine = null
|
|
450
|
+
if (selection.mode === 'around') {
|
|
451
|
+
const center = normalized.locator.source_ref !== undefined ? refLine : selection.center_line
|
|
452
|
+
if (!center || center > lineCount) {
|
|
453
|
+
throw new ContractError('LINE_RANGE_INVALID', `center line ${center ?? '(missing)'} is missing or beyond document length ${lineCount}`)
|
|
454
|
+
}
|
|
455
|
+
startLine = Math.max(1, center - selection.before_lines)
|
|
456
|
+
endLine = Math.min(lineCount, center + selection.after_lines)
|
|
457
|
+
} else if (selection.mode === 'range') {
|
|
458
|
+
if (selection.start_line > lineCount) {
|
|
459
|
+
throw new ContractError('LINE_RANGE_INVALID', `start_line ${selection.start_line} is beyond document length ${lineCount}`)
|
|
460
|
+
}
|
|
461
|
+
startLine = selection.start_line
|
|
462
|
+
endLine = Math.min(lineCount, selection.end_line)
|
|
463
|
+
} else if (selection.mode === 'section') {
|
|
464
|
+
if (document.document_type !== 'knowledge' || document.document_kind !== 'wiki') {
|
|
465
|
+
throw new ContractError('INVALID_REQUEST', 'section mode can only read Wiki documents')
|
|
466
|
+
}
|
|
467
|
+
const ranges = wikiSectionRanges(record, [selection.section])
|
|
468
|
+
if (!ranges.length) {
|
|
469
|
+
throw new ContractError('DOCUMENT_NOT_FOUND', `Wiki 文档“${document.display_title}”没有字段“${selection.section}”`)
|
|
470
|
+
}
|
|
471
|
+
if (ranges.length > 1) {
|
|
472
|
+
throw new ContractError('DOCUMENT_AMBIGUOUS',
|
|
473
|
+
`Wiki 文档“${document.display_title}”包含多个“${selection.section}”字段;请用 corpus_search 获取具体行范围`)
|
|
474
|
+
}
|
|
475
|
+
startLine = ranges[0].start_line
|
|
476
|
+
endLine = ranges[0].end_line
|
|
477
|
+
} else {
|
|
478
|
+
let nextLine = selection.start_line
|
|
479
|
+
if (selection.cursor !== null) {
|
|
480
|
+
if (typeof selection.cursor !== 'string' || selection.cursor.length < 1 || selection.cursor.length > 4096) {
|
|
481
|
+
throw new ContractError('CURSOR_INVALID', 'cursor must be a string of 1..4096 chars')
|
|
482
|
+
}
|
|
483
|
+
const payload = decodeCursor(selection.cursor)
|
|
484
|
+
if (payload.v !== 1 || payload.document_id !== documentId || payload.data_version !== store.dataVersion) {
|
|
485
|
+
throw new ContractError('CURSOR_VERSION_MISMATCH', 'cursor is bound to a different document or data_version')
|
|
486
|
+
}
|
|
487
|
+
nextLine = payload.next_line
|
|
488
|
+
if (nextLine < 1 || nextLine > lineCount) {
|
|
489
|
+
throw new ContractError('CURSOR_INVALID', `cursor next_line ${nextLine} is out of range`)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
startLine = nextLine
|
|
493
|
+
endLine = lineCount
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 应用 limits 截取
|
|
497
|
+
const { max_lines: maxLines, max_chars: maxChars } = normalized.limits
|
|
498
|
+
const spanLines = endLine - startLine + 1
|
|
499
|
+
let truncated = false
|
|
500
|
+
let truncationReason = null
|
|
501
|
+
let selectedEnd = endLine
|
|
502
|
+
if (spanLines > maxLines) {
|
|
503
|
+
selectedEnd = startLine + maxLines - 1
|
|
504
|
+
truncated = true
|
|
505
|
+
truncationReason = 'max_lines'
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const selectedLines = []
|
|
509
|
+
let charCount = 0
|
|
510
|
+
for (let lineNumber = startLine; lineNumber <= selectedEnd; lineNumber += 1) {
|
|
511
|
+
const line = record.lines[lineNumber - 1]
|
|
512
|
+
if (charCount + line.text.length > maxChars) {
|
|
513
|
+
truncated = true
|
|
514
|
+
truncationReason = truncationReason ?? 'max_chars'
|
|
515
|
+
break
|
|
516
|
+
}
|
|
517
|
+
charCount += line.text.length
|
|
518
|
+
selectedLines.push(line)
|
|
519
|
+
}
|
|
520
|
+
// 首行即超过 max_chars 时不能返回“0 行 + 原地游标”的 ok 响应——document
|
|
521
|
+
// 模式的 next_cursor 不会前进,模型分页会死循环。与 activity 模式一致
|
|
522
|
+
// 上报预算不足,让调用方提高 max_chars 后重试。
|
|
523
|
+
if (!selectedLines.length && truncated) {
|
|
524
|
+
throw new ContractError('BUDGET_EXCEEDED',
|
|
525
|
+
`读取范围内首行长度已超过 max_chars=${maxChars};请提高 max_chars 后重试`)
|
|
526
|
+
}
|
|
527
|
+
const returned = selectedLines.length
|
|
528
|
+
const hasMore = truncated || selectedEnd < endLine
|
|
529
|
+
let nextCursor = null
|
|
530
|
+
if (selection.mode === 'document' && hasMore) {
|
|
531
|
+
cursorNextLine = startLine + returned
|
|
532
|
+
nextCursor = encodeCursor({
|
|
533
|
+
v: 1,
|
|
534
|
+
document_id: documentId,
|
|
535
|
+
data_version: store.dataVersion,
|
|
536
|
+
next_line: cursorNextLine,
|
|
537
|
+
max_lines: maxLines,
|
|
538
|
+
max_chars: maxChars,
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// 内容投影
|
|
543
|
+
let content
|
|
544
|
+
if (normalized.format === 'plain_text') {
|
|
545
|
+
content = { format: 'plain_text', text: selectedLines.map((line) => line.text).join('\n') }
|
|
546
|
+
} else {
|
|
547
|
+
content = {
|
|
548
|
+
format: 'lines',
|
|
549
|
+
lines: selectedLines.map((line) => ({
|
|
550
|
+
line_number: line.line_number,
|
|
551
|
+
...(line.source_line_id ? { source_line_id: line.source_line_id } : {}),
|
|
552
|
+
line_type: line.line_type ?? '',
|
|
553
|
+
speaker_raw: line.speaker_raw ?? '',
|
|
554
|
+
...(line.speaker_id ? { speaker_id: line.speaker_id } : {}),
|
|
555
|
+
text: line.text ?? '',
|
|
556
|
+
...(line.audio ? { audio: line.audio } : {}),
|
|
557
|
+
...(line.hint ? { hint: line.hint } : {}),
|
|
558
|
+
source_ref: `${document.source_ref_prefix}:L${line.line_number}`,
|
|
559
|
+
})),
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// 相邻文档摘要
|
|
564
|
+
let adjacentDocuments
|
|
565
|
+
if (normalized.include_adjacent_documents) {
|
|
566
|
+
const previousId = document.previous_document_id || null
|
|
567
|
+
const nextId = document.next_document_id || null
|
|
568
|
+
const previous = previousId ? await store.getDocument(previousId) : null
|
|
569
|
+
const next = nextId ? await store.getDocument(nextId) : null
|
|
570
|
+
adjacentDocuments = {
|
|
571
|
+
previous: previous ? toDocumentSummary(previous.record.document) : null,
|
|
572
|
+
next: next ? toDocumentSummary(next.record.document) : null,
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
assertCorpusVersion(store, snapshot)
|
|
577
|
+
return {
|
|
578
|
+
contract_version: CONTRACT_VERSION,
|
|
579
|
+
status: 'ok',
|
|
580
|
+
request_id: normalized.request_id,
|
|
581
|
+
data_version: store.dataVersion,
|
|
582
|
+
package_schema_version: packManifest.package_schema_version ?? 1,
|
|
583
|
+
index_schema_version: packManifest.index_schema_version ?? 1,
|
|
584
|
+
normalized_request: normalized,
|
|
585
|
+
document: recordSummary(record),
|
|
586
|
+
selection: {
|
|
587
|
+
mode: selection.mode,
|
|
588
|
+
...(selection.mode === 'section' ? { wiki_section: selection.section } : {}),
|
|
589
|
+
line_start: returned > 0 ? selectedLines[0].line_number : startLine,
|
|
590
|
+
line_end: returned > 0 ? selectedLines[returned - 1].line_number : startLine - 1,
|
|
591
|
+
line_count: returned,
|
|
592
|
+
character_count: charCount,
|
|
593
|
+
truncated,
|
|
594
|
+
...(truncationReason ? { truncation_reason: truncationReason } : {}),
|
|
595
|
+
},
|
|
596
|
+
content,
|
|
597
|
+
page: {
|
|
598
|
+
limit: maxLines,
|
|
599
|
+
returned,
|
|
600
|
+
has_more: hasMore,
|
|
601
|
+
next_cursor: nextCursor,
|
|
602
|
+
total: selection.mode === 'document' ? lineCount : spanLines,
|
|
603
|
+
total_relation: 'eq',
|
|
604
|
+
},
|
|
605
|
+
...(adjacentDocuments ? { adjacent_documents: adjacentDocuments } : {}),
|
|
606
|
+
integrity: {
|
|
607
|
+
verified: integrityVerified,
|
|
608
|
+
expected_text_sha256: expectedIntegrity,
|
|
609
|
+
actual_text_sha256: actualIntegrity,
|
|
610
|
+
},
|
|
611
|
+
stats: {
|
|
612
|
+
elapsed_ms: Date.now() - startedAt,
|
|
613
|
+
scanned_documents: 1,
|
|
614
|
+
scanned_lines: lineCount,
|
|
615
|
+
returned_chars: charCount,
|
|
616
|
+
estimated_input_tokens: estimateTokens(charCount),
|
|
617
|
+
truncated,
|
|
618
|
+
},
|
|
619
|
+
warnings: [],
|
|
620
|
+
}
|
|
621
|
+
} catch (error) {
|
|
622
|
+
try { if (snapshot) assertCorpusVersion(store, snapshot) } catch (changed) { error = changed }
|
|
623
|
+
if (error instanceof ContractError || error?.code === 'PACKAGE_VERSION_MISMATCH') {
|
|
624
|
+
return {
|
|
625
|
+
contract_version: CONTRACT_VERSION,
|
|
626
|
+
status: 'error',
|
|
627
|
+
request_id: typeof rawArgs?.request_id === 'string' ? rawArgs.request_id : `req-${randomBytes(8).toString('hex')}`,
|
|
628
|
+
data_version: store.dataVersion ?? null,
|
|
629
|
+
error: { code: error.code, message: error.message, retryable: error.retryable },
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (error?.code === 'ENOENT' || /current\.json|release-manifest/.test(error?.message ?? '')) {
|
|
633
|
+
return {
|
|
634
|
+
contract_version: CONTRACT_VERSION,
|
|
635
|
+
status: 'error',
|
|
636
|
+
request_id: `req-${randomBytes(8).toString('hex')}`,
|
|
637
|
+
data_version: null,
|
|
638
|
+
error: { code: 'PACKAGE_NOT_INSTALLED',
|
|
639
|
+
message: '本地资料包未安装或不完整,请在 PRTS 语料设置中重新下载或激活版本',
|
|
640
|
+
retryable: true },
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
throw error // 基础设施故障交给宿主 isError
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** 合集行保留可检索自然标题;同名碎片由稳定 document_uid 消歧。 */
|
|
648
|
+
function streamDocumentLabels(docs) {
|
|
649
|
+
return docs.map((item) => naturalDocumentTitle(item.document))
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** activity/collection 共用的跨文档连续读取。 */
|
|
653
|
+
async function executeStoryStreamRead(store, normalized, { runtime, startedAt }) {
|
|
654
|
+
const { selection } = normalized
|
|
655
|
+
const isActivity = selection.mode === 'activity'
|
|
656
|
+
let docs
|
|
657
|
+
const anchorDocumentId = normalized.locator.document_uid
|
|
658
|
+
? store.getDocumentIdByUid(normalized.locator.document_uid) || '' : ''
|
|
659
|
+
if (normalized.locator.document_uid && !anchorDocumentId) {
|
|
660
|
+
throw new ContractError('DOCUMENT_NOT_FOUND',
|
|
661
|
+
`本地资料包中找不到 document_uid=${normalized.locator.document_uid}`)
|
|
662
|
+
}
|
|
663
|
+
try {
|
|
664
|
+
docs = isActivity
|
|
665
|
+
? store.activityStoryDocuments({ activityId: normalized.locator.activity_id,
|
|
666
|
+
activityName: normalized.locator.activity_name, anchorDocumentId })
|
|
667
|
+
: store.endfieldCollectionDocuments({ collectionName: normalized.locator.collection_name,
|
|
668
|
+
contentTypes: selection.content_types ?? [], anchorDocumentId })
|
|
669
|
+
} catch (error) {
|
|
670
|
+
if (error?.code === 'DOCUMENT_AMBIGUOUS') {
|
|
671
|
+
throw new ContractError('DOCUMENT_AMBIGUOUS', error.message)
|
|
672
|
+
}
|
|
673
|
+
throw error
|
|
674
|
+
}
|
|
675
|
+
const targetName = String(isActivity
|
|
676
|
+
? normalized.locator.activity_name || normalized.locator.activity_id || ''
|
|
677
|
+
: normalized.locator.collection_name || '')
|
|
678
|
+
if (!docs.length) {
|
|
679
|
+
throw new ContractError('DOCUMENT_NOT_FOUND',
|
|
680
|
+
`本地资料包中找不到${isActivity ? '活动' : '终末地集合'}“${targetName}”的剧情原文`)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const totalLines = docs.reduce((total, item) => total + Number(item.document.line_count || 0), 0)
|
|
684
|
+
const collectionId = String(docs[0].document.collection_id || docs[0].document.activity_id || '')
|
|
685
|
+
const streamKey = JSON.stringify([selection.mode, collectionId, selection.content_types ?? []])
|
|
686
|
+
let docIndex = 0
|
|
687
|
+
let startLine = 1
|
|
688
|
+
let startPosition = selection.start_position
|
|
689
|
+
if (selection.cursor !== null) {
|
|
690
|
+
if (typeof selection.cursor !== 'string' || selection.cursor.length < 1 || selection.cursor.length > 4096) {
|
|
691
|
+
throw new ContractError('CURSOR_INVALID', 'cursor must be a string of 1..4096 chars')
|
|
692
|
+
}
|
|
693
|
+
const payload = decodeCursor(selection.cursor)
|
|
694
|
+
const legacyActivity = payload.v === 2 && isActivity
|
|
695
|
+
if ((!legacyActivity && (payload.v !== 3 || payload.stream_key !== streamKey))
|
|
696
|
+
|| payload.data_version !== store.dataVersion) {
|
|
697
|
+
throw new ContractError('CURSOR_VERSION_MISMATCH',
|
|
698
|
+
'cursor is bound to a different collection, filter, or data_version')
|
|
699
|
+
}
|
|
700
|
+
if (!Number.isInteger(payload.doc_index) || payload.doc_index < 0 || payload.doc_index >= docs.length
|
|
701
|
+
|| !Number.isInteger(payload.next_line) || payload.next_line < 1
|
|
702
|
+
|| payload.next_line > Number(docs[payload.doc_index].document.line_count || 0)) {
|
|
703
|
+
throw new ContractError('CURSOR_INVALID', 'story-stream cursor payload is invalid')
|
|
704
|
+
}
|
|
705
|
+
docIndex = payload.doc_index
|
|
706
|
+
startLine = payload.next_line
|
|
707
|
+
startPosition = docs.slice(0, docIndex).reduce(
|
|
708
|
+
(total, item) => total + Number(item.document.line_count || 0), 0) + startLine
|
|
709
|
+
} else {
|
|
710
|
+
if (startPosition > totalLines) {
|
|
711
|
+
throw new ContractError('LINE_RANGE_INVALID',
|
|
712
|
+
`position ${startPosition} is beyond collection length ${totalLines}`)
|
|
713
|
+
}
|
|
714
|
+
let remaining = startPosition
|
|
715
|
+
for (let index = 0; index < docs.length; index += 1) {
|
|
716
|
+
const lineCount = Number(docs[index].document.line_count || 0)
|
|
717
|
+
if (remaining <= lineCount) {
|
|
718
|
+
docIndex = index
|
|
719
|
+
startLine = remaining
|
|
720
|
+
break
|
|
721
|
+
}
|
|
722
|
+
remaining -= lineCount
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const labels = streamDocumentLabels(docs)
|
|
727
|
+
const { max_lines: maxLines, max_chars: maxChars } = normalized.limits
|
|
728
|
+
const selected = []
|
|
729
|
+
let charCount = 0
|
|
730
|
+
let currentPosition = startPosition
|
|
731
|
+
let nextLocation = null
|
|
732
|
+
let truncationReason = null
|
|
733
|
+
streamDocuments:
|
|
734
|
+
for (let index = docIndex; index < docs.length; index += 1) {
|
|
735
|
+
if (runtime.signal?.aborted) throw new ContractError('CANCELLED', 'aborted during story-stream read')
|
|
736
|
+
const found = await store.getDocument(docs[index].document.document_id)
|
|
737
|
+
if (!found) continue
|
|
738
|
+
const record = found.record
|
|
739
|
+
const document = record.document
|
|
740
|
+
const actualIntegrity = computeLinesIntegrity(record.lines)
|
|
741
|
+
if (record.local_integrity?.sha256 !== actualIntegrity) {
|
|
742
|
+
throw new ContractError('INDEX_CORRUPT',
|
|
743
|
+
`integrity mismatch for ${document.document_id}: expected ${record.local_integrity?.sha256}, got ${actualIntegrity}`)
|
|
744
|
+
}
|
|
745
|
+
const start = index === docIndex ? startLine : 1
|
|
746
|
+
for (let number = start; number <= record.lines.length; number += 1) {
|
|
747
|
+
const line = record.lines[number - 1]
|
|
748
|
+
if (selected.length >= maxLines) {
|
|
749
|
+
truncationReason = 'max_lines'
|
|
750
|
+
nextLocation = { doc_index: index, next_line: number }
|
|
751
|
+
break streamDocuments
|
|
752
|
+
}
|
|
753
|
+
if (charCount + line.text.length > maxChars) {
|
|
754
|
+
truncationReason = 'max_chars'
|
|
755
|
+
nextLocation = { doc_index: index, next_line: number }
|
|
756
|
+
break streamDocuments
|
|
757
|
+
}
|
|
758
|
+
charCount += line.text.length
|
|
759
|
+
selected.push({ line, document, packId: found.packId,
|
|
760
|
+
documentTitle: labels[index], streamPosition: currentPosition })
|
|
761
|
+
currentPosition += 1
|
|
762
|
+
nextLocation = number < record.lines.length
|
|
763
|
+
? { doc_index: index, next_line: number + 1 }
|
|
764
|
+
: index + 1 < docs.length ? { doc_index: index + 1, next_line: 1 } : null
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (!selected.length) {
|
|
768
|
+
throw new ContractError('BUDGET_EXCEEDED',
|
|
769
|
+
`读取范围内首行长度已超过 max_chars=${maxChars};请提高 max_chars 后重试`)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const nextStreamPosition = nextLocation ? currentPosition : null
|
|
773
|
+
const nextCursor = nextLocation ? encodeCursor({ v: 3, data_version: store.dataVersion,
|
|
774
|
+
stream_key: streamKey, ...nextLocation }) : null
|
|
775
|
+
const first = selected[0]
|
|
776
|
+
const firstDocSummary = toDocumentSummary(first.document)
|
|
777
|
+
const streamSources = new Map()
|
|
778
|
+
for (const item of selected) {
|
|
779
|
+
const id = String(item.document.document_id || '')
|
|
780
|
+
const current = streamSources.get(id) || {
|
|
781
|
+
document_id: id, document_uid: documentUid(id), title: item.documentTitle,
|
|
782
|
+
line_start: item.line.line_number, line_end: item.line.line_number,
|
|
783
|
+
}
|
|
784
|
+
current.line_start = Math.min(current.line_start, item.line.line_number)
|
|
785
|
+
current.line_end = Math.max(current.line_end, item.line.line_number)
|
|
786
|
+
streamSources.set(id, current)
|
|
787
|
+
}
|
|
788
|
+
const streamName = String(isActivity
|
|
789
|
+
? first.document.activity_name || targetName : first.document.collection_name || targetName)
|
|
790
|
+
const stream = {
|
|
791
|
+
mode: selection.mode,
|
|
792
|
+
game: isActivity ? 'arknights' : 'endfield',
|
|
793
|
+
name: streamName,
|
|
794
|
+
document_count: docs.length,
|
|
795
|
+
total_lines: totalLines,
|
|
796
|
+
position_start: startPosition,
|
|
797
|
+
position_end: currentPosition - 1,
|
|
798
|
+
next_position: nextStreamPosition,
|
|
799
|
+
order_kind: isActivity ? 'source_sequence' : 'derived_content_grouping',
|
|
800
|
+
order_confidence: isActivity ? 'source_backed' : 'derived',
|
|
801
|
+
order_note: isActivity
|
|
802
|
+
? '按明日方舟活动资料的来源序列连续读取。'
|
|
803
|
+
: '终末地碎片缺少可证明的全局时间线;当前仅按内容类型分组,并在组内按自然编号排序,不代表游戏内先后。',
|
|
804
|
+
anchor_document_uid: documentUid(first.document.document_id),
|
|
805
|
+
sources: [...streamSources.values()],
|
|
806
|
+
...(selection.content_types?.length ? { content_types: selection.content_types } : {}),
|
|
807
|
+
}
|
|
808
|
+
const activity = isActivity ? {
|
|
809
|
+
activity_id: String(firstDocSummary.activity_id || firstDocSummary.collection_id || ''),
|
|
810
|
+
activity_name: streamName,
|
|
811
|
+
story_count: docs.length,
|
|
812
|
+
total_lines: totalLines,
|
|
813
|
+
} : null
|
|
814
|
+
|
|
815
|
+
const content = normalized.format === 'plain_text'
|
|
816
|
+
? { format: 'plain_text', text: selected.map((item) => item.line.text).join('\n') }
|
|
817
|
+
: { format: 'lines', lines: selected.map((item) => ({
|
|
818
|
+
line_number: item.line.line_number,
|
|
819
|
+
...(item.line.source_line_id ? { source_line_id: item.line.source_line_id } : {}),
|
|
820
|
+
line_type: item.line.line_type ?? '',
|
|
821
|
+
speaker_raw: item.line.speaker_raw ?? '',
|
|
822
|
+
...(item.line.speaker_id ? { speaker_id: item.line.speaker_id } : {}),
|
|
823
|
+
text: item.line.text ?? '',
|
|
824
|
+
...(item.line.audio ? { audio: item.line.audio } : {}),
|
|
825
|
+
...(item.line.hint ? { hint: item.line.hint } : {}),
|
|
826
|
+
source_ref: `${item.document.source_ref_prefix}:L${item.line.line_number}`,
|
|
827
|
+
document_id: item.document.document_id,
|
|
828
|
+
document_uid: documentUid(item.document.document_id),
|
|
829
|
+
document_title: item.documentTitle,
|
|
830
|
+
stream_position: item.streamPosition,
|
|
831
|
+
})) }
|
|
832
|
+
|
|
833
|
+
const firstPackManifest = store.packs.get(first.packId)
|
|
834
|
+
const returnedIntegrity = computeLinesIntegrity(selected.map((item) => item.line))
|
|
835
|
+
return {
|
|
836
|
+
contract_version: CONTRACT_VERSION,
|
|
837
|
+
status: 'ok',
|
|
838
|
+
request_id: normalized.request_id,
|
|
839
|
+
data_version: store.dataVersion,
|
|
840
|
+
package_schema_version: firstPackManifest?.package_schema_version ?? 1,
|
|
841
|
+
index_schema_version: firstPackManifest?.index_schema_version ?? 1,
|
|
842
|
+
normalized_request: normalized,
|
|
843
|
+
document: firstDocSummary,
|
|
844
|
+
selection: {
|
|
845
|
+
mode: selection.mode,
|
|
846
|
+
line_start: startPosition,
|
|
847
|
+
line_end: currentPosition - 1,
|
|
848
|
+
line_count: selected.length,
|
|
849
|
+
character_count: charCount,
|
|
850
|
+
truncated: Boolean(nextLocation),
|
|
851
|
+
...(truncationReason ? { truncation_reason: truncationReason } : {}),
|
|
852
|
+
},
|
|
853
|
+
content,
|
|
854
|
+
page: { limit: maxLines, returned: selected.length, has_more: Boolean(nextLocation),
|
|
855
|
+
next_cursor: nextCursor, total: totalLines, total_relation: 'eq' },
|
|
856
|
+
stream,
|
|
857
|
+
...(activity ? { activity } : {}),
|
|
858
|
+
integrity: { verified: true, expected_text_sha256: returnedIntegrity,
|
|
859
|
+
actual_text_sha256: returnedIntegrity },
|
|
860
|
+
stats: {
|
|
861
|
+
elapsed_ms: Date.now() - startedAt,
|
|
862
|
+
scanned_documents: docs.length,
|
|
863
|
+
scanned_lines: selected.length,
|
|
864
|
+
returned_chars: charCount,
|
|
865
|
+
estimated_input_tokens: estimateTokens(selected.map((item) => item.line.text).join('\n')),
|
|
866
|
+
truncated: Boolean(nextLocation),
|
|
867
|
+
},
|
|
868
|
+
warnings: [],
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** ---- 模型可见文本渲染 ---- */
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* 模型可见的单行渲染格式(renderRead 使用)。evidence-state.js 的读取去重
|
|
876
|
+
* 依赖在模型 surface 中检索同一格式的行标记,两处必须共用本函数,否则
|
|
877
|
+
* 去重判定会与模型实际可见文本脱节。
|
|
878
|
+
*/
|
|
879
|
+
export function readableRenderedLine(line, marker = 'L') {
|
|
880
|
+
const speaker = String(line.speaker_raw || '').trim()
|
|
881
|
+
let text = String(line.text || '')
|
|
882
|
+
if (speaker) {
|
|
883
|
+
const escaped = speaker.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
|
|
884
|
+
text = text.replace(new RegExp(`^${escaped}\\s*[::]\\s*`, 'u'), '')
|
|
885
|
+
}
|
|
886
|
+
return `${marker}${line.line_number} ${line.line_type || ''} ${speaker ? `${speaker}: ` : ''}${text}`
|
|
887
|
+
.replace(/\s+$/u, '')
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function publicLine(line) {
|
|
891
|
+
const speaker = String(line.speaker_raw || '').trim()
|
|
892
|
+
let text = String(line.text || '')
|
|
893
|
+
if (speaker) {
|
|
894
|
+
const escaped = speaker.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
|
|
895
|
+
text = text.replace(new RegExp(`^${escaped}\\s*[::]\\s*`, 'u'), '')
|
|
896
|
+
}
|
|
897
|
+
return { line: line.line_number,
|
|
898
|
+
...(line.source_line_id ? { source_line_id: line.source_line_id } : {}),
|
|
899
|
+
line_type: line.line_type || '', speaker,
|
|
900
|
+
...(line.speaker_id ? { speaker_id: line.speaker_id } : {}),
|
|
901
|
+
text,
|
|
902
|
+
...(line.document_title ? { document_title: line.document_title } : {}),
|
|
903
|
+
...(line.document_uid ? { document_uid: line.document_uid } : {}),
|
|
904
|
+
...(Number.isInteger(line.stream_position) ? { stream_position: line.stream_position } : {}),
|
|
905
|
+
...(line.document_title ? { citation: `《${line.document_title}》${line.document_uid
|
|
906
|
+
? `(document_uid=${line.document_uid})` : ''}第 ${line.line_number} 行` } : {}),
|
|
907
|
+
...(line.audio ? { audio: line.audio } : {}),
|
|
908
|
+
...(line.hint ? { hint: line.hint } : {}) }
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** 执行层富响应 → 模型/程序共用的自然定位 public result。 */
|
|
912
|
+
export function projectReadPublic(value) {
|
|
913
|
+
if (value?.status === 'error') return value
|
|
914
|
+
if (value?.primary?.kind === 'official_story_collection') return value
|
|
915
|
+
if (value?.primary) {
|
|
916
|
+
const hasMore = Boolean(value.page?.has_more)
|
|
917
|
+
const nextLine = Number(value.primary.selection?.line_end) + 1
|
|
918
|
+
const existing = value.page?.continuation
|
|
919
|
+
const shortLocator = existing && typeof existing === 'object'
|
|
920
|
+
? existing
|
|
921
|
+
: value.primary.stage_code && value.primary.story_part
|
|
922
|
+
? { stage_code: value.primary.stage_code, story_part: value.primary.story_part }
|
|
923
|
+
: { title: value.primary.title }
|
|
924
|
+
const dataVersion = String(existing?.data_version ?? value.presentation?.data_version
|
|
925
|
+
?? value.data_version ?? '')
|
|
926
|
+
return { ...value, page: {
|
|
927
|
+
returned_lines: Number(value.page?.returned_lines || value.primary.lines?.length || 0),
|
|
928
|
+
has_more: hasMore,
|
|
929
|
+
continuation: hasMore && Number.isInteger(nextLine) && nextLine > 0
|
|
930
|
+
? { ...shortLocator, mode: 'document', line: nextLine,
|
|
931
|
+
...(dataVersion ? { data_version: dataVersion } : {}) }
|
|
932
|
+
: null,
|
|
933
|
+
} }
|
|
934
|
+
}
|
|
935
|
+
if (value.stream) {
|
|
936
|
+
const lines = value.content?.format === 'lines' ? (value.content.lines || []).map(publicLine) : []
|
|
937
|
+
const stream = value.stream
|
|
938
|
+
const isActivity = stream.mode === 'activity'
|
|
939
|
+
const requestedLocator = value.normalized_request?.locator || {}
|
|
940
|
+
const continuationLocator = requestedLocator.document_uid
|
|
941
|
+
? { document_uid: requestedLocator.document_uid }
|
|
942
|
+
: { [isActivity ? 'activity_name' : 'collection_name']: stream.name }
|
|
943
|
+
const continuation = value.page?.has_more && Number.isInteger(stream.next_position)
|
|
944
|
+
? { ...continuationLocator,
|
|
945
|
+
mode: stream.mode, position: stream.next_position,
|
|
946
|
+
...(stream.content_types?.length ? { content_types: stream.content_types } : {}),
|
|
947
|
+
data_version: String(value.data_version || '') }
|
|
948
|
+
: null
|
|
949
|
+
const title = isActivity
|
|
950
|
+
? `${stream.name} / 活动剧情连续阅读` : `${stream.name} / 终末地任务连续阅读`
|
|
951
|
+
return {
|
|
952
|
+
primary: { game: stream.game, title, kind: 'official_story_collection',
|
|
953
|
+
selection: { mode: stream.mode, line_start: stream.position_start,
|
|
954
|
+
line_end: stream.position_end, truncated: Boolean(value.selection?.truncated) },
|
|
955
|
+
lines,
|
|
956
|
+
ordering: stream.order_kind,
|
|
957
|
+
ordering_note: stream.order_note,
|
|
958
|
+
...(value.content?.format === 'plain_text' ? { text: value.content.text || '' } : {}),
|
|
959
|
+
citation: '连续阅读结果按每行的 document_title 与 line 引用' },
|
|
960
|
+
page: { returned_lines: Number(value.page?.returned || lines.length),
|
|
961
|
+
has_more: Boolean(value.page?.has_more), continuation },
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
const title = naturalDocumentTitle(value.document || {})
|
|
965
|
+
const stageCode = publicStoryStageCode(value.document?.story_code)
|
|
966
|
+
const storyPart = publicStoryPart(value.document?.part_type)
|
|
967
|
+
const hasStoryStage = documentGame(value.document || {}) === 'arknights' && stageCode
|
|
968
|
+
&& Boolean(storyPart)
|
|
969
|
+
const recordSegment = operatorRecordSegment(value.document || {})
|
|
970
|
+
const hasOperatorRecord = Boolean(recordSegment) && value.document?.document_kind === 'story'
|
|
971
|
+
&& value.document?.part_type === 'body'
|
|
972
|
+
const material = publicCharacterMaterial(value.document || {})
|
|
973
|
+
const continuationLocator = hasStoryStage
|
|
974
|
+
? { stage_code: stageCode, story_part: storyPart }
|
|
975
|
+
: hasOperatorRecord
|
|
976
|
+
? { character_name: value.document.character_name,
|
|
977
|
+
record_name: value.document.story_name, segment: recordSegment }
|
|
978
|
+
: material
|
|
979
|
+
? { character_name: value.document.character_name, material,
|
|
980
|
+
game: documentGame(value.document || {}) }
|
|
981
|
+
: { title }
|
|
982
|
+
const lines = value.content?.format === 'lines' ? (value.content.lines || []).map(publicLine) : []
|
|
983
|
+
const kind = value.document?.document_type === 'story' ? 'official_story'
|
|
984
|
+
: value.document?.document_type === 'knowledge' && value.document?.document_kind === 'wiki'
|
|
985
|
+
? 'wiki_curated' : 'local_document'
|
|
986
|
+
return {
|
|
987
|
+
primary: { game: documentGame(value.document || {}), title,
|
|
988
|
+
...(hasStoryStage ? { stage_code: stageCode, story_part: storyPart } : {}),
|
|
989
|
+
...(hasOperatorRecord ? { character_name: value.document.character_name,
|
|
990
|
+
record_name: value.document.story_name, segment: recordSegment } : {}),
|
|
991
|
+
...(material ? { character_name: value.document.character_name, material } : {}),
|
|
992
|
+
kind,
|
|
993
|
+
selection: { mode: value.selection?.mode, line_start: value.selection?.line_start,
|
|
994
|
+
line_end: value.selection?.line_end,
|
|
995
|
+
...(value.selection?.wiki_section ? { section: value.selection.wiki_section } : {}),
|
|
996
|
+
truncated: Boolean(value.selection?.truncated) },
|
|
997
|
+
lines,
|
|
998
|
+
...(value.content?.format === 'plain_text' ? { text: value.content.text || '' } : {}),
|
|
999
|
+
citation: value.selection?.wiki_section ? `《${title}》Wiki·${value.selection.wiki_section}`
|
|
1000
|
+
: `《${title}》第 ${value.selection?.line_start === value.selection?.line_end
|
|
1001
|
+
? value.selection?.line_start : `${value.selection?.line_start}-${value.selection?.line_end}`} 行` },
|
|
1002
|
+
...(value.coverage ? { coverage: value.coverage } : {}),
|
|
1003
|
+
...(value.guidance ? { guidance: value.guidance } : {}),
|
|
1004
|
+
page: { returned_lines: Number(value.page?.returned || lines.length),
|
|
1005
|
+
has_more: Boolean(value.page?.has_more),
|
|
1006
|
+
continuation: value.page?.has_more
|
|
1007
|
+
? { ...continuationLocator, mode: 'document', line: Number(value.selection?.line_end) + 1,
|
|
1008
|
+
data_version: String(value.data_version || '') }
|
|
1009
|
+
: null },
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* 将契约响应渲染为模型可见文本(output.render 用)。
|
|
1015
|
+
* @param {object} _args
|
|
1016
|
+
* @param {object} value executeRead 的返回值
|
|
1017
|
+
*/
|
|
1018
|
+
export function renderRead(_args, value) {
|
|
1019
|
+
if (value?.status === 'error') {
|
|
1020
|
+
return [{
|
|
1021
|
+
type: 'text',
|
|
1022
|
+
text: `[corpus_read:error] code=${value.error.code} retryable=${value.error.retryable}\n${value.error.message}`,
|
|
1023
|
+
}]
|
|
1024
|
+
}
|
|
1025
|
+
const projected = projectReadPublic(value)
|
|
1026
|
+
const parts = []
|
|
1027
|
+
if (projected.primary) {
|
|
1028
|
+
const primary = projected.primary
|
|
1029
|
+
parts.push(`# ${primary.title}`)
|
|
1030
|
+
if (primary.kind === 'official_story_collection') {
|
|
1031
|
+
parts.push(`连续位置:第 ${primary.selection.line_start}-${primary.selection.line_end} 行`)
|
|
1032
|
+
if (primary.ordering_note) parts.push(`顺序说明:${primary.ordering_note}`)
|
|
1033
|
+
let activeTitle = ''
|
|
1034
|
+
let activeUid = ''
|
|
1035
|
+
let groupStart = null
|
|
1036
|
+
let groupEnd = null
|
|
1037
|
+
const finishGroup = () => {
|
|
1038
|
+
if (activeTitle && groupStart != null) {
|
|
1039
|
+
parts.push(`引用:《${activeTitle}》${activeUid ? `(document_uid=${activeUid})` : ''}第 ${groupStart === groupEnd ? groupStart : `${groupStart}-${groupEnd}`} 行`)
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
for (const line of primary.lines || []) {
|
|
1043
|
+
if (line.document_title !== activeTitle || line.document_uid !== activeUid) {
|
|
1044
|
+
finishGroup()
|
|
1045
|
+
activeTitle = line.document_title || primary.title
|
|
1046
|
+
activeUid = line.document_uid || ''
|
|
1047
|
+
groupStart = line.line
|
|
1048
|
+
groupEnd = line.line
|
|
1049
|
+
parts.push(`## ${activeTitle}${activeUid ? `(document_uid=${activeUid})` : ''}`)
|
|
1050
|
+
} else {
|
|
1051
|
+
groupEnd = line.line
|
|
1052
|
+
}
|
|
1053
|
+
parts.push(readableRenderedLine({ line_number: line.line, line_type: line.line_type,
|
|
1054
|
+
speaker_raw: line.speaker, text: line.text }))
|
|
1055
|
+
}
|
|
1056
|
+
finishGroup()
|
|
1057
|
+
if (primary.text) parts.push(primary.text)
|
|
1058
|
+
if (projected.page.has_more && projected.page.continuation) {
|
|
1059
|
+
parts.push(`继续阅读:corpus_read(${JSON.stringify(projected.page.continuation)})`)
|
|
1060
|
+
}
|
|
1061
|
+
return [{ type: 'text', text: parts.join('\n') }]
|
|
1062
|
+
}
|
|
1063
|
+
if (primary.kind === 'wiki_curated') {
|
|
1064
|
+
parts.push('引文状态:Wiki 为整理性资料;其中引号内容未核验为当前资料包官方原文,逐字引用前请回查原文。')
|
|
1065
|
+
}
|
|
1066
|
+
if (primary.selection?.section) parts.push(`字段:${primary.selection.section}`)
|
|
1067
|
+
else parts.push(`范围:第 ${primary.selection.line_start}-${primary.selection.line_end} 行`)
|
|
1068
|
+
if (projected.coverage?.reused_ranges?.length) {
|
|
1069
|
+
const ranges = projected.coverage.reused_ranges.map((range) =>
|
|
1070
|
+
range.line_start === range.line_end ? String(range.line_start)
|
|
1071
|
+
: `${range.line_start}-${range.line_end}`).join('、')
|
|
1072
|
+
parts.push(`复用上文:第 ${ranges} 行已在上方可见工具结果中;本次只展示尚未覆盖的行。`)
|
|
1073
|
+
}
|
|
1074
|
+
for (const line of primary.lines || []) {
|
|
1075
|
+
parts.push(readableRenderedLine({ line_number: line.line, line_type: line.line_type,
|
|
1076
|
+
speaker_raw: line.speaker, text: line.text }))
|
|
1077
|
+
}
|
|
1078
|
+
if (primary.text) parts.push(primary.text)
|
|
1079
|
+
parts.push(`引用:${primary.citation}`)
|
|
1080
|
+
if (projected.page.has_more && projected.page.continuation) {
|
|
1081
|
+
const next = projected.page.continuation
|
|
1082
|
+
parts.push(`继续阅读《${primary.title}》,从第 ${next.line} 行开始。`)
|
|
1083
|
+
if (next.stage_code && next.story_part) {
|
|
1084
|
+
parts.push(`调用:corpus_read(${JSON.stringify({ stage_code: next.stage_code,
|
|
1085
|
+
story_part: next.story_part, mode: 'document', line: next.line,
|
|
1086
|
+
data_version: next.data_version })})`)
|
|
1087
|
+
} else if (next.record_name || next.material) {
|
|
1088
|
+
parts.push(`调用:corpus_read(${JSON.stringify(next)})`)
|
|
1089
|
+
} else {
|
|
1090
|
+
parts.push(`调用:corpus_read(${JSON.stringify({ title: String(next.title || ''),
|
|
1091
|
+
mode: 'document', line: next.line, data_version: next.data_version })})`)
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
return [{ type: 'text', text: parts.join('\n') }]
|
|
1095
|
+
}
|
|
1096
|
+
return [{ type: 'text', text: '[corpus_read:error] 无法投影读取结果' }]
|
|
1097
|
+
}
|