@musnows/scriverse 0.4.11 → 0.4.12
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/dist/ai.js +209 -17
- package/dist/ai.js.map +1 -1
- package/dist/app.js +39 -1
- package/dist/app.js.map +1 -1
- package/dist/public/ai-message-meta.js +7 -2
- package/dist/public/app.js +181 -13
- package/dist/public/index.html +3 -3
- package/dist/public/styles.css +38 -0
- package/dist/store.js +21 -6
- package/dist/store.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/ai.js
CHANGED
|
@@ -202,6 +202,43 @@ export function resolveOutputTokens(usage, content) {
|
|
|
202
202
|
}
|
|
203
203
|
return estimateAiTokens(content);
|
|
204
204
|
}
|
|
205
|
+
function resolveInputCacheUsage(usage) {
|
|
206
|
+
if (!usage || typeof usage !== "object")
|
|
207
|
+
return null;
|
|
208
|
+
const record = usage;
|
|
209
|
+
const promptDetails = record.prompt_tokens_details && typeof record.prompt_tokens_details === "object"
|
|
210
|
+
? record.prompt_tokens_details
|
|
211
|
+
: {};
|
|
212
|
+
const inputDetails = record.input_tokens_details && typeof record.input_tokens_details === "object"
|
|
213
|
+
? record.input_tokens_details
|
|
214
|
+
: {};
|
|
215
|
+
const cached = promptDetails.cached_tokens
|
|
216
|
+
?? inputDetails.cached_tokens
|
|
217
|
+
?? record.prompt_cache_hit_tokens
|
|
218
|
+
?? record.cache_read_input_tokens
|
|
219
|
+
?? record.cached_input_tokens;
|
|
220
|
+
if (typeof cached !== "number" || !Number.isFinite(cached))
|
|
221
|
+
return null;
|
|
222
|
+
const reportedInput = record.prompt_tokens ?? record.input_tokens;
|
|
223
|
+
const missed = record.prompt_cache_miss_tokens;
|
|
224
|
+
const inputTokens = typeof reportedInput === "number" && Number.isFinite(reportedInput)
|
|
225
|
+
? Math.max(0, Math.round(reportedInput))
|
|
226
|
+
: typeof missed === "number" && Number.isFinite(missed)
|
|
227
|
+
? Math.max(0, Math.round(cached)) + Math.max(0, Math.round(missed))
|
|
228
|
+
: 0;
|
|
229
|
+
if (inputTokens <= 0)
|
|
230
|
+
return null;
|
|
231
|
+
return {
|
|
232
|
+
inputTokens,
|
|
233
|
+
cachedInputTokens: Math.min(inputTokens, Math.max(0, Math.round(cached)))
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
export function resolveCacheHitPercent(usage) {
|
|
237
|
+
const resolved = resolveInputCacheUsage(usage);
|
|
238
|
+
if (!resolved)
|
|
239
|
+
return undefined;
|
|
240
|
+
return Math.round(resolved.cachedInputTokens / resolved.inputTokens * 1_000) / 10;
|
|
241
|
+
}
|
|
205
242
|
function normalizeModelPreset(input, modelId = "") {
|
|
206
243
|
const maxTokens = typeof input.max_tokens === "number" && Number.isFinite(input.max_tokens)
|
|
207
244
|
? Math.round(clamp(input.max_tokens, 1, 32_768))
|
|
@@ -428,13 +465,16 @@ export class ContextBuilder {
|
|
|
428
465
|
? [`作品:${String(work.title)}\n作者:${String(work.author) || "未填写"}`]
|
|
429
466
|
: [];
|
|
430
467
|
const contentSections = [];
|
|
431
|
-
const
|
|
468
|
+
const availableSettings = this.store.listSettings(workId);
|
|
469
|
+
const contextualSettings = scope.includeAllSettings ? availableSettings : availableSettings.filter((item) => item.locked);
|
|
432
470
|
const allCharacters = this.store.listCharacters(workId);
|
|
433
471
|
const lockedCharacters = allCharacters.filter((item) => Array.isArray(item.lockedFields) && item.lockedFields.length > 0);
|
|
434
472
|
const organizations = this.store.listOrganizations(workId);
|
|
435
|
-
const relationshipConstraints =
|
|
436
|
-
|
|
437
|
-
|
|
473
|
+
const relationshipConstraints = scope.excludeRelationshipConstraints
|
|
474
|
+
? []
|
|
475
|
+
: selectRelationshipConstraints(this.store, workId, scope.characterIds ?? []);
|
|
476
|
+
if (includeAutomaticContext && contextualSettings.length > 0) {
|
|
477
|
+
constraints.push(`${scope.includeAllSettings ? "全部作品设定(关系分析参考)" : "作者锁定设定(硬约束)"}:\n${contextualSettings
|
|
438
478
|
.map((item) => `- [${String(item.category)}] ${String(item.title)}:${String(item.content)}`)
|
|
439
479
|
.join("\n")}`);
|
|
440
480
|
}
|
|
@@ -1020,7 +1060,7 @@ export class AiManager {
|
|
|
1020
1060
|
source_text, content, action, status, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, suggestionId, generated.callId, input.workId, chapter ? String(chapter.id) : null, chapter ? Number(chapter.versionNo) : null, input.taskType, input.instruction, effectiveInput.scope.selection ?? "", generated.content, action, now(), currentRequestActor()?.userId ?? null);
|
|
1021
1061
|
if (input.taskType === "continue")
|
|
1022
1062
|
await this.runSuggestionGuard(suggestionId);
|
|
1023
|
-
return { ...this.getSuggestion(suggestionId), outputTokens: generated.outputTokens, toolCalls: generated.toolCalls, processSteps: generated.processSteps };
|
|
1063
|
+
return { ...this.getSuggestion(suggestionId), outputTokens: generated.outputTokens, ...(generated.cacheHitPercent === undefined ? {} : { cacheHitPercent: generated.cacheHitPercent }), toolCalls: generated.toolCalls, processSteps: generated.processSteps };
|
|
1024
1064
|
}
|
|
1025
1065
|
async createStreamingChat(input, onDelta) {
|
|
1026
1066
|
const generated = this.enabledAgentTools(input.workId, "chat").length
|
|
@@ -1032,7 +1072,7 @@ export class AiManager {
|
|
|
1032
1072
|
const suggestionId = id("suggestion");
|
|
1033
1073
|
this.store.db.run(`INSERT INTO ai_suggestions (id, call_id, work_id, chapter_id, chapter_version, task_type, instruction,
|
|
1034
1074
|
source_text, content, action, status, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, 'chat', ?, ?, ?, 'note', 'pending', ?, ?)`, suggestionId, generated.callId, input.workId, chapter ? String(chapter.id) : null, chapter ? Number(chapter.versionNo) : null, input.instruction, input.scope.selection ?? "", generated.content, now(), currentRequestActor()?.userId ?? null);
|
|
1035
|
-
return { ...this.getSuggestion(suggestionId), outputTokens: generated.outputTokens, toolCalls: generated.toolCalls, processSteps: generated.processSteps };
|
|
1075
|
+
return { ...this.getSuggestion(suggestionId), outputTokens: generated.outputTokens, ...(generated.cacheHitPercent === undefined ? {} : { cacheHitPercent: generated.cacheHitPercent }), toolCalls: generated.toolCalls, processSteps: generated.processSteps };
|
|
1036
1076
|
}
|
|
1037
1077
|
async runSuggestionGuard(suggestionId, candidateContent) {
|
|
1038
1078
|
const suggestion = this.getSuggestion(suggestionId);
|
|
@@ -1750,6 +1790,10 @@ export class AiManager {
|
|
|
1750
1790
|
const endpoint = `${normalizeBaseUrl(stringValue(provider, "base_url"))}/chat/completions`;
|
|
1751
1791
|
const timeoutMs = input.taskType === "book-analysis" || input.taskType === "relationship-analysis" ? 300_000 : 60_000;
|
|
1752
1792
|
const maximumAttempts = Math.round(clamp(input.maxAttempts ?? 3, 1, 5));
|
|
1793
|
+
let completionRequestCount = 0;
|
|
1794
|
+
let cacheUsageComplete = true;
|
|
1795
|
+
let totalInputTokens = 0;
|
|
1796
|
+
let totalCachedInputTokens = 0;
|
|
1753
1797
|
const requestCompletion = async (toolChoice) => {
|
|
1754
1798
|
let lastFailure = null;
|
|
1755
1799
|
for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
|
|
@@ -1793,7 +1837,16 @@ export class AiManager {
|
|
|
1793
1837
|
});
|
|
1794
1838
|
if (candidate.ok) {
|
|
1795
1839
|
try {
|
|
1796
|
-
|
|
1840
|
+
const parsed = JSON.parse(candidate.body);
|
|
1841
|
+
completionRequestCount += 1;
|
|
1842
|
+
const cacheUsage = resolveInputCacheUsage(parsed.usage);
|
|
1843
|
+
if (!cacheUsage)
|
|
1844
|
+
cacheUsageComplete = false;
|
|
1845
|
+
else {
|
|
1846
|
+
totalInputTokens += cacheUsage.inputTokens;
|
|
1847
|
+
totalCachedInputTokens += cacheUsage.cachedInputTokens;
|
|
1848
|
+
}
|
|
1849
|
+
return parsed;
|
|
1797
1850
|
}
|
|
1798
1851
|
catch {
|
|
1799
1852
|
throw new Error(`Chat Completions returned invalid JSON: ${candidate.body.slice(0, 500)}`);
|
|
@@ -1897,6 +1950,9 @@ export class AiManager {
|
|
|
1897
1950
|
}
|
|
1898
1951
|
this.store.db.run("UPDATE ai_calls SET status = 'completed', output_chars = ?, completed_at = ? WHERE id = ?", content.length, now(), callId);
|
|
1899
1952
|
const outputTokens = resolveOutputTokens(payload.usage, content);
|
|
1953
|
+
const cacheHitPercent = cacheUsageComplete && completionRequestCount > 0 && totalInputTokens > 0
|
|
1954
|
+
? Math.round(totalCachedInputTokens / totalInputTokens * 1_000) / 10
|
|
1955
|
+
: undefined;
|
|
1900
1956
|
logger.info("ai.call.completed", {
|
|
1901
1957
|
callId,
|
|
1902
1958
|
workId: input.workId,
|
|
@@ -1907,7 +1963,7 @@ export class AiManager {
|
|
|
1907
1963
|
outputTokens,
|
|
1908
1964
|
toolCallCount: executedToolCalls.length
|
|
1909
1965
|
});
|
|
1910
|
-
return { callId, content, outputTokens, provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: executedToolCalls, processSteps };
|
|
1966
|
+
return { callId, content, outputTokens, ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }), provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: executedToolCalls, processSteps };
|
|
1911
1967
|
}
|
|
1912
1968
|
catch (error) {
|
|
1913
1969
|
const message = error instanceof Error ? error.message : "AI 调用失败";
|
|
@@ -2024,7 +2080,7 @@ export class AiManager {
|
|
|
2024
2080
|
}
|
|
2025
2081
|
if (streamedResult === null)
|
|
2026
2082
|
throw lastFailure instanceof Error ? lastFailure : new Error("AI 流式请求重试后仍未返回响应");
|
|
2027
|
-
const { content, reasoning, outputTokens } = streamedResult;
|
|
2083
|
+
const { content, reasoning, outputTokens, cacheHitPercent } = streamedResult;
|
|
2028
2084
|
const processSteps = reasoning.trim()
|
|
2029
2085
|
? [{ id: thinkingStepId, type: "thinking", round: 1, content: reasoning, createdAt: thinkingCreatedAt }]
|
|
2030
2086
|
: [];
|
|
@@ -2038,7 +2094,7 @@ export class AiManager {
|
|
|
2038
2094
|
outputChars: content.length,
|
|
2039
2095
|
outputTokens
|
|
2040
2096
|
});
|
|
2041
|
-
return { callId, content, outputTokens, provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: [], processSteps };
|
|
2097
|
+
return { callId, content, outputTokens, ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }), provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: [], processSteps };
|
|
2042
2098
|
}
|
|
2043
2099
|
catch (error) {
|
|
2044
2100
|
const message = error instanceof Error ? error.message : "AI 流式调用失败";
|
|
@@ -2105,7 +2161,8 @@ export class AiManager {
|
|
|
2105
2161
|
consumeEvent(buffer);
|
|
2106
2162
|
if (!content.trim())
|
|
2107
2163
|
throw new Error(`Chat Completions 流式响应缺少可用正文,finish_reason=${finishReason}`);
|
|
2108
|
-
|
|
2164
|
+
const cacheHitPercent = resolveCacheHitPercent(usage);
|
|
2165
|
+
return { content, reasoning, outputTokens: resolveOutputTokens(usage, content), ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }) };
|
|
2109
2166
|
}
|
|
2110
2167
|
async runChapterAnalysis(workId, scope, modelId, taskId) {
|
|
2111
2168
|
if (!scope.chapterId)
|
|
@@ -2991,6 +3048,17 @@ export class AiManager {
|
|
|
2991
3048
|
const characters = this.store.listCharacters(workId);
|
|
2992
3049
|
if (characters.length < 2)
|
|
2993
3050
|
throw new AppError(409, "CHARACTERS_REQUIRED", "人物关系分析至少需要两个角色档案");
|
|
3051
|
+
const selectedCharacterIds = new Set(scope.characterIds ?? []);
|
|
3052
|
+
for (const characterId of selectedCharacterIds) {
|
|
3053
|
+
const character = characters.find((item) => item.id === characterId);
|
|
3054
|
+
if (!character)
|
|
3055
|
+
throw new AppError(400, "CHARACTER_WORK_MISMATCH", "被分析角色不属于当前作品");
|
|
3056
|
+
}
|
|
3057
|
+
const targeted = selectedCharacterIds.size > 0;
|
|
3058
|
+
const targetedRoster = characters
|
|
3059
|
+
.filter((character) => selectedCharacterIds.has(String(character.id)))
|
|
3060
|
+
.map((character) => `${String(character.id)} | ${String(character.name)}`)
|
|
3061
|
+
.join("\n");
|
|
2994
3062
|
const chapters = this.getScopeChapters(workId, scope);
|
|
2995
3063
|
if (chapters.length === 0)
|
|
2996
3064
|
throw new AppError(409, "CHAPTERS_REQUIRED", "人物关系分析范围内没有章节");
|
|
@@ -3008,10 +3076,28 @@ export class AiManager {
|
|
|
3008
3076
|
taskType: "relationship-analysis",
|
|
3009
3077
|
signal: this.taskSignal(taskId),
|
|
3010
3078
|
maxAttempts,
|
|
3011
|
-
scope: {
|
|
3079
|
+
scope: {
|
|
3080
|
+
type: "selection",
|
|
3081
|
+
selection: text,
|
|
3082
|
+
includeAllSettings: scope.includeAllSettings,
|
|
3083
|
+
...(targeted ? { characterIds: [...selectedCharacterIds], excludeRelationshipConstraints: scope.replaceExistingRelationships === true } : {})
|
|
3084
|
+
},
|
|
3012
3085
|
...(modelId ? { modelId } : {}),
|
|
3013
3086
|
parameters: { temperature: 0.1 },
|
|
3014
|
-
instruction: [
|
|
3087
|
+
instruction: targeted ? [
|
|
3088
|
+
"你是定向人物关系证据收集器。本阶段只建立跨章节证据账本,不下最终关系结论。",
|
|
3089
|
+
"被分析角色:",
|
|
3090
|
+
targetedRoster,
|
|
3091
|
+
"完整角色规范表:",
|
|
3092
|
+
roster,
|
|
3093
|
+
"规则:",
|
|
3094
|
+
"1. 只记录与至少一名被分析角色直接有关的互动、称谓、亲缘线索、权力行为、情感变化、冲突、回忆或第三方陈述。",
|
|
3095
|
+
"2. 单次见面、同场出现和含糊代词可以作为待汇总线索,但必须如实描述,不能在本阶段升级为长期关系。",
|
|
3096
|
+
"3. 人物引用优先填写规范表中的 characterId;暂时不能确定对方身份时填写 relatedReference,禁止创造角色。",
|
|
3097
|
+
"4. 每条线索只引用一个连续原文短句,quote 不超过 80 字,并准确提供 chapterId、chapterTitle 和 contextType。",
|
|
3098
|
+
"5. 输出 JSON 数组。字段:targetCharacterId、relatedCharacterId、relatedReference、observation、possibleCategory、possibleSubtype、directionHint、timeHint、chapterId、chapterTitle、quote、contextType。",
|
|
3099
|
+
"6. 没有与目标角色直接相关的线索时输出 []。"
|
|
3100
|
+
].join("\n") : [
|
|
3015
3101
|
"你是小说人物关系抽取器,不是续写者。只抽取角色规范表中人物之间、对跨章节人物图有长期意义且有原文证据的关系。",
|
|
3016
3102
|
"角色规范表:",
|
|
3017
3103
|
roster,
|
|
@@ -3042,7 +3128,12 @@ export class AiManager {
|
|
|
3042
3128
|
"23. 输出 JSON 数组。字段:fromCharacterId、toCharacterId、category(family/social/emotional/conflict/uncertain)、subtype、keywords、directed、currentStatus、timeRange、confidence、evidence。",
|
|
3043
3129
|
"24. 共同执行一次任务、同属一个组织、在同一集体场景中被感谢或落泪、替第三人转发消息,都不能单独证明同事、朋友或盟友。此类关系必须有原文明示身份,或至少两个不同章节的持续互动证据。"
|
|
3044
3130
|
].join("\n"),
|
|
3045
|
-
extraSystemPrompt:
|
|
3131
|
+
extraSystemPrompt: [
|
|
3132
|
+
targeted
|
|
3133
|
+
? "你正在为指定角色收集可审计的跨章节关系线索。不得在证据收集阶段把单次互动直接判定为长期关系。"
|
|
3134
|
+
: "关系候选必须可审计。严禁把梦境伴侣、醉后梦话、单次约定、同章共现、礼称、同族归属、救援照护或类比提及写成现实长期关系。逐句校验说话人和关系方向。",
|
|
3135
|
+
scope.additionalPrompt?.trim() ? `作者追加的关系分析提示:\n${scope.additionalPrompt.trim()}` : ""
|
|
3136
|
+
].filter(Boolean).join("\n\n")
|
|
3046
3137
|
});
|
|
3047
3138
|
const extracted = extractJson(generated.content);
|
|
3048
3139
|
if (!Array.isArray(extracted))
|
|
@@ -3066,7 +3157,8 @@ export class AiManager {
|
|
|
3066
3157
|
}
|
|
3067
3158
|
}, (completed) => {
|
|
3068
3159
|
if (taskId && this.store.getTask(taskId).status === "running") {
|
|
3069
|
-
|
|
3160
|
+
const maximumProgress = targeted ? 72 : 92;
|
|
3161
|
+
this.store.updateTask(taskId, { status: "running", progress: Math.min(maximumProgress, 5 + Math.round(completed / chunks.length * (maximumProgress - 5))) });
|
|
3070
3162
|
}
|
|
3071
3163
|
});
|
|
3072
3164
|
let fallbackSegmentCount = 0;
|
|
@@ -3087,6 +3179,87 @@ export class AiManager {
|
|
|
3087
3179
|
batchCount: chunks.length
|
|
3088
3180
|
});
|
|
3089
3181
|
}
|
|
3182
|
+
const targetedEvidenceCount = targeted ? rawCandidates.length : 0;
|
|
3183
|
+
let aggregationBatchCount = 0;
|
|
3184
|
+
if (targeted && rawCandidates.length > 0) {
|
|
3185
|
+
const evidenceGroups = new Map();
|
|
3186
|
+
for (const evidence of rawCandidates) {
|
|
3187
|
+
const target = String(evidence.targetCharacterId ?? "");
|
|
3188
|
+
const related = String(evidence.relatedCharacterId ?? evidence.relatedReference ?? "unknown");
|
|
3189
|
+
const key = `${target}|${related}`;
|
|
3190
|
+
const group = evidenceGroups.get(key) ?? [];
|
|
3191
|
+
group.push(evidence);
|
|
3192
|
+
evidenceGroups.set(key, group);
|
|
3193
|
+
}
|
|
3194
|
+
const evidenceBatches = [];
|
|
3195
|
+
let currentBatch = [];
|
|
3196
|
+
let currentLength = 0;
|
|
3197
|
+
for (const group of evidenceGroups.values()) {
|
|
3198
|
+
const groupLength = JSON.stringify(group).length;
|
|
3199
|
+
if (currentBatch.length > 0 && currentLength + groupLength > 60_000) {
|
|
3200
|
+
evidenceBatches.push(currentBatch);
|
|
3201
|
+
currentBatch = [];
|
|
3202
|
+
currentLength = 0;
|
|
3203
|
+
}
|
|
3204
|
+
currentBatch.push(...group);
|
|
3205
|
+
currentLength += groupLength;
|
|
3206
|
+
}
|
|
3207
|
+
if (currentBatch.length > 0)
|
|
3208
|
+
evidenceBatches.push(currentBatch);
|
|
3209
|
+
aggregationBatchCount = evidenceBatches.length;
|
|
3210
|
+
const aggregationResults = await this.processChunks(evidenceBatches, Math.min(concurrency, 4), async (evidenceBatch) => {
|
|
3211
|
+
const generated = await this.generateTaggedJson({
|
|
3212
|
+
workId,
|
|
3213
|
+
taskType: "relationship-analysis",
|
|
3214
|
+
signal: this.taskSignal(taskId),
|
|
3215
|
+
maxAttempts: 2,
|
|
3216
|
+
scope: {
|
|
3217
|
+
type: "entities",
|
|
3218
|
+
includeAllSettings: scope.includeAllSettings,
|
|
3219
|
+
characterIds: [...selectedCharacterIds],
|
|
3220
|
+
excludeRelationshipConstraints: scope.replaceExistingRelationships === true
|
|
3221
|
+
},
|
|
3222
|
+
...(modelId ? { modelId } : {}),
|
|
3223
|
+
parameters: { temperature: 0.1 },
|
|
3224
|
+
instruction: [
|
|
3225
|
+
"你是小说人物关系全局归纳器。请综合分析范围内为指定角色收集的全部跨章节证据线索,形成最终长期关系候选。",
|
|
3226
|
+
"被分析角色:",
|
|
3227
|
+
targetedRoster,
|
|
3228
|
+
"完整角色规范表:",
|
|
3229
|
+
roster,
|
|
3230
|
+
"证据账本:",
|
|
3231
|
+
JSON.stringify(evidenceBatch),
|
|
3232
|
+
"归纳规则:",
|
|
3233
|
+
"1. 只输出至少一端属于被分析角色的关系,另一端也必须解析为角色规范表中的 characterId。",
|
|
3234
|
+
"2. 综合不同章节、不同阶段和设定信息判断关系;设定只用于身份消歧和辅助理解,不能代替章节原文证据。",
|
|
3235
|
+
"3. 单次见面、同场出现、一次任务协作、同组织或同族不能单独升级为长期朋友、同事、盟友、君臣或亲属。",
|
|
3236
|
+
"4. evidence 只能使用证据账本中的连续原文 quote,必须包含 chapterId、chapterTitle、quote、contextType、supports;quote 不超过 80 字。",
|
|
3237
|
+
"5. category 只能是 family、social、emotional、conflict、uncertain;confidence 低于 0.6 不输出。",
|
|
3238
|
+
"6. subtype 使用稳定简短中文词;父母子女、君臣、师生、倾慕、施害与受害等有方向关系必须正确设置 from、to 和 directed=true。",
|
|
3239
|
+
"7. 同一人物对的阶段变化合并进 timeRange.stages;同一 category/subtype 不得输出反向重复边。",
|
|
3240
|
+
"8. keywords 提供 2 至 8 个描述双方互动、权力结构、情感阶段或剧情张力的中文关键词。",
|
|
3241
|
+
"9. 输出 JSON 数组。字段:fromCharacterId、toCharacterId、category、subtype、keywords、directed、currentStatus、timeRange、confidence、evidence。"
|
|
3242
|
+
].join("\n"),
|
|
3243
|
+
extraSystemPrompt: [
|
|
3244
|
+
"你正在执行指定角色的跨章节关系归纳。所有结论必须能回溯到证据账本中的章节原文,不得沿用缺乏本次证据的旧关系。",
|
|
3245
|
+
scope.additionalPrompt?.trim() ? `作者追加的关系分析提示:\n${scope.additionalPrompt.trim()}` : ""
|
|
3246
|
+
].filter(Boolean).join("\n\n")
|
|
3247
|
+
});
|
|
3248
|
+
const extracted = extractJson(generated.content);
|
|
3249
|
+
if (!Array.isArray(extracted))
|
|
3250
|
+
throw new AppError(502, "AI_INVALID_JSON", "定向人物关系归纳结果必须是数组");
|
|
3251
|
+
return {
|
|
3252
|
+
candidates: extracted.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item)),
|
|
3253
|
+
callId: generated.callId
|
|
3254
|
+
};
|
|
3255
|
+
}, (completed) => {
|
|
3256
|
+
if (taskId && this.store.getTask(taskId).status === "running") {
|
|
3257
|
+
this.store.updateTask(taskId, { status: "running", progress: Math.min(92, 72 + Math.round(completed / evidenceBatches.length * 20)) });
|
|
3258
|
+
}
|
|
3259
|
+
});
|
|
3260
|
+
rawCandidates.splice(0, rawCandidates.length, ...aggregationResults.flatMap((result) => result.candidates));
|
|
3261
|
+
callIds.push(...aggregationResults.map((result) => result.callId));
|
|
3262
|
+
}
|
|
3090
3263
|
const chapterById = new Map(chapters.map((chapter) => [String(chapter.id), chapter]));
|
|
3091
3264
|
const categories = new Set(["family", "social", "emotional", "conflict", "uncertain"]);
|
|
3092
3265
|
const merged = new Map();
|
|
@@ -3104,6 +3277,10 @@ export class AiManager {
|
|
|
3104
3277
|
skipped.push({ index, reason: "人物引用无效" });
|
|
3105
3278
|
return;
|
|
3106
3279
|
}
|
|
3280
|
+
if (targeted && !selectedCharacterIds.has(fromResolved) && !selectedCharacterIds.has(toResolved)) {
|
|
3281
|
+
skipped.push({ index, reason: "关系不涉及本次选定角色" });
|
|
3282
|
+
return;
|
|
3283
|
+
}
|
|
3107
3284
|
if (typeof candidate.category !== "string" || !categories.has(candidate.category)) {
|
|
3108
3285
|
skipped.push({ index, reason: "关系分类无效" });
|
|
3109
3286
|
return;
|
|
@@ -3213,10 +3390,17 @@ export class AiManager {
|
|
|
3213
3390
|
merged.delete(key);
|
|
3214
3391
|
}
|
|
3215
3392
|
const relationshipIds = [];
|
|
3393
|
+
let replacedRelationshipCount = 0;
|
|
3216
3394
|
this.store.db.transaction(() => {
|
|
3217
|
-
if (scope.type === "book") {
|
|
3395
|
+
if (!targeted && scope.type === "book") {
|
|
3218
3396
|
this.store.db.run("DELETE FROM relationships WHERE work_id = ? AND confirmation_status = 'pending' AND locked = 0", workId);
|
|
3219
3397
|
}
|
|
3398
|
+
if (targeted && scope.replaceExistingRelationships === true) {
|
|
3399
|
+
const relationshipsToReplace = this.store.listRelationships(workId).filter((relationship) => selectedCharacterIds.has(String(relationship.fromCharacterId)) || selectedCharacterIds.has(String(relationship.toCharacterId)));
|
|
3400
|
+
for (const relationship of relationshipsToReplace)
|
|
3401
|
+
this.store.deleteRelationship(String(relationship.id));
|
|
3402
|
+
replacedRelationshipCount = relationshipsToReplace.length;
|
|
3403
|
+
}
|
|
3220
3404
|
const existing = this.store.listRelationships(workId).filter((relationship) => relationship.confirmationStatus !== "rejected");
|
|
3221
3405
|
const unorderedPairKey = (fromCharacterId, toCharacterId) => {
|
|
3222
3406
|
const pair = [String(fromCharacterId), String(toCharacterId)].sort((left, right) => left.localeCompare(right));
|
|
@@ -3413,7 +3597,11 @@ export class AiManager {
|
|
|
3413
3597
|
skippedCount: skipped.length,
|
|
3414
3598
|
fallbackSegmentCount,
|
|
3415
3599
|
policyOmittedSegmentCount,
|
|
3416
|
-
scopeType: scope.type
|
|
3600
|
+
scopeType: scope.type,
|
|
3601
|
+
targetedCharacterCount: selectedCharacterIds.size,
|
|
3602
|
+
targetedEvidenceCount,
|
|
3603
|
+
aggregationBatchCount,
|
|
3604
|
+
replacedRelationshipCount
|
|
3417
3605
|
});
|
|
3418
3606
|
return {
|
|
3419
3607
|
relationshipIds,
|
|
@@ -3424,6 +3612,10 @@ export class AiManager {
|
|
|
3424
3612
|
coveredChapterCount: chapters.length,
|
|
3425
3613
|
fallbackSegmentCount,
|
|
3426
3614
|
policyOmittedSegmentCount,
|
|
3615
|
+
targetedCharacterIds: [...selectedCharacterIds],
|
|
3616
|
+
targetedEvidenceCount,
|
|
3617
|
+
aggregationBatchCount,
|
|
3618
|
+
replacedRelationshipCount,
|
|
3427
3619
|
callIds
|
|
3428
3620
|
};
|
|
3429
3621
|
}
|