@musnows/scriverse 0.5.3 → 0.5.5

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
@@ -1,11 +1,12 @@
1
+ import { buildCompletionRequestBody, normalizeProviderBaseUrl, parseCompletionPayload, providerCompletionEndpoint, providerModelEndpoints, providerRequestHeaders } from "./ai-protocol.js";
1
2
  import { PLATFORM_AI_WORK_ID } from "./database.js";
2
3
  import { AppError, notFound } from "./errors.js";
3
4
  import { logger, sanitizeError } from "./logger.js";
4
5
  import { paginated, paginationSql } from "./pagination.js";
5
6
  import { currentRequestActor } from "./request-context.js";
6
7
  import { fetchSafeAiEndpoint } from "./security.js";
7
- import { RELATIONSHIP_SEARCH_POLICY_VERSION, RelationshipApproximateMatchLimitError, findApproximateNameMatchesChunked, ftsPhrase, normalizeRelationshipSearchText, relationshipCharacterTokenText, relationshipCharacterTokens, relationshipPinyinTokenText, relationshipPinyinTokens } from "./relationship-search.js";
8
- import { clamp, id, json, maskSecret, normalizeBaseUrl, now } from "./utils.js";
8
+ import { RELATIONSHIP_SEARCH_POLICY_VERSION, RelationshipApproximateMatchLimitError, findApproximateNameMatchesChunked, ftsPhrase, isRelationshipPhoneticReference, normalizeRelationshipSearchText, relationshipCharacterTokenText, relationshipCharacterTokens, relationshipPinyinTokenText, relationshipPinyinTokens } from "./relationship-search.js";
9
+ import { clamp, id, json, maskSecret, now } from "./utils.js";
9
10
  import { z } from "zod";
10
11
  export function aiErrorForLog(error) {
11
12
  const sanitized = sanitizeError(error);
@@ -25,6 +26,10 @@ const RELATIONSHIP_MAX_FUZZY_SOURCES = 200;
25
26
  const RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS = 4_000_000;
26
27
  const RELATIONSHIP_MAX_FUZZY_MATCHES = 600;
27
28
  const RELATIONSHIP_MAX_SOURCE_MATCHES = 256;
29
+ const RELATIONSHIP_PREFILTER_DISABLE_HINT = "请取消勾选“分析前按人物名称和拼音过滤来源”后重新预览";
30
+ function relationshipCandidateLimitMessage(message) {
31
+ return `${message};${RELATIONSHIP_PREFILTER_DISABLE_HINT}`;
32
+ }
28
33
  function isGeminiProviderOrModel(provider, model) {
29
34
  const endpoint = stringValue(provider, "base_url").toLowerCase();
30
35
  const modelId = stringValue(model, "model_id").toLowerCase();
@@ -33,9 +38,22 @@ function isGeminiProviderOrModel(provider, model) {
33
38
  function isKimiModelId(modelId) {
34
39
  return modelId.toLowerCase().includes("kimi");
35
40
  }
41
+ function providerProtocol(provider) {
42
+ return stringValue(provider, "protocol") === "anthropic-messages" ? "anthropic-messages" : "openai-chat-completions";
43
+ }
44
+ function isLongCatProvider(provider) {
45
+ try {
46
+ return new URL(stringValue(provider, "base_url")).hostname.toLowerCase() === "api.longcat.chat";
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
36
52
  function thinkingParameters(provider, model) {
37
53
  if (isGeminiProviderOrModel(provider, model))
38
54
  return {};
55
+ if (providerProtocol(provider) === "anthropic-messages" && !isLongCatProvider(provider))
56
+ return {};
39
57
  return { thinking: { type: boolValue(model, "thinking_enabled") ? "enabled" : "disabled" } };
40
58
  }
41
59
  const AGENT_TOOL_IDS = ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections"];
@@ -305,10 +323,25 @@ export function resolveOutputTokens(usage, content) {
305
323
  }
306
324
  return estimateAiTokens(content);
307
325
  }
326
+ function reportedTokenCount(value) {
327
+ return typeof value === "number" && Number.isFinite(value)
328
+ ? Math.max(0, Math.round(value))
329
+ : null;
330
+ }
308
331
  function resolveInputCacheUsage(usage) {
309
332
  if (!usage || typeof usage !== "object")
310
333
  return null;
311
334
  const record = usage;
335
+ const anthropicCacheRead = reportedTokenCount(record.cache_read_input_tokens);
336
+ const anthropicCacheCreation = reportedTokenCount(record.cache_creation_input_tokens);
337
+ if (anthropicCacheRead !== null || anthropicCacheCreation !== null) {
338
+ const uncachedInputTokens = reportedTokenCount(record.input_tokens) ?? 0;
339
+ const cachedInputTokens = anthropicCacheRead ?? 0;
340
+ const inputTokens = uncachedInputTokens + cachedInputTokens + (anthropicCacheCreation ?? 0);
341
+ if (inputTokens <= 0)
342
+ return null;
343
+ return { inputTokens, cachedInputTokens };
344
+ }
312
345
  const promptDetails = record.prompt_tokens_details && typeof record.prompt_tokens_details === "object"
313
346
  ? record.prompt_tokens_details
314
347
  : {};
@@ -342,6 +375,37 @@ export function resolveCacheHitPercent(usage) {
342
375
  return undefined;
343
376
  return Math.round(resolved.cachedInputTokens / resolved.inputTokens * 1_000) / 10;
344
377
  }
378
+ export function resolveAiTokenUsage(usage, estimatedInputTokens, estimatedOutputTokens) {
379
+ const record = usage && typeof usage === "object" && !Array.isArray(usage)
380
+ ? usage
381
+ : {};
382
+ const reportedInputTokens = reportedTokenCount(record.prompt_tokens ?? record.input_tokens);
383
+ const reportedOutputTokens = reportedTokenCount(record.completion_tokens ?? record.output_tokens);
384
+ const cacheUsage = resolveInputCacheUsage(record);
385
+ const inputTokens = cacheUsage?.inputTokens
386
+ ?? reportedInputTokens
387
+ ?? Math.max(0, Math.round(estimatedInputTokens));
388
+ const outputTokens = reportedOutputTokens ?? Math.max(0, Math.round(estimatedOutputTokens));
389
+ return {
390
+ inputTokens,
391
+ outputTokens,
392
+ cachedInputTokens: cacheUsage?.cachedInputTokens ?? 0,
393
+ cacheEligibleInputTokens: cacheUsage?.inputTokens ?? 0,
394
+ source: reportedInputTokens !== null && reportedOutputTokens !== null
395
+ ? "reported"
396
+ : reportedInputTokens === null && reportedOutputTokens === null
397
+ ? "estimated"
398
+ : "mixed"
399
+ };
400
+ }
401
+ function completionPayloadOutputText(payload) {
402
+ const message = payload.choices?.[0]?.message;
403
+ return [
404
+ message?.reasoning_content ?? "",
405
+ message?.content ?? "",
406
+ ...(message?.tool_calls ?? []).map((toolCall) => `${toolCall.function.name}\n${String(toolCall.function.arguments ?? "")}`)
407
+ ].filter(Boolean).join("\n");
408
+ }
345
409
  function normalizeModelPreset(input, modelId = "") {
346
410
  const maxTokens = typeof input.max_tokens === "number" && Number.isFinite(input.max_tokens)
347
411
  ? Math.round(clamp(input.max_tokens, 1, 32_768))
@@ -864,6 +928,7 @@ export class AiManager {
864
928
  relationshipIndexBuilds = new Map();
865
929
  relationshipSelectionCache = new Map();
866
930
  relationshipSelectionBuilds = new Map();
931
+ relationshipIndexSyncTimers = new Map();
867
932
  relationshipIndexSerial = Promise.resolve();
868
933
  relationshipIndexTimer = null;
869
934
  relationshipIndexDisposed = false;
@@ -876,12 +941,101 @@ export class AiManager {
876
941
  this.authorizeTaskRun = authorizeTaskRun;
877
942
  this.contextBuilder = new ContextBuilder(store);
878
943
  this.store.setAnalysisTaskQueuedHandler((workId) => this.scheduleAutoRun(workId));
944
+ this.store.setRelationshipIndexQueuedHandler((workId) => this.scheduleRelationshipIndexSync(workId));
879
945
  this.relationshipIndexTimer = setTimeout(() => {
880
946
  this.relationshipIndexTimer = null;
881
947
  void this.schedulePendingRelationshipIndexes();
882
948
  }, 0);
883
949
  logger.info("ai.manager.ready");
884
950
  }
951
+ getPlatformTokenUsage(timezoneOffset) {
952
+ return this.getTokenUsage(null, timezoneOffset, true);
953
+ }
954
+ getWorkTokenUsage(workId, timezoneOffset) {
955
+ this.store.getWork(workId);
956
+ return this.getTokenUsage(workId, timezoneOffset, false);
957
+ }
958
+ getTokenUsage(workId, timezoneOffset, includeWorks) {
959
+ const scopeSql = workId === null ? "" : " AND call.work_id = ?";
960
+ const scopeParams = workId === null ? [] : [workId];
961
+ const usageFilter = "(call.input_tokens > 0 OR call.output_tokens > 0)";
962
+ const summary = this.store.db.get(`SELECT
963
+ COALESCE(SUM(call.input_tokens), 0) AS input_tokens,
964
+ COALESCE(SUM(call.output_tokens), 0) AS output_tokens,
965
+ COALESCE(SUM(call.cached_input_tokens), 0) AS cached_input_tokens,
966
+ COALESCE(SUM(call.cache_eligible_input_tokens), 0) AS cache_eligible_input_tokens,
967
+ COUNT(*) AS request_count,
968
+ COALESCE(SUM(CASE WHEN call.token_usage_source = 'reported' THEN 0 ELSE 1 END), 0) AS estimated_request_count,
969
+ MIN(call.created_at) AS first_used_at,
970
+ MAX(call.created_at) AS last_used_at
971
+ FROM ai_calls call
972
+ JOIN works work ON work.id = call.work_id
973
+ WHERE COALESCE(work.is_internal, 0) = 0 AND ${usageFilter}${scopeSql}`, ...scopeParams) ?? {};
974
+ const daily = this.store.db.all(`SELECT
975
+ date(call.created_at, printf('%+d minutes', ?)) AS usage_date,
976
+ COALESCE(SUM(call.input_tokens), 0) AS input_tokens,
977
+ COALESCE(SUM(call.output_tokens), 0) AS output_tokens,
978
+ COALESCE(SUM(call.cached_input_tokens), 0) AS cached_input_tokens,
979
+ COALESCE(SUM(call.cache_eligible_input_tokens), 0) AS cache_eligible_input_tokens,
980
+ COUNT(*) AS request_count,
981
+ COALESCE(SUM(CASE WHEN call.token_usage_source = 'reported' THEN 0 ELSE 1 END), 0) AS estimated_request_count
982
+ FROM ai_calls call
983
+ JOIN works work ON work.id = call.work_id
984
+ WHERE COALESCE(work.is_internal, 0) = 0 AND ${usageFilter}${scopeSql}
985
+ GROUP BY usage_date
986
+ ORDER BY usage_date`, timezoneOffset, ...scopeParams).map((row) => this.mapTokenUsageRow(row, { date: stringValue(row, "usage_date") }));
987
+ const works = includeWorks
988
+ ? this.store.db.all(`SELECT
989
+ work.id AS work_id,
990
+ work.title AS work_title,
991
+ COALESCE(SUM(call.input_tokens), 0) AS input_tokens,
992
+ COALESCE(SUM(call.output_tokens), 0) AS output_tokens,
993
+ COALESCE(SUM(call.cached_input_tokens), 0) AS cached_input_tokens,
994
+ COALESCE(SUM(call.cache_eligible_input_tokens), 0) AS cache_eligible_input_tokens,
995
+ COUNT(call.id) AS request_count,
996
+ COALESCE(SUM(CASE WHEN call.id IS NULL OR call.token_usage_source = 'reported' THEN 0 ELSE 1 END), 0) AS estimated_request_count,
997
+ MIN(call.created_at) AS first_used_at,
998
+ MAX(call.created_at) AS last_used_at
999
+ FROM works work
1000
+ LEFT JOIN ai_calls call ON call.work_id = work.id AND ${usageFilter}
1001
+ WHERE COALESCE(work.is_internal, 0) = 0
1002
+ GROUP BY work.id, work.title
1003
+ ORDER BY (COALESCE(SUM(call.input_tokens), 0) + COALESCE(SUM(call.output_tokens), 0)) DESC, work.title`).map((row) => this.mapTokenUsageRow(row, {
1004
+ workId: stringValue(row, "work_id"),
1005
+ workTitle: stringValue(row, "work_title"),
1006
+ firstUsedAt: row.first_used_at === null ? null : stringValue(row, "first_used_at"),
1007
+ lastUsedAt: row.last_used_at === null ? null : stringValue(row, "last_used_at")
1008
+ }))
1009
+ : undefined;
1010
+ return {
1011
+ summary: this.mapTokenUsageRow(summary, {
1012
+ firstUsedAt: summary.first_used_at === null || summary.first_used_at === undefined ? null : stringValue(summary, "first_used_at"),
1013
+ lastUsedAt: summary.last_used_at === null || summary.last_used_at === undefined ? null : stringValue(summary, "last_used_at")
1014
+ }),
1015
+ daily,
1016
+ ...(works ? { works } : {}),
1017
+ timezoneOffset
1018
+ };
1019
+ }
1020
+ mapTokenUsageRow(row, extra) {
1021
+ const inputTokens = numberValue(row, "input_tokens");
1022
+ const outputTokens = numberValue(row, "output_tokens");
1023
+ const cachedInputTokens = numberValue(row, "cached_input_tokens");
1024
+ const cacheEligibleInputTokens = numberValue(row, "cache_eligible_input_tokens");
1025
+ return {
1026
+ ...extra,
1027
+ totalTokens: inputTokens + outputTokens,
1028
+ inputTokens,
1029
+ outputTokens,
1030
+ cachedInputTokens,
1031
+ cacheEligibleInputTokens,
1032
+ cacheHitRate: cacheEligibleInputTokens > 0
1033
+ ? Math.round(cachedInputTokens / cacheEligibleInputTokens * 1_000) / 10
1034
+ : null,
1035
+ requestCount: numberValue(row, "request_count"),
1036
+ estimatedRequestCount: numberValue(row, "estimated_request_count")
1037
+ };
1038
+ }
885
1039
  resetAutoRunBatch(workId) {
886
1040
  this.autoRunBatches.set(workId, { claimed: 0, starting: new Set() });
887
1041
  }
@@ -932,10 +1086,14 @@ export class AiManager {
932
1086
  this.autoRunTimers.clear();
933
1087
  this.autoRunBatches.clear();
934
1088
  this.relationshipIndexDisposed = true;
1089
+ for (const timer of this.relationshipIndexSyncTimers.values())
1090
+ clearTimeout(timer);
1091
+ this.relationshipIndexSyncTimers.clear();
935
1092
  if (this.relationshipIndexTimer)
936
1093
  clearTimeout(this.relationshipIndexTimer);
937
1094
  this.relationshipIndexTimer = null;
938
1095
  this.store.setAnalysisTaskQueuedHandler(null);
1096
+ this.store.setRelationshipIndexQueuedHandler(null);
939
1097
  logger.info("ai.manager.disposed");
940
1098
  }
941
1099
  getAutoRunBatch(workId) {
@@ -990,10 +1148,12 @@ export class AiManager {
990
1148
  const providerId = id("provider");
991
1149
  const encrypted = this.vault.encrypt(input.apiKey);
992
1150
  const timestamp = now();
993
- this.store.db.run(`INSERT INTO providers (id, work_id, name, base_url, encrypted_key, key_iv, key_tag, key_hint, status,
1151
+ const protocol = input.protocol ?? "openai-chat-completions";
1152
+ const baseUrl = normalizeProviderBaseUrl(input.baseUrl);
1153
+ this.store.db.run(`INSERT INTO providers (id, work_id, name, base_url, protocol, encrypted_key, key_iv, key_tag, key_hint, status,
994
1154
  connection_status, concurrency_limit, rpm_limit, max_tokens, note, created_at, updated_at)
995
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'unchecked', ?, ?, ?, ?, ?, ?)`, providerId, PLATFORM_AI_WORK_ID, input.name, normalizeBaseUrl(input.baseUrl), encrypted.encrypted, encrypted.iv, encrypted.tag, maskSecret(input.apiKey), input.status ?? "disabled", input.concurrencyLimit ?? 10, input.rpmLimit ?? 10, input.maxTokens ?? DEFAULT_MAX_TOKENS, input.note ?? "", timestamp, timestamp);
996
- this.store.audit(PLATFORM_AI_WORK_ID, "provider.created", "provider", providerId, { name: input.name, baseUrl: normalizeBaseUrl(input.baseUrl) });
1155
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unchecked', ?, ?, ?, ?, ?, ?)`, providerId, PLATFORM_AI_WORK_ID, input.name, baseUrl, protocol, encrypted.encrypted, encrypted.iv, encrypted.tag, maskSecret(input.apiKey), input.status ?? "disabled", input.concurrencyLimit ?? 10, input.rpmLimit ?? 10, input.maxTokens ?? DEFAULT_MAX_TOKENS, input.note ?? "", timestamp, timestamp);
1156
+ this.store.audit(PLATFORM_AI_WORK_ID, "provider.created", "provider", providerId, { name: input.name, baseUrl, protocol });
997
1157
  return this.getProvider(providerId);
998
1158
  }
999
1159
  listProviders() {
@@ -1022,10 +1182,12 @@ export class AiManager {
1022
1182
  keyHint = maskSecret(input.apiKey);
1023
1183
  connectionStatus = "unchecked";
1024
1184
  }
1025
- if (input.baseUrl && normalizeBaseUrl(input.baseUrl) !== stringValue(row, "base_url"))
1185
+ if (input.baseUrl && normalizeProviderBaseUrl(input.baseUrl) !== stringValue(row, "base_url"))
1186
+ connectionStatus = "unchecked";
1187
+ if (input.protocol && input.protocol !== providerProtocol(row))
1026
1188
  connectionStatus = "unchecked";
1027
- this.store.db.run(`UPDATE providers SET name = ?, base_url = ?, encrypted_key = ?, key_iv = ?, key_tag = ?, key_hint = ?,
1028
- status = ?, connection_status = ?, concurrency_limit = ?, rpm_limit = ?, max_tokens = ?, note = ?, updated_at = ? WHERE id = ?`, input.name ?? stringValue(row, "name"), input.baseUrl ? normalizeBaseUrl(input.baseUrl) : stringValue(row, "base_url"), encryptedKey, keyIv, keyTag, keyHint, input.status ?? stringValue(row, "status"), connectionStatus, input.concurrencyLimit ?? numberValue(row, "concurrency_limit"), input.rpmLimit ?? numberValue(row, "rpm_limit"), input.maxTokens ?? numberValue(row, "max_tokens"), input.note ?? stringValue(row, "note"), now(), providerId);
1189
+ this.store.db.run(`UPDATE providers SET name = ?, base_url = ?, protocol = ?, encrypted_key = ?, key_iv = ?, key_tag = ?, key_hint = ?,
1190
+ status = ?, connection_status = ?, concurrency_limit = ?, rpm_limit = ?, max_tokens = ?, note = ?, updated_at = ? WHERE id = ?`, input.name ?? stringValue(row, "name"), input.baseUrl ? normalizeProviderBaseUrl(input.baseUrl) : stringValue(row, "base_url"), input.protocol ?? providerProtocol(row), encryptedKey, keyIv, keyTag, keyHint, input.status ?? stringValue(row, "status"), connectionStatus, input.concurrencyLimit ?? numberValue(row, "concurrency_limit"), input.rpmLimit ?? numberValue(row, "rpm_limit"), input.maxTokens ?? numberValue(row, "max_tokens"), input.note ?? stringValue(row, "note"), now(), providerId);
1029
1191
  this.store.audit(PLATFORM_AI_WORK_ID, "provider.updated", "provider", providerId, {
1030
1192
  fields: Object.keys(input).filter((key) => key !== "apiKey"),
1031
1193
  keyReplaced: Boolean(input.apiKey)
@@ -1051,26 +1213,40 @@ export class AiManager {
1051
1213
  async testProvider(providerId) {
1052
1214
  const row = this.getProviderRow(providerId);
1053
1215
  const apiKey = this.decryptKey(row);
1216
+ const protocol = providerProtocol(row);
1054
1217
  const controller = new AbortController();
1055
1218
  const timeout = setTimeout(() => controller.abort(), 10_000);
1056
1219
  const startedAt = process.hrtime.bigint();
1057
1220
  logger.info("ai.provider_test.started", { providerId });
1058
1221
  try {
1059
- const endpoint = `${normalizeBaseUrl(stringValue(row, "base_url"))}/models`;
1060
- const response = await this.outboundFetch(endpoint, {
1061
- headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
1062
- signal: controller.signal
1063
- });
1064
- if (!response.ok) {
1222
+ let payload = null;
1223
+ let lastFailure = "AI 供应商没有返回模型列表";
1224
+ const endpoints = providerModelEndpoints(stringValue(row, "base_url"), protocol);
1225
+ for (let index = 0; index < endpoints.length; index += 1) {
1226
+ const endpoint = endpoints[index];
1227
+ if (!endpoint)
1228
+ continue;
1229
+ const response = await this.outboundFetch(endpoint, {
1230
+ headers: providerRequestHeaders(protocol, apiKey, "application/json"),
1231
+ signal: controller.signal
1232
+ });
1233
+ if (response.ok) {
1234
+ payload = (await response.json());
1235
+ break;
1236
+ }
1065
1237
  const message = await response.text();
1066
- throw new Error(`HTTP ${response.status}: ${message.slice(0, 300)}`);
1238
+ lastFailure = `HTTP ${response.status}: ${message.slice(0, 300)}`;
1239
+ if (response.status !== 404 || index === endpoints.length - 1)
1240
+ break;
1067
1241
  }
1068
- const payload = (await response.json());
1242
+ if (!payload)
1243
+ throw new Error(lastFailure);
1069
1244
  const availableModels = Array.isArray(payload.data) ? payload.data.map((item) => item.id).filter(Boolean) : [];
1070
1245
  const timestamp = now();
1071
1246
  this.store.db.run("UPDATE providers SET connection_status = 'success', last_error = NULL, last_success_at = ?, updated_at = ? WHERE id = ?", timestamp, timestamp, providerId);
1072
1247
  logger.info("ai.provider_test.completed", {
1073
1248
  providerId,
1249
+ protocol,
1074
1250
  ok: true,
1075
1251
  availableModelCount: availableModels.length,
1076
1252
  durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000
@@ -1078,10 +1254,11 @@ export class AiManager {
1078
1254
  return { ok: true, availableModels, provider: this.getProvider(providerId) };
1079
1255
  }
1080
1256
  catch (error) {
1081
- const message = error instanceof Error ? error.message : "连接失败";
1257
+ const message = error instanceof Error ? redactProviderSecret(error.message, apiKey) : "连接失败";
1082
1258
  this.store.db.run("UPDATE providers SET connection_status = 'failed', last_error = ?, updated_at = ? WHERE id = ?", message, now(), providerId);
1083
1259
  logger.warn("ai.provider_test.completed", {
1084
1260
  providerId,
1261
+ protocol,
1085
1262
  ok: false,
1086
1263
  durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000,
1087
1264
  error: aiErrorForLog(error)
@@ -1192,12 +1369,226 @@ export class AiManager {
1192
1369
  const modelId = input.modelId ?? (defaultRow ? stringValue(defaultRow, "model_id") : undefined);
1193
1370
  if (modelId)
1194
1371
  this.resolveModel(workId, modelPurpose, modelId);
1372
+ const relationshipScope = input.taskType === "relationship-analysis" && input.scope
1373
+ ? input.scope
1374
+ : null;
1375
+ if (relationshipScope && Array.isArray(relationshipScope.relationshipSourceRefs)) {
1376
+ this.relationshipSourcesFromRefs(workId, relationshipScope, this.store.listCharacters(workId), relationshipScope.relationshipSourceRefs);
1377
+ }
1195
1378
  return this.store.createTask(workId, {
1196
1379
  taskType: input.taskType,
1197
1380
  ...(input.scope ? { scope: input.scope } : {}),
1198
1381
  ...(modelId ? { modelId } : {})
1199
1382
  });
1200
1383
  }
1384
+ applyRelationshipChangePreview(taskId) {
1385
+ const task = this.store.getTask(taskId);
1386
+ if (task.taskType !== "relationship-analysis") {
1387
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_REQUIRED", "只有人物关系分析任务可以应用关系变更");
1388
+ }
1389
+ const result = this.store.getTaskStoredResult(taskId);
1390
+ const preview = result.relationshipChangePreview && typeof result.relationshipChangePreview === "object"
1391
+ && !Array.isArray(result.relationshipChangePreview)
1392
+ ? result.relationshipChangePreview
1393
+ : null;
1394
+ if (!preview)
1395
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_REQUIRED", "该任务没有待确认的关系变更");
1396
+ if (preview.status !== "pending") {
1397
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_NOT_PENDING", preview.status === "applied"
1398
+ ? "本次关系变更已经应用"
1399
+ : "本次关系变更已经放弃");
1400
+ }
1401
+ if (!this.store.isTaskSourceCurrent(taskId)) {
1402
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_SOURCE_CHANGED", "分析来源已发生变化,请重新运行分析后再应用");
1403
+ }
1404
+ const operations = Array.isArray(preview.operations)
1405
+ ? preview.operations.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item)
1406
+ && ["created", "updated", "deleted"].includes(String(item.action)))
1407
+ : [];
1408
+ if (operations.length !== Number(preview.totalCount ?? operations.length) || operations.length > 5_000) {
1409
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_INVALID", "关系变更预览数据不完整,请重新运行分析");
1410
+ }
1411
+ const workId = String(task.workId);
1412
+ const relationshipInput = (snapshot) => ({
1413
+ fromCharacterId: String(snapshot.fromCharacterId),
1414
+ toCharacterId: String(snapshot.toCharacterId),
1415
+ category: String(snapshot.category),
1416
+ subtype: String(snapshot.subtype ?? ""),
1417
+ keywords: Array.isArray(snapshot.keywords) ? snapshot.keywords.map(String) : [],
1418
+ directed: snapshot.directed === true,
1419
+ currentStatus: String(snapshot.currentStatus ?? "active"),
1420
+ timeRange: snapshot.timeRange && typeof snapshot.timeRange === "object" && !Array.isArray(snapshot.timeRange)
1421
+ ? snapshot.timeRange
1422
+ : {},
1423
+ confidence: Number(snapshot.confidence ?? 0.5),
1424
+ evidence: Array.isArray(snapshot.evidence) ? snapshot.evidence : [],
1425
+ confirmationStatus: String(snapshot.confirmationStatus ?? "pending"),
1426
+ locked: snapshot.locked === true
1427
+ });
1428
+ return this.store.db.transaction(() => {
1429
+ const currentResult = this.store.getTaskStoredResult(taskId);
1430
+ const currentPreview = currentResult.relationshipChangePreview && typeof currentResult.relationshipChangePreview === "object"
1431
+ && !Array.isArray(currentResult.relationshipChangePreview)
1432
+ ? currentResult.relationshipChangePreview
1433
+ : null;
1434
+ if (currentPreview?.status !== "pending") {
1435
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_NOT_PENDING", currentPreview?.status === "applied"
1436
+ ? "本次关系变更已经应用"
1437
+ : "本次关系变更已经放弃");
1438
+ }
1439
+ for (const operation of operations) {
1440
+ if (operation.action === "created")
1441
+ continue;
1442
+ const before = operation.before;
1443
+ const expectedVersionNo = Number(operation.expectedVersionNo ?? before?.versionNo);
1444
+ let current;
1445
+ try {
1446
+ current = this.store.getRelationship(operation.relationshipId);
1447
+ }
1448
+ catch {
1449
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_STALE", "待处理的人物关系已经不存在,请重新运行分析", {
1450
+ relationshipId: operation.relationshipId
1451
+ });
1452
+ }
1453
+ if (String(current.workId) !== workId || !Number.isInteger(expectedVersionNo) || Number(current.versionNo) !== expectedVersionNo) {
1454
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_STALE", "人物关系已在预览后发生变化,请重新运行分析", {
1455
+ relationshipId: operation.relationshipId,
1456
+ expectedVersionNo,
1457
+ actualVersionNo: Number(current.versionNo)
1458
+ });
1459
+ }
1460
+ }
1461
+ const appliedResults = [];
1462
+ const appliedRelationshipIds = [];
1463
+ for (const operation of operations.filter((item) => item.action === "deleted")) {
1464
+ const before = operation.before;
1465
+ this.store.deleteRelationship(operation.relationshipId, Number(operation.expectedVersionNo ?? before.versionNo));
1466
+ appliedResults.push(this.relationshipResultSnapshot(workId, "deleted", before));
1467
+ }
1468
+ for (const operation of operations.filter((item) => item.action === "updated")) {
1469
+ const after = operation.after;
1470
+ const updated = this.store.updateRelationship(operation.relationshipId, relationshipInput(after), "analysis", taskId, "应用 AI 人物关系变更预览", Number(operation.expectedVersionNo ?? operation.before?.versionNo));
1471
+ appliedRelationshipIds.push(String(updated.id));
1472
+ appliedResults.push(this.relationshipResultSnapshot(workId, "updated", updated));
1473
+ }
1474
+ for (const operation of operations.filter((item) => item.action === "created")) {
1475
+ const created = this.store.createRelationship(workId, relationshipInput(operation.after), "analysis", taskId);
1476
+ appliedRelationshipIds.push(String(created.id));
1477
+ appliedResults.push(this.relationshipResultSnapshot(workId, "created", created));
1478
+ }
1479
+ const unchangedResults = Array.isArray(currentResult.relationshipResults)
1480
+ ? currentResult.relationshipResults.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item)
1481
+ && item.action === "unchanged")
1482
+ : [];
1483
+ const appliedAt = now();
1484
+ const nextResult = {
1485
+ ...currentResult,
1486
+ relationshipIds: appliedRelationshipIds,
1487
+ candidateCount: appliedRelationshipIds.length,
1488
+ createdCount: operations.filter((item) => item.action === "created").length,
1489
+ updatedCount: operations.filter((item) => item.action === "updated").length,
1490
+ deletedCount: operations.filter((item) => item.action === "deleted").length,
1491
+ relationshipResults: [...appliedResults, ...unchangedResults],
1492
+ relationshipChangePreview: {
1493
+ ...currentPreview,
1494
+ status: "applied",
1495
+ appliedAt,
1496
+ appliedRelationshipIds
1497
+ }
1498
+ };
1499
+ const updatedTask = this.store.updateTask(taskId, { status: String(task.status), result: nextResult });
1500
+ this.store.audit(workId, "relationship.analysis.changes-applied", "analysis-task", taskId, {
1501
+ createdCount: nextResult.createdCount,
1502
+ updatedCount: nextResult.updatedCount,
1503
+ deletedCount: nextResult.deletedCount
1504
+ });
1505
+ logger.info("ai.relationship_changes.applied", {
1506
+ taskId,
1507
+ workId,
1508
+ createdCount: nextResult.createdCount,
1509
+ updatedCount: nextResult.updatedCount,
1510
+ deletedCount: nextResult.deletedCount
1511
+ });
1512
+ return updatedTask;
1513
+ });
1514
+ }
1515
+ discardRelationshipChangePreview(taskId) {
1516
+ const task = this.store.getTask(taskId);
1517
+ if (task.taskType !== "relationship-analysis") {
1518
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_REQUIRED", "只有人物关系分析任务可以放弃关系变更");
1519
+ }
1520
+ const result = this.store.getTaskStoredResult(taskId);
1521
+ const preview = result.relationshipChangePreview && typeof result.relationshipChangePreview === "object"
1522
+ && !Array.isArray(result.relationshipChangePreview)
1523
+ ? result.relationshipChangePreview
1524
+ : null;
1525
+ if (!preview)
1526
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_REQUIRED", "该任务没有待确认的关系变更");
1527
+ if (preview.status !== "pending") {
1528
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_NOT_PENDING", preview.status === "applied"
1529
+ ? "本次关系变更已经应用"
1530
+ : "本次关系变更已经放弃");
1531
+ }
1532
+ return this.store.db.transaction(() => {
1533
+ const currentResult = this.store.getTaskStoredResult(taskId);
1534
+ const currentPreview = currentResult.relationshipChangePreview && typeof currentResult.relationshipChangePreview === "object"
1535
+ && !Array.isArray(currentResult.relationshipChangePreview)
1536
+ ? currentResult.relationshipChangePreview
1537
+ : null;
1538
+ if (currentPreview?.status !== "pending") {
1539
+ throw new AppError(409, "RELATIONSHIP_PREVIEW_NOT_PENDING", currentPreview?.status === "applied"
1540
+ ? "本次关系变更已经应用"
1541
+ : "本次关系变更已经放弃");
1542
+ }
1543
+ const discardedAt = now();
1544
+ const updatedTask = this.store.updateTask(taskId, {
1545
+ status: String(task.status),
1546
+ result: {
1547
+ ...currentResult,
1548
+ relationshipChangePreview: { ...currentPreview, status: "discarded", discardedAt }
1549
+ }
1550
+ });
1551
+ this.store.audit(String(task.workId), "relationship.analysis.changes-discarded", "analysis-task", taskId, {
1552
+ changeCount: Number(currentPreview.totalCount ?? 0)
1553
+ });
1554
+ logger.info("ai.relationship_changes.discarded", {
1555
+ taskId,
1556
+ workId: task.workId,
1557
+ changeCount: Number(currentPreview.totalCount ?? 0)
1558
+ });
1559
+ return updatedTask;
1560
+ });
1561
+ }
1562
+ rerunTask(taskId) {
1563
+ const original = this.store.getTask(taskId);
1564
+ const rerunnableStatuses = new Set(["review", "completed", "partial", "expired", "cancelled"]);
1565
+ if (!rerunnableStatuses.has(String(original.status))) {
1566
+ throw new AppError(409, "TASK_NOT_RERUNNABLE", "只有已结束的分析任务可以按原配置重跑");
1567
+ }
1568
+ const originalScope = original.scope && typeof original.scope === "object" && !Array.isArray(original.scope)
1569
+ ? original.scope
1570
+ : {};
1571
+ const { targetCharacters: _targetCharacters, relationshipSourceRefs: _relationshipSourceRefs, ...scope } = originalScope;
1572
+ const originalModel = original.model && typeof original.model === "object" && !Array.isArray(original.model)
1573
+ ? original.model
1574
+ : null;
1575
+ const modelId = typeof originalModel?.id === "string" ? originalModel.id : undefined;
1576
+ if (modelId)
1577
+ this.resolveModel(String(original.workId), this.analysisTaskModelPurpose(String(original.taskType)), modelId);
1578
+ const rerun = this.store.createTask(String(original.workId), {
1579
+ taskType: String(original.taskType),
1580
+ scope,
1581
+ ...(modelId ? { modelId } : {}),
1582
+ rerunOfTaskId: taskId
1583
+ });
1584
+ logger.info("ai.task.rerun_created", {
1585
+ taskId: rerun.id,
1586
+ originalTaskId: taskId,
1587
+ workId: original.workId,
1588
+ taskType: original.taskType
1589
+ });
1590
+ return { ...rerun, rerunOfTaskId: taskId };
1591
+ }
1201
1592
  async createSuggestion(input) {
1202
1593
  const action = input.taskType === "continue" ? "append" : input.taskType === "polish" ? "replace-selection" : "note";
1203
1594
  if (action === "replace-selection" && !input.scope.selection) {
@@ -1572,7 +1963,10 @@ export class AiManager {
1572
1963
  if (this.store.getTask(taskId).status !== "running")
1573
1964
  return this.store.getTask(taskId);
1574
1965
  const message = error instanceof Error ? error.message : "分析失败";
1575
- this.store.updateTask(taskId, { status: "partial", progress: 100, failures: [{ message }] });
1966
+ const failure = error instanceof AppError
1967
+ ? { message, code: error.code, ...(error.details === undefined ? {} : { details: error.details }) }
1968
+ : { message };
1969
+ this.store.updateTask(taskId, { status: "partial", progress: 100, failures: [failure] });
1576
1970
  logger.error("ai.task.failed", { taskId, workId, durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000, error: aiErrorForLog(error) });
1577
1971
  throw error;
1578
1972
  }
@@ -2028,22 +2422,43 @@ export class AiManager {
2028
2422
  this.store.db.run("UPDATE ai_call_traces SET rounds_json = ?, source_refs_json = ?, updated_at = ? WHERE call_id = ?", JSON.stringify(traceRounds), JSON.stringify(taskTraceSourceRefs(messages, traceRounds)), now(), callId);
2029
2423
  };
2030
2424
  const callStartedAt = process.hrtime.bigint();
2425
+ const protocol = providerProtocol(provider);
2031
2426
  logger.info("ai.call.started", {
2032
2427
  callId,
2033
2428
  workId: input.workId,
2034
2429
  taskType: input.taskType,
2035
2430
  providerId: stringValue(provider, "id"),
2036
2431
  modelId: stringValue(model, "id"),
2432
+ protocol,
2037
2433
  streaming: false,
2038
2434
  contextChars: context.length,
2039
2435
  instructionChars: input.instruction.length,
2040
2436
  toolCount: tools.length
2041
2437
  });
2042
2438
  let activeApiKey = "";
2439
+ let trackedInputTokens = 0;
2440
+ let trackedOutputTokens = 0;
2441
+ let trackedCachedInputTokens = 0;
2442
+ let trackedCacheEligibleInputTokens = 0;
2443
+ const trackedUsageSources = new Set();
2444
+ const trackUsage = (usage) => {
2445
+ trackedInputTokens += usage.inputTokens;
2446
+ trackedOutputTokens += usage.outputTokens;
2447
+ trackedCachedInputTokens += usage.cachedInputTokens;
2448
+ trackedCacheEligibleInputTokens += usage.cacheEligibleInputTokens;
2449
+ trackedUsageSources.add(usage.source);
2450
+ };
2451
+ const trackedUsageSource = () => {
2452
+ if (trackedUsageSources.size === 1 && trackedUsageSources.has("reported"))
2453
+ return "reported";
2454
+ if (trackedUsageSources.size === 1 && trackedUsageSources.has("estimated"))
2455
+ return "estimated";
2456
+ return "mixed";
2457
+ };
2043
2458
  try {
2044
2459
  const apiKey = this.decryptKey(provider);
2045
2460
  activeApiKey = apiKey;
2046
- const endpoint = `${normalizeBaseUrl(stringValue(provider, "base_url"))}/chat/completions`;
2461
+ const endpoint = providerCompletionEndpoint(stringValue(provider, "base_url"), protocol);
2047
2462
  const timeoutMs = input.taskType === "book-analysis" || input.taskType === "relationship-analysis" ? 300_000 : 60_000;
2048
2463
  const maximumAttempts = Math.round(clamp(input.maxAttempts ?? 3, 1, 5));
2049
2464
  let completionRequestCount = 0;
@@ -2090,13 +2505,15 @@ export class AiManager {
2090
2505
  try {
2091
2506
  const response = await this.outboundFetch(endpoint, {
2092
2507
  method: "POST",
2093
- headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", Accept: "application/json" },
2094
- body: JSON.stringify({
2508
+ headers: providerRequestHeaders(protocol, apiKey, "application/json"),
2509
+ body: JSON.stringify(buildCompletionRequestBody({
2510
+ protocol,
2095
2511
  model: stringValue(model, "model_id"),
2096
2512
  messages: completionMessages,
2097
- ...parameters,
2098
- ...(tools.length && toolChoice === "auto" ? { tools, tool_choice: "auto" } : {})
2099
- }),
2513
+ parameters,
2514
+ tools,
2515
+ toolChoice
2516
+ })),
2100
2517
  signal: controller.signal
2101
2518
  });
2102
2519
  return { ok: response.ok, status: response.status, body: await response.text() };
@@ -2115,7 +2532,7 @@ export class AiManager {
2115
2532
  });
2116
2533
  if (candidate.ok) {
2117
2534
  try {
2118
- const parsed = redactProviderSecrets(JSON.parse(candidate.body), apiKey);
2535
+ const parsed = parseCompletionPayload(protocol, redactProviderSecrets(JSON.parse(candidate.body), apiKey));
2119
2536
  traceAttempt.completedAt = now();
2120
2537
  traceAttempt.status = "completed";
2121
2538
  traceAttempt.httpStatus = candidate.status;
@@ -2129,10 +2546,12 @@ export class AiManager {
2129
2546
  totalInputTokens += cacheUsage.inputTokens;
2130
2547
  totalCachedInputTokens += cacheUsage.cachedInputTokens;
2131
2548
  }
2549
+ const outputText = completionPayloadOutputText(parsed);
2550
+ trackUsage(resolveAiTokenUsage(parsed.usage, estimateAiTokens(JSON.stringify(completionMessages)), outputText ? estimateAiTokens(outputText) : 0));
2132
2551
  return parsed;
2133
2552
  }
2134
2553
  catch {
2135
- throw new Error(`Chat Completions returned invalid JSON: ${candidate.body.slice(0, 500)}`);
2554
+ throw new Error(`${protocol === "anthropic-messages" ? "Anthropic Messages" : "Chat Completions"} returned invalid JSON: ${candidate.body.slice(0, 500)}`);
2136
2555
  }
2137
2556
  }
2138
2557
  lastFailure = new Error(`HTTP ${candidate.status}: ${candidate.body.slice(0, 500)}`);
@@ -2211,7 +2630,8 @@ export class AiManager {
2211
2630
  role: "assistant",
2212
2631
  content: choice.message.content ?? null,
2213
2632
  reasoning_content: choice.message.reasoning_content ?? null,
2214
- tool_calls: normalizedToolCalls
2633
+ tool_calls: normalizedToolCalls,
2634
+ ...(choice.message.anthropic_content?.length ? { anthropic_content: choice.message.anthropic_content } : {})
2215
2635
  });
2216
2636
  for (const toolCall of toolCalls) {
2217
2637
  const execution = this.executeAgentTool(input.workId, toolCall);
@@ -2244,13 +2664,17 @@ export class AiManager {
2244
2664
  const suffix = choice?.finish_reason === "length" || reasoningLength > 0
2245
2665
  ? `;模型已生成 ${reasoningLength} 个推理字符,请提高 max_tokens 输出预算`
2246
2666
  : "";
2247
- throw new Error(`Chat Completions 响应缺少可用正文,finish_reason=${choice?.finish_reason ?? "unknown"}${suffix}`);
2667
+ throw new Error(`${protocol === "anthropic-messages" ? "Anthropic Messages" : "Chat Completions"} 响应缺少可用正文,finish_reason=${choice?.finish_reason ?? "unknown"}${suffix}`);
2248
2668
  }
2249
- this.store.db.run("UPDATE ai_calls SET status = 'completed', output_chars = ?, completed_at = ? WHERE id = ?", content.length, now(), callId);
2250
2669
  const outputTokens = resolveOutputTokens(payload.usage, content);
2251
2670
  const cacheHitPercent = cacheUsageComplete && completionRequestCount > 0 && totalInputTokens > 0
2252
2671
  ? Math.round(totalCachedInputTokens / totalInputTokens * 1_000) / 10
2253
2672
  : undefined;
2673
+ this.store.db.run(`UPDATE ai_calls
2674
+ SET status = 'completed', output_chars = ?, input_tokens = ?, output_tokens = ?,
2675
+ cached_input_tokens = ?, cache_eligible_input_tokens = ?, cache_usage_available = ?,
2676
+ token_usage_source = ?, completed_at = ?
2677
+ WHERE id = ?`, content.length, trackedInputTokens, trackedOutputTokens, trackedCachedInputTokens, trackedCacheEligibleInputTokens, trackedCacheEligibleInputTokens > 0 ? 1 : 0, trackedUsageSource(), now(), callId);
2254
2678
  logger.info("ai.call.completed", {
2255
2679
  callId,
2256
2680
  workId: input.workId,
@@ -2265,7 +2689,11 @@ export class AiManager {
2265
2689
  }
2266
2690
  catch (error) {
2267
2691
  const message = error instanceof Error ? redactProviderSecret(error.message, activeApiKey) : "AI 调用失败";
2268
- this.store.db.run("UPDATE ai_calls SET status = 'failed', failure = ?, completed_at = ? WHERE id = ?", message, now(), callId);
2692
+ this.store.db.run(`UPDATE ai_calls
2693
+ SET status = 'failed', failure = ?, input_tokens = ?, output_tokens = ?,
2694
+ cached_input_tokens = ?, cache_eligible_input_tokens = ?, cache_usage_available = ?,
2695
+ token_usage_source = ?, completed_at = ?
2696
+ WHERE id = ?`, message, trackedInputTokens, trackedOutputTokens, trackedCachedInputTokens, trackedCacheEligibleInputTokens, trackedCacheEligibleInputTokens > 0 ? 1 : 0, trackedUsageSource(), now(), callId);
2269
2697
  logger.error("ai.call.failed", {
2270
2698
  callId,
2271
2699
  workId: input.workId,
@@ -2290,19 +2718,23 @@ export class AiManager {
2290
2718
  this.store.db.run(`INSERT INTO ai_calls (id, work_id, task_type, provider_id, model_id, context_scope_json, parameters_json,
2291
2719
  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, now(), currentRequestActor()?.userId ?? null);
2292
2720
  const callStartedAt = process.hrtime.bigint();
2721
+ const protocol = providerProtocol(provider);
2293
2722
  logger.info("ai.call.started", {
2294
2723
  callId,
2295
2724
  workId: input.workId,
2296
2725
  taskType: input.taskType,
2297
2726
  providerId: stringValue(provider, "id"),
2298
2727
  modelId: stringValue(model, "id"),
2728
+ protocol,
2299
2729
  streaming: true,
2300
2730
  contextChars: context.length,
2301
2731
  instructionChars: input.instruction.length
2302
2732
  });
2733
+ let activeApiKey = "";
2303
2734
  try {
2304
2735
  const apiKey = this.decryptKey(provider);
2305
- const endpoint = `${normalizeBaseUrl(stringValue(provider, "base_url"))}/chat/completions`;
2736
+ activeApiKey = apiKey;
2737
+ const endpoint = providerCompletionEndpoint(stringValue(provider, "base_url"), protocol);
2306
2738
  const maximumAttempts = Math.round(clamp(input.maxAttempts ?? 3, 1, 5));
2307
2739
  let streamedResult = null;
2308
2740
  let lastFailure = null;
@@ -2324,13 +2756,19 @@ export class AiManager {
2324
2756
  try {
2325
2757
  const response = await this.outboundFetch(endpoint, {
2326
2758
  method: "POST",
2327
- headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", Accept: "text/event-stream" },
2328
- body: JSON.stringify({ model: stringValue(model, "model_id"), messages, ...parameters, stream: true, stream_options: { include_usage: true } }),
2759
+ headers: providerRequestHeaders(protocol, apiKey, "text/event-stream"),
2760
+ body: JSON.stringify(buildCompletionRequestBody({
2761
+ protocol,
2762
+ model: stringValue(model, "model_id"),
2763
+ messages,
2764
+ parameters,
2765
+ stream: true
2766
+ })),
2329
2767
  signal: controller.signal
2330
2768
  });
2331
2769
  if (!response.ok)
2332
2770
  return { ok: false, status: response.status, body: await response.text() };
2333
- const streamed = await this.readCompletionStream(response, (delta) => {
2771
+ const streamed = await this.readCompletionStream(response, protocol, estimateAiTokens(JSON.stringify(messages)), (delta) => {
2334
2772
  emitted = true;
2335
2773
  onDelta(delta);
2336
2774
  }, (delta) => {
@@ -2378,11 +2816,15 @@ export class AiManager {
2378
2816
  }
2379
2817
  if (streamedResult === null)
2380
2818
  throw lastFailure instanceof Error ? lastFailure : new Error("AI 流式请求重试后仍未返回响应");
2381
- const { content, reasoning, outputTokens, cacheHitPercent } = streamedResult;
2819
+ const { content, reasoning, outputTokens, cacheHitPercent, tokenUsage } = streamedResult;
2382
2820
  const processSteps = reasoning.trim()
2383
2821
  ? [{ id: thinkingStepId, type: "thinking", round: 1, content: reasoning, createdAt: thinkingCreatedAt }]
2384
2822
  : [];
2385
- this.store.db.run("UPDATE ai_calls SET status = 'completed', output_chars = ?, completed_at = ? WHERE id = ?", content.length, now(), callId);
2823
+ this.store.db.run(`UPDATE ai_calls
2824
+ SET status = 'completed', output_chars = ?, input_tokens = ?, output_tokens = ?,
2825
+ cached_input_tokens = ?, cache_eligible_input_tokens = ?, cache_usage_available = ?,
2826
+ token_usage_source = ?, completed_at = ?
2827
+ WHERE id = ?`, content.length, tokenUsage.inputTokens, tokenUsage.outputTokens, tokenUsage.cachedInputTokens, tokenUsage.cacheEligibleInputTokens, tokenUsage.cacheEligibleInputTokens > 0 ? 1 : 0, tokenUsage.source, now(), callId);
2386
2828
  logger.info("ai.call.completed", {
2387
2829
  callId,
2388
2830
  workId: input.workId,
@@ -2395,7 +2837,7 @@ export class AiManager {
2395
2837
  return { callId, content, outputTokens, ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }), provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: [], processSteps };
2396
2838
  }
2397
2839
  catch (error) {
2398
- const message = error instanceof Error ? error.message : "AI 流式调用失败";
2840
+ const message = error instanceof Error ? redactProviderSecret(error.message, activeApiKey) : "AI 流式调用失败";
2399
2841
  this.store.db.run("UPDATE ai_calls SET status = 'failed', failure = ?, completed_at = ? WHERE id = ?", message, now(), callId);
2400
2842
  logger.error("ai.call.failed", {
2401
2843
  callId,
@@ -2408,9 +2850,10 @@ export class AiManager {
2408
2850
  throw new AppError(502, "AI_CALL_FAILED", "AI 调用失败", { callId, failure: message });
2409
2851
  }
2410
2852
  }
2411
- async readCompletionStream(response, onDelta, onThinkingDelta) {
2853
+ async readCompletionStream(response, protocol, estimatedInputTokens, onDelta, onThinkingDelta) {
2854
+ const protocolLabel = protocol === "anthropic-messages" ? "Anthropic Messages" : "Chat Completions";
2412
2855
  if (!response.body)
2413
- throw new Error("Chat Completions 流式响应缺少正文");
2856
+ throw new Error(`${protocolLabel} 流式响应缺少正文`);
2414
2857
  const reader = response.body.getReader();
2415
2858
  const decoder = new TextDecoder();
2416
2859
  let buffer = "";
@@ -2427,19 +2870,59 @@ export class AiManager {
2427
2870
  if (!data || data === "[DONE]")
2428
2871
  return;
2429
2872
  const payload = JSON.parse(data);
2430
- if (payload.error)
2431
- throw new Error(payload.error.message || "上游流式响应返回错误");
2432
- if (payload.usage)
2433
- usage = payload.usage;
2434
- const choice = payload.choices?.[0];
2435
- if (choice?.finish_reason)
2873
+ const error = payload.error && typeof payload.error === "object" && !Array.isArray(payload.error)
2874
+ ? payload.error
2875
+ : null;
2876
+ if (error)
2877
+ throw new Error(typeof error.message === "string" ? error.message : "上游流式响应返回错误");
2878
+ if (protocol === "anthropic-messages") {
2879
+ const eventUsage = payload.usage && typeof payload.usage === "object" && !Array.isArray(payload.usage)
2880
+ ? payload.usage
2881
+ : null;
2882
+ const message = payload.message && typeof payload.message === "object" && !Array.isArray(payload.message)
2883
+ ? payload.message
2884
+ : null;
2885
+ const messageUsage = message?.usage && typeof message.usage === "object" && !Array.isArray(message.usage)
2886
+ ? message.usage
2887
+ : null;
2888
+ if (eventUsage || messageUsage) {
2889
+ usage = { ...(usage && typeof usage === "object" ? usage : {}), ...(messageUsage ?? {}), ...(eventUsage ?? {}) };
2890
+ }
2891
+ const eventDelta = payload.delta && typeof payload.delta === "object" && !Array.isArray(payload.delta)
2892
+ ? payload.delta
2893
+ : {};
2894
+ if (typeof eventDelta.stop_reason === "string")
2895
+ finishReason = eventDelta.stop_reason;
2896
+ if (eventDelta.type === "thinking_delta" && typeof eventDelta.thinking === "string" && eventDelta.thinking.length > 0) {
2897
+ reasoning += eventDelta.thinking;
2898
+ onThinkingDelta(eventDelta.thinking);
2899
+ }
2900
+ if (eventDelta.type === "text_delta" && typeof eventDelta.text === "string" && eventDelta.text.length > 0) {
2901
+ content += eventDelta.text;
2902
+ onDelta(eventDelta.text);
2903
+ }
2904
+ return;
2905
+ }
2906
+ const streamUsage = payload.usage && typeof payload.usage === "object" && !Array.isArray(payload.usage)
2907
+ ? payload.usage
2908
+ : null;
2909
+ if (streamUsage)
2910
+ usage = streamUsage;
2911
+ const choices = Array.isArray(payload.choices) ? payload.choices : [];
2912
+ const choice = choices[0] && typeof choices[0] === "object" && !Array.isArray(choices[0])
2913
+ ? choices[0]
2914
+ : null;
2915
+ if (typeof choice?.finish_reason === "string")
2436
2916
  finishReason = choice.finish_reason;
2437
- const thinkingDelta = choice?.delta?.reasoning_content;
2917
+ const deltaRecord = choice?.delta && typeof choice.delta === "object" && !Array.isArray(choice.delta)
2918
+ ? choice.delta
2919
+ : {};
2920
+ const thinkingDelta = deltaRecord.reasoning_content;
2438
2921
  if (typeof thinkingDelta === "string" && thinkingDelta.length > 0) {
2439
2922
  reasoning += thinkingDelta;
2440
2923
  onThinkingDelta(thinkingDelta);
2441
2924
  }
2442
- const delta = choice?.delta?.content;
2925
+ const delta = deltaRecord.content;
2443
2926
  if (typeof delta === "string" && delta.length > 0) {
2444
2927
  content += delta;
2445
2928
  onDelta(delta);
@@ -2458,9 +2941,16 @@ export class AiManager {
2458
2941
  if (buffer.trim())
2459
2942
  consumeEvent(buffer);
2460
2943
  if (!content.trim())
2461
- throw new Error(`Chat Completions 流式响应缺少可用正文,finish_reason=${finishReason}`);
2944
+ throw new Error(`${protocolLabel} 流式响应缺少可用正文,finish_reason=${finishReason}`);
2462
2945
  const cacheHitPercent = resolveCacheHitPercent(usage);
2463
- return { content, reasoning, outputTokens: resolveOutputTokens(usage, content), ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }) };
2946
+ const outputTokens = resolveOutputTokens(usage, content);
2947
+ return {
2948
+ content,
2949
+ reasoning,
2950
+ outputTokens,
2951
+ ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }),
2952
+ tokenUsage: resolveAiTokenUsage(usage, estimatedInputTokens, outputTokens)
2953
+ };
2464
2954
  }
2465
2955
  async runChapterAnalysis(workId, scope, modelId, taskId) {
2466
2956
  if (!scope.chapterId)
@@ -3356,6 +3846,104 @@ export class AiManager {
3356
3846
  const workIds = this.store.db.all("SELECT DISTINCT work_id FROM relationship_source_index_queue ORDER BY work_id").map((row) => String(row.work_id));
3357
3847
  await Promise.allSettled(workIds.map((workId) => this.ensureRelationshipSearchIndex(workId)));
3358
3848
  }
3849
+ scheduleRelationshipIndexSync(workId) {
3850
+ if (this.relationshipIndexDisposed)
3851
+ return;
3852
+ const existing = this.relationshipIndexSyncTimers.get(workId);
3853
+ if (existing)
3854
+ clearTimeout(existing);
3855
+ const timer = setTimeout(() => {
3856
+ this.relationshipIndexSyncTimers.delete(workId);
3857
+ try {
3858
+ const status = this.getRelationshipSearchIndexStatus(workId);
3859
+ if (Number(status.queuedSourceCount ?? 0) > 0) {
3860
+ void this.ensureRelationshipSearchIndex(workId).catch(() => undefined);
3861
+ }
3862
+ }
3863
+ catch {
3864
+ // 作品可能已在等待期间被删除
3865
+ }
3866
+ }, 2_000);
3867
+ this.relationshipIndexSyncTimers.set(workId, timer);
3868
+ logger.debug("relationship.search_index.auto_sync_scheduled", { workId });
3869
+ }
3870
+ getRelationshipSearchIndexStatus(workId) {
3871
+ this.store.getWork(workId);
3872
+ const row = this.store.db.get("SELECT status, generation, error, updated_at FROM relationship_source_index_state WHERE work_id = ?", workId);
3873
+ const queuedSources = this.store.db.all(`SELECT source_type, COUNT(*) AS count, MIN(queued_at) AS oldest_queued_at
3874
+ FROM relationship_source_index_queue WHERE work_id = ?
3875
+ GROUP BY source_type ORDER BY source_type`, workId).map((item) => ({
3876
+ sourceType: String(item.source_type),
3877
+ count: Number(item.count ?? 0),
3878
+ oldestQueuedAt: String(item.oldest_queued_at ?? "")
3879
+ }));
3880
+ const queuedSourceCount = queuedSources.reduce((total, item) => total + item.count, 0);
3881
+ const storedStatus = String(row?.status ?? "");
3882
+ const status = storedStatus === "building"
3883
+ ? "building"
3884
+ : queuedSourceCount > 0
3885
+ ? "queued"
3886
+ : storedStatus || "ready";
3887
+ return {
3888
+ workId,
3889
+ status,
3890
+ generation: Number(row?.generation ?? 0),
3891
+ queuedSourceCount,
3892
+ queuedSources,
3893
+ indexedSourceCount: Number(this.store.db.get("SELECT COUNT(*) AS count FROM relationship_source_search WHERE work_id = ?", workId)?.count ?? 0),
3894
+ indexedParagraphCount: Number(this.store.db.get(`SELECT COUNT(*) AS count FROM chapter_paragraph_pinyin_fts pinyin
3895
+ JOIN chapter_paragraph_search paragraph ON paragraph.id = pinyin.rowid
3896
+ WHERE paragraph.work_id = ?`, workId)?.count ?? 0),
3897
+ error: String(row?.error ?? ""),
3898
+ updatedAt: String(row?.updated_at ?? "")
3899
+ };
3900
+ }
3901
+ syncRelationshipSearchIndex(workId) {
3902
+ const status = this.getRelationshipSearchIndexStatus(workId);
3903
+ if (Number(status.queuedSourceCount ?? 0) > 0 || status.status === "building") {
3904
+ void this.ensureRelationshipSearchIndex(workId).catch(() => undefined);
3905
+ }
3906
+ return status;
3907
+ }
3908
+ rebuildRelationshipSearchIndex(workId) {
3909
+ this.store.getWork(workId);
3910
+ const timestamp = now();
3911
+ const queueSources = [
3912
+ { table: "works", sourceType: "work", idColumn: "id" },
3913
+ { table: "chapters", sourceType: "chapter", idColumn: "id" },
3914
+ { table: "settings", sourceType: "setting", idColumn: "id" },
3915
+ { table: "characters", sourceType: "character", idColumn: "id" },
3916
+ { table: "races", sourceType: "race", idColumn: "id" },
3917
+ { table: "organizations", sourceType: "organization", idColumn: "id" },
3918
+ { table: "timeline_tracks", sourceType: "timeline-track", idColumn: "id" },
3919
+ { table: "timeline_events", sourceType: "timeline-event", idColumn: "id" },
3920
+ { table: "relationships", sourceType: "relationship", idColumn: "id" },
3921
+ { table: "foreshadows", sourceType: "foreshadow", idColumn: "id" },
3922
+ { table: "review_items", sourceType: "review", idColumn: "id" }
3923
+ ];
3924
+ this.store.db.transaction(() => {
3925
+ this.store.db.run(`INSERT INTO relationship_source_index_queue(work_id, source_type, source_id, queued_at)
3926
+ SELECT work_id, source_type, source_id, ? FROM relationship_source_search WHERE work_id = ?
3927
+ ON CONFLICT(work_id, source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at`, timestamp, workId);
3928
+ for (const source of queueSources) {
3929
+ const workColumn = source.table === "works" ? "id" : "work_id";
3930
+ this.store.db.run(`INSERT INTO relationship_source_index_queue(work_id, source_type, source_id, queued_at)
3931
+ SELECT ${workColumn}, ?, ${source.idColumn}, ? FROM ${source.table} WHERE ${workColumn} = ?
3932
+ ON CONFLICT(work_id, source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at`, source.sourceType, timestamp, workId);
3933
+ }
3934
+ this.store.db.run(`INSERT INTO relationship_source_index_queue(work_id, source_type, source_id, queued_at)
3935
+ SELECT chapter.work_id, 'chapter-outline', outline.chapter_id, ?
3936
+ FROM chapter_outlines outline JOIN chapters chapter ON chapter.id = outline.chapter_id
3937
+ WHERE chapter.work_id = ?
3938
+ ON CONFLICT(work_id, source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at`, timestamp, workId);
3939
+ this.store.db.run(`INSERT INTO relationship_source_index_state(work_id, status, generation, error, updated_at)
3940
+ VALUES (?, 'queued', 0, '', ?)
3941
+ ON CONFLICT(work_id) DO UPDATE SET status = 'queued', error = '', updated_at = excluded.updated_at`, workId, timestamp);
3942
+ });
3943
+ const status = this.getRelationshipSearchIndexStatus(workId);
3944
+ void this.ensureRelationshipSearchIndex(workId).catch(() => undefined);
3945
+ return status;
3946
+ }
3359
3947
  ensureRelationshipSearchIndex(workId) {
3360
3948
  const existing = this.relationshipIndexBuilds.get(workId);
3361
3949
  if (existing)
@@ -3533,7 +4121,6 @@ export class AiManager {
3533
4121
  }
3534
4122
  relationshipFuzzyIndexMatches(workId, reference, includeSettings, scope) {
3535
4123
  const result = new Set();
3536
- const characterTokens = [...new Set(relationshipCharacterTokens(reference))];
3537
4124
  const pinyinTokens = [...new Set(relationshipPinyinTokens(reference))];
3538
4125
  const score = new Map();
3539
4126
  const add = (key) => {
@@ -3577,45 +4164,57 @@ export class AiManager {
3577
4164
  result.add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
3578
4165
  }
3579
4166
  const normalizedCharacters = [...normalizeRelationshipSearchText(reference).trim()];
3580
- if (includeChapters) {
3581
- for (const character of [...new Set(normalizedCharacters)]) {
4167
+ const addSelectiveSignal = (keys) => {
4168
+ if (keys.size > RELATIONSHIP_MAX_FUZZY_SOURCES)
4169
+ return;
4170
+ for (const key of keys)
4171
+ add(key);
4172
+ };
4173
+ for (const character of [...new Set(normalizedCharacters)]) {
4174
+ const keys = new Set();
4175
+ if (includeChapters) {
3582
4176
  for (const row of this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_short_terms term
3583
4177
  JOIN chapter_paragraph_search paragraph ON paragraph.id = term.paragraph_id
3584
4178
  WHERE paragraph.work_id = ? AND term.term = ? ${chapterScope.sql}
3585
4179
  LIMIT 201`, workId, character, ...chapterScope.params))
3586
- add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
3587
- }
3588
- for (const token of pinyinTokens) {
3589
- for (const row of this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_pinyin_fts
3590
- JOIN chapter_paragraph_search paragraph ON paragraph.id = chapter_paragraph_pinyin_fts.rowid
3591
- WHERE paragraph.work_id = ? AND chapter_paragraph_pinyin_fts MATCH ? ${chapterScope.sql}
3592
- LIMIT 201`, workId, token, ...chapterScope.params))
3593
- add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
4180
+ keys.add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
3594
4181
  }
3595
- }
3596
- if (includeSettings) {
3597
- for (const token of characterTokens) {
3598
- for (const row of this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_exact_fts
4182
+ if (includeSettings) {
4183
+ const token = relationshipCharacterTokens(character)[0];
4184
+ if (token)
4185
+ for (const row of this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_exact_fts
3599
4186
  JOIN relationship_source_search source ON source.id = relationship_source_exact_fts.rowid
3600
4187
  WHERE source.work_id = ? AND relationship_source_exact_fts MATCH ?
3601
- AND NOT (source.source_type = 'review' AND EXISTS (
4188
+ AND NOT (source.source_type = 'review' AND EXISTS (
3602
4189
  SELECT 1 FROM review_items review
3603
4190
  WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
3604
4191
  ))
3605
4192
  LIMIT 201`, workId, token))
3606
- add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
4193
+ keys.add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
4194
+ }
4195
+ addSelectiveSignal(keys);
4196
+ }
4197
+ for (const token of pinyinTokens) {
4198
+ const keys = new Set();
4199
+ if (includeChapters) {
4200
+ for (const row of this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_pinyin_fts
4201
+ JOIN chapter_paragraph_search paragraph ON paragraph.id = chapter_paragraph_pinyin_fts.rowid
4202
+ WHERE paragraph.work_id = ? AND chapter_paragraph_pinyin_fts MATCH ? ${chapterScope.sql}
4203
+ LIMIT 201`, workId, token, ...chapterScope.params))
4204
+ keys.add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
3607
4205
  }
3608
- for (const token of pinyinTokens) {
4206
+ if (includeSettings) {
3609
4207
  for (const row of this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_pinyin_fts
3610
4208
  JOIN relationship_source_search source ON source.id = relationship_source_pinyin_fts.rowid
3611
4209
  WHERE source.work_id = ? AND relationship_source_pinyin_fts MATCH ?
3612
- AND NOT (source.source_type = 'review' AND EXISTS (
4210
+ AND NOT (source.source_type = 'review' AND EXISTS (
3613
4211
  SELECT 1 FROM review_items review
3614
4212
  WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
3615
4213
  ))
3616
4214
  LIMIT 201`, workId, token))
3617
- add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
4215
+ keys.add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
3618
4216
  }
4217
+ addSelectiveSignal(keys);
3619
4218
  }
3620
4219
  const threshold = Math.max(1, [...normalizeRelationshipSearchText(reference).trim()].length - 1);
3621
4220
  for (const [key, count] of score)
@@ -3636,6 +4235,7 @@ export class AiManager {
3636
4235
  String(character.code ?? ""),
3637
4236
  String(character.species ?? ""),
3638
4237
  String(character.race?.name ?? ""),
4238
+ String(character.attributes?.identity ?? ""),
3639
4239
  ...(Array.isArray(character.organizations) ? character.organizations.map((item) => String(item.name ?? "")) : []),
3640
4240
  ...[...relatedIds].flatMap((relatedId) => {
3641
4241
  try {
@@ -3685,16 +4285,19 @@ export class AiManager {
3685
4285
  for (const character of targetCharacters) {
3686
4286
  const targetCharacterId = String(character.id);
3687
4287
  const exactReferences = [...new Set([String(character.name), ...character.aliases].map((item) => item.trim()).filter(Boolean))];
3688
- const fuzzyReferenceCount = exactReferences.filter((reference) => [...normalizeRelationshipSearchText(reference).trim()].length >= 2).length;
4288
+ const anchors = this.relationshipIdentityAnchors(workId, character);
4289
+ const fuzzyReferenceCount = exactReferences.filter(isRelationshipPhoneticReference).length;
3689
4290
  if (fuzzyReferenceCount > RELATIONSHIP_MAX_FUZZY_REFERENCES) {
3690
- throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "人物名称和别名过多,无法在安全预算内完成疑似写法匹配", {
4291
+ throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", relationshipCandidateLimitMessage("人物名称和别名过多,无法在安全预算内完成疑似写法匹配"), {
3691
4292
  characterId: targetCharacterId,
4293
+ targetName: String(character.name),
4294
+ reason: "registered-references",
3692
4295
  fuzzyReferenceCount,
3693
- maximumFuzzyReferences: RELATIONSHIP_MAX_FUZZY_REFERENCES
4296
+ maximumFuzzyReferences: RELATIONSHIP_MAX_FUZZY_REFERENCES,
4297
+ identityAnchorCount: anchors.length
3694
4298
  });
3695
4299
  }
3696
4300
  const normalizedExactReferences = new Set(exactReferences.map((item) => normalizeRelationshipSearchText(item).trim()));
3697
- const anchors = this.relationshipIdentityAnchors(workId, character);
3698
4301
  const anchorKeys = new Set();
3699
4302
  for (const anchor of anchors) {
3700
4303
  for (const chapterId of this.relationshipChapterExactMatches(workId, anchor)) {
@@ -3717,24 +4320,35 @@ export class AiManager {
3717
4320
  if (includeSettings)
3718
4321
  for (const key of this.relationshipSettingExactMatches(workId, reference))
3719
4322
  exactKeys.add(key);
4323
+ if (!isRelationshipPhoneticReference(reference))
4324
+ continue;
3720
4325
  const referenceLength = [...normalizeRelationshipSearchText(reference).trim()].length;
3721
4326
  if (referenceLength < 2)
3722
4327
  continue;
3723
- const fuzzyIndexKeys = referenceLength === 2
4328
+ const rawFuzzyIndexKeys = referenceLength === 2
3724
4329
  ? anchorKeys
3725
4330
  : this.relationshipFuzzyIndexMatches(workId, reference, includeSettings, scope);
4331
+ const fuzzyIndexKeys = rawFuzzyIndexKeys.size > RELATIONSHIP_MAX_FUZZY_SOURCES && anchorKeys.size > 0
4332
+ ? new Set([...rawFuzzyIndexKeys].filter((key) => anchorKeys.has(key)))
4333
+ : rawFuzzyIndexKeys;
3726
4334
  for (const key of fuzzyIndexKeys) {
3727
4335
  const ref = this.relationshipIndexedSourceRef(key);
3728
4336
  if (ref.sourceType === "chapter" && !allowedChapterIds.has(ref.sourceId))
3729
4337
  continue;
3730
4338
  if (ref.sourceType !== "chapter" && !includeSettings)
3731
4339
  continue;
4340
+ if (exactKeys.has(key))
4341
+ continue;
3732
4342
  targetIndexCandidateKeys.add(key);
3733
4343
  if (targetIndexCandidateKeys.size > RELATIONSHIP_MAX_FUZZY_SOURCES) {
3734
- throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名来源过多,请补充人物别名或身份资料后重试", {
4344
+ throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", relationshipCandidateLimitMessage(`“${String(character.name)}”的拼音疑似来源仍然过多`), {
3735
4345
  characterId: targetCharacterId,
4346
+ targetName: String(character.name),
4347
+ reference,
4348
+ reason: "candidate-sources",
3736
4349
  candidateCount: targetIndexCandidateKeys.size,
3737
- maximum: RELATIONSHIP_MAX_FUZZY_SOURCES
4350
+ maximum: RELATIONSHIP_MAX_FUZZY_SOURCES,
4351
+ identityAnchorCount: anchors.length
3738
4352
  });
3739
4353
  }
3740
4354
  let indexed = loadedSources.get(key);
@@ -3751,10 +4365,14 @@ export class AiManager {
3751
4365
  const normalizedSearchable = normalizeRelationshipSearchText(searchable);
3752
4366
  fuzzyScanCharacters += normalizedSearchable.length;
3753
4367
  if (fuzzyScanCharacters > RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS) {
3754
- throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名待核对文本过多,请缩小分析范围或补充人物别名", {
4368
+ throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", relationshipCandidateLimitMessage(`“${String(character.name)}”的拼音疑似来源待核对文本过多`), {
3755
4369
  characterId: targetCharacterId,
4370
+ targetName: String(character.name),
4371
+ reference,
4372
+ reason: "scan-characters",
3756
4373
  scannedCharacters: fuzzyScanCharacters,
3757
- maximumScannedCharacters: RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS
4374
+ maximumScannedCharacters: RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS,
4375
+ identityAnchorCount: anchors.length
3758
4376
  });
3759
4377
  }
3760
4378
  const referenceCharacters = [...normalizeRelationshipSearchText(reference).trim()];
@@ -3765,11 +4383,15 @@ export class AiManager {
3765
4383
  catch (error) {
3766
4384
  if (!(error instanceof RelationshipApproximateMatchLimitError))
3767
4385
  throw error;
3768
- throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "单个来源中的疑似人物名写法过多,请缩小分析范围或补充人物别名", {
4386
+ throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", relationshipCandidateLimitMessage(`单个来源中“${String(character.name)}”的拼音疑似写法过多`), {
3769
4387
  characterId: targetCharacterId,
4388
+ targetName: String(character.name),
4389
+ reference,
4390
+ reason: "source-matches",
3770
4391
  sourceType: indexed.sourceType,
3771
4392
  sourceId: indexed.sourceId,
3772
- maximumSourceMatches: error.maximumCandidates
4393
+ maximumSourceMatches: error.maximumCandidates,
4394
+ identityAnchorCount: anchors.length
3773
4395
  });
3774
4396
  }
3775
4397
  for (const match of approximateMatches) {
@@ -3789,10 +4411,14 @@ export class AiManager {
3789
4411
  candidateOccurrences.set(occurrenceKey, occurrenceCount + 1);
3790
4412
  fuzzyMatchCount += 1;
3791
4413
  if (fuzzyMatchCount > RELATIONSHIP_MAX_FUZZY_MATCHES) {
3792
- throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名写法过多,请缩小分析范围或补充人物别名", {
4414
+ throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", relationshipCandidateLimitMessage(`“${String(character.name)}”的拼音疑似写法仍然过多`), {
3793
4415
  characterId: targetCharacterId,
4416
+ targetName: String(character.name),
4417
+ reference,
4418
+ reason: "fuzzy-matches",
3794
4419
  fuzzyMatchCount,
3795
- maximumFuzzyMatches: RELATIONSHIP_MAX_FUZZY_MATCHES
4420
+ maximumFuzzyMatches: RELATIONSHIP_MAX_FUZZY_MATCHES,
4421
+ identityAnchorCount: anchors.length
3796
4422
  });
3797
4423
  }
3798
4424
  targetFuzzySourceKeys.add(key);
@@ -3816,10 +4442,13 @@ export class AiManager {
3816
4442
  }
3817
4443
  }
3818
4444
  if (targetFuzzySourceKeys.size > RELATIONSHIP_MAX_FUZZY_SOURCES) {
3819
- throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名来源过多,请补充人物别名或身份资料后重试", {
4445
+ throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", relationshipCandidateLimitMessage(`“${String(character.name)}”的拼音疑似来源仍然过多`), {
3820
4446
  characterId: targetCharacterId,
4447
+ targetName: String(character.name),
4448
+ reason: "candidate-sources",
3821
4449
  candidateCount: targetFuzzySourceKeys.size,
3822
- maximum: RELATIONSHIP_MAX_FUZZY_SOURCES
4450
+ maximum: RELATIONSHIP_MAX_FUZZY_SOURCES,
4451
+ identityAnchorCount: anchors.length
3823
4452
  });
3824
4453
  }
3825
4454
  }
@@ -3976,10 +4605,14 @@ export class AiManager {
3976
4605
  WHERE chapter.work_id = ? ORDER BY volume.sort_order, chapter.sort_order`, workId).map((row, index) => [String(row.id), index]));
3977
4606
  chapters.sort((left, right) => (chapterOrder.get(String(left.id)) ?? Number.MAX_SAFE_INTEGER) - (chapterOrder.get(String(right.id)) ?? Number.MAX_SAFE_INTEGER));
3978
4607
  settings.sort((left, right) => `${left.sourceType}:${left.id}`.localeCompare(`${right.sourceType}:${right.id}`, "zh-CN"));
4608
+ const matchKinds = {};
4609
+ for (const key of selectedKeys)
4610
+ matchKinds[key] = local.exactKeys.includes(key) ? "exact" : "fuzzy";
3979
4611
  return {
3980
4612
  generation,
3981
4613
  chapters,
3982
4614
  settings,
4615
+ matchKinds,
3983
4616
  variantDecisions: verified.decisions,
3984
4617
  verificationCallIds: verified.callIds,
3985
4618
  summary: {
@@ -4007,6 +4640,7 @@ export class AiManager {
4007
4640
  const serialize = (value) => JSON.stringify(cleanStrings(value), null, 2);
4008
4641
  const source = (title, value, version) => ({
4009
4642
  id: sourceType === "setting" ? sourceId : `${sourceType}:${sourceId}`,
4643
+ sourceId,
4010
4644
  title,
4011
4645
  sourceType,
4012
4646
  content: serialize(value),
@@ -4175,11 +4809,174 @@ export class AiManager {
4175
4809
  return materialized ? [materialized] : [];
4176
4810
  });
4177
4811
  }
4812
+ relationshipChangeOperations(before, after) {
4813
+ const beforeById = new Map(before.map((relationship) => [String(relationship.id), relationship]));
4814
+ const afterById = new Map(after.map((relationship) => [String(relationship.id), relationship]));
4815
+ return [
4816
+ ...before.flatMap((relationship) => {
4817
+ const relationshipId = String(relationship.id);
4818
+ const next = afterById.get(relationshipId);
4819
+ if (!next) {
4820
+ return [{
4821
+ action: "deleted",
4822
+ relationshipId,
4823
+ expectedVersionNo: Number(relationship.versionNo),
4824
+ before: relationship
4825
+ }];
4826
+ }
4827
+ if (Number(next.versionNo) !== Number(relationship.versionNo)) {
4828
+ return [{
4829
+ action: "updated",
4830
+ relationshipId,
4831
+ expectedVersionNo: Number(relationship.versionNo),
4832
+ before: relationship,
4833
+ after: next
4834
+ }];
4835
+ }
4836
+ return [];
4837
+ }),
4838
+ ...after.flatMap((relationship) => {
4839
+ const relationshipId = String(relationship.id);
4840
+ return beforeById.has(relationshipId)
4841
+ ? []
4842
+ : [{ action: "created", relationshipId, after: relationship }];
4843
+ })
4844
+ ];
4845
+ }
4846
+ relationshipResultSnapshot(workId, action, relationship) {
4847
+ const evidence = Array.isArray(relationship.evidence)
4848
+ ? relationship.evidence.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
4849
+ : [];
4850
+ const characterName = (characterId) => {
4851
+ try {
4852
+ const character = this.store.getCharacter(String(characterId));
4853
+ return character.workId === workId ? String(character.name) : String(characterId);
4854
+ }
4855
+ catch {
4856
+ return String(characterId);
4857
+ }
4858
+ };
4859
+ return {
4860
+ relationshipId: String(relationship.id),
4861
+ action,
4862
+ fromCharacterId: String(relationship.fromCharacterId),
4863
+ fromCharacterName: characterName(relationship.fromCharacterId),
4864
+ toCharacterId: String(relationship.toCharacterId),
4865
+ toCharacterName: characterName(relationship.toCharacterId),
4866
+ category: String(relationship.category),
4867
+ subtype: String(relationship.subtype),
4868
+ keywords: Array.isArray(relationship.keywords) ? relationship.keywords.map(String) : [],
4869
+ directed: Boolean(relationship.directed),
4870
+ currentStatus: String(relationship.currentStatus ?? ""),
4871
+ timeRange: relationship.timeRange && typeof relationship.timeRange === "object" && !Array.isArray(relationship.timeRange)
4872
+ ? relationship.timeRange
4873
+ : {},
4874
+ confidence: Number(relationship.confidence ?? 0),
4875
+ confirmationStatus: String(relationship.confirmationStatus ?? "pending"),
4876
+ evidenceCount: evidence.length,
4877
+ evidence: evidence.slice(0, 3).map((item) => ({
4878
+ chapterId: String(item.chapterId ?? ""),
4879
+ chapterTitle: String(item.chapterTitle ?? item.settingTitle ?? ""),
4880
+ quote: String(item.quote ?? ""),
4881
+ supports: String(item.supports ?? "")
4882
+ })),
4883
+ evidenceTruncated: evidence.length > 3
4884
+ };
4885
+ }
4886
+ relationshipSourcesFromRefs(workId, scope, characters, refs) {
4887
+ const requestedRefs = new Map(refs.map((ref) => [
4888
+ this.relationshipIndexedSourceKey(ref.sourceType, ref.sourceId),
4889
+ ref
4890
+ ]));
4891
+ const availableChapters = scope.type === "settings" ? [] : this.getScopeChapters(workId, scope);
4892
+ const includeSettings = scope.type === "settings" || scope.includeAllSettings === true;
4893
+ const availableSettings = includeSettings ? this.relationshipSettingSources(workId, characters) : [];
4894
+ const availableVersions = new Map([
4895
+ ...availableChapters.map((chapter) => [
4896
+ this.relationshipIndexedSourceKey("chapter", String(chapter.id)),
4897
+ String(chapter.versionNo ?? "")
4898
+ ]),
4899
+ ...availableSettings.map((source) => [
4900
+ this.relationshipIndexedSourceKey(source.sourceType, source.sourceId),
4901
+ source.version
4902
+ ])
4903
+ ]);
4904
+ for (const [sourceKey, ref] of requestedRefs) {
4905
+ const currentVersion = availableVersions.get(sourceKey);
4906
+ if (currentVersion === undefined || currentVersion !== ref.sourceVersion) {
4907
+ throw new AppError(409, "RELATIONSHIP_SOURCE_PREVIEW_STALE", "来源已在预检后发生变化,请重新预览", {
4908
+ sourceType: ref.sourceType,
4909
+ sourceId: ref.sourceId
4910
+ });
4911
+ }
4912
+ }
4913
+ const chapters = availableChapters.filter((chapter) => requestedRefs.has(this.relationshipIndexedSourceKey("chapter", String(chapter.id))));
4914
+ const settings = availableSettings.filter((source) => requestedRefs.has(this.relationshipIndexedSourceKey(source.sourceType, source.sourceId)));
4915
+ return { chapters, settings };
4916
+ }
4917
+ async previewRelationshipSources(workId, scope, modelId) {
4918
+ const characters = this.store.listCharacters(workId);
4919
+ if (characters.length < 2)
4920
+ throw new AppError(409, "CHARACTERS_REQUIRED", "人物关系分析至少需要两个角色档案");
4921
+ const selectedCharacterIds = new Set(scope.characterIds ?? []);
4922
+ if (selectedCharacterIds.size === 0) {
4923
+ throw new AppError(400, "RELATIONSHIP_PREVIEW_CHARACTERS_REQUIRED", "请先选择需要定向分析的角色");
4924
+ }
4925
+ for (const characterId of selectedCharacterIds) {
4926
+ if (!characters.some((character) => String(character.id) === characterId)) {
4927
+ throw new AppError(400, "CHARACTER_WORK_MISMATCH", "被分析角色不属于当前作品");
4928
+ }
4929
+ }
4930
+ const preFilterRelationshipSources = scope.preFilterRelationshipSources !== false;
4931
+ const sourceSelection = preFilterRelationshipSources
4932
+ ? await this.selectRelationshipSources(workId, scope, characters, selectedCharacterIds, modelId)
4933
+ : null;
4934
+ const chapters = sourceSelection?.chapters
4935
+ ?? (scope.type === "settings" ? [] : this.getScopeChapters(workId, scope));
4936
+ const settings = sourceSelection?.settings
4937
+ ?? (scope.type === "settings" || scope.includeAllSettings === true
4938
+ ? this.relationshipSettingSources(workId, characters)
4939
+ : []);
4940
+ const sources = [
4941
+ ...chapters.map((chapter) => ({
4942
+ sourceType: "chapter",
4943
+ sourceId: String(chapter.id),
4944
+ title: String(chapter.title),
4945
+ version: String(chapter.versionNo ?? ""),
4946
+ characterCount: String(chapter.content ?? "").length,
4947
+ matchType: sourceSelection?.matchKinds[this.relationshipIndexedSourceKey("chapter", String(chapter.id))] ?? "scope"
4948
+ })),
4949
+ ...settings.map((setting) => ({
4950
+ sourceType: setting.sourceType,
4951
+ sourceId: setting.sourceId,
4952
+ title: setting.title,
4953
+ version: setting.version,
4954
+ characterCount: setting.content.length,
4955
+ matchType: sourceSelection?.matchKinds[this.relationshipIndexedSourceKey(setting.sourceType, setting.sourceId)] ?? "scope"
4956
+ }))
4957
+ ];
4958
+ if (sources.length > 5_000) {
4959
+ throw new AppError(409, "RELATIONSHIP_SOURCE_PREVIEW_TOO_LARGE", "预检来源超过 5000 条,请缩小分析范围");
4960
+ }
4961
+ return {
4962
+ preFilterRelationshipSources,
4963
+ chapterCount: chapters.length,
4964
+ settingCount: settings.length,
4965
+ sourceCount: sources.length,
4966
+ totalCharacters: sources.reduce((total, source) => total + source.characterCount, 0),
4967
+ estimatedBatchCount: this.buildChapterChunks(chapters, 12_000).length + this.buildSettingChunks(settings, 12_000).length,
4968
+ sources,
4969
+ indexGeneration: sourceSelection?.generation ?? null,
4970
+ selectionSummary: sourceSelection?.summary ?? null,
4971
+ verificationCallCount: sourceSelection?.verificationCallIds.length ?? 0
4972
+ };
4973
+ }
4178
4974
  async runRelationshipAnalysis(workId, scope, modelId, taskId) {
4179
4975
  const characters = this.store.listCharacters(workId);
4180
4976
  if (characters.length < 2)
4181
4977
  throw new AppError(409, "CHARACTERS_REQUIRED", "人物关系分析至少需要两个角色档案");
4182
4978
  const settingsOnly = scope.type === "settings";
4979
+ const includesSettings = settingsOnly || scope.includeAllSettings === true;
4183
4980
  const selectedCharacterIds = new Set(scope.characterIds ?? []);
4184
4981
  for (const characterId of selectedCharacterIds) {
4185
4982
  const character = characters.find((item) => item.id === characterId);
@@ -4187,19 +4984,24 @@ export class AiManager {
4187
4984
  throw new AppError(400, "CHARACTER_WORK_MISMATCH", "被分析角色不属于当前作品");
4188
4985
  }
4189
4986
  const targeted = selectedCharacterIds.size > 0;
4987
+ const preFilterRelationshipSources = targeted && scope.preFilterRelationshipSources !== false;
4988
+ const previewRelationshipChanges = scope.previewRelationshipChanges === true;
4190
4989
  const targetedRoster = characters
4191
4990
  .filter((character) => selectedCharacterIds.has(String(character.id)))
4192
4991
  .map((character) => `${String(character.id)} | ${String(character.name)}`)
4193
4992
  .join("\n");
4194
- const sourceSelection = targeted
4993
+ const previewedSources = Array.isArray(scope.relationshipSourceRefs)
4994
+ ? this.relationshipSourcesFromRefs(workId, scope, characters, scope.relationshipSourceRefs)
4995
+ : null;
4996
+ const sourceSelection = !previewedSources && preFilterRelationshipSources
4195
4997
  ? await this.selectRelationshipSources(workId, scope, characters, selectedCharacterIds, modelId, taskId)
4196
4998
  : null;
4197
- const scopedChapters = targeted ? [] : settingsOnly ? [] : this.getScopeChapters(workId, scope);
4198
- const chapters = sourceSelection?.chapters ?? scopedChapters;
4199
- const availableSettings = targeted ? [] : settingsOnly || scope.includeAllSettings === true
4999
+ const scopedChapters = preFilterRelationshipSources || settingsOnly ? [] : this.getScopeChapters(workId, scope);
5000
+ const chapters = previewedSources?.chapters ?? sourceSelection?.chapters ?? scopedChapters;
5001
+ const availableSettings = !preFilterRelationshipSources && (settingsOnly || scope.includeAllSettings === true)
4200
5002
  ? this.relationshipSettingSources(workId, characters)
4201
5003
  : [];
4202
- const settings = sourceSelection?.settings ?? availableSettings;
5004
+ const settings = previewedSources?.settings ?? sourceSelection?.settings ?? availableSettings;
4203
5005
  if (!targeted && settingsOnly && availableSettings.length === 0)
4204
5006
  throw new AppError(409, "SETTINGS_REQUIRED", "人物关系分析范围内没有设定数据");
4205
5007
  if (!targeted && !settingsOnly && scopedChapters.length === 0 && availableSettings.length === 0) {
@@ -4214,7 +5016,9 @@ export class AiManager {
4214
5016
  relationshipIds: [],
4215
5017
  candidateCount: 0,
4216
5018
  rawCandidateCount: 0,
4217
- skipped: [{ index: -1, reason: "没有章节或设定数据命中被分析角色的名称或别名" }],
5019
+ skipped: [{ index: -1, reason: preFilterRelationshipSources
5020
+ ? "没有章节或设定数据命中被分析角色的名称或别名"
5021
+ : "人物关系分析范围内没有章节或设定数据" }],
4218
5022
  batchCount: 0,
4219
5023
  coveredChapterCount: 0,
4220
5024
  coveredSettingCount: 0,
@@ -4224,6 +5028,19 @@ export class AiManager {
4224
5028
  targetedEvidenceCount: 0,
4225
5029
  aggregationBatchCount: 0,
4226
5030
  replacedRelationshipCount: 0,
5031
+ preFilterRelationshipSources,
5032
+ ...(previewRelationshipChanges ? {
5033
+ relationshipChangePreview: {
5034
+ status: "pending",
5035
+ totalCount: 0,
5036
+ createdCount: 0,
5037
+ updatedCount: 0,
5038
+ deletedCount: 0,
5039
+ generatedAt: now(),
5040
+ operations: []
5041
+ }
5042
+ } : {}),
5043
+ sourcePreviewApplied: Boolean(previewedSources),
4227
5044
  sourceSelection: sourceSelection?.summary,
4228
5045
  callIds: sourceSelection?.verificationCallIds ?? []
4229
5046
  };
@@ -4611,14 +5428,16 @@ export class AiManager {
4611
5428
  const relationshipId = String(relationship.id);
4612
5429
  const previous = relationshipOutcomes.get(relationshipId);
4613
5430
  relationshipOutcomes.set(relationshipId, {
4614
- action: previous?.action === "created" ? "created" : action,
5431
+ action: action === "deleted" ? "deleted" : previous?.action === "created" ? "created" : action,
4615
5432
  relationship
4616
5433
  });
4617
5434
  };
4618
5435
  let replacedRelationshipCount = 0;
5436
+ let relationshipChangeOperations = [];
5437
+ const relationshipsBeforePreview = previewRelationshipChanges ? this.store.listRelationships(workId) : [];
4619
5438
  if (!this.taskCanCommit(taskId))
4620
5439
  return { interrupted: true, callIds };
4621
- this.store.db.transaction(() => {
5440
+ const processRelationshipChanges = () => {
4622
5441
  if (targeted && scope.replaceExistingRelationships === true) {
4623
5442
  const relationshipsToReplace = this.store.listRelationships(workId).filter((relationship) => selectedCharacterIds.has(String(relationship.fromCharacterId)) || selectedCharacterIds.has(String(relationship.toCharacterId)));
4624
5443
  for (const relationship of relationshipsToReplace)
@@ -4823,9 +5642,31 @@ export class AiManager {
4823
5642
  recordRelationshipOutcome("created", relationship);
4824
5643
  existing.push(relationship);
4825
5644
  }
4826
- if (taskId && settingsOnly)
5645
+ if (taskId && includesSettings)
4827
5646
  this.store.refreshTaskSourceVersions(taskId);
4828
- });
5647
+ if (previewRelationshipChanges) {
5648
+ relationshipChangeOperations = this.relationshipChangeOperations(relationshipsBeforePreview, this.store.listRelationships(workId));
5649
+ }
5650
+ };
5651
+ if (previewRelationshipChanges)
5652
+ this.store.db.rollbackTransaction(processRelationshipChanges);
5653
+ else
5654
+ this.store.db.transaction(processRelationshipChanges);
5655
+ if (previewRelationshipChanges) {
5656
+ const unchangedOutcomes = [...relationshipOutcomes.values()].filter((outcome) => outcome.action === "unchanged");
5657
+ relationshipOutcomes.clear();
5658
+ for (const operation of relationshipChangeOperations) {
5659
+ recordRelationshipOutcome(operation.action, (operation.action === "deleted" ? operation.before : operation.after));
5660
+ }
5661
+ for (const outcome of unchangedOutcomes) {
5662
+ if (!relationshipOutcomes.has(String(outcome.relationship.id))) {
5663
+ recordRelationshipOutcome("unchanged", outcome.relationship);
5664
+ }
5665
+ }
5666
+ relationshipIds.length = 0;
5667
+ if (taskId && includesSettings)
5668
+ this.store.refreshTaskSourceVersions(taskId);
5669
+ }
4829
5670
  if (sourceSelection) {
4830
5671
  const acceptedVariants = sourceSelection.variantDecisions.filter((decision) => decision.verdict === "same" && decision.confidence >= 0.8);
4831
5672
  const reviewIds = new Set();
@@ -4872,48 +5713,21 @@ export class AiManager {
4872
5713
  });
4873
5714
  sourceSelection.summary.reviewIds = [...reviewIds];
4874
5715
  }
4875
- const characterNameById = new Map(characters.map((character) => [String(character.id), String(character.name)]));
4876
- const relationshipResults = [...relationshipOutcomes.values()].map(({ action, relationship }) => {
4877
- const evidence = Array.isArray(relationship.evidence)
4878
- ? relationship.evidence.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
4879
- : [];
4880
- return {
4881
- relationshipId: String(relationship.id),
4882
- action,
4883
- fromCharacterId: String(relationship.fromCharacterId),
4884
- fromCharacterName: characterNameById.get(String(relationship.fromCharacterId)) ?? String(relationship.fromCharacterId),
4885
- toCharacterId: String(relationship.toCharacterId),
4886
- toCharacterName: characterNameById.get(String(relationship.toCharacterId)) ?? String(relationship.toCharacterId),
4887
- category: String(relationship.category),
4888
- subtype: String(relationship.subtype),
4889
- keywords: Array.isArray(relationship.keywords) ? relationship.keywords.map(String) : [],
4890
- directed: Boolean(relationship.directed),
4891
- currentStatus: String(relationship.currentStatus ?? ""),
4892
- timeRange: relationship.timeRange && typeof relationship.timeRange === "object" && !Array.isArray(relationship.timeRange)
4893
- ? relationship.timeRange
4894
- : {},
4895
- confidence: Number(relationship.confidence ?? 0),
4896
- confirmationStatus: String(relationship.confirmationStatus ?? "pending"),
4897
- evidenceCount: evidence.length,
4898
- evidence: evidence.slice(0, 3).map((item) => ({
4899
- chapterId: String(item.chapterId ?? ""),
4900
- chapterTitle: String(item.chapterTitle ?? chapterById.get(String(item.chapterId))?.title ?? ""),
4901
- quote: String(item.quote ?? ""),
4902
- supports: String(item.supports ?? "")
4903
- })),
4904
- evidenceTruncated: evidence.length > 3
4905
- };
4906
- });
5716
+ if (previewRelationshipChanges && taskId && includesSettings)
5717
+ this.store.refreshTaskSourceVersions(taskId);
5718
+ const relationshipResults = [...relationshipOutcomes.values()].map(({ action, relationship }) => this.relationshipResultSnapshot(workId, action, relationship));
4907
5719
  const createdCount = relationshipResults.filter((item) => item.action === "created").length;
4908
5720
  const updatedCount = relationshipResults.filter((item) => item.action === "updated").length;
5721
+ const deletedCount = relationshipResults.filter((item) => item.action === "deleted").length;
4909
5722
  const unchangedCount = relationshipResults.filter((item) => item.action === "unchanged").length;
4910
- this.store.audit(workId, "relationship.analysis.completed", "work", workId, {
5723
+ this.store.audit(workId, previewRelationshipChanges ? "relationship.analysis.previewed" : "relationship.analysis.completed", "work", workId, {
4911
5724
  batchCount: chunks.length,
4912
5725
  coveredChapterCount: chapters.length,
4913
5726
  coveredSettingCount: settings.length,
4914
5727
  rawCandidateCount: rawCandidates.length,
4915
5728
  savedCount: relationshipIds.length,
4916
5729
  updatedCount,
5730
+ deletedCount,
4917
5731
  unchangedCount,
4918
5732
  skippedCount: skipped.length,
4919
5733
  fallbackSegmentCount,
@@ -4922,15 +5736,28 @@ export class AiManager {
4922
5736
  targetedCharacterCount: selectedCharacterIds.size,
4923
5737
  targetedEvidenceCount,
4924
5738
  aggregationBatchCount,
4925
- replacedRelationshipCount
5739
+ replacedRelationshipCount,
5740
+ preFilterRelationshipSources
4926
5741
  });
4927
5742
  return {
4928
5743
  relationshipIds,
4929
- candidateCount: relationshipIds.length,
5744
+ candidateCount: previewRelationshipChanges ? createdCount + updatedCount : relationshipIds.length,
4930
5745
  createdCount,
4931
5746
  updatedCount,
5747
+ deletedCount,
4932
5748
  unchangedCount,
4933
5749
  relationshipResults,
5750
+ ...(previewRelationshipChanges ? {
5751
+ relationshipChangePreview: {
5752
+ status: "pending",
5753
+ totalCount: relationshipChangeOperations.length,
5754
+ createdCount,
5755
+ updatedCount,
5756
+ deletedCount,
5757
+ generatedAt: now(),
5758
+ operations: relationshipChangeOperations
5759
+ }
5760
+ } : {}),
4934
5761
  analysisTarget: {
4935
5762
  mode: targeted ? "targeted-characters" : "all-relationships",
4936
5763
  scopeType: scope.type,
@@ -4939,7 +5766,8 @@ export class AiManager {
4939
5766
  .filter((character) => selectedCharacterIds.has(String(character.id)))
4940
5767
  .map((character) => String(character.name)),
4941
5768
  coveredChapterCount: chapters.length,
4942
- includeAllSettings: scope.includeAllSettings === true
5769
+ includeAllSettings: scope.includeAllSettings === true,
5770
+ preFilterRelationshipSources
4943
5771
  },
4944
5772
  rawCandidateCount: rawCandidates.length,
4945
5773
  skipped,
@@ -4952,6 +5780,8 @@ export class AiManager {
4952
5780
  targetedEvidenceCount,
4953
5781
  aggregationBatchCount,
4954
5782
  replacedRelationshipCount,
5783
+ preFilterRelationshipSources,
5784
+ sourcePreviewApplied: Boolean(previewedSources),
4955
5785
  ...(sourceSelection ? { sourceSelection: sourceSelection.summary } : {}),
4956
5786
  callIds
4957
5787
  };
@@ -5610,6 +6440,7 @@ export class AiManager {
5610
6440
  scope: "platform",
5611
6441
  name: stringValue(row, "name"),
5612
6442
  baseUrl: stringValue(row, "base_url"),
6443
+ protocol: providerProtocol(row),
5613
6444
  apiKey: stringValue(row, "key_hint"),
5614
6445
  status: stringValue(row, "status"),
5615
6446
  connectionStatus: stringValue(row, "connection_status"),