@waterwx/dsh-novel-forge 0.1.0 → 1.1.0

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.
Files changed (37) hide show
  1. package/README.md +67 -25
  2. package/cordis.patch.yml +1 -1
  3. package/lib/client.js +4563 -1093
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +1138 -92
  6. package/lib/index.js.map +1 -1
  7. package/lib/types/assets.d.ts +2 -2
  8. package/lib/types/assistant.d.ts +2 -0
  9. package/lib/types/bookshelf.d.ts +2 -0
  10. package/lib/types/client/api.d.ts +61 -2
  11. package/lib/types/client/locales.d.ts +24 -24
  12. package/lib/types/client/panel/AssetsTab.d.ts +6 -1
  13. package/lib/types/client/panel/CreateBookView.d.ts +9 -0
  14. package/lib/types/client/panel/NovelPanel.d.ts +1 -1
  15. package/lib/types/client/panel/ShelfView.d.ts +11 -0
  16. package/lib/types/client/panel/WorldTab.d.ts +10 -0
  17. package/lib/types/engine.d.ts +67 -6
  18. package/lib/types/index.d.ts +1 -1
  19. package/lib/types/protocol.d.ts +177 -0
  20. package/package.json +1 -13
  21. package/src/assets.ts +9 -7
  22. package/src/assistant.ts +152 -32
  23. package/src/bookshelf.ts +14 -0
  24. package/src/client/api.ts +80 -3
  25. package/src/client/locales.ts +24 -24
  26. package/src/client/panel/AssetsTab.tsx +139 -41
  27. package/src/client/panel/AssistantTab.tsx +136 -23
  28. package/src/client/panel/BookshelfBar.tsx +62 -8
  29. package/src/client/panel/CreateBookView.tsx +189 -0
  30. package/src/client/panel/NovelPanel.tsx +1834 -201
  31. package/src/client/panel/ShelfView.tsx +112 -0
  32. package/src/client/panel/WorldTab.tsx +202 -0
  33. package/src/client/panel/panel.module.css +1210 -59
  34. package/src/engine.ts +618 -29
  35. package/src/index.ts +1 -1
  36. package/src/protocol.ts +189 -1
  37. package/src/routes.ts +511 -14
package/lib/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
2
  import z from "schemastery";
3
3
  import { exec } from "node:child_process";
4
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
5
- import { strFromU8, unzipSync } from "fflate";
4
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
6
5
  import { join } from "node:path";
6
+ import { strFromU8, unzipSync } from "fflate";
7
7
  import { BlockAssembler, ReasoningEffortId, createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
8
8
  import { homedir } from "node:os";
9
9
  import { randomBytes } from "node:crypto";
@@ -28,13 +28,41 @@ const NOVEL_API = {
28
28
  review: "/api/dsh-novel-forge/review",
29
29
  rewrite: "/api/dsh-novel-forge/rewrite",
30
30
  polish: "/api/dsh-novel-forge/polish",
31
+ /** 采纳待确认草稿(润色/重写产物)覆盖正文文件。 */
32
+ draftApply: "/api/dsh-novel-forge/draft/apply",
33
+ /** 放弃待确认草稿,保留原稿。 */
34
+ draftDiscard: "/api/dsh-novel-forge/draft/discard",
31
35
  summary: "/api/dsh-novel-forge/summary",
32
36
  foreshadow: "/api/dsh-novel-forge/foreshadow",
33
37
  exportBook: "/api/dsh-novel-forge/export",
34
38
  chapter: "/api/dsh-novel-forge/chapter",
39
+ /** 审查任意正文文本(作者手动编辑后,不落盘)。 */
40
+ chapterCheck: "/api/dsh-novel-forge/chapter/check",
41
+ /** 保存手动编辑的正文(自动备份 .bak)。 */
42
+ chapterSave: "/api/dsh-novel-forge/chapter/save",
35
43
  assistant: "/api/dsh-novel-forge/assistant",
36
44
  assistantHistory: "/api/dsh-novel-forge/assistant-history",
45
+ /** 清空助手对话记录。 */
46
+ assistantClear: "/api/dsh-novel-forge/assistant/clear",
37
47
  bookshelf: "/api/dsh-novel-forge/bookshelf",
48
+ /** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
49
+ reset: "/api/dsh-novel-forge/reset",
50
+ /** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
51
+ audit: "/api/dsh-novel-forge/audit",
52
+ /** 角色卡刷新:基于事实库与各章摘要聚合角色当前状态。 */
53
+ charactersRefresh: "/api/dsh-novel-forge/characters/refresh",
54
+ /** 事实库回填:对历史已生成章节批量抽取事实(旧章节无事实记录时用)。 */
55
+ factsBackfill: "/api/dsh-novel-forge/facts/backfill",
56
+ /** 设定圣经局部修补(如世界观规则编辑)。 */
57
+ biblePatch: "/api/dsh-novel-forge/bible/patch",
58
+ /** 小说简介:生成(AI)/补全(AI)/保存。 */
59
+ blurb: "/api/dsh-novel-forge/blurb",
60
+ /** 重命名当前书(同步项目与书架条目)。 */
61
+ rename: "/api/dsh-novel-forge/rename",
62
+ /** 大世界:AI 提炼 / 保存结构化数据(境界/区域/势力)。 */
63
+ world: "/api/dsh-novel-forge/world",
64
+ /** 封面:GET 读取(dataUrl)/ POST 上传或移除。 */
65
+ cover: "/api/dsh-novel-forge/blurb/cover",
38
66
  config: "/api/dsh-novel-forge/config",
39
67
  openFolder: "/api/dsh-novel-forge/open-folder"
40
68
  };
@@ -631,11 +659,12 @@ function effectiveAntiAiRules(assets) {
631
659
  const customNames = new Set(custom.map((r) => r.name));
632
660
  return [...BUILTIN_ANTI_AI_RULES.filter((r) => !customNames.has(r.name)), ...custom];
633
661
  }
634
- /** 把生效规则渲染成提示词块。 */
662
+ /** 把生效规则渲染成提示词块(压缩:avoid/fix 截断,省 token)。 */
635
663
  function renderAntiAiRules(assets) {
636
664
  const rules = effectiveAntiAiRules(assets);
637
665
  if (rules.length === 0) return "";
638
- return ["==================== 反 AI 规则(写作时必须遵守的表达边界) ====================", ...rules.map((r) => `- ${r.name}:避免——${r.avoid};修正——${r.fix}`)].join("\n");
666
+ const clip = (value, max) => value.length > max ? value.slice(0, max) + "…" : value;
667
+ return ["==================== 反 AI 规则(写作时必须遵守的表达边界) ====================", ...rules.map((r) => `- ${r.name}:避免——${clip(r.avoid, 90)}${r.fix !== "" ? `;修正——${clip(r.fix, 50)}` : ""}`)].join("\n");
639
668
  }
640
669
  /** 渲染题材与推进模式提示词块。 */
641
670
  function renderGenreAndProgression(assets) {
@@ -665,17 +694,18 @@ function renderGenreAndProgression(assets) {
665
694
  }
666
695
  return sections.join("\n");
667
696
  }
668
- /** 渲染写法资产提示词块。 */
697
+ /** 渲染写法资产提示词块(规则去重,省 token)。 */
669
698
  function renderStyleAssets(assets) {
670
699
  const styles = assets?.styleAssets ?? [];
671
700
  if (styles.length === 0) return "";
672
701
  const sections = ["==================== 写法资产(本书的叙事风格约束) ===================="];
673
702
  for (const style of styles) {
674
703
  sections.push(`【${style.name}】`);
675
- if (style.proseRules.length > 0) sections.push("叙述与节奏:\n" + style.proseRules.map((r) => `- ${r}`).join("\n"));
676
- if (style.dialogueRules.length > 0) sections.push("台词风格:\n" + style.dialogueRules.map((r) => `- ${r}`).join("\n"));
677
- if (style.descriptionRules.length > 0) sections.push("描写与情绪:\n" + style.descriptionRules.map((r) => `- ${r}`).join("\n"));
678
- if (style.boundaries.length > 0) sections.push("表达边界:\n" + style.boundaries.map((r) => `- ${r}`).join("\n"));
704
+ const unique = (rules) => [...new Set(rules)];
705
+ if (style.proseRules.length > 0) sections.push("叙述与节奏:\n" + unique(style.proseRules).map((r) => `- ${r}`).join("\n"));
706
+ if (style.dialogueRules.length > 0) sections.push("台词风格:\n" + unique(style.dialogueRules).map((r) => `- ${r}`).join("\n"));
707
+ if (style.descriptionRules.length > 0) sections.push("描写与情绪:\n" + unique(style.descriptionRules).map((r) => `- ${r}`).join("\n"));
708
+ if (style.boundaries.length > 0) sections.push("表达边界:\n" + unique(style.boundaries).map((r) => `- ${r}`).join("\n"));
679
709
  }
680
710
  return sections.join("\n");
681
711
  }
@@ -738,6 +768,7 @@ function loadProject(outputDir) {
738
768
  if (!Array.isArray(raw.assets.antiAiRules)) raw.assets.antiAiRules = [];
739
769
  if (!Array.isArray(raw.assets.auxiliaryProgressions)) raw.assets.auxiliaryProgressions = [];
740
770
  if (!Array.isArray(raw.assets.styleAssets)) raw.assets.styleAssets = [];
771
+ if (!Array.isArray(raw.facts)) raw.facts = [];
741
772
  return raw;
742
773
  } catch {
743
774
  return;
@@ -752,7 +783,7 @@ function saveProject(outputDir, project) {
752
783
  function listChapterFiles(outputDir) {
753
784
  if (!existsSync(outputDir)) return [];
754
785
  try {
755
- return readdirSync(outputDir).filter((name) => /^第\d+章_.*\.md$/.test(name)).sort((a, b) => {
786
+ return readdirSync(outputDir).filter((name) => /^第\d+章_.*\.md$/.test(name) && !name.endsWith(".bak.md")).sort((a, b) => {
756
787
  return Number(/^第(\d+)章/.exec(a)?.[1] ?? 0) - Number(/^第(\d+)章/.exec(b)?.[1] ?? 0);
757
788
  });
758
789
  } catch {
@@ -792,6 +823,7 @@ function createProject(outline, outlinePath) {
792
823
  chapters: [],
793
824
  foreshadows: [],
794
825
  assets: emptyProjectAssets(),
826
+ facts: [],
795
827
  createdAt: now,
796
828
  updatedAt: now
797
829
  };
@@ -944,7 +976,7 @@ async function extractBible(ctx, config, outline) {
944
976
  style: strArray(raw.style),
945
977
  generatedAt: (/* @__PURE__ */ new Date()).toISOString()
946
978
  };
947
- if (bible.worldRules.length === 0 && bible.characters.length === 0 && bible.redLines.length === 0) throw new Error("设定圣经生成失败:模型没有返回有效内容");
979
+ if (bible.worldRules.length === 0 && bible.characters.length === 0 && bible.redLines.length === 0) throw new Error("道藏生成失败:模型没有返回有效内容");
948
980
  return bible;
949
981
  }
950
982
  /** System prompt for volume planning. */
@@ -967,7 +999,8 @@ async function planVolumes(ctx, config, outline) {
967
999
  const parsed = parseJsonArray(await complete(ctx, config, {
968
1000
  system: volumeSystemPrompt(),
969
1001
  user,
970
- temperature: .4
1002
+ temperature: .4,
1003
+ maxTokens: Math.max(config.maxTokens, 12e3)
971
1004
  }));
972
1005
  const volumes = [];
973
1006
  for (let i = 0; i < parsed.length; i++) {
@@ -1005,8 +1038,8 @@ function planSystemPrompt(volumes) {
1005
1038
  "2. 章节之间要衔接自然,前章结尾为后章埋下钩子。",
1006
1039
  "3. 严格遵循大纲的人设、金手指规则、战力体系与世界观设定,不得自行发明冲突设定。",
1007
1040
  "4. 输出必须是合法的 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记。",
1008
- "5. 数组每个元素格式:{\"title\": \"章节标题(10字以内,有网文感)\", \"beats\": \"本章剧情要点(150-250字,含起承转合与钩子)\"}",
1009
- "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
1041
+ "5. 数组每个元素格式:{\"title\": \"章节标题(10字以内,有网文感)\", \"beats\": \"结构化剧情要点(150-250字,必须包含四段,段间用换行分隔):\\n本章目标:本章要完成的核心推进;\\n剧情要点:主要情节的起承转合(2-4 句);\\n爽点/钩子:本章的爽点兑现或情绪钩子;\\n结尾钩子:本章结尾为下一章埋下的悬念\"}",
1042
+ "重要:beats 字段内部必须使用 \\n 转义表示换行(JSON 字符串内不得有真实换行符),其余字符串值也不得包含真实换行符,JSON 必须在一段内完整结束。",
1010
1043
  "重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
1011
1044
  volumes !== void 0 && volumes.length > 0 ? ["\n全书分卷结构(规划章节时需落在对应卷内):"].concat(volumes.map((v) => `第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})`)).join("\n") : ""
1012
1045
  ].join("\n");
@@ -1034,8 +1067,11 @@ function writeSystemPrompt(project) {
1034
1067
  if (bible.redLines.length > 0) sections.push("写作红线(违反即失败):\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
1035
1068
  if (bible.style.length > 0) sections.push("风格要求:\n" + bible.style.map((r) => `- ${r}`).join("\n"));
1036
1069
  }
1070
+ const worldBlock = renderWorld(project.world);
1071
+ if (worldBlock !== "") sections.push(worldBlock);
1037
1072
  sections.push("==================== 全书大纲 ====================");
1038
- sections.push(project.outline);
1073
+ const outlineBlock = project.outline.length > 6e3 ? project.outline.slice(0, 6e3) + "\n…(大纲过长已节选,完整内容见总纲页)" : project.outline;
1074
+ sections.push(outlineBlock);
1039
1075
  sections.push("==================== 大纲结束 ====================");
1040
1076
  const assetsBlock = renderAllAssets(project.assets);
1041
1077
  if (assetsBlock !== "") sections.push(assetsBlock);
@@ -1068,7 +1104,8 @@ async function planChapters(ctx, config, project, chapterCount, volumeNo) {
1068
1104
  const parsed = parseJsonArray(await complete(ctx, config, {
1069
1105
  system: planSystemPrompt(project.volumes),
1070
1106
  user,
1071
- temperature: .7
1107
+ temperature: .7,
1108
+ maxTokens: Math.max(config.maxTokens, 2e4)
1072
1109
  }));
1073
1110
  const chapters = [];
1074
1111
  const existing = new Set(project.chapters.map((c) => c.no));
@@ -1140,7 +1177,8 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
1140
1177
  const raw = parseJsonObject(await complete(ctx, config, {
1141
1178
  system: reviewSystemPrompt(project),
1142
1179
  user,
1143
- temperature: .3
1180
+ temperature: .3,
1181
+ maxTokens: Math.max(config.maxTokens, 16e3)
1144
1182
  }));
1145
1183
  const issues = Array.isArray(raw.issues) ? raw.issues.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
1146
1184
  severity: [
@@ -1165,6 +1203,40 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
1165
1203
  saveProject(outputDir, project);
1166
1204
  return report;
1167
1205
  }
1206
+ /**
1207
+ * 审查「任意正文文本」(作者手动编辑后的草稿,不落盘)。
1208
+ * 复用审稿提示词与红线/道藏/反AI规则;仅返回报告,不改文件不改状态。
1209
+ */
1210
+ async function reviewChapterText(ctx, config, project, text) {
1211
+ const user = [
1212
+ `书名:《${project.bookName}》`,
1213
+ "==================== 待审查正文 ====================",
1214
+ text.slice(0, 2e4)
1215
+ ].join("\n");
1216
+ const raw = parseJsonObject(await complete(ctx, config, {
1217
+ system: reviewSystemPrompt(project),
1218
+ user,
1219
+ temperature: .3,
1220
+ maxTokens: Math.max(config.maxTokens, 8e3)
1221
+ }));
1222
+ const issues = Array.isArray(raw.issues) ? raw.issues.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
1223
+ severity: [
1224
+ "high",
1225
+ "medium",
1226
+ "low"
1227
+ ].includes(entry.severity) ? entry.severity : "medium",
1228
+ item: typeof entry.item === "string" ? entry.item : "",
1229
+ suggestion: typeof entry.suggestion === "string" ? entry.suggestion : ""
1230
+ })).filter((issue) => issue.item !== "") : [];
1231
+ const score = typeof raw.score === "number" ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60;
1232
+ return {
1233
+ score,
1234
+ passed: score >= config.reviewPassScore,
1235
+ verdict: typeof raw.verdict === "string" ? raw.verdict.slice(0, 200) : "",
1236
+ issues,
1237
+ reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
1238
+ };
1239
+ }
1168
1240
  /** Build the rewrite system prompt (fix review issues / instructions). */
1169
1241
  function rewriteSystemPrompt(project) {
1170
1242
  return writeSystemPrompt(project) + "\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。";
@@ -1257,19 +1329,14 @@ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo,
1257
1329
  ].filter((part) => part !== "").join("\n\n");
1258
1330
  else newBody = rewritten;
1259
1331
  if (newBody.length < 100) throw new Error("修订结果过短,可能失败,请重试");
1260
- const fileName = chapterFileName(chapter);
1261
- const markdown = `# 第${chapter.no}章 ${chapter.title}\n\n${newBody}\n`;
1262
- writeFileSync(join(outputDir, fileName), markdown, "utf8");
1263
- chapter.status = "written";
1264
- chapter.chars = newBody.length;
1332
+ chapter.pendingDraft = newBody;
1265
1333
  chapter.error = void 0;
1266
- chapter.review = void 0;
1267
1334
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1268
1335
  saveProject(outputDir, project);
1269
1336
  yield {
1270
- frame: "done",
1271
- file: fileName,
1272
- chars: newBody.length
1337
+ frame: "drafted",
1338
+ chars: newBody.length,
1339
+ draft: newBody
1273
1340
  };
1274
1341
  }
1275
1342
  /** The de-AI-ify polish system prompt (with project writing assets injected). */
@@ -1286,7 +1353,8 @@ function polishSystemPrompt(project) {
1286
1353
  assetsBlock !== "" ? assetsBlock : ""
1287
1354
  ].join("\n");
1288
1355
  }
1289
- /** Stream a chapter polish (de-AI-ify). */
1356
+ /** Stream a chapter polish (de-AI-ify). Draft-mode: the polished body lands
1357
+ * in `chapter.pendingDraft` and is only applied on draft/apply. */
1290
1358
  async function* polishChapterStream(ctx, config, project, outputDir, chapterNo) {
1291
1359
  const chapter = project.chapters.find((c) => c.no === chapterNo);
1292
1360
  if (chapter === void 0) throw new Error(`章节 ${chapterNo} 不在计划中`);
@@ -1327,16 +1395,13 @@ async function* polishChapterStream(ctx, config, project, outputDir, chapterNo)
1327
1395
  const newBody = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
1328
1396
  if (streamError !== void 0) throw streamError;
1329
1397
  if (newBody.length < 100) throw new Error("润色结果过短,可能失败,请重试");
1330
- const fileName = chapterFileName(chapter);
1331
- writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${newBody}\n`, "utf8");
1332
- chapter.status = "written";
1333
- chapter.chars = newBody.length;
1398
+ chapter.pendingDraft = newBody;
1334
1399
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1335
1400
  saveProject(outputDir, project);
1336
1401
  yield {
1337
- frame: "done",
1338
- file: fileName,
1339
- chars: newBody.length
1402
+ frame: "drafted",
1403
+ chars: newBody.length,
1404
+ draft: newBody
1340
1405
  };
1341
1406
  }
1342
1407
  /** Generate one chapter (streaming). Yields progress frames; persists when done. */
@@ -1350,6 +1415,17 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
1350
1415
  if (existsSync(prevPath)) continuity = readFileSync(prevPath, "utf8").slice(-900);
1351
1416
  }
1352
1417
  const prevSummary = prev?.summary;
1418
+ const allFacts = project.facts ?? [];
1419
+ const recentFacts = allFacts.slice(-20).map((f) => f.text);
1420
+ const beatsText = chapter.beats;
1421
+ const relatedFacts = allFacts.slice(-120).filter((f) => {
1422
+ const head = f.text.slice(0, 24);
1423
+ for (let i = 0; i + 3 <= head.length; i++) {
1424
+ const tri = head.slice(i, i + 3);
1425
+ if (tri.trim() !== "" && beatsText.includes(tri)) return true;
1426
+ }
1427
+ return false;
1428
+ }).slice(-15).map((f) => `[第${f.chapterNo}章] ${f.text}`);
1353
1429
  const messages = [createUserMessage({
1354
1430
  content: [{
1355
1431
  type: "text",
@@ -1357,6 +1433,8 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
1357
1433
  `现在写第 ${chapter.no} 章,标题《${chapter.title}》。`,
1358
1434
  `本章剧情要点:${chapter.beats}`,
1359
1435
  "",
1436
+ recentFacts.length > 0 ? `本书已确立的事实(新写内容不得与之矛盾):\n${recentFacts.join("\n")}` : "",
1437
+ relatedFacts.length > 0 ? `本章相关的既往事实(同样不得违背):\n${relatedFacts.join("\n")}` : "",
1360
1438
  prevSummary !== void 0 && prevSummary !== "" ? `上一章摘要:${prevSummary}` : "",
1361
1439
  continuity !== "" ? `上一章结尾(用于衔接,不要复述):\n${continuity}` : "这是第一章,注意开篇要有吸引力。",
1362
1440
  "",
@@ -1421,12 +1499,451 @@ async function summarizeChapter(ctx, config, project, outputDir, chapterNo) {
1421
1499
  ].join("\n"),
1422
1500
  user: body.replace(/^#\s+.*$/m, "").trim(),
1423
1501
  temperature: .3,
1424
- maxTokens: 800
1502
+ maxTokens: Math.max(config.maxTokens, 4e3)
1425
1503
  })).slice(0, 500);
1426
1504
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1427
1505
  saveProject(outputDir, project);
1428
1506
  return chapter.summary;
1429
1507
  }
1508
+ /**
1509
+ * 摘要 + 事实抽取合并为一次 LLM 调用(省一次调用与一次正文输入,
1510
+ * 批量生成时整体开销约省 25%)。
1511
+ * @returns 摘要与新增事实条数(失败返回空,调用方 best-effort)。
1512
+ */
1513
+ async function summarizeAndExtractFacts(ctx, config, project, outputDir, chapterNo) {
1514
+ const chapter = project.chapters.find((c) => c.no === chapterNo);
1515
+ if (chapter === void 0) return {
1516
+ summary: "",
1517
+ factCount: 0
1518
+ };
1519
+ const body = readChapterFile(outputDir, chapter);
1520
+ if (body === void 0) return {
1521
+ summary: "",
1522
+ factCount: 0
1523
+ };
1524
+ const raw = parseJsonObject(await complete(ctx, config, {
1525
+ system: [
1526
+ "你是一位网文编辑。请为下面一章做两件事,输出合法 JSON 对象:",
1527
+ "{\"summary\": \"120-200字摘要,含关键事件/主角状态变化(境界资源伤势心境)/新增伏笔线索/角色关系变化,客观陈述不评价\", \"facts\": [\"已确立事实1\", \"…3-6条\"]}",
1528
+ "facts 指:本章明确写出的、对后续有约束力的事实——人物当前状态、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。",
1529
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
1530
+ ].join("\n"),
1531
+ user: body.replace(/^#\s+.*$/m, "").trim(),
1532
+ temperature: .2,
1533
+ maxTokens: Math.max(config.maxTokens, 5e3)
1534
+ }));
1535
+ const summary = typeof raw.summary === "string" ? raw.summary.trim().slice(0, 500) : "";
1536
+ const factLines = Array.isArray(raw.facts) ? raw.facts.filter((v) => typeof v === "string" && v.trim().length > 8).map((v) => v.trim().slice(0, 140)) : [];
1537
+ if (summary !== "") chapter.summary = summary;
1538
+ const list = project.facts ?? [];
1539
+ for (const line of factLines.slice(0, 8)) list.push({
1540
+ chapterNo,
1541
+ text: line
1542
+ });
1543
+ project.facts = list.slice(-300);
1544
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1545
+ saveProject(outputDir, project);
1546
+ return {
1547
+ summary,
1548
+ factCount: factLines.length
1549
+ };
1550
+ }
1551
+ /**
1552
+ * 抽取本章「已确立事实」追加到事实库/时间线(最多 300 条,最新优先)。
1553
+ * 事实注入后续章节生成提示词,保证人物状态/境界/资源/关系长期一致。
1554
+ * @returns 新增事实条数(失败返回 0,调用方 best-effort)。
1555
+ */
1556
+ async function extractFacts(ctx, config, project, outputDir, chapterNo) {
1557
+ const chapter = project.chapters.find((c) => c.no === chapterNo);
1558
+ if (chapter === void 0) return 0;
1559
+ const body = readChapterFile(outputDir, chapter);
1560
+ if (body === void 0) return 0;
1561
+ const lines = (await complete(ctx, config, {
1562
+ system: [
1563
+ "你是一位网文编辑。请从本章正文中抽取「已确立事实」,供后续章节保持一致。",
1564
+ "事实指:人物当前状态(境界/修为/伤势/资源/心境)、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。",
1565
+ "要求:",
1566
+ "1. 只抽取本章明确写出的、对后续有约束力的内容;纯心理活动与无关细节不要。",
1567
+ "2. 每行一条事实,用客观陈述句,不含主观评价。",
1568
+ "3. 输出 3-6 条,每行一条,不要编号、不要前缀、不要解释。"
1569
+ ].join("\n"),
1570
+ user: body.replace(/^#\s+.*$/m, "").trim(),
1571
+ temperature: .2,
1572
+ maxTokens: Math.max(config.maxTokens, 4e3)
1573
+ })).split("\n").map((line) => line.replace(/^[-*\d.\s]+/, "").trim()).filter((line) => line.length > 8).slice(0, 8);
1574
+ if (lines.length === 0) return 0;
1575
+ const facts = project.facts ?? [];
1576
+ for (const line of lines) facts.push({
1577
+ chapterNo,
1578
+ text: line.slice(0, 140)
1579
+ });
1580
+ project.facts = facts.slice(-300);
1581
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1582
+ saveProject(outputDir, project);
1583
+ return lines.length;
1584
+ }
1585
+ const AUDIT_BATCH_SIZE = 10;
1586
+ /** 单批质检:设定 + 事实库 + 该批章节节选 → 矛盾清单。 */
1587
+ async function auditBatch(ctx, config, project, outputDir, batch) {
1588
+ const system = [
1589
+ "你是一位严谨的网文连续性审校编辑。你会收到一本小说的设定圣经、事实库和一批章节正文节选。",
1590
+ "请找出这批章节中的一致性矛盾,例如:",
1591
+ "- 人物状态冲突:境界/修为/伤势/资源在同一章内或跨章前后矛盾。",
1592
+ "- 设定违背:正文与世界观规则、金手指规则、写作红线冲突。",
1593
+ "- 时间线错乱:事件顺序、时间跨度、地点移动不合逻辑。",
1594
+ "- 细节穿帮:人名/地名/物品/数字前后不一致。",
1595
+ "要求:",
1596
+ "1. 只报告有实质证据的矛盾,不要泛泛而谈写作质量问题。",
1597
+ "2. 每条必须定位到具体章节号。",
1598
+ "3. 输出必须是合法 JSON 数组,格式:[{\"chapterNo\": 章节号, \"severity\": \"high|medium|low\", \"item\": \"矛盾描述\", \"suggestion\": \"修改建议\"}]",
1599
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
1600
+ ].join("\n");
1601
+ const factsBlock = (project.facts ?? []).slice(-60).map((f) => `[第${f.chapterNo}章] ${f.text}`).join("\n");
1602
+ const chapterBlocks = batch.map((c) => {
1603
+ const excerpt = (readChapterFile(outputDir, c) ?? "").replace(/^#\s+.*$/m, "").trim().slice(0, 700);
1604
+ return `【第${c.no}章《${c.title}》】\n${excerpt}`;
1605
+ }).join("\n\n");
1606
+ const parsed = parseJsonArray(await complete(ctx, config, {
1607
+ system,
1608
+ user: [
1609
+ "请对以下小说做一致性质检。",
1610
+ project.bible !== void 0 ? "设定圣经:\n" + [
1611
+ project.bible.worldRules.length > 0 ? `世界规则:\n${project.bible.worldRules.map((r) => `- ${r}`).join("\n")}` : "",
1612
+ project.bible.redLines.length > 0 ? `写作红线:\n${project.bible.redLines.map((r) => `- ${r}`).join("\n")}` : "",
1613
+ project.bible.characters.length > 0 ? `角色:\n${project.bible.characters.map((ch) => `- ${ch.name}(${ch.traits.join("、")})`).join("\n")}` : ""
1614
+ ].filter((s) => s !== "").join("\n") : "",
1615
+ factsBlock !== "" ? `已确立事实库:\n${factsBlock}` : "",
1616
+ `正文节选(每章前 700 字):\n${chapterBlocks}`,
1617
+ "只输出 JSON 数组。"
1618
+ ].filter((s) => s !== "").join("\n\n"),
1619
+ temperature: .2,
1620
+ maxTokens: Math.max(config.maxTokens, 12e3)
1621
+ }));
1622
+ const issues = [];
1623
+ for (const entry of parsed) {
1624
+ const item = typeof entry.item === "string" ? entry.item : "";
1625
+ if (item === "") continue;
1626
+ issues.push({
1627
+ chapterNo: Number(entry.chapterNo) || 0,
1628
+ severity: [
1629
+ "high",
1630
+ "medium",
1631
+ "low"
1632
+ ].includes(entry.severity) ? entry.severity : "medium",
1633
+ item,
1634
+ suggestion: typeof entry.suggestion === "string" ? entry.suggestion : ""
1635
+ });
1636
+ }
1637
+ return issues;
1638
+ }
1639
+ /** 全书一致性质检:LLM 分批扫描已生成章节 + 设定 + 事实库,聚合矛盾清单。 */
1640
+ async function auditBook(ctx, config, project, outputDir) {
1641
+ const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
1642
+ if (written.length === 0) return [];
1643
+ const all = [];
1644
+ for (let i = 0; i < written.length; i += AUDIT_BATCH_SIZE) {
1645
+ const batch = written.slice(i, i + AUDIT_BATCH_SIZE);
1646
+ try {
1647
+ all.push(...await auditBatch(ctx, config, project, outputDir, batch));
1648
+ } catch {}
1649
+ }
1650
+ return all.slice(0, 50);
1651
+ }
1652
+ /** 小说简介:AI 生成或按已写开头补全(面向读者的作品门面)。 */
1653
+ async function generateBlurb(ctx, config, project, partial = "") {
1654
+ const system = [
1655
+ "你是一位网文平台编辑,擅长写抓人的作品简介。",
1656
+ "要求:",
1657
+ "1. 120-250 字,突出核心卖点(金手指/题材/爽点/人设反差),用一两句抛出开局钩子。",
1658
+ "2. 不剧透结局与关键反转;语气贴合题材(热血/悬疑/轻松/虐心)。",
1659
+ "3. 中文,直接输出简介正文,不要 Markdown、不要引号包裹、不要「简介:」前缀。"
1660
+ ].join("\n");
1661
+ const genreBlock = project.bible?.genre !== void 0 ? `题材:${project.bible.genre}` : "";
1662
+ const volumeBlock = (project.volumes ?? []).slice(0, 3).map((v) => v.title).join("、");
1663
+ return (await complete(ctx, config, {
1664
+ system,
1665
+ user: [
1666
+ `书名:《${project.bookName}》`,
1667
+ genreBlock,
1668
+ volumeBlock !== "" ? `卷结构:${volumeBlock}` : "",
1669
+ `已写章节数:${project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating").length}`,
1670
+ "大纲节选:\n" + project.outline.slice(0, 2500),
1671
+ partial.trim() !== "" ? `已有开头草稿(请保留其内容与语气,续写补全为完整简介):\n${partial.trim()}` : "请全量生成一份完整简介。"
1672
+ ].filter((s) => s !== "").join("\n\n"),
1673
+ temperature: .7,
1674
+ maxTokens: Math.max(config.maxTokens, 4e3)
1675
+ })).replace(/^["'「『]|["'」』]$/g, "").replace(/^简介[::]\s*/, "").trim().slice(0, 600);
1676
+ }
1677
+ /**
1678
+ * 组装全书上下文包(AI 助手 book_overview 工具)。
1679
+ * 分片策略:章节要点默认只给最近 30 章(避免超长后爆上下文);
1680
+ * scope='full' 全量;scope=数字 只给该卷章节。
1681
+ */
1682
+ function bookOverview(project, scope = "recent") {
1683
+ const s = [];
1684
+ s.push(`书名:${project.bookName}`);
1685
+ s.push(`【大纲全文】\n${project.outline}`);
1686
+ if (project.bible !== void 0) {
1687
+ const bible = project.bible;
1688
+ s.push("【设定圣经】");
1689
+ if (bible.genre !== "") s.push(`题材基调:${bible.genre}`);
1690
+ if (bible.worldRules.length > 0) s.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
1691
+ if (bible.characters.length > 0) {
1692
+ s.push("角色卡:");
1693
+ for (const card of bible.characters) {
1694
+ const roleName = {
1695
+ protagonist: "主角",
1696
+ supporting: "配角",
1697
+ antagonist: "反派",
1698
+ other: "其他"
1699
+ }[card.role];
1700
+ s.push(`- ${card.name}(${roleName}):${card.traits.join("、")}${card.goals !== "" ? `;目标:${card.goals}` : ""}${card.relations !== "" ? `;关系:${card.relations}` : ""}`);
1701
+ }
1702
+ }
1703
+ if (bible.redLines.length > 0) s.push("写作红线:\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
1704
+ if (bible.style.length > 0) s.push("风格要求:\n" + bible.style.map((r) => `- ${r}`).join("\n"));
1705
+ }
1706
+ const worldBlock = renderWorld(project.world);
1707
+ if (worldBlock !== "") s.push(worldBlock);
1708
+ if (project.volumes !== void 0 && project.volumes.length > 0) {
1709
+ s.push("【卷结构】");
1710
+ for (const v of project.volumes) s.push(`第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})`);
1711
+ }
1712
+ if (project.chapters.length > 0) {
1713
+ const maxNo = project.chapters.reduce((m, c) => Math.max(m, c.no), 0);
1714
+ const shown = project.chapters.filter((c) => {
1715
+ if (scope === "full") return true;
1716
+ if (typeof scope === "number") return c.volume === scope;
1717
+ return c.no > Math.max(0, maxNo - 30);
1718
+ });
1719
+ const label = scope === "full" ? "全部章节(标题/状态/剧情要点/摘要)" : typeof scope === "number" ? `第 ${scope} 卷章节(标题/状态/剧情要点/摘要)` : `最近 ${shown.length} 章(标题/状态/剧情要点/摘要)`;
1720
+ s.push(`【${label}】`);
1721
+ const statusText = {
1722
+ pending: "待生成",
1723
+ generating: "生成中",
1724
+ written: "待审稿",
1725
+ reviewing: "审稿中",
1726
+ approved: "已通过",
1727
+ rejected: "待修订",
1728
+ error: "失败"
1729
+ };
1730
+ for (const c of shown) s.push(`第${c.no}章《${c.title}》[${statusText[c.status] ?? c.status}]${c.chars !== void 0 ? ` ${c.chars}字` : ""}\n剧情要点:${c.beats}\n摘要:${c.summary ?? "无"}`);
1731
+ if (scope !== "full" && project.chapters.length > shown.length) s.push(`(还有 ${project.chapters.length - shown.length} 章未列出,可用 scope=volume:N 查看指定卷)`);
1732
+ }
1733
+ if ((project.facts ?? []).length > 0) {
1734
+ s.push("【事实库(最近 40 条;更多用 facts_query 检索)】");
1735
+ for (const f of (project.facts ?? []).slice(-40)) s.push(`- [第${f.chapterNo}章] ${f.text}`);
1736
+ }
1737
+ if (project.foreshadows.length > 0) {
1738
+ s.push("【伏笔】");
1739
+ for (const f of project.foreshadows) s.push(`- [${f.status}] ${f.description}${f.targetChapter !== void 0 ? `(预计 ${f.targetChapter} 章回收)` : ""}`);
1740
+ }
1741
+ if (project.blurb !== void 0 && project.blurb !== "") s.push(`【小说简介】${project.blurb}`);
1742
+ return s.join("\n\n");
1743
+ }
1744
+ /**
1745
+ * 影响分析:LLM 扫描全书(大纲/设定/大世界/事实库/已写章节),
1746
+ * 定位一次改动波及的所有位置。助手在修改后主动调用,做连锁维护。
1747
+ */
1748
+ async function analyzeImpact(ctx, config, project, outputDir, change) {
1749
+ const system = [
1750
+ "你是一位网文一致性审校。作者要做一处修改,请找出这次改动会波及的所有位置(设定、大纲、已写章节正文、事实库、简介中可能因此过时或矛盾的内容)。",
1751
+ "输出必须是合法 JSON 数组,格式:[{\"location\": \"位置(第N章/大纲/设定圣经-世界规则/大世界-境界/事实库/简介)\", \"quote\": \"原文片段(20-60字)\", \"suggestion\": \"修改建议\", \"kind\": \"must|optional|note\"}]",
1752
+ "kind 含义:must=必须同步改否则矛盾;optional=建议改(影响观感);note=备注(如旧称保留为古称、或无需改但需知晓)。",
1753
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
1754
+ ].join("\n");
1755
+ const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
1756
+ const base = [
1757
+ `要做的修改:${change}`,
1758
+ "以下为全书设定与规则要点(章节为分批节选):",
1759
+ `大纲节选:\n${project.outline.slice(0, 2e3)}`,
1760
+ project.bible !== void 0 ? `道藏:${project.bible.worldRules.length} 条世界规则 / ${project.bible.redLines.length} 条红线 / 人物 ${project.bible.characters.map((c) => c.name).join("、")}` : "",
1761
+ (project.facts ?? []).length > 0 ? `编年录最近 40 条:\n${(project.facts ?? []).slice(-40).map((f) => `[第${f.chapterNo}章] ${f.text}`).join("\n")}` : ""
1762
+ ].filter((s) => s !== "").join("\n\n");
1763
+ const items = [];
1764
+ const IMPACT_BATCH_SIZE = 8;
1765
+ for (let i = 0; i < written.length; i += IMPACT_BATCH_SIZE) {
1766
+ const batch = written.slice(i, i + IMPACT_BATCH_SIZE);
1767
+ const chapterBlock = batch.map((c) => {
1768
+ const excerpt = (readChapterFile(outputDir, c) ?? "").replace(/^#\s+.*$/m, "").trim().slice(0, 500);
1769
+ return `【第${c.no}章《${c.title}》】\n${excerpt}`;
1770
+ }).join("\n\n");
1771
+ const user = `${base}\n\n本批章节(第 ${batch[0].no}-${batch[batch.length - 1].no} 章):\n${chapterBlock}\n\n只输出 JSON 数组。`;
1772
+ try {
1773
+ const text = await complete(ctx, config, {
1774
+ system,
1775
+ user,
1776
+ temperature: .2,
1777
+ maxTokens: Math.max(config.maxTokens, 12e3)
1778
+ });
1779
+ for (const entry of parseJsonArray(text)) {
1780
+ const quote = typeof entry.quote === "string" ? entry.quote.trim() : "";
1781
+ if (quote === "") continue;
1782
+ items.push({
1783
+ location: typeof entry.location === "string" ? entry.location : "未定位",
1784
+ quote: quote.slice(0, 120),
1785
+ suggestion: typeof entry.suggestion === "string" ? entry.suggestion : "",
1786
+ kind: entry.kind === "must" || entry.kind === "optional" || entry.kind === "note" ? entry.kind : "optional"
1787
+ });
1788
+ }
1789
+ } catch {}
1790
+ }
1791
+ return items.slice(0, 30);
1792
+ }
1793
+ /** 把大世界结构化数据渲染成提示词块(境界体系按顺序强约束)。 */
1794
+ function renderWorld(world) {
1795
+ if (world === void 0) return "";
1796
+ const sections = ["==================== 大世界(结构化设定,写作时严格遵守) ===================="];
1797
+ if (world.realms.length > 0) {
1798
+ sections.push("境界体系(由低到高,不得随意跳级或自创境界):");
1799
+ world.realms.forEach((realm, i) => {
1800
+ sections.push(`${i + 1}. ${realm.name}${realm.description !== "" ? ` — ${realm.description}` : ""}`);
1801
+ });
1802
+ }
1803
+ if (world.regions.length > 0) {
1804
+ sections.push("地理区域:");
1805
+ for (const region of world.regions) sections.push(`- ${region.name}${region.description !== "" ? `:${region.description}` : ""}${region.faction !== void 0 && region.faction !== "" ? `(势力:${region.faction})` : ""}`);
1806
+ }
1807
+ if (world.factions.length > 0) {
1808
+ sections.push("势力分布:");
1809
+ for (const faction of world.factions) sections.push(`- ${faction.name}(${faction.kind})${faction.description !== "" ? `:${faction.description}` : ""}${faction.region !== void 0 && faction.region !== "" ? `(驻地:${faction.region})` : ""}`);
1810
+ }
1811
+ return sections.join("\n");
1812
+ }
1813
+ /** AI 提炼大世界:从大纲 + 设定圣经生成结构化境界体系/区域/势力。 */
1814
+ async function extractWorld(ctx, config, project) {
1815
+ const system = [
1816
+ "你是一位网文世界观架构师。请根据小说大纲与设定圣经,提炼结构化「大世界」数据。",
1817
+ "输出必须是合法 JSON 对象:",
1818
+ "{\"realms\": [{\"name\": \"境界名\", \"description\": \"突破条件/寿命/标志等\"}], \"regions\": [{\"name\": \"区域名\", \"description\": \"描述\", \"faction\": \"关联势力名或空\"}], \"factions\": [{\"name\": \"势力名\", \"kind\": \"宗门/家族/王朝/组织等\", \"description\": \"描述\", \"region\": \"驻地区域或空\"}]}",
1819
+ "要求:",
1820
+ "1. realms 按由低到高顺序排列(修仙题材必须含完整境界链;无境界设定的题材可输出空数组)。",
1821
+ "2. 数量贴合大纲:realms 3-12 个,regions 2-10 个,factions 2-10 个。",
1822
+ "3. 内容严格来自大纲与设定圣经,不要凭空发明与大纲冲突的设定。",
1823
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
1824
+ ].join("\n");
1825
+ const bibleBlock = project.bible !== void 0 ? [project.bible.genre !== "" ? `题材:${project.bible.genre}` : "", project.bible.worldRules.length > 0 ? `世界规则:\n${project.bible.worldRules.map((r) => `- ${r}`).join("\n")}` : ""].filter((s) => s !== "").join("\n") : "";
1826
+ const raw = parseJsonObject(await complete(ctx, config, {
1827
+ system,
1828
+ user: [
1829
+ "请为这部小说提炼大世界数据。",
1830
+ `书名:《${project.bookName}》`,
1831
+ bibleBlock !== "" ? bibleBlock : "",
1832
+ "大纲:\n" + project.outline.slice(0, 5e3),
1833
+ "只输出 JSON 对象。"
1834
+ ].filter((s) => s !== "").join("\n\n"),
1835
+ temperature: .3,
1836
+ maxTokens: Math.max(config.maxTokens, 12e3)
1837
+ }));
1838
+ const str = (value) => typeof value === "string" ? value.trim() : "";
1839
+ const objArray = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "object" && v !== null) : [];
1840
+ return {
1841
+ realms: objArray(raw.realms).map((entry) => ({
1842
+ name: str(entry.name).slice(0, 20) || "未命名境界",
1843
+ description: str(entry.description).slice(0, 200)
1844
+ })).filter((r) => r.name !== "未命名境界" || r.description !== ""),
1845
+ regions: objArray(raw.regions).map((entry) => ({
1846
+ name: str(entry.name).slice(0, 30) || "未命名区域",
1847
+ description: str(entry.description).slice(0, 200),
1848
+ faction: str(entry.faction).slice(0, 30)
1849
+ })).filter((r) => r.name !== "未命名区域" || r.description !== ""),
1850
+ factions: objArray(raw.factions).map((entry) => ({
1851
+ name: str(entry.name).slice(0, 30) || "未命名势力",
1852
+ kind: str(entry.kind).slice(0, 20) || "组织",
1853
+ description: str(entry.description).slice(0, 200),
1854
+ region: str(entry.region).slice(0, 30)
1855
+ })).filter((f) => f.name !== "未命名势力" || f.description !== "")
1856
+ };
1857
+ }
1858
+ /**
1859
+ * 事实库回填:对历史已生成章节批量抽取事实(无事实记录的旧章节)。
1860
+ * @returns 回填的章节数。
1861
+ */
1862
+ async function backfillFacts(ctx, config, project, outputDir) {
1863
+ const have = new Set((project.facts ?? []).map((f) => f.chapterNo));
1864
+ let filled = 0;
1865
+ for (const chapter of project.chapters) {
1866
+ if (chapter.status === "pending" || chapter.status === "generating") continue;
1867
+ if (chapter.file === void 0 || have.has(chapter.no)) continue;
1868
+ try {
1869
+ if (await extractFacts(ctx, config, project, outputDir, chapter.no) > 0) filled++;
1870
+ } catch {}
1871
+ have.add(chapter.no);
1872
+ }
1873
+ return filled;
1874
+ }
1875
+ /**
1876
+ * 角色卡刷新:出场统计由服务端从正文精确计算(角色名出现过的章节数、
1877
+ * 最近出现章节),LLM 只负责聚合「当前状态」一句话。
1878
+ */
1879
+ async function refreshCharacters(ctx, config, project, outputDir) {
1880
+ const roster = project.bible?.characters ?? [];
1881
+ const facts = project.facts ?? [];
1882
+ if (roster.length === 0 && facts.length === 0) return [];
1883
+ const stat = /* @__PURE__ */ new Map();
1884
+ const known = roster.map((card) => card.name);
1885
+ for (const chapter of project.chapters) {
1886
+ if (chapter.status === "pending" || chapter.status === "generating") continue;
1887
+ const body = readChapterFile(outputDir, chapter);
1888
+ if (body === void 0) continue;
1889
+ for (const name of known) if (body.includes(name)) {
1890
+ const entry = stat.get(name) ?? {
1891
+ chapters: /* @__PURE__ */ new Set(),
1892
+ last: 0
1893
+ };
1894
+ entry.chapters.add(chapter.no);
1895
+ if (chapter.no > entry.last) entry.last = chapter.no;
1896
+ stat.set(name, entry);
1897
+ }
1898
+ }
1899
+ let statuses = /* @__PURE__ */ new Map();
1900
+ if (facts.length > 0) {
1901
+ const system = [
1902
+ "你是一位网文角色档案管理员。请根据「角色名单」与「已确立事实库」,为每个角色输出「当前状态」一句话(境界/修为/伤势/资源/心境)。",
1903
+ "输出必须是合法 JSON 数组,格式:[{\"name\": \"角色名\", \"status\": \"当前状态一句话\"}]",
1904
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
1905
+ ].join("\n");
1906
+ const rosterBlock = roster.map((ch) => `- ${ch.name}(${ch.traits.join("、")})`).join("\n");
1907
+ const factsBlock = facts.map((f) => `[第${f.chapterNo}章] ${f.text}`).join("\n");
1908
+ const user = [
1909
+ `角色名单:\n${rosterBlock}`,
1910
+ `已确立事实库(${facts.length} 条):\n${factsBlock.slice(-6e3)}`,
1911
+ "只输出 JSON 数组。"
1912
+ ].join("\n\n");
1913
+ try {
1914
+ const text = await complete(ctx, config, {
1915
+ system,
1916
+ user,
1917
+ temperature: .2,
1918
+ maxTokens: Math.max(config.maxTokens, 8e3)
1919
+ });
1920
+ for (const entry of parseJsonArray(text)) {
1921
+ const name = typeof entry.name === "string" ? entry.name : "";
1922
+ if (name !== "" && typeof entry.status === "string") statuses.set(name, entry.status);
1923
+ }
1924
+ } catch {}
1925
+ }
1926
+ const cards = [];
1927
+ const roleOf = (name) => roster.find((c) => c.name === name)?.role ?? "other";
1928
+ for (const card of roster) {
1929
+ const entry = stat.get(card.name);
1930
+ cards.push({
1931
+ name: card.name,
1932
+ role: card.role,
1933
+ status: statuses.get(card.name) ?? "",
1934
+ lastChapter: entry?.last ?? 0,
1935
+ appearances: entry?.chapters.size ?? 0
1936
+ });
1937
+ }
1938
+ for (const [name, entry] of stat) if (!cards.some((c) => c.name === name)) cards.push({
1939
+ name,
1940
+ role: roleOf(name),
1941
+ status: statuses.get(name) ?? "",
1942
+ lastChapter: entry.last,
1943
+ appearances: entry.chapters.size
1944
+ });
1945
+ return cards;
1946
+ }
1430
1947
  /** System prompt for foreshadow suggestions. */
1431
1948
  function foreshadowSystemPrompt() {
1432
1949
  return [
@@ -1450,7 +1967,8 @@ async function suggestForeshadows(ctx, config, project) {
1450
1967
  const parsed = parseJsonArray(await complete(ctx, config, {
1451
1968
  system: foreshadowSystemPrompt(),
1452
1969
  user,
1453
- temperature: .5
1970
+ temperature: .5,
1971
+ maxTokens: Math.max(config.maxTokens, 12e3)
1454
1972
  }));
1455
1973
  const existing = new Set(project.foreshadows.map((f) => f.description));
1456
1974
  const created = [];
@@ -1480,7 +1998,8 @@ async function extractStyleAsset(ctx, config, sampleText) {
1480
1998
  const raw = parseJsonObject(await complete(ctx, config, {
1481
1999
  system: styleEngineSystemPrompt(),
1482
2000
  user,
1483
- temperature: .3
2001
+ temperature: .3,
2002
+ maxTokens: Math.max(config.maxTokens, 12e3)
1484
2003
  }));
1485
2004
  const strArray = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string" && v.trim() !== "") : [];
1486
2005
  const result = {
@@ -1549,15 +2068,33 @@ function loadAssistantHistory(outputDir) {
1549
2068
  } catch {}
1550
2069
  return messages;
1551
2070
  }
1552
- /** Append one message to the persisted history. */
2071
+ /** Append one message to the persisted history. Tool payloads (e.g. full
2072
+ * outline / chapter text) are capped so the jsonl and later LLM context
2073
+ * don't grow unboundedly. */
1553
2074
  function appendHistory(outputDir, message) {
1554
2075
  mkdirSync(outputDir, { recursive: true });
1555
- appendFileSync(join(outputDir, ASSISTANT_HISTORY_FILE), JSON.stringify(message) + "\n", "utf8");
2076
+ const entry = message.role === "tool" && message.content.length > 4e3 ? {
2077
+ ...message,
2078
+ content: message.content.slice(0, 4e3) + "\n…(已截断,如需完整内容请重新调用工具)"
2079
+ } : message;
2080
+ appendFileSync(join(outputDir, ASSISTANT_HISTORY_FILE), JSON.stringify(entry) + "\n", "utf8");
2081
+ }
2082
+ /** 清空助手对话记录(删除历史文件)。 */
2083
+ function clearAssistantHistory(outputDir) {
2084
+ const file = join(outputDir, ASSISTANT_HISTORY_FILE);
2085
+ if (existsSync(file)) rmSync(file, { force: true });
1556
2086
  }
1557
2087
  /** Render the project snapshot the assistant sees. */
1558
2088
  function renderProjectSnapshot(project) {
1559
2089
  const sections = [];
1560
2090
  sections.push(`书名:${project.bookName}`);
2091
+ sections.push(`大纲节选(如需全文用 outline_text 工具):\n${project.outline.slice(0, 2500)}`);
2092
+ const assetNames = [];
2093
+ if (project.assets?.genre !== void 0) assetNames.push(`题材:${project.assets.genre.name}`);
2094
+ if (project.assets?.primaryProgression !== void 0) assetNames.push(`主推进:${project.assets.primaryProgression.name}`);
2095
+ if ((project.assets?.styleAssets?.length ?? 0) > 0) assetNames.push(`写法:${project.assets.styleAssets.map((s) => s.name).join("、")}`);
2096
+ if ((project.assets?.antiAiRules?.length ?? 0) > 0) assetNames.push(`文戒自定义:${project.assets.antiAiRules.map((r) => r.name).join("、")}`);
2097
+ if (assetNames.length > 0) sections.push(`【写作资产】${assetNames.join(" · ")}`);
1561
2098
  if (project.bible !== void 0) {
1562
2099
  const bible = project.bible;
1563
2100
  sections.push("【设定圣经】");
@@ -1577,13 +2114,21 @@ function renderProjectSnapshot(project) {
1577
2114
  }
1578
2115
  if (bible.redLines.length > 0) sections.push("写作红线:\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
1579
2116
  }
2117
+ if (project.world !== void 0) {
2118
+ const world = project.world;
2119
+ sections.push("【大世界】");
2120
+ if (world.realms.length > 0) sections.push("境界体系:" + world.realms.map((r, i) => `${i + 1}.${r.name}(${r.description.slice(0, 40)})`).join(" → "));
2121
+ if (world.regions.length > 0) sections.push("区域:" + world.regions.map((r) => r.name).join("、"));
2122
+ if (world.factions.length > 0) sections.push("势力:" + world.factions.map((f) => `${f.name}(${f.kind})`).join("、"));
2123
+ }
1580
2124
  if (project.volumes !== void 0 && project.volumes.length > 0) {
1581
2125
  sections.push("【卷结构】");
1582
2126
  for (const v of project.volumes) sections.push(`第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})`);
1583
2127
  }
1584
2128
  if (project.chapters.length > 0) {
1585
- sections.push("【章节计划与进度】");
1586
- for (const c of project.chapters) {
2129
+ const shown = project.chapters.slice(-30);
2130
+ sections.push(`【章节计划与进度(最近 ${shown.length} 章)】`);
2131
+ for (const c of shown) {
1587
2132
  const statusText = {
1588
2133
  pending: "待生成",
1589
2134
  generating: "生成中",
@@ -1595,27 +2140,48 @@ function renderProjectSnapshot(project) {
1595
2140
  }[c.status];
1596
2141
  sections.push(`第${c.no}章《${c.title}》[${statusText}]${c.chars !== void 0 ? ` ${c.chars}字` : ""}${c.summary !== void 0 && c.summary !== "" ? ` 摘要:${c.summary}` : ""}`);
1597
2142
  }
2143
+ if (project.chapters.length > shown.length) sections.push(`(还有 ${project.chapters.length - shown.length} 章未列出,可用 book_overview scope=volume:N 查看)`);
2144
+ const recent = project.chapters.filter((c) => c.status === "approved" || c.status === "written").slice(-2);
2145
+ if (recent.length > 0) {
2146
+ sections.push("【最近章节正文节选】");
2147
+ for (const c of recent) sections.push(`第${c.no}章《${c.title}》(节选,如需全文用 chapter_text):${c.beats.slice(0, 300)}`);
2148
+ }
1598
2149
  }
1599
2150
  if (project.foreshadows.length > 0) {
1600
2151
  sections.push("【伏笔】");
1601
2152
  for (const f of project.foreshadows) sections.push(`- [${f.status}] ${f.description}${f.targetChapter !== void 0 ? `(预计 ${f.targetChapter} 章回收)` : ""}`);
1602
2153
  }
2154
+ if ((project.facts ?? []).length > 0) {
2155
+ sections.push("【已确立事实库(最近 40 条,回答设定问题必须遵守)】");
2156
+ for (const f of (project.facts ?? []).slice(-40)) sections.push(`- [第${f.chapterNo}章] ${f.text}`);
2157
+ }
2158
+ if (project.blurb !== void 0 && project.blurb !== "") sections.push(`【小说简介】${project.blurb}`);
1603
2159
  return sections.join("\n");
1604
2160
  }
1605
2161
  /** The assistant system prompt. */
1606
2162
  function assistantSystemPrompt(project) {
1607
2163
  return [
1608
- "你是这部小说的 AI 编辑助理,负责陪作者讨论剧情、人设、世界观,并把讨论结果落实到项目里。",
2164
+ "你是「编辑老师」——服务这本书作者的资深中文网文编辑。",
2165
+ "人设:二十年网文老编辑,懂套路、懂市场、懂节奏,说话直接但句句有用。",
2166
+ "座右铭:「书是你的,但坑我替你盯着。」",
2167
+ "职责边界:陪作者讨论剧情/人设/世界观/爽点节奏并落地修改、维护全书一致性;不闲聊、不彩虹屁、不无意义长篇大论。",
1609
2168
  "==================== 当前项目快照 ====================",
1610
2169
  renderProjectSnapshot(project),
1611
2170
  "==================== 快照结束 ====================",
1612
2171
  "",
1613
- "你可以:",
1614
- "1. 与作者讨论剧情走向、人物动机、爽点节奏、伏笔安排等,给出专业建议(直接文字回答)。",
1615
- "2. 讨论达成一致后,用动作指令实际修改内容。动作指令格式(放在回复末尾单独一行):",
1616
- " <dsh-action name=\"工具名\">{\"参数名\": 值}</dsh-action>",
2172
+ "工作规则(严格遵守):",
2173
+ "1. 全量知情:回答和修改必须基于项目真实数据,禁止编造书中不存在的设定。需要完整信息时,先调用 book_overview 获取全书上下文(大纲全文/设定圣经/大世界/事实库/全部章节要点/伏笔/简介);需要某章正文用 chapter_text。",
2174
+ "2. 修改流程:改前用一句话说明意图 → 执行工具 → 改后简要汇报。",
2175
+ "3. 连锁维护:改动可能波及其它位置(其它章节、设定、事实库、简介)时,执行后主动调用 impact_analysis 分析影响面,并把「必须同步」的项一并处理或明确提示作者逐项确认。",
2176
+ "4. 删除红线:删除章节、清空设定等破坏性操作必须等作者明确同意。",
2177
+ "5. 品质门槛:建议必须具体——指出问题在哪一章、哪一段、哪一句,并给出可落地的改法;禁止\"建议增强冲突\"这类空话。",
2178
+ "6. 设定忠诚:忠于大纲、设定圣经、大世界、事实库;发现书中已有内容与设定冲突时,主动指出并给修正方案。",
2179
+ "7. 中文回复,简洁有干货。",
1617
2180
  "",
1618
2181
  "可用工具:",
2182
+ "- book_overview:{\"scope\": \"recent|full|volume:2\"(可选,默认 recent)}。返回全书上下文包(大纲/道藏/大世界/章节要点/事实库/伏笔/简介)。recent=最近30章;full=全部章节(书很长时慎用);volume:N=只看第N卷。",
2183
+ "- facts_query:{\"keyword\": \"关键词\"}。从编年录(事实库)按关键词检索相关事实(如灵石、境界名、人物名)。",
2184
+ "- impact_analysis:{\"change\": \"要做的修改描述\"}。分析这次改动会波及哪些位置,返回影响清单(定位到章节/设定/事实库)。",
1619
2185
  "- outline_text:无参数。返回当前大纲全文。",
1620
2186
  "- outline_replace:{\"old\": \"要替换的原文片段\", \"new\": \"新文本\"}。在大纲中替换一段文字(old 必须能在大纲中找到)。",
1621
2187
  "- bible_set_rule:{\"index\": 序号(0起), \"text\": \"新规则文本\"} 或 {\"append\": \"追加的规则\"}。修改设定圣经的世界规则。",
@@ -1630,7 +2196,14 @@ function assistantSystemPrompt(project) {
1630
2196
  "- assets_status:无参数。查看本书当前写作资产(题材/推进模式/反AI规则/写法)。",
1631
2197
  "- assets_set_genre:{\"name\": \"题材名\", \"description\": \"题材说明(可选)\"}。设置本书题材基底。",
1632
2198
  "- assets_set_progression:{\"name\": \"模式名\", \"driver\": \"驱动力\", \"primary\": true/false}。设置主/辅助推进模式。",
1633
- "- assets_add_rule:{\"name\": \"规则名(可选)\", \"avoid\": \"要避免的表达问题\", \"fix\": \"修正方向(可选)\"}。新增反 AI 规则。",
2199
+ "- assets_add_rule:{\"name\": \"规则名(可选)\", \"avoid\": \"要避免的表达问题\", \"fix\": \"修正方向(可选)}。新增反 AI 规则。",
2200
+ "",
2201
+ "回答质量要求(非常重要):",
2202
+ "- 具体:回答必须引用项目里的真实内容(人名、境界、章节、伏笔、设定),禁止空泛套话。快照里没有的信息,先调用工具获取(chapter_text / outline_text)再回答。",
2203
+ "- 专业:给建议时说明理由,指出问题所在章节/段落,给出可直接落地的修改方案(改什么、怎么改)。",
2204
+ "- 主动:作者说\"改一下\",主动调用对应工具执行,不要只给建议不动手;执行前用一句话说明意图,执行后简短汇报结果。",
2205
+ "- 忠于设定:以大纲、设定圣经、事实库为准,不得自相矛盾;发现问题(如剧情与设定冲突)主动指出。",
2206
+ "- 中文回复;文字量适中,别啰嗦。",
1634
2207
  "",
1635
2208
  "使用规则(非常重要):",
1636
2209
  "- 当你想执行任何工具时,你的【整个回复】必须只包含动作指令标签,格式如下(不要有任何解释文字、不要用自然语言说\"我要去改\",直接输出标签):",
@@ -1644,7 +2217,7 @@ function assistantSystemPrompt(project) {
1644
2217
  "- 如果工具执行失败(例如片段未找到),根据错误信息修正参数后自动重试一次,不要直接放弃或让作者手动操作。",
1645
2218
  "- 修改前先向作者说明你要改什么、为什么;动作执行后简要汇报结果。",
1646
2219
  "- 涉及删除类操作(删除章节、清空设定)必须等作者明确同意。",
1647
- "- 严格忠于设定圣经与大刚;不得自行发明与既有设定冲突的内容。",
2220
+ "- 严格忠于设定圣经与大纲;不得自行发明与既有设定冲突的内容。",
1648
2221
  "- 用中文回复。"
1649
2222
  ].join("\n");
1650
2223
  }
@@ -1662,6 +2235,25 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
1662
2235
  for await (const step of stream) if (step.frame === "delta") yield step.text;
1663
2236
  };
1664
2237
  switch (name) {
2238
+ case "book_overview": {
2239
+ const scopeArg = str(args.scope);
2240
+ return bookOverview(project, scopeArg === "full" ? "full" : /^volume:(\d+)$/.test(scopeArg) ? Number(scopeArg.slice(7)) : "recent");
2241
+ }
2242
+ case "facts_query": {
2243
+ const keyword = str(args.keyword).trim();
2244
+ if (keyword === "") throw new Error("facts_query 需要 keyword");
2245
+ const hits = (project.facts ?? []).filter((f) => f.text.includes(keyword)).slice(-30);
2246
+ if (hits.length === 0) return `编年录中未找到与「${keyword}」相关的事实记录。`;
2247
+ return `编年录中与「${keyword}」相关的事实(${hits.length} 条):\n` + hits.map((f) => `- [第${f.chapterNo}章] ${f.text}`).join("\n");
2248
+ }
2249
+ case "impact_analysis": {
2250
+ const change = str(args.change);
2251
+ if (change === "") throw new Error("impact_analysis 需要 change(要做的修改描述)");
2252
+ const items = await analyzeImpact(ctx, config, project, outputDir, change);
2253
+ if (items.length === 0) return "影响分析:未发现需要同步修改的位置。";
2254
+ const lines = items.map((it, i) => `${i + 1}. [${it.location}]「${it.quote}」${it.suggestion !== "" ? ` → ${it.suggestion}` : ""}(${it.kind === "must" ? "必须同步" : it.kind === "optional" ? "建议" : "备注"})`);
2255
+ return `影响分析:这次改动波及 ${items.length} 处——\n${lines.join("\n")}\n请据此提示作者逐项处理;章节内的修改可引导作者在工作区查看。`;
2256
+ }
1665
2257
  case "outline_text": return project.outline;
1666
2258
  case "outline_replace": {
1667
2259
  const old = str(args.old);
@@ -1673,7 +2265,7 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
1673
2265
  return `大纲已修改:替换了 ${old.length} 字符的片段。`;
1674
2266
  }
1675
2267
  case "bible_set_rule": {
1676
- if (project.bible === void 0) throw new Error("尚无设定圣经,请先提炼");
2268
+ if (project.bible === void 0) throw new Error("尚无道藏,请先提炼");
1677
2269
  const index = num(args.index);
1678
2270
  if (index !== void 0) project.bible.worldRules[index] = str(args.text);
1679
2271
  else if (str(args.append) !== "") project.bible.worldRules.push(str(args.append));
@@ -1683,7 +2275,7 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
1683
2275
  return `世界规则已更新(当前 ${project.bible.worldRules.length} 条)。`;
1684
2276
  }
1685
2277
  case "bible_set_redline": {
1686
- if (project.bible === void 0) throw new Error("尚无设定圣经,请先提炼");
2278
+ if (project.bible === void 0) throw new Error("尚无道藏,请先提炼");
1687
2279
  const index = num(args.index);
1688
2280
  if (index !== void 0) project.bible.redLines[index] = str(args.text);
1689
2281
  else if (str(args.append) !== "") project.bible.redLines.push(str(args.append));
@@ -1707,9 +2299,23 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
1707
2299
  const instructions = str(args.instructions);
1708
2300
  const target = str(args.target);
1709
2301
  for await (const chunk of forward(rewriteChapterStream(ctx, config, project, outputDir, no, instructions, target === "" ? void 0 : target))) yield chunk;
1710
- yield "(正在生成章节摘要…)";
2302
+ const chapter = project.chapters.find((c) => c.no === no);
2303
+ const draft = chapter?.pendingDraft;
2304
+ if (chapter === void 0 || draft === void 0 || draft === "") throw new Error(`章节 ${no} 修订后没有产出草稿`);
2305
+ const fileName = chapterFileName(chapter);
2306
+ mkdirSync(outputDir, { recursive: true });
2307
+ writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, "utf8");
2308
+ chapter.pendingDraft = void 0;
2309
+ chapter.status = "written";
2310
+ chapter.chars = draft.length;
2311
+ chapter.file = fileName;
2312
+ chapter.review = void 0;
2313
+ chapter.error = void 0;
2314
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2315
+ saveProject(outputDir, project);
2316
+ yield "(已采纳修订稿,正在生成章节摘要与编年录…)";
1711
2317
  try {
1712
- await summarizeChapter(ctx, config, project, outputDir, no);
2318
+ await summarizeAndExtractFacts(ctx, config, project, outputDir, no);
1713
2319
  } catch {}
1714
2320
  yield "(正在 AI 审稿…)";
1715
2321
  const report = await reviewChapter(ctx, config, project, outputDir, no);
@@ -1719,9 +2325,9 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
1719
2325
  const no = num(args.no);
1720
2326
  if (no === void 0) throw new Error("chapter_generate 需要 no");
1721
2327
  for await (const chunk of forward(generateChapterStream(ctx, config, project, outputDir, no))) yield chunk;
1722
- yield "(正在生成章节摘要…)";
2328
+ yield "(正在生成章节摘要与编年录…)";
1723
2329
  try {
1724
- await summarizeChapter(ctx, config, project, outputDir, no);
2330
+ await summarizeAndExtractFacts(ctx, config, project, outputDir, no);
1725
2331
  } catch {}
1726
2332
  yield "(正在 AI 审稿…)";
1727
2333
  const report = await reviewChapter(ctx, config, project, outputDir, no);
@@ -1838,11 +2444,11 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
1838
2444
  default: throw new Error(`未知工具 ${name}`);
1839
2445
  }
1840
2446
  }
1841
- /** Extract the first action directive from a reply. */
2447
+ /** Extract the first action directive from a reply (tolerant to common tag misspellings). */
1842
2448
  function extractAction(reply) {
1843
- const match = /<dsh-action\s+name="([^"]+)"\s*>([\s\S]*?)<\/dsh-action>/.exec(reply);
2449
+ const match = /<([a-z_]*d[a-z]?sh?-action)\s+name="([^"]+)"\s*>([\s\S]*?)<\/\1>/.exec(reply);
1844
2450
  if (match === null) return void 0;
1845
- const rawArgs = match[2]?.trim() ?? "";
2451
+ const rawArgs = match[3]?.trim() ?? "";
1846
2452
  let args;
1847
2453
  try {
1848
2454
  args = rawArgs === "" ? {} : JSON.parse(rawArgs);
@@ -1850,7 +2456,7 @@ function extractAction(reply) {
1850
2456
  throw new Error(`动作参数不是合法 JSON:${rawArgs.slice(0, 80)}`);
1851
2457
  }
1852
2458
  return {
1853
- name: match[1] ?? "",
2459
+ name: match[2] ?? "",
1854
2460
  args,
1855
2461
  index: match.index
1856
2462
  };
@@ -1879,16 +2485,19 @@ function historyToMessages(history) {
1879
2485
  model: "deepseek-v4-flash"
1880
2486
  }
1881
2487
  }));
1882
- else if (entry.role === "tool") messages.push(createUserMessage({
1883
- content: [{
1884
- type: "text",
1885
- text: `【工具 ${entry.tool ?? ""} 的执行结果】\n${entry.content}`
1886
- }],
1887
- source: {
1888
- kind: "plugin",
1889
- plugin: "dsh-novel-forge"
1890
- }
1891
- }));
2488
+ else if (entry.role === "tool") {
2489
+ const body = entry.content.length > 2e3 ? entry.content.slice(0, 2e3) + "\n…(结果过长已截断,需要完整内容请重新调用工具)" : entry.content;
2490
+ messages.push(createUserMessage({
2491
+ content: [{
2492
+ type: "text",
2493
+ text: `【工具 ${entry.tool ?? ""} 的执行结果】\n${body}`
2494
+ }],
2495
+ source: {
2496
+ kind: "plugin",
2497
+ plugin: "dsh-novel-forge"
2498
+ }
2499
+ }));
2500
+ }
1892
2501
  return messages;
1893
2502
  }
1894
2503
  /** One non-streaming LLM chat turn (used inside the tool loop). */
@@ -1899,7 +2508,7 @@ async function chatOnce(ctx, config, system, history) {
1899
2508
  model: config.model,
1900
2509
  messages,
1901
2510
  system,
1902
- maxTokens: config.maxTokens,
2511
+ maxTokens: Math.max(config.maxTokens, 16e3),
1903
2512
  temperature: .7
1904
2513
  };
1905
2514
  const assembler = new BlockAssembler();
@@ -1932,9 +2541,11 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
1932
2541
  const reply = await chatOnce(ctx, config, system, history);
1933
2542
  const action = extractAction(reply);
1934
2543
  if (action === void 0) {
1935
- if (/(改|修改|修订|重写|替换|调整|生成|新增|删除|导出|看看|查看|调出|读一下|加上|加一个|去掉|删掉|把.+改成)/.test(reply) && !nudged) {
2544
+ const intendsAction = /(改|修改|修订|重写|替换|调整|生成|新增|删除|导出|看看|查看|调出|读一下|加上|加一个|去掉|删掉|把.+改成)/.test(reply);
2545
+ const strayTag = /<[a-z_-]*action[^>]*>/.test(reply);
2546
+ if ((intendsAction || strayTag) && !nudged) {
1936
2547
  nudged = true;
1937
- const nudge = "你的上一条回复表达了想操作项目的意图(如查看/修改大纲、章节等),但没有输出动作指令标签,因此没有执行任何操作。请直接输出 <dsh-action name=\"工具名\">{\"参数\":值}</dsh-action> 标签来执行,不要用文字描述意图。如果需要先看内容,先输出 outline_text 或 chapter_text 标签。";
2548
+ const nudge = "你的上一条回复表达了想操作项目的意图(或动作标签格式有误),因此没有执行任何操作。请直接输出动作标签来执行,格式必须为 <dsh-action name=\"工具名\">{\"参数\":值}</dsh-action>(注意拼写是 dsh-action,不是 dash-action;标签成对出现,参数为合法 JSON)。如果需要先看内容,先输出 outline_text 或 chapter_text 标签。";
1938
2549
  history.push({
1939
2550
  role: "tool",
1940
2551
  content: nudge,
@@ -2095,11 +2706,14 @@ function bookshelfSnapshot(store) {
2095
2706
  books: store.books.map((book) => {
2096
2707
  const project = loadProject(book.outputDir);
2097
2708
  const done = project === void 0 ? 0 : project.chapters.filter((c) => c.status === "approved" || c.status === "written" || c.status === "rejected").length;
2709
+ const hasCover = project?.coverPath !== void 0 && project.coverPath !== "" && existsSync(join(book.outputDir, project.coverPath));
2098
2710
  return {
2099
2711
  ...book,
2100
2712
  done,
2101
2713
  total: project?.chapters.length ?? 0,
2102
- hasProject: project !== void 0
2714
+ hasProject: project !== void 0,
2715
+ hasCover,
2716
+ blurb: project?.blurb
2103
2717
  };
2104
2718
  }),
2105
2719
  activeBookId: store.activeBookId
@@ -2122,6 +2736,16 @@ function createBook(bookName, outputDir) {
2122
2736
  saveBookshelf(store);
2123
2737
  return book;
2124
2738
  }
2739
+ /** 更新某本书的书名(开书向导导入大纲后书名以大纲首行为准)。 */
2740
+ function renameBook(id, bookName) {
2741
+ const store = loadBookshelf();
2742
+ const book = store.books.find((b) => b.id === id);
2743
+ if (book === void 0) return false;
2744
+ book.bookName = bookName;
2745
+ book.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2746
+ saveBookshelf(store);
2747
+ return true;
2748
+ }
2125
2749
  /**
2126
2750
  * 播种:书架为空时,把指定输出目录下已有的项目自动登记为第一本书。
2127
2751
  * 兼容升级场景 —— 旧版插件直接在输出目录写项目,从未登记书架。
@@ -2168,8 +2792,8 @@ function defaultOutputDirFor(bookName) {
2168
2792
  }
2169
2793
  //#endregion
2170
2794
  //#region src/routes.ts
2171
- /** Cap on JSON request bodies. */
2172
- const MAX_JSON_BODY_BYTES = 4 * 1024 * 1024;
2795
+ /** Cap on JSON request bodies (generous: cover images travel as base64). */
2796
+ const MAX_JSON_BODY_BYTES = 16 * 1024 * 1024;
2173
2797
  /** Loopback-only fence (mirrors the family plugins' pairing routes). */
2174
2798
  function isLoopbackRequest(request) {
2175
2799
  const address = request.socket.remoteAddress;
@@ -2264,7 +2888,10 @@ function makeRoutes(deps) {
2264
2888
  }
2265
2889
  writeJson(res, 200, {
2266
2890
  config,
2267
- project: project ?? void 0,
2891
+ project: project !== void 0 ? {
2892
+ ...project,
2893
+ facts: (project.facts ?? []).slice(-80)
2894
+ } : void 0,
2268
2895
  generatedFiles: listChapterFiles(config.outputDir)
2269
2896
  });
2270
2897
  }
@@ -2466,9 +3093,9 @@ function makeRoutes(deps) {
2466
3093
  title: chapter.title
2467
3094
  });
2468
3095
  try {
2469
- await summarizeChapter(ctx, config, project, config.outputDir, no);
3096
+ await summarizeAndExtractFacts(ctx, config, project, config.outputDir, no);
2470
3097
  } catch (error) {
2471
- console.warn("[dsh-novel-forge] summary failed:", error.message);
3098
+ console.warn("[dsh-novel-forge] summary/facts failed:", error.message);
2472
3099
  }
2473
3100
  if (!(body?.skipReview === true) && (config.autoReview ?? true)) send({
2474
3101
  type: "review",
@@ -2544,16 +3171,11 @@ function makeRoutes(deps) {
2544
3171
  type: "delta",
2545
3172
  text: step.text
2546
3173
  });
2547
- else if (step.frame === "done") send({
2548
- type: "rewritten",
3174
+ else if (step.frame === "drafted") send({
3175
+ type: "drafted",
2549
3176
  no,
2550
- file: step.file,
2551
- chars: step.chars
2552
- });
2553
- send({
2554
- type: "review",
2555
- no,
2556
- report: await reviewChapter(ctx, config, project, config.outputDir, no)
3177
+ chars: step.chars,
3178
+ draft: step.draft
2557
3179
  });
2558
3180
  res.end();
2559
3181
  } catch (error) {
@@ -2596,11 +3218,11 @@ function makeRoutes(deps) {
2596
3218
  type: "delta",
2597
3219
  text: step.text
2598
3220
  });
2599
- else if (step.frame === "done") send({
2600
- type: "rewritten",
3221
+ else if (step.frame === "drafted") send({
3222
+ type: "drafted",
2601
3223
  no,
2602
- file: step.file,
2603
- chars: step.chars
3224
+ chars: step.chars,
3225
+ draft: step.draft
2604
3226
  });
2605
3227
  res.end();
2606
3228
  } catch (error) {
@@ -2615,6 +3237,77 @@ function makeRoutes(deps) {
2615
3237
  }
2616
3238
  }
2617
3239
  };
3240
+ /** 采纳待确认草稿:覆盖正文文件 + 状态回 written + 清空草稿。 */
3241
+ const draftApplyRoute = {
3242
+ kind: "exact",
3243
+ path: NOVEL_API.draftApply,
3244
+ handler: async (req, res) => {
3245
+ if (!guard(req, res, "POST")) return;
3246
+ const config = getConfig();
3247
+ const project = requireProject(res);
3248
+ if (project === void 0) return;
3249
+ const body = await readJsonBody(req);
3250
+ if (!Number.isInteger(body?.chapterNo)) {
3251
+ writeJson(res, 400, { error: "chapterNo 须为正整数" });
3252
+ return;
3253
+ }
3254
+ const chapter = project.chapters.find((c) => c.no === body.chapterNo);
3255
+ if (chapter === void 0) {
3256
+ writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` });
3257
+ return;
3258
+ }
3259
+ if (chapter.pendingDraft === void 0 || chapter.pendingDraft === "") {
3260
+ writeJson(res, 400, { error: `章节 ${chapter.no} 没有待确认的草稿` });
3261
+ return;
3262
+ }
3263
+ const draft = chapter.pendingDraft;
3264
+ const fileName = chapterFileName(chapter);
3265
+ mkdirSync(config.outputDir, { recursive: true });
3266
+ const targetPath = join(config.outputDir, fileName);
3267
+ if (existsSync(targetPath)) copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, "")}.bak.md`));
3268
+ writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, "utf8");
3269
+ chapter.pendingDraft = void 0;
3270
+ chapter.status = "written";
3271
+ chapter.chars = draft.length;
3272
+ chapter.file = fileName;
3273
+ chapter.review = void 0;
3274
+ chapter.error = void 0;
3275
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3276
+ saveProject(config.outputDir, project);
3277
+ writeJson(res, 200, {
3278
+ ok: true,
3279
+ chars: draft.length,
3280
+ file: fileName
3281
+ });
3282
+ }
3283
+ };
3284
+ /** 放弃待确认草稿:保留原稿,仅清空草稿字段。 */
3285
+ const draftDiscardRoute = {
3286
+ kind: "exact",
3287
+ path: NOVEL_API.draftDiscard,
3288
+ handler: async (req, res) => {
3289
+ if (!guard(req, res, "POST")) return;
3290
+ const config = getConfig();
3291
+ const project = requireProject(res);
3292
+ if (project === void 0) return;
3293
+ const body = await readJsonBody(req);
3294
+ if (!Number.isInteger(body?.chapterNo)) {
3295
+ writeJson(res, 400, { error: "chapterNo 须为正整数" });
3296
+ return;
3297
+ }
3298
+ const chapter = project.chapters.find((c) => c.no === body.chapterNo);
3299
+ if (chapter === void 0) {
3300
+ writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` });
3301
+ return;
3302
+ }
3303
+ if (chapter.pendingDraft !== void 0) {
3304
+ chapter.pendingDraft = void 0;
3305
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3306
+ saveProject(config.outputDir, project);
3307
+ }
3308
+ writeJson(res, 200, { ok: true });
3309
+ }
3310
+ };
2618
3311
  const summaryRoute = {
2619
3312
  kind: "exact",
2620
3313
  path: NOVEL_API.summary,
@@ -2732,6 +3425,77 @@ function makeRoutes(deps) {
2732
3425
  });
2733
3426
  }
2734
3427
  };
3428
+ /** 审查手动编辑的正文(不落盘,返回审稿报告)。 */
3429
+ const chapterCheckRoute = {
3430
+ kind: "exact",
3431
+ path: NOVEL_API.chapterCheck,
3432
+ handler: async (req, res) => {
3433
+ if (!guard(req, res, "POST")) return;
3434
+ const config = getConfig();
3435
+ const project = requireProject(res);
3436
+ if (project === void 0) return;
3437
+ const text = (await readJsonBody(req))?.text?.trim() ?? "";
3438
+ if (text.length < 50) {
3439
+ writeJson(res, 400, { error: "正文过短(<50 字),请先编辑内容" });
3440
+ return;
3441
+ }
3442
+ try {
3443
+ writeJson(res, 200, { report: await reviewChapterText(ctx, config, project, text) });
3444
+ } catch (error) {
3445
+ writeJson(res, 500, { error: error.message });
3446
+ }
3447
+ }
3448
+ };
3449
+ /** 保存手动编辑的正文(自动备份 .bak,状态回 written)。 */
3450
+ const chapterSaveRoute = {
3451
+ kind: "exact",
3452
+ path: NOVEL_API.chapterSave,
3453
+ handler: async (req, res) => {
3454
+ if (!guard(req, res, "POST")) return;
3455
+ const config = getConfig();
3456
+ const project = requireProject(res);
3457
+ if (project === void 0) return;
3458
+ const body = await readJsonBody(req);
3459
+ if (!Number.isInteger(body?.chapterNo)) {
3460
+ writeJson(res, 400, { error: "chapterNo 须为正整数" });
3461
+ return;
3462
+ }
3463
+ const chapter = project.chapters.find((c) => c.no === body.chapterNo);
3464
+ if (chapter === void 0) {
3465
+ writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` });
3466
+ return;
3467
+ }
3468
+ const text = body?.text?.trim() ?? "";
3469
+ if (text.length < 50) {
3470
+ writeJson(res, 400, { error: "正文过短(<50 字),未保存" });
3471
+ return;
3472
+ }
3473
+ const fileName = chapterFileName(chapter);
3474
+ mkdirSync(config.outputDir, { recursive: true });
3475
+ const targetPath = join(config.outputDir, fileName);
3476
+ if (existsSync(targetPath)) copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, "")}.bak.md`));
3477
+ writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${text}\n`, "utf8");
3478
+ chapter.status = "written";
3479
+ chapter.chars = text.length;
3480
+ chapter.file = fileName;
3481
+ chapter.pendingDraft = void 0;
3482
+ let report;
3483
+ const carried = body?.report;
3484
+ if (carried !== void 0 && typeof carried.score === "number") {
3485
+ report = carried;
3486
+ chapter.review = report;
3487
+ chapter.status = report.passed ? "approved" : "rejected";
3488
+ } else report = await reviewChapter(ctx, config, project, config.outputDir, chapter.no);
3489
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3490
+ saveProject(config.outputDir, project);
3491
+ writeJson(res, 200, {
3492
+ ok: true,
3493
+ chars: text.length,
3494
+ file: fileName,
3495
+ report
3496
+ });
3497
+ }
3498
+ };
2735
3499
  const assistantRoute = {
2736
3500
  kind: "exact",
2737
3501
  path: NOVEL_API.assistant,
@@ -2791,6 +3555,16 @@ function makeRoutes(deps) {
2791
3555
  writeJson(res, 200, { messages: loadAssistantHistory(getConfig().outputDir) });
2792
3556
  }
2793
3557
  };
3558
+ /** 清空助手对话记录。 */
3559
+ const assistantClearRoute = {
3560
+ kind: "exact",
3561
+ path: NOVEL_API.assistantClear,
3562
+ handler: (req, res) => {
3563
+ if (!guard(req, res, "POST")) return;
3564
+ clearAssistantHistory(getConfig().outputDir);
3565
+ writeJson(res, 200, { ok: true });
3566
+ }
3567
+ };
2794
3568
  return [
2795
3569
  statusRoute,
2796
3570
  loadOutlineRoute,
@@ -2802,10 +3576,14 @@ function makeRoutes(deps) {
2802
3576
  reviewRoute,
2803
3577
  rewriteRoute,
2804
3578
  polishRoute,
3579
+ draftApplyRoute,
3580
+ draftDiscardRoute,
2805
3581
  summaryRoute,
2806
3582
  foreshadowRoute,
2807
3583
  exportRoute,
2808
3584
  chapterRoute,
3585
+ chapterCheckRoute,
3586
+ chapterSaveRoute,
2809
3587
  {
2810
3588
  kind: "exact",
2811
3589
  path: NOVEL_API.assets,
@@ -2887,6 +3665,7 @@ function makeRoutes(deps) {
2887
3665
  },
2888
3666
  assistantRoute,
2889
3667
  assistantHistoryRoute,
3668
+ assistantClearRoute,
2890
3669
  {
2891
3670
  kind: "exact",
2892
3671
  path: NOVEL_API.bookshelf,
@@ -2911,7 +3690,14 @@ function makeRoutes(deps) {
2911
3690
  writeJson(res, 400, { error: "bookName 不能为空" });
2912
3691
  return;
2913
3692
  }
2914
- createBook(bookName, body?.outputDir?.trim() !== "" && body?.outputDir !== void 0 ? body.outputDir : defaultOutputDirFor(bookName));
3693
+ const outputDir = body?.outputDir?.trim() !== "" && body?.outputDir !== void 0 ? body.outputDir : defaultOutputDirFor(bookName);
3694
+ const book = createBook(bookName, outputDir);
3695
+ const outline = body?.outline?.trim();
3696
+ if (outline !== void 0 && outline.length >= 50) {
3697
+ const project = createProject(outline);
3698
+ saveProject(outputDir, project);
3699
+ renameBook(book.id, project.bookName);
3700
+ }
2915
3701
  writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
2916
3702
  return;
2917
3703
  }
@@ -2952,6 +3738,266 @@ function makeRoutes(deps) {
2952
3738
  writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
2953
3739
  }
2954
3740
  },
3741
+ {
3742
+ kind: "exact",
3743
+ path: NOVEL_API.reset,
3744
+ handler: async (req, res) => {
3745
+ if (!guard(req, res, "POST")) return;
3746
+ const config = getConfig();
3747
+ const project = loadProject(config.outputDir);
3748
+ if (project === void 0) {
3749
+ writeJson(res, 400, { error: "输出目录中没有项目,无需重置" });
3750
+ return;
3751
+ }
3752
+ const outline = (await readJsonBody(req))?.outline?.trim();
3753
+ if (outline !== void 0 && outline.length >= 50) {
3754
+ project.outline = outline;
3755
+ project.bookName = createProject(outline).bookName;
3756
+ }
3757
+ project.bible = void 0;
3758
+ project.volumes = void 0;
3759
+ project.chapters = [];
3760
+ project.foreshadows = [];
3761
+ project.assets = emptyProjectAssets();
3762
+ project.facts = [];
3763
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3764
+ saveProject(config.outputDir, project);
3765
+ writeJson(res, 200, {
3766
+ ok: true,
3767
+ bookName: project.bookName
3768
+ });
3769
+ }
3770
+ },
3771
+ {
3772
+ kind: "exact",
3773
+ path: NOVEL_API.audit,
3774
+ handler: async (req, res) => {
3775
+ if (!guard(req, res, "POST")) return;
3776
+ const config = getConfig();
3777
+ const project = requireProject(res);
3778
+ if (project === void 0) return;
3779
+ try {
3780
+ writeJson(res, 200, {
3781
+ issues: await auditBook(ctx, config, project, config.outputDir),
3782
+ auditedChapters: project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating").length,
3783
+ auditedAt: (/* @__PURE__ */ new Date()).toISOString()
3784
+ });
3785
+ } catch (error) {
3786
+ writeJson(res, 500, { error: error.message });
3787
+ }
3788
+ }
3789
+ },
3790
+ {
3791
+ kind: "exact",
3792
+ path: NOVEL_API.charactersRefresh,
3793
+ handler: async (req, res) => {
3794
+ if (!guard(req, res, "POST")) return;
3795
+ const config = getConfig();
3796
+ const project = requireProject(res);
3797
+ if (project === void 0) return;
3798
+ try {
3799
+ writeJson(res, 200, { cards: await refreshCharacters(ctx, config, project, config.outputDir) });
3800
+ } catch (error) {
3801
+ writeJson(res, 500, { error: error.message });
3802
+ }
3803
+ }
3804
+ },
3805
+ {
3806
+ kind: "exact",
3807
+ path: NOVEL_API.factsBackfill,
3808
+ handler: async (req, res) => {
3809
+ if (!guard(req, res, "POST")) return;
3810
+ const config = getConfig();
3811
+ const project = requireProject(res);
3812
+ if (project === void 0) return;
3813
+ try {
3814
+ writeJson(res, 200, {
3815
+ ok: true,
3816
+ filled: await backfillFacts(ctx, config, project, config.outputDir)
3817
+ });
3818
+ } catch (error) {
3819
+ writeJson(res, 500, { error: error.message });
3820
+ }
3821
+ }
3822
+ },
3823
+ {
3824
+ kind: "exact",
3825
+ path: NOVEL_API.biblePatch,
3826
+ handler: async (req, res) => {
3827
+ if (!guard(req, res, "POST")) return;
3828
+ const config = getConfig();
3829
+ const project = requireProject(res);
3830
+ if (project === void 0) return;
3831
+ if (project.bible === void 0) {
3832
+ writeJson(res, 400, { error: "尚未生成道藏,请先生成" });
3833
+ return;
3834
+ }
3835
+ const body = await readJsonBody(req);
3836
+ if (Array.isArray(body?.worldRules)) project.bible.worldRules = body.worldRules.filter((r) => r.trim() !== "");
3837
+ if (Array.isArray(body?.redLines)) project.bible.redLines = body.redLines.filter((r) => r.trim() !== "");
3838
+ if (Array.isArray(body?.style)) project.bible.style = body.style.filter((r) => r.trim() !== "");
3839
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3840
+ saveProject(config.outputDir, project);
3841
+ writeJson(res, 200, { bible: project.bible });
3842
+ }
3843
+ },
3844
+ {
3845
+ kind: "exact",
3846
+ path: NOVEL_API.blurb,
3847
+ handler: async (req, res) => {
3848
+ if (!guard(req, res, "POST")) return;
3849
+ const config = getConfig();
3850
+ const project = requireProject(res);
3851
+ if (project === void 0) return;
3852
+ const body = await readJsonBody(req);
3853
+ try {
3854
+ if (body?.action === "save") {
3855
+ const text = body.text?.trim() ?? "";
3856
+ project.blurb = text;
3857
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3858
+ saveProject(config.outputDir, project);
3859
+ writeJson(res, 200, { blurb: text });
3860
+ return;
3861
+ }
3862
+ const partial = body?.partial?.trim() ?? "";
3863
+ const blurb = await generateBlurb(ctx, config, project, partial);
3864
+ project.blurb = blurb;
3865
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3866
+ saveProject(config.outputDir, project);
3867
+ writeJson(res, 200, { blurb });
3868
+ } catch (error) {
3869
+ writeJson(res, 500, { error: error.message });
3870
+ }
3871
+ }
3872
+ },
3873
+ {
3874
+ kind: "exact",
3875
+ path: NOVEL_API.cover,
3876
+ handler: async (req, res) => {
3877
+ const config = getConfig();
3878
+ if (req.method === "GET") {
3879
+ if (!isLoopbackRequest(req)) {
3880
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
3881
+ return;
3882
+ }
3883
+ const dirParam = new URL(req.url ?? "/", "http://localhost").searchParams.get("dir");
3884
+ const targetDir = dirParam !== null && dirParam !== "" ? dirParam : config.outputDir;
3885
+ const coverPath = loadProject(targetDir)?.coverPath;
3886
+ if (coverPath === void 0 || coverPath === "") {
3887
+ writeJson(res, 200, { dataUrl: null });
3888
+ return;
3889
+ }
3890
+ const file = join(targetDir, coverPath);
3891
+ if (!existsSync(file)) {
3892
+ writeJson(res, 200, { dataUrl: null });
3893
+ return;
3894
+ }
3895
+ writeJson(res, 200, { dataUrl: `data:${coverPath.toLowerCase().endsWith(".png") ? "image/png" : coverPath.toLowerCase().endsWith(".jpg") || coverPath.toLowerCase().endsWith(".jpeg") ? "image/jpeg" : coverPath.toLowerCase().endsWith(".webp") ? "image/webp" : "image/png"};base64,${readFileSync(file).toString("base64")}` });
3896
+ return;
3897
+ }
3898
+ if (req.method === "POST") {
3899
+ if (!guard(req, res, "POST")) return;
3900
+ const project = requireProject(res);
3901
+ if (project === void 0) return;
3902
+ const body = await readJsonBody(req);
3903
+ try {
3904
+ if (body?.action === "remove") {
3905
+ if (project.coverPath !== void 0 && project.coverPath !== "") {
3906
+ const oldFile = join(config.outputDir, project.coverPath);
3907
+ if (existsSync(oldFile)) rmSync(oldFile, { force: true });
3908
+ }
3909
+ project.coverPath = void 0;
3910
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3911
+ saveProject(config.outputDir, project);
3912
+ writeJson(res, 200, {
3913
+ ok: true,
3914
+ coverPath: null
3915
+ });
3916
+ return;
3917
+ }
3918
+ const dataUrl = body?.dataUrl ?? "";
3919
+ const match = /^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/s.exec(dataUrl);
3920
+ if (match === null) {
3921
+ writeJson(res, 400, { error: "封面须为 PNG/JPEG/WebP 的 base64 data URL" });
3922
+ return;
3923
+ }
3924
+ const mime = match[1];
3925
+ const fileName = `cover.${mime === "image/png" ? "png" : mime === "image/webp" ? "webp" : "jpg"}`;
3926
+ const targetPath = join(config.outputDir, fileName);
3927
+ const oldPath = project.coverPath;
3928
+ mkdirSync(config.outputDir, { recursive: true });
3929
+ writeFileSync(targetPath, Buffer.from(match[2], "base64"));
3930
+ if (oldPath !== void 0 && oldPath !== "" && oldPath !== fileName) {
3931
+ const oldFile = join(config.outputDir, oldPath);
3932
+ if (existsSync(oldFile)) rmSync(oldFile, { force: true });
3933
+ }
3934
+ project.coverPath = fileName;
3935
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3936
+ saveProject(config.outputDir, project);
3937
+ writeJson(res, 200, {
3938
+ ok: true,
3939
+ coverPath: fileName
3940
+ });
3941
+ } catch (error) {
3942
+ writeJson(res, 500, { error: error.message });
3943
+ }
3944
+ return;
3945
+ }
3946
+ writeJson(res, 405, { error: "method not allowed (expected GET or POST)" });
3947
+ }
3948
+ },
3949
+ {
3950
+ kind: "exact",
3951
+ path: NOVEL_API.world,
3952
+ handler: async (req, res) => {
3953
+ if (!guard(req, res, "POST")) return;
3954
+ const config = getConfig();
3955
+ const project = requireProject(res);
3956
+ if (project === void 0) return;
3957
+ const body = await readJsonBody(req);
3958
+ try {
3959
+ if (body?.action === "save" && body.world !== void 0) {
3960
+ project.world = {
3961
+ realms: Array.isArray(body.world.realms) ? body.world.realms : [],
3962
+ regions: Array.isArray(body.world.regions) ? body.world.regions : [],
3963
+ factions: Array.isArray(body.world.factions) ? body.world.factions : []
3964
+ };
3965
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3966
+ saveProject(config.outputDir, project);
3967
+ writeJson(res, 200, { world: project.world });
3968
+ return;
3969
+ }
3970
+ const world = await extractWorld(ctx, config, project);
3971
+ project.world = world;
3972
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3973
+ saveProject(config.outputDir, project);
3974
+ writeJson(res, 200, { world });
3975
+ } catch (error) {
3976
+ writeJson(res, 500, { error: error.message });
3977
+ }
3978
+ }
3979
+ },
3980
+ {
3981
+ kind: "exact",
3982
+ path: NOVEL_API.rename,
3983
+ handler: async (req, res) => {
3984
+ if (!guard(req, res, "POST")) return;
3985
+ const config = getConfig();
3986
+ const project = requireProject(res);
3987
+ if (project === void 0) return;
3988
+ const bookName = (await readJsonBody(req))?.bookName?.trim();
3989
+ if (bookName === void 0 || bookName === "") {
3990
+ writeJson(res, 400, { error: "书名不能为空" });
3991
+ return;
3992
+ }
3993
+ project.bookName = bookName.slice(0, 60);
3994
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3995
+ saveProject(config.outputDir, project);
3996
+ const store = loadBookshelf();
3997
+ if (store.activeBookId !== null) renameBook(store.activeBookId, project.bookName);
3998
+ writeJson(res, 200, { bookName: project.bookName });
3999
+ }
4000
+ },
2955
4001
  {
2956
4002
  kind: "exact",
2957
4003
  path: NOVEL_API.config,
@@ -3027,7 +4073,7 @@ const DEFAULT_AUTO_REVIEW = true;
3027
4073
  /** Order of the announcement section within the tool-guidance band. */
3028
4074
  const SECTION_ORDER = 160;
3029
4075
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
3030
- const NOVEL_GUIDANCE = "本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲(默认桌面《归墟玉主》大纲)或粘贴大纲文本;用 LLM 提炼设定圣经(人设/世界观/金手指规则/写作红线);生成卷计划与章节计划;逐章调用 LLM 生成 3000-4000 字正文并保存为 Markdown(默认输出到 桌面\\归墟玉主);每章自动生成摘要(叙事记忆)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑),支持按审稿意见重写、去 AI 味润色、伏笔管理、批量连写与全本导出(txt/md)。限制:生成消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 伏笔 / 润色 / 归墟玉主」时即指本插件,请据此协作。";
4076
+ const NOVEL_GUIDANCE = "本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲(默认桌面《归墟玉主》大纲)或粘贴大纲文本;用 LLM 提炼道藏(人设/世界观/金手指规则/写作红线,即设定圣经);生成卷计划与章节计划;逐章调用 LLM 生成 3000-4000 字正文并保存为 Markdown(默认输出到 桌面\\归墟玉主);每章自动生成摘要(叙事记忆)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑),支持按审稿意见重写、去 AI 味润色、暗线(伏笔)管理、批量连写与全本导出(txt/md)。限制:生成消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色 / 归墟玉主」时即指本插件,请据此协作。";
3031
4077
  /** Resolve a config-like value into the full runtime config. */
3032
4078
  function resolveConfig(value) {
3033
4079
  return {