@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/README.md +4 -3
- package/dist/ai-protocol.js +216 -0
- package/dist/ai-protocol.js.map +1 -0
- package/dist/ai.js +961 -130
- package/dist/ai.js.map +1 -1
- package/dist/app.js +126 -58
- package/dist/app.js.map +1 -1
- package/dist/collaboration-presence.js +18 -7
- package/dist/collaboration-presence.js.map +1 -1
- package/dist/database.js +85 -0
- package/dist/database.js.map +1 -1
- package/dist/public/ai-usage.d.ts +20 -0
- package/dist/public/ai-usage.js +75 -0
- package/dist/public/app.js +1107 -117
- package/dist/public/background-task-center.d.ts +28 -0
- package/dist/public/background-task-center.js +30 -0
- package/dist/public/display-labels.d.ts +1 -0
- package/dist/public/display-labels.js +7 -0
- package/dist/public/index.html +41 -4
- package/dist/public/page-route.js +2 -2
- package/dist/public/race-hierarchy.js +35 -0
- package/dist/public/styles.css +230 -10
- package/dist/public/theme-init.js +2 -2
- package/dist/relationship-search.js +8 -1
- package/dist/relationship-search.js.map +1 -1
- package/dist/store.js +135 -75
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +7 -2
- package/dist/user-auth.js.map +1 -1
- package/dist/utils.js +1 -1
- package/dist/utils.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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,
|
|
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
|
-
|
|
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,
|
|
996
|
-
this.store.audit(PLATFORM_AI_WORK_ID, "provider.created", "provider", providerId, { name: input.name, 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 &&
|
|
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 ?
|
|
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
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
-
|
|
1238
|
+
lastFailure = `HTTP ${response.status}: ${message.slice(0, 300)}`;
|
|
1239
|
+
if (response.status !== 404 || index === endpoints.length - 1)
|
|
1240
|
+
break;
|
|
1067
1241
|
}
|
|
1068
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
2098
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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:
|
|
2328
|
-
body: JSON.stringify(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
if (
|
|
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
|
|
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 =
|
|
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(
|
|
2944
|
+
throw new Error(`${protocolLabel} 流式响应缺少可用正文,finish_reason=${finishReason}`);
|
|
2462
2945
|
const cacheHitPercent = resolveCacheHitPercent(usage);
|
|
2463
|
-
|
|
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
|
-
|
|
3581
|
-
|
|
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
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
4198
|
-
const chapters = sourceSelection?.chapters ?? scopedChapters;
|
|
4199
|
-
const availableSettings =
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
4876
|
-
|
|
4877
|
-
|
|
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"),
|