@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 +388 -61
- 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 +186 -20
- package/dist/public/index.html +1 -1
- package/dist/public/plain-text-paste.js +89 -0
- package/dist/public/styles.css +28 -0
- package/dist/store.js +198 -67
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
1268
|
-
|
|
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
|
-
|
|
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
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
contentSections.push(wrapAiContextRegion("
|
|
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.
|
|
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
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
const
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
5890
|
-
|
|
5891
|
-
|
|
5892
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
6961
|
-
|
|
6962
|
-
|
|
6963
|
-
|
|
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
|
-
|
|
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
|
|
6969
|
-
|
|
6970
|
-
|
|
6971
|
-
|
|
6972
|
-
|
|
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
|
-
|
|
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
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
|
|
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
|
-
|
|
8747
|
-
|
|
8748
|
-
|
|
8749
|
-
|
|
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
|
|
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));
|