@musnows/scriverse 0.4.11 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai.js +383 -20
- package/dist/ai.js.map +1 -1
- package/dist/app.js +69 -12
- package/dist/app.js.map +1 -1
- package/dist/database.js +55 -0
- package/dist/database.js.map +1 -1
- package/dist/pagination.js +1 -1
- package/dist/public/ai-message-meta.js +7 -2
- package/dist/public/app.js +575 -81
- package/dist/public/global-search.d.ts +12 -0
- package/dist/public/global-search.js +23 -0
- package/dist/public/index.html +18 -5
- package/dist/public/relationship-filters.d.ts +10 -0
- package/dist/public/relationship-filters.js +11 -0
- package/dist/public/relationship-graph.js +59 -3
- package/dist/public/styles.css +247 -2
- package/dist/store.js +123 -30
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +12 -0
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
package/dist/ai.js
CHANGED
|
@@ -33,6 +33,59 @@ function thinkingParameters(provider, model) {
|
|
|
33
33
|
return { thinking: { type: boolValue(model, "thinking_enabled") ? "enabled" : "disabled" } };
|
|
34
34
|
}
|
|
35
35
|
const AGENT_TOOL_IDS = ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections"];
|
|
36
|
+
function redactProviderSecret(value, apiKey) {
|
|
37
|
+
if (!apiKey)
|
|
38
|
+
return value;
|
|
39
|
+
return value.split(apiKey).join("[REDACTED]");
|
|
40
|
+
}
|
|
41
|
+
function redactProviderSecrets(value, apiKey, depth = 0) {
|
|
42
|
+
if (typeof value === "string")
|
|
43
|
+
return redactProviderSecret(value, apiKey);
|
|
44
|
+
if (value === null || typeof value === "number" || typeof value === "boolean")
|
|
45
|
+
return value;
|
|
46
|
+
if (depth >= 32)
|
|
47
|
+
return "[REDACTED_DEPTH_LIMIT]";
|
|
48
|
+
if (Array.isArray(value))
|
|
49
|
+
return value.map((item) => redactProviderSecrets(item, apiKey, depth + 1));
|
|
50
|
+
if (!value || typeof value !== "object")
|
|
51
|
+
return null;
|
|
52
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactProviderSecrets(item, apiKey, depth + 1)]));
|
|
53
|
+
}
|
|
54
|
+
function sanitizeCompletionTraceResponse(value) {
|
|
55
|
+
const response = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
56
|
+
const choices = Array.isArray(response.choices) ? response.choices : [];
|
|
57
|
+
return {
|
|
58
|
+
choices: choices.map((choice) => {
|
|
59
|
+
const choiceRecord = choice && typeof choice === "object" && !Array.isArray(choice) ? choice : {};
|
|
60
|
+
const message = choiceRecord.message && typeof choiceRecord.message === "object" && !Array.isArray(choiceRecord.message)
|
|
61
|
+
? choiceRecord.message
|
|
62
|
+
: {};
|
|
63
|
+
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
64
|
+
return {
|
|
65
|
+
finish_reason: typeof choiceRecord.finish_reason === "string" || choiceRecord.finish_reason === null ? choiceRecord.finish_reason : null,
|
|
66
|
+
message: {
|
|
67
|
+
content: typeof message.content === "string" || message.content === null ? message.content : null,
|
|
68
|
+
reasoning_content: typeof message.reasoning_content === "string" || message.reasoning_content === null ? message.reasoning_content : null,
|
|
69
|
+
tool_calls: toolCalls.map((toolCall) => {
|
|
70
|
+
const toolCallRecord = toolCall && typeof toolCall === "object" && !Array.isArray(toolCall) ? toolCall : {};
|
|
71
|
+
const fn = toolCallRecord.function && typeof toolCallRecord.function === "object" && !Array.isArray(toolCallRecord.function)
|
|
72
|
+
? toolCallRecord.function
|
|
73
|
+
: {};
|
|
74
|
+
return {
|
|
75
|
+
id: typeof toolCallRecord.id === "string" ? toolCallRecord.id : "",
|
|
76
|
+
type: "function",
|
|
77
|
+
function: {
|
|
78
|
+
name: typeof fn.name === "string" ? fn.name : "",
|
|
79
|
+
arguments: fn.arguments ?? ""
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}),
|
|
86
|
+
...(response.usage && typeof response.usage === "object" && !Array.isArray(response.usage) ? { usage: response.usage } : {})
|
|
87
|
+
};
|
|
88
|
+
}
|
|
36
89
|
const MAX_AGENT_TOOL_ROUNDS = 6;
|
|
37
90
|
const MAX_AGENT_TOOL_CALLS = 12;
|
|
38
91
|
const MAX_CONFIGURED_AGENT_TOOL_CALLS = 48;
|
|
@@ -202,6 +255,43 @@ export function resolveOutputTokens(usage, content) {
|
|
|
202
255
|
}
|
|
203
256
|
return estimateAiTokens(content);
|
|
204
257
|
}
|
|
258
|
+
function resolveInputCacheUsage(usage) {
|
|
259
|
+
if (!usage || typeof usage !== "object")
|
|
260
|
+
return null;
|
|
261
|
+
const record = usage;
|
|
262
|
+
const promptDetails = record.prompt_tokens_details && typeof record.prompt_tokens_details === "object"
|
|
263
|
+
? record.prompt_tokens_details
|
|
264
|
+
: {};
|
|
265
|
+
const inputDetails = record.input_tokens_details && typeof record.input_tokens_details === "object"
|
|
266
|
+
? record.input_tokens_details
|
|
267
|
+
: {};
|
|
268
|
+
const cached = promptDetails.cached_tokens
|
|
269
|
+
?? inputDetails.cached_tokens
|
|
270
|
+
?? record.prompt_cache_hit_tokens
|
|
271
|
+
?? record.cache_read_input_tokens
|
|
272
|
+
?? record.cached_input_tokens;
|
|
273
|
+
if (typeof cached !== "number" || !Number.isFinite(cached))
|
|
274
|
+
return null;
|
|
275
|
+
const reportedInput = record.prompt_tokens ?? record.input_tokens;
|
|
276
|
+
const missed = record.prompt_cache_miss_tokens;
|
|
277
|
+
const inputTokens = typeof reportedInput === "number" && Number.isFinite(reportedInput)
|
|
278
|
+
? Math.max(0, Math.round(reportedInput))
|
|
279
|
+
: typeof missed === "number" && Number.isFinite(missed)
|
|
280
|
+
? Math.max(0, Math.round(cached)) + Math.max(0, Math.round(missed))
|
|
281
|
+
: 0;
|
|
282
|
+
if (inputTokens <= 0)
|
|
283
|
+
return null;
|
|
284
|
+
return {
|
|
285
|
+
inputTokens,
|
|
286
|
+
cachedInputTokens: Math.min(inputTokens, Math.max(0, Math.round(cached)))
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
export function resolveCacheHitPercent(usage) {
|
|
290
|
+
const resolved = resolveInputCacheUsage(usage);
|
|
291
|
+
if (!resolved)
|
|
292
|
+
return undefined;
|
|
293
|
+
return Math.round(resolved.cachedInputTokens / resolved.inputTokens * 1_000) / 10;
|
|
294
|
+
}
|
|
205
295
|
function normalizeModelPreset(input, modelId = "") {
|
|
206
296
|
const maxTokens = typeof input.max_tokens === "number" && Number.isFinite(input.max_tokens)
|
|
207
297
|
? Math.round(clamp(input.max_tokens, 1, 32_768))
|
|
@@ -428,13 +518,16 @@ export class ContextBuilder {
|
|
|
428
518
|
? [`作品:${String(work.title)}\n作者:${String(work.author) || "未填写"}`]
|
|
429
519
|
: [];
|
|
430
520
|
const contentSections = [];
|
|
431
|
-
const
|
|
521
|
+
const availableSettings = this.store.listSettings(workId);
|
|
522
|
+
const contextualSettings = scope.includeAllSettings ? availableSettings : availableSettings.filter((item) => item.locked);
|
|
432
523
|
const allCharacters = this.store.listCharacters(workId);
|
|
433
524
|
const lockedCharacters = allCharacters.filter((item) => Array.isArray(item.lockedFields) && item.lockedFields.length > 0);
|
|
434
525
|
const organizations = this.store.listOrganizations(workId);
|
|
435
|
-
const relationshipConstraints =
|
|
436
|
-
|
|
437
|
-
|
|
526
|
+
const relationshipConstraints = scope.excludeRelationshipConstraints
|
|
527
|
+
? []
|
|
528
|
+
: selectRelationshipConstraints(this.store, workId, scope.characterIds ?? []);
|
|
529
|
+
if (includeAutomaticContext && contextualSettings.length > 0) {
|
|
530
|
+
constraints.push(`${scope.includeAllSettings ? "全部作品设定(关系分析参考)" : "作者锁定设定(硬约束)"}:\n${contextualSettings
|
|
438
531
|
.map((item) => `- [${String(item.category)}] ${String(item.title)}:${String(item.content)}`)
|
|
439
532
|
.join("\n")}`);
|
|
440
533
|
}
|
|
@@ -1020,7 +1113,7 @@ export class AiManager {
|
|
|
1020
1113
|
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
1114
|
if (input.taskType === "continue")
|
|
1022
1115
|
await this.runSuggestionGuard(suggestionId);
|
|
1023
|
-
return { ...this.getSuggestion(suggestionId), outputTokens: generated.outputTokens, toolCalls: generated.toolCalls, processSteps: generated.processSteps };
|
|
1116
|
+
return { ...this.getSuggestion(suggestionId), outputTokens: generated.outputTokens, ...(generated.cacheHitPercent === undefined ? {} : { cacheHitPercent: generated.cacheHitPercent }), toolCalls: generated.toolCalls, processSteps: generated.processSteps };
|
|
1024
1117
|
}
|
|
1025
1118
|
async createStreamingChat(input, onDelta) {
|
|
1026
1119
|
const generated = this.enabledAgentTools(input.workId, "chat").length
|
|
@@ -1032,7 +1125,7 @@ export class AiManager {
|
|
|
1032
1125
|
const suggestionId = id("suggestion");
|
|
1033
1126
|
this.store.db.run(`INSERT INTO ai_suggestions (id, call_id, work_id, chapter_id, chapter_version, task_type, instruction,
|
|
1034
1127
|
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 };
|
|
1128
|
+
return { ...this.getSuggestion(suggestionId), outputTokens: generated.outputTokens, ...(generated.cacheHitPercent === undefined ? {} : { cacheHitPercent: generated.cacheHitPercent }), toolCalls: generated.toolCalls, processSteps: generated.processSteps };
|
|
1036
1129
|
}
|
|
1037
1130
|
async runSuggestionGuard(suggestionId, candidateContent) {
|
|
1038
1131
|
const suggestion = this.getSuggestion(suggestionId);
|
|
@@ -1183,6 +1276,7 @@ export class AiManager {
|
|
|
1183
1276
|
return this.store.db.all("SELECT * FROM ai_calls WHERE work_id = ? ORDER BY created_at DESC LIMIT 200", workId).map((row) => ({
|
|
1184
1277
|
id: stringValue(row, "id"),
|
|
1185
1278
|
workId: stringValue(row, "work_id"),
|
|
1279
|
+
taskId: row.task_id === null ? null : stringValue(row, "task_id"),
|
|
1186
1280
|
taskType: stringValue(row, "task_type"),
|
|
1187
1281
|
provider: this.getProvider(stringValue(row, "provider_id")),
|
|
1188
1282
|
model: this.getModel(stringValue(row, "model_id")),
|
|
@@ -1203,6 +1297,7 @@ export class AiManager {
|
|
|
1203
1297
|
return paginated(rows.map((row) => ({
|
|
1204
1298
|
id: stringValue(row, "id"),
|
|
1205
1299
|
workId: stringValue(row, "work_id"),
|
|
1300
|
+
taskId: row.task_id === null ? null : stringValue(row, "task_id"),
|
|
1206
1301
|
taskType: stringValue(row, "task_type"),
|
|
1207
1302
|
provider: this.getProvider(stringValue(row, "provider_id")),
|
|
1208
1303
|
model: this.getModel(stringValue(row, "model_id")),
|
|
@@ -1216,6 +1311,55 @@ export class AiManager {
|
|
|
1216
1311
|
completedAt: row.completed_at === null ? null : stringValue(row, "completed_at")
|
|
1217
1312
|
})), pagination);
|
|
1218
1313
|
}
|
|
1314
|
+
getTaskTrace(taskId) {
|
|
1315
|
+
this.store.getTask(taskId);
|
|
1316
|
+
const rows = this.store.db.all(`SELECT call.*, trace.initial_messages_json, trace.rounds_json, trace.created_at AS trace_created_at,
|
|
1317
|
+
trace.updated_at AS trace_updated_at, provider.name AS provider_name,
|
|
1318
|
+
model.display_name AS model_display_name, model.model_id AS external_model_id
|
|
1319
|
+
FROM ai_calls call
|
|
1320
|
+
LEFT JOIN ai_call_traces trace ON trace.call_id = call.id
|
|
1321
|
+
LEFT JOIN providers provider ON provider.id = call.provider_id
|
|
1322
|
+
LEFT JOIN models model ON model.id = call.model_id
|
|
1323
|
+
WHERE call.task_id = ?
|
|
1324
|
+
ORDER BY call.created_at ASC, call.id ASC`, taskId);
|
|
1325
|
+
const calls = rows.map((row) => {
|
|
1326
|
+
const hasTrace = row.initial_messages_json !== null && row.initial_messages_json !== undefined;
|
|
1327
|
+
return {
|
|
1328
|
+
id: stringValue(row, "id"),
|
|
1329
|
+
taskType: stringValue(row, "task_type"),
|
|
1330
|
+
provider: {
|
|
1331
|
+
id: stringValue(row, "provider_id"),
|
|
1332
|
+
name: row.provider_name === null ? "已删除的供应商" : stringValue(row, "provider_name"),
|
|
1333
|
+
deleted: row.provider_name === null
|
|
1334
|
+
},
|
|
1335
|
+
model: {
|
|
1336
|
+
id: stringValue(row, "model_id"),
|
|
1337
|
+
displayName: row.model_display_name === null ? "已删除的模型" : stringValue(row, "model_display_name"),
|
|
1338
|
+
modelId: row.external_model_id === null ? null : stringValue(row, "external_model_id"),
|
|
1339
|
+
deleted: row.model_display_name === null
|
|
1340
|
+
},
|
|
1341
|
+
contextScope: json(stringValue(row, "context_scope_json"), {}),
|
|
1342
|
+
parameters: json(stringValue(row, "parameters_json"), {}),
|
|
1343
|
+
status: stringValue(row, "status"),
|
|
1344
|
+
failure: row.failure === null ? null : stringValue(row, "failure"),
|
|
1345
|
+
inputChars: numberValue(row, "input_chars"),
|
|
1346
|
+
outputChars: numberValue(row, "output_chars"),
|
|
1347
|
+
createdAt: stringValue(row, "created_at"),
|
|
1348
|
+
completedAt: row.completed_at === null ? null : stringValue(row, "completed_at"),
|
|
1349
|
+
trace: hasTrace ? {
|
|
1350
|
+
initialMessages: json(stringValue(row, "initial_messages_json"), []),
|
|
1351
|
+
rounds: json(stringValue(row, "rounds_json"), []),
|
|
1352
|
+
createdAt: stringValue(row, "trace_created_at"),
|
|
1353
|
+
updatedAt: stringValue(row, "trace_updated_at")
|
|
1354
|
+
} : null
|
|
1355
|
+
};
|
|
1356
|
+
});
|
|
1357
|
+
return {
|
|
1358
|
+
taskId,
|
|
1359
|
+
captured: calls.some((call) => call.trace !== null),
|
|
1360
|
+
calls
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1219
1363
|
async runTask(taskId, modelId) {
|
|
1220
1364
|
const task = this.store.getTask(taskId);
|
|
1221
1365
|
const workId = String(task.workId);
|
|
@@ -1270,6 +1414,7 @@ export class AiManager {
|
|
|
1270
1414
|
else {
|
|
1271
1415
|
const generated = await this.generate({
|
|
1272
1416
|
workId,
|
|
1417
|
+
taskId,
|
|
1273
1418
|
taskType: taskType === "book-analysis" ? "book-analysis" : "chapter-analysis",
|
|
1274
1419
|
instruction: "请基于上下文完成分析,给出有原文依据的中文结论。",
|
|
1275
1420
|
scope,
|
|
@@ -1731,8 +1876,20 @@ export class AiManager {
|
|
|
1731
1876
|
});
|
|
1732
1877
|
const callId = id("call");
|
|
1733
1878
|
const timestamp = now();
|
|
1734
|
-
|
|
1735
|
-
|
|
1879
|
+
const traceRounds = [];
|
|
1880
|
+
this.store.db.transaction(() => {
|
|
1881
|
+
this.store.db.run(`INSERT INTO ai_calls (id, work_id, task_id, task_type, provider_id, model_id, context_scope_json, parameters_json,
|
|
1882
|
+
status, input_chars, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?)`, callId, input.workId, input.taskId ?? null, input.taskType, stringValue(provider, "id"), stringValue(model, "id"), JSON.stringify(input.scope), JSON.stringify(parameters), context.length + input.instruction.length, timestamp, currentRequestActor()?.userId ?? null);
|
|
1883
|
+
if (input.taskId) {
|
|
1884
|
+
this.store.db.run(`INSERT INTO ai_call_traces (call_id, task_id, initial_messages_json, rounds_json, created_at, updated_at)
|
|
1885
|
+
VALUES (?, ?, ?, '[]', ?, ?)`, callId, input.taskId, JSON.stringify(messages), timestamp, timestamp);
|
|
1886
|
+
}
|
|
1887
|
+
});
|
|
1888
|
+
const saveTrace = () => {
|
|
1889
|
+
if (!input.taskId)
|
|
1890
|
+
return;
|
|
1891
|
+
this.store.db.run("UPDATE ai_call_traces SET rounds_json = ?, updated_at = ? WHERE call_id = ?", JSON.stringify(traceRounds), now(), callId);
|
|
1892
|
+
};
|
|
1736
1893
|
const callStartedAt = process.hrtime.bigint();
|
|
1737
1894
|
logger.info("ai.call.started", {
|
|
1738
1895
|
callId,
|
|
@@ -1745,16 +1902,44 @@ export class AiManager {
|
|
|
1745
1902
|
instructionChars: input.instruction.length,
|
|
1746
1903
|
toolCount: tools.length
|
|
1747
1904
|
});
|
|
1905
|
+
let activeApiKey = "";
|
|
1748
1906
|
try {
|
|
1749
1907
|
const apiKey = this.decryptKey(provider);
|
|
1908
|
+
activeApiKey = apiKey;
|
|
1750
1909
|
const endpoint = `${normalizeBaseUrl(stringValue(provider, "base_url"))}/chat/completions`;
|
|
1751
1910
|
const timeoutMs = input.taskType === "book-analysis" || input.taskType === "relationship-analysis" ? 300_000 : 60_000;
|
|
1752
1911
|
const maximumAttempts = Math.round(clamp(input.maxAttempts ?? 3, 1, 5));
|
|
1912
|
+
let completionRequestCount = 0;
|
|
1913
|
+
let cacheUsageComplete = true;
|
|
1914
|
+
let totalInputTokens = 0;
|
|
1915
|
+
let totalCachedInputTokens = 0;
|
|
1753
1916
|
const requestCompletion = async (toolChoice) => {
|
|
1917
|
+
const traceRound = {
|
|
1918
|
+
round: traceRounds.length + 1,
|
|
1919
|
+
requestedAt: now(),
|
|
1920
|
+
request: {
|
|
1921
|
+
model: stringValue(model, "model_id"),
|
|
1922
|
+
messages: structuredClone(completionMessages),
|
|
1923
|
+
parameters: structuredClone(parameters),
|
|
1924
|
+
tools: toolChoice === "auto" ? structuredClone(tools) : [],
|
|
1925
|
+
toolChoice
|
|
1926
|
+
},
|
|
1927
|
+
attempts: [],
|
|
1928
|
+
toolExecutions: []
|
|
1929
|
+
};
|
|
1930
|
+
traceRounds.push(traceRound);
|
|
1931
|
+
saveTrace();
|
|
1754
1932
|
let lastFailure = null;
|
|
1755
1933
|
for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
|
|
1756
1934
|
let retryable = true;
|
|
1757
1935
|
const attemptStartedAt = process.hrtime.bigint();
|
|
1936
|
+
const traceAttempt = {
|
|
1937
|
+
attempt,
|
|
1938
|
+
startedAt: now(),
|
|
1939
|
+
status: "running"
|
|
1940
|
+
};
|
|
1941
|
+
traceRound.attempts.push(traceAttempt);
|
|
1942
|
+
saveTrace();
|
|
1758
1943
|
logger.info("ai.call.attempt_started", { callId, attempt, maximumAttempts, toolChoice });
|
|
1759
1944
|
try {
|
|
1760
1945
|
const candidate = await this.scheduleProviderRequest(provider, input.signal, async () => {
|
|
@@ -1793,13 +1978,32 @@ export class AiManager {
|
|
|
1793
1978
|
});
|
|
1794
1979
|
if (candidate.ok) {
|
|
1795
1980
|
try {
|
|
1796
|
-
|
|
1981
|
+
const parsed = redactProviderSecrets(JSON.parse(candidate.body), apiKey);
|
|
1982
|
+
traceAttempt.completedAt = now();
|
|
1983
|
+
traceAttempt.status = "completed";
|
|
1984
|
+
traceAttempt.httpStatus = candidate.status;
|
|
1985
|
+
traceAttempt.response = sanitizeCompletionTraceResponse(parsed);
|
|
1986
|
+
saveTrace();
|
|
1987
|
+
completionRequestCount += 1;
|
|
1988
|
+
const cacheUsage = resolveInputCacheUsage(parsed.usage);
|
|
1989
|
+
if (!cacheUsage)
|
|
1990
|
+
cacheUsageComplete = false;
|
|
1991
|
+
else {
|
|
1992
|
+
totalInputTokens += cacheUsage.inputTokens;
|
|
1993
|
+
totalCachedInputTokens += cacheUsage.cachedInputTokens;
|
|
1994
|
+
}
|
|
1995
|
+
return parsed;
|
|
1797
1996
|
}
|
|
1798
1997
|
catch {
|
|
1799
1998
|
throw new Error(`Chat Completions returned invalid JSON: ${candidate.body.slice(0, 500)}`);
|
|
1800
1999
|
}
|
|
1801
2000
|
}
|
|
1802
2001
|
lastFailure = new Error(`HTTP ${candidate.status}: ${candidate.body.slice(0, 500)}`);
|
|
2002
|
+
traceAttempt.completedAt = now();
|
|
2003
|
+
traceAttempt.status = "failed";
|
|
2004
|
+
traceAttempt.httpStatus = candidate.status;
|
|
2005
|
+
traceAttempt.failure = redactProviderSecret(`HTTP ${candidate.status}: ${candidate.body.slice(0, 2_000)}`, apiKey);
|
|
2006
|
+
saveTrace();
|
|
1803
2007
|
if (candidate.status !== 429 && candidate.status < 500) {
|
|
1804
2008
|
retryable = false;
|
|
1805
2009
|
throw lastFailure;
|
|
@@ -1807,6 +2011,14 @@ export class AiManager {
|
|
|
1807
2011
|
}
|
|
1808
2012
|
catch (error) {
|
|
1809
2013
|
lastFailure = error;
|
|
2014
|
+
if (traceAttempt.status === "running") {
|
|
2015
|
+
traceAttempt.completedAt = now();
|
|
2016
|
+
traceAttempt.status = "failed";
|
|
2017
|
+
traceAttempt.failure = error instanceof Error
|
|
2018
|
+
? redactProviderSecret(error.message.slice(0, 2_000), apiKey)
|
|
2019
|
+
: "AI request failed";
|
|
2020
|
+
saveTrace();
|
|
2021
|
+
}
|
|
1810
2022
|
logger.warn("ai.call.attempt_failed", {
|
|
1811
2023
|
callId,
|
|
1812
2024
|
attempt,
|
|
@@ -1868,6 +2080,8 @@ export class AiManager {
|
|
|
1868
2080
|
const execution = this.executeAgentTool(input.workId, toolCall);
|
|
1869
2081
|
logger.info("ai.tool_call.completed", { callId, toolName: execution.name, status: execution.status, round });
|
|
1870
2082
|
executedToolCalls.push(execution);
|
|
2083
|
+
traceRounds.at(-1)?.toolExecutions.push(execution);
|
|
2084
|
+
saveTrace();
|
|
1871
2085
|
processSteps.push({ id: id("process"), type: "tool", round, toolCall: execution, createdAt: execution.calledAt });
|
|
1872
2086
|
input.onToolCall?.(execution, round);
|
|
1873
2087
|
completionMessages.push({ role: "tool", tool_call_id: toolCall.id, content: JSON.stringify(execution.result) });
|
|
@@ -1897,6 +2111,9 @@ export class AiManager {
|
|
|
1897
2111
|
}
|
|
1898
2112
|
this.store.db.run("UPDATE ai_calls SET status = 'completed', output_chars = ?, completed_at = ? WHERE id = ?", content.length, now(), callId);
|
|
1899
2113
|
const outputTokens = resolveOutputTokens(payload.usage, content);
|
|
2114
|
+
const cacheHitPercent = cacheUsageComplete && completionRequestCount > 0 && totalInputTokens > 0
|
|
2115
|
+
? Math.round(totalCachedInputTokens / totalInputTokens * 1_000) / 10
|
|
2116
|
+
: undefined;
|
|
1900
2117
|
logger.info("ai.call.completed", {
|
|
1901
2118
|
callId,
|
|
1902
2119
|
workId: input.workId,
|
|
@@ -1907,10 +2124,10 @@ export class AiManager {
|
|
|
1907
2124
|
outputTokens,
|
|
1908
2125
|
toolCallCount: executedToolCalls.length
|
|
1909
2126
|
});
|
|
1910
|
-
return { callId, content, outputTokens, provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: executedToolCalls, processSteps };
|
|
2127
|
+
return { callId, content, outputTokens, ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }), provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: executedToolCalls, processSteps };
|
|
1911
2128
|
}
|
|
1912
2129
|
catch (error) {
|
|
1913
|
-
const message = error instanceof Error ? error.message : "AI 调用失败";
|
|
2130
|
+
const message = error instanceof Error ? redactProviderSecret(error.message, activeApiKey) : "AI 调用失败";
|
|
1914
2131
|
this.store.db.run("UPDATE ai_calls SET status = 'failed', failure = ?, completed_at = ? WHERE id = ?", message, now(), callId);
|
|
1915
2132
|
logger.error("ai.call.failed", {
|
|
1916
2133
|
callId,
|
|
@@ -2024,7 +2241,7 @@ export class AiManager {
|
|
|
2024
2241
|
}
|
|
2025
2242
|
if (streamedResult === null)
|
|
2026
2243
|
throw lastFailure instanceof Error ? lastFailure : new Error("AI 流式请求重试后仍未返回响应");
|
|
2027
|
-
const { content, reasoning, outputTokens } = streamedResult;
|
|
2244
|
+
const { content, reasoning, outputTokens, cacheHitPercent } = streamedResult;
|
|
2028
2245
|
const processSteps = reasoning.trim()
|
|
2029
2246
|
? [{ id: thinkingStepId, type: "thinking", round: 1, content: reasoning, createdAt: thinkingCreatedAt }]
|
|
2030
2247
|
: [];
|
|
@@ -2038,7 +2255,7 @@ export class AiManager {
|
|
|
2038
2255
|
outputChars: content.length,
|
|
2039
2256
|
outputTokens
|
|
2040
2257
|
});
|
|
2041
|
-
return { callId, content, outputTokens, provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: [], processSteps };
|
|
2258
|
+
return { callId, content, outputTokens, ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }), provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: [], processSteps };
|
|
2042
2259
|
}
|
|
2043
2260
|
catch (error) {
|
|
2044
2261
|
const message = error instanceof Error ? error.message : "AI 流式调用失败";
|
|
@@ -2105,7 +2322,8 @@ export class AiManager {
|
|
|
2105
2322
|
consumeEvent(buffer);
|
|
2106
2323
|
if (!content.trim())
|
|
2107
2324
|
throw new Error(`Chat Completions 流式响应缺少可用正文,finish_reason=${finishReason}`);
|
|
2108
|
-
|
|
2325
|
+
const cacheHitPercent = resolveCacheHitPercent(usage);
|
|
2326
|
+
return { content, reasoning, outputTokens: resolveOutputTokens(usage, content), ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }) };
|
|
2109
2327
|
}
|
|
2110
2328
|
async runChapterAnalysis(workId, scope, modelId, taskId) {
|
|
2111
2329
|
if (!scope.chapterId)
|
|
@@ -2113,6 +2331,7 @@ export class AiManager {
|
|
|
2113
2331
|
const chapter = this.store.getChapter(scope.chapterId);
|
|
2114
2332
|
const generated = await this.generateTaggedJson({
|
|
2115
2333
|
workId,
|
|
2334
|
+
taskId,
|
|
2116
2335
|
taskType: "chapter-analysis",
|
|
2117
2336
|
signal: this.taskSignal(taskId),
|
|
2118
2337
|
instruction: "分析本章并输出 JSON 对象,字段为 summary(1至3句)、events(数组)、characters(数组)、settings(数组)、evidence(数组,每项含 conclusion 和 quote)、uncertainties(数组)。",
|
|
@@ -2132,6 +2351,7 @@ export class AiManager {
|
|
|
2132
2351
|
async runTimelineAnalysis(workId, scope, modelId, taskId) {
|
|
2133
2352
|
const generated = await this.generateTaggedJson({
|
|
2134
2353
|
workId,
|
|
2354
|
+
taskId,
|
|
2135
2355
|
taskType: "timeline-analysis",
|
|
2136
2356
|
signal: this.taskSignal(taskId),
|
|
2137
2357
|
instruction: "抽取大事件候选并输出 JSON 数组。每项字段:name、description、eventType、timeLabel、timeSort(无法确定为 null)、location、impactScope、chapterIds、participantIds、evidence。必须区分发生时间与叙述时间;不确定时使用‘时间待定’。",
|
|
@@ -2171,6 +2391,7 @@ export class AiManager {
|
|
|
2171
2391
|
throw new AppError(409, "CHAPTERS_REQUIRED", "世界观分析范围内没有章节");
|
|
2172
2392
|
const generated = await this.generateTaggedJson({
|
|
2173
2393
|
workId,
|
|
2394
|
+
taskId,
|
|
2174
2395
|
taskType: "book-analysis",
|
|
2175
2396
|
signal: this.taskSignal(taskId),
|
|
2176
2397
|
instruction: [
|
|
@@ -2268,6 +2489,7 @@ export class AiManager {
|
|
|
2268
2489
|
return { candidates: [], callId: null };
|
|
2269
2490
|
const generated = await this.generateTaggedJson({
|
|
2270
2491
|
workId,
|
|
2492
|
+
taskId,
|
|
2271
2493
|
taskType: "book-analysis",
|
|
2272
2494
|
signal: this.taskSignal(taskId),
|
|
2273
2495
|
maxAttempts: 2,
|
|
@@ -2411,6 +2633,7 @@ export class AiManager {
|
|
|
2411
2633
|
async runConsistencyCheck(workId, scope, modelId, taskId) {
|
|
2412
2634
|
const generated = await this.generateTaggedJson({
|
|
2413
2635
|
workId,
|
|
2636
|
+
taskId,
|
|
2414
2637
|
taskType: "consistency-check",
|
|
2415
2638
|
signal: this.taskSignal(taskId),
|
|
2416
2639
|
instruction: "检查设定、人物状态、关系和时间是否冲突,输出 JSON 数组。每项字段:itemType、severity(low/medium/high)、title、description、entityRefs、evidence、suggestion。没有问题时输出 []。",
|
|
@@ -2466,6 +2689,7 @@ export class AiManager {
|
|
|
2466
2689
|
}).join("\n");
|
|
2467
2690
|
const generated = await this.generateTaggedJson({
|
|
2468
2691
|
workId,
|
|
2692
|
+
taskId,
|
|
2469
2693
|
taskType: "book-analysis",
|
|
2470
2694
|
signal: this.taskSignal(taskId),
|
|
2471
2695
|
scope: scope.type === "none" ? scope : { type: "none" },
|
|
@@ -2586,6 +2810,7 @@ export class AiManager {
|
|
|
2586
2810
|
async verifyCharacterTitlePairs(workId, pairs, modelId, taskId) {
|
|
2587
2811
|
const generated = await this.generateTaggedJson({
|
|
2588
2812
|
workId,
|
|
2813
|
+
taskId,
|
|
2589
2814
|
taskType: "book-analysis",
|
|
2590
2815
|
signal: this.taskSignal(taskId),
|
|
2591
2816
|
scope: { type: "none" },
|
|
@@ -2639,6 +2864,7 @@ export class AiManager {
|
|
|
2639
2864
|
const extractChunk = async (text, maxAttempts = 3) => {
|
|
2640
2865
|
const generated = await this.generateTaggedJson({
|
|
2641
2866
|
workId,
|
|
2867
|
+
taskId,
|
|
2642
2868
|
taskType: "book-analysis",
|
|
2643
2869
|
signal: this.taskSignal(taskId),
|
|
2644
2870
|
maxAttempts,
|
|
@@ -2991,6 +3217,17 @@ export class AiManager {
|
|
|
2991
3217
|
const characters = this.store.listCharacters(workId);
|
|
2992
3218
|
if (characters.length < 2)
|
|
2993
3219
|
throw new AppError(409, "CHARACTERS_REQUIRED", "人物关系分析至少需要两个角色档案");
|
|
3220
|
+
const selectedCharacterIds = new Set(scope.characterIds ?? []);
|
|
3221
|
+
for (const characterId of selectedCharacterIds) {
|
|
3222
|
+
const character = characters.find((item) => item.id === characterId);
|
|
3223
|
+
if (!character)
|
|
3224
|
+
throw new AppError(400, "CHARACTER_WORK_MISMATCH", "被分析角色不属于当前作品");
|
|
3225
|
+
}
|
|
3226
|
+
const targeted = selectedCharacterIds.size > 0;
|
|
3227
|
+
const targetedRoster = characters
|
|
3228
|
+
.filter((character) => selectedCharacterIds.has(String(character.id)))
|
|
3229
|
+
.map((character) => `${String(character.id)} | ${String(character.name)}`)
|
|
3230
|
+
.join("\n");
|
|
2994
3231
|
const chapters = this.getScopeChapters(workId, scope);
|
|
2995
3232
|
if (chapters.length === 0)
|
|
2996
3233
|
throw new AppError(409, "CHAPTERS_REQUIRED", "人物关系分析范围内没有章节");
|
|
@@ -3005,13 +3242,32 @@ export class AiManager {
|
|
|
3005
3242
|
const extractChunk = async (text, maxAttempts = 3) => {
|
|
3006
3243
|
const generated = await this.generateTaggedJson({
|
|
3007
3244
|
workId,
|
|
3245
|
+
taskId,
|
|
3008
3246
|
taskType: "relationship-analysis",
|
|
3009
3247
|
signal: this.taskSignal(taskId),
|
|
3010
3248
|
maxAttempts,
|
|
3011
|
-
scope: {
|
|
3249
|
+
scope: {
|
|
3250
|
+
type: "selection",
|
|
3251
|
+
selection: text,
|
|
3252
|
+
includeAllSettings: scope.includeAllSettings,
|
|
3253
|
+
...(targeted ? { characterIds: [...selectedCharacterIds], excludeRelationshipConstraints: scope.replaceExistingRelationships === true } : {})
|
|
3254
|
+
},
|
|
3012
3255
|
...(modelId ? { modelId } : {}),
|
|
3013
3256
|
parameters: { temperature: 0.1 },
|
|
3014
|
-
instruction: [
|
|
3257
|
+
instruction: targeted ? [
|
|
3258
|
+
"你是定向人物关系证据收集器。本阶段只建立跨章节证据账本,不下最终关系结论。",
|
|
3259
|
+
"被分析角色:",
|
|
3260
|
+
targetedRoster,
|
|
3261
|
+
"完整角色规范表:",
|
|
3262
|
+
roster,
|
|
3263
|
+
"规则:",
|
|
3264
|
+
"1. 只记录与至少一名被分析角色直接有关的互动、称谓、亲缘线索、权力行为、情感变化、冲突、回忆或第三方陈述。",
|
|
3265
|
+
"2. 单次见面、同场出现和含糊代词可以作为待汇总线索,但必须如实描述,不能在本阶段升级为长期关系。",
|
|
3266
|
+
"3. 人物引用优先填写规范表中的 characterId;暂时不能确定对方身份时填写 relatedReference,禁止创造角色。",
|
|
3267
|
+
"4. 每条线索只引用一个连续原文短句,quote 不超过 80 字,并准确提供 chapterId、chapterTitle 和 contextType。",
|
|
3268
|
+
"5. 输出 JSON 数组。字段:targetCharacterId、relatedCharacterId、relatedReference、observation、possibleCategory、possibleSubtype、directionHint、timeHint、chapterId、chapterTitle、quote、contextType。",
|
|
3269
|
+
"6. 没有与目标角色直接相关的线索时输出 []。"
|
|
3270
|
+
].join("\n") : [
|
|
3015
3271
|
"你是小说人物关系抽取器,不是续写者。只抽取角色规范表中人物之间、对跨章节人物图有长期意义且有原文证据的关系。",
|
|
3016
3272
|
"角色规范表:",
|
|
3017
3273
|
roster,
|
|
@@ -3042,7 +3298,12 @@ export class AiManager {
|
|
|
3042
3298
|
"23. 输出 JSON 数组。字段:fromCharacterId、toCharacterId、category(family/social/emotional/conflict/uncertain)、subtype、keywords、directed、currentStatus、timeRange、confidence、evidence。",
|
|
3043
3299
|
"24. 共同执行一次任务、同属一个组织、在同一集体场景中被感谢或落泪、替第三人转发消息,都不能单独证明同事、朋友或盟友。此类关系必须有原文明示身份,或至少两个不同章节的持续互动证据。"
|
|
3044
3300
|
].join("\n"),
|
|
3045
|
-
extraSystemPrompt:
|
|
3301
|
+
extraSystemPrompt: [
|
|
3302
|
+
targeted
|
|
3303
|
+
? "你正在为指定角色收集可审计的跨章节关系线索。不得在证据收集阶段把单次互动直接判定为长期关系。"
|
|
3304
|
+
: "关系候选必须可审计。严禁把梦境伴侣、醉后梦话、单次约定、同章共现、礼称、同族归属、救援照护或类比提及写成现实长期关系。逐句校验说话人和关系方向。",
|
|
3305
|
+
scope.additionalPrompt?.trim() ? `作者追加的关系分析提示:\n${scope.additionalPrompt.trim()}` : ""
|
|
3306
|
+
].filter(Boolean).join("\n\n")
|
|
3046
3307
|
});
|
|
3047
3308
|
const extracted = extractJson(generated.content);
|
|
3048
3309
|
if (!Array.isArray(extracted))
|
|
@@ -3066,7 +3327,8 @@ export class AiManager {
|
|
|
3066
3327
|
}
|
|
3067
3328
|
}, (completed) => {
|
|
3068
3329
|
if (taskId && this.store.getTask(taskId).status === "running") {
|
|
3069
|
-
|
|
3330
|
+
const maximumProgress = targeted ? 72 : 92;
|
|
3331
|
+
this.store.updateTask(taskId, { status: "running", progress: Math.min(maximumProgress, 5 + Math.round(completed / chunks.length * (maximumProgress - 5))) });
|
|
3070
3332
|
}
|
|
3071
3333
|
});
|
|
3072
3334
|
let fallbackSegmentCount = 0;
|
|
@@ -3087,6 +3349,88 @@ export class AiManager {
|
|
|
3087
3349
|
batchCount: chunks.length
|
|
3088
3350
|
});
|
|
3089
3351
|
}
|
|
3352
|
+
const targetedEvidenceCount = targeted ? rawCandidates.length : 0;
|
|
3353
|
+
let aggregationBatchCount = 0;
|
|
3354
|
+
if (targeted && rawCandidates.length > 0) {
|
|
3355
|
+
const evidenceGroups = new Map();
|
|
3356
|
+
for (const evidence of rawCandidates) {
|
|
3357
|
+
const target = String(evidence.targetCharacterId ?? "");
|
|
3358
|
+
const related = String(evidence.relatedCharacterId ?? evidence.relatedReference ?? "unknown");
|
|
3359
|
+
const key = `${target}|${related}`;
|
|
3360
|
+
const group = evidenceGroups.get(key) ?? [];
|
|
3361
|
+
group.push(evidence);
|
|
3362
|
+
evidenceGroups.set(key, group);
|
|
3363
|
+
}
|
|
3364
|
+
const evidenceBatches = [];
|
|
3365
|
+
let currentBatch = [];
|
|
3366
|
+
let currentLength = 0;
|
|
3367
|
+
for (const group of evidenceGroups.values()) {
|
|
3368
|
+
const groupLength = JSON.stringify(group).length;
|
|
3369
|
+
if (currentBatch.length > 0 && currentLength + groupLength > 60_000) {
|
|
3370
|
+
evidenceBatches.push(currentBatch);
|
|
3371
|
+
currentBatch = [];
|
|
3372
|
+
currentLength = 0;
|
|
3373
|
+
}
|
|
3374
|
+
currentBatch.push(...group);
|
|
3375
|
+
currentLength += groupLength;
|
|
3376
|
+
}
|
|
3377
|
+
if (currentBatch.length > 0)
|
|
3378
|
+
evidenceBatches.push(currentBatch);
|
|
3379
|
+
aggregationBatchCount = evidenceBatches.length;
|
|
3380
|
+
const aggregationResults = await this.processChunks(evidenceBatches, Math.min(concurrency, 4), async (evidenceBatch) => {
|
|
3381
|
+
const generated = await this.generateTaggedJson({
|
|
3382
|
+
workId,
|
|
3383
|
+
taskId,
|
|
3384
|
+
taskType: "relationship-analysis",
|
|
3385
|
+
signal: this.taskSignal(taskId),
|
|
3386
|
+
maxAttempts: 2,
|
|
3387
|
+
scope: {
|
|
3388
|
+
type: "entities",
|
|
3389
|
+
includeAllSettings: scope.includeAllSettings,
|
|
3390
|
+
characterIds: [...selectedCharacterIds],
|
|
3391
|
+
excludeRelationshipConstraints: scope.replaceExistingRelationships === true
|
|
3392
|
+
},
|
|
3393
|
+
...(modelId ? { modelId } : {}),
|
|
3394
|
+
parameters: { temperature: 0.1 },
|
|
3395
|
+
instruction: [
|
|
3396
|
+
"你是小说人物关系全局归纳器。请综合分析范围内为指定角色收集的全部跨章节证据线索,形成最终长期关系候选。",
|
|
3397
|
+
"被分析角色:",
|
|
3398
|
+
targetedRoster,
|
|
3399
|
+
"完整角色规范表:",
|
|
3400
|
+
roster,
|
|
3401
|
+
"证据账本:",
|
|
3402
|
+
JSON.stringify(evidenceBatch),
|
|
3403
|
+
"归纳规则:",
|
|
3404
|
+
"1. 只输出至少一端属于被分析角色的关系,另一端也必须解析为角色规范表中的 characterId。",
|
|
3405
|
+
"2. 综合不同章节、不同阶段和设定信息判断关系;设定只用于身份消歧和辅助理解,不能代替章节原文证据。",
|
|
3406
|
+
"3. 单次见面、同场出现、一次任务协作、同组织或同族不能单独升级为长期朋友、同事、盟友、君臣或亲属。",
|
|
3407
|
+
"4. evidence 只能使用证据账本中的连续原文 quote,必须包含 chapterId、chapterTitle、quote、contextType、supports;quote 不超过 80 字。",
|
|
3408
|
+
"5. category 只能是 family、social、emotional、conflict、uncertain;confidence 低于 0.6 不输出。",
|
|
3409
|
+
"6. subtype 使用稳定简短中文词;父母子女、君臣、师生、倾慕、施害与受害等有方向关系必须正确设置 from、to 和 directed=true。",
|
|
3410
|
+
"7. 同一人物对的阶段变化合并进 timeRange.stages;同一 category/subtype 不得输出反向重复边。",
|
|
3411
|
+
"8. keywords 提供 2 至 8 个描述双方互动、权力结构、情感阶段或剧情张力的中文关键词。",
|
|
3412
|
+
"9. 输出 JSON 数组。字段:fromCharacterId、toCharacterId、category、subtype、keywords、directed、currentStatus、timeRange、confidence、evidence。"
|
|
3413
|
+
].join("\n"),
|
|
3414
|
+
extraSystemPrompt: [
|
|
3415
|
+
"你正在执行指定角色的跨章节关系归纳。所有结论必须能回溯到证据账本中的章节原文,不得沿用缺乏本次证据的旧关系。",
|
|
3416
|
+
scope.additionalPrompt?.trim() ? `作者追加的关系分析提示:\n${scope.additionalPrompt.trim()}` : ""
|
|
3417
|
+
].filter(Boolean).join("\n\n")
|
|
3418
|
+
});
|
|
3419
|
+
const extracted = extractJson(generated.content);
|
|
3420
|
+
if (!Array.isArray(extracted))
|
|
3421
|
+
throw new AppError(502, "AI_INVALID_JSON", "定向人物关系归纳结果必须是数组");
|
|
3422
|
+
return {
|
|
3423
|
+
candidates: extracted.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item)),
|
|
3424
|
+
callId: generated.callId
|
|
3425
|
+
};
|
|
3426
|
+
}, (completed) => {
|
|
3427
|
+
if (taskId && this.store.getTask(taskId).status === "running") {
|
|
3428
|
+
this.store.updateTask(taskId, { status: "running", progress: Math.min(92, 72 + Math.round(completed / evidenceBatches.length * 20)) });
|
|
3429
|
+
}
|
|
3430
|
+
});
|
|
3431
|
+
rawCandidates.splice(0, rawCandidates.length, ...aggregationResults.flatMap((result) => result.candidates));
|
|
3432
|
+
callIds.push(...aggregationResults.map((result) => result.callId));
|
|
3433
|
+
}
|
|
3090
3434
|
const chapterById = new Map(chapters.map((chapter) => [String(chapter.id), chapter]));
|
|
3091
3435
|
const categories = new Set(["family", "social", "emotional", "conflict", "uncertain"]);
|
|
3092
3436
|
const merged = new Map();
|
|
@@ -3104,6 +3448,10 @@ export class AiManager {
|
|
|
3104
3448
|
skipped.push({ index, reason: "人物引用无效" });
|
|
3105
3449
|
return;
|
|
3106
3450
|
}
|
|
3451
|
+
if (targeted && !selectedCharacterIds.has(fromResolved) && !selectedCharacterIds.has(toResolved)) {
|
|
3452
|
+
skipped.push({ index, reason: "关系不涉及本次选定角色" });
|
|
3453
|
+
return;
|
|
3454
|
+
}
|
|
3107
3455
|
if (typeof candidate.category !== "string" || !categories.has(candidate.category)) {
|
|
3108
3456
|
skipped.push({ index, reason: "关系分类无效" });
|
|
3109
3457
|
return;
|
|
@@ -3213,10 +3561,17 @@ export class AiManager {
|
|
|
3213
3561
|
merged.delete(key);
|
|
3214
3562
|
}
|
|
3215
3563
|
const relationshipIds = [];
|
|
3564
|
+
let replacedRelationshipCount = 0;
|
|
3216
3565
|
this.store.db.transaction(() => {
|
|
3217
|
-
if (scope.type === "book") {
|
|
3566
|
+
if (!targeted && scope.type === "book") {
|
|
3218
3567
|
this.store.db.run("DELETE FROM relationships WHERE work_id = ? AND confirmation_status = 'pending' AND locked = 0", workId);
|
|
3219
3568
|
}
|
|
3569
|
+
if (targeted && scope.replaceExistingRelationships === true) {
|
|
3570
|
+
const relationshipsToReplace = this.store.listRelationships(workId).filter((relationship) => selectedCharacterIds.has(String(relationship.fromCharacterId)) || selectedCharacterIds.has(String(relationship.toCharacterId)));
|
|
3571
|
+
for (const relationship of relationshipsToReplace)
|
|
3572
|
+
this.store.deleteRelationship(String(relationship.id));
|
|
3573
|
+
replacedRelationshipCount = relationshipsToReplace.length;
|
|
3574
|
+
}
|
|
3220
3575
|
const existing = this.store.listRelationships(workId).filter((relationship) => relationship.confirmationStatus !== "rejected");
|
|
3221
3576
|
const unorderedPairKey = (fromCharacterId, toCharacterId) => {
|
|
3222
3577
|
const pair = [String(fromCharacterId), String(toCharacterId)].sort((left, right) => left.localeCompare(right));
|
|
@@ -3413,7 +3768,11 @@ export class AiManager {
|
|
|
3413
3768
|
skippedCount: skipped.length,
|
|
3414
3769
|
fallbackSegmentCount,
|
|
3415
3770
|
policyOmittedSegmentCount,
|
|
3416
|
-
scopeType: scope.type
|
|
3771
|
+
scopeType: scope.type,
|
|
3772
|
+
targetedCharacterCount: selectedCharacterIds.size,
|
|
3773
|
+
targetedEvidenceCount,
|
|
3774
|
+
aggregationBatchCount,
|
|
3775
|
+
replacedRelationshipCount
|
|
3417
3776
|
});
|
|
3418
3777
|
return {
|
|
3419
3778
|
relationshipIds,
|
|
@@ -3424,6 +3783,10 @@ export class AiManager {
|
|
|
3424
3783
|
coveredChapterCount: chapters.length,
|
|
3425
3784
|
fallbackSegmentCount,
|
|
3426
3785
|
policyOmittedSegmentCount,
|
|
3786
|
+
targetedCharacterIds: [...selectedCharacterIds],
|
|
3787
|
+
targetedEvidenceCount,
|
|
3788
|
+
aggregationBatchCount,
|
|
3789
|
+
replacedRelationshipCount,
|
|
3427
3790
|
callIds
|
|
3428
3791
|
};
|
|
3429
3792
|
}
|