prts-terrarchive 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.en.md +100 -23
  2. package/README.md +82 -18
  3. package/bin/build-mainline-catalog.py +195 -0
  4. package/contracts/corpus_i18n_v1.schema.json +10 -12
  5. package/contracts/corpus_tools_v1.schema.json +30 -1
  6. package/docs/endfield-mainline-navigation.md +45 -0
  7. package/lib/client.js +157 -27
  8. package/lib/skins/common.css +12 -2
  9. package/lib/skins/endfield-aic.css +1 -1
  10. package/lib/skins/prts-agent.css +9 -7
  11. package/package.json +7 -3
  12. package/presets/definition.js +1 -1
  13. package/presets/prts/preset.yml +1 -1
  14. package/resources/endfield-mainline.json +4632 -0
  15. package/skills/prts-composition/SKILL.md +3 -1
  16. package/skills/prts-investigation/SKILL.md +19 -113
  17. package/skills/prts-investigation/references/board-guide.md +131 -0
  18. package/skills/prts-retrieval/SKILL.md +4 -2
  19. package/skills/prts-retrieval/references/cloud-tools.md +3 -1
  20. package/skills/prts-retrieval/references/local-tools.md +3 -3
  21. package/skills/prts-retrieval/references/module-arknights.md +1 -1
  22. package/skills/prts-retrieval/references/module-dual.md +1 -1
  23. package/skills/prts-retrieval/references/module-endfield.md +3 -0
  24. package/skills/prts-retrieval/references/tools-runtime.md +22 -10
  25. package/src/cloud-projection.js +23 -2
  26. package/src/cloud.js +24 -0
  27. package/src/entity-recognizer.js +26 -5
  28. package/src/entity-routing.js +18 -4
  29. package/src/evidence-state.js +4 -2
  30. package/src/i18n.js +79 -30
  31. package/src/index.js +346 -162
  32. package/src/installer.js +86 -14
  33. package/src/investigation-tools.js +40 -17
  34. package/src/navigation.js +342 -0
  35. package/src/read-args.js +86 -0
  36. package/src/read.js +66 -15
  37. package/src/search-projection.js +154 -19
  38. package/src/search.js +177 -21
  39. package/src/skill.js +25 -16
  40. package/src/skin-capabilities.js +28 -0
  41. package/src/store.js +127 -3
  42. package/src/timeline.js +86 -2
  43. package/src/tool-args.js +53 -0
  44. package/src/ui.js +158 -34
  45. package/src/web-guidance.js +14 -2
@@ -96,7 +96,7 @@ export class EntityAliasAutomaton {
96
96
  // 单字别名在普通句子中误报率过高;仅当整个输入就是该字时保留。
97
97
  if (output.length === 1 && characters.length > 1) continue
98
98
  found.push({ canonical: output.canonical, alias: output.alias, games: output.games,
99
- start, end: index + 1 })
99
+ curated: Boolean(this.curated?.has(normalized(output.alias))), start, end: index + 1 })
100
100
  }
101
101
  }
102
102
  found.sort((left, right) => left.start - right.start
@@ -130,6 +130,7 @@ export async function prepareEntityRecognition(store, { signal } = {}) {
130
130
  }
131
131
  store._aliasGroups ||= groups
132
132
  state.automaton = new EntityAliasAutomaton(groups)
133
+ state.automaton.curated = store._curatedAliases || new Set()
133
134
  return state.automaton
134
135
  })
135
136
  .catch((error) => {
@@ -159,7 +160,7 @@ export function createEntityRecognizer(store) {
159
160
  if (signal?.aborted) throw cancelledError()
160
161
  const catalog = await loadEntityRelationCatalog(store)
161
162
  if (signal?.aborted) throw cancelledError()
162
- return detectEntities(automaton, catalog, text)
163
+ return withAliasConflicts(store, detectEntities(automaton, catalog, text), text)
163
164
  }
164
165
  }
165
166
  }
@@ -186,13 +187,26 @@ function detectEntities(automaton, catalog, text) {
186
187
  relation_hints: relationHints }
187
188
  }
188
189
 
190
+ /** 本轮问题里出现的冲突别名字面:不做实体归属,只在上下文里点出数据缺陷。 */
191
+ function aliasConflictNotes(store, text) {
192
+ const conflicts = store?._aliasConflicts
193
+ if (!conflicts?.length) return []
194
+ const normalizedText = normalized(text)
195
+ return conflicts.filter((item) => normalizedText.includes(normalized(item.alias)))
196
+ }
197
+
198
+ function withAliasConflicts(store, result, text) {
199
+ const conflicts = aliasConflictNotes(store, text)
200
+ return conflicts.length ? { ...result, alias_conflicts: conflicts } : result
201
+ }
202
+
189
203
  /** 仅在后台预热已经完成时同步识别;prompt assembly 不能等待异步 I/O。 */
190
204
  function detectPreparedEntities(store, text) {
191
205
  const state = preparedRecognizers.get(store)
192
206
  const catalog = store?._endfieldRelationCatalog
193
207
  if (!store?.loaded || !state?.automaton || state.dataVersion !== store.dataVersion
194
208
  || catalog?.dataVersion !== store.dataVersion) return null
195
- return detectEntities(state.automaton, catalog.value, text)
209
+ return withAliasConflicts(store, detectEntities(state.automaton, catalog.value, text), text)
196
210
  }
197
211
 
198
212
  function messageText(message) {
@@ -204,8 +218,9 @@ function messageText(message) {
204
218
  function recognitionContext(result, enabledGames) {
205
219
  const aliases = new Map()
206
220
  for (const match of result.matches) {
207
- const value = aliases.get(match.canonical) || { aliases: new Set(), games: new Set() }
221
+ const value = aliases.get(match.canonical) || { aliases: new Set(), games: new Set(), curated: false }
208
222
  value.aliases.add(match.alias)
223
+ if (match.curated) value.curated = true
209
224
  for (const game of match.games || []) value.games.add(game)
210
225
  aliases.set(match.canonical, value)
211
226
  }
@@ -226,19 +241,23 @@ function recognitionContext(result, enabledGames) {
226
241
  }
227
242
  const lines = [...aliases].map(([canonical, value]) => {
228
243
  const ownership = [...value.games].map((game) => GAME_LABELS[game]).filter(Boolean)
244
+ const basis = value.curated ? ' · 依据:人工别名表' : ''
229
245
  return `- ${promptData(canonical)} — ${ownership.length ? ownership.join(' + ') : '归属未确定'}` +
230
- `(问题中命中:${[...value.aliases].map((alias) => promptData(alias)).join('、')})`
246
+ `(问题中命中:${[...value.aliases].map((alias) => promptData(alias)).join('、')}${basis})`
231
247
  })
232
248
  const enabled = enabledGames.map((game) => GAME_LABELS[game]).filter(Boolean)
233
249
  const relationLines = (result.relation_hints || []).map((hint) =>
234
250
  hint.kind === 'retraveler_memory_prototype'
235
251
  ? `- ${promptData(hint.endfield_name)}:再旅者;泰拉记忆原型=${promptData(hint.terra_memory_prototype || '未登记')}。检索词:${(Array.isArray(hint.query_terms) ? hint.query_terms : []).map((term) => promptData(term)).join('、')}。两者不是别名。`
236
252
  : `- ${promptData(hint.endfield_name)} / ${promptData(hint.arknights_name)}:仅登记外观相似;现有剧情没有关系证据,不得推断为再旅者或记忆原型。`)
253
+ const conflictLines = (result.alias_conflicts || []).map((item) =>
254
+ `- 别名冲突:${promptData(item.alias)} 同时被 ${item.canonicals.map((name) => promptData(name)).join('、')} 声明为别名。本轮已忽略该别名、不归属任何实体;需要这些实体时请直呼规范名或分别检索。`)
237
255
  return [
238
256
  '<prts:retrieval-context>',
239
257
  `当前搭载资料:${enabled.join('、') || '无'}。`,
240
258
  ...(lines.length ? ['用户问题中识别到的规范实体与游戏归属:', ...lines] : []),
241
259
  ...relationLines,
260
+ ...conflictLines,
242
261
  '</prts:retrieval-context>',
243
262
  ].join('\n')
244
263
  }
@@ -302,6 +321,8 @@ export function applyEntityRecognition(ctx, store, shared = null) {
302
321
  ctx.systemPrompt.context({
303
322
  name: 'prts-terrarchive:retrieval-entities',
304
323
  order: 1000,
324
+ // Recognized entity names are user content: never resolve {{name}} in them.
325
+ interpolate: false,
305
326
  text: ({ scope }) => {
306
327
  const state = turns.get(scope)
307
328
  return state?.context && hasVisibleRetrievalSkill(scope, []) ? state.context : ''
@@ -30,8 +30,11 @@ function mergeRelationCatalog(fallback, installed) {
30
30
  /** 资料包内关系表优先,插件内置表只为旧包提供最小兼容路由。 */
31
31
  export async function loadEntityRelationCatalog(store) {
32
32
  const dataVersion = store?.dataVersion ?? 'no-store'
33
+ const scopeFingerprint = store?.scopeFingerprint ?? null
34
+ const installationId = store?.installationId ?? null
33
35
  const cached = store?._endfieldRelationCatalog
34
- if (cached?.dataVersion === dataVersion) return cached.value
36
+ if (cached?.dataVersion === dataVersion && cached.scopeFingerprint === scopeFingerprint
37
+ && cached.installationId === installationId) return cached.value
35
38
  let installed = {}
36
39
  try {
37
40
  const loaded = await store?.getDocumentByPath?.('config/retravelers.json')
@@ -39,7 +42,7 @@ export async function loadEntityRelationCatalog(store) {
39
42
  installed = JSON.parse((record?.lines || []).map((line) => line.text).join('\n') || '{}')
40
43
  } catch { installed = {} }
41
44
  const value = mergeRelationCatalog(await bundledRelations(), installed)
42
- if (store) store._endfieldRelationCatalog = { dataVersion, value }
45
+ if (store) store._endfieldRelationCatalog = { dataVersion, scopeFingerprint, installationId, value }
43
46
  return value
44
47
  }
45
48
 
@@ -72,11 +75,22 @@ export function relevantRetravelerRelations(catalog, ...values) {
72
75
  }))
73
76
  }
74
77
 
75
- /** 仅双模块启用时,把人工审校关系作为独立附属字段挂到检索结果。 */
78
+ /**
79
+ * 仅双模块启用、且本次请求本身覆盖两款游戏时,把人工审校关系作为独立附属字段挂到
80
+ * 检索结果。只按请求参数与命中文档标题判断相关性:命中正文里顺带出现某个名字
81
+ * (如语音里提到“艾雅法拉”)不代表用户在问跨游戏关系,挂上只会制造噪声。
82
+ */
76
83
  export async function attachRetravelerRelations(store, response, request, enabledGames) {
77
84
  if (!enabledGames?.includes('arknights') || !enabledGames?.includes('endfield')
78
85
  || response?.status === 'error' || response?.error) return response
86
+ const requestedGames = Array.isArray(request?.games) && request.games.length ? request.games : null
87
+ if (requestedGames && !(requestedGames.includes('arknights') && requestedGames.includes('endfield'))) {
88
+ return response
89
+ }
79
90
  const catalog = await loadEntityRelationCatalog(store)
80
- const relations = relevantRetravelerRelations(catalog, request, response?.documents)
91
+ const { after: _after, cursor: _cursor, ...requestFields } = request || {}
92
+ const titles = (Array.isArray(response?.documents) ? response.documents : [])
93
+ .map((document) => document?.title).filter(Boolean)
94
+ const relations = relevantRetravelerRelations(catalog, requestFields, titles)
81
95
  return relations.length ? { ...response, retraveler_relations: relations } : response
82
96
  }
@@ -293,7 +293,8 @@ export function replayCoveredRead(state, requested, contract) {
293
293
  stats: { elapsed_ms: 0, scanned_documents: 0, scanned_lines: 0, returned_chars: 0,
294
294
  estimated_input_tokens: 0, truncated: hasMore, reused_local_evidence: true },
295
295
  warnings: [],
296
- guidance: '该范围已在当前模型可见的上文工具结果中完整出现;这里复用已核验的本地原文,不要再次读取相同范围。',
296
+ guidance: '该范围已在当前模型可见的上文工具结果中完整出现;这里复用已核验的本地原文,不要再次读取相同范围。'
297
+ + '若上文其实已经看不到这些行(被压缩或折叠),传 reuse:false 重发本页全文。',
297
298
  }
298
299
  }
299
300
 
@@ -327,6 +328,7 @@ export function reuseReadCoverage(response, plan) {
327
328
  },
328
329
  warnings: [...(response.warnings || []), { code: 'PARTIAL_READ_REUSED',
329
330
  message: `本页已有 ${plan.reusedRanges.length} 段经过核验,本次只返回新读取行。` }],
330
- guidance: '本页与当前模型可见的上文原文部分重叠;这里只返回新读取行,coverage.reused_ranges 对应旧行就在上方可见工具结果中。',
331
+ guidance: '本页与当前模型可见的上文原文部分重叠;这里只返回新读取行,coverage.reused_ranges 对应旧行就在上方可见工具结果中。'
332
+ + '若上文其实已经看不到这些行(被压缩或折叠),传 reuse:false 重发本页全文。',
331
333
  }
332
334
  }
package/src/i18n.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** Official localizations: exact source identities, independent of reading coverage. */
2
2
  import { createHash } from 'node:crypto'
3
3
  import { assertCorpusVersion, corpusVersionSnapshot, documentGame, documentUid,
4
- naturalDocumentTitle } from './store.js'
4
+ naturalDocumentTitle, DATA_VERSION_REF_PATTERN, dataVersionMatches, shortDataVersion } from './store.js'
5
5
  import { LOCALIZATION_LANGUAGES } from './localization-format.js'
6
6
 
7
7
  const PACK = 'endfield_official_game'
@@ -23,35 +23,29 @@ for (const [locale, code] of Object.entries({ 'en-us': 'EN', 'ja-jp': 'JP', 'ko-
23
23
  const fault = (code, message) => Object.assign(new Error(message), { code,
24
24
  retryable: code === 'PACKAGE_VERSION_MISMATCH' })
25
25
 
26
- export const I18N_DESCRIPTION = [
27
- '查询终末地官方本地化文本,返回其他语言原文;不生成翻译。',
28
- '已有资料时优先使用完整 title 或 document_uid,可加 line;只有原句/名称时用 query 反查,source_language 默认 CN。',
29
- 'languages 指定目标语言(EN 英语、JP 日语、KR 韩语、TC 繁中等)。query 默认 exact,片段用 match_mode=literal。',
30
- '相同原文可能对应多个不同译文,按来源消歧。角色档案按整条记录、档案库按整篇内容块对齐,中文行号不是目标语言行号。',
31
- '默认不显示内部文本 ID;仅核验时 include_ids=true,后续可用 text_ids 精确查询。续页原样提交 page.continuation。',
32
- ].join(' ')
26
+ export const I18N_DESCRIPTION = '查询终末地官方本地化原文。query/title/document_uid/text_ids 四选一;同文异译按来源消歧,中文行号不是译文行号。续页原样提交返回的“续页”调用。原文与各语言译文完全相同的条目会合并为一组并汇总出处。'
33
27
 
34
28
  export const I18N_PARAMETERS = {
35
29
  type: 'object', additionalProperties: false, required: ['languages'],
36
30
  properties: {
37
31
  game: { type: 'string', enum: ['endfield'], description: '当前支持终末地官方资料' },
38
- query: { type: 'string', description: '名称或原句;仅有片段时使用 match_mode=literal' },
39
- title: { type: 'string', description: '已有检索结果的完整篇章标题,与 query/document_uid/text_ids 四选一' },
40
- document_uid: { type: 'string', description: '已有检索结果的文档定位,替代 title' },
41
- line: { type: 'integer', description: '已有中文结果中的行号,只用于定位对应文本/记录' },
42
- text_ids: { type: 'array', items: { type: 'string' }, description: '显式请求 ID 后可用于精确复查;普通查询不需要' },
43
- source_language: { type: 'string', description: '原句的语言,默认 CN;也接受 en/ja/ko/zh-CN 等标准代码' },
32
+ query: { type: 'string', description: '名称或原句' },
33
+ title: { type: 'string', description: '已有结果的完整篇章标题' },
34
+ document_uid: { type: 'string', description: '已有结果的文档定位' },
35
+ line: { type: 'integer', description: '已有中文结果中的行号' },
36
+ text_ids: { type: 'array', items: { type: 'string' }, description: '返回的文本 ID 字符串,用于精确复查' },
37
+ source_language: { type: 'string', description: '原句语言,默认 CN;支持 en/ja/ko/zh-CN 等代码' },
44
38
  languages: { type: 'array', items: { type: 'string' }, description: '1–4 种目标语言:CN/EN/JP/KR/TC/MX/BR/FR/DE/RU/IT/ID/TH/VN' },
45
- match_mode: { type: 'string', enum: ['exact', 'literal'], description: '原句精确匹配或字面量片段搜索,默认 exact' },
46
- include_ids: { type: 'boolean', description: '默认 false;仅调试、核验或精确复查时返回内部文本 ID 与来源字段' },
39
+ match_mode: { type: 'string', enum: ['exact', 'literal'], description: '默认 exact 精确匹配;片段用 literal' },
40
+ include_ids: { type: 'boolean', description: '核验时返回内部 ID,默认 false' },
47
41
  max_matches: { type: 'integer', description: '每页候选数,默认 5,最多 20' },
48
42
  max_chars: { type: 'integer', description: '每页原文和译文的字符预算,默认 16000,范围 1000–48000' },
49
- data_version: { type: 'string', description: '绑定已有结果的资料版本,避免混用版本' },
43
+ data_version: { type: 'string', description: '已有结果的资料版本(可省略;续页给出的短版本号即可)' },
50
44
  after: { type: 'object', additionalProperties: false,
51
- required: ['data_version', 'request_hash', 'position', 'text_offset'],
45
+ required: ['position', 'text_offset'],
52
46
  properties: { data_version: { type: 'string' }, request_hash: { type: 'string' },
53
47
  position: { type: 'integer' }, text_offset: { type: 'integer' } },
54
- description: '仅原样提交工具返回的续页位置;长记录会明确分块返回' },
48
+ description: '原样复制 page.continuation.after' },
55
49
  },
56
50
  }
57
51
 
@@ -125,14 +119,15 @@ export function normalizeI18nRequest(args) {
125
119
  request.max_matches = integer(args.max_matches ?? 5, 1, 20, 'max_matches')
126
120
  request.max_chars = integer(args.max_chars ?? 16000, 1000, 48000, 'max_chars')
127
121
  if (args.data_version !== undefined) {
128
- if (typeof args.data_version !== 'string' || !/^[0-9a-f]{64}$/u.test(args.data_version)) throw fault('INVALID_REQUEST', 'data_version 必须是有效的 SHA-256')
122
+ if (typeof args.data_version !== 'string' || !DATA_VERSION_REF_PATTERN.test(args.data_version)) throw fault('INVALID_REQUEST', 'data_version 必须是续页给出的资料版本(至少 8 位十六进制)')
129
123
  request.data_version = args.data_version
130
124
  }
131
125
  if (args.after !== undefined) {
132
126
  const after = args.after
133
127
  if (!after || typeof after !== 'object' || Array.isArray(after)
134
- || Object.keys(after).sort().join() !== 'data_version,position,request_hash,text_offset'
135
- || !/^[0-9a-f]{64}$/u.test(after.data_version) || !/^[0-9a-f]{64}$/u.test(after.request_hash)) {
128
+ || Object.keys(after).some((key) => !['data_version', 'position', 'request_hash', 'text_offset'].includes(key))
129
+ || (after.data_version !== undefined && !DATA_VERSION_REF_PATTERN.test(String(after.data_version)))
130
+ || (after.request_hash !== undefined && !/^[0-9a-f]{8,64}$/u.test(String(after.request_hash)))) {
136
131
  throw fault('INVALID_REQUEST', 'after 必须原样使用工具返回的续页位置')
137
132
  }
138
133
  request.after = { ...after,
@@ -260,13 +255,13 @@ export async function executeI18n(store, raw, { signal, enabledGames = ['endfiel
260
255
  await store.ready()
261
256
  const snapshot = corpusVersionSnapshot(store)
262
257
  checkpoint(store, snapshot, signal)
263
- if ((request.data_version && request.data_version !== snapshot.dataVersion)
264
- || (request.after && request.after.data_version !== snapshot.dataVersion)) {
258
+ if ((request.data_version && !dataVersionMatches(request.data_version, snapshot.dataVersion))
259
+ || (request.after?.data_version && !dataVersionMatches(request.after.data_version, snapshot.dataVersion))) {
265
260
  throw fault('PACKAGE_VERSION_MISMATCH', '本地化查询所属资料版本已变化,请重新查询')
266
261
  }
267
262
  const { after, data_version: _version, ...identity } = request
268
263
  const requestHash = sha256(JSON.stringify(identity))
269
- if (after && after.request_hash !== requestHash) throw fault('INVALID_REQUEST', '续页参数与原查询不一致')
264
+ if (after?.request_hash && !requestHash.startsWith(after.request_hash)) throw fault('INVALID_REQUEST', '续页参数与原查询不一致')
270
265
  const base = { game: 'endfield', data_version: snapshot.dataVersion,
271
266
  source_language: request.source_language, languages: request.languages }
272
267
  const pack = store.packs.get(PACK), meta = pack?.localization
@@ -325,6 +320,19 @@ export async function executeI18n(store, raw, { signal, enabledGames = ['endfiel
325
320
  return a[0] - b[0] || a[1] - b[1] || a[2].localeCompare(b[2], 'en', { numeric: true })
326
321
  })
327
322
  }
323
+ // 按名称查询时同一译名常对应上千个文本 ID(每段对话的说话人字段各一份);原文与所有
324
+ // 请求语言完全一致的条目合并为一组,出处汇总到代表条目,避免逐页翻同一个译名。
325
+ const groupMembers = new Map()
326
+ if (request.query && ids.length > 1) {
327
+ const bySignature = new Map()
328
+ for (const id of ids) {
329
+ const signature = JSON.stringify(requestedLanguages.map((lang) => dictionaries.get(lang)?.byId.get(id) ?? null))
330
+ const representative = bySignature.get(signature)
331
+ if (representative === undefined) { bySignature.set(signature, id); groupMembers.set(id, [id]) }
332
+ else groupMembers.get(representative).push(id)
333
+ }
334
+ ids = [...groupMembers.keys()]
335
+ }
328
336
  // Preserve source/catalog order for document reads and deterministic query pages.
329
337
  let position = after?.position ?? 0, offset = after?.text_offset ?? 0
330
338
  if (position > ids.length || (position === ids.length && offset)) throw fault('INVALID_REQUEST', '续页位置超出查询结果')
@@ -340,8 +348,11 @@ export async function executeI18n(store, raw, { signal, enabledGames = ['endfiel
340
348
  const values = [sourceValue, ...Object.values(translations)]
341
349
  const maximumLength = Math.max(...requestedLanguages.map((lang) => [...(dictionaries.get(lang)?.byId.get(id) || '')].length))
342
350
  if (offset && offset >= maximumLength) throw fault('INVALID_REQUEST', '记录续页偏移超出正文')
343
- const refs = entry.references.filter((ref) => !documentId || ref.document_id === documentId)
351
+ const members = groupMembers.get(id) || [id]
352
+ const refs = members.flatMap((member) => catalog.byId.get(member).references)
353
+ .filter((ref) => !documentId || ref.document_id === documentId)
344
354
  matches.push({ source: { language: request.source_language, ...sourceValue }, translations,
355
+ ...(members.length > 1 ? { merged_text_count: members.length } : {}),
345
356
  references: refs.slice(0, 8).map((ref) => publicReference(store, ref)), reference_count: refs.length,
346
357
  ...(request.include_ids ? { text_id: id, source_fields: entry.sources.slice(0, 8),
347
358
  source_field_count: entry.sources.length } : {}) })
@@ -355,11 +366,49 @@ export async function executeI18n(store, raw, { signal, enabledGames = ['endfiel
355
366
  matches, total_matches: ids.length,
356
367
  ...(request.text_ids ? { missing_text_ids: request.text_ids.filter((id) => !catalog.byId.has(id)) } : {}),
357
368
  note: '返回官方本地化原文;引用请注明语言。中文行号仅用于定位,record/document 对齐不表示跨语言逐行对应。',
358
- page: { has_more: hasMore, continuation: hasMore ? { ...identity,
359
- data_version: snapshot.dataVersion, after: { data_version: snapshot.dataVersion,
360
- request_hash: requestHash, position, text_offset: offset } } : null } }
369
+ page: { has_more: hasMore, continuation: hasMore ? { ...compactIdentity(identity),
370
+ after: { data_version: shortDataVersion(snapshot.dataVersion),
371
+ request_hash: requestHash.slice(0, 12), position, text_offset: offset } } : null } }
372
+ }
373
+
374
+ /** 续页调用只保留用户实际给出的参数,默认值不重复回显。 */
375
+ function compactIdentity(identity) {
376
+ const defaults = { source_language: 'CN', match_mode: 'exact', include_ids: false, max_matches: 5, max_chars: 16000 }
377
+ return Object.fromEntries(Object.entries(identity).filter(([key, value]) =>
378
+ value !== undefined && value !== null && defaults[key] !== value))
361
379
  }
362
380
 
381
+ function renderValue(value) {
382
+ if (!value) return '(无)'
383
+ if (value.status === 'unavailable') return '(未安装该语言)'
384
+ if (value.status === 'missing_localization') return '(该语言无对应译文)'
385
+ const range = value.truncated || value.character_start
386
+ ? ` 〔第 ${value.character_start + 1}–${value.character_end} 字 / 共 ${value.total_characters} 字〕` : ''
387
+ return `${value.text}${range}`
388
+ }
389
+
390
+ /** 文本投影:与其他检索工具一致,按条目分块展示原文、译文与出处,不直接回显 JSON。 */
363
391
  export function renderI18n(_args, value) {
364
- return [{ type: 'text', text: JSON.stringify(value) }]
392
+ if (!value || typeof value !== 'object') return [{ type: 'text', text: '[corpus_i18n:error] 无法投影本地化结果' }]
393
+ const lines = [`# 终末地官方本地化(${value.game_version || '版本未知'}):${value.source_language} → ${(value.languages || []).join('/')}`]
394
+ if (value.status !== 'ok') {
395
+ lines.push(value.message || (value.status === 'not_found'
396
+ ? '未找到匹配原文。名称默认 exact 精确匹配整条文本;片段请设 match_mode="literal"。本工具只收录终末地官方本地化,明日方舟文本不在范围内。'
397
+ : value.status === 'unmapped_source' ? '该定位没有可对齐的本地化文本。' : `状态:${value.status}`))
398
+ }
399
+ if (value.total_matches) {
400
+ const raw = value.matches.reduce((sum, match) => sum + (match.merged_text_count || 1), 0)
401
+ lines.push(`共 ${value.total_matches} 组不同原文/译文${raw > value.matches.length ? `;本页 ${value.matches.length} 组合并自 ${raw} 条同文文本` : ''}。`)
402
+ }
403
+ value.matches.forEach((match, index) => {
404
+ lines.push('', `${index + 1}. ${match.source.language}:${renderValue(match.source)}`)
405
+ for (const [lang, translation] of Object.entries(match.translations || {})) lines.push(` ${lang}:${renderValue(translation)}`)
406
+ const refs = (match.references || []).slice(0, 3).map((ref) => ref.citation)
407
+ if (refs.length) lines.push(` 出处${match.reference_count > refs.length ? `(共 ${match.reference_count} 处,示例)` : ''}:${refs.join(';')}`)
408
+ if (match.text_id) lines.push(` text_id=${match.text_id}`)
409
+ })
410
+ if (value.missing_text_ids?.length) lines.push(`未找到的 text_ids:${value.missing_text_ids.join('、')}`)
411
+ if (value.note) lines.push('', value.note)
412
+ if (value.page?.has_more && value.page.continuation) lines.push(`续页:corpus_i18n(${JSON.stringify(value.page.continuation)})`)
413
+ return [{ type: 'text', text: lines.join('\n') }]
365
414
  }