@musnows/scriverse 0.2.0 → 0.3.1
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/README.en.md +13 -4
- package/README.md +13 -4
- package/dist/ai.js +3548 -0
- package/dist/ai.js.map +1 -0
- package/dist/app.js +1079 -0
- package/dist/app.js.map +1 -0
- package/dist/cli-core.js +147 -20
- package/dist/cli-core.js.map +1 -1
- package/dist/credential-vault.js +40 -0
- package/dist/credential-vault.js.map +1 -0
- package/dist/database.js +1220 -0
- package/dist/database.js.map +1 -0
- package/dist/docx-security.js +184 -0
- package/dist/docx-security.js.map +1 -0
- package/dist/domain.js +21 -0
- package/dist/domain.js.map +1 -0
- package/dist/errors.js +16 -0
- package/dist/errors.js.map +1 -0
- package/dist/http-logging.js +76 -0
- package/dist/http-logging.js.map +1 -0
- package/dist/image-captcha.js +173 -0
- package/dist/image-captcha.js.map +1 -0
- package/dist/image-metadata.js +128 -0
- package/dist/image-metadata.js.map +1 -0
- package/dist/import-security.js +46 -0
- package/dist/import-security.js.map +1 -0
- package/dist/logger.js +110 -0
- package/dist/logger.js.map +1 -0
- package/dist/parser.js +223 -0
- package/dist/parser.js.map +1 -0
- package/dist/public/ai-context-meter.js +10 -0
- package/dist/public/ai-conversation.js +3 -0
- package/dist/public/ai-mentions.js +55 -0
- package/dist/public/ai-message-actions.js +27 -0
- package/dist/public/ai-message-meta.js +15 -0
- package/dist/public/ai-message-time.js +23 -0
- package/dist/public/ai-prompt-keyboard.js +3 -0
- package/dist/public/app.js +4435 -0
- package/dist/public/character-profile.d.ts +9 -0
- package/dist/public/character-profile.js +65 -0
- package/dist/public/character-version.d.ts +2 -0
- package/dist/public/character-version.js +31 -0
- package/dist/public/entity-version.js +34 -0
- package/dist/public/icon.svg +10 -0
- package/dist/public/index.html +547 -0
- package/dist/public/line-number-layout.js +15 -0
- package/dist/public/markdown.js +199 -0
- package/dist/public/model-config.d.ts +17 -0
- package/dist/public/model-config.js +57 -0
- package/dist/public/page-route.d.ts +11 -0
- package/dist/public/page-route.js +81 -0
- package/dist/public/relationship-graph.js +2017 -0
- package/dist/public/site.webmanifest +17 -0
- package/dist/public/styles.css +1441 -0
- package/dist/public/text-formatting.d.ts +2 -0
- package/dist/public/text-formatting.js +20 -0
- package/dist/public/theme-init.js +8 -0
- package/dist/public/theme.js +13 -0
- package/dist/public/whitespace-visualization.js +20 -0
- package/dist/request-context.js +16 -0
- package/dist/request-context.js.map +1 -0
- package/dist/security.js +247 -0
- package/dist/security.js.map +1 -0
- package/dist/server-runtime.js +90 -0
- package/dist/server-runtime.js.map +1 -0
- package/dist/server.js +26 -0
- package/dist/server.js.map +1 -0
- package/dist/store.js +2568 -0
- package/dist/store.js.map +1 -0
- package/dist/user-auth.js +515 -0
- package/dist/user-auth.js.map +1 -0
- package/dist/utils.js +67 -0
- package/dist/utils.js.map +1 -0
- package/package.json +4 -9
package/dist/ai.js
ADDED
|
@@ -0,0 +1,3548 @@
|
|
|
1
|
+
import { PLATFORM_AI_WORK_ID } from "./database.js";
|
|
2
|
+
import { AppError, notFound } from "./errors.js";
|
|
3
|
+
import { logger, sanitizeError } from "./logger.js";
|
|
4
|
+
import { currentRequestActor } from "./request-context.js";
|
|
5
|
+
import { fetchSafeAiEndpoint } from "./security.js";
|
|
6
|
+
import { clamp, id, json, maskSecret, normalizeBaseUrl, now } from "./utils.js";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
export function aiErrorForLog(error) {
|
|
9
|
+
const sanitized = sanitizeError(error);
|
|
10
|
+
const message = typeof sanitized.message === "string" ? sanitized.message : "AI operation failed";
|
|
11
|
+
const httpStatus = message.match(/^HTTP (\d{3}):/u)?.[1];
|
|
12
|
+
if (httpStatus)
|
|
13
|
+
return { name: sanitized.name ?? "Error", message: `Provider returned HTTP ${httpStatus}` };
|
|
14
|
+
if (message.includes("returned invalid JSON"))
|
|
15
|
+
return { name: sanitized.name ?? "Error", message: "Provider returned invalid JSON" };
|
|
16
|
+
return sanitized;
|
|
17
|
+
}
|
|
18
|
+
const allowedParameters = new Set(["temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty", "seed"]);
|
|
19
|
+
const DEFAULT_MAX_TOKENS = 32_000;
|
|
20
|
+
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
21
|
+
const AGENT_TOOL_IDS = ["story_index", "read_chapters", "grep", "query_story_knowledge"];
|
|
22
|
+
const MAX_AGENT_TOOL_ROUNDS = 6;
|
|
23
|
+
const MAX_AGENT_TOOL_CALLS = 12;
|
|
24
|
+
const storyIndexArguments = z.object({
|
|
25
|
+
offset: z.number().int().min(0).max(10_000).default(0),
|
|
26
|
+
limit: z.number().int().min(1).max(50).default(20)
|
|
27
|
+
}).strict();
|
|
28
|
+
const readChaptersArguments = z.object({
|
|
29
|
+
chapterIds: z.array(z.string().min(1).max(200)).min(1).max(3),
|
|
30
|
+
include: z.enum(["summary", "content", "both"]).default("both")
|
|
31
|
+
}).strict();
|
|
32
|
+
const grepArguments = z.object({
|
|
33
|
+
keyword: z.string().trim().min(1).max(200),
|
|
34
|
+
limit: z.number().int().min(1).max(100).default(20)
|
|
35
|
+
}).strict();
|
|
36
|
+
const queryStoryKnowledgeArguments = z.object({
|
|
37
|
+
query: z.string().trim().min(1).max(200),
|
|
38
|
+
categories: z.array(z.enum(["setting", "character", "race", "organization", "timeline", "relationship", "outline", "foreshadow"])).max(8).default([])
|
|
39
|
+
}).strict();
|
|
40
|
+
const AGENT_TOOL_DEFINITIONS = {
|
|
41
|
+
story_index: {
|
|
42
|
+
type: "function",
|
|
43
|
+
function: {
|
|
44
|
+
name: "story_index",
|
|
45
|
+
description: "读取当前作品的基本信息,并按分页列出卷章目录和章节概要。回答作品简介、整体结构或定位章节时优先使用;不会返回正文。",
|
|
46
|
+
parameters: { type: "object", properties: { offset: { type: "integer", minimum: 0 }, limit: { type: "integer", minimum: 1, maximum: 50 } }, additionalProperties: false }
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
read_chapters: {
|
|
50
|
+
type: "function",
|
|
51
|
+
function: {
|
|
52
|
+
name: "read_chapters",
|
|
53
|
+
description: "读取指定章节的当前正文与章节概要。仅在需要原文证据或精确措辞时使用;每次最多 3 章。",
|
|
54
|
+
parameters: { type: "object", properties: { chapterIds: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 3 }, include: { type: "string", enum: ["summary", "content", "both"] } }, required: ["chapterIds"], additionalProperties: false }
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
grep: {
|
|
58
|
+
type: "function",
|
|
59
|
+
function: {
|
|
60
|
+
name: "grep",
|
|
61
|
+
description: "在当前作品的章节正文索引中查询关键字,返回关键字所在的完整段落及章节标题和 ID。默认返回前 20 条,可按需调整 limit。",
|
|
62
|
+
parameters: { type: "object", properties: { keyword: { type: "string", minLength: 1, maxLength: 200 }, limit: { type: "integer", minimum: 1, maximum: 100, default: 20 } }, required: ["keyword"], additionalProperties: false }
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
query_story_knowledge: {
|
|
66
|
+
type: "function",
|
|
67
|
+
function: {
|
|
68
|
+
name: "query_story_knowledge",
|
|
69
|
+
description: "按关键词查询作品知识:设定、人物、种族、组织、时间线、关系、大纲和伏笔。结果为简要匹配项;需要正文时再调用 read_chapters。",
|
|
70
|
+
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 }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
export function estimateAiTokens(value) {
|
|
75
|
+
let wideCharacters = 0;
|
|
76
|
+
let narrowCharacters = 0;
|
|
77
|
+
for (const character of value) {
|
|
78
|
+
if (/[^\u0000-\u00ff]/u.test(character))
|
|
79
|
+
wideCharacters += 1;
|
|
80
|
+
else
|
|
81
|
+
narrowCharacters += 1;
|
|
82
|
+
}
|
|
83
|
+
return Math.max(1, Math.ceil(wideCharacters * 1.1 + narrowCharacters / 4));
|
|
84
|
+
}
|
|
85
|
+
function contextSearchTerms(value) {
|
|
86
|
+
const normalized = value.normalize("NFKC").toLocaleLowerCase("zh-CN");
|
|
87
|
+
const terms = new Set();
|
|
88
|
+
for (const word of normalized.match(/[a-z0-9][a-z0-9_-]{1,}/gu) ?? [])
|
|
89
|
+
terms.add(word);
|
|
90
|
+
for (const segment of normalized.match(/[\p{Script=Han}]{2,}/gu) ?? []) {
|
|
91
|
+
const maximumSize = Math.min(4, segment.length);
|
|
92
|
+
for (let size = 2; size <= maximumSize; size += 1) {
|
|
93
|
+
for (let index = 0; index <= segment.length - size; index += 1)
|
|
94
|
+
terms.add(segment.slice(index, index + size));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return [...terms].slice(0, 160);
|
|
98
|
+
}
|
|
99
|
+
function contextRelevance(query, value) {
|
|
100
|
+
const normalized = value.normalize("NFKC").toLocaleLowerCase("zh-CN");
|
|
101
|
+
return contextSearchTerms(query).reduce((score, term) => score + (normalized.includes(term) ? Math.min(6, term.length) : 0), 0);
|
|
102
|
+
}
|
|
103
|
+
function sliceToTokenBudget(value, maximumTokens, fromEnd = false) {
|
|
104
|
+
if (estimateAiTokens(value) <= maximumTokens)
|
|
105
|
+
return value;
|
|
106
|
+
let start = 0;
|
|
107
|
+
let end = value.length;
|
|
108
|
+
while (start < end) {
|
|
109
|
+
const middle = Math.floor((start + end + 1) / 2);
|
|
110
|
+
const candidate = fromEnd ? value.slice(value.length - middle) : value.slice(0, middle);
|
|
111
|
+
if (estimateAiTokens(candidate) <= maximumTokens)
|
|
112
|
+
start = middle;
|
|
113
|
+
else
|
|
114
|
+
end = middle - 1;
|
|
115
|
+
}
|
|
116
|
+
return fromEnd ? value.slice(value.length - start) : value.slice(0, start);
|
|
117
|
+
}
|
|
118
|
+
function truncateContextText(value, maximumTokens, notice = "[内容已按上下文预算压缩]") {
|
|
119
|
+
if (maximumTokens <= 0)
|
|
120
|
+
return "";
|
|
121
|
+
if (estimateAiTokens(value) <= maximumTokens)
|
|
122
|
+
return value;
|
|
123
|
+
const noticeTokens = estimateAiTokens(notice) + 2;
|
|
124
|
+
if (noticeTokens >= maximumTokens)
|
|
125
|
+
return sliceToTokenBudget(value, maximumTokens);
|
|
126
|
+
const contentBudget = maximumTokens - noticeTokens;
|
|
127
|
+
const headBudget = Math.max(1, Math.ceil(contentBudget * 0.55));
|
|
128
|
+
const tailBudget = Math.max(1, contentBudget - headBudget);
|
|
129
|
+
return `${sliceToTokenBudget(value, headBudget)}\n${notice}\n${sliceToTokenBudget(value, tailBudget, true)}`;
|
|
130
|
+
}
|
|
131
|
+
const CONVERSATION_MEMORY_FIELDS = ["authorGoals", "confirmedDecisions", "storyFacts", "constraints", "unresolvedQuestions", "importantReferences"];
|
|
132
|
+
function normalizeConversationMemory(value) {
|
|
133
|
+
const source = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
134
|
+
return Object.fromEntries(CONVERSATION_MEMORY_FIELDS.map((field) => {
|
|
135
|
+
const items = (Array.isArray(source[field]) ? source[field] : []).flatMap((item) => {
|
|
136
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
137
|
+
return [];
|
|
138
|
+
const record = item;
|
|
139
|
+
const text = typeof record.text === "string" ? record.text.trim().slice(0, 2_000) : "";
|
|
140
|
+
if (!text)
|
|
141
|
+
return [];
|
|
142
|
+
const sourceMessageIds = [...new Set((Array.isArray(record.sourceMessageIds) ? record.sourceMessageIds : [])
|
|
143
|
+
.filter((messageId) => typeof messageId === "string" && messageId.length <= 300))].slice(0, 20);
|
|
144
|
+
return [{ text, sourceMessageIds }];
|
|
145
|
+
}).slice(0, 100);
|
|
146
|
+
return [field, items];
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
function renderConversationMemory(value) {
|
|
150
|
+
try {
|
|
151
|
+
const memory = normalizeConversationMemory(JSON.parse(value));
|
|
152
|
+
const labels = {
|
|
153
|
+
authorGoals: "作者目标",
|
|
154
|
+
confirmedDecisions: "已确认决定",
|
|
155
|
+
storyFacts: "对话中确认的事实",
|
|
156
|
+
constraints: "限制与约束",
|
|
157
|
+
unresolvedQuestions: "未解决问题",
|
|
158
|
+
importantReferences: "重要引用"
|
|
159
|
+
};
|
|
160
|
+
const sections = CONVERSATION_MEMORY_FIELDS.flatMap((field) => memory[field].length
|
|
161
|
+
? [`${labels[field]}:\n${memory[field].map((item) => `- ${item.text}${item.sourceMessageIds.length ? ` [来源:${item.sourceMessageIds.join("、")}]` : ""}`).join("\n")}`]
|
|
162
|
+
: []);
|
|
163
|
+
return sections.join("\n\n") || value;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
export function resolveOutputTokens(usage, content) {
|
|
170
|
+
if (usage && typeof usage === "object") {
|
|
171
|
+
const record = usage;
|
|
172
|
+
const reported = record.completion_tokens ?? record.output_tokens;
|
|
173
|
+
if (typeof reported === "number" && Number.isFinite(reported))
|
|
174
|
+
return Math.max(0, Math.round(reported));
|
|
175
|
+
}
|
|
176
|
+
return estimateAiTokens(content);
|
|
177
|
+
}
|
|
178
|
+
function normalizeModelPreset(input) {
|
|
179
|
+
const maxTokens = typeof input.max_tokens === "number" && Number.isFinite(input.max_tokens)
|
|
180
|
+
? Math.round(clamp(input.max_tokens, 1, 32_768))
|
|
181
|
+
: DEFAULT_MAX_TOKENS;
|
|
182
|
+
return { ...input, max_tokens: maxTokens };
|
|
183
|
+
}
|
|
184
|
+
function stringValue(row, key) {
|
|
185
|
+
return String(row[key] ?? "");
|
|
186
|
+
}
|
|
187
|
+
function numberValue(row, key) {
|
|
188
|
+
return Number(row[key] ?? 0);
|
|
189
|
+
}
|
|
190
|
+
function boolValue(row, key) {
|
|
191
|
+
return Number(row[key] ?? 0) === 1;
|
|
192
|
+
}
|
|
193
|
+
function safeJsonObject(value) {
|
|
194
|
+
return json(value, {});
|
|
195
|
+
}
|
|
196
|
+
export function extractJson(content, accepts) {
|
|
197
|
+
const trimmed = content.trim();
|
|
198
|
+
const candidates = [trimmed];
|
|
199
|
+
const taggedCandidates = [...trimmed.matchAll(/<json>\s*([\s\S]*?)\s*<\/json>/giu)]
|
|
200
|
+
.map((match) => match[1]?.trim())
|
|
201
|
+
.filter((candidate) => Boolean(candidate));
|
|
202
|
+
candidates.push(...taggedCandidates.reverse());
|
|
203
|
+
for (const match of trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)\s*```/giu)) {
|
|
204
|
+
if (match[1])
|
|
205
|
+
candidates.push(match[1].trim());
|
|
206
|
+
}
|
|
207
|
+
const maximumScanSteps = Math.max(100_000, trimmed.length * 20);
|
|
208
|
+
let scanSteps = 0;
|
|
209
|
+
balancedCandidates: for (let start = 0; start < trimmed.length; start += 1) {
|
|
210
|
+
const first = trimmed[start];
|
|
211
|
+
if (first !== "{" && first !== "[")
|
|
212
|
+
continue;
|
|
213
|
+
const stack = [first];
|
|
214
|
+
let inString = false;
|
|
215
|
+
let escaped = false;
|
|
216
|
+
for (let index = start + 1; index < trimmed.length; index += 1) {
|
|
217
|
+
scanSteps += 1;
|
|
218
|
+
if (scanSteps > maximumScanSteps)
|
|
219
|
+
break balancedCandidates;
|
|
220
|
+
const character = trimmed[index];
|
|
221
|
+
if (inString) {
|
|
222
|
+
if (escaped)
|
|
223
|
+
escaped = false;
|
|
224
|
+
else if (character === "\\")
|
|
225
|
+
escaped = true;
|
|
226
|
+
else if (character === '"')
|
|
227
|
+
inString = false;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (character === '"') {
|
|
231
|
+
inString = true;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (character === "{" || character === "[")
|
|
235
|
+
stack.push(character);
|
|
236
|
+
else if (character === "}" || character === "]") {
|
|
237
|
+
const expected = character === "}" ? "{" : "[";
|
|
238
|
+
if (stack.at(-1) !== expected)
|
|
239
|
+
break;
|
|
240
|
+
stack.pop();
|
|
241
|
+
if (stack.length === 0) {
|
|
242
|
+
candidates.push(trimmed.slice(start, index + 1));
|
|
243
|
+
start = index;
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const candidate of [...new Set(candidates)]) {
|
|
250
|
+
try {
|
|
251
|
+
const parsed = JSON.parse(candidate);
|
|
252
|
+
if (!accepts || accepts(parsed))
|
|
253
|
+
return parsed;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// 继续尝试模型响应中的下一个结构化片段
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
throw new AppError(502, "AI_INVALID_JSON", "AI 返回的分析结果不是有效 JSON", { output: trimmed.slice(0, 500) });
|
|
260
|
+
}
|
|
261
|
+
const guardIssueTypes = new Set(["character", "location", "time", "world", "outline", "foreshadow"]);
|
|
262
|
+
const guardSeverities = new Set(["low", "medium", "high"]);
|
|
263
|
+
const unsafeGlobalAliases = new Set([
|
|
264
|
+
"怪兽之王", "怪兽女王", "君王", "女王", "吾王", "博士", "陈博士", "玲博士", "老师", "舰长", "上尉", "司令", "族长",
|
|
265
|
+
"父亲", "母亲", "爸爸", "妈妈", "哥哥", "姐姐", "大哥", "妹妹", "先生", "小姐", "陛下", "尔森"
|
|
266
|
+
]);
|
|
267
|
+
export function isSafeGlobalAlias(value) {
|
|
268
|
+
const normalized = value.normalize("NFKC").trim().replace(/\s+/gu, " ").toLocaleLowerCase("zh-CN");
|
|
269
|
+
if (!normalized || /^[a-z0-9]$/iu.test(normalized))
|
|
270
|
+
return false;
|
|
271
|
+
return !unsafeGlobalAliases.has(value.normalize("NFKC").trim());
|
|
272
|
+
}
|
|
273
|
+
export function canonicalizeRelationshipSubtype(category, subtype) {
|
|
274
|
+
const original = subtype.normalize("NFKC").trim().replace(/\s+/gu, " ");
|
|
275
|
+
const key = original.toLocaleLowerCase("zh-CN").replace(/[\s_\-—→/]+/gu, "");
|
|
276
|
+
if (category === "family") {
|
|
277
|
+
if (/parentchild|fatherdaughter|fatherson|motherdaughter|motherson|父母子女|父女|父子|母女|母子/u.test(key))
|
|
278
|
+
return "父母子女";
|
|
279
|
+
if (/adopt|收养|养父|养母|养子|养女/u.test(key))
|
|
280
|
+
return "收养亲子";
|
|
281
|
+
if (/sister|brother|sibling|姐妹|兄弟|手足/u.test(key))
|
|
282
|
+
return "手足";
|
|
283
|
+
if (/uncle|aunt|nephew|niece|叔侄|姑侄|舅甥/u.test(key))
|
|
284
|
+
return "叔侄";
|
|
285
|
+
}
|
|
286
|
+
if (category === "social") {
|
|
287
|
+
if (/monarchsubject|subjecttoruler|rulersubject|superiorsubordinate|君王臣属|君臣|臣属君王/u.test(key))
|
|
288
|
+
return "君臣";
|
|
289
|
+
if (/mentorstudent|teacherstudent|导师学生|师生/u.test(key))
|
|
290
|
+
return "师生";
|
|
291
|
+
if (/colleague|coworker|同事/u.test(key) || /^(?:同僚|共事)$/u.test(key))
|
|
292
|
+
return "同事";
|
|
293
|
+
if (/ally|allies|盟友/u.test(key) || /^(?:同盟|联盟)$/u.test(key))
|
|
294
|
+
return "盟友";
|
|
295
|
+
if (/friend|friends|朋友|友人/u.test(key) || /^(?:旧友|老友|好友|挚友|战友|搭档|伙伴)$/u.test(key))
|
|
296
|
+
return "朋友";
|
|
297
|
+
}
|
|
298
|
+
if (category === "emotional") {
|
|
299
|
+
if (/romanticpartner|romanticpartners|partner|partners|lover|lovers|spouse|夫妻|伴侣|恋人/u.test(key))
|
|
300
|
+
return "伴侣";
|
|
301
|
+
if (/admirer|admired|crush|倾慕|单恋|追求/u.test(key))
|
|
302
|
+
return "倾慕";
|
|
303
|
+
if (/closebond|亲密羁绊|亲密关系/u.test(key))
|
|
304
|
+
return "亲密羁绊";
|
|
305
|
+
}
|
|
306
|
+
if (category === "conflict") {
|
|
307
|
+
if (/enemy|enemies|rival|rivals|宿敌|敌人|死敌/u.test(key))
|
|
308
|
+
return "宿敌";
|
|
309
|
+
if (/abuser|victim|施害|受害/u.test(key))
|
|
310
|
+
return "施害与受害";
|
|
311
|
+
if (/manipulat|操纵|利用/u.test(key))
|
|
312
|
+
return "操纵与被操纵";
|
|
313
|
+
}
|
|
314
|
+
return original;
|
|
315
|
+
}
|
|
316
|
+
export function canonicalizeRelationshipCategory(category, subtype) {
|
|
317
|
+
const key = subtype.normalize("NFKC").toLocaleLowerCase("zh-CN").replace(/[\s_\-—→/]+/gu, "");
|
|
318
|
+
if (/parentchild|fatherdaughter|fatherson|motherdaughter|motherson|adopt|sister|brother|sibling|uncle|aunt|nephew|niece|父母子女|父女|父子|母女|母子|收养|养父|养母|养子|养女|姐妹|兄弟|手足|叔侄|姑侄|舅甥/u.test(key))
|
|
319
|
+
return "family";
|
|
320
|
+
if (/romanticpartner|partner|lover|spouse|夫妻|伴侣|恋人|admirer|admired|crush|倾慕|单恋|追求|closebond|亲密羁绊|亲密关系/u.test(key))
|
|
321
|
+
return "emotional";
|
|
322
|
+
if (/enemy|enemies|rival|宿敌|敌人|死敌|abuser|victim|施害|受害|manipulat|操纵|利用/u.test(key))
|
|
323
|
+
return "conflict";
|
|
324
|
+
const simplePeerSocial = /^(?:同事|同僚|共事|盟友|同盟|联盟|朋友|友人|旧友|老友|好友|挚友|战友|搭档|伙伴)$/u.test(key);
|
|
325
|
+
if (/monarchsubject|subjecttoruler|rulersubject|superiorsubordinate|君王臣属|君臣|臣属君王|mentorstudent|teacherstudent|导师学生|师生|colleague|coworker|ally|allies|friend|friends/u.test(key) || simplePeerSocial)
|
|
326
|
+
return "social";
|
|
327
|
+
return category;
|
|
328
|
+
}
|
|
329
|
+
function reversesHierarchyDirection(subtype) {
|
|
330
|
+
const key = subtype.normalize("NFKC").toLocaleLowerCase("zh-CN").replace(/[\s_\-—→/]+/gu, "");
|
|
331
|
+
return /subjecttoruler|subordinatetoruler|臣属君王/u.test(key);
|
|
332
|
+
}
|
|
333
|
+
function parseGuardIssues(content) {
|
|
334
|
+
const value = extractJson(content);
|
|
335
|
+
if (!Array.isArray(value))
|
|
336
|
+
throw new AppError(502, "AI_INVALID_JSON", "续写一致性检查结果必须是数组");
|
|
337
|
+
return value.map((item, index) => {
|
|
338
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
339
|
+
throw new AppError(502, "AI_INVALID_GUARD", `续写一致性检查第 ${index + 1} 项不是对象`);
|
|
340
|
+
}
|
|
341
|
+
const issue = item;
|
|
342
|
+
if (typeof issue.type !== "string" || !guardIssueTypes.has(issue.type)) {
|
|
343
|
+
throw new AppError(502, "AI_INVALID_GUARD", `续写一致性检查第 ${index + 1} 项类型无效`);
|
|
344
|
+
}
|
|
345
|
+
if (typeof issue.severity !== "string" || !guardSeverities.has(issue.severity)) {
|
|
346
|
+
throw new AppError(502, "AI_INVALID_GUARD", `续写一致性检查第 ${index + 1} 项严重程度无效`);
|
|
347
|
+
}
|
|
348
|
+
if (typeof issue.title !== "string" || !issue.title.trim()) {
|
|
349
|
+
throw new AppError(502, "AI_INVALID_GUARD", `续写一致性检查第 ${index + 1} 项缺少标题`);
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
type: issue.type,
|
|
353
|
+
severity: issue.severity,
|
|
354
|
+
title: issue.title.trim(),
|
|
355
|
+
description: typeof issue.description === "string" ? issue.description : "",
|
|
356
|
+
candidateQuote: typeof issue.candidateQuote === "string" ? issue.candidateQuote : "",
|
|
357
|
+
sourceRefs: Array.isArray(issue.sourceRefs) ? issue.sourceRefs : [],
|
|
358
|
+
suggestion: typeof issue.suggestion === "string" ? issue.suggestion : ""
|
|
359
|
+
};
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
function selectRelationshipConstraints(store, workId, characterIds) {
|
|
363
|
+
const selectedCharacterIds = new Set(characterIds);
|
|
364
|
+
return store.listRelationships(workId)
|
|
365
|
+
.filter((relationship) => relationship.confirmationStatus !== "rejected")
|
|
366
|
+
.filter((relationship) => relationship.locked === true || (relationship.confirmationStatus === "confirmed"
|
|
367
|
+
&& (selectedCharacterIds.has(String(relationship.fromCharacterId)) || selectedCharacterIds.has(String(relationship.toCharacterId)))))
|
|
368
|
+
.sort((left, right) => String(left.id).localeCompare(String(right.id)));
|
|
369
|
+
}
|
|
370
|
+
export class ContextBuilder {
|
|
371
|
+
store;
|
|
372
|
+
constructor(store) {
|
|
373
|
+
this.store = store;
|
|
374
|
+
}
|
|
375
|
+
build(workId, scope, maximumTokens = 60_000, bookSummaryMaximumTokens, query = "") {
|
|
376
|
+
return this.buildPlan(workId, scope, maximumTokens, bookSummaryMaximumTokens, query).context;
|
|
377
|
+
}
|
|
378
|
+
buildPlan(workId, scope, maximumTokens = 60_000, bookSummaryMaximumTokens, query = "") {
|
|
379
|
+
const work = this.store.getWork(workId);
|
|
380
|
+
const includeAutomaticContext = scope.type !== "none";
|
|
381
|
+
const constraints = includeAutomaticContext
|
|
382
|
+
? [`作品:${String(work.title)}\n作者:${String(work.author) || "未填写"}`]
|
|
383
|
+
: [];
|
|
384
|
+
const contentSections = [];
|
|
385
|
+
const lockedSettings = this.store.listSettings(workId).filter((item) => item.locked);
|
|
386
|
+
const allCharacters = this.store.listCharacters(workId);
|
|
387
|
+
const lockedCharacters = allCharacters.filter((item) => Array.isArray(item.lockedFields) && item.lockedFields.length > 0);
|
|
388
|
+
const organizations = this.store.listOrganizations(workId);
|
|
389
|
+
const relationshipConstraints = selectRelationshipConstraints(this.store, workId, scope.characterIds ?? []);
|
|
390
|
+
if (includeAutomaticContext && lockedSettings.length > 0) {
|
|
391
|
+
constraints.push(`作者锁定设定(硬约束):\n${lockedSettings
|
|
392
|
+
.map((item) => `- [${String(item.category)}] ${String(item.title)}:${String(item.content)}`)
|
|
393
|
+
.join("\n")}`);
|
|
394
|
+
}
|
|
395
|
+
if (includeAutomaticContext && lockedCharacters.length > 0) {
|
|
396
|
+
constraints.push(`作者锁定角色属性(硬约束):\n${lockedCharacters
|
|
397
|
+
.map((item) => {
|
|
398
|
+
const locked = item.lockedFields;
|
|
399
|
+
const attributes = item.attributes;
|
|
400
|
+
const state = item.currentState;
|
|
401
|
+
const values = locked.map((key) => {
|
|
402
|
+
const entityValue = item[key];
|
|
403
|
+
const value = entityValue === undefined || entityValue === null || entityValue === "" ? attributes[key] ?? state[key] : entityValue;
|
|
404
|
+
return `${key}=${String(value ?? "未填写")}`;
|
|
405
|
+
}).join(";");
|
|
406
|
+
return `- ${String(item.name)}:${values}`;
|
|
407
|
+
})
|
|
408
|
+
.join("\n")}`);
|
|
409
|
+
}
|
|
410
|
+
if (includeAutomaticContext && organizations.length > 0) {
|
|
411
|
+
constraints.push(`世界内组织:\n${organizations.map((item) => {
|
|
412
|
+
const settings = Array.isArray(item.settings) ? item.settings.map(String).filter(Boolean) : [];
|
|
413
|
+
const members = Array.isArray(item.members)
|
|
414
|
+
? item.members.map((member) => String(member.name)).filter(Boolean)
|
|
415
|
+
: [];
|
|
416
|
+
return `- ${String(item.name)}:${String(item.description) || "未填写简介"}${settings.length ? `;设定=${settings.join("、")}` : ""}${members.length ? `;成员=${members.join("、")}` : ""}`;
|
|
417
|
+
}).join("\n")}`);
|
|
418
|
+
}
|
|
419
|
+
if (relationshipConstraints.length > 0) {
|
|
420
|
+
const characterNameById = new Map(allCharacters.map((character) => [String(character.id), String(character.name)]));
|
|
421
|
+
constraints.push(`相关人物关系(创作约束):\n${relationshipConstraints.map((relationship) => {
|
|
422
|
+
const from = characterNameById.get(String(relationship.fromCharacterId)) ?? "未知角色";
|
|
423
|
+
const to = characterNameById.get(String(relationship.toCharacterId)) ?? "未知角色";
|
|
424
|
+
const keywords = Array.isArray(relationship.keywords) ? relationship.keywords.map(String).filter(Boolean) : [];
|
|
425
|
+
const marker = relationship.directed ? "→" : "—";
|
|
426
|
+
return `- ${from} ${marker} ${to}:[${String(relationship.category)}/${String(relationship.subtype) || "未细分"}]${keywords.length ? ` 关键词=${keywords.join("、")}` : ""};当前状态=${String(relationship.currentStatus)}${relationship.locked ? ";作者锁定" : ";作者确认"}`;
|
|
427
|
+
}).join("\n")}`);
|
|
428
|
+
}
|
|
429
|
+
if (scope.type === "selection") {
|
|
430
|
+
if (!scope.selection)
|
|
431
|
+
throw new AppError(400, "SELECTION_REQUIRED", "选中文本上下文不能为空");
|
|
432
|
+
contentSections.push(`当前选中文本:\n${scope.selection}`);
|
|
433
|
+
if (scope.chapterId)
|
|
434
|
+
this.appendChapter(contentSections, workId, scope.chapterId, false);
|
|
435
|
+
}
|
|
436
|
+
else if (scope.type === "chapter") {
|
|
437
|
+
if (!scope.chapterId)
|
|
438
|
+
throw new AppError(400, "CHAPTER_REQUIRED", "章节上下文缺少章节标识");
|
|
439
|
+
this.appendPreviousChapterTail(contentSections, workId, scope.chapterId);
|
|
440
|
+
this.appendChapter(contentSections, workId, scope.chapterId, true);
|
|
441
|
+
if (scope.selection)
|
|
442
|
+
contentSections.push(`当前选中文本(本次修改目标):\n${scope.selection}`);
|
|
443
|
+
}
|
|
444
|
+
else if (scope.type === "volume") {
|
|
445
|
+
if (!scope.volumeId)
|
|
446
|
+
throw new AppError(400, "VOLUME_REQUIRED", "卷上下文缺少卷标识");
|
|
447
|
+
const tree = this.store.getWorkTree(workId);
|
|
448
|
+
const volume = tree.volumes.find((item) => item.id === scope.volumeId);
|
|
449
|
+
if (!volume)
|
|
450
|
+
throw notFound("卷");
|
|
451
|
+
const chapters = volume.chapters;
|
|
452
|
+
contentSections.push(`当前卷:${String(volume.title)}`);
|
|
453
|
+
for (const chapter of chapters) {
|
|
454
|
+
contentSections.push(`[${String(volume.title)} / ${String(chapter.title)} | 版本 ${String(chapter.versionNo)}]\n${String(chapter.content)}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
else if (scope.type === "book") {
|
|
458
|
+
const tree = this.store.getWorkTree(workId);
|
|
459
|
+
const volumes = tree.volumes;
|
|
460
|
+
contentSections.push("全书正文(按问题相关度选取原文,完整结构见章节概要):");
|
|
461
|
+
for (const volume of volumes) {
|
|
462
|
+
for (const chapter of volume.chapters) {
|
|
463
|
+
contentSections.push(`[# ${String(volume.title)} / ${String(chapter.title)} | 版本 ${String(chapter.versionNo)}]\n${String(chapter.content)}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (scope.includeBookSummary || scope.type === "book" || scope.type === "volume") {
|
|
468
|
+
this.appendBookSummary(contentSections, workId, bookSummaryMaximumTokens ?? Math.max(160, Math.floor(maximumTokens * 0.35)), query, scope.type === "volume" ? scope.volumeId : undefined);
|
|
469
|
+
}
|
|
470
|
+
if (scope.characterIds?.length) {
|
|
471
|
+
const characters = scope.characterIds.map((characterId) => this.store.getCharacter(characterId));
|
|
472
|
+
for (const character of characters) {
|
|
473
|
+
if (character.workId !== workId)
|
|
474
|
+
throw new AppError(400, "CHARACTER_WORK_MISMATCH", "角色不属于当前作品");
|
|
475
|
+
}
|
|
476
|
+
constraints.push(`选定角色:\n${characters
|
|
477
|
+
.map((item) => {
|
|
478
|
+
const attributes = item.attributes;
|
|
479
|
+
return `- ${String(item.name)};种族=${String(item.species || attributes.species) || "未填写"};别名=${JSON.stringify(item.aliases)};属性=${JSON.stringify(item.attributes)};当前状态=${JSON.stringify(item.currentState)};设定=${JSON.stringify(item.profile)}`;
|
|
480
|
+
})
|
|
481
|
+
.join("\n")}`);
|
|
482
|
+
}
|
|
483
|
+
if (scope.settingIds?.length) {
|
|
484
|
+
const settings = scope.settingIds.map((settingId) => this.store.getSetting(settingId));
|
|
485
|
+
for (const setting of settings) {
|
|
486
|
+
if (setting.workId !== workId)
|
|
487
|
+
throw new AppError(400, "SETTING_WORK_MISMATCH", "设定不属于当前作品");
|
|
488
|
+
}
|
|
489
|
+
constraints.push(`选定设定:\n${settings.map((item) => `- [${String(item.category)}] ${String(item.title)}:${String(item.content)}`).join("\n")}`);
|
|
490
|
+
}
|
|
491
|
+
if (scope.chapterIds?.length) {
|
|
492
|
+
const chapterIds = [...new Set(scope.chapterIds)]
|
|
493
|
+
.filter((chapterId) => scope.type !== "chapter" || chapterId !== scope.chapterId);
|
|
494
|
+
const chapters = chapterIds.map((chapterId) => this.store.getChapter(chapterId));
|
|
495
|
+
for (const chapter of chapters) {
|
|
496
|
+
if (chapter.workId !== workId)
|
|
497
|
+
throw new AppError(400, "CHAPTER_WORK_MISMATCH", "引用章节不属于当前作品");
|
|
498
|
+
}
|
|
499
|
+
if (chapters.length) {
|
|
500
|
+
contentSections.push(`作者主动引用的章节:\n${chapters
|
|
501
|
+
.map((chapter) => `[${String(chapter.title)} | 版本 ${String(chapter.versionNo)}]\n${String(chapter.content)}`)
|
|
502
|
+
.join("\n\n")}`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (scope.type !== "none" && scope.chapterId)
|
|
506
|
+
this.appendChapterKnowledge(constraints, workId, scope.chapterId);
|
|
507
|
+
const hardContext = constraints.join("\n\n");
|
|
508
|
+
const hardTokens = hardContext ? estimateAiTokens(hardContext) : 0;
|
|
509
|
+
if (hardTokens > maximumTokens - 32) {
|
|
510
|
+
throw new AppError(413, "CONSTRAINT_CONTEXT_TOO_LARGE", "锁定设定、相关人物和创作约束超过上下文上限,请精简后重试", {
|
|
511
|
+
maximumTokens,
|
|
512
|
+
constraintTokens: hardTokens
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
const sections = contentSections.map((text, order) => {
|
|
516
|
+
const required = /^(?:当前选中文本|当前章节|所在章节|作者主动引用的章节)/u.test(text);
|
|
517
|
+
const summary = /章节概要(/u.test(text);
|
|
518
|
+
return {
|
|
519
|
+
id: `context-${order}`,
|
|
520
|
+
text,
|
|
521
|
+
kind: required ? "required" : summary ? "summary" : "detail",
|
|
522
|
+
order,
|
|
523
|
+
relevance: contextRelevance(query, text)
|
|
524
|
+
};
|
|
525
|
+
});
|
|
526
|
+
const selected = hardContext ? [hardContext] : [];
|
|
527
|
+
const planningNotice = "[上下文规划:低相关原文区块将不直接载入,优先保留跨卷概要和相关正文;需要精确证据时请调用章节读取工具。]";
|
|
528
|
+
const requiresPlanning = estimateAiTokens([hardContext, ...contentSections].filter(Boolean).join("\n\n")) > maximumTokens;
|
|
529
|
+
const includedBlockIds = [];
|
|
530
|
+
const omittedBlockIds = [];
|
|
531
|
+
const degradedBlockIds = [];
|
|
532
|
+
const currentTokens = () => estimateAiTokens(selected.filter(Boolean).join("\n\n"));
|
|
533
|
+
const remainingTokens = () => Math.max(0, maximumTokens - currentTokens());
|
|
534
|
+
const addSection = (section, budget = remainingTokens()) => {
|
|
535
|
+
const available = Math.min(remainingTokens(), Math.max(0, budget));
|
|
536
|
+
if (available <= 2) {
|
|
537
|
+
omittedBlockIds.push(section.id);
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
const fullTokens = estimateAiTokens(section.text);
|
|
541
|
+
const text = fullTokens <= available
|
|
542
|
+
? section.text
|
|
543
|
+
: truncateContextText(section.text, available, "[本区块已降级,保留开头与结尾;可调用工具读取完整章节]");
|
|
544
|
+
if (!text) {
|
|
545
|
+
omittedBlockIds.push(section.id);
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
selected.push(text);
|
|
549
|
+
includedBlockIds.push(section.id);
|
|
550
|
+
if (fullTokens > available)
|
|
551
|
+
degradedBlockIds.push(section.id);
|
|
552
|
+
return true;
|
|
553
|
+
};
|
|
554
|
+
for (const section of sections.filter((item) => item.kind === "required"))
|
|
555
|
+
addSection(section);
|
|
556
|
+
if (requiresPlanning && remainingTokens() >= 8) {
|
|
557
|
+
selected.push(truncateContextText(planningNotice, Math.min(estimateAiTokens(planningNotice), remainingTokens())));
|
|
558
|
+
}
|
|
559
|
+
const summaries = sections.filter((item) => item.kind === "summary");
|
|
560
|
+
for (let index = 0; index < summaries.length; index += 1) {
|
|
561
|
+
const share = Math.floor(remainingTokens() / Math.max(1, summaries.length - index));
|
|
562
|
+
addSection(summaries[index], share);
|
|
563
|
+
}
|
|
564
|
+
const details = sections.filter((item) => item.kind === "detail")
|
|
565
|
+
.sort((left, right) => right.relevance - left.relevance || right.order - left.order);
|
|
566
|
+
for (const section of details) {
|
|
567
|
+
const fullTokens = estimateAiTokens(section.text);
|
|
568
|
+
if (fullTokens <= remainingTokens())
|
|
569
|
+
addSection(section);
|
|
570
|
+
else if (section.relevance > 0 && remainingTokens() >= 80)
|
|
571
|
+
addSection(section);
|
|
572
|
+
else
|
|
573
|
+
omittedBlockIds.push(section.id);
|
|
574
|
+
}
|
|
575
|
+
const context = selected.filter(Boolean).join("\n\n");
|
|
576
|
+
return {
|
|
577
|
+
context,
|
|
578
|
+
tokenCount: estimateAiTokens(context),
|
|
579
|
+
includedBlockIds,
|
|
580
|
+
omittedBlockIds,
|
|
581
|
+
degradedBlockIds
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
appendChapter(sections, workId, chapterId, includeContent) {
|
|
585
|
+
const chapter = this.store.getChapter(chapterId);
|
|
586
|
+
if (chapter.workId !== workId)
|
|
587
|
+
throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
|
|
588
|
+
sections.push(includeContent
|
|
589
|
+
? `当前章节:${String(chapter.title)} | 版本 ${String(chapter.versionNo)}\n${String(chapter.content)}`
|
|
590
|
+
: `所在章节:${String(chapter.title)} | 版本 ${String(chapter.versionNo)}`);
|
|
591
|
+
}
|
|
592
|
+
appendBookSummary(sections, workId, maximumTokens, query, volumeId) {
|
|
593
|
+
const tree = this.store.getWorkTree(workId);
|
|
594
|
+
const volumes = tree.volumes.filter((volume) => !volumeId || volume.id === volumeId);
|
|
595
|
+
const insights = this.store.listCurrentChapterInsights(workId);
|
|
596
|
+
const summaryByChapterId = new Map(insights.map((item) => [String(item.chapterId), String(item.summary)]));
|
|
597
|
+
if (!volumes.length)
|
|
598
|
+
return;
|
|
599
|
+
const perVolumeBudget = Math.max(24, Math.floor(maximumTokens / volumes.length));
|
|
600
|
+
for (const volume of volumes) {
|
|
601
|
+
const chapters = volume.chapters;
|
|
602
|
+
const ranked = chapters.map((chapter, order) => {
|
|
603
|
+
const summary = summaryByChapterId.get(String(chapter.id)) ?? "";
|
|
604
|
+
const line = `- ${String(chapter.title)}:${summary || "尚无章节概要"}`;
|
|
605
|
+
return { line, order, relevance: contextRelevance(query, `${String(chapter.title)}\n${summary}`) };
|
|
606
|
+
}).sort((left, right) => right.relevance - left.relevance || left.order - right.order);
|
|
607
|
+
const header = `全书章节概要(分卷覆盖,不含正文):\n# ${String(volume.title)}`;
|
|
608
|
+
const chosen = [header];
|
|
609
|
+
for (const item of ranked) {
|
|
610
|
+
const candidate = [...chosen, item.line].join("\n");
|
|
611
|
+
if (estimateAiTokens(candidate) <= perVolumeBudget)
|
|
612
|
+
chosen.push(item.line);
|
|
613
|
+
}
|
|
614
|
+
if (chosen.length === 1 && ranked[0])
|
|
615
|
+
chosen.push(ranked[0].line);
|
|
616
|
+
sections.push(truncateContextText(chosen.join("\n"), perVolumeBudget, "[本卷其余章节概要已按预算折叠]"));
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
appendPreviousChapterTail(sections, workId, chapterId) {
|
|
620
|
+
const tree = this.store.getWorkTree(workId);
|
|
621
|
+
const chapters = tree.volumes
|
|
622
|
+
.flatMap((volume) => volume.chapters);
|
|
623
|
+
const index = chapters.findIndex((chapter) => chapter.id === chapterId);
|
|
624
|
+
if (index <= 0)
|
|
625
|
+
return;
|
|
626
|
+
const previous = chapters[index - 1];
|
|
627
|
+
if (!previous)
|
|
628
|
+
return;
|
|
629
|
+
const content = String(previous.content);
|
|
630
|
+
sections.push(`上一章节结尾:${String(previous.title)} | 版本 ${String(previous.versionNo)}\n${content.slice(-5000)}`);
|
|
631
|
+
}
|
|
632
|
+
appendChapterKnowledge(sections, workId, chapterId) {
|
|
633
|
+
const outline = this.store.getChapterOutline(chapterId);
|
|
634
|
+
if (outline) {
|
|
635
|
+
sections.push(`当前章大纲(创作约束):\n目标:${String(outline.goal) || "未填写"}\n冲突:${String(outline.conflict) || "未填写"}\n转折:${String(outline.turningPoint) || "未填写"}\n状态:${String(outline.status)}`);
|
|
636
|
+
}
|
|
637
|
+
const foreshadows = this.store.listForeshadows(workId, "unresolved", chapterId).slice(0, 50);
|
|
638
|
+
if (foreshadows.length > 0) {
|
|
639
|
+
sections.push(`尚未回收的伏笔(不得擅自遗忘或违背):\n${foreshadows.map((item) => {
|
|
640
|
+
const linkedHere = item.occurrences.some((occurrence) => occurrence.chapterId === chapterId);
|
|
641
|
+
const marker = item.plannedPayoffChapterId === chapterId ? "本章计划回收" : linkedHere ? "与本章关联" : "全书未回收";
|
|
642
|
+
return `- [${String(item.importance)} / ${marker}] ${String(item.title)}:${String(item.description)}`;
|
|
643
|
+
}).join("\n")}`);
|
|
644
|
+
}
|
|
645
|
+
const timeline = this.store.listTimelineEvents(workId).filter((item) => Array.isArray(item.chapterIds) && item.chapterIds.includes(chapterId));
|
|
646
|
+
if (timeline.length > 0) {
|
|
647
|
+
sections.push(`本章关联时间线:\n${timeline.map((item) => `- ${String(item.timeLabel)}|${String(item.name)}|地点=${String(item.location) || "未填写"}`).join("\n")}`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
export class AiManager {
|
|
652
|
+
store;
|
|
653
|
+
vault;
|
|
654
|
+
fetchImpl;
|
|
655
|
+
validateOutboundUrl;
|
|
656
|
+
contextBuilder;
|
|
657
|
+
taskControllers = new Map();
|
|
658
|
+
autoRunBatches = new Map();
|
|
659
|
+
autoRunTimers = new Map();
|
|
660
|
+
providerSchedules = new Map();
|
|
661
|
+
constructor(store, vault, fetchImpl = fetch, validateOutboundUrl) {
|
|
662
|
+
this.store = store;
|
|
663
|
+
this.vault = vault;
|
|
664
|
+
this.fetchImpl = fetchImpl;
|
|
665
|
+
this.validateOutboundUrl = validateOutboundUrl;
|
|
666
|
+
this.contextBuilder = new ContextBuilder(store);
|
|
667
|
+
this.store.setAnalysisTaskQueuedHandler((workId) => this.scheduleAutoRun(workId));
|
|
668
|
+
logger.info("ai.manager.ready");
|
|
669
|
+
}
|
|
670
|
+
resetAutoRunBatch(workId) {
|
|
671
|
+
this.autoRunBatches.set(workId, { claimed: 0, starting: new Set() });
|
|
672
|
+
}
|
|
673
|
+
scheduleAutoRun(workId) {
|
|
674
|
+
try {
|
|
675
|
+
if (!this.store.getWorkAiSettings(workId).autoRunEnabled)
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const existing = this.autoRunTimers.get(workId);
|
|
682
|
+
if (existing)
|
|
683
|
+
clearTimeout(existing);
|
|
684
|
+
const timer = setTimeout(() => {
|
|
685
|
+
this.autoRunTimers.delete(workId);
|
|
686
|
+
void this.drainAutoRun(workId);
|
|
687
|
+
}, 0);
|
|
688
|
+
this.autoRunTimers.set(workId, timer);
|
|
689
|
+
logger.debug("ai.auto_run.scheduled", { workId });
|
|
690
|
+
}
|
|
691
|
+
startAutoRunBatch(workId) {
|
|
692
|
+
this.store.getWork(workId);
|
|
693
|
+
const settings = this.store.getWorkAiSettings(workId);
|
|
694
|
+
if (!settings.autoRunEnabled) {
|
|
695
|
+
throw new AppError(400, "AUTO_RUN_DISABLED", "请先开启分析任务自动运行");
|
|
696
|
+
}
|
|
697
|
+
this.resetAutoRunBatch(workId);
|
|
698
|
+
this.scheduleAutoRun(workId);
|
|
699
|
+
logger.info("ai.auto_run.batch_started", {
|
|
700
|
+
workId,
|
|
701
|
+
concurrency: settings.autoRunConcurrency,
|
|
702
|
+
batchLimit: settings.autoRunBatchLimit
|
|
703
|
+
});
|
|
704
|
+
return {
|
|
705
|
+
workId,
|
|
706
|
+
autoRunEnabled: true,
|
|
707
|
+
autoRunConcurrency: settings.autoRunConcurrency,
|
|
708
|
+
autoRunBatchLimit: settings.autoRunBatchLimit,
|
|
709
|
+
pendingCount: this.store.countPendingTasks(workId),
|
|
710
|
+
runningCount: this.store.countRunningTasks(workId)
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
dispose() {
|
|
714
|
+
logger.info("ai.manager.disposing", { scheduledWorks: this.autoRunTimers.size, activeTasks: this.taskControllers.size });
|
|
715
|
+
for (const timer of this.autoRunTimers.values())
|
|
716
|
+
clearTimeout(timer);
|
|
717
|
+
this.autoRunTimers.clear();
|
|
718
|
+
this.autoRunBatches.clear();
|
|
719
|
+
this.store.setAnalysisTaskQueuedHandler(null);
|
|
720
|
+
logger.info("ai.manager.disposed");
|
|
721
|
+
}
|
|
722
|
+
getAutoRunBatch(workId) {
|
|
723
|
+
const existing = this.autoRunBatches.get(workId);
|
|
724
|
+
if (existing)
|
|
725
|
+
return existing;
|
|
726
|
+
const created = { claimed: 0, starting: new Set() };
|
|
727
|
+
this.autoRunBatches.set(workId, created);
|
|
728
|
+
return created;
|
|
729
|
+
}
|
|
730
|
+
async drainAutoRun(workId) {
|
|
731
|
+
try {
|
|
732
|
+
logger.debug("ai.auto_run.drain_started", { workId });
|
|
733
|
+
const settings = this.store.getWorkAiSettings(workId);
|
|
734
|
+
if (!settings.autoRunEnabled)
|
|
735
|
+
return;
|
|
736
|
+
const batch = this.getAutoRunBatch(workId);
|
|
737
|
+
const concurrency = Number(settings.autoRunConcurrency);
|
|
738
|
+
const batchLimit = Number(settings.autoRunBatchLimit);
|
|
739
|
+
while (true) {
|
|
740
|
+
// 只计 DB running:starting 任务会在 runTask 同步阶段立刻标为 running,再加 starting 会重复计数
|
|
741
|
+
const inFlight = this.store.countRunningTasks(workId);
|
|
742
|
+
const remainingClaims = batchLimit - batch.claimed;
|
|
743
|
+
if (inFlight >= concurrency || remainingClaims <= 0)
|
|
744
|
+
return;
|
|
745
|
+
const candidates = this.store.listOldestPendingTaskIds(workId, remainingClaims)
|
|
746
|
+
.filter((taskId) => !batch.starting.has(taskId) && !this.taskControllers.has(taskId));
|
|
747
|
+
if (!candidates.length)
|
|
748
|
+
return;
|
|
749
|
+
const taskId = candidates[0];
|
|
750
|
+
if (!taskId)
|
|
751
|
+
return;
|
|
752
|
+
batch.starting.add(taskId);
|
|
753
|
+
batch.claimed += 1;
|
|
754
|
+
void this.runTask(taskId)
|
|
755
|
+
.catch(() => undefined)
|
|
756
|
+
.finally(() => {
|
|
757
|
+
batch.starting.delete(taskId);
|
|
758
|
+
this.scheduleAutoRun(workId);
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
logger.warn("ai.auto_run.drain_failed", { workId, error: aiErrorForLog(error) });
|
|
764
|
+
// 数据库已关闭或作品不存在时忽略自动调度
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
outboundFetch(url, init) {
|
|
768
|
+
return fetchSafeAiEndpoint(this.fetchImpl, url, init, this.validateOutboundUrl);
|
|
769
|
+
}
|
|
770
|
+
createProvider(input) {
|
|
771
|
+
const providerId = id("provider");
|
|
772
|
+
const encrypted = this.vault.encrypt(input.apiKey);
|
|
773
|
+
const timestamp = now();
|
|
774
|
+
this.store.db.run(`INSERT INTO providers (id, work_id, name, base_url, encrypted_key, key_iv, key_tag, key_hint, status,
|
|
775
|
+
connection_status, concurrency_limit, rpm_limit, max_tokens, note, created_at, updated_at)
|
|
776
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'unchecked', ?, ?, ?, ?, ?, ?)`, providerId, PLATFORM_AI_WORK_ID, input.name, normalizeBaseUrl(input.baseUrl), encrypted.encrypted, encrypted.iv, encrypted.tag, maskSecret(input.apiKey), input.status ?? "disabled", input.concurrencyLimit ?? 10, input.rpmLimit ?? 10, input.maxTokens ?? DEFAULT_MAX_TOKENS, input.note ?? "", timestamp, timestamp);
|
|
777
|
+
this.store.audit(PLATFORM_AI_WORK_ID, "provider.created", "provider", providerId, { name: input.name, baseUrl: normalizeBaseUrl(input.baseUrl) });
|
|
778
|
+
return this.getProvider(providerId);
|
|
779
|
+
}
|
|
780
|
+
listProviders() {
|
|
781
|
+
return this.store.db.all("SELECT * FROM providers WHERE work_id = ? ORDER BY created_at", PLATFORM_AI_WORK_ID).map((row) => this.mapProvider(row));
|
|
782
|
+
}
|
|
783
|
+
getProvider(providerId) {
|
|
784
|
+
return this.mapProvider(this.getProviderRow(providerId));
|
|
785
|
+
}
|
|
786
|
+
updateProvider(providerId, input) {
|
|
787
|
+
const row = this.getProviderRow(providerId);
|
|
788
|
+
let encryptedKey = stringValue(row, "encrypted_key");
|
|
789
|
+
let keyIv = stringValue(row, "key_iv");
|
|
790
|
+
let keyTag = stringValue(row, "key_tag");
|
|
791
|
+
let keyHint = stringValue(row, "key_hint");
|
|
792
|
+
let connectionStatus = stringValue(row, "connection_status");
|
|
793
|
+
if (input.apiKey) {
|
|
794
|
+
const encrypted = this.vault.encrypt(input.apiKey);
|
|
795
|
+
encryptedKey = encrypted.encrypted;
|
|
796
|
+
keyIv = encrypted.iv;
|
|
797
|
+
keyTag = encrypted.tag;
|
|
798
|
+
keyHint = maskSecret(input.apiKey);
|
|
799
|
+
connectionStatus = "unchecked";
|
|
800
|
+
}
|
|
801
|
+
if (input.baseUrl && normalizeBaseUrl(input.baseUrl) !== stringValue(row, "base_url"))
|
|
802
|
+
connectionStatus = "unchecked";
|
|
803
|
+
this.store.db.run(`UPDATE providers SET name = ?, base_url = ?, encrypted_key = ?, key_iv = ?, key_tag = ?, key_hint = ?,
|
|
804
|
+
status = ?, connection_status = ?, concurrency_limit = ?, rpm_limit = ?, max_tokens = ?, note = ?, updated_at = ? WHERE id = ?`, input.name ?? stringValue(row, "name"), input.baseUrl ? normalizeBaseUrl(input.baseUrl) : stringValue(row, "base_url"), encryptedKey, keyIv, keyTag, keyHint, input.status ?? stringValue(row, "status"), connectionStatus, input.concurrencyLimit ?? numberValue(row, "concurrency_limit"), input.rpmLimit ?? numberValue(row, "rpm_limit"), input.maxTokens ?? numberValue(row, "max_tokens"), input.note ?? stringValue(row, "note"), now(), providerId);
|
|
805
|
+
this.store.audit(PLATFORM_AI_WORK_ID, "provider.updated", "provider", providerId, {
|
|
806
|
+
fields: Object.keys(input).filter((key) => key !== "apiKey"),
|
|
807
|
+
keyReplaced: Boolean(input.apiKey)
|
|
808
|
+
});
|
|
809
|
+
const schedule = this.providerSchedules.get(providerId);
|
|
810
|
+
if (schedule) {
|
|
811
|
+
schedule.concurrencyLimit = Math.round(clamp((input.concurrencyLimit ?? numberValue(row, "concurrency_limit")) || 10, 1, 100));
|
|
812
|
+
schedule.rpmLimit = Math.round(clamp((input.rpmLimit ?? numberValue(row, "rpm_limit")) || 10, 1, 10_000));
|
|
813
|
+
this.pumpProviderSchedule(providerId);
|
|
814
|
+
}
|
|
815
|
+
return this.getProvider(providerId);
|
|
816
|
+
}
|
|
817
|
+
deleteProvider(providerId) {
|
|
818
|
+
const row = this.getProviderRow(providerId);
|
|
819
|
+
const modelCount = this.store.db.get("SELECT COUNT(*) AS value FROM models WHERE provider_id = ?", providerId);
|
|
820
|
+
const defaultCount = this.store.db.get("SELECT COUNT(*) AS value FROM task_defaults WHERE model_id IN (SELECT id FROM models WHERE provider_id = ?)", providerId);
|
|
821
|
+
this.store.audit(PLATFORM_AI_WORK_ID, "provider.deleted", "provider", providerId, {
|
|
822
|
+
modelCount: numberValue(modelCount ?? {}, "value"),
|
|
823
|
+
affectedDefaults: numberValue(defaultCount ?? {}, "value")
|
|
824
|
+
});
|
|
825
|
+
this.store.db.run("DELETE FROM providers WHERE id = ?", providerId);
|
|
826
|
+
}
|
|
827
|
+
async testProvider(providerId) {
|
|
828
|
+
const row = this.getProviderRow(providerId);
|
|
829
|
+
const apiKey = this.decryptKey(row);
|
|
830
|
+
const controller = new AbortController();
|
|
831
|
+
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
832
|
+
const startedAt = process.hrtime.bigint();
|
|
833
|
+
logger.info("ai.provider_test.started", { providerId });
|
|
834
|
+
try {
|
|
835
|
+
const endpoint = `${normalizeBaseUrl(stringValue(row, "base_url"))}/models`;
|
|
836
|
+
const response = await this.outboundFetch(endpoint, {
|
|
837
|
+
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
838
|
+
signal: controller.signal
|
|
839
|
+
});
|
|
840
|
+
if (!response.ok) {
|
|
841
|
+
const message = await response.text();
|
|
842
|
+
throw new Error(`HTTP ${response.status}: ${message.slice(0, 300)}`);
|
|
843
|
+
}
|
|
844
|
+
const payload = (await response.json());
|
|
845
|
+
const availableModels = Array.isArray(payload.data) ? payload.data.map((item) => item.id).filter(Boolean) : [];
|
|
846
|
+
const timestamp = now();
|
|
847
|
+
this.store.db.run("UPDATE providers SET connection_status = 'success', last_error = NULL, last_success_at = ?, updated_at = ? WHERE id = ?", timestamp, timestamp, providerId);
|
|
848
|
+
logger.info("ai.provider_test.completed", {
|
|
849
|
+
providerId,
|
|
850
|
+
ok: true,
|
|
851
|
+
availableModelCount: availableModels.length,
|
|
852
|
+
durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000
|
|
853
|
+
});
|
|
854
|
+
return { ok: true, availableModels, provider: this.getProvider(providerId) };
|
|
855
|
+
}
|
|
856
|
+
catch (error) {
|
|
857
|
+
const message = error instanceof Error ? error.message : "连接失败";
|
|
858
|
+
this.store.db.run("UPDATE providers SET connection_status = 'failed', last_error = ?, updated_at = ? WHERE id = ?", message, now(), providerId);
|
|
859
|
+
logger.warn("ai.provider_test.completed", {
|
|
860
|
+
providerId,
|
|
861
|
+
ok: false,
|
|
862
|
+
durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000,
|
|
863
|
+
error: aiErrorForLog(error)
|
|
864
|
+
});
|
|
865
|
+
return { ok: false, error: message, provider: this.getProvider(providerId) };
|
|
866
|
+
}
|
|
867
|
+
finally {
|
|
868
|
+
clearTimeout(timeout);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
createModel(providerId, input) {
|
|
872
|
+
const provider = this.getProviderRow(providerId);
|
|
873
|
+
const modelId = id("model");
|
|
874
|
+
const timestamp = now();
|
|
875
|
+
this.store.db.run(`INSERT INTO models (id, provider_id, display_name, model_id, purposes_json, context_note, context_window, output_note,
|
|
876
|
+
preset_json, thinking_enabled, enabled, note, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, modelId, providerId, input.displayName, input.modelId, JSON.stringify(input.purposes ?? []), input.contextNote ?? "", input.contextWindow ?? DEFAULT_CONTEXT_WINDOW, input.outputNote ?? "", JSON.stringify(normalizeModelPreset(input.preset ?? {})), (input.thinkingEnabled ?? true) ? 1 : 0, (input.enabled ?? true) ? 1 : 0, input.note ?? "", timestamp, timestamp);
|
|
877
|
+
this.store.audit(PLATFORM_AI_WORK_ID, "model.created", "model", modelId, { providerId, modelId: input.modelId });
|
|
878
|
+
return this.getModel(modelId);
|
|
879
|
+
}
|
|
880
|
+
listModels(providerId) {
|
|
881
|
+
this.getProviderRow(providerId);
|
|
882
|
+
return this.store.db.all("SELECT * FROM models WHERE provider_id = ? ORDER BY created_at", providerId).map((row) => this.mapModel(row));
|
|
883
|
+
}
|
|
884
|
+
listPlatformModels() {
|
|
885
|
+
return this.store.db
|
|
886
|
+
.all("SELECT m.*, p.name AS provider_name FROM models m JOIN providers p ON p.id = m.provider_id WHERE p.work_id = ? ORDER BY p.created_at, m.created_at", PLATFORM_AI_WORK_ID)
|
|
887
|
+
.map((row) => ({ ...this.mapModel(row), providerName: stringValue(row, "provider_name") }));
|
|
888
|
+
}
|
|
889
|
+
listWorkModels(workId) {
|
|
890
|
+
this.store.getWork(workId);
|
|
891
|
+
return this.listPlatformModels();
|
|
892
|
+
}
|
|
893
|
+
getModel(modelId) {
|
|
894
|
+
const row = this.getModelRow(modelId);
|
|
895
|
+
return this.mapModel(row);
|
|
896
|
+
}
|
|
897
|
+
updateModel(modelId, input) {
|
|
898
|
+
const row = this.getModelRow(modelId);
|
|
899
|
+
const preset = normalizeModelPreset(input.preset ?? safeJsonObject(stringValue(row, "preset_json")));
|
|
900
|
+
this.store.db.run(`UPDATE models SET display_name = ?, model_id = ?, purposes_json = ?, context_note = ?, context_window = ?, output_note = ?,
|
|
901
|
+
preset_json = ?, thinking_enabled = ?, enabled = ?, note = ?, updated_at = ? WHERE id = ?`, input.displayName ?? stringValue(row, "display_name"), input.modelId ?? stringValue(row, "model_id"), JSON.stringify(input.purposes ?? json(stringValue(row, "purposes_json"), [])), input.contextNote ?? stringValue(row, "context_note"), input.contextWindow ?? (numberValue(row, "context_window") || DEFAULT_CONTEXT_WINDOW), input.outputNote ?? stringValue(row, "output_note"), JSON.stringify(preset), (input.thinkingEnabled ?? boolValue(row, "thinking_enabled")) ? 1 : 0, (input.enabled ?? boolValue(row, "enabled")) ? 1 : 0, input.note ?? stringValue(row, "note"), now(), modelId);
|
|
902
|
+
return this.getModel(modelId);
|
|
903
|
+
}
|
|
904
|
+
deleteModel(modelId) {
|
|
905
|
+
this.getModelRow(modelId);
|
|
906
|
+
this.store.db.run("DELETE FROM models WHERE id = ?", modelId);
|
|
907
|
+
}
|
|
908
|
+
setTaskDefault(workId, taskType, modelId) {
|
|
909
|
+
const model = this.getModelRow(modelId);
|
|
910
|
+
const provider = this.getProviderRow(stringValue(model, "provider_id"));
|
|
911
|
+
if (stringValue(provider, "work_id") !== PLATFORM_AI_WORK_ID)
|
|
912
|
+
throw new AppError(400, "MODEL_PLATFORM_MISMATCH", "模型不属于平台 AI 配置");
|
|
913
|
+
this.assertAvailable(provider, model);
|
|
914
|
+
this.store.db.run(`INSERT INTO task_defaults (work_id, task_type, model_id) VALUES (?, ?, ?)
|
|
915
|
+
ON CONFLICT(work_id, task_type) DO UPDATE SET model_id = excluded.model_id`, workId, taskType, modelId);
|
|
916
|
+
return { workId, taskType, model: this.getModel(modelId), provider: this.getProvider(stringValue(model, "provider_id")) };
|
|
917
|
+
}
|
|
918
|
+
listTaskDefaults(workId) {
|
|
919
|
+
this.store.getWork(workId);
|
|
920
|
+
return this.store.db.all("SELECT * FROM task_defaults WHERE work_id = ? ORDER BY task_type", workId).map((row) => ({
|
|
921
|
+
taskType: stringValue(row, "task_type"),
|
|
922
|
+
model: this.getModel(stringValue(row, "model_id"))
|
|
923
|
+
}));
|
|
924
|
+
}
|
|
925
|
+
async createSuggestion(input) {
|
|
926
|
+
const action = input.taskType === "continue" ? "append" : input.taskType === "polish" ? "replace-selection" : "note";
|
|
927
|
+
if (action === "replace-selection" && !input.scope.selection) {
|
|
928
|
+
throw new AppError(400, "SELECTION_REQUIRED", "润色任务必须提供选中文本");
|
|
929
|
+
}
|
|
930
|
+
const effectiveInput = input.taskType === "continue"
|
|
931
|
+
? { ...input, scope: this.enrichContinuationScope(input.workId, input.scope, input.instruction) }
|
|
932
|
+
: input;
|
|
933
|
+
const generated = await this.generate(effectiveInput);
|
|
934
|
+
const chapter = effectiveInput.scope.chapterId ? this.store.getChapter(effectiveInput.scope.chapterId) : null;
|
|
935
|
+
const suggestionId = id("suggestion");
|
|
936
|
+
this.store.db.run(`INSERT INTO ai_suggestions (id, call_id, work_id, chapter_id, chapter_version, task_type, instruction,
|
|
937
|
+
source_text, content, action, status, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, suggestionId, generated.callId, input.workId, chapter ? String(chapter.id) : null, chapter ? Number(chapter.versionNo) : null, input.taskType, input.instruction, effectiveInput.scope.selection ?? "", generated.content, action, now(), currentRequestActor()?.userId ?? null);
|
|
938
|
+
if (input.taskType === "continue")
|
|
939
|
+
await this.runSuggestionGuard(suggestionId);
|
|
940
|
+
return { ...this.getSuggestion(suggestionId), outputTokens: generated.outputTokens, toolCalls: generated.toolCalls, processSteps: generated.processSteps };
|
|
941
|
+
}
|
|
942
|
+
async createStreamingChat(input, onDelta) {
|
|
943
|
+
const generated = this.enabledAgentTools(input.workId, "chat").length
|
|
944
|
+
? await this.generate({ ...input, taskType: "chat" })
|
|
945
|
+
: await this.generateStream({ ...input, taskType: "chat" }, onDelta);
|
|
946
|
+
if (this.enabledAgentTools(input.workId, "chat").length)
|
|
947
|
+
onDelta(generated.content);
|
|
948
|
+
const chapter = input.scope.chapterId ? this.store.getChapter(input.scope.chapterId) : null;
|
|
949
|
+
const suggestionId = id("suggestion");
|
|
950
|
+
this.store.db.run(`INSERT INTO ai_suggestions (id, call_id, work_id, chapter_id, chapter_version, task_type, instruction,
|
|
951
|
+
source_text, content, action, status, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, 'chat', ?, ?, ?, 'note', 'pending', ?, ?)`, suggestionId, generated.callId, input.workId, chapter ? String(chapter.id) : null, chapter ? Number(chapter.versionNo) : null, input.instruction, input.scope.selection ?? "", generated.content, now(), currentRequestActor()?.userId ?? null);
|
|
952
|
+
return { ...this.getSuggestion(suggestionId), outputTokens: generated.outputTokens, toolCalls: generated.toolCalls, processSteps: generated.processSteps };
|
|
953
|
+
}
|
|
954
|
+
async runSuggestionGuard(suggestionId, candidateContent) {
|
|
955
|
+
const suggestion = this.getSuggestion(suggestionId);
|
|
956
|
+
if (suggestion.taskType !== "continue" || !suggestion.chapterId) {
|
|
957
|
+
throw new AppError(409, "GUARD_NOT_APPLICABLE", "只有续写建议可以运行一致性守卫");
|
|
958
|
+
}
|
|
959
|
+
const chapter = this.store.getChapter(String(suggestion.chapterId));
|
|
960
|
+
if (chapter.versionNo !== suggestion.chapterVersion) {
|
|
961
|
+
throw new AppError(409, "STALE_SUGGESTION", "正文版本已变化,请重新生成建议");
|
|
962
|
+
}
|
|
963
|
+
const call = this.store.db.get("SELECT model_id, context_scope_json FROM ai_calls WHERE id = ?", String(suggestion.callId));
|
|
964
|
+
if (!call)
|
|
965
|
+
throw notFound("AI 调用记录");
|
|
966
|
+
const originalScope = json(stringValue(call, "context_scope_json"), {
|
|
967
|
+
type: "chapter",
|
|
968
|
+
chapterId: String(suggestion.chapterId)
|
|
969
|
+
});
|
|
970
|
+
const scope = this.enrichContinuationScope(String(suggestion.workId), originalScope, String(suggestion.instruction));
|
|
971
|
+
const content = candidateContent ?? String(suggestion.content);
|
|
972
|
+
const contextRefs = this.buildContinuationContextRefs(String(suggestion.workId), String(suggestion.chapterId), scope);
|
|
973
|
+
try {
|
|
974
|
+
const generated = await this.generateTaggedJson({
|
|
975
|
+
workId: String(suggestion.workId),
|
|
976
|
+
taskType: "consistency-check",
|
|
977
|
+
modelId: stringValue(call, "model_id"),
|
|
978
|
+
scope,
|
|
979
|
+
instruction: [
|
|
980
|
+
"检查下面的续写候选是否与提供的上下文冲突。输出 JSON 数组,没有冲突时输出 []。",
|
|
981
|
+
"每项字段必须为:type(character/location/time/world/outline/foreshadow)、severity(low/medium/high)、title、description、candidateQuote、sourceRefs(数组)、suggestion。",
|
|
982
|
+
"不得把文风偏好当成事实冲突,不得使用 Markdown 代码块。",
|
|
983
|
+
"续写候选:",
|
|
984
|
+
content
|
|
985
|
+
].join("\n\n"),
|
|
986
|
+
extraSystemPrompt: "你是续写一致性守卫。必须逐项对照人物状态、地点、时间、世界观硬约束、章节大纲和未回收伏笔。"
|
|
987
|
+
});
|
|
988
|
+
const issues = parseGuardIssues(generated.content);
|
|
989
|
+
return this.store.createContinuationGuard({
|
|
990
|
+
suggestionId,
|
|
991
|
+
callId: generated.callId,
|
|
992
|
+
chapterVersion: Number(chapter.versionNo),
|
|
993
|
+
content,
|
|
994
|
+
status: issues.length > 0 ? "warning" : "clear",
|
|
995
|
+
issues,
|
|
996
|
+
contextRefs
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
catch (error) {
|
|
1000
|
+
const failure = error instanceof Error ? error.message : "一致性检查失败";
|
|
1001
|
+
const callId = error instanceof AppError && error.details && typeof error.details === "object" && "callId" in error.details
|
|
1002
|
+
? String(error.details.callId)
|
|
1003
|
+
: null;
|
|
1004
|
+
return this.store.createContinuationGuard({
|
|
1005
|
+
suggestionId,
|
|
1006
|
+
callId,
|
|
1007
|
+
chapterVersion: Number(chapter.versionNo),
|
|
1008
|
+
content,
|
|
1009
|
+
status: "failed",
|
|
1010
|
+
issues: [],
|
|
1011
|
+
contextRefs,
|
|
1012
|
+
failure
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
listSuggestions(workId, status) {
|
|
1017
|
+
this.store.getWork(workId);
|
|
1018
|
+
const rows = status
|
|
1019
|
+
? this.store.db.all("SELECT * FROM ai_suggestions WHERE work_id = ? AND status = ? ORDER BY created_at DESC", workId, status)
|
|
1020
|
+
: this.store.db.all("SELECT * FROM ai_suggestions WHERE work_id = ? ORDER BY created_at DESC", workId);
|
|
1021
|
+
return rows.map((row) => this.mapSuggestion(row));
|
|
1022
|
+
}
|
|
1023
|
+
getSuggestion(suggestionId) {
|
|
1024
|
+
const row = this.store.db.get("SELECT * FROM ai_suggestions WHERE id = ?", suggestionId);
|
|
1025
|
+
if (!row)
|
|
1026
|
+
throw notFound("AI 建议");
|
|
1027
|
+
return this.mapSuggestion(row);
|
|
1028
|
+
}
|
|
1029
|
+
acceptSuggestion(suggestionId, acceptedContent) {
|
|
1030
|
+
const suggestion = this.getSuggestion(suggestionId);
|
|
1031
|
+
if (suggestion.status !== "pending")
|
|
1032
|
+
throw new AppError(409, "SUGGESTION_DECIDED", "该建议已经处理");
|
|
1033
|
+
if (!suggestion.chapterId || suggestion.action === "note") {
|
|
1034
|
+
throw new AppError(409, "SUGGESTION_NOT_APPLICABLE", "问答或分析类建议不能直接写入正文");
|
|
1035
|
+
}
|
|
1036
|
+
const chapter = this.store.getChapter(String(suggestion.chapterId));
|
|
1037
|
+
if (chapter.versionNo !== suggestion.chapterVersion) {
|
|
1038
|
+
throw new AppError(409, "STALE_SUGGESTION", "正文版本已变化,请重新生成建议", {
|
|
1039
|
+
expectedVersion: suggestion.chapterVersion,
|
|
1040
|
+
currentVersion: chapter.versionNo
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
const content = acceptedContent ?? String(suggestion.content);
|
|
1044
|
+
if (suggestion.taskType === "continue") {
|
|
1045
|
+
const guard = this.store.getLatestContinuationGuard(suggestionId);
|
|
1046
|
+
if (!guard)
|
|
1047
|
+
throw new AppError(409, "GUARD_REQUIRED", "续写建议尚未完成一致性检查");
|
|
1048
|
+
if (guard.status === "failed") {
|
|
1049
|
+
throw new AppError(409, "GUARD_FAILED", "续写一致性检查失败,请重新运行检查后再采纳");
|
|
1050
|
+
}
|
|
1051
|
+
if (guard.chapterVersion !== chapter.versionNo || guard.contentHash !== this.store.hashContent(content)) {
|
|
1052
|
+
throw new AppError(409, "GUARD_STALE", "续写内容或正文版本已变化,请重新运行一致性检查");
|
|
1053
|
+
}
|
|
1054
|
+
const call = this.store.db.get("SELECT context_scope_json FROM ai_calls WHERE id = ?", String(suggestion.callId));
|
|
1055
|
+
if (!call)
|
|
1056
|
+
throw notFound("AI 调用记录");
|
|
1057
|
+
const originalScope = json(stringValue(call, "context_scope_json"), {
|
|
1058
|
+
type: "chapter",
|
|
1059
|
+
chapterId: String(suggestion.chapterId)
|
|
1060
|
+
});
|
|
1061
|
+
const currentScope = this.enrichContinuationScope(String(suggestion.workId), originalScope, String(suggestion.instruction));
|
|
1062
|
+
const currentContextRefs = this.buildContinuationContextRefs(String(suggestion.workId), String(suggestion.chapterId), currentScope);
|
|
1063
|
+
if (JSON.stringify(guard.contextRefs) !== JSON.stringify(currentContextRefs)) {
|
|
1064
|
+
throw new AppError(409, "GUARD_STALE", "人物状态、锁定设定、大纲、伏笔或时间线已变化,请重新运行一致性检查");
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
let nextContent;
|
|
1068
|
+
if (suggestion.action === "append") {
|
|
1069
|
+
nextContent = `${String(chapter.content).trimEnd()}\n\n${content.trim()}`.trim();
|
|
1070
|
+
}
|
|
1071
|
+
else {
|
|
1072
|
+
const sourceText = String(suggestion.sourceText);
|
|
1073
|
+
if (!sourceText || !String(chapter.content).includes(sourceText)) {
|
|
1074
|
+
throw new AppError(409, "SOURCE_TEXT_CHANGED", "原选中文本已不存在,请重新生成建议");
|
|
1075
|
+
}
|
|
1076
|
+
nextContent = String(chapter.content).replace(sourceText, content);
|
|
1077
|
+
}
|
|
1078
|
+
const updated = this.store.saveChapter(String(chapter.id), { content: nextContent }, "ai-suggestion", suggestionId);
|
|
1079
|
+
this.store.db.run("UPDATE ai_suggestions SET status = 'accepted', content = ?, decided_at = ?, decided_by_user_id = ? WHERE id = ?", content, now(), currentRequestActor()?.userId ?? null, suggestionId);
|
|
1080
|
+
this.store.audit(String(suggestion.workId), "suggestion.accepted", "ai-suggestion", suggestionId, { chapterId: chapter.id });
|
|
1081
|
+
return { suggestion: this.getSuggestion(suggestionId), chapter: updated };
|
|
1082
|
+
}
|
|
1083
|
+
rejectSuggestion(suggestionId) {
|
|
1084
|
+
const suggestion = this.getSuggestion(suggestionId);
|
|
1085
|
+
if (suggestion.status !== "pending")
|
|
1086
|
+
throw new AppError(409, "SUGGESTION_DECIDED", "该建议已经处理");
|
|
1087
|
+
this.store.db.run("UPDATE ai_suggestions SET status = 'rejected', decided_at = ?, decided_by_user_id = ? WHERE id = ?", now(), currentRequestActor()?.userId ?? null, suggestionId);
|
|
1088
|
+
return this.getSuggestion(suggestionId);
|
|
1089
|
+
}
|
|
1090
|
+
listCalls(workId) {
|
|
1091
|
+
this.store.getWork(workId);
|
|
1092
|
+
return this.store.db.all("SELECT * FROM ai_calls WHERE work_id = ? ORDER BY created_at DESC LIMIT 200", workId).map((row) => ({
|
|
1093
|
+
id: stringValue(row, "id"),
|
|
1094
|
+
workId: stringValue(row, "work_id"),
|
|
1095
|
+
taskType: stringValue(row, "task_type"),
|
|
1096
|
+
provider: this.getProvider(stringValue(row, "provider_id")),
|
|
1097
|
+
model: this.getModel(stringValue(row, "model_id")),
|
|
1098
|
+
contextScope: json(stringValue(row, "context_scope_json"), {}),
|
|
1099
|
+
parameters: json(stringValue(row, "parameters_json"), {}),
|
|
1100
|
+
status: stringValue(row, "status"),
|
|
1101
|
+
failure: row.failure === null ? null : stringValue(row, "failure"),
|
|
1102
|
+
inputChars: numberValue(row, "input_chars"),
|
|
1103
|
+
outputChars: numberValue(row, "output_chars"),
|
|
1104
|
+
createdAt: stringValue(row, "created_at"),
|
|
1105
|
+
completedAt: row.completed_at === null ? null : stringValue(row, "completed_at")
|
|
1106
|
+
}));
|
|
1107
|
+
}
|
|
1108
|
+
async runTask(taskId, modelId) {
|
|
1109
|
+
const task = this.store.getTask(taskId);
|
|
1110
|
+
const workId = String(task.workId);
|
|
1111
|
+
const batch = this.getAutoRunBatch(workId);
|
|
1112
|
+
const startedAt = process.hrtime.bigint();
|
|
1113
|
+
logger.info("ai.task.started", { taskId, workId, taskType: task.taskType, modelId: modelId ?? null });
|
|
1114
|
+
if (task.status !== "pending")
|
|
1115
|
+
throw new AppError(409, "TASK_NOT_PENDING", "只有待执行任务可以运行");
|
|
1116
|
+
if (!this.store.isTaskSourceCurrent(taskId)) {
|
|
1117
|
+
const expired = this.store.updateTask(taskId, { status: "expired" });
|
|
1118
|
+
batch.starting.delete(taskId);
|
|
1119
|
+
this.scheduleAutoRun(workId);
|
|
1120
|
+
logger.warn("ai.task.expired", { taskId, workId, durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000 });
|
|
1121
|
+
return expired;
|
|
1122
|
+
}
|
|
1123
|
+
const settings = this.store.getWorkAiSettings(workId);
|
|
1124
|
+
// 自动 drain 已在 starting 集合中认领;手动运行在开关开启时计入本轮配额
|
|
1125
|
+
if (Boolean(settings.autoRunEnabled) && !batch.starting.has(taskId)) {
|
|
1126
|
+
batch.claimed += 1;
|
|
1127
|
+
}
|
|
1128
|
+
this.store.updateTask(taskId, { status: "running", progress: 5 });
|
|
1129
|
+
const taskController = new AbortController();
|
|
1130
|
+
this.taskControllers.set(taskId, taskController);
|
|
1131
|
+
try {
|
|
1132
|
+
const taskType = String(task.taskType);
|
|
1133
|
+
const scope = task.scope;
|
|
1134
|
+
let result;
|
|
1135
|
+
if (taskType === "chapter-analysis") {
|
|
1136
|
+
result = await this.runChapterAnalysis(workId, scope, modelId, taskId);
|
|
1137
|
+
}
|
|
1138
|
+
else if (taskType === "character-extraction" || taskType === "character-summary") {
|
|
1139
|
+
result = await this.runCharacterExtraction(workId, scope, modelId, taskId);
|
|
1140
|
+
}
|
|
1141
|
+
else if (taskType === "timeline-analysis") {
|
|
1142
|
+
result = await this.runTimelineAnalysis(workId, scope, modelId, taskId);
|
|
1143
|
+
}
|
|
1144
|
+
else if (taskType === "relationship-analysis") {
|
|
1145
|
+
result = await this.runRelationshipAnalysis(workId, scope, modelId, taskId);
|
|
1146
|
+
}
|
|
1147
|
+
else if (taskType === "worldview-analysis") {
|
|
1148
|
+
result = await this.runWorldviewAnalysis(workId, scope, modelId, taskId);
|
|
1149
|
+
}
|
|
1150
|
+
else if (taskType === "setting-extraction") {
|
|
1151
|
+
result = await this.runSettingExtraction(workId, scope, modelId, taskId);
|
|
1152
|
+
}
|
|
1153
|
+
else if (taskType === "consistency-check") {
|
|
1154
|
+
result = await this.runConsistencyCheck(workId, scope, modelId, taskId);
|
|
1155
|
+
}
|
|
1156
|
+
else {
|
|
1157
|
+
const generated = await this.generate({
|
|
1158
|
+
workId,
|
|
1159
|
+
taskType: taskType === "book-analysis" ? "book-analysis" : "chapter-analysis",
|
|
1160
|
+
instruction: "请基于上下文完成分析,给出有原文依据的中文结论。",
|
|
1161
|
+
scope,
|
|
1162
|
+
signal: taskController.signal,
|
|
1163
|
+
...(modelId ? { modelId } : {})
|
|
1164
|
+
});
|
|
1165
|
+
result = { content: generated.content, callId: generated.callId };
|
|
1166
|
+
}
|
|
1167
|
+
if (!this.taskCanCommit(taskId)) {
|
|
1168
|
+
logger.warn("ai.task.result_discarded", { taskId, workId, durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000 });
|
|
1169
|
+
return this.store.getTask(taskId);
|
|
1170
|
+
}
|
|
1171
|
+
const completed = this.store.updateTask(taskId, { status: "review", progress: 100, result });
|
|
1172
|
+
logger.info("ai.task.completed", { taskId, workId, durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000 });
|
|
1173
|
+
return completed;
|
|
1174
|
+
}
|
|
1175
|
+
catch (error) {
|
|
1176
|
+
if (this.store.getTask(taskId).status !== "running")
|
|
1177
|
+
return this.store.getTask(taskId);
|
|
1178
|
+
const message = error instanceof Error ? error.message : "分析失败";
|
|
1179
|
+
this.store.updateTask(taskId, { status: "partial", progress: 100, failures: [{ message }] });
|
|
1180
|
+
logger.error("ai.task.failed", { taskId, workId, durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000, error: aiErrorForLog(error) });
|
|
1181
|
+
throw error;
|
|
1182
|
+
}
|
|
1183
|
+
finally {
|
|
1184
|
+
this.taskControllers.delete(taskId);
|
|
1185
|
+
batch.starting.delete(taskId);
|
|
1186
|
+
this.scheduleAutoRun(workId);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
cancelTask(taskId) {
|
|
1190
|
+
const task = this.store.cancelTask(taskId);
|
|
1191
|
+
this.taskControllers.get(taskId)?.abort(new Error("分析任务已取消"));
|
|
1192
|
+
this.taskControllers.delete(taskId);
|
|
1193
|
+
this.scheduleAutoRun(String(task.workId));
|
|
1194
|
+
logger.warn("ai.task.cancelled", { taskId, workId: task.workId });
|
|
1195
|
+
return task;
|
|
1196
|
+
}
|
|
1197
|
+
contextBudget(input, model) {
|
|
1198
|
+
const contextWindow = numberValue(model, "context_window") || DEFAULT_CONTEXT_WINDOW;
|
|
1199
|
+
const preset = safeJsonObject(stringValue(model, "preset_json"));
|
|
1200
|
+
const configuredOutputTokens = typeof preset.max_tokens === "number" ? preset.max_tokens : DEFAULT_MAX_TOKENS;
|
|
1201
|
+
const outputReserveTokens = Math.max(512, Math.min(configuredOutputTokens, Math.floor(contextWindow * 0.25), contextWindow - 512));
|
|
1202
|
+
const availableInputTokens = Math.max(256, contextWindow - outputReserveTokens - 512);
|
|
1203
|
+
const conversation = input.conversationId
|
|
1204
|
+
? this.store.getAiConversationContext(input.conversationId, input.workId, input.excludeConversationMessageId)
|
|
1205
|
+
: null;
|
|
1206
|
+
const renderedMemory = conversation?.summary ? renderConversationMemory(conversation.summary) : "";
|
|
1207
|
+
const conversationTokens = conversation
|
|
1208
|
+
? estimateAiTokens(renderedMemory) + conversation.messages.reduce((total, message) => total + estimateAiTokens(message.content), 0)
|
|
1209
|
+
: 0;
|
|
1210
|
+
const conversationBudgetTokens = Math.max(256, Math.floor(availableInputTokens * 0.32));
|
|
1211
|
+
const instructionTokens = estimateAiTokens(input.instruction);
|
|
1212
|
+
const workContextBudgetTokens = Math.max(256, availableInputTokens
|
|
1213
|
+
- Math.min(conversationTokens, conversationBudgetTokens)
|
|
1214
|
+
- Math.min(instructionTokens, Math.floor(availableInputTokens * 0.25))
|
|
1215
|
+
- Math.min(1_024, Math.floor(availableInputTokens * 0.12)));
|
|
1216
|
+
return {
|
|
1217
|
+
contextWindow,
|
|
1218
|
+
outputReserveTokens,
|
|
1219
|
+
availableInputTokens,
|
|
1220
|
+
conversation,
|
|
1221
|
+
conversationTokens,
|
|
1222
|
+
conversationBudgetTokens,
|
|
1223
|
+
conversationUsagePercent: Math.round(conversationTokens / conversationBudgetTokens * 100),
|
|
1224
|
+
workContextBudgetTokens
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
getContextUsage(input) {
|
|
1228
|
+
const { model } = this.resolveModel(input.workId, input.taskType, input.modelId);
|
|
1229
|
+
const budget = this.contextBudget(input, model);
|
|
1230
|
+
const contextPlan = this.buildContextPlan(input, model, budget);
|
|
1231
|
+
const context = contextPlan.context;
|
|
1232
|
+
const messages = this.buildMessages(input, context);
|
|
1233
|
+
const contextWindow = numberValue(model, "context_window") || DEFAULT_CONTEXT_WINDOW;
|
|
1234
|
+
const inputTokens = messages.reduce((total, message) => total + estimateAiTokens(message.content), 0);
|
|
1235
|
+
const remainingTokens = Math.max(0, contextWindow - inputTokens);
|
|
1236
|
+
const threshold = Math.min(90, Math.max(50, Number(this.store.getWorkAiSettings(input.workId).contextCompactThreshold) || 85));
|
|
1237
|
+
const conversation = budget.conversation;
|
|
1238
|
+
const conversationUsagePercent = Number(budget.conversationUsagePercent) || 0;
|
|
1239
|
+
const compactableMessageCount = Math.max(0, (conversation?.messages.length ?? 0) - 2);
|
|
1240
|
+
return {
|
|
1241
|
+
modelId: stringValue(model, "id"),
|
|
1242
|
+
contextWindow,
|
|
1243
|
+
inputTokens,
|
|
1244
|
+
contextTokens: contextPlan.tokenCount,
|
|
1245
|
+
conversationTokens: Number(budget.conversationTokens),
|
|
1246
|
+
conversationBudgetTokens: Number(budget.conversationBudgetTokens),
|
|
1247
|
+
conversationUsagePercent,
|
|
1248
|
+
outputReserveTokens: Number(budget.outputReserveTokens),
|
|
1249
|
+
remainingTokens,
|
|
1250
|
+
usagePercent: Math.min(100, Math.round(inputTokens / contextWindow * 100)),
|
|
1251
|
+
compactThreshold: threshold,
|
|
1252
|
+
compactRecommended: compactableMessageCount > 0 && conversationUsagePercent >= threshold,
|
|
1253
|
+
contextWarningPending: conversation?.warningPending ?? false,
|
|
1254
|
+
compactedMessageCount: conversation?.compactedMessageCount ?? 0,
|
|
1255
|
+
includedContextBlocks: contextPlan.includedBlockIds.length,
|
|
1256
|
+
omittedContextBlocks: contextPlan.omittedBlockIds.length,
|
|
1257
|
+
degradedContextBlocks: contextPlan.degradedBlockIds.length
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
async prepareConversationContext(input) {
|
|
1261
|
+
const usage = this.getContextUsage({ ...input, taskType: "chat" });
|
|
1262
|
+
const conversation = this.store.getAiConversationContext(input.conversationId, input.workId);
|
|
1263
|
+
if (!usage.compactRecommended) {
|
|
1264
|
+
if (conversation.warningPending)
|
|
1265
|
+
this.store.setAiConversationContextWarning(input.conversationId, false);
|
|
1266
|
+
return { action: "ready", usage: { ...usage, contextWarningPending: false } };
|
|
1267
|
+
}
|
|
1268
|
+
if (!conversation.warningPending) {
|
|
1269
|
+
this.store.setAiConversationContextWarning(input.conversationId, true);
|
|
1270
|
+
return { action: "warn", usage: { ...usage, contextWarningPending: true } };
|
|
1271
|
+
}
|
|
1272
|
+
const compaction = await this.compactConversation(input);
|
|
1273
|
+
const compactedUsage = this.getContextUsage({ ...input, taskType: "chat" });
|
|
1274
|
+
return { action: "compacted", usage: compactedUsage, compaction };
|
|
1275
|
+
}
|
|
1276
|
+
async compactConversation(input) {
|
|
1277
|
+
const conversation = this.store.getAiConversationContext(input.conversationId, input.workId);
|
|
1278
|
+
const { model } = this.resolveModel(input.workId, "chat", input.modelId);
|
|
1279
|
+
const budget = this.contextBudget({ ...input, taskType: "chat", instruction: "" }, model);
|
|
1280
|
+
const recentTokenBudget = Math.max(128, Math.floor(Number(budget.conversationBudgetTokens) * 0.75));
|
|
1281
|
+
let retainedMessageCount = 0;
|
|
1282
|
+
let retainedTokens = 0;
|
|
1283
|
+
for (let index = conversation.messages.length - 1; index >= 0 && retainedMessageCount < 8; index -= 1) {
|
|
1284
|
+
const message = conversation.messages[index];
|
|
1285
|
+
if (!message)
|
|
1286
|
+
continue;
|
|
1287
|
+
const messageTokens = estimateAiTokens(message.content);
|
|
1288
|
+
if (retainedMessageCount >= 2 && retainedTokens + messageTokens > recentTokenBudget)
|
|
1289
|
+
break;
|
|
1290
|
+
retainedMessageCount += 1;
|
|
1291
|
+
retainedTokens += messageTokens;
|
|
1292
|
+
}
|
|
1293
|
+
const targetMessageCount = conversation.compactedMessageCount + Math.max(0, conversation.messages.length - retainedMessageCount);
|
|
1294
|
+
const numberToCompact = targetMessageCount - conversation.compactedMessageCount;
|
|
1295
|
+
if (numberToCompact <= 0) {
|
|
1296
|
+
this.store.setAiConversationContextWarning(input.conversationId, false);
|
|
1297
|
+
return {
|
|
1298
|
+
conversationId: input.conversationId,
|
|
1299
|
+
compactedMessageCount: conversation.compactedMessageCount,
|
|
1300
|
+
retainedMessageCount: conversation.totalMessageCount - conversation.compactedMessageCount,
|
|
1301
|
+
changed: false
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
const transcript = conversation.messages.slice(0, numberToCompact)
|
|
1305
|
+
.map((message) => `[${message.id}] ${message.role === "user" ? "作者" : "助手"}:${message.content}`)
|
|
1306
|
+
.join("\n\n");
|
|
1307
|
+
const source = [conversation.summary ? `已有结构化长期记忆:\n${conversation.summary}` : "", `待压缩对话:\n${transcript}`].filter(Boolean).join("\n\n");
|
|
1308
|
+
const generated = await this.generateTaggedJson({
|
|
1309
|
+
workId: input.workId,
|
|
1310
|
+
taskType: "chat",
|
|
1311
|
+
instruction: [
|
|
1312
|
+
"将下面的历史对话整理为可供后续创作对话继续使用的结构化中文长期记忆。",
|
|
1313
|
+
"输出 JSON 对象,字段必须为 authorGoals、confirmedDecisions、storyFacts、constraints、unresolvedQuestions、importantReferences。",
|
|
1314
|
+
"每个字段都是数组,每项包含 text 和 sourceMessageIds;sourceMessageIds 只能引用输入中方括号内的消息 ID。",
|
|
1315
|
+
"保留作者目标、明确事实、决定、限制、未解决问题和重要引用;删除寒暄、重复表达及已被后文取代的信息。",
|
|
1316
|
+
"合并已有长期记忆时不得丢失仍然有效的项目,无法确定是否失效时继续保留。",
|
|
1317
|
+
source
|
|
1318
|
+
].join("\n\n"),
|
|
1319
|
+
scope: { type: "entities" },
|
|
1320
|
+
modelId: input.modelId,
|
|
1321
|
+
parameters: { temperature: 0.2 },
|
|
1322
|
+
extraSystemPrompt: "你正在执行对话长期记忆整理。不得调用工具,不得回答原问题,只能生成忠实、紧凑且可追溯的结构化记忆。",
|
|
1323
|
+
disableTools: true
|
|
1324
|
+
});
|
|
1325
|
+
const memory = normalizeConversationMemory(extractJson(generated.content));
|
|
1326
|
+
const memoryItemCount = CONVERSATION_MEMORY_FIELDS.reduce((total, field) => total + memory[field].length, 0);
|
|
1327
|
+
if (memoryItemCount === 0)
|
|
1328
|
+
throw new AppError(502, "AI_EMPTY_MEMORY", "AI 返回的对话长期记忆为空");
|
|
1329
|
+
const serializedMemory = JSON.stringify(memory);
|
|
1330
|
+
this.store.saveAiConversationCompaction(input.conversationId, serializedMemory, targetMessageCount);
|
|
1331
|
+
return {
|
|
1332
|
+
conversationId: input.conversationId,
|
|
1333
|
+
compactedMessageCount: targetMessageCount,
|
|
1334
|
+
retainedMessageCount: conversation.totalMessageCount - targetMessageCount,
|
|
1335
|
+
summaryTokens: estimateAiTokens(renderConversationMemory(serializedMemory)),
|
|
1336
|
+
memoryItemCount,
|
|
1337
|
+
changed: true
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
buildMessages(input, context) {
|
|
1341
|
+
const platformPrompt = String(this.store.getPlatformAiSettings().systemPrompt ?? "").trim();
|
|
1342
|
+
const workPrompt = String(this.store.getWorkAiSettings(input.workId).systemPrompt ?? "").trim();
|
|
1343
|
+
const enabledToolIds = this.enabledAgentToolIds(input.workId, input.taskType);
|
|
1344
|
+
const toolGuidance = enabledToolIds.length > 0
|
|
1345
|
+
? [
|
|
1346
|
+
`当前可用作品查询工具:${enabledToolIds.join("、")}。`,
|
|
1347
|
+
"当作者询问当前作品、项目、章节、情节、人物、关系、世界观或设定,而预加载上下文为空或不足时,必须先调用工具主动查询;不得直接声称没有上下文,也不得先要求作者补充本系统已经能够查询的信息。",
|
|
1348
|
+
"整体介绍、作品基本信息、目录或章节定位优先调用 story_index;按关键字定位正文段落时调用 grep;已知章节 ID 且需要原文事实或精确措辞时调用 read_chapters;查询设定、人物、组织、时间线、关系、大纲或伏笔时调用 query_story_knowledge。",
|
|
1349
|
+
"根据问题选择最少且必要的工具。工具结果仍不足时才说明未知,并明确已经查询过什么;不要重复无效调用。"
|
|
1350
|
+
].join("\n")
|
|
1351
|
+
: "";
|
|
1352
|
+
const systemPrompt = [
|
|
1353
|
+
"你是小说作者的创作协作助手。作者锁定的事实是不可违反的硬约束。",
|
|
1354
|
+
"只根据提供的正文和设定回答;不确定时明确说明,不得把推测当成事实。",
|
|
1355
|
+
"引用事实时注明章节或设定名称。不要声称已经修改正文。",
|
|
1356
|
+
toolGuidance,
|
|
1357
|
+
platformPrompt ? `平台全局追加系统提示词:\n${platformPrompt}` : "",
|
|
1358
|
+
workPrompt ? `本书追加系统提示词:\n${workPrompt}` : "",
|
|
1359
|
+
input.extraSystemPrompt ?? ""
|
|
1360
|
+
].filter(Boolean).join("\n\n");
|
|
1361
|
+
const renderedContext = context.trim() || (enabledToolIds.length > 0
|
|
1362
|
+
? "[本轮未预加载作品上下文。若问题涉及当前作品,请先使用已启用的作品查询工具主动获取信息。]"
|
|
1363
|
+
: "[本轮未提供作品上下文。]");
|
|
1364
|
+
const conversation = input.conversationId
|
|
1365
|
+
? this.store.getAiConversationContext(input.conversationId, input.workId, input.excludeConversationMessageId)
|
|
1366
|
+
: null;
|
|
1367
|
+
if (!conversation) {
|
|
1368
|
+
return [
|
|
1369
|
+
{ role: "system", content: systemPrompt },
|
|
1370
|
+
{ role: "user", content: `上下文如下:\n\n${renderedContext}\n\n作者指令:\n${input.instruction}` }
|
|
1371
|
+
];
|
|
1372
|
+
}
|
|
1373
|
+
const conversationMessages = conversation?.messages.map((message) => ({ role: message.role, content: message.content })) ?? [];
|
|
1374
|
+
return [
|
|
1375
|
+
{ role: "system", content: systemPrompt },
|
|
1376
|
+
...(conversation?.summary ? [{ role: "system", content: `较早对话的结构化长期记忆:\n${renderConversationMemory(conversation.summary)}` }] : []),
|
|
1377
|
+
{ role: "user", content: `本次创作上下文如下:\n\n${renderedContext}` },
|
|
1378
|
+
...conversationMessages,
|
|
1379
|
+
{ role: "user", content: `作者当前指令:\n${input.instruction}` }
|
|
1380
|
+
];
|
|
1381
|
+
}
|
|
1382
|
+
buildContextPlan(input, model, existingBudget) {
|
|
1383
|
+
const budget = existingBudget ?? this.contextBudget(input, model);
|
|
1384
|
+
const contextWindow = numberValue(model, "context_window") || DEFAULT_CONTEXT_WINDOW;
|
|
1385
|
+
const settings = this.store.getWorkAiSettings(input.workId);
|
|
1386
|
+
const percentage = Math.min(90, Math.max(1, Number(settings.bookSummaryContextPercent) || 50));
|
|
1387
|
+
const workContextBudgetTokens = Number(budget.workContextBudgetTokens) || 256;
|
|
1388
|
+
const bookSummaryMaximumTokens = input.scope.includeBookSummary || input.scope.type === "book" || input.scope.type === "volume"
|
|
1389
|
+
? Math.max(32, Math.min(Math.floor(contextWindow * percentage / 100), Math.floor(workContextBudgetTokens * 0.45)))
|
|
1390
|
+
: undefined;
|
|
1391
|
+
return this.contextBuilder.buildPlan(input.workId, input.scope, workContextBudgetTokens, bookSummaryMaximumTokens, input.instruction);
|
|
1392
|
+
}
|
|
1393
|
+
buildContext(input, model) {
|
|
1394
|
+
return this.buildContextPlan(input, model).context;
|
|
1395
|
+
}
|
|
1396
|
+
enabledAgentToolIds(workId, taskType) {
|
|
1397
|
+
if (taskType !== "chat")
|
|
1398
|
+
return [];
|
|
1399
|
+
const enabled = new Set(this.store.getWorkAiSettings(workId).agentTools
|
|
1400
|
+
.filter((item) => typeof item === "string" && AGENT_TOOL_IDS.includes(item)));
|
|
1401
|
+
return AGENT_TOOL_IDS.filter((toolId) => enabled.has(toolId));
|
|
1402
|
+
}
|
|
1403
|
+
enabledAgentTools(workId, taskType) {
|
|
1404
|
+
return this.enabledAgentToolIds(workId, taskType).map((toolId) => AGENT_TOOL_DEFINITIONS[toolId]);
|
|
1405
|
+
}
|
|
1406
|
+
executeAgentTool(workId, toolCall) {
|
|
1407
|
+
const name = toolCall.function.name;
|
|
1408
|
+
const calledAt = now();
|
|
1409
|
+
let rawArguments = toolCall.function.arguments;
|
|
1410
|
+
if (typeof rawArguments === "string") {
|
|
1411
|
+
try {
|
|
1412
|
+
rawArguments = JSON.parse(rawArguments);
|
|
1413
|
+
}
|
|
1414
|
+
catch {
|
|
1415
|
+
return {
|
|
1416
|
+
id: toolCall.id,
|
|
1417
|
+
name,
|
|
1418
|
+
calledAt,
|
|
1419
|
+
arguments: null,
|
|
1420
|
+
status: "failed",
|
|
1421
|
+
result: { ok: false, error: { code: "TOOL_ARGUMENTS_INVALID_JSON", message: `Invalid arguments for ${name}: expected a JSON object.` } }
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
const suppliedArguments = rawArguments && typeof rawArguments === "object" && !Array.isArray(rawArguments)
|
|
1426
|
+
? rawArguments
|
|
1427
|
+
: null;
|
|
1428
|
+
const schema = name === "story_index" ? storyIndexArguments
|
|
1429
|
+
: name === "read_chapters" ? readChaptersArguments
|
|
1430
|
+
: name === "grep" ? grepArguments
|
|
1431
|
+
: name === "query_story_knowledge" ? queryStoryKnowledgeArguments
|
|
1432
|
+
: null;
|
|
1433
|
+
if (!schema) {
|
|
1434
|
+
return {
|
|
1435
|
+
id: toolCall.id,
|
|
1436
|
+
name,
|
|
1437
|
+
calledAt,
|
|
1438
|
+
arguments: suppliedArguments,
|
|
1439
|
+
status: "failed",
|
|
1440
|
+
result: { ok: false, error: { code: "TOOL_NOT_AVAILABLE", message: `Tool '${name}' is not available for this request.` } }
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
const parsed = schema.safeParse(suppliedArguments);
|
|
1444
|
+
if (!parsed.success) {
|
|
1445
|
+
const details = parsed.error.issues.map((issue) => `${issue.path.join(".") || "arguments"}: ${issue.message}`).join("; ");
|
|
1446
|
+
return {
|
|
1447
|
+
id: toolCall.id,
|
|
1448
|
+
name,
|
|
1449
|
+
calledAt,
|
|
1450
|
+
arguments: suppliedArguments,
|
|
1451
|
+
status: "failed",
|
|
1452
|
+
result: { ok: false, error: { code: "TOOL_ARGUMENTS_INVALID", message: `Invalid arguments for ${name}: ${details}` } }
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
const args = parsed.data;
|
|
1456
|
+
if (name === "story_index") {
|
|
1457
|
+
const { offset, limit } = args;
|
|
1458
|
+
const work = this.store.getWork(workId);
|
|
1459
|
+
const tree = this.store.getWorkTree(workId);
|
|
1460
|
+
const summaries = new Map(this.store.listCurrentChapterInsights(workId).map((item) => [String(item.chapterId), String(item.summary)]));
|
|
1461
|
+
const chapters = tree.volumes.flatMap((volume) => volume.chapters.map((chapter) => ({
|
|
1462
|
+
id: String(chapter.id), volumeTitle: String(volume.title), title: String(chapter.title), versionNo: Number(chapter.versionNo), summary: summaries.get(String(chapter.id)) ?? ""
|
|
1463
|
+
})));
|
|
1464
|
+
return {
|
|
1465
|
+
id: toolCall.id,
|
|
1466
|
+
name,
|
|
1467
|
+
calledAt,
|
|
1468
|
+
arguments: { offset, limit },
|
|
1469
|
+
status: "completed",
|
|
1470
|
+
result: {
|
|
1471
|
+
ok: true,
|
|
1472
|
+
data: {
|
|
1473
|
+
work: {
|
|
1474
|
+
id: work.id,
|
|
1475
|
+
title: work.title,
|
|
1476
|
+
author: work.author,
|
|
1477
|
+
description: work.description,
|
|
1478
|
+
language: work.language,
|
|
1479
|
+
tags: work.tags,
|
|
1480
|
+
chapterCount: work.chapterCount,
|
|
1481
|
+
wordCount: work.wordCount
|
|
1482
|
+
},
|
|
1483
|
+
totalChapters: chapters.length,
|
|
1484
|
+
offset,
|
|
1485
|
+
chapters: chapters.slice(offset, offset + limit),
|
|
1486
|
+
nextOffset: offset + limit < chapters.length ? offset + limit : null
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
if (name === "read_chapters") {
|
|
1492
|
+
const { chapterIds, include } = args;
|
|
1493
|
+
const summaries = new Map(this.store.listCurrentChapterInsights(workId).map((item) => [String(item.chapterId), String(item.summary)]));
|
|
1494
|
+
let remainingChars = 36_000;
|
|
1495
|
+
const chapters = chapterIds.map((chapterId) => {
|
|
1496
|
+
try {
|
|
1497
|
+
const chapter = this.store.getChapter(chapterId);
|
|
1498
|
+
if (chapter.workId !== workId)
|
|
1499
|
+
return { chapterId, error: { code: "CHAPTER_WORK_MISMATCH", message: "The requested chapter belongs to a different work." } };
|
|
1500
|
+
const content = String(chapter.content);
|
|
1501
|
+
const excerpt = content.slice(0, Math.max(0, remainingChars));
|
|
1502
|
+
remainingChars -= excerpt.length;
|
|
1503
|
+
return { chapterId, title: chapter.title, versionNo: chapter.versionNo, ...(include !== "content" ? { summary: summaries.get(chapterId) ?? "" } : {}), ...(include !== "summary" ? { content: excerpt, contentTruncated: excerpt.length < content.length } : {}) };
|
|
1504
|
+
}
|
|
1505
|
+
catch {
|
|
1506
|
+
return { chapterId, error: { code: "CHAPTER_NOT_FOUND", message: "The requested chapter was not found." } };
|
|
1507
|
+
}
|
|
1508
|
+
});
|
|
1509
|
+
return { id: toolCall.id, name, calledAt, arguments: { chapterIds, include }, status: "completed", result: { ok: true, data: { chapters, contentLimitChars: 36_000 } } };
|
|
1510
|
+
}
|
|
1511
|
+
if (name === "grep") {
|
|
1512
|
+
const { keyword, limit } = args;
|
|
1513
|
+
const matches = this.store.searchChapterParagraphs(workId, keyword, limit);
|
|
1514
|
+
return {
|
|
1515
|
+
id: toolCall.id,
|
|
1516
|
+
name,
|
|
1517
|
+
calledAt,
|
|
1518
|
+
arguments: { keyword, limit },
|
|
1519
|
+
status: "completed",
|
|
1520
|
+
result: { ok: true, data: { keyword, limit, matches } }
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
if (name === "query_story_knowledge") {
|
|
1524
|
+
const { query, categories: categoryList } = args;
|
|
1525
|
+
const categories = new Set(categoryList);
|
|
1526
|
+
const allowed = new Set(["setting", "character", "race", "organization", "timeline", "relationship", "outline", "foreshadow"]);
|
|
1527
|
+
const matches = this.store.search(workId, query).filter((item) => !categories.size || categories.has(String(item.type))).slice(0, 20);
|
|
1528
|
+
const extra = [
|
|
1529
|
+
...this.store.listTimelineEvents(workId).map((item) => ({ type: "timeline", id: item.id, title: item.name, snippet: `${item.description} ${item.timeLabel}` })),
|
|
1530
|
+
...this.store.listRelationships(workId).map((item) => ({ type: "relationship", id: item.id, title: `${item.fromCharacterId} / ${item.toCharacterId}`, snippet: `${item.category} ${item.subtype} ${item.keywords.join(" ")}` })),
|
|
1531
|
+
...this.store.listChapterOutlines(workId).map((item) => ({ type: "outline", id: item.chapterId, title: item.chapterTitle, snippet: `${item.goal} ${item.conflict} ${item.turningPoint} ${item.notes}` })),
|
|
1532
|
+
...this.store.listForeshadows(workId).map((item) => ({ type: "foreshadow", id: item.id, title: item.title, snippet: `${item.description} ${item.resolutionNote}` }))
|
|
1533
|
+
].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);
|
|
1534
|
+
return { id: toolCall.id, name, calledAt, arguments: { query, categories: categoryList }, status: "completed", result: { ok: true, data: { query, matches: [...matches, ...extra].slice(0, 30) } } };
|
|
1535
|
+
}
|
|
1536
|
+
throw new Error(`Unhandled agent tool: ${name}`);
|
|
1537
|
+
}
|
|
1538
|
+
constrainParametersForContext(model, messages, parameters) {
|
|
1539
|
+
const contextWindow = numberValue(model, "context_window") || DEFAULT_CONTEXT_WINDOW;
|
|
1540
|
+
const inputTokens = messages.reduce((total, message) => total + estimateAiTokens(message.content), 0);
|
|
1541
|
+
if (inputTokens >= contextWindow) {
|
|
1542
|
+
throw new AppError(400, "CONTEXT_WINDOW_EXCEEDED", `当前上下文约 ${inputTokens} Token,已超过模型 ${contextWindow} Token 的上下文容量`);
|
|
1543
|
+
}
|
|
1544
|
+
return {
|
|
1545
|
+
...parameters,
|
|
1546
|
+
max_tokens: Math.min(Number(parameters.max_tokens) || DEFAULT_MAX_TOKENS, contextWindow - inputTokens)
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
generateTaggedJson(input) {
|
|
1550
|
+
const userRequirement = "将最终 JSON 放在唯一一对 <json> 和 </json> 标签中;标签外不要输出任何内容,也不要使用 Markdown 代码块。";
|
|
1551
|
+
const systemRequirement = "结构化响应要求:最终 JSON 必须且只能放在唯一一对 <json> 和 </json> 标签中。";
|
|
1552
|
+
return this.generate({
|
|
1553
|
+
...input,
|
|
1554
|
+
instruction: `${input.instruction}\n${userRequirement}`,
|
|
1555
|
+
extraSystemPrompt: [input.extraSystemPrompt, systemRequirement].filter(Boolean).join("\n")
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
async generate(input) {
|
|
1559
|
+
const { model, provider } = this.resolveModel(input.workId, input.taskType, input.modelId);
|
|
1560
|
+
const context = this.buildContext(input, model);
|
|
1561
|
+
const preset = safeJsonObject(stringValue(model, "preset_json"));
|
|
1562
|
+
const messages = this.buildMessages(input, context);
|
|
1563
|
+
const tools = input.disableTools ? [] : this.enabledAgentTools(input.workId, input.taskType);
|
|
1564
|
+
const completionMessages = [...messages];
|
|
1565
|
+
const parameters = this.constrainParametersForContext(model, messages, {
|
|
1566
|
+
...this.sanitizeParameters({ ...preset, ...(input.parameters ?? {}), max_tokens: numberValue(provider, "max_tokens") || DEFAULT_MAX_TOKENS }),
|
|
1567
|
+
thinking: { type: boolValue(model, "thinking_enabled") ? "enabled" : "disabled" }
|
|
1568
|
+
});
|
|
1569
|
+
const callId = id("call");
|
|
1570
|
+
const timestamp = now();
|
|
1571
|
+
this.store.db.run(`INSERT INTO ai_calls (id, work_id, task_type, provider_id, model_id, context_scope_json, parameters_json,
|
|
1572
|
+
status, input_chars, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?)`, callId, input.workId, input.taskType, stringValue(provider, "id"), stringValue(model, "id"), JSON.stringify(input.scope), JSON.stringify(parameters), context.length + input.instruction.length, timestamp, currentRequestActor()?.userId ?? null);
|
|
1573
|
+
const callStartedAt = process.hrtime.bigint();
|
|
1574
|
+
logger.info("ai.call.started", {
|
|
1575
|
+
callId,
|
|
1576
|
+
workId: input.workId,
|
|
1577
|
+
taskType: input.taskType,
|
|
1578
|
+
providerId: stringValue(provider, "id"),
|
|
1579
|
+
modelId: stringValue(model, "id"),
|
|
1580
|
+
streaming: false,
|
|
1581
|
+
contextChars: context.length,
|
|
1582
|
+
instructionChars: input.instruction.length,
|
|
1583
|
+
toolCount: tools.length
|
|
1584
|
+
});
|
|
1585
|
+
try {
|
|
1586
|
+
const apiKey = this.decryptKey(provider);
|
|
1587
|
+
const endpoint = `${normalizeBaseUrl(stringValue(provider, "base_url"))}/chat/completions`;
|
|
1588
|
+
const timeoutMs = input.taskType === "book-analysis" || input.taskType === "relationship-analysis" ? 300_000 : 60_000;
|
|
1589
|
+
const maximumAttempts = Math.round(clamp(input.maxAttempts ?? 3, 1, 5));
|
|
1590
|
+
const requestCompletion = async (toolChoice) => {
|
|
1591
|
+
let lastFailure = null;
|
|
1592
|
+
for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
|
|
1593
|
+
let retryable = true;
|
|
1594
|
+
const attemptStartedAt = process.hrtime.bigint();
|
|
1595
|
+
logger.info("ai.call.attempt_started", { callId, attempt, maximumAttempts, toolChoice });
|
|
1596
|
+
try {
|
|
1597
|
+
const candidate = await this.scheduleProviderRequest(provider, input.signal, async () => {
|
|
1598
|
+
const controller = new AbortController();
|
|
1599
|
+
const forwardAbort = () => controller.abort(input.signal?.reason);
|
|
1600
|
+
if (input.signal?.aborted)
|
|
1601
|
+
forwardAbort();
|
|
1602
|
+
else
|
|
1603
|
+
input.signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
1604
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
1605
|
+
try {
|
|
1606
|
+
const response = await this.outboundFetch(endpoint, {
|
|
1607
|
+
method: "POST",
|
|
1608
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
1609
|
+
body: JSON.stringify({ model: stringValue(model, "model_id"), messages: completionMessages, ...parameters, ...(tools.length ? { tools, tool_choice: toolChoice } : {}) }),
|
|
1610
|
+
signal: controller.signal
|
|
1611
|
+
});
|
|
1612
|
+
return { ok: response.ok, status: response.status, body: await response.text() };
|
|
1613
|
+
}
|
|
1614
|
+
finally {
|
|
1615
|
+
clearTimeout(timeout);
|
|
1616
|
+
input.signal?.removeEventListener("abort", forwardAbort);
|
|
1617
|
+
}
|
|
1618
|
+
});
|
|
1619
|
+
logger.info("ai.call.attempt_completed", {
|
|
1620
|
+
callId,
|
|
1621
|
+
attempt,
|
|
1622
|
+
status: candidate.status,
|
|
1623
|
+
ok: candidate.ok,
|
|
1624
|
+
durationMs: Number(process.hrtime.bigint() - attemptStartedAt) / 1_000_000
|
|
1625
|
+
});
|
|
1626
|
+
if (candidate.ok) {
|
|
1627
|
+
try {
|
|
1628
|
+
return JSON.parse(candidate.body);
|
|
1629
|
+
}
|
|
1630
|
+
catch {
|
|
1631
|
+
throw new Error(`Chat Completions returned invalid JSON: ${candidate.body.slice(0, 500)}`);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
lastFailure = new Error(`HTTP ${candidate.status}: ${candidate.body.slice(0, 500)}`);
|
|
1635
|
+
if (candidate.status !== 429 && candidate.status < 500) {
|
|
1636
|
+
retryable = false;
|
|
1637
|
+
throw lastFailure;
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
catch (error) {
|
|
1641
|
+
lastFailure = error;
|
|
1642
|
+
logger.warn("ai.call.attempt_failed", {
|
|
1643
|
+
callId,
|
|
1644
|
+
attempt,
|
|
1645
|
+
retryable: retryable && attempt < maximumAttempts && !input.signal?.aborted,
|
|
1646
|
+
durationMs: Number(process.hrtime.bigint() - attemptStartedAt) / 1_000_000,
|
|
1647
|
+
error: aiErrorForLog(error)
|
|
1648
|
+
});
|
|
1649
|
+
if (input.signal?.aborted)
|
|
1650
|
+
throw error;
|
|
1651
|
+
if (!retryable || attempt >= maximumAttempts)
|
|
1652
|
+
throw error;
|
|
1653
|
+
}
|
|
1654
|
+
if (attempt < maximumAttempts)
|
|
1655
|
+
await new Promise((resolve) => setTimeout(resolve, attempt * 1200));
|
|
1656
|
+
}
|
|
1657
|
+
throw lastFailure instanceof Error ? lastFailure : new Error("AI request failed after all retries.");
|
|
1658
|
+
};
|
|
1659
|
+
let payload = await requestCompletion("auto");
|
|
1660
|
+
let choice = payload.choices?.[0];
|
|
1661
|
+
const executedToolCalls = [];
|
|
1662
|
+
const processSteps = [];
|
|
1663
|
+
const recordChoiceProcess = (currentChoice, round, includeIntermediate) => {
|
|
1664
|
+
const reasoning = currentChoice?.message?.reasoning_content;
|
|
1665
|
+
if (reasoning?.trim()) {
|
|
1666
|
+
const step = { id: id("process"), type: "thinking", round, content: reasoning, createdAt: now() };
|
|
1667
|
+
processSteps.push(step);
|
|
1668
|
+
input.onProcessStep?.(step);
|
|
1669
|
+
}
|
|
1670
|
+
const intermediate = currentChoice?.message?.content;
|
|
1671
|
+
if (includeIntermediate && intermediate?.trim()) {
|
|
1672
|
+
const step = { id: id("process"), type: "intermediate", round, content: intermediate, createdAt: now() };
|
|
1673
|
+
processSteps.push(step);
|
|
1674
|
+
input.onProcessStep?.(step);
|
|
1675
|
+
}
|
|
1676
|
+
};
|
|
1677
|
+
let toolRound = 0;
|
|
1678
|
+
while (choice?.message?.tool_calls?.length) {
|
|
1679
|
+
const round = toolRound + 1;
|
|
1680
|
+
recordChoiceProcess(choice, round, true);
|
|
1681
|
+
const toolCalls = choice.message.tool_calls;
|
|
1682
|
+
if (executedToolCalls.length + toolCalls.length > MAX_AGENT_TOOL_CALLS) {
|
|
1683
|
+
throw new Error(`AI requested more than ${MAX_AGENT_TOOL_CALLS} tool calls in one response cycle.`);
|
|
1684
|
+
}
|
|
1685
|
+
const normalizedToolCalls = toolCalls.map((toolCall) => ({
|
|
1686
|
+
...toolCall,
|
|
1687
|
+
function: {
|
|
1688
|
+
...toolCall.function,
|
|
1689
|
+
arguments: typeof toolCall.function.arguments === "string" ? toolCall.function.arguments : JSON.stringify(toolCall.function.arguments ?? {})
|
|
1690
|
+
}
|
|
1691
|
+
}));
|
|
1692
|
+
completionMessages.push({
|
|
1693
|
+
role: "assistant",
|
|
1694
|
+
content: choice.message.content ?? null,
|
|
1695
|
+
reasoning_content: choice.message.reasoning_content ?? null,
|
|
1696
|
+
tool_calls: normalizedToolCalls
|
|
1697
|
+
});
|
|
1698
|
+
for (const toolCall of toolCalls) {
|
|
1699
|
+
const execution = this.executeAgentTool(input.workId, toolCall);
|
|
1700
|
+
logger.info("ai.tool_call.completed", { callId, toolName: execution.name, status: execution.status, round });
|
|
1701
|
+
executedToolCalls.push(execution);
|
|
1702
|
+
processSteps.push({ id: id("process"), type: "tool", round, toolCall: execution, createdAt: execution.calledAt });
|
|
1703
|
+
input.onToolCall?.(execution, round);
|
|
1704
|
+
completionMessages.push({ role: "tool", tool_call_id: toolCall.id, content: JSON.stringify(execution.result) });
|
|
1705
|
+
}
|
|
1706
|
+
toolRound += 1;
|
|
1707
|
+
const forceFinalAnswer = toolRound >= MAX_AGENT_TOOL_ROUNDS;
|
|
1708
|
+
payload = await requestCompletion(forceFinalAnswer ? "none" : "auto");
|
|
1709
|
+
choice = payload.choices?.[0];
|
|
1710
|
+
if (forceFinalAnswer && choice?.message?.tool_calls?.length) {
|
|
1711
|
+
throw new Error(`AI returned tool calls after tool_choice was set to none at the ${MAX_AGENT_TOOL_ROUNDS}-round safety limit.`);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
recordChoiceProcess(choice, toolRound + 1, false);
|
|
1715
|
+
const content = choice?.message?.content;
|
|
1716
|
+
if (!content?.trim()) {
|
|
1717
|
+
const reasoningLength = choice?.message?.reasoning_content?.length ?? 0;
|
|
1718
|
+
const suffix = choice?.finish_reason === "length" || reasoningLength > 0
|
|
1719
|
+
? `;模型已生成 ${reasoningLength} 个推理字符,请提高 max_tokens 输出预算`
|
|
1720
|
+
: "";
|
|
1721
|
+
throw new Error(`Chat Completions 响应缺少可用正文,finish_reason=${choice?.finish_reason ?? "unknown"}${suffix}`);
|
|
1722
|
+
}
|
|
1723
|
+
this.store.db.run("UPDATE ai_calls SET status = 'completed', output_chars = ?, completed_at = ? WHERE id = ?", content.length, now(), callId);
|
|
1724
|
+
const outputTokens = resolveOutputTokens(payload.usage, content);
|
|
1725
|
+
logger.info("ai.call.completed", {
|
|
1726
|
+
callId,
|
|
1727
|
+
workId: input.workId,
|
|
1728
|
+
taskType: input.taskType,
|
|
1729
|
+
streaming: false,
|
|
1730
|
+
durationMs: Number(process.hrtime.bigint() - callStartedAt) / 1_000_000,
|
|
1731
|
+
outputChars: content.length,
|
|
1732
|
+
outputTokens,
|
|
1733
|
+
toolCallCount: executedToolCalls.length
|
|
1734
|
+
});
|
|
1735
|
+
return { callId, content, outputTokens, provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: executedToolCalls, processSteps };
|
|
1736
|
+
}
|
|
1737
|
+
catch (error) {
|
|
1738
|
+
const message = error instanceof Error ? error.message : "AI 调用失败";
|
|
1739
|
+
this.store.db.run("UPDATE ai_calls SET status = 'failed', failure = ?, completed_at = ? WHERE id = ?", message, now(), callId);
|
|
1740
|
+
logger.error("ai.call.failed", {
|
|
1741
|
+
callId,
|
|
1742
|
+
workId: input.workId,
|
|
1743
|
+
taskType: input.taskType,
|
|
1744
|
+
streaming: false,
|
|
1745
|
+
durationMs: Number(process.hrtime.bigint() - callStartedAt) / 1_000_000,
|
|
1746
|
+
error: aiErrorForLog(error)
|
|
1747
|
+
});
|
|
1748
|
+
throw new AppError(502, "AI_CALL_FAILED", "AI 调用失败", { callId, failure: message });
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
async generateStream(input, onDelta) {
|
|
1752
|
+
const { model, provider } = this.resolveModel(input.workId, input.taskType, input.modelId);
|
|
1753
|
+
const context = this.buildContext(input, model);
|
|
1754
|
+
const preset = safeJsonObject(stringValue(model, "preset_json"));
|
|
1755
|
+
const messages = this.buildMessages(input, context);
|
|
1756
|
+
const parameters = this.constrainParametersForContext(model, messages, {
|
|
1757
|
+
...this.sanitizeParameters({ ...preset, ...(input.parameters ?? {}), max_tokens: numberValue(provider, "max_tokens") || DEFAULT_MAX_TOKENS }),
|
|
1758
|
+
thinking: { type: boolValue(model, "thinking_enabled") ? "enabled" : "disabled" }
|
|
1759
|
+
});
|
|
1760
|
+
const callId = id("call");
|
|
1761
|
+
this.store.db.run(`INSERT INTO ai_calls (id, work_id, task_type, provider_id, model_id, context_scope_json, parameters_json,
|
|
1762
|
+
status, input_chars, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?)`, callId, input.workId, input.taskType, stringValue(provider, "id"), stringValue(model, "id"), JSON.stringify(input.scope), JSON.stringify(parameters), context.length + input.instruction.length, now(), currentRequestActor()?.userId ?? null);
|
|
1763
|
+
const callStartedAt = process.hrtime.bigint();
|
|
1764
|
+
logger.info("ai.call.started", {
|
|
1765
|
+
callId,
|
|
1766
|
+
workId: input.workId,
|
|
1767
|
+
taskType: input.taskType,
|
|
1768
|
+
providerId: stringValue(provider, "id"),
|
|
1769
|
+
modelId: stringValue(model, "id"),
|
|
1770
|
+
streaming: true,
|
|
1771
|
+
contextChars: context.length,
|
|
1772
|
+
instructionChars: input.instruction.length
|
|
1773
|
+
});
|
|
1774
|
+
try {
|
|
1775
|
+
const apiKey = this.decryptKey(provider);
|
|
1776
|
+
const endpoint = `${normalizeBaseUrl(stringValue(provider, "base_url"))}/chat/completions`;
|
|
1777
|
+
const maximumAttempts = Math.round(clamp(input.maxAttempts ?? 3, 1, 5));
|
|
1778
|
+
let streamedResult = null;
|
|
1779
|
+
let lastFailure = null;
|
|
1780
|
+
let emitted = false;
|
|
1781
|
+
const thinkingStepId = id("process");
|
|
1782
|
+
const thinkingCreatedAt = now();
|
|
1783
|
+
for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
|
|
1784
|
+
const attemptStartedAt = process.hrtime.bigint();
|
|
1785
|
+
logger.info("ai.call.attempt_started", { callId, attempt, maximumAttempts, streaming: true });
|
|
1786
|
+
try {
|
|
1787
|
+
const candidate = await this.scheduleProviderRequest(provider, input.signal, async () => {
|
|
1788
|
+
const controller = new AbortController();
|
|
1789
|
+
const forwardAbort = () => controller.abort(input.signal?.reason);
|
|
1790
|
+
if (input.signal?.aborted)
|
|
1791
|
+
forwardAbort();
|
|
1792
|
+
else
|
|
1793
|
+
input.signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
1794
|
+
const timeout = setTimeout(() => controller.abort(), 60_000);
|
|
1795
|
+
try {
|
|
1796
|
+
const response = await this.outboundFetch(endpoint, {
|
|
1797
|
+
method: "POST",
|
|
1798
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", Accept: "text/event-stream" },
|
|
1799
|
+
body: JSON.stringify({ model: stringValue(model, "model_id"), messages, ...parameters, stream: true, stream_options: { include_usage: true } }),
|
|
1800
|
+
signal: controller.signal
|
|
1801
|
+
});
|
|
1802
|
+
if (!response.ok)
|
|
1803
|
+
return { ok: false, status: response.status, body: await response.text() };
|
|
1804
|
+
const streamed = await this.readCompletionStream(response, (delta) => {
|
|
1805
|
+
emitted = true;
|
|
1806
|
+
onDelta(delta);
|
|
1807
|
+
}, (delta) => {
|
|
1808
|
+
emitted = true;
|
|
1809
|
+
input.onProcessStep?.({ id: thinkingStepId, type: "thinking", round: 1, content: delta, createdAt: thinkingCreatedAt, append: true });
|
|
1810
|
+
});
|
|
1811
|
+
return { ok: true, status: response.status, result: streamed };
|
|
1812
|
+
}
|
|
1813
|
+
finally {
|
|
1814
|
+
clearTimeout(timeout);
|
|
1815
|
+
input.signal?.removeEventListener("abort", forwardAbort);
|
|
1816
|
+
}
|
|
1817
|
+
});
|
|
1818
|
+
logger.info("ai.call.attempt_completed", {
|
|
1819
|
+
callId,
|
|
1820
|
+
attempt,
|
|
1821
|
+
status: candidate.status,
|
|
1822
|
+
ok: candidate.ok,
|
|
1823
|
+
durationMs: Number(process.hrtime.bigint() - attemptStartedAt) / 1_000_000,
|
|
1824
|
+
streaming: true
|
|
1825
|
+
});
|
|
1826
|
+
if (candidate.ok) {
|
|
1827
|
+
streamedResult = candidate.result;
|
|
1828
|
+
break;
|
|
1829
|
+
}
|
|
1830
|
+
lastFailure = new Error(`HTTP ${candidate.status}: ${candidate.body.slice(0, 500)}`);
|
|
1831
|
+
if (candidate.status !== 429 && candidate.status < 500)
|
|
1832
|
+
attempt = maximumAttempts;
|
|
1833
|
+
}
|
|
1834
|
+
catch (error) {
|
|
1835
|
+
lastFailure = error;
|
|
1836
|
+
logger.warn("ai.call.attempt_failed", {
|
|
1837
|
+
callId,
|
|
1838
|
+
attempt,
|
|
1839
|
+
retryable: !input.signal?.aborted && !emitted && attempt < maximumAttempts,
|
|
1840
|
+
durationMs: Number(process.hrtime.bigint() - attemptStartedAt) / 1_000_000,
|
|
1841
|
+
streaming: true,
|
|
1842
|
+
error: aiErrorForLog(error)
|
|
1843
|
+
});
|
|
1844
|
+
if (input.signal?.aborted || emitted || attempt >= maximumAttempts)
|
|
1845
|
+
throw error;
|
|
1846
|
+
}
|
|
1847
|
+
if (attempt < maximumAttempts)
|
|
1848
|
+
await new Promise((resolve) => setTimeout(resolve, attempt * 1200));
|
|
1849
|
+
}
|
|
1850
|
+
if (streamedResult === null)
|
|
1851
|
+
throw lastFailure instanceof Error ? lastFailure : new Error("AI 流式请求重试后仍未返回响应");
|
|
1852
|
+
const { content, reasoning, outputTokens } = streamedResult;
|
|
1853
|
+
const processSteps = reasoning.trim()
|
|
1854
|
+
? [{ id: thinkingStepId, type: "thinking", round: 1, content: reasoning, createdAt: thinkingCreatedAt }]
|
|
1855
|
+
: [];
|
|
1856
|
+
this.store.db.run("UPDATE ai_calls SET status = 'completed', output_chars = ?, completed_at = ? WHERE id = ?", content.length, now(), callId);
|
|
1857
|
+
logger.info("ai.call.completed", {
|
|
1858
|
+
callId,
|
|
1859
|
+
workId: input.workId,
|
|
1860
|
+
taskType: input.taskType,
|
|
1861
|
+
streaming: true,
|
|
1862
|
+
durationMs: Number(process.hrtime.bigint() - callStartedAt) / 1_000_000,
|
|
1863
|
+
outputChars: content.length,
|
|
1864
|
+
outputTokens
|
|
1865
|
+
});
|
|
1866
|
+
return { callId, content, outputTokens, provider: this.mapProvider(provider), model: this.mapModel(model), context, toolCalls: [], processSteps };
|
|
1867
|
+
}
|
|
1868
|
+
catch (error) {
|
|
1869
|
+
const message = error instanceof Error ? error.message : "AI 流式调用失败";
|
|
1870
|
+
this.store.db.run("UPDATE ai_calls SET status = 'failed', failure = ?, completed_at = ? WHERE id = ?", message, now(), callId);
|
|
1871
|
+
logger.error("ai.call.failed", {
|
|
1872
|
+
callId,
|
|
1873
|
+
workId: input.workId,
|
|
1874
|
+
taskType: input.taskType,
|
|
1875
|
+
streaming: true,
|
|
1876
|
+
durationMs: Number(process.hrtime.bigint() - callStartedAt) / 1_000_000,
|
|
1877
|
+
error: aiErrorForLog(error)
|
|
1878
|
+
});
|
|
1879
|
+
throw new AppError(502, "AI_CALL_FAILED", "AI 调用失败", { callId, failure: message });
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
async readCompletionStream(response, onDelta, onThinkingDelta) {
|
|
1883
|
+
if (!response.body)
|
|
1884
|
+
throw new Error("Chat Completions 流式响应缺少正文");
|
|
1885
|
+
const reader = response.body.getReader();
|
|
1886
|
+
const decoder = new TextDecoder();
|
|
1887
|
+
let buffer = "";
|
|
1888
|
+
let content = "";
|
|
1889
|
+
let reasoning = "";
|
|
1890
|
+
let finishReason = "unknown";
|
|
1891
|
+
let usage = null;
|
|
1892
|
+
const consumeEvent = (eventText) => {
|
|
1893
|
+
const data = eventText.split(/\r?\n/u)
|
|
1894
|
+
.filter((line) => line.startsWith("data:"))
|
|
1895
|
+
.map((line) => line.slice(5).trimStart())
|
|
1896
|
+
.join("\n")
|
|
1897
|
+
.trim();
|
|
1898
|
+
if (!data || data === "[DONE]")
|
|
1899
|
+
return;
|
|
1900
|
+
const payload = JSON.parse(data);
|
|
1901
|
+
if (payload.error)
|
|
1902
|
+
throw new Error(payload.error.message || "上游流式响应返回错误");
|
|
1903
|
+
if (payload.usage)
|
|
1904
|
+
usage = payload.usage;
|
|
1905
|
+
const choice = payload.choices?.[0];
|
|
1906
|
+
if (choice?.finish_reason)
|
|
1907
|
+
finishReason = choice.finish_reason;
|
|
1908
|
+
const thinkingDelta = choice?.delta?.reasoning_content;
|
|
1909
|
+
if (typeof thinkingDelta === "string" && thinkingDelta.length > 0) {
|
|
1910
|
+
reasoning += thinkingDelta;
|
|
1911
|
+
onThinkingDelta(thinkingDelta);
|
|
1912
|
+
}
|
|
1913
|
+
const delta = choice?.delta?.content;
|
|
1914
|
+
if (typeof delta === "string" && delta.length > 0) {
|
|
1915
|
+
content += delta;
|
|
1916
|
+
onDelta(delta);
|
|
1917
|
+
}
|
|
1918
|
+
};
|
|
1919
|
+
while (true) {
|
|
1920
|
+
const chunk = await reader.read();
|
|
1921
|
+
buffer += decoder.decode(chunk.value, { stream: !chunk.done });
|
|
1922
|
+
const events = buffer.split(/\r?\n\r?\n/u);
|
|
1923
|
+
buffer = events.pop() ?? "";
|
|
1924
|
+
for (const eventText of events)
|
|
1925
|
+
consumeEvent(eventText);
|
|
1926
|
+
if (chunk.done)
|
|
1927
|
+
break;
|
|
1928
|
+
}
|
|
1929
|
+
if (buffer.trim())
|
|
1930
|
+
consumeEvent(buffer);
|
|
1931
|
+
if (!content.trim())
|
|
1932
|
+
throw new Error(`Chat Completions 流式响应缺少可用正文,finish_reason=${finishReason}`);
|
|
1933
|
+
return { content, reasoning, outputTokens: resolveOutputTokens(usage, content) };
|
|
1934
|
+
}
|
|
1935
|
+
async runChapterAnalysis(workId, scope, modelId, taskId) {
|
|
1936
|
+
if (!scope.chapterId)
|
|
1937
|
+
throw new AppError(400, "CHAPTER_REQUIRED", "章节分析必须指定章节");
|
|
1938
|
+
const chapter = this.store.getChapter(scope.chapterId);
|
|
1939
|
+
const generated = await this.generateTaggedJson({
|
|
1940
|
+
workId,
|
|
1941
|
+
taskType: "chapter-analysis",
|
|
1942
|
+
signal: this.taskSignal(taskId),
|
|
1943
|
+
instruction: "分析本章并输出 JSON 对象,字段为 summary(1至3句)、events(数组)、characters(数组)、settings(数组)、evidence(数组,每项含 conclusion 和 quote)、uncertainties(数组)。",
|
|
1944
|
+
scope,
|
|
1945
|
+
...(modelId ? { modelId } : {}),
|
|
1946
|
+
extraSystemPrompt: "本任务要求严格输出可解析的 JSON。"
|
|
1947
|
+
});
|
|
1948
|
+
const data = extractJson(generated.content);
|
|
1949
|
+
if (!this.taskCanCommit(taskId))
|
|
1950
|
+
return { interrupted: true, callId: generated.callId };
|
|
1951
|
+
const insightId = id("insight");
|
|
1952
|
+
this.store.db.run(`INSERT INTO chapter_insights (id, chapter_id, chapter_version, summary, events_json, characters_json,
|
|
1953
|
+
settings_json, evidence_json, uncertainties_json, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'review', ?)`, insightId, String(chapter.id), Number(chapter.versionNo), data.summary ?? "", JSON.stringify(data.events ?? []), JSON.stringify(data.characters ?? []), JSON.stringify(data.settings ?? []), JSON.stringify(data.evidence ?? []), JSON.stringify(data.uncertainties ?? []), now());
|
|
1954
|
+
this.store.db.run("UPDATE chapters SET analysis_status = 'review' WHERE id = ?", String(chapter.id));
|
|
1955
|
+
return { insightId, chapterId: chapter.id, chapterVersion: chapter.versionNo, callId: generated.callId, ...data };
|
|
1956
|
+
}
|
|
1957
|
+
async runTimelineAnalysis(workId, scope, modelId, taskId) {
|
|
1958
|
+
const generated = await this.generateTaggedJson({
|
|
1959
|
+
workId,
|
|
1960
|
+
taskType: "timeline-analysis",
|
|
1961
|
+
signal: this.taskSignal(taskId),
|
|
1962
|
+
instruction: "抽取大事件候选并输出 JSON 数组。每项字段:name、description、eventType、timeLabel、timeSort(无法确定为 null)、location、impactScope、chapterIds、participantIds、evidence。必须区分发生时间与叙述时间;不确定时使用‘时间待定’。",
|
|
1963
|
+
scope,
|
|
1964
|
+
...(modelId ? { modelId } : {}),
|
|
1965
|
+
extraSystemPrompt: "本任务要求严格输出可解析的 JSON。仅生成候选,不得声称已确认。"
|
|
1966
|
+
});
|
|
1967
|
+
const events = extractJson(generated.content);
|
|
1968
|
+
if (!Array.isArray(events))
|
|
1969
|
+
throw new AppError(502, "AI_INVALID_JSON", "时间轴分析结果必须是数组");
|
|
1970
|
+
if (!this.taskCanCommit(taskId))
|
|
1971
|
+
return { interrupted: true, callId: generated.callId };
|
|
1972
|
+
const eventIds = [];
|
|
1973
|
+
for (const event of events) {
|
|
1974
|
+
if (typeof event.name !== "string" || !event.name.trim())
|
|
1975
|
+
continue;
|
|
1976
|
+
const created = this.store.createTimelineEvent(workId, {
|
|
1977
|
+
name: event.name,
|
|
1978
|
+
description: typeof event.description === "string" ? event.description : "",
|
|
1979
|
+
eventType: typeof event.eventType === "string" ? event.eventType : "other",
|
|
1980
|
+
timeLabel: typeof event.timeLabel === "string" ? event.timeLabel : "时间待定",
|
|
1981
|
+
timeSort: typeof event.timeSort === "number" ? event.timeSort : null,
|
|
1982
|
+
chapterIds: Array.isArray(event.chapterIds) ? event.chapterIds.filter((value) => typeof value === "string") : [],
|
|
1983
|
+
participantIds: Array.isArray(event.participantIds) ? event.participantIds.filter((value) => typeof value === "string") : [],
|
|
1984
|
+
location: typeof event.location === "string" ? event.location : "",
|
|
1985
|
+
impactScope: typeof event.impactScope === "string" ? event.impactScope : "personal",
|
|
1986
|
+
evidence: Array.isArray(event.evidence) ? event.evidence : [],
|
|
1987
|
+
status: "candidate"
|
|
1988
|
+
}, "analysis", taskId ?? generated.callId);
|
|
1989
|
+
eventIds.push(String(created.id));
|
|
1990
|
+
}
|
|
1991
|
+
return { eventIds, candidateCount: eventIds.length, callId: generated.callId };
|
|
1992
|
+
}
|
|
1993
|
+
async runWorldviewAnalysis(workId, scope, modelId, taskId) {
|
|
1994
|
+
const chapters = this.getScopeChapters(workId, scope);
|
|
1995
|
+
if (chapters.length === 0)
|
|
1996
|
+
throw new AppError(409, "CHAPTERS_REQUIRED", "世界观分析范围内没有章节");
|
|
1997
|
+
const generated = await this.generateTaggedJson({
|
|
1998
|
+
workId,
|
|
1999
|
+
taskType: "book-analysis",
|
|
2000
|
+
signal: this.taskSignal(taskId),
|
|
2001
|
+
instruction: [
|
|
2002
|
+
"分析正文中已经出现的世界观并输出一个 JSON 对象。",
|
|
2003
|
+
"顶层字段:summary、dimensions、conflicts、uncertainties。",
|
|
2004
|
+
"dimensions 是数组,每项字段:category、title、conclusion、confidence(0 到 1 的数字)、evidence。",
|
|
2005
|
+
"category 只能是:宇宙与自然、地理与环境、社会与制度、历史与文明、科技与能力、资源与经济、宗教与文化、规则与限制、其他。",
|
|
2006
|
+
"conflicts 是数组,每项字段:title、description、evidence。uncertainties 是数组,每项字段:question、reason、evidence。",
|
|
2007
|
+
"每条 evidence 必须包含 chapterId、chapterTitle、quote;quote 必须是原文连续短引文且不超过 120 字。",
|
|
2008
|
+
"只总结原文明示或可由多处证据直接支持的结论,区分事实、传闻、角色认知和未知项,不得补写正文中不存在的设定。"
|
|
2009
|
+
].join("\n"),
|
|
2010
|
+
scope,
|
|
2011
|
+
...(modelId ? { modelId } : {}),
|
|
2012
|
+
parameters: { temperature: 0.1 },
|
|
2013
|
+
extraSystemPrompt: "你是可审计的小说世界观分析器。所有结论必须能追溯到给定正文;证据不足时放入 uncertainties。"
|
|
2014
|
+
});
|
|
2015
|
+
const parsed = extractJson(generated.content, (value) => {
|
|
2016
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
2017
|
+
return false;
|
|
2018
|
+
const candidate = value;
|
|
2019
|
+
return ["summary", "dimensions", "conflicts", "uncertainties"].some((key) => key in candidate);
|
|
2020
|
+
});
|
|
2021
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2022
|
+
throw new AppError(502, "AI_INVALID_WORLDVIEW", "世界观分析结果必须是对象");
|
|
2023
|
+
}
|
|
2024
|
+
if (!this.taskCanCommit(taskId))
|
|
2025
|
+
return { interrupted: true, callId: generated.callId };
|
|
2026
|
+
const data = parsed;
|
|
2027
|
+
const categories = new Set(["宇宙与自然", "地理与环境", "社会与制度", "历史与文明", "科技与能力", "资源与经济", "宗教与文化", "规则与限制", "其他"]);
|
|
2028
|
+
let omittedDimensionCount = 0;
|
|
2029
|
+
const dimensions = (Array.isArray(data.dimensions) ? data.dimensions : []).flatMap((value) => {
|
|
2030
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2031
|
+
omittedDimensionCount += 1;
|
|
2032
|
+
return [];
|
|
2033
|
+
}
|
|
2034
|
+
const item = value;
|
|
2035
|
+
const title = typeof item.title === "string" ? item.title.trim() : "";
|
|
2036
|
+
const conclusion = typeof item.conclusion === "string" ? item.conclusion.trim() : "";
|
|
2037
|
+
const evidence = this.validateAnalysisEvidence(chapters, item.evidence);
|
|
2038
|
+
if (!title || !conclusion || evidence.length === 0) {
|
|
2039
|
+
omittedDimensionCount += 1;
|
|
2040
|
+
return [];
|
|
2041
|
+
}
|
|
2042
|
+
return [{
|
|
2043
|
+
category: typeof item.category === "string" && categories.has(item.category) ? item.category : "其他",
|
|
2044
|
+
title,
|
|
2045
|
+
conclusion,
|
|
2046
|
+
confidence: typeof item.confidence === "number"
|
|
2047
|
+
? clamp(item.confidence, 0, 1)
|
|
2048
|
+
: ({ high: 0.9, medium: 0.7, low: 0.5 })[String(item.confidence).toLocaleLowerCase()] ?? 0.5,
|
|
2049
|
+
evidence
|
|
2050
|
+
}];
|
|
2051
|
+
});
|
|
2052
|
+
const sanitizeFinding = (value, titleField) => {
|
|
2053
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
2054
|
+
return [];
|
|
2055
|
+
const item = value;
|
|
2056
|
+
const title = typeof item[titleField] === "string" ? item[titleField].trim() : "";
|
|
2057
|
+
const evidence = this.validateAnalysisEvidence(chapters, item.evidence);
|
|
2058
|
+
if (!title || evidence.length === 0)
|
|
2059
|
+
return [];
|
|
2060
|
+
return [{
|
|
2061
|
+
[titleField]: title,
|
|
2062
|
+
[titleField === "title" ? "description" : "reason"]: typeof item[titleField === "title" ? "description" : "reason"] === "string"
|
|
2063
|
+
? String(item[titleField === "title" ? "description" : "reason"]).trim()
|
|
2064
|
+
: "",
|
|
2065
|
+
evidence
|
|
2066
|
+
}];
|
|
2067
|
+
};
|
|
2068
|
+
const conflicts = (Array.isArray(data.conflicts) ? data.conflicts : []).flatMap((item) => sanitizeFinding(item, "title"));
|
|
2069
|
+
const uncertainties = (Array.isArray(data.uncertainties) ? data.uncertainties : []).flatMap((item) => sanitizeFinding(item, "question"));
|
|
2070
|
+
const summary = typeof data.summary === "string" ? data.summary.trim() : "";
|
|
2071
|
+
if (!summary && dimensions.length === 0 && conflicts.length === 0 && uncertainties.length === 0) {
|
|
2072
|
+
throw new AppError(502, "AI_EMPTY_WORLDVIEW", "AI 返回的世界观分析为空");
|
|
2073
|
+
}
|
|
2074
|
+
return {
|
|
2075
|
+
summary,
|
|
2076
|
+
dimensions,
|
|
2077
|
+
conflicts,
|
|
2078
|
+
uncertainties,
|
|
2079
|
+
dimensionCount: dimensions.length,
|
|
2080
|
+
omittedDimensionCount,
|
|
2081
|
+
coveredChapterCount: chapters.length,
|
|
2082
|
+
callId: generated.callId
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
async runSettingExtraction(workId, scope, modelId, taskId) {
|
|
2086
|
+
const chapters = this.getScopeChapters(workId, scope);
|
|
2087
|
+
if (chapters.length === 0)
|
|
2088
|
+
throw new AppError(409, "CHAPTERS_REQUIRED", "设定抽取范围内没有章节");
|
|
2089
|
+
const chunks = this.buildChapterChunks(chapters, 10_000);
|
|
2090
|
+
const concurrency = this.configuredConcurrency(workId, "book-analysis", modelId);
|
|
2091
|
+
const chunkResults = await this.processChunks(chunks, concurrency, async (chunk) => {
|
|
2092
|
+
if (taskId && this.store.getTask(taskId).status !== "running")
|
|
2093
|
+
return { candidates: [], callId: null };
|
|
2094
|
+
const generated = await this.generateTaggedJson({
|
|
2095
|
+
workId,
|
|
2096
|
+
taskType: "book-analysis",
|
|
2097
|
+
signal: this.taskSignal(taskId),
|
|
2098
|
+
maxAttempts: 2,
|
|
2099
|
+
scope: { type: "selection", selection: chunk.text },
|
|
2100
|
+
...(modelId ? { modelId } : {}),
|
|
2101
|
+
parameters: { temperature: 0.1 },
|
|
2102
|
+
instruction: [
|
|
2103
|
+
"从本批正文抽取可复用、会影响后续创作的世界设定候选,输出 JSON 数组。",
|
|
2104
|
+
"每项字段:title、category、content、tags、confidence、evidence。",
|
|
2105
|
+
"category 只能是:世界规则、历史与年代、地点与地图、组织与阵营、物种与族群、科技与物品、术语与称谓、创作约束。",
|
|
2106
|
+
"每条 evidence 必须包含 chapterId、chapterTitle、quote;quote 必须是原文连续短引文且不超过 120 字。",
|
|
2107
|
+
"只抽取原文明示、跨场景可复用的事实或约束;不要把一次性动作、剧情摘要、人物关系、推测、梦境或未证实传闻当作确定设定。",
|
|
2108
|
+
"同一设定在本批只输出一次。证据不足或 confidence 低于 0.6 时不要输出。"
|
|
2109
|
+
].join("\n"),
|
|
2110
|
+
extraSystemPrompt: "你是严格的小说设定抽取器。不得补写、常识推断或伪造引文;候选最终由作者确认。"
|
|
2111
|
+
});
|
|
2112
|
+
const extracted = extractJson(generated.content);
|
|
2113
|
+
if (!Array.isArray(extracted))
|
|
2114
|
+
throw new AppError(502, "AI_INVALID_JSON", "设定抽取结果必须是数组");
|
|
2115
|
+
return {
|
|
2116
|
+
candidates: extracted.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item)),
|
|
2117
|
+
callId: generated.callId
|
|
2118
|
+
};
|
|
2119
|
+
}, (completed) => {
|
|
2120
|
+
if (taskId && this.store.getTask(taskId).status === "running") {
|
|
2121
|
+
this.store.updateTask(taskId, { status: "running", progress: Math.min(92, 5 + Math.round(completed / chunks.length * 87)) });
|
|
2122
|
+
}
|
|
2123
|
+
});
|
|
2124
|
+
if (!this.taskCanCommit(taskId))
|
|
2125
|
+
return { interrupted: true, callIds: chunkResults.map((item) => item.callId).filter(Boolean) };
|
|
2126
|
+
const categories = new Set(["世界规则", "历史与年代", "地点与地图", "组织与阵营", "物种与族群", "科技与物品", "术语与称谓", "创作约束"]);
|
|
2127
|
+
const rawCandidates = chunkResults.flatMap((item) => item.candidates);
|
|
2128
|
+
const callIds = chunkResults.map((item) => item.callId).filter((item) => typeof item === "string");
|
|
2129
|
+
const skipped = [];
|
|
2130
|
+
const merged = new Map();
|
|
2131
|
+
for (const raw of rawCandidates) {
|
|
2132
|
+
const title = typeof raw.title === "string" ? raw.title.normalize("NFKC").trim().slice(0, 200) : "";
|
|
2133
|
+
const category = typeof raw.category === "string" ? raw.category.trim() : "";
|
|
2134
|
+
const content = typeof raw.content === "string" ? raw.content.trim().slice(0, 200_000) : "";
|
|
2135
|
+
const confidence = typeof raw.confidence === "number" ? clamp(raw.confidence, 0, 1) : 0;
|
|
2136
|
+
const evidence = this.validateAnalysisEvidence(chapters, raw.evidence);
|
|
2137
|
+
if (!title || !content || !categories.has(category) || confidence < 0.6 || evidence.length === 0) {
|
|
2138
|
+
skipped.push({ title: title || "未命名候选", reason: "字段、分类、置信度或原文证据无效" });
|
|
2139
|
+
continue;
|
|
2140
|
+
}
|
|
2141
|
+
const tags = [...new Set((Array.isArray(raw.tags) ? raw.tags : [])
|
|
2142
|
+
.filter((tag) => typeof tag === "string")
|
|
2143
|
+
.map((tag) => tag.normalize("NFKC").trim().slice(0, 100))
|
|
2144
|
+
.filter(Boolean))].slice(0, 30);
|
|
2145
|
+
const key = `${this.normalizeReference(category)}|${this.normalizeReference(title)}`;
|
|
2146
|
+
const existing = merged.get(key);
|
|
2147
|
+
if (!existing) {
|
|
2148
|
+
merged.set(key, { title, category, content, tags, confidence, evidence });
|
|
2149
|
+
continue;
|
|
2150
|
+
}
|
|
2151
|
+
const seenEvidence = new Set(existing.evidence.map((item) => `${String(item.chapterId)}|${String(item.quote)}`));
|
|
2152
|
+
for (const item of evidence) {
|
|
2153
|
+
const evidenceKey = `${String(item.chapterId)}|${String(item.quote)}`;
|
|
2154
|
+
if (!seenEvidence.has(evidenceKey))
|
|
2155
|
+
existing.evidence.push(item);
|
|
2156
|
+
}
|
|
2157
|
+
if (content.length > existing.content.length)
|
|
2158
|
+
existing.content = content;
|
|
2159
|
+
existing.tags = [...new Set([...existing.tags, ...tags])].slice(0, 30);
|
|
2160
|
+
existing.confidence = Math.max(existing.confidence, confidence);
|
|
2161
|
+
}
|
|
2162
|
+
const existingSettings = this.store.listSettings(workId);
|
|
2163
|
+
const settingIds = [];
|
|
2164
|
+
let createdCount = 0;
|
|
2165
|
+
let updatedCount = 0;
|
|
2166
|
+
this.store.db.transaction(() => {
|
|
2167
|
+
for (const candidate of merged.values()) {
|
|
2168
|
+
const duplicateIndex = existingSettings.findIndex((setting) => this.normalizeReference(String(setting.category)) === this.normalizeReference(candidate.category)
|
|
2169
|
+
&& this.normalizeReference(String(setting.title)) === this.normalizeReference(candidate.title));
|
|
2170
|
+
const chapterIds = [...new Set(candidate.evidence.map((item) => String(item.chapterId)))];
|
|
2171
|
+
if (duplicateIndex >= 0) {
|
|
2172
|
+
const duplicate = existingSettings[duplicateIndex];
|
|
2173
|
+
if (duplicate.status !== "pending" || duplicate.locked === true) {
|
|
2174
|
+
skipped.push({ title: candidate.title, reason: "同名作者设定已存在,未覆盖" });
|
|
2175
|
+
continue;
|
|
2176
|
+
}
|
|
2177
|
+
const mergedEvidence = [...(Array.isArray(duplicate.evidence) ? duplicate.evidence : [])];
|
|
2178
|
+
const seenEvidence = new Set(mergedEvidence.map((item) => `${String(item.chapterId)}|${String(item.quote)}`));
|
|
2179
|
+
for (const item of candidate.evidence) {
|
|
2180
|
+
const evidenceKey = `${String(item.chapterId)}|${String(item.quote)}`;
|
|
2181
|
+
if (!seenEvidence.has(evidenceKey))
|
|
2182
|
+
mergedEvidence.push(item);
|
|
2183
|
+
}
|
|
2184
|
+
const previousScope = duplicate.scope && typeof duplicate.scope === "object" && !Array.isArray(duplicate.scope)
|
|
2185
|
+
? duplicate.scope
|
|
2186
|
+
: {};
|
|
2187
|
+
const previousChapterIds = Array.isArray(previousScope.chapterIds) ? previousScope.chapterIds.map(String) : [];
|
|
2188
|
+
const updated = this.store.updateSetting(String(duplicate.id), {
|
|
2189
|
+
content: candidate.content.length > String(duplicate.content).length ? candidate.content : String(duplicate.content),
|
|
2190
|
+
tags: [...new Set([...(Array.isArray(duplicate.tags) ? duplicate.tags.map(String) : []), ...candidate.tags])].slice(0, 30),
|
|
2191
|
+
evidence: mergedEvidence,
|
|
2192
|
+
scope: { ...previousScope, chapterIds: [...new Set([...previousChapterIds, ...chapterIds])] },
|
|
2193
|
+
authorNote: `AI 设定候选,最高置信度 ${Math.round(candidate.confidence * 100)}%,需由作者确认。`
|
|
2194
|
+
}, "analysis", taskId ?? callIds[0] ?? null, "AI 合并设定证据");
|
|
2195
|
+
existingSettings[duplicateIndex] = updated;
|
|
2196
|
+
settingIds.push(String(updated.id));
|
|
2197
|
+
updatedCount += 1;
|
|
2198
|
+
continue;
|
|
2199
|
+
}
|
|
2200
|
+
const created = this.store.createSetting(workId, {
|
|
2201
|
+
title: candidate.title,
|
|
2202
|
+
category: candidate.category,
|
|
2203
|
+
content: candidate.content,
|
|
2204
|
+
tags: candidate.tags,
|
|
2205
|
+
status: "pending",
|
|
2206
|
+
locked: false,
|
|
2207
|
+
evidence: candidate.evidence,
|
|
2208
|
+
scope: { chapterIds },
|
|
2209
|
+
authorNote: `AI 设定候选,置信度 ${Math.round(candidate.confidence * 100)}%,需由作者确认。`
|
|
2210
|
+
}, "analysis", taskId ?? callIds[0] ?? null);
|
|
2211
|
+
existingSettings.push(created);
|
|
2212
|
+
settingIds.push(String(created.id));
|
|
2213
|
+
createdCount += 1;
|
|
2214
|
+
}
|
|
2215
|
+
});
|
|
2216
|
+
this.store.audit(workId, "setting.analysis.completed", "work", workId, {
|
|
2217
|
+
batchCount: chunks.length,
|
|
2218
|
+
coveredChapterCount: chapters.length,
|
|
2219
|
+
rawCandidateCount: rawCandidates.length,
|
|
2220
|
+
savedCount: settingIds.length,
|
|
2221
|
+
skippedCount: skipped.length,
|
|
2222
|
+
scopeType: scope.type
|
|
2223
|
+
});
|
|
2224
|
+
return {
|
|
2225
|
+
settingIds,
|
|
2226
|
+
candidateCount: settingIds.length,
|
|
2227
|
+
rawCandidateCount: rawCandidates.length,
|
|
2228
|
+
createdCount,
|
|
2229
|
+
updatedCount,
|
|
2230
|
+
skipped,
|
|
2231
|
+
batchCount: chunks.length,
|
|
2232
|
+
coveredChapterCount: chapters.length,
|
|
2233
|
+
callIds
|
|
2234
|
+
};
|
|
2235
|
+
}
|
|
2236
|
+
async runConsistencyCheck(workId, scope, modelId, taskId) {
|
|
2237
|
+
const generated = await this.generateTaggedJson({
|
|
2238
|
+
workId,
|
|
2239
|
+
taskType: "consistency-check",
|
|
2240
|
+
signal: this.taskSignal(taskId),
|
|
2241
|
+
instruction: "检查设定、人物状态、关系和时间是否冲突,输出 JSON 数组。每项字段:itemType、severity(low/medium/high)、title、description、entityRefs、evidence、suggestion。没有问题时输出 []。",
|
|
2242
|
+
scope,
|
|
2243
|
+
...(modelId ? { modelId } : {}),
|
|
2244
|
+
extraSystemPrompt: "本任务要求严格输出可解析的 JSON。"
|
|
2245
|
+
});
|
|
2246
|
+
const issues = extractJson(generated.content);
|
|
2247
|
+
if (!Array.isArray(issues))
|
|
2248
|
+
throw new AppError(502, "AI_INVALID_JSON", "一致性检查结果必须是数组");
|
|
2249
|
+
if (!this.taskCanCommit(taskId))
|
|
2250
|
+
return { interrupted: true, callId: generated.callId };
|
|
2251
|
+
const reviewIds = [];
|
|
2252
|
+
for (const issue of issues) {
|
|
2253
|
+
if (typeof issue.title !== "string" || !issue.title.trim())
|
|
2254
|
+
continue;
|
|
2255
|
+
const review = this.store.createReviewItem(workId, {
|
|
2256
|
+
itemType: typeof issue.itemType === "string" ? issue.itemType : "consistency",
|
|
2257
|
+
severity: typeof issue.severity === "string" ? issue.severity : "medium",
|
|
2258
|
+
title: issue.title,
|
|
2259
|
+
description: typeof issue.description === "string" ? issue.description : "",
|
|
2260
|
+
entityRefs: Array.isArray(issue.entityRefs) ? issue.entityRefs : [],
|
|
2261
|
+
evidence: Array.isArray(issue.evidence) ? issue.evidence : [],
|
|
2262
|
+
suggestion: typeof issue.suggestion === "string" ? issue.suggestion : ""
|
|
2263
|
+
});
|
|
2264
|
+
reviewIds.push(String(review.id));
|
|
2265
|
+
}
|
|
2266
|
+
return { reviewIds, issueCount: reviewIds.length, callId: generated.callId };
|
|
2267
|
+
}
|
|
2268
|
+
async runCharacterExtraction(workId, scope, modelId, taskId) {
|
|
2269
|
+
const chapters = this.getScopeChapters(workId, scope);
|
|
2270
|
+
if (chapters.length === 0)
|
|
2271
|
+
throw new AppError(409, "CHAPTERS_REQUIRED", "人物抽取范围内没有章节");
|
|
2272
|
+
const chunks = this.buildChapterChunks(chapters, 10_000);
|
|
2273
|
+
const concurrency = this.configuredConcurrency(workId, "book-analysis", modelId);
|
|
2274
|
+
const rawCandidates = [];
|
|
2275
|
+
const callIds = [];
|
|
2276
|
+
const extractChunk = async (text, maxAttempts = 3) => {
|
|
2277
|
+
const generated = await this.generateTaggedJson({
|
|
2278
|
+
workId,
|
|
2279
|
+
taskType: "book-analysis",
|
|
2280
|
+
signal: this.taskSignal(taskId),
|
|
2281
|
+
maxAttempts,
|
|
2282
|
+
scope: { type: "selection", selection: text },
|
|
2283
|
+
...(modelId ? { modelId } : {}),
|
|
2284
|
+
parameters: { temperature: 0.2 },
|
|
2285
|
+
instruction: [
|
|
2286
|
+
"抽取本批原文中有名字且对跨章节剧情有意义的人物或具有人格的生物。输出 JSON 数组。",
|
|
2287
|
+
"每项字段:canonicalName、aliases(仅无歧义昵称或拼写变体)、species(仅原文明确说明时填写)、identity、firstEvidence(chapterId、chapterTitle、quote)。",
|
|
2288
|
+
"规则:合并明显拼写变体;不能把怪兽之王、怪兽女王、君王、女王、吾王、博士、舰长、上尉、司令、族长、老师、父亲、母亲、哥哥、姐姐等称号作为全局别名;不能把单字母简称作为别名;梦境或作品内虚构角色需在 identity 标明;不得创造人物;quote 必须是原文连续引文且不超过 80 字。",
|
|
2289
|
+
"没有合格人物时输出 [],不得使用 Markdown 代码块。"
|
|
2290
|
+
].join("\n"),
|
|
2291
|
+
extraSystemPrompt: [
|
|
2292
|
+
"你是严格的人物规范化抽取器。相似名字不能凭空合并。",
|
|
2293
|
+
"必须区分:真酱与真姬;魔斯拉与魔蛇;基多拉、银月基多拉、奥尔森与真姬;伊比拉与达哥拉;安吉拉斯与安胡卢克;陈伊琳、陈玲、陈欣、陈芳、陈雅丽与陈妍菲。",
|
|
2294
|
+
"明确拼写变体应合并:安吉拉斯/安基拉斯/安加拉斯,伊莉丝/伊莉斯,伊莎贝拉/伊萨贝拉,卡玛佐兹/卡玛左滋/卡玛卓兹/卡玛佐治。",
|
|
2295
|
+
"奥卡编号、月柔、加隆、雅典娜和小塞是不同 AI 实例,不能仅因共享奥卡或 AI 称谓而合并。"
|
|
2296
|
+
].join("\n")
|
|
2297
|
+
});
|
|
2298
|
+
const extracted = extractJson(generated.content);
|
|
2299
|
+
if (!Array.isArray(extracted))
|
|
2300
|
+
throw new AppError(502, "AI_INVALID_JSON", "人物抽取结果必须是数组");
|
|
2301
|
+
return {
|
|
2302
|
+
candidates: extracted.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item)),
|
|
2303
|
+
callId: generated.callId
|
|
2304
|
+
};
|
|
2305
|
+
};
|
|
2306
|
+
const chunkResults = await this.processChunks(chunks, concurrency, async (chunk) => {
|
|
2307
|
+
if (taskId && this.store.getTask(taskId).status !== "running") {
|
|
2308
|
+
return { candidates: [], callIds: [], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
|
|
2309
|
+
}
|
|
2310
|
+
try {
|
|
2311
|
+
const extracted = await extractChunk(chunk.text, 1);
|
|
2312
|
+
return { candidates: extracted.candidates, callIds: [extracted.callId], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
|
|
2313
|
+
}
|
|
2314
|
+
catch {
|
|
2315
|
+
const segments = this.splitMarkedChapters(chunk.text);
|
|
2316
|
+
return this.runChapterSegmentFallback(segments, taskId, extractChunk, (segment) => this.localCharacterFallback(workId, segment), concurrency);
|
|
2317
|
+
}
|
|
2318
|
+
}, (completed) => {
|
|
2319
|
+
if (taskId && this.store.getTask(taskId).status === "running") {
|
|
2320
|
+
this.store.updateTask(taskId, { status: "running", progress: Math.min(92, 5 + Math.round(completed / chunks.length * 87)) });
|
|
2321
|
+
}
|
|
2322
|
+
});
|
|
2323
|
+
let fallbackSegmentCount = 0;
|
|
2324
|
+
let policyOmittedSegmentCount = 0;
|
|
2325
|
+
for (const result of chunkResults) {
|
|
2326
|
+
rawCandidates.push(...result.candidates);
|
|
2327
|
+
callIds.push(...result.callIds);
|
|
2328
|
+
fallbackSegmentCount += result.fallbackSegmentCount;
|
|
2329
|
+
policyOmittedSegmentCount += result.policyOmittedSegmentCount;
|
|
2330
|
+
}
|
|
2331
|
+
if (!this.taskCanCommit(taskId))
|
|
2332
|
+
return { interrupted: true, callIds };
|
|
2333
|
+
const byChapterId = new Map(chapters.map((chapter) => [String(chapter.id), chapter]));
|
|
2334
|
+
const groups = [];
|
|
2335
|
+
for (const candidate of rawCandidates) {
|
|
2336
|
+
if (typeof candidate.canonicalName !== "string" || !candidate.canonicalName.trim())
|
|
2337
|
+
continue;
|
|
2338
|
+
const name = candidate.canonicalName.normalize("NFKC").trim();
|
|
2339
|
+
const aliases = (Array.isArray(candidate.aliases) ? candidate.aliases : [])
|
|
2340
|
+
.filter((value) => typeof value === "string")
|
|
2341
|
+
.map((value) => value.normalize("NFKC").trim())
|
|
2342
|
+
.filter((value) => value && this.isSafeGlobalAlias(value));
|
|
2343
|
+
const evidence = candidate.firstEvidence && typeof candidate.firstEvidence === "object" && !Array.isArray(candidate.firstEvidence)
|
|
2344
|
+
? candidate.firstEvidence
|
|
2345
|
+
: null;
|
|
2346
|
+
const chapterId = evidence && typeof evidence.chapterId === "string" ? evidence.chapterId : null;
|
|
2347
|
+
const quote = evidence && typeof evidence.quote === "string" ? evidence.quote.trim() : "";
|
|
2348
|
+
if (!chapterId || !quote || quote.length > 80 || !byChapterId.has(chapterId)
|
|
2349
|
+
|| !this.quoteExists(String(byChapterId.get(chapterId)?.content ?? ""), quote))
|
|
2350
|
+
continue;
|
|
2351
|
+
const refs = new Set([name, ...aliases].map((value) => this.normalizeReference(value)));
|
|
2352
|
+
const matches = groups.filter((group) => [...refs].some((value) => group.references.has(value)));
|
|
2353
|
+
const group = matches[0] ?? {
|
|
2354
|
+
name,
|
|
2355
|
+
aliases: new Set(),
|
|
2356
|
+
species: typeof candidate.species === "string" ? candidate.species.trim() : "",
|
|
2357
|
+
identity: typeof candidate.identity === "string" ? candidate.identity : "",
|
|
2358
|
+
firstChapterId: chapterId,
|
|
2359
|
+
references: new Set()
|
|
2360
|
+
};
|
|
2361
|
+
if (!matches.length)
|
|
2362
|
+
groups.push(group);
|
|
2363
|
+
for (const value of refs)
|
|
2364
|
+
group.references.add(value);
|
|
2365
|
+
for (const alias of aliases)
|
|
2366
|
+
if (this.normalizeReference(alias) !== this.normalizeReference(group.name))
|
|
2367
|
+
group.aliases.add(alias);
|
|
2368
|
+
if (!group.identity && typeof candidate.identity === "string")
|
|
2369
|
+
group.identity = candidate.identity;
|
|
2370
|
+
if (!group.species && typeof candidate.species === "string")
|
|
2371
|
+
group.species = candidate.species.trim();
|
|
2372
|
+
if (!group.firstChapterId && chapterId)
|
|
2373
|
+
group.firstChapterId = chapterId;
|
|
2374
|
+
for (const duplicate of matches.slice(1)) {
|
|
2375
|
+
for (const alias of [duplicate.name, ...duplicate.aliases]) {
|
|
2376
|
+
if (this.normalizeReference(alias) !== this.normalizeReference(group.name) && this.isSafeGlobalAlias(alias))
|
|
2377
|
+
group.aliases.add(alias);
|
|
2378
|
+
}
|
|
2379
|
+
for (const value of duplicate.references)
|
|
2380
|
+
group.references.add(value);
|
|
2381
|
+
const duplicateIndex = groups.indexOf(duplicate);
|
|
2382
|
+
if (duplicateIndex >= 0)
|
|
2383
|
+
groups.splice(duplicateIndex, 1);
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
const characterIds = [];
|
|
2387
|
+
const skipped = [];
|
|
2388
|
+
for (const group of groups) {
|
|
2389
|
+
const aliases = [...group.aliases].filter((alias) => this.isSafeGlobalAlias(alias));
|
|
2390
|
+
const extractedRaceId = group.species ? this.store.resolveRaceReference(workId, group.species) : null;
|
|
2391
|
+
const existingId = [group.name, ...aliases]
|
|
2392
|
+
.map((value) => this.store.resolveCharacterReference(workId, value))
|
|
2393
|
+
.find((value) => Boolean(value));
|
|
2394
|
+
try {
|
|
2395
|
+
if (existingId) {
|
|
2396
|
+
const existing = this.store.getCharacter(existingId);
|
|
2397
|
+
const mergedAliases = [...new Set([...existing.aliases, ...aliases])]
|
|
2398
|
+
.filter((alias) => this.isSafeGlobalAlias(alias) && this.normalizeReference(alias) !== this.normalizeReference(String(existing.name)));
|
|
2399
|
+
const updated = this.store.updateCharacter(existingId, {
|
|
2400
|
+
aliases: mergedAliases,
|
|
2401
|
+
raceId: existing.raceId ?? extractedRaceId,
|
|
2402
|
+
attributes: { ...existing.attributes, ...(group.identity ? { identity: group.identity } : {}) },
|
|
2403
|
+
firstChapterId: existing.firstChapterId ?? group.firstChapterId
|
|
2404
|
+
});
|
|
2405
|
+
characterIds.push(String(updated.id));
|
|
2406
|
+
}
|
|
2407
|
+
else {
|
|
2408
|
+
const created = this.store.createCharacter(workId, {
|
|
2409
|
+
name: group.name,
|
|
2410
|
+
aliases,
|
|
2411
|
+
raceId: extractedRaceId,
|
|
2412
|
+
attributes: group.identity ? { identity: group.identity } : {},
|
|
2413
|
+
firstChapterId: group.firstChapterId
|
|
2414
|
+
});
|
|
2415
|
+
characterIds.push(String(created.id));
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
catch (error) {
|
|
2419
|
+
skipped.push({ name: group.name, reason: error instanceof Error ? error.message : "名称冲突" });
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
return {
|
|
2423
|
+
characterIds: [...new Set(characterIds)],
|
|
2424
|
+
candidateCount: groups.length,
|
|
2425
|
+
savedCount: new Set(characterIds).size,
|
|
2426
|
+
skipped,
|
|
2427
|
+
batchCount: chunks.length,
|
|
2428
|
+
coveredChapterCount: chapters.length,
|
|
2429
|
+
fallbackSegmentCount,
|
|
2430
|
+
policyOmittedSegmentCount,
|
|
2431
|
+
callIds
|
|
2432
|
+
};
|
|
2433
|
+
}
|
|
2434
|
+
async runRelationshipAnalysis(workId, scope, modelId, taskId) {
|
|
2435
|
+
const characters = this.store.listCharacters(workId);
|
|
2436
|
+
if (characters.length < 2)
|
|
2437
|
+
throw new AppError(409, "CHARACTERS_REQUIRED", "人物关系分析至少需要两个角色档案");
|
|
2438
|
+
const chapters = this.getScopeChapters(workId, scope);
|
|
2439
|
+
if (chapters.length === 0)
|
|
2440
|
+
throw new AppError(409, "CHAPTERS_REQUIRED", "人物关系分析范围内没有章节");
|
|
2441
|
+
const chunks = this.buildChapterChunks(chapters, 12_000);
|
|
2442
|
+
const concurrency = this.configuredConcurrency(workId, "relationship-analysis", modelId);
|
|
2443
|
+
const roster = characters.map((character) => {
|
|
2444
|
+
const aliases = character.aliases.filter((alias) => this.isSafeGlobalAlias(alias));
|
|
2445
|
+
return `${String(character.id)} | ${String(character.name)}${aliases.length ? ` | 别名:${aliases.join("、")}` : ""}`;
|
|
2446
|
+
}).join("\n");
|
|
2447
|
+
const rawCandidates = [];
|
|
2448
|
+
const callIds = [];
|
|
2449
|
+
const extractChunk = async (text, maxAttempts = 3) => {
|
|
2450
|
+
const generated = await this.generateTaggedJson({
|
|
2451
|
+
workId,
|
|
2452
|
+
taskType: "relationship-analysis",
|
|
2453
|
+
signal: this.taskSignal(taskId),
|
|
2454
|
+
maxAttempts,
|
|
2455
|
+
scope: { type: "selection", selection: text },
|
|
2456
|
+
...(modelId ? { modelId } : {}),
|
|
2457
|
+
parameters: { temperature: 0.1 },
|
|
2458
|
+
instruction: [
|
|
2459
|
+
"你是小说人物关系抽取器,不是续写者。只抽取角色规范表中人物之间、对跨章节人物图有长期意义且有原文证据的关系。",
|
|
2460
|
+
"角色规范表:",
|
|
2461
|
+
roster,
|
|
2462
|
+
"硬规则:",
|
|
2463
|
+
"1. 人名、别名、昵称和拼写变体必须归一到唯一 characterId,禁止创造角色或把相似名字强行合并。",
|
|
2464
|
+
"2. 单次见面、同场出现、对话、传话、约定或共同目睹事件本身不是长期人物关系,没有长期意义时不要输出。",
|
|
2465
|
+
"3. 区分现实当前、真实历史、回忆/第三方陈述、梦境/平行可能、假设、媒体作品和作者注释。梦境、假设或替代人生不能改变现实关系状态。",
|
|
2466
|
+
"3.1 标记为‘作者的话’的章节默认不会进入自动分析;若原文片段仍包含序言、后记、作者注或现实创作说明,其中的人名和关系也不得写入小说人物图。",
|
|
2467
|
+
"4. 父母→子女、君王→臣属、导师→学生、施害者→受害者、倾慕者→被倾慕者使用 directed=true;伴侣、朋友、兄弟姐妹、盟友、互为宿敌使用 directed=false。",
|
|
2468
|
+
"5. 同一人物对、同一 category、同一 subtype 只输出一次;不得输出反向重复边。",
|
|
2469
|
+
"6. currentStatus 表示本批正文结束时的状态;阶段变化写入 timeRange.stages。",
|
|
2470
|
+
"7. 每条 evidence 必须同时提供 chapterId、chapterTitle、quote、contextType、supports。quote 必须是原文连续短引文且不超过 80 字。",
|
|
2471
|
+
"8. 明示事实可用一条直接证据;confidence>=0.8 原则上需强直接证据,只有共现或含糊代词时不要输出。",
|
|
2472
|
+
"9. confidence 低于 0.6 不输出。uncertain 仅用于原文明示关系未知且对剧情重要的情况,不能用来填充证据不足的组合。",
|
|
2473
|
+
"10. subtype 必须使用简短中文稳定词:父母子女、收养亲子、手足、叔侄、君臣、师生、同事、盟友、朋友、伴侣、倾慕、亲密羁绊、宿敌、施害与受害、操纵与被操纵;确有其他关系时才新增中文词,禁止英文、下划线和近义重复。父母子女/收养亲子/手足/叔侄只能属于 family;君臣/师生/同事/盟友/朋友只能属于 social;伴侣/倾慕/亲密羁绊只能属于 emotional;宿敌/施害与受害/操纵与被操纵只能属于 conflict。",
|
|
2474
|
+
"11. 君臣关系必须 from=君王、to=臣属;父母子女必须 from=父母、to=子女;倾慕必须 from=倾慕者、to=被倾慕者。一次下令或一次服从不能单独证明长期君臣。",
|
|
2475
|
+
"12. 同一人物对若已有伴侣,不再另报朋友、亲密羁绊或相互倾慕;已有宿敌,不再另报敌人或竞争者。只保留语义最强的长期边,并把阶段变化合并到 timeRange.stages。",
|
|
2476
|
+
"13. 组织、阵营或国家之间的盟约不能投射成代表个人之间的盟友;某人替组织传话、执行任务或参与同一行动,也不能据此建立个人长期关系。",
|
|
2477
|
+
"14. evidence 的引文和 supports 必须能共同识别关系双方及关系类型;仅有一方名字、模糊代词或旁人泛称时不要输出。",
|
|
2478
|
+
"15. keywords 必须是 2 至 8 个简短中文关键词,描述这两个人之间具体的互动方式、权力结构、情感阶段或剧情张力,例如共同守护、长期信任、王权效忠、单向追求、决裂后和解;不能只重复 subtype。",
|
|
2479
|
+
"16. 血亲关系必须有明确亲属称谓、出生或收养证据;年龄差、同族、救援幼崽、照护后辈都不能推断为父子、叔侄或手足。",
|
|
2480
|
+
"17. 君臣关系必须同时出现明确权力身份与效忠、听命、下令或服从行为;仅称呼‘君王/女王’、表现敬畏、属于同一族群或接受帮助都不构成君臣,也不能给每个具名族民批量建立君臣边。",
|
|
2481
|
+
"18. 宿敌必须有跨两个不同章节/时期的持续冲突证据,或原文直接使用宿敌、世仇、长期威胁等表述;单场危机只能使用战时敌对、围攻与反击、追杀与反击等准确 subtype。",
|
|
2482
|
+
"19. 严格核对对话说话人、提问者和回答中的主语。不能把回答者的行为归给提问者,不能因某人被类比、被提及、出现在角色规范表或既有关系上下文中就生成新边。",
|
|
2483
|
+
"20. 前任向继任者让位属于前任与继任,不是继任者统御前任;方向必须由原文中的权力交接和实际服从行为共同决定。",
|
|
2484
|
+
"21. 关键词只能描述双方互动,不得混入任何一方单独的基因改造、意识变化、物种背景或未参与本关系的事件,也不得把不同时间阶段压成互相矛盾的同一组关键词。",
|
|
2485
|
+
"22. 集合身份、分身或内部意识不能当作额外人物扩散关系。若银月基多拉等聚合角色已代表内部意识与外部对象的整体关系,不得再把同一任务协作复制成每个内部意识与该对象的多条边;别名更不能彼此建边。",
|
|
2486
|
+
"23. 输出 JSON 数组。字段:fromCharacterId、toCharacterId、category(family/social/emotional/conflict/uncertain)、subtype、keywords、directed、currentStatus、timeRange、confidence、evidence。",
|
|
2487
|
+
"24. 共同执行一次任务、同属一个组织、在同一集体场景中被感谢或落泪、替第三人转发消息,都不能单独证明同事、朋友或盟友。此类关系必须有原文明示身份,或至少两个不同章节的持续互动证据。"
|
|
2488
|
+
].join("\n"),
|
|
2489
|
+
extraSystemPrompt: "关系候选必须可审计。严禁把梦境伴侣、醉后梦话、单次约定、同章共现、礼称、同族归属、救援照护或类比提及写成现实长期关系。逐句校验说话人和关系方向。"
|
|
2490
|
+
});
|
|
2491
|
+
const extracted = extractJson(generated.content);
|
|
2492
|
+
if (!Array.isArray(extracted))
|
|
2493
|
+
throw new AppError(502, "AI_INVALID_JSON", "人物关系分析结果必须是数组");
|
|
2494
|
+
return {
|
|
2495
|
+
candidates: extracted.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item)),
|
|
2496
|
+
callId: generated.callId
|
|
2497
|
+
};
|
|
2498
|
+
};
|
|
2499
|
+
const chunkResults = await this.processChunks(chunks, concurrency, async (chunk) => {
|
|
2500
|
+
if (taskId && this.store.getTask(taskId).status !== "running") {
|
|
2501
|
+
return { candidates: [], callIds: [], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
|
|
2502
|
+
}
|
|
2503
|
+
try {
|
|
2504
|
+
const extracted = await extractChunk(chunk.text, 1);
|
|
2505
|
+
return { candidates: extracted.candidates, callIds: [extracted.callId], fallbackSegmentCount: 0, policyOmittedSegmentCount: 0 };
|
|
2506
|
+
}
|
|
2507
|
+
catch {
|
|
2508
|
+
const segments = this.splitMarkedChapters(chunk.text);
|
|
2509
|
+
return this.runChapterSegmentFallback(segments, taskId, extractChunk, undefined, concurrency);
|
|
2510
|
+
}
|
|
2511
|
+
}, (completed) => {
|
|
2512
|
+
if (taskId && this.store.getTask(taskId).status === "running") {
|
|
2513
|
+
this.store.updateTask(taskId, { status: "running", progress: Math.min(92, 5 + Math.round(completed / chunks.length * 87)) });
|
|
2514
|
+
}
|
|
2515
|
+
});
|
|
2516
|
+
let fallbackSegmentCount = 0;
|
|
2517
|
+
let policyOmittedSegmentCount = 0;
|
|
2518
|
+
for (const result of chunkResults) {
|
|
2519
|
+
rawCandidates.push(...result.candidates);
|
|
2520
|
+
callIds.push(...result.callIds);
|
|
2521
|
+
fallbackSegmentCount += result.fallbackSegmentCount;
|
|
2522
|
+
policyOmittedSegmentCount += result.policyOmittedSegmentCount;
|
|
2523
|
+
}
|
|
2524
|
+
if (!this.taskCanCommit(taskId))
|
|
2525
|
+
return { interrupted: true, callIds };
|
|
2526
|
+
if (fallbackSegmentCount > 0 || callIds.length === 0) {
|
|
2527
|
+
throw new AppError(502, "RELATIONSHIP_ANALYSIS_INCOMPLETE", "人物关系分析存在未完成批次,已保留原有关系,请重试", {
|
|
2528
|
+
fallbackSegmentCount,
|
|
2529
|
+
policyOmittedSegmentCount,
|
|
2530
|
+
successfulCallCount: callIds.length,
|
|
2531
|
+
batchCount: chunks.length
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2534
|
+
const chapterById = new Map(chapters.map((chapter) => [String(chapter.id), chapter]));
|
|
2535
|
+
const categories = new Set(["family", "social", "emotional", "conflict", "uncertain"]);
|
|
2536
|
+
const merged = new Map();
|
|
2537
|
+
const skipped = [];
|
|
2538
|
+
rawCandidates.forEach((candidate, index) => {
|
|
2539
|
+
const fromRaw = candidate.fromCharacterId ?? candidate.fromCharacter;
|
|
2540
|
+
const toRaw = candidate.toCharacterId ?? candidate.toCharacter;
|
|
2541
|
+
const fromResolved = typeof fromRaw === "string"
|
|
2542
|
+
? (characters.some((character) => character.id === fromRaw) ? fromRaw : this.store.resolveCharacterReference(workId, fromRaw))
|
|
2543
|
+
: null;
|
|
2544
|
+
const toResolved = typeof toRaw === "string"
|
|
2545
|
+
? (characters.some((character) => character.id === toRaw) ? toRaw : this.store.resolveCharacterReference(workId, toRaw))
|
|
2546
|
+
: null;
|
|
2547
|
+
if (!fromResolved || !toResolved || fromResolved === toResolved) {
|
|
2548
|
+
skipped.push({ index, reason: "人物引用无效" });
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
if (typeof candidate.category !== "string" || !categories.has(candidate.category)) {
|
|
2552
|
+
skipped.push({ index, reason: "关系分类无效" });
|
|
2553
|
+
return;
|
|
2554
|
+
}
|
|
2555
|
+
const reportedCategory = candidate.category;
|
|
2556
|
+
const rawSubtype = typeof candidate.subtype === "string" ? candidate.subtype.trim() : "";
|
|
2557
|
+
if (!rawSubtype) {
|
|
2558
|
+
skipped.push({ index, reason: "缺少长期关系子类" });
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2561
|
+
const category = canonicalizeRelationshipCategory(reportedCategory, rawSubtype);
|
|
2562
|
+
let subtype = canonicalizeRelationshipSubtype(category, rawSubtype);
|
|
2563
|
+
const currentStatus = typeof candidate.currentStatus === "string" ? candidate.currentStatus.trim() : "active";
|
|
2564
|
+
const keywords = this.normalizeRelationshipKeywords(candidate.keywords, subtype);
|
|
2565
|
+
const confidence = typeof candidate.confidence === "number" ? clamp(candidate.confidence, 0, 1) : 0;
|
|
2566
|
+
if (confidence < 0.6) {
|
|
2567
|
+
skipped.push({ index, reason: "置信度低于 0.6" });
|
|
2568
|
+
return;
|
|
2569
|
+
}
|
|
2570
|
+
const directed = candidate.directed === true;
|
|
2571
|
+
let fromCharacterId = fromResolved;
|
|
2572
|
+
let toCharacterId = toResolved;
|
|
2573
|
+
if (directed && reversesHierarchyDirection(rawSubtype))
|
|
2574
|
+
[fromCharacterId, toCharacterId] = [toCharacterId, fromCharacterId];
|
|
2575
|
+
if (!directed && fromCharacterId.localeCompare(toCharacterId) > 0)
|
|
2576
|
+
[fromCharacterId, toCharacterId] = [toCharacterId, fromCharacterId];
|
|
2577
|
+
const evidence = (Array.isArray(candidate.evidence) ? candidate.evidence : [])
|
|
2578
|
+
.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
|
|
2579
|
+
.filter((item) => {
|
|
2580
|
+
if (typeof item.chapterId !== "string" || typeof item.quote !== "string" || item.quote.trim().length > 80)
|
|
2581
|
+
return false;
|
|
2582
|
+
const chapter = chapterById.get(item.chapterId);
|
|
2583
|
+
return Boolean(chapter && this.quoteExists(String(chapter.content), item.quote));
|
|
2584
|
+
})
|
|
2585
|
+
.map((item) => ({
|
|
2586
|
+
chapterId: item.chapterId,
|
|
2587
|
+
chapterTitle: String(chapterById.get(String(item.chapterId))?.title ?? ""),
|
|
2588
|
+
quote: String(item.quote).trim(),
|
|
2589
|
+
contextType: typeof item.contextType === "string" ? item.contextType : "current",
|
|
2590
|
+
supports: typeof item.supports === "string" ? item.supports : ""
|
|
2591
|
+
}));
|
|
2592
|
+
if (evidence.length === 0) {
|
|
2593
|
+
skipped.push({ index, reason: "证据引文未在对应章节原文命中" });
|
|
2594
|
+
return;
|
|
2595
|
+
}
|
|
2596
|
+
const evidenceText = evidence.map((item) => String(item.quote)).join("\n");
|
|
2597
|
+
if (category === "family" && ["父母子女", "收养亲子", "手足", "叔侄"].includes(subtype)) {
|
|
2598
|
+
const explicitKinship = /父亲|母亲|爸爸|妈妈|父子|父女|母子|母女|儿子|女儿|孩子|亲生|收养|养父|养母|养子|养女|兄弟|姐妹|哥哥|弟弟|姐姐|妹妹|手足|叔叔|叔父|侄|姑姑|舅舅|外甥/u.test(evidenceText);
|
|
2599
|
+
if (!explicitKinship) {
|
|
2600
|
+
skipped.push({ index, reason: "血亲关系缺少明确亲属称谓、出生或收养证据" });
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
if (category === "social" && subtype === "君臣") {
|
|
2605
|
+
const hasAuthority = /君王|女王|国王|陛下|领主|统治者|首领/u.test(evidenceText);
|
|
2606
|
+
const hasObedience = /效忠|臣属|臣服|服从|听命|领命|奉命|遵命|命令|下令|宣誓|跪拜|麾下|部下|属下/u.test(evidenceText);
|
|
2607
|
+
if (!hasAuthority || !hasObedience) {
|
|
2608
|
+
skipped.push({ index, reason: "君臣关系缺少权力身份与效忠、命令或服从的双重证据" });
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
if (category === "conflict" && subtype === "宿敌") {
|
|
2613
|
+
const evidenceChapters = new Set(evidence.map((item) => String(item.chapterId)));
|
|
2614
|
+
const explicitlyLongRunning = /宿敌|世仇|死敌|多年|长期|世代|一直.{0,24}(?:敌|威胁|对抗|杀手)|远古.{0,16}(?:战|敌)|多次.{0,16}(?:交战|对抗|冲突)/u.test(evidenceText);
|
|
2615
|
+
if (evidenceChapters.size < 2 && !explicitlyLongRunning)
|
|
2616
|
+
subtype = "战时敌对";
|
|
2617
|
+
}
|
|
2618
|
+
const key = [fromCharacterId, toCharacterId, category, this.normalizeReference(subtype), directed ? "1" : "0"].join("|");
|
|
2619
|
+
const current = merged.get(key);
|
|
2620
|
+
if (current) {
|
|
2621
|
+
current.confidence = Math.max(current.confidence, confidence);
|
|
2622
|
+
current.currentStatus = currentStatus || current.currentStatus;
|
|
2623
|
+
current.keywords = [...new Set([...current.keywords, ...keywords])].slice(0, 8);
|
|
2624
|
+
const seenEvidence = new Set(current.evidence.map((item) => `${String(item.chapterId)}|${String(item.quote)}`));
|
|
2625
|
+
for (const item of evidence) {
|
|
2626
|
+
const evidenceKey = `${String(item.chapterId)}|${String(item.quote)}`;
|
|
2627
|
+
if (!seenEvidence.has(evidenceKey))
|
|
2628
|
+
current.evidence.push(item);
|
|
2629
|
+
}
|
|
2630
|
+
return;
|
|
2631
|
+
}
|
|
2632
|
+
merged.set(key, {
|
|
2633
|
+
fromCharacterId,
|
|
2634
|
+
toCharacterId,
|
|
2635
|
+
category,
|
|
2636
|
+
subtype,
|
|
2637
|
+
keywords,
|
|
2638
|
+
directed,
|
|
2639
|
+
currentStatus,
|
|
2640
|
+
timeRange: candidate.timeRange && typeof candidate.timeRange === "object" && !Array.isArray(candidate.timeRange)
|
|
2641
|
+
? candidate.timeRange
|
|
2642
|
+
: {},
|
|
2643
|
+
confidence,
|
|
2644
|
+
evidence
|
|
2645
|
+
});
|
|
2646
|
+
});
|
|
2647
|
+
for (const [key, candidate] of merged) {
|
|
2648
|
+
const durablePeerSubtype = /同事|同僚|共事|搭档|伙伴|朋友|好友|挚友|老友|旧友|战友|盟友|同盟|联盟/u.test(candidate.subtype);
|
|
2649
|
+
if (candidate.category !== "social" || !durablePeerSubtype)
|
|
2650
|
+
continue;
|
|
2651
|
+
const evidenceChapters = new Set(candidate.evidence.map((item) => String(item.chapterId)));
|
|
2652
|
+
const evidenceText = candidate.evidence.map((item) => String(item.quote)).join("\n");
|
|
2653
|
+
const explicitlyLongRunning = /同事|同僚|共事|搭档|伙伴|朋友|好友|挚友|老友|旧友|老朋友|战友|盟友|同盟|联盟|结盟|缔盟|盟约|旧识|好久不见|多年|长期|几十年|经常|往日|一直.{0,16}(?:合作|支援|互助|并肩)/u.test(evidenceText);
|
|
2654
|
+
if (evidenceChapters.size >= 2 || explicitlyLongRunning)
|
|
2655
|
+
continue;
|
|
2656
|
+
skipped.push({ index: -1, reason: `“${candidate.subtype}”缺少明确身份或跨章长期互动证据` });
|
|
2657
|
+
merged.delete(key);
|
|
2658
|
+
}
|
|
2659
|
+
const relationshipIds = [];
|
|
2660
|
+
this.store.db.transaction(() => {
|
|
2661
|
+
if (scope.type === "book") {
|
|
2662
|
+
this.store.db.run("DELETE FROM relationships WHERE work_id = ? AND confirmation_status = 'pending' AND locked = 0", workId);
|
|
2663
|
+
}
|
|
2664
|
+
const existing = this.store.listRelationships(workId).filter((relationship) => relationship.confirmationStatus !== "rejected");
|
|
2665
|
+
const unorderedPairKey = (fromCharacterId, toCharacterId) => {
|
|
2666
|
+
const pair = [String(fromCharacterId), String(toCharacterId)].sort((left, right) => left.localeCompare(right));
|
|
2667
|
+
return `${pair[0]}|${pair[1]}`;
|
|
2668
|
+
};
|
|
2669
|
+
const relationshipHasEnded = (relationship) => {
|
|
2670
|
+
const status = String(relationship.currentStatus ?? "").trim();
|
|
2671
|
+
if (/未结束|尚未结束|没有结束|未终止|尚未终止|没有终止|未死亡|尚未死亡|没有死亡|\bnot\s+(?:ended|completed|dead|deceased)\b|\bstill\s+alive\b/iu.test(status))
|
|
2672
|
+
return false;
|
|
2673
|
+
if (/已结束|关系结束|已终止|关系终止|已死|死亡|去世|离世|至死亡/iu.test(status))
|
|
2674
|
+
return true;
|
|
2675
|
+
if (/\b(?:active|ongoing|reconciled|established|stable)\b|仍在|持续|现阶段|当前/iu.test(status))
|
|
2676
|
+
return false;
|
|
2677
|
+
if (/\b(?:ended|completed|historical|deceased|dead)\b/iu.test(status))
|
|
2678
|
+
return true;
|
|
2679
|
+
return /历史关系|曾经在一起/iu.test(status);
|
|
2680
|
+
};
|
|
2681
|
+
const allCandidates = [...existing, ...merged.values()];
|
|
2682
|
+
const endedPartnerPairs = new Set(allCandidates
|
|
2683
|
+
.filter((relationship) => relationshipHasEnded(relationship)
|
|
2684
|
+
&& relationship.category === "emotional"
|
|
2685
|
+
&& canonicalizeRelationshipSubtype("emotional", String(relationship.subtype)) === "伴侣")
|
|
2686
|
+
.map((relationship) => unorderedPairKey(relationship.fromCharacterId, relationship.toCharacterId)));
|
|
2687
|
+
const currentPartnerPairs = new Set(allCandidates
|
|
2688
|
+
.filter((relationship) => !relationshipHasEnded(relationship)
|
|
2689
|
+
&& relationship.category === "emotional"
|
|
2690
|
+
&& canonicalizeRelationshipSubtype("emotional", String(relationship.subtype)) === "伴侣")
|
|
2691
|
+
.map((relationship) => unorderedPairKey(relationship.fromCharacterId, relationship.toCharacterId)));
|
|
2692
|
+
const endedEnemyPairs = new Set(allCandidates
|
|
2693
|
+
.filter((relationship) => relationshipHasEnded(relationship)
|
|
2694
|
+
&& relationship.category === "conflict"
|
|
2695
|
+
&& canonicalizeRelationshipSubtype("conflict", String(relationship.subtype)) === "宿敌")
|
|
2696
|
+
.map((relationship) => unorderedPairKey(relationship.fromCharacterId, relationship.toCharacterId)));
|
|
2697
|
+
const currentEnemyPairs = new Set(allCandidates
|
|
2698
|
+
.filter((relationship) => !relationshipHasEnded(relationship)
|
|
2699
|
+
&& relationship.category === "conflict"
|
|
2700
|
+
&& canonicalizeRelationshipSubtype("conflict", String(relationship.subtype)) === "宿敌")
|
|
2701
|
+
.map((relationship) => unorderedPairKey(relationship.fromCharacterId, relationship.toCharacterId)));
|
|
2702
|
+
const endedFamilyLikePairs = new Set(allCandidates
|
|
2703
|
+
.filter((relationship) => {
|
|
2704
|
+
if (!relationshipHasEnded(relationship))
|
|
2705
|
+
return false;
|
|
2706
|
+
const subtype = canonicalizeRelationshipSubtype(String(relationship.category), String(relationship.subtype));
|
|
2707
|
+
return relationship.category === "family" || /父母|亲子|手足|兄弟|姐妹|姐弟|叔侄|监护/u.test(subtype);
|
|
2708
|
+
})
|
|
2709
|
+
.map((relationship) => unorderedPairKey(relationship.fromCharacterId, relationship.toCharacterId)));
|
|
2710
|
+
const currentFamilyLikePairs = new Set(allCandidates
|
|
2711
|
+
.filter((relationship) => {
|
|
2712
|
+
if (relationshipHasEnded(relationship))
|
|
2713
|
+
return false;
|
|
2714
|
+
const subtype = canonicalizeRelationshipSubtype(String(relationship.category), String(relationship.subtype));
|
|
2715
|
+
return relationship.category === "family" || /父母|亲子|手足|兄弟|姐妹|姐弟|叔侄|监护/u.test(subtype);
|
|
2716
|
+
})
|
|
2717
|
+
.map((relationship) => unorderedPairKey(relationship.fromCharacterId, relationship.toCharacterId)));
|
|
2718
|
+
const peerSocialStrength = (relationship) => {
|
|
2719
|
+
if (relationship.category !== "social")
|
|
2720
|
+
return 0;
|
|
2721
|
+
const subtype = canonicalizeRelationshipSubtype("social", String(relationship.subtype));
|
|
2722
|
+
if (/盟友|挚友/u.test(subtype))
|
|
2723
|
+
return 3;
|
|
2724
|
+
if (/朋友|战友|搭档|合作伙伴/u.test(subtype))
|
|
2725
|
+
return 2;
|
|
2726
|
+
if (/同事|同僚|共事/u.test(subtype))
|
|
2727
|
+
return 1;
|
|
2728
|
+
return 0;
|
|
2729
|
+
};
|
|
2730
|
+
const strongestEndedPeerSocialByPair = new Map();
|
|
2731
|
+
const strongestCurrentPeerSocialByPair = new Map();
|
|
2732
|
+
for (const relationship of allCandidates) {
|
|
2733
|
+
const pair = unorderedPairKey(relationship.fromCharacterId, relationship.toCharacterId);
|
|
2734
|
+
if (relationshipHasEnded(relationship)) {
|
|
2735
|
+
strongestEndedPeerSocialByPair.set(pair, Math.max(strongestEndedPeerSocialByPair.get(pair) ?? 0, peerSocialStrength(relationship)));
|
|
2736
|
+
}
|
|
2737
|
+
else {
|
|
2738
|
+
strongestCurrentPeerSocialByPair.set(pair, Math.max(strongestCurrentPeerSocialByPair.get(pair) ?? 0, peerSocialStrength(relationship)));
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
for (const candidate of merged.values()) {
|
|
2742
|
+
const candidatePair = unorderedPairKey(candidate.fromCharacterId, candidate.toCharacterId);
|
|
2743
|
+
const candidateEnded = relationshipHasEnded(candidate);
|
|
2744
|
+
const relevantPartnerPairs = candidateEnded ? endedPartnerPairs : currentPartnerPairs;
|
|
2745
|
+
const relevantEnemyPairs = candidateEnded ? endedEnemyPairs : currentEnemyPairs;
|
|
2746
|
+
const relevantFamilyLikePairs = candidateEnded ? endedFamilyLikePairs : currentFamilyLikePairs;
|
|
2747
|
+
const relevantPeerStrength = candidateEnded ? strongestEndedPeerSocialByPair : strongestCurrentPeerSocialByPair;
|
|
2748
|
+
const weakerThanPartner = (candidate.category === "emotional" && ["倾慕", "亲密羁绊"].includes(candidate.subtype))
|
|
2749
|
+
|| (candidate.category === "social" && candidate.subtype === "朋友");
|
|
2750
|
+
if (weakerThanPartner && relevantPartnerPairs.has(candidatePair)) {
|
|
2751
|
+
skipped.push({ index: -1, reason: `已有伴侣关系,忽略较弱的“${candidate.subtype}”重复边` });
|
|
2752
|
+
continue;
|
|
2753
|
+
}
|
|
2754
|
+
const weakerEncounterConflict = ["施害与受害", "战时敌对", "围攻与反击", "追杀与反击", "单次交锋"].includes(candidate.subtype);
|
|
2755
|
+
if (candidate.category === "conflict" && weakerEncounterConflict && relevantEnemyPairs.has(candidatePair)) {
|
|
2756
|
+
skipped.push({ index: -1, reason: `已有宿敌关系,忽略较弱的“${candidate.subtype}”重复边` });
|
|
2757
|
+
continue;
|
|
2758
|
+
}
|
|
2759
|
+
const weakerThanFamilyLike = (candidate.category === "emotional" && candidate.subtype === "亲密羁绊")
|
|
2760
|
+
|| (candidate.category === "social" && ["同事", "朋友"].includes(candidate.subtype));
|
|
2761
|
+
if (weakerThanFamilyLike && relevantFamilyLikePairs.has(candidatePair)) {
|
|
2762
|
+
skipped.push({ index: -1, reason: `已有亲属或监护关系,忽略较弱的“${candidate.subtype}”重复边` });
|
|
2763
|
+
continue;
|
|
2764
|
+
}
|
|
2765
|
+
const candidatePeerStrength = peerSocialStrength(candidate);
|
|
2766
|
+
if (candidatePeerStrength > 0 && (relevantPeerStrength.get(candidatePair) ?? 0) > candidatePeerStrength) {
|
|
2767
|
+
skipped.push({ index: -1, reason: `已有更强的同级社会关系,忽略较弱的“${candidate.subtype}”重复边` });
|
|
2768
|
+
continue;
|
|
2769
|
+
}
|
|
2770
|
+
const duplicateIndex = existing.findIndex((relationship) => {
|
|
2771
|
+
const same = relationship.fromCharacterId === candidate.fromCharacterId && relationship.toCharacterId === candidate.toCharacterId;
|
|
2772
|
+
const reverse = !candidate.directed && !relationship.directed
|
|
2773
|
+
&& relationship.fromCharacterId === candidate.toCharacterId && relationship.toCharacterId === candidate.fromCharacterId;
|
|
2774
|
+
return (same || reverse)
|
|
2775
|
+
&& Boolean(relationship.directed) === candidate.directed
|
|
2776
|
+
&& relationship.category === candidate.category
|
|
2777
|
+
&& this.normalizeReference(canonicalizeRelationshipSubtype(String(relationship.category), String(relationship.subtype)))
|
|
2778
|
+
=== this.normalizeReference(candidate.subtype);
|
|
2779
|
+
});
|
|
2780
|
+
if (duplicateIndex >= 0) {
|
|
2781
|
+
const duplicate = existing[duplicateIndex];
|
|
2782
|
+
if (duplicate.confirmationStatus === "pending" && duplicate.locked !== true) {
|
|
2783
|
+
const mergedEvidence = [...(duplicate.evidence ?? [])];
|
|
2784
|
+
const seenEvidence = new Set(mergedEvidence.map((item) => `${String(item.chapterId)}|${String(item.quote)}`));
|
|
2785
|
+
for (const item of candidate.evidence) {
|
|
2786
|
+
const evidenceKey = `${String(item.chapterId)}|${String(item.quote)}`;
|
|
2787
|
+
if (!seenEvidence.has(evidenceKey))
|
|
2788
|
+
mergedEvidence.push(item);
|
|
2789
|
+
}
|
|
2790
|
+
existing[duplicateIndex] = this.store.updateRelationship(String(duplicate.id), {
|
|
2791
|
+
subtype: candidate.subtype,
|
|
2792
|
+
keywords: [...new Set([...(duplicate.keywords ?? []), ...candidate.keywords])].slice(0, 8),
|
|
2793
|
+
confidence: Math.max(Number(duplicate.confidence ?? 0), candidate.confidence),
|
|
2794
|
+
currentStatus: candidate.currentStatus,
|
|
2795
|
+
timeRange: candidate.timeRange,
|
|
2796
|
+
evidence: mergedEvidence
|
|
2797
|
+
}, "analysis", taskId ?? null, "AI 合并关系证据");
|
|
2798
|
+
}
|
|
2799
|
+
if (candidatePeerStrength > 0) {
|
|
2800
|
+
for (let index = existing.length - 1; index >= 0; index -= 1) {
|
|
2801
|
+
const relationship = existing[index];
|
|
2802
|
+
if (String(relationship.id) === String(duplicate.id)
|
|
2803
|
+
|| unorderedPairKey(relationship.fromCharacterId, relationship.toCharacterId) !== candidatePair
|
|
2804
|
+
|| relationship.category !== "social"
|
|
2805
|
+
|| Boolean(relationship.directed) !== candidate.directed
|
|
2806
|
+
|| relationship.confirmationStatus !== "pending"
|
|
2807
|
+
|| relationship.locked === true
|
|
2808
|
+
|| relationshipHasEnded(relationship) !== candidateEnded
|
|
2809
|
+
|| peerSocialStrength(relationship) <= 0
|
|
2810
|
+
|| peerSocialStrength(relationship) >= candidatePeerStrength)
|
|
2811
|
+
continue;
|
|
2812
|
+
this.store.deleteRelationship(String(relationship.id));
|
|
2813
|
+
existing.splice(index, 1);
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
continue;
|
|
2817
|
+
}
|
|
2818
|
+
const weakerExistingPeerIndex = candidatePeerStrength > 0
|
|
2819
|
+
? existing.findIndex((relationship) => unorderedPairKey(relationship.fromCharacterId, relationship.toCharacterId) === candidatePair
|
|
2820
|
+
&& relationship.category === "social"
|
|
2821
|
+
&& Boolean(relationship.directed) === candidate.directed
|
|
2822
|
+
&& relationship.confirmationStatus === "pending"
|
|
2823
|
+
&& relationship.locked !== true
|
|
2824
|
+
&& relationshipHasEnded(relationship) === candidateEnded
|
|
2825
|
+
&& peerSocialStrength(relationship) > 0
|
|
2826
|
+
&& peerSocialStrength(relationship) < candidatePeerStrength)
|
|
2827
|
+
: -1;
|
|
2828
|
+
if (weakerExistingPeerIndex >= 0) {
|
|
2829
|
+
const weaker = existing[weakerExistingPeerIndex];
|
|
2830
|
+
const mergedEvidence = [...(weaker.evidence ?? [])];
|
|
2831
|
+
const seenEvidence = new Set(mergedEvidence.map((item) => `${String(item.chapterId)}|${String(item.quote)}`));
|
|
2832
|
+
for (const item of candidate.evidence) {
|
|
2833
|
+
const evidenceKey = `${String(item.chapterId)}|${String(item.quote)}`;
|
|
2834
|
+
if (!seenEvidence.has(evidenceKey))
|
|
2835
|
+
mergedEvidence.push(item);
|
|
2836
|
+
}
|
|
2837
|
+
existing[weakerExistingPeerIndex] = this.store.updateRelationship(String(weaker.id), {
|
|
2838
|
+
subtype: candidate.subtype,
|
|
2839
|
+
keywords: [...new Set([...(weaker.keywords ?? []), ...candidate.keywords])].slice(0, 8),
|
|
2840
|
+
confidence: Math.max(Number(weaker.confidence ?? 0), candidate.confidence),
|
|
2841
|
+
currentStatus: candidate.currentStatus,
|
|
2842
|
+
timeRange: candidate.timeRange,
|
|
2843
|
+
evidence: mergedEvidence
|
|
2844
|
+
}, "analysis", taskId ?? null, "AI 更新关系强度");
|
|
2845
|
+
continue;
|
|
2846
|
+
}
|
|
2847
|
+
const relationship = this.store.createRelationship(workId, { ...candidate, confirmationStatus: "pending", locked: false }, "analysis", taskId ?? null);
|
|
2848
|
+
relationshipIds.push(String(relationship.id));
|
|
2849
|
+
existing.push(relationship);
|
|
2850
|
+
}
|
|
2851
|
+
});
|
|
2852
|
+
this.store.audit(workId, "relationship.analysis.completed", "work", workId, {
|
|
2853
|
+
batchCount: chunks.length,
|
|
2854
|
+
coveredChapterCount: chapters.length,
|
|
2855
|
+
rawCandidateCount: rawCandidates.length,
|
|
2856
|
+
savedCount: relationshipIds.length,
|
|
2857
|
+
skippedCount: skipped.length,
|
|
2858
|
+
fallbackSegmentCount,
|
|
2859
|
+
policyOmittedSegmentCount,
|
|
2860
|
+
scopeType: scope.type
|
|
2861
|
+
});
|
|
2862
|
+
return {
|
|
2863
|
+
relationshipIds,
|
|
2864
|
+
candidateCount: relationshipIds.length,
|
|
2865
|
+
rawCandidateCount: rawCandidates.length,
|
|
2866
|
+
skipped,
|
|
2867
|
+
batchCount: chunks.length,
|
|
2868
|
+
coveredChapterCount: chapters.length,
|
|
2869
|
+
fallbackSegmentCount,
|
|
2870
|
+
policyOmittedSegmentCount,
|
|
2871
|
+
callIds
|
|
2872
|
+
};
|
|
2873
|
+
}
|
|
2874
|
+
getScopeChapters(workId, scope) {
|
|
2875
|
+
const tree = this.store.getWorkTree(workId);
|
|
2876
|
+
const volumes = tree.volumes;
|
|
2877
|
+
if (scope.type === "chapter") {
|
|
2878
|
+
if (!scope.chapterId)
|
|
2879
|
+
throw new AppError(400, "CHAPTER_REQUIRED", "分析范围缺少章节标识");
|
|
2880
|
+
const chapter = this.store.getChapter(scope.chapterId);
|
|
2881
|
+
if (chapter.workId !== workId)
|
|
2882
|
+
throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
|
|
2883
|
+
return [chapter];
|
|
2884
|
+
}
|
|
2885
|
+
if (scope.type === "volume") {
|
|
2886
|
+
if (!scope.volumeId)
|
|
2887
|
+
throw new AppError(400, "VOLUME_REQUIRED", "分析范围缺少卷标识");
|
|
2888
|
+
const volume = volumes.find((item) => item.id === scope.volumeId);
|
|
2889
|
+
if (!volume)
|
|
2890
|
+
throw notFound("卷");
|
|
2891
|
+
return volume.chapters.filter((chapter) => this.isAutomaticAnalysisChapter(chapter));
|
|
2892
|
+
}
|
|
2893
|
+
return volumes.flatMap((volume) => volume.chapters)
|
|
2894
|
+
.filter((chapter) => this.isAutomaticAnalysisChapter(chapter));
|
|
2895
|
+
}
|
|
2896
|
+
validateAnalysisEvidence(chapters, value) {
|
|
2897
|
+
const chaptersById = new Map(chapters.map((chapter) => [String(chapter.id), chapter]));
|
|
2898
|
+
return (Array.isArray(value) ? value : []).flatMap((candidate) => {
|
|
2899
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
|
|
2900
|
+
return [];
|
|
2901
|
+
const evidence = candidate;
|
|
2902
|
+
const chapterId = typeof evidence.chapterId === "string" ? evidence.chapterId : "";
|
|
2903
|
+
const quote = typeof evidence.quote === "string" ? evidence.quote.trim().slice(0, 120) : "";
|
|
2904
|
+
let chapter = chaptersById.get(chapterId);
|
|
2905
|
+
if (!chapter) {
|
|
2906
|
+
const normalizeTitle = (input) => String(input ?? "").normalize("NFKC").replace(/[\s·::—_\-]/gu, "").toLocaleLowerCase("zh-CN");
|
|
2907
|
+
const idReference = normalizeTitle(chapterId);
|
|
2908
|
+
const titleReference = normalizeTitle(evidence.chapterTitle);
|
|
2909
|
+
const matches = chapters.filter((item) => {
|
|
2910
|
+
const actualTitle = normalizeTitle(item.title);
|
|
2911
|
+
return (idReference.length >= 2 && actualTitle.includes(idReference))
|
|
2912
|
+
|| (titleReference.length >= 2 && actualTitle.includes(titleReference));
|
|
2913
|
+
});
|
|
2914
|
+
if (matches.length === 1)
|
|
2915
|
+
chapter = matches[0];
|
|
2916
|
+
}
|
|
2917
|
+
if (!chapter || !this.quoteExists(String(chapter.content), quote))
|
|
2918
|
+
return [];
|
|
2919
|
+
return [{ chapterId: String(chapter.id), chapterTitle: String(chapter.title), quote }];
|
|
2920
|
+
});
|
|
2921
|
+
}
|
|
2922
|
+
isAutomaticAnalysisChapter(chapter) {
|
|
2923
|
+
return !chapter.excludedFromAnalysis && chapter.chapterType !== "作者的话";
|
|
2924
|
+
}
|
|
2925
|
+
buildChapterChunks(chapters, maximumChars = 10_000) {
|
|
2926
|
+
const chunks = [];
|
|
2927
|
+
let text = "";
|
|
2928
|
+
let chapterIds = [];
|
|
2929
|
+
const flush = () => {
|
|
2930
|
+
if (!text)
|
|
2931
|
+
return;
|
|
2932
|
+
chunks.push({ text, chapterIds });
|
|
2933
|
+
text = "";
|
|
2934
|
+
chapterIds = [];
|
|
2935
|
+
};
|
|
2936
|
+
for (const chapter of chapters) {
|
|
2937
|
+
const header = `\n<CHAPTER id="${String(chapter.id)}" title="${String(chapter.title).replaceAll('"', "'")}">\n`;
|
|
2938
|
+
const footer = "\n</CHAPTER>\n";
|
|
2939
|
+
const content = String(chapter.content);
|
|
2940
|
+
const block = `${header}${content}${footer}`;
|
|
2941
|
+
if (text && text.length + block.length > maximumChars)
|
|
2942
|
+
flush();
|
|
2943
|
+
if (block.length <= maximumChars) {
|
|
2944
|
+
text += block;
|
|
2945
|
+
chapterIds.push(String(chapter.id));
|
|
2946
|
+
continue;
|
|
2947
|
+
}
|
|
2948
|
+
const segmentSize = Math.max(1000, maximumChars - header.length - footer.length - 80);
|
|
2949
|
+
for (let offset = 0; offset < content.length; offset += segmentSize) {
|
|
2950
|
+
flush();
|
|
2951
|
+
const part = Math.floor(offset / segmentSize) + 1;
|
|
2952
|
+
text = `${header.replace("<CHAPTER ", `<CHAPTER part="${part}" `)}${content.slice(offset, offset + segmentSize)}${footer}`;
|
|
2953
|
+
chapterIds = [String(chapter.id)];
|
|
2954
|
+
flush();
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
flush();
|
|
2958
|
+
return chunks;
|
|
2959
|
+
}
|
|
2960
|
+
splitMarkedChapters(text) {
|
|
2961
|
+
const segments = text.match(/<CHAPTER\b[^>]*>[\s\S]*?<\/CHAPTER>/gu) ?? [];
|
|
2962
|
+
return segments.length > 0 ? segments : [text];
|
|
2963
|
+
}
|
|
2964
|
+
splitMarkedChapterFragments(markedText, maximumChars = 800, overlapChars = 80) {
|
|
2965
|
+
const opening = markedText.match(/<CHAPTER\b([^>]*)>/u);
|
|
2966
|
+
if (!opening || opening.index === undefined)
|
|
2967
|
+
return [markedText];
|
|
2968
|
+
const attributes = opening[1] ?? "";
|
|
2969
|
+
const chapterId = attributes.match(/\bid="([^"]+)"/u)?.[1];
|
|
2970
|
+
const chapterTitle = attributes.match(/\btitle="([^"]*)"/u)?.[1] ?? "";
|
|
2971
|
+
const contentStart = opening.index + opening[0].length;
|
|
2972
|
+
const contentEnd = markedText.lastIndexOf("</CHAPTER>");
|
|
2973
|
+
if (!chapterId || contentEnd <= contentStart)
|
|
2974
|
+
return [markedText];
|
|
2975
|
+
const content = markedText.slice(contentStart, contentEnd).replace(/^\s+/u, "").replace(/\s+$/u, "");
|
|
2976
|
+
if (content.length <= maximumChars)
|
|
2977
|
+
return [markedText];
|
|
2978
|
+
const pieces = [];
|
|
2979
|
+
let start = 0;
|
|
2980
|
+
while (start < content.length) {
|
|
2981
|
+
let end = Math.min(content.length, start + maximumChars);
|
|
2982
|
+
if (end < content.length) {
|
|
2983
|
+
const minimumChunkSize = Math.max(40, Math.min(600, Math.floor(maximumChars * 0.75)));
|
|
2984
|
+
const minimumTailSize = Math.max(40, Math.min(400, Math.floor(maximumChars / 2)));
|
|
2985
|
+
const minimumEnd = Math.min(end, start + minimumChunkSize);
|
|
2986
|
+
const boundaryText = content.slice(minimumEnd, end);
|
|
2987
|
+
let boundary = -1;
|
|
2988
|
+
for (const match of boundaryText.matchAll(/[。!?!?;;\n]/gu))
|
|
2989
|
+
boundary = match.index ?? boundary;
|
|
2990
|
+
if (boundary >= 0)
|
|
2991
|
+
end = minimumEnd + boundary + 1;
|
|
2992
|
+
if (content.length - Math.max(start + 1, end - overlapChars) < minimumTailSize)
|
|
2993
|
+
end = content.length;
|
|
2994
|
+
}
|
|
2995
|
+
pieces.push(content.slice(start, end));
|
|
2996
|
+
if (end >= content.length)
|
|
2997
|
+
break;
|
|
2998
|
+
start = Math.max(start + 1, end - overlapChars);
|
|
2999
|
+
}
|
|
3000
|
+
return pieces.map((piece, index) => [
|
|
3001
|
+
`<CHAPTER id="${chapterId}" title="${chapterTitle}" fragment="${index + 1}/${pieces.length}">`,
|
|
3002
|
+
piece,
|
|
3003
|
+
"</CHAPTER>"
|
|
3004
|
+
].join("\n"));
|
|
3005
|
+
}
|
|
3006
|
+
async runChapterSegmentFallback(segments, taskId, extractChunk, minimumFallback, concurrency = 10) {
|
|
3007
|
+
const candidates = [];
|
|
3008
|
+
const callIds = [];
|
|
3009
|
+
let fallbackSegmentCount = 0;
|
|
3010
|
+
let policyOmittedSegmentCount = 0;
|
|
3011
|
+
const chapterResults = await this.processChunks(segments, concurrency, async (segment) => {
|
|
3012
|
+
if (taskId && this.store.getTask(taskId).status !== "running") {
|
|
3013
|
+
return { candidates: [], callId: null, failedSegment: null };
|
|
3014
|
+
}
|
|
3015
|
+
try {
|
|
3016
|
+
const extracted = await extractChunk(segment);
|
|
3017
|
+
return { candidates: extracted.candidates, callId: extracted.callId, failedSegment: null };
|
|
3018
|
+
}
|
|
3019
|
+
catch {
|
|
3020
|
+
return { candidates: [], callId: null, failedSegment: segment };
|
|
3021
|
+
}
|
|
3022
|
+
});
|
|
3023
|
+
const fragments = [];
|
|
3024
|
+
for (const result of chapterResults) {
|
|
3025
|
+
candidates.push(...result.candidates);
|
|
3026
|
+
if (result.callId)
|
|
3027
|
+
callIds.push(result.callId);
|
|
3028
|
+
if (!result.failedSegment)
|
|
3029
|
+
continue;
|
|
3030
|
+
const split = this.splitMarkedChapterFragments(result.failedSegment);
|
|
3031
|
+
fragments.push(...split);
|
|
3032
|
+
}
|
|
3033
|
+
const fragmentResults = await this.processChunks(fragments, concurrency, async (fragment) => {
|
|
3034
|
+
if (taskId && this.store.getTask(taskId).status !== "running") {
|
|
3035
|
+
return { candidates: [], callId: null, failedSegment: null };
|
|
3036
|
+
}
|
|
3037
|
+
try {
|
|
3038
|
+
const extracted = await extractChunk(fragment);
|
|
3039
|
+
return { candidates: extracted.candidates, callId: extracted.callId, failedSegment: null };
|
|
3040
|
+
}
|
|
3041
|
+
catch {
|
|
3042
|
+
return { candidates: [], callId: null, failedSegment: fragment };
|
|
3043
|
+
}
|
|
3044
|
+
});
|
|
3045
|
+
const microFragments = [];
|
|
3046
|
+
for (const result of fragmentResults) {
|
|
3047
|
+
candidates.push(...result.candidates);
|
|
3048
|
+
if (result.callId)
|
|
3049
|
+
callIds.push(result.callId);
|
|
3050
|
+
if (!result.failedSegment)
|
|
3051
|
+
continue;
|
|
3052
|
+
const split = this.splitMarkedChapterFragments(result.failedSegment, 240, 32);
|
|
3053
|
+
microFragments.push(...split);
|
|
3054
|
+
}
|
|
3055
|
+
const microResults = await this.processChunks(microFragments, concurrency, async (fragment) => {
|
|
3056
|
+
if (taskId && this.store.getTask(taskId).status !== "running") {
|
|
3057
|
+
return { candidates: [], callId: null, failedSegment: null };
|
|
3058
|
+
}
|
|
3059
|
+
try {
|
|
3060
|
+
const extracted = await extractChunk(fragment, 5);
|
|
3061
|
+
return { candidates: extracted.candidates, callId: extracted.callId, failedSegment: null };
|
|
3062
|
+
}
|
|
3063
|
+
catch {
|
|
3064
|
+
return { candidates: [], callId: null, failedSegment: fragment };
|
|
3065
|
+
}
|
|
3066
|
+
});
|
|
3067
|
+
const tinyFragments = [];
|
|
3068
|
+
for (const result of microResults) {
|
|
3069
|
+
candidates.push(...result.candidates);
|
|
3070
|
+
if (result.callId)
|
|
3071
|
+
callIds.push(result.callId);
|
|
3072
|
+
if (!result.failedSegment)
|
|
3073
|
+
continue;
|
|
3074
|
+
const split = this.splitMarkedChapterFragments(result.failedSegment, 120, 16);
|
|
3075
|
+
tinyFragments.push(...split);
|
|
3076
|
+
}
|
|
3077
|
+
const tinyResults = await this.processChunks(tinyFragments, concurrency, async (fragment) => {
|
|
3078
|
+
if (taskId && this.store.getTask(taskId).status !== "running") {
|
|
3079
|
+
return { candidates: [], callId: null, fallback: false };
|
|
3080
|
+
}
|
|
3081
|
+
try {
|
|
3082
|
+
const extracted = await extractChunk(fragment, 5);
|
|
3083
|
+
return { candidates: extracted.candidates, callId: extracted.callId, fallback: false, policyOmitted: false };
|
|
3084
|
+
}
|
|
3085
|
+
catch (error) {
|
|
3086
|
+
const policyOmitted = !minimumFallback && this.isSecurityAuditFailure(error);
|
|
3087
|
+
return {
|
|
3088
|
+
candidates: minimumFallback?.(fragment) ?? [],
|
|
3089
|
+
callId: null,
|
|
3090
|
+
fallback: !policyOmitted,
|
|
3091
|
+
policyOmitted
|
|
3092
|
+
};
|
|
3093
|
+
}
|
|
3094
|
+
});
|
|
3095
|
+
for (const result of tinyResults) {
|
|
3096
|
+
candidates.push(...result.candidates);
|
|
3097
|
+
if (result.callId)
|
|
3098
|
+
callIds.push(result.callId);
|
|
3099
|
+
if (result.fallback)
|
|
3100
|
+
fallbackSegmentCount += 1;
|
|
3101
|
+
if (result.policyOmitted)
|
|
3102
|
+
policyOmittedSegmentCount += 1;
|
|
3103
|
+
}
|
|
3104
|
+
return { candidates, callIds, fallbackSegmentCount, policyOmittedSegmentCount };
|
|
3105
|
+
}
|
|
3106
|
+
isSecurityAuditFailure(error) {
|
|
3107
|
+
if (error instanceof AppError && error.details && typeof error.details === "object" && !Array.isArray(error.details)) {
|
|
3108
|
+
const failure = error.details.failure;
|
|
3109
|
+
if (typeof failure === "string" && /security_audit_fail|security_error/iu.test(failure))
|
|
3110
|
+
return true;
|
|
3111
|
+
}
|
|
3112
|
+
return error instanceof Error && /security_audit_fail|security_error/iu.test(error.message);
|
|
3113
|
+
}
|
|
3114
|
+
taskCanCommit(taskId) {
|
|
3115
|
+
if (!taskId)
|
|
3116
|
+
return true;
|
|
3117
|
+
const task = this.store.getTask(taskId);
|
|
3118
|
+
if (task.status !== "running")
|
|
3119
|
+
return false;
|
|
3120
|
+
if (this.store.isTaskSourceCurrent(taskId))
|
|
3121
|
+
return true;
|
|
3122
|
+
this.store.updateTask(taskId, { status: "expired" });
|
|
3123
|
+
return false;
|
|
3124
|
+
}
|
|
3125
|
+
taskSignal(taskId) {
|
|
3126
|
+
return taskId ? this.taskControllers.get(taskId)?.signal : undefined;
|
|
3127
|
+
}
|
|
3128
|
+
localCharacterFallback(workId, markedText) {
|
|
3129
|
+
const header = markedText.match(/<CHAPTER\b[^>]*id="([^"]+)"[^>]*title="([^"]*)"[^>]*>/u);
|
|
3130
|
+
if (!header?.[1])
|
|
3131
|
+
return [];
|
|
3132
|
+
const chapterId = header[1];
|
|
3133
|
+
const chapterTitle = header[2] ?? "";
|
|
3134
|
+
const content = markedText.replace(/^[\s\S]*?<CHAPTER\b[^>]*>/u, "").replace(/<\/CHAPTER>[\s\S]*$/u, "");
|
|
3135
|
+
const candidates = [];
|
|
3136
|
+
for (const character of this.store.listCharacters(workId)) {
|
|
3137
|
+
const names = [String(character.name), ...character.aliases];
|
|
3138
|
+
const matchedName = names.find((name) => content.includes(name));
|
|
3139
|
+
if (!matchedName)
|
|
3140
|
+
continue;
|
|
3141
|
+
const index = content.indexOf(matchedName);
|
|
3142
|
+
const start = Math.max(0, index - 24);
|
|
3143
|
+
const quote = content.slice(start, Math.min(content.length, start + 76)).trim();
|
|
3144
|
+
candidates.push({
|
|
3145
|
+
canonicalName: character.name,
|
|
3146
|
+
aliases: character.aliases.filter((alias) => this.isSafeGlobalAlias(alias)),
|
|
3147
|
+
species: character.species,
|
|
3148
|
+
identity: String(character.attributes.identity ?? "本地回退识别"),
|
|
3149
|
+
firstEvidence: { chapterId, chapterTitle, quote }
|
|
3150
|
+
});
|
|
3151
|
+
}
|
|
3152
|
+
return candidates;
|
|
3153
|
+
}
|
|
3154
|
+
async processChunks(items, concurrency, worker, onProgress) {
|
|
3155
|
+
const results = new Array(items.length);
|
|
3156
|
+
const failures = [];
|
|
3157
|
+
let cursor = 0;
|
|
3158
|
+
let completed = 0;
|
|
3159
|
+
const runners = Array.from({ length: Math.min(Math.max(1, concurrency), items.length) }, async () => {
|
|
3160
|
+
while (cursor < items.length) {
|
|
3161
|
+
const index = cursor;
|
|
3162
|
+
cursor += 1;
|
|
3163
|
+
try {
|
|
3164
|
+
results[index] = await worker(items[index], index);
|
|
3165
|
+
}
|
|
3166
|
+
catch (error) {
|
|
3167
|
+
failures.push({ index, message: error instanceof Error ? error.message : "批次处理失败" });
|
|
3168
|
+
}
|
|
3169
|
+
finally {
|
|
3170
|
+
completed += 1;
|
|
3171
|
+
onProgress?.(completed);
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
});
|
|
3175
|
+
await Promise.all(runners);
|
|
3176
|
+
const firstPassFailures = failures.splice(0);
|
|
3177
|
+
for (const failure of firstPassFailures) {
|
|
3178
|
+
try {
|
|
3179
|
+
results[failure.index] = await worker(items[failure.index], failure.index);
|
|
3180
|
+
}
|
|
3181
|
+
catch (error) {
|
|
3182
|
+
failures.push({ index: failure.index, message: error instanceof Error ? error.message : "批次处理失败" });
|
|
3183
|
+
}
|
|
3184
|
+
finally {
|
|
3185
|
+
completed += 1;
|
|
3186
|
+
onProgress?.(completed);
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
if (failures.length > 0) {
|
|
3190
|
+
throw new AppError(502, "AI_BATCH_FAILED", `${failures.length} 个分析批次在双重重试后仍失败`, { failures, completed, total: items.length });
|
|
3191
|
+
}
|
|
3192
|
+
return results;
|
|
3193
|
+
}
|
|
3194
|
+
normalizeReference(value) {
|
|
3195
|
+
return value.normalize("NFKC").trim().replace(/\s+/gu, " ").toLocaleLowerCase("zh-CN");
|
|
3196
|
+
}
|
|
3197
|
+
quoteExists(content, quote) {
|
|
3198
|
+
if (!quote.trim())
|
|
3199
|
+
return false;
|
|
3200
|
+
const normalize = (value) => value.normalize("NFKC").replace(/\s+/gu, "").trim();
|
|
3201
|
+
return normalize(content).includes(normalize(quote));
|
|
3202
|
+
}
|
|
3203
|
+
isSafeGlobalAlias(value) {
|
|
3204
|
+
return isSafeGlobalAlias(value);
|
|
3205
|
+
}
|
|
3206
|
+
enrichContinuationScope(workId, scope, instruction) {
|
|
3207
|
+
if (!scope.chapterId)
|
|
3208
|
+
throw new AppError(400, "CHAPTER_REQUIRED", "续写任务必须指定当前章节");
|
|
3209
|
+
const chapter = this.store.getChapter(scope.chapterId);
|
|
3210
|
+
if (chapter.workId !== workId)
|
|
3211
|
+
throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
|
|
3212
|
+
if (scope.type === "none")
|
|
3213
|
+
return scope;
|
|
3214
|
+
const haystack = `${String(chapter.content)}\n${instruction}`;
|
|
3215
|
+
const ids = new Set(scope.characterIds ?? []);
|
|
3216
|
+
for (const character of this.store.listCharacters(workId)) {
|
|
3217
|
+
const names = [String(character.name), ...character.aliases];
|
|
3218
|
+
if (names.some((name) => this.textMentionsName(haystack, name)))
|
|
3219
|
+
ids.add(String(character.id));
|
|
3220
|
+
}
|
|
3221
|
+
return { ...scope, type: "chapter", chapterId: scope.chapterId, characterIds: [...ids] };
|
|
3222
|
+
}
|
|
3223
|
+
textMentionsName(text, name) {
|
|
3224
|
+
const normalized = name.normalize("NFKC").trim();
|
|
3225
|
+
if (!normalized)
|
|
3226
|
+
return false;
|
|
3227
|
+
if (/^[\x00-\x7F]+$/u.test(normalized)) {
|
|
3228
|
+
const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
3229
|
+
return new RegExp(`(^|[^\\p{L}\\p{N}_])${escaped}([^\\p{L}\\p{N}_]|$)`, "iu").test(text.normalize("NFKC"));
|
|
3230
|
+
}
|
|
3231
|
+
return text.normalize("NFKC").includes(normalized);
|
|
3232
|
+
}
|
|
3233
|
+
buildContinuationContextRefs(workId, chapterId, scope) {
|
|
3234
|
+
const work = this.store.getWork(workId);
|
|
3235
|
+
const outline = this.store.getChapterOutline(chapterId);
|
|
3236
|
+
const foreshadows = this.store.listForeshadows(workId, "unresolved", chapterId);
|
|
3237
|
+
const timeline = this.store.listTimelineEvents(workId).filter((item) => Array.isArray(item.chapterIds) && item.chapterIds.includes(chapterId));
|
|
3238
|
+
const allCharacters = this.store.listCharacters(workId);
|
|
3239
|
+
const selectedCharacterIds = new Set(scope.characterIds ?? []);
|
|
3240
|
+
const characters = allCharacters.filter((item) => selectedCharacterIds.has(String(item.id))
|
|
3241
|
+
|| (Array.isArray(item.lockedFields) && item.lockedFields.length > 0));
|
|
3242
|
+
const selectedSettingIds = new Set(scope.settingIds ?? []);
|
|
3243
|
+
const settings = this.store.listSettings(workId).filter((item) => item.locked || selectedSettingIds.has(String(item.id)));
|
|
3244
|
+
const organizations = this.store.listOrganizations(workId);
|
|
3245
|
+
const relationships = selectRelationshipConstraints(this.store, workId, selectedCharacterIds);
|
|
3246
|
+
const characterNameById = new Map(allCharacters.map((character) => [String(character.id), String(character.name)]));
|
|
3247
|
+
const revision = (value) => this.store.hashContent(JSON.stringify(value));
|
|
3248
|
+
return {
|
|
3249
|
+
version: 4,
|
|
3250
|
+
chapterId,
|
|
3251
|
+
chapterVersion: this.store.getChapter(chapterId).versionNo,
|
|
3252
|
+
workRevision: revision({ title: work.title, author: work.author }),
|
|
3253
|
+
characters: characters.map((item) => ({
|
|
3254
|
+
id: item.id,
|
|
3255
|
+
revision: revision({
|
|
3256
|
+
name: item.name,
|
|
3257
|
+
aliases: item.aliases,
|
|
3258
|
+
species: item.species,
|
|
3259
|
+
attributes: item.attributes,
|
|
3260
|
+
profile: item.profile,
|
|
3261
|
+
currentState: item.currentState,
|
|
3262
|
+
lockedFields: item.lockedFields
|
|
3263
|
+
})
|
|
3264
|
+
})).sort((left, right) => String(left.id).localeCompare(String(right.id))),
|
|
3265
|
+
settings: settings.map((item) => ({
|
|
3266
|
+
id: item.id,
|
|
3267
|
+
revision: revision({ title: item.title, category: item.category, content: item.content, locked: item.locked })
|
|
3268
|
+
})).sort((left, right) => String(left.id).localeCompare(String(right.id))),
|
|
3269
|
+
organizations: organizations.map((item) => ({
|
|
3270
|
+
id: item.id,
|
|
3271
|
+
revision: revision({
|
|
3272
|
+
name: item.name,
|
|
3273
|
+
description: item.description,
|
|
3274
|
+
settings: item.settings,
|
|
3275
|
+
memberIds: item.memberIds
|
|
3276
|
+
})
|
|
3277
|
+
})).sort((left, right) => String(left.id).localeCompare(String(right.id))),
|
|
3278
|
+
relationships: relationships.map((item) => ({
|
|
3279
|
+
id: item.id,
|
|
3280
|
+
revision: revision({
|
|
3281
|
+
fromCharacterId: item.fromCharacterId,
|
|
3282
|
+
fromName: characterNameById.get(String(item.fromCharacterId)) ?? "",
|
|
3283
|
+
toCharacterId: item.toCharacterId,
|
|
3284
|
+
toName: characterNameById.get(String(item.toCharacterId)) ?? "",
|
|
3285
|
+
category: item.category,
|
|
3286
|
+
subtype: item.subtype,
|
|
3287
|
+
keywords: item.keywords,
|
|
3288
|
+
directed: item.directed,
|
|
3289
|
+
currentStatus: item.currentStatus,
|
|
3290
|
+
timeRange: item.timeRange,
|
|
3291
|
+
confirmationStatus: item.confirmationStatus,
|
|
3292
|
+
locked: item.locked
|
|
3293
|
+
})
|
|
3294
|
+
})),
|
|
3295
|
+
timeline: timeline.map((item) => ({
|
|
3296
|
+
id: item.id,
|
|
3297
|
+
revision: revision({ name: item.name, timeLabel: item.timeLabel, timeSort: item.timeSort, location: item.location, status: item.status })
|
|
3298
|
+
})).sort((left, right) => String(left.id).localeCompare(String(right.id))),
|
|
3299
|
+
outlineRevision: outline ? revision({
|
|
3300
|
+
goal: outline.goal,
|
|
3301
|
+
conflict: outline.conflict,
|
|
3302
|
+
turningPoint: outline.turningPoint,
|
|
3303
|
+
notes: outline.notes,
|
|
3304
|
+
status: outline.status
|
|
3305
|
+
}) : null,
|
|
3306
|
+
foreshadows: foreshadows.map((item) => ({
|
|
3307
|
+
id: item.id,
|
|
3308
|
+
revision: revision({
|
|
3309
|
+
title: item.title,
|
|
3310
|
+
description: item.description,
|
|
3311
|
+
status: item.status,
|
|
3312
|
+
importance: item.importance,
|
|
3313
|
+
plannedPayoffChapterId: item.plannedPayoffChapterId,
|
|
3314
|
+
occurrences: item.occurrences
|
|
3315
|
+
})
|
|
3316
|
+
})).sort((left, right) => String(left.id).localeCompare(String(right.id)))
|
|
3317
|
+
};
|
|
3318
|
+
}
|
|
3319
|
+
resolveModel(workId, taskType, explicitModelId) {
|
|
3320
|
+
let modelId = explicitModelId;
|
|
3321
|
+
if (!modelId) {
|
|
3322
|
+
const defaultRow = this.store.db.get("SELECT model_id FROM task_defaults WHERE work_id = ? AND task_type = ?", workId, taskType);
|
|
3323
|
+
modelId = defaultRow ? stringValue(defaultRow, "model_id") : undefined;
|
|
3324
|
+
}
|
|
3325
|
+
if (!modelId)
|
|
3326
|
+
throw new AppError(409, "MODEL_REQUIRED", `尚未为 ${taskType} 配置默认模型,请先选择模型`);
|
|
3327
|
+
const model = this.getModelRow(modelId);
|
|
3328
|
+
const provider = this.getProviderRow(stringValue(model, "provider_id"));
|
|
3329
|
+
if (stringValue(provider, "work_id") !== PLATFORM_AI_WORK_ID)
|
|
3330
|
+
throw new AppError(400, "MODEL_PLATFORM_MISMATCH", "模型不属于平台 AI 配置");
|
|
3331
|
+
this.assertAvailable(provider, model);
|
|
3332
|
+
return { model, provider };
|
|
3333
|
+
}
|
|
3334
|
+
configuredConcurrency(workId, taskType, modelId) {
|
|
3335
|
+
const { provider } = this.resolveModel(workId, taskType, modelId);
|
|
3336
|
+
return Math.round(clamp(numberValue(provider, "concurrency_limit") || 10, 1, 100));
|
|
3337
|
+
}
|
|
3338
|
+
scheduleProviderRequest(provider, signal, run) {
|
|
3339
|
+
const providerId = stringValue(provider, "id");
|
|
3340
|
+
const concurrencyLimit = Math.round(clamp(numberValue(provider, "concurrency_limit") || 10, 1, 100));
|
|
3341
|
+
const rpmLimit = Math.round(clamp(numberValue(provider, "rpm_limit") || 10, 1, 10_000));
|
|
3342
|
+
let schedule = this.providerSchedules.get(providerId);
|
|
3343
|
+
if (!schedule) {
|
|
3344
|
+
schedule = { active: 0, starts: [], concurrencyLimit, rpmLimit, queue: [], timer: null };
|
|
3345
|
+
this.providerSchedules.set(providerId, schedule);
|
|
3346
|
+
}
|
|
3347
|
+
else {
|
|
3348
|
+
schedule.concurrencyLimit = concurrencyLimit;
|
|
3349
|
+
schedule.rpmLimit = rpmLimit;
|
|
3350
|
+
}
|
|
3351
|
+
if (signal?.aborted)
|
|
3352
|
+
return Promise.reject(this.abortReason(signal));
|
|
3353
|
+
return new Promise((resolve, reject) => {
|
|
3354
|
+
let entry;
|
|
3355
|
+
const onAbort = () => {
|
|
3356
|
+
const index = schedule.queue.indexOf(entry);
|
|
3357
|
+
if (index < 0)
|
|
3358
|
+
return;
|
|
3359
|
+
schedule.queue.splice(index, 1);
|
|
3360
|
+
entry.detachAbort();
|
|
3361
|
+
reject(this.abortReason(signal));
|
|
3362
|
+
this.pumpProviderSchedule(providerId);
|
|
3363
|
+
};
|
|
3364
|
+
entry = {
|
|
3365
|
+
signal,
|
|
3366
|
+
run,
|
|
3367
|
+
resolve: (value) => resolve(value),
|
|
3368
|
+
reject,
|
|
3369
|
+
detachAbort: () => signal?.removeEventListener("abort", onAbort)
|
|
3370
|
+
};
|
|
3371
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
3372
|
+
schedule.queue.push(entry);
|
|
3373
|
+
logger.debug("ai.provider_queue.enqueued", {
|
|
3374
|
+
providerId,
|
|
3375
|
+
active: schedule.active,
|
|
3376
|
+
queued: schedule.queue.length,
|
|
3377
|
+
concurrencyLimit: schedule.concurrencyLimit,
|
|
3378
|
+
rpmLimit: schedule.rpmLimit
|
|
3379
|
+
});
|
|
3380
|
+
this.pumpProviderSchedule(providerId);
|
|
3381
|
+
});
|
|
3382
|
+
}
|
|
3383
|
+
pumpProviderSchedule(providerId) {
|
|
3384
|
+
const schedule = this.providerSchedules.get(providerId);
|
|
3385
|
+
if (!schedule)
|
|
3386
|
+
return;
|
|
3387
|
+
if (schedule.timer) {
|
|
3388
|
+
clearTimeout(schedule.timer);
|
|
3389
|
+
schedule.timer = null;
|
|
3390
|
+
}
|
|
3391
|
+
const currentTime = Date.now();
|
|
3392
|
+
schedule.starts = schedule.starts.filter((startedAt) => startedAt > currentTime - 60_000);
|
|
3393
|
+
while (schedule.active < schedule.concurrencyLimit && schedule.starts.length < schedule.rpmLimit && schedule.queue.length > 0) {
|
|
3394
|
+
const entry = schedule.queue.shift();
|
|
3395
|
+
if (!entry)
|
|
3396
|
+
break;
|
|
3397
|
+
entry.detachAbort();
|
|
3398
|
+
if (entry.signal?.aborted) {
|
|
3399
|
+
entry.reject(this.abortReason(entry.signal));
|
|
3400
|
+
continue;
|
|
3401
|
+
}
|
|
3402
|
+
schedule.active += 1;
|
|
3403
|
+
schedule.starts.push(Date.now());
|
|
3404
|
+
logger.debug("ai.provider_queue.dispatched", { providerId, active: schedule.active, queued: schedule.queue.length });
|
|
3405
|
+
void Promise.resolve()
|
|
3406
|
+
.then(entry.run)
|
|
3407
|
+
.then(entry.resolve, entry.reject)
|
|
3408
|
+
.finally(() => {
|
|
3409
|
+
schedule.active -= 1;
|
|
3410
|
+
logger.debug("ai.provider_queue.finished", { providerId, active: schedule.active, queued: schedule.queue.length });
|
|
3411
|
+
this.pumpProviderSchedule(providerId);
|
|
3412
|
+
});
|
|
3413
|
+
}
|
|
3414
|
+
if (schedule.queue.length > 0 && schedule.active < schedule.concurrencyLimit && schedule.starts.length >= schedule.rpmLimit) {
|
|
3415
|
+
const delay = Math.max(1, (schedule.starts[0] ?? Date.now()) + 60_000 - Date.now() + 1);
|
|
3416
|
+
logger.info("ai.provider_queue.rate_limited", { providerId, queued: schedule.queue.length, retryInMs: delay });
|
|
3417
|
+
schedule.timer = setTimeout(() => this.pumpProviderSchedule(providerId), delay);
|
|
3418
|
+
schedule.timer.unref?.();
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
abortReason(signal) {
|
|
3422
|
+
return signal?.reason instanceof Error ? signal.reason : new Error("AI 请求已取消");
|
|
3423
|
+
}
|
|
3424
|
+
normalizeRelationshipKeywords(value, subtype) {
|
|
3425
|
+
const source = Array.isArray(value)
|
|
3426
|
+
? value
|
|
3427
|
+
: typeof value === "string"
|
|
3428
|
+
? value.split(/[,,、;;|]/u)
|
|
3429
|
+
: [];
|
|
3430
|
+
const keywords = source
|
|
3431
|
+
.filter((item) => typeof item === "string")
|
|
3432
|
+
.map((item) => item.normalize("NFKC").trim().replace(/^#+/u, "").replace(/\s+/gu, " "))
|
|
3433
|
+
.filter((item) => item.length > 0 && item.length <= 24);
|
|
3434
|
+
return [...new Set(keywords.length > 0 ? keywords : [subtype])].slice(0, 8);
|
|
3435
|
+
}
|
|
3436
|
+
assertAvailable(provider, model) {
|
|
3437
|
+
if (stringValue(provider, "status") !== "enabled")
|
|
3438
|
+
throw new AppError(409, "PROVIDER_DISABLED", "供应商已停用,不能创建新任务");
|
|
3439
|
+
if (stringValue(provider, "connection_status") !== "success") {
|
|
3440
|
+
throw new AppError(409, "PROVIDER_UNAVAILABLE", "供应商尚未通过连接测试或连接异常");
|
|
3441
|
+
}
|
|
3442
|
+
if (!boolValue(model, "enabled"))
|
|
3443
|
+
throw new AppError(409, "MODEL_DISABLED", "模型已停用,不能创建新任务");
|
|
3444
|
+
}
|
|
3445
|
+
sanitizeParameters(input) {
|
|
3446
|
+
const output = {};
|
|
3447
|
+
for (const [key, value] of Object.entries(input)) {
|
|
3448
|
+
if (!allowedParameters.has(key))
|
|
3449
|
+
continue;
|
|
3450
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
3451
|
+
output[key] = value;
|
|
3452
|
+
}
|
|
3453
|
+
if (typeof output.temperature === "number")
|
|
3454
|
+
output.temperature = clamp(output.temperature, 0, 2);
|
|
3455
|
+
if (typeof output.top_p === "number")
|
|
3456
|
+
output.top_p = clamp(output.top_p, 0, 1);
|
|
3457
|
+
output.max_tokens = typeof output.max_tokens === "number"
|
|
3458
|
+
? Math.round(clamp(output.max_tokens, 1, 32_768))
|
|
3459
|
+
: DEFAULT_MAX_TOKENS;
|
|
3460
|
+
return output;
|
|
3461
|
+
}
|
|
3462
|
+
decryptKey(row) {
|
|
3463
|
+
try {
|
|
3464
|
+
return this.vault.decrypt({
|
|
3465
|
+
encrypted: stringValue(row, "encrypted_key"),
|
|
3466
|
+
iv: stringValue(row, "key_iv"),
|
|
3467
|
+
tag: stringValue(row, "key_tag")
|
|
3468
|
+
});
|
|
3469
|
+
}
|
|
3470
|
+
catch {
|
|
3471
|
+
throw new AppError(500, "CREDENTIAL_DECRYPT_FAILED", "供应商凭据无法解密,请重新填写 API 密钥");
|
|
3472
|
+
}
|
|
3473
|
+
}
|
|
3474
|
+
getProviderRow(providerId) {
|
|
3475
|
+
const row = this.store.db.get("SELECT * FROM providers WHERE id = ?", providerId);
|
|
3476
|
+
if (!row)
|
|
3477
|
+
throw notFound("AI 供应商");
|
|
3478
|
+
return row;
|
|
3479
|
+
}
|
|
3480
|
+
getModelRow(modelId) {
|
|
3481
|
+
const row = this.store.db.get("SELECT * FROM models WHERE id = ?", modelId);
|
|
3482
|
+
if (!row)
|
|
3483
|
+
throw notFound("AI 模型");
|
|
3484
|
+
return row;
|
|
3485
|
+
}
|
|
3486
|
+
mapProvider(row) {
|
|
3487
|
+
return {
|
|
3488
|
+
id: stringValue(row, "id"),
|
|
3489
|
+
scope: "platform",
|
|
3490
|
+
name: stringValue(row, "name"),
|
|
3491
|
+
baseUrl: stringValue(row, "base_url"),
|
|
3492
|
+
apiKey: stringValue(row, "key_hint"),
|
|
3493
|
+
status: stringValue(row, "status"),
|
|
3494
|
+
connectionStatus: stringValue(row, "connection_status"),
|
|
3495
|
+
concurrencyLimit: numberValue(row, "concurrency_limit") || 10,
|
|
3496
|
+
rpmLimit: numberValue(row, "rpm_limit") || 10,
|
|
3497
|
+
maxTokens: numberValue(row, "max_tokens") || DEFAULT_MAX_TOKENS,
|
|
3498
|
+
defaultModelId: row.default_model_id === null ? null : stringValue(row, "default_model_id"),
|
|
3499
|
+
note: stringValue(row, "note"),
|
|
3500
|
+
lastError: row.last_error === null ? null : stringValue(row, "last_error"),
|
|
3501
|
+
lastSuccessAt: row.last_success_at === null ? null : stringValue(row, "last_success_at"),
|
|
3502
|
+
createdAt: stringValue(row, "created_at"),
|
|
3503
|
+
updatedAt: stringValue(row, "updated_at")
|
|
3504
|
+
};
|
|
3505
|
+
}
|
|
3506
|
+
mapModel(row) {
|
|
3507
|
+
return {
|
|
3508
|
+
id: stringValue(row, "id"),
|
|
3509
|
+
providerId: stringValue(row, "provider_id"),
|
|
3510
|
+
displayName: stringValue(row, "display_name"),
|
|
3511
|
+
modelId: stringValue(row, "model_id"),
|
|
3512
|
+
purposes: json(stringValue(row, "purposes_json"), []),
|
|
3513
|
+
contextNote: stringValue(row, "context_note"),
|
|
3514
|
+
contextWindow: numberValue(row, "context_window") || DEFAULT_CONTEXT_WINDOW,
|
|
3515
|
+
outputNote: stringValue(row, "output_note"),
|
|
3516
|
+
preset: normalizeModelPreset(safeJsonObject(stringValue(row, "preset_json"))),
|
|
3517
|
+
thinkingEnabled: boolValue(row, "thinking_enabled"),
|
|
3518
|
+
enabled: boolValue(row, "enabled"),
|
|
3519
|
+
note: stringValue(row, "note"),
|
|
3520
|
+
createdAt: stringValue(row, "created_at"),
|
|
3521
|
+
updatedAt: stringValue(row, "updated_at")
|
|
3522
|
+
};
|
|
3523
|
+
}
|
|
3524
|
+
mapSuggestion(row) {
|
|
3525
|
+
const call = this.store.db.get("SELECT provider_id, model_id FROM ai_calls WHERE id = ?", stringValue(row, "call_id"));
|
|
3526
|
+
const guard = this.store.getLatestContinuationGuard(stringValue(row, "id"));
|
|
3527
|
+
return {
|
|
3528
|
+
id: stringValue(row, "id"),
|
|
3529
|
+
callId: stringValue(row, "call_id"),
|
|
3530
|
+
workId: stringValue(row, "work_id"),
|
|
3531
|
+
chapterId: row.chapter_id === null ? null : stringValue(row, "chapter_id"),
|
|
3532
|
+
chapterVersion: row.chapter_version === null ? null : numberValue(row, "chapter_version"),
|
|
3533
|
+
taskType: stringValue(row, "task_type"),
|
|
3534
|
+
instruction: stringValue(row, "instruction"),
|
|
3535
|
+
sourceText: stringValue(row, "source_text"),
|
|
3536
|
+
content: stringValue(row, "content"),
|
|
3537
|
+
action: stringValue(row, "action"),
|
|
3538
|
+
status: stringValue(row, "status"),
|
|
3539
|
+
outputTokens: estimateAiTokens(stringValue(row, "content")),
|
|
3540
|
+
guard,
|
|
3541
|
+
provider: call ? this.getProvider(stringValue(call, "provider_id")) : null,
|
|
3542
|
+
model: call ? this.getModel(stringValue(call, "model_id")) : null,
|
|
3543
|
+
createdAt: stringValue(row, "created_at"),
|
|
3544
|
+
decidedAt: row.decided_at === null ? null : stringValue(row, "decided_at")
|
|
3545
|
+
};
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3548
|
+
//# sourceMappingURL=ai.js.map
|