prts-terrarchive 0.2.0 → 0.2.2
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/README.en.md +84 -18
- package/README.md +64 -15
- package/THIRD_PARTY_NOTICES.md +10 -0
- package/bin/build-runtime.mjs +32 -0
- package/bin/check-runtime.mjs +56 -0
- package/bin/install.js +24 -5
- package/contracts/corpus_i18n_v1.schema.json +10 -10
- package/cordis.patch.yml +4 -4
- package/docs/web-access.md +24 -0
- package/lib/client.js +84 -23
- package/lib/rhine/rhine.css +1 -1
- package/lib/rhine/rhine.js +208 -208
- package/lib/runtime/licenses/js-yaml-MIT.txt +21 -0
- package/lib/runtime/licenses/zod-MIT.txt +21 -0
- package/lib/runtime/versions.json +4 -0
- package/lib/runtime/yaml.js +3062 -0
- package/lib/runtime/zod.js +14356 -0
- package/lib/skins/common.css +59 -0
- package/lib/skins/endfield-aic.css +22 -9
- package/lib/skins/prts-agent.css +15 -5
- package/lib/skins/rhine-lab.css +9 -0
- package/package.json +15 -8
- package/presets/composition.js +53 -0
- package/presets/definition.js +2 -2
- package/presets/prts/agent.cordis.yml +1 -1
- package/presets/prts/preset.yml +1 -1
- package/presets/register.js +23 -10
- package/resources/runtime/yaml.js +1 -0
- package/resources/runtime/zod.js +1 -0
- package/skills/prts-composition/SKILL.md +60 -0
- package/skills/prts-investigation/SKILL.md +19 -102
- package/skills/prts-investigation/references/board-guide.md +131 -0
- package/skills/prts-retrieval/SKILL.md +5 -3
- package/src/composition-skill.js +19 -0
- package/src/i18n.js +11 -17
- package/src/index.js +63 -81
- package/src/investigation-review.js +14 -6
- package/src/investigation-store.js +97 -25
- package/src/investigation-tools.js +67 -30
- package/src/investigation-validation.js +33 -0
- package/src/manual-search.js +1 -1
- package/src/skill.js +25 -16
- package/src/skin-capabilities.js +28 -0
- package/src/tools.js +13 -0
- package/src/web-guidance.js +44 -0
- package/ui/rhine/archive-rack.ts +10 -8
- package/ui/rhine/catalogue.ts +16 -4
- package/ui/rhine/evidence-board-panel.ts +15 -0
- package/ui/rhine/investigation-board.css +3 -0
- package/ui/rhine/investigation-board.ts +71 -24
- package/ui/rhine/investigation-history.ts +15 -0
- package/ui/rhine/reading-room.css +14 -3
- package/ui/rhine/scene.ts +44 -24
- package/ui/rhine/tool-activity.ts +13 -6
- package/ui/rhine/types.ts +6 -0
- package/ui/rhine/workbench.ts +82 -40
package/src/index.js
CHANGED
|
@@ -45,6 +45,7 @@ import { createSharedState } from './state.js'
|
|
|
45
45
|
import { applyUi } from './ui.js'
|
|
46
46
|
import { acquireInvestigationStore } from './investigation-store.js'
|
|
47
47
|
import { mountInvestigationTools } from './investigation-tools.js'
|
|
48
|
+
import { mountWebGuidance } from './web-guidance.js'
|
|
48
49
|
import { attachLocalSourceMappings } from './source-map.js'
|
|
49
50
|
import { projectCloudInspect, projectCloudSearch } from './cloud-projection.js'
|
|
50
51
|
import { coveredRead, createEvidenceStateRegistry,
|
|
@@ -109,35 +110,15 @@ async function requireLocalCorpus(store) {
|
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
const READ_DESCRIPTION =
|
|
113
|
-
'读取 PRTS.chat 本地资料。明日方舟关卡用 stage_code,只有多篇时才填 story_part;干员密录用 character_name + record_name + segment;角色资料用 character_name + material。',
|
|
114
|
-
'整个明日方舟活动用 activity_name + mode=activity,终末地任务用 collection_name + mode=collection;合集续页原样提交 page.continuation 的 position。其他资料使用完整 title。',
|
|
115
|
-
'搜索结果若给出 document_uid,说明标题或合集同名:它替代 title,读取时只提交 document_uid,不得同时提交 title;单篇可配 line 或 mode=document,所属合集可配 mode=activity/collection。',
|
|
116
|
-
'line 扩大单篇原文上下文,section 读取 Wiki 字段,mode=document 分页全文。所有续页都原样提交 page.continuation;旧会话里的 cursor 仅用于兼容。',
|
|
117
|
-
'引用原文使用“《篇章名》第 N 行”;同名结果保留工具给出的 document_uid 作为唯一定位器。不要使用内部代号、路径或自造篇章名。',
|
|
118
|
-
].join(' ')
|
|
113
|
+
const READ_DESCRIPTION = '读取 PRTS.chat 本地资料,支持行号上下文、Wiki 字段和单篇/合集连续阅读。只选一种主定位方式;续页原样提交 page.continuation。'
|
|
119
114
|
|
|
120
|
-
const SEARCH_DESCRIPTION =
|
|
121
|
-
'像 grep 一样搜索 PRTS.chat 本地语料;命中立即返回原行及上下各一行,并按文档归并。',
|
|
122
|
-
'query 使用短实体名、篇章展示名或原句片段;也可省略 query,仅按过滤条件列出资料入口。',
|
|
123
|
-
'角色个人页用 character_wiki;活动/密录整理页用 story_wiki;角色在单个活动中的辅助整理用 character_activity_wiki。wiki_sections 可精确限定相关活动、相关角色、剧情总结、角色剧情概括等标签字段。',
|
|
124
|
-
'literal 是默认连续字面匹配;只有特殊模式才使用受限 regex。新查询的下一页保留原搜索条件,并把返回的 next_after 原样放入 after;锚点由完整资料版本、资料类型与自然标题组成,不再暴露内部 cursor。旧会话的 cursor 链仅按返回的 next_cursor 继续。',
|
|
125
|
-
].join(' ')
|
|
115
|
+
const SEARCH_DESCRIPTION = '搜索 PRTS.chat 本地语料,返回按文档归并的命中行及上下各一行;省略 query 可列目录。过滤数组内 OR、字段间 AND。'
|
|
126
116
|
|
|
127
|
-
const TIMELINE_DESCRIPTION =
|
|
128
|
-
'按活动名、年份及自动展开的实体别名检索活动时间线(PRTS Wiki《泰拉年表》本地投影)。',
|
|
129
|
-
'人物放 entity_names 以自动裂变别名;结果只给时间、事件正文和“年表出处”标记,把标记原样传回 source_marker 可反查完整来源。',
|
|
130
|
-
].join(' ')
|
|
117
|
+
const TIMELINE_DESCRIPTION = '检索明日方舟活动时间线(PRTS Wiki《泰拉年表》),返回年份、事件与可反查的出处标记。'
|
|
131
118
|
|
|
132
|
-
const CLOUD_SEARCH_DESCRIPTION =
|
|
133
|
-
'用一次自然语言请求同时查询 PRTS.chat 云端的明日方舟与终末地图谱、档案、原文、自建 Wiki 和时间线组合索引;只有用户明确限定游戏时才使用 games 收窄。',
|
|
134
|
-
'返回末尾的「## 可读取原文」列出已映射到本地篇章的完整自然语言标题与行号;有同名消歧标记时按 document_uid + line 调用,否则按 title + line。',
|
|
135
|
-
].join(' ')
|
|
119
|
+
const CLOUD_SEARCH_DESCRIPTION = '用自然语言检索 PRTS.chat 云端图谱、档案、原文、Wiki 和时间线。返回的「可读取原文」定位可交给 corpus_read 核验。'
|
|
136
120
|
|
|
137
|
-
const CLOUD_INSPECT_DESCRIPTION =
|
|
138
|
-
'按 section 和过滤条件读取最近一次云端检索状态(request_id 由运行时自动注入)。',
|
|
139
|
-
'用于定点读取回答材料、候选状态或诊断记录,并支持按 next_cursor 分页。',
|
|
140
|
-
].join(' ')
|
|
121
|
+
const CLOUD_INSPECT_DESCRIPTION = '读取最近一次云端检索的回答材料、候选或诊断记录;request_id 由运行时注入。'
|
|
141
122
|
|
|
142
123
|
const TOOL_GAME_LABELS = Object.freeze({ arknights: '明日方舟', endfield: '终末地' })
|
|
143
124
|
|
|
@@ -186,34 +167,34 @@ const stringList = (description) => ({ type: 'array', items: { type: 'string' },
|
|
|
186
167
|
const SEARCH_PARAMETERS = {
|
|
187
168
|
type: 'object', additionalProperties: false,
|
|
188
169
|
properties: {
|
|
189
|
-
cursor: { type: 'string', description: '
|
|
190
|
-
query: { type: 'string', description: '
|
|
170
|
+
cursor: { type: 'string', description: '旧会话 next_cursor,单独提交;新查询用 after' },
|
|
171
|
+
query: { type: 'string', description: '短实体名、标题或原句片段;完整研究问题用 cloud_search' },
|
|
191
172
|
resource_types: { type: 'array', items: { type: 'string', enum: RESOURCE_TYPES },
|
|
192
|
-
description: '资料类型;character_bundle
|
|
173
|
+
description: '资料类型;character_bundle 聚合角色资料' },
|
|
193
174
|
games: { type: 'array', items: { type: 'string', enum: ['arknights', 'endfield'] },
|
|
194
|
-
description: '
|
|
175
|
+
description: '游戏范围;省略时查询全部启用游戏' },
|
|
195
176
|
content_types: { type: 'array', items: { type: 'string', enum: CONTENT_TYPES },
|
|
196
|
-
description: '
|
|
197
|
-
collection_names: stringList('
|
|
198
|
-
character_names: stringList('
|
|
199
|
-
story_names: stringList('
|
|
177
|
+
description: '内容形式' },
|
|
178
|
+
collection_names: stringList('活动或任务等上级集合展示名'),
|
|
179
|
+
character_names: stringList('资料归属角色的展示名'),
|
|
180
|
+
story_names: stringList('剧情篇章展示名'),
|
|
200
181
|
activity_names: stringList('活动展示名'),
|
|
201
182
|
wiki_sections: { type: 'array', items: { type: 'string', enum: WIKI_SECTION_VALUES },
|
|
202
|
-
description: 'Wiki
|
|
183
|
+
description: '限定 Wiki 标签字段' },
|
|
203
184
|
entity_names: stringList('只返回出现指定实体的行'),
|
|
204
|
-
speakers: stringList('
|
|
185
|
+
speakers: stringList('说话人展示名,仅匹配亲口台词'),
|
|
205
186
|
match_mode: { type: 'string', enum: ['literal', 'regex'],
|
|
206
|
-
description: '默认 literal
|
|
187
|
+
description: '默认 literal 连续字面匹配;regex 为受限正则' },
|
|
207
188
|
context_terms: { type: 'array', items: { type: 'string' },
|
|
208
|
-
description: '
|
|
189
|
+
description: '命中附近须同时出现的语境词,最多 8 个' },
|
|
209
190
|
after: { type: 'object', additionalProperties: false,
|
|
210
|
-
description: '
|
|
191
|
+
description: '原样复制 page.next_after,保留原 query 和过滤条件;切版后重新搜索',
|
|
211
192
|
required: ['data_version', 'resource_type', 'title', 'position'], properties: {
|
|
212
193
|
data_version: { type: 'string',
|
|
213
|
-
description: '
|
|
194
|
+
description: '上一页的完整 SHA-256 资料版本' },
|
|
214
195
|
resource_type: { type: 'string', enum: RESOURCE_TYPES },
|
|
215
|
-
title: { type: 'string'
|
|
216
|
-
position: { type: 'integer'
|
|
196
|
+
title: { type: 'string' },
|
|
197
|
+
position: { type: 'integer' },
|
|
217
198
|
} },
|
|
218
199
|
},
|
|
219
200
|
}
|
|
@@ -295,34 +276,34 @@ const SEARCH_OUTPUT_SCHEMA = {
|
|
|
295
276
|
const READ_PARAMETERS = {
|
|
296
277
|
type: 'object',
|
|
297
278
|
properties: {
|
|
298
|
-
title: { type: 'string', description: '
|
|
279
|
+
title: { type: 'string', description: '完整展示标题;与 document_uid 互斥,须配 line、section 或 mode=document' },
|
|
299
280
|
document_uid: { type: 'string',
|
|
300
|
-
description: '
|
|
301
|
-
stage_code: { type: 'string', description: '
|
|
281
|
+
description: '搜索返回的同名消歧定位,替代 title;可配合集 mode' },
|
|
282
|
+
stage_code: { type: 'string', description: '明日方舟关卡代号,如 15-17、TW-ST-1' },
|
|
302
283
|
story_part: { type: 'string', enum: ['before', 'after', 'story'],
|
|
303
|
-
description: '
|
|
284
|
+
description: '多篇时消歧:before=行动前,after=行动后,story=幕间;单篇可省略' },
|
|
304
285
|
character_name: { type: 'string', description: '角色展示名;与 record_name 或 material 配合' },
|
|
305
|
-
record_name: { type: 'string', description: '
|
|
306
|
-
segment: { type: 'integer', description: '
|
|
286
|
+
record_name: { type: 'string', description: '干员密录名;多段时加 segment' },
|
|
287
|
+
segment: { type: 'integer', description: '密录段号,从 1 开始' },
|
|
307
288
|
material: { type: 'string', enum: ['profile', 'module', 'voice', 'skin', 'recruitment', 'potential'],
|
|
308
|
-
description: '
|
|
289
|
+
description: '资料类别:档案/模组/语音/时装/招聘合同/信物' },
|
|
309
290
|
game: { type: 'string', enum: ['arknights', 'endfield'],
|
|
310
|
-
description: '
|
|
311
|
-
activity_name: { type: 'string', description: '
|
|
312
|
-
collection_name: { type: 'string', description: '
|
|
291
|
+
description: '仅角色资料同名时限定游戏' },
|
|
292
|
+
activity_name: { type: 'string', description: '明日方舟活动展示名,配 mode=activity' },
|
|
293
|
+
collection_name: { type: 'string', description: '终末地任务集合展示名,配 mode=collection' },
|
|
313
294
|
content_types: { type: 'array', items: { type: 'string', enum: END_FIELD_STORY_CONTENT_TYPES },
|
|
314
|
-
description: '
|
|
315
|
-
line: { type: 'integer', description: '
|
|
316
|
-
position: { type: 'integer', description: '
|
|
295
|
+
description: '终末地集合的内容形式过滤;续页保留' },
|
|
296
|
+
line: { type: 'integer', description: '上下文中心行号;mode=document 时为起始行' },
|
|
297
|
+
position: { type: 'integer', description: '合集续读位置,从 page.continuation 复制' },
|
|
317
298
|
mode: { type: 'string', enum: ['document', 'activity', 'collection'],
|
|
318
|
-
description: '
|
|
299
|
+
description: 'document=单篇全文,activity=活动,collection=任务集合;document_uid、关卡、密录和角色资料默认单篇全文' },
|
|
319
300
|
section: { type: 'string', enum: WIKI_SECTION_VALUES, description: '读取 Wiki 标签字段' },
|
|
320
|
-
before: { type: 'integer', description: '
|
|
321
|
-
after: { type: 'integer', description: '
|
|
322
|
-
cursor: { type: 'string', description: '
|
|
323
|
-
data_version: { type: 'string', description: '
|
|
324
|
-
max_lines: { type: 'integer', description: '
|
|
325
|
-
max_chars: { type: 'integer', description: '
|
|
301
|
+
before: { type: 'integer', description: '前文行数,默认 3,上限 100' },
|
|
302
|
+
after: { type: 'integer', description: '后文行数,默认 3,上限 100' },
|
|
303
|
+
cursor: { type: 'string', description: '仅兼容旧会话游标' },
|
|
304
|
+
data_version: { type: 'string', description: '续页复制 page.continuation.data_version' },
|
|
305
|
+
max_lines: { type: 'integer', description: '输出行数上限,默认 100,最大 500' },
|
|
306
|
+
max_chars: { type: 'integer', description: '输出字符上限,默认 12000,最大 100000' },
|
|
326
307
|
},
|
|
327
308
|
additionalProperties: false,
|
|
328
309
|
}
|
|
@@ -473,12 +454,12 @@ function readPresentationMeta(_args, value) {
|
|
|
473
454
|
const TIMELINE_PARAMETERS = {
|
|
474
455
|
type: 'object', additionalProperties: false,
|
|
475
456
|
properties: {
|
|
476
|
-
query: { type: 'string', description: '
|
|
477
|
-
activity_names: { type: 'array', items: { type: 'string' }, description: '
|
|
478
|
-
entity_names: { type: 'array', items: { type: 'string' }, description: '
|
|
479
|
-
year_start: { type: 'integer', description: '
|
|
480
|
-
year_end: { type: 'integer', description: '
|
|
481
|
-
source_marker: { type: 'string', description: '
|
|
457
|
+
query: { type: 'string', description: '事件正文短语,最多 200 字符' },
|
|
458
|
+
activity_names: { type: 'array', items: { type: 'string' }, description: '活动展示名,最多 20 项' },
|
|
459
|
+
entity_names: { type: 'array', items: { type: 'string' }, description: '实体展示名,自动展开别名,最多 20 项' },
|
|
460
|
+
year_start: { type: 'integer', description: '起始年份,含边界' },
|
|
461
|
+
year_end: { type: 'integer', description: '结束年份,含边界' },
|
|
462
|
+
source_marker: { type: 'string', description: '反查来源时复制返回的年表出处标记' },
|
|
482
463
|
max_results: { type: 'integer', description: '最多返回事件数,默认 20,上限 100' },
|
|
483
464
|
},
|
|
484
465
|
}
|
|
@@ -486,20 +467,19 @@ const TIMELINE_PARAMETERS = {
|
|
|
486
467
|
const CLOUD_SEARCH_PARAMETERS = {
|
|
487
468
|
type: 'object', additionalProperties: false, required: ['query'],
|
|
488
469
|
properties: {
|
|
489
|
-
query: { type: 'string', description: '
|
|
470
|
+
query: { type: 'string', description: '包含主体、事件和所求关系的完整问题' },
|
|
490
471
|
games: { type: 'array', items: { type: 'string', enum: ['arknights', 'endfield'] },
|
|
491
|
-
description: '
|
|
472
|
+
description: '游戏范围;省略时查询全部启用游戏' },
|
|
492
473
|
depth: { type: 'string', enum: ['fast', 'standard', 'deep'],
|
|
493
|
-
description: '
|
|
474
|
+
description: '兼容字段,不改变检索路线;通常省略' },
|
|
494
475
|
evidence_policy: { type: 'string', enum: ['mixed', 'original_only'],
|
|
495
|
-
description: 'mixed
|
|
476
|
+
description: '默认 mixed 混合检索;original_only 仅官方剧情原文' },
|
|
496
477
|
options: {
|
|
497
478
|
type: 'object', additionalProperties: false,
|
|
498
|
-
description: '通常省略。只有用户记得一句原文大意且措辞可能不准时,选择官方剧情单句向量路线',
|
|
499
479
|
properties: {
|
|
500
480
|
search_intent: {
|
|
501
481
|
type: 'string', enum: ['single_sentence_search'],
|
|
502
|
-
description: '
|
|
482
|
+
description: '仅用户记得一句官方原文大意、措辞不准时使用',
|
|
503
483
|
},
|
|
504
484
|
},
|
|
505
485
|
},
|
|
@@ -508,13 +488,12 @@ const CLOUD_SEARCH_PARAMETERS = {
|
|
|
508
488
|
|
|
509
489
|
const CLOUD_INSPECT_PARAMETERS = {
|
|
510
490
|
type: 'object', additionalProperties: false,
|
|
511
|
-
description: '查看最近一次云端检索的指定区段;request_id 由运行时自动注入',
|
|
512
491
|
properties: {
|
|
513
492
|
games: { type: 'array', items: { type: 'string', enum: ['arknights', 'endfield'] },
|
|
514
|
-
description: '
|
|
493
|
+
description: '游戏过滤;省略时查看全部启用游戏' },
|
|
515
494
|
section: { type: 'string', enum: ['summary', 'candidates', 'selected_sources', 'events', 'trace_steps', 'answer_context'],
|
|
516
495
|
description: '默认 summary' },
|
|
517
|
-
cursor: { type: 'integer', description: '
|
|
496
|
+
cursor: { type: 'integer', description: '复制返回的 next_cursor' },
|
|
518
497
|
limit: { type: 'integer', description: '本页条数' },
|
|
519
498
|
channels: stringList('按渠道过滤(candidates/events)'),
|
|
520
499
|
query_variants: stringList('按查询变体过滤'),
|
|
@@ -529,7 +508,7 @@ const CLOUD_INSPECT_PARAMETERS = {
|
|
|
529
508
|
event_time_from: { type: 'string', description: '事件时间下界' },
|
|
530
509
|
event_time_to: { type: 'string', description: '事件时间上界' },
|
|
531
510
|
content_mode: { type: 'string', enum: ['none', 'preview', 'full'],
|
|
532
|
-
description: 'none
|
|
511
|
+
description: 'none=无正文,preview=摘要,full=本页全文' },
|
|
533
512
|
content_max_chars: { type: 'integer', description: '正文字符上限' },
|
|
534
513
|
},
|
|
535
514
|
}
|
|
@@ -867,7 +846,7 @@ function renderCloudInspect(_args, value) {
|
|
|
867
846
|
* @param {{
|
|
868
847
|
* releasesDir?: string,
|
|
869
848
|
* cacheShards?: number,
|
|
870
|
-
* uiSkin?: 'harness' | 'prts-agent',
|
|
849
|
+
* uiSkin?: 'harness' | 'prts-agent' | 'endfield-aic' | 'rhine-lab',
|
|
871
850
|
* download?: { order?: ('modelscope'|'site')[], siteBaseUrl?: string },
|
|
872
851
|
* cloud?: { baseUrl?: string, game?: 'arknights' | 'endfield', userId?: string, token?: string, timeoutMs?: number, maxResponseBytes?: number },
|
|
873
852
|
* }} [config]
|
|
@@ -875,7 +854,7 @@ function renderCloudInspect(_args, value) {
|
|
|
875
854
|
export async function apply(ctx, config = {}) {
|
|
876
855
|
// Cordis 传空配置时可能是 null 而非 undefined,默认参数不生效,需兜底
|
|
877
856
|
config = config ?? {}
|
|
878
|
-
//
|
|
857
|
+
// Host 常驻实例只做资料管理;PRTS 或自定义 Agent 预设按需注册工具。
|
|
879
858
|
const enableTools = config.registerTools !== false
|
|
880
859
|
const configuredHome = process.env.DSH_HOME?.trim()
|
|
881
860
|
const dshHome = resolve(configuredHome || join(homedir(), '.dsh'))
|
|
@@ -1216,9 +1195,12 @@ export async function apply(ctx, config = {}) {
|
|
|
1216
1195
|
const handle = acquireInvestigationStore(investigationCtx.storageDomain)
|
|
1217
1196
|
shared.investigations = handle.service
|
|
1218
1197
|
investigationCtx.effect(() => () => { if (shared.investigations === handle.service) shared.investigations = null; return handle.release() }, 'prts: investigations')
|
|
1219
|
-
if (enableTools) await investigationCtx.inject(['tools', 'systemPrompt'], toolCtx => mountInvestigationTools(toolCtx, handle.service))
|
|
1198
|
+
if (enableTools) await investigationCtx.inject(['tools', 'systemPrompt'], toolCtx => mountInvestigationTools(toolCtx, handle.service, shared))
|
|
1199
|
+
})
|
|
1200
|
+
if (enableTools) await ctx.inject(['tools', 'systemPrompt'], async toolCtx => {
|
|
1201
|
+
mountWebGuidance(toolCtx)
|
|
1202
|
+
await mountTools(toolCtx)
|
|
1220
1203
|
})
|
|
1221
|
-
if (enableTools) await ctx.inject(['tools', 'systemPrompt'], mountTools)
|
|
1222
1204
|
|
|
1223
1205
|
if (enableTools) {
|
|
1224
1206
|
await mkdir(releasesDir, { recursive: true })
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Shared read model for Agent recall; browser reads never acknowledge these notices. */
|
|
2
2
|
const copy = value => structuredClone(value)
|
|
3
3
|
const keyOf = ({ area, board_id = '', item_id = '' }) => JSON.stringify([area, board_id, item_id])
|
|
4
|
-
const metadata = ({ content, ...source }) => copy(source)
|
|
4
|
+
const metadata = ({ content, fragments, ...source }) => copy(source)
|
|
5
5
|
const fail = message => { throw Object.assign(new Error(message), { code: 'INVESTIGATION_NOT_FOUND' }) }
|
|
6
6
|
|
|
7
7
|
export function noteUserChange(portfolio, change) {
|
|
@@ -11,9 +11,15 @@ export function noteUserChange(portfolio, change) {
|
|
|
11
11
|
else portfolio.attention[index] = item
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// Old tool receipts predate the explicit flag. Browser imports use reserved call IDs.
|
|
15
|
+
export function receivedByAgent(source) {
|
|
16
|
+
return source.agentReceived ?? Boolean(source.agentRead || source.state === 'read'
|
|
17
|
+
|| source.callId && !['user', 'user-rack', 'user-inbox', 'legacy-user'].includes(source.callId))
|
|
18
|
+
}
|
|
19
|
+
|
|
14
20
|
export function reviewSummary(p) {
|
|
15
21
|
const pending = p?.attention || []
|
|
16
|
-
return { pending_count: pending.length, rack_count: p?.sources.filter(s => s
|
|
22
|
+
return { pending_count: pending.length, rack_count: p?.sources.filter(s => receivedByAgent(s) || p.rack.some(r => r.source_id === s.id)).length || 0,
|
|
17
23
|
user_saved_count: p?.rack.length || 0, changes: pending.slice(-20).map(copy),
|
|
18
24
|
...(pending.length > 20 ? { more: 'investigation_get(section="changes") 可分页读取其余提醒' } : {}) }
|
|
19
25
|
}
|
|
@@ -33,19 +39,20 @@ export function inspectPortfolio(p, args, boardSummary) {
|
|
|
33
39
|
}
|
|
34
40
|
if (section === 'rack' || section === 'sources') {
|
|
35
41
|
const saved = new Map(p.rack.map(item => [item.source_id, item]))
|
|
36
|
-
|
|
42
|
+
// Rack pages follow first arrival: new receipts append instead of shifting page offsets.
|
|
43
|
+
const rows = (section === 'sources' ? [...p.sources].reverse() : p.sources).filter(source => section === 'sources' || (saved_only ? saved.has(source.id) : receivedByAgent(source) || saved.has(source.id)))
|
|
37
44
|
.map(source => ({ ...metadata(source), saved: saved.has(source.id), ...(saved.has(source.id) ? { saved_at: saved.get(source.id).addedAt } : {}) }))
|
|
38
45
|
.filter(matches)
|
|
39
46
|
const result = page(rows), ids = new Set(result.items.map(item => item.id))
|
|
40
47
|
return respond({ total: result.total, nextCursor: result.nextCursor, sources: result.items, rack_revision: p.rackRevision, ...reviewSummary(p),
|
|
41
|
-
guidance: '
|
|
48
|
+
guidance: '档案架保留已交付给 Agent 的资料与手动收藏;入架不代表已读原文。目录包含摘要和定位信息,用 section="source", source_id 读取已保存正文。agentRead=false 的资料仍需原文核验。' },
|
|
42
49
|
item => section === 'rack' && item.area === 'rack' && ids.has(item.item_id))
|
|
43
50
|
}
|
|
44
51
|
if (section === 'source') {
|
|
45
52
|
const source = p.sources.find(s => s.id === source_id || s.sourceId === source_id)
|
|
46
53
|
if (!source) fail('来源不存在;先读取 rack 或 sources 取得 source_id')
|
|
47
54
|
return respond({ source: copy(source), saved: p.rack.some(item => item.source_id === source.id),
|
|
48
|
-
guidance: source.agentRead ? '
|
|
55
|
+
guidance: source.agentRead ? 'fragments 按阅读片段保留正文和行范围;content 为最后一次阅读。contentTruncated=true 时需要继续读取原文。' : '此资料尚未经过 Agent 原文核验;按 documentId/sourceRef/url 调用读取工具核验。' },
|
|
49
56
|
item => item.area === 'rack' && item.item_id === source.id)
|
|
50
57
|
}
|
|
51
58
|
if (!['board', 'clues', 'report', 'inbox'].includes(section)) fail('未知调查资料 section')
|
|
@@ -66,7 +73,8 @@ export function inspectPortfolio(p, args, boardSummary) {
|
|
|
66
73
|
if (section === 'report') {
|
|
67
74
|
const report = report_version === undefined ? board.reports.at(-1) : board.reports.find(r => r.version === report_version)
|
|
68
75
|
if (!report) fail('指定的调查报告尚不存在')
|
|
69
|
-
|
|
76
|
+
const { boardSnapshot, ...published } = report
|
|
77
|
+
return respond({ ...identity, report: { ...copy(published), sources: report.sources.map(metadata) } })
|
|
70
78
|
}
|
|
71
79
|
if (section === 'clues') {
|
|
72
80
|
const result = page(board.clues.filter(c => (!clue_id || c.id === clue_id) && matches(c)))
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/** Durable investigation portfolios, shared by Host UI and agent preset instances. */
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto'
|
|
3
|
-
import { z } from 'zod'
|
|
4
|
-
import { inspectPortfolio, noteUserChange, reviewSummary } from './investigation-review.js'
|
|
3
|
+
import { z } from '../lib/runtime/zod.js'
|
|
4
|
+
import { inspectPortfolio, noteUserChange, reviewSummary, receivedByAgent } from './investigation-review.js'
|
|
5
|
+
import { originalQuotation, sourceRecovery } from './investigation-validation.js'
|
|
5
6
|
|
|
6
7
|
const id = z.string().min(1).max(512)
|
|
7
8
|
const short = z.string().max(512)
|
|
@@ -10,8 +11,10 @@ const range = z.object({ start: z.number().int().positive(), end: z.number().int
|
|
|
10
11
|
export const clueKinds = ['excerpt', 'finding', 'time', 'relation', 'question', 'contrast']
|
|
11
12
|
const sourceSchema = z.object({
|
|
12
13
|
id, sourceId: id, title: short, kind: z.string().max(80), origin: z.enum(['local', 'cloud', 'web']),
|
|
13
|
-
state: z.enum(['found', 'read']), agentRead: z.boolean(), excerpt: z.string().max(4000),
|
|
14
|
+
state: z.enum(['found', 'read']), agentRead: z.boolean(), agentReceived: z.boolean().optional(), excerpt: z.string().max(4000),
|
|
14
15
|
content: z.string().max(64000).optional(), contentTruncated: z.boolean().optional(),
|
|
16
|
+
// Only trusted tool receipts populate these fragments; callers cannot import trust.
|
|
17
|
+
fragments: z.array(z.object({ content: z.string().max(64000), ranges: z.array(range).max(256), callId: short })).max(256).default([]),
|
|
15
18
|
documentId: z.string().max(2048).optional(), documentUid: z.string().max(512).optional(),
|
|
16
19
|
sourceRef: z.string().max(2048).optional(), dataVersion: z.string().max(128).optional(),
|
|
17
20
|
url: z.string().max(4096).optional(), lineStart: z.number().int().positive().optional(),
|
|
@@ -31,18 +34,20 @@ export const clueSchema = z.object({
|
|
|
31
34
|
userLayoutLocked: z.boolean(), mergedInto: id.optional(),
|
|
32
35
|
})
|
|
33
36
|
const relationSchema = z.object({ id, from: id, to: id, type: z.enum(['supports', 'contradicts', 'precedes', 'relates']), label: z.string().max(100) })
|
|
34
|
-
const reportSchema = z.object({ id, version: z.number().int().positive(), title: z.string().min(1).max(180),
|
|
35
|
-
summary: z.string().max(1000), markdown: z.string().min(1).max(120000), publishedAt: z.string(), runId: id,
|
|
36
|
-
basisKnowledgeRevision: z.number().int(), clues: z.array(clueSchema).max(128), sources: z.array(sourceSchema).max(1024) })
|
|
37
37
|
const inboxEntrySchema = z.object({ source_id: id, note: z.string().max(600),
|
|
38
38
|
status: z.enum(['pending', 'promoted', 'removed']), addedBy: z.enum(['agent', 'user']),
|
|
39
39
|
addedAt: z.string(), updatedAt: z.string(), removedBy: z.enum(['agent', 'user']).optional(), clueIds: z.array(id).max(128) })
|
|
40
|
-
const
|
|
40
|
+
const boardStateSchema = z.object({ id, title: z.string().min(1).max(120), objective: z.string().min(1).max(2000),
|
|
41
41
|
createdAt: z.string(), updatedAt: z.string(), knowledgeRevision: z.number().int().nonnegative(), layoutRevision: z.number().int().nonnegative(),
|
|
42
|
-
clues: z.array(clueSchema).max(512), relations: z.array(relationSchema).max(1024),
|
|
42
|
+
clues: z.array(clueSchema).max(512), relations: z.array(relationSchema).max(1024),
|
|
43
43
|
evidenceInbox: z.array(inboxEntrySchema).max(512).default([]), inboxRevision: z.number().int().nonnegative().default(0),
|
|
44
44
|
openQuestions: z.array(z.string().max(500)).max(32), nextClue: z.number().int().positive(),
|
|
45
45
|
})
|
|
46
|
+
const reportSchema = z.object({ id, version: z.number().int().positive(), title: z.string().min(1).max(180),
|
|
47
|
+
summary: z.string().max(1000), markdown: z.string().min(1).max(120000), publishedAt: z.string(), runId: id,
|
|
48
|
+
basisKnowledgeRevision: z.number().int(), clues: z.array(clueSchema).max(128), sources: z.array(sourceSchema).max(4096),
|
|
49
|
+
boardSnapshot: boardStateSchema.extend({ sources: z.array(sourceSchema).max(4096) }).optional() })
|
|
50
|
+
const boardSchema = boardStateSchema.extend({ reports: z.array(reportSchema).max(128) })
|
|
46
51
|
const runSchema = z.object({ id, boardId: id, turnId: short, reason: z.string().max(500), startedAt: z.string(),
|
|
47
52
|
status: z.enum(['running', 'completed', 'interrupted', 'error']), endedAt: z.string().optional() })
|
|
48
53
|
export const portfolioSchema = z.object({ schemaVersion: z.literal(1), sessionId: id, commitSeq: z.number().int().nonnegative(),
|
|
@@ -75,7 +80,7 @@ function requireRun(portfolio, boardId, runId, turnId) {
|
|
|
75
80
|
requireValue(run?.boardId === boardId && run.status === 'running' && run.turnId === String(turnId ?? ''), '本次运行未绑定到这块调查板,或已经结束;请重新选择调查', 'INVESTIGATION_RUN_MISMATCH')
|
|
76
81
|
return run
|
|
77
82
|
}
|
|
78
|
-
function sourceIdentity(source) { return source.documentUid || source.documentId ? JSON.stringify([source.dataVersion || '', source.documentUid || source.documentId]) : source.url ? `url:${source.url.replace(/#.*$/, '')}` : source.sourceId || source.id }
|
|
83
|
+
function sourceIdentity(source) { return source.documentUid || source.documentId ? JSON.stringify([source.dataVersion || '', source.documentUid || source.documentId]) : source.url ? `url:${source.url.replace(/#.*$/, '')}` : `id:${JSON.stringify([source.dataVersion || '', source.sourceId || source.id])}` }
|
|
79
84
|
function uniqueRanges(values) {
|
|
80
85
|
return [...new Map(values.filter(r => Number.isSafeInteger(r?.start) && r.start > 0 && Number.isSafeInteger(r.end) && r.end >= r.start).map(r => [`${r.start}:${r.end}`, { start: r.start, end: r.end }])).values()].slice(-256)
|
|
81
86
|
}
|
|
@@ -84,7 +89,7 @@ function normalizeSource(source, alias, callId, trusted) {
|
|
|
84
89
|
const body = bounded(source.content, 64000)
|
|
85
90
|
return sourceSchema.parse({ id: alias, sourceId: bounded(source.sourceId || source.id, 512), title: bounded(source.title),
|
|
86
91
|
kind: bounded(source.kind || 'source', 80), origin: ['local', 'cloud', 'web'].includes(source.origin) ? source.origin : 'local',
|
|
87
|
-
state: read ? 'read' : 'found', agentRead: read, excerpt: bounded(source.excerpt, 4000), ...(body ? { content: body } : {}),
|
|
92
|
+
state: read ? 'read' : 'found', agentRead: read, agentReceived: trusted, excerpt: bounded(source.excerpt, 4000), ...(body ? { content: body } : {}),
|
|
88
93
|
contentTruncated: !!source.contentTruncated || typeof source.content === 'string' && source.content.length > 64000,
|
|
89
94
|
...Object.fromEntries(['documentId', 'documentUid', 'sourceRef', 'dataVersion'].filter(key => typeof source[key] === 'string' && source[key]).map(key => [key, bounded(source[key], key === 'dataVersion' ? 128 : key === 'documentUid' ? 512 : 2048)])),
|
|
90
95
|
...(safeUrl(source.url) ? { url: safeUrl(source.url) } : {}),
|
|
@@ -94,19 +99,40 @@ function normalizeSource(source, alias, callId, trusted) {
|
|
|
94
99
|
callId: bounded(callId), updatedAt: now(), contentHash: hash(body || bounded(source.excerpt, 4000)),
|
|
95
100
|
})
|
|
96
101
|
}
|
|
102
|
+
function readingFragments(source) {
|
|
103
|
+
if (!source?.agentRead) return []
|
|
104
|
+
if (source.fragments?.length) return source.fragments
|
|
105
|
+
const content = source.content || source.excerpt
|
|
106
|
+
if (!content) return []
|
|
107
|
+
// A legacy body belongs to its last read page, not to the union of past ranges.
|
|
108
|
+
const ranges = source.lineStart ? [{ start: source.lineStart, end: source.lineEnd || source.lineStart }]
|
|
109
|
+
: source.ranges?.length === 1 ? source.ranges : []
|
|
110
|
+
return [{ content, ranges, callId: source.callId }]
|
|
111
|
+
}
|
|
112
|
+
function mergeReadingFragments(previous, item) {
|
|
113
|
+
const fragments = [...new Map([...readingFragments(previous), ...readingFragments(item)]
|
|
114
|
+
.map(f => [hash([f.content, f.ranges]), f])).values()]
|
|
115
|
+
requireValue(fragments.length <= 256 && fragments.reduce((n, f) => n + f.content.length, 0) <= 512000,
|
|
116
|
+
'这份资料的已读片段已达保存上限;已有片段保留,本次阅读尚未保存', 'INVESTIGATION_CAPACITY')
|
|
117
|
+
return fragments
|
|
118
|
+
}
|
|
119
|
+
function sourceMetadata({ content, fragments, ...source }) { return clone(source) }
|
|
97
120
|
function ingestSources(portfolio, incoming, callId, trusted) {
|
|
98
121
|
const aliases = {}
|
|
99
122
|
for (const raw of incoming.slice(0, 128)) {
|
|
100
123
|
if (!raw?.title || !(raw.id || raw.sourceId)) continue
|
|
101
124
|
const identity = sourceIdentity(raw)
|
|
102
|
-
const previous = portfolio.sources.find(source => sourceIdentity(source) === identity
|
|
125
|
+
const previous = portfolio.sources.find(source => sourceIdentity(source) === identity
|
|
126
|
+
|| !raw.documentUid && !raw.documentId && !raw.url && source.dataVersion === raw.dataVersion
|
|
127
|
+
&& source.sourceId === (raw.sourceId || raw.id))
|
|
103
128
|
if (!previous) requireValue(portfolio.sources.length < 4096, '当前会话来源记录已满;已保存的资料不会删除', 'INVESTIGATION_CAPACITY')
|
|
104
129
|
const item = normalizeSource(raw, previous?.id || `R${String(portfolio.nextSource++).padStart(4, '0')}`, callId, trusted)
|
|
130
|
+
item.fragments = mergeReadingFragments(previous, item)
|
|
105
131
|
aliases[raw.sourceId || raw.id] = item.id
|
|
106
132
|
if (previous) {
|
|
107
133
|
// A later search hit must not turn a read source back into a summary.
|
|
108
134
|
const preferOld = previous.agentRead && !item.agentRead
|
|
109
|
-
Object.assign(previous, { ...item, ...preferOld ? { state: previous.state, agentRead: true,
|
|
135
|
+
Object.assign(previous, { ...item, agentReceived: receivedByAgent(previous) || item.agentReceived, ...preferOld ? { state: previous.state, agentRead: true,
|
|
110
136
|
excerpt: previous.excerpt, content: previous.content, contentTruncated: previous.contentTruncated, contentHash: previous.contentHash,
|
|
111
137
|
callId: previous.callId } : {}, ranges: uniqueRanges([...previous.ranges, ...item.ranges]) })
|
|
112
138
|
} else portfolio.sources.push(item)
|
|
@@ -117,19 +143,37 @@ function resolveReferences(portfolio, incoming) {
|
|
|
117
143
|
requireValue(Array.isArray(incoming) && incoming.length <= 32, 'sources 必须是最多 32 项的来源引用列表')
|
|
118
144
|
return incoming.map(raw => {
|
|
119
145
|
const ref = referenceSchema.parse(raw)
|
|
120
|
-
const
|
|
146
|
+
const direct = portfolio.sources.find(row => row.id === ref.source_id)
|
|
147
|
+
const aliases = direct ? [direct] : portfolio.sources.filter(row => row.sourceId === ref.source_id || row.documentUid === ref.source_id)
|
|
148
|
+
requireValue(aliases.length <= 1, `来源 ${ref.source_id} 对应多个数据版本,请使用来源目录中的 R 编号`, 'INVESTIGATION_SOURCE_AMBIGUOUS')
|
|
149
|
+
const source = aliases[0]
|
|
121
150
|
requireValue(source, `来源 ${ref.source_id} 未在当前会话返回;用 investigation_get 的 sources 部分查看有效 ID`, 'INVESTIGATION_SOURCE_MISSING')
|
|
122
151
|
if (ref.line_start || ref.line_end) {
|
|
123
152
|
requireValue(ref.line_start && ref.line_end && ref.line_end >= ref.line_start, '引用行范围不完整')
|
|
124
|
-
|
|
153
|
+
if (!source.agentRead || !source.ranges.some(r => r.start <= ref.line_start && r.end >= ref.line_end)) {
|
|
154
|
+
throw investigationError('INVESTIGATION_SOURCE_UNREAD', '引用的行范围不在实际已读范围内', sourceRecovery(source, ref))
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (ref.quote) {
|
|
158
|
+
const fragments = readingFragments(source)
|
|
159
|
+
const bodies = fragments.length ? fragments.filter(f => !ref.line_start
|
|
160
|
+
|| f.ranges.some(r => r.start <= ref.line_start && r.end >= ref.line_end)).map(f => f.content)
|
|
161
|
+
: [source.content || source.excerpt]
|
|
162
|
+
let original = null
|
|
163
|
+
for (const body of bodies) {
|
|
164
|
+
original = originalQuotation(body, ref.quote)
|
|
165
|
+
if (original !== null) break
|
|
166
|
+
}
|
|
167
|
+
if (original === null) throw investigationError('INVESTIGATION_QUOTE_MISMATCH',
|
|
168
|
+
`来源 ${source.id} 的摘录不在保存的对应片段内;请读取对应原文或修正引文`, sourceRecovery(source, ref))
|
|
169
|
+
ref.quote = original
|
|
125
170
|
}
|
|
126
|
-
if (ref.quote) requireValue((source.content || source.excerpt).includes(ref.quote), '摘录不在保存的返回片段内;请先读取对应原文或去掉直接引文', 'INVESTIGATION_QUOTE_MISMATCH')
|
|
127
171
|
return { ...ref, source_id: source.id }
|
|
128
172
|
})
|
|
129
173
|
}
|
|
130
174
|
function cluePatch(portfolio, board, change, actor, mapping) {
|
|
131
175
|
const previous = change.id ? board.clues.find(row => row.id === change.id) : undefined
|
|
132
|
-
requireValue(!change.id || previous, `线索 ${change.id}
|
|
176
|
+
requireValue(!change.id || previous, `线索 ${change.id} 不存在;新建请省略 id、使用 client_key,后续更新使用 returned created_ids`, 'INVESTIGATION_CLUE_MISSING')
|
|
133
177
|
const action = change.action || 'upsert'
|
|
134
178
|
if (action === 'layout') {
|
|
135
179
|
requireValue(previous, '布局修改需要已有线索')
|
|
@@ -236,9 +280,19 @@ export function createInvestigationStore(storageDomain) {
|
|
|
236
280
|
tables: { portfolios: { valueSchema: portfolioSchema } } })
|
|
237
281
|
const table = domain.table('portfolios')
|
|
238
282
|
if (!table.get('state')) await table.put('state', freshPortfolio(sessionId))
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
283
|
+
// Upgrade provenance without discarding old tool deliveries or trusting browser imports.
|
|
284
|
+
// Receipt aliases also recover a found source later edited from the browser.
|
|
285
|
+
if (table.get('state').sources.some(source => source.agentReceived === undefined)
|
|
286
|
+
|| table.get('state').runs.some(run => run.status === 'running')) await table.update('state', current => {
|
|
287
|
+
const next = portfolioSchema.parse(clone(current))
|
|
288
|
+
const delivered = new Set(Object.values(next.mutations).flatMap(({ result }) =>
|
|
289
|
+
result?.sources && !Array.isArray(result.sources) ? Object.values(result.sources) : []))
|
|
290
|
+
let upgraded = false
|
|
291
|
+
for (const source of next.sources) if (source.agentReceived === undefined) {
|
|
292
|
+
source.agentReceived = delivered.has(source.id) || receivedByAgent(source); upgraded = true
|
|
293
|
+
}
|
|
294
|
+
if (upgraded) next.rackRevision++
|
|
295
|
+
// No live run survives a process/service restart. Preserve its partial work.
|
|
242
296
|
for (const run of next.runs) if (run.status === 'running') { run.status = 'interrupted'; run.endedAt = now() }
|
|
243
297
|
next.commitSeq++; return next
|
|
244
298
|
})
|
|
@@ -292,12 +346,12 @@ export function createInvestigationStore(storageDomain) {
|
|
|
292
346
|
const offset = Math.max(0, Number(cursor) || 0), count = Math.min(128, Math.max(1, Number(limit) || 20))
|
|
293
347
|
return { commitSeq: p.commitSeq, rackRevision: p.rackRevision, workingBoardId: working?.boardId || null, boards: rows.slice(offset, offset + count).map(b => boardSummary(b, p)), nextCursor: rows.length > offset + count ? offset + count : null }
|
|
294
348
|
}
|
|
295
|
-
if (section === 'sources') return { commitSeq: p.commitSeq, sources: p.sources.slice(-Math.min(128, Number(limit) || 40)).map(
|
|
349
|
+
if (section === 'sources') return { commitSeq: p.commitSeq, sources: p.sources.slice(-Math.min(128, Number(limit) || 40)).map(sourceMetadata) }
|
|
296
350
|
const board = boardOf(p, board_id)
|
|
297
351
|
if (section === 'inbox') {
|
|
298
352
|
const items = pendingEvidence(board), offset = Math.max(0, Number(cursor) || 0), count = Math.min(128, Math.max(1, Number(limit) || 20))
|
|
299
353
|
return { board_id: board.id, title: board.title, inbox_revision: board.inboxRevision, pending_count: items.length,
|
|
300
|
-
items: items.slice(offset, offset + count).map(item => { const
|
|
354
|
+
items: items.slice(offset, offset + count).map(item => { const source = p.sources.find(s => s.id === item.source_id); return { ...clone(item), source: sourceMetadata(source) } }),
|
|
301
355
|
nextCursor: items.length > offset + count ? offset + count : null }
|
|
302
356
|
}
|
|
303
357
|
return { commitSeq: p.commitSeq, workingBoardId: working?.boardId || null, ...boardSummary(board, p), board: clone(board),
|
|
@@ -375,7 +429,18 @@ export function createInvestigationStore(storageDomain) {
|
|
|
375
429
|
requireValue(board.knowledgeRevision === args.expected_revision, '调查内容已更新,请用 investigation_get 重读后提交', 'INVESTIGATION_CONFLICT')
|
|
376
430
|
requireValue(Array.isArray(args.clues || []) && (args.clues || []).length <= 20, '每次更新最多 20 条线索')
|
|
377
431
|
const mapping = {}, updated = []
|
|
378
|
-
|
|
432
|
+
const issues = []
|
|
433
|
+
for (const [index, change] of (args.clues || []).entries()) {
|
|
434
|
+
try { updated.push(cluePatch(p, board, change, 'agent', mapping).id) }
|
|
435
|
+
catch (error) {
|
|
436
|
+
if (!error.code && !(error instanceof z.ZodError)) throw error
|
|
437
|
+
issues.push({ clue_index: index, clue: change.id || change.client_key || change.title,
|
|
438
|
+
code: error.code || 'INVALID_INVESTIGATION', message: error.message, ...error.details })
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (issues.length) throw investigationError(issues[0].code,
|
|
442
|
+
`本批更新未保存,revision 仍为 ${args.expected_revision}。修正以下线索后重试;不要重复提交未修正的整批正文。\n${JSON.stringify({ issues })}`,
|
|
443
|
+
{ applied: false, revision: args.expected_revision, issues })
|
|
379
444
|
const resolveId = value => mapping[value] || value
|
|
380
445
|
requireValue(Array.isArray(args.relations || []) && (args.relations || []).length <= 32, '每次更新最多 32 条关系')
|
|
381
446
|
for (const raw of args.relations || []) {
|
|
@@ -445,10 +510,13 @@ export function createInvestigationStore(storageDomain) {
|
|
|
445
510
|
requireValue(board.knowledgeRevision === args.expected_revision, '有新线索尚未纳入报告,请重读调查后发布', 'INVESTIGATION_CONFLICT')
|
|
446
511
|
requireValue(Array.isArray(args.clue_ids) && args.clue_ids.length <= 128, 'clue_ids 必须列出报告使用的线索')
|
|
447
512
|
const cited = [...new Set(args.clue_ids)].map(value => { const clue = board.clues.find(c => c.id === value); requireValue(clue && !['retracted', 'superseded'].includes(clue.status), `报告引用的线索 ${value} 已不可用`); return clone(clue) })
|
|
448
|
-
const sources = p.sources.filter(s => cited.some(c => c.sources.some(ref => ref.source_id === s.id))).map(
|
|
513
|
+
const sources = p.sources.filter(s => cited.some(c => c.sources.some(ref => ref.source_id === s.id))).map(sourceMetadata)
|
|
514
|
+
const boardSnapshot = boardStateSchema.parse(clone(board))
|
|
515
|
+
boardSnapshot.sources = p.sources.filter(s => board.clues.some(c => c.sources.some(ref => ref.source_id === s.id))
|
|
516
|
+
|| board.evidenceInbox.some(item => item.source_id === s.id)).map(sourceMetadata)
|
|
449
517
|
const report = reportSchema.parse({ id: `report-${randomUUID()}`, version: board.reports.length + 1, title: args.title,
|
|
450
518
|
summary: args.summary, markdown: args.markdown, publishedAt: now(), runId: args.run_id,
|
|
451
|
-
basisKnowledgeRevision: board.knowledgeRevision, clues: cited, sources })
|
|
519
|
+
basisKnowledgeRevision: board.knowledgeRevision, clues: cited, sources, boardSnapshot })
|
|
452
520
|
board.reports.push(report); board.updatedAt = now()
|
|
453
521
|
return { board_id: board.id, report_id: report.id, version: report.version, title: report.title, revision: board.knowledgeRevision,
|
|
454
522
|
message: '调查报告已保存到线索板中央。向用户说明结果即可,不必再次生成一份不同的全文。' }
|
|
@@ -533,7 +601,11 @@ export function createInvestigationStore(storageDomain) {
|
|
|
533
601
|
},
|
|
534
602
|
recordSources(sessionId, sources, callId) {
|
|
535
603
|
if (!sources.length) return Promise.resolve()
|
|
536
|
-
const pending = mutate(sessionId, `sources:${callId}`, sources, p =>
|
|
604
|
+
const pending = mutate(sessionId, `sources:${callId}`, sources, p => {
|
|
605
|
+
const aliases = ingestSources(p, sources, callId, true)
|
|
606
|
+
if (Object.keys(aliases).length) p.rackRevision++
|
|
607
|
+
return { sources: aliases }
|
|
608
|
+
})
|
|
537
609
|
const previous = pendingReceipts.get(sessionId) || Promise.resolve()
|
|
538
610
|
const settled = Promise.allSettled([previous, pending]).then(() => {})
|
|
539
611
|
pendingReceipts.set(sessionId, settled)
|