@musnows/scriverse 0.7.11 → 0.7.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai.js +468 -90
- package/dist/ai.js.map +1 -1
- package/dist/app.js +67 -2
- package/dist/app.js.map +1 -1
- package/dist/public/app.js +321 -29
- package/dist/public/index.html +4 -4
- package/dist/public/plain-text-paste.js +89 -0
- package/dist/public/stream-typewriter.d.ts +12 -1
- package/dist/public/stream-typewriter.js +65 -5
- package/dist/public/styles.css +85 -1
- package/dist/store.js +384 -97
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +1 -1
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/ai.js
CHANGED
|
@@ -63,6 +63,9 @@ const interactiveStreamErrorCodes = new Set([
|
|
|
63
63
|
function isInteractiveStreamError(error) {
|
|
64
64
|
return error instanceof AppError && interactiveStreamErrorCodes.has(error.code);
|
|
65
65
|
}
|
|
66
|
+
function isAuthorNoteChapter(chapter) {
|
|
67
|
+
return String(chapter.chapterType ?? "") === "作者的话";
|
|
68
|
+
}
|
|
66
69
|
function interactiveStreamRequestCancelledError() {
|
|
67
70
|
return new AppError(499, "AI_STREAM_REQUEST_CANCELLED", "AI 流式请求已取消");
|
|
68
71
|
}
|
|
@@ -1200,11 +1203,11 @@ export class ContextBuilder {
|
|
|
1200
1203
|
? [wrapAiContextRegion("work", `作品:${String(work.title)}\n作者:${String(work.author) || "未填写"}`)]
|
|
1201
1204
|
: [];
|
|
1202
1205
|
const contentSections = [];
|
|
1203
|
-
const availableSettings = this.store.listSettings(workId);
|
|
1206
|
+
const availableSettings = includeSettingInfo ? this.store.listSettings(workId) : [];
|
|
1204
1207
|
const contextualSettings = !includeSettingInfo
|
|
1205
1208
|
? []
|
|
1206
1209
|
: scope.includeAllSettings ? availableSettings : availableSettings.filter((item) => item.locked);
|
|
1207
|
-
const allCharacters = this.store.listCharacters(workId);
|
|
1210
|
+
const allCharacters = includeSettingInfo ? this.store.listCharacters(workId) : [];
|
|
1208
1211
|
const lockedCharacters = includeSettingInfo
|
|
1209
1212
|
? allCharacters.filter((item) => Array.isArray(item.lockedFields) && item.lockedFields.length > 0)
|
|
1210
1213
|
: [];
|
|
@@ -1256,30 +1259,49 @@ export class ContextBuilder {
|
|
|
1256
1259
|
if (scope.type === "selection") {
|
|
1257
1260
|
if (!scope.selection)
|
|
1258
1261
|
throw new AppError(400, "SELECTION_REQUIRED", "选中文本上下文不能为空");
|
|
1262
|
+
const selectionChapter = scope.chapterId ? this.store.getChapter(scope.chapterId) : null;
|
|
1263
|
+
if (selectionChapter && selectionChapter.workId !== workId)
|
|
1264
|
+
throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
|
|
1259
1265
|
// 分析任务会在 selection 中放入服务端 CHAPTER 标记,不能转义
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1266
|
+
if (!selectionChapter || !isAuthorNoteChapter(selectionChapter)) {
|
|
1267
|
+
contentSections.push(wrapAiContextRegion("selection", `当前选中文本:\n${scope.selection}`, { escape: false }));
|
|
1268
|
+
if (scope.chapterId)
|
|
1269
|
+
this.appendChapter(contentSections, workId, scope.chapterId, false);
|
|
1270
|
+
}
|
|
1263
1271
|
}
|
|
1264
1272
|
else if (scope.type === "chapter") {
|
|
1265
|
-
|
|
1273
|
+
const chapterIds = [...new Set([
|
|
1274
|
+
...(scope.chapterId ? [scope.chapterId] : []),
|
|
1275
|
+
...(scope.chapterIds ?? [])
|
|
1276
|
+
])];
|
|
1277
|
+
if (chapterIds.length === 0)
|
|
1266
1278
|
throw new AppError(400, "CHAPTER_REQUIRED", "章节上下文缺少章节标识");
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1279
|
+
if (chapterIds.length === 1 && scope.chapterId)
|
|
1280
|
+
this.appendPreviousChapterTail(contentSections, workId, scope.chapterId);
|
|
1281
|
+
for (const chapterId of chapterIds)
|
|
1282
|
+
this.appendChapter(contentSections, workId, chapterId, true);
|
|
1283
|
+
if (scope.selection && (!scope.chapterId || !isAuthorNoteChapter(this.store.getChapter(scope.chapterId)))) {
|
|
1270
1284
|
contentSections.push(wrapAiContextRegion("selection", `当前选中文本(本次修改目标):\n${scope.selection}`, { escape: false }));
|
|
1285
|
+
}
|
|
1271
1286
|
}
|
|
1272
1287
|
else if (scope.type === "volume") {
|
|
1273
|
-
|
|
1288
|
+
const volumeIds = [...new Set([
|
|
1289
|
+
...(scope.volumeId ? [scope.volumeId] : []),
|
|
1290
|
+
...(scope.volumeIds ?? [])
|
|
1291
|
+
])];
|
|
1292
|
+
if (volumeIds.length === 0)
|
|
1274
1293
|
throw new AppError(400, "VOLUME_REQUIRED", "卷上下文缺少卷标识");
|
|
1275
1294
|
const tree = this.store.getWorkTree(workId);
|
|
1276
|
-
const
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
contentSections.push(wrapAiContextRegion("
|
|
1295
|
+
const volumes = tree.volumes;
|
|
1296
|
+
for (const volumeId of volumeIds) {
|
|
1297
|
+
const volume = volumes.find((item) => item.id === volumeId);
|
|
1298
|
+
if (!volume)
|
|
1299
|
+
throw notFound("卷");
|
|
1300
|
+
const chapters = volume.chapters.filter((chapter) => !isAuthorNoteChapter(chapter));
|
|
1301
|
+
contentSections.push(wrapAiContextRegion("volume", `当前卷:${String(volume.title)}`));
|
|
1302
|
+
for (const chapter of chapters) {
|
|
1303
|
+
contentSections.push(wrapAiContextRegion("chapter", `[${String(volume.title)} / ${String(chapter.title)} | 版本 ${String(chapter.versionNo)}]\n${String(chapter.content)}`));
|
|
1304
|
+
}
|
|
1283
1305
|
}
|
|
1284
1306
|
}
|
|
1285
1307
|
else if (scope.type === "book") {
|
|
@@ -1287,7 +1309,7 @@ export class ContextBuilder {
|
|
|
1287
1309
|
const volumes = tree.volumes;
|
|
1288
1310
|
contentSections.push(wrapAiContextRegion("book", "全书正文(按问题相关度选取原文,完整结构见章节概要):"));
|
|
1289
1311
|
for (const volume of volumes) {
|
|
1290
|
-
for (const chapter of volume.chapters) {
|
|
1312
|
+
for (const chapter of volume.chapters.filter((item) => !isAuthorNoteChapter(item))) {
|
|
1291
1313
|
contentSections.push(wrapAiContextRegion("chapter", `[# ${String(volume.title)} / ${String(chapter.title)} | 版本 ${String(chapter.versionNo)}]\n${String(chapter.content)}`));
|
|
1292
1314
|
}
|
|
1293
1315
|
}
|
|
@@ -1302,7 +1324,7 @@ export class ContextBuilder {
|
|
|
1302
1324
|
: "设定库目录:\n(暂无设定条目)"));
|
|
1303
1325
|
}
|
|
1304
1326
|
if (scope.includeBookSummary || scope.type === "book" || scope.type === "volume") {
|
|
1305
|
-
this.appendBookSummary(contentSections, workId, bookSummaryMaximumTokens ?? Math.max(160, Math.floor(maximumTokens * 0.35)), query, scope.type === "volume" ? scope.volumeId : undefined);
|
|
1327
|
+
this.appendBookSummary(contentSections, workId, bookSummaryMaximumTokens ?? Math.max(160, Math.floor(maximumTokens * 0.35)), query, scope.type === "volume" && !scope.volumeIds?.length ? scope.volumeId : undefined);
|
|
1306
1328
|
}
|
|
1307
1329
|
if (scope.characterIds?.length) {
|
|
1308
1330
|
const characters = scope.characterIds.map((characterId) => this.store.getCharacter(characterId));
|
|
@@ -1371,8 +1393,9 @@ export class ContextBuilder {
|
|
|
1371
1393
|
if (chapter.workId !== workId)
|
|
1372
1394
|
throw new AppError(400, "CHAPTER_WORK_MISMATCH", "引用章节不属于当前作品");
|
|
1373
1395
|
}
|
|
1374
|
-
|
|
1375
|
-
|
|
1396
|
+
const eligibleChapters = chapters.filter((chapter) => !isAuthorNoteChapter(chapter));
|
|
1397
|
+
if (eligibleChapters.length) {
|
|
1398
|
+
contentSections.push(wrapAiContextRegion("referenced_chapters", `作者主动引用的章节:\n${eligibleChapters
|
|
1376
1399
|
.map((chapter) => `[${String(chapter.title)} | 版本 ${String(chapter.versionNo)}]\n${String(chapter.content)}`)
|
|
1377
1400
|
.join("\n\n")}`));
|
|
1378
1401
|
}
|
|
@@ -1477,6 +1500,8 @@ export class ContextBuilder {
|
|
|
1477
1500
|
const chapter = this.store.getChapter(chapterId);
|
|
1478
1501
|
if (chapter.workId !== workId)
|
|
1479
1502
|
throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
|
|
1503
|
+
if (isAuthorNoteChapter(chapter))
|
|
1504
|
+
return;
|
|
1480
1505
|
sections.push(wrapAiContextRegion("chapter", includeContent
|
|
1481
1506
|
? `当前章节:${String(chapter.title)} | 版本 ${String(chapter.versionNo)}\n${String(chapter.content)}`
|
|
1482
1507
|
: `所在章节:${String(chapter.title)} | 版本 ${String(chapter.versionNo)}`));
|
|
@@ -1490,7 +1515,7 @@ export class ContextBuilder {
|
|
|
1490
1515
|
return;
|
|
1491
1516
|
const perVolumeBudget = Math.max(24, Math.floor(maximumTokens / volumes.length));
|
|
1492
1517
|
for (const volume of volumes) {
|
|
1493
|
-
const chapters = volume.chapters;
|
|
1518
|
+
const chapters = volume.chapters.filter((chapter) => !isAuthorNoteChapter(chapter));
|
|
1494
1519
|
const ranked = chapters.map((chapter, order) => {
|
|
1495
1520
|
const summary = summaryByChapterId.get(String(chapter.id)) ?? "";
|
|
1496
1521
|
const line = `- ${String(chapter.title)}:${summary || "尚无章节概要"}`;
|
|
@@ -1519,9 +1544,12 @@ export class ContextBuilder {
|
|
|
1519
1544
|
}
|
|
1520
1545
|
}
|
|
1521
1546
|
appendPreviousChapterTail(sections, workId, chapterId) {
|
|
1547
|
+
const current = this.store.getChapter(chapterId);
|
|
1548
|
+
if (current.workId !== workId || isAuthorNoteChapter(current))
|
|
1549
|
+
return;
|
|
1522
1550
|
const tree = this.store.getWorkTree(workId);
|
|
1523
1551
|
const chapters = tree.volumes
|
|
1524
|
-
.flatMap((volume) => volume.chapters);
|
|
1552
|
+
.flatMap((volume) => volume.chapters.filter((chapter) => !isAuthorNoteChapter(chapter)));
|
|
1525
1553
|
const index = chapters.findIndex((chapter) => chapter.id === chapterId);
|
|
1526
1554
|
if (index <= 0)
|
|
1527
1555
|
return;
|
|
@@ -1532,6 +1560,9 @@ export class ContextBuilder {
|
|
|
1532
1560
|
sections.push(wrapAiContextRegion("previous_chapter_tail", `上一章节结尾:${String(previous.title)} | 版本 ${String(previous.versionNo)}\n${content.slice(-5000)}`));
|
|
1533
1561
|
}
|
|
1534
1562
|
appendChapterKnowledge(sections, workId, chapterId) {
|
|
1563
|
+
const chapter = this.store.getChapter(chapterId);
|
|
1564
|
+
if (chapter.workId !== workId || isAuthorNoteChapter(chapter))
|
|
1565
|
+
return;
|
|
1535
1566
|
const outline = this.store.getChapterOutline(chapterId);
|
|
1536
1567
|
if (outline) {
|
|
1537
1568
|
sections.push(wrapAiContextRegion("chapter_outline", `当前章大纲(创作约束):\n目标:${String(outline.goal) || "未填写"}\n冲突:${String(outline.conflict) || "未填写"}\n转折:${String(outline.turningPoint) || "未填写"}\n状态:${String(outline.status)}`));
|
|
@@ -2686,6 +2717,166 @@ export class AiManager {
|
|
|
2686
2717
|
model: this.getModel(stringValue(row, "model_id"))
|
|
2687
2718
|
})), pagination);
|
|
2688
2719
|
}
|
|
2720
|
+
analysisTaskContextPreviewInput(workId, taskType, scope) {
|
|
2721
|
+
const previewTaskType = this.analysisTaskModelPurpose(taskType);
|
|
2722
|
+
let previewScope = scope;
|
|
2723
|
+
let instruction = "请基于所选分析范围完成结构化小说分析,只引用注入资料中的事实并给出可追溯证据。";
|
|
2724
|
+
let agentToolIds;
|
|
2725
|
+
if (taskType === "chapter-analysis") {
|
|
2726
|
+
const chapters = this.getScopeChapters(workId, scope);
|
|
2727
|
+
if (chapters.length === 0)
|
|
2728
|
+
throw new AppError(409, "CHAPTERS_REQUIRED", "章节分析范围内没有章节");
|
|
2729
|
+
const chapter = [...chapters].sort((left, right) => String(right.content).length - String(left.content).length)[0];
|
|
2730
|
+
if (!chapter)
|
|
2731
|
+
throw new AppError(409, "CHAPTERS_REQUIRED", "章节分析范围内没有章节");
|
|
2732
|
+
previewScope = {
|
|
2733
|
+
...scope,
|
|
2734
|
+
type: "chapter",
|
|
2735
|
+
chapterId: String(chapter.id),
|
|
2736
|
+
chapterIds: undefined,
|
|
2737
|
+
volumeId: undefined,
|
|
2738
|
+
volumeIds: undefined
|
|
2739
|
+
};
|
|
2740
|
+
instruction = "分析当前章节并输出结构化结果,字段包括摘要、事件、人物、设定、证据和不确定项。";
|
|
2741
|
+
}
|
|
2742
|
+
else if (taskType === "character-extraction" || taskType === "character-summary" || taskType === "setting-extraction") {
|
|
2743
|
+
const chapters = this.getScopeChapters(workId, scope);
|
|
2744
|
+
if (chapters.length === 0)
|
|
2745
|
+
throw new AppError(409, "CHAPTERS_REQUIRED", "分析范围内没有章节");
|
|
2746
|
+
const chunks = this.buildChapterChunks(chapters, 10_000);
|
|
2747
|
+
const selection = [...chunks].sort((left, right) => right.text.length - left.text.length)[0]?.text ?? "";
|
|
2748
|
+
previewScope = { type: "selection", selection };
|
|
2749
|
+
instruction = taskType === "setting-extraction"
|
|
2750
|
+
? "从本批正文抽取可复用的世界设定候选,并为每条候选提供原文证据。"
|
|
2751
|
+
: "从本批正文抽取人物候选,并为每位候选提供原文首次出现证据。";
|
|
2752
|
+
}
|
|
2753
|
+
else if (taskType === "relationship-analysis") {
|
|
2754
|
+
const targeted = Boolean(scope.characterIds?.length);
|
|
2755
|
+
const characters = targeted
|
|
2756
|
+
? this.store.db.all(`SELECT id, name, aliases_json FROM characters
|
|
2757
|
+
WHERE work_id = ? AND merged_into_character_id IS NULL ORDER BY name`, workId).map((row) => ({
|
|
2758
|
+
id: String(row.id),
|
|
2759
|
+
name: String(row.name),
|
|
2760
|
+
aliases: json(String(row.aliases_json), [])
|
|
2761
|
+
}))
|
|
2762
|
+
: this.store.listCharacters(workId);
|
|
2763
|
+
const roster = characters.map((character) => `${String(character.id)} | ${String(character.name)}${Array.isArray(character.aliases) && character.aliases.length ? ` | 别名:${character.aliases.join("、")}` : ""}`).join("\n");
|
|
2764
|
+
let selection = "本次范围没有可注入的正文或设定数据。";
|
|
2765
|
+
if (scope.type === "settings") {
|
|
2766
|
+
const chunks = this.buildSettingChunks(this.relationshipSettingSources(workId, characters), 12_000);
|
|
2767
|
+
selection = [...chunks].sort((left, right) => right.text.length - left.text.length)[0]?.text ?? selection;
|
|
2768
|
+
previewScope = { type: "settings", selection };
|
|
2769
|
+
}
|
|
2770
|
+
else {
|
|
2771
|
+
if (targeted && scope.type === "book") {
|
|
2772
|
+
const stats = this.store.db.get(`SELECT COUNT(*) AS chapter_count,
|
|
2773
|
+
COALESCE(SUM(LENGTH(chapter.content)), 0) AS total_characters
|
|
2774
|
+
FROM chapters chapter
|
|
2775
|
+
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
2776
|
+
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL
|
|
2777
|
+
AND chapter.excluded_from_analysis = 0 AND chapter.chapter_type <> '作者的话'`, workId) ?? {};
|
|
2778
|
+
const chapterCount = Number(stats.chapter_count ?? 0);
|
|
2779
|
+
const totalCharacters = Number(stats.total_characters ?? 0);
|
|
2780
|
+
if (chapterCount > 0 && totalCharacters > 0) {
|
|
2781
|
+
const previewCharacters = Math.min(totalCharacters + chapterCount * 80, 12_000);
|
|
2782
|
+
selection = `<CHAPTER id="context-preview" title="上下文预检">\n${"字".repeat(previewCharacters)}\n</CHAPTER>`;
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
else {
|
|
2786
|
+
const chunks = this.buildChapterChunks(this.getScopeChapters(workId, scope), 12_000);
|
|
2787
|
+
selection = [...chunks].sort((left, right) => right.text.length - left.text.length)[0]?.text ?? selection;
|
|
2788
|
+
}
|
|
2789
|
+
previewScope = {
|
|
2790
|
+
type: "selection",
|
|
2791
|
+
selection,
|
|
2792
|
+
...(targeted ? { suppressAutomaticContext: true } : {})
|
|
2793
|
+
};
|
|
2794
|
+
}
|
|
2795
|
+
instruction = `抽取本批正文或设定中的人物长期关系候选,只使用原文证据。角色规范表:\n${roster}`;
|
|
2796
|
+
}
|
|
2797
|
+
else if (taskType === "character-identity-audit") {
|
|
2798
|
+
const characters = this.store.listCharacters(workId);
|
|
2799
|
+
const roster = characters.map((character) => `${String(character.id)} | ${String(character.name)} | 别名=${JSON.stringify(character.aliases)} | 身份=${String(character.attributes.identity ?? "未知")}`).join("\n");
|
|
2800
|
+
instruction = `审核角色规范表,找出疑似重复角色并给出原文证据。角色规范表:\n${roster}`;
|
|
2801
|
+
agentToolIds = ["search_story_entities", "grep", "read_chapters"];
|
|
2802
|
+
}
|
|
2803
|
+
else if (taskType === "timeline-analysis") {
|
|
2804
|
+
instruction = "抽取所选范围内的大事件候选,区分发生时间与叙述时间,并为每项提供原文证据。";
|
|
2805
|
+
}
|
|
2806
|
+
else if (taskType === "worldview-analysis") {
|
|
2807
|
+
instruction = "分析所选范围内已经出现的世界观,区分事实、传闻和未知项,并为结论提供原文证据。";
|
|
2808
|
+
}
|
|
2809
|
+
else if (taskType === "consistency-check") {
|
|
2810
|
+
instruction = "检查所选范围内的设定、人物状态、关系和时间冲突,并为每项问题提供原文证据。";
|
|
2811
|
+
}
|
|
2812
|
+
return { taskType: previewTaskType, instruction, scope: previewScope, ...(agentToolIds ? { agentToolIds } : {}) };
|
|
2813
|
+
}
|
|
2814
|
+
previewAnalysisTaskContext(workId, input) {
|
|
2815
|
+
this.store.getWork(workId);
|
|
2816
|
+
const scope = (input.scope ?? { type: "book" });
|
|
2817
|
+
const { model } = this.resolveModel(workId, this.analysisTaskModelPurpose(input.taskType), input.modelId);
|
|
2818
|
+
const previewInput = this.analysisTaskContextPreviewInput(workId, input.taskType, scope);
|
|
2819
|
+
const modelId = stringValue(model, "id");
|
|
2820
|
+
const modelName = stringValue(model, "display_name") || stringValue(model, "model_id");
|
|
2821
|
+
const contextWindow = numberValue(model, "context_window") || DEFAULT_CONTEXT_WINDOW;
|
|
2822
|
+
const compactThreshold = Math.min(90, Math.max(50, Number(this.store.getWorkAiSettings(workId).contextCompactThreshold) || 85));
|
|
2823
|
+
const thresholdTokens = Math.max(256, Math.floor(contextWindow * compactThreshold / 100));
|
|
2824
|
+
const budget = this.contextBudget({ workId, ...previewInput }, model);
|
|
2825
|
+
const base = {
|
|
2826
|
+
allowed: false,
|
|
2827
|
+
taskType: input.taskType,
|
|
2828
|
+
modelId,
|
|
2829
|
+
modelName,
|
|
2830
|
+
contextWindow,
|
|
2831
|
+
thresholdPercent: compactThreshold,
|
|
2832
|
+
thresholdTokens,
|
|
2833
|
+
availableInputTokens: Number(budget.availableInputTokens),
|
|
2834
|
+
contextBudgetTokens: Number(budget.workContextBudgetTokens),
|
|
2835
|
+
requiredContextWindow: null,
|
|
2836
|
+
estimatedInputTokens: null,
|
|
2837
|
+
estimatedContextTokens: null,
|
|
2838
|
+
omittedContextBlocks: 0,
|
|
2839
|
+
degradedContextBlocks: 0
|
|
2840
|
+
};
|
|
2841
|
+
let usage;
|
|
2842
|
+
try {
|
|
2843
|
+
usage = this.getContextUsage({
|
|
2844
|
+
workId,
|
|
2845
|
+
taskType: previewInput.taskType,
|
|
2846
|
+
modelId,
|
|
2847
|
+
scope: previewInput.scope,
|
|
2848
|
+
instruction: previewInput.instruction,
|
|
2849
|
+
agentToolIds: previewInput.agentToolIds
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
catch (error) {
|
|
2853
|
+
if (!(error instanceof AppError) || error.code !== "CONSTRAINT_CONTEXT_TOO_LARGE")
|
|
2854
|
+
throw error;
|
|
2855
|
+
return {
|
|
2856
|
+
...base,
|
|
2857
|
+
overThreshold: true,
|
|
2858
|
+
message: `当前分析范围注入的锁定设定和人物资料已超过模型“${modelName}”的安全上下文容量,请切换到上下文更长的模型,或缩小分析范围后重试。`
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2861
|
+
const estimatedInputTokens = Number(usage.inputTokens) || 0;
|
|
2862
|
+
const estimatedContextTokens = Number(usage.contextTokens) || 0;
|
|
2863
|
+
const omittedContextBlocks = Number(usage.omittedContextBlocks) || 0;
|
|
2864
|
+
const degradedContextBlocks = Number(usage.degradedContextBlocks) || 0;
|
|
2865
|
+
const overThreshold = estimatedInputTokens >= thresholdTokens || omittedContextBlocks > 0 || degradedContextBlocks > 0;
|
|
2866
|
+
return {
|
|
2867
|
+
...base,
|
|
2868
|
+
allowed: !overThreshold,
|
|
2869
|
+
overThreshold,
|
|
2870
|
+
estimatedInputTokens,
|
|
2871
|
+
estimatedContextTokens,
|
|
2872
|
+
omittedContextBlocks,
|
|
2873
|
+
degradedContextBlocks,
|
|
2874
|
+
requiredContextWindow: overThreshold ? Math.ceil(estimatedInputTokens * 100 / compactThreshold) : null,
|
|
2875
|
+
message: overThreshold
|
|
2876
|
+
? `当前分析范围预计注入约 ${estimatedInputTokens.toLocaleString("zh-CN")} Token,已达到模型“${modelName}”安全阈值(${contextWindow.toLocaleString("zh-CN")} Token 的 ${compactThreshold}%),部分资料将被压缩或省略。请切换到上下文更长的模型,或缩小分析范围后重试。`
|
|
2877
|
+
: "当前分析范围在所选模型的安全上下文阈值内。"
|
|
2878
|
+
};
|
|
2879
|
+
}
|
|
2689
2880
|
createTask(workId, input) {
|
|
2690
2881
|
this.store.getWork(workId);
|
|
2691
2882
|
const modelPurpose = this.analysisTaskModelPurpose(input.taskType);
|
|
@@ -2697,7 +2888,17 @@ export class AiManager {
|
|
|
2697
2888
|
? input.scope
|
|
2698
2889
|
: null;
|
|
2699
2890
|
if (relationshipScope && Array.isArray(relationshipScope.relationshipSourceRefs)) {
|
|
2700
|
-
this.
|
|
2891
|
+
this.validateRelationshipSourceRefs(workId, relationshipScope, relationshipScope.relationshipSourceRefs);
|
|
2892
|
+
}
|
|
2893
|
+
if (modelId) {
|
|
2894
|
+
const contextPreview = this.previewAnalysisTaskContext(workId, {
|
|
2895
|
+
taskType: input.taskType,
|
|
2896
|
+
scope: input.scope,
|
|
2897
|
+
modelId
|
|
2898
|
+
});
|
|
2899
|
+
if (contextPreview.allowed !== true) {
|
|
2900
|
+
throw new AppError(413, "AI_CONTEXT_TOO_LARGE", String(contextPreview.message), contextPreview);
|
|
2901
|
+
}
|
|
2701
2902
|
}
|
|
2702
2903
|
return this.store.createTask(workId, {
|
|
2703
2904
|
taskType: input.taskType,
|
|
@@ -3251,7 +3452,26 @@ export class AiManager {
|
|
|
3251
3452
|
&& titleModelId
|
|
3252
3453
|
&& (conversationBefore?.title === "新对话" || conversationBefore?.title === defaultTitle));
|
|
3253
3454
|
const processStartedAt = process.hrtime.bigint();
|
|
3254
|
-
|
|
3455
|
+
let persistedConversationMessage = null;
|
|
3456
|
+
let streamedConversationContent = "";
|
|
3457
|
+
const persistStreamDelta = (delta) => {
|
|
3458
|
+
if (input.conversationId && input.assistantMessageRequestId && delta.length > 0) {
|
|
3459
|
+
streamedConversationContent += delta;
|
|
3460
|
+
persistedConversationMessage = this.store.upsertAiConversationAssistantMessage(input.conversationId, input.assistantMessageRequestId, streamedConversationContent);
|
|
3461
|
+
}
|
|
3462
|
+
onDelta(delta);
|
|
3463
|
+
};
|
|
3464
|
+
let generated;
|
|
3465
|
+
try {
|
|
3466
|
+
generated = await this.generate({ ...input, taskType: "chat" }, persistStreamDelta);
|
|
3467
|
+
}
|
|
3468
|
+
catch (error) {
|
|
3469
|
+
if (persistedConversationMessage && input.conversationId && input.assistantMessageRequestId) {
|
|
3470
|
+
const interruptionCode = error instanceof AppError ? error.code : "AI_STREAM_FAILED";
|
|
3471
|
+
persistedConversationMessage = this.store.upsertAiConversationAssistantMessage(input.conversationId, input.assistantMessageRequestId, streamedConversationContent, { interrupted: true, interruptionCode }, true);
|
|
3472
|
+
}
|
|
3473
|
+
throw error;
|
|
3474
|
+
}
|
|
3255
3475
|
const processDurationMs = Math.min(86_400_000, Math.max(0, Math.round(Number(process.hrtime.bigint() - processStartedAt) / 1_000_000)));
|
|
3256
3476
|
const chapter = input.scope.chapterId ? this.store.getChapter(input.scope.chapterId) : null;
|
|
3257
3477
|
const suggestionId = id("suggestion");
|
|
@@ -3259,22 +3479,17 @@ export class AiManager {
|
|
|
3259
3479
|
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);
|
|
3260
3480
|
const modelDisplayName = typeof generated.model.displayName === "string" ? generated.model.displayName : undefined;
|
|
3261
3481
|
const conversationMessage = input.conversationId && input.assistantMessageRequestId
|
|
3262
|
-
? this.store.
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
processSteps: generated.processSteps,
|
|
3274
|
-
...(generated.anthropicContent?.length ? { anthropicContent: generated.anthropicContent } : {})
|
|
3275
|
-
}
|
|
3276
|
-
})
|
|
3277
|
-
: null;
|
|
3482
|
+
? this.store.upsertAiConversationAssistantMessage(input.conversationId, input.assistantMessageRequestId, generated.content, {
|
|
3483
|
+
...(modelDisplayName ? { modelDisplayName } : {}),
|
|
3484
|
+
outputTokens: generated.outputTokens,
|
|
3485
|
+
processDurationMs,
|
|
3486
|
+
...(generated.reasoningContent === undefined ? {} : { reasoningContent: generated.reasoningContent }),
|
|
3487
|
+
...(generated.cacheHitPercent === undefined ? {} : { cacheHitPercent: generated.cacheHitPercent }),
|
|
3488
|
+
toolCalls: generated.toolCalls,
|
|
3489
|
+
processSteps: generated.processSteps,
|
|
3490
|
+
...(generated.anthropicContent?.length ? { anthropicContent: generated.anthropicContent } : {})
|
|
3491
|
+
}, true)
|
|
3492
|
+
: persistedConversationMessage;
|
|
3278
3493
|
if (shouldGenerateTitle && conversationMessage && input.conversationId) {
|
|
3279
3494
|
void this.generateConversationTitle(input.workId, input.conversationId, titleModelId, firstUserContent, generated.content, defaultTitle).catch((error) => {
|
|
3280
3495
|
logger.warn("ai.conversation_title.failed", { workId: input.workId, conversationId: input.conversationId, error: aiErrorForLog(error) });
|
|
@@ -4356,7 +4571,7 @@ export class AiManager {
|
|
|
4356
4571
|
.filter(([, module]) => canReadWorkModule(permissions, module))
|
|
4357
4572
|
.map(([category]) => category));
|
|
4358
4573
|
}
|
|
4359
|
-
async executeAgentTool(workId, toolCall, maximumResultChars = AGENT_TOOL_RESULT_MAX_CHARS, roleplayCharacterId = null, allowedToolIds, signal, onUsage) {
|
|
4574
|
+
async executeAgentTool(workId, toolCall, maximumResultChars = AGENT_TOOL_RESULT_MAX_CHARS, roleplayCharacterId = null, allowedToolIds, signal, onUsage, scope) {
|
|
4360
4575
|
const name = toolCall.function.name;
|
|
4361
4576
|
const calledAt = now();
|
|
4362
4577
|
const maximumRecordChars = Math.max(128, Math.min(6_000, maximumResultChars - 500));
|
|
@@ -4423,6 +4638,9 @@ export class AiManager {
|
|
|
4423
4638
|
};
|
|
4424
4639
|
}
|
|
4425
4640
|
const args = parsed.data;
|
|
4641
|
+
const scopedChapterIds = scope && (scope.type === "chapter" || scope.type === "volume" || scope.type === "book")
|
|
4642
|
+
? new Set(this.getScopeChapters(workId, scope).map((chapter) => String(chapter.id)))
|
|
4643
|
+
: null;
|
|
4426
4644
|
if (name === "recall_relationship") {
|
|
4427
4645
|
if (!roleplayCharacterId)
|
|
4428
4646
|
throw new Error("Roleplay character is required for recall_relationship");
|
|
@@ -4634,7 +4852,7 @@ export class AiManager {
|
|
|
4634
4852
|
.map((item) => item.trim()).filter(Boolean).slice(0, 10);
|
|
4635
4853
|
const seenParagraphs = new Set();
|
|
4636
4854
|
for (const identityTerm of identityTerms) {
|
|
4637
|
-
for (const paragraph of this.store.searchChapterParagraphs(workId, identityTerm, 50)) {
|
|
4855
|
+
for (const paragraph of this.store.searchChapterParagraphs(workId, identityTerm, 50, { excludeAuthorNotes: true })) {
|
|
4638
4856
|
const key = `${String(paragraph.chapterId)}:${String(paragraph.paragraph)}`;
|
|
4639
4857
|
if (seenParagraphs.has(key))
|
|
4640
4858
|
continue;
|
|
@@ -4669,7 +4887,7 @@ export class AiManager {
|
|
|
4669
4887
|
if (name === "story_index") {
|
|
4670
4888
|
const { offset, limit, cursor } = args;
|
|
4671
4889
|
const work = this.store.getWork(workId);
|
|
4672
|
-
const chapterPage = this.store.getStoryIndexChapterPage(workId, offset, limit);
|
|
4890
|
+
const chapterPage = this.store.getStoryIndexChapterPage(workId, offset, limit, { excludeAuthorNotes: true });
|
|
4673
4891
|
const workRecords = structuralToolResultRecords([{
|
|
4674
4892
|
id: work.id,
|
|
4675
4893
|
title: work.title,
|
|
@@ -4721,10 +4939,15 @@ export class AiManager {
|
|
|
4721
4939
|
const { chapterIds, include, cursor } = args;
|
|
4722
4940
|
const summaries = new Map(this.store.listCurrentChapterInsights(workId).map((item) => [String(item.chapterId), String(item.summary)]));
|
|
4723
4941
|
const chapters = chapterIds.map((chapterId) => {
|
|
4942
|
+
if (scopedChapterIds && !scopedChapterIds.has(chapterId)) {
|
|
4943
|
+
return { chapterId, error: { code: "CHAPTER_OUTSIDE_ANALYSIS_SCOPE", message: "The requested chapter is outside the current analysis scope." } };
|
|
4944
|
+
}
|
|
4724
4945
|
try {
|
|
4725
4946
|
const chapter = this.store.getChapter(chapterId);
|
|
4726
4947
|
if (chapter.workId !== workId)
|
|
4727
4948
|
return { chapterId, error: { code: "CHAPTER_WORK_MISMATCH", message: "The requested chapter belongs to a different work." } };
|
|
4949
|
+
if (isAuthorNoteChapter(chapter))
|
|
4950
|
+
return { chapterId, error: { code: "CHAPTER_AUTHOR_NOTE_EXCLUDED", message: "Author notes are excluded from AI context." } };
|
|
4728
4951
|
const content = collapseAiBlankLines(String(chapter.content));
|
|
4729
4952
|
return { chapterId, title: chapter.title, versionNo: chapter.versionNo, ...(include !== "content" ? { summary: summaries.get(chapterId) ?? "" } : {}), ...(include !== "summary" ? { content } : {}) };
|
|
4730
4953
|
}
|
|
@@ -4742,7 +4965,8 @@ export class AiManager {
|
|
|
4742
4965
|
}
|
|
4743
4966
|
if (name === "grep") {
|
|
4744
4967
|
const { keyword, limit, cursor } = args;
|
|
4745
|
-
const matches = this.store.searchChapterParagraphs(workId, keyword, limit)
|
|
4968
|
+
const matches = this.store.searchChapterParagraphs(workId, keyword, limit, { excludeAuthorNotes: true })
|
|
4969
|
+
.filter((match) => !scopedChapterIds || scopedChapterIds.has(String(match.chapterId)));
|
|
4746
4970
|
const records = structuralToolResultRecords(matches, maximumRecordChars);
|
|
4747
4971
|
const result = paginateToolResultRecords(records, cursor, (page, pagination) => ({
|
|
4748
4972
|
ok: true,
|
|
@@ -4770,6 +4994,15 @@ export class AiManager {
|
|
|
4770
4994
|
: sourceType === "chapter-outline" ? "outline" : sourceType;
|
|
4771
4995
|
if (!requestedCategories.has(type))
|
|
4772
4996
|
return [];
|
|
4997
|
+
if (sourceType === "chapter") {
|
|
4998
|
+
try {
|
|
4999
|
+
if (isAuthorNoteChapter(this.store.getChapter(String(item.id))))
|
|
5000
|
+
return [];
|
|
5001
|
+
}
|
|
5002
|
+
catch {
|
|
5003
|
+
return [];
|
|
5004
|
+
}
|
|
5005
|
+
}
|
|
4773
5006
|
return [{
|
|
4774
5007
|
...item,
|
|
4775
5008
|
...this.hybridAiSearchDetails(workId, sourceType, String(item.id)),
|
|
@@ -5417,7 +5650,7 @@ export class AiManager {
|
|
|
5417
5650
|
const maximumResultChars = toolResultMaximumChars(assistantToolMessage, toolCalls.length);
|
|
5418
5651
|
const currentRoundMessages = [assistantToolMessage];
|
|
5419
5652
|
for (const toolCall of toolCalls) {
|
|
5420
|
-
const execution = await this.executeAgentTool(input.workId, toolCall, maximumResultChars, generationRoleplayCharacterId, allowedToolIds, input.signal, trackUsage);
|
|
5653
|
+
const execution = await this.executeAgentTool(input.workId, toolCall, maximumResultChars, generationRoleplayCharacterId, allowedToolIds, input.signal, trackUsage, input.scope);
|
|
5421
5654
|
logger.info("ai.tool_call.completed", {
|
|
5422
5655
|
callId,
|
|
5423
5656
|
toolName: execution.name,
|
|
@@ -5869,27 +6102,52 @@ export class AiManager {
|
|
|
5869
6102
|
};
|
|
5870
6103
|
}
|
|
5871
6104
|
async runChapterAnalysis(workId, scope, modelId, taskId) {
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
const
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
5890
|
-
|
|
5891
|
-
|
|
5892
|
-
|
|
6105
|
+
const chapters = this.getScopeChapters(workId, scope);
|
|
6106
|
+
if (chapters.length === 0)
|
|
6107
|
+
throw new AppError(409, "CHAPTERS_REQUIRED", "章节分析范围内没有章节");
|
|
6108
|
+
const analyses = [];
|
|
6109
|
+
const insightIds = [];
|
|
6110
|
+
const callIds = [];
|
|
6111
|
+
for (const [index, chapter] of chapters.entries()) {
|
|
6112
|
+
const chapterScope = {
|
|
6113
|
+
...scope,
|
|
6114
|
+
type: "chapter",
|
|
6115
|
+
chapterId: String(chapter.id),
|
|
6116
|
+
chapterIds: undefined,
|
|
6117
|
+
volumeId: undefined,
|
|
6118
|
+
volumeIds: undefined
|
|
6119
|
+
};
|
|
6120
|
+
const generated = await this.generateTaggedJson({
|
|
6121
|
+
workId,
|
|
6122
|
+
taskId,
|
|
6123
|
+
taskType: "chapter-analysis",
|
|
6124
|
+
signal: this.taskSignal(taskId),
|
|
6125
|
+
instruction: "分析本章并输出 JSON 对象,字段为 summary(1至3句)、events(数组)、characters(数组)、settings(数组)、evidence(数组,每项含 conclusion 和 quote)、uncertainties(数组)。",
|
|
6126
|
+
scope: chapterScope,
|
|
6127
|
+
...(modelId ? { modelId } : {}),
|
|
6128
|
+
extraSystemPrompt: "本任务要求严格输出可解析的 JSON。"
|
|
6129
|
+
});
|
|
6130
|
+
const data = extractJson(generated.content);
|
|
6131
|
+
if (!this.taskCanCommit(taskId))
|
|
6132
|
+
return { interrupted: true, callIds };
|
|
6133
|
+
const insightId = id("insight");
|
|
6134
|
+
this.store.db.run(`INSERT INTO chapter_insights (id, chapter_id, chapter_version, summary, events_json, characters_json,
|
|
6135
|
+
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());
|
|
6136
|
+
this.store.db.run("UPDATE chapters SET analysis_status = 'review' WHERE id = ?", String(chapter.id));
|
|
6137
|
+
analyses.push({ insightId, chapterId: chapter.id, chapterVersion: chapter.versionNo, callId: generated.callId, ...data });
|
|
6138
|
+
insightIds.push(insightId);
|
|
6139
|
+
callIds.push(generated.callId);
|
|
6140
|
+
if (taskId && this.store.getTask(taskId).status === "running") {
|
|
6141
|
+
this.store.updateTask(taskId, { status: "running", progress: Math.min(95, Math.round((index + 1) / chapters.length * 95)) });
|
|
6142
|
+
}
|
|
6143
|
+
}
|
|
6144
|
+
return {
|
|
6145
|
+
...(chapters.length === 1 ? analyses[0] : {}),
|
|
6146
|
+
insightIds,
|
|
6147
|
+
chapterIds: chapters.map((chapter) => String(chapter.id)),
|
|
6148
|
+
chapterCount: chapters.length,
|
|
6149
|
+
callIds
|
|
6150
|
+
};
|
|
5893
6151
|
}
|
|
5894
6152
|
async runTimelineAnalysis(workId, scope, modelId, taskId) {
|
|
5895
6153
|
const generated = await this.generateTaggedJson({
|
|
@@ -6230,18 +6488,24 @@ export class AiManager {
|
|
|
6230
6488
|
`首次章节=${String(character.firstChapterId ?? "未知")}`
|
|
6231
6489
|
].join(" | ");
|
|
6232
6490
|
}).join("\n");
|
|
6491
|
+
const selectedScopeDescription = scope.type === "chapter"
|
|
6492
|
+
? `指定章节:${[...(scope.chapterIds ?? []), ...(scope.chapterId ? [scope.chapterId] : [])].join("、")}`
|
|
6493
|
+
: scope.type === "volume"
|
|
6494
|
+
? `指定分卷:${[...(scope.volumeIds ?? []), ...(scope.volumeId ? [scope.volumeId] : [])].join("、")}`
|
|
6495
|
+
: scope.type === "book" ? "全书" : "当前分析范围";
|
|
6233
6496
|
const generated = await this.generateTaggedJson({
|
|
6234
6497
|
workId,
|
|
6235
6498
|
taskId,
|
|
6236
6499
|
taskType: "book-analysis",
|
|
6237
6500
|
signal: this.taskSignal(taskId),
|
|
6238
|
-
scope
|
|
6501
|
+
scope,
|
|
6239
6502
|
...(modelId ? { modelId } : {}),
|
|
6240
6503
|
parameters: { temperature: 0.1 },
|
|
6241
6504
|
agentToolIds: requiredTools,
|
|
6242
6505
|
agentToolCallLimit: 48,
|
|
6243
6506
|
instruction: [
|
|
6244
6507
|
"审核角色规范表,找出可能把同一个角色误建成两个档案的组合,最多输出 12 组。",
|
|
6508
|
+
`本次只审核${selectedScopeDescription}内的正文证据。search_story_entities 可以查询角色档案,但 grep 和 read_chapters 只能用于当前分析范围,不能扩大到范围外章节。`,
|
|
6245
6509
|
"角色规范表:",
|
|
6246
6510
|
roster,
|
|
6247
6511
|
"你必须主动使用 search_story_entities 按角色主名或别名查找角色档案和关系,并使用 grep 分别搜索疑似组合两侧的主名或别名;需要上下文时再用 read_chapters。工具调用总数不得超过 48 次。",
|
|
@@ -6955,21 +7219,43 @@ export class AiManager {
|
|
|
6955
7219
|
if (scope.type === "settings")
|
|
6956
7220
|
return new Set();
|
|
6957
7221
|
if (scope.type === "chapter") {
|
|
6958
|
-
|
|
7222
|
+
const chapterIds = [...new Set([
|
|
7223
|
+
...(scope.chapterId ? [scope.chapterId] : []),
|
|
7224
|
+
...(scope.chapterIds ?? [])
|
|
7225
|
+
])];
|
|
7226
|
+
if (chapterIds.length === 0)
|
|
6959
7227
|
throw new AppError(400, "CHAPTER_REQUIRED", "分析范围缺少章节标识");
|
|
6960
|
-
const
|
|
6961
|
-
|
|
6962
|
-
|
|
6963
|
-
|
|
7228
|
+
for (const chapterId of chapterIds) {
|
|
7229
|
+
const chapter = this.store.getChapter(chapterId);
|
|
7230
|
+
if (String(chapter.workId) !== workId)
|
|
7231
|
+
throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
|
|
7232
|
+
}
|
|
7233
|
+
return new Set(chapterIds.filter((chapterId) => {
|
|
7234
|
+
try {
|
|
7235
|
+
return !isAuthorNoteChapter(this.store.getChapter(chapterId));
|
|
7236
|
+
}
|
|
7237
|
+
catch {
|
|
7238
|
+
return false;
|
|
7239
|
+
}
|
|
7240
|
+
}));
|
|
6964
7241
|
}
|
|
6965
7242
|
if (scope.type === "volume") {
|
|
6966
|
-
|
|
7243
|
+
const volumeIds = [...new Set([
|
|
7244
|
+
...(scope.volumeId ? [scope.volumeId] : []),
|
|
7245
|
+
...(scope.volumeIds ?? [])
|
|
7246
|
+
])];
|
|
7247
|
+
if (volumeIds.length === 0)
|
|
6967
7248
|
throw new AppError(400, "VOLUME_REQUIRED", "分析范围缺少分卷标识");
|
|
6968
|
-
const
|
|
6969
|
-
|
|
6970
|
-
|
|
6971
|
-
|
|
6972
|
-
|
|
7249
|
+
const chapterIds = new Set();
|
|
7250
|
+
for (const volumeId of volumeIds) {
|
|
7251
|
+
const volume = this.store.getVolume(volumeId);
|
|
7252
|
+
if (String(volume.workId) !== workId)
|
|
7253
|
+
throw new AppError(400, "VOLUME_WORK_MISMATCH", "分卷不属于当前作品");
|
|
7254
|
+
for (const row of this.store.db.all(`SELECT id FROM chapters WHERE work_id = ? AND volume_id = ? AND deleted_at IS NULL
|
|
7255
|
+
AND excluded_from_analysis = 0 AND chapter_type <> '作者的话'`, workId, volumeId))
|
|
7256
|
+
chapterIds.add(String(row.id));
|
|
7257
|
+
}
|
|
7258
|
+
return chapterIds;
|
|
6973
7259
|
}
|
|
6974
7260
|
return new Set(this.store.db.all(`SELECT id FROM chapters WHERE work_id = ? AND deleted_at IS NULL AND excluded_from_analysis = 0 AND chapter_type <> '作者的话'`, workId).map((row) => String(row.id)));
|
|
6975
7261
|
}
|
|
@@ -6979,6 +7265,8 @@ export class AiManager {
|
|
|
6979
7265
|
const chapter = this.store.getChapter(sourceId);
|
|
6980
7266
|
if (String(chapter.workId) !== workId)
|
|
6981
7267
|
return null;
|
|
7268
|
+
if (isAuthorNoteChapter(chapter))
|
|
7269
|
+
return null;
|
|
6982
7270
|
return {
|
|
6983
7271
|
sourceType,
|
|
6984
7272
|
sourceId,
|
|
@@ -7859,6 +8147,79 @@ export class AiManager {
|
|
|
7859
8147
|
const settings = availableSettings.filter((source) => requestedRefs.has(this.relationshipIndexedSourceKey(source.sourceType, source.sourceId)));
|
|
7860
8148
|
return { chapters, settings };
|
|
7861
8149
|
}
|
|
8150
|
+
validateRelationshipSourceRefs(workId, scope, refs) {
|
|
8151
|
+
for (const ref of refs) {
|
|
8152
|
+
const currentVersion = this.relationshipSourceRefVersion(workId, scope, ref.sourceType, ref.sourceId);
|
|
8153
|
+
if (currentVersion === null || currentVersion !== ref.sourceVersion) {
|
|
8154
|
+
throw new AppError(409, "RELATIONSHIP_SOURCE_PREVIEW_STALE", "来源已在预检后发生变化,请重新预览", {
|
|
8155
|
+
sourceType: ref.sourceType,
|
|
8156
|
+
sourceId: ref.sourceId
|
|
8157
|
+
});
|
|
8158
|
+
}
|
|
8159
|
+
}
|
|
8160
|
+
}
|
|
8161
|
+
relationshipSourceRefVersion(workId, scope, sourceType, sourceId) {
|
|
8162
|
+
if (sourceType === "chapter") {
|
|
8163
|
+
if (scope.type === "settings")
|
|
8164
|
+
return null;
|
|
8165
|
+
const chapter = this.store.db.get(`SELECT chapter.version_no, chapter.volume_id, chapter.chapter_type, chapter.excluded_from_analysis
|
|
8166
|
+
FROM chapters chapter
|
|
8167
|
+
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
8168
|
+
WHERE chapter.id = ? AND chapter.work_id = ?
|
|
8169
|
+
AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL`, sourceId, workId);
|
|
8170
|
+
if (!chapter)
|
|
8171
|
+
return null;
|
|
8172
|
+
if (scope.type === "chapter" && scope.chapterId !== sourceId)
|
|
8173
|
+
return null;
|
|
8174
|
+
if (scope.type === "volume" && scope.volumeId !== String(chapter.volume_id))
|
|
8175
|
+
return null;
|
|
8176
|
+
if (Boolean(chapter.excluded_from_analysis) || String(chapter.chapter_type) === "作者的话")
|
|
8177
|
+
return null;
|
|
8178
|
+
return String(chapter.version_no);
|
|
8179
|
+
}
|
|
8180
|
+
if (scope.type !== "settings" && scope.includeAllSettings !== true)
|
|
8181
|
+
return null;
|
|
8182
|
+
if (sourceType === "work") {
|
|
8183
|
+
const work = this.store.db.get("SELECT version_no FROM works WHERE id = ? AND id = ? AND deleted_at IS NULL", sourceId, workId);
|
|
8184
|
+
return work ? String(work.version_no) : null;
|
|
8185
|
+
}
|
|
8186
|
+
if (sourceType === "character") {
|
|
8187
|
+
const character = this.store.db.get("SELECT version_no FROM characters WHERE id = ? AND work_id = ? AND merged_into_character_id IS NULL", sourceId, workId);
|
|
8188
|
+
return character ? String(character.version_no) : null;
|
|
8189
|
+
}
|
|
8190
|
+
if (sourceType === "review") {
|
|
8191
|
+
const review = this.store.db.get("SELECT updated_at FROM review_items WHERE id = ? AND work_id = ?", sourceId, workId);
|
|
8192
|
+
return review ? String(review.updated_at) : null;
|
|
8193
|
+
}
|
|
8194
|
+
if (sourceType === "chapter-outline") {
|
|
8195
|
+
const outline = this.store.db.get(`SELECT outline.chapter_id FROM chapter_outlines outline
|
|
8196
|
+
JOIN chapters chapter ON chapter.id = outline.chapter_id
|
|
8197
|
+
WHERE outline.chapter_id = ? AND chapter.work_id = ? AND chapter.deleted_at IS NULL`, sourceId, workId);
|
|
8198
|
+
if (!outline)
|
|
8199
|
+
return null;
|
|
8200
|
+
const version = this.store.db.get(`SELECT COALESCE(MAX(version_no), 0) AS version_no FROM entity_versions
|
|
8201
|
+
WHERE work_id = ? AND entity_type = 'chapter-outline' AND entity_id = ?`, workId, sourceId);
|
|
8202
|
+
return String(Number(version?.version_no ?? 0));
|
|
8203
|
+
}
|
|
8204
|
+
const tableBySourceType = {
|
|
8205
|
+
setting: "settings",
|
|
8206
|
+
race: "races",
|
|
8207
|
+
organization: "organizations",
|
|
8208
|
+
"timeline-track": "timeline_tracks",
|
|
8209
|
+
"timeline-event": "timeline_events",
|
|
8210
|
+
relationship: "relationships",
|
|
8211
|
+
foreshadow: "foreshadows"
|
|
8212
|
+
};
|
|
8213
|
+
const table = tableBySourceType[sourceType];
|
|
8214
|
+
if (!table)
|
|
8215
|
+
return null;
|
|
8216
|
+
const source = this.store.db.get(`SELECT id FROM ${table} WHERE id = ? AND work_id = ?`, sourceId, workId);
|
|
8217
|
+
if (!source)
|
|
8218
|
+
return null;
|
|
8219
|
+
const version = this.store.db.get(`SELECT COALESCE(MAX(version_no), 0) AS version_no FROM entity_versions
|
|
8220
|
+
WHERE work_id = ? AND entity_type = ? AND entity_id = ?`, workId, sourceType, sourceId);
|
|
8221
|
+
return String(Number(version?.version_no ?? 0));
|
|
8222
|
+
}
|
|
7862
8223
|
async previewRelationshipSources(workId, scope, modelId) {
|
|
7863
8224
|
const characters = this.store.listCharacters(workId);
|
|
7864
8225
|
if (characters.length < 2)
|
|
@@ -8735,20 +9096,37 @@ export class AiManager {
|
|
|
8735
9096
|
const tree = this.store.getWorkTree(workId);
|
|
8736
9097
|
const volumes = tree.volumes;
|
|
8737
9098
|
if (scope.type === "chapter") {
|
|
8738
|
-
|
|
9099
|
+
const chapterIds = [...new Set([
|
|
9100
|
+
...(scope.chapterId ? [scope.chapterId] : []),
|
|
9101
|
+
...(scope.chapterIds ?? [])
|
|
9102
|
+
])];
|
|
9103
|
+
if (chapterIds.length === 0)
|
|
8739
9104
|
throw new AppError(400, "CHAPTER_REQUIRED", "分析范围缺少章节标识");
|
|
8740
|
-
const
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
|
|
9105
|
+
const selected = new Set(chapterIds);
|
|
9106
|
+
const chapters = volumes.flatMap((volume) => volume.chapters)
|
|
9107
|
+
.filter((chapter) => selected.has(String(chapter.id)) && this.isAutomaticAnalysisChapter(chapter));
|
|
9108
|
+
if (chapters.length !== selected.size) {
|
|
9109
|
+
for (const chapterId of chapterIds) {
|
|
9110
|
+
const chapter = this.store.getChapter(chapterId);
|
|
9111
|
+
if (chapter.workId !== workId)
|
|
9112
|
+
throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
|
|
9113
|
+
}
|
|
9114
|
+
}
|
|
9115
|
+
return chapters;
|
|
8744
9116
|
}
|
|
8745
9117
|
if (scope.type === "volume") {
|
|
8746
|
-
|
|
8747
|
-
|
|
8748
|
-
|
|
8749
|
-
|
|
9118
|
+
const volumeIds = [...new Set([
|
|
9119
|
+
...(scope.volumeId ? [scope.volumeId] : []),
|
|
9120
|
+
...(scope.volumeIds ?? [])
|
|
9121
|
+
])];
|
|
9122
|
+
if (volumeIds.length === 0)
|
|
9123
|
+
throw new AppError(400, "VOLUME_REQUIRED", "分析范围缺少分卷标识");
|
|
9124
|
+
const selected = new Set(volumeIds);
|
|
9125
|
+
const selectedVolumes = volumes.filter((volume) => selected.has(String(volume.id)));
|
|
9126
|
+
if (selectedVolumes.length !== selected.size)
|
|
8750
9127
|
throw notFound("卷");
|
|
8751
|
-
return
|
|
9128
|
+
return selectedVolumes.flatMap((volume) => volume.chapters)
|
|
9129
|
+
.filter((chapter) => this.isAutomaticAnalysisChapter(chapter));
|
|
8752
9130
|
}
|
|
8753
9131
|
return volumes.flatMap((volume) => volume.chapters)
|
|
8754
9132
|
.filter((chapter) => this.isAutomaticAnalysisChapter(chapter));
|
|
@@ -8780,7 +9158,7 @@ export class AiManager {
|
|
|
8780
9158
|
});
|
|
8781
9159
|
}
|
|
8782
9160
|
isAutomaticAnalysisChapter(chapter) {
|
|
8783
|
-
return !chapter.excludedFromAnalysis && chapter
|
|
9161
|
+
return !chapter.excludedFromAnalysis && !isAuthorNoteChapter(chapter);
|
|
8784
9162
|
}
|
|
8785
9163
|
buildChapterChunks(chapters, maximumChars = 10_000) {
|
|
8786
9164
|
const chunks = [];
|