@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 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
- contentSections.push(wrapAiContextRegion("selection", `当前选中文本:\n${scope.selection}`, { escape: false }));
1261
- if (scope.chapterId)
1262
- this.appendChapter(contentSections, workId, scope.chapterId, false);
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
- if (!scope.chapterId)
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
- this.appendPreviousChapterTail(contentSections, workId, scope.chapterId);
1268
- this.appendChapter(contentSections, workId, scope.chapterId, true);
1269
- if (scope.selection)
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
- if (!scope.volumeId)
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 volume = tree.volumes.find((item) => item.id === scope.volumeId);
1277
- if (!volume)
1278
- throw notFound("卷");
1279
- const chapters = volume.chapters;
1280
- contentSections.push(wrapAiContextRegion("volume", `当前卷:${String(volume.title)}`));
1281
- for (const chapter of chapters) {
1282
- contentSections.push(wrapAiContextRegion("chapter", `[${String(volume.title)} / ${String(chapter.title)} | 版本 ${String(chapter.versionNo)}]\n${String(chapter.content)}`));
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
- if (chapters.length) {
1375
- contentSections.push(wrapAiContextRegion("referenced_chapters", `作者主动引用的章节:\n${chapters
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.relationshipSourcesFromRefs(workId, relationshipScope, this.store.listCharacters(workId), relationshipScope.relationshipSourceRefs);
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
- const generated = await this.generate({ ...input, taskType: "chat" }, onDelta);
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.addAiConversationMessage(input.conversationId, {
3263
- role: "assistant",
3264
- content: generated.content,
3265
- requestId: input.assistantMessageRequestId,
3266
- metadata: {
3267
- ...(modelDisplayName ? { modelDisplayName } : {}),
3268
- outputTokens: generated.outputTokens,
3269
- processDurationMs,
3270
- ...(generated.reasoningContent === undefined ? {} : { reasoningContent: generated.reasoningContent }),
3271
- ...(generated.cacheHitPercent === undefined ? {} : { cacheHitPercent: generated.cacheHitPercent }),
3272
- toolCalls: generated.toolCalls,
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
- if (!scope.chapterId)
5873
- throw new AppError(400, "CHAPTER_REQUIRED", "章节分析必须指定章节");
5874
- const chapter = this.store.getChapter(scope.chapterId);
5875
- const generated = await this.generateTaggedJson({
5876
- workId,
5877
- taskId,
5878
- taskType: "chapter-analysis",
5879
- signal: this.taskSignal(taskId),
5880
- instruction: "分析本章并输出 JSON 对象,字段为 summary(1至3句)、events(数组)、characters(数组)、settings(数组)、evidence(数组,每项含 conclusion 和 quote)、uncertainties(数组)。",
5881
- scope,
5882
- ...(modelId ? { modelId } : {}),
5883
- extraSystemPrompt: "本任务要求严格输出可解析的 JSON。"
5884
- });
5885
- const data = extractJson(generated.content);
5886
- if (!this.taskCanCommit(taskId))
5887
- return { interrupted: true, callId: generated.callId };
5888
- const insightId = id("insight");
5889
- this.store.db.run(`INSERT INTO chapter_insights (id, chapter_id, chapter_version, summary, events_json, characters_json,
5890
- 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());
5891
- this.store.db.run("UPDATE chapters SET analysis_status = 'review' WHERE id = ?", String(chapter.id));
5892
- return { insightId, chapterId: chapter.id, chapterVersion: chapter.versionNo, callId: generated.callId, ...data };
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: scope.type === "none" ? scope : { type: "none" },
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
- if (!scope.chapterId)
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 chapter = this.store.getChapter(scope.chapterId);
6961
- if (String(chapter.workId) !== workId)
6962
- throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
6963
- return new Set([scope.chapterId]);
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
- if (!scope.volumeId)
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 volume = this.store.getVolume(scope.volumeId);
6969
- if (String(volume.workId) !== workId)
6970
- throw new AppError(400, "VOLUME_WORK_MISMATCH", "分卷不属于当前作品");
6971
- return new Set(this.store.db.all(`SELECT id FROM chapters WHERE work_id = ? AND volume_id = ? AND deleted_at IS NULL
6972
- AND excluded_from_analysis = 0 AND chapter_type <> '作者的话'`, workId, scope.volumeId).map((row) => String(row.id)));
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
- if (!scope.chapterId)
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 chapter = this.store.getChapter(scope.chapterId);
8741
- if (chapter.workId !== workId)
8742
- throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
8743
- return [chapter];
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
- if (!scope.volumeId)
8747
- throw new AppError(400, "VOLUME_REQUIRED", "分析范围缺少卷标识");
8748
- const volume = volumes.find((item) => item.id === scope.volumeId);
8749
- if (!volume)
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 volume.chapters.filter((chapter) => this.isAutomaticAnalysisChapter(chapter));
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.chapterType !== "作者的话";
9161
+ return !chapter.excludedFromAnalysis && !isAuthorNoteChapter(chapter);
8784
9162
  }
8785
9163
  buildChapterChunks(chapters, maximumChars = 10_000) {
8786
9164
  const chunks = [];