@musnows/scriverse 0.7.3 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +3 -0
- package/README.md +3 -0
- package/dist/ai-connectivity-test.js +109 -0
- package/dist/ai-connectivity-test.js.map +1 -0
- package/dist/ai-conversation-export.js +70 -0
- package/dist/ai-conversation-export.js.map +1 -0
- package/dist/ai-stream-timeout.js +18 -0
- package/dist/ai-stream-timeout.js.map +1 -0
- package/dist/ai.js +681 -107
- package/dist/ai.js.map +1 -1
- package/dist/app.js +326 -53
- package/dist/app.js.map +1 -1
- package/dist/character-extraction.js +133 -0
- package/dist/character-extraction.js.map +1 -0
- package/dist/cli-core.js +7 -6
- package/dist/cli-core.js.map +1 -1
- package/dist/database.js +175 -2
- package/dist/database.js.map +1 -1
- package/dist/epub-export.js +319 -0
- package/dist/epub-export.js.map +1 -0
- package/dist/hybrid-search.js +8 -0
- package/dist/hybrid-search.js.map +1 -1
- package/dist/public/ai-connectivity-test.d.ts +7 -0
- package/dist/public/ai-connectivity-test.js +82 -0
- package/dist/public/ai-request-manager.js +99 -0
- package/dist/public/ai-stream-protocol.js +51 -0
- package/dist/public/app.js +2567 -265
- package/dist/public/chapter-version-diff.d.ts +20 -0
- package/dist/public/chapter-version-diff.js +116 -0
- package/dist/public/foreshadow-reminder.d.ts +32 -0
- package/dist/public/foreshadow-reminder.js +73 -0
- package/dist/public/global-replace-refresh.js +60 -0
- package/dist/public/index.html +120 -12
- package/dist/public/outline-board.d.ts +61 -0
- package/dist/public/outline-board.js +137 -0
- package/dist/public/page-route.d.ts +1 -0
- package/dist/public/page-route.js +8 -0
- package/dist/public/reading-preview.d.ts +32 -0
- package/dist/public/reading-preview.js +136 -0
- package/dist/public/styles.css +485 -4
- package/dist/public/upload-progress.d.ts +2 -0
- package/dist/public/upload-progress.js +10 -0
- package/dist/security.js +5 -2
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +2 -0
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +825 -88
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +45 -9
- package/dist/user-auth.js.map +1 -1
- package/dist/utils.js +3 -0
- package/dist/utils.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
package/dist/ai.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { ANALYSIS_TASK_TYPES, HISTORICAL_ANALYSIS_TASK_TYPES } from "./domain.js";
|
|
2
2
|
import { buildCompletionRequestBody, isAiProviderProtocol, normalizeProviderBaseUrl, parseCompletionPayload, providerCompletionEndpoint, providerModelEndpoints, providerProtocolLabelText, providerRequestHeaders } from "./ai-protocol.js";
|
|
3
3
|
import { AGENT_TOOL_RESULT_MAX_CHARS, DEFAULT_AGENT_TOOL_CALL_GLOBAL_MULTIPLIER, MIN_AGENT_TOOL_CALL_LIMIT, agentToolCallGlobalLimit, agentToolCallQuotaNoticeBudgetChars, agentToolCallQuotaUsedAfterCompact, agentToolCallSoftWarningThreshold, clampAgentToolCallGlobalMultiplier, paginateToolResultRecords, resolveMaxAgentToolCallLimit, shouldRejectAgentToolCalls, shouldRejectGlobalToolCalls, structuralToolResultRecords, withAgentToolCallQuotaNotice } from "./ai-tool-results.js";
|
|
4
|
+
import { AiConnectivityTestGate, hashAiConnectivityConfiguration } from "./ai-connectivity-test.js";
|
|
5
|
+
import { DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS } from "./ai-stream-timeout.js";
|
|
6
|
+
import { characterExtractionHash, characterExtractionSelectionFingerprint, editableCharacterExtractionCandidate, normalizeCharacterExtractionCandidate, parseStoredCharacterExtractionCandidates } from "./character-extraction.js";
|
|
4
7
|
import { PLATFORM_AI_WORK_ID } from "./database.js";
|
|
5
8
|
import { AppError, notFound } from "./errors.js";
|
|
6
9
|
import { assertOfficialGoogleVertexBaseUrl, fetchGoogleOAuthAccessToken, GoogleVertexTokenCache, maskServiceAccountHint, parseGoogleServiceAccount } from "./google-vertex-auth.js";
|
|
7
|
-
import { HYBRID_SEARCH_TYPES, buildHybridSearchSnippet, documentParagraphLineRange, fuseHybridSearchChannels } from "./hybrid-search.js";
|
|
10
|
+
import { HYBRID_SEARCH_TYPES, MAXIMUM_WORK_SEARCH_QUERY_LENGTH, buildHybridSearchSnippet, documentParagraphLineRange, fuseHybridSearchChannels, normalizeWorkSearchQuery } from "./hybrid-search.js";
|
|
8
11
|
import { logger, sanitizeError } from "./logger.js";
|
|
9
12
|
import { paginated, paginationSql } from "./pagination.js";
|
|
10
13
|
import { currentRequestActor } from "./request-context.js";
|
|
@@ -25,11 +28,83 @@ export function aiErrorForLog(error) {
|
|
|
25
28
|
return { name: sanitized.name ?? "Error", message: "Provider returned invalid JSON" };
|
|
26
29
|
return sanitized;
|
|
27
30
|
}
|
|
31
|
+
function connectivityTestErrorForLog(error) {
|
|
32
|
+
if (error instanceof AppError) {
|
|
33
|
+
return { category: "application_error", status: error.status, code: error.code };
|
|
34
|
+
}
|
|
35
|
+
if (!(error instanceof Error))
|
|
36
|
+
return { category: "upstream_failure" };
|
|
37
|
+
if (error.name === "AbortError")
|
|
38
|
+
return { category: "timeout" };
|
|
39
|
+
const httpStatus = error.message.match(/^HTTP ([1-5]\d{2})(?::|$)/u)?.[1];
|
|
40
|
+
if (httpStatus)
|
|
41
|
+
return { category: "upstream_http", status: Number(httpStatus) };
|
|
42
|
+
if (/无效 JSON|响应缺少可用回复|没有返回(?:模型列表|可用模型)/u.test(error.message)) {
|
|
43
|
+
return { category: "invalid_response" };
|
|
44
|
+
}
|
|
45
|
+
if (error instanceof TypeError)
|
|
46
|
+
return { category: "network_error" };
|
|
47
|
+
return { category: "upstream_failure" };
|
|
48
|
+
}
|
|
28
49
|
const AUTO_RUN_MAX_ATTEMPTS = 3;
|
|
29
50
|
const AUTO_RUN_RETRY_DELAYS_MS = [5_000, 30_000];
|
|
30
51
|
const AI_INTERACTIVE_TIMEOUT_MS = 60_000;
|
|
31
52
|
const AI_LONG_RUNNING_TIMEOUT_MS = 300_000;
|
|
32
53
|
const analysisTaskTypes = new Set(ANALYSIS_TASK_TYPES);
|
|
54
|
+
const interactiveStreamErrorCodes = new Set([
|
|
55
|
+
"AI_STREAM_IDLE_TIMEOUT",
|
|
56
|
+
"AI_STREAM_UPSTREAM_CLOSED",
|
|
57
|
+
"AI_STREAM_NETWORK_ERROR",
|
|
58
|
+
"AI_STREAM_REQUEST_CANCELLED"
|
|
59
|
+
]);
|
|
60
|
+
function isInteractiveStreamError(error) {
|
|
61
|
+
return error instanceof AppError && interactiveStreamErrorCodes.has(error.code);
|
|
62
|
+
}
|
|
63
|
+
function interactiveStreamRequestCancelledError() {
|
|
64
|
+
return new AppError(499, "AI_STREAM_REQUEST_CANCELLED", "AI 流式请求已取消");
|
|
65
|
+
}
|
|
66
|
+
class InteractiveStreamIdleWatchdog {
|
|
67
|
+
controller;
|
|
68
|
+
timeoutMs;
|
|
69
|
+
timer = null;
|
|
70
|
+
completed = false;
|
|
71
|
+
failure = null;
|
|
72
|
+
constructor(controller, timeoutMs) {
|
|
73
|
+
this.controller = controller;
|
|
74
|
+
this.timeoutMs = timeoutMs;
|
|
75
|
+
}
|
|
76
|
+
start() {
|
|
77
|
+
this.arm("first_event");
|
|
78
|
+
}
|
|
79
|
+
receivedEvent() {
|
|
80
|
+
this.arm("between_events");
|
|
81
|
+
}
|
|
82
|
+
complete() {
|
|
83
|
+
this.completed = true;
|
|
84
|
+
this.clear();
|
|
85
|
+
}
|
|
86
|
+
dispose() {
|
|
87
|
+
this.clear();
|
|
88
|
+
}
|
|
89
|
+
arm(phase) {
|
|
90
|
+
if (this.completed || this.failure)
|
|
91
|
+
return;
|
|
92
|
+
this.clear();
|
|
93
|
+
this.timer = setTimeout(() => {
|
|
94
|
+
const idleTimeoutSeconds = this.timeoutMs / 1_000;
|
|
95
|
+
this.failure = new AppError(504, "AI_STREAM_IDLE_TIMEOUT", phase === "first_event"
|
|
96
|
+
? `等待 AI 首个流事件超时(${idleTimeoutSeconds} 秒无新事件),流已关闭`
|
|
97
|
+
: `AI 流已因 ${idleTimeoutSeconds} 秒无新事件而关闭,已保留已生成内容`, { phase, idleTimeoutSeconds });
|
|
98
|
+
this.controller.abort(this.failure);
|
|
99
|
+
}, this.timeoutMs);
|
|
100
|
+
}
|
|
101
|
+
clear() {
|
|
102
|
+
if (!this.timer)
|
|
103
|
+
return;
|
|
104
|
+
clearTimeout(this.timer);
|
|
105
|
+
this.timer = null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
33
108
|
function isAnalysisTaskType(value) {
|
|
34
109
|
return analysisTaskTypes.has(value);
|
|
35
110
|
}
|
|
@@ -254,7 +329,7 @@ function redactProviderSecrets(value, secrets, depth = 0) {
|
|
|
254
329
|
return null;
|
|
255
330
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactProviderSecrets(item, list, depth + 1)]));
|
|
256
331
|
}
|
|
257
|
-
class ProviderSecretStreamRedactor {
|
|
332
|
+
export class ProviderSecretStreamRedactor {
|
|
258
333
|
pending = "";
|
|
259
334
|
secrets;
|
|
260
335
|
constructor(apiKey) {
|
|
@@ -275,10 +350,21 @@ class ProviderSecretStreamRedactor {
|
|
|
275
350
|
this.pending = retainedLength > 0 ? combined.slice(-retainedLength) : "";
|
|
276
351
|
return retainedLength > 0 ? combined.slice(0, -retainedLength) : combined;
|
|
277
352
|
}
|
|
278
|
-
flush() {
|
|
279
|
-
const
|
|
353
|
+
flush(options = {}) {
|
|
354
|
+
const pending = this.pending;
|
|
355
|
+
const value = redactProviderSecretsText(pending, ...this.secrets);
|
|
280
356
|
this.pending = "";
|
|
281
|
-
|
|
357
|
+
if (!options.interrupted || !pending)
|
|
358
|
+
return value;
|
|
359
|
+
const matchingSecrets = this.secrets.filter((secret) => secret.startsWith(pending));
|
|
360
|
+
if (matchingSecrets.length === 0)
|
|
361
|
+
return value;
|
|
362
|
+
const visiblePrefixLength = Math.min(...matchingSecrets.map((secret) => secret.length > 7 ? 4 : 0));
|
|
363
|
+
if (pending.length <= visiblePrefixLength)
|
|
364
|
+
return value;
|
|
365
|
+
if (visiblePrefixLength === 0)
|
|
366
|
+
return "********";
|
|
367
|
+
return `${pending.slice(0, visiblePrefixLength)}*****`;
|
|
282
368
|
}
|
|
283
369
|
}
|
|
284
370
|
function sanitizeCompletionTraceResponse(value) {
|
|
@@ -338,7 +424,7 @@ const grepArguments = z.object({
|
|
|
338
424
|
cursor: agentToolCursor
|
|
339
425
|
}).strict();
|
|
340
426
|
const searchStoryEntitiesArguments = z.object({
|
|
341
|
-
query: z.string().trim().min(1).max(
|
|
427
|
+
query: z.string().trim().min(1).max(MAXIMUM_WORK_SEARCH_QUERY_LENGTH),
|
|
342
428
|
categories: z.array(z.enum(["setting", "character", "race", "organization", "timeline", "relationship", "outline", "foreshadow"])).max(8).default([]),
|
|
343
429
|
limit: z.number().int().min(1).max(30).default(30),
|
|
344
430
|
cursor: agentToolCursor
|
|
@@ -403,7 +489,7 @@ const AGENT_TOOL_DEFINITIONS = {
|
|
|
403
489
|
function: {
|
|
404
490
|
name: "search_story_entities",
|
|
405
491
|
description: "按短关键词在结构化作品实体中进行元数据、精确全文和拼音混合检索:设定、人物(含 Markdown 档案章节)、种族、组织、时间线、关系、大纲和伏笔。人物、种族、组织结果分别包含权威布尔状态 isDead、isExtinct、isDissolved;只有值为 true 才能判定该角色已死亡、该种族已灭绝或该组织已解散,字段为 false 时必须视为仍存活、未灭绝或未解散,禁止根据正文情节自行改判。不是语义问答;请传入实体名、别名、标题、拼音或短关键词,不要传入自然语言整句。结果按综合相关度排序;人物结果含 sectionId 时可再调用 read_character_sections 精读。无匹配时改用更短关键词,或改用 story_index / grep。",
|
|
406
|
-
parameters: { type: "object", properties: { query: { type: "string", minLength: 1, maxLength:
|
|
492
|
+
parameters: { type: "object", properties: { query: { type: "string", minLength: 1, maxLength: MAXIMUM_WORK_SEARCH_QUERY_LENGTH }, categories: { type: "array", items: { type: "string", enum: ["setting", "character", "race", "organization", "timeline", "relationship", "outline", "foreshadow"] }, maxItems: 8 }, limit: { type: "integer", minimum: 1, maximum: 30, default: 30 }, cursor: agentToolCursorParameter }, required: ["query"], additionalProperties: false }
|
|
407
493
|
}
|
|
408
494
|
},
|
|
409
495
|
read_character_sections: {
|
|
@@ -692,6 +778,36 @@ function numberValue(row, key) {
|
|
|
692
778
|
function boolValue(row, key) {
|
|
693
779
|
return Number(row[key] ?? 0) === 1;
|
|
694
780
|
}
|
|
781
|
+
const providerConnectivityConfigurationFields = [
|
|
782
|
+
"name",
|
|
783
|
+
"base_url",
|
|
784
|
+
"protocol",
|
|
785
|
+
"encrypted_key",
|
|
786
|
+
"key_iv",
|
|
787
|
+
"key_tag",
|
|
788
|
+
"status",
|
|
789
|
+
"concurrency_limit",
|
|
790
|
+
"rpm_limit",
|
|
791
|
+
"max_tokens",
|
|
792
|
+
"default_model_id",
|
|
793
|
+
"note"
|
|
794
|
+
];
|
|
795
|
+
const modelConnectivityConfigurationFields = [
|
|
796
|
+
"display_name",
|
|
797
|
+
"model_id",
|
|
798
|
+
"purposes_json",
|
|
799
|
+
"context_note",
|
|
800
|
+
"context_window",
|
|
801
|
+
"output_note",
|
|
802
|
+
"preset_json",
|
|
803
|
+
"thinking_enabled",
|
|
804
|
+
"multimodal_enabled",
|
|
805
|
+
"enabled",
|
|
806
|
+
"note"
|
|
807
|
+
];
|
|
808
|
+
function connectivityConfigurationValues(row, fields) {
|
|
809
|
+
return fields.map((field) => row[field] ?? null);
|
|
810
|
+
}
|
|
695
811
|
function safeJsonObject(value) {
|
|
696
812
|
return json(value, {});
|
|
697
813
|
}
|
|
@@ -1425,6 +1541,7 @@ export class AiManager {
|
|
|
1425
1541
|
authorizeTaskRun;
|
|
1426
1542
|
attachmentStorage;
|
|
1427
1543
|
contextBuilder;
|
|
1544
|
+
interactiveStreamIdleTimeoutMs;
|
|
1428
1545
|
taskControllers = new Map();
|
|
1429
1546
|
autoRunStarting = new Map();
|
|
1430
1547
|
autoRunTimers = new Map();
|
|
@@ -1438,13 +1555,19 @@ export class AiManager {
|
|
|
1438
1555
|
relationshipIndexDisposed = false;
|
|
1439
1556
|
providerSchedules = new Map();
|
|
1440
1557
|
vertexTokenCache = new GoogleVertexTokenCache();
|
|
1441
|
-
|
|
1558
|
+
connectivityTestGate;
|
|
1559
|
+
constructor(store, vault, fetchImpl = fetch, validateOutboundUrl, authorizeTaskRun, attachmentStorage, options = {}) {
|
|
1442
1560
|
this.store = store;
|
|
1443
1561
|
this.vault = vault;
|
|
1444
1562
|
this.fetchImpl = fetchImpl;
|
|
1445
1563
|
this.validateOutboundUrl = validateOutboundUrl;
|
|
1446
1564
|
this.authorizeTaskRun = authorizeTaskRun;
|
|
1447
1565
|
this.attachmentStorage = attachmentStorage;
|
|
1566
|
+
this.connectivityTestGate = new AiConnectivityTestGate(store.db);
|
|
1567
|
+
this.interactiveStreamIdleTimeoutMs = Number.isSafeInteger(options.interactiveStreamIdleTimeoutMs)
|
|
1568
|
+
&& Number(options.interactiveStreamIdleTimeoutMs) > 0
|
|
1569
|
+
? Number(options.interactiveStreamIdleTimeoutMs)
|
|
1570
|
+
: DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS;
|
|
1448
1571
|
this.contextBuilder = new ContextBuilder(store);
|
|
1449
1572
|
this.store.setAnalysisTaskQueuedHandler((workId) => this.scheduleAutoRun(workId));
|
|
1450
1573
|
this.autoRunStartupTimer = setTimeout(() => {
|
|
@@ -1457,7 +1580,7 @@ export class AiManager {
|
|
|
1457
1580
|
this.relationshipIndexTimer = null;
|
|
1458
1581
|
void this.schedulePendingRelationshipIndexes();
|
|
1459
1582
|
}, 0);
|
|
1460
|
-
logger.info("ai.manager.ready");
|
|
1583
|
+
logger.info("ai.manager.ready", { interactiveStreamIdleTimeoutMs: this.interactiveStreamIdleTimeoutMs });
|
|
1461
1584
|
}
|
|
1462
1585
|
getPlatformTokenUsage(timezoneOffset) {
|
|
1463
1586
|
return this.getTokenUsage(null, timezoneOffset, true);
|
|
@@ -1490,7 +1613,7 @@ export class AiManager {
|
|
|
1490
1613
|
}
|
|
1491
1614
|
async searchWork(workId, query, options = {}) {
|
|
1492
1615
|
this.store.getWork(workId);
|
|
1493
|
-
const normalizedQuery =
|
|
1616
|
+
const normalizedQuery = normalizeWorkSearchQuery(query);
|
|
1494
1617
|
if (!normalizedQuery)
|
|
1495
1618
|
return [];
|
|
1496
1619
|
const requestedTypes = options.type ? new Set([options.type]) : new Set(HYBRID_SEARCH_TYPES);
|
|
@@ -2075,7 +2198,7 @@ export class AiManager {
|
|
|
2075
2198
|
this.vertexTokenCache.clear(providerId);
|
|
2076
2199
|
}
|
|
2077
2200
|
async testProvider(providerId) {
|
|
2078
|
-
const row = this.
|
|
2201
|
+
const { row, configFingerprint, claim } = this.acquireProviderConnectivityTest(providerId);
|
|
2079
2202
|
const protocol = providerProtocol(row);
|
|
2080
2203
|
const controller = new AbortController();
|
|
2081
2204
|
const timeout = setTimeout(() => controller.abort(), AI_INTERACTIVE_TIMEOUT_MS);
|
|
@@ -2123,39 +2246,62 @@ export class AiManager {
|
|
|
2123
2246
|
: `${lastFailure};也可先添加模型后再测试连接`);
|
|
2124
2247
|
}
|
|
2125
2248
|
await this.probeProviderModel(row, accessToken, probeModel, controller.signal);
|
|
2126
|
-
const
|
|
2127
|
-
|
|
2249
|
+
const cooldown = this.connectivityTestGate.complete(claim, "success", {
|
|
2250
|
+
isConfigurationCurrent: () => {
|
|
2251
|
+
try {
|
|
2252
|
+
return this.providerConnectivityTestFingerprint(this.getProviderRow(providerId)) === configFingerprint;
|
|
2253
|
+
}
|
|
2254
|
+
catch {
|
|
2255
|
+
return false;
|
|
2256
|
+
}
|
|
2257
|
+
},
|
|
2258
|
+
onApplied: (completedAt) => {
|
|
2259
|
+
this.store.db.run("UPDATE providers SET connection_status = 'success', last_error = NULL, last_success_at = ?, updated_at = ? WHERE id = ?", completedAt, completedAt, providerId);
|
|
2260
|
+
}
|
|
2261
|
+
});
|
|
2128
2262
|
logger.info("ai.provider_test.completed", {
|
|
2129
2263
|
providerId,
|
|
2130
2264
|
protocol,
|
|
2131
2265
|
ok: true,
|
|
2266
|
+
cooldownApplied: cooldown.reason !== "configuration_changed",
|
|
2132
2267
|
availableModelCount: availableModels.length,
|
|
2133
2268
|
durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000
|
|
2134
2269
|
});
|
|
2135
|
-
return { ok: true, availableModels, provider: this.getProvider(providerId) };
|
|
2270
|
+
return { ok: true, availableModels, cooldown, provider: this.getProvider(providerId) };
|
|
2136
2271
|
}
|
|
2137
2272
|
catch (error) {
|
|
2138
2273
|
const message = error instanceof Error
|
|
2139
2274
|
? redactProviderSecretsText(error.message, credentialSecret, accessToken)
|
|
2140
2275
|
: "连接失败";
|
|
2141
|
-
this.
|
|
2276
|
+
const cooldown = this.connectivityTestGate.complete(claim, "failure", {
|
|
2277
|
+
isConfigurationCurrent: () => {
|
|
2278
|
+
try {
|
|
2279
|
+
return this.providerConnectivityTestFingerprint(this.getProviderRow(providerId)) === configFingerprint;
|
|
2280
|
+
}
|
|
2281
|
+
catch {
|
|
2282
|
+
return false;
|
|
2283
|
+
}
|
|
2284
|
+
},
|
|
2285
|
+
onApplied: (completedAt) => {
|
|
2286
|
+
this.store.db.run("UPDATE providers SET connection_status = 'failed', last_error = ?, updated_at = ? WHERE id = ?", message, completedAt, providerId);
|
|
2287
|
+
}
|
|
2288
|
+
});
|
|
2142
2289
|
logger.warn("ai.provider_test.completed", {
|
|
2143
2290
|
providerId,
|
|
2144
2291
|
protocol,
|
|
2145
2292
|
ok: false,
|
|
2293
|
+
cooldownApplied: cooldown.reason !== "configuration_changed",
|
|
2146
2294
|
durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000,
|
|
2147
|
-
error:
|
|
2295
|
+
error: connectivityTestErrorForLog(error)
|
|
2148
2296
|
});
|
|
2149
|
-
return { ok: false, error: message, provider: this.getProvider(providerId) };
|
|
2297
|
+
return { ok: false, error: message, cooldown, provider: this.getProvider(providerId) };
|
|
2150
2298
|
}
|
|
2151
2299
|
finally {
|
|
2152
2300
|
clearTimeout(timeout);
|
|
2153
2301
|
}
|
|
2154
2302
|
}
|
|
2155
2303
|
async testModel(modelId) {
|
|
2156
|
-
const model = this.
|
|
2157
|
-
const providerId = stringValue(model, "provider_id");
|
|
2158
|
-
const provider = this.getProviderRow(providerId);
|
|
2304
|
+
const { model, provider, providerId, configFingerprint, claim } = this.acquireModelConnectivityTest(modelId);
|
|
2159
2305
|
const controller = new AbortController();
|
|
2160
2306
|
const timeout = setTimeout(() => controller.abort(), AI_INTERACTIVE_TIMEOUT_MS);
|
|
2161
2307
|
const startedAt = process.hrtime.bigint();
|
|
@@ -2167,31 +2313,60 @@ export class AiManager {
|
|
|
2167
2313
|
try {
|
|
2168
2314
|
({ accessToken, credentialSecret } = await this.resolveProviderAccessToken(provider));
|
|
2169
2315
|
await this.probeProviderModel(provider, accessToken, stringValue(model, "model_id"), controller.signal, { multimodal: multimodalTested });
|
|
2170
|
-
const
|
|
2171
|
-
|
|
2316
|
+
const cooldown = this.connectivityTestGate.complete(claim, "success", {
|
|
2317
|
+
isConfigurationCurrent: () => {
|
|
2318
|
+
try {
|
|
2319
|
+
const currentModel = this.getModelRow(modelId);
|
|
2320
|
+
const currentProvider = this.getProviderRow(stringValue(currentModel, "provider_id"));
|
|
2321
|
+
return this.modelConnectivityTestFingerprint(currentModel, currentProvider) === configFingerprint;
|
|
2322
|
+
}
|
|
2323
|
+
catch {
|
|
2324
|
+
return false;
|
|
2325
|
+
}
|
|
2326
|
+
},
|
|
2327
|
+
onApplied: (completedAt) => {
|
|
2328
|
+
this.store.db.run("UPDATE providers SET connection_status = 'success', last_error = NULL, last_success_at = ?, updated_at = ? WHERE id = ?", completedAt, completedAt, providerId);
|
|
2329
|
+
}
|
|
2330
|
+
});
|
|
2172
2331
|
logger.info("ai.model_test.completed", {
|
|
2173
2332
|
modelId,
|
|
2174
2333
|
providerId,
|
|
2175
2334
|
protocol,
|
|
2176
2335
|
ok: true,
|
|
2336
|
+
cooldownApplied: cooldown.reason !== "configuration_changed",
|
|
2177
2337
|
durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000
|
|
2178
2338
|
});
|
|
2179
|
-
return { ok: true, multimodalTested, model: this.getModel(modelId), provider: this.getProvider(providerId) };
|
|
2339
|
+
return { ok: true, multimodalTested, cooldown, model: this.getModel(modelId), provider: this.getProvider(providerId) };
|
|
2180
2340
|
}
|
|
2181
2341
|
catch (error) {
|
|
2182
2342
|
const message = error instanceof Error
|
|
2183
2343
|
? redactProviderSecretsText(error.message, credentialSecret, accessToken)
|
|
2184
2344
|
: "连接失败";
|
|
2185
|
-
this.
|
|
2345
|
+
const cooldown = this.connectivityTestGate.complete(claim, "failure", {
|
|
2346
|
+
isConfigurationCurrent: () => {
|
|
2347
|
+
try {
|
|
2348
|
+
const currentModel = this.getModelRow(modelId);
|
|
2349
|
+
const currentProvider = this.getProviderRow(stringValue(currentModel, "provider_id"));
|
|
2350
|
+
return this.modelConnectivityTestFingerprint(currentModel, currentProvider) === configFingerprint;
|
|
2351
|
+
}
|
|
2352
|
+
catch {
|
|
2353
|
+
return false;
|
|
2354
|
+
}
|
|
2355
|
+
},
|
|
2356
|
+
onApplied: (completedAt) => {
|
|
2357
|
+
this.store.db.run("UPDATE providers SET connection_status = 'failed', last_error = ?, updated_at = ? WHERE id = ?", message, completedAt, providerId);
|
|
2358
|
+
}
|
|
2359
|
+
});
|
|
2186
2360
|
logger.warn("ai.model_test.completed", {
|
|
2187
2361
|
modelId,
|
|
2188
2362
|
providerId,
|
|
2189
2363
|
protocol,
|
|
2190
2364
|
ok: false,
|
|
2365
|
+
cooldownApplied: cooldown.reason !== "configuration_changed",
|
|
2191
2366
|
durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000,
|
|
2192
|
-
error:
|
|
2367
|
+
error: connectivityTestErrorForLog(error)
|
|
2193
2368
|
});
|
|
2194
|
-
return { ok: false, error: message, model: this.getModel(modelId), provider: this.getProvider(providerId) };
|
|
2369
|
+
return { ok: false, error: message, cooldown, model: this.getModel(modelId), provider: this.getProvider(providerId) };
|
|
2195
2370
|
}
|
|
2196
2371
|
finally {
|
|
2197
2372
|
clearTimeout(timeout);
|
|
@@ -2377,6 +2552,295 @@ export class AiManager {
|
|
|
2377
2552
|
...(modelId ? { modelId } : {})
|
|
2378
2553
|
});
|
|
2379
2554
|
}
|
|
2555
|
+
assertCharacterExtractionTask(taskId) {
|
|
2556
|
+
const task = this.store.getTask(taskId);
|
|
2557
|
+
if (task.taskType !== "character-extraction" && task.taskType !== "character-summary") {
|
|
2558
|
+
throw new AppError(409, "CHARACTER_EXTRACTION_TASK_REQUIRED", "只有角色抽取任务可以预览或应用角色档案");
|
|
2559
|
+
}
|
|
2560
|
+
if (task.status !== "review" && task.status !== "completed") {
|
|
2561
|
+
throw new AppError(409, "CHARACTER_EXTRACTION_TASK_NOT_COMPLETED", "只有已成功完成的角色抽取任务可以应用角色档案");
|
|
2562
|
+
}
|
|
2563
|
+
return task;
|
|
2564
|
+
}
|
|
2565
|
+
characterExtractionMatches(candidate, characters) {
|
|
2566
|
+
const candidateNames = [candidate.name, ...candidate.aliases];
|
|
2567
|
+
const candidateNormalized = new Map(candidateNames.map((name) => [normalizeCharacterName(name), name]));
|
|
2568
|
+
const stable = candidate.stableCharacterId
|
|
2569
|
+
? characters.find((character) => character.id === candidate.stableCharacterId)
|
|
2570
|
+
: undefined;
|
|
2571
|
+
const matches = new Map();
|
|
2572
|
+
for (const character of characters) {
|
|
2573
|
+
const primaryName = String(character.name);
|
|
2574
|
+
const aliases = Array.isArray(character.aliases) ? character.aliases.map(String) : [];
|
|
2575
|
+
const primaryNormalized = normalizeCharacterName(primaryName);
|
|
2576
|
+
const aliasNormalized = new Set(aliases.map(normalizeCharacterName));
|
|
2577
|
+
const matchedNames = [...candidateNormalized]
|
|
2578
|
+
.filter(([normalized]) => normalized === primaryNormalized || aliasNormalized.has(normalized))
|
|
2579
|
+
.map(([, name]) => name);
|
|
2580
|
+
if (matchedNames.length === 0 && character !== stable)
|
|
2581
|
+
continue;
|
|
2582
|
+
const matchType = character === stable
|
|
2583
|
+
? "stable"
|
|
2584
|
+
: matchedNames.some((name) => normalizeCharacterName(name) === primaryNormalized)
|
|
2585
|
+
? "name"
|
|
2586
|
+
: "alias";
|
|
2587
|
+
matches.set(String(character.id), {
|
|
2588
|
+
characterId: String(character.id),
|
|
2589
|
+
name: primaryName,
|
|
2590
|
+
aliases,
|
|
2591
|
+
versionNo: Number(character.versionNo),
|
|
2592
|
+
matchType,
|
|
2593
|
+
matchedNames
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
2596
|
+
const conflicts = [];
|
|
2597
|
+
if (candidate.stableCharacterId && !stable)
|
|
2598
|
+
conflicts.push("任务生成时匹配的角色已不存在,请改为新建或跳过");
|
|
2599
|
+
if (matches.size > 1)
|
|
2600
|
+
conflicts.push("候选名称或别名分别命中了多个已有角色,必须明确选择目标或改名新建");
|
|
2601
|
+
const priority = { stable: 0, name: 1, alias: 2 };
|
|
2602
|
+
return {
|
|
2603
|
+
matches: [...matches.values()].sort((left, right) => priority[left.matchType] - priority[right.matchType]
|
|
2604
|
+
|| left.name.localeCompare(right.name, "zh-CN")),
|
|
2605
|
+
conflicts
|
|
2606
|
+
};
|
|
2607
|
+
}
|
|
2608
|
+
characterExtractionPreviewData(task, result, candidates) {
|
|
2609
|
+
const workId = String(task.workId);
|
|
2610
|
+
const characters = this.store.listCharacters(workId, false, false, false);
|
|
2611
|
+
const items = candidates.map((candidate) => {
|
|
2612
|
+
const { matches, conflicts } = this.characterExtractionMatches(candidate, characters);
|
|
2613
|
+
return {
|
|
2614
|
+
...candidate,
|
|
2615
|
+
suggestedAction: matches.length === 0 ? "create" : matches.length === 1 || matches[0]?.matchType === "stable" ? "merge" : "skip",
|
|
2616
|
+
matchCandidates: matches,
|
|
2617
|
+
conflicts
|
|
2618
|
+
};
|
|
2619
|
+
});
|
|
2620
|
+
const previewToken = characterExtractionHash({
|
|
2621
|
+
taskId: task.id,
|
|
2622
|
+
taskUpdatedAt: task.updatedAt,
|
|
2623
|
+
candidates,
|
|
2624
|
+
roster: characters.map((character) => ({
|
|
2625
|
+
id: character.id,
|
|
2626
|
+
name: character.name,
|
|
2627
|
+
aliases: character.aliases,
|
|
2628
|
+
raceId: character.raceId,
|
|
2629
|
+
identity: character.attributes && typeof character.attributes === "object" && !Array.isArray(character.attributes)
|
|
2630
|
+
? String(character.attributes.identity ?? "")
|
|
2631
|
+
: "",
|
|
2632
|
+
firstChapterId: character.firstChapterId,
|
|
2633
|
+
versionNo: character.versionNo
|
|
2634
|
+
}))
|
|
2635
|
+
});
|
|
2636
|
+
const application = result.characterApplication && typeof result.characterApplication === "object"
|
|
2637
|
+
&& !Array.isArray(result.characterApplication)
|
|
2638
|
+
? result.characterApplication
|
|
2639
|
+
: null;
|
|
2640
|
+
return {
|
|
2641
|
+
taskId: String(task.id),
|
|
2642
|
+
status: application?.status === "applied" ? "applied" : "pending",
|
|
2643
|
+
totalCount: candidates.length,
|
|
2644
|
+
previewToken,
|
|
2645
|
+
items,
|
|
2646
|
+
...(application?.status === "applied" ? { application } : {})
|
|
2647
|
+
};
|
|
2648
|
+
}
|
|
2649
|
+
getCharacterExtractionPreview(taskId) {
|
|
2650
|
+
const task = this.assertCharacterExtractionTask(taskId);
|
|
2651
|
+
const result = this.store.getTaskStoredResult(taskId);
|
|
2652
|
+
const application = result.characterApplication && typeof result.characterApplication === "object"
|
|
2653
|
+
&& !Array.isArray(result.characterApplication)
|
|
2654
|
+
? result.characterApplication
|
|
2655
|
+
: null;
|
|
2656
|
+
if (application?.status !== "applied" && !this.store.isTaskSourceCurrent(taskId)) {
|
|
2657
|
+
throw new AppError(409, "CHARACTER_EXTRACTION_SOURCE_CHANGED", "任务分析的正文来源已发生变化,请重新运行角色抽取后再应用");
|
|
2658
|
+
}
|
|
2659
|
+
const candidates = parseStoredCharacterExtractionCandidates(result.characterCandidates);
|
|
2660
|
+
return this.characterExtractionPreviewData(task, result, candidates);
|
|
2661
|
+
}
|
|
2662
|
+
characterExtractionFirstChapter(workId, candidate) {
|
|
2663
|
+
if (!candidate.firstChapterId)
|
|
2664
|
+
return { firstChapterId: null };
|
|
2665
|
+
try {
|
|
2666
|
+
const chapter = this.store.getChapter(candidate.firstChapterId);
|
|
2667
|
+
if (chapter.workId === workId)
|
|
2668
|
+
return { firstChapterId: candidate.firstChapterId };
|
|
2669
|
+
}
|
|
2670
|
+
catch {
|
|
2671
|
+
// 原任务结果可能来自旧数据;应用时按当前作品重新核验。
|
|
2672
|
+
}
|
|
2673
|
+
return { firstChapterId: null, conflict: "首次登场章节已不存在或不属于当前作品,未写入该关联" };
|
|
2674
|
+
}
|
|
2675
|
+
applyCharacterExtractionPreview(taskId, previewToken, selections) {
|
|
2676
|
+
this.assertCharacterExtractionTask(taskId);
|
|
2677
|
+
const requestFingerprint = characterExtractionSelectionFingerprint(selections);
|
|
2678
|
+
return this.store.db.transaction(() => {
|
|
2679
|
+
const task = this.assertCharacterExtractionTask(taskId);
|
|
2680
|
+
const result = this.store.getTaskStoredResult(taskId);
|
|
2681
|
+
const application = result.characterApplication && typeof result.characterApplication === "object"
|
|
2682
|
+
&& !Array.isArray(result.characterApplication)
|
|
2683
|
+
? result.characterApplication
|
|
2684
|
+
: null;
|
|
2685
|
+
if (application?.status === "applied") {
|
|
2686
|
+
if (application.requestFingerprint === requestFingerprint)
|
|
2687
|
+
return application;
|
|
2688
|
+
throw new AppError(409, "CHARACTER_EXTRACTION_ALREADY_APPLIED", "本任务已按另一组确认结果应用,不能再次修改角色档案");
|
|
2689
|
+
}
|
|
2690
|
+
if (!this.store.isTaskSourceCurrent(taskId)) {
|
|
2691
|
+
throw new AppError(409, "CHARACTER_EXTRACTION_SOURCE_CHANGED", "任务分析的正文来源已发生变化,请重新运行角色抽取后再应用");
|
|
2692
|
+
}
|
|
2693
|
+
const candidates = parseStoredCharacterExtractionCandidates(result.characterCandidates);
|
|
2694
|
+
const selectionById = new Map(selections.map((selection) => [selection.candidateId, selection]));
|
|
2695
|
+
if (selectionById.size !== selections.length
|
|
2696
|
+
|| selectionById.size !== candidates.length
|
|
2697
|
+
|| candidates.some((candidate) => !selectionById.has(candidate.candidateId))) {
|
|
2698
|
+
throw new AppError(400, "CHARACTER_EXTRACTION_SELECTION_INVALID", "必须为预览中的每个角色候选明确选择新建、合并或跳过");
|
|
2699
|
+
}
|
|
2700
|
+
const preview = this.characterExtractionPreviewData(task, result, candidates);
|
|
2701
|
+
if (preview.previewToken !== previewToken) {
|
|
2702
|
+
throw new AppError(409, "CHARACTER_EXTRACTION_PREVIEW_STALE", "角色档案在预览后已发生变化,请刷新预览再确认");
|
|
2703
|
+
}
|
|
2704
|
+
const previewItems = new Map(preview.items
|
|
2705
|
+
.map((item) => [item.candidateId, item]));
|
|
2706
|
+
const workId = String(task.workId);
|
|
2707
|
+
const appliedItems = [];
|
|
2708
|
+
const characterIds = [];
|
|
2709
|
+
for (const candidate of candidates) {
|
|
2710
|
+
const selection = selectionById.get(candidate.candidateId);
|
|
2711
|
+
if (selection.action === "skip") {
|
|
2712
|
+
appliedItems.push({ candidateId: candidate.candidateId, action: "skip", status: "skipped" });
|
|
2713
|
+
continue;
|
|
2714
|
+
}
|
|
2715
|
+
const editable = editableCharacterExtractionCandidate(candidate, selection);
|
|
2716
|
+
const firstChapter = this.characterExtractionFirstChapter(workId, candidate);
|
|
2717
|
+
const conflicts = firstChapter.conflict ? [firstChapter.conflict] : [];
|
|
2718
|
+
const raceId = editable.species ? this.store.resolveRaceReference(workId, editable.species) : null;
|
|
2719
|
+
if (editable.species && !raceId)
|
|
2720
|
+
conflicts.push(`种族“${editable.species}”未命中当前作品已有种族,未写入种族关联`);
|
|
2721
|
+
if (selection.action === "create") {
|
|
2722
|
+
const created = this.store.createCharacter(workId, {
|
|
2723
|
+
name: editable.name,
|
|
2724
|
+
aliases: editable.aliases,
|
|
2725
|
+
raceId,
|
|
2726
|
+
attributes: editable.identity ? { identity: editable.identity } : {},
|
|
2727
|
+
firstChapterId: firstChapter.firstChapterId
|
|
2728
|
+
}, "ai", taskId, "应用 AI 角色抽取预览并新建档案");
|
|
2729
|
+
characterIds.push(String(created.id));
|
|
2730
|
+
appliedItems.push({
|
|
2731
|
+
candidateId: candidate.candidateId,
|
|
2732
|
+
action: "create",
|
|
2733
|
+
status: "created",
|
|
2734
|
+
characterId: String(created.id),
|
|
2735
|
+
characterName: String(created.name),
|
|
2736
|
+
...(conflicts.length ? { conflicts } : {})
|
|
2737
|
+
});
|
|
2738
|
+
continue;
|
|
2739
|
+
}
|
|
2740
|
+
const previewItem = previewItems.get(candidate.candidateId);
|
|
2741
|
+
const targetMatch = previewItem.matchCandidates.find((match) => match.characterId === selection.targetCharacterId);
|
|
2742
|
+
if (!targetMatch || !selection.targetCharacterId) {
|
|
2743
|
+
throw new AppError(400, "CHARACTER_EXTRACTION_TARGET_INVALID", "合并目标不是服务端预览确认的候选角色", {
|
|
2744
|
+
candidateId: candidate.candidateId
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2747
|
+
const target = this.store.getCharacter(selection.targetCharacterId);
|
|
2748
|
+
if (target.workId !== workId || target.mergedIntoCharacterId) {
|
|
2749
|
+
throw new AppError(409, "CHARACTER_EXTRACTION_TARGET_STALE", "合并目标已失效,请刷新预览再确认", {
|
|
2750
|
+
candidateId: candidate.candidateId
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
const existingAliases = Array.isArray(target.aliases) ? target.aliases.map(String) : [];
|
|
2754
|
+
const existingNames = new Set([String(target.name), ...existingAliases].map(normalizeCharacterName));
|
|
2755
|
+
const addedAliases = [];
|
|
2756
|
+
for (const alias of [editable.name, ...editable.aliases]) {
|
|
2757
|
+
const normalized = normalizeCharacterName(alias);
|
|
2758
|
+
if (!normalized || existingNames.has(normalized))
|
|
2759
|
+
continue;
|
|
2760
|
+
const ownerId = this.store.resolveCharacterReference(workId, alias);
|
|
2761
|
+
if (ownerId && ownerId !== target.id) {
|
|
2762
|
+
conflicts.push(`名称或别名“${alias}”已属于其他角色,未合并该别名`);
|
|
2763
|
+
continue;
|
|
2764
|
+
}
|
|
2765
|
+
existingNames.add(normalized);
|
|
2766
|
+
addedAliases.push(alias);
|
|
2767
|
+
}
|
|
2768
|
+
const update = {};
|
|
2769
|
+
if (addedAliases.length > 0)
|
|
2770
|
+
update.aliases = [...existingAliases, ...addedAliases];
|
|
2771
|
+
const attributes = target.attributes && typeof target.attributes === "object" && !Array.isArray(target.attributes)
|
|
2772
|
+
? target.attributes
|
|
2773
|
+
: {};
|
|
2774
|
+
const existingIdentity = typeof attributes.identity === "string" ? attributes.identity.trim() : "";
|
|
2775
|
+
if (editable.identity && !existingIdentity)
|
|
2776
|
+
update.attributes = { ...attributes, identity: editable.identity };
|
|
2777
|
+
else if (editable.identity && normalizeCharacterName(editable.identity) !== normalizeCharacterName(existingIdentity)) {
|
|
2778
|
+
conflicts.push("已有身份与定位内容未被抽取结果覆盖");
|
|
2779
|
+
}
|
|
2780
|
+
if (editable.species) {
|
|
2781
|
+
if (!target.raceId && raceId)
|
|
2782
|
+
update.raceId = raceId;
|
|
2783
|
+
else if (target.raceId && (!raceId || target.raceId !== raceId))
|
|
2784
|
+
conflicts.push("已有种族关联未被抽取结果覆盖");
|
|
2785
|
+
}
|
|
2786
|
+
if (!target.firstChapterId && firstChapter.firstChapterId)
|
|
2787
|
+
update.firstChapterId = firstChapter.firstChapterId;
|
|
2788
|
+
const changed = Object.keys(update).length > 0;
|
|
2789
|
+
const updated = changed
|
|
2790
|
+
? this.store.updateCharacter(String(target.id), update, "ai", taskId, "应用 AI 角色抽取预览并合并可靠信息", Number(target.versionNo))
|
|
2791
|
+
: target;
|
|
2792
|
+
characterIds.push(String(updated.id));
|
|
2793
|
+
appliedItems.push({
|
|
2794
|
+
candidateId: candidate.candidateId,
|
|
2795
|
+
action: "merge",
|
|
2796
|
+
status: changed ? "merged" : "unchanged",
|
|
2797
|
+
characterId: String(updated.id),
|
|
2798
|
+
characterName: String(updated.name),
|
|
2799
|
+
...(addedAliases.length ? { addedAliases } : {}),
|
|
2800
|
+
...(conflicts.length ? { conflicts } : {})
|
|
2801
|
+
});
|
|
2802
|
+
}
|
|
2803
|
+
const appliedAt = now();
|
|
2804
|
+
const applicationResult = {
|
|
2805
|
+
status: "applied",
|
|
2806
|
+
previewToken,
|
|
2807
|
+
requestFingerprint,
|
|
2808
|
+
...(typeof application?.generatedAt === "string" ? { generatedAt: application.generatedAt } : {}),
|
|
2809
|
+
appliedAt,
|
|
2810
|
+
totalCount: candidates.length,
|
|
2811
|
+
createdCount: appliedItems.filter((item) => item.status === "created").length,
|
|
2812
|
+
mergedCount: appliedItems.filter((item) => item.status === "merged").length,
|
|
2813
|
+
unchangedCount: appliedItems.filter((item) => item.status === "unchanged").length,
|
|
2814
|
+
skippedCount: appliedItems.filter((item) => item.status === "skipped").length,
|
|
2815
|
+
characterIds: [...new Set(characterIds)],
|
|
2816
|
+
items: appliedItems
|
|
2817
|
+
};
|
|
2818
|
+
this.store.updateTask(taskId, {
|
|
2819
|
+
status: String(task.status),
|
|
2820
|
+
result: {
|
|
2821
|
+
...result,
|
|
2822
|
+
characterIds: applicationResult.characterIds,
|
|
2823
|
+
savedCount: applicationResult.characterIds.length,
|
|
2824
|
+
characterApplication: applicationResult
|
|
2825
|
+
}
|
|
2826
|
+
});
|
|
2827
|
+
this.store.audit(workId, "character.extraction.applied", "analysis-task", taskId, {
|
|
2828
|
+
createdCount: applicationResult.createdCount,
|
|
2829
|
+
mergedCount: applicationResult.mergedCount,
|
|
2830
|
+
unchangedCount: applicationResult.unchangedCount,
|
|
2831
|
+
skippedCount: applicationResult.skippedCount,
|
|
2832
|
+
characterIds: applicationResult.characterIds
|
|
2833
|
+
});
|
|
2834
|
+
logger.info("ai.character_extraction.applied", {
|
|
2835
|
+
taskId,
|
|
2836
|
+
workId,
|
|
2837
|
+
createdCount: applicationResult.createdCount,
|
|
2838
|
+
mergedCount: applicationResult.mergedCount,
|
|
2839
|
+
skippedCount: applicationResult.skippedCount
|
|
2840
|
+
});
|
|
2841
|
+
return applicationResult;
|
|
2842
|
+
});
|
|
2843
|
+
}
|
|
2380
2844
|
applyRelationshipChangePreview(taskId) {
|
|
2381
2845
|
const task = this.store.getTask(taskId);
|
|
2382
2846
|
if (task.taskType !== "relationship-analysis") {
|
|
@@ -3192,18 +3656,29 @@ export class AiManager {
|
|
|
3192
3656
|
}
|
|
3193
3657
|
};
|
|
3194
3658
|
}
|
|
3195
|
-
|
|
3659
|
+
inspectConversationContext(input) {
|
|
3196
3660
|
const usage = this.getContextUsage({ ...input, taskType: "chat" });
|
|
3197
3661
|
const conversation = this.store.getAiConversationContext(input.conversationId, input.workId);
|
|
3198
3662
|
if (!usage.compactRecommended) {
|
|
3199
|
-
if (conversation.warningPending)
|
|
3200
|
-
this.store.setAiConversationContextWarning(input.conversationId, false);
|
|
3201
3663
|
return { action: "ready", usage: { ...usage, contextWarningPending: false } };
|
|
3202
3664
|
}
|
|
3203
3665
|
if (!conversation.warningPending) {
|
|
3204
|
-
this.store.setAiConversationContextWarning(input.conversationId, true);
|
|
3205
3666
|
return { action: "warn", usage: { ...usage, contextWarningPending: true } };
|
|
3206
3667
|
}
|
|
3668
|
+
return { action: "compact", usage };
|
|
3669
|
+
}
|
|
3670
|
+
async prepareConversationContext(input, options = {}) {
|
|
3671
|
+
const inspection = this.inspectConversationContext(input);
|
|
3672
|
+
if (inspection.action === "ready") {
|
|
3673
|
+
const conversation = this.store.getAiConversationContext(input.conversationId, input.workId);
|
|
3674
|
+
if (conversation.warningPending)
|
|
3675
|
+
this.store.setAiConversationContextWarning(input.conversationId, false);
|
|
3676
|
+
return inspection;
|
|
3677
|
+
}
|
|
3678
|
+
if (inspection.action === "warn" && !options.skipWarning) {
|
|
3679
|
+
this.store.setAiConversationContextWarning(input.conversationId, true);
|
|
3680
|
+
return inspection;
|
|
3681
|
+
}
|
|
3207
3682
|
const compaction = await this.compactConversation(input);
|
|
3208
3683
|
const compactedUsage = this.getContextUsage({ ...input, taskType: "chat" });
|
|
3209
3684
|
return { action: "compacted", usage: compactedUsage, compaction };
|
|
@@ -3216,7 +3691,7 @@ export class AiManager {
|
|
|
3216
3691
|
return this.mergeInstructionEntityMatches(input.scope, matches);
|
|
3217
3692
|
}
|
|
3218
3693
|
async compactConversation(input) {
|
|
3219
|
-
const conversation = this.store.getAiConversationContext(input.conversationId, input.workId);
|
|
3694
|
+
const conversation = this.store.getAiConversationContext(input.conversationId, input.workId, input.excludeConversationMessageId);
|
|
3220
3695
|
const { model } = this.resolveModel(input.workId, "chat", input.modelId);
|
|
3221
3696
|
const budget = this.contextBudget({ ...input, taskType: "chat", instruction: "" }, model);
|
|
3222
3697
|
const recentTokenBudget = Math.max(128, Math.floor(Number(budget.conversationBudgetTokens) * 0.75));
|
|
@@ -4304,6 +4779,7 @@ export class AiManager {
|
|
|
4304
4779
|
toolCount: tools.length
|
|
4305
4780
|
});
|
|
4306
4781
|
let activeSecrets = [];
|
|
4782
|
+
let streamedContent = "";
|
|
4307
4783
|
let trackedInputTokens = 0;
|
|
4308
4784
|
let trackedOutputTokens = 0;
|
|
4309
4785
|
let trackedCachedInputTokens = 0;
|
|
@@ -4337,7 +4813,6 @@ export class AiManager {
|
|
|
4337
4813
|
let totalCachedInputTokens = 0;
|
|
4338
4814
|
const processSteps = [];
|
|
4339
4815
|
const completionDelivery = new WeakMap();
|
|
4340
|
-
let streamedContent = "";
|
|
4341
4816
|
let streamingGenerationRound = 0;
|
|
4342
4817
|
const requestCompletion = async (toolChoice, options = {}) => {
|
|
4343
4818
|
const requestMessages = options.messages ?? completionMessages;
|
|
@@ -4387,7 +4862,14 @@ export class AiManager {
|
|
|
4387
4862
|
forwardAbort();
|
|
4388
4863
|
else
|
|
4389
4864
|
input.signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
4390
|
-
const
|
|
4865
|
+
const streamWatchdog = streamResponse
|
|
4866
|
+
? new InteractiveStreamIdleWatchdog(controller, this.interactiveStreamIdleTimeoutMs)
|
|
4867
|
+
: null;
|
|
4868
|
+
const timeout = streamResponse
|
|
4869
|
+
? null
|
|
4870
|
+
: setTimeout(() => controller.abort(new Error(`AI 请求超时(${Math.round(timeoutMs / 1_000)} 秒)`)), timeoutMs);
|
|
4871
|
+
let responseReceived = false;
|
|
4872
|
+
streamWatchdog?.start();
|
|
4391
4873
|
try {
|
|
4392
4874
|
const response = await this.outboundFetch(endpoint, {
|
|
4393
4875
|
method: "POST",
|
|
@@ -4403,6 +4885,7 @@ export class AiManager {
|
|
|
4403
4885
|
})),
|
|
4404
4886
|
signal: controller.signal
|
|
4405
4887
|
});
|
|
4888
|
+
responseReceived = true;
|
|
4406
4889
|
if (!response.ok) {
|
|
4407
4890
|
return { ok: false, status: response.status, body: await readResponseTextLimited(response) };
|
|
4408
4891
|
}
|
|
@@ -4435,7 +4918,8 @@ export class AiManager {
|
|
|
4435
4918
|
}
|
|
4436
4919
|
streamedThinkingStep.content += delta;
|
|
4437
4920
|
input.onProcessStep?.({ ...streamedThinkingStep, content: delta, append: true });
|
|
4438
|
-
});
|
|
4921
|
+
}, () => streamWatchdog?.receivedEvent());
|
|
4922
|
+
streamWatchdog?.complete();
|
|
4439
4923
|
return {
|
|
4440
4924
|
ok: true,
|
|
4441
4925
|
status: response.status,
|
|
@@ -4443,8 +4927,20 @@ export class AiManager {
|
|
|
4443
4927
|
delivery: "sse"
|
|
4444
4928
|
};
|
|
4445
4929
|
}
|
|
4930
|
+
catch (error) {
|
|
4931
|
+
if (streamResponse && input.signal?.aborted)
|
|
4932
|
+
throw interactiveStreamRequestCancelledError();
|
|
4933
|
+
if (streamWatchdog?.failure)
|
|
4934
|
+
throw streamWatchdog.failure;
|
|
4935
|
+
if (streamResponse && !responseReceived) {
|
|
4936
|
+
throw new AppError(502, "AI_STREAM_NETWORK_ERROR", "AI 上游流连接失败,尚未收到首个事件");
|
|
4937
|
+
}
|
|
4938
|
+
throw error;
|
|
4939
|
+
}
|
|
4446
4940
|
finally {
|
|
4447
|
-
|
|
4941
|
+
if (timeout)
|
|
4942
|
+
clearTimeout(timeout);
|
|
4943
|
+
streamWatchdog?.dispose();
|
|
4448
4944
|
input.signal?.removeEventListener("abort", forwardAbort);
|
|
4449
4945
|
}
|
|
4450
4946
|
});
|
|
@@ -4489,6 +4985,8 @@ export class AiManager {
|
|
|
4489
4985
|
}
|
|
4490
4986
|
catch (error) {
|
|
4491
4987
|
lastFailure = error;
|
|
4988
|
+
if (isInteractiveStreamError(error))
|
|
4989
|
+
retryable = false;
|
|
4492
4990
|
if (traceAttempt.status === "running") {
|
|
4493
4991
|
traceAttempt.completedAt = now();
|
|
4494
4992
|
traceAttempt.status = "failed";
|
|
@@ -4785,10 +5283,10 @@ export class AiManager {
|
|
|
4785
5283
|
const message = error instanceof Error ? redactProviderSecretsText(error.message, ...activeSecrets) : "AI 调用失败";
|
|
4786
5284
|
const failureTarget = aiFailureTargetDetails(provider, model);
|
|
4787
5285
|
this.store.db.run(`UPDATE ai_calls
|
|
4788
|
-
SET status = 'failed', failure = ?, input_tokens = ?, output_tokens = ?,
|
|
5286
|
+
SET status = 'failed', failure = ?, output_chars = ?, input_tokens = ?, output_tokens = ?,
|
|
4789
5287
|
cached_input_tokens = ?, cache_eligible_input_tokens = ?, cache_usage_available = ?,
|
|
4790
5288
|
token_usage_source = ?, completed_at = ?
|
|
4791
|
-
WHERE id = ?`, message, trackedInputTokens, trackedOutputTokens, trackedCachedInputTokens, trackedCacheEligibleInputTokens, trackedCacheEligibleInputTokens > 0 ? 1 : 0, trackedUsageSource(), now(), callId);
|
|
5289
|
+
WHERE id = ?`, message, streamedContent.length, trackedInputTokens, trackedOutputTokens, trackedCachedInputTokens, trackedCacheEligibleInputTokens, trackedCacheEligibleInputTokens > 0 ? 1 : 0, trackedUsageSource(), now(), callId);
|
|
4792
5290
|
logger.error("ai.call.failed", {
|
|
4793
5291
|
callId,
|
|
4794
5292
|
workId: input.workId,
|
|
@@ -4797,7 +5295,9 @@ export class AiManager {
|
|
|
4797
5295
|
durationMs: Number(process.hrtime.bigint() - callStartedAt) / 1_000_000,
|
|
4798
5296
|
error: aiErrorForLog(error)
|
|
4799
5297
|
});
|
|
4800
|
-
if (error instanceof AppError && (error.code === "CONTEXT_WINDOW_EXCEEDED"
|
|
5298
|
+
if (error instanceof AppError && (error.code === "CONTEXT_WINDOW_EXCEEDED"
|
|
5299
|
+
|| error.code === "DAILY_TOKEN_QUOTA_EXCEEDED"
|
|
5300
|
+
|| isInteractiveStreamError(error))) {
|
|
4801
5301
|
throw new AppError(error.status, error.code, error.message, {
|
|
4802
5302
|
callId,
|
|
4803
5303
|
...(error.details && typeof error.details === "object" ? error.details : {}),
|
|
@@ -4807,7 +5307,7 @@ export class AiManager {
|
|
|
4807
5307
|
throw new AppError(502, "AI_CALL_FAILED", "AI 调用失败", { callId, failure: message, ...failureTarget });
|
|
4808
5308
|
}
|
|
4809
5309
|
}
|
|
4810
|
-
async readCompletionStream(response, protocol, apiKey, onDelta, onThinkingDelta) {
|
|
5310
|
+
async readCompletionStream(response, protocol, apiKey, onDelta, onThinkingDelta, onEvent) {
|
|
4811
5311
|
const protocolLabel = providerProtocolLabelText(protocol);
|
|
4812
5312
|
if (!response.body)
|
|
4813
5313
|
throw new Error(`${protocolLabel} 流式响应缺少正文`);
|
|
@@ -4880,10 +5380,10 @@ export class AiManager {
|
|
|
4880
5380
|
.join("\n")
|
|
4881
5381
|
.trim();
|
|
4882
5382
|
if (!data)
|
|
4883
|
-
return;
|
|
5383
|
+
return false;
|
|
4884
5384
|
if (data === "[DONE]") {
|
|
4885
5385
|
upstreamDone = true;
|
|
4886
|
-
return;
|
|
5386
|
+
return true;
|
|
4887
5387
|
}
|
|
4888
5388
|
const payload = JSON.parse(data);
|
|
4889
5389
|
const error = payload.error && typeof payload.error === "object" && !Array.isArray(payload.error)
|
|
@@ -4960,7 +5460,7 @@ export class AiManager {
|
|
|
4960
5460
|
}
|
|
4961
5461
|
if (type === "message_stop")
|
|
4962
5462
|
upstreamDone = true;
|
|
4963
|
-
return;
|
|
5463
|
+
return true;
|
|
4964
5464
|
}
|
|
4965
5465
|
const streamUsage = payload.usage && typeof payload.usage === "object" && !Array.isArray(payload.usage)
|
|
4966
5466
|
? payload.usage
|
|
@@ -5011,44 +5511,74 @@ export class AiManager {
|
|
|
5011
5511
|
if (typeof delta === "string" && delta.length > 0) {
|
|
5012
5512
|
appendContent(delta);
|
|
5013
5513
|
}
|
|
5514
|
+
return true;
|
|
5515
|
+
};
|
|
5516
|
+
let redactorsFlushed = false;
|
|
5517
|
+
const flushRedactors = (interrupted) => {
|
|
5518
|
+
if (redactorsFlushed)
|
|
5519
|
+
return;
|
|
5520
|
+
redactorsFlushed = true;
|
|
5521
|
+
const finalContent = contentRedactor.flush({ interrupted });
|
|
5522
|
+
if (finalContent) {
|
|
5523
|
+
content += finalContent;
|
|
5524
|
+
onDelta(finalContent);
|
|
5525
|
+
}
|
|
5526
|
+
const finalReasoning = reasoningRedactor.flush({ interrupted });
|
|
5527
|
+
if (finalReasoning) {
|
|
5528
|
+
reasoning += finalReasoning;
|
|
5529
|
+
onThinkingDelta(finalReasoning);
|
|
5530
|
+
}
|
|
5014
5531
|
};
|
|
5015
5532
|
let receivedBytes = 0;
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5533
|
+
let readerEnded = false;
|
|
5534
|
+
try {
|
|
5535
|
+
while (true) {
|
|
5536
|
+
let chunk;
|
|
5537
|
+
try {
|
|
5538
|
+
chunk = await reader.read();
|
|
5539
|
+
}
|
|
5540
|
+
catch (error) {
|
|
5541
|
+
if (error instanceof AppError)
|
|
5542
|
+
throw error;
|
|
5543
|
+
throw new AppError(502, "AI_STREAM_NETWORK_ERROR", "AI 上游流连接中断,已保留已生成内容");
|
|
5544
|
+
}
|
|
5545
|
+
if (chunk.value?.byteLength) {
|
|
5546
|
+
receivedBytes += chunk.value.byteLength;
|
|
5547
|
+
if (receivedBytes > AI_RESPONSE_MAX_BYTES) {
|
|
5548
|
+
await reader.cancel().catch(() => undefined);
|
|
5549
|
+
throw new AppError(502, "AI_RESPONSE_TOO_LARGE", `AI 供应商响应超过 ${AI_RESPONSE_MAX_BYTES} 字节上限`);
|
|
5550
|
+
}
|
|
5551
|
+
}
|
|
5552
|
+
buffer += decoder.decode(chunk.value, { stream: !chunk.done });
|
|
5553
|
+
const events = buffer.split(/\r?\n\r?\n/u);
|
|
5554
|
+
buffer = events.pop() ?? "";
|
|
5555
|
+
for (const eventText of events) {
|
|
5556
|
+
if (consumeEvent(eventText))
|
|
5557
|
+
onEvent();
|
|
5558
|
+
if (upstreamDone)
|
|
5559
|
+
break;
|
|
5560
|
+
}
|
|
5561
|
+
if (upstreamDone) {
|
|
5021
5562
|
await reader.cancel().catch(() => undefined);
|
|
5022
|
-
|
|
5563
|
+
buffer = "";
|
|
5564
|
+
break;
|
|
5023
5565
|
}
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
const events = buffer.split(/\r?\n\r?\n/u);
|
|
5027
|
-
buffer = events.pop() ?? "";
|
|
5028
|
-
for (const eventText of events) {
|
|
5029
|
-
consumeEvent(eventText);
|
|
5030
|
-
if (upstreamDone)
|
|
5566
|
+
if (chunk.done) {
|
|
5567
|
+
readerEnded = true;
|
|
5031
5568
|
break;
|
|
5569
|
+
}
|
|
5032
5570
|
}
|
|
5033
|
-
if (
|
|
5034
|
-
|
|
5035
|
-
buffer = "";
|
|
5036
|
-
break;
|
|
5037
|
-
}
|
|
5038
|
-
if (chunk.done)
|
|
5039
|
-
break;
|
|
5571
|
+
if (buffer.trim() && consumeEvent(buffer))
|
|
5572
|
+
onEvent();
|
|
5040
5573
|
}
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
|
|
5044
|
-
if (finalContent) {
|
|
5045
|
-
content += finalContent;
|
|
5046
|
-
onDelta(finalContent);
|
|
5574
|
+
catch (error) {
|
|
5575
|
+
flushRedactors(true);
|
|
5576
|
+
throw error;
|
|
5047
5577
|
}
|
|
5048
|
-
const
|
|
5049
|
-
|
|
5050
|
-
|
|
5051
|
-
|
|
5578
|
+
const upstreamClosed = readerEnded && !upstreamDone && finishReason === "unknown";
|
|
5579
|
+
flushRedactors(upstreamClosed);
|
|
5580
|
+
if (upstreamClosed) {
|
|
5581
|
+
throw new AppError(502, "AI_STREAM_UPSTREAM_CLOSED", "AI 上游流在正常结束前已关闭,已保留已生成内容");
|
|
5052
5582
|
}
|
|
5053
5583
|
const sortedOpenAiToolCalls = [...openAiToolCalls.entries()].sort(([left], [right]) => left - right);
|
|
5054
5584
|
const openAiToolCallsComplete = openAiToolCallsFinalized
|
|
@@ -5706,9 +6236,12 @@ export class AiManager {
|
|
|
5706
6236
|
return { interrupted: true, callIds };
|
|
5707
6237
|
const byChapterId = new Map(chapters.map((chapter) => [String(chapter.id), chapter]));
|
|
5708
6238
|
const groups = [];
|
|
6239
|
+
const preprocessingSkipped = [];
|
|
5709
6240
|
for (const candidate of rawCandidates) {
|
|
5710
|
-
if (typeof candidate.canonicalName !== "string" || !candidate.canonicalName.trim())
|
|
6241
|
+
if (typeof candidate.canonicalName !== "string" || !candidate.canonicalName.trim()) {
|
|
6242
|
+
preprocessingSkipped.push({ name: "未命名候选", reason: "角色标准名为空,未进入入库预览" });
|
|
5711
6243
|
continue;
|
|
6244
|
+
}
|
|
5712
6245
|
const name = candidate.canonicalName.normalize("NFKC").trim();
|
|
5713
6246
|
const aliases = (Array.isArray(candidate.aliases) ? candidate.aliases : [])
|
|
5714
6247
|
.filter((value) => typeof value === "string")
|
|
@@ -5720,8 +6253,10 @@ export class AiManager {
|
|
|
5720
6253
|
const chapterId = evidence && typeof evidence.chapterId === "string" ? evidence.chapterId : null;
|
|
5721
6254
|
const quote = evidence && typeof evidence.quote === "string" ? evidence.quote.trim() : "";
|
|
5722
6255
|
if (!chapterId || !quote || quote.length > 80 || !byChapterId.has(chapterId)
|
|
5723
|
-
|| !this.quoteExists(String(byChapterId.get(chapterId)?.content ?? ""), quote))
|
|
6256
|
+
|| !this.quoteExists(String(byChapterId.get(chapterId)?.content ?? ""), quote)) {
|
|
6257
|
+
preprocessingSkipped.push({ name: name.slice(0, 200), reason: "首次出现证据无效或无法在本次正文范围内核验" });
|
|
5724
6258
|
continue;
|
|
6259
|
+
}
|
|
5725
6260
|
const refs = new Set([name, ...aliases].map((value) => this.normalizeReference(value)));
|
|
5726
6261
|
const matches = groups.filter((group) => [...refs].some((value) => group.references.has(value)));
|
|
5727
6262
|
const group = matches[0] ?? {
|
|
@@ -5818,7 +6353,7 @@ export class AiManager {
|
|
|
5818
6353
|
addVerificationPair(candidateSubjects[leftIndex], existing);
|
|
5819
6354
|
}
|
|
5820
6355
|
}
|
|
5821
|
-
const skipped = [];
|
|
6356
|
+
const skipped = [...preprocessingSkipped];
|
|
5822
6357
|
let verificationCallId = null;
|
|
5823
6358
|
let confirmedSameCount = 0;
|
|
5824
6359
|
let confirmedSeparateCount = 0;
|
|
@@ -5940,50 +6475,42 @@ export class AiManager {
|
|
|
5940
6475
|
if (!blockedReasons.has(root) && blockedReasons.has(index))
|
|
5941
6476
|
blockedReasons.set(root, blockedReasons.get(index));
|
|
5942
6477
|
}
|
|
5943
|
-
const
|
|
6478
|
+
const characterCandidates = [];
|
|
5944
6479
|
for (const [root, group] of mergedGroups) {
|
|
5945
6480
|
if (blockedGroups.has(root)) {
|
|
5946
6481
|
skipped.push({ name: group.name, reason: blockedReasons.get(root) ?? "角色身份二次确认未通过" });
|
|
5947
6482
|
continue;
|
|
5948
6483
|
}
|
|
5949
6484
|
const aliases = [...group.aliases].filter((alias) => this.isSafeGlobalAlias(alias));
|
|
5950
|
-
const extractedRaceId = group.species ? this.store.resolveRaceReference(workId, group.species) : null;
|
|
5951
6485
|
const existingId = existingIdByRoot.get(root) ?? [group.name, ...aliases]
|
|
5952
6486
|
.map((value) => this.store.resolveCharacterReference(workId, value))
|
|
5953
6487
|
.find((value) => Boolean(value));
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
5957
|
-
|
|
5958
|
-
|
|
5959
|
-
|
|
5960
|
-
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
5964
|
-
|
|
5965
|
-
|
|
5966
|
-
}
|
|
5967
|
-
else {
|
|
5968
|
-
const created = this.store.createCharacter(workId, {
|
|
5969
|
-
name: group.name,
|
|
5970
|
-
aliases,
|
|
5971
|
-
raceId: extractedRaceId,
|
|
5972
|
-
attributes: group.identity ? { identity: group.identity } : {},
|
|
5973
|
-
firstChapterId: group.firstChapterId
|
|
5974
|
-
});
|
|
5975
|
-
characterIds.push(String(created.id));
|
|
5976
|
-
}
|
|
5977
|
-
}
|
|
5978
|
-
catch (error) {
|
|
5979
|
-
skipped.push({ name: group.name, reason: error instanceof Error ? error.message : "名称冲突" });
|
|
5980
|
-
}
|
|
6488
|
+
const candidate = normalizeCharacterExtractionCandidate({
|
|
6489
|
+
name: group.name,
|
|
6490
|
+
aliases,
|
|
6491
|
+
species: group.species,
|
|
6492
|
+
identity: group.identity,
|
|
6493
|
+
firstChapterId: group.firstChapterId,
|
|
6494
|
+
firstEvidence: group.firstEvidence,
|
|
6495
|
+
stableCharacterId: existingId ?? null
|
|
6496
|
+
}, characterCandidates.length);
|
|
6497
|
+
if (candidate)
|
|
6498
|
+
characterCandidates.push(candidate);
|
|
6499
|
+
else
|
|
6500
|
+
skipped.push({ name: group.name.slice(0, 200), reason: "候选名称或属性不符合角色档案字段限制" });
|
|
5981
6501
|
}
|
|
6502
|
+
const generatedAt = now();
|
|
5982
6503
|
return {
|
|
5983
|
-
characterIds: [
|
|
5984
|
-
|
|
5985
|
-
|
|
6504
|
+
characterIds: [],
|
|
6505
|
+
characterCandidates,
|
|
6506
|
+
candidateCount: characterCandidates.length,
|
|
6507
|
+
savedCount: 0,
|
|
5986
6508
|
skipped,
|
|
6509
|
+
characterApplication: {
|
|
6510
|
+
status: "pending",
|
|
6511
|
+
totalCount: characterCandidates.length,
|
|
6512
|
+
generatedAt
|
|
6513
|
+
},
|
|
5987
6514
|
batchCount: chunks.length,
|
|
5988
6515
|
coveredChapterCount: chapters.length,
|
|
5989
6516
|
fallbackSegmentCount,
|
|
@@ -8634,6 +9161,53 @@ export class AiManager {
|
|
|
8634
9161
|
throw new AppError(500, "CREDENTIAL_DECRYPT_FAILED", "供应商凭据无法解密,请重新填写密钥或服务账号 JSON");
|
|
8635
9162
|
}
|
|
8636
9163
|
}
|
|
9164
|
+
providerConnectivityTestFingerprint(row) {
|
|
9165
|
+
const localModels = this.store.db.all("SELECT * FROM models WHERE provider_id = ? ORDER BY created_at, id", stringValue(row, "id")).map((model) => [
|
|
9166
|
+
stringValue(model, "id"),
|
|
9167
|
+
connectivityConfigurationValues(model, modelConnectivityConfigurationFields)
|
|
9168
|
+
]);
|
|
9169
|
+
return hashAiConnectivityConfiguration([
|
|
9170
|
+
"provider-connectivity-v1",
|
|
9171
|
+
connectivityConfigurationValues(row, providerConnectivityConfigurationFields),
|
|
9172
|
+
localModels
|
|
9173
|
+
]);
|
|
9174
|
+
}
|
|
9175
|
+
acquireProviderConnectivityTest(providerId) {
|
|
9176
|
+
const acquired = this.connectivityTestGate.acquireWithConfiguration("provider", providerId, () => {
|
|
9177
|
+
const row = this.getProviderRow(providerId);
|
|
9178
|
+
const configFingerprint = this.providerConnectivityTestFingerprint(row);
|
|
9179
|
+
return { configFingerprint, configuration: row };
|
|
9180
|
+
});
|
|
9181
|
+
return {
|
|
9182
|
+
row: acquired.configuration,
|
|
9183
|
+
configFingerprint: acquired.configFingerprint,
|
|
9184
|
+
claim: acquired.claim
|
|
9185
|
+
};
|
|
9186
|
+
}
|
|
9187
|
+
modelConnectivityTestFingerprint(model, provider) {
|
|
9188
|
+
return hashAiConnectivityConfiguration([
|
|
9189
|
+
"model-connectivity-v1",
|
|
9190
|
+
connectivityConfigurationValues(provider, providerConnectivityConfigurationFields),
|
|
9191
|
+
connectivityConfigurationValues(model, modelConnectivityConfigurationFields)
|
|
9192
|
+
]);
|
|
9193
|
+
}
|
|
9194
|
+
acquireModelConnectivityTest(modelId) {
|
|
9195
|
+
const acquired = this.connectivityTestGate.acquireWithConfiguration("model", modelId, () => {
|
|
9196
|
+
const model = this.getModelRow(modelId);
|
|
9197
|
+
const providerId = stringValue(model, "provider_id");
|
|
9198
|
+
const provider = this.getProviderRow(providerId);
|
|
9199
|
+
const configFingerprint = this.modelConnectivityTestFingerprint(model, provider);
|
|
9200
|
+
return {
|
|
9201
|
+
configFingerprint,
|
|
9202
|
+
configuration: { model, provider, providerId }
|
|
9203
|
+
};
|
|
9204
|
+
});
|
|
9205
|
+
return {
|
|
9206
|
+
...acquired.configuration,
|
|
9207
|
+
configFingerprint: acquired.configFingerprint,
|
|
9208
|
+
claim: acquired.claim
|
|
9209
|
+
};
|
|
9210
|
+
}
|
|
8637
9211
|
getProviderRow(providerId) {
|
|
8638
9212
|
const row = this.store.db.get("SELECT * FROM providers WHERE id = ?", providerId);
|
|
8639
9213
|
if (!row)
|