@musnows/scriverse 0.7.3 → 0.7.4

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