@musnows/scriverse 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ai.js CHANGED
@@ -1,10 +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 { clamp, id, json, maskSecret, normalizeBaseUrl, now } from "./utils.js";
8
+ import { RELATIONSHIP_SEARCH_POLICY_VERSION, RelationshipApproximateMatchLimitError, findApproximateNameMatchesChunked, ftsPhrase, normalizeRelationshipSearchText, relationshipCharacterTokenText, relationshipCharacterTokens, relationshipPinyinTokenText, relationshipPinyinTokens } from "./relationship-search.js";
9
+ import { clamp, id, json, maskSecret, now } from "./utils.js";
8
10
  import { z } from "zod";
9
11
  export function aiErrorForLog(error) {
10
12
  const sanitized = sanitizeError(error);
@@ -19,6 +21,11 @@ export function aiErrorForLog(error) {
19
21
  const allowedParameters = new Set(["temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty", "seed"]);
20
22
  const DEFAULT_MAX_TOKENS = 32_000;
21
23
  const DEFAULT_CONTEXT_WINDOW = 128_000;
24
+ const RELATIONSHIP_MAX_FUZZY_REFERENCES = 32;
25
+ const RELATIONSHIP_MAX_FUZZY_SOURCES = 200;
26
+ const RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS = 4_000_000;
27
+ const RELATIONSHIP_MAX_FUZZY_MATCHES = 600;
28
+ const RELATIONSHIP_MAX_SOURCE_MATCHES = 256;
22
29
  function isGeminiProviderOrModel(provider, model) {
23
30
  const endpoint = stringValue(provider, "base_url").toLowerCase();
24
31
  const modelId = stringValue(model, "model_id").toLowerCase();
@@ -27,9 +34,22 @@ function isGeminiProviderOrModel(provider, model) {
27
34
  function isKimiModelId(modelId) {
28
35
  return modelId.toLowerCase().includes("kimi");
29
36
  }
37
+ function providerProtocol(provider) {
38
+ return stringValue(provider, "protocol") === "anthropic-messages" ? "anthropic-messages" : "openai-chat-completions";
39
+ }
40
+ function isLongCatProvider(provider) {
41
+ try {
42
+ return new URL(stringValue(provider, "base_url")).hostname.toLowerCase() === "api.longcat.chat";
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
30
48
  function thinkingParameters(provider, model) {
31
49
  if (isGeminiProviderOrModel(provider, model))
32
50
  return {};
51
+ if (providerProtocol(provider) === "anthropic-messages" && !isLongCatProvider(provider))
52
+ return {};
33
53
  return { thinking: { type: boolValue(model, "thinking_enabled") ? "enabled" : "disabled" } };
34
54
  }
35
55
  const AGENT_TOOL_IDS = ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections"];
@@ -850,18 +870,30 @@ export class AiManager {
850
870
  vault;
851
871
  fetchImpl;
852
872
  validateOutboundUrl;
873
+ authorizeTaskRun;
853
874
  contextBuilder;
854
875
  taskControllers = new Map();
855
876
  autoRunBatches = new Map();
856
877
  autoRunTimers = new Map();
878
+ relationshipIndexBuilds = new Map();
879
+ relationshipSelectionCache = new Map();
880
+ relationshipSelectionBuilds = new Map();
881
+ relationshipIndexSerial = Promise.resolve();
882
+ relationshipIndexTimer = null;
883
+ relationshipIndexDisposed = false;
857
884
  providerSchedules = new Map();
858
- constructor(store, vault, fetchImpl = fetch, validateOutboundUrl) {
885
+ constructor(store, vault, fetchImpl = fetch, validateOutboundUrl, authorizeTaskRun) {
859
886
  this.store = store;
860
887
  this.vault = vault;
861
888
  this.fetchImpl = fetchImpl;
862
889
  this.validateOutboundUrl = validateOutboundUrl;
890
+ this.authorizeTaskRun = authorizeTaskRun;
863
891
  this.contextBuilder = new ContextBuilder(store);
864
892
  this.store.setAnalysisTaskQueuedHandler((workId) => this.scheduleAutoRun(workId));
893
+ this.relationshipIndexTimer = setTimeout(() => {
894
+ this.relationshipIndexTimer = null;
895
+ void this.schedulePendingRelationshipIndexes();
896
+ }, 0);
865
897
  logger.info("ai.manager.ready");
866
898
  }
867
899
  resetAutoRunBatch(workId) {
@@ -913,6 +945,10 @@ export class AiManager {
913
945
  clearTimeout(timer);
914
946
  this.autoRunTimers.clear();
915
947
  this.autoRunBatches.clear();
948
+ this.relationshipIndexDisposed = true;
949
+ if (this.relationshipIndexTimer)
950
+ clearTimeout(this.relationshipIndexTimer);
951
+ this.relationshipIndexTimer = null;
916
952
  this.store.setAnalysisTaskQueuedHandler(null);
917
953
  logger.info("ai.manager.disposed");
918
954
  }
@@ -968,10 +1004,12 @@ export class AiManager {
968
1004
  const providerId = id("provider");
969
1005
  const encrypted = this.vault.encrypt(input.apiKey);
970
1006
  const timestamp = now();
971
- this.store.db.run(`INSERT INTO providers (id, work_id, name, base_url, encrypted_key, key_iv, key_tag, key_hint, status,
1007
+ const protocol = input.protocol ?? "openai-chat-completions";
1008
+ const baseUrl = normalizeProviderBaseUrl(input.baseUrl);
1009
+ this.store.db.run(`INSERT INTO providers (id, work_id, name, base_url, protocol, encrypted_key, key_iv, key_tag, key_hint, status,
972
1010
  connection_status, concurrency_limit, rpm_limit, max_tokens, note, created_at, updated_at)
973
- 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);
974
- this.store.audit(PLATFORM_AI_WORK_ID, "provider.created", "provider", providerId, { name: input.name, baseUrl: normalizeBaseUrl(input.baseUrl) });
1011
+ 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);
1012
+ this.store.audit(PLATFORM_AI_WORK_ID, "provider.created", "provider", providerId, { name: input.name, baseUrl, protocol });
975
1013
  return this.getProvider(providerId);
976
1014
  }
977
1015
  listProviders() {
@@ -1000,10 +1038,12 @@ export class AiManager {
1000
1038
  keyHint = maskSecret(input.apiKey);
1001
1039
  connectionStatus = "unchecked";
1002
1040
  }
1003
- if (input.baseUrl && normalizeBaseUrl(input.baseUrl) !== stringValue(row, "base_url"))
1041
+ if (input.baseUrl && normalizeProviderBaseUrl(input.baseUrl) !== stringValue(row, "base_url"))
1042
+ connectionStatus = "unchecked";
1043
+ if (input.protocol && input.protocol !== providerProtocol(row))
1004
1044
  connectionStatus = "unchecked";
1005
- this.store.db.run(`UPDATE providers SET name = ?, base_url = ?, encrypted_key = ?, key_iv = ?, key_tag = ?, key_hint = ?,
1006
- 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);
1045
+ this.store.db.run(`UPDATE providers SET name = ?, base_url = ?, protocol = ?, encrypted_key = ?, key_iv = ?, key_tag = ?, key_hint = ?,
1046
+ 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);
1007
1047
  this.store.audit(PLATFORM_AI_WORK_ID, "provider.updated", "provider", providerId, {
1008
1048
  fields: Object.keys(input).filter((key) => key !== "apiKey"),
1009
1049
  keyReplaced: Boolean(input.apiKey)
@@ -1029,26 +1069,40 @@ export class AiManager {
1029
1069
  async testProvider(providerId) {
1030
1070
  const row = this.getProviderRow(providerId);
1031
1071
  const apiKey = this.decryptKey(row);
1072
+ const protocol = providerProtocol(row);
1032
1073
  const controller = new AbortController();
1033
1074
  const timeout = setTimeout(() => controller.abort(), 10_000);
1034
1075
  const startedAt = process.hrtime.bigint();
1035
1076
  logger.info("ai.provider_test.started", { providerId });
1036
1077
  try {
1037
- const endpoint = `${normalizeBaseUrl(stringValue(row, "base_url"))}/models`;
1038
- const response = await this.outboundFetch(endpoint, {
1039
- headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
1040
- signal: controller.signal
1041
- });
1042
- if (!response.ok) {
1078
+ let payload = null;
1079
+ let lastFailure = "AI 供应商没有返回模型列表";
1080
+ const endpoints = providerModelEndpoints(stringValue(row, "base_url"), protocol);
1081
+ for (let index = 0; index < endpoints.length; index += 1) {
1082
+ const endpoint = endpoints[index];
1083
+ if (!endpoint)
1084
+ continue;
1085
+ const response = await this.outboundFetch(endpoint, {
1086
+ headers: providerRequestHeaders(protocol, apiKey, "application/json"),
1087
+ signal: controller.signal
1088
+ });
1089
+ if (response.ok) {
1090
+ payload = (await response.json());
1091
+ break;
1092
+ }
1043
1093
  const message = await response.text();
1044
- throw new Error(`HTTP ${response.status}: ${message.slice(0, 300)}`);
1094
+ lastFailure = `HTTP ${response.status}: ${message.slice(0, 300)}`;
1095
+ if (response.status !== 404 || index === endpoints.length - 1)
1096
+ break;
1045
1097
  }
1046
- const payload = (await response.json());
1098
+ if (!payload)
1099
+ throw new Error(lastFailure);
1047
1100
  const availableModels = Array.isArray(payload.data) ? payload.data.map((item) => item.id).filter(Boolean) : [];
1048
1101
  const timestamp = now();
1049
1102
  this.store.db.run("UPDATE providers SET connection_status = 'success', last_error = NULL, last_success_at = ?, updated_at = ? WHERE id = ?", timestamp, timestamp, providerId);
1050
1103
  logger.info("ai.provider_test.completed", {
1051
1104
  providerId,
1105
+ protocol,
1052
1106
  ok: true,
1053
1107
  availableModelCount: availableModels.length,
1054
1108
  durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000
@@ -1056,10 +1110,11 @@ export class AiManager {
1056
1110
  return { ok: true, availableModels, provider: this.getProvider(providerId) };
1057
1111
  }
1058
1112
  catch (error) {
1059
- const message = error instanceof Error ? error.message : "连接失败";
1113
+ const message = error instanceof Error ? redactProviderSecret(error.message, apiKey) : "连接失败";
1060
1114
  this.store.db.run("UPDATE providers SET connection_status = 'failed', last_error = ?, updated_at = ? WHERE id = ?", message, now(), providerId);
1061
1115
  logger.warn("ai.provider_test.completed", {
1062
1116
  providerId,
1117
+ protocol,
1063
1118
  ok: false,
1064
1119
  durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000,
1065
1120
  error: aiErrorForLog(error)
@@ -1091,14 +1146,27 @@ export class AiManager {
1091
1146
  }
1092
1147
  listPlatformModels() {
1093
1148
  return this.store.db
1094
- .all("SELECT m.*, p.name AS provider_name FROM models m JOIN providers p ON p.id = m.provider_id WHERE p.work_id = ? ORDER BY p.created_at, m.created_at", PLATFORM_AI_WORK_ID)
1095
- .map((row) => ({ ...this.mapModel(row), providerName: stringValue(row, "provider_name") }));
1149
+ .all(`SELECT m.*, p.name AS provider_name, p.status AS provider_status, p.connection_status AS provider_connection_status
1150
+ FROM models m JOIN providers p ON p.id = m.provider_id
1151
+ WHERE p.work_id = ? ORDER BY p.created_at, m.created_at`, PLATFORM_AI_WORK_ID)
1152
+ .map((row) => ({
1153
+ ...this.mapModel(row),
1154
+ providerName: stringValue(row, "provider_name"),
1155
+ providerStatus: stringValue(row, "provider_status"),
1156
+ providerConnectionStatus: stringValue(row, "provider_connection_status")
1157
+ }));
1096
1158
  }
1097
1159
  listPlatformModelsPage(pagination) {
1098
1160
  const page = paginationSql(pagination);
1099
- const rows = this.store.db.all(`SELECT m.*, p.name AS provider_name FROM models m JOIN providers p ON p.id = m.provider_id
1161
+ const rows = this.store.db.all(`SELECT m.*, p.name AS provider_name, p.status AS provider_status, p.connection_status AS provider_connection_status
1162
+ FROM models m JOIN providers p ON p.id = m.provider_id
1100
1163
  WHERE p.work_id = ? ORDER BY p.created_at, m.created_at${page.sql}`, PLATFORM_AI_WORK_ID, ...page.params);
1101
- return paginated(rows.map((row) => ({ ...this.mapModel(row), providerName: stringValue(row, "provider_name") })), pagination);
1164
+ return paginated(rows.map((row) => ({
1165
+ ...this.mapModel(row),
1166
+ providerName: stringValue(row, "provider_name"),
1167
+ providerStatus: stringValue(row, "provider_status"),
1168
+ providerConnectionStatus: stringValue(row, "provider_connection_status")
1169
+ })), pagination);
1102
1170
  }
1103
1171
  listWorkModels(workId) {
1104
1172
  this.store.getWork(workId);
@@ -1150,6 +1218,19 @@ export class AiManager {
1150
1218
  model: this.getModel(stringValue(row, "model_id"))
1151
1219
  })), pagination);
1152
1220
  }
1221
+ createTask(workId, input) {
1222
+ this.store.getWork(workId);
1223
+ const modelPurpose = this.analysisTaskModelPurpose(input.taskType);
1224
+ const defaultRow = this.store.db.get("SELECT model_id FROM task_defaults WHERE work_id = ? AND task_type = ?", workId, modelPurpose);
1225
+ const modelId = input.modelId ?? (defaultRow ? stringValue(defaultRow, "model_id") : undefined);
1226
+ if (modelId)
1227
+ this.resolveModel(workId, modelPurpose, modelId);
1228
+ return this.store.createTask(workId, {
1229
+ taskType: input.taskType,
1230
+ ...(input.scope ? { scope: input.scope } : {}),
1231
+ ...(modelId ? { modelId } : {})
1232
+ });
1233
+ }
1153
1234
  async createSuggestion(input) {
1154
1235
  const action = input.taskType === "continue" ? "append" : input.taskType === "polish" ? "replace-selection" : "note";
1155
1236
  if (action === "replace-selection" && !input.scope.selection) {
@@ -1444,12 +1525,17 @@ export class AiManager {
1444
1525
  }
1445
1526
  };
1446
1527
  }
1447
- async runTask(taskId, modelId) {
1528
+ async runTask(taskId, modelId, actor) {
1448
1529
  const task = this.store.getTask(taskId);
1530
+ this.authorizeTaskRun?.(task, actor);
1449
1531
  const workId = String(task.workId);
1532
+ const taskModel = task.model && typeof task.model === "object" && !Array.isArray(task.model)
1533
+ ? task.model
1534
+ : null;
1535
+ const selectedModelId = modelId ?? (typeof taskModel?.id === "string" ? taskModel.id : undefined);
1450
1536
  const batch = this.getAutoRunBatch(workId);
1451
1537
  const startedAt = process.hrtime.bigint();
1452
- logger.info("ai.task.started", { taskId, workId, taskType: task.taskType, modelId: modelId ?? null });
1538
+ logger.info("ai.task.started", { taskId, workId, taskType: task.taskType, modelId: selectedModelId ?? null });
1453
1539
  if (task.status !== "pending")
1454
1540
  throw new AppError(409, "TASK_NOT_PENDING", "只有待执行任务可以运行");
1455
1541
  if (!this.store.isTaskSourceCurrent(taskId)) {
@@ -1472,28 +1558,28 @@ export class AiManager {
1472
1558
  const scope = task.scope;
1473
1559
  let result;
1474
1560
  if (taskType === "chapter-analysis") {
1475
- result = await this.runChapterAnalysis(workId, scope, modelId, taskId);
1561
+ result = await this.runChapterAnalysis(workId, scope, selectedModelId, taskId);
1476
1562
  }
1477
1563
  else if (taskType === "character-extraction" || taskType === "character-summary") {
1478
- result = await this.runCharacterExtraction(workId, scope, modelId, taskId);
1564
+ result = await this.runCharacterExtraction(workId, scope, selectedModelId, taskId);
1479
1565
  }
1480
1566
  else if (taskType === "character-identity-audit") {
1481
- result = await this.runCharacterIdentityAudit(workId, scope, modelId, taskId);
1567
+ result = await this.runCharacterIdentityAudit(workId, scope, selectedModelId, taskId);
1482
1568
  }
1483
1569
  else if (taskType === "timeline-analysis") {
1484
- result = await this.runTimelineAnalysis(workId, scope, modelId, taskId);
1570
+ result = await this.runTimelineAnalysis(workId, scope, selectedModelId, taskId);
1485
1571
  }
1486
1572
  else if (taskType === "relationship-analysis") {
1487
- result = await this.runRelationshipAnalysis(workId, scope, modelId, taskId);
1573
+ result = await this.runRelationshipAnalysis(workId, scope, selectedModelId, taskId);
1488
1574
  }
1489
1575
  else if (taskType === "worldview-analysis") {
1490
- result = await this.runWorldviewAnalysis(workId, scope, modelId, taskId);
1576
+ result = await this.runWorldviewAnalysis(workId, scope, selectedModelId, taskId);
1491
1577
  }
1492
1578
  else if (taskType === "setting-extraction") {
1493
- result = await this.runSettingExtraction(workId, scope, modelId, taskId);
1579
+ result = await this.runSettingExtraction(workId, scope, selectedModelId, taskId);
1494
1580
  }
1495
1581
  else if (taskType === "consistency-check") {
1496
- result = await this.runConsistencyCheck(workId, scope, modelId, taskId);
1582
+ result = await this.runConsistencyCheck(workId, scope, selectedModelId, taskId);
1497
1583
  }
1498
1584
  else {
1499
1585
  const generated = await this.generate({
@@ -1503,7 +1589,7 @@ export class AiManager {
1503
1589
  instruction: "请基于上下文完成分析,给出有原文依据的中文结论。",
1504
1590
  scope,
1505
1591
  signal: taskController.signal,
1506
- ...(modelId ? { modelId } : {})
1592
+ ...(selectedModelId ? { modelId: selectedModelId } : {})
1507
1593
  });
1508
1594
  result = { content: generated.content, callId: generated.callId };
1509
1595
  }
@@ -1975,12 +2061,14 @@ export class AiManager {
1975
2061
  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);
1976
2062
  };
1977
2063
  const callStartedAt = process.hrtime.bigint();
2064
+ const protocol = providerProtocol(provider);
1978
2065
  logger.info("ai.call.started", {
1979
2066
  callId,
1980
2067
  workId: input.workId,
1981
2068
  taskType: input.taskType,
1982
2069
  providerId: stringValue(provider, "id"),
1983
2070
  modelId: stringValue(model, "id"),
2071
+ protocol,
1984
2072
  streaming: false,
1985
2073
  contextChars: context.length,
1986
2074
  instructionChars: input.instruction.length,
@@ -1990,7 +2078,7 @@ export class AiManager {
1990
2078
  try {
1991
2079
  const apiKey = this.decryptKey(provider);
1992
2080
  activeApiKey = apiKey;
1993
- const endpoint = `${normalizeBaseUrl(stringValue(provider, "base_url"))}/chat/completions`;
2081
+ const endpoint = providerCompletionEndpoint(stringValue(provider, "base_url"), protocol);
1994
2082
  const timeoutMs = input.taskType === "book-analysis" || input.taskType === "relationship-analysis" ? 300_000 : 60_000;
1995
2083
  const maximumAttempts = Math.round(clamp(input.maxAttempts ?? 3, 1, 5));
1996
2084
  let completionRequestCount = 0;
@@ -2037,13 +2125,15 @@ export class AiManager {
2037
2125
  try {
2038
2126
  const response = await this.outboundFetch(endpoint, {
2039
2127
  method: "POST",
2040
- headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", Accept: "application/json" },
2041
- body: JSON.stringify({
2128
+ headers: providerRequestHeaders(protocol, apiKey, "application/json"),
2129
+ body: JSON.stringify(buildCompletionRequestBody({
2130
+ protocol,
2042
2131
  model: stringValue(model, "model_id"),
2043
2132
  messages: completionMessages,
2044
- ...parameters,
2045
- ...(tools.length && toolChoice === "auto" ? { tools, tool_choice: "auto" } : {})
2046
- }),
2133
+ parameters,
2134
+ tools,
2135
+ toolChoice
2136
+ })),
2047
2137
  signal: controller.signal
2048
2138
  });
2049
2139
  return { ok: response.ok, status: response.status, body: await response.text() };
@@ -2062,7 +2152,7 @@ export class AiManager {
2062
2152
  });
2063
2153
  if (candidate.ok) {
2064
2154
  try {
2065
- const parsed = redactProviderSecrets(JSON.parse(candidate.body), apiKey);
2155
+ const parsed = parseCompletionPayload(protocol, redactProviderSecrets(JSON.parse(candidate.body), apiKey));
2066
2156
  traceAttempt.completedAt = now();
2067
2157
  traceAttempt.status = "completed";
2068
2158
  traceAttempt.httpStatus = candidate.status;
@@ -2079,7 +2169,7 @@ export class AiManager {
2079
2169
  return parsed;
2080
2170
  }
2081
2171
  catch {
2082
- throw new Error(`Chat Completions returned invalid JSON: ${candidate.body.slice(0, 500)}`);
2172
+ throw new Error(`${protocol === "anthropic-messages" ? "Anthropic Messages" : "Chat Completions"} returned invalid JSON: ${candidate.body.slice(0, 500)}`);
2083
2173
  }
2084
2174
  }
2085
2175
  lastFailure = new Error(`HTTP ${candidate.status}: ${candidate.body.slice(0, 500)}`);
@@ -2158,7 +2248,8 @@ export class AiManager {
2158
2248
  role: "assistant",
2159
2249
  content: choice.message.content ?? null,
2160
2250
  reasoning_content: choice.message.reasoning_content ?? null,
2161
- tool_calls: normalizedToolCalls
2251
+ tool_calls: normalizedToolCalls,
2252
+ ...(choice.message.anthropic_content?.length ? { anthropic_content: choice.message.anthropic_content } : {})
2162
2253
  });
2163
2254
  for (const toolCall of toolCalls) {
2164
2255
  const execution = this.executeAgentTool(input.workId, toolCall);
@@ -2191,7 +2282,7 @@ export class AiManager {
2191
2282
  const suffix = choice?.finish_reason === "length" || reasoningLength > 0
2192
2283
  ? `;模型已生成 ${reasoningLength} 个推理字符,请提高 max_tokens 输出预算`
2193
2284
  : "";
2194
- throw new Error(`Chat Completions 响应缺少可用正文,finish_reason=${choice?.finish_reason ?? "unknown"}${suffix}`);
2285
+ throw new Error(`${protocol === "anthropic-messages" ? "Anthropic Messages" : "Chat Completions"} 响应缺少可用正文,finish_reason=${choice?.finish_reason ?? "unknown"}${suffix}`);
2195
2286
  }
2196
2287
  this.store.db.run("UPDATE ai_calls SET status = 'completed', output_chars = ?, completed_at = ? WHERE id = ?", content.length, now(), callId);
2197
2288
  const outputTokens = resolveOutputTokens(payload.usage, content);
@@ -2237,19 +2328,23 @@ export class AiManager {
2237
2328
  this.store.db.run(`INSERT INTO ai_calls (id, work_id, task_type, provider_id, model_id, context_scope_json, parameters_json,
2238
2329
  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);
2239
2330
  const callStartedAt = process.hrtime.bigint();
2331
+ const protocol = providerProtocol(provider);
2240
2332
  logger.info("ai.call.started", {
2241
2333
  callId,
2242
2334
  workId: input.workId,
2243
2335
  taskType: input.taskType,
2244
2336
  providerId: stringValue(provider, "id"),
2245
2337
  modelId: stringValue(model, "id"),
2338
+ protocol,
2246
2339
  streaming: true,
2247
2340
  contextChars: context.length,
2248
2341
  instructionChars: input.instruction.length
2249
2342
  });
2343
+ let activeApiKey = "";
2250
2344
  try {
2251
2345
  const apiKey = this.decryptKey(provider);
2252
- const endpoint = `${normalizeBaseUrl(stringValue(provider, "base_url"))}/chat/completions`;
2346
+ activeApiKey = apiKey;
2347
+ const endpoint = providerCompletionEndpoint(stringValue(provider, "base_url"), protocol);
2253
2348
  const maximumAttempts = Math.round(clamp(input.maxAttempts ?? 3, 1, 5));
2254
2349
  let streamedResult = null;
2255
2350
  let lastFailure = null;
@@ -2271,13 +2366,19 @@ export class AiManager {
2271
2366
  try {
2272
2367
  const response = await this.outboundFetch(endpoint, {
2273
2368
  method: "POST",
2274
- headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", Accept: "text/event-stream" },
2275
- body: JSON.stringify({ model: stringValue(model, "model_id"), messages, ...parameters, stream: true, stream_options: { include_usage: true } }),
2369
+ headers: providerRequestHeaders(protocol, apiKey, "text/event-stream"),
2370
+ body: JSON.stringify(buildCompletionRequestBody({
2371
+ protocol,
2372
+ model: stringValue(model, "model_id"),
2373
+ messages,
2374
+ parameters,
2375
+ stream: true
2376
+ })),
2276
2377
  signal: controller.signal
2277
2378
  });
2278
2379
  if (!response.ok)
2279
2380
  return { ok: false, status: response.status, body: await response.text() };
2280
- const streamed = await this.readCompletionStream(response, (delta) => {
2381
+ const streamed = await this.readCompletionStream(response, protocol, (delta) => {
2281
2382
  emitted = true;
2282
2383
  onDelta(delta);
2283
2384
  }, (delta) => {
@@ -2342,7 +2443,7 @@ export class AiManager {
2342
2443
  return { callId, content, outputTokens, ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }), provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: [], processSteps };
2343
2444
  }
2344
2445
  catch (error) {
2345
- const message = error instanceof Error ? error.message : "AI 流式调用失败";
2446
+ const message = error instanceof Error ? redactProviderSecret(error.message, activeApiKey) : "AI 流式调用失败";
2346
2447
  this.store.db.run("UPDATE ai_calls SET status = 'failed', failure = ?, completed_at = ? WHERE id = ?", message, now(), callId);
2347
2448
  logger.error("ai.call.failed", {
2348
2449
  callId,
@@ -2355,9 +2456,10 @@ export class AiManager {
2355
2456
  throw new AppError(502, "AI_CALL_FAILED", "AI 调用失败", { callId, failure: message });
2356
2457
  }
2357
2458
  }
2358
- async readCompletionStream(response, onDelta, onThinkingDelta) {
2459
+ async readCompletionStream(response, protocol, onDelta, onThinkingDelta) {
2460
+ const protocolLabel = protocol === "anthropic-messages" ? "Anthropic Messages" : "Chat Completions";
2359
2461
  if (!response.body)
2360
- throw new Error("Chat Completions 流式响应缺少正文");
2462
+ throw new Error(`${protocolLabel} 流式响应缺少正文`);
2361
2463
  const reader = response.body.getReader();
2362
2464
  const decoder = new TextDecoder();
2363
2465
  let buffer = "";
@@ -2374,19 +2476,59 @@ export class AiManager {
2374
2476
  if (!data || data === "[DONE]")
2375
2477
  return;
2376
2478
  const payload = JSON.parse(data);
2377
- if (payload.error)
2378
- throw new Error(payload.error.message || "上游流式响应返回错误");
2379
- if (payload.usage)
2380
- usage = payload.usage;
2381
- const choice = payload.choices?.[0];
2382
- if (choice?.finish_reason)
2479
+ const error = payload.error && typeof payload.error === "object" && !Array.isArray(payload.error)
2480
+ ? payload.error
2481
+ : null;
2482
+ if (error)
2483
+ throw new Error(typeof error.message === "string" ? error.message : "上游流式响应返回错误");
2484
+ if (protocol === "anthropic-messages") {
2485
+ const eventUsage = payload.usage && typeof payload.usage === "object" && !Array.isArray(payload.usage)
2486
+ ? payload.usage
2487
+ : null;
2488
+ const message = payload.message && typeof payload.message === "object" && !Array.isArray(payload.message)
2489
+ ? payload.message
2490
+ : null;
2491
+ const messageUsage = message?.usage && typeof message.usage === "object" && !Array.isArray(message.usage)
2492
+ ? message.usage
2493
+ : null;
2494
+ if (eventUsage || messageUsage) {
2495
+ usage = { ...(usage && typeof usage === "object" ? usage : {}), ...(messageUsage ?? {}), ...(eventUsage ?? {}) };
2496
+ }
2497
+ const eventDelta = payload.delta && typeof payload.delta === "object" && !Array.isArray(payload.delta)
2498
+ ? payload.delta
2499
+ : {};
2500
+ if (typeof eventDelta.stop_reason === "string")
2501
+ finishReason = eventDelta.stop_reason;
2502
+ if (eventDelta.type === "thinking_delta" && typeof eventDelta.thinking === "string" && eventDelta.thinking.length > 0) {
2503
+ reasoning += eventDelta.thinking;
2504
+ onThinkingDelta(eventDelta.thinking);
2505
+ }
2506
+ if (eventDelta.type === "text_delta" && typeof eventDelta.text === "string" && eventDelta.text.length > 0) {
2507
+ content += eventDelta.text;
2508
+ onDelta(eventDelta.text);
2509
+ }
2510
+ return;
2511
+ }
2512
+ const streamUsage = payload.usage && typeof payload.usage === "object" && !Array.isArray(payload.usage)
2513
+ ? payload.usage
2514
+ : null;
2515
+ if (streamUsage)
2516
+ usage = streamUsage;
2517
+ const choices = Array.isArray(payload.choices) ? payload.choices : [];
2518
+ const choice = choices[0] && typeof choices[0] === "object" && !Array.isArray(choices[0])
2519
+ ? choices[0]
2520
+ : null;
2521
+ if (typeof choice?.finish_reason === "string")
2383
2522
  finishReason = choice.finish_reason;
2384
- const thinkingDelta = choice?.delta?.reasoning_content;
2523
+ const deltaRecord = choice?.delta && typeof choice.delta === "object" && !Array.isArray(choice.delta)
2524
+ ? choice.delta
2525
+ : {};
2526
+ const thinkingDelta = deltaRecord.reasoning_content;
2385
2527
  if (typeof thinkingDelta === "string" && thinkingDelta.length > 0) {
2386
2528
  reasoning += thinkingDelta;
2387
2529
  onThinkingDelta(thinkingDelta);
2388
2530
  }
2389
- const delta = choice?.delta?.content;
2531
+ const delta = deltaRecord.content;
2390
2532
  if (typeof delta === "string" && delta.length > 0) {
2391
2533
  content += delta;
2392
2534
  onDelta(delta);
@@ -2405,7 +2547,7 @@ export class AiManager {
2405
2547
  if (buffer.trim())
2406
2548
  consumeEvent(buffer);
2407
2549
  if (!content.trim())
2408
- throw new Error(`Chat Completions 流式响应缺少可用正文,finish_reason=${finishReason}`);
2550
+ throw new Error(`${protocolLabel} 流式响应缺少可用正文,finish_reason=${finishReason}`);
2409
2551
  const cacheHitPercent = resolveCacheHitPercent(usage);
2410
2552
  return { content, reasoning, outputTokens: resolveOutputTokens(usage, content), ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }) };
2411
2553
  }
@@ -3297,8 +3439,651 @@ export class AiManager {
3297
3439
  }
3298
3440
  };
3299
3441
  }
3300
- relationshipSettingSources(workId, characters) {
3301
- const characterNameById = new Map(characters.map((character) => [String(character.id), String(character.name)]));
3442
+ async schedulePendingRelationshipIndexes() {
3443
+ if (this.relationshipIndexDisposed)
3444
+ return;
3445
+ 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));
3446
+ await Promise.allSettled(workIds.map((workId) => this.ensureRelationshipSearchIndex(workId)));
3447
+ }
3448
+ ensureRelationshipSearchIndex(workId) {
3449
+ const existing = this.relationshipIndexBuilds.get(workId);
3450
+ if (existing)
3451
+ return existing;
3452
+ const build = this.relationshipIndexSerial.then(async () => this.drainRelationshipSearchIndex(workId));
3453
+ this.relationshipIndexSerial = build.then(() => undefined, () => undefined);
3454
+ this.relationshipIndexBuilds.set(workId, build);
3455
+ void build.finally(() => {
3456
+ if (this.relationshipIndexBuilds.get(workId) === build)
3457
+ this.relationshipIndexBuilds.delete(workId);
3458
+ }).catch(() => undefined);
3459
+ return build;
3460
+ }
3461
+ async drainRelationshipSearchIndex(workId) {
3462
+ if (this.relationshipIndexDisposed)
3463
+ return 0;
3464
+ const timestamp = now();
3465
+ this.store.db.run(`INSERT INTO relationship_source_index_state(work_id, status, generation, error, updated_at)
3466
+ VALUES (?, 'building', 0, '', ?)
3467
+ ON CONFLICT(work_id) DO UPDATE SET status = 'building', error = '', updated_at = excluded.updated_at`, workId, timestamp);
3468
+ let processed = 0;
3469
+ try {
3470
+ while (!this.relationshipIndexDisposed) {
3471
+ const queued = this.store.db.all(`SELECT source_type, source_id, queued_at FROM relationship_source_index_queue
3472
+ WHERE work_id = ? ORDER BY queued_at, source_type, source_id LIMIT 50`, workId);
3473
+ if (queued.length === 0)
3474
+ break;
3475
+ for (const item of queued) {
3476
+ const sourceType = String(item.source_type);
3477
+ const sourceId = String(item.source_id);
3478
+ const queuedAt = String(item.queued_at);
3479
+ this.store.db.transaction(() => {
3480
+ if (sourceType === "chapter")
3481
+ this.indexRelationshipChapter(workId, sourceId);
3482
+ else
3483
+ this.indexRelationshipSettingSource(workId, sourceType, sourceId);
3484
+ this.store.db.run(`DELETE FROM relationship_source_index_queue
3485
+ WHERE work_id = ? AND source_type = ? AND source_id = ? AND queued_at = ?`, workId, sourceType, sourceId, queuedAt);
3486
+ });
3487
+ processed += 1;
3488
+ }
3489
+ await new Promise((resolve) => setImmediate(resolve));
3490
+ }
3491
+ if (this.relationshipIndexDisposed) {
3492
+ this.store.db.run("UPDATE relationship_source_index_state SET status = 'queued', updated_at = ? WHERE work_id = ?", now(), workId);
3493
+ return 0;
3494
+ }
3495
+ this.store.db.run(`UPDATE relationship_source_index_state
3496
+ SET status = 'ready', generation = generation + ?, error = '', updated_at = ? WHERE work_id = ?`, processed > 0 ? 1 : 0, now(), workId);
3497
+ const generation = Number(this.store.db.get("SELECT generation FROM relationship_source_index_state WHERE work_id = ?", workId)?.generation ?? 0);
3498
+ logger.info("relationship.search_index.ready", { workId, generation, processed });
3499
+ return generation;
3500
+ }
3501
+ catch (error) {
3502
+ const message = error instanceof Error ? error.message : "索引构建失败";
3503
+ this.store.db.run("UPDATE relationship_source_index_state SET status = 'failed', error = ?, updated_at = ? WHERE work_id = ?", message.slice(0, 2_000), now(), workId);
3504
+ logger.error("relationship.search_index.failed", { workId, processed, error: sanitizeError(error) });
3505
+ throw error;
3506
+ }
3507
+ }
3508
+ indexRelationshipChapter(workId, chapterId) {
3509
+ const chapter = this.store.db.get("SELECT id FROM chapters WHERE id = ? AND work_id = ?", chapterId, workId);
3510
+ if (!chapter)
3511
+ return;
3512
+ const paragraphs = this.store.db.all("SELECT id, search_content FROM chapter_paragraph_search WHERE chapter_id = ? ORDER BY paragraph_order", chapterId);
3513
+ for (const paragraph of paragraphs) {
3514
+ const rowId = Number(paragraph.id);
3515
+ this.store.db.run("DELETE FROM chapter_paragraph_pinyin_fts WHERE rowid = ?", rowId);
3516
+ this.store.db.run("INSERT INTO chapter_paragraph_pinyin_fts(rowid, pinyin_tokens) VALUES (?, ?)", rowId, relationshipPinyinTokenText(String(paragraph.search_content)));
3517
+ }
3518
+ }
3519
+ indexRelationshipSettingSource(workId, sourceType, sourceId) {
3520
+ const materialized = this.relationshipSettingSource(workId, sourceType, sourceId);
3521
+ const existing = this.store.db.get("SELECT id FROM relationship_source_search WHERE work_id = ? AND source_type = ? AND source_id = ?", workId, sourceType, sourceId);
3522
+ if (!materialized) {
3523
+ if (existing)
3524
+ this.store.db.run("DELETE FROM relationship_source_search WHERE id = ?", Number(existing.id));
3525
+ return;
3526
+ }
3527
+ const searchable = `${materialized.title}\n${materialized.content}`;
3528
+ const contentHash = this.store.hashContent(searchable);
3529
+ let rowId = Number(existing?.id ?? 0);
3530
+ if (existing) {
3531
+ this.store.db.run(`UPDATE relationship_source_search SET source_version = ?, content_hash = ?, updated_at = ? WHERE id = ?`, materialized.version, contentHash, now(), rowId);
3532
+ this.store.db.run("DELETE FROM relationship_source_exact_fts WHERE rowid = ?", rowId);
3533
+ this.store.db.run("DELETE FROM relationship_source_pinyin_fts WHERE rowid = ?", rowId);
3534
+ }
3535
+ else {
3536
+ rowId = Number(this.store.db.run(`INSERT INTO relationship_source_search(work_id, source_type, source_id, source_version, content_hash, updated_at)
3537
+ VALUES (?, ?, ?, ?, ?, ?)`, workId, sourceType, sourceId, materialized.version, contentHash, now()).lastInsertRowid);
3538
+ }
3539
+ this.store.db.run("INSERT INTO relationship_source_exact_fts(rowid, character_tokens) VALUES (?, ?)", rowId, relationshipCharacterTokenText(searchable));
3540
+ this.store.db.run("INSERT INTO relationship_source_pinyin_fts(rowid, pinyin_tokens) VALUES (?, ?)", rowId, relationshipPinyinTokenText(searchable));
3541
+ }
3542
+ relationshipScopeChapterIds(workId, scope) {
3543
+ if (scope.type === "settings")
3544
+ return new Set();
3545
+ if (scope.type === "chapter") {
3546
+ if (!scope.chapterId)
3547
+ throw new AppError(400, "CHAPTER_REQUIRED", "分析范围缺少章节标识");
3548
+ const chapter = this.store.getChapter(scope.chapterId);
3549
+ if (String(chapter.workId) !== workId)
3550
+ throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
3551
+ return new Set([scope.chapterId]);
3552
+ }
3553
+ if (scope.type === "volume") {
3554
+ if (!scope.volumeId)
3555
+ throw new AppError(400, "VOLUME_REQUIRED", "分析范围缺少分卷标识");
3556
+ const volume = this.store.getVolume(scope.volumeId);
3557
+ if (String(volume.workId) !== workId)
3558
+ throw new AppError(400, "VOLUME_WORK_MISMATCH", "分卷不属于当前作品");
3559
+ return new Set(this.store.db.all(`SELECT id FROM chapters WHERE work_id = ? AND volume_id = ?
3560
+ AND excluded_from_analysis = 0 AND chapter_type <> '作者的话'`, workId, scope.volumeId).map((row) => String(row.id)));
3561
+ }
3562
+ return new Set(this.store.db.all(`SELECT id FROM chapters WHERE work_id = ? AND excluded_from_analysis = 0 AND chapter_type <> '作者的话'`, workId).map((row) => String(row.id)));
3563
+ }
3564
+ relationshipIndexedSource(workId, sourceType, sourceId) {
3565
+ if (sourceType === "chapter") {
3566
+ try {
3567
+ const chapter = this.store.getChapter(sourceId);
3568
+ if (String(chapter.workId) !== workId)
3569
+ return null;
3570
+ return {
3571
+ sourceType,
3572
+ sourceId,
3573
+ title: String(chapter.title),
3574
+ content: String(chapter.content),
3575
+ version: String(chapter.versionNo)
3576
+ };
3577
+ }
3578
+ catch {
3579
+ return null;
3580
+ }
3581
+ }
3582
+ const source = this.relationshipSettingSource(workId, sourceType, sourceId);
3583
+ return source ? {
3584
+ sourceType,
3585
+ sourceId,
3586
+ title: source.title,
3587
+ content: source.content,
3588
+ version: source.version
3589
+ } : null;
3590
+ }
3591
+ relationshipIndexedSourceKey(sourceType, sourceId) {
3592
+ return `${sourceType}:${sourceId}`;
3593
+ }
3594
+ relationshipIndexedSourceRef(key) {
3595
+ const separator = key.indexOf(":");
3596
+ return separator < 0
3597
+ ? { sourceType: "setting", sourceId: key }
3598
+ : { sourceType: key.slice(0, separator), sourceId: key.slice(separator + 1) };
3599
+ }
3600
+ relationshipChapterExactMatches(workId, reference) {
3601
+ const normalized = normalizeRelationshipSearchText(reference).trim();
3602
+ if (!normalized)
3603
+ return [];
3604
+ const rows = [...normalized].length < 3
3605
+ ? this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_short_terms term
3606
+ JOIN chapter_paragraph_search paragraph ON paragraph.id = term.paragraph_id
3607
+ WHERE paragraph.work_id = ? AND term.term = ?`, workId, normalized)
3608
+ : this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_search_fts
3609
+ JOIN chapter_paragraph_search paragraph ON paragraph.id = chapter_paragraph_search_fts.rowid
3610
+ WHERE paragraph.work_id = ? AND chapter_paragraph_search_fts MATCH ?`, workId, `"${normalized.replaceAll('"', '""')}"`);
3611
+ return rows.map((row) => String(row.chapter_id));
3612
+ }
3613
+ relationshipSettingExactMatches(workId, reference) {
3614
+ const phrase = ftsPhrase(relationshipCharacterTokens(reference));
3615
+ return this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_exact_fts
3616
+ JOIN relationship_source_search source ON source.id = relationship_source_exact_fts.rowid
3617
+ WHERE source.work_id = ? AND relationship_source_exact_fts MATCH ?
3618
+ AND NOT (source.source_type = 'review' AND EXISTS (
3619
+ SELECT 1 FROM review_items review
3620
+ WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
3621
+ ))`, workId, phrase).map((row) => this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
3622
+ }
3623
+ relationshipFuzzyIndexMatches(workId, reference, includeSettings, scope) {
3624
+ const result = new Set();
3625
+ const characterTokens = [...new Set(relationshipCharacterTokens(reference))];
3626
+ const pinyinTokens = [...new Set(relationshipPinyinTokens(reference))];
3627
+ const score = new Map();
3628
+ const add = (key) => {
3629
+ score.set(key, (score.get(key) ?? 0) + 1);
3630
+ };
3631
+ const chapterScope = scope.type === "chapter"
3632
+ ? { sql: "AND paragraph.chapter_id = ?", params: [scope.chapterId ?? ""] }
3633
+ : scope.type === "volume"
3634
+ ? {
3635
+ sql: `AND EXISTS (
3636
+ SELECT 1 FROM chapters chapter WHERE chapter.id = paragraph.chapter_id
3637
+ AND chapter.volume_id = ? AND chapter.excluded_from_analysis = 0 AND chapter.chapter_type <> '作者的话'
3638
+ )`,
3639
+ params: [scope.volumeId ?? ""]
3640
+ }
3641
+ : {
3642
+ sql: `AND EXISTS (
3643
+ SELECT 1 FROM chapters chapter WHERE chapter.id = paragraph.chapter_id
3644
+ AND chapter.excluded_from_analysis = 0 AND chapter.chapter_type <> '作者的话'
3645
+ )`,
3646
+ params: []
3647
+ };
3648
+ const includeChapters = scope.type !== "settings";
3649
+ const pinyinPhrase = ftsPhrase(relationshipPinyinTokens(reference));
3650
+ if (includeChapters) {
3651
+ for (const row of this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_pinyin_fts
3652
+ JOIN chapter_paragraph_search paragraph ON paragraph.id = chapter_paragraph_pinyin_fts.rowid
3653
+ WHERE paragraph.work_id = ? AND chapter_paragraph_pinyin_fts MATCH ? ${chapterScope.sql}
3654
+ LIMIT 201`, workId, pinyinPhrase, ...chapterScope.params))
3655
+ result.add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
3656
+ }
3657
+ if (includeSettings) {
3658
+ for (const row of this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_pinyin_fts
3659
+ JOIN relationship_source_search source ON source.id = relationship_source_pinyin_fts.rowid
3660
+ WHERE source.work_id = ? AND relationship_source_pinyin_fts MATCH ?
3661
+ AND NOT (source.source_type = 'review' AND EXISTS (
3662
+ SELECT 1 FROM review_items review
3663
+ WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
3664
+ ))
3665
+ LIMIT 201`, workId, pinyinPhrase))
3666
+ result.add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
3667
+ }
3668
+ const normalizedCharacters = [...normalizeRelationshipSearchText(reference).trim()];
3669
+ if (includeChapters) {
3670
+ for (const character of [...new Set(normalizedCharacters)]) {
3671
+ for (const row of this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_short_terms term
3672
+ JOIN chapter_paragraph_search paragraph ON paragraph.id = term.paragraph_id
3673
+ WHERE paragraph.work_id = ? AND term.term = ? ${chapterScope.sql}
3674
+ LIMIT 201`, workId, character, ...chapterScope.params))
3675
+ add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
3676
+ }
3677
+ for (const token of pinyinTokens) {
3678
+ for (const row of this.store.db.all(`SELECT DISTINCT paragraph.chapter_id FROM chapter_paragraph_pinyin_fts
3679
+ JOIN chapter_paragraph_search paragraph ON paragraph.id = chapter_paragraph_pinyin_fts.rowid
3680
+ WHERE paragraph.work_id = ? AND chapter_paragraph_pinyin_fts MATCH ? ${chapterScope.sql}
3681
+ LIMIT 201`, workId, token, ...chapterScope.params))
3682
+ add(this.relationshipIndexedSourceKey("chapter", String(row.chapter_id)));
3683
+ }
3684
+ }
3685
+ if (includeSettings) {
3686
+ for (const token of characterTokens) {
3687
+ for (const row of this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_exact_fts
3688
+ JOIN relationship_source_search source ON source.id = relationship_source_exact_fts.rowid
3689
+ WHERE source.work_id = ? AND relationship_source_exact_fts MATCH ?
3690
+ AND NOT (source.source_type = 'review' AND EXISTS (
3691
+ SELECT 1 FROM review_items review
3692
+ WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
3693
+ ))
3694
+ LIMIT 201`, workId, token))
3695
+ add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
3696
+ }
3697
+ for (const token of pinyinTokens) {
3698
+ for (const row of this.store.db.all(`SELECT source.source_type, source.source_id FROM relationship_source_pinyin_fts
3699
+ JOIN relationship_source_search source ON source.id = relationship_source_pinyin_fts.rowid
3700
+ WHERE source.work_id = ? AND relationship_source_pinyin_fts MATCH ?
3701
+ AND NOT (source.source_type = 'review' AND EXISTS (
3702
+ SELECT 1 FROM review_items review
3703
+ WHERE review.id = source.source_id AND review.item_type = 'character-name-variant'
3704
+ ))
3705
+ LIMIT 201`, workId, token))
3706
+ add(this.relationshipIndexedSourceKey(String(row.source_type), String(row.source_id)));
3707
+ }
3708
+ }
3709
+ const threshold = Math.max(1, [...normalizeRelationshipSearchText(reference).trim()].length - 1);
3710
+ for (const [key, count] of score)
3711
+ if (count >= threshold)
3712
+ result.add(key);
3713
+ return result;
3714
+ }
3715
+ relationshipIdentityAnchors(workId, character) {
3716
+ const characterId = String(character.id);
3717
+ const relatedIds = new Set();
3718
+ for (const relationship of this.store.listRelationships(workId)) {
3719
+ if (String(relationship.fromCharacterId) === characterId)
3720
+ relatedIds.add(String(relationship.toCharacterId));
3721
+ if (String(relationship.toCharacterId) === characterId)
3722
+ relatedIds.add(String(relationship.fromCharacterId));
3723
+ }
3724
+ const anchors = [
3725
+ String(character.code ?? ""),
3726
+ String(character.species ?? ""),
3727
+ String(character.race?.name ?? ""),
3728
+ ...(Array.isArray(character.organizations) ? character.organizations.map((item) => String(item.name ?? "")) : []),
3729
+ ...[...relatedIds].flatMap((relatedId) => {
3730
+ try {
3731
+ const related = this.store.getCharacter(relatedId);
3732
+ return [String(related.name), ...related.aliases];
3733
+ }
3734
+ catch {
3735
+ return [];
3736
+ }
3737
+ })
3738
+ ].map((value) => normalizeRelationshipSearchText(value).trim())
3739
+ .filter((value) => [...value].length >= 2);
3740
+ return [...new Set(anchors)];
3741
+ }
3742
+ async localRelationshipSourceSelection(workId, scope, characters, selectedCharacterIds, generation) {
3743
+ const targetCharacters = characters.filter((character) => selectedCharacterIds.has(String(character.id)));
3744
+ const cacheKey = JSON.stringify({
3745
+ workId,
3746
+ scope: {
3747
+ type: scope.type,
3748
+ chapterId: scope.chapterId ?? null,
3749
+ volumeId: scope.volumeId ?? null,
3750
+ includeAllSettings: scope.includeAllSettings === true
3751
+ },
3752
+ targets: targetCharacters.map((character) => ({ id: character.id, versionNo: character.versionNo })),
3753
+ generation,
3754
+ policyVersion: RELATIONSHIP_SEARCH_POLICY_VERSION
3755
+ });
3756
+ const cached = this.relationshipSelectionCache.get(cacheKey);
3757
+ if (cached)
3758
+ return cached;
3759
+ const existingBuild = this.relationshipSelectionBuilds.get(cacheKey);
3760
+ if (existingBuild)
3761
+ return existingBuild;
3762
+ const build = (async () => {
3763
+ const allowedChapterIds = this.relationshipScopeChapterIds(workId, scope);
3764
+ const includeSettings = scope.type === "settings" || scope.includeAllSettings === true;
3765
+ const exactKeys = new Set();
3766
+ const candidates = [];
3767
+ const candidateKeys = new Set();
3768
+ const candidateOccurrences = new Map();
3769
+ const loadedSources = new Map();
3770
+ const knownCharacterReferences = new Set(characters.flatMap((character) => [
3771
+ String(character.name),
3772
+ ...(Array.isArray(character.aliases) ? character.aliases.map(String) : [])
3773
+ ]).map((value) => normalizeRelationshipSearchText(value).trim()).filter(Boolean));
3774
+ for (const character of targetCharacters) {
3775
+ const targetCharacterId = String(character.id);
3776
+ const exactReferences = [...new Set([String(character.name), ...character.aliases].map((item) => item.trim()).filter(Boolean))];
3777
+ const fuzzyReferenceCount = exactReferences.filter((reference) => [...normalizeRelationshipSearchText(reference).trim()].length >= 2).length;
3778
+ if (fuzzyReferenceCount > RELATIONSHIP_MAX_FUZZY_REFERENCES) {
3779
+ throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "人物名称和别名过多,无法在安全预算内完成疑似写法匹配", {
3780
+ characterId: targetCharacterId,
3781
+ fuzzyReferenceCount,
3782
+ maximumFuzzyReferences: RELATIONSHIP_MAX_FUZZY_REFERENCES
3783
+ });
3784
+ }
3785
+ const normalizedExactReferences = new Set(exactReferences.map((item) => normalizeRelationshipSearchText(item).trim()));
3786
+ const anchors = this.relationshipIdentityAnchors(workId, character);
3787
+ const anchorKeys = new Set();
3788
+ for (const anchor of anchors) {
3789
+ for (const chapterId of this.relationshipChapterExactMatches(workId, anchor)) {
3790
+ if (allowedChapterIds.has(chapterId))
3791
+ anchorKeys.add(this.relationshipIndexedSourceKey("chapter", chapterId));
3792
+ }
3793
+ if (includeSettings)
3794
+ for (const key of this.relationshipSettingExactMatches(workId, anchor))
3795
+ anchorKeys.add(key);
3796
+ }
3797
+ const targetIndexCandidateKeys = new Set();
3798
+ const targetFuzzySourceKeys = new Set();
3799
+ let fuzzyScanCharacters = 0;
3800
+ let fuzzyMatchCount = 0;
3801
+ for (const reference of exactReferences) {
3802
+ for (const chapterId of this.relationshipChapterExactMatches(workId, reference)) {
3803
+ if (allowedChapterIds.has(chapterId))
3804
+ exactKeys.add(this.relationshipIndexedSourceKey("chapter", chapterId));
3805
+ }
3806
+ if (includeSettings)
3807
+ for (const key of this.relationshipSettingExactMatches(workId, reference))
3808
+ exactKeys.add(key);
3809
+ const referenceLength = [...normalizeRelationshipSearchText(reference).trim()].length;
3810
+ if (referenceLength < 2)
3811
+ continue;
3812
+ const fuzzyIndexKeys = referenceLength === 2
3813
+ ? anchorKeys
3814
+ : this.relationshipFuzzyIndexMatches(workId, reference, includeSettings, scope);
3815
+ for (const key of fuzzyIndexKeys) {
3816
+ const ref = this.relationshipIndexedSourceRef(key);
3817
+ if (ref.sourceType === "chapter" && !allowedChapterIds.has(ref.sourceId))
3818
+ continue;
3819
+ if (ref.sourceType !== "chapter" && !includeSettings)
3820
+ continue;
3821
+ targetIndexCandidateKeys.add(key);
3822
+ if (targetIndexCandidateKeys.size > RELATIONSHIP_MAX_FUZZY_SOURCES) {
3823
+ throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名来源过多,请补充人物别名或身份资料后重试", {
3824
+ characterId: targetCharacterId,
3825
+ candidateCount: targetIndexCandidateKeys.size,
3826
+ maximum: RELATIONSHIP_MAX_FUZZY_SOURCES
3827
+ });
3828
+ }
3829
+ let indexed = loadedSources.get(key);
3830
+ if (!indexed) {
3831
+ const loaded = this.relationshipIndexedSource(workId, ref.sourceType, ref.sourceId);
3832
+ if (!loaded)
3833
+ continue;
3834
+ indexed = loaded;
3835
+ loadedSources.set(key, indexed);
3836
+ }
3837
+ if (indexed.sourceType === "review" && indexed.content.includes('"itemType": "character-name-variant"'))
3838
+ continue;
3839
+ const searchable = `${indexed.title}\n${indexed.content}`;
3840
+ const normalizedSearchable = normalizeRelationshipSearchText(searchable);
3841
+ fuzzyScanCharacters += normalizedSearchable.length;
3842
+ if (fuzzyScanCharacters > RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS) {
3843
+ throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名待核对文本过多,请缩小分析范围或补充人物别名", {
3844
+ characterId: targetCharacterId,
3845
+ scannedCharacters: fuzzyScanCharacters,
3846
+ maximumScannedCharacters: RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS
3847
+ });
3848
+ }
3849
+ const referenceCharacters = [...normalizeRelationshipSearchText(reference).trim()];
3850
+ let approximateMatches;
3851
+ try {
3852
+ approximateMatches = await findApproximateNameMatchesChunked(searchable, reference, 24, knownCharacterReferences, RELATIONSHIP_MAX_SOURCE_MATCHES);
3853
+ }
3854
+ catch (error) {
3855
+ if (!(error instanceof RelationshipApproximateMatchLimitError))
3856
+ throw error;
3857
+ throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "单个来源中的疑似人物名写法过多,请缩小分析范围或补充人物别名", {
3858
+ characterId: targetCharacterId,
3859
+ sourceType: indexed.sourceType,
3860
+ sourceId: indexed.sourceId,
3861
+ maximumSourceMatches: error.maximumCandidates
3862
+ });
3863
+ }
3864
+ for (const match of approximateMatches) {
3865
+ if (normalizedExactReferences.has(normalizeRelationshipSearchText(match.observed).trim()))
3866
+ continue;
3867
+ if (referenceLength === 2
3868
+ && !anchors.some((anchor) => normalizedSearchable.includes(anchor)))
3869
+ continue;
3870
+ const occurrenceKey = [targetCharacterId, indexed.sourceType, indexed.sourceId, match.observed].join("|");
3871
+ const occurrenceCount = candidateOccurrences.get(occurrenceKey) ?? 0;
3872
+ if (occurrenceCount >= 3)
3873
+ continue;
3874
+ const candidateKey = [targetCharacterId, indexed.sourceType, indexed.sourceId, match.observed, reference, match.start].join("|");
3875
+ if (candidateKeys.has(candidateKey))
3876
+ continue;
3877
+ candidateKeys.add(candidateKey);
3878
+ candidateOccurrences.set(occurrenceKey, occurrenceCount + 1);
3879
+ fuzzyMatchCount += 1;
3880
+ if (fuzzyMatchCount > RELATIONSHIP_MAX_FUZZY_MATCHES) {
3881
+ throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名写法过多,请缩小分析范围或补充人物别名", {
3882
+ characterId: targetCharacterId,
3883
+ fuzzyMatchCount,
3884
+ maximumFuzzyMatches: RELATIONSHIP_MAX_FUZZY_MATCHES
3885
+ });
3886
+ }
3887
+ targetFuzzySourceKeys.add(key);
3888
+ const snippetStart = Math.max(0, match.utf16Start - 240);
3889
+ const snippetEnd = Math.min(normalizedSearchable.length, match.utf16End + 240);
3890
+ candidates.push({
3891
+ key: candidateKey,
3892
+ targetCharacterId,
3893
+ targetName: String(character.name),
3894
+ reference,
3895
+ sourceType: indexed.sourceType,
3896
+ sourceId: indexed.sourceId,
3897
+ sourceTitle: indexed.title,
3898
+ sourceVersion: indexed.version,
3899
+ observed: match.observed,
3900
+ snippet: normalizedSearchable.slice(snippetStart, snippetEnd),
3901
+ characterDistance: match.characterDistance,
3902
+ pinyinDistance: match.pinyinDistance
3903
+ });
3904
+ }
3905
+ }
3906
+ }
3907
+ if (targetFuzzySourceKeys.size > RELATIONSHIP_MAX_FUZZY_SOURCES) {
3908
+ throw new AppError(409, "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED", "疑似人物名来源过多,请补充人物别名或身份资料后重试", {
3909
+ characterId: targetCharacterId,
3910
+ candidateCount: targetFuzzySourceKeys.size,
3911
+ maximum: RELATIONSHIP_MAX_FUZZY_SOURCES
3912
+ });
3913
+ }
3914
+ }
3915
+ const result = { generation, exactKeys: [...exactKeys], candidates };
3916
+ this.relationshipSelectionCache.set(cacheKey, result);
3917
+ if (this.relationshipSelectionCache.size > 128) {
3918
+ const oldest = this.relationshipSelectionCache.keys().next().value;
3919
+ if (typeof oldest === "string")
3920
+ this.relationshipSelectionCache.delete(oldest);
3921
+ }
3922
+ return result;
3923
+ })();
3924
+ this.relationshipSelectionBuilds.set(cacheKey, build);
3925
+ try {
3926
+ return await build;
3927
+ }
3928
+ finally {
3929
+ if (this.relationshipSelectionBuilds.get(cacheKey) === build)
3930
+ this.relationshipSelectionBuilds.delete(cacheKey);
3931
+ }
3932
+ }
3933
+ async verifyRelationshipVariantCandidates(workId, candidates, modelId, taskId) {
3934
+ if (candidates.length === 0)
3935
+ return { decisions: [], callIds: [] };
3936
+ const batches = [];
3937
+ let batch = [];
3938
+ let batchLength = 0;
3939
+ for (const candidate of candidates) {
3940
+ const length = JSON.stringify(candidate).length;
3941
+ if (batch.length > 0 && batchLength + length > 12_000) {
3942
+ batches.push(batch);
3943
+ batch = [];
3944
+ batchLength = 0;
3945
+ }
3946
+ batch.push(candidate);
3947
+ batchLength += length;
3948
+ }
3949
+ if (batch.length > 0)
3950
+ batches.push(batch);
3951
+ const decisions = [];
3952
+ const callIds = [];
3953
+ try {
3954
+ for (const candidateBatch of batches) {
3955
+ const snippets = candidateBatch.map((candidate) => {
3956
+ const tag = candidate.sourceType === "chapter" ? "CHAPTER" : "SETTING";
3957
+ return [
3958
+ `<${tag} id="${candidate.sourceId.replaceAll('"', "'")}" title="${candidate.sourceTitle.replaceAll('"', "'")}">`,
3959
+ JSON.stringify({
3960
+ key: candidate.key,
3961
+ targetCharacterId: candidate.targetCharacterId,
3962
+ targetName: candidate.targetName,
3963
+ registeredReference: candidate.reference,
3964
+ observed: candidate.observed,
3965
+ characterDistance: candidate.characterDistance,
3966
+ pinyinDistance: candidate.pinyinDistance,
3967
+ snippet: candidate.snippet
3968
+ }),
3969
+ `</${tag}>`
3970
+ ].join("\n");
3971
+ }).join("\n");
3972
+ const generated = await this.generateTaggedJson({
3973
+ workId,
3974
+ taskId,
3975
+ taskType: "relationship-analysis",
3976
+ signal: this.taskSignal(taskId),
3977
+ maxAttempts: 2,
3978
+ scope: { type: "selection", selection: snippets, suppressAutomaticContext: true },
3979
+ ...(modelId ? { modelId } : {}),
3980
+ parameters: { temperature: 0.1 },
3981
+ instruction: [
3982
+ "你是人物名称变体确认器。判断每个片段中的 observed 是否指向对应 targetName,而不是另一个人物、普通词语或无法判断的对象。",
3983
+ "只依据每个候选附带的局部片段判断,禁止使用未提供的正文或设定。",
3984
+ "必须为每个 key 恰好输出一次结果,不得遗漏、重复或新增 key。",
3985
+ "verdict 只能是 same、separate、uncertain;confidence 是 0 到 1;reason 使用简短中文说明片段内依据。",
3986
+ "拼音相同或字形相近只能说明疑似,不能单独作为 same 的依据。上下文不能可靠确认时必须输出 uncertain。",
3987
+ "输出 JSON 数组,字段为 key、verdict、confidence、reason。"
3988
+ ].join("\n")
3989
+ });
3990
+ callIds.push(generated.callId);
3991
+ const extracted = extractJson(generated.content);
3992
+ if (!Array.isArray(extracted))
3993
+ throw new Error("variant verification result is not an array");
3994
+ const byKey = new Map(candidateBatch.map((candidate) => [candidate.key, candidate]));
3995
+ const seen = new Set();
3996
+ for (const item of extracted) {
3997
+ if (!item || typeof item !== "object" || Array.isArray(item))
3998
+ throw new Error("variant verification item is invalid");
3999
+ const value = item;
4000
+ const key = typeof value.key === "string" ? value.key : "";
4001
+ const verdict = value.verdict;
4002
+ const confidence = Number(value.confidence);
4003
+ const reason = typeof value.reason === "string" ? value.reason.trim() : "";
4004
+ const candidate = byKey.get(key);
4005
+ if (!candidate || seen.has(key) || !["same", "separate", "uncertain"].includes(String(verdict))
4006
+ || !Number.isFinite(confidence) || confidence < 0 || confidence > 1 || !reason) {
4007
+ throw new Error("variant verification item is incomplete");
4008
+ }
4009
+ seen.add(key);
4010
+ decisions.push({
4011
+ ...candidate,
4012
+ verdict: verdict,
4013
+ confidence,
4014
+ reason
4015
+ });
4016
+ }
4017
+ if (seen.size !== candidateBatch.length)
4018
+ throw new Error("variant verification result omitted candidates");
4019
+ }
4020
+ }
4021
+ catch (error) {
4022
+ if (error instanceof AppError && error.code === "TASK_CANCELLED")
4023
+ throw error;
4024
+ throw new AppError(502, "RELATIONSHIP_VARIANT_VERIFICATION_FAILED", "疑似人物名身份确认失败,未写入人物关系", {
4025
+ candidateCount: candidates.length,
4026
+ completedCallCount: callIds.length
4027
+ });
4028
+ }
4029
+ return { decisions, callIds };
4030
+ }
4031
+ async selectRelationshipSources(workId, scope, characters, selectedCharacterIds, modelId, taskId) {
4032
+ let generation;
4033
+ try {
4034
+ generation = await this.ensureRelationshipSearchIndex(workId);
4035
+ }
4036
+ catch {
4037
+ throw new AppError(503, "RELATIONSHIP_INDEX_BUILD_FAILED", "人物关系来源索引构建失败,请稍后重试");
4038
+ }
4039
+ const local = await this.localRelationshipSourceSelection(workId, scope, characters, selectedCharacterIds, generation);
4040
+ const verified = await this.verifyRelationshipVariantCandidates(workId, local.candidates, modelId, taskId);
4041
+ const accepted = verified.decisions.filter((decision) => decision.verdict === "same" && decision.confidence >= 0.8);
4042
+ const selectedKeys = new Set([...local.exactKeys, ...accepted.map((decision) => this.relationshipIndexedSourceKey(decision.sourceType, decision.sourceId))]);
4043
+ const chapters = [];
4044
+ const settings = [];
4045
+ for (const key of selectedKeys) {
4046
+ const ref = this.relationshipIndexedSourceRef(key);
4047
+ if (ref.sourceType === "chapter") {
4048
+ const source = this.relationshipIndexedSource(workId, ref.sourceType, ref.sourceId);
4049
+ if (source)
4050
+ chapters.push({
4051
+ id: source.sourceId,
4052
+ workId,
4053
+ title: source.title,
4054
+ content: collapseAiBlankLines(source.content),
4055
+ versionNo: Number(source.version)
4056
+ });
4057
+ }
4058
+ else {
4059
+ const source = this.relationshipSettingSource(workId, ref.sourceType, ref.sourceId);
4060
+ if (source)
4061
+ settings.push(source);
4062
+ }
4063
+ }
4064
+ const chapterOrder = new Map(this.store.db.all(`SELECT chapter.id FROM chapters chapter JOIN volumes volume ON volume.id = chapter.volume_id
4065
+ WHERE chapter.work_id = ? ORDER BY volume.sort_order, chapter.sort_order`, workId).map((row, index) => [String(row.id), index]));
4066
+ chapters.sort((left, right) => (chapterOrder.get(String(left.id)) ?? Number.MAX_SAFE_INTEGER) - (chapterOrder.get(String(right.id)) ?? Number.MAX_SAFE_INTEGER));
4067
+ settings.sort((left, right) => `${left.sourceType}:${left.id}`.localeCompare(`${right.sourceType}:${right.id}`, "zh-CN"));
4068
+ return {
4069
+ generation,
4070
+ chapters,
4071
+ settings,
4072
+ variantDecisions: verified.decisions,
4073
+ verificationCallIds: verified.callIds,
4074
+ summary: {
4075
+ policyVersion: RELATIONSHIP_SEARCH_POLICY_VERSION,
4076
+ indexGeneration: generation,
4077
+ exactSourceCount: new Set(local.exactKeys).size,
4078
+ fuzzyCandidateCount: local.candidates.length,
4079
+ confirmedSourceCount: new Set(accepted.map((decision) => this.relationshipIndexedSourceKey(decision.sourceType, decision.sourceId))).size,
4080
+ rejectedSourceCount: verified.decisions.filter((decision) => decision.verdict === "separate").length,
4081
+ uncertainSourceCount: verified.decisions.filter((decision) => decision.verdict === "uncertain" || (decision.verdict === "same" && decision.confidence < 0.8)).length,
4082
+ reviewIds: []
4083
+ }
4084
+ };
4085
+ }
4086
+ relationshipSettingSource(workId, sourceType, sourceId) {
3302
4087
  const cleanStrings = (value) => {
3303
4088
  if (typeof value === "string")
3304
4089
  return collapseAiBlankLines(value);
@@ -3309,123 +4094,175 @@ export class AiManager {
3309
4094
  return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cleanStrings(item)]));
3310
4095
  };
3311
4096
  const serialize = (value) => JSON.stringify(cleanStrings(value), null, 2);
3312
- const source = (sourceType, sourceId, title, value) => ({
3313
- id: sourceType === "setting" ? String(sourceId) : `${sourceType}:${String(sourceId)}`,
4097
+ const source = (title, value, version) => ({
4098
+ id: sourceType === "setting" ? sourceId : `${sourceType}:${sourceId}`,
3314
4099
  title,
3315
4100
  sourceType,
3316
- content: serialize(value)
4101
+ content: serialize(value),
4102
+ version: String(version ?? "")
4103
+ });
4104
+ try {
4105
+ if (sourceType === "work") {
4106
+ const item = this.store.getWork(sourceId);
4107
+ if (String(item.id) !== workId)
4108
+ return null;
4109
+ return source(`作品资料:${String(item.title)}`, {
4110
+ title: item.title, author: item.author, description: item.description, language: item.language
4111
+ }, item.versionNo);
4112
+ }
4113
+ if (sourceType === "setting") {
4114
+ const item = this.store.getSetting(sourceId);
4115
+ if (String(item.workId) !== workId)
4116
+ return null;
4117
+ return source(String(item.title), {
4118
+ category: item.category, content: item.content, tags: item.tags, status: item.status, authorNote: item.authorNote
4119
+ }, item.versionNo ?? item.updatedAt);
4120
+ }
4121
+ if (sourceType === "character") {
4122
+ const item = this.store.getCharacter(sourceId);
4123
+ if (String(item.workId) !== workId)
4124
+ return null;
4125
+ return source(`人物档案:${String(item.name)}`, {
4126
+ name: item.name, aliases: item.aliases, code: item.code, species: item.species, race: item.race,
4127
+ organizations: item.organizations, attributes: item.attributes, profile: item.profile,
4128
+ currentState: item.currentState, lockedFields: item.lockedFields
4129
+ }, item.versionNo);
4130
+ }
4131
+ if (sourceType === "race") {
4132
+ const item = this.store.getRace(sourceId);
4133
+ if (String(item.workId) !== workId)
4134
+ return null;
4135
+ return source(`种族设定:${String(item.name)}`, {
4136
+ name: item.name, description: item.description, lineage: item.lineage, settings: item.settings,
4137
+ effectiveSettings: item.effectiveSettings, members: item.members
4138
+ }, item.versionNo);
4139
+ }
4140
+ if (sourceType === "organization") {
4141
+ const item = this.store.getOrganization(sourceId);
4142
+ if (String(item.workId) !== workId)
4143
+ return null;
4144
+ return source(`组织设定:${String(item.name)}`, {
4145
+ name: item.name, description: item.description, settings: item.settings, members: item.members
4146
+ }, item.versionNo);
4147
+ }
4148
+ if (sourceType === "timeline-track") {
4149
+ const item = this.store.getTimelineTrack(sourceId);
4150
+ if (String(item.workId) !== workId)
4151
+ return null;
4152
+ return source(`时间轴:${String(item.name)}`, { name: item.name, description: item.description }, item.versionNo);
4153
+ }
4154
+ if (sourceType === "timeline-event") {
4155
+ const item = this.store.getTimelineEvent(sourceId);
4156
+ if (String(item.workId) !== workId)
4157
+ return null;
4158
+ return source(`时间线事件:${String(item.name)}`, {
4159
+ name: item.name,
4160
+ description: item.description,
4161
+ eventType: item.eventType,
4162
+ timeLabel: item.timeLabel,
4163
+ participants: (Array.isArray(item.participantIds) ? item.participantIds : []).map((characterId) => {
4164
+ try {
4165
+ return { characterId, name: this.store.getCharacter(String(characterId)).name };
4166
+ }
4167
+ catch {
4168
+ return { characterId, name: "已删除角色" };
4169
+ }
4170
+ }),
4171
+ location: item.location,
4172
+ causes: item.causes,
4173
+ impactScope: item.impactScope,
4174
+ evidence: item.evidence,
4175
+ status: item.status
4176
+ }, item.versionNo);
4177
+ }
4178
+ if (sourceType === "relationship") {
4179
+ const item = this.store.getRelationship(sourceId);
4180
+ if (String(item.workId) !== workId)
4181
+ return null;
4182
+ const fromName = String(this.store.getCharacter(String(item.fromCharacterId)).name);
4183
+ const toName = String(this.store.getCharacter(String(item.toCharacterId)).name);
4184
+ return source(`人物关系:${fromName} / ${toName}`, {
4185
+ fromCharacter: { id: item.fromCharacterId, name: fromName },
4186
+ toCharacter: { id: item.toCharacterId, name: toName },
4187
+ category: item.category,
4188
+ subtype: item.subtype,
4189
+ keywords: item.keywords,
4190
+ directed: item.directed,
4191
+ currentStatus: item.currentStatus,
4192
+ timeRange: item.timeRange,
4193
+ confidence: item.confidence,
4194
+ evidence: item.evidence,
4195
+ confirmationStatus: item.confirmationStatus,
4196
+ locked: item.locked
4197
+ }, item.versionNo);
4198
+ }
4199
+ if (sourceType === "chapter-outline") {
4200
+ const item = this.store.getChapterOutline(sourceId);
4201
+ if (!item || String(item.workId) !== workId)
4202
+ return null;
4203
+ const volumeTitle = String(this.store.getVolume(String(item.volumeId)).title);
4204
+ return source(`章节大纲:${volumeTitle} / ${String(item.chapterTitle)}`, {
4205
+ chapterTitle: item.chapterTitle,
4206
+ volumeTitle,
4207
+ goal: item.goal,
4208
+ conflict: item.conflict,
4209
+ turningPoint: item.turningPoint,
4210
+ notes: item.notes,
4211
+ status: item.status
4212
+ }, item.versionNo ?? item.updatedAt);
4213
+ }
4214
+ if (sourceType === "foreshadow") {
4215
+ const item = this.store.getForeshadow(sourceId);
4216
+ if (String(item.workId) !== workId)
4217
+ return null;
4218
+ return source(`伏笔:${String(item.title)}`, {
4219
+ title: item.title,
4220
+ description: item.description,
4221
+ status: item.status,
4222
+ importance: item.importance,
4223
+ resolutionNote: item.resolutionNote,
4224
+ occurrences: item.occurrences
4225
+ }, item.versionNo);
4226
+ }
4227
+ if (sourceType === "review") {
4228
+ const item = this.store.getReviewItem(sourceId);
4229
+ if (String(item.workId) !== workId)
4230
+ return null;
4231
+ return source(`审核项:${String(item.title)}`, {
4232
+ itemType: item.itemType,
4233
+ severity: item.severity,
4234
+ title: item.title,
4235
+ description: item.description,
4236
+ evidence: item.evidence,
4237
+ suggestion: item.suggestion,
4238
+ status: item.status,
4239
+ resolutionNote: item.resolutionNote
4240
+ }, item.updatedAt);
4241
+ }
4242
+ }
4243
+ catch {
4244
+ return null;
4245
+ }
4246
+ return null;
4247
+ }
4248
+ relationshipSettingSources(workId, characters) {
4249
+ const refs = [
4250
+ ["work", workId],
4251
+ ...this.store.listSettings(workId).map((item) => ["setting", String(item.id)]),
4252
+ ...characters.map((item) => ["character", String(item.id)]),
4253
+ ...this.store.listRaces(workId).map((item) => ["race", String(item.id)]),
4254
+ ...this.store.listOrganizations(workId).map((item) => ["organization", String(item.id)]),
4255
+ ...this.store.listTimelineTracks(workId).map((item) => ["timeline-track", String(item.id)]),
4256
+ ...this.store.listTimelineEvents(workId).map((item) => ["timeline-event", String(item.id)]),
4257
+ ...this.store.listRelationships(workId).map((item) => ["relationship", String(item.id)]),
4258
+ ...this.store.listChapterOutlines(workId).map((item) => ["chapter-outline", String(item.chapterId)]),
4259
+ ...this.store.listForeshadows(workId).map((item) => ["foreshadow", String(item.id)]),
4260
+ ...this.store.listReviewItems(workId).map((item) => ["review", String(item.id)])
4261
+ ];
4262
+ return refs.flatMap(([sourceType, sourceId]) => {
4263
+ const materialized = this.relationshipSettingSource(workId, sourceType, sourceId);
4264
+ return materialized ? [materialized] : [];
3317
4265
  });
3318
- const work = this.store.getWork(workId);
3319
- const settings = this.store.listSettings(workId).map((item) => source("setting", item.id, String(item.title), {
3320
- category: item.category,
3321
- content: item.content,
3322
- tags: item.tags,
3323
- status: item.status,
3324
- authorNote: item.authorNote
3325
- }));
3326
- const characterSources = this.store.listCharacters(workId, true).map((item) => source("character", item.id, `人物档案:${String(item.name)}`, {
3327
- name: item.name,
3328
- aliases: item.aliases,
3329
- code: item.code,
3330
- species: item.species,
3331
- race: item.race,
3332
- organizations: item.organizations,
3333
- attributes: item.attributes,
3334
- profile: item.profile,
3335
- currentState: item.currentState,
3336
- lockedFields: item.lockedFields
3337
- }));
3338
- const races = this.store.listRaces(workId).map((item) => source("race", item.id, `种族设定:${String(item.name)}`, {
3339
- name: item.name,
3340
- description: item.description,
3341
- lineage: item.lineage,
3342
- settings: item.settings,
3343
- effectiveSettings: item.effectiveSettings,
3344
- members: item.members
3345
- }));
3346
- const organizations = this.store.listOrganizations(workId).map((item) => source("organization", item.id, `组织设定:${String(item.name)}`, {
3347
- name: item.name,
3348
- description: item.description,
3349
- settings: item.settings,
3350
- members: item.members
3351
- }));
3352
- const tracks = this.store.listTimelineTracks(workId).map((item) => source("timeline-track", item.id, `时间轴:${String(item.name)}`, {
3353
- name: item.name,
3354
- description: item.description
3355
- }));
3356
- const timeline = this.store.listTimelineEvents(workId).map((item) => source("timeline-event", item.id, `时间线事件:${String(item.name)}`, {
3357
- name: item.name,
3358
- description: item.description,
3359
- eventType: item.eventType,
3360
- timeLabel: item.timeLabel,
3361
- participants: (Array.isArray(item.participantIds) ? item.participantIds : []).map((characterId) => ({
3362
- characterId,
3363
- name: characterNameById.get(String(characterId)) ?? "已删除角色"
3364
- })),
3365
- location: item.location,
3366
- causes: item.causes,
3367
- impactScope: item.impactScope,
3368
- evidence: item.evidence,
3369
- status: item.status
3370
- }));
3371
- const relationships = this.store.listRelationships(workId).map((item) => source("relationship", item.id, `人物关系:${characterNameById.get(String(item.fromCharacterId)) ?? "已删除角色"} / ${characterNameById.get(String(item.toCharacterId)) ?? "已删除角色"}`, {
3372
- fromCharacter: { id: item.fromCharacterId, name: characterNameById.get(String(item.fromCharacterId)) ?? "已删除角色" },
3373
- toCharacter: { id: item.toCharacterId, name: characterNameById.get(String(item.toCharacterId)) ?? "已删除角色" },
3374
- category: item.category,
3375
- subtype: item.subtype,
3376
- keywords: item.keywords,
3377
- directed: item.directed,
3378
- currentStatus: item.currentStatus,
3379
- timeRange: item.timeRange,
3380
- confidence: item.confidence,
3381
- evidence: item.evidence,
3382
- confirmationStatus: item.confirmationStatus,
3383
- locked: item.locked
3384
- }));
3385
- const outlines = this.store.listChapterOutlines(workId).map((item) => source("chapter-outline", item.chapterId, `章节大纲:${String(item.volumeTitle)} / ${String(item.chapterTitle)}`, {
3386
- chapterTitle: item.chapterTitle,
3387
- volumeTitle: item.volumeTitle,
3388
- goal: item.goal,
3389
- conflict: item.conflict,
3390
- turningPoint: item.turningPoint,
3391
- notes: item.notes,
3392
- status: item.status
3393
- }));
3394
- const foreshadows = this.store.listForeshadows(workId).map((item) => source("foreshadow", item.id, `伏笔:${String(item.title)}`, {
3395
- title: item.title,
3396
- description: item.description,
3397
- status: item.status,
3398
- importance: item.importance,
3399
- resolutionNote: item.resolutionNote,
3400
- occurrences: item.occurrences
3401
- }));
3402
- const reviews = this.store.listReviewItems(workId).map((item) => source("review", item.id, `审核项:${String(item.title)}`, {
3403
- itemType: item.itemType,
3404
- severity: item.severity,
3405
- title: item.title,
3406
- description: item.description,
3407
- evidence: item.evidence,
3408
- suggestion: item.suggestion,
3409
- status: item.status,
3410
- resolutionNote: item.resolutionNote
3411
- }));
3412
- return [source("work", work.id, `作品资料:${String(work.title)}`, {
3413
- title: work.title,
3414
- author: work.author,
3415
- description: work.description,
3416
- language: work.language
3417
- }), ...settings, ...characterSources, ...races, ...organizations, ...tracks, ...timeline, ...relationships, ...outlines, ...foreshadows, ...reviews];
3418
- }
3419
- relationshipSearchKeywords(characters, selectedCharacterIds) {
3420
- return [...new Set(characters
3421
- .filter((character) => selectedCharacterIds.has(String(character.id)))
3422
- .flatMap((character) => [String(character.name), ...(character.aliases ?? []).map(String)])
3423
- .map((keyword) => keyword.normalize("NFKC").trim().toLocaleLowerCase("zh-CN"))
3424
- .filter(Boolean))];
3425
- }
3426
- relationshipSourceContainsKeyword(source, keywords) {
3427
- const searchable = `${String(source.title ?? "")}\n${String(source.content ?? "")}`.normalize("NFKC").toLocaleLowerCase("zh-CN");
3428
- return keywords.some((keyword) => searchable.includes(keyword));
3429
4266
  }
3430
4267
  async runRelationshipAnalysis(workId, scope, modelId, taskId) {
3431
4268
  const characters = this.store.listCharacters(workId);
@@ -3443,20 +4280,18 @@ export class AiManager {
3443
4280
  .filter((character) => selectedCharacterIds.has(String(character.id)))
3444
4281
  .map((character) => `${String(character.id)} | ${String(character.name)}`)
3445
4282
  .join("\n");
3446
- const searchKeywords = targeted ? this.relationshipSearchKeywords(characters, selectedCharacterIds) : [];
3447
- const scopedChapters = settingsOnly ? [] : this.getScopeChapters(workId, scope);
3448
- const chapters = targeted
3449
- ? scopedChapters.filter((chapter) => this.relationshipSourceContainsKeyword(chapter, searchKeywords))
3450
- : scopedChapters;
3451
- const availableSettings = settingsOnly || scope.includeAllSettings === true
4283
+ const sourceSelection = targeted
4284
+ ? await this.selectRelationshipSources(workId, scope, characters, selectedCharacterIds, modelId, taskId)
4285
+ : null;
4286
+ const scopedChapters = targeted ? [] : settingsOnly ? [] : this.getScopeChapters(workId, scope);
4287
+ const chapters = sourceSelection?.chapters ?? scopedChapters;
4288
+ const availableSettings = targeted ? [] : settingsOnly || scope.includeAllSettings === true
3452
4289
  ? this.relationshipSettingSources(workId, characters)
3453
4290
  : [];
3454
- const settings = targeted
3455
- ? availableSettings.filter((setting) => this.relationshipSourceContainsKeyword(setting, searchKeywords))
3456
- : availableSettings;
3457
- if (settingsOnly && availableSettings.length === 0)
4291
+ const settings = sourceSelection?.settings ?? availableSettings;
4292
+ if (!targeted && settingsOnly && availableSettings.length === 0)
3458
4293
  throw new AppError(409, "SETTINGS_REQUIRED", "人物关系分析范围内没有设定数据");
3459
- if (!settingsOnly && scopedChapters.length === 0 && availableSettings.length === 0) {
4294
+ if (!targeted && !settingsOnly && scopedChapters.length === 0 && availableSettings.length === 0) {
3460
4295
  throw new AppError(409, "RELATIONSHIP_SOURCES_REQUIRED", "人物关系分析范围内没有章节或设定数据");
3461
4296
  }
3462
4297
  const chunks = [
@@ -3478,7 +4313,8 @@ export class AiManager {
3478
4313
  targetedEvidenceCount: 0,
3479
4314
  aggregationBatchCount: 0,
3480
4315
  replacedRelationshipCount: 0,
3481
- callIds: []
4316
+ sourceSelection: sourceSelection?.summary,
4317
+ callIds: sourceSelection?.verificationCallIds ?? []
3482
4318
  };
3483
4319
  }
3484
4320
  const concurrency = this.configuredConcurrency(workId, "relationship-analysis", modelId);
@@ -3489,7 +4325,7 @@ export class AiManager {
3489
4325
  const rawCandidates = [];
3490
4326
  const chapterEvidenceCandidates = [];
3491
4327
  const settingCandidates = [];
3492
- const callIds = [];
4328
+ const callIds = [...(sourceSelection?.verificationCallIds ?? [])];
3493
4329
  const settingsInstruction = [
3494
4330
  "你是小说人物关系设定抽取器,不是续写者。只根据本批系统设定数据抽取角色规范表中人物之间被明确写出的长期关系。",
3495
4331
  ...(targeted ? ["被分析角色:", targetedRoster, "只输出至少一端属于被分析角色的关系。"] : []),
@@ -4079,6 +4915,52 @@ export class AiManager {
4079
4915
  if (taskId && settingsOnly)
4080
4916
  this.store.refreshTaskSourceVersions(taskId);
4081
4917
  });
4918
+ if (sourceSelection) {
4919
+ const acceptedVariants = sourceSelection.variantDecisions.filter((decision) => decision.verdict === "same" && decision.confidence >= 0.8);
4920
+ const reviewIds = new Set();
4921
+ this.store.db.transaction(() => {
4922
+ for (const decision of acceptedVariants) {
4923
+ const observedIndex = decision.snippet.indexOf(decision.observed);
4924
+ const quote = observedIndex < 0
4925
+ ? decision.snippet.slice(0, 160)
4926
+ : decision.snippet.slice(Math.max(0, observedIndex - 60), Math.min(decision.snippet.length, observedIndex + decision.observed.length + 60));
4927
+ const dedupeKey = this.store.hashContent([
4928
+ decision.targetCharacterId,
4929
+ normalizeRelationshipSearchText(decision.observed),
4930
+ decision.sourceType,
4931
+ decision.sourceId,
4932
+ decision.sourceVersion
4933
+ ].join("|"));
4934
+ const review = this.store.createReviewItem(workId, {
4935
+ itemType: "character-name-variant",
4936
+ dedupeKey,
4937
+ severity: "medium",
4938
+ title: `疑似人物名错字:${decision.observed} → ${decision.targetName}`,
4939
+ description: `AI 判断来源“${decision.sourceTitle}”中的“${decision.observed}”可能指向人物“${decision.targetName}”。`,
4940
+ entityRefs: [{
4941
+ characterId: decision.targetCharacterId,
4942
+ sourceType: decision.sourceType,
4943
+ sourceId: decision.sourceId,
4944
+ sourceVersion: decision.sourceVersion
4945
+ }],
4946
+ evidence: [{
4947
+ sourceType: decision.sourceType,
4948
+ sourceId: decision.sourceId,
4949
+ sourceTitle: decision.sourceTitle,
4950
+ sourceVersion: decision.sourceVersion,
4951
+ observed: decision.observed,
4952
+ quote,
4953
+ confidence: decision.confidence,
4954
+ reason: decision.reason
4955
+ }],
4956
+ suggestion: `请核对“${decision.observed}”是否为“${decision.targetName}”的错别字;确认后再修改原文或登记别名。`,
4957
+ status: "pending"
4958
+ });
4959
+ reviewIds.add(String(review.id));
4960
+ }
4961
+ });
4962
+ sourceSelection.summary.reviewIds = [...reviewIds];
4963
+ }
4082
4964
  const characterNameById = new Map(characters.map((character) => [String(character.id), String(character.name)]));
4083
4965
  const relationshipResults = [...relationshipOutcomes.values()].map(({ action, relationship }) => {
4084
4966
  const evidence = Array.isArray(relationship.evidence)
@@ -4159,6 +5041,7 @@ export class AiManager {
4159
5041
  targetedEvidenceCount,
4160
5042
  aggregationBatchCount,
4161
5043
  replacedRelationshipCount,
5044
+ ...(sourceSelection ? { sourceSelection: sourceSelection.summary } : {}),
4162
5045
  callIds
4163
5046
  };
4164
5047
  }
@@ -4645,6 +5528,17 @@ export class AiManager {
4645
5528
  this.assertAvailable(provider, model);
4646
5529
  return { model, provider };
4647
5530
  }
5531
+ analysisTaskModelPurpose(taskType) {
5532
+ if (taskType === "timeline-analysis")
5533
+ return "timeline-analysis";
5534
+ if (taskType === "relationship-analysis")
5535
+ return "relationship-analysis";
5536
+ if (taskType === "consistency-check")
5537
+ return "consistency-check";
5538
+ if (taskType === "chapter-analysis")
5539
+ return "chapter-analysis";
5540
+ return "book-analysis";
5541
+ }
4648
5542
  configuredConcurrency(workId, taskType, modelId) {
4649
5543
  const { provider } = this.resolveModel(workId, taskType, modelId);
4650
5544
  return Math.round(clamp(numberValue(provider, "concurrency_limit") || 10, 1, 100));
@@ -4805,6 +5699,7 @@ export class AiManager {
4805
5699
  scope: "platform",
4806
5700
  name: stringValue(row, "name"),
4807
5701
  baseUrl: stringValue(row, "base_url"),
5702
+ protocol: providerProtocol(row),
4808
5703
  apiKey: stringValue(row, "key_hint"),
4809
5704
  status: stringValue(row, "status"),
4810
5705
  connectionStatus: stringValue(row, "connection_status"),