prts-terrarchive 0.2.2 → 0.2.4
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 +52 -18
- package/README.md +49 -16
- package/bin/build-mainline-catalog.py +195 -0
- package/contracts/corpus_i18n_v1.schema.json +1 -3
- package/contracts/corpus_tools_v1.schema.json +30 -1
- package/docs/endfield-mainline-navigation.md +45 -0
- package/lib/client.js +154 -27
- package/lib/skins/common.css +12 -2
- package/lib/skins/endfield-aic.css +1 -1
- package/lib/skins/prts-agent.css +9 -7
- package/package.json +7 -3
- package/resources/endfield-mainline.json +4632 -0
- package/skills/prts-investigation/SKILL.md +1 -1
- package/skills/prts-investigation/references/board-guide.md +1 -1
- package/skills/prts-retrieval/SKILL.md +2 -0
- package/skills/prts-retrieval/references/cloud-tools.md +3 -1
- package/skills/prts-retrieval/references/local-tools.md +3 -3
- package/skills/prts-retrieval/references/module-arknights.md +1 -1
- package/skills/prts-retrieval/references/module-dual.md +1 -1
- package/skills/prts-retrieval/references/module-endfield.md +3 -0
- package/skills/prts-retrieval/references/tools-runtime.md +22 -10
- package/src/cloud-projection.js +23 -2
- package/src/cloud.js +24 -0
- package/src/entity-recognizer.js +26 -5
- package/src/entity-routing.js +18 -4
- package/src/evidence-state.js +4 -2
- package/src/i18n.js +70 -15
- package/src/index.js +326 -103
- package/src/installer.js +86 -14
- package/src/investigation-tools.js +7 -4
- package/src/navigation.js +342 -0
- package/src/read-args.js +86 -0
- package/src/read.js +66 -15
- package/src/search-projection.js +154 -19
- package/src/search.js +177 -21
- package/src/store.js +127 -3
- package/src/timeline.js +86 -2
- package/src/tool-args.js +53 -0
- package/src/ui.js +158 -34
- package/src/web-guidance.js +14 -2
package/src/timeline.js
CHANGED
|
@@ -149,6 +149,63 @@ class TimelineFault extends Error {
|
|
|
149
149
|
* 2. references/char_alias.txt 的分号分隔行(挂到同名 canonical 组,找不到则自建)。
|
|
150
150
|
* @returns {Promise<Array<{canonical: string, aliases: string[], games: string[]}>>}
|
|
151
151
|
*/
|
|
152
|
+
/** 别名字面归一:与识别器同源(NFKC + 小写 + 压缩空白),避免大小写或全角差异放过冲突。 */
|
|
153
|
+
function aliasKey(value) {
|
|
154
|
+
return String(value || '').normalize('NFKC').replace(/\s+/gu, ' ').trim().toLocaleLowerCase()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 一个别名字面只能指向一个实体。资料里出现过把正文提及词当成别名的记录
|
|
159
|
+
* (如「玛利图斯」的 aliases 里塞进「预言家」「海嗣」),AC 自动机便会对同一字面
|
|
160
|
+
* 同时吐出两个 canonical,让无关实体混进本轮检索上下文。
|
|
161
|
+
* 裁决:字面等于某实体自身规范名时保留该实体(如「海嗣」保留给海嗣),
|
|
162
|
+
* 其余情况一律丢弃并登记冲突,交给数据侧修正别名表——不在代码里猜谁才是对的。
|
|
163
|
+
*/
|
|
164
|
+
function resolveAliasConflicts(groups, pinned = new Set()) {
|
|
165
|
+
const owners = new Map()
|
|
166
|
+
for (const group of groups.values()) for (const alias of group.aliases) {
|
|
167
|
+
const key = aliasKey(alias)
|
|
168
|
+
if (!key) continue
|
|
169
|
+
const list = owners.get(key) || []
|
|
170
|
+
list.push({ group, alias })
|
|
171
|
+
owners.set(key, list)
|
|
172
|
+
}
|
|
173
|
+
const conflicts = []
|
|
174
|
+
for (const [key, list] of owners) {
|
|
175
|
+
const canonicals = [...new Set(list.map((item) => item.group.canonical))]
|
|
176
|
+
if (canonicals.length < 2) continue
|
|
177
|
+
const keeper = list.find((item) => item.group.canonical === item.alias)
|
|
178
|
+
for (const item of list) {
|
|
179
|
+
if (item === keeper) continue
|
|
180
|
+
item.group.aliases.delete(item.alias)
|
|
181
|
+
}
|
|
182
|
+
if (!keeper && !pinned.has(key)) conflicts.push({ alias: list[0].alias, canonicals: canonicals.sort() })
|
|
183
|
+
}
|
|
184
|
+
return conflicts
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 去掉"太泛"的短别名:两个字的词很容易是通用词(如某组织把「联合」当别名,
|
|
189
|
+
* 于是"联合解析"这种句子会把它整个拉进检索上下文)。
|
|
190
|
+
* 判据是它在多少条**其他**实体的规范名里当子串——只有人工别名表里裁决过的短别名豁免。
|
|
191
|
+
*/
|
|
192
|
+
function dropGenericShortAliases(groups, curated) {
|
|
193
|
+
const canonicals = [...groups.keys()]
|
|
194
|
+
const dropped = []
|
|
195
|
+
for (const group of groups.values()) {
|
|
196
|
+
for (const alias of [...group.aliases]) {
|
|
197
|
+
const key = aliasKey(alias)
|
|
198
|
+
if (key.length > 2 || key === aliasKey(group.canonical) || curated.has(key)) continue
|
|
199
|
+
const others = canonicals.filter((name) => name !== group.canonical
|
|
200
|
+
&& aliasKey(name).includes(key)).length
|
|
201
|
+
if (others < 3) continue
|
|
202
|
+
group.aliases.delete(alias)
|
|
203
|
+
dropped.push({ alias, canonical: group.canonical, others })
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return dropped
|
|
207
|
+
}
|
|
208
|
+
|
|
152
209
|
export async function buildAliasGroups(store) {
|
|
153
210
|
const groups = new Map()
|
|
154
211
|
const crossGameNames = relationEndfieldNames(await loadEntityRelationCatalog(store))
|
|
@@ -156,7 +213,11 @@ export async function buildAliasGroups(store) {
|
|
|
156
213
|
const canonical = String(canonicalValue ?? '').trim()
|
|
157
214
|
if (!canonical) return
|
|
158
215
|
const group = groups.get(canonical) || { canonical, aliases: new Set([canonical]), games: new Set() }
|
|
159
|
-
|
|
216
|
+
// 实体 JSON 点里 aliases 是分号分隔的字符串(个别用全角分号),资料包里是数组;
|
|
217
|
+
// 字符串直接迭代会被按字拆开,这里统一先切分。
|
|
218
|
+
const aliasList = typeof aliasValues === 'string' ? aliasValues.split(/[;;]/u)
|
|
219
|
+
: Array.isArray(aliasValues) ? aliasValues : []
|
|
220
|
+
for (const value of aliasList) {
|
|
160
221
|
const alias = String(value ?? '').trim()
|
|
161
222
|
// 旧 PRTS 数据曾把终末地再旅者名塞进泰拉人物 aliases 方便联搜。
|
|
162
223
|
// 关系现在由独立附属字段承载;跨游戏名不再改变实体身份。
|
|
@@ -178,15 +239,38 @@ export async function buildAliasGroups(store) {
|
|
|
178
239
|
entity.aliases || record.aliases, game)
|
|
179
240
|
}
|
|
180
241
|
|
|
242
|
+
// 人工别名表(char_alias.txt)是裁决,不是补充:实体 JSON 里的 aliases 由当时的
|
|
243
|
+
// LLM 生成 json 点时顺手写出,会把正文提及词当成别名(「玛利图斯」曾带「预言家」
|
|
244
|
+
// 「海嗣」)。表里每一行声明的别名只归该行实体,其余组必须让出。
|
|
181
245
|
const aliasReference = await store.getDocumentByPath('char_alias.txt')
|
|
246
|
+
const pinned = new Set()
|
|
182
247
|
for (const line of aliasReference?.record?.lines || []) {
|
|
183
248
|
const aliases = [...new Set(String(line.text || '').split(';').map((item) => item.trim()).filter(Boolean))]
|
|
184
249
|
if (!aliases.length) continue
|
|
185
250
|
const existing = [...groups.values()].find((group) =>
|
|
186
251
|
group.canonical === aliases[0] || group.aliases.has(aliases[0]))
|
|
187
|
-
|
|
252
|
+
const canonical = existing?.canonical || aliases[0]
|
|
253
|
+
for (const alias of aliases) {
|
|
254
|
+
const key = aliasKey(alias)
|
|
255
|
+
if (!key) continue
|
|
256
|
+
for (const group of [...groups.values()]) {
|
|
257
|
+
if (group.canonical === canonical) continue
|
|
258
|
+
for (const owned of [...group.aliases]) {
|
|
259
|
+
if (aliasKey(owned) === key) group.aliases.delete(owned)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
pinned.add(key)
|
|
263
|
+
}
|
|
264
|
+
remember(canonical, aliases)
|
|
188
265
|
}
|
|
189
266
|
|
|
267
|
+
const conflicts = resolveAliasConflicts(groups, pinned)
|
|
268
|
+
const generic = dropGenericShortAliases(groups, pinned)
|
|
269
|
+
if (store && typeof store === 'object') {
|
|
270
|
+
store._aliasConflicts = conflicts
|
|
271
|
+
store._genericAliases = generic
|
|
272
|
+
store._curatedAliases = pinned
|
|
273
|
+
}
|
|
190
274
|
return [...groups.values()].map((group) => ({ canonical: group.canonical,
|
|
191
275
|
aliases: [...group.aliases], games: [...group.games] }))
|
|
192
276
|
}
|
package/src/tool-args.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模型参数的空占位归一化。
|
|
3
|
+
*
|
|
4
|
+
* DSH 工具 Schema 子集不支持 minLength/minItems/minimum,模型或第三方接口常把
|
|
5
|
+
* 可选字段整体填成 ""、[]、{}、null 或 0。这些值不表达任何意图,却会触发执行层
|
|
6
|
+
* 的严格校验并让 Agent 陷入循环。这里只删除“明确为空”的占位:
|
|
7
|
+
* - null / undefined
|
|
8
|
+
* - 去空白后为空的字符串
|
|
9
|
+
* - 删除空字符串项后为空的数组(非空项保持原样,交给执行层严格校验)
|
|
10
|
+
* - 递归清理后没有任何键(或只剩数字 0)的普通对象
|
|
11
|
+
* - 显式列入 zeroFields 的整数 0(仅用于从 1 开始计数的字段)
|
|
12
|
+
* 部分填写的对象、非空的非法值仍原样保留,由执行层报告具体错误。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object'
|
|
16
|
+
&& !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
17
|
+
|
|
18
|
+
const isBlankString = (value) => typeof value === 'string' && value.trim() === ''
|
|
19
|
+
|
|
20
|
+
function cleanValue(value) {
|
|
21
|
+
if (value === null || value === undefined) return undefined
|
|
22
|
+
if (isBlankString(value)) return undefined
|
|
23
|
+
if (Array.isArray(value)) {
|
|
24
|
+
const items = value.filter((item) => item !== null && item !== undefined && !isBlankString(item))
|
|
25
|
+
return items.length ? items : undefined
|
|
26
|
+
}
|
|
27
|
+
if (isPlainObject(value)) {
|
|
28
|
+
const cleaned = {}
|
|
29
|
+
for (const [key, item] of Object.entries(value)) {
|
|
30
|
+
const next = cleanValue(item)
|
|
31
|
+
if (next !== undefined) cleaned[key] = next
|
|
32
|
+
}
|
|
33
|
+
// {position: 0} 这类只剩数字 0 的对象同样是整体占位(如空的 after 分页凭据)。
|
|
34
|
+
return Object.values(cleaned).some((item) => item !== 0) ? cleaned : undefined
|
|
35
|
+
}
|
|
36
|
+
return value
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {unknown} args 模型原始参数
|
|
41
|
+
* @param {{ zeroFields?: string[] }} [options]
|
|
42
|
+
* @returns {unknown} 删除空占位后的新对象;非对象原样返回,交给调用方报错
|
|
43
|
+
*/
|
|
44
|
+
export function dropEmptyPlaceholders(args, { zeroFields = [] } = {}) {
|
|
45
|
+
if (!isPlainObject(args)) return args
|
|
46
|
+
const result = {}
|
|
47
|
+
for (const [key, value] of Object.entries(args)) {
|
|
48
|
+
if (zeroFields.includes(key) && value === 0) continue
|
|
49
|
+
const next = cleanValue(value)
|
|
50
|
+
if (next !== undefined) result[key] = next
|
|
51
|
+
}
|
|
52
|
+
return result
|
|
53
|
+
}
|
package/src/ui.js
CHANGED
|
@@ -641,13 +641,14 @@ const emptyDataset = (game) => ({
|
|
|
641
641
|
})
|
|
642
642
|
|
|
643
643
|
/** 把联合 release 中的 pack 汇总成用户可理解的两个游戏资料库。 */
|
|
644
|
-
async function readReleaseDatasets(dir, manifest, packManifests = null) {
|
|
644
|
+
async function readReleaseDatasets(dir, manifest, packManifests = null, installedPackIds = null) {
|
|
645
645
|
const datasets = {
|
|
646
646
|
arknights: emptyDataset('arknights'),
|
|
647
647
|
endfield: emptyDataset('endfield'),
|
|
648
648
|
}
|
|
649
649
|
const sharedPacks = []
|
|
650
650
|
for (const pack of Array.isArray(manifest?.packs) ? manifest.packs : []) {
|
|
651
|
+
if (installedPackIds && !installedPackIds.includes(pack.pack_id)) continue
|
|
651
652
|
const manifestPath = String(pack?.manifest_path ?? '')
|
|
652
653
|
if (!manifestPath || manifestPath.includes('..') || manifestPath.startsWith('/')) continue
|
|
653
654
|
let detail = packManifests?.get(String(pack?.pack_id ?? ''))
|
|
@@ -657,8 +658,8 @@ async function readReleaseDatasets(dir, manifest, packManifests = null) {
|
|
|
657
658
|
const packId = String(detail.pack_id ?? pack.pack_id ?? '')
|
|
658
659
|
const game = detail.game === 'arknights' || detail.game === 'endfield'
|
|
659
660
|
? detail.game
|
|
660
|
-
: packId
|
|
661
|
-
:
|
|
661
|
+
: packId.startsWith('endfield_') ? 'endfield'
|
|
662
|
+
: ['official_game', 'reviewed_wiki', 'terra_journey', 'entities', 'references'].includes(packId) ? 'arknights' : null
|
|
662
663
|
const summary = {
|
|
663
664
|
packId,
|
|
664
665
|
authority: detail.authority ?? pack.authority ?? null,
|
|
@@ -697,43 +698,57 @@ async function readReleaseDatasets(dir, manifest, packManifests = null) {
|
|
|
697
698
|
async function readLocalReleases(shared, sizeCache = new Map()) {
|
|
698
699
|
const releases = []
|
|
699
700
|
let activeId = null
|
|
701
|
+
let previousId = null
|
|
700
702
|
try {
|
|
701
|
-
|
|
703
|
+
const pointer = await readCurrentReleasePointer(shared.releasesDir)
|
|
704
|
+
activeId = pointer.release_id
|
|
705
|
+
previousId = pointer.previous_release_id ?? null
|
|
702
706
|
} catch { /* 尚无激活版本 */ }
|
|
703
707
|
let entries = []
|
|
704
708
|
try {
|
|
705
709
|
entries = await readdir(shared.releasesDir, { withFileTypes: true })
|
|
706
|
-
} catch { return { activeId, releases } }
|
|
710
|
+
} catch { return { activeId, previousId, releases } }
|
|
707
711
|
for (const entry of entries) {
|
|
708
712
|
if (!entry.isDirectory() || !RELEASE_ID_PATTERN.test(entry.name)) continue
|
|
709
713
|
const dir = join(shared.releasesDir, entry.name)
|
|
710
714
|
let manifest = null
|
|
711
715
|
let packManifests = null
|
|
716
|
+
let installedPackIds = null
|
|
717
|
+
let installedGames = []
|
|
712
718
|
try {
|
|
713
|
-
const validated = await validateLocalRelease(shared.releasesDir, entry.name, { details: true })
|
|
719
|
+
const validated = await validateLocalRelease(shared.releasesDir, entry.name, { details: true, allowPartial: true })
|
|
714
720
|
manifest = validated.manifest
|
|
715
721
|
packManifests = validated.packManifests
|
|
722
|
+
installedPackIds = validated.installedPackIds ?? manifest.packs.map((pack) => pack.pack_id)
|
|
723
|
+
installedGames = validated.installedGames ?? []
|
|
716
724
|
} catch { /* 半成品/外来目录:仍列出,标记不完整 */ }
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
725
|
+
// 目录大小只对不完整的目录才有意义(用于提示半成品占用);完整版本直接取
|
|
726
|
+
// 清单里的 compressed_size。此前对每个 release 都全量 stat 一遍,
|
|
727
|
+
// 设置页要等十几秒才出内容。
|
|
728
|
+
let sizeBytes = manifest
|
|
729
|
+
? (manifest.compressed_size ?? 0)
|
|
730
|
+
: await releaseDirSize(dir).catch(() => 0)
|
|
722
731
|
releases.push({
|
|
723
732
|
releaseId: entry.name,
|
|
724
733
|
active: entry.name === activeId,
|
|
725
734
|
complete: Boolean(manifest),
|
|
735
|
+
installedGames, installedPackIds,
|
|
736
|
+
missingGames: manifest ? missingEnabledGamePacks(manifest, shared.effective().enabledGames, installedPackIds) : shared.effective().enabledGames,
|
|
726
737
|
dataVersion: manifest?.data_version ?? null,
|
|
727
|
-
documentCount: manifest
|
|
728
|
-
|
|
738
|
+
documentCount: manifest ? manifest.packs.filter((pack) => installedPackIds.includes(pack.pack_id))
|
|
739
|
+
.reduce((sum, pack) => sum + pack.document_count, 0) : null,
|
|
740
|
+
compressedSize: manifest ? manifest.packs.filter((pack) => installedPackIds.includes(pack.pack_id))
|
|
741
|
+
.reduce((sum, pack) => sum + pack.compressed_size, 0) : null,
|
|
742
|
+
releaseDocumentCount: manifest?.document_count ?? null,
|
|
743
|
+
releaseCompressedSize: manifest?.compressed_size ?? null,
|
|
729
744
|
createdAt: manifest?.created_at ?? null,
|
|
730
745
|
sizeBytes,
|
|
731
746
|
needsExtract: true, // 本地分片以 .jsonl.gz 存储,打开时需解压
|
|
732
|
-
datasets: manifest ? await readReleaseDatasets(dir, manifest, packManifests) : null,
|
|
747
|
+
datasets: manifest ? await readReleaseDatasets(dir, manifest, packManifests, installedPackIds) : null,
|
|
733
748
|
})
|
|
734
749
|
}
|
|
735
750
|
releases.sort((left, right) => String(right.createdAt ?? '').localeCompare(String(left.createdAt ?? '')))
|
|
736
|
-
return { activeId, releases }
|
|
751
|
+
return { activeId, previousId, releases }
|
|
737
752
|
}
|
|
738
753
|
|
|
739
754
|
/**
|
|
@@ -752,6 +767,8 @@ async function checkForUpdate(shared, env = {}, sizeCache) {
|
|
|
752
767
|
dataVersion: localRelease?.dataVersion ?? null,
|
|
753
768
|
documentCount: localRelease?.documentCount ?? null,
|
|
754
769
|
sizeBytes: localRelease?.sizeBytes ?? null,
|
|
770
|
+
installedGames: localRelease?.installedGames ?? [],
|
|
771
|
+
missingGames: localRelease?.missingGames ?? shared.effective().enabledGames,
|
|
755
772
|
}
|
|
756
773
|
try {
|
|
757
774
|
const trusted = await resolveTrustedCurrentRelease({ fetchImpl,
|
|
@@ -765,7 +782,7 @@ async function checkForUpdate(shared, env = {}, sizeCache) {
|
|
|
765
782
|
uncompressedSize: trusted.uncompressedSize,
|
|
766
783
|
createdAt: null,
|
|
767
784
|
}
|
|
768
|
-
const updateAvailable = local.activeId !== trusted.releaseId
|
|
785
|
+
const updateAvailable = localInfo.missingGames.length > 0 || local.activeId !== trusted.releaseId
|
|
769
786
|
|| (localInfo.dataVersion != null && localInfo.dataVersion !== trusted.dataVersion)
|
|
770
787
|
return { source: 'site', local: localInfo, remote, updateAvailable }
|
|
771
788
|
} catch (error) {
|
|
@@ -780,6 +797,8 @@ async function checkForUpdate(shared, env = {}, sizeCache) {
|
|
|
780
797
|
* @param {{ logger?: object }} [env]
|
|
781
798
|
*/
|
|
782
799
|
export function buildApi(shared, env = {}) {
|
|
800
|
+
// 保留最近几个版本:当前版本 + 回滚目标 + 余量。
|
|
801
|
+
const KEEP_RELEASES = 3
|
|
783
802
|
const sizeCache = new Map()
|
|
784
803
|
const fetchImpl = env.fetchImpl ?? fetch
|
|
785
804
|
const manualCloudSearch = createManualCloudSearch(shared, { fetchImpl })
|
|
@@ -789,7 +808,51 @@ export function buildApi(shared, env = {}) {
|
|
|
789
808
|
releaseMutation = running.catch(() => {})
|
|
790
809
|
return running
|
|
791
810
|
}
|
|
811
|
+
/**
|
|
812
|
+
* 自动清理旧资料版本:只保留最近 KEEP_RELEASES 个(当前版本与回滚目标必须保留),
|
|
813
|
+
* 否则每下载一次就在用户硬盘上多堆一份几百 MB 的旧包。
|
|
814
|
+
* 只删「能通过 Release 校验」的受管目录;半成品/外来目录一律不碰。
|
|
815
|
+
*/
|
|
816
|
+
const pruneOldReleases = async ({ keep = KEEP_RELEASES } = {}) => {
|
|
817
|
+
let pointer = null
|
|
818
|
+
try { pointer = await readCurrentReleasePointer(shared.releasesDir) } catch { /* 无激活版本 */ }
|
|
819
|
+
const activeId = pointer?.release_id ?? null
|
|
820
|
+
const previousId = pointer?.previous_release_id ?? null
|
|
821
|
+
let entries = []
|
|
822
|
+
try { entries = await readdir(shared.releasesDir, { withFileTypes: true }) } catch { return [] }
|
|
823
|
+
const candidates = []
|
|
824
|
+
for (const entry of entries) {
|
|
825
|
+
if (!entry.isDirectory() || !RELEASE_ID_PATTERN.test(entry.name)) continue
|
|
826
|
+
if (entry.name === activeId || entry.name === previousId) continue
|
|
827
|
+
if (shared.download?.active && shared.download.releaseId === entry.name) continue
|
|
828
|
+
try {
|
|
829
|
+
const validated = await validateLocalRelease(shared.releasesDir, entry.name, { allowPartial: true })
|
|
830
|
+
candidates.push({ releaseId: entry.name, createdAt: String(validated.manifest?.created_at ?? '') })
|
|
831
|
+
} catch { /* 不是受管版本,绝不自动删除 */ }
|
|
832
|
+
}
|
|
833
|
+
const reserved = (activeId ? 1 : 0) + (previousId ? 1 : 0)
|
|
834
|
+
const room = Math.max(0, keep - reserved)
|
|
835
|
+
candidates.sort((left, right) => right.createdAt.localeCompare(left.createdAt)
|
|
836
|
+
|| right.releaseId.localeCompare(left.releaseId))
|
|
837
|
+
const removed = []
|
|
838
|
+
for (const item of candidates.slice(room)) {
|
|
839
|
+
try {
|
|
840
|
+
await rm(join(shared.releasesDir, item.releaseId), { recursive: true, force: true })
|
|
841
|
+
sizeCache.delete(item.releaseId)
|
|
842
|
+
removed.push(item.releaseId)
|
|
843
|
+
} catch (error) {
|
|
844
|
+
env.logger?.warn?.(`prts-corpus: 清理旧版本失败 ${item.releaseId}: ${error?.message ?? error}`)
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (removed.length) {
|
|
848
|
+
env.logger?.info?.(`prts-corpus: 自动清理 ${removed.length} 个旧版本(保留最近 ${keep} 个):${removed.join('、')}`)
|
|
849
|
+
}
|
|
850
|
+
return removed
|
|
851
|
+
}
|
|
852
|
+
|
|
792
853
|
const startDownload = async ({ releaseId }) => {
|
|
854
|
+
const config = shared.effective()
|
|
855
|
+
const enabledGames = [...config.enabledGames]
|
|
793
856
|
const progress = shared.download
|
|
794
857
|
if (progress.active) throw new ApiError(409, '已有下载任务在进行中')
|
|
795
858
|
// 目标解析包含最长数十秒的网络请求;下载槽位必须在首个 await 之前同步
|
|
@@ -837,10 +900,10 @@ export function buildApi(shared, env = {}) {
|
|
|
837
900
|
// 后台执行;结果写回 shared.download,界面轮询 status 跟踪
|
|
838
901
|
void (async () => {
|
|
839
902
|
try {
|
|
840
|
-
const config = shared.effective()
|
|
841
903
|
const result = await ensureCorpusRelease({
|
|
842
904
|
releasesDir: shared.releasesDir,
|
|
843
905
|
releaseId: target,
|
|
906
|
+
enabledGames,
|
|
844
907
|
...(trustedCurrent ? { trustedCurrent } : {}),
|
|
845
908
|
order: config.downloadOrder,
|
|
846
909
|
siteBaseUrl: config.downloadSiteBaseUrl,
|
|
@@ -861,7 +924,8 @@ export function buildApi(shared, env = {}) {
|
|
|
861
924
|
if (shared.store) {
|
|
862
925
|
try {
|
|
863
926
|
const pointer = await readCurrentReleasePointer(shared.releasesDir)
|
|
864
|
-
if (pointer.release_id !== shared.store.releaseId
|
|
927
|
+
if (pointer.release_id !== shared.store.releaseId
|
|
928
|
+
|| (pointer.installation_id ?? null) !== shared.store.installationId) {
|
|
865
929
|
shared.store.reset()
|
|
866
930
|
}
|
|
867
931
|
} catch { /* current.json 异常时保持现状 */ }
|
|
@@ -874,10 +938,15 @@ export function buildApi(shared, env = {}) {
|
|
|
874
938
|
progress.active = false
|
|
875
939
|
progress.finishedAt = new Date().toISOString()
|
|
876
940
|
shared.notifyRuntime()
|
|
941
|
+
// 新包落位后立刻回收旧包;失败不影响下载结果。
|
|
942
|
+
await pruneOldReleases().catch((error) => {
|
|
943
|
+
env.logger?.warn?.(`prts-corpus: 自动清理旧版本失败:${error?.message ?? error}`)
|
|
944
|
+
})
|
|
877
945
|
}
|
|
878
946
|
})()
|
|
879
947
|
return { started: true, releaseId: target }
|
|
880
948
|
}
|
|
949
|
+
// 供启动清理调用(不进入路由,不暴露给 UI)。
|
|
881
950
|
|
|
882
951
|
/** 原始路由分发:ApiError/配置校验错误转为响应,其余向上抛给 HTTP 层。 */
|
|
883
952
|
const routeCall = async (method, pathname, body, { signal } = {}) => {
|
|
@@ -1025,11 +1094,17 @@ export function buildApi(shared, env = {}) {
|
|
|
1025
1094
|
const config = shared.effective()
|
|
1026
1095
|
let installed = false
|
|
1027
1096
|
let installationIssue = null
|
|
1097
|
+
let installedGames = []
|
|
1098
|
+
let installedPackIds = []
|
|
1099
|
+
let missingGames = []
|
|
1028
1100
|
try {
|
|
1029
1101
|
const pointer = await readCurrentReleasePointer(shared.releasesDir)
|
|
1030
1102
|
const releaseId = String(pointer.release_id || '')
|
|
1031
|
-
const
|
|
1032
|
-
const
|
|
1103
|
+
const validated = await validateLocalRelease(shared.releasesDir, releaseId, { details: true, allowPartial: true })
|
|
1104
|
+
const { manifest } = validated
|
|
1105
|
+
installedGames = validated.installedGames ?? []
|
|
1106
|
+
installedPackIds = validated.installedPackIds ?? manifest.packs.map((pack) => pack.pack_id)
|
|
1107
|
+
missingGames = missingEnabledGamePacks(manifest, config.enabledGames, installedPackIds)
|
|
1033
1108
|
installed = Boolean((!pointer.data_version || pointer.data_version === manifest.data_version)
|
|
1034
1109
|
&& missingGames.length === 0)
|
|
1035
1110
|
if (missingGames.length) installationIssue = `当前版本缺少已启用模块:${missingGames.map((game) =>
|
|
@@ -1040,7 +1115,7 @@ export function buildApi(shared, env = {}) {
|
|
|
1040
1115
|
? '未找到本地语料;请下载资料或检查资料目录配置'
|
|
1041
1116
|
: `无法读取本地语料配置:${error?.message ?? error}`
|
|
1042
1117
|
}
|
|
1043
|
-
let storeInfo = { loaded: ready, installed, installationIssue,
|
|
1118
|
+
let storeInfo = { loaded: ready, installed, installationIssue, installedGames, installedPackIds, missingGames,
|
|
1044
1119
|
releaseId: null, dataVersion: null, documentCount: null, packCount: null }
|
|
1045
1120
|
if (store && ready && store.releaseId) {
|
|
1046
1121
|
storeInfo = { ...storeInfo, loaded: true,
|
|
@@ -1050,8 +1125,8 @@ export function buildApi(shared, env = {}) {
|
|
|
1050
1125
|
return { status: 200, json: { store: storeInfo, download: { ...shared.download }, config: redactConfig(config) } }
|
|
1051
1126
|
}
|
|
1052
1127
|
if (method === 'GET' && route === 'releases') {
|
|
1053
|
-
const { activeId, releases } = await readLocalReleases(shared, sizeCache)
|
|
1054
|
-
return { status: 200, json: { activeId, releases } }
|
|
1128
|
+
const { activeId, previousId, releases } = await readLocalReleases(shared, sizeCache)
|
|
1129
|
+
return { status: 200, json: { activeId, previousId, releases } }
|
|
1055
1130
|
}
|
|
1056
1131
|
if (method === 'GET' && route === 'check-update') {
|
|
1057
1132
|
return { status: 200, json: await checkForUpdate(shared, env, sizeCache) }
|
|
@@ -1079,21 +1154,38 @@ export function buildApi(shared, env = {}) {
|
|
|
1079
1154
|
return await withReleaseMutationLock(shared.releasesDir, async () => {
|
|
1080
1155
|
let manifest
|
|
1081
1156
|
try {
|
|
1082
|
-
|
|
1157
|
+
const validated = await validateLocalRelease(shared.releasesDir, releaseId, { verifyHashes: true, details: true, allowPartial: true })
|
|
1158
|
+
manifest = validated.manifest
|
|
1159
|
+
const missing = missingEnabledGamePacks(manifest, shared.effective().enabledGames, validated.installedPackIds)
|
|
1160
|
+
if (missing.length) throw new Error(`缺少已启用模块:${missing.join("、")}`)
|
|
1083
1161
|
} catch (error) {
|
|
1084
1162
|
throw new ApiError(400, `版本不完整,无法激活:${error?.message ?? error}`)
|
|
1085
1163
|
}
|
|
1164
|
+
// 记下上一版本,设置页才能提供「回滚」;否则切换一次就把退路弄丢了。
|
|
1165
|
+
let previousId = null
|
|
1166
|
+
let generation = 1
|
|
1167
|
+
try {
|
|
1168
|
+
const old = await readCurrentReleasePointer(shared.releasesDir)
|
|
1169
|
+
previousId = old.release_id && old.release_id !== releaseId ? old.release_id
|
|
1170
|
+
: (old.previous_release_id ?? null)
|
|
1171
|
+
generation = Number(old.generation ?? 0) + 1
|
|
1172
|
+
} catch { /* 首次激活 */ }
|
|
1086
1173
|
const pointerTemp = join(shared.releasesDir, `current.json.${randomBytes(6).toString('hex')}.tmp`)
|
|
1087
1174
|
await writeFile(pointerTemp, JSON.stringify({
|
|
1088
1175
|
release_id: releaseId, data_version: manifest.data_version,
|
|
1089
1176
|
channel: 'manual', public_download: true, schema_version: 1,
|
|
1177
|
+
installation_id: randomBytes(16).toString('hex'),
|
|
1090
1178
|
activated_at: new Date().toISOString(),
|
|
1179
|
+
generation,
|
|
1180
|
+
...(previousId ? { previous_release_id: previousId } : {}),
|
|
1181
|
+
rollback_available: Boolean(previousId),
|
|
1091
1182
|
}))
|
|
1092
1183
|
await rename(pointerTemp, join(shared.releasesDir, 'current.json'))
|
|
1093
1184
|
shared.store?.reset()
|
|
1094
1185
|
shared.notifyRuntime()
|
|
1095
1186
|
env.logger?.info?.(`prts-corpus: 激活版本切换为 ${releaseId}`)
|
|
1096
|
-
|
|
1187
|
+
const pruned = await pruneOldReleases().catch(() => [])
|
|
1188
|
+
return { status: 200, json: { activated: releaseId, pruned } }
|
|
1097
1189
|
})
|
|
1098
1190
|
} catch (error) {
|
|
1099
1191
|
if (error?.code === 'DOWNLOAD_BUSY') throw new ApiError(409, error.message)
|
|
@@ -1101,6 +1193,18 @@ export function buildApi(shared, env = {}) {
|
|
|
1101
1193
|
}
|
|
1102
1194
|
})
|
|
1103
1195
|
}
|
|
1196
|
+
if (method === 'POST' && route === 'prune') {
|
|
1197
|
+
return withReleaseMutation(async () => {
|
|
1198
|
+
try {
|
|
1199
|
+
return await withReleaseMutationLock(shared.releasesDir, async () => ({
|
|
1200
|
+
status: 200, json: { removed: await pruneOldReleases() },
|
|
1201
|
+
}))
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
if (error?.code === 'DOWNLOAD_BUSY') throw new ApiError(409, error.message)
|
|
1204
|
+
throw error
|
|
1205
|
+
}
|
|
1206
|
+
})
|
|
1207
|
+
}
|
|
1104
1208
|
if (method === 'POST' && route === 'delete') {
|
|
1105
1209
|
const releaseId = String(body?.releaseId ?? '')
|
|
1106
1210
|
if (!RELEASE_ID_PATTERN.test(releaseId)) throw new ApiError(400, 'releaseId 非法')
|
|
@@ -1116,7 +1220,7 @@ export function buildApi(shared, env = {}) {
|
|
|
1116
1220
|
throw new ApiError(409, '该版本正在下载')
|
|
1117
1221
|
}
|
|
1118
1222
|
try {
|
|
1119
|
-
await validateLocalRelease(shared.releasesDir, releaseId)
|
|
1223
|
+
await validateLocalRelease(shared.releasesDir, releaseId, { allowPartial: true })
|
|
1120
1224
|
} catch (error) {
|
|
1121
1225
|
throw new ApiError(400, `目录不是可确认的完整资料版本,拒绝递归删除:${error?.message ?? error}`)
|
|
1122
1226
|
}
|
|
@@ -1134,6 +1238,7 @@ export function buildApi(shared, env = {}) {
|
|
|
1134
1238
|
}
|
|
1135
1239
|
|
|
1136
1240
|
return {
|
|
1241
|
+
pruneOldReleases,
|
|
1137
1242
|
async call(method, pathname, body, options = {}) {
|
|
1138
1243
|
try {
|
|
1139
1244
|
return await routeCall(method, pathname, body, options)
|
|
@@ -1160,6 +1265,12 @@ export function applyUi(ctx, shared) {
|
|
|
1160
1265
|
const connection = ctx.get?.('connection') ?? ctx.connection
|
|
1161
1266
|
if (!connection) return false
|
|
1162
1267
|
const api = buildApi(shared, { logger: ctx.logger })
|
|
1268
|
+
// 启动后再清一次:老用户机器上可能已经堆了十几个版本,不必等下一次下载。
|
|
1269
|
+
setTimeout(() => {
|
|
1270
|
+
void api.pruneOldReleases?.().catch?. ((error) => {
|
|
1271
|
+
ctx.logger?.warn?.(`prts-corpus: 启动清理旧版本失败:${error?.message ?? error}`)
|
|
1272
|
+
})
|
|
1273
|
+
}, 15_000).unref?.()
|
|
1163
1274
|
const endpoints = Object.freeze({
|
|
1164
1275
|
status: ['GET', '/api/prts-corpus/status'],
|
|
1165
1276
|
releases: ['GET', '/api/prts-corpus/releases'],
|
|
@@ -1169,6 +1280,7 @@ export function applyUi(ctx, shared) {
|
|
|
1169
1280
|
download: ['POST', '/api/prts-corpus/download'],
|
|
1170
1281
|
activate: ['POST', '/api/prts-corpus/activate'],
|
|
1171
1282
|
delete: ['POST', '/api/prts-corpus/delete'],
|
|
1283
|
+
prune: ['POST', '/api/prts-corpus/prune'],
|
|
1172
1284
|
read: ['POST', '/api/prts-corpus/read'],
|
|
1173
1285
|
'archive.search': ['POST', '/api/prts-corpus/archive/search'],
|
|
1174
1286
|
'archive.cloud-search': ['POST', '/api/prts-corpus/archive/cloud-search'],
|
|
@@ -1299,8 +1411,20 @@ export function applyUi(ctx, shared) {
|
|
|
1299
1411
|
|
|
1300
1412
|
function applyWebAssets(ctx, shared) {
|
|
1301
1413
|
const webServer = ctx.webServer
|
|
1414
|
+
const registerAsset = (route) => webServer.register({
|
|
1415
|
+
...route,
|
|
1416
|
+
handler: (req, res) => {
|
|
1417
|
+
// Match /api admission before serving legacy paths. Older Connection
|
|
1418
|
+
// providers expose no admit helper and retain their static-asset behavior.
|
|
1419
|
+
const admission = ctx.connection.admit?.(req)
|
|
1420
|
+
if (admission && 'rejection' in admission) {
|
|
1421
|
+
res.writeHead(admission.rejection); res.end(); return
|
|
1422
|
+
}
|
|
1423
|
+
return route.handler(req, res)
|
|
1424
|
+
},
|
|
1425
|
+
})
|
|
1302
1426
|
ctx.effect(() => {
|
|
1303
|
-
const disposeSkin =
|
|
1427
|
+
const disposeSkin = registerAsset({
|
|
1304
1428
|
kind: 'exact', path: '/prts-corpus/ui-skin.json',
|
|
1305
1429
|
handler: (req, res) => {
|
|
1306
1430
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
@@ -1311,31 +1435,31 @@ function applyWebAssets(ctx, shared) {
|
|
|
1311
1435
|
res.end(req.method === 'HEAD' ? undefined : body)
|
|
1312
1436
|
},
|
|
1313
1437
|
})
|
|
1314
|
-
const disposeAgentSkin =
|
|
1438
|
+
const disposeAgentSkin = registerAsset({
|
|
1315
1439
|
kind: 'exact', path: '/prts-corpus/skins/prts-agent.css',
|
|
1316
1440
|
handler: (req, res) => serveSkinStylesheet(req, res, 'prts-agent.css'),
|
|
1317
1441
|
})
|
|
1318
|
-
const disposeCommonSkin =
|
|
1442
|
+
const disposeCommonSkin = registerAsset({
|
|
1319
1443
|
kind: 'exact', path: '/prts-corpus/skins/common.css',
|
|
1320
1444
|
handler: (req, res) => serveSkinStylesheet(req, res, 'common.css'),
|
|
1321
1445
|
})
|
|
1322
|
-
const disposeAicSkin =
|
|
1446
|
+
const disposeAicSkin = registerAsset({
|
|
1323
1447
|
kind: 'exact', path: '/prts-corpus/skins/endfield-aic.css',
|
|
1324
1448
|
handler: (req, res) => serveSkinStylesheet(req, res, 'endfield-aic.css'),
|
|
1325
1449
|
})
|
|
1326
|
-
const disposeRhineSkin =
|
|
1450
|
+
const disposeRhineSkin = registerAsset({
|
|
1327
1451
|
kind: 'exact', path: '/prts-corpus/skins/rhine-lab.css',
|
|
1328
1452
|
handler: (req, res) => serveSkinStylesheet(req, res, 'rhine-lab.css'),
|
|
1329
1453
|
})
|
|
1330
|
-
const disposeRhineAssets = Object.keys(RHINE_ASSETS).map((relative) =>
|
|
1454
|
+
const disposeRhineAssets = Object.keys(RHINE_ASSETS).map((relative) => registerAsset({
|
|
1331
1455
|
kind: 'exact', path: `/prts-corpus/rhine/${relative}`,
|
|
1332
1456
|
handler: (req, res) => serveRhineAsset(req, res, relative),
|
|
1333
1457
|
}))
|
|
1334
|
-
const disposeBundle =
|
|
1458
|
+
const disposeBundle = registerAsset({
|
|
1335
1459
|
kind: 'prefix', path: '/prts-corpus/endfield-map',
|
|
1336
1460
|
handler: (req, res) => serveEndfieldMapAsset(req, res, '/prts-corpus/endfield-map'),
|
|
1337
1461
|
})
|
|
1338
|
-
const disposeResources =
|
|
1462
|
+
const disposeResources = registerAsset({
|
|
1339
1463
|
kind: 'prefix', path: '/webmap3d/resources',
|
|
1340
1464
|
handler: (req, res) => serveEndfieldMapAsset(req, res, '/webmap3d'),
|
|
1341
1465
|
})
|
package/src/web-guidance.js
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
/** Observe the official web tools without replacing providers or handling their credentials. */
|
|
2
2
|
const searchSetup = 'DSH 网页搜索的凭据独立于桌面账号登录。缺少 Key 时请告知用户在插件 → 网页搜索中配置搜索提供方的 API Key;旧版可在设置 → 模型配置对应凭据。不要索取聊天中的密钥,不要把登录 token 当 API Key,也不要自行更换端点。'
|
|
3
3
|
const fetchSetup = 'web_fetch 不需要网页搜索的 API Key。单个站点连接失败不能推断整个网络不可用;可换用另一个已知的权威来源核验。若多个站点持续失败,提示用户检查 DSH 启动进程的网络/HTTPS_PROXY 配置并重启,不要猜测代理地址或关闭证书验证。'
|
|
4
|
+
const fetchCredentialSetup = '当前联网抓取由 DeepSeek 官方接口提供,需要与聊天相同的 API Key:请提示用户在模型配置中补齐该凭据,或改回默认的匿名 HTTP 抓取。单个站点失败不能推断整个网络不可用;配置未改变前不要重复抓取同一个 URL。'
|
|
5
|
+
|
|
6
|
+
// 0.2.x 有两套 DeepSeek 联网实现:聊天同源的 deepseek-official 报 "DeepSeek search has no
|
|
7
|
+
// API key",新版 Search/Fetch 接口报 "DeepSeek web access has no API key"。两者都带
|
|
8
|
+
// WEB_PROVIDER_CREDENTIAL_MISSING,文案匹配保证旧宿主与新版开关都能命中。
|
|
9
|
+
const credentialMissing = (code, text) => code === 'WEB_PROVIDER_CREDENTIAL_MISSING'
|
|
10
|
+
|| /DeepSeek (?:search|web access) has no API key/.test(text)
|
|
4
11
|
|
|
5
12
|
export function webFailureGuidance(name, result) {
|
|
6
13
|
if (!['web_search', 'web_fetch'].includes(name) || !result.isError) return null
|
|
7
14
|
const code = result.error?.info?.code || result.error?.code || ''
|
|
8
15
|
const text = (result.content || []).filter(block => block.type === 'text').map(block => block.text).join('\n')
|
|
9
16
|
if (/ABORT|CANCEL/.test(code) || /aborted|cancelled|已取消/i.test(text)) return null
|
|
10
|
-
if (name === 'web_search' && (code
|
|
17
|
+
if (name === 'web_search' && credentialMissing(code, text)) {
|
|
11
18
|
return { code: 'WEB_PROVIDER_CREDENTIAL_MISSING', guidance: searchSetup + '配置未改变前不要重复搜索;继续本地核验,并明确说明现实资料尚未完成联网查证。' }
|
|
12
19
|
}
|
|
20
|
+
if (name === 'web_fetch' && credentialMissing(code, text)) {
|
|
21
|
+
return { code: 'WEB_PROVIDER_CREDENTIAL_MISSING', guidance: fetchCredentialSetup }
|
|
22
|
+
}
|
|
13
23
|
if (name === 'web_fetch' && /web fetch failed/.test(text)) {
|
|
14
24
|
return { code: 'WEB_FETCH_CONNECTION_FAILED', guidance: fetchSetup }
|
|
15
25
|
}
|
|
@@ -34,7 +44,9 @@ export function mountWebGuidance(ctx) {
|
|
|
34
44
|
states.set(exec.agent, state)
|
|
35
45
|
} else if (!result.isError) state.delete(key)
|
|
36
46
|
})
|
|
37
|
-
|
|
47
|
+
// interpolate:false keeps literal text: DSH 0.2.1 resolves {{name}} in contexts
|
|
48
|
+
// and throws on unknown names, while this text is data/receipt prose.
|
|
49
|
+
ctx.systemPrompt.context({ name: 'prts-terrarchive:web-access', order: 1006, interpolate: false, text: ({ scope }) => {
|
|
38
50
|
const issues = scope && states.get(scope)
|
|
39
51
|
if (!issues?.size) return ''
|
|
40
52
|
return '<prts:web-access>\n以下是本会话最近的联网工具故障及恢复指引。不得把未抓取到的网页或模型记忆说成已联网核实的来源。\n'
|