@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 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 lockedSettings = this.store.listSettings(workId).filter((item) => item.locked);
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 = selectRelationshipConstraints(this.store, workId, scope.characterIds ?? []);
436
- if (includeAutomaticContext && lockedSettings.length > 0) {
437
- constraints.push(`作者锁定设定(硬约束):\n${lockedSettings
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
- this.store.db.run(`INSERT INTO ai_calls (id, work_id, task_type, provider_id, model_id, context_scope_json, parameters_json,
1735
- status, input_chars, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?)`, callId, input.workId, input.taskType, stringValue(provider, "id"), stringValue(model, "id"), JSON.stringify(input.scope), JSON.stringify(parameters), context.length + input.instruction.length, timestamp, currentRequestActor()?.userId ?? null);
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
- return JSON.parse(candidate.body);
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
- return { content, reasoning, outputTokens: resolveOutputTokens(usage, content) };
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: { type: "selection", selection: text },
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
- this.store.updateTask(taskId, { status: "running", progress: Math.min(92, 5 + Math.round(completed / chunks.length * 87)) });
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
  }