@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.
Files changed (82) hide show
  1. package/README.en.md +3 -0
  2. package/README.md +8 -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 +926 -354
  10. package/dist/ai.js.map +1 -1
  11. package/dist/app.js +498 -80
  12. package/dist/app.js.map +1 -1
  13. package/dist/attachment-storage.js +28 -12
  14. package/dist/attachment-storage.js.map +1 -1
  15. package/dist/backup-encryption.js +129 -0
  16. package/dist/backup-encryption.js.map +1 -0
  17. package/dist/character-extraction.js +133 -0
  18. package/dist/character-extraction.js.map +1 -0
  19. package/dist/cli-core.js +7 -6
  20. package/dist/cli-core.js.map +1 -1
  21. package/dist/collaboration-presence.js +200 -18
  22. package/dist/collaboration-presence.js.map +1 -1
  23. package/dist/database.js +335 -3
  24. package/dist/database.js.map +1 -1
  25. package/dist/docx-export.js +1 -1
  26. package/dist/docx-export.js.map +1 -1
  27. package/dist/domain.js +18 -0
  28. package/dist/domain.js.map +1 -1
  29. package/dist/epub-export.js +319 -0
  30. package/dist/epub-export.js.map +1 -0
  31. package/dist/hybrid-search.js +8 -0
  32. package/dist/hybrid-search.js.map +1 -1
  33. package/dist/image-metadata.js +17 -2
  34. package/dist/image-metadata.js.map +1 -1
  35. package/dist/presence-store.js +160 -0
  36. package/dist/presence-store.js.map +1 -0
  37. package/dist/public/ai-connectivity-test.d.ts +7 -0
  38. package/dist/public/ai-connectivity-test.js +82 -0
  39. package/dist/public/ai-context-meter.js +27 -0
  40. package/dist/public/ai-mentions.js +14 -0
  41. package/dist/public/ai-request-manager.js +99 -0
  42. package/dist/public/ai-stream-protocol.js +51 -0
  43. package/dist/public/app.js +3587 -559
  44. package/dist/public/background-task-center.js +1 -1
  45. package/dist/public/chapter-editor-virtualization.js +52 -0
  46. package/dist/public/chapter-version-diff.d.ts +20 -0
  47. package/dist/public/chapter-version-diff.js +116 -0
  48. package/dist/public/foreshadow-reminder.d.ts +32 -0
  49. package/dist/public/foreshadow-reminder.js +73 -0
  50. package/dist/public/global-replace-refresh.js +60 -0
  51. package/dist/public/index.html +161 -31
  52. package/dist/public/outline-board.d.ts +61 -0
  53. package/dist/public/outline-board.js +137 -0
  54. package/dist/public/page-route.d.ts +1 -0
  55. package/dist/public/page-route.js +8 -0
  56. package/dist/public/presence-client-id.d.ts +10 -0
  57. package/dist/public/presence-client-id.js +31 -0
  58. package/dist/public/reading-preview.d.ts +32 -0
  59. package/dist/public/reading-preview.js +136 -0
  60. package/dist/public/s3-backup-ui.d.ts +10 -0
  61. package/dist/public/s3-backup-ui.js +29 -0
  62. package/dist/public/setting-filters.d.ts +16 -0
  63. package/dist/public/setting-filters.js +19 -0
  64. package/dist/public/styles.css +522 -11
  65. package/dist/public/upload-progress.d.ts +2 -0
  66. package/dist/public/upload-progress.js +10 -0
  67. package/dist/s3-backup.js +197 -12
  68. package/dist/s3-backup.js.map +1 -1
  69. package/dist/security.js +86 -21
  70. package/dist/security.js.map +1 -1
  71. package/dist/server-runtime.js +38 -18
  72. package/dist/server-runtime.js.map +1 -1
  73. package/dist/store.js +899 -104
  74. package/dist/store.js.map +1 -1
  75. package/dist/upload-limits.js +35 -0
  76. package/dist/upload-limits.js.map +1 -0
  77. package/dist/user-auth.js +56 -10
  78. package/dist/user-auth.js.map +1 -1
  79. package/dist/utils.js +3 -0
  80. package/dist/utils.js.map +1 -1
  81. package/dist/version.js +1 -1
  82. 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 value = redactProviderSecretsText(this.pending, ...this.secrets);
353
+ flush(options = {}) {
354
+ const pending = this.pending;
355
+ const value = redactProviderSecretsText(pending, ...this.secrets);
272
356
  this.pending = "";
273
- 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)}*****`;
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(200),
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: 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 }
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
- constructor(store, vault, fetchImpl = fetch, validateOutboundUrl, authorizeTaskRun, attachmentStorage) {
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 = normalizeRelationshipSearchText(query).trim();
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.getProviderRow(providerId);
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 timestamp = now();
2119
- 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
+ });
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.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
+ });
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: aiErrorForLog(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.getModelRow(modelId);
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 timestamp = now();
2163
- 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
+ });
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.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
+ });
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: aiErrorForLog(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 rerunnableStatuses = new Set(["review", "completed", "partial", "expired", "cancelled"]);
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: String(original.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 chatTools = this.enabledAgentTools(input.workId, "chat", input.agentToolIds, input.conversationId);
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 taskType = String(task.taskType);
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: taskType === "book-analysis" ? "book-analysis" : "chapter-analysis",
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
- this.store.updateTask(taskId, { status: "partial", progress: 100, failures: [failure] });
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
- async prepareConversationContext(input) {
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
- applyKeywordEntityMentions(workId, instruction, scope, conversationId, persist) {
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
- const matches = matchKeywordEntities(this.store, workId, instruction, {
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: false,
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 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();
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
- return { ok: response.ok, status: response.status, body: await readResponseTextLimited(response) };
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
- clearTimeout(timeout);
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
- try {
4384
- const parsed = parseCompletionPayload(protocol, redactProviderSecrets(JSON.parse(candidate.body), activeSecrets));
4385
- traceAttempt.completedAt = now();
4386
- traceAttempt.status = "completed";
4387
- traceAttempt.httpStatus = candidate.status;
4388
- traceAttempt.response = sanitizeCompletionTraceResponse(parsed);
4389
- saveTrace();
4390
- completionRequestCount += 1;
4391
- const cacheUsage = resolveInputCacheUsage(parsed.usage);
4392
- if (!cacheUsage)
4393
- cacheUsageComplete = false;
4394
- else {
4395
- totalInputTokens += cacheUsage.inputTokens;
4396
- totalCachedInputTokens += cacheUsage.cachedInputTokens;
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 = (currentChoice, round, includeIntermediate) => {
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(choice, round, true);
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(choice, toolRound + 1, false);
4653
- const content = choice?.message?.content;
4654
- if (!content?.trim()) {
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
- const outputTokens = resolveOutputTokens(payload.usage, content);
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: false,
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
- ...(choice?.message?.anthropic_content?.length ? { anthropicContent: choice.message.anthropic_content } : {}),
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: false,
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" || 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))) {
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 generateStream(input, onDelta) {
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" || inputJson === undefined)
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(inputJson);
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
- return;
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
- while (true) {
5058
- const chunk = await reader.read();
5059
- if (chunk.value?.byteLength) {
5060
- receivedBytes += chunk.value.byteLength;
5061
- 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) {
5062
5562
  await reader.cancel().catch(() => undefined);
5063
- throw new AppError(502, "AI_RESPONSE_TOO_LARGE", `AI 供应商响应超过 ${AI_RESPONSE_MAX_BYTES} 字节上限`);
5563
+ buffer = "";
5564
+ break;
5064
5565
  }
5065
- }
5066
- buffer += decoder.decode(chunk.value, { stream: !chunk.done });
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 (upstreamDone) {
5075
- await reader.cancel().catch(() => undefined);
5076
- buffer = "";
5077
- break;
5078
- }
5079
- if (chunk.done)
5080
- break;
5571
+ if (buffer.trim() && consumeEvent(buffer))
5572
+ onEvent();
5081
5573
  }
5082
- if (buffer.trim())
5083
- consumeEvent(buffer);
5084
- const finalContent = contentRedactor.flush();
5085
- if (finalContent) {
5086
- content += finalContent;
5087
- onDelta(finalContent);
5574
+ catch (error) {
5575
+ flushRedactors(true);
5576
+ throw error;
5088
5577
  }
5089
- const finalReasoning = reasoningRedactor.flush();
5090
- if (finalReasoning) {
5091
- reasoning += finalReasoning;
5092
- 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 上游流在正常结束前已关闭,已保留已生成内容");
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
- const cacheHitPercent = resolveCacheHitPercent(usage);
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(([index, block]) => {
5102
- finalizeAnthropicToolInput(index);
5103
- return redactProviderSecrets(block, apiKey);
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
- content,
5108
- reasoning,
5109
- outputTokens,
5110
- ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }),
5111
- ...(anthropicContent?.length ? { anthropicContent } : {}),
5112
- tokenUsage: resolveAiTokenUsage(usage, estimatedInputTokens, outputTokens)
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 characterIds = [];
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
- try {
5957
- if (existingId) {
5958
- const existing = this.store.getCharacter(existingId);
5959
- const mergedAliases = [...new Set([...existing.aliases, group.name, ...aliases])]
5960
- .filter((alias) => this.isSafeGlobalAlias(alias) && this.normalizeReference(alias) !== this.normalizeReference(String(existing.name)));
5961
- const updated = this.store.updateCharacter(existingId, {
5962
- aliases: mergedAliases,
5963
- raceId: existing.raceId ?? extractedRaceId,
5964
- attributes: { ...existing.attributes, ...(group.identity ? { identity: group.identity } : {}) },
5965
- firstChapterId: existing.firstChapterId ?? group.firstChapterId
5966
- }, "ai", taskId ?? null, "全书角色抽取及身份二次确认");
5967
- characterIds.push(String(updated.id));
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: [...new Set(characterIds)],
5986
- candidateCount: mergedGroups.size,
5987
- savedCount: new Set(characterIds).size,
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)