@musnows/scriverse 0.7.2 → 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 +8 -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 +926 -354
- package/dist/ai.js.map +1 -1
- package/dist/app.js +498 -80
- package/dist/app.js.map +1 -1
- package/dist/attachment-storage.js +28 -12
- package/dist/attachment-storage.js.map +1 -1
- package/dist/backup-encryption.js +129 -0
- package/dist/backup-encryption.js.map +1 -0
- 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/collaboration-presence.js +200 -18
- package/dist/collaboration-presence.js.map +1 -1
- package/dist/database.js +335 -3
- package/dist/database.js.map +1 -1
- package/dist/docx-export.js +1 -1
- package/dist/docx-export.js.map +1 -1
- package/dist/domain.js +18 -0
- package/dist/domain.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/image-metadata.js +17 -2
- package/dist/image-metadata.js.map +1 -1
- package/dist/presence-store.js +160 -0
- package/dist/presence-store.js.map +1 -0
- package/dist/public/ai-connectivity-test.d.ts +7 -0
- package/dist/public/ai-connectivity-test.js +82 -0
- package/dist/public/ai-context-meter.js +27 -0
- package/dist/public/ai-mentions.js +14 -0
- package/dist/public/ai-request-manager.js +99 -0
- package/dist/public/ai-stream-protocol.js +51 -0
- package/dist/public/app.js +3587 -559
- package/dist/public/background-task-center.js +1 -1
- package/dist/public/chapter-editor-virtualization.js +52 -0
- 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 +161 -31
- 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/presence-client-id.d.ts +10 -0
- package/dist/public/presence-client-id.js +31 -0
- package/dist/public/reading-preview.d.ts +32 -0
- package/dist/public/reading-preview.js +136 -0
- package/dist/public/s3-backup-ui.d.ts +10 -0
- package/dist/public/s3-backup-ui.js +29 -0
- package/dist/public/setting-filters.d.ts +16 -0
- package/dist/public/setting-filters.js +19 -0
- package/dist/public/styles.css +522 -11
- package/dist/public/upload-progress.d.ts +2 -0
- package/dist/public/upload-progress.js +10 -0
- package/dist/s3-backup.js +197 -12
- package/dist/s3-backup.js.map +1 -1
- package/dist/security.js +86 -21
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +38 -18
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +899 -104
- package/dist/store.js.map +1 -1
- package/dist/upload-limits.js +35 -0
- package/dist/upload-limits.js.map +1 -0
- package/dist/user-auth.js +56 -10
- 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 +4 -2
package/dist/ai.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { ANALYSIS_TASK_TYPES, HISTORICAL_ANALYSIS_TASK_TYPES } from "./domain.js";
|
|
1
2
|
import { buildCompletionRequestBody, isAiProviderProtocol, normalizeProviderBaseUrl, parseCompletionPayload, providerCompletionEndpoint, providerModelEndpoints, providerProtocolLabelText, providerRequestHeaders } from "./ai-protocol.js";
|
|
2
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";
|
|
3
7
|
import { PLATFORM_AI_WORK_ID } from "./database.js";
|
|
4
8
|
import { AppError, notFound } from "./errors.js";
|
|
5
9
|
import { assertOfficialGoogleVertexBaseUrl, fetchGoogleOAuthAccessToken, GoogleVertexTokenCache, maskServiceAccountHint, parseGoogleServiceAccount } from "./google-vertex-auth.js";
|
|
6
|
-
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";
|
|
7
11
|
import { logger, sanitizeError } from "./logger.js";
|
|
8
12
|
import { paginated, paginationSql } from "./pagination.js";
|
|
9
13
|
import { currentRequestActor } from "./request-context.js";
|
|
@@ -24,10 +28,89 @@ export function aiErrorForLog(error) {
|
|
|
24
28
|
return { name: sanitized.name ?? "Error", message: "Provider returned invalid JSON" };
|
|
25
29
|
return sanitized;
|
|
26
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
|
+
}
|
|
27
49
|
const AUTO_RUN_MAX_ATTEMPTS = 3;
|
|
28
50
|
const AUTO_RUN_RETRY_DELAYS_MS = [5_000, 30_000];
|
|
29
51
|
const AI_INTERACTIVE_TIMEOUT_MS = 60_000;
|
|
30
52
|
const AI_LONG_RUNNING_TIMEOUT_MS = 300_000;
|
|
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
|
+
}
|
|
108
|
+
function isAnalysisTaskType(value) {
|
|
109
|
+
return analysisTaskTypes.has(value);
|
|
110
|
+
}
|
|
111
|
+
function unsupportedTaskType(taskType) {
|
|
112
|
+
return new AppError(400, "UNSUPPORTED_TASK_TYPE", `不支持的任务类型:${taskType}`);
|
|
113
|
+
}
|
|
31
114
|
// A small but non-transparent 128x128 PNG. The model test must exercise an actual image_url
|
|
32
115
|
// payload, while keeping the request cheap and avoiding any user data in the probe.
|
|
33
116
|
const MULTIMODAL_TEST_IMAGE_DATA_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAACXBIWXMAAAPoAAAD6AG1e1JrAAACfklEQVR4nO2cwY3EQBACJ8LOglRJyw4DJOpR/xOUuF17Zp91H9xsBi/9B8AhABIcC4AEx78AJDg+AyDB8SEQCY5vAUhwfA1EguM5ABIcD4KQ4HgSiATHo2AkON4FIMHxMggJjreBSHC8DkaC4zwAEhwHQpDgOBGEBMeRMCQ4zgQiwXEoFAmOU8FIcBwLR4LjXgASHBdDkOC4GYQEx9UwJDjuBiLBcTkUCY7bwUhwXA8319P5fQCPS8APRChfAgIUBOFRWADlS0CAgiA8CgugfAkIUBCER2EBlC8BAQqC8CgsgPIlIEBBEB6FBVC+BAQoCMKjsADKl4AABUF4FBZA+RIQoCAIj8ICKF8CAhQE4VFYAOVLQICCIDwKC6B8CQhQEIRHYQGULwEBCoLwKCyA8iUgQEEQHoUFUL4EBCgIwqOwAMqXgAAFQXgUFkD5EhCgIAiPwgIoXwICFAThUVgA5UtAgIIgPAoLoHwJCFAQhEdhAZQvAQEKgvAoLIDyJSBAQRAehQVQvgQEKAjCo7AAypeAAAVBeBQWQPkSEKAgCI/CAihfAgIUBOFRWADlS0CAgiA8CgugfAkIUBCER2EBlC8BAQqC8CgsgPIlIEBBEB6FBVC+BAQoCMKjsADKl4AABUF4FBZA+RIQoCAIj8ICKF8CAhQE4VFYAOVLQICCIDwKC6B8CQhQEIRHYQGULwEBCoLwKCyA8iUgQEEQHoUFUL4EBCgIwqOwAMqXgAAFQXgUFkD5EhCgIAiPwgIoXwICFAThUVgA5UtAgIIgPAoLoHwJCFAQhEdhAZQvAQEKgvAoLIDyJSBAQRAehQVQvgQEKAjCo7AAypeAAAVBeJQfFY4JQ620WGEAAAAASUVORK5CYII=";
|
|
@@ -246,7 +329,7 @@ function redactProviderSecrets(value, secrets, depth = 0) {
|
|
|
246
329
|
return null;
|
|
247
330
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactProviderSecrets(item, list, depth + 1)]));
|
|
248
331
|
}
|
|
249
|
-
class ProviderSecretStreamRedactor {
|
|
332
|
+
export class ProviderSecretStreamRedactor {
|
|
250
333
|
pending = "";
|
|
251
334
|
secrets;
|
|
252
335
|
constructor(apiKey) {
|
|
@@ -267,10 +350,21 @@ class ProviderSecretStreamRedactor {
|
|
|
267
350
|
this.pending = retainedLength > 0 ? combined.slice(-retainedLength) : "";
|
|
268
351
|
return retainedLength > 0 ? combined.slice(0, -retainedLength) : combined;
|
|
269
352
|
}
|
|
270
|
-
flush() {
|
|
271
|
-
const
|
|
353
|
+
flush(options = {}) {
|
|
354
|
+
const pending = this.pending;
|
|
355
|
+
const value = redactProviderSecretsText(pending, ...this.secrets);
|
|
272
356
|
this.pending = "";
|
|
273
|
-
|
|
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)}*****`;
|
|
274
368
|
}
|
|
275
369
|
}
|
|
276
370
|
function sanitizeCompletionTraceResponse(value) {
|
|
@@ -330,7 +424,7 @@ const grepArguments = z.object({
|
|
|
330
424
|
cursor: agentToolCursor
|
|
331
425
|
}).strict();
|
|
332
426
|
const searchStoryEntitiesArguments = z.object({
|
|
333
|
-
query: z.string().trim().min(1).max(
|
|
427
|
+
query: z.string().trim().min(1).max(MAXIMUM_WORK_SEARCH_QUERY_LENGTH),
|
|
334
428
|
categories: z.array(z.enum(["setting", "character", "race", "organization", "timeline", "relationship", "outline", "foreshadow"])).max(8).default([]),
|
|
335
429
|
limit: z.number().int().min(1).max(30).default(30),
|
|
336
430
|
cursor: agentToolCursor
|
|
@@ -395,7 +489,7 @@ const AGENT_TOOL_DEFINITIONS = {
|
|
|
395
489
|
function: {
|
|
396
490
|
name: "search_story_entities",
|
|
397
491
|
description: "按短关键词在结构化作品实体中进行元数据、精确全文和拼音混合检索:设定、人物(含 Markdown 档案章节)、种族、组织、时间线、关系、大纲和伏笔。人物、种族、组织结果分别包含权威布尔状态 isDead、isExtinct、isDissolved;只有值为 true 才能判定该角色已死亡、该种族已灭绝或该组织已解散,字段为 false 时必须视为仍存活、未灭绝或未解散,禁止根据正文情节自行改判。不是语义问答;请传入实体名、别名、标题、拼音或短关键词,不要传入自然语言整句。结果按综合相关度排序;人物结果含 sectionId 时可再调用 read_character_sections 精读。无匹配时改用更短关键词,或改用 story_index / grep。",
|
|
398
|
-
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 }
|
|
399
493
|
}
|
|
400
494
|
},
|
|
401
495
|
read_character_sections: {
|
|
@@ -684,6 +778,36 @@ function numberValue(row, key) {
|
|
|
684
778
|
function boolValue(row, key) {
|
|
685
779
|
return Number(row[key] ?? 0) === 1;
|
|
686
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
|
+
}
|
|
687
811
|
function safeJsonObject(value) {
|
|
688
812
|
return json(value, {});
|
|
689
813
|
}
|
|
@@ -1417,6 +1541,7 @@ export class AiManager {
|
|
|
1417
1541
|
authorizeTaskRun;
|
|
1418
1542
|
attachmentStorage;
|
|
1419
1543
|
contextBuilder;
|
|
1544
|
+
interactiveStreamIdleTimeoutMs;
|
|
1420
1545
|
taskControllers = new Map();
|
|
1421
1546
|
autoRunStarting = new Map();
|
|
1422
1547
|
autoRunTimers = new Map();
|
|
@@ -1430,13 +1555,19 @@ export class AiManager {
|
|
|
1430
1555
|
relationshipIndexDisposed = false;
|
|
1431
1556
|
providerSchedules = new Map();
|
|
1432
1557
|
vertexTokenCache = new GoogleVertexTokenCache();
|
|
1433
|
-
|
|
1558
|
+
connectivityTestGate;
|
|
1559
|
+
constructor(store, vault, fetchImpl = fetch, validateOutboundUrl, authorizeTaskRun, attachmentStorage, options = {}) {
|
|
1434
1560
|
this.store = store;
|
|
1435
1561
|
this.vault = vault;
|
|
1436
1562
|
this.fetchImpl = fetchImpl;
|
|
1437
1563
|
this.validateOutboundUrl = validateOutboundUrl;
|
|
1438
1564
|
this.authorizeTaskRun = authorizeTaskRun;
|
|
1439
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;
|
|
1440
1571
|
this.contextBuilder = new ContextBuilder(store);
|
|
1441
1572
|
this.store.setAnalysisTaskQueuedHandler((workId) => this.scheduleAutoRun(workId));
|
|
1442
1573
|
this.autoRunStartupTimer = setTimeout(() => {
|
|
@@ -1449,7 +1580,7 @@ export class AiManager {
|
|
|
1449
1580
|
this.relationshipIndexTimer = null;
|
|
1450
1581
|
void this.schedulePendingRelationshipIndexes();
|
|
1451
1582
|
}, 0);
|
|
1452
|
-
logger.info("ai.manager.ready");
|
|
1583
|
+
logger.info("ai.manager.ready", { interactiveStreamIdleTimeoutMs: this.interactiveStreamIdleTimeoutMs });
|
|
1453
1584
|
}
|
|
1454
1585
|
getPlatformTokenUsage(timezoneOffset) {
|
|
1455
1586
|
return this.getTokenUsage(null, timezoneOffset, true);
|
|
@@ -1482,7 +1613,7 @@ export class AiManager {
|
|
|
1482
1613
|
}
|
|
1483
1614
|
async searchWork(workId, query, options = {}) {
|
|
1484
1615
|
this.store.getWork(workId);
|
|
1485
|
-
const normalizedQuery =
|
|
1616
|
+
const normalizedQuery = normalizeWorkSearchQuery(query);
|
|
1486
1617
|
if (!normalizedQuery)
|
|
1487
1618
|
return [];
|
|
1488
1619
|
const requestedTypes = options.type ? new Set([options.type]) : new Set(HYBRID_SEARCH_TYPES);
|
|
@@ -1929,7 +2060,7 @@ export class AiManager {
|
|
|
1929
2060
|
failures: [{ message, ...(error instanceof AppError ? { code: error.code } : {}) }]
|
|
1930
2061
|
});
|
|
1931
2062
|
}
|
|
1932
|
-
if (current.status !== "partial")
|
|
2063
|
+
if (current.status !== "partial" && current.status !== "failed")
|
|
1933
2064
|
return;
|
|
1934
2065
|
const disposition = autoRunFailureDisposition(error, Number(current.attemptCount));
|
|
1935
2066
|
const settings = this.store.recordAutoRunFailure(workId, message, disposition.pauseImmediately);
|
|
@@ -2067,7 +2198,7 @@ export class AiManager {
|
|
|
2067
2198
|
this.vertexTokenCache.clear(providerId);
|
|
2068
2199
|
}
|
|
2069
2200
|
async testProvider(providerId) {
|
|
2070
|
-
const row = this.
|
|
2201
|
+
const { row, configFingerprint, claim } = this.acquireProviderConnectivityTest(providerId);
|
|
2071
2202
|
const protocol = providerProtocol(row);
|
|
2072
2203
|
const controller = new AbortController();
|
|
2073
2204
|
const timeout = setTimeout(() => controller.abort(), AI_INTERACTIVE_TIMEOUT_MS);
|
|
@@ -2115,39 +2246,62 @@ export class AiManager {
|
|
|
2115
2246
|
: `${lastFailure};也可先添加模型后再测试连接`);
|
|
2116
2247
|
}
|
|
2117
2248
|
await this.probeProviderModel(row, accessToken, probeModel, controller.signal);
|
|
2118
|
-
const
|
|
2119
|
-
|
|
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
|
+
});
|
|
2120
2262
|
logger.info("ai.provider_test.completed", {
|
|
2121
2263
|
providerId,
|
|
2122
2264
|
protocol,
|
|
2123
2265
|
ok: true,
|
|
2266
|
+
cooldownApplied: cooldown.reason !== "configuration_changed",
|
|
2124
2267
|
availableModelCount: availableModels.length,
|
|
2125
2268
|
durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000
|
|
2126
2269
|
});
|
|
2127
|
-
return { ok: true, availableModels, provider: this.getProvider(providerId) };
|
|
2270
|
+
return { ok: true, availableModels, cooldown, provider: this.getProvider(providerId) };
|
|
2128
2271
|
}
|
|
2129
2272
|
catch (error) {
|
|
2130
2273
|
const message = error instanceof Error
|
|
2131
2274
|
? redactProviderSecretsText(error.message, credentialSecret, accessToken)
|
|
2132
2275
|
: "连接失败";
|
|
2133
|
-
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
|
+
});
|
|
2134
2289
|
logger.warn("ai.provider_test.completed", {
|
|
2135
2290
|
providerId,
|
|
2136
2291
|
protocol,
|
|
2137
2292
|
ok: false,
|
|
2293
|
+
cooldownApplied: cooldown.reason !== "configuration_changed",
|
|
2138
2294
|
durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000,
|
|
2139
|
-
error:
|
|
2295
|
+
error: connectivityTestErrorForLog(error)
|
|
2140
2296
|
});
|
|
2141
|
-
return { ok: false, error: message, provider: this.getProvider(providerId) };
|
|
2297
|
+
return { ok: false, error: message, cooldown, provider: this.getProvider(providerId) };
|
|
2142
2298
|
}
|
|
2143
2299
|
finally {
|
|
2144
2300
|
clearTimeout(timeout);
|
|
2145
2301
|
}
|
|
2146
2302
|
}
|
|
2147
2303
|
async testModel(modelId) {
|
|
2148
|
-
const model = this.
|
|
2149
|
-
const providerId = stringValue(model, "provider_id");
|
|
2150
|
-
const provider = this.getProviderRow(providerId);
|
|
2304
|
+
const { model, provider, providerId, configFingerprint, claim } = this.acquireModelConnectivityTest(modelId);
|
|
2151
2305
|
const controller = new AbortController();
|
|
2152
2306
|
const timeout = setTimeout(() => controller.abort(), AI_INTERACTIVE_TIMEOUT_MS);
|
|
2153
2307
|
const startedAt = process.hrtime.bigint();
|
|
@@ -2159,31 +2313,60 @@ export class AiManager {
|
|
|
2159
2313
|
try {
|
|
2160
2314
|
({ accessToken, credentialSecret } = await this.resolveProviderAccessToken(provider));
|
|
2161
2315
|
await this.probeProviderModel(provider, accessToken, stringValue(model, "model_id"), controller.signal, { multimodal: multimodalTested });
|
|
2162
|
-
const
|
|
2163
|
-
|
|
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
|
+
});
|
|
2164
2331
|
logger.info("ai.model_test.completed", {
|
|
2165
2332
|
modelId,
|
|
2166
2333
|
providerId,
|
|
2167
2334
|
protocol,
|
|
2168
2335
|
ok: true,
|
|
2336
|
+
cooldownApplied: cooldown.reason !== "configuration_changed",
|
|
2169
2337
|
durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000
|
|
2170
2338
|
});
|
|
2171
|
-
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) };
|
|
2172
2340
|
}
|
|
2173
2341
|
catch (error) {
|
|
2174
2342
|
const message = error instanceof Error
|
|
2175
2343
|
? redactProviderSecretsText(error.message, credentialSecret, accessToken)
|
|
2176
2344
|
: "连接失败";
|
|
2177
|
-
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
|
+
});
|
|
2178
2360
|
logger.warn("ai.model_test.completed", {
|
|
2179
2361
|
modelId,
|
|
2180
2362
|
providerId,
|
|
2181
2363
|
protocol,
|
|
2182
2364
|
ok: false,
|
|
2365
|
+
cooldownApplied: cooldown.reason !== "configuration_changed",
|
|
2183
2366
|
durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000,
|
|
2184
|
-
error:
|
|
2367
|
+
error: connectivityTestErrorForLog(error)
|
|
2185
2368
|
});
|
|
2186
|
-
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) };
|
|
2187
2370
|
}
|
|
2188
2371
|
finally {
|
|
2189
2372
|
clearTimeout(timeout);
|
|
@@ -2369,6 +2552,295 @@ export class AiManager {
|
|
|
2369
2552
|
...(modelId ? { modelId } : {})
|
|
2370
2553
|
});
|
|
2371
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
|
+
}
|
|
2372
2844
|
applyRelationshipChangePreview(taskId) {
|
|
2373
2845
|
const task = this.store.getTask(taskId);
|
|
2374
2846
|
if (task.taskType !== "relationship-analysis") {
|
|
@@ -2549,7 +3021,11 @@ export class AiManager {
|
|
|
2549
3021
|
}
|
|
2550
3022
|
rerunTask(taskId, modelOverrideId) {
|
|
2551
3023
|
const original = this.store.getTask(taskId);
|
|
2552
|
-
const
|
|
3024
|
+
const originalTaskType = String(original.taskType);
|
|
3025
|
+
if (HISTORICAL_ANALYSIS_TASK_TYPES.some((taskType) => taskType === originalTaskType)) {
|
|
3026
|
+
throw new AppError(409, "TASK_NOT_RERUNNABLE", `任务类型“${originalTaskType}”已经不支持重跑`);
|
|
3027
|
+
}
|
|
3028
|
+
const rerunnableStatuses = new Set(["review", "completed", "partial", "failed", "expired", "cancelled"]);
|
|
2553
3029
|
if (!rerunnableStatuses.has(String(original.status))) {
|
|
2554
3030
|
throw new AppError(409, "TASK_NOT_RERUNNABLE", "只有已结束的分析任务可以按原配置重跑");
|
|
2555
3031
|
}
|
|
@@ -2565,7 +3041,7 @@ export class AiManager {
|
|
|
2565
3041
|
if (modelId)
|
|
2566
3042
|
this.resolveModel(String(original.workId), this.analysisTaskModelPurpose(String(original.taskType)), modelId);
|
|
2567
3043
|
const rerun = this.store.createTask(String(original.workId), {
|
|
2568
|
-
taskType:
|
|
3044
|
+
taskType: originalTaskType,
|
|
2569
3045
|
scope,
|
|
2570
3046
|
...(modelId ? { modelId } : {}),
|
|
2571
3047
|
rerunOfTaskId: taskId
|
|
@@ -2618,12 +3094,7 @@ export class AiManager {
|
|
|
2618
3094
|
&& firstUserContent
|
|
2619
3095
|
&& titleModelId
|
|
2620
3096
|
&& (conversationBefore?.title === "新对话" || conversationBefore?.title === defaultTitle));
|
|
2621
|
-
const
|
|
2622
|
-
const generated = chatTools.length
|
|
2623
|
-
? await this.generate({ ...input, taskType: "chat" })
|
|
2624
|
-
: await this.generateStream({ ...input, taskType: "chat" }, onDelta);
|
|
2625
|
-
if (chatTools.length)
|
|
2626
|
-
onDelta(generated.content);
|
|
3097
|
+
const generated = await this.generate({ ...input, taskType: "chat" }, onDelta);
|
|
2627
3098
|
const chapter = input.scope.chapterId ? this.store.getChapter(input.scope.chapterId) : null;
|
|
2628
3099
|
const suggestionId = id("suggestion");
|
|
2629
3100
|
this.store.db.run(`INSERT INTO ai_suggestions (id, call_id, work_id, chapter_id, chapter_version, task_type, instruction,
|
|
@@ -2984,7 +3455,10 @@ export class AiManager {
|
|
|
2984
3455
|
const taskController = new AbortController();
|
|
2985
3456
|
this.taskControllers.set(taskId, taskController);
|
|
2986
3457
|
try {
|
|
2987
|
-
const
|
|
3458
|
+
const taskTypeValue = String(task.taskType);
|
|
3459
|
+
if (!isAnalysisTaskType(taskTypeValue))
|
|
3460
|
+
throw unsupportedTaskType(taskTypeValue);
|
|
3461
|
+
const taskType = taskTypeValue;
|
|
2988
3462
|
const scope = task.scope;
|
|
2989
3463
|
let result;
|
|
2990
3464
|
if (taskType === "chapter-analysis") {
|
|
@@ -3011,11 +3485,11 @@ export class AiManager {
|
|
|
3011
3485
|
else if (taskType === "consistency-check") {
|
|
3012
3486
|
result = await this.runConsistencyCheck(workId, scope, selectedModelId, taskId);
|
|
3013
3487
|
}
|
|
3014
|
-
else {
|
|
3488
|
+
else if (taskType === "book-analysis") {
|
|
3015
3489
|
const generated = await this.generate({
|
|
3016
3490
|
workId,
|
|
3017
3491
|
taskId,
|
|
3018
|
-
taskType:
|
|
3492
|
+
taskType: "book-analysis",
|
|
3019
3493
|
instruction: "请基于上下文完成分析,给出有原文依据的中文结论。",
|
|
3020
3494
|
scope,
|
|
3021
3495
|
signal: taskController.signal,
|
|
@@ -3023,6 +3497,9 @@ export class AiManager {
|
|
|
3023
3497
|
});
|
|
3024
3498
|
result = { content: generated.content, callId: generated.callId };
|
|
3025
3499
|
}
|
|
3500
|
+
else {
|
|
3501
|
+
throw unsupportedTaskType(taskType);
|
|
3502
|
+
}
|
|
3026
3503
|
if (!this.taskCanCommit(taskId)) {
|
|
3027
3504
|
logger.warn("ai.task.result_discarded", { taskId, workId, durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000 });
|
|
3028
3505
|
return this.store.getTask(taskId);
|
|
@@ -3054,7 +3531,8 @@ export class AiManager {
|
|
|
3054
3531
|
throw error;
|
|
3055
3532
|
}
|
|
3056
3533
|
}
|
|
3057
|
-
|
|
3534
|
+
const failedStatus = error instanceof AppError && error.code === "UNSUPPORTED_TASK_TYPE" ? "failed" : "partial";
|
|
3535
|
+
this.store.updateTask(taskId, { status: failedStatus, progress: 100, failures: [failure] });
|
|
3058
3536
|
logger.error("ai.task.failed", { taskId, workId, durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000, error: aiErrorForLog(error) });
|
|
3059
3537
|
throw error;
|
|
3060
3538
|
}
|
|
@@ -3178,24 +3656,42 @@ export class AiManager {
|
|
|
3178
3656
|
}
|
|
3179
3657
|
};
|
|
3180
3658
|
}
|
|
3181
|
-
|
|
3659
|
+
inspectConversationContext(input) {
|
|
3182
3660
|
const usage = this.getContextUsage({ ...input, taskType: "chat" });
|
|
3183
3661
|
const conversation = this.store.getAiConversationContext(input.conversationId, input.workId);
|
|
3184
3662
|
if (!usage.compactRecommended) {
|
|
3185
|
-
if (conversation.warningPending)
|
|
3186
|
-
this.store.setAiConversationContextWarning(input.conversationId, false);
|
|
3187
3663
|
return { action: "ready", usage: { ...usage, contextWarningPending: false } };
|
|
3188
3664
|
}
|
|
3189
3665
|
if (!conversation.warningPending) {
|
|
3190
|
-
this.store.setAiConversationContextWarning(input.conversationId, true);
|
|
3191
3666
|
return { action: "warn", usage: { ...usage, contextWarningPending: true } };
|
|
3192
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
|
+
}
|
|
3193
3682
|
const compaction = await this.compactConversation(input);
|
|
3194
3683
|
const compactedUsage = this.getContextUsage({ ...input, taskType: "chat" });
|
|
3195
3684
|
return { action: "compacted", usage: compactedUsage, compaction };
|
|
3196
3685
|
}
|
|
3686
|
+
/** 解析本轮消息中的自动角色提及;不使用会话累计排除集,也不改写累计注入状态。 */
|
|
3687
|
+
resolveInstructionMentions(input) {
|
|
3688
|
+
if (input.taskType !== "chat" || this.roleplayCharacterId(input.workId, input.conversationId))
|
|
3689
|
+
return input.scope;
|
|
3690
|
+
const matches = this.matchInstructionEntities(input.workId, input.instruction, input.scope, { characters: [], races: [], organizations: [] });
|
|
3691
|
+
return this.mergeInstructionEntityMatches(input.scope, matches);
|
|
3692
|
+
}
|
|
3197
3693
|
async compactConversation(input) {
|
|
3198
|
-
const conversation = this.store.getAiConversationContext(input.conversationId, input.workId);
|
|
3694
|
+
const conversation = this.store.getAiConversationContext(input.conversationId, input.workId, input.excludeConversationMessageId);
|
|
3199
3695
|
const { model } = this.resolveModel(input.workId, "chat", input.modelId);
|
|
3200
3696
|
const budget = this.contextBudget({ ...input, taskType: "chat", instruction: "" }, model);
|
|
3201
3697
|
const recentTokenBudget = Math.max(128, Math.floor(Number(budget.conversationBudgetTokens) * 0.75));
|
|
@@ -3404,13 +3900,10 @@ export class AiManager {
|
|
|
3404
3900
|
: this.applyKeywordEntityMentions(input.workId, input.instruction, baseScope, input.conversationId, persistKeywordInjections);
|
|
3405
3901
|
return this.contextBuilder.buildPlan(input.workId, scope, workContextBudgetTokens, bookSummaryMaximumTokens, input.instruction);
|
|
3406
3902
|
}
|
|
3407
|
-
|
|
3408
|
-
const injected = conversationId
|
|
3409
|
-
? this.store.getAiConversationInjectedEntities(conversationId, workId)
|
|
3410
|
-
: { characters: [], races: [], organizations: [] };
|
|
3903
|
+
matchInstructionEntities(workId, instruction, scope, injected) {
|
|
3411
3904
|
const proseSettingInfoOn = scope.suppressAutomaticContext !== true && (scope.includeSettingInfo === true || (PROSE_CONTEXT_SCOPE_TYPES.has(scope.type)
|
|
3412
3905
|
&& scope.includeSettingInfo !== false));
|
|
3413
|
-
|
|
3906
|
+
return matchKeywordEntities(this.store, workId, instruction, {
|
|
3414
3907
|
excludeCharacterIds: [
|
|
3415
3908
|
...(scope.characterIds ?? []),
|
|
3416
3909
|
...(scope.mentionCharacterIds ?? []),
|
|
@@ -3421,9 +3914,23 @@ export class AiManager {
|
|
|
3421
3914
|
// 正文范围已整表注入组织/种族时,关键词不再重复塞提及卡
|
|
3422
3915
|
skipRacesAndOrganizations: proseSettingInfoOn
|
|
3423
3916
|
});
|
|
3917
|
+
}
|
|
3918
|
+
mergeInstructionEntityMatches(scope, matches) {
|
|
3424
3919
|
const mentionCharacterIds = [...new Set([...(scope.mentionCharacterIds ?? []), ...matches.characterIds])];
|
|
3425
3920
|
const raceIds = [...new Set([...(scope.raceIds ?? []), ...matches.raceIds])];
|
|
3426
3921
|
const organizationIds = [...new Set([...(scope.organizationIds ?? []), ...matches.organizationIds])];
|
|
3922
|
+
return {
|
|
3923
|
+
...scope,
|
|
3924
|
+
...(mentionCharacterIds.length ? { mentionCharacterIds } : {}),
|
|
3925
|
+
...(raceIds.length ? { raceIds } : {}),
|
|
3926
|
+
...(organizationIds.length ? { organizationIds } : {})
|
|
3927
|
+
};
|
|
3928
|
+
}
|
|
3929
|
+
applyKeywordEntityMentions(workId, instruction, scope, conversationId, persist) {
|
|
3930
|
+
const injected = conversationId
|
|
3931
|
+
? this.store.getAiConversationInjectedEntities(conversationId, workId)
|
|
3932
|
+
: { characters: [], races: [], organizations: [] };
|
|
3933
|
+
const matches = this.matchInstructionEntities(workId, instruction, scope, injected);
|
|
3427
3934
|
if (persist && conversationId && (matches.characterIds.length || matches.raceIds.length || matches.organizationIds.length)) {
|
|
3428
3935
|
this.store.mergeAiConversationInjectedEntities(conversationId, workId, {
|
|
3429
3936
|
characters: matches.characterIds,
|
|
@@ -3431,12 +3938,7 @@ export class AiManager {
|
|
|
3431
3938
|
organizations: matches.organizationIds
|
|
3432
3939
|
});
|
|
3433
3940
|
}
|
|
3434
|
-
return
|
|
3435
|
-
...scope,
|
|
3436
|
-
...(mentionCharacterIds.length ? { mentionCharacterIds } : {}),
|
|
3437
|
-
...(raceIds.length ? { raceIds } : {}),
|
|
3438
|
-
...(organizationIds.length ? { organizationIds } : {})
|
|
3439
|
-
};
|
|
3941
|
+
return this.mergeInstructionEntityMatches(scope, matches);
|
|
3440
3942
|
}
|
|
3441
3943
|
buildContext(input, model) {
|
|
3442
3944
|
return collapseAiBlankLines(this.buildContextPlan(input, model, undefined, true).context);
|
|
@@ -4199,7 +4701,7 @@ export class AiManager {
|
|
|
4199
4701
|
extraSystemPrompt: [input.extraSystemPrompt, systemRequirement].filter(Boolean).join("\n")
|
|
4200
4702
|
});
|
|
4201
4703
|
}
|
|
4202
|
-
async generate(input) {
|
|
4704
|
+
async generate(input, onDelta) {
|
|
4203
4705
|
const generationRoleplayCharacterId = this.roleplayCharacterId(input.workId, input.conversationId);
|
|
4204
4706
|
const { model, provider } = this.resolveModel(input.workId, input.taskType, input.modelId);
|
|
4205
4707
|
const preset = safeJsonObject(stringValue(model, "preset_json"));
|
|
@@ -4271,12 +4773,13 @@ export class AiManager {
|
|
|
4271
4773
|
providerId: stringValue(provider, "id"),
|
|
4272
4774
|
modelId: stringValue(model, "id"),
|
|
4273
4775
|
protocol,
|
|
4274
|
-
streaming:
|
|
4776
|
+
streaming: Boolean(onDelta),
|
|
4275
4777
|
contextChars: context.length,
|
|
4276
4778
|
instructionChars: input.instruction.length,
|
|
4277
4779
|
toolCount: tools.length
|
|
4278
4780
|
});
|
|
4279
4781
|
let activeSecrets = [];
|
|
4782
|
+
let streamedContent = "";
|
|
4280
4783
|
let trackedInputTokens = 0;
|
|
4281
4784
|
let trackedOutputTokens = 0;
|
|
4282
4785
|
let trackedCachedInputTokens = 0;
|
|
@@ -4308,11 +4811,18 @@ export class AiManager {
|
|
|
4308
4811
|
let cacheUsageComplete = true;
|
|
4309
4812
|
let totalInputTokens = 0;
|
|
4310
4813
|
let totalCachedInputTokens = 0;
|
|
4814
|
+
const processSteps = [];
|
|
4815
|
+
const completionDelivery = new WeakMap();
|
|
4816
|
+
let streamingGenerationRound = 0;
|
|
4311
4817
|
const requestCompletion = async (toolChoice, options = {}) => {
|
|
4312
4818
|
const requestMessages = options.messages ?? completionMessages;
|
|
4313
4819
|
const requestParameters = options.parameters ?? parameters;
|
|
4314
4820
|
const purpose = options.purpose ?? "generation";
|
|
4315
4821
|
const requestTools = toolChoice === "auto" ? tools : [];
|
|
4822
|
+
const streamResponse = Boolean(onDelta) && purpose === "generation";
|
|
4823
|
+
const processRound = streamResponse ? streamingGenerationRound + 1 : 0;
|
|
4824
|
+
if (streamResponse)
|
|
4825
|
+
streamingGenerationRound = processRound;
|
|
4316
4826
|
const roundParameters = this.constrainParametersForDailyTokenQuota(input.workId, requestMessages, this.constrainParametersForContext(model, requestMessages, requestParameters, requestTools), requestTools, trackedInputTokens + trackedOutputTokens);
|
|
4317
4827
|
const traceRound = {
|
|
4318
4828
|
round: traceRounds.length + 1,
|
|
@@ -4330,9 +4840,11 @@ export class AiManager {
|
|
|
4330
4840
|
};
|
|
4331
4841
|
traceRounds.push(traceRound);
|
|
4332
4842
|
saveTrace();
|
|
4843
|
+
let streamedThinkingStep = null;
|
|
4333
4844
|
let lastFailure = null;
|
|
4334
4845
|
for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
|
|
4335
4846
|
let retryable = true;
|
|
4847
|
+
let attemptEmitted = false;
|
|
4336
4848
|
const attemptStartedAt = process.hrtime.bigint();
|
|
4337
4849
|
const traceAttempt = {
|
|
4338
4850
|
attempt,
|
|
@@ -4350,25 +4862,85 @@ export class AiManager {
|
|
|
4350
4862
|
forwardAbort();
|
|
4351
4863
|
else
|
|
4352
4864
|
input.signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
4353
|
-
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();
|
|
4354
4873
|
try {
|
|
4355
4874
|
const response = await this.outboundFetch(endpoint, {
|
|
4356
4875
|
method: "POST",
|
|
4357
|
-
headers: providerRequestHeaders(protocol, accessToken, "application/json"),
|
|
4876
|
+
headers: providerRequestHeaders(protocol, accessToken, streamResponse ? "text/event-stream" : "application/json"),
|
|
4358
4877
|
body: JSON.stringify(buildCompletionRequestBody({
|
|
4359
4878
|
protocol,
|
|
4360
4879
|
model: stringValue(model, "model_id"),
|
|
4361
4880
|
messages: requestMessages,
|
|
4362
4881
|
parameters: roundParameters,
|
|
4363
4882
|
tools: requestTools,
|
|
4364
|
-
toolChoice
|
|
4883
|
+
toolChoice,
|
|
4884
|
+
...(streamResponse ? { stream: true } : {})
|
|
4365
4885
|
})),
|
|
4366
4886
|
signal: controller.signal
|
|
4367
4887
|
});
|
|
4368
|
-
|
|
4888
|
+
responseReceived = true;
|
|
4889
|
+
if (!response.ok) {
|
|
4890
|
+
return { ok: false, status: response.status, body: await readResponseTextLimited(response) };
|
|
4891
|
+
}
|
|
4892
|
+
const isEventStream = response.headers.get("content-type")?.toLowerCase().includes("text/event-stream") ?? false;
|
|
4893
|
+
if (!streamResponse || !isEventStream) {
|
|
4894
|
+
const body = await readResponseTextLimited(response);
|
|
4895
|
+
try {
|
|
4896
|
+
const payload = parseCompletionPayload(protocol, redactProviderSecrets(JSON.parse(body), activeSecrets));
|
|
4897
|
+
return { ok: true, status: response.status, payload, delivery: "json" };
|
|
4898
|
+
}
|
|
4899
|
+
catch {
|
|
4900
|
+
throw new Error(`${providerProtocolLabelText(protocol)} returned invalid JSON: ${body.slice(0, 500)}`);
|
|
4901
|
+
}
|
|
4902
|
+
}
|
|
4903
|
+
const payload = await this.readCompletionStream(response, protocol, activeSecrets, (delta) => {
|
|
4904
|
+
attemptEmitted = true;
|
|
4905
|
+
streamedContent += delta;
|
|
4906
|
+
onDelta?.(delta);
|
|
4907
|
+
}, (delta) => {
|
|
4908
|
+
attemptEmitted = true;
|
|
4909
|
+
if (!streamedThinkingStep) {
|
|
4910
|
+
streamedThinkingStep = {
|
|
4911
|
+
id: id("process"),
|
|
4912
|
+
type: "thinking",
|
|
4913
|
+
round: processRound,
|
|
4914
|
+
content: "",
|
|
4915
|
+
createdAt: now()
|
|
4916
|
+
};
|
|
4917
|
+
processSteps.push(streamedThinkingStep);
|
|
4918
|
+
}
|
|
4919
|
+
streamedThinkingStep.content += delta;
|
|
4920
|
+
input.onProcessStep?.({ ...streamedThinkingStep, content: delta, append: true });
|
|
4921
|
+
}, () => streamWatchdog?.receivedEvent());
|
|
4922
|
+
streamWatchdog?.complete();
|
|
4923
|
+
return {
|
|
4924
|
+
ok: true,
|
|
4925
|
+
status: response.status,
|
|
4926
|
+
payload: redactProviderSecrets(payload, activeSecrets),
|
|
4927
|
+
delivery: "sse"
|
|
4928
|
+
};
|
|
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;
|
|
4369
4939
|
}
|
|
4370
4940
|
finally {
|
|
4371
|
-
|
|
4941
|
+
if (timeout)
|
|
4942
|
+
clearTimeout(timeout);
|
|
4943
|
+
streamWatchdog?.dispose();
|
|
4372
4944
|
input.signal?.removeEventListener("abort", forwardAbort);
|
|
4373
4945
|
}
|
|
4374
4946
|
});
|
|
@@ -4377,31 +4949,28 @@ export class AiManager {
|
|
|
4377
4949
|
attempt,
|
|
4378
4950
|
status: candidate.status,
|
|
4379
4951
|
ok: candidate.ok,
|
|
4380
|
-
durationMs: Number(process.hrtime.bigint() - attemptStartedAt) / 1_000_000
|
|
4952
|
+
durationMs: Number(process.hrtime.bigint() - attemptStartedAt) / 1_000_000,
|
|
4953
|
+
streaming: streamResponse
|
|
4381
4954
|
});
|
|
4382
4955
|
if (candidate.ok) {
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
}
|
|
4398
|
-
const outputText = completionPayloadOutputText(parsed);
|
|
4399
|
-
trackUsage(resolveAiTokenUsage(parsed.usage, estimateAiTokens(JSON.stringify(requestMessages)), outputText ? estimateAiTokens(outputText) : 0));
|
|
4400
|
-
return parsed;
|
|
4401
|
-
}
|
|
4402
|
-
catch {
|
|
4403
|
-
throw new Error(`${providerProtocolLabelText(protocol)} returned invalid JSON: ${candidate.body.slice(0, 500)}`);
|
|
4956
|
+
const parsed = candidate.payload;
|
|
4957
|
+
completionDelivery.set(parsed, candidate.delivery);
|
|
4958
|
+
traceAttempt.completedAt = now();
|
|
4959
|
+
traceAttempt.status = "completed";
|
|
4960
|
+
traceAttempt.httpStatus = candidate.status;
|
|
4961
|
+
traceAttempt.response = sanitizeCompletionTraceResponse(parsed);
|
|
4962
|
+
saveTrace();
|
|
4963
|
+
completionRequestCount += 1;
|
|
4964
|
+
const cacheUsage = resolveInputCacheUsage(parsed.usage);
|
|
4965
|
+
if (!cacheUsage)
|
|
4966
|
+
cacheUsageComplete = false;
|
|
4967
|
+
else {
|
|
4968
|
+
totalInputTokens += cacheUsage.inputTokens;
|
|
4969
|
+
totalCachedInputTokens += cacheUsage.cachedInputTokens;
|
|
4404
4970
|
}
|
|
4971
|
+
const outputText = completionPayloadOutputText(parsed);
|
|
4972
|
+
trackUsage(resolveAiTokenUsage(parsed.usage, estimateAiTokens(JSON.stringify(requestMessages)), outputText ? estimateAiTokens(outputText) : 0));
|
|
4973
|
+
return parsed;
|
|
4405
4974
|
}
|
|
4406
4975
|
lastFailure = new Error(`HTTP ${candidate.status}: ${candidate.body.slice(0, 500)}`);
|
|
4407
4976
|
traceAttempt.completedAt = now();
|
|
@@ -4416,6 +4985,8 @@ export class AiManager {
|
|
|
4416
4985
|
}
|
|
4417
4986
|
catch (error) {
|
|
4418
4987
|
lastFailure = error;
|
|
4988
|
+
if (isInteractiveStreamError(error))
|
|
4989
|
+
retryable = false;
|
|
4419
4990
|
if (traceAttempt.status === "running") {
|
|
4420
4991
|
traceAttempt.completedAt = now();
|
|
4421
4992
|
traceAttempt.status = "failed";
|
|
@@ -4427,11 +4998,12 @@ export class AiManager {
|
|
|
4427
4998
|
logger.warn("ai.call.attempt_failed", {
|
|
4428
4999
|
callId,
|
|
4429
5000
|
attempt,
|
|
4430
|
-
retryable: retryable && attempt < maximumAttempts && !input.signal?.aborted,
|
|
5001
|
+
retryable: retryable && !attemptEmitted && attempt < maximumAttempts && !input.signal?.aborted,
|
|
4431
5002
|
durationMs: Number(process.hrtime.bigint() - attemptStartedAt) / 1_000_000,
|
|
5003
|
+
streaming: streamResponse,
|
|
4432
5004
|
error: aiErrorForLog(error)
|
|
4433
5005
|
});
|
|
4434
|
-
if (input.signal?.aborted)
|
|
5006
|
+
if (input.signal?.aborted || attemptEmitted)
|
|
4435
5007
|
throw error;
|
|
4436
5008
|
if (!retryable || attempt >= maximumAttempts)
|
|
4437
5009
|
throw error;
|
|
@@ -4441,7 +5013,6 @@ export class AiManager {
|
|
|
4441
5013
|
}
|
|
4442
5014
|
throw lastFailure instanceof Error ? lastFailure : new Error("AI request failed after all retries.");
|
|
4443
5015
|
};
|
|
4444
|
-
const processSteps = [];
|
|
4445
5016
|
const baseMessageCount = messages.length;
|
|
4446
5017
|
const firstUserMessageIndex = messages.findIndex((message) => message.role !== "system");
|
|
4447
5018
|
const compactedMessageIndex = firstUserMessageIndex < 0 ? messages.length : firstUserMessageIndex;
|
|
@@ -4559,15 +5130,17 @@ export class AiManager {
|
|
|
4559
5130
|
let payload = await requestCompletion("auto");
|
|
4560
5131
|
let choice = payload.choices?.[0];
|
|
4561
5132
|
const executedToolCalls = [];
|
|
4562
|
-
const recordChoiceProcess = (
|
|
5133
|
+
const recordChoiceProcess = (currentPayload, round, includeIntermediate) => {
|
|
5134
|
+
const currentChoice = currentPayload.choices?.[0];
|
|
5135
|
+
const deliveredAsSse = completionDelivery.get(currentPayload) === "sse";
|
|
4563
5136
|
const reasoning = currentChoice?.message?.reasoning_content;
|
|
4564
|
-
if (reasoning?.trim()) {
|
|
5137
|
+
if (!deliveredAsSse && reasoning?.trim()) {
|
|
4565
5138
|
const step = { id: id("process"), type: "thinking", round, content: reasoning, createdAt: now() };
|
|
4566
5139
|
processSteps.push(step);
|
|
4567
5140
|
input.onProcessStep?.(step);
|
|
4568
5141
|
}
|
|
4569
5142
|
const intermediate = currentChoice?.message?.content;
|
|
4570
|
-
if (includeIntermediate && intermediate?.trim()) {
|
|
5143
|
+
if (!deliveredAsSse && includeIntermediate && intermediate?.trim()) {
|
|
4571
5144
|
const step = { id: id("process"), type: "intermediate", round, content: intermediate, createdAt: now() };
|
|
4572
5145
|
processSteps.push(step);
|
|
4573
5146
|
input.onProcessStep?.(step);
|
|
@@ -4576,7 +5149,7 @@ export class AiManager {
|
|
|
4576
5149
|
let toolRound = 0;
|
|
4577
5150
|
while (choice?.message?.tool_calls?.length) {
|
|
4578
5151
|
const round = toolRound + 1;
|
|
4579
|
-
recordChoiceProcess(
|
|
5152
|
+
recordChoiceProcess(payload, round, true);
|
|
4580
5153
|
const toolCalls = choice.message.tool_calls;
|
|
4581
5154
|
if (shouldRejectGlobalToolCalls(globalToolCallUsed, toolCalls.length, globalToolCallLimit)) {
|
|
4582
5155
|
logger.warn("ai.tool_call.global_limit_reached", {
|
|
@@ -4649,16 +5222,21 @@ export class AiManager {
|
|
|
4649
5222
|
payload = await requestCompletion("auto");
|
|
4650
5223
|
choice = payload.choices?.[0];
|
|
4651
5224
|
}
|
|
4652
|
-
recordChoiceProcess(
|
|
4653
|
-
const
|
|
4654
|
-
if (!
|
|
5225
|
+
recordChoiceProcess(payload, toolRound + 1, false);
|
|
5226
|
+
const finalContent = choice?.message?.content;
|
|
5227
|
+
if (!finalContent?.trim()) {
|
|
4655
5228
|
const reasoningLength = choice?.message?.reasoning_content?.length ?? 0;
|
|
4656
5229
|
const suffix = choice?.finish_reason === "length" || reasoningLength > 0
|
|
4657
5230
|
? `;模型已生成 ${reasoningLength} 个推理字符,请提高 max_tokens 输出预算`
|
|
4658
5231
|
: "";
|
|
4659
5232
|
throw new Error(`${providerProtocolLabelText(protocol)} 响应缺少可用正文,finish_reason=${choice?.finish_reason ?? "unknown"}${suffix}`);
|
|
4660
5233
|
}
|
|
4661
|
-
|
|
5234
|
+
if (onDelta && completionDelivery.get(payload) !== "sse") {
|
|
5235
|
+
streamedContent += finalContent;
|
|
5236
|
+
onDelta(finalContent);
|
|
5237
|
+
}
|
|
5238
|
+
const content = onDelta ? streamedContent : finalContent;
|
|
5239
|
+
const outputTokens = resolveOutputTokens(payload.usage, finalContent);
|
|
4662
5240
|
const cacheHitPercent = cacheUsageComplete && completionRequestCount > 0 && totalInputTokens > 0
|
|
4663
5241
|
? Math.round(totalCachedInputTokens / totalInputTokens * 1_000) / 10
|
|
4664
5242
|
: undefined;
|
|
@@ -4671,12 +5249,19 @@ export class AiManager {
|
|
|
4671
5249
|
callId,
|
|
4672
5250
|
workId: input.workId,
|
|
4673
5251
|
taskType: input.taskType,
|
|
4674
|
-
streaming:
|
|
5252
|
+
streaming: Boolean(onDelta),
|
|
4675
5253
|
durationMs: Number(process.hrtime.bigint() - callStartedAt) / 1_000_000,
|
|
4676
5254
|
outputChars: content.length,
|
|
4677
5255
|
outputTokens,
|
|
4678
5256
|
toolCallCount: executedToolCalls.length
|
|
4679
5257
|
});
|
|
5258
|
+
const finalAnthropicContent = choice?.message?.anthropic_content;
|
|
5259
|
+
const replayAnthropicContent = onDelta && finalAnthropicContent?.length && content !== finalContent
|
|
5260
|
+
? [
|
|
5261
|
+
...finalAnthropicContent.filter((block) => block.type !== "text" && block.type !== "tool_use"),
|
|
5262
|
+
{ type: "text", text: content }
|
|
5263
|
+
]
|
|
5264
|
+
: finalAnthropicContent;
|
|
4680
5265
|
return {
|
|
4681
5266
|
callId,
|
|
4682
5267
|
content,
|
|
@@ -4685,7 +5270,7 @@ export class AiManager {
|
|
|
4685
5270
|
? { reasoningContent: choice.message.reasoning_content }
|
|
4686
5271
|
: {}),
|
|
4687
5272
|
...(cacheHitPercent === undefined ? {} : { cacheHitPercent }),
|
|
4688
|
-
...(
|
|
5273
|
+
...(replayAnthropicContent?.length ? { anthropicContent: replayAnthropicContent } : {}),
|
|
4689
5274
|
provider: this.mapProvider(provider),
|
|
4690
5275
|
model: this.mapModel(model),
|
|
4691
5276
|
context,
|
|
@@ -4698,19 +5283,21 @@ export class AiManager {
|
|
|
4698
5283
|
const message = error instanceof Error ? redactProviderSecretsText(error.message, ...activeSecrets) : "AI 调用失败";
|
|
4699
5284
|
const failureTarget = aiFailureTargetDetails(provider, model);
|
|
4700
5285
|
this.store.db.run(`UPDATE ai_calls
|
|
4701
|
-
SET status = 'failed', failure = ?, input_tokens = ?, output_tokens = ?,
|
|
5286
|
+
SET status = 'failed', failure = ?, output_chars = ?, input_tokens = ?, output_tokens = ?,
|
|
4702
5287
|
cached_input_tokens = ?, cache_eligible_input_tokens = ?, cache_usage_available = ?,
|
|
4703
5288
|
token_usage_source = ?, completed_at = ?
|
|
4704
|
-
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);
|
|
4705
5290
|
logger.error("ai.call.failed", {
|
|
4706
5291
|
callId,
|
|
4707
5292
|
workId: input.workId,
|
|
4708
5293
|
taskType: input.taskType,
|
|
4709
|
-
streaming:
|
|
5294
|
+
streaming: Boolean(onDelta),
|
|
4710
5295
|
durationMs: Number(process.hrtime.bigint() - callStartedAt) / 1_000_000,
|
|
4711
5296
|
error: aiErrorForLog(error)
|
|
4712
5297
|
});
|
|
4713
|
-
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))) {
|
|
4714
5301
|
throw new AppError(error.status, error.code, error.message, {
|
|
4715
5302
|
callId,
|
|
4716
5303
|
...(error.details && typeof error.details === "object" ? error.details : {}),
|
|
@@ -4720,175 +5307,7 @@ export class AiManager {
|
|
|
4720
5307
|
throw new AppError(502, "AI_CALL_FAILED", "AI 调用失败", { callId, failure: message, ...failureTarget });
|
|
4721
5308
|
}
|
|
4722
5309
|
}
|
|
4723
|
-
async
|
|
4724
|
-
const { model, provider } = this.resolveModel(input.workId, input.taskType, input.modelId);
|
|
4725
|
-
const context = this.buildContext(input, model);
|
|
4726
|
-
const preset = safeJsonObject(stringValue(model, "preset_json"));
|
|
4727
|
-
const messages = this.buildMessages(input, context);
|
|
4728
|
-
let parameters;
|
|
4729
|
-
try {
|
|
4730
|
-
parameters = this.constrainParametersForContext(model, messages, {
|
|
4731
|
-
...this.sanitizeParameters({ ...preset, ...(input.parameters ?? {}) }, stringValue(model, "model_id")),
|
|
4732
|
-
...thinkingParameters(provider, model)
|
|
4733
|
-
});
|
|
4734
|
-
}
|
|
4735
|
-
catch (error) {
|
|
4736
|
-
if (!(error instanceof AppError) || error.code !== "CONTEXT_WINDOW_EXCEEDED")
|
|
4737
|
-
throw error;
|
|
4738
|
-
throw initialContextWindowError(error, provider, model);
|
|
4739
|
-
}
|
|
4740
|
-
parameters = this.constrainParametersForDailyTokenQuota(input.workId, messages, parameters);
|
|
4741
|
-
const callId = id("call");
|
|
4742
|
-
this.store.db.run(`INSERT INTO ai_calls (id, work_id, task_type, provider_id, model_id, context_scope_json, parameters_json,
|
|
4743
|
-
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);
|
|
4744
|
-
const callStartedAt = process.hrtime.bigint();
|
|
4745
|
-
const protocol = providerProtocol(provider);
|
|
4746
|
-
logger.info("ai.call.started", {
|
|
4747
|
-
callId,
|
|
4748
|
-
workId: input.workId,
|
|
4749
|
-
taskType: input.taskType,
|
|
4750
|
-
providerId: stringValue(provider, "id"),
|
|
4751
|
-
modelId: stringValue(model, "id"),
|
|
4752
|
-
protocol,
|
|
4753
|
-
streaming: true,
|
|
4754
|
-
contextChars: context.length,
|
|
4755
|
-
instructionChars: input.instruction.length
|
|
4756
|
-
});
|
|
4757
|
-
let activeSecrets = [];
|
|
4758
|
-
try {
|
|
4759
|
-
const { accessToken, credentialSecret } = await this.resolveProviderAccessToken(provider);
|
|
4760
|
-
activeSecrets = [credentialSecret, accessToken];
|
|
4761
|
-
const endpoint = providerCompletionEndpoint(stringValue(provider, "base_url"), protocol);
|
|
4762
|
-
const maximumAttempts = Math.round(clamp(input.maxAttempts ?? 3, 1, 5));
|
|
4763
|
-
let streamedResult = null;
|
|
4764
|
-
let lastFailure = null;
|
|
4765
|
-
let emitted = false;
|
|
4766
|
-
const thinkingStepId = id("process");
|
|
4767
|
-
const thinkingCreatedAt = now();
|
|
4768
|
-
for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
|
|
4769
|
-
const attemptStartedAt = process.hrtime.bigint();
|
|
4770
|
-
logger.info("ai.call.attempt_started", { callId, attempt, maximumAttempts, streaming: true });
|
|
4771
|
-
try {
|
|
4772
|
-
const candidate = await this.scheduleProviderRequest(provider, input.signal, async () => {
|
|
4773
|
-
const controller = new AbortController();
|
|
4774
|
-
const forwardAbort = () => controller.abort(input.signal?.reason);
|
|
4775
|
-
if (input.signal?.aborted)
|
|
4776
|
-
forwardAbort();
|
|
4777
|
-
else
|
|
4778
|
-
input.signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
4779
|
-
const timeout = setTimeout(() => controller.abort(new Error(`AI 请求超时(${Math.round(AI_INTERACTIVE_TIMEOUT_MS / 1_000)} 秒)`)), AI_INTERACTIVE_TIMEOUT_MS);
|
|
4780
|
-
try {
|
|
4781
|
-
const response = await this.outboundFetch(endpoint, {
|
|
4782
|
-
method: "POST",
|
|
4783
|
-
headers: providerRequestHeaders(protocol, accessToken, "text/event-stream"),
|
|
4784
|
-
body: JSON.stringify(buildCompletionRequestBody({
|
|
4785
|
-
protocol,
|
|
4786
|
-
model: stringValue(model, "model_id"),
|
|
4787
|
-
messages,
|
|
4788
|
-
parameters,
|
|
4789
|
-
stream: true
|
|
4790
|
-
})),
|
|
4791
|
-
signal: controller.signal
|
|
4792
|
-
});
|
|
4793
|
-
if (!response.ok)
|
|
4794
|
-
return { ok: false, status: response.status, body: await readResponseTextLimited(response) };
|
|
4795
|
-
const streamed = await this.readCompletionStream(response, protocol, estimateAiTokens(JSON.stringify(messages)), activeSecrets, (delta) => {
|
|
4796
|
-
emitted = true;
|
|
4797
|
-
onDelta(delta);
|
|
4798
|
-
}, (delta) => {
|
|
4799
|
-
emitted = true;
|
|
4800
|
-
input.onProcessStep?.({ id: thinkingStepId, type: "thinking", round: 1, content: delta, createdAt: thinkingCreatedAt, append: true });
|
|
4801
|
-
});
|
|
4802
|
-
return { ok: true, status: response.status, result: streamed };
|
|
4803
|
-
}
|
|
4804
|
-
finally {
|
|
4805
|
-
clearTimeout(timeout);
|
|
4806
|
-
input.signal?.removeEventListener("abort", forwardAbort);
|
|
4807
|
-
}
|
|
4808
|
-
});
|
|
4809
|
-
logger.info("ai.call.attempt_completed", {
|
|
4810
|
-
callId,
|
|
4811
|
-
attempt,
|
|
4812
|
-
status: candidate.status,
|
|
4813
|
-
ok: candidate.ok,
|
|
4814
|
-
durationMs: Number(process.hrtime.bigint() - attemptStartedAt) / 1_000_000,
|
|
4815
|
-
streaming: true
|
|
4816
|
-
});
|
|
4817
|
-
if (candidate.ok) {
|
|
4818
|
-
streamedResult = candidate.result;
|
|
4819
|
-
break;
|
|
4820
|
-
}
|
|
4821
|
-
lastFailure = new Error(`HTTP ${candidate.status}: ${candidate.body.slice(0, 500)}`);
|
|
4822
|
-
if (candidate.status !== 429 && candidate.status < 500)
|
|
4823
|
-
attempt = maximumAttempts;
|
|
4824
|
-
}
|
|
4825
|
-
catch (error) {
|
|
4826
|
-
lastFailure = error;
|
|
4827
|
-
logger.warn("ai.call.attempt_failed", {
|
|
4828
|
-
callId,
|
|
4829
|
-
attempt,
|
|
4830
|
-
retryable: !input.signal?.aborted && !emitted && attempt < maximumAttempts,
|
|
4831
|
-
durationMs: Number(process.hrtime.bigint() - attemptStartedAt) / 1_000_000,
|
|
4832
|
-
streaming: true,
|
|
4833
|
-
error: aiErrorForLog(error)
|
|
4834
|
-
});
|
|
4835
|
-
if (input.signal?.aborted || emitted || attempt >= maximumAttempts)
|
|
4836
|
-
throw error;
|
|
4837
|
-
}
|
|
4838
|
-
if (attempt < maximumAttempts)
|
|
4839
|
-
await new Promise((resolve) => setTimeout(resolve, attempt * 1200));
|
|
4840
|
-
}
|
|
4841
|
-
if (streamedResult === null)
|
|
4842
|
-
throw lastFailure instanceof Error ? lastFailure : new Error("AI 流式请求重试后仍未返回响应");
|
|
4843
|
-
const { content, reasoning, outputTokens, cacheHitPercent, anthropicContent, tokenUsage } = streamedResult;
|
|
4844
|
-
const processSteps = reasoning.trim()
|
|
4845
|
-
? [{ id: thinkingStepId, type: "thinking", round: 1, content: reasoning, createdAt: thinkingCreatedAt }]
|
|
4846
|
-
: [];
|
|
4847
|
-
this.store.db.run(`UPDATE ai_calls
|
|
4848
|
-
SET status = 'completed', output_chars = ?, input_tokens = ?, output_tokens = ?,
|
|
4849
|
-
cached_input_tokens = ?, cache_eligible_input_tokens = ?, cache_usage_available = ?,
|
|
4850
|
-
token_usage_source = ?, completed_at = ?
|
|
4851
|
-
WHERE id = ?`, content.length, tokenUsage.inputTokens, tokenUsage.outputTokens, tokenUsage.cachedInputTokens, tokenUsage.cacheEligibleInputTokens, tokenUsage.cacheEligibleInputTokens > 0 ? 1 : 0, tokenUsage.source, now(), callId);
|
|
4852
|
-
logger.info("ai.call.completed", {
|
|
4853
|
-
callId,
|
|
4854
|
-
workId: input.workId,
|
|
4855
|
-
taskType: input.taskType,
|
|
4856
|
-
streaming: true,
|
|
4857
|
-
durationMs: Number(process.hrtime.bigint() - callStartedAt) / 1_000_000,
|
|
4858
|
-
outputChars: content.length,
|
|
4859
|
-
outputTokens
|
|
4860
|
-
});
|
|
4861
|
-
return {
|
|
4862
|
-
callId,
|
|
4863
|
-
content,
|
|
4864
|
-
outputTokens,
|
|
4865
|
-
...(reasoning.length > 0 ? { reasoningContent: reasoning } : {}),
|
|
4866
|
-
...(cacheHitPercent === undefined ? {} : { cacheHitPercent }),
|
|
4867
|
-
...(anthropicContent?.length ? { anthropicContent } : {}),
|
|
4868
|
-
provider: this.mapProvider(provider),
|
|
4869
|
-
model: this.mapModel(model),
|
|
4870
|
-
context,
|
|
4871
|
-
toolCalls: [],
|
|
4872
|
-
processSteps,
|
|
4873
|
-
contextUsage: this.completionContextUsage(input, model, messages, [])
|
|
4874
|
-
};
|
|
4875
|
-
}
|
|
4876
|
-
catch (error) {
|
|
4877
|
-
const message = error instanceof Error ? redactProviderSecretsText(error.message, ...activeSecrets) : "AI 流式调用失败";
|
|
4878
|
-
const failureTarget = aiFailureTargetDetails(provider, model);
|
|
4879
|
-
this.store.db.run("UPDATE ai_calls SET status = 'failed', failure = ?, completed_at = ? WHERE id = ?", message, now(), callId);
|
|
4880
|
-
logger.error("ai.call.failed", {
|
|
4881
|
-
callId,
|
|
4882
|
-
workId: input.workId,
|
|
4883
|
-
taskType: input.taskType,
|
|
4884
|
-
streaming: true,
|
|
4885
|
-
durationMs: Number(process.hrtime.bigint() - callStartedAt) / 1_000_000,
|
|
4886
|
-
error: aiErrorForLog(error)
|
|
4887
|
-
});
|
|
4888
|
-
throw new AppError(502, "AI_CALL_FAILED", "AI 调用失败", { callId, failure: message, ...failureTarget });
|
|
4889
|
-
}
|
|
4890
|
-
}
|
|
4891
|
-
async readCompletionStream(response, protocol, estimatedInputTokens, apiKey, onDelta, onThinkingDelta) {
|
|
5310
|
+
async readCompletionStream(response, protocol, apiKey, onDelta, onThinkingDelta, onEvent) {
|
|
4892
5311
|
const protocolLabel = providerProtocolLabelText(protocol);
|
|
4893
5312
|
if (!response.body)
|
|
4894
5313
|
throw new Error(`${protocolLabel} 流式响应缺少正文`);
|
|
@@ -4918,6 +5337,9 @@ export class AiManager {
|
|
|
4918
5337
|
};
|
|
4919
5338
|
const anthropicBlocks = new Map();
|
|
4920
5339
|
const anthropicToolInputJson = new Map();
|
|
5340
|
+
const finalizedAnthropicToolInputs = new Map();
|
|
5341
|
+
const openAiToolCalls = new Map();
|
|
5342
|
+
let openAiToolCallsFinalized = false;
|
|
4921
5343
|
const eventIndex = (payload) => {
|
|
4922
5344
|
const index = payload.index;
|
|
4923
5345
|
return typeof index === "number" && Number.isInteger(index) && index >= 0 ? index : null;
|
|
@@ -4937,16 +5359,18 @@ export class AiManager {
|
|
|
4937
5359
|
const finalizeAnthropicToolInput = (index) => {
|
|
4938
5360
|
const block = anthropicBlocks.get(index);
|
|
4939
5361
|
const inputJson = anthropicToolInputJson.get(index);
|
|
4940
|
-
if (!block || block.type !== "tool_use"
|
|
5362
|
+
if (!block || block.type !== "tool_use")
|
|
4941
5363
|
return;
|
|
5364
|
+
const completeInputJson = inputJson ?? JSON.stringify(block.input ?? {});
|
|
4942
5365
|
try {
|
|
4943
|
-
const parsed = JSON.parse(
|
|
5366
|
+
const parsed = JSON.parse(completeInputJson);
|
|
4944
5367
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
4945
5368
|
block.input = parsed;
|
|
4946
5369
|
}
|
|
4947
5370
|
catch {
|
|
4948
5371
|
block.input = {};
|
|
4949
5372
|
}
|
|
5373
|
+
finalizedAnthropicToolInputs.set(index, completeInputJson);
|
|
4950
5374
|
anthropicToolInputJson.delete(index);
|
|
4951
5375
|
};
|
|
4952
5376
|
const consumeEvent = (eventText) => {
|
|
@@ -4956,10 +5380,10 @@ export class AiManager {
|
|
|
4956
5380
|
.join("\n")
|
|
4957
5381
|
.trim();
|
|
4958
5382
|
if (!data)
|
|
4959
|
-
return;
|
|
5383
|
+
return false;
|
|
4960
5384
|
if (data === "[DONE]") {
|
|
4961
5385
|
upstreamDone = true;
|
|
4962
|
-
return;
|
|
5386
|
+
return true;
|
|
4963
5387
|
}
|
|
4964
5388
|
const payload = JSON.parse(data);
|
|
4965
5389
|
const error = payload.error && typeof payload.error === "object" && !Array.isArray(payload.error)
|
|
@@ -4982,6 +5406,12 @@ export class AiManager {
|
|
|
4982
5406
|
if (contentBlock.type === "tool_use" && !contentBlock.input)
|
|
4983
5407
|
contentBlock.input = {};
|
|
4984
5408
|
anthropicBlocks.set(index, contentBlock);
|
|
5409
|
+
if (contentBlock.type === "text" && typeof contentBlock.text === "string" && contentBlock.text.length > 0) {
|
|
5410
|
+
appendContent(contentBlock.text);
|
|
5411
|
+
}
|
|
5412
|
+
if (contentBlock.type === "thinking" && typeof contentBlock.thinking === "string" && contentBlock.thinking.length > 0) {
|
|
5413
|
+
appendReasoning(contentBlock.thinking);
|
|
5414
|
+
}
|
|
4985
5415
|
}
|
|
4986
5416
|
}
|
|
4987
5417
|
const eventUsage = payload.usage && typeof payload.usage === "object" && !Array.isArray(payload.usage)
|
|
@@ -5028,7 +5458,9 @@ export class AiManager {
|
|
|
5028
5458
|
if (eventDelta.type === "text_delta" && typeof eventDelta.text === "string" && eventDelta.text.length > 0) {
|
|
5029
5459
|
appendContent(eventDelta.text);
|
|
5030
5460
|
}
|
|
5031
|
-
|
|
5461
|
+
if (type === "message_stop")
|
|
5462
|
+
upstreamDone = true;
|
|
5463
|
+
return true;
|
|
5032
5464
|
}
|
|
5033
5465
|
const streamUsage = payload.usage && typeof payload.usage === "object" && !Array.isArray(payload.usage)
|
|
5034
5466
|
? payload.usage
|
|
@@ -5039,11 +5471,38 @@ export class AiManager {
|
|
|
5039
5471
|
const choice = choices[0] && typeof choices[0] === "object" && !Array.isArray(choices[0])
|
|
5040
5472
|
? choices[0]
|
|
5041
5473
|
: null;
|
|
5042
|
-
if (typeof choice?.finish_reason === "string")
|
|
5474
|
+
if (typeof choice?.finish_reason === "string") {
|
|
5043
5475
|
finishReason = choice.finish_reason;
|
|
5476
|
+
if (finishReason === "tool_calls")
|
|
5477
|
+
openAiToolCallsFinalized = true;
|
|
5478
|
+
}
|
|
5044
5479
|
const deltaRecord = choice?.delta && typeof choice.delta === "object" && !Array.isArray(choice.delta)
|
|
5045
5480
|
? choice.delta
|
|
5046
5481
|
: {};
|
|
5482
|
+
const toolCallDeltas = Array.isArray(deltaRecord.tool_calls) ? deltaRecord.tool_calls : [];
|
|
5483
|
+
for (const [position, value] of toolCallDeltas.entries()) {
|
|
5484
|
+
const toolCallDelta = value && typeof value === "object" && !Array.isArray(value)
|
|
5485
|
+
? value
|
|
5486
|
+
: {};
|
|
5487
|
+
const index = typeof toolCallDelta.index === "number" && Number.isInteger(toolCallDelta.index) && toolCallDelta.index >= 0
|
|
5488
|
+
? toolCallDelta.index
|
|
5489
|
+
: position;
|
|
5490
|
+
const current = openAiToolCalls.get(index) ?? {
|
|
5491
|
+
id: "",
|
|
5492
|
+
type: "function",
|
|
5493
|
+
function: { name: "", arguments: "" }
|
|
5494
|
+
};
|
|
5495
|
+
if (!current.id && typeof toolCallDelta.id === "string")
|
|
5496
|
+
current.id = toolCallDelta.id;
|
|
5497
|
+
const fn = toolCallDelta.function && typeof toolCallDelta.function === "object" && !Array.isArray(toolCallDelta.function)
|
|
5498
|
+
? toolCallDelta.function
|
|
5499
|
+
: {};
|
|
5500
|
+
if (typeof fn.name === "string")
|
|
5501
|
+
current.function.name += fn.name;
|
|
5502
|
+
if (typeof fn.arguments === "string")
|
|
5503
|
+
current.function.arguments = `${String(current.function.arguments)}${fn.arguments}`;
|
|
5504
|
+
openAiToolCalls.set(index, current);
|
|
5505
|
+
}
|
|
5047
5506
|
const thinkingDelta = deltaRecord.reasoning_content;
|
|
5048
5507
|
if (typeof thinkingDelta === "string" && thinkingDelta.length > 0) {
|
|
5049
5508
|
appendReasoning(thinkingDelta);
|
|
@@ -5052,64 +5511,133 @@ export class AiManager {
|
|
|
5052
5511
|
if (typeof delta === "string" && delta.length > 0) {
|
|
5053
5512
|
appendContent(delta);
|
|
5054
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
|
+
}
|
|
5055
5531
|
};
|
|
5056
5532
|
let receivedBytes = 0;
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
|
|
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) {
|
|
5062
5562
|
await reader.cancel().catch(() => undefined);
|
|
5063
|
-
|
|
5563
|
+
buffer = "";
|
|
5564
|
+
break;
|
|
5064
5565
|
}
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
const events = buffer.split(/\r?\n\r?\n/u);
|
|
5068
|
-
buffer = events.pop() ?? "";
|
|
5069
|
-
for (const eventText of events) {
|
|
5070
|
-
consumeEvent(eventText);
|
|
5071
|
-
if (upstreamDone)
|
|
5566
|
+
if (chunk.done) {
|
|
5567
|
+
readerEnded = true;
|
|
5072
5568
|
break;
|
|
5569
|
+
}
|
|
5073
5570
|
}
|
|
5074
|
-
if (
|
|
5075
|
-
|
|
5076
|
-
buffer = "";
|
|
5077
|
-
break;
|
|
5078
|
-
}
|
|
5079
|
-
if (chunk.done)
|
|
5080
|
-
break;
|
|
5571
|
+
if (buffer.trim() && consumeEvent(buffer))
|
|
5572
|
+
onEvent();
|
|
5081
5573
|
}
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
if (finalContent) {
|
|
5086
|
-
content += finalContent;
|
|
5087
|
-
onDelta(finalContent);
|
|
5574
|
+
catch (error) {
|
|
5575
|
+
flushRedactors(true);
|
|
5576
|
+
throw error;
|
|
5088
5577
|
}
|
|
5089
|
-
const
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5578
|
+
const upstreamClosed = readerEnded && !upstreamDone && finishReason === "unknown";
|
|
5579
|
+
flushRedactors(upstreamClosed);
|
|
5580
|
+
if (upstreamClosed) {
|
|
5581
|
+
throw new AppError(502, "AI_STREAM_UPSTREAM_CLOSED", "AI 上游流在正常结束前已关闭,已保留已生成内容");
|
|
5582
|
+
}
|
|
5583
|
+
const sortedOpenAiToolCalls = [...openAiToolCalls.entries()].sort(([left], [right]) => left - right);
|
|
5584
|
+
const openAiToolCallsComplete = openAiToolCallsFinalized
|
|
5585
|
+
&& sortedOpenAiToolCalls.length > 0
|
|
5586
|
+
&& sortedOpenAiToolCalls.every(([, toolCall]) => Boolean(toolCall.id && toolCall.function.name));
|
|
5587
|
+
const anthropicToolBlocks = [...anthropicBlocks.entries()]
|
|
5588
|
+
.filter(([, block]) => block.type === "tool_use")
|
|
5589
|
+
.sort(([left], [right]) => left - right);
|
|
5590
|
+
const anthropicToolCallsComplete = finishReason === "tool_use"
|
|
5591
|
+
&& anthropicToolBlocks.length > 0
|
|
5592
|
+
&& anthropicToolBlocks.every(([index, block]) => (finalizedAnthropicToolInputs.has(index)
|
|
5593
|
+
&& typeof block.id === "string"
|
|
5594
|
+
&& block.id.length > 0
|
|
5595
|
+
&& typeof block.name === "string"
|
|
5596
|
+
&& block.name.length > 0));
|
|
5597
|
+
const toolCalls = protocol === "anthropic-messages"
|
|
5598
|
+
? anthropicToolCallsComplete
|
|
5599
|
+
? anthropicToolBlocks.map(([index, block]) => ({
|
|
5600
|
+
id: String(block.id),
|
|
5601
|
+
type: "function",
|
|
5602
|
+
function: {
|
|
5603
|
+
name: String(block.name),
|
|
5604
|
+
arguments: finalizedAnthropicToolInputs.get(index) ?? "{}"
|
|
5605
|
+
}
|
|
5606
|
+
}))
|
|
5607
|
+
: []
|
|
5608
|
+
: openAiToolCallsComplete
|
|
5609
|
+
? sortedOpenAiToolCalls.map(([, toolCall]) => toolCall)
|
|
5610
|
+
: [];
|
|
5611
|
+
if ((finishReason === "tool_calls" || finishReason === "tool_use") && toolCalls.length === 0) {
|
|
5612
|
+
throw new Error(`${protocolLabel} 流式工具调用不完整,finish_reason=${finishReason}`);
|
|
5093
5613
|
}
|
|
5094
|
-
if (!content.trim())
|
|
5614
|
+
if (!content.trim() && toolCalls.length === 0) {
|
|
5095
5615
|
throw new Error(`${protocolLabel} 流式响应缺少可用正文,finish_reason=${finishReason}`);
|
|
5096
|
-
|
|
5097
|
-
const outputTokens = resolveOutputTokens(usage, content);
|
|
5616
|
+
}
|
|
5098
5617
|
const anthropicContent = protocol === "anthropic-messages"
|
|
5099
5618
|
? [...anthropicBlocks.entries()]
|
|
5100
5619
|
.sort(([left], [right]) => left - right)
|
|
5101
|
-
.map(([
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5620
|
+
.map(([, block]) => redactProviderSecrets(block, apiKey))
|
|
5621
|
+
: undefined;
|
|
5622
|
+
const usageRecord = usage && typeof usage === "object" && !Array.isArray(usage)
|
|
5623
|
+
? usage
|
|
5105
5624
|
: undefined;
|
|
5625
|
+
const normalizedFinishReason = finishReason === "unknown"
|
|
5626
|
+
? null
|
|
5627
|
+
: protocol === "anthropic-messages" && finishReason === "max_tokens"
|
|
5628
|
+
? "length"
|
|
5629
|
+
: finishReason;
|
|
5106
5630
|
return {
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5631
|
+
...(usageRecord ? { usage: usageRecord } : {}),
|
|
5632
|
+
choices: [{
|
|
5633
|
+
finish_reason: normalizedFinishReason,
|
|
5634
|
+
message: {
|
|
5635
|
+
content: content || null,
|
|
5636
|
+
reasoning_content: reasoning || null,
|
|
5637
|
+
...(toolCalls.length ? { tool_calls: toolCalls } : {}),
|
|
5638
|
+
...(anthropicContent?.length ? { anthropic_content: anthropicContent } : {})
|
|
5639
|
+
}
|
|
5640
|
+
}]
|
|
5113
5641
|
};
|
|
5114
5642
|
}
|
|
5115
5643
|
async runChapterAnalysis(workId, scope, modelId, taskId) {
|
|
@@ -5708,9 +6236,12 @@ export class AiManager {
|
|
|
5708
6236
|
return { interrupted: true, callIds };
|
|
5709
6237
|
const byChapterId = new Map(chapters.map((chapter) => [String(chapter.id), chapter]));
|
|
5710
6238
|
const groups = [];
|
|
6239
|
+
const preprocessingSkipped = [];
|
|
5711
6240
|
for (const candidate of rawCandidates) {
|
|
5712
|
-
if (typeof candidate.canonicalName !== "string" || !candidate.canonicalName.trim())
|
|
6241
|
+
if (typeof candidate.canonicalName !== "string" || !candidate.canonicalName.trim()) {
|
|
6242
|
+
preprocessingSkipped.push({ name: "未命名候选", reason: "角色标准名为空,未进入入库预览" });
|
|
5713
6243
|
continue;
|
|
6244
|
+
}
|
|
5714
6245
|
const name = candidate.canonicalName.normalize("NFKC").trim();
|
|
5715
6246
|
const aliases = (Array.isArray(candidate.aliases) ? candidate.aliases : [])
|
|
5716
6247
|
.filter((value) => typeof value === "string")
|
|
@@ -5722,8 +6253,10 @@ export class AiManager {
|
|
|
5722
6253
|
const chapterId = evidence && typeof evidence.chapterId === "string" ? evidence.chapterId : null;
|
|
5723
6254
|
const quote = evidence && typeof evidence.quote === "string" ? evidence.quote.trim() : "";
|
|
5724
6255
|
if (!chapterId || !quote || quote.length > 80 || !byChapterId.has(chapterId)
|
|
5725
|
-
|| !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: "首次出现证据无效或无法在本次正文范围内核验" });
|
|
5726
6258
|
continue;
|
|
6259
|
+
}
|
|
5727
6260
|
const refs = new Set([name, ...aliases].map((value) => this.normalizeReference(value)));
|
|
5728
6261
|
const matches = groups.filter((group) => [...refs].some((value) => group.references.has(value)));
|
|
5729
6262
|
const group = matches[0] ?? {
|
|
@@ -5820,7 +6353,7 @@ export class AiManager {
|
|
|
5820
6353
|
addVerificationPair(candidateSubjects[leftIndex], existing);
|
|
5821
6354
|
}
|
|
5822
6355
|
}
|
|
5823
|
-
const skipped = [];
|
|
6356
|
+
const skipped = [...preprocessingSkipped];
|
|
5824
6357
|
let verificationCallId = null;
|
|
5825
6358
|
let confirmedSameCount = 0;
|
|
5826
6359
|
let confirmedSeparateCount = 0;
|
|
@@ -5942,50 +6475,42 @@ export class AiManager {
|
|
|
5942
6475
|
if (!blockedReasons.has(root) && blockedReasons.has(index))
|
|
5943
6476
|
blockedReasons.set(root, blockedReasons.get(index));
|
|
5944
6477
|
}
|
|
5945
|
-
const
|
|
6478
|
+
const characterCandidates = [];
|
|
5946
6479
|
for (const [root, group] of mergedGroups) {
|
|
5947
6480
|
if (blockedGroups.has(root)) {
|
|
5948
6481
|
skipped.push({ name: group.name, reason: blockedReasons.get(root) ?? "角色身份二次确认未通过" });
|
|
5949
6482
|
continue;
|
|
5950
6483
|
}
|
|
5951
6484
|
const aliases = [...group.aliases].filter((alias) => this.isSafeGlobalAlias(alias));
|
|
5952
|
-
const extractedRaceId = group.species ? this.store.resolveRaceReference(workId, group.species) : null;
|
|
5953
6485
|
const existingId = existingIdByRoot.get(root) ?? [group.name, ...aliases]
|
|
5954
6486
|
.map((value) => this.store.resolveCharacterReference(workId, value))
|
|
5955
6487
|
.find((value) => Boolean(value));
|
|
5956
|
-
|
|
5957
|
-
|
|
5958
|
-
|
|
5959
|
-
|
|
5960
|
-
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
5964
|
-
|
|
5965
|
-
|
|
5966
|
-
|
|
5967
|
-
|
|
5968
|
-
}
|
|
5969
|
-
else {
|
|
5970
|
-
const created = this.store.createCharacter(workId, {
|
|
5971
|
-
name: group.name,
|
|
5972
|
-
aliases,
|
|
5973
|
-
raceId: extractedRaceId,
|
|
5974
|
-
attributes: group.identity ? { identity: group.identity } : {},
|
|
5975
|
-
firstChapterId: group.firstChapterId
|
|
5976
|
-
});
|
|
5977
|
-
characterIds.push(String(created.id));
|
|
5978
|
-
}
|
|
5979
|
-
}
|
|
5980
|
-
catch (error) {
|
|
5981
|
-
skipped.push({ name: group.name, reason: error instanceof Error ? error.message : "名称冲突" });
|
|
5982
|
-
}
|
|
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: "候选名称或属性不符合角色档案字段限制" });
|
|
5983
6501
|
}
|
|
6502
|
+
const generatedAt = now();
|
|
5984
6503
|
return {
|
|
5985
|
-
characterIds: [
|
|
5986
|
-
|
|
5987
|
-
|
|
6504
|
+
characterIds: [],
|
|
6505
|
+
characterCandidates,
|
|
6506
|
+
candidateCount: characterCandidates.length,
|
|
6507
|
+
savedCount: 0,
|
|
5988
6508
|
skipped,
|
|
6509
|
+
characterApplication: {
|
|
6510
|
+
status: "pending",
|
|
6511
|
+
totalCount: characterCandidates.length,
|
|
6512
|
+
generatedAt
|
|
6513
|
+
},
|
|
5989
6514
|
batchCount: chunks.length,
|
|
5990
6515
|
coveredChapterCount: chapters.length,
|
|
5991
6516
|
fallbackSegmentCount,
|
|
@@ -8636,6 +9161,53 @@ export class AiManager {
|
|
|
8636
9161
|
throw new AppError(500, "CREDENTIAL_DECRYPT_FAILED", "供应商凭据无法解密,请重新填写密钥或服务账号 JSON");
|
|
8637
9162
|
}
|
|
8638
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
|
+
}
|
|
8639
9211
|
getProviderRow(providerId) {
|
|
8640
9212
|
const row = this.store.db.get("SELECT * FROM providers WHERE id = ?", providerId);
|
|
8641
9213
|
if (!row)
|