@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 +350 -67
- package/dist/ai.js.map +1 -1
- package/dist/app.js +46 -35
- package/dist/app.js.map +1 -1
- package/dist/cli-core.js +161 -20
- package/dist/cli-core.js.map +1 -1
- package/dist/database.js +72 -0
- package/dist/database.js.map +1 -1
- package/dist/hybrid-search.js +106 -0
- package/dist/hybrid-search.js.map +1 -0
- package/dist/public/app.js +215 -54
- package/dist/public/display-labels.js +13 -1
- package/dist/public/global-search.d.ts +6 -5
- package/dist/public/global-search.js +44 -3
- package/dist/public/index.html +20 -7
- package/dist/public/race-hierarchy.js +0 -35
- package/dist/public/styles.css +24 -5
- package/dist/relationship-search.js +21 -2
- package/dist/relationship-search.js.map +1 -1
- package/dist/store.js +137 -5
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +27 -1
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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: "
|
|
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
|
-
|
|
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
|
-
|
|
1040
|
-
|
|
1041
|
-
}
|
|
1042
|
-
scheduleAutoRun(workId) {
|
|
1239
|
+
scheduleAutoRun(workId, delayMs = 0) {
|
|
1240
|
+
let resolvedDelay = Math.max(0, delayMs);
|
|
1043
1241
|
try {
|
|
1044
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
1270
|
+
resumeAutoRun(workId) {
|
|
1061
1271
|
this.store.getWork(workId);
|
|
1062
|
-
|
|
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.
|
|
1276
|
+
settings = this.store.clearAutoRunPause(workId);
|
|
1067
1277
|
this.scheduleAutoRun(workId);
|
|
1068
|
-
logger.info("ai.auto_run.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1100
|
-
const existing = this.
|
|
1308
|
+
getAutoRunStarting(workId) {
|
|
1309
|
+
const existing = this.autoRunStarting.get(workId);
|
|
1101
1310
|
if (existing)
|
|
1102
1311
|
return existing;
|
|
1103
|
-
const created =
|
|
1104
|
-
this.
|
|
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
|
-
|
|
1330
|
+
}
|
|
1331
|
+
const starting = this.getAutoRunStarting(workId);
|
|
1114
1332
|
const concurrency = Number(settings.autoRunConcurrency);
|
|
1115
|
-
const
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
-
|
|
1130
|
-
|
|
1131
|
-
void this.runTask(taskId)
|
|
1132
|
-
.
|
|
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
|
-
|
|
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
|
|
1907
|
-
|
|
1908
|
-
|
|
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
|
|
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
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
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: "
|
|
2610
|
+
matchMode: "hybrid_exact_phonetic",
|
|
2338
2611
|
matches: combined,
|
|
2339
2612
|
...(combined.length === 0
|
|
2340
|
-
? { hint: "
|
|
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,
|
|
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
|
}
|