@waterwx/dsh-novel-forge 0.1.0 → 1.0.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.
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, 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,6 +28,10 @@ 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",
@@ -35,6 +39,16 @@ const NOVEL_API = {
35
39
  assistant: "/api/dsh-novel-forge/assistant",
36
40
  assistantHistory: "/api/dsh-novel-forge/assistant-history",
37
41
  bookshelf: "/api/dsh-novel-forge/bookshelf",
42
+ /** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
43
+ reset: "/api/dsh-novel-forge/reset",
44
+ /** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
45
+ audit: "/api/dsh-novel-forge/audit",
46
+ /** 角色卡刷新:基于事实库与各章摘要聚合角色当前状态。 */
47
+ charactersRefresh: "/api/dsh-novel-forge/characters/refresh",
48
+ /** 事实库回填:对历史已生成章节批量抽取事实(旧章节无事实记录时用)。 */
49
+ factsBackfill: "/api/dsh-novel-forge/facts/backfill",
50
+ /** 设定圣经局部修补(如世界观规则编辑)。 */
51
+ biblePatch: "/api/dsh-novel-forge/bible/patch",
38
52
  config: "/api/dsh-novel-forge/config",
39
53
  openFolder: "/api/dsh-novel-forge/open-folder"
40
54
  };
@@ -738,6 +752,7 @@ function loadProject(outputDir) {
738
752
  if (!Array.isArray(raw.assets.antiAiRules)) raw.assets.antiAiRules = [];
739
753
  if (!Array.isArray(raw.assets.auxiliaryProgressions)) raw.assets.auxiliaryProgressions = [];
740
754
  if (!Array.isArray(raw.assets.styleAssets)) raw.assets.styleAssets = [];
755
+ if (!Array.isArray(raw.facts)) raw.facts = [];
741
756
  return raw;
742
757
  } catch {
743
758
  return;
@@ -752,7 +767,7 @@ function saveProject(outputDir, project) {
752
767
  function listChapterFiles(outputDir) {
753
768
  if (!existsSync(outputDir)) return [];
754
769
  try {
755
- return readdirSync(outputDir).filter((name) => /^第\d+章_.*\.md$/.test(name)).sort((a, b) => {
770
+ return readdirSync(outputDir).filter((name) => /^第\d+章_.*\.md$/.test(name) && !name.endsWith(".bak.md")).sort((a, b) => {
756
771
  return Number(/^第(\d+)章/.exec(a)?.[1] ?? 0) - Number(/^第(\d+)章/.exec(b)?.[1] ?? 0);
757
772
  });
758
773
  } catch {
@@ -792,6 +807,7 @@ function createProject(outline, outlinePath) {
792
807
  chapters: [],
793
808
  foreshadows: [],
794
809
  assets: emptyProjectAssets(),
810
+ facts: [],
795
811
  createdAt: now,
796
812
  updatedAt: now
797
813
  };
@@ -1005,8 +1021,8 @@ function planSystemPrompt(volumes) {
1005
1021
  "2. 章节之间要衔接自然,前章结尾为后章埋下钩子。",
1006
1022
  "3. 严格遵循大纲的人设、金手指规则、战力体系与世界观设定,不得自行发明冲突设定。",
1007
1023
  "4. 输出必须是合法的 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记。",
1008
- "5. 数组每个元素格式:{\"title\": \"章节标题(10字以内,有网文感)\", \"beats\": \"本章剧情要点(150-250字,含起承转合与钩子)\"}",
1009
- "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
1024
+ "5. 数组每个元素格式:{\"title\": \"章节标题(10字以内,有网文感)\", \"beats\": \"结构化剧情要点(150-250字,必须包含四段,段间用换行分隔):\\n本章目标:本章要完成的核心推进;\\n剧情要点:主要情节的起承转合(2-4 句);\\n爽点/钩子:本章的爽点兑现或情绪钩子;\\n结尾钩子:本章结尾为下一章埋下的悬念\"}",
1025
+ "重要:beats 字段内部必须使用 \\n 转义表示换行(JSON 字符串内不得有真实换行符),其余字符串值也不得包含真实换行符,JSON 必须在一段内完整结束。",
1010
1026
  "重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
1011
1027
  volumes !== void 0 && volumes.length > 0 ? ["\n全书分卷结构(规划章节时需落在对应卷内):"].concat(volumes.map((v) => `第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})`)).join("\n") : ""
1012
1028
  ].join("\n");
@@ -1257,19 +1273,14 @@ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo,
1257
1273
  ].filter((part) => part !== "").join("\n\n");
1258
1274
  else newBody = rewritten;
1259
1275
  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;
1276
+ chapter.pendingDraft = newBody;
1265
1277
  chapter.error = void 0;
1266
- chapter.review = void 0;
1267
1278
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1268
1279
  saveProject(outputDir, project);
1269
1280
  yield {
1270
- frame: "done",
1271
- file: fileName,
1272
- chars: newBody.length
1281
+ frame: "drafted",
1282
+ chars: newBody.length,
1283
+ draft: newBody
1273
1284
  };
1274
1285
  }
1275
1286
  /** The de-AI-ify polish system prompt (with project writing assets injected). */
@@ -1286,7 +1297,8 @@ function polishSystemPrompt(project) {
1286
1297
  assetsBlock !== "" ? assetsBlock : ""
1287
1298
  ].join("\n");
1288
1299
  }
1289
- /** Stream a chapter polish (de-AI-ify). */
1300
+ /** Stream a chapter polish (de-AI-ify). Draft-mode: the polished body lands
1301
+ * in `chapter.pendingDraft` and is only applied on draft/apply. */
1290
1302
  async function* polishChapterStream(ctx, config, project, outputDir, chapterNo) {
1291
1303
  const chapter = project.chapters.find((c) => c.no === chapterNo);
1292
1304
  if (chapter === void 0) throw new Error(`章节 ${chapterNo} 不在计划中`);
@@ -1327,16 +1339,13 @@ async function* polishChapterStream(ctx, config, project, outputDir, chapterNo)
1327
1339
  const newBody = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
1328
1340
  if (streamError !== void 0) throw streamError;
1329
1341
  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;
1342
+ chapter.pendingDraft = newBody;
1334
1343
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1335
1344
  saveProject(outputDir, project);
1336
1345
  yield {
1337
- frame: "done",
1338
- file: fileName,
1339
- chars: newBody.length
1346
+ frame: "drafted",
1347
+ chars: newBody.length,
1348
+ draft: newBody
1340
1349
  };
1341
1350
  }
1342
1351
  /** Generate one chapter (streaming). Yields progress frames; persists when done. */
@@ -1350,6 +1359,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
1350
1359
  if (existsSync(prevPath)) continuity = readFileSync(prevPath, "utf8").slice(-900);
1351
1360
  }
1352
1361
  const prevSummary = prev?.summary;
1362
+ const recentFacts = (project.facts ?? []).slice(-20).map((f) => f.text);
1353
1363
  const messages = [createUserMessage({
1354
1364
  content: [{
1355
1365
  type: "text",
@@ -1357,6 +1367,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
1357
1367
  `现在写第 ${chapter.no} 章,标题《${chapter.title}》。`,
1358
1368
  `本章剧情要点:${chapter.beats}`,
1359
1369
  "",
1370
+ recentFacts.length > 0 ? `本书已确立的事实(新写内容不得与之矛盾):\n${recentFacts.join("\n")}` : "",
1360
1371
  prevSummary !== void 0 && prevSummary !== "" ? `上一章摘要:${prevSummary}` : "",
1361
1372
  continuity !== "" ? `上一章结尾(用于衔接,不要复述):\n${continuity}` : "这是第一章,注意开篇要有吸引力。",
1362
1373
  "",
@@ -1427,6 +1438,184 @@ async function summarizeChapter(ctx, config, project, outputDir, chapterNo) {
1427
1438
  saveProject(outputDir, project);
1428
1439
  return chapter.summary;
1429
1440
  }
1441
+ /**
1442
+ * 抽取本章「已确立事实」追加到事实库/时间线(最多 300 条,最新优先)。
1443
+ * 事实注入后续章节生成提示词,保证人物状态/境界/资源/关系长期一致。
1444
+ * @returns 新增事实条数(失败返回 0,调用方 best-effort)。
1445
+ */
1446
+ async function extractFacts(ctx, config, project, outputDir, chapterNo) {
1447
+ const chapter = project.chapters.find((c) => c.no === chapterNo);
1448
+ if (chapter === void 0) return 0;
1449
+ const body = readChapterFile(outputDir, chapter);
1450
+ if (body === void 0) return 0;
1451
+ const lines = (await complete(ctx, config, {
1452
+ system: [
1453
+ "你是一位网文编辑。请从本章正文中抽取「已确立事实」,供后续章节保持一致。",
1454
+ "事实指:人物当前状态(境界/修为/伤势/资源/心境)、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。",
1455
+ "要求:",
1456
+ "1. 只抽取本章明确写出的、对后续有约束力的内容;纯心理活动与无关细节不要。",
1457
+ "2. 每行一条事实,用客观陈述句,不含主观评价。",
1458
+ "3. 输出 3-6 条,每行一条,不要编号、不要前缀、不要解释。"
1459
+ ].join("\n"),
1460
+ user: body.replace(/^#\s+.*$/m, "").trim(),
1461
+ temperature: .2,
1462
+ maxTokens: Math.max(config.maxTokens, 4e3)
1463
+ })).split("\n").map((line) => line.replace(/^[-*\d.\s]+/, "").trim()).filter((line) => line.length > 8).slice(0, 8);
1464
+ if (lines.length === 0) return 0;
1465
+ const facts = project.facts ?? [];
1466
+ for (const line of lines) facts.push({
1467
+ chapterNo,
1468
+ text: line.slice(0, 140)
1469
+ });
1470
+ project.facts = facts.slice(-300);
1471
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1472
+ saveProject(outputDir, project);
1473
+ return lines.length;
1474
+ }
1475
+ /** 全书一致性质检:LLM 扫描已生成章节 + 设定 + 事实库,输出矛盾清单。 */
1476
+ async function auditBook(ctx, config, project, outputDir) {
1477
+ const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
1478
+ if (written.length === 0) return [];
1479
+ const system = [
1480
+ "你是一位严谨的网文连续性审校编辑。你会收到一本小说的设定圣经、事实库和各章正文节选。",
1481
+ "请找出全书的一致性矛盾,例如:",
1482
+ "- 人物状态冲突:境界/修为/伤势/资源在同一章内或跨章前后矛盾。",
1483
+ "- 设定违背:正文与世界观规则、金手指规则、写作红线冲突。",
1484
+ "- 时间线错乱:事件顺序、时间跨度、地点移动不合逻辑。",
1485
+ "- 细节穿帮:人名/地名/物品/数字前后不一致。",
1486
+ "要求:",
1487
+ "1. 只报告有实质证据的矛盾,不要泛泛而谈写作质量问题。",
1488
+ "2. 每条必须定位到具体章节号。",
1489
+ "3. 输出必须是合法 JSON 数组,格式:[{\"chapterNo\": 章节号, \"severity\": \"high|medium|low\", \"item\": \"矛盾描述\", \"suggestion\": \"修改建议\"}]",
1490
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
1491
+ ].join("\n");
1492
+ const factsBlock = (project.facts ?? []).slice(-80).map((f) => `[第${f.chapterNo}章] ${f.text}`).join("\n");
1493
+ const chapterBlocks = written.map((c) => {
1494
+ const excerpt = (readChapterFile(outputDir, c) ?? "").replace(/^#\s+.*$/m, "").trim().slice(0, 700);
1495
+ return `【第${c.no}章《${c.title}》】\n${excerpt}`;
1496
+ }).join("\n\n");
1497
+ const parsed = parseJsonArray(await complete(ctx, config, {
1498
+ system,
1499
+ user: [
1500
+ "请对以下小说做全书一致性质检。",
1501
+ project.bible !== void 0 ? "设定圣经:\n" + [
1502
+ project.bible.worldRules.length > 0 ? `世界规则:\n${project.bible.worldRules.map((r) => `- ${r}`).join("\n")}` : "",
1503
+ project.bible.redLines.length > 0 ? `写作红线:\n${project.bible.redLines.map((r) => `- ${r}`).join("\n")}` : "",
1504
+ project.bible.characters.length > 0 ? `角色:\n${project.bible.characters.map((ch) => `- ${ch.name}(${ch.traits.join("、")})`).join("\n")}` : ""
1505
+ ].filter((s) => s !== "").join("\n") : "",
1506
+ factsBlock !== "" ? `已确立事实库:\n${factsBlock}` : "",
1507
+ `正文节选(每章前 700 字):\n${chapterBlocks}`,
1508
+ "只输出 JSON 数组。"
1509
+ ].filter((s) => s !== "").join("\n\n"),
1510
+ temperature: .2,
1511
+ maxTokens: Math.max(config.maxTokens, 12e3)
1512
+ }));
1513
+ const issues = [];
1514
+ for (const entry of parsed) {
1515
+ const item = typeof entry.item === "string" ? entry.item : "";
1516
+ if (item === "") continue;
1517
+ issues.push({
1518
+ chapterNo: Number(entry.chapterNo) || 0,
1519
+ severity: [
1520
+ "high",
1521
+ "medium",
1522
+ "low"
1523
+ ].includes(entry.severity) ? entry.severity : "medium",
1524
+ item,
1525
+ suggestion: typeof entry.suggestion === "string" ? entry.suggestion : ""
1526
+ });
1527
+ }
1528
+ return issues.slice(0, 30);
1529
+ }
1530
+ /**
1531
+ * 事实库回填:对历史已生成章节批量抽取事实(无事实记录的旧章节)。
1532
+ * @returns 回填的章节数。
1533
+ */
1534
+ async function backfillFacts(ctx, config, project, outputDir) {
1535
+ const have = new Set((project.facts ?? []).map((f) => f.chapterNo));
1536
+ let filled = 0;
1537
+ for (const chapter of project.chapters) {
1538
+ if (chapter.status === "pending" || chapter.status === "generating") continue;
1539
+ if (chapter.file === void 0 || have.has(chapter.no)) continue;
1540
+ try {
1541
+ if (await extractFacts(ctx, config, project, outputDir, chapter.no) > 0) filled++;
1542
+ } catch {}
1543
+ have.add(chapter.no);
1544
+ }
1545
+ return filled;
1546
+ }
1547
+ /**
1548
+ * 角色卡刷新:出场统计由服务端从正文精确计算(角色名出现过的章节数、
1549
+ * 最近出现章节),LLM 只负责聚合「当前状态」一句话。
1550
+ */
1551
+ async function refreshCharacters(ctx, config, project, outputDir) {
1552
+ const roster = project.bible?.characters ?? [];
1553
+ const facts = project.facts ?? [];
1554
+ if (roster.length === 0 && facts.length === 0) return [];
1555
+ const stat = /* @__PURE__ */ new Map();
1556
+ const known = roster.map((card) => card.name);
1557
+ for (const chapter of project.chapters) {
1558
+ if (chapter.status === "pending" || chapter.status === "generating") continue;
1559
+ const body = readChapterFile(outputDir, chapter);
1560
+ if (body === void 0) continue;
1561
+ for (const name of known) if (body.includes(name)) {
1562
+ const entry = stat.get(name) ?? {
1563
+ chapters: /* @__PURE__ */ new Set(),
1564
+ last: 0
1565
+ };
1566
+ entry.chapters.add(chapter.no);
1567
+ if (chapter.no > entry.last) entry.last = chapter.no;
1568
+ stat.set(name, entry);
1569
+ }
1570
+ }
1571
+ let statuses = /* @__PURE__ */ new Map();
1572
+ if (facts.length > 0) {
1573
+ const system = [
1574
+ "你是一位网文角色档案管理员。请根据「角色名单」与「已确立事实库」,为每个角色输出「当前状态」一句话(境界/修为/伤势/资源/心境)。",
1575
+ "输出必须是合法 JSON 数组,格式:[{\"name\": \"角色名\", \"status\": \"当前状态一句话\"}]",
1576
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
1577
+ ].join("\n");
1578
+ const rosterBlock = roster.map((ch) => `- ${ch.name}(${ch.traits.join("、")})`).join("\n");
1579
+ const factsBlock = facts.map((f) => `[第${f.chapterNo}章] ${f.text}`).join("\n");
1580
+ const user = [
1581
+ `角色名单:\n${rosterBlock}`,
1582
+ `已确立事实库(${facts.length} 条):\n${factsBlock.slice(-6e3)}`,
1583
+ "只输出 JSON 数组。"
1584
+ ].join("\n\n");
1585
+ try {
1586
+ const text = await complete(ctx, config, {
1587
+ system,
1588
+ user,
1589
+ temperature: .2,
1590
+ maxTokens: Math.max(config.maxTokens, 8e3)
1591
+ });
1592
+ for (const entry of parseJsonArray(text)) {
1593
+ const name = typeof entry.name === "string" ? entry.name : "";
1594
+ if (name !== "" && typeof entry.status === "string") statuses.set(name, entry.status);
1595
+ }
1596
+ } catch {}
1597
+ }
1598
+ const cards = [];
1599
+ const roleOf = (name) => roster.find((c) => c.name === name)?.role ?? "other";
1600
+ for (const card of roster) {
1601
+ const entry = stat.get(card.name);
1602
+ cards.push({
1603
+ name: card.name,
1604
+ role: card.role,
1605
+ status: statuses.get(card.name) ?? "",
1606
+ lastChapter: entry?.last ?? 0,
1607
+ appearances: entry?.chapters.size ?? 0
1608
+ });
1609
+ }
1610
+ for (const [name, entry] of stat) if (!cards.some((c) => c.name === name)) cards.push({
1611
+ name,
1612
+ role: roleOf(name),
1613
+ status: statuses.get(name) ?? "",
1614
+ lastChapter: entry.last,
1615
+ appearances: entry.chapters.size
1616
+ });
1617
+ return cards;
1618
+ }
1430
1619
  /** System prompt for foreshadow suggestions. */
1431
1620
  function foreshadowSystemPrompt() {
1432
1621
  return [
@@ -1707,7 +1896,21 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
1707
1896
  const instructions = str(args.instructions);
1708
1897
  const target = str(args.target);
1709
1898
  for await (const chunk of forward(rewriteChapterStream(ctx, config, project, outputDir, no, instructions, target === "" ? void 0 : target))) yield chunk;
1710
- yield "(正在生成章节摘要…)";
1899
+ const chapter = project.chapters.find((c) => c.no === no);
1900
+ const draft = chapter?.pendingDraft;
1901
+ if (chapter === void 0 || draft === void 0 || draft === "") throw new Error(`章节 ${no} 修订后没有产出草稿`);
1902
+ const fileName = chapterFileName(chapter);
1903
+ mkdirSync(outputDir, { recursive: true });
1904
+ writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, "utf8");
1905
+ chapter.pendingDraft = void 0;
1906
+ chapter.status = "written";
1907
+ chapter.chars = draft.length;
1908
+ chapter.file = fileName;
1909
+ chapter.review = void 0;
1910
+ chapter.error = void 0;
1911
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1912
+ saveProject(outputDir, project);
1913
+ yield "(已采纳修订稿,正在生成章节摘要…)";
1711
1914
  try {
1712
1915
  await summarizeChapter(ctx, config, project, outputDir, no);
1713
1916
  } catch {}
@@ -1723,6 +1926,9 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
1723
1926
  try {
1724
1927
  await summarizeChapter(ctx, config, project, outputDir, no);
1725
1928
  } catch {}
1929
+ try {
1930
+ await extractFacts(ctx, config, project, outputDir, no);
1931
+ } catch {}
1726
1932
  yield "(正在 AI 审稿…)";
1727
1933
  const report = await reviewChapter(ctx, config, project, outputDir, no);
1728
1934
  return `章节 ${no} 已生成(${project.chapters.find((c) => c.no === no)?.chars ?? "?"} 字)。审稿:${report.score} 分 — ${report.verdict}`;
@@ -2122,6 +2328,16 @@ function createBook(bookName, outputDir) {
2122
2328
  saveBookshelf(store);
2123
2329
  return book;
2124
2330
  }
2331
+ /** 更新某本书的书名(开书向导导入大纲后书名以大纲首行为准)。 */
2332
+ function renameBook(id, bookName) {
2333
+ const store = loadBookshelf();
2334
+ const book = store.books.find((b) => b.id === id);
2335
+ if (book === void 0) return false;
2336
+ book.bookName = bookName;
2337
+ book.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2338
+ saveBookshelf(store);
2339
+ return true;
2340
+ }
2125
2341
  /**
2126
2342
  * 播种:书架为空时,把指定输出目录下已有的项目自动登记为第一本书。
2127
2343
  * 兼容升级场景 —— 旧版插件直接在输出目录写项目,从未登记书架。
@@ -2470,6 +2686,11 @@ function makeRoutes(deps) {
2470
2686
  } catch (error) {
2471
2687
  console.warn("[dsh-novel-forge] summary failed:", error.message);
2472
2688
  }
2689
+ try {
2690
+ await extractFacts(ctx, config, project, config.outputDir, no);
2691
+ } catch (error) {
2692
+ console.warn("[dsh-novel-forge] facts extraction failed:", error.message);
2693
+ }
2473
2694
  if (!(body?.skipReview === true) && (config.autoReview ?? true)) send({
2474
2695
  type: "review",
2475
2696
  no,
@@ -2544,16 +2765,11 @@ function makeRoutes(deps) {
2544
2765
  type: "delta",
2545
2766
  text: step.text
2546
2767
  });
2547
- else if (step.frame === "done") send({
2548
- type: "rewritten",
2549
- no,
2550
- file: step.file,
2551
- chars: step.chars
2552
- });
2553
- send({
2554
- type: "review",
2768
+ else if (step.frame === "drafted") send({
2769
+ type: "drafted",
2555
2770
  no,
2556
- report: await reviewChapter(ctx, config, project, config.outputDir, no)
2771
+ chars: step.chars,
2772
+ draft: step.draft
2557
2773
  });
2558
2774
  res.end();
2559
2775
  } catch (error) {
@@ -2596,11 +2812,11 @@ function makeRoutes(deps) {
2596
2812
  type: "delta",
2597
2813
  text: step.text
2598
2814
  });
2599
- else if (step.frame === "done") send({
2600
- type: "rewritten",
2815
+ else if (step.frame === "drafted") send({
2816
+ type: "drafted",
2601
2817
  no,
2602
- file: step.file,
2603
- chars: step.chars
2818
+ chars: step.chars,
2819
+ draft: step.draft
2604
2820
  });
2605
2821
  res.end();
2606
2822
  } catch (error) {
@@ -2615,6 +2831,77 @@ function makeRoutes(deps) {
2615
2831
  }
2616
2832
  }
2617
2833
  };
2834
+ /** 采纳待确认草稿:覆盖正文文件 + 状态回 written + 清空草稿。 */
2835
+ const draftApplyRoute = {
2836
+ kind: "exact",
2837
+ path: NOVEL_API.draftApply,
2838
+ handler: async (req, res) => {
2839
+ if (!guard(req, res, "POST")) return;
2840
+ const config = getConfig();
2841
+ const project = requireProject(res);
2842
+ if (project === void 0) return;
2843
+ const body = await readJsonBody(req);
2844
+ if (!Number.isInteger(body?.chapterNo)) {
2845
+ writeJson(res, 400, { error: "chapterNo 须为正整数" });
2846
+ return;
2847
+ }
2848
+ const chapter = project.chapters.find((c) => c.no === body.chapterNo);
2849
+ if (chapter === void 0) {
2850
+ writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` });
2851
+ return;
2852
+ }
2853
+ if (chapter.pendingDraft === void 0 || chapter.pendingDraft === "") {
2854
+ writeJson(res, 400, { error: `章节 ${chapter.no} 没有待确认的草稿` });
2855
+ return;
2856
+ }
2857
+ const draft = chapter.pendingDraft;
2858
+ const fileName = chapterFileName(chapter);
2859
+ mkdirSync(config.outputDir, { recursive: true });
2860
+ const targetPath = join(config.outputDir, fileName);
2861
+ if (existsSync(targetPath)) copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, "")}.bak.md`));
2862
+ writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, "utf8");
2863
+ chapter.pendingDraft = void 0;
2864
+ chapter.status = "written";
2865
+ chapter.chars = draft.length;
2866
+ chapter.file = fileName;
2867
+ chapter.review = void 0;
2868
+ chapter.error = void 0;
2869
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2870
+ saveProject(config.outputDir, project);
2871
+ writeJson(res, 200, {
2872
+ ok: true,
2873
+ chars: draft.length,
2874
+ file: fileName
2875
+ });
2876
+ }
2877
+ };
2878
+ /** 放弃待确认草稿:保留原稿,仅清空草稿字段。 */
2879
+ const draftDiscardRoute = {
2880
+ kind: "exact",
2881
+ path: NOVEL_API.draftDiscard,
2882
+ handler: async (req, res) => {
2883
+ if (!guard(req, res, "POST")) return;
2884
+ const config = getConfig();
2885
+ const project = requireProject(res);
2886
+ if (project === void 0) return;
2887
+ const body = await readJsonBody(req);
2888
+ if (!Number.isInteger(body?.chapterNo)) {
2889
+ writeJson(res, 400, { error: "chapterNo 须为正整数" });
2890
+ return;
2891
+ }
2892
+ const chapter = project.chapters.find((c) => c.no === body.chapterNo);
2893
+ if (chapter === void 0) {
2894
+ writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` });
2895
+ return;
2896
+ }
2897
+ if (chapter.pendingDraft !== void 0) {
2898
+ chapter.pendingDraft = void 0;
2899
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2900
+ saveProject(config.outputDir, project);
2901
+ }
2902
+ writeJson(res, 200, { ok: true });
2903
+ }
2904
+ };
2618
2905
  const summaryRoute = {
2619
2906
  kind: "exact",
2620
2907
  path: NOVEL_API.summary,
@@ -2802,6 +3089,8 @@ function makeRoutes(deps) {
2802
3089
  reviewRoute,
2803
3090
  rewriteRoute,
2804
3091
  polishRoute,
3092
+ draftApplyRoute,
3093
+ draftDiscardRoute,
2805
3094
  summaryRoute,
2806
3095
  foreshadowRoute,
2807
3096
  exportRoute,
@@ -2911,7 +3200,14 @@ function makeRoutes(deps) {
2911
3200
  writeJson(res, 400, { error: "bookName 不能为空" });
2912
3201
  return;
2913
3202
  }
2914
- createBook(bookName, body?.outputDir?.trim() !== "" && body?.outputDir !== void 0 ? body.outputDir : defaultOutputDirFor(bookName));
3203
+ const outputDir = body?.outputDir?.trim() !== "" && body?.outputDir !== void 0 ? body.outputDir : defaultOutputDirFor(bookName);
3204
+ const book = createBook(bookName, outputDir);
3205
+ const outline = body?.outline?.trim();
3206
+ if (outline !== void 0 && outline.length >= 50) {
3207
+ const project = createProject(outline);
3208
+ saveProject(outputDir, project);
3209
+ renameBook(book.id, project.bookName);
3210
+ }
2915
3211
  writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
2916
3212
  return;
2917
3213
  }
@@ -2952,6 +3248,109 @@ function makeRoutes(deps) {
2952
3248
  writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
2953
3249
  }
2954
3250
  },
3251
+ {
3252
+ kind: "exact",
3253
+ path: NOVEL_API.reset,
3254
+ handler: async (req, res) => {
3255
+ if (!guard(req, res, "POST")) return;
3256
+ const config = getConfig();
3257
+ const project = loadProject(config.outputDir);
3258
+ if (project === void 0) {
3259
+ writeJson(res, 400, { error: "输出目录中没有项目,无需重置" });
3260
+ return;
3261
+ }
3262
+ const outline = (await readJsonBody(req))?.outline?.trim();
3263
+ if (outline !== void 0 && outline.length >= 50) {
3264
+ project.outline = outline;
3265
+ project.bookName = createProject(outline).bookName;
3266
+ }
3267
+ project.bible = void 0;
3268
+ project.volumes = void 0;
3269
+ project.chapters = [];
3270
+ project.foreshadows = [];
3271
+ project.assets = emptyProjectAssets();
3272
+ project.facts = [];
3273
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3274
+ saveProject(config.outputDir, project);
3275
+ writeJson(res, 200, {
3276
+ ok: true,
3277
+ bookName: project.bookName
3278
+ });
3279
+ }
3280
+ },
3281
+ {
3282
+ kind: "exact",
3283
+ path: NOVEL_API.audit,
3284
+ handler: async (req, res) => {
3285
+ if (!guard(req, res, "POST")) return;
3286
+ const config = getConfig();
3287
+ const project = requireProject(res);
3288
+ if (project === void 0) return;
3289
+ try {
3290
+ writeJson(res, 200, {
3291
+ issues: await auditBook(ctx, config, project, config.outputDir),
3292
+ auditedChapters: project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating").length,
3293
+ auditedAt: (/* @__PURE__ */ new Date()).toISOString()
3294
+ });
3295
+ } catch (error) {
3296
+ writeJson(res, 500, { error: error.message });
3297
+ }
3298
+ }
3299
+ },
3300
+ {
3301
+ kind: "exact",
3302
+ path: NOVEL_API.charactersRefresh,
3303
+ handler: async (req, res) => {
3304
+ if (!guard(req, res, "POST")) return;
3305
+ const config = getConfig();
3306
+ const project = requireProject(res);
3307
+ if (project === void 0) return;
3308
+ try {
3309
+ writeJson(res, 200, { cards: await refreshCharacters(ctx, config, project, config.outputDir) });
3310
+ } catch (error) {
3311
+ writeJson(res, 500, { error: error.message });
3312
+ }
3313
+ }
3314
+ },
3315
+ {
3316
+ kind: "exact",
3317
+ path: NOVEL_API.factsBackfill,
3318
+ handler: async (req, res) => {
3319
+ if (!guard(req, res, "POST")) return;
3320
+ const config = getConfig();
3321
+ const project = requireProject(res);
3322
+ if (project === void 0) return;
3323
+ try {
3324
+ writeJson(res, 200, {
3325
+ ok: true,
3326
+ filled: await backfillFacts(ctx, config, project, config.outputDir)
3327
+ });
3328
+ } catch (error) {
3329
+ writeJson(res, 500, { error: error.message });
3330
+ }
3331
+ }
3332
+ },
3333
+ {
3334
+ kind: "exact",
3335
+ path: NOVEL_API.biblePatch,
3336
+ handler: async (req, res) => {
3337
+ if (!guard(req, res, "POST")) return;
3338
+ const config = getConfig();
3339
+ const project = requireProject(res);
3340
+ if (project === void 0) return;
3341
+ if (project.bible === void 0) {
3342
+ writeJson(res, 400, { error: "尚未生成设定圣经,请先生成" });
3343
+ return;
3344
+ }
3345
+ const body = await readJsonBody(req);
3346
+ if (Array.isArray(body?.worldRules)) project.bible.worldRules = body.worldRules.filter((r) => r.trim() !== "");
3347
+ if (Array.isArray(body?.redLines)) project.bible.redLines = body.redLines.filter((r) => r.trim() !== "");
3348
+ if (Array.isArray(body?.style)) project.bible.style = body.style.filter((r) => r.trim() !== "");
3349
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3350
+ saveProject(config.outputDir, project);
3351
+ writeJson(res, 200, { bible: project.bible });
3352
+ }
3353
+ },
2955
3354
  {
2956
3355
  kind: "exact",
2957
3356
  path: NOVEL_API.config,