@musnows/scriverse 0.5.8 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ai.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { buildCompletionRequestBody, normalizeProviderBaseUrl, parseCompletionPayload, providerCompletionEndpoint, providerModelEndpoints, providerRequestHeaders } from "./ai-protocol.js";
2
2
  import { PLATFORM_AI_WORK_ID } from "./database.js";
3
3
  import { AppError, notFound } from "./errors.js";
4
+ import { HYBRID_SEARCH_TYPES, buildHybridSearchSnippet, documentParagraphLineRange, fuseHybridSearchChannels } from "./hybrid-search.js";
4
5
  import { logger, sanitizeError } from "./logger.js";
5
6
  import { paginated, paginationSql } from "./pagination.js";
6
7
  import { currentRequestActor } from "./request-context.js";
7
8
  import { fetchSafeAiEndpoint } from "./security.js";
8
- import { RELATIONSHIP_SEARCH_POLICY_VERSION, RelationshipApproximateMatchLimitError, findApproximateNameMatchesChunked, ftsPhrase, isRelationshipPhoneticReference, normalizeRelationshipSearchText, relationshipCharacterTokenText, relationshipCharacterTokens, relationshipPinyinTokenText, relationshipPinyinTokens } from "./relationship-search.js";
9
+ import { RELATIONSHIP_SEARCH_POLICY_VERSION, RelationshipApproximateMatchLimitError, findApproximateNameMatchesChunked, ftsPhrase, isRelationshipPhoneticReference, normalizeRelationshipSearchText, relationshipCharacterTokenText, relationshipCharacterTokens, relationshipPinyinSearchTokens, relationshipPinyinTokenText, relationshipPinyinTokens } from "./relationship-search.js";
9
10
  import { clamp, id, json, maskSecret, now } from "./utils.js";
10
11
  import { z } from "zod";
11
12
  export function aiErrorForLog(error) {
@@ -18,6 +19,37 @@ export function aiErrorForLog(error) {
18
19
  return { name: sanitized.name ?? "Error", message: "Provider returned invalid JSON" };
19
20
  return sanitized;
20
21
  }
22
+ const AUTO_RUN_MAX_ATTEMPTS = 3;
23
+ const AUTO_RUN_RETRY_DELAYS_MS = [5_000, 30_000];
24
+ const AUTO_RUN_FATAL_CODES = new Set([
25
+ "CREDENTIAL_DECRYPT_FAILED",
26
+ "MODEL_REQUIRED",
27
+ "MODEL_DISABLED",
28
+ "MODEL_PLATFORM_MISMATCH",
29
+ "PROVIDER_DISABLED",
30
+ "PROVIDER_UNAVAILABLE",
31
+ "WORK_ACCESS_DENIED",
32
+ "WORK_MODULE_READ_DENIED"
33
+ ]);
34
+ export function autoRunFailureDisposition(error, attemptCount) {
35
+ const appError = error instanceof AppError ? error : null;
36
+ const details = appError?.details && typeof appError.details === "object" && !Array.isArray(appError.details)
37
+ ? appError.details
38
+ : null;
39
+ const providerFailure = typeof details?.failure === "string" ? details.failure : "";
40
+ const httpStatus = Number(providerFailure.match(/HTTP (\d{3})/u)?.[1] ?? 0);
41
+ const pauseImmediately = Boolean(appError && (AUTO_RUN_FATAL_CODES.has(appError.code) || httpStatus === 401 || httpStatus === 403));
42
+ const retryable = !pauseImmediately && (!appError
43
+ || (appError.code === "AI_CALL_FAILED"
44
+ ? httpStatus === 0 || httpStatus === 408 || httpStatus === 425 || httpStatus === 429 || httpStatus >= 500
45
+ : appError.status >= 500));
46
+ const retry = retryable && attemptCount < AUTO_RUN_MAX_ATTEMPTS;
47
+ return {
48
+ retry,
49
+ retryDelayMs: retry ? AUTO_RUN_RETRY_DELAYS_MS[Math.max(0, attemptCount - 1)] ?? AUTO_RUN_RETRY_DELAYS_MS.at(-1) ?? 30_000 : 0,
50
+ pauseImmediately
51
+ };
52
+ }
21
53
  const allowedParameters = new Set(["temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty", "seed"]);
22
54
  const DEFAULT_MAX_TOKENS = 32_000;
23
55
  const DEFAULT_CONTEXT_WINDOW = 128_000;
@@ -201,7 +233,7 @@ const AGENT_TOOL_DEFINITIONS = {
201
233
  type: "function",
202
234
  function: {
203
235
  name: "search_story_entities",
204
- description: "按短关键词在结构化作品实体中做字面/子串匹配:设定、人物(含 Markdown 档案章节)、种族、组织、时间线、关系、大纲和伏笔。不是语义检索或知识库问答;请传入实体名、别名、标题或其子串,不要传入自然语言整句。返回简要匹配项;人物结果含 sectionId 时可再调用 read_character_sections 精读。无匹配时改用更短关键词,或改用 story_index / grep。",
236
+ description: "按短关键词在结构化作品实体中进行元数据、精确全文和拼音混合检索:设定、人物(含 Markdown 档案章节)、种族、组织、时间线、关系、大纲和伏笔。不是语义问答;请传入实体名、别名、标题、拼音或短关键词,不要传入自然语言整句。结果按综合相关度排序;人物结果含 sectionId 时可再调用 read_character_sections 精读。无匹配时改用更短关键词,或改用 story_index / grep。",
205
237
  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 } }, required: ["query"], additionalProperties: false }
206
238
  }
207
239
  },
@@ -923,8 +955,9 @@ export class AiManager {
923
955
  authorizeTaskRun;
924
956
  contextBuilder;
925
957
  taskControllers = new Map();
926
- autoRunBatches = new Map();
958
+ autoRunStarting = new Map();
927
959
  autoRunTimers = new Map();
960
+ autoRunStartupTimer = null;
928
961
  relationshipIndexBuilds = new Map();
929
962
  relationshipSelectionCache = new Map();
930
963
  relationshipSelectionBuilds = new Map();
@@ -941,6 +974,11 @@ export class AiManager {
941
974
  this.authorizeTaskRun = authorizeTaskRun;
942
975
  this.contextBuilder = new ContextBuilder(store);
943
976
  this.store.setAnalysisTaskQueuedHandler((workId) => this.scheduleAutoRun(workId));
977
+ this.autoRunStartupTimer = setTimeout(() => {
978
+ this.autoRunStartupTimer = null;
979
+ for (const workId of this.store.listAutoRunWorkIds())
980
+ this.scheduleAutoRun(workId);
981
+ }, 0);
944
982
  this.store.setRelationshipIndexQueuedHandler((workId) => this.scheduleRelationshipIndexSync(workId));
945
983
  this.relationshipIndexTimer = setTimeout(() => {
946
984
  this.relationshipIndexTimer = null;
@@ -955,6 +993,168 @@ export class AiManager {
955
993
  this.store.getWork(workId);
956
994
  return this.getTokenUsage(workId, timezoneOffset, false);
957
995
  }
996
+ async searchWork(workId, query, options = {}) {
997
+ this.store.getWork(workId);
998
+ const normalizedQuery = normalizeRelationshipSearchText(query).trim();
999
+ if (!normalizedQuery)
1000
+ return [];
1001
+ await this.ensureRelationshipSearchIndex(workId);
1002
+ const requestedTypes = options.type ? new Set([options.type]) : new Set(HYBRID_SEARCH_TYPES);
1003
+ const resultLimit = Math.min(100, Math.max(1, Math.trunc(options.limit ?? 50)));
1004
+ const channelLimit = Math.min(200, Math.max(50, resultLimit * 4));
1005
+ const accepts = (type) => requestedTypes.has(type);
1006
+ const metadataDetails = new Map();
1007
+ const metadataCandidates = this.store.search(workId, normalizedQuery).flatMap((item) => {
1008
+ const type = String(item.type);
1009
+ const itemId = String(item.id ?? "");
1010
+ if (!itemId || !accepts(type))
1011
+ return [];
1012
+ const key = `${type}:${itemId}`;
1013
+ const { type: _type, id: _id, title: _title, snippet: _snippet, ...details } = item;
1014
+ metadataDetails.set(key, { ...(metadataDetails.get(key) ?? {}), ...details });
1015
+ return [{
1016
+ key,
1017
+ type,
1018
+ id: itemId,
1019
+ title: String(item.title ?? "未命名资料"),
1020
+ subtitle: typeof item.category === "string" ? item.category : undefined,
1021
+ snippet: buildHybridSearchSnippet(String(item.snippet ?? ""), normalizedQuery),
1022
+ sectionId: typeof item.sectionId === "string" ? item.sectionId : undefined,
1023
+ matchKind: "metadata"
1024
+ }];
1025
+ }).slice(0, channelLimit);
1026
+ const exactCandidates = [
1027
+ ...(requestedTypes.has("chapter") ? this.hybridChapterMatches(workId, normalizedQuery, "exact", channelLimit) : []),
1028
+ ...this.hybridIndexedSourceMatches(workId, normalizedQuery, "exact", requestedTypes, channelLimit)
1029
+ ];
1030
+ const phoneticCandidates = [
1031
+ ...(requestedTypes.has("chapter") ? this.hybridChapterMatches(workId, normalizedQuery, "phonetic", channelLimit) : []),
1032
+ ...this.hybridIndexedSourceMatches(workId, normalizedQuery, "phonetic", requestedTypes, channelLimit)
1033
+ ];
1034
+ return fuseHybridSearchChannels([
1035
+ { weight: 1.4, candidates: metadataCandidates },
1036
+ { weight: 1, candidates: exactCandidates },
1037
+ { weight: 0.55, candidates: phoneticCandidates }
1038
+ ], resultLimit).map((item) => ({
1039
+ ...(metadataDetails.get(`${item.type}:${item.id}`) ?? {}),
1040
+ ...item
1041
+ }));
1042
+ }
1043
+ hybridChapterMatches(workId, query, matchKind, limit) {
1044
+ let rows;
1045
+ if (matchKind === "phonetic") {
1046
+ const tokens = relationshipPinyinSearchTokens(query);
1047
+ if (tokens.length === 0)
1048
+ return [];
1049
+ rows = this.store.db.all(`SELECT paragraph.chapter_id, paragraph.paragraph_order, paragraph.content AS paragraph_content,
1050
+ chapter.title AS chapter_title, chapter.content AS chapter_content, volume.title AS volume_title
1051
+ FROM chapter_paragraph_pinyin_fts pinyin
1052
+ JOIN chapter_paragraph_search paragraph ON paragraph.id = pinyin.rowid
1053
+ JOIN chapters chapter ON chapter.id = paragraph.chapter_id
1054
+ JOIN volumes volume ON volume.id = chapter.volume_id
1055
+ WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL
1056
+ AND chapter_paragraph_pinyin_fts MATCH ?
1057
+ ORDER BY bm25(chapter_paragraph_pinyin_fts), volume.sort_order, chapter.sort_order, paragraph.paragraph_order
1058
+ LIMIT ?`, workId, ftsPhrase(tokens), limit);
1059
+ }
1060
+ else if ([...query].length < 3) {
1061
+ rows = this.store.db.all(`SELECT paragraph.chapter_id, paragraph.paragraph_order, paragraph.content AS paragraph_content,
1062
+ chapter.title AS chapter_title, chapter.content AS chapter_content, volume.title AS volume_title
1063
+ FROM chapter_paragraph_short_terms term
1064
+ JOIN chapter_paragraph_search paragraph ON paragraph.id = term.paragraph_id
1065
+ JOIN chapters chapter ON chapter.id = paragraph.chapter_id
1066
+ JOIN volumes volume ON volume.id = chapter.volume_id
1067
+ WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL AND term.term = ?
1068
+ ORDER BY volume.sort_order, chapter.sort_order, paragraph.paragraph_order
1069
+ LIMIT ?`, workId, query, limit);
1070
+ }
1071
+ else {
1072
+ rows = this.store.db.all(`SELECT paragraph.chapter_id, paragraph.paragraph_order, paragraph.content AS paragraph_content,
1073
+ chapter.title AS chapter_title, chapter.content AS chapter_content, volume.title AS volume_title
1074
+ FROM chapter_paragraph_search_fts fts
1075
+ JOIN chapter_paragraph_search paragraph ON paragraph.id = fts.rowid
1076
+ JOIN chapters chapter ON chapter.id = paragraph.chapter_id
1077
+ JOIN volumes volume ON volume.id = chapter.volume_id
1078
+ WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL
1079
+ AND chapter_paragraph_search_fts MATCH ?
1080
+ ORDER BY bm25(chapter_paragraph_search_fts), volume.sort_order, chapter.sort_order, paragraph.paragraph_order
1081
+ LIMIT ?`, workId, `"${query.replaceAll('"', '""')}"`, limit);
1082
+ }
1083
+ const seen = new Set();
1084
+ return rows.flatMap((row) => {
1085
+ const chapterId = String(row.chapter_id ?? "");
1086
+ const key = `chapter:${chapterId}`;
1087
+ if (!chapterId || seen.has(key))
1088
+ return [];
1089
+ seen.add(key);
1090
+ const range = documentParagraphLineRange(String(row.chapter_content ?? ""), Number(row.paragraph_order));
1091
+ return [{
1092
+ key,
1093
+ type: "chapter",
1094
+ id: chapterId,
1095
+ title: String(row.chapter_title ?? "未命名章节"),
1096
+ subtitle: String(row.volume_title ?? ""),
1097
+ snippet: buildHybridSearchSnippet(String(row.paragraph_content ?? ""), query),
1098
+ matchKind,
1099
+ ...(range ?? {})
1100
+ }];
1101
+ });
1102
+ }
1103
+ hybridIndexedSourceMatches(workId, query, matchKind, requestedTypes, limit) {
1104
+ const tokens = matchKind === "exact" ? relationshipCharacterTokens(query) : relationshipPinyinSearchTokens(query);
1105
+ if (tokens.length === 0)
1106
+ return [];
1107
+ const table = matchKind === "exact" ? "relationship_source_exact_fts" : "relationship_source_pinyin_fts";
1108
+ const rows = this.store.db.all(`SELECT source.source_type, source.source_id FROM ${table} search_index
1109
+ JOIN relationship_source_search source ON source.id = search_index.rowid
1110
+ WHERE source.work_id = ? AND ${table} MATCH ?
1111
+ ORDER BY bm25(${table}), source.source_type, source.source_id
1112
+ LIMIT ?`, workId, ftsPhrase(tokens), limit);
1113
+ return rows.flatMap((row) => {
1114
+ const sourceType = String(row.source_type ?? "");
1115
+ const sourceId = String(row.source_id ?? "");
1116
+ if (!sourceId || !requestedTypes.has(sourceType))
1117
+ return [];
1118
+ const source = this.relationshipIndexedSource(workId, sourceType, sourceId);
1119
+ if (!source)
1120
+ return [];
1121
+ return [{
1122
+ key: `${sourceType}:${sourceId}`,
1123
+ type: sourceType,
1124
+ id: sourceId,
1125
+ title: this.hybridSourceTitle(sourceType, source.title),
1126
+ snippet: buildHybridSearchSnippet(source.content, query),
1127
+ matchKind
1128
+ }];
1129
+ });
1130
+ }
1131
+ hybridSourceTitle(sourceType, title) {
1132
+ const prefixes = {
1133
+ character: "人物档案:",
1134
+ race: "种族设定:",
1135
+ organization: "组织设定:",
1136
+ "timeline-track": "时间轴:",
1137
+ "timeline-event": "时间线事件:",
1138
+ relationship: "人物关系:",
1139
+ "chapter-outline": "章节大纲:",
1140
+ foreshadow: "伏笔:",
1141
+ review: "审核项:"
1142
+ };
1143
+ const prefix = prefixes[sourceType] ?? "";
1144
+ return prefix && title.startsWith(prefix) ? title.slice(prefix.length) : title;
1145
+ }
1146
+ hybridAiSearchDetails(workId, sourceType, sourceId) {
1147
+ const source = this.relationshipIndexedSource(workId, sourceType, sourceId);
1148
+ if (!source)
1149
+ return {};
1150
+ try {
1151
+ const parsed = JSON.parse(source.content);
1152
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1153
+ }
1154
+ catch {
1155
+ return {};
1156
+ }
1157
+ }
958
1158
  getTokenUsage(workId, timezoneOffset, includeWorks) {
959
1159
  const scopeSql = workId === null ? "" : " AND call.work_id = ?";
960
1160
  const scopeParams = workId === null ? [] : [workId];
@@ -1036,12 +1236,22 @@ export class AiManager {
1036
1236
  estimatedRequestCount: numberValue(row, "estimated_request_count")
1037
1237
  };
1038
1238
  }
1039
- resetAutoRunBatch(workId) {
1040
- this.autoRunBatches.set(workId, { claimed: 0, starting: new Set() });
1041
- }
1042
- scheduleAutoRun(workId) {
1239
+ scheduleAutoRun(workId, delayMs = 0) {
1240
+ let resolvedDelay = Math.max(0, delayMs);
1043
1241
  try {
1044
- if (!this.store.getWorkAiSettings(workId).autoRunEnabled)
1242
+ let settings = this.store.getWorkAiSettings(workId);
1243
+ if (!settings.autoRunEnabled)
1244
+ return;
1245
+ if (settings.autoRunPaused) {
1246
+ const resumeAt = typeof settings.autoRunResumeAt === "string" ? Date.parse(settings.autoRunResumeAt) : Number.NaN;
1247
+ if (!Number.isFinite(resumeAt))
1248
+ return;
1249
+ if (resumeAt <= Date.now())
1250
+ settings = this.store.clearAutoRunPause(workId);
1251
+ else
1252
+ resolvedDelay = Math.max(resolvedDelay, resumeAt - Date.now());
1253
+ }
1254
+ if (settings.autoRunPaused && resolvedDelay === 0)
1045
1255
  return;
1046
1256
  }
1047
1257
  catch {
@@ -1053,38 +1263,37 @@ export class AiManager {
1053
1263
  const timer = setTimeout(() => {
1054
1264
  this.autoRunTimers.delete(workId);
1055
1265
  void this.drainAutoRun(workId);
1056
- }, 0);
1266
+ }, Math.min(resolvedDelay, 2_147_483_647));
1057
1267
  this.autoRunTimers.set(workId, timer);
1058
- logger.debug("ai.auto_run.scheduled", { workId });
1268
+ logger.debug("ai.auto_run.scheduled", { workId, delayMs: resolvedDelay });
1059
1269
  }
1060
- startAutoRunBatch(workId) {
1270
+ resumeAutoRun(workId) {
1061
1271
  this.store.getWork(workId);
1062
- const settings = this.store.getWorkAiSettings(workId);
1272
+ let settings = this.store.getWorkAiSettings(workId);
1063
1273
  if (!settings.autoRunEnabled) {
1064
1274
  throw new AppError(400, "AUTO_RUN_DISABLED", "请先开启分析任务自动运行");
1065
1275
  }
1066
- this.resetAutoRunBatch(workId);
1276
+ settings = this.store.clearAutoRunPause(workId);
1067
1277
  this.scheduleAutoRun(workId);
1068
- logger.info("ai.auto_run.batch_started", {
1278
+ logger.info("ai.auto_run.resumed", {
1069
1279
  workId,
1070
- concurrency: settings.autoRunConcurrency,
1071
- batchLimit: settings.autoRunBatchLimit
1280
+ concurrency: settings.autoRunConcurrency
1072
1281
  });
1073
1282
  return {
1074
- workId,
1075
- autoRunEnabled: true,
1076
- autoRunConcurrency: settings.autoRunConcurrency,
1077
- autoRunBatchLimit: settings.autoRunBatchLimit,
1283
+ ...settings,
1078
1284
  pendingCount: this.store.countPendingTasks(workId),
1079
1285
  runningCount: this.store.countRunningTasks(workId)
1080
1286
  };
1081
1287
  }
1082
1288
  dispose() {
1083
1289
  logger.info("ai.manager.disposing", { scheduledWorks: this.autoRunTimers.size, activeTasks: this.taskControllers.size });
1290
+ if (this.autoRunStartupTimer)
1291
+ clearTimeout(this.autoRunStartupTimer);
1292
+ this.autoRunStartupTimer = null;
1084
1293
  for (const timer of this.autoRunTimers.values())
1085
1294
  clearTimeout(timer);
1086
1295
  this.autoRunTimers.clear();
1087
- this.autoRunBatches.clear();
1296
+ this.autoRunStarting.clear();
1088
1297
  this.relationshipIndexDisposed = true;
1089
1298
  for (const timer of this.relationshipIndexSyncTimers.values())
1090
1299
  clearTimeout(timer);
@@ -1096,42 +1305,57 @@ export class AiManager {
1096
1305
  this.store.setRelationshipIndexQueuedHandler(null);
1097
1306
  logger.info("ai.manager.disposed");
1098
1307
  }
1099
- getAutoRunBatch(workId) {
1100
- const existing = this.autoRunBatches.get(workId);
1308
+ getAutoRunStarting(workId) {
1309
+ const existing = this.autoRunStarting.get(workId);
1101
1310
  if (existing)
1102
1311
  return existing;
1103
- const created = { claimed: 0, starting: new Set() };
1104
- this.autoRunBatches.set(workId, created);
1312
+ const created = new Set();
1313
+ this.autoRunStarting.set(workId, created);
1105
1314
  return created;
1106
1315
  }
1107
1316
  async drainAutoRun(workId) {
1108
1317
  try {
1109
1318
  logger.debug("ai.auto_run.drain_started", { workId });
1110
1319
  const settings = this.store.getWorkAiSettings(workId);
1111
- if (!settings.autoRunEnabled)
1320
+ if (!settings.autoRunEnabled || settings.autoRunPaused)
1321
+ return;
1322
+ const dailyTaskLimit = Number(settings.autoRunDailyTaskLimit);
1323
+ if (dailyTaskLimit > 0 && this.store.countAutoRunAttemptsToday(workId) >= dailyTaskLimit) {
1324
+ const resumeAt = new Date();
1325
+ resumeAt.setUTCHours(24, 0, 0, 0);
1326
+ this.store.pauseAutoRun(workId, `已达到每日自动执行上限 ${dailyTaskLimit} 个任务`, resumeAt.toISOString());
1327
+ this.scheduleAutoRun(workId);
1328
+ logger.info("ai.auto_run.daily_limit_reached", { workId, dailyTaskLimit, resumeAt: resumeAt.toISOString() });
1112
1329
  return;
1113
- const batch = this.getAutoRunBatch(workId);
1330
+ }
1331
+ const starting = this.getAutoRunStarting(workId);
1114
1332
  const concurrency = Number(settings.autoRunConcurrency);
1115
- const batchLimit = Number(settings.autoRunBatchLimit);
1116
- while (true) {
1117
- // 只计 DB running:starting 任务会在 runTask 同步阶段立刻标为 running,再加 starting 会重复计数
1118
- const inFlight = this.store.countRunningTasks(workId);
1119
- const remainingClaims = batchLimit - batch.claimed;
1120
- if (inFlight >= concurrency || remainingClaims <= 0)
1121
- return;
1122
- const candidates = this.store.listOldestPendingTaskIds(workId, remainingClaims)
1123
- .filter((taskId) => !batch.starting.has(taskId) && !this.taskControllers.has(taskId));
1124
- if (!candidates.length)
1333
+ const remainingDailyTasks = dailyTaskLimit > 0
1334
+ ? Math.max(0, dailyTaskLimit - this.store.countAutoRunAttemptsToday(workId))
1335
+ : Number.POSITIVE_INFINITY;
1336
+ let availableSlots = Math.min(Math.max(0, concurrency - this.store.countRunningTasks(workId)), remainingDailyTasks);
1337
+ while (availableSlots > 0) {
1338
+ const candidates = this.store.listOldestPendingTaskIds(workId, concurrency)
1339
+ .filter((taskId) => !starting.has(taskId) && !this.taskControllers.has(taskId));
1340
+ if (!candidates.length) {
1341
+ const nextAttemptAt = this.store.nextPendingTaskAttemptAt(workId);
1342
+ if (nextAttemptAt)
1343
+ this.scheduleAutoRun(workId, Math.max(1, Date.parse(nextAttemptAt) - Date.now()));
1125
1344
  return;
1345
+ }
1126
1346
  const taskId = candidates[0];
1127
1347
  if (!taskId)
1128
1348
  return;
1129
- batch.starting.add(taskId);
1130
- batch.claimed += 1;
1131
- void this.runTask(taskId)
1132
- .catch(() => undefined)
1349
+ starting.add(taskId);
1350
+ availableSlots -= 1;
1351
+ void this.runTask(taskId, undefined, undefined, { runningLimit: concurrency, autoRun: true })
1352
+ .then((result) => {
1353
+ if (result.status === "review" || result.status === "completed")
1354
+ this.store.recordAutoRunSuccess(workId);
1355
+ })
1356
+ .catch((error) => this.handleAutoRunFailure(workId, taskId, error))
1133
1357
  .finally(() => {
1134
- batch.starting.delete(taskId);
1358
+ starting.delete(taskId);
1135
1359
  this.scheduleAutoRun(workId);
1136
1360
  });
1137
1361
  }
@@ -1141,6 +1365,39 @@ export class AiManager {
1141
1365
  // 数据库已关闭或作品不存在时忽略自动调度
1142
1366
  }
1143
1367
  }
1368
+ handleAutoRunFailure(workId, taskId, error) {
1369
+ let current = this.store.getTask(taskId);
1370
+ if (current.status === "pending" && error instanceof AppError && error.code === "TASK_NOT_PENDING")
1371
+ return;
1372
+ if (current.status === "pending" && current.nextAttemptAt) {
1373
+ logger.info("ai.auto_run.retry_waiting", {
1374
+ workId,
1375
+ taskId,
1376
+ attemptCount: current.attemptCount,
1377
+ nextAttemptAt: current.nextAttemptAt
1378
+ });
1379
+ return;
1380
+ }
1381
+ const message = error instanceof AppError ? error.message : "自动执行失败";
1382
+ if (current.status === "pending") {
1383
+ current = this.store.updateTask(taskId, {
1384
+ status: "partial",
1385
+ progress: 100,
1386
+ failures: [{ message, ...(error instanceof AppError ? { code: error.code } : {}) }]
1387
+ });
1388
+ }
1389
+ if (current.status !== "partial")
1390
+ return;
1391
+ const disposition = autoRunFailureDisposition(error, Number(current.attemptCount));
1392
+ const settings = this.store.recordAutoRunFailure(workId, message, disposition.pauseImmediately);
1393
+ logger.warn("ai.auto_run.task_failed", {
1394
+ workId,
1395
+ taskId,
1396
+ consecutiveFailures: settings.autoRunConsecutiveFailures,
1397
+ paused: settings.autoRunPaused,
1398
+ error: aiErrorForLog(error)
1399
+ });
1400
+ }
1144
1401
  outboundFetch(url, init) {
1145
1402
  return fetchSafeAiEndpoint(this.fetchImpl, url, init, this.validateOutboundUrl);
1146
1403
  }
@@ -1883,7 +2140,7 @@ export class AiManager {
1883
2140
  }
1884
2141
  };
1885
2142
  }
1886
- async runTask(taskId, modelId, actor) {
2143
+ async runTask(taskId, modelId, actor, options = {}) {
1887
2144
  const task = this.store.getTask(taskId);
1888
2145
  this.authorizeTaskRun?.(task, actor);
1889
2146
  const workId = String(task.workId);
@@ -1891,24 +2148,19 @@ export class AiManager {
1891
2148
  ? task.model
1892
2149
  : null;
1893
2150
  const selectedModelId = modelId ?? (typeof taskModel?.id === "string" ? taskModel.id : undefined);
1894
- const batch = this.getAutoRunBatch(workId);
1895
2151
  const startedAt = process.hrtime.bigint();
1896
2152
  logger.info("ai.task.started", { taskId, workId, taskType: task.taskType, modelId: selectedModelId ?? null });
1897
2153
  if (task.status !== "pending")
1898
2154
  throw new AppError(409, "TASK_NOT_PENDING", "只有待执行任务可以运行");
1899
2155
  if (!this.store.isTaskSourceCurrent(taskId)) {
1900
2156
  const expired = this.store.updateTask(taskId, { status: "expired" });
1901
- batch.starting.delete(taskId);
1902
2157
  this.scheduleAutoRun(workId);
1903
2158
  logger.warn("ai.task.expired", { taskId, workId, durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000 });
1904
2159
  return expired;
1905
2160
  }
1906
- const settings = this.store.getWorkAiSettings(workId);
1907
- // 自动 drain 已在 starting 集合中认领;手动运行在开关开启时计入本轮配额
1908
- if (Boolean(settings.autoRunEnabled) && !batch.starting.has(taskId)) {
1909
- batch.claimed += 1;
1910
- }
1911
- this.store.updateTask(taskId, { status: "running", progress: 5 });
2161
+ const claimed = this.store.claimPendingTask(taskId, options.runningLimit);
2162
+ if (!claimed)
2163
+ throw new AppError(409, "TASK_NOT_PENDING", "任务已被其他执行器认领或当前并发已满");
1912
2164
  const taskController = new AbortController();
1913
2165
  this.taskControllers.set(taskId, taskController);
1914
2166
  try {
@@ -1966,13 +2218,28 @@ export class AiManager {
1966
2218
  const failure = error instanceof AppError
1967
2219
  ? { message, code: error.code, ...(error.details === undefined ? {} : { details: error.details }) }
1968
2220
  : { message };
2221
+ if (options.autoRun) {
2222
+ const current = this.store.getTask(taskId);
2223
+ const disposition = autoRunFailureDisposition(error, Number(current.attemptCount));
2224
+ if (disposition.retry) {
2225
+ const nextAttemptAt = new Date(Date.now() + disposition.retryDelayMs).toISOString();
2226
+ const pending = this.store.rescheduleTask(taskId, failure, nextAttemptAt);
2227
+ logger.warn("ai.task.retry_scheduled", {
2228
+ taskId,
2229
+ workId,
2230
+ attemptCount: pending.attemptCount,
2231
+ nextAttemptAt,
2232
+ error: aiErrorForLog(error)
2233
+ });
2234
+ throw error;
2235
+ }
2236
+ }
1969
2237
  this.store.updateTask(taskId, { status: "partial", progress: 100, failures: [failure] });
1970
2238
  logger.error("ai.task.failed", { taskId, workId, durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000, error: aiErrorForLog(error) });
1971
2239
  throw error;
1972
2240
  }
1973
2241
  finally {
1974
2242
  this.taskControllers.delete(taskId);
1975
- batch.starting.delete(taskId);
1976
2243
  this.scheduleAutoRun(workId);
1977
2244
  }
1978
2245
  }
@@ -2135,7 +2402,7 @@ export class AiManager {
2135
2402
  ? [
2136
2403
  `当前可用作品查询工具:${enabledToolIds.join("、")}。`,
2137
2404
  "当作者询问当前作品、项目、章节、情节、人物、关系、世界观或设定,而预加载上下文为空或不足时,必须先调用工具主动查询;不得直接声称没有上下文,也不得先要求作者补充本系统已经能够查询的信息。",
2138
- "整体介绍、作品基本信息、目录或章节定位优先调用 story_index;按关键字定位正文段落时调用 grep;已知章节 ID 且需要原文事实或精确措辞时调用 read_chapters;查找设定、人物、组织、时间线、关系、大纲或伏笔时调用 search_story_entities(传入短实体名或关键词子串,勿用自然语言整句);人物匹配结果包含 sectionId 且需要背景故事、能力或经历原文时调用 read_character_sections。",
2405
+ "整体介绍、作品基本信息、目录或章节定位优先调用 story_index;按关键字定位正文段落时调用 grep;已知章节 ID 且需要原文事实或精确措辞时调用 read_chapters;查找设定、人物、组织、时间线、关系、大纲或伏笔时调用 search_story_entities(可传入短实体名、拼音或关键词,勿用自然语言整句);人物匹配结果包含 sectionId 且需要背景故事、能力或经历原文时调用 read_character_sections。",
2139
2406
  "根据问题选择最少且必要的工具。工具结果仍不足时才说明未知,并明确已经查询过什么;不要重复无效调用。"
2140
2407
  ].join("\n")
2141
2408
  : "";
@@ -2194,7 +2461,7 @@ export class AiManager {
2194
2461
  enabledAgentTools(workId, taskType, requestedToolIds) {
2195
2462
  return this.enabledAgentToolIds(workId, taskType, requestedToolIds).map((toolId) => AGENT_TOOL_DEFINITIONS[toolId]);
2196
2463
  }
2197
- executeAgentTool(workId, toolCall) {
2464
+ async executeAgentTool(workId, toolCall) {
2198
2465
  const name = toolCall.function.name;
2199
2466
  const calledAt = now();
2200
2467
  let rawArguments = toolCall.function.arguments;
@@ -2316,14 +2583,20 @@ export class AiManager {
2316
2583
  const { query, categories: categoryList } = args;
2317
2584
  const categories = new Set(categoryList);
2318
2585
  const allowed = new Set(["setting", "character", "race", "organization", "timeline", "relationship", "outline", "foreshadow"]);
2319
- const matches = this.store.search(workId, query).filter((item) => !categories.size || categories.has(String(item.type))).slice(0, 20);
2320
- const extra = [
2321
- ...this.store.listTimelineEvents(workId).map((item) => ({ type: "timeline", id: item.id, title: item.name, snippet: `${item.description} ${item.timeLabel}` })),
2322
- ...this.store.listRelationships(workId).map((item) => ({ type: "relationship", id: item.id, title: `${item.fromCharacterId} / ${item.toCharacterId}`, snippet: `${item.category} ${item.subtype} ${item.keywords.join(" ")}` })),
2323
- ...this.store.listChapterOutlines(workId).map((item) => ({ type: "outline", id: item.chapterId, title: item.chapterTitle, snippet: `${item.goal} ${item.conflict} ${item.turningPoint} ${item.notes}` })),
2324
- ...this.store.listForeshadows(workId).map((item) => ({ type: "foreshadow", id: item.id, title: item.title, snippet: `${item.description} ${item.resolutionNote}` }))
2325
- ].filter((item) => allowed.has(item.type) && (!categories.size || categories.has(item.type)) && `${item.title} ${item.snippet}`.toLocaleLowerCase("zh-CN").includes(query.toLocaleLowerCase("zh-CN"))).slice(0, 20);
2326
- const combined = [...matches, ...extra].slice(0, 30);
2586
+ const combined = (await this.searchWork(workId, query, { limit: 100 })).flatMap((item) => {
2587
+ const sourceType = String(item.type);
2588
+ const type = sourceType === "timeline-track" || sourceType === "timeline-event"
2589
+ ? "timeline"
2590
+ : sourceType === "chapter-outline" ? "outline" : sourceType;
2591
+ if (!allowed.has(type) || (categories.size > 0 && !categories.has(type)))
2592
+ return [];
2593
+ return [{
2594
+ ...item,
2595
+ ...this.hybridAiSearchDetails(workId, sourceType, String(item.id)),
2596
+ type,
2597
+ sourceType
2598
+ }];
2599
+ }).slice(0, 30);
2327
2600
  return {
2328
2601
  id: toolCall.id,
2329
2602
  name,
@@ -2334,10 +2607,10 @@ export class AiManager {
2334
2607
  ok: true,
2335
2608
  data: {
2336
2609
  query,
2337
- matchMode: "literal_substring",
2610
+ matchMode: "hybrid_exact_phonetic",
2338
2611
  matches: combined,
2339
2612
  ...(combined.length === 0
2340
- ? { hint: "无字面匹配。请改用更短的实体名、别名或标题子串;也可改用 story_index 浏览目录,或用 grep 搜索正文关键字。" }
2613
+ ? { hint: "没有找到精确或拼音相关结果。请改用更短的实体名、别名或标题,也可使用 story_index 浏览目录,或用 grep 搜索正文关键字。" }
2341
2614
  : {})
2342
2615
  }
2343
2616
  }
@@ -2634,7 +2907,7 @@ export class AiManager {
2634
2907
  ...(choice.message.anthropic_content?.length ? { anthropic_content: choice.message.anthropic_content } : {})
2635
2908
  });
2636
2909
  for (const toolCall of toolCalls) {
2637
- const execution = this.executeAgentTool(input.workId, toolCall);
2910
+ const execution = await this.executeAgentTool(input.workId, toolCall);
2638
2911
  logger.info("ai.tool_call.completed", { callId, toolName: execution.name, status: execution.status, round });
2639
2912
  executedToolCalls.push(execution);
2640
2913
  traceRounds.at(-1)?.toolExecutions.push(execution);
@@ -4670,7 +4943,13 @@ export class AiManager {
4670
4943
  return source(`人物档案:${String(item.name)}`, {
4671
4944
  name: item.name, aliases: item.aliases, code: item.code, species: item.species, race: item.race,
4672
4945
  organizations: item.organizations, attributes: item.attributes, profile: item.profile,
4673
- currentState: item.currentState, lockedFields: item.lockedFields
4946
+ currentState: item.currentState, lockedFields: item.lockedFields,
4947
+ profileSections: this.store.listCharacterProfileSections(sourceId).map((section) => ({
4948
+ title: section.title,
4949
+ sectionType: section.sectionType,
4950
+ summary: section.summary,
4951
+ contentMarkdown: section.contentMarkdown
4952
+ }))
4674
4953
  }, item.versionNo);
4675
4954
  }
4676
4955
  if (sourceType === "race") {
@@ -4678,7 +4957,11 @@ export class AiManager {
4678
4957
  if (String(item.workId) !== workId)
4679
4958
  return null;
4680
4959
  return source(`种族设定:${String(item.name)}`, {
4681
- name: item.name, description: item.description, lineage: item.lineage, settings: item.settings,
4960
+ name: item.name,
4961
+ description: item.description,
4962
+ racePath: item.lineage.map((entry) => entry.name).join(" / "),
4963
+ lineage: item.lineage,
4964
+ settings: item.settings,
4682
4965
  effectiveSettings: item.effectiveSettings, members: item.members
4683
4966
  }, item.versionNo);
4684
4967
  }