@musnows/scriverse 0.7.11 → 0.7.12

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
@@ -1200,11 +1200,11 @@ export class ContextBuilder {
1200
1200
  ? [wrapAiContextRegion("work", `作品:${String(work.title)}\n作者:${String(work.author) || "未填写"}`)]
1201
1201
  : [];
1202
1202
  const contentSections = [];
1203
- const availableSettings = this.store.listSettings(workId);
1203
+ const availableSettings = includeSettingInfo ? this.store.listSettings(workId) : [];
1204
1204
  const contextualSettings = !includeSettingInfo
1205
1205
  ? []
1206
1206
  : scope.includeAllSettings ? availableSettings : availableSettings.filter((item) => item.locked);
1207
- const allCharacters = this.store.listCharacters(workId);
1207
+ const allCharacters = includeSettingInfo ? this.store.listCharacters(workId) : [];
1208
1208
  const lockedCharacters = includeSettingInfo
1209
1209
  ? allCharacters.filter((item) => Array.isArray(item.lockedFields) && item.lockedFields.length > 0)
1210
1210
  : [];
@@ -1262,24 +1262,37 @@ export class ContextBuilder {
1262
1262
  this.appendChapter(contentSections, workId, scope.chapterId, false);
1263
1263
  }
1264
1264
  else if (scope.type === "chapter") {
1265
- if (!scope.chapterId)
1265
+ const chapterIds = [...new Set([
1266
+ ...(scope.chapterId ? [scope.chapterId] : []),
1267
+ ...(scope.chapterIds ?? [])
1268
+ ])];
1269
+ if (chapterIds.length === 0)
1266
1270
  throw new AppError(400, "CHAPTER_REQUIRED", "章节上下文缺少章节标识");
1267
- this.appendPreviousChapterTail(contentSections, workId, scope.chapterId);
1268
- this.appendChapter(contentSections, workId, scope.chapterId, true);
1271
+ if (chapterIds.length === 1 && scope.chapterId)
1272
+ this.appendPreviousChapterTail(contentSections, workId, scope.chapterId);
1273
+ for (const chapterId of chapterIds)
1274
+ this.appendChapter(contentSections, workId, chapterId, true);
1269
1275
  if (scope.selection)
1270
1276
  contentSections.push(wrapAiContextRegion("selection", `当前选中文本(本次修改目标):\n${scope.selection}`, { escape: false }));
1271
1277
  }
1272
1278
  else if (scope.type === "volume") {
1273
- if (!scope.volumeId)
1279
+ const volumeIds = [...new Set([
1280
+ ...(scope.volumeId ? [scope.volumeId] : []),
1281
+ ...(scope.volumeIds ?? [])
1282
+ ])];
1283
+ if (volumeIds.length === 0)
1274
1284
  throw new AppError(400, "VOLUME_REQUIRED", "卷上下文缺少卷标识");
1275
1285
  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)}`));
1286
+ const volumes = tree.volumes;
1287
+ for (const volumeId of volumeIds) {
1288
+ const volume = volumes.find((item) => item.id === volumeId);
1289
+ if (!volume)
1290
+ throw notFound("");
1291
+ const chapters = volume.chapters;
1292
+ contentSections.push(wrapAiContextRegion("volume", `当前卷:${String(volume.title)}`));
1293
+ for (const chapter of chapters) {
1294
+ contentSections.push(wrapAiContextRegion("chapter", `[${String(volume.title)} / ${String(chapter.title)} | 版本 ${String(chapter.versionNo)}]\n${String(chapter.content)}`));
1295
+ }
1283
1296
  }
1284
1297
  }
1285
1298
  else if (scope.type === "book") {
@@ -1302,7 +1315,7 @@ export class ContextBuilder {
1302
1315
  : "设定库目录:\n(暂无设定条目)"));
1303
1316
  }
1304
1317
  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);
1318
+ this.appendBookSummary(contentSections, workId, bookSummaryMaximumTokens ?? Math.max(160, Math.floor(maximumTokens * 0.35)), query, scope.type === "volume" && !scope.volumeIds?.length ? scope.volumeId : undefined);
1306
1319
  }
1307
1320
  if (scope.characterIds?.length) {
1308
1321
  const characters = scope.characterIds.map((characterId) => this.store.getCharacter(characterId));
@@ -2686,6 +2699,166 @@ export class AiManager {
2686
2699
  model: this.getModel(stringValue(row, "model_id"))
2687
2700
  })), pagination);
2688
2701
  }
2702
+ analysisTaskContextPreviewInput(workId, taskType, scope) {
2703
+ const previewTaskType = this.analysisTaskModelPurpose(taskType);
2704
+ let previewScope = scope;
2705
+ let instruction = "请基于所选分析范围完成结构化小说分析,只引用注入资料中的事实并给出可追溯证据。";
2706
+ let agentToolIds;
2707
+ if (taskType === "chapter-analysis") {
2708
+ const chapters = this.getScopeChapters(workId, scope);
2709
+ if (chapters.length === 0)
2710
+ throw new AppError(409, "CHAPTERS_REQUIRED", "章节分析范围内没有章节");
2711
+ const chapter = [...chapters].sort((left, right) => String(right.content).length - String(left.content).length)[0];
2712
+ if (!chapter)
2713
+ throw new AppError(409, "CHAPTERS_REQUIRED", "章节分析范围内没有章节");
2714
+ previewScope = {
2715
+ ...scope,
2716
+ type: "chapter",
2717
+ chapterId: String(chapter.id),
2718
+ chapterIds: undefined,
2719
+ volumeId: undefined,
2720
+ volumeIds: undefined
2721
+ };
2722
+ instruction = "分析当前章节并输出结构化结果,字段包括摘要、事件、人物、设定、证据和不确定项。";
2723
+ }
2724
+ else if (taskType === "character-extraction" || taskType === "character-summary" || taskType === "setting-extraction") {
2725
+ const chapters = this.getScopeChapters(workId, scope);
2726
+ if (chapters.length === 0)
2727
+ throw new AppError(409, "CHAPTERS_REQUIRED", "分析范围内没有章节");
2728
+ const chunks = this.buildChapterChunks(chapters, 10_000);
2729
+ const selection = [...chunks].sort((left, right) => right.text.length - left.text.length)[0]?.text ?? "";
2730
+ previewScope = { type: "selection", selection };
2731
+ instruction = taskType === "setting-extraction"
2732
+ ? "从本批正文抽取可复用的世界设定候选,并为每条候选提供原文证据。"
2733
+ : "从本批正文抽取人物候选,并为每位候选提供原文首次出现证据。";
2734
+ }
2735
+ else if (taskType === "relationship-analysis") {
2736
+ const targeted = Boolean(scope.characterIds?.length);
2737
+ const characters = targeted
2738
+ ? this.store.db.all(`SELECT id, name, aliases_json FROM characters
2739
+ WHERE work_id = ? AND merged_into_character_id IS NULL ORDER BY name`, workId).map((row) => ({
2740
+ id: String(row.id),
2741
+ name: String(row.name),
2742
+ aliases: json(String(row.aliases_json), [])
2743
+ }))
2744
+ : this.store.listCharacters(workId);
2745
+ const roster = characters.map((character) => `${String(character.id)} | ${String(character.name)}${Array.isArray(character.aliases) && character.aliases.length ? ` | 别名:${character.aliases.join("、")}` : ""}`).join("\n");
2746
+ let selection = "本次范围没有可注入的正文或设定数据。";
2747
+ if (scope.type === "settings") {
2748
+ const chunks = this.buildSettingChunks(this.relationshipSettingSources(workId, characters), 12_000);
2749
+ selection = [...chunks].sort((left, right) => right.text.length - left.text.length)[0]?.text ?? selection;
2750
+ previewScope = { type: "settings", selection };
2751
+ }
2752
+ else {
2753
+ if (targeted && scope.type === "book") {
2754
+ const stats = this.store.db.get(`SELECT COUNT(*) AS chapter_count,
2755
+ COALESCE(SUM(LENGTH(chapter.content)), 0) AS total_characters
2756
+ FROM chapters chapter
2757
+ JOIN volumes volume ON volume.id = chapter.volume_id
2758
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL
2759
+ AND chapter.excluded_from_analysis = 0 AND chapter.chapter_type <> '作者的话'`, workId) ?? {};
2760
+ const chapterCount = Number(stats.chapter_count ?? 0);
2761
+ const totalCharacters = Number(stats.total_characters ?? 0);
2762
+ if (chapterCount > 0 && totalCharacters > 0) {
2763
+ const previewCharacters = Math.min(totalCharacters + chapterCount * 80, 12_000);
2764
+ selection = `<CHAPTER id="context-preview" title="上下文预检">\n${"字".repeat(previewCharacters)}\n</CHAPTER>`;
2765
+ }
2766
+ }
2767
+ else {
2768
+ const chunks = this.buildChapterChunks(this.getScopeChapters(workId, scope), 12_000);
2769
+ selection = [...chunks].sort((left, right) => right.text.length - left.text.length)[0]?.text ?? selection;
2770
+ }
2771
+ previewScope = {
2772
+ type: "selection",
2773
+ selection,
2774
+ ...(targeted ? { suppressAutomaticContext: true } : {})
2775
+ };
2776
+ }
2777
+ instruction = `抽取本批正文或设定中的人物长期关系候选,只使用原文证据。角色规范表:\n${roster}`;
2778
+ }
2779
+ else if (taskType === "character-identity-audit") {
2780
+ const characters = this.store.listCharacters(workId);
2781
+ const roster = characters.map((character) => `${String(character.id)} | ${String(character.name)} | 别名=${JSON.stringify(character.aliases)} | 身份=${String(character.attributes.identity ?? "未知")}`).join("\n");
2782
+ instruction = `审核角色规范表,找出疑似重复角色并给出原文证据。角色规范表:\n${roster}`;
2783
+ agentToolIds = ["search_story_entities", "grep", "read_chapters"];
2784
+ }
2785
+ else if (taskType === "timeline-analysis") {
2786
+ instruction = "抽取所选范围内的大事件候选,区分发生时间与叙述时间,并为每项提供原文证据。";
2787
+ }
2788
+ else if (taskType === "worldview-analysis") {
2789
+ instruction = "分析所选范围内已经出现的世界观,区分事实、传闻和未知项,并为结论提供原文证据。";
2790
+ }
2791
+ else if (taskType === "consistency-check") {
2792
+ instruction = "检查所选范围内的设定、人物状态、关系和时间冲突,并为每项问题提供原文证据。";
2793
+ }
2794
+ return { taskType: previewTaskType, instruction, scope: previewScope, ...(agentToolIds ? { agentToolIds } : {}) };
2795
+ }
2796
+ previewAnalysisTaskContext(workId, input) {
2797
+ this.store.getWork(workId);
2798
+ const scope = (input.scope ?? { type: "book" });
2799
+ const { model } = this.resolveModel(workId, this.analysisTaskModelPurpose(input.taskType), input.modelId);
2800
+ const previewInput = this.analysisTaskContextPreviewInput(workId, input.taskType, scope);
2801
+ const modelId = stringValue(model, "id");
2802
+ const modelName = stringValue(model, "display_name") || stringValue(model, "model_id");
2803
+ const contextWindow = numberValue(model, "context_window") || DEFAULT_CONTEXT_WINDOW;
2804
+ const compactThreshold = Math.min(90, Math.max(50, Number(this.store.getWorkAiSettings(workId).contextCompactThreshold) || 85));
2805
+ const thresholdTokens = Math.max(256, Math.floor(contextWindow * compactThreshold / 100));
2806
+ const budget = this.contextBudget({ workId, ...previewInput }, model);
2807
+ const base = {
2808
+ allowed: false,
2809
+ taskType: input.taskType,
2810
+ modelId,
2811
+ modelName,
2812
+ contextWindow,
2813
+ thresholdPercent: compactThreshold,
2814
+ thresholdTokens,
2815
+ availableInputTokens: Number(budget.availableInputTokens),
2816
+ contextBudgetTokens: Number(budget.workContextBudgetTokens),
2817
+ requiredContextWindow: null,
2818
+ estimatedInputTokens: null,
2819
+ estimatedContextTokens: null,
2820
+ omittedContextBlocks: 0,
2821
+ degradedContextBlocks: 0
2822
+ };
2823
+ let usage;
2824
+ try {
2825
+ usage = this.getContextUsage({
2826
+ workId,
2827
+ taskType: previewInput.taskType,
2828
+ modelId,
2829
+ scope: previewInput.scope,
2830
+ instruction: previewInput.instruction,
2831
+ agentToolIds: previewInput.agentToolIds
2832
+ });
2833
+ }
2834
+ catch (error) {
2835
+ if (!(error instanceof AppError) || error.code !== "CONSTRAINT_CONTEXT_TOO_LARGE")
2836
+ throw error;
2837
+ return {
2838
+ ...base,
2839
+ overThreshold: true,
2840
+ message: `当前分析范围注入的锁定设定和人物资料已超过模型“${modelName}”的安全上下文容量,请切换到上下文更长的模型,或缩小分析范围后重试。`
2841
+ };
2842
+ }
2843
+ const estimatedInputTokens = Number(usage.inputTokens) || 0;
2844
+ const estimatedContextTokens = Number(usage.contextTokens) || 0;
2845
+ const omittedContextBlocks = Number(usage.omittedContextBlocks) || 0;
2846
+ const degradedContextBlocks = Number(usage.degradedContextBlocks) || 0;
2847
+ const overThreshold = estimatedInputTokens >= thresholdTokens || omittedContextBlocks > 0 || degradedContextBlocks > 0;
2848
+ return {
2849
+ ...base,
2850
+ allowed: !overThreshold,
2851
+ overThreshold,
2852
+ estimatedInputTokens,
2853
+ estimatedContextTokens,
2854
+ omittedContextBlocks,
2855
+ degradedContextBlocks,
2856
+ requiredContextWindow: overThreshold ? Math.ceil(estimatedInputTokens * 100 / compactThreshold) : null,
2857
+ message: overThreshold
2858
+ ? `当前分析范围预计注入约 ${estimatedInputTokens.toLocaleString("zh-CN")} Token,已达到模型“${modelName}”安全阈值(${contextWindow.toLocaleString("zh-CN")} Token 的 ${compactThreshold}%),部分资料将被压缩或省略。请切换到上下文更长的模型,或缩小分析范围后重试。`
2859
+ : "当前分析范围在所选模型的安全上下文阈值内。"
2860
+ };
2861
+ }
2689
2862
  createTask(workId, input) {
2690
2863
  this.store.getWork(workId);
2691
2864
  const modelPurpose = this.analysisTaskModelPurpose(input.taskType);
@@ -2697,7 +2870,17 @@ export class AiManager {
2697
2870
  ? input.scope
2698
2871
  : null;
2699
2872
  if (relationshipScope && Array.isArray(relationshipScope.relationshipSourceRefs)) {
2700
- this.relationshipSourcesFromRefs(workId, relationshipScope, this.store.listCharacters(workId), relationshipScope.relationshipSourceRefs);
2873
+ this.validateRelationshipSourceRefs(workId, relationshipScope, relationshipScope.relationshipSourceRefs);
2874
+ }
2875
+ if (modelId) {
2876
+ const contextPreview = this.previewAnalysisTaskContext(workId, {
2877
+ taskType: input.taskType,
2878
+ scope: input.scope,
2879
+ modelId
2880
+ });
2881
+ if (contextPreview.allowed !== true) {
2882
+ throw new AppError(413, "AI_CONTEXT_TOO_LARGE", String(contextPreview.message), contextPreview);
2883
+ }
2701
2884
  }
2702
2885
  return this.store.createTask(workId, {
2703
2886
  taskType: input.taskType,
@@ -4356,7 +4539,7 @@ export class AiManager {
4356
4539
  .filter(([, module]) => canReadWorkModule(permissions, module))
4357
4540
  .map(([category]) => category));
4358
4541
  }
4359
- async executeAgentTool(workId, toolCall, maximumResultChars = AGENT_TOOL_RESULT_MAX_CHARS, roleplayCharacterId = null, allowedToolIds, signal, onUsage) {
4542
+ async executeAgentTool(workId, toolCall, maximumResultChars = AGENT_TOOL_RESULT_MAX_CHARS, roleplayCharacterId = null, allowedToolIds, signal, onUsage, scope) {
4360
4543
  const name = toolCall.function.name;
4361
4544
  const calledAt = now();
4362
4545
  const maximumRecordChars = Math.max(128, Math.min(6_000, maximumResultChars - 500));
@@ -4423,6 +4606,9 @@ export class AiManager {
4423
4606
  };
4424
4607
  }
4425
4608
  const args = parsed.data;
4609
+ const scopedChapterIds = scope && (scope.type === "chapter" || scope.type === "volume")
4610
+ ? new Set(this.getScopeChapters(workId, scope).map((chapter) => String(chapter.id)))
4611
+ : null;
4426
4612
  if (name === "recall_relationship") {
4427
4613
  if (!roleplayCharacterId)
4428
4614
  throw new Error("Roleplay character is required for recall_relationship");
@@ -4721,6 +4907,9 @@ export class AiManager {
4721
4907
  const { chapterIds, include, cursor } = args;
4722
4908
  const summaries = new Map(this.store.listCurrentChapterInsights(workId).map((item) => [String(item.chapterId), String(item.summary)]));
4723
4909
  const chapters = chapterIds.map((chapterId) => {
4910
+ if (scopedChapterIds && !scopedChapterIds.has(chapterId)) {
4911
+ return { chapterId, error: { code: "CHAPTER_OUTSIDE_ANALYSIS_SCOPE", message: "The requested chapter is outside the current analysis scope." } };
4912
+ }
4724
4913
  try {
4725
4914
  const chapter = this.store.getChapter(chapterId);
4726
4915
  if (chapter.workId !== workId)
@@ -4742,7 +4931,8 @@ export class AiManager {
4742
4931
  }
4743
4932
  if (name === "grep") {
4744
4933
  const { keyword, limit, cursor } = args;
4745
- const matches = this.store.searchChapterParagraphs(workId, keyword, limit);
4934
+ const matches = this.store.searchChapterParagraphs(workId, keyword, limit)
4935
+ .filter((match) => !scopedChapterIds || scopedChapterIds.has(String(match.chapterId)));
4746
4936
  const records = structuralToolResultRecords(matches, maximumRecordChars);
4747
4937
  const result = paginateToolResultRecords(records, cursor, (page, pagination) => ({
4748
4938
  ok: true,
@@ -5417,7 +5607,7 @@ export class AiManager {
5417
5607
  const maximumResultChars = toolResultMaximumChars(assistantToolMessage, toolCalls.length);
5418
5608
  const currentRoundMessages = [assistantToolMessage];
5419
5609
  for (const toolCall of toolCalls) {
5420
- const execution = await this.executeAgentTool(input.workId, toolCall, maximumResultChars, generationRoleplayCharacterId, allowedToolIds, input.signal, trackUsage);
5610
+ const execution = await this.executeAgentTool(input.workId, toolCall, maximumResultChars, generationRoleplayCharacterId, allowedToolIds, input.signal, trackUsage, input.scope);
5421
5611
  logger.info("ai.tool_call.completed", {
5422
5612
  callId,
5423
5613
  toolName: execution.name,
@@ -5869,27 +6059,52 @@ export class AiManager {
5869
6059
  };
5870
6060
  }
5871
6061
  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 };
6062
+ const chapters = this.getScopeChapters(workId, scope);
6063
+ if (chapters.length === 0)
6064
+ throw new AppError(409, "CHAPTERS_REQUIRED", "章节分析范围内没有章节");
6065
+ const analyses = [];
6066
+ const insightIds = [];
6067
+ const callIds = [];
6068
+ for (const [index, chapter] of chapters.entries()) {
6069
+ const chapterScope = {
6070
+ ...scope,
6071
+ type: "chapter",
6072
+ chapterId: String(chapter.id),
6073
+ chapterIds: undefined,
6074
+ volumeId: undefined,
6075
+ volumeIds: undefined
6076
+ };
6077
+ const generated = await this.generateTaggedJson({
6078
+ workId,
6079
+ taskId,
6080
+ taskType: "chapter-analysis",
6081
+ signal: this.taskSignal(taskId),
6082
+ instruction: "分析本章并输出 JSON 对象,字段为 summary(1至3句)、events(数组)、characters(数组)、settings(数组)、evidence(数组,每项含 conclusion quote)、uncertainties(数组)。",
6083
+ scope: chapterScope,
6084
+ ...(modelId ? { modelId } : {}),
6085
+ extraSystemPrompt: "本任务要求严格输出可解析的 JSON。"
6086
+ });
6087
+ const data = extractJson(generated.content);
6088
+ if (!this.taskCanCommit(taskId))
6089
+ return { interrupted: true, callIds };
6090
+ const insightId = id("insight");
6091
+ this.store.db.run(`INSERT INTO chapter_insights (id, chapter_id, chapter_version, summary, events_json, characters_json,
6092
+ 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());
6093
+ this.store.db.run("UPDATE chapters SET analysis_status = 'review' WHERE id = ?", String(chapter.id));
6094
+ analyses.push({ insightId, chapterId: chapter.id, chapterVersion: chapter.versionNo, callId: generated.callId, ...data });
6095
+ insightIds.push(insightId);
6096
+ callIds.push(generated.callId);
6097
+ if (taskId && this.store.getTask(taskId).status === "running") {
6098
+ this.store.updateTask(taskId, { status: "running", progress: Math.min(95, Math.round((index + 1) / chapters.length * 95)) });
6099
+ }
6100
+ }
6101
+ return {
6102
+ ...(chapters.length === 1 ? analyses[0] : {}),
6103
+ insightIds,
6104
+ chapterIds: chapters.map((chapter) => String(chapter.id)),
6105
+ chapterCount: chapters.length,
6106
+ callIds
6107
+ };
5893
6108
  }
5894
6109
  async runTimelineAnalysis(workId, scope, modelId, taskId) {
5895
6110
  const generated = await this.generateTaggedJson({
@@ -6230,18 +6445,24 @@ export class AiManager {
6230
6445
  `首次章节=${String(character.firstChapterId ?? "未知")}`
6231
6446
  ].join(" | ");
6232
6447
  }).join("\n");
6448
+ const selectedScopeDescription = scope.type === "chapter"
6449
+ ? `指定章节:${[...(scope.chapterIds ?? []), ...(scope.chapterId ? [scope.chapterId] : [])].join("、")}`
6450
+ : scope.type === "volume"
6451
+ ? `指定分卷:${[...(scope.volumeIds ?? []), ...(scope.volumeId ? [scope.volumeId] : [])].join("、")}`
6452
+ : scope.type === "book" ? "全书" : "当前分析范围";
6233
6453
  const generated = await this.generateTaggedJson({
6234
6454
  workId,
6235
6455
  taskId,
6236
6456
  taskType: "book-analysis",
6237
6457
  signal: this.taskSignal(taskId),
6238
- scope: scope.type === "none" ? scope : { type: "none" },
6458
+ scope,
6239
6459
  ...(modelId ? { modelId } : {}),
6240
6460
  parameters: { temperature: 0.1 },
6241
6461
  agentToolIds: requiredTools,
6242
6462
  agentToolCallLimit: 48,
6243
6463
  instruction: [
6244
6464
  "审核角色规范表,找出可能把同一个角色误建成两个档案的组合,最多输出 12 组。",
6465
+ `本次只审核${selectedScopeDescription}内的正文证据。search_story_entities 可以查询角色档案,但 grep 和 read_chapters 只能用于当前分析范围,不能扩大到范围外章节。`,
6245
6466
  "角色规范表:",
6246
6467
  roster,
6247
6468
  "你必须主动使用 search_story_entities 按角色主名或别名查找角色档案和关系,并使用 grep 分别搜索疑似组合两侧的主名或别名;需要上下文时再用 read_chapters。工具调用总数不得超过 48 次。",
@@ -6955,21 +7176,36 @@ export class AiManager {
6955
7176
  if (scope.type === "settings")
6956
7177
  return new Set();
6957
7178
  if (scope.type === "chapter") {
6958
- if (!scope.chapterId)
7179
+ const chapterIds = [...new Set([
7180
+ ...(scope.chapterId ? [scope.chapterId] : []),
7181
+ ...(scope.chapterIds ?? [])
7182
+ ])];
7183
+ if (chapterIds.length === 0)
6959
7184
  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]);
7185
+ for (const chapterId of chapterIds) {
7186
+ const chapter = this.store.getChapter(chapterId);
7187
+ if (String(chapter.workId) !== workId)
7188
+ throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
7189
+ }
7190
+ return new Set(chapterIds);
6964
7191
  }
6965
7192
  if (scope.type === "volume") {
6966
- if (!scope.volumeId)
7193
+ const volumeIds = [...new Set([
7194
+ ...(scope.volumeId ? [scope.volumeId] : []),
7195
+ ...(scope.volumeIds ?? [])
7196
+ ])];
7197
+ if (volumeIds.length === 0)
6967
7198
  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)));
7199
+ const chapterIds = new Set();
7200
+ for (const volumeId of volumeIds) {
7201
+ const volume = this.store.getVolume(volumeId);
7202
+ if (String(volume.workId) !== workId)
7203
+ throw new AppError(400, "VOLUME_WORK_MISMATCH", "分卷不属于当前作品");
7204
+ for (const row of this.store.db.all(`SELECT id FROM chapters WHERE work_id = ? AND volume_id = ? AND deleted_at IS NULL
7205
+ AND excluded_from_analysis = 0 AND chapter_type <> '作者的话'`, workId, volumeId))
7206
+ chapterIds.add(String(row.id));
7207
+ }
7208
+ return chapterIds;
6973
7209
  }
6974
7210
  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
7211
  }
@@ -7859,6 +8095,80 @@ export class AiManager {
7859
8095
  const settings = availableSettings.filter((source) => requestedRefs.has(this.relationshipIndexedSourceKey(source.sourceType, source.sourceId)));
7860
8096
  return { chapters, settings };
7861
8097
  }
8098
+ validateRelationshipSourceRefs(workId, scope, refs) {
8099
+ for (const ref of refs) {
8100
+ const currentVersion = this.relationshipSourceRefVersion(workId, scope, ref.sourceType, ref.sourceId);
8101
+ if (currentVersion === null || currentVersion !== ref.sourceVersion) {
8102
+ throw new AppError(409, "RELATIONSHIP_SOURCE_PREVIEW_STALE", "来源已在预检后发生变化,请重新预览", {
8103
+ sourceType: ref.sourceType,
8104
+ sourceId: ref.sourceId
8105
+ });
8106
+ }
8107
+ }
8108
+ }
8109
+ relationshipSourceRefVersion(workId, scope, sourceType, sourceId) {
8110
+ if (sourceType === "chapter") {
8111
+ if (scope.type === "settings")
8112
+ return null;
8113
+ const chapter = this.store.db.get(`SELECT chapter.version_no, chapter.volume_id, chapter.chapter_type, chapter.excluded_from_analysis
8114
+ FROM chapters chapter
8115
+ JOIN volumes volume ON volume.id = chapter.volume_id
8116
+ WHERE chapter.id = ? AND chapter.work_id = ?
8117
+ AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL`, sourceId, workId);
8118
+ if (!chapter)
8119
+ return null;
8120
+ if (scope.type === "chapter" && scope.chapterId !== sourceId)
8121
+ return null;
8122
+ if (scope.type === "volume" && scope.volumeId !== String(chapter.volume_id))
8123
+ return null;
8124
+ if ((scope.type === "book" || scope.type === "volume")
8125
+ && (Boolean(chapter.excluded_from_analysis) || String(chapter.chapter_type) === "作者的话"))
8126
+ return null;
8127
+ return String(chapter.version_no);
8128
+ }
8129
+ if (scope.type !== "settings" && scope.includeAllSettings !== true)
8130
+ return null;
8131
+ if (sourceType === "work") {
8132
+ const work = this.store.db.get("SELECT version_no FROM works WHERE id = ? AND id = ? AND deleted_at IS NULL", sourceId, workId);
8133
+ return work ? String(work.version_no) : null;
8134
+ }
8135
+ if (sourceType === "character") {
8136
+ 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);
8137
+ return character ? String(character.version_no) : null;
8138
+ }
8139
+ if (sourceType === "review") {
8140
+ const review = this.store.db.get("SELECT updated_at FROM review_items WHERE id = ? AND work_id = ?", sourceId, workId);
8141
+ return review ? String(review.updated_at) : null;
8142
+ }
8143
+ if (sourceType === "chapter-outline") {
8144
+ const outline = this.store.db.get(`SELECT outline.chapter_id FROM chapter_outlines outline
8145
+ JOIN chapters chapter ON chapter.id = outline.chapter_id
8146
+ WHERE outline.chapter_id = ? AND chapter.work_id = ? AND chapter.deleted_at IS NULL`, sourceId, workId);
8147
+ if (!outline)
8148
+ return null;
8149
+ const version = this.store.db.get(`SELECT COALESCE(MAX(version_no), 0) AS version_no FROM entity_versions
8150
+ WHERE work_id = ? AND entity_type = 'chapter-outline' AND entity_id = ?`, workId, sourceId);
8151
+ return String(Number(version?.version_no ?? 0));
8152
+ }
8153
+ const tableBySourceType = {
8154
+ setting: "settings",
8155
+ race: "races",
8156
+ organization: "organizations",
8157
+ "timeline-track": "timeline_tracks",
8158
+ "timeline-event": "timeline_events",
8159
+ relationship: "relationships",
8160
+ foreshadow: "foreshadows"
8161
+ };
8162
+ const table = tableBySourceType[sourceType];
8163
+ if (!table)
8164
+ return null;
8165
+ const source = this.store.db.get(`SELECT id FROM ${table} WHERE id = ? AND work_id = ?`, sourceId, workId);
8166
+ if (!source)
8167
+ return null;
8168
+ const version = this.store.db.get(`SELECT COALESCE(MAX(version_no), 0) AS version_no FROM entity_versions
8169
+ WHERE work_id = ? AND entity_type = ? AND entity_id = ?`, workId, sourceType, sourceId);
8170
+ return String(Number(version?.version_no ?? 0));
8171
+ }
7862
8172
  async previewRelationshipSources(workId, scope, modelId) {
7863
8173
  const characters = this.store.listCharacters(workId);
7864
8174
  if (characters.length < 2)
@@ -8735,20 +9045,37 @@ export class AiManager {
8735
9045
  const tree = this.store.getWorkTree(workId);
8736
9046
  const volumes = tree.volumes;
8737
9047
  if (scope.type === "chapter") {
8738
- if (!scope.chapterId)
9048
+ const chapterIds = [...new Set([
9049
+ ...(scope.chapterId ? [scope.chapterId] : []),
9050
+ ...(scope.chapterIds ?? [])
9051
+ ])];
9052
+ if (chapterIds.length === 0)
8739
9053
  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];
9054
+ const selected = new Set(chapterIds);
9055
+ const chapters = volumes.flatMap((volume) => volume.chapters)
9056
+ .filter((chapter) => selected.has(String(chapter.id)) && this.isAutomaticAnalysisChapter(chapter));
9057
+ if (chapters.length !== selected.size) {
9058
+ for (const chapterId of chapterIds) {
9059
+ const chapter = this.store.getChapter(chapterId);
9060
+ if (chapter.workId !== workId)
9061
+ throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
9062
+ }
9063
+ }
9064
+ return chapters;
8744
9065
  }
8745
9066
  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)
9067
+ const volumeIds = [...new Set([
9068
+ ...(scope.volumeId ? [scope.volumeId] : []),
9069
+ ...(scope.volumeIds ?? [])
9070
+ ])];
9071
+ if (volumeIds.length === 0)
9072
+ throw new AppError(400, "VOLUME_REQUIRED", "分析范围缺少分卷标识");
9073
+ const selected = new Set(volumeIds);
9074
+ const selectedVolumes = volumes.filter((volume) => selected.has(String(volume.id)));
9075
+ if (selectedVolumes.length !== selected.size)
8750
9076
  throw notFound("卷");
8751
- return volume.chapters.filter((chapter) => this.isAutomaticAnalysisChapter(chapter));
9077
+ return selectedVolumes.flatMap((volume) => volume.chapters)
9078
+ .filter((chapter) => this.isAutomaticAnalysisChapter(chapter));
8752
9079
  }
8753
9080
  return volumes.flatMap((volume) => volume.chapters)
8754
9081
  .filter((chapter) => this.isAutomaticAnalysisChapter(chapter));