@musnows/scriverse 0.5.8 → 0.5.10
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 +355 -70
- package/dist/ai.js.map +1 -1
- package/dist/app.js +48 -37
- 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 +421 -64
- 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 +36 -10
- package/dist/public/vditor-line-number-layout.d.ts +14 -0
- package/dist/public/vditor-line-number-layout.js +51 -0
- 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/dist/version.js.map +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
|
}
|
|
@@ -1559,7 +1816,7 @@ export class AiManager {
|
|
|
1559
1816
|
return updatedTask;
|
|
1560
1817
|
});
|
|
1561
1818
|
}
|
|
1562
|
-
rerunTask(taskId) {
|
|
1819
|
+
rerunTask(taskId, modelOverrideId) {
|
|
1563
1820
|
const original = this.store.getTask(taskId);
|
|
1564
1821
|
const rerunnableStatuses = new Set(["review", "completed", "partial", "expired", "cancelled"]);
|
|
1565
1822
|
if (!rerunnableStatuses.has(String(original.status))) {
|
|
@@ -1572,7 +1829,8 @@ export class AiManager {
|
|
|
1572
1829
|
const originalModel = original.model && typeof original.model === "object" && !Array.isArray(original.model)
|
|
1573
1830
|
? original.model
|
|
1574
1831
|
: null;
|
|
1575
|
-
const
|
|
1832
|
+
const originalModelId = typeof originalModel?.id === "string" ? originalModel.id : undefined;
|
|
1833
|
+
const modelId = modelOverrideId ?? originalModelId;
|
|
1576
1834
|
if (modelId)
|
|
1577
1835
|
this.resolveModel(String(original.workId), this.analysisTaskModelPurpose(String(original.taskType)), modelId);
|
|
1578
1836
|
const rerun = this.store.createTask(String(original.workId), {
|
|
@@ -1585,7 +1843,8 @@ export class AiManager {
|
|
|
1585
1843
|
taskId: rerun.id,
|
|
1586
1844
|
originalTaskId: taskId,
|
|
1587
1845
|
workId: original.workId,
|
|
1588
|
-
taskType: original.taskType
|
|
1846
|
+
taskType: original.taskType,
|
|
1847
|
+
...(modelOverrideId ? { modelId: modelOverrideId } : {})
|
|
1589
1848
|
});
|
|
1590
1849
|
return { ...rerun, rerunOfTaskId: taskId };
|
|
1591
1850
|
}
|
|
@@ -1883,7 +2142,7 @@ export class AiManager {
|
|
|
1883
2142
|
}
|
|
1884
2143
|
};
|
|
1885
2144
|
}
|
|
1886
|
-
async runTask(taskId, modelId, actor) {
|
|
2145
|
+
async runTask(taskId, modelId, actor, options = {}) {
|
|
1887
2146
|
const task = this.store.getTask(taskId);
|
|
1888
2147
|
this.authorizeTaskRun?.(task, actor);
|
|
1889
2148
|
const workId = String(task.workId);
|
|
@@ -1891,24 +2150,19 @@ export class AiManager {
|
|
|
1891
2150
|
? task.model
|
|
1892
2151
|
: null;
|
|
1893
2152
|
const selectedModelId = modelId ?? (typeof taskModel?.id === "string" ? taskModel.id : undefined);
|
|
1894
|
-
const batch = this.getAutoRunBatch(workId);
|
|
1895
2153
|
const startedAt = process.hrtime.bigint();
|
|
1896
2154
|
logger.info("ai.task.started", { taskId, workId, taskType: task.taskType, modelId: selectedModelId ?? null });
|
|
1897
2155
|
if (task.status !== "pending")
|
|
1898
2156
|
throw new AppError(409, "TASK_NOT_PENDING", "只有待执行任务可以运行");
|
|
1899
2157
|
if (!this.store.isTaskSourceCurrent(taskId)) {
|
|
1900
2158
|
const expired = this.store.updateTask(taskId, { status: "expired" });
|
|
1901
|
-
batch.starting.delete(taskId);
|
|
1902
2159
|
this.scheduleAutoRun(workId);
|
|
1903
2160
|
logger.warn("ai.task.expired", { taskId, workId, durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000 });
|
|
1904
2161
|
return expired;
|
|
1905
2162
|
}
|
|
1906
|
-
const
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
batch.claimed += 1;
|
|
1910
|
-
}
|
|
1911
|
-
this.store.updateTask(taskId, { status: "running", progress: 5 });
|
|
2163
|
+
const claimed = this.store.claimPendingTask(taskId, options.runningLimit);
|
|
2164
|
+
if (!claimed)
|
|
2165
|
+
throw new AppError(409, "TASK_NOT_PENDING", "任务已被其他执行器认领或当前并发已满");
|
|
1912
2166
|
const taskController = new AbortController();
|
|
1913
2167
|
this.taskControllers.set(taskId, taskController);
|
|
1914
2168
|
try {
|
|
@@ -1966,13 +2220,28 @@ export class AiManager {
|
|
|
1966
2220
|
const failure = error instanceof AppError
|
|
1967
2221
|
? { message, code: error.code, ...(error.details === undefined ? {} : { details: error.details }) }
|
|
1968
2222
|
: { message };
|
|
2223
|
+
if (options.autoRun) {
|
|
2224
|
+
const current = this.store.getTask(taskId);
|
|
2225
|
+
const disposition = autoRunFailureDisposition(error, Number(current.attemptCount));
|
|
2226
|
+
if (disposition.retry) {
|
|
2227
|
+
const nextAttemptAt = new Date(Date.now() + disposition.retryDelayMs).toISOString();
|
|
2228
|
+
const pending = this.store.rescheduleTask(taskId, failure, nextAttemptAt);
|
|
2229
|
+
logger.warn("ai.task.retry_scheduled", {
|
|
2230
|
+
taskId,
|
|
2231
|
+
workId,
|
|
2232
|
+
attemptCount: pending.attemptCount,
|
|
2233
|
+
nextAttemptAt,
|
|
2234
|
+
error: aiErrorForLog(error)
|
|
2235
|
+
});
|
|
2236
|
+
throw error;
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
1969
2239
|
this.store.updateTask(taskId, { status: "partial", progress: 100, failures: [failure] });
|
|
1970
2240
|
logger.error("ai.task.failed", { taskId, workId, durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000, error: aiErrorForLog(error) });
|
|
1971
2241
|
throw error;
|
|
1972
2242
|
}
|
|
1973
2243
|
finally {
|
|
1974
2244
|
this.taskControllers.delete(taskId);
|
|
1975
|
-
batch.starting.delete(taskId);
|
|
1976
2245
|
this.scheduleAutoRun(workId);
|
|
1977
2246
|
}
|
|
1978
2247
|
}
|
|
@@ -2135,7 +2404,7 @@ export class AiManager {
|
|
|
2135
2404
|
? [
|
|
2136
2405
|
`当前可用作品查询工具:${enabledToolIds.join("、")}。`,
|
|
2137
2406
|
"当作者询问当前作品、项目、章节、情节、人物、关系、世界观或设定,而预加载上下文为空或不足时,必须先调用工具主动查询;不得直接声称没有上下文,也不得先要求作者补充本系统已经能够查询的信息。",
|
|
2138
|
-
"整体介绍、作品基本信息、目录或章节定位优先调用 story_index;按关键字定位正文段落时调用 grep;已知章节 ID 且需要原文事实或精确措辞时调用 read_chapters;查找设定、人物、组织、时间线、关系、大纲或伏笔时调用 search_story_entities
|
|
2407
|
+
"整体介绍、作品基本信息、目录或章节定位优先调用 story_index;按关键字定位正文段落时调用 grep;已知章节 ID 且需要原文事实或精确措辞时调用 read_chapters;查找设定、人物、组织、时间线、关系、大纲或伏笔时调用 search_story_entities(可传入短实体名、拼音或关键词,勿用自然语言整句);人物匹配结果包含 sectionId 且需要背景故事、能力或经历原文时调用 read_character_sections。",
|
|
2139
2408
|
"根据问题选择最少且必要的工具。工具结果仍不足时才说明未知,并明确已经查询过什么;不要重复无效调用。"
|
|
2140
2409
|
].join("\n")
|
|
2141
2410
|
: "";
|
|
@@ -2194,7 +2463,7 @@ export class AiManager {
|
|
|
2194
2463
|
enabledAgentTools(workId, taskType, requestedToolIds) {
|
|
2195
2464
|
return this.enabledAgentToolIds(workId, taskType, requestedToolIds).map((toolId) => AGENT_TOOL_DEFINITIONS[toolId]);
|
|
2196
2465
|
}
|
|
2197
|
-
executeAgentTool(workId, toolCall) {
|
|
2466
|
+
async executeAgentTool(workId, toolCall) {
|
|
2198
2467
|
const name = toolCall.function.name;
|
|
2199
2468
|
const calledAt = now();
|
|
2200
2469
|
let rawArguments = toolCall.function.arguments;
|
|
@@ -2316,14 +2585,20 @@ export class AiManager {
|
|
|
2316
2585
|
const { query, categories: categoryList } = args;
|
|
2317
2586
|
const categories = new Set(categoryList);
|
|
2318
2587
|
const allowed = new Set(["setting", "character", "race", "organization", "timeline", "relationship", "outline", "foreshadow"]);
|
|
2319
|
-
const
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2588
|
+
const combined = (await this.searchWork(workId, query, { limit: 100 })).flatMap((item) => {
|
|
2589
|
+
const sourceType = String(item.type);
|
|
2590
|
+
const type = sourceType === "timeline-track" || sourceType === "timeline-event"
|
|
2591
|
+
? "timeline"
|
|
2592
|
+
: sourceType === "chapter-outline" ? "outline" : sourceType;
|
|
2593
|
+
if (!allowed.has(type) || (categories.size > 0 && !categories.has(type)))
|
|
2594
|
+
return [];
|
|
2595
|
+
return [{
|
|
2596
|
+
...item,
|
|
2597
|
+
...this.hybridAiSearchDetails(workId, sourceType, String(item.id)),
|
|
2598
|
+
type,
|
|
2599
|
+
sourceType
|
|
2600
|
+
}];
|
|
2601
|
+
}).slice(0, 30);
|
|
2327
2602
|
return {
|
|
2328
2603
|
id: toolCall.id,
|
|
2329
2604
|
name,
|
|
@@ -2334,10 +2609,10 @@ export class AiManager {
|
|
|
2334
2609
|
ok: true,
|
|
2335
2610
|
data: {
|
|
2336
2611
|
query,
|
|
2337
|
-
matchMode: "
|
|
2612
|
+
matchMode: "hybrid_exact_phonetic",
|
|
2338
2613
|
matches: combined,
|
|
2339
2614
|
...(combined.length === 0
|
|
2340
|
-
? { hint: "
|
|
2615
|
+
? { hint: "没有找到精确或拼音相关结果。请改用更短的实体名、别名或标题,也可使用 story_index 浏览目录,或用 grep 搜索正文关键字。" }
|
|
2341
2616
|
: {})
|
|
2342
2617
|
}
|
|
2343
2618
|
}
|
|
@@ -2634,7 +2909,7 @@ export class AiManager {
|
|
|
2634
2909
|
...(choice.message.anthropic_content?.length ? { anthropic_content: choice.message.anthropic_content } : {})
|
|
2635
2910
|
});
|
|
2636
2911
|
for (const toolCall of toolCalls) {
|
|
2637
|
-
const execution = this.executeAgentTool(input.workId, toolCall);
|
|
2912
|
+
const execution = await this.executeAgentTool(input.workId, toolCall);
|
|
2638
2913
|
logger.info("ai.tool_call.completed", { callId, toolName: execution.name, status: execution.status, round });
|
|
2639
2914
|
executedToolCalls.push(execution);
|
|
2640
2915
|
traceRounds.at(-1)?.toolExecutions.push(execution);
|
|
@@ -4670,7 +4945,13 @@ export class AiManager {
|
|
|
4670
4945
|
return source(`人物档案:${String(item.name)}`, {
|
|
4671
4946
|
name: item.name, aliases: item.aliases, code: item.code, species: item.species, race: item.race,
|
|
4672
4947
|
organizations: item.organizations, attributes: item.attributes, profile: item.profile,
|
|
4673
|
-
currentState: item.currentState, lockedFields: item.lockedFields
|
|
4948
|
+
currentState: item.currentState, lockedFields: item.lockedFields,
|
|
4949
|
+
profileSections: this.store.listCharacterProfileSections(sourceId).map((section) => ({
|
|
4950
|
+
title: section.title,
|
|
4951
|
+
sectionType: section.sectionType,
|
|
4952
|
+
summary: section.summary,
|
|
4953
|
+
contentMarkdown: section.contentMarkdown
|
|
4954
|
+
}))
|
|
4674
4955
|
}, item.versionNo);
|
|
4675
4956
|
}
|
|
4676
4957
|
if (sourceType === "race") {
|
|
@@ -4678,7 +4959,11 @@ export class AiManager {
|
|
|
4678
4959
|
if (String(item.workId) !== workId)
|
|
4679
4960
|
return null;
|
|
4680
4961
|
return source(`种族设定:${String(item.name)}`, {
|
|
4681
|
-
name: item.name,
|
|
4962
|
+
name: item.name,
|
|
4963
|
+
description: item.description,
|
|
4964
|
+
racePath: item.lineage.map((entry) => entry.name).join(" / "),
|
|
4965
|
+
lineage: item.lineage,
|
|
4966
|
+
settings: item.settings,
|
|
4682
4967
|
effectiveSettings: item.effectiveSettings, members: item.members
|
|
4683
4968
|
}, item.versionNo);
|
|
4684
4969
|
}
|