@waterwx/dsh-novel-forge 1.1.7 → 1.1.9

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
@@ -3,139 +3,10 @@ import z from "schemastery";
3
3
  import { exec } from "node:child_process";
4
4
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
- import { strFromU8, unzipSync } from "fflate";
7
6
  import { BlockAssembler, ReasoningEffortId, createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
8
7
  import { homedir } from "node:os";
9
8
  import { randomBytes } from "node:crypto";
10
- //#region src/protocol.ts
11
- /**
12
- * dsh-novel-forge — shared protocol between the host half (Node) and the
13
- * browser half (web GUI). Route paths, request/response shapes, the project
14
- * state file format, and the NDJSON generation stream frames all live here so
15
- * both halves spell exactly one vocabulary.
16
- */
17
- /** The /api/dsh-novel-forge route family (same-origin, loopback-fenced). */
18
- const NOVEL_API = {
19
- status: "/api/dsh-novel-forge/status",
20
- loadOutline: "/api/dsh-novel-forge/load-outline",
21
- saveOutline: "/api/dsh-novel-forge/save-outline",
22
- plan: "/api/dsh-novel-forge/plan",
23
- volumes: "/api/dsh-novel-forge/volumes",
24
- bible: "/api/dsh-novel-forge/bible",
25
- assets: "/api/dsh-novel-forge/assets",
26
- styleEngine: "/api/dsh-novel-forge/style-engine",
27
- generate: "/api/dsh-novel-forge/generate",
28
- review: "/api/dsh-novel-forge/review",
29
- rewrite: "/api/dsh-novel-forge/rewrite",
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",
35
- summary: "/api/dsh-novel-forge/summary",
36
- foreshadow: "/api/dsh-novel-forge/foreshadow",
37
- exportBook: "/api/dsh-novel-forge/export",
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",
43
- assistant: "/api/dsh-novel-forge/assistant",
44
- assistantHistory: "/api/dsh-novel-forge/assistant-history",
45
- /** 清空助手对话记录。 */
46
- assistantClear: "/api/dsh-novel-forge/assistant/clear",
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",
66
- /** 剧情线管理:增删改 + 关联章节。 */
67
- plotlines: "/api/dsh-novel-forge/plotlines",
68
- /** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
69
- roles: "/api/dsh-novel-forge/roles",
70
- /** 作者复盘补跑:对已写章节补齐 authorReview(全书流式 / 单章 JSON)。 */
71
- reviewBackfill: "/api/dsh-novel-forge/review/backfill",
72
- /** 章节复位:generating 卡死 → pending(可重新生成)。 */
73
- chapterReset: "/api/dsh-novel-forge/chapter/reset",
74
- /** 章节直接通过:作者对 rejected/written 章节行使最终决定权。 */
75
- chapterApprove: "/api/dsh-novel-forge/chapter/approve",
76
- /** 敏感词检查:全书已写章节或指定文本。 */
77
- sensitiveCheck: "/api/dsh-novel-forge/sensitive-check",
78
- config: "/api/dsh-novel-forge/config",
79
- openFolder: "/api/dsh-novel-forge/open-folder"
80
- };
81
- //#endregion
82
- //#region src/docx.ts
83
- /**
84
- * docx outline extraction: a .docx is a zip whose word/document.xml holds the
85
- * body text in <w:t> runs inside <w:p> paragraphs. We unzip with fflate and
86
- * walk the XML with a tiny tokenizer — no heavyweight XML/DOM dependency.
87
- */
88
- /** Decode the handful of XML entities docx bodies actually use. */
89
- function decodeEntities(text) {
90
- return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&apos;/g, "'").replace(/&amp;/g, "&").replace(/&nbsp;/g, " ");
91
- }
92
- /**
93
- * Extract plain text from a docx buffer: one line per <w:p> paragraph, with
94
- * <w:tab>/<w:br> preserved as whitespace. Tables and nested structures are
95
- * flattened in document order (their paragraphs are just <w:p> too).
96
- * @param buffer - the raw .docx bytes.
97
- * @returns the body text.
98
- */
99
- function extractDocxText(buffer) {
100
- let files;
101
- try {
102
- files = unzipSync(buffer);
103
- } catch (error) {
104
- throw new Error(`not a valid docx (zip open failed): ${error.message}`);
105
- }
106
- const document = files["word/document.xml"];
107
- if (document === void 0) throw new Error("not a valid docx (word/document.xml missing)");
108
- const xml = strFromU8(document);
109
- const paragraphs = [];
110
- const parts = xml.split(/<w:p\b[^>]*>/);
111
- for (let i = 1; i < parts.length; i++) {
112
- const segment = parts[i];
113
- const runs = [];
114
- for (const match of segment.matchAll(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>|<w:tab\b[^>]*\/>|<w:br\b[^>]*\/>/g)) if (match[0].startsWith("<w:tab")) runs.push(" ");
115
- else if (match[0].startsWith("<w:br")) runs.push("\n");
116
- else runs.push(decodeEntities(match[1] ?? ""));
117
- const line = runs.join("").replace(/\u00a0/g, " ").trimEnd();
118
- paragraphs.push(line);
119
- }
120
- const text = paragraphs.join("\n").replace(/\n{3,}/g, "\n\n").trim();
121
- if (text.length === 0) throw new Error("docx contains no extractable text");
122
- return text;
123
- }
124
- /**
125
- * Read and extract a docx outline from disk.
126
- * @param path - absolute path to the .docx file.
127
- * @returns the extracted outline text.
128
- */
129
- function readOutlineFromDocx(path) {
130
- let buffer;
131
- try {
132
- buffer = readFileSync(path);
133
- } catch (error) {
134
- throw new Error(`cannot read outline file "${path}": ${error.message}`);
135
- }
136
- return extractDocxText(new Uint8Array(buffer));
137
- }
138
- //#endregion
9
+ import { strFromU8, unzipSync } from "fflate";
139
10
  //#region src/assets.ts
140
11
  /** 预置写法模板(来自 AI-Novel-Writing-Assistant 内置 DEFAULT_STYLE_TEMPLATES)。 */
141
12
  const BUILTIN_STYLE_TEMPLATES = [
@@ -751,6 +622,22 @@ function styleEngineSystemPrompt() {
751
622
  * tracking, project persistence, and whole-book export. Pure Node (no
752
623
  * web-server dependencies), so routes stay thin and logic is testable.
753
624
  */
625
+ /**
626
+ * 内容合规红线(平台硬性要求):所有书籍、所有章节无条件生效,
627
+ * 优先级高于单书大纲/圣经中的任何设定与作者自定义红线。
628
+ * 注入点:章节生成系统提示 + 审稿系统提示(命中即 high)。
629
+ */
630
+ const COMPLIANCE_REDLINES = [
631
+ "1. 不得出现反对宪法所确定的基本原则的内容。",
632
+ "2. 不得出现危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的内容。",
633
+ "3. 不得出现危害国家荣誉和利益的内容。",
634
+ "4. 不得出现煽动民族仇恨、民族歧视、破坏民族团结的内容。",
635
+ "5. 不得出现破坏国家宗教政策、宣扬邪教和愚昧迷信的内容(不得以真实宗教、邪教或迷信活动为背景进行宣扬)。",
636
+ "6. 不得出现散布谣言、扰乱社会秩序、破坏社会稳定的内容。",
637
+ "7. 不得出现淫秽色情、赌博、暴力、凶杀、恐怖或教唆犯罪的内容(网文语境:禁止露骨性描写、血腥暴力渲染、赌博教唆、犯罪手法详细教学)。",
638
+ "8. 不得出现侮辱或者诽谤他人、侵害他人合法权益的内容(不得以真实人物、组织为原型进行侮辱或影射攻击)。",
639
+ "9. 不得出现法律法规禁止的其他内容。"
640
+ ];
754
641
  /** Project state file name inside the output dir. */
755
642
  const PROJECT_FILE = "novel-project.json";
756
643
  /** Sanitize a file name: keep CJK/alphanumerics/space/dash/underscore. */
@@ -1501,6 +1388,14 @@ function writeSystemPrompt(project) {
1501
1388
  sections.push("3. 尊重大纲与设定圣经:人设不崩、金手指规则不自相矛盾、战力不随意膨胀。");
1502
1389
  sections.push("4. 章末留一个钩子(悬念、反转或新线索),吸引读者读下一章。");
1503
1390
  sections.push("5. 语言流畅自然,符合中文网文语感,避免翻译腔与病句。");
1391
+ sections.push("6. 对话与冲突密度:每章至少 1 处实质对话或正面对抗/交锋场面;推理与心理活动必须用动作、环境细节、微表情、对话呈现,禁止整章纯内心独白铺陈(禁止\"解说式\"交代线索)。");
1392
+ sections.push("7. 反派与对手的行动力:本章出现的反派/对手必须有其行动、反制或压迫感(布局、试探、追索、交锋至少占其一),不得作为纯背景板存在。");
1393
+ sections.push("8. 配角辨识度:重要新登场配角应给姓名或可辨识的独有特征;禁止通篇用\"瘦高个/灰衣人/戴面具者\"等身形标签代称同一角色。");
1394
+ sections.push("9. 信息呈现方式:关键线索、设定、局势通过对话、动作、发现物呈现,禁止主角内心\"讲解\"给读者听。");
1395
+ sections.push("");
1396
+ sections.push("==================== 内容合规红线(平台硬性要求,最高优先级,违反即失败) ====================");
1397
+ sections.push(COMPLIANCE_REDLINES.join("\n"));
1398
+ sections.push("以上九条为硬性底线,任何情况下不得以任何形式出现或影射;若剧情确需涉及(如批判、反讽),只能以明确否定、揭露、批判的立场呈现,且不得展开细节。");
1504
1399
  return sections.join("\n");
1505
1400
  }
1506
1401
  /**
@@ -1584,6 +1479,8 @@ function reviewSystemPrompt(project) {
1584
1479
  "5. 节奏与爽点:本章是否有推进、有钩子,是否拖沓灌水。",
1585
1480
  "6. 逻辑漏洞:前后矛盾、时间线错误、对话失真。",
1586
1481
  "7. 反 AI 规则:逐条核对下方「反 AI 规则」清单,命中即列为问题。",
1482
+ "8. 呈现方式:整章是否纯内心推理铺陈(无对话/无对抗,推理全靠解说);反派是否纯背景板无行动;重要配角是否无名标签化(瘦高个/灰衣人全程代称)——命中即列为问题。",
1483
+ "9. 内容合规(最高优先级):逐条核对下方「内容合规红线」,任何一条命中(含影射、暗示、详细描写)必须列为 high,并给出改写建议。",
1587
1484
  "输出必须是合法 JSON 对象,不要输出任何其他文字:",
1588
1485
  "{\"score\": 0-100的整数, \"verdict\": \"一句话总评\", \"issues\": [{\"severity\": \"high|medium|low\", \"item\": \"问题描述\", \"suggestion\": \"修改建议\"}]}",
1589
1486
  "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
@@ -1603,6 +1500,9 @@ function reviewSystemPrompt(project) {
1603
1500
  }
1604
1501
  if (bible.redLines.length > 0) sections.push("红线:\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
1605
1502
  }
1503
+ sections.push("\n==================== 内容合规红线(平台硬性要求,最高优先级) ====================");
1504
+ sections.push(COMPLIANCE_REDLINES.join("\n"));
1505
+ sections.push("以上九条为硬性底线:正文中任何一条命中(含影射、暗示、详细展开)都必须列为 high,并给出改写建议;作者自定义红线不得豁免这九条。");
1606
1506
  return sections.join("\n");
1607
1507
  }
1608
1508
  /** Run the AI review on one chapter. */
@@ -1650,17 +1550,18 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
1650
1550
  * 审查「任意正文文本」(作者手动编辑后的草稿,不落盘)。
1651
1551
  * 复用审稿提示词与红线/道藏/反AI规则;仅返回报告,不改文件不改状态。
1652
1552
  */
1653
- async function reviewChapterText(ctx, config, project, text) {
1553
+ async function reviewChapterText(ctx, config, project, text, previousReport) {
1654
1554
  const user = [
1655
1555
  `书名:《${project.bookName}》`,
1656
- "==================== 待审查正文 ====================",
1556
+ previousReport !== void 0 ? "==================== 上一轮审稿意见(逐条核对是否已解决) ====================\n" + previousReport.issues.map((it, i) => `${i + 1}. [${it.severity}] ${it.item}${it.suggestion !== "" ? ` → ${it.suggestion}` : ""}`).join("\n") : "",
1557
+ previousReport !== void 0 ? "==================== 修订稿(上一轮审稿后按意见修订的正文) ====================" : "==================== 待审查正文 ====================",
1657
1558
  text.slice(0, 2e4)
1658
1559
  ].join("\n");
1659
1560
  const raw = parseJsonObject(await complete(ctx, config, {
1660
- system: reviewSystemPrompt(project),
1561
+ system: previousReport !== void 0 ? verifySystemPrompt(project) : reviewSystemPrompt(project),
1661
1562
  user,
1662
1563
  temperature: .3,
1663
- maxTokens: Math.max(config.maxTokens, 8e3)
1564
+ maxTokens: Math.max(config.maxTokens, 16e3)
1664
1565
  }));
1665
1566
  const issues = Array.isArray(raw.issues) ? raw.issues.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
1666
1567
  severity: [
@@ -1672,14 +1573,37 @@ async function reviewChapterText(ctx, config, project, text) {
1672
1573
  suggestion: typeof entry.suggestion === "string" ? entry.suggestion : ""
1673
1574
  })).filter((issue) => issue.item !== "") : [];
1674
1575
  const score = typeof raw.score === "number" ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60;
1576
+ let passed = score >= config.reviewPassScore;
1577
+ if (previousReport !== void 0) {
1578
+ const hasHigh = issues.some((i) => i.severity === "high");
1579
+ const prevHighResolved = previousReport.issues.filter((i) => i.severity === "high").every((p) => !issues.some((i) => i.item.includes(p.item.slice(0, 12))));
1580
+ passed = !hasHigh && prevHighResolved;
1581
+ }
1675
1582
  return {
1676
1583
  score,
1677
- passed: score >= config.reviewPassScore,
1584
+ passed,
1678
1585
  verdict: typeof raw.verdict === "string" ? raw.verdict.slice(0, 200) : "",
1679
1586
  issues,
1680
1587
  reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
1681
1588
  };
1682
1589
  }
1590
+ /** 验证模式系统提示:修订后逐条核对原意见是否解决,只挑新增 high,不重复挑剔主观项。 */
1591
+ function verifySystemPrompt(project) {
1592
+ return [
1593
+ "你是一位网文审稿验证员。作者已按上一轮审稿意见修订了本章,你需要验证修订效果。",
1594
+ "你的任务(严格按此执行):",
1595
+ "1. 逐条核对「上一轮意见」中的每一条是否已在修订稿中解决——已解决的不再列出;未解决或部分解决的,按原严重度列出(item 需注明\"未解决:原意见 xxx\")。",
1596
+ "2. 只挑修订【新引入】的 high 级问题(设定矛盾/逻辑硬伤/事实错误)——新引入的 medium/low 主观项(文笔/套话/节奏)不要列。",
1597
+ "3. 禁止重复挑剔上一轮已指出且本次已解决的主观项(如\"缓缓/微微\"等套话、错别字)——即使换个说法再提也不行。",
1598
+ "4. 严禁为了显得专业而新增\"换一批毛病\"式的意见;如果修订稿已解决全部 high 且无新增 high,输出 issues 为空数组。",
1599
+ "score 评分:按修订稿整体质量给 50-90 分(解决全部 high 且无新增 high 时给 70 以上)。",
1600
+ "verdict:一句话结论(如\"原 high 已解决,无新增高风险问题\"或\"仍有未解决的 high\")。",
1601
+ "输出必须是合法 JSON 对象:{\"score\": 数字, \"verdict\": \"一句话\", \"issues\": [{\"severity\": \"high|medium|low\", \"item\": \"问题\", \"suggestion\": \"建议\"}]}",
1602
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
1603
+ "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。",
1604
+ `本书设定圣经(核对设定冲突用):\n${project.bible !== void 0 ? JSON.stringify(project.bible).slice(0, 3e3) : "(无)"}`
1605
+ ].join("\n");
1606
+ }
1683
1607
  /** Build the author-review system prompt (narrative structure, not prose). */
1684
1608
  function authorReviewSystemPrompt() {
1685
1609
  return [
@@ -1877,6 +1801,402 @@ async function extractRoles(ctx, config, project) {
1877
1801
  }
1878
1802
  return roles;
1879
1803
  }
1804
+ /**
1805
+ * 为单个角色提炼「动漫形象描述词」:扫描该角色出场的已写章节正文,
1806
+ * 截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
1807
+ */
1808
+ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
1809
+ const role = (project.roles ?? []).find((r) => r.name === roleName);
1810
+ if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
1811
+ const appearanceHints = /(发|眉|眼|眸|脸|肤|唇|身材|身高|衣|袍|裙|衫|靴|腰带|气质|模样|长相|容貌|披|束|扎|戴|佩|挂|绣|青|白|黑|红|蓝|紫|灰|银|金|少年|青年|少女|汉子|老者|中年|纤细|挺拔|瘦削|壮实|清秀|俊朗|英气|阴鸷|慈眉)/;
1812
+ const excerpts = [];
1813
+ const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.file !== void 0).slice(-60);
1814
+ for (const chapter of written) {
1815
+ const body = readChapterFile(outputDir, chapter);
1816
+ if (body === void 0) continue;
1817
+ const paras = body.split(/\n{2,}/).map((p) => p.trim()).filter((p) => p.length > 0);
1818
+ let perChapter = 0;
1819
+ for (const para of paras) {
1820
+ if (perChapter >= 2 || excerpts.length >= 12) break;
1821
+ if (!para.includes(roleName)) continue;
1822
+ if (appearanceHints.test(para) || excerpts.length < 4) {
1823
+ excerpts.push({
1824
+ no: chapter.no,
1825
+ text: para.slice(0, 220)
1826
+ });
1827
+ perChapter++;
1828
+ }
1829
+ }
1830
+ if (excerpts.length >= 12) break;
1831
+ }
1832
+ if (excerpts.length === 0) throw new Error(`正文中未找到「${roleName}」的出场描写(仅搜索最近 60 章),请确认角色名与正文一致`);
1833
+ const raw = parseJsonObject(await complete(ctx, config, {
1834
+ system: [
1835
+ "你是一位动漫角色设定师。根据网文正文中该角色的实际外貌描写,提炼「动漫形象描述词」,用于 AI 绘图(NovelAI / Stable Diffusion / Midjourney / 豆包等)生成一致的角色立绘。",
1836
+ "硬性要求(依据优先):",
1837
+ "1. 发色/发型/瞳色/服装/气质/标志物必须来自提供的正文段落,不得凭空发明。",
1838
+ "2. 正文未明确写到的项目(如瞳色没写),用「未定」标注,不要编造。",
1839
+ "3. 服装优先取正文明确出现的(颜色+款式),多次出现取最常穿的组合。",
1840
+ "输出三个部分:",
1841
+ "- zh:中文外貌描述,一段连贯文字(60-150 字):发色发型、瞳色、脸型气质、服装(颜色款式)、身材、标志性物件。",
1842
+ "- en:英文绘图标签,booru 风格、逗号分隔、小写,30-50 个标签:含性别(1boy/1girl)、发色、发型、瞳色、服装(如 chinese hanfu / daoist robe)、气质、背景无关项。不要输出负面提示词。",
1843
+ "- tags:中文关键标签数组,5-10 个(如 [\"黑发\",\"束发\",\"青色道袍\",\"清秀\",\"腰悬古玉\"])。",
1844
+ "- source:说明依据(如\"第1章/第8章外貌描写;瞳色未明确\")。",
1845
+ "输出必须是合法 JSON 对象:{\"zh\": \"...\", \"en\": \"...\", \"tags\": [...], \"source\": \"...\"}",
1846
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
1847
+ "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
1848
+ ].join("\n"),
1849
+ user: [
1850
+ `书名:《${project.bookName}》`,
1851
+ `目标角色:${role.name}(${role.identity})`,
1852
+ role.traits.length > 0 ? `性格标签:${role.traits.join("、")}` : "",
1853
+ `正文出场描写(含外貌线索的段落):`,
1854
+ ...excerpts.map((e) => `[第${e.no}章] ${e.text}`),
1855
+ "只输出 JSON 对象。"
1856
+ ].join("\n\n"),
1857
+ temperature: .4,
1858
+ maxTokens: Math.max(config.maxTokens, 4e3)
1859
+ }));
1860
+ const zh = typeof raw.zh === "string" ? raw.zh.trim().slice(0, 500) : "";
1861
+ const en = typeof raw.en === "string" ? raw.en.trim().slice(0, 1500) : "";
1862
+ const tags = Array.isArray(raw.tags) ? raw.tags.filter((t) => typeof t === "string" && t.trim() !== "").map((t) => t.trim().slice(0, 20)).slice(0, 12) : [];
1863
+ const source = typeof raw.source === "string" ? raw.source.trim().slice(0, 300) : "";
1864
+ if (zh === "" || en === "") throw new Error("形象描述提炼失败:LLM 未返回有效 JSON");
1865
+ return {
1866
+ zh,
1867
+ en,
1868
+ tags,
1869
+ source
1870
+ };
1871
+ }
1872
+ /**
1873
+ * 开书想法 → AI 大纲:输入一句话想法,生成 2-3 个方向不同、可直接开书的完整大纲方案。
1874
+ * @param count 本次生成几个(默认 3,最多 3)
1875
+ * @param exclude 已暂留方案的剧情方向/卖点摘要(换批时避开,防止重复)
1876
+ */
1877
+ async function suggestOutlines(ctx, config, idea, count = 3, exclude = []) {
1878
+ const n = Math.max(1, Math.min(3, Math.floor(count)));
1879
+ const parsed = parseJsonArray(await complete(ctx, config, {
1880
+ system: [
1881
+ "你是一位资深网文策划。作者只给了一句「想法」,你需要把它扩展成 2-3 个【方向差异明显】的完整小说大纲方案,供作者挑选。",
1882
+ "每个方案必须满足:",
1883
+ "1. bookName:书名(6 字以内,抓眼球、点题)。",
1884
+ "2. genre:题材(如 仙侠修真 / 都市异能 / 玄幻 / 悬疑)。",
1885
+ "3. sellingPoint:核心卖点一句话(金手指/爽点/差异化,40 字内)。",
1886
+ "4. outline:完整大纲文本(至少 800 字,可直接作为开书大纲),结构包含:书名与题材、金手指/核心设定、主角人设与动机、主线剧情走向(至少 5 个阶段)、关键配角与势力、卖点与爽点设计、预计分卷(3-5 卷)。",
1887
+ "方向差异要求:",
1888
+ "- 方案之间的金手指/剧情走向必须明显不同(如:苟道发育流 vs 随身老爷爷流 vs 群像争霸流),不能只是换书名。",
1889
+ "- 忠实于作者想法的核心要素,但允许在不同方向上进行合理演绎。",
1890
+ "- 不输出任何与已列「需避开的方向」雷同的方案。",
1891
+ "输出必须是合法 JSON 数组,只输出数组本身:",
1892
+ "[{\"id\": \"唯一id\", \"bookName\": \"...\", \"genre\": \"...\", \"sellingPoint\": \"...\", \"outline\": \"...\"}]",
1893
+ `本次只输出 ${n} 个方案。`,
1894
+ "重要:所有字符串值内部不得包含换行符(大纲内部分段请用「。\n」或「;」自然断句),JSON 必须在一段内完整结束。",
1895
+ "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
1896
+ ].join("\n"),
1897
+ user: [
1898
+ `作者的想法:${idea}`,
1899
+ idea.trim().length < 40 ? "作者的想法非常简短(可能只有一句)。请基于通用网文套路合理扩展补全:为每个方案自洽地设计金手指/核心设定、主角人设与动机、主线走向,使其成为完整可开书的大纲;不同方案的方向仍须明显差异。" : "",
1900
+ exclude.length > 0 ? `需避开的已暂留方案方向(新方案不得与之雷同):\n${exclude.map((e, i) => `${i + 1}. ${e}`).join("\n")}` : "",
1901
+ `请生成 ${n} 个大纲方案。`,
1902
+ "只输出 JSON 数组。"
1903
+ ].join("\n\n"),
1904
+ temperature: .85,
1905
+ maxTokens: Math.max(config.maxTokens, 12e3)
1906
+ }));
1907
+ const candidates = [];
1908
+ for (const entry of parsed) {
1909
+ if (typeof entry !== "object" || entry === null) continue;
1910
+ const bookName = typeof entry.bookName === "string" ? entry.bookName.trim().slice(0, 30) : "";
1911
+ const outline = typeof entry.outline === "string" ? entry.outline.trim() : "";
1912
+ if (bookName === "" || outline.length < 300) continue;
1913
+ candidates.push({
1914
+ id: typeof entry.id === "string" && entry.id !== "" ? entry.id : `oc-${Date.now().toString(36)}-${candidates.length}`,
1915
+ bookName,
1916
+ genre: typeof entry.genre === "string" ? entry.genre.trim().slice(0, 20) : "",
1917
+ sellingPoint: typeof entry.sellingPoint === "string" ? entry.sellingPoint.trim().slice(0, 120) : "",
1918
+ outline
1919
+ });
1920
+ }
1921
+ if (candidates.length === 0) throw new Error("大纲方案生成失败:LLM 未返回有效 JSON(可重试)");
1922
+ return candidates.slice(0, n);
1923
+ }
1924
+ /** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检。
1925
+ * 两阶段管道(借鉴 AI-Novel-Writing-Assistant):
1926
+ * ① 源片段笔记:每章抽取结构化笔记(剧情/人物/设定/写法/卖点/短板信号)
1927
+ * ② 分节分析:按维度各跑一次 LLM,输出可读分析稿 + 结构化数据 + 证据链。
1928
+ * @param scope 'recent'(默认最近20章) | 'volume:N' | 'all'
1929
+ * @param preset 'quick'(总览/剧情/人物/文风) | 'standard'(+卖点)
1930
+ * @param budgetTokens token 预算上限(超过即截断章节取样)。
1931
+ */
1932
+ async function breakdownBook(ctx, config, project, outputDir, scope = "recent", preset = "quick", budgetTokens = 5e4) {
1933
+ const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.summary !== void 0 && c.summary !== "");
1934
+ let selected = written;
1935
+ if (scope === "recent") selected = written.slice(-20);
1936
+ else if (/^volume:\d+$/.test(scope)) {
1937
+ const v = Number(scope.slice(7));
1938
+ selected = written.filter((c) => c.volume === v);
1939
+ }
1940
+ if (selected.length === 0) throw new Error("没有可分析的已写章节(需要已生成并带摘要)");
1941
+ let budget = budgetTokens;
1942
+ const chunks = [];
1943
+ for (const c of selected.slice().reverse()) {
1944
+ const bodySlice = (readChapterFile(outputDir, c) ?? "").replace(/^#\s+.*$/m, "").trim().slice(0, 4e3);
1945
+ const est = Math.ceil((bodySlice.length + (c.summary?.length ?? 0)) / 4) + 400;
1946
+ if (est > budget && chunks.length > 0) break;
1947
+ chunks.unshift({
1948
+ no: c.no,
1949
+ title: c.title,
1950
+ summary: c.summary ?? "",
1951
+ body: bodySlice
1952
+ });
1953
+ budget -= est;
1954
+ }
1955
+ const notes = [];
1956
+ let usedTokens = 0;
1957
+ const noteSystem = [
1958
+ "你是中文网文拆书助手。把单章正文整理成结构化笔记,供后续章节级分析复用。",
1959
+ "只输出 JSON 对象:",
1960
+ "{\"summary\": \"1-2句\", \"plotPoints\": [\"...\"], \"characters\": [\"...\"], \"worldbuilding\": [\"...\"], \"styleTechniques\": [\"...\"], \"marketHighlights\": [\"...\"], \"weaknessSignals\": [\"...\"]}",
1961
+ "硬规则:只提取正文明确出现的信息;每数组最多 4 项;不要补写原文外的动机/意图;evidence 不在此阶段输出。",
1962
+ "重要:直接输出 JSON,不要输出其他文字;字符串内不含换行。"
1963
+ ].join("\n");
1964
+ for (const ch of chunks) {
1965
+ const noteUser = [
1966
+ `第${ch.no}章《${ch.title}》`,
1967
+ "正文:",
1968
+ ch.body.slice(0, 3e3)
1969
+ ].join("\n");
1970
+ try {
1971
+ const raw = parseJsonObject(await complete(ctx, config, {
1972
+ system: noteSystem,
1973
+ user: noteUser,
1974
+ temperature: .2,
1975
+ maxTokens: Math.max(config.maxTokens, 3e3)
1976
+ }));
1977
+ const pick = (k) => Array.isArray(raw[k]) ? raw[k].filter((x) => typeof x === "string" && x.trim() !== "").map((x) => x.trim().slice(0, 120)).slice(0, 4) : [];
1978
+ notes.push(`【第${ch.no}章《${ch.title}》】\n摘要:${typeof raw.summary === "string" ? raw.summary.slice(0, 200) : ""}\n剧情:${pick("plotPoints").join(";")}\n人物:${pick("characters").join(";")}\n设定:${pick("worldbuilding").join(";")}\n写法:${pick("styleTechniques").join(";")}\n卖点:${pick("marketHighlights").join(";")}\n短板信号:${pick("weaknessSignals").join(";") || "(无明显短板信号)"}`);
1979
+ usedTokens += 800;
1980
+ } catch {}
1981
+ }
1982
+ const sectionsConfig = [
1983
+ {
1984
+ key: "overview",
1985
+ title: "拆书总览",
1986
+ focus: "一句话定位、题材标签、整体优势与短板",
1987
+ system: [
1988
+ "你是资深中文网文拆书分析师,负责《拆书总览》小节。",
1989
+ "基于给定章节笔记做低风险综合判断,输出 JSON:{\"markdown\": \"可直接展示的分析稿(简体中文,先给结论再说明体现在哪、为何成立)\", \"structured\": {\"oneLinePositioning\": \"一句话定位\", \"genreTags\": [\"题材标签\"], \"sellingPointTags\": [\"卖点标签\"], \"strengths\": [\"整体优势\"], \"weaknesses\": [\"整体短板\"]}}",
1990
+ "硬规则:只基于笔记归纳;推断用「更偏向/可能」等谨慎措辞;证据不足写「材料不足」;不虚构原文细节。",
1991
+ "重要:直接输出 JSON,字符串内不含换行。"
1992
+ ].join("\n")
1993
+ },
1994
+ {
1995
+ key: "plot",
1996
+ title: "剧情结构",
1997
+ focus: "主线梗概、阶段推进、冲突升级、节奏风险",
1998
+ system: [
1999
+ "你是资深中文网文拆书分析师,负责《剧情结构》小节。",
2000
+ "基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"mainlineSummary\": \"主线梗概\", \"phaseProgressions\": [\"阶段推进\"], \"escalationDesigns\": [\"冲突升级\"], \"paceRisks\": [\"节奏风险\"], \"reusablePatterns\": [\"可复用套路\"]}}",
2001
+ "硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
2002
+ "重要:直接输出 JSON,字符串内不含换行。"
2003
+ ].join("\n")
2004
+ },
2005
+ {
2006
+ key: "character",
2007
+ title: "人物系统",
2008
+ focus: "主角定位、配角功能、关系网络、成长弧线、辨识度风险",
2009
+ system: [
2010
+ "你是资深中文网文拆书分析师,负责《人物系统》小节。",
2011
+ "基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"protagonistPositioning\": \"主角定位\", \"supportingFunctions\": [\"配角功能\"], \"relationshipNetwork\": [\"关系网络\"], \"growthArcs\": [\"成长弧线\"], \"clarityRisks\": [\"辨识度风险\"]}}",
2012
+ "硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
2013
+ "重要:直接输出 JSON,字符串内不含换行。"
2014
+ ].join("\n")
2015
+ },
2016
+ {
2017
+ key: "style",
2018
+ title: "文风与技法",
2019
+ focus: "叙事视角、语言风格、描写方式、节奏控制、钩子设计、可复用写法",
2020
+ system: [
2021
+ "你是资深中文网文拆书分析师,负责《文风与技法》小节。",
2022
+ "基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"narrativePov\": \"叙事视角\", \"languageStyle\": \"语言风格\", \"dialoguePatterns\": [\"对话特征\"], \"rhythmControl\": [\"节奏控制\"], \"hookDesigns\": [\"钩子设计\"], \"reusableTechniques\": [\"可复用写法\"]}}",
2023
+ "硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
2024
+ "重要:直接输出 JSON,字符串内不含换行。"
2025
+ ].join("\n")
2026
+ }
2027
+ ];
2028
+ if (preset === "standard") sectionsConfig.push({
2029
+ key: "market",
2030
+ title: "商业化卖点",
2031
+ focus: "读者爽点、点击驱动、人物/题材卖点、商业化风险",
2032
+ system: [
2033
+ "你是资深中文网文拆书分析师,负责《商业化卖点》小节。",
2034
+ "基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"hookPoints\": [\"读者爽点\"], \"clickDrivers\": [\"点击驱动\"], \"characterSellingPoints\": [\"人物卖点\"], \"genreSellingPoints\": [\"题材卖点\"], \"commercialRisks\": [\"商业化风险\"]}}",
2035
+ "硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
2036
+ "重要:直接输出 JSON,字符串内不含换行。"
2037
+ ].join("\n")
2038
+ });
2039
+ const notesText = notes.join("\n\n");
2040
+ const sections = [];
2041
+ const evidence = [];
2042
+ for (const sec of sectionsConfig) try {
2043
+ const raw = parseJsonObject(await complete(ctx, config, {
2044
+ system: sec.system,
2045
+ user: `分析范围:${selected.length} 章(${scope === "all" ? "全书" : scope === "recent" ? "最近 20 章" : "指定卷"})。\n\n章节笔记:\n${notesText}`,
2046
+ temperature: .3,
2047
+ maxTokens: Math.max(config.maxTokens, 6e3)
2048
+ }));
2049
+ sections.push({
2050
+ key: sec.key,
2051
+ title: sec.title,
2052
+ markdown: typeof raw.markdown === "string" ? raw.markdown.trim() : "(生成失败)",
2053
+ structured: typeof raw.structured === "object" && raw.structured !== null ? raw.structured : {}
2054
+ });
2055
+ usedTokens += 2e3;
2056
+ } catch {
2057
+ sections.push({
2058
+ key: sec.key,
2059
+ title: sec.title,
2060
+ markdown: "(本节生成失败,可重试)",
2061
+ structured: {}
2062
+ });
2063
+ }
2064
+ return {
2065
+ sections,
2066
+ evidence,
2067
+ chaptersScanned: chunks.length,
2068
+ usedTokens
2069
+ };
2070
+ }
2071
+ /**
2072
+ * 漫剧分镜生成:把一章正文改编为短视频漫剧分镜(吸收 manga-script-master 方法论)。
2073
+ * 产出:本集标题 + 赛道节奏说明 + 角色视觉锚点卡 + 分镜表(8-12 格)+ 结尾钩子。
2074
+ * 角色锚点优先复用角色库 imagePrompt(无则从正文提炼)。
2075
+ */
2076
+ async function generateStoryboard(ctx, config, project, outputDir, chapterNo, genre, platform, tool) {
2077
+ const chapter = project.chapters.find((c) => c.no === chapterNo);
2078
+ if (chapter === void 0) throw new Error(`章节 ${chapterNo} 不在计划中`);
2079
+ const body = readChapterFile(outputDir, chapter);
2080
+ if (body === void 0) throw new Error(`章节 ${chapterNo} 尚未生成正文`);
2081
+ const roleCards = (project.roles ?? []).filter((r) => r.imagePrompt !== void 0 && (body.includes(r.name) || chapter.summary?.includes(r.name) === true)).map((r) => ({
2082
+ name: r.name,
2083
+ anchor: r.imagePrompt.zh.slice(0, 120),
2084
+ tags: r.imagePrompt.en.slice(0, 300)
2085
+ })).slice(0, 3);
2086
+ const raw = parseJsonObject(await complete(ctx, config, {
2087
+ system: [
2088
+ "你是一位精通短视频算法与 AIGC 的顶级漫剧导演。把给定的一章网文改编为高完播率的漫剧分镜。",
2089
+ "硬性要求:",
2090
+ "1. 前 3 秒必须有强冲突或视觉冲击(黄金开局);每 10 秒至少一个小高潮;结尾必须有悬念钩子。",
2091
+ "2. 所有情感必须具象化为动作(不能写\"他很愤怒\",要写\"他拳头砸碎桌角,碎片飞溅\")。",
2092
+ "3. 台词每句 ≤15 字,用画面推进叙事。",
2093
+ "4. 总时长 60-120 秒,分镜 8-12 格,每格标注时间码、景别、转场/动效。",
2094
+ "5. 输出 JSON:",
2095
+ "{\"title\": \"本集标题(核心设定+冲突+悬念句式)\", \"pacingNote\": \"赛道判断与节奏说明(按爽文/甜宠/悬疑/搞笑模型)\", \"hook\": \"本集钩子一句话\", \"panels\": [{\"timecode\": \"0:00-0:03\", \"shot\": \"特写\", \"visual\": \"具象画面描述\", \"dialogue\": \"(情绪)台词\", \"transition\": \"硬切/闪白等\", \"prompt\": \"英文AI提示词:角色标签+表情+动作+场景+景别+光影+风格+9:16\"}], \"endingHook\": \"结尾钩子台词\"}",
2096
+ "6. 英文 prompt 里的角色标签必须复用提供的角色锚点标签;多角色同框用 left/right/foreground 分隔。",
2097
+ "7. 严禁在提示词里出现文字/水印(画面内字幕后期加)。",
2098
+ "重要:直接输出 JSON,字符串内不含换行。"
2099
+ ].join("\n"),
2100
+ user: [
2101
+ `书名:《${project.bookName}》`,
2102
+ `章节:第 ${chapter.no} 章《${chapter.title}》`,
2103
+ chapter.summary !== void 0 ? `章节摘要:${chapter.summary}` : "",
2104
+ `目标平台:${platform}(默认抖音竖屏 9:16)`,
2105
+ `赛道:${genre !== "" ? genre : "(由你按内容判断:爽文/甜宠/悬疑/搞笑)"}`,
2106
+ `AI 工具:${tool}(影响提示词格式)`,
2107
+ roleCards.length > 0 ? `角色视觉锚点(英文标签全分镜强制复用):\n${roleCards.map((r) => `- ${r.name}:${r.tags}`).join("\n")}` : "(无角色锚点卡,从正文提炼 1-2 个核心角色并给出标签)",
2108
+ "==================== 正文 ====================",
2109
+ body.replace(/^#\s+.*$/m, "").trim().slice(0, 6e3),
2110
+ "只输出 JSON 对象。"
2111
+ ].join("\n\n"),
2112
+ temperature: .5,
2113
+ maxTokens: Math.max(config.maxTokens, 12e3)
2114
+ }));
2115
+ const str = (v, fallback = "") => typeof v === "string" ? v.trim() : fallback;
2116
+ const panels = (Array.isArray(raw.panels) ? raw.panels.filter((p) => typeof p === "object" && p !== null) : []).map((p, i) => ({
2117
+ index: i + 1,
2118
+ timecode: str(p.timecode, `0:${String(i * 10).padStart(2, "0")}`),
2119
+ shot: str(p.shot, "中景"),
2120
+ visual: str(p.visual),
2121
+ dialogue: str(p.dialogue),
2122
+ transition: str(p.transition, "硬切"),
2123
+ prompt: str(p.prompt)
2124
+ })).filter((p) => p.visual !== "").slice(0, 12);
2125
+ return {
2126
+ title: str(raw.title, `第 ${chapter.no} 集`),
2127
+ pacingNote: str(raw.pacingNote, "(按内容判断赛道节奏)"),
2128
+ hook: str(raw.hook),
2129
+ characters: roleCards.map((r) => ({
2130
+ name: r.name,
2131
+ visualAnchor: r.anchor,
2132
+ tags: r.tags,
2133
+ expressions: []
2134
+ })),
2135
+ panels,
2136
+ endingHook: str(raw.endingHook)
2137
+ };
2138
+ }
2139
+ /**
2140
+ * 漫剧分集计划:AI 通读一卷的章节标题+摘要+beats,按故事弧线把章节分组为漫剧集。
2141
+ * 原则(吸收 manga-script 节奏模型):高潮章单独成集、过渡章合并、断点选在钩子最强处。
2142
+ */
2143
+ async function planStoryboardEpisodes(ctx, config, project, volumeNo, platform, maxEpisodes) {
2144
+ const volume = project.volumes?.find((v) => v.no === volumeNo);
2145
+ if (volume === void 0) throw new Error(`第 ${volumeNo} 卷不存在(卷计划未生成或卷号错误)`);
2146
+ const chapters = project.chapters.filter((c) => c.volume === volumeNo && c.status !== "pending" && c.status !== "generating").sort((a, b) => a.no - b.no);
2147
+ if (chapters.length === 0) throw new Error(`第 ${volumeNo} 卷没有已写章节`);
2148
+ const chapterBriefs = chapters.map((c) => {
2149
+ const beatsHead = (c.beats ?? "").split("\n")[0] ?? "";
2150
+ return `第${c.no}章《${c.title}》|${beatsHead.slice(0, 60)}|${(c.summary ?? "").slice(0, 100)}`;
2151
+ });
2152
+ const raw = parseJsonObject(await complete(ctx, config, {
2153
+ system: [
2154
+ "你是一位精通短视频算法与漫剧节奏的总导演。把给定的一卷网文改编为「漫剧分集计划」——不是按章分,而是按故事弧线分集。",
2155
+ "分集原则:",
2156
+ "1. 高潮章(大冲突/身份揭晓/大收获)单独成集或两章一集;过渡章(赶路/准备/日常)2-3 章合并为一集;支线穿插章并入相邻主线圈。",
2157
+ "2. 每集 60-120 秒 ≈ 1-2 章正文,但以\"叙事任务完整\"为准,不被章节号束缚。",
2158
+ "3. 断点选在钩子最强处:每集结尾必须留下让观众点下集的悬念。",
2159
+ "4. 全集数控制在 8 到上限之间(上限由输入给出),宁少勿碎。",
2160
+ "5. 卷的开局集要强(3 秒钩子),卷的结尾集要收束本卷弧线并埋下卷间钩子。",
2161
+ "输出 JSON:",
2162
+ "{\"strategy\": \"本卷漫剧化策略一句话(赛道判断+整体节奏安排)\", \"episodes\": [{\"title\": \"集标题(冲突+悬念句式)\", \"chapters\": [章号数组], \"narrativeJob\": \"这集讲什么、为什么这么分(1-2句)\", \"openingHook\": \"开头3秒钩子\", \"endingHook\": \"结尾钩子\"}]}",
2163
+ "硬性要求:episodes 按章号顺序排列且覆盖全部给定章节(不得遗漏);chapters 数组必须连续(如 [81,82,83]);每集 chapters 至少 1 章、最多 4 章;所有文字用简体中文。",
2164
+ "重要:直接输出 JSON,字符串内不含换行。"
2165
+ ].join("\n"),
2166
+ user: [
2167
+ `书名:《${project.bookName}》`,
2168
+ `第 ${volumeNo} 卷《${volume.title}》(${volume.chapterStart}-${volume.chapterEnd} 章)`,
2169
+ `卷定位:${volume.summary}`,
2170
+ `目标平台:${platform}(竖屏 9:16)`,
2171
+ `本卷已写章节(${chapters.length} 章,按序):\n${chapterBriefs.join("\n")}`,
2172
+ `全集数上限:${maxEpisodes}`,
2173
+ "只输出 JSON 对象。"
2174
+ ].join("\n\n"),
2175
+ temperature: .4,
2176
+ maxTokens: Math.max(config.maxTokens, 12e3)
2177
+ }));
2178
+ const episodesRaw = Array.isArray(raw.episodes) ? raw.episodes.filter((e) => typeof e === "object" && e !== null) : [];
2179
+ const str = (v, fallback = "") => typeof v === "string" ? v.trim() : fallback;
2180
+ const episodes = [];
2181
+ for (const e of episodesRaw) {
2182
+ const chaptersArr = Array.isArray(e.chapters) ? e.chapters.filter((n) => typeof n === "number" && Number.isInteger(n) && n > 0) : [];
2183
+ if (chaptersArr.length === 0) continue;
2184
+ episodes.push({
2185
+ index: episodes.length + 1,
2186
+ title: str(e.title, `第 ${episodes.length + 1} 集`),
2187
+ chapters: chaptersArr,
2188
+ narrativeJob: str(e.narrativeJob),
2189
+ openingHook: str(e.openingHook),
2190
+ endingHook: str(e.endingHook)
2191
+ });
2192
+ }
2193
+ if (episodes.length === 0) throw new Error("分集计划生成失败:LLM 未返回有效集数");
2194
+ return {
2195
+ strategy: str(raw.strategy, "(按内容判断赛道节奏)"),
2196
+ episodes,
2197
+ chaptersScanned: chapters.length
2198
+ };
2199
+ }
1880
2200
  /** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
1881
2201
  async function analyzePlotlineHealth(ctx, config, project) {
1882
2202
  const system = [
@@ -2181,6 +2501,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
2181
2501
  score
2182
2502
  };
2183
2503
  }).filter((x) => x.score >= 3).sort((a, b) => b.score - a.score).slice(0, 15).map((x) => `[第${x.f.chapterNo}章] ${x.f.text}`).filter((t) => !recentSet.has(t.slice(t.indexOf("]") + 2)));
2504
+ const foreshadowHints = (project.foreshadows ?? []).filter((f) => f.status === "planned" && f.targetChapter !== void 0 && f.targetChapter > 0).filter((f) => Math.abs(f.targetChapter - chapterNo) <= 12).map((f) => `- ${f.description.slice(0, 120)}${f.targetChapter !== void 0 ? `(计划回收于第 ${f.targetChapter} 章)` : ""}`);
2184
2505
  const messages = [createUserMessage({
2185
2506
  content: [{
2186
2507
  type: "text",
@@ -2188,6 +2509,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
2188
2509
  `现在写第 ${chapter.no} 章,标题《${chapter.title}》。`,
2189
2510
  `本章剧情要点:${chapter.beats}`,
2190
2511
  "",
2512
+ foreshadowHints.length > 0 ? `本章附近需顺势埋下以下暗线(自然带过,不喧宾夺主,1-2 句即可,但细节要可辨识、与描述吻合):\n${foreshadowHints.join("\n")}` : "",
2191
2513
  recentFacts.length > 0 ? `本书已确立的事实(新写内容不得与之矛盾):\n${recentFacts.join("\n")}` : "",
2192
2514
  relatedFacts.length > 0 ? `本章相关的既往事实(同样不得违背):\n${relatedFacts.join("\n")}` : "",
2193
2515
  prevSummary !== void 0 && prevSummary !== "" ? `上一章摘要:${prevSummary}` : "",
@@ -2304,17 +2626,50 @@ async function summarizeAndExtractFacts(ctx, config, project, outputDir, chapter
2304
2626
  };
2305
2627
  }
2306
2628
  /**
2307
- * 抽取本章「已确立事实」追加到事实库/时间线(最多 300 条,最新优先)。
2308
- * 事实注入后续章节生成提示词,保证人物状态/境界/资源/关系长期一致。
2309
- * @returns 新增事实条数(失败返回 0,调用方 best-effort)。
2629
+ * 伏笔落地标记:检查刚生成的章节正文是否埋下了 planned 伏笔(关键词匹配),
2630
+ * 命中则将该伏笔标记为 planted 并记录 plantedChapter——保证暗线管理页与正文同步。
2631
+ * 纯关键词粗匹配,宁缺毋滥:仅处理「描述含可辨识关键词」的伏笔,无把握则不标。
2310
2632
  */
2311
- async function extractFacts(ctx, config, project, outputDir, chapterNo) {
2633
+ function markForeshadowPlanted(project, outputDir, chapterNo) {
2312
2634
  const chapter = project.chapters.find((c) => c.no === chapterNo);
2313
2635
  if (chapter === void 0) return 0;
2314
2636
  const body = readChapterFile(outputDir, chapter);
2315
2637
  if (body === void 0) return 0;
2316
- const lines = (await complete(ctx, config, {
2317
- system: [
2638
+ let marked = 0;
2639
+ for (const f of project.foreshadows ?? []) {
2640
+ if (f.status !== "planned") continue;
2641
+ if (f.plantedChapter !== void 0) continue;
2642
+ const quoted = f.description.match(/[「“『《]([^」”』》]{2,12})[」”』》]/g);
2643
+ const keywords = (quoted !== null ? quoted : []).map((q) => q.slice(1, -1)).filter((k) => k.length >= 2);
2644
+ const nearTarget = f.targetChapter !== void 0 && Math.abs(f.targetChapter - chapterNo) <= 12;
2645
+ if (keywords.length === 0 && !nearTarget) continue;
2646
+ const hit = keywords.length === 0 ? false : keywords.some((k) => body.includes(k));
2647
+ if (hit || keywords.length === 0 && nearTarget) {
2648
+ if (hit) {
2649
+ f.status = "planted";
2650
+ f.plantedChapter = chapterNo;
2651
+ marked++;
2652
+ }
2653
+ }
2654
+ }
2655
+ if (marked > 0) {
2656
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2657
+ saveProject(outputDir, project);
2658
+ }
2659
+ return marked;
2660
+ }
2661
+ /**
2662
+ * 抽取本章「已确立事实」追加到事实库/时间线(最多 300 条,最新优先)。
2663
+ * 事实注入后续章节生成提示词,保证人物状态/境界/资源/关系长期一致。
2664
+ * @returns 新增事实条数(失败返回 0,调用方 best-effort)。
2665
+ */
2666
+ async function extractFacts(ctx, config, project, outputDir, chapterNo) {
2667
+ const chapter = project.chapters.find((c) => c.no === chapterNo);
2668
+ if (chapter === void 0) return 0;
2669
+ const body = readChapterFile(outputDir, chapter);
2670
+ if (body === void 0) return 0;
2671
+ const lines = (await complete(ctx, config, {
2672
+ system: [
2318
2673
  "你是一位网文编辑。请从本章正文中抽取「已确立事实」,供后续章节保持一致。",
2319
2674
  "事实指:人物当前状态(境界/修为/伤势/资源/心境)、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。",
2320
2675
  "要求:",
@@ -2749,47 +3104,631 @@ async function suggestForeshadows(ctx, config, project) {
2749
3104
  return created;
2750
3105
  }
2751
3106
  /**
2752
- * 写法引擎:从样本文本提取一份写法资产(叙事风格规则)。
2753
- * @returns 提取出的风格规则(未持久化,由调用方存入 project.assets)。
3107
+ * 写法引擎:从样本文本提取一份写法资产(叙事风格规则)。
3108
+ * @returns 提取出的风格规则(未持久化,由调用方存入 project.assets)。
3109
+ */
3110
+ async function extractStyleAsset(ctx, config, sampleText) {
3111
+ const user = `请分析下面这段样本文本,提炼其叙事风格规则:\n\n${sampleText}`;
3112
+ const raw = parseJsonObject(await complete(ctx, config, {
3113
+ system: styleEngineSystemPrompt(),
3114
+ user,
3115
+ temperature: .3,
3116
+ maxTokens: Math.max(config.maxTokens, 12e3)
3117
+ }));
3118
+ const strArray = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string" && v.trim() !== "") : [];
3119
+ const result = {
3120
+ proseRules: strArray(raw.proseRules),
3121
+ dialogueRules: strArray(raw.dialogueRules),
3122
+ descriptionRules: strArray(raw.descriptionRules),
3123
+ boundaries: strArray(raw.boundaries)
3124
+ };
3125
+ if (result.proseRules.length + result.dialogueRules.length + result.descriptionRules.length + result.boundaries.length === 0) throw new Error("写法提取失败:模型没有返回有效规则");
3126
+ return result;
3127
+ }
3128
+ /** Export the whole book as one txt/md file. */
3129
+ function exportBook(outputDir, project, format) {
3130
+ const parts = [];
3131
+ if (format === "md") parts.push(`# ${project.bookName}\n`);
3132
+ else parts.push(project.bookName, "");
3133
+ const done = project.chapters.filter((c) => c.file !== void 0);
3134
+ for (const chapter of done) {
3135
+ const body = readChapterFile(outputDir, chapter) ?? "";
3136
+ if (format === "md") parts.push(`\n## 第${chapter.no}章 ${chapter.title}\n`, body.trim(), "");
3137
+ else parts.push("", `第${chapter.no}章 ${chapter.title}`, "", body.trim(), "");
3138
+ }
3139
+ const content = parts.join("\n");
3140
+ const ext = format === "md" ? "md" : "txt";
3141
+ const file = `《${safeFileName(project.bookName)}》全本.${ext}`;
3142
+ writeFileSync(join(outputDir, file), content, "utf8");
3143
+ return {
3144
+ file,
3145
+ chars: content.length,
3146
+ chapters: done.length
3147
+ };
3148
+ }
3149
+ //#endregion
3150
+ //#region src/bookshelf.ts
3151
+ /**
3152
+ * 书架(Bookshelf)— 多书管理:一本书记录一个独立输出目录。
3153
+ * 状态持久化到 ~/.dsh/dsh-novel-forge-bookshelf.json(跟随 dsh 配置惯例)。
3154
+ */
3155
+ /** 书架配置文件路径。 */
3156
+ function bookshelfFile() {
3157
+ return join(homedir(), ".dsh", "dsh-novel-forge-bookshelf.json");
3158
+ }
3159
+ function defaultStore() {
3160
+ return {
3161
+ books: [],
3162
+ activeBookId: null
3163
+ };
3164
+ }
3165
+ /** 读取书架(无则返回空)。 */
3166
+ function loadBookshelf() {
3167
+ const file = bookshelfFile();
3168
+ if (!existsSync(file)) return defaultStore();
3169
+ try {
3170
+ let raw = readFileSync(file, "utf8");
3171
+ if (raw.charCodeAt(0) === 65279) raw = raw.slice(1);
3172
+ const parsed = JSON.parse(raw);
3173
+ if (!Array.isArray(parsed.books)) return defaultStore();
3174
+ return {
3175
+ books: parsed.books,
3176
+ activeBookId: parsed.activeBookId ?? null
3177
+ };
3178
+ } catch {
3179
+ return defaultStore();
3180
+ }
3181
+ }
3182
+ /** 持久化书架。 */
3183
+ function saveBookshelf(store) {
3184
+ const file = bookshelfFile();
3185
+ mkdirSync(join(homedir(), ".dsh"), { recursive: true });
3186
+ writeFileSync(file, JSON.stringify(store, null, 2), "utf8");
3187
+ }
3188
+ /** 当前激活的书。 */
3189
+ function activeBook(store) {
3190
+ return store.books.find((b) => b.id === store.activeBookId);
3191
+ }
3192
+ /** 书架快照(含每本书的进度摘要)。 */
3193
+ function bookshelfSnapshot(store) {
3194
+ return {
3195
+ books: store.books.map((book) => {
3196
+ const project = loadProject(book.outputDir);
3197
+ const done = project === void 0 ? 0 : project.chapters.filter((c) => c.status === "approved" || c.status === "written" || c.status === "rejected").length;
3198
+ const hasCover = project?.coverPath !== void 0 && project.coverPath !== "" && existsSync(join(book.outputDir, project.coverPath));
3199
+ return {
3200
+ ...book,
3201
+ done,
3202
+ total: project?.chapters.length ?? 0,
3203
+ hasProject: project !== void 0,
3204
+ hasCover,
3205
+ blurb: project?.blurb
3206
+ };
3207
+ }),
3208
+ activeBookId: store.activeBookId
3209
+ };
3210
+ }
3211
+ /** 新建一本书(自动成为当前书)。 */
3212
+ function createBook(bookName, outputDir) {
3213
+ const store = loadBookshelf();
3214
+ const id = `book-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
3215
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3216
+ const book = {
3217
+ id,
3218
+ bookName,
3219
+ outputDir,
3220
+ createdAt: now,
3221
+ updatedAt: now
3222
+ };
3223
+ store.books.push(book);
3224
+ store.activeBookId = id;
3225
+ saveBookshelf(store);
3226
+ return book;
3227
+ }
3228
+ /** 更新某本书的书名(开书向导导入大纲后书名以大纲首行为准)。 */
3229
+ function renameBook(id, bookName) {
3230
+ const store = loadBookshelf();
3231
+ const book = store.books.find((b) => b.id === id);
3232
+ if (book === void 0) return false;
3233
+ book.bookName = bookName;
3234
+ book.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3235
+ saveBookshelf(store);
3236
+ return true;
3237
+ }
3238
+ /**
3239
+ * 播种:书架为空时,把指定输出目录下已有的项目自动登记为第一本书。
3240
+ * 兼容升级场景 —— 旧版插件直接在输出目录写项目,从未登记书架。
3241
+ * @param outputDir - 候选输出目录(通常为 settings 的默认输出目录)。
3242
+ * @returns 是否发生了播种。
3243
+ */
3244
+ function seedBookshelfFromOutputDir(outputDir) {
3245
+ if (loadBookshelf().books.length > 0) return false;
3246
+ if (!existsSync(outputDir)) return false;
3247
+ const hasProject = existsSync(join(outputDir, "novel-project.json"));
3248
+ const hasChapters = existsSync(outputDir);
3249
+ if (!hasProject && !hasChapters) return false;
3250
+ createBook(loadProject(outputDir)?.bookName ?? outputDir.split(/[\\/]/).pop() ?? "未命名小说", outputDir);
3251
+ return true;
3252
+ }
3253
+ /** 激活一本书。 */
3254
+ function activateBook(id) {
3255
+ const store = loadBookshelf();
3256
+ const book = store.books.find((b) => b.id === id);
3257
+ if (book === void 0) return void 0;
3258
+ store.activeBookId = id;
3259
+ book.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3260
+ saveBookshelf(store);
3261
+ return book;
3262
+ }
3263
+ /** 移除一本书。 */
3264
+ function removeBook(id) {
3265
+ const store = loadBookshelf();
3266
+ const idx = store.books.findIndex((b) => b.id === id);
3267
+ if (idx === -1) return false;
3268
+ store.books.splice(idx, 1);
3269
+ if (store.activeBookId === id) store.activeBookId = store.books[0]?.id ?? null;
3270
+ saveBookshelf(store);
3271
+ return true;
3272
+ }
3273
+ /** 当前书输出目录(无书架则 undefined,回退 settings)。 */
3274
+ function activeBookOutputDir() {
3275
+ return activeBook(loadBookshelf())?.outputDir;
3276
+ }
3277
+ /** 默认输出目录推断:小说目录/书名。 */
3278
+ function defaultOutputDirFor(bookName) {
3279
+ return join("D:\\ryan work\\harness\\novels", bookName.replace(/[\\/:*?"<>|]/g, "").trim().slice(0, 40) || "未命名小说");
3280
+ }
3281
+ //#endregion
3282
+ //#region src/run.ts
3283
+ /**
3284
+ * 生产单(Production Run):批量章节生产的标准执行器。
3285
+ * 职责:计划补足 → 逐章生成(完整质量门)→ 被拒分级处理(豁免/修订+验证/待人工)→ 断点续跑。
3286
+ * 与路由层解耦:只依赖 engine 导出函数与磁盘状态,自身不碰 HTTP。
3287
+ * 串行纪律:单例执行器 + working 锁;每章重新从磁盘加载,写前合并易变字段。
3288
+ */
3289
+ /** 生产单 checkpoint 文件名(放在书目录下)。 */
3290
+ function runStateFile(outputDir) {
3291
+ return join(outputDir, "run-state.json");
3292
+ }
3293
+ var ProductionRunner = class {
3294
+ deps;
3295
+ state = null;
3296
+ working = false;
3297
+ pauseRequested = false;
3298
+ stopRequested = false;
3299
+ /** 生产单绑定目录(start 时快照;所有读写固定用它,防止运行中切书导致写错目录)。 */
3300
+ bookDir = null;
3301
+ constructor(deps) {
3302
+ this.deps = deps;
3303
+ }
3304
+ /** 当前生产单状态(内存优先;web 重启后从磁盘恢复)。 */
3305
+ status() {
3306
+ if (this.state !== null) return this.state;
3307
+ const candidates = [this.deps.getConfig().outputDir];
3308
+ const bookshelf = loadBookshelf();
3309
+ for (const b of bookshelf.books) if (!candidates.includes(b.outputDir)) candidates.push(b.outputDir);
3310
+ for (const outputDir of candidates) {
3311
+ const file = runStateFile(outputDir);
3312
+ if (!existsSync(file)) continue;
3313
+ try {
3314
+ const raw = readFileSync(file, "utf8");
3315
+ const parsed = JSON.parse(raw);
3316
+ if (parsed?.runId === void 0) continue;
3317
+ if (parsed.status === "running") parsed.status = "paused";
3318
+ this.state = parsed;
3319
+ this.bookDir = outputDir;
3320
+ return this.state;
3321
+ } catch {}
3322
+ }
3323
+ return null;
3324
+ }
3325
+ persist() {
3326
+ if (this.state === null) return;
3327
+ const outputDir = this.bookDir ?? this.deps.getConfig().outputDir;
3328
+ mkdirSync(outputDir, { recursive: true });
3329
+ writeFileSync(runStateFile(outputDir), JSON.stringify(this.state, null, 2), "utf8");
3330
+ }
3331
+ log(text) {
3332
+ if (this.state === null) return;
3333
+ this.state.log.push({
3334
+ at: (/* @__PURE__ */ new Date()).toISOString(),
3335
+ text
3336
+ });
3337
+ if (this.state.log.length > 300) this.state.log = this.state.log.slice(-300);
3338
+ this.state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3339
+ }
3340
+ /** 启动/续跑生产单:startNo..endNo 区间,endNo 超出计划时先自动补计划。 */
3341
+ async start(startNo, endNo) {
3342
+ const config = this.deps.getConfig();
3343
+ if (this.working) throw new Error("生产单正在运行中,请先暂停或停止");
3344
+ let project = loadProject(config.outputDir);
3345
+ if (project === void 0) throw new Error("输出目录中没有项目");
3346
+ if (endNo > project.chapters.length) {
3347
+ const need = endNo - project.chapters.length;
3348
+ this.log(`计划不足,追加 ${need} 章计划…`);
3349
+ const chapters = await planChapters(this.deps.ctx, config, project, need, void 0, config.outputDir);
3350
+ mergeVolatileFromDisk(config.outputDir, project);
3351
+ project.chapters.push(...chapters);
3352
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3353
+ saveProject(config.outputDir, project);
3354
+ this.log(`计划已追加,全书 ${project.chapters.length} 章`);
3355
+ }
3356
+ this.state = {
3357
+ runId: `run-${Date.now().toString(36)}`,
3358
+ startNo,
3359
+ endNo,
3360
+ status: "running",
3361
+ currentNo: startNo,
3362
+ stats: {
3363
+ generated: 0,
3364
+ revised: 0,
3365
+ exempted: 0,
3366
+ regenerated: 0,
3367
+ error: 0
3368
+ },
3369
+ pendingManual: [],
3370
+ log: [],
3371
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3372
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3373
+ };
3374
+ this.pauseRequested = false;
3375
+ this.stopRequested = false;
3376
+ this.bookDir = config.outputDir;
3377
+ this.working = true;
3378
+ this.persist();
3379
+ this.loop();
3380
+ return this.state;
3381
+ }
3382
+ pause() {
3383
+ this.pauseRequested = true;
3384
+ }
3385
+ resume() {
3386
+ if (this.state === null || this.state.status !== "paused") return;
3387
+ this.state.status = "running";
3388
+ this.working = true;
3389
+ this.pauseRequested = false;
3390
+ this.stopRequested = false;
3391
+ this.persist();
3392
+ this.loop();
3393
+ }
3394
+ stop() {
3395
+ this.stopRequested = true;
3396
+ }
3397
+ /** 主循环:从 currentNo 扫描到 endNo,逐章处理;支持暂停/停止。 */
3398
+ async loop() {
3399
+ const config = this.deps.getConfig();
3400
+ const outputDir = this.bookDir ?? config.outputDir;
3401
+ try {
3402
+ while (this.state !== null) {
3403
+ if (this.stopRequested) {
3404
+ this.stopRequested = false;
3405
+ this.state.status = "stopped";
3406
+ this.log("生产单已停止");
3407
+ this.persist();
3408
+ break;
3409
+ }
3410
+ if (this.pauseRequested) {
3411
+ this.pauseRequested = false;
3412
+ this.state.status = "paused";
3413
+ this.log("生产单已暂停(随时可继续)");
3414
+ this.persist();
3415
+ break;
3416
+ }
3417
+ const project = loadProject(outputDir);
3418
+ if (project === void 0) {
3419
+ this.state.status = "error";
3420
+ this.state.error = "项目丢失";
3421
+ this.persist();
3422
+ break;
3423
+ }
3424
+ let next;
3425
+ for (let no = this.state.currentNo; no <= this.state.endNo; no++) {
3426
+ const ch = project.chapters.find((c) => c.no === no);
3427
+ if (ch === void 0) continue;
3428
+ if (ch.status === "approved") {
3429
+ this.state.currentNo = no;
3430
+ continue;
3431
+ }
3432
+ next = ch;
3433
+ break;
3434
+ }
3435
+ if (next === void 0) {
3436
+ this.state.status = "done";
3437
+ this.log(`生产单完成:${this.state.startNo}-${this.state.endNo} 章处理完毕`);
3438
+ this.persist();
3439
+ break;
3440
+ }
3441
+ this.state.currentNo = next.no;
3442
+ await this.processChapter(project, next);
3443
+ this.persist();
3444
+ }
3445
+ } catch (error) {
3446
+ if (this.state !== null) {
3447
+ this.state.status = "error";
3448
+ this.state.error = error.message;
3449
+ this.log(`生产单异常:${error.message}`);
3450
+ this.persist();
3451
+ }
3452
+ } finally {
3453
+ this.working = false;
3454
+ }
3455
+ }
3456
+ async processChapter(project, chapter) {
3457
+ if (chapter.status === "pending" || chapter.status === "error" || chapter.status === "written") await this.produce(project, chapter);
3458
+ else if (chapter.status === "rejected") await this.handleRejected(project, chapter);
3459
+ else if (chapter.status === "generating") {
3460
+ chapter.status = "pending";
3461
+ await this.produce(project, chapter);
3462
+ }
3463
+ }
3464
+ /** 完整质量门:生成 → 摘要+事实 → 伏笔标记 → 审稿 → 作者复盘。 */
3465
+ async produce(project, chapter) {
3466
+ const { ctx, getConfig } = this.deps;
3467
+ const config = getConfig();
3468
+ const outputDir = this.bookDir ?? config.outputDir;
3469
+ const no = chapter.no;
3470
+ const wasError = chapter.status === "error";
3471
+ chapter.status = "generating";
3472
+ chapter.error = void 0;
3473
+ chapter.generatingAt = (/* @__PURE__ */ new Date()).toISOString();
3474
+ mergeVolatileFromDisk(outputDir, project);
3475
+ saveProject(outputDir, project);
3476
+ this.log(wasError ? `第${no}章《${chapter.title}》重新生成…` : `第${no}章《${chapter.title}》生成…`);
3477
+ try {
3478
+ for await (const step of generateChapterStream(ctx, config, project, outputDir, no));
3479
+ try {
3480
+ await summarizeAndExtractFacts(ctx, config, project, outputDir, no);
3481
+ } catch (e) {
3482
+ console.warn("[dsh-novel-forge] run summary/facts:", e.message);
3483
+ }
3484
+ try {
3485
+ markForeshadowPlanted(project, outputDir, no);
3486
+ } catch (e) {
3487
+ console.warn("[dsh-novel-forge] run foreshadow:", e.message);
3488
+ }
3489
+ if (config.autoReview ?? true) {
3490
+ const report = await reviewChapter(ctx, config, project, outputDir, no);
3491
+ if (this.state !== null) this.state.stats[wasError ? "regenerated" : "generated"]++;
3492
+ this.log(`第${no}章 审稿 ${report.score}分 ${report.passed ? "✅ 通过" : "⚠️ 被拒"}`);
3493
+ } else {
3494
+ chapter.status = "approved";
3495
+ mergeVolatileFromDisk(outputDir, project);
3496
+ saveProject(outputDir, project);
3497
+ }
3498
+ if (config.autoAuthorReview ?? true) try {
3499
+ const body = readChapterFile(outputDir, chapter);
3500
+ if (body !== void 0) {
3501
+ let prevTail = "";
3502
+ if (no > 1) {
3503
+ const prev = project.chapters.find((c) => c.no === no - 1);
3504
+ if (prev !== void 0) prevTail = (readChapterFile(outputDir, prev) ?? "").replace(/^#.*$/m, "").trim().slice(-600);
3505
+ }
3506
+ const review = await authorReviewChapter(ctx, config, project, no, body, prevTail);
3507
+ chapter.authorReview = review;
3508
+ if (review.advancedLines !== void 0) autoLinkPlotlines(project, no, review.advancedLines);
3509
+ mergeVolatileFromDisk(outputDir, project);
3510
+ saveProject(outputDir, project);
3511
+ }
3512
+ } catch (e) {
3513
+ console.warn("[dsh-novel-forge] run author review:", e.message);
3514
+ }
3515
+ } catch (error) {
3516
+ chapter.status = "error";
3517
+ chapter.error = error.message;
3518
+ mergeVolatileFromDisk(outputDir, project);
3519
+ saveProject(outputDir, project);
3520
+ if (this.state !== null) this.state.stats.error++;
3521
+ this.log(`第${no}章 失败:${error.message}`);
3522
+ }
3523
+ }
3524
+ /** 被拒分级处理:无 high 豁免;有 high 按意见修订(最多 2 轮)+ 验证模式;仍不过 → 待人工。 */
3525
+ async handleRejected(project, chapter) {
3526
+ const { ctx, getConfig } = this.deps;
3527
+ const config = getConfig();
3528
+ const outputDir = this.bookDir ?? config.outputDir;
3529
+ const no = chapter.no;
3530
+ const highs = (chapter.review?.issues ?? []).filter((i) => i.severity === "high");
3531
+ if (highs.length === 0) {
3532
+ chapter.status = "approved";
3533
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3534
+ saveProject(outputDir, project);
3535
+ if (this.state !== null) this.state.stats.exempted++;
3536
+ this.log(`第${no}章 豁免通过(无 high)`);
3537
+ return;
3538
+ }
3539
+ this.log(`第${no}章 修订(${highs.length} 个 high)…`);
3540
+ for (let round = 1; round <= 2; round++) {
3541
+ const instr = "按审稿意见修订(优先处理):\n" + highs.map((h) => `[${h.severity}] ${h.item}${h.suggestion !== "" ? `\n建议:${h.suggestion}` : ""}`).join("\n\n");
3542
+ try {
3543
+ for await (const step of rewriteChapterStream(ctx, config, project, outputDir, no, instr, void 0));
3544
+ } catch (error) {
3545
+ this.log(`第${no}章 第${round}轮修订出错:${error.message}`);
3546
+ continue;
3547
+ }
3548
+ const fresh = loadProject(outputDir);
3549
+ const freshCh = fresh?.chapters.find((c) => c.no === no);
3550
+ const draft = freshCh?.pendingDraft;
3551
+ if (draft === void 0 || draft.length < 50) {
3552
+ this.log(`第${no}章 第${round}轮草稿缺失,重试`);
3553
+ continue;
3554
+ }
3555
+ const verify = await reviewChapterText(ctx, config, fresh, draft, chapter.review);
3556
+ const highs2 = (verify.issues ?? []).filter((i) => i.severity === "high");
3557
+ if (verify.passed || highs2.length === 0) {
3558
+ this.applyDraft(fresh, freshCh, draft, verify);
3559
+ if (this.state !== null) this.state.stats.revised++;
3560
+ this.log(`第${no}章 第${round}轮修订通过(${verify.score}分)`);
3561
+ return;
3562
+ }
3563
+ this.log(`第${no}章 第${round}轮仍不过(${highs2.length} high)`);
3564
+ }
3565
+ if (this.state !== null) this.state.pendingManual.push(no);
3566
+ this.log(`第${no}章 ⚠️ 两轮修订仍不过 → 保留草稿待人工`);
3567
+ }
3568
+ applyDraft(project, chapter, draft, report) {
3569
+ const config = this.deps.getConfig();
3570
+ const fileName = chapterFileName(chapter);
3571
+ mkdirSync(config.outputDir, { recursive: true });
3572
+ const targetPath = join(config.outputDir, fileName);
3573
+ if (existsSync(targetPath)) copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, "")}.bak.md`));
3574
+ writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, "utf8");
3575
+ chapter.pendingDraft = void 0;
3576
+ chapter.chars = draft.length;
3577
+ chapter.file = fileName;
3578
+ if (typeof report.score === "number") {
3579
+ chapter.review = report;
3580
+ chapter.status = report.passed === true ? "approved" : "rejected";
3581
+ } else {
3582
+ chapter.status = "written";
3583
+ chapter.review = void 0;
3584
+ }
3585
+ chapter.error = void 0;
3586
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3587
+ saveProject(config.outputDir, project);
3588
+ }
3589
+ };
3590
+ //#endregion
3591
+ //#region src/protocol.ts
3592
+ /**
3593
+ * dsh-novel-forge — shared protocol between the host half (Node) and the
3594
+ * browser half (web GUI). Route paths, request/response shapes, the project
3595
+ * state file format, and the NDJSON generation stream frames all live here so
3596
+ * both halves spell exactly one vocabulary.
3597
+ */
3598
+ /** The /api/dsh-novel-forge route family (same-origin, loopback-fenced). */
3599
+ const NOVEL_API = {
3600
+ status: "/api/dsh-novel-forge/status",
3601
+ loadOutline: "/api/dsh-novel-forge/load-outline",
3602
+ saveOutline: "/api/dsh-novel-forge/save-outline",
3603
+ plan: "/api/dsh-novel-forge/plan",
3604
+ volumes: "/api/dsh-novel-forge/volumes",
3605
+ bible: "/api/dsh-novel-forge/bible",
3606
+ assets: "/api/dsh-novel-forge/assets",
3607
+ styleEngine: "/api/dsh-novel-forge/style-engine",
3608
+ generate: "/api/dsh-novel-forge/generate",
3609
+ review: "/api/dsh-novel-forge/review",
3610
+ rewrite: "/api/dsh-novel-forge/rewrite",
3611
+ polish: "/api/dsh-novel-forge/polish",
3612
+ /** 采纳待确认草稿(润色/重写产物)覆盖正文文件。 */
3613
+ draftApply: "/api/dsh-novel-forge/draft/apply",
3614
+ /** 放弃待确认草稿,保留原稿。 */
3615
+ draftDiscard: "/api/dsh-novel-forge/draft/discard",
3616
+ summary: "/api/dsh-novel-forge/summary",
3617
+ foreshadow: "/api/dsh-novel-forge/foreshadow",
3618
+ exportBook: "/api/dsh-novel-forge/export",
3619
+ chapter: "/api/dsh-novel-forge/chapter",
3620
+ /** 审查任意正文文本(作者手动编辑后,不落盘)。 */
3621
+ chapterCheck: "/api/dsh-novel-forge/chapter/check",
3622
+ /** 保存手动编辑的正文(自动备份 .bak)。 */
3623
+ chapterSave: "/api/dsh-novel-forge/chapter/save",
3624
+ assistant: "/api/dsh-novel-forge/assistant",
3625
+ assistantHistory: "/api/dsh-novel-forge/assistant-history",
3626
+ /** 清空助手对话记录。 */
3627
+ assistantClear: "/api/dsh-novel-forge/assistant/clear",
3628
+ bookshelf: "/api/dsh-novel-forge/bookshelf",
3629
+ /** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
3630
+ reset: "/api/dsh-novel-forge/reset",
3631
+ /** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
3632
+ audit: "/api/dsh-novel-forge/audit",
3633
+ /** 角色卡刷新:基于事实库与各章摘要聚合角色当前状态。 */
3634
+ charactersRefresh: "/api/dsh-novel-forge/characters/refresh",
3635
+ /** 事实库回填:对历史已生成章节批量抽取事实(旧章节无事实记录时用)。 */
3636
+ factsBackfill: "/api/dsh-novel-forge/facts/backfill",
3637
+ /** 设定圣经局部修补(如世界观规则编辑)。 */
3638
+ biblePatch: "/api/dsh-novel-forge/bible/patch",
3639
+ /** 小说简介:生成(AI)/补全(AI)/保存。 */
3640
+ blurb: "/api/dsh-novel-forge/blurb",
3641
+ /** 重命名当前书(同步项目与书架条目)。 */
3642
+ rename: "/api/dsh-novel-forge/rename",
3643
+ /** 大世界:AI 提炼 / 保存结构化数据(境界/区域/势力)。 */
3644
+ world: "/api/dsh-novel-forge/world",
3645
+ /** 封面:GET 读取(dataUrl)/ POST 上传或移除。 */
3646
+ cover: "/api/dsh-novel-forge/blurb/cover",
3647
+ /** 剧情线管理:增删改 + 关联章节。 */
3648
+ plotlines: "/api/dsh-novel-forge/plotlines",
3649
+ /** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
3650
+ roles: "/api/dsh-novel-forge/roles",
3651
+ /** 作者复盘补跑:对已写章节补齐 authorReview(全书流式 / 单章 JSON)。 */
3652
+ reviewBackfill: "/api/dsh-novel-forge/review/backfill",
3653
+ /** 章节复位:generating 卡死 → pending(可重新生成)。 */
3654
+ chapterReset: "/api/dsh-novel-forge/chapter/reset",
3655
+ /** 章节直接通过:作者对 rejected/written 章节行使最终决定权。 */
3656
+ chapterApprove: "/api/dsh-novel-forge/chapter/approve",
3657
+ /** 敏感词检查:全书已写章节或指定文本。 */
3658
+ sensitiveCheck: "/api/dsh-novel-forge/sensitive-check",
3659
+ /** 开书想法 → AI 补全大纲:输入一句话想法,生成 2-3 个可选大纲方案。 */
3660
+ outlineSuggest: "/api/dsh-novel-forge/outline/suggest",
3661
+ /** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检(两阶段:源笔记→分节分析)。 */
3662
+ breakdown: "/api/dsh-novel-forge/breakdown",
3663
+ /** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(可适配豆包/Seedance/SD)。 */
3664
+ storyboard: "/api/dsh-novel-forge/storyboard",
3665
+ /** 漫剧分集计划:读一卷 → 按故事弧线分集(高潮拆集/过渡并章)。 */
3666
+ storyboardPlan: "/api/dsh-novel-forge/storyboard/plan",
3667
+ /** 生产单:启动批量生产(计划补足 + 逐章生成 + 被拒分级处理)。 */
3668
+ runStart: "/api/dsh-novel-forge/run/start",
3669
+ /** 生产单控制:pause / resume / stop。 */
3670
+ runControl: "/api/dsh-novel-forge/run/control",
3671
+ /** 生产单状态(含进度统计与日志)。 */
3672
+ runStatus: "/api/dsh-novel-forge/run/status",
3673
+ config: "/api/dsh-novel-forge/config",
3674
+ openFolder: "/api/dsh-novel-forge/open-folder"
3675
+ };
3676
+ //#endregion
3677
+ //#region src/docx.ts
3678
+ /**
3679
+ * docx outline extraction: a .docx is a zip whose word/document.xml holds the
3680
+ * body text in <w:t> runs inside <w:p> paragraphs. We unzip with fflate and
3681
+ * walk the XML with a tiny tokenizer — no heavyweight XML/DOM dependency.
3682
+ */
3683
+ /** Decode the handful of XML entities docx bodies actually use. */
3684
+ function decodeEntities(text) {
3685
+ return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&apos;/g, "'").replace(/&amp;/g, "&").replace(/&nbsp;/g, " ");
3686
+ }
3687
+ /**
3688
+ * Extract plain text from a docx buffer: one line per <w:p> paragraph, with
3689
+ * <w:tab>/<w:br> preserved as whitespace. Tables and nested structures are
3690
+ * flattened in document order (their paragraphs are just <w:p> too).
3691
+ * @param buffer - the raw .docx bytes.
3692
+ * @returns the body text.
2754
3693
  */
2755
- async function extractStyleAsset(ctx, config, sampleText) {
2756
- const user = `请分析下面这段样本文本,提炼其叙事风格规则:\n\n${sampleText}`;
2757
- const raw = parseJsonObject(await complete(ctx, config, {
2758
- system: styleEngineSystemPrompt(),
2759
- user,
2760
- temperature: .3,
2761
- maxTokens: Math.max(config.maxTokens, 12e3)
2762
- }));
2763
- const strArray = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string" && v.trim() !== "") : [];
2764
- const result = {
2765
- proseRules: strArray(raw.proseRules),
2766
- dialogueRules: strArray(raw.dialogueRules),
2767
- descriptionRules: strArray(raw.descriptionRules),
2768
- boundaries: strArray(raw.boundaries)
2769
- };
2770
- if (result.proseRules.length + result.dialogueRules.length + result.descriptionRules.length + result.boundaries.length === 0) throw new Error("写法提取失败:模型没有返回有效规则");
2771
- return result;
3694
+ function extractDocxText(buffer) {
3695
+ let files;
3696
+ try {
3697
+ files = unzipSync(buffer);
3698
+ } catch (error) {
3699
+ throw new Error(`not a valid docx (zip open failed): ${error.message}`);
3700
+ }
3701
+ const document = files["word/document.xml"];
3702
+ if (document === void 0) throw new Error("not a valid docx (word/document.xml missing)");
3703
+ const xml = strFromU8(document);
3704
+ const paragraphs = [];
3705
+ const parts = xml.split(/<w:p\b[^>]*>/);
3706
+ for (let i = 1; i < parts.length; i++) {
3707
+ const segment = parts[i];
3708
+ const runs = [];
3709
+ for (const match of segment.matchAll(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>|<w:tab\b[^>]*\/>|<w:br\b[^>]*\/>/g)) if (match[0].startsWith("<w:tab")) runs.push(" ");
3710
+ else if (match[0].startsWith("<w:br")) runs.push("\n");
3711
+ else runs.push(decodeEntities(match[1] ?? ""));
3712
+ const line = runs.join("").replace(/\u00a0/g, " ").trimEnd();
3713
+ paragraphs.push(line);
3714
+ }
3715
+ const text = paragraphs.join("\n").replace(/\n{3,}/g, "\n\n").trim();
3716
+ if (text.length === 0) throw new Error("docx contains no extractable text");
3717
+ return text;
2772
3718
  }
2773
- /** Export the whole book as one txt/md file. */
2774
- function exportBook(outputDir, project, format) {
2775
- const parts = [];
2776
- if (format === "md") parts.push(`# ${project.bookName}\n`);
2777
- else parts.push(project.bookName, "");
2778
- const done = project.chapters.filter((c) => c.file !== void 0);
2779
- for (const chapter of done) {
2780
- const body = readChapterFile(outputDir, chapter) ?? "";
2781
- if (format === "md") parts.push(`\n## 第${chapter.no}章 ${chapter.title}\n`, body.trim(), "");
2782
- else parts.push("", `第${chapter.no}章 ${chapter.title}`, "", body.trim(), "");
3719
+ /**
3720
+ * Read and extract a docx outline from disk.
3721
+ * @param path - absolute path to the .docx file.
3722
+ * @returns the extracted outline text.
3723
+ */
3724
+ function readOutlineFromDocx(path) {
3725
+ let buffer;
3726
+ try {
3727
+ buffer = readFileSync(path);
3728
+ } catch (error) {
3729
+ throw new Error(`cannot read outline file "${path}": ${error.message}`);
2783
3730
  }
2784
- const content = parts.join("\n");
2785
- const ext = format === "md" ? "md" : "txt";
2786
- const file = `《${safeFileName(project.bookName)}》全本.${ext}`;
2787
- writeFileSync(join(outputDir, file), content, "utf8");
2788
- return {
2789
- file,
2790
- chars: content.length,
2791
- chapters: done.length
2792
- };
3731
+ return extractDocxText(new Uint8Array(buffer));
2793
3732
  }
2794
3733
  //#endregion
2795
3734
  //#region src/assistant.ts
@@ -3476,185 +4415,52 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
3476
4415
  name,
3477
4416
  status: "error",
3478
4417
  detail: error.message
3479
- };
3480
- }
3481
- if (prose !== "") {
3482
- history.push({
3483
- role: "assistant",
3484
- content: prose,
3485
- ts: (/* @__PURE__ */ new Date()).toISOString()
3486
- });
3487
- appendHistory(outputDir, {
3488
- role: "assistant",
3489
- content: prose,
3490
- ts: (/* @__PURE__ */ new Date()).toISOString()
3491
- });
3492
- }
3493
- history.push({
3494
- role: "tool",
3495
- content: result,
3496
- tool: name,
3497
- ts: (/* @__PURE__ */ new Date()).toISOString()
3498
- });
3499
- appendHistory(outputDir, {
3500
- role: "tool",
3501
- content: result,
3502
- tool: name,
3503
- ts: (/* @__PURE__ */ new Date()).toISOString()
3504
- });
3505
- round++;
3506
- if (round >= MAX_TOOL_ROUNDS) {
3507
- const message = `(已连续执行 ${round} 次修改操作,本轮停止。如需继续请再说。)`;
3508
- history.push({
3509
- role: "assistant",
3510
- content: message,
3511
- ts: (/* @__PURE__ */ new Date()).toISOString()
3512
- });
3513
- appendHistory(outputDir, {
3514
- role: "assistant",
3515
- content: message,
3516
- ts: (/* @__PURE__ */ new Date()).toISOString()
3517
- });
3518
- yield {
3519
- frame: "delta",
3520
- text: message
3521
- };
3522
- return;
3523
- }
3524
- }
3525
- }
3526
- //#endregion
3527
- //#region src/bookshelf.ts
3528
- /**
3529
- * 书架(Bookshelf)— 多书管理:一本书记录一个独立输出目录。
3530
- * 状态持久化到 ~/.dsh/dsh-novel-forge-bookshelf.json(跟随 dsh 配置惯例)。
3531
- */
3532
- /** 书架配置文件路径。 */
3533
- function bookshelfFile() {
3534
- return join(homedir(), ".dsh", "dsh-novel-forge-bookshelf.json");
3535
- }
3536
- function defaultStore() {
3537
- return {
3538
- books: [],
3539
- activeBookId: null
3540
- };
3541
- }
3542
- /** 读取书架(无则返回空)。 */
3543
- function loadBookshelf() {
3544
- const file = bookshelfFile();
3545
- if (!existsSync(file)) return defaultStore();
3546
- try {
3547
- let raw = readFileSync(file, "utf8");
3548
- if (raw.charCodeAt(0) === 65279) raw = raw.slice(1);
3549
- const parsed = JSON.parse(raw);
3550
- if (!Array.isArray(parsed.books)) return defaultStore();
3551
- return {
3552
- books: parsed.books,
3553
- activeBookId: parsed.activeBookId ?? null
3554
- };
3555
- } catch {
3556
- return defaultStore();
3557
- }
3558
- }
3559
- /** 持久化书架。 */
3560
- function saveBookshelf(store) {
3561
- const file = bookshelfFile();
3562
- mkdirSync(join(homedir(), ".dsh"), { recursive: true });
3563
- writeFileSync(file, JSON.stringify(store, null, 2), "utf8");
3564
- }
3565
- /** 当前激活的书。 */
3566
- function activeBook(store) {
3567
- return store.books.find((b) => b.id === store.activeBookId);
3568
- }
3569
- /** 书架快照(含每本书的进度摘要)。 */
3570
- function bookshelfSnapshot(store) {
3571
- return {
3572
- books: store.books.map((book) => {
3573
- const project = loadProject(book.outputDir);
3574
- const done = project === void 0 ? 0 : project.chapters.filter((c) => c.status === "approved" || c.status === "written" || c.status === "rejected").length;
3575
- const hasCover = project?.coverPath !== void 0 && project.coverPath !== "" && existsSync(join(book.outputDir, project.coverPath));
3576
- return {
3577
- ...book,
3578
- done,
3579
- total: project?.chapters.length ?? 0,
3580
- hasProject: project !== void 0,
3581
- hasCover,
3582
- blurb: project?.blurb
3583
- };
3584
- }),
3585
- activeBookId: store.activeBookId
3586
- };
3587
- }
3588
- /** 新建一本书(自动成为当前书)。 */
3589
- function createBook(bookName, outputDir) {
3590
- const store = loadBookshelf();
3591
- const id = `book-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
3592
- const now = (/* @__PURE__ */ new Date()).toISOString();
3593
- const book = {
3594
- id,
3595
- bookName,
3596
- outputDir,
3597
- createdAt: now,
3598
- updatedAt: now
3599
- };
3600
- store.books.push(book);
3601
- store.activeBookId = id;
3602
- saveBookshelf(store);
3603
- return book;
3604
- }
3605
- /** 更新某本书的书名(开书向导导入大纲后书名以大纲首行为准)。 */
3606
- function renameBook(id, bookName) {
3607
- const store = loadBookshelf();
3608
- const book = store.books.find((b) => b.id === id);
3609
- if (book === void 0) return false;
3610
- book.bookName = bookName;
3611
- book.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3612
- saveBookshelf(store);
3613
- return true;
3614
- }
3615
- /**
3616
- * 播种:书架为空时,把指定输出目录下已有的项目自动登记为第一本书。
3617
- * 兼容升级场景 —— 旧版插件直接在输出目录写项目,从未登记书架。
3618
- * @param outputDir - 候选输出目录(通常为 settings 的默认输出目录)。
3619
- * @returns 是否发生了播种。
3620
- */
3621
- function seedBookshelfFromOutputDir(outputDir) {
3622
- if (loadBookshelf().books.length > 0) return false;
3623
- if (!existsSync(outputDir)) return false;
3624
- const hasProject = existsSync(join(outputDir, "novel-project.json"));
3625
- const hasChapters = existsSync(outputDir);
3626
- if (!hasProject && !hasChapters) return false;
3627
- createBook(loadProject(outputDir)?.bookName ?? outputDir.split(/[\\/]/).pop() ?? "未命名小说", outputDir);
3628
- return true;
3629
- }
3630
- /** 激活一本书。 */
3631
- function activateBook(id) {
3632
- const store = loadBookshelf();
3633
- const book = store.books.find((b) => b.id === id);
3634
- if (book === void 0) return void 0;
3635
- store.activeBookId = id;
3636
- book.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3637
- saveBookshelf(store);
3638
- return book;
3639
- }
3640
- /** 移除一本书。 */
3641
- function removeBook(id) {
3642
- const store = loadBookshelf();
3643
- const idx = store.books.findIndex((b) => b.id === id);
3644
- if (idx === -1) return false;
3645
- store.books.splice(idx, 1);
3646
- if (store.activeBookId === id) store.activeBookId = store.books[0]?.id ?? null;
3647
- saveBookshelf(store);
3648
- return true;
3649
- }
3650
- /** 当前书输出目录(无书架则 undefined,回退 settings)。 */
3651
- function activeBookOutputDir() {
3652
- return activeBook(loadBookshelf())?.outputDir;
3653
- }
3654
- /** 默认输出目录推断:桌面/书名。 */
3655
- function defaultOutputDirFor(bookName) {
3656
- const clean = bookName.replace(/[\\/:*?"<>|]/g, "").trim().slice(0, 40) || "未命名小说";
3657
- return join(homedir(), "Desktop", clean);
4418
+ };
4419
+ }
4420
+ if (prose !== "") {
4421
+ history.push({
4422
+ role: "assistant",
4423
+ content: prose,
4424
+ ts: (/* @__PURE__ */ new Date()).toISOString()
4425
+ });
4426
+ appendHistory(outputDir, {
4427
+ role: "assistant",
4428
+ content: prose,
4429
+ ts: (/* @__PURE__ */ new Date()).toISOString()
4430
+ });
4431
+ }
4432
+ history.push({
4433
+ role: "tool",
4434
+ content: result,
4435
+ tool: name,
4436
+ ts: (/* @__PURE__ */ new Date()).toISOString()
4437
+ });
4438
+ appendHistory(outputDir, {
4439
+ role: "tool",
4440
+ content: result,
4441
+ tool: name,
4442
+ ts: (/* @__PURE__ */ new Date()).toISOString()
4443
+ });
4444
+ round++;
4445
+ if (round >= MAX_TOOL_ROUNDS) {
4446
+ const message = `(已连续执行 ${round} 次修改操作,本轮停止。如需继续请再说。)`;
4447
+ history.push({
4448
+ role: "assistant",
4449
+ content: message,
4450
+ ts: (/* @__PURE__ */ new Date()).toISOString()
4451
+ });
4452
+ appendHistory(outputDir, {
4453
+ role: "assistant",
4454
+ content: message,
4455
+ ts: (/* @__PURE__ */ new Date()).toISOString()
4456
+ });
4457
+ yield {
4458
+ frame: "delta",
4459
+ text: message
4460
+ };
4461
+ return;
4462
+ }
4463
+ }
3658
4464
  }
3659
4465
  //#endregion
3660
4466
  //#region src/routes.ts
@@ -3975,6 +4781,12 @@ function makeRoutes(deps) {
3975
4781
  } catch (error) {
3976
4782
  console.warn("[dsh-novel-forge] summary/facts failed:", error.message);
3977
4783
  }
4784
+ try {
4785
+ const marked = markForeshadowPlanted(project, config.outputDir, no);
4786
+ if (marked > 0) console.log(`[dsh-novel-forge] 第${no}章已埋伏笔 ${marked} 条`);
4787
+ } catch (error) {
4788
+ console.warn("[dsh-novel-forge] markForeshadowPlanted failed:", error.message);
4789
+ }
3978
4790
  if (!(body?.skipReview === true) && (config.autoReview ?? true)) send({
3979
4791
  type: "review",
3980
4792
  no,
@@ -4169,17 +4981,24 @@ function makeRoutes(deps) {
4169
4981
  if (existsSync(targetPath)) copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, "")}.bak.md`));
4170
4982
  writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, "utf8");
4171
4983
  chapter.pendingDraft = void 0;
4172
- chapter.status = "written";
4173
4984
  chapter.chars = draft.length;
4174
4985
  chapter.file = fileName;
4175
- chapter.review = void 0;
4986
+ const carried = body?.report;
4987
+ if (carried !== void 0 && typeof carried.score === "number") {
4988
+ chapter.review = carried;
4989
+ chapter.status = carried.passed ? "approved" : "rejected";
4990
+ } else {
4991
+ chapter.status = "written";
4992
+ chapter.review = void 0;
4993
+ }
4176
4994
  chapter.error = void 0;
4177
4995
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4178
4996
  saveProject(config.outputDir, project);
4179
4997
  writeJson(res, 200, {
4180
4998
  ok: true,
4181
4999
  chars: draft.length,
4182
- file: fileName
5000
+ file: fileName,
5001
+ markdown: draft
4183
5002
  });
4184
5003
  }
4185
5004
  };
@@ -4336,13 +5155,14 @@ function makeRoutes(deps) {
4336
5155
  const config = getConfig();
4337
5156
  const project = requireProject(res);
4338
5157
  if (project === void 0) return;
4339
- const text = (await readJsonBody(req))?.text?.trim() ?? "";
5158
+ const body = await readJsonBody(req);
5159
+ const text = body?.text?.trim() ?? "";
4340
5160
  if (text.length < 50) {
4341
5161
  writeJson(res, 400, { error: "正文过短(<50 字),请先编辑内容" });
4342
5162
  return;
4343
5163
  }
4344
5164
  try {
4345
- writeJson(res, 200, { report: await reviewChapterText(ctx, config, project, text) });
5165
+ writeJson(res, 200, { report: await reviewChapterText(ctx, config, project, text, body?.previousReport) });
4346
5166
  } catch (error) {
4347
5167
  writeJson(res, 500, { error: error.message });
4348
5168
  }
@@ -5087,6 +5907,31 @@ function makeRoutes(deps) {
5087
5907
  if (idx === -1) project.roles.push(r);
5088
5908
  else project.roles[idx] = r;
5089
5909
  } else if (op === "remove" && body?.name !== void 0) project.roles = project.roles.filter((x) => x.name !== body.name);
5910
+ else if (op === "visual") {
5911
+ const name = body?.name?.trim();
5912
+ if (name === void 0 || name === "") {
5913
+ writeJson(res, 400, { error: "name(角色名)必填" });
5914
+ return;
5915
+ }
5916
+ try {
5917
+ const visual = await extractRoleVisual(ctx, config, project, config.outputDir, name);
5918
+ const role = project.roles.find((r) => r.name === name);
5919
+ if (role !== void 0) role.imagePrompt = visual;
5920
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5921
+ saveProject(config.outputDir, project);
5922
+ writeJson(res, 200, {
5923
+ roles: project.roles,
5924
+ visual
5925
+ });
5926
+ return;
5927
+ } catch (error) {
5928
+ writeJson(res, 500, { error: `形象提炼失败:${error.message}` });
5929
+ return;
5930
+ }
5931
+ } else {
5932
+ writeJson(res, 400, { error: "未知的 roles op" });
5933
+ return;
5934
+ }
5090
5935
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5091
5936
  saveProject(config.outputDir, project);
5092
5937
  writeJson(res, 200, { roles: project.roles });
@@ -5150,6 +5995,208 @@ function makeRoutes(deps) {
5150
5995
  });
5151
5996
  }
5152
5997
  };
5998
+ /** 作者复盘补跑:对已写章节补齐 authorReview(body.chapterNo=单章 JSON,缺省=全书 NDJSON 流)。 */
5999
+ const reviewBackfillRoute = {
6000
+ kind: "exact",
6001
+ path: NOVEL_API.reviewBackfill,
6002
+ handler: async (req, res) => {
6003
+ if (!guard(req, res, "POST")) return;
6004
+ const config = getConfig();
6005
+ const project = requireProject(res);
6006
+ if (project === void 0) return;
6007
+ const body = await readJsonBody(req);
6008
+ /** 对一章执行作者复盘(读取已落盘正文,不改变章节状态/正文)。 */
6009
+ const runOne = async (chapter) => {
6010
+ const currentBody = readChapterFile(config.outputDir, chapter);
6011
+ if (currentBody === void 0) throw new Error(`章节 ${chapter.no} 的正文文件不存在`);
6012
+ let prevTail = "";
6013
+ if (chapter.no > 1) {
6014
+ const prev = project.chapters.find((c) => c.no === chapter.no - 1);
6015
+ if (prev !== void 0) prevTail = (readChapterFile(config.outputDir, prev) ?? "").replace(/^#.*$/m, "").trim().slice(-600);
6016
+ }
6017
+ return authorReviewChapter(ctx, config, project, chapter.no, currentBody, prevTail);
6018
+ };
6019
+ if (typeof body?.chapterNo === "number" && body.chapterNo > 0) {
6020
+ const chapter = project.chapters.find((c) => c.no === body.chapterNo);
6021
+ if (chapter === void 0) {
6022
+ writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` });
6023
+ return;
6024
+ }
6025
+ if (chapter.status === "pending") {
6026
+ writeJson(res, 400, { error: "该章尚未生成正文,无法复盘" });
6027
+ return;
6028
+ }
6029
+ try {
6030
+ const review = await runOne(chapter);
6031
+ chapter.authorReview = review;
6032
+ if (review.advancedLines !== void 0) autoLinkPlotlines(project, chapter.no, review.advancedLines);
6033
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
6034
+ saveProject(config.outputDir, project);
6035
+ writeJson(res, 200, {
6036
+ no: chapter.no,
6037
+ review
6038
+ });
6039
+ return;
6040
+ } catch (error) {
6041
+ writeJson(res, 500, { error: error.message });
6042
+ return;
6043
+ }
6044
+ }
6045
+ const missing = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.authorReview === void 0);
6046
+ if (missing.length === 0) {
6047
+ writeJson(res, 200, { count: 0 });
6048
+ return;
6049
+ }
6050
+ res.writeHead(200, {
6051
+ "content-type": "application/x-ndjson; charset=utf-8",
6052
+ "cache-control": "no-cache",
6053
+ "referrer-policy": "no-referrer"
6054
+ });
6055
+ const send = (frame) => {
6056
+ res.write(JSON.stringify(frame) + "\n");
6057
+ };
6058
+ let done = 0;
6059
+ for (const chapter of missing) try {
6060
+ const review = await runOne(chapter);
6061
+ chapter.authorReview = review;
6062
+ if (review.advancedLines !== void 0) autoLinkPlotlines(project, chapter.no, review.advancedLines);
6063
+ done++;
6064
+ saveProject(config.outputDir, project);
6065
+ send({
6066
+ type: "author-review",
6067
+ no: chapter.no,
6068
+ review
6069
+ });
6070
+ } catch (error) {
6071
+ console.warn(`[dsh-novel-forge] author backfill ch.${chapter.no} failed:`, error.message);
6072
+ }
6073
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
6074
+ saveProject(config.outputDir, project);
6075
+ send({
6076
+ type: "author-backfill-done",
6077
+ count: done
6078
+ });
6079
+ res.end();
6080
+ }
6081
+ };
6082
+ const configRoute = {
6083
+ kind: "exact",
6084
+ path: NOVEL_API.config,
6085
+ handler: async (req, res) => {
6086
+ if (!guard(req, res, "POST")) return;
6087
+ const body = await readJsonBody(req);
6088
+ if (body === void 0) {
6089
+ writeJson(res, 400, { error: "无效的配置 JSON" });
6090
+ return;
6091
+ }
6092
+ try {
6093
+ writeJson(res, 200, { config: await patchConfig(body) });
6094
+ } catch (error) {
6095
+ writeJson(res, 400, { error: error.message });
6096
+ }
6097
+ }
6098
+ };
6099
+ const openFolderRoute = {
6100
+ kind: "exact",
6101
+ path: NOVEL_API.openFolder,
6102
+ handler: async (req, res) => {
6103
+ if (!guard(req, res, "POST")) return;
6104
+ const dir = getConfig().outputDir;
6105
+ exec(`explorer "${dir.replace(/"/g, "")}"`, (error) => {
6106
+ if (error) writeJson(res, 500, {
6107
+ ok: false,
6108
+ error: error.message
6109
+ });
6110
+ else writeJson(res, 200, { ok: true });
6111
+ });
6112
+ }
6113
+ };
6114
+ /** 开书想法 → AI 大纲:2-3 个可选方案(支持暂留换批:count 只补空槽 + exclude 避开已留方向)。 */
6115
+ const outlineSuggestRoute = {
6116
+ kind: "exact",
6117
+ path: NOVEL_API.outlineSuggest,
6118
+ handler: async (req, res) => {
6119
+ if (!guard(req, res, "POST")) return;
6120
+ const config = getConfig();
6121
+ const body = await readJsonBody(req);
6122
+ const idea = body?.idea?.trim() ?? "";
6123
+ if (idea.length < 50) {
6124
+ writeJson(res, 400, { error: "想法太短(<50 字),请多写一两句:主角是谁、什么世界、想要什么爽点" });
6125
+ return;
6126
+ }
6127
+ const count = body?.count !== void 0 ? Math.max(1, Math.min(3, Math.floor(body.count))) : 3;
6128
+ const exclude = Array.isArray(body?.exclude) ? body.exclude.filter((e) => typeof e === "string" && e.trim() !== "").map((e) => e.trim().slice(0, 200)) : [];
6129
+ try {
6130
+ writeJson(res, 200, { candidates: await suggestOutlines(ctx, config, idea, count, exclude) });
6131
+ } catch (error) {
6132
+ writeJson(res, 500, { error: `大纲方案生成失败:${error.message}` });
6133
+ }
6134
+ }
6135
+ };
6136
+ /** 拆书分析:对已写章节做结构/人物/文风/卖点体检(两阶段:源笔记→分节分析)。 */
6137
+ const breakdownRoute = {
6138
+ kind: "exact",
6139
+ path: NOVEL_API.breakdown,
6140
+ handler: async (req, res) => {
6141
+ if (!guard(req, res, "POST")) return;
6142
+ const config = getConfig();
6143
+ const project = requireProject(res);
6144
+ if (project === void 0) return;
6145
+ const body = await readJsonBody(req);
6146
+ try {
6147
+ writeJson(res, 200, await breakdownBook(ctx, config, project, config.outputDir, body?.scope ?? "recent", body?.preset ?? "quick", body?.budgetTokens ?? 5e4));
6148
+ } catch (error) {
6149
+ writeJson(res, 500, { error: `拆书分析失败:${error.message}` });
6150
+ }
6151
+ }
6152
+ };
6153
+ /** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(适配豆包/Seedance/SD)。 */
6154
+ const storyboardRoute = {
6155
+ kind: "exact",
6156
+ path: NOVEL_API.storyboard,
6157
+ handler: async (req, res) => {
6158
+ if (!guard(req, res, "POST")) return;
6159
+ const config = getConfig();
6160
+ const project = requireProject(res);
6161
+ if (project === void 0) return;
6162
+ const body = await readJsonBody(req);
6163
+ if (!Number.isInteger(body?.chapterNo)) {
6164
+ writeJson(res, 400, { error: "chapterNo 须为正整数" });
6165
+ return;
6166
+ }
6167
+ try {
6168
+ writeJson(res, 200, await generateStoryboard(ctx, config, project, config.outputDir, body.chapterNo, body?.genre ?? "", body?.platform ?? "抖音", body?.tool ?? "doubao"));
6169
+ } catch (error) {
6170
+ writeJson(res, 500, { error: `分镜生成失败:${error.message}` });
6171
+ }
6172
+ }
6173
+ };
6174
+ /** 漫剧分集计划:读一卷 → 按故事弧线分集(高潮拆集/过渡并章)。 */
6175
+ const storyboardPlanRoute = {
6176
+ kind: "exact",
6177
+ path: NOVEL_API.storyboardPlan,
6178
+ handler: async (req, res) => {
6179
+ if (!guard(req, res, "POST")) return;
6180
+ const config = getConfig();
6181
+ const project = requireProject(res);
6182
+ if (project === void 0) return;
6183
+ const body = await readJsonBody(req);
6184
+ if (!Number.isInteger(body?.volumeNo) || (body?.volumeNo ?? 0) < 1) {
6185
+ writeJson(res, 400, { error: "volumeNo 须为正整数" });
6186
+ return;
6187
+ }
6188
+ try {
6189
+ writeJson(res, 200, await planStoryboardEpisodes(ctx, config, project, body.volumeNo, body?.platform ?? "抖音", body?.maxEpisodes ?? 25));
6190
+ } catch (error) {
6191
+ writeJson(res, 500, { error: `分集计划生成失败:${error.message}` });
6192
+ }
6193
+ }
6194
+ };
6195
+ /** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
6196
+ const runner = new ProductionRunner({
6197
+ ctx,
6198
+ getConfig
6199
+ });
5153
6200
  return [
5154
6201
  statusRoute,
5155
6202
  loadOutlineRoute,
@@ -5189,121 +6236,67 @@ function makeRoutes(deps) {
5189
6236
  plotlinesRoute,
5190
6237
  rolesRoute,
5191
6238
  sensitiveRoute,
6239
+ reviewBackfillRoute,
6240
+ chapterResetRoute,
6241
+ chapterApproveRoute,
6242
+ configRoute,
6243
+ openFolderRoute,
6244
+ outlineSuggestRoute,
6245
+ breakdownRoute,
6246
+ storyboardRoute,
6247
+ storyboardPlanRoute,
5192
6248
  {
5193
6249
  kind: "exact",
5194
- path: NOVEL_API.reviewBackfill,
6250
+ path: NOVEL_API.runStart,
5195
6251
  handler: async (req, res) => {
5196
6252
  if (!guard(req, res, "POST")) return;
5197
- const config = getConfig();
5198
- const project = requireProject(res);
5199
- if (project === void 0) return;
5200
6253
  const body = await readJsonBody(req);
5201
- /** 对一章执行作者复盘(读取已落盘正文,不改变章节状态/正文)。 */
5202
- const runOne = async (chapter) => {
5203
- const currentBody = readChapterFile(config.outputDir, chapter);
5204
- if (currentBody === void 0) throw new Error(`章节 ${chapter.no} 的正文文件不存在`);
5205
- let prevTail = "";
5206
- if (chapter.no > 1) {
5207
- const prev = project.chapters.find((c) => c.no === chapter.no - 1);
5208
- if (prev !== void 0) prevTail = (readChapterFile(config.outputDir, prev) ?? "").replace(/^#.*$/m, "").trim().slice(-600);
5209
- }
5210
- return authorReviewChapter(ctx, config, project, chapter.no, currentBody, prevTail);
5211
- };
5212
- if (typeof body?.chapterNo === "number" && body.chapterNo > 0) {
5213
- const chapter = project.chapters.find((c) => c.no === body.chapterNo);
5214
- if (chapter === void 0) {
5215
- writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` });
5216
- return;
5217
- }
5218
- if (chapter.status === "pending") {
5219
- writeJson(res, 400, { error: "该章尚未生成正文,无法复盘" });
5220
- return;
5221
- }
5222
- try {
5223
- const review = await runOne(chapter);
5224
- chapter.authorReview = review;
5225
- if (review.advancedLines !== void 0) autoLinkPlotlines(project, chapter.no, review.advancedLines);
5226
- project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5227
- saveProject(config.outputDir, project);
5228
- writeJson(res, 200, {
5229
- no: chapter.no,
5230
- review
5231
- });
5232
- return;
5233
- } catch (error) {
5234
- writeJson(res, 500, { error: error.message });
5235
- return;
5236
- }
6254
+ const project = loadProject(getConfig().outputDir);
6255
+ if (project === void 0) {
6256
+ writeJson(res, 400, { error: "输出目录中没有项目" });
6257
+ return;
5237
6258
  }
5238
- const missing = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.authorReview === void 0);
5239
- if (missing.length === 0) {
5240
- writeJson(res, 200, { count: 0 });
6259
+ const startNo = Number.isInteger(body?.startNo) && body.startNo >= 1 ? body.startNo : 1;
6260
+ let endNo;
6261
+ if (Number.isInteger(body?.endNo) && body.endNo >= startNo) endNo = body.endNo;
6262
+ else if (Number.isInteger(body?.count) && body.count >= 1 && body.count <= 200) endNo = Math.max(0, ...project.chapters.map((c) => c.no)) + body.count;
6263
+ else {
6264
+ writeJson(res, 400, { error: "请提供 endNo 或 count(1-200)" });
5241
6265
  return;
5242
6266
  }
5243
- res.writeHead(200, {
5244
- "content-type": "application/x-ndjson; charset=utf-8",
5245
- "cache-control": "no-cache",
5246
- "referrer-policy": "no-referrer"
5247
- });
5248
- const send = (frame) => {
5249
- res.write(JSON.stringify(frame) + "\n");
5250
- };
5251
- let done = 0;
5252
- for (const chapter of missing) try {
5253
- const review = await runOne(chapter);
5254
- chapter.authorReview = review;
5255
- if (review.advancedLines !== void 0) autoLinkPlotlines(project, chapter.no, review.advancedLines);
5256
- done++;
5257
- saveProject(config.outputDir, project);
5258
- send({
5259
- type: "author-review",
5260
- no: chapter.no,
5261
- review
5262
- });
6267
+ try {
6268
+ writeJson(res, 200, await runner.start(startNo, endNo));
5263
6269
  } catch (error) {
5264
- console.warn(`[dsh-novel-forge] author backfill ch.${chapter.no} failed:`, error.message);
6270
+ writeJson(res, 409, { error: error.message });
5265
6271
  }
5266
- project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5267
- saveProject(config.outputDir, project);
5268
- send({
5269
- type: "author-backfill-done",
5270
- count: done
5271
- });
5272
- res.end();
5273
6272
  }
5274
6273
  },
5275
- chapterResetRoute,
5276
- chapterApproveRoute,
5277
6274
  {
5278
6275
  kind: "exact",
5279
- path: NOVEL_API.config,
6276
+ path: NOVEL_API.runControl,
5280
6277
  handler: async (req, res) => {
5281
6278
  if (!guard(req, res, "POST")) return;
5282
6279
  const body = await readJsonBody(req);
5283
- if (body === void 0) {
5284
- writeJson(res, 400, { error: "无效的配置 JSON" });
6280
+ if (runner.status() === null) {
6281
+ writeJson(res, 400, { error: "没有生产单" });
5285
6282
  return;
5286
6283
  }
5287
- try {
5288
- writeJson(res, 200, { config: await patchConfig(body) });
5289
- } catch (error) {
5290
- writeJson(res, 400, { error: error.message });
6284
+ if (body?.action === "pause") runner.pause();
6285
+ else if (body?.action === "resume") runner.resume();
6286
+ else if (body?.action === "stop") runner.stop();
6287
+ else {
6288
+ writeJson(res, 400, { error: "action 须为 pause / resume / stop" });
6289
+ return;
5291
6290
  }
6291
+ writeJson(res, 200, runner.status());
5292
6292
  }
5293
6293
  },
5294
6294
  {
5295
6295
  kind: "exact",
5296
- path: NOVEL_API.openFolder,
5297
- handler: async (req, res) => {
5298
- if (!guard(req, res, "POST")) return;
5299
- const dir = getConfig().outputDir;
5300
- exec(`explorer "${dir.replace(/"/g, "")}"`, (error) => {
5301
- if (error) writeJson(res, 500, {
5302
- ok: false,
5303
- error: error.message
5304
- });
5305
- else writeJson(res, 200, { ok: true });
5306
- });
6296
+ path: NOVEL_API.runStatus,
6297
+ handler: (req, res) => {
6298
+ if (!guard(req, res, "GET")) return;
6299
+ writeJson(res, 200, runner.status());
5307
6300
  }
5308
6301
  }
5309
6302
  ];
@@ -5327,20 +6320,21 @@ const NOVEL_SETTINGS_NAMESPACE = settingsNamespace("dsh-novel-forge");
5327
6320
  const Config = z.object({
5328
6321
  announceToAgent: z.boolean().default(true),
5329
6322
  enabled: z.boolean().default(true),
5330
- outlinePath: z.string().default("C:\\Users\\Ryan\\Desktop\\《归墟玉主》全书大纲_重新排版版.docx"),
5331
- outputDir: z.string().default("C:\\Users\\Ryan\\Desktop\\归墟玉主"),
6323
+ outlinePath: z.string().default("D:\\ryan work\\harness\\novels\\归墟玉主\\《归墟玉主》全书大纲_重新排版版.docx"),
6324
+ outputDir: z.string().default("D:\\ryan work\\harness\\novels\\归墟玉主"),
5332
6325
  provider: z.string().default("deepseek-official"),
5333
6326
  model: z.string().default("deepseek-v4-flash"),
5334
6327
  chapterChars: z.number().default(3500),
5335
6328
  maxTokens: z.number().default(12e3),
5336
6329
  reviewPassScore: z.number().default(70),
5337
6330
  autoReview: z.boolean().default(true),
5338
- autoAuthorReview: z.boolean().default(true)
6331
+ autoAuthorReview: z.boolean().default(true),
6332
+ autoReviewAfterRevise: z.boolean().default(true)
5339
6333
  });
5340
6334
  /** Schema defaults, re-read for hand-built test contexts. */
5341
6335
  const DEFAULT_ANNOUNCE = true;
5342
- const DEFAULT_OUTLINE_PATH = "C:\\Users\\Ryan\\Desktop\\《归墟玉主》全书大纲_重新排版版.docx";
5343
- const DEFAULT_OUTPUT_DIR = "C:\\Users\\Ryan\\Desktop\\归墟玉主";
6336
+ const DEFAULT_OUTLINE_PATH = "D:\\ryan work\\harness\\novels\\归墟玉主\\《归墟玉主》全书大纲_重新排版版.docx";
6337
+ const DEFAULT_OUTPUT_DIR = "D:\\ryan work\\harness\\novels\\归墟玉主";
5344
6338
  const DEFAULT_PROVIDER = "deepseek-official";
5345
6339
  const DEFAULT_MODEL = "deepseek-v4-flash";
5346
6340
  const DEFAULT_CHAPTER_CHARS = 3500;
@@ -5348,10 +6342,11 @@ const DEFAULT_MAX_TOKENS = 12e3;
5348
6342
  const DEFAULT_REVIEW_PASS_SCORE = 70;
5349
6343
  const DEFAULT_AUTO_REVIEW = true;
5350
6344
  const DEFAULT_AUTO_AUTHOR_REVIEW = true;
6345
+ const DEFAULT_AUTO_REVIEW_AFTER_REVISE = true;
5351
6346
  /** Order of the announcement section within the tool-guidance band. */
5352
6347
  const SECTION_ORDER = 160;
5353
6348
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
5354
- const NOVEL_GUIDANCE = "本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲(默认桌面《归墟玉主》大纲)或粘贴大纲文本;用 LLM 提炼道藏(人设/世界观/金手指规则/写作红线,即设定圣经);生成卷计划与章节计划;逐章调用 LLM 生成 3000-4000 字正文并保存为 Markdown(默认输出到 桌面\\归墟玉主);每章自动生成摘要(叙事记忆)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑),支持按审稿意见重写、去 AI 味润色、暗线(伏笔)管理、批量连写与全本导出(txt/md)。限制:生成消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色 / 归墟玉主」时即指本插件,请据此协作。";
6349
+ const NOVEL_GUIDANCE = "本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲(默认 D:\\ryan work\\harness\\novels\\归墟玉主 大纲)或粘贴大纲文本;用 LLM 提炼道藏(人设/世界观/金手指规则/写作红线,即设定圣经);生成卷计划与章节计划;逐章调用 LLM 生成 3000-4000 字正文并保存为 Markdown(默认输出到 D:\\ryan work\\harness\\novels\\归墟玉主);每章自动生成摘要(叙事记忆)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑),支持按审稿意见重写、去 AI 味润色、暗线(伏笔)管理、批量连写与全本导出(txt/md)。限制:生成消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色 / 归墟玉主」时即指本插件,请据此协作。";
5355
6350
  /** Resolve a config-like value into the full runtime config. */
5356
6351
  function resolveConfig(value) {
5357
6352
  return {
@@ -5363,7 +6358,8 @@ function resolveConfig(value) {
5363
6358
  maxTokens: value?.maxTokens ?? DEFAULT_MAX_TOKENS,
5364
6359
  reviewPassScore: value?.reviewPassScore ?? DEFAULT_REVIEW_PASS_SCORE,
5365
6360
  autoReview: value?.autoReview ?? DEFAULT_AUTO_REVIEW,
5366
- autoAuthorReview: value?.autoAuthorReview ?? DEFAULT_AUTO_AUTHOR_REVIEW
6361
+ autoAuthorReview: value?.autoAuthorReview ?? DEFAULT_AUTO_AUTHOR_REVIEW,
6362
+ autoReviewAfterRevise: value?.autoReviewAfterRevise ?? DEFAULT_AUTO_REVIEW_AFTER_REVISE
5367
6363
  };
5368
6364
  }
5369
6365
  /**
@@ -5393,6 +6389,7 @@ function apply(ctx, config) {
5393
6389
  if (patch.reviewPassScore !== void 0) next.reviewPassScore = patch.reviewPassScore;
5394
6390
  if (patch.autoReview !== void 0) next.autoReview = patch.autoReview;
5395
6391
  if (patch.autoAuthorReview !== void 0) next.autoAuthorReview = patch.autoAuthorReview;
6392
+ if (patch.autoReviewAfterRevise !== void 0) next.autoReviewAfterRevise = patch.autoReviewAfterRevise;
5396
6393
  const settings = ctx.get("settings");
5397
6394
  if (settings !== void 0) await settings.update(NOVEL_SETTINGS_NAMESPACE, next);
5398
6395
  else current = () => ({
@@ -5438,6 +6435,53 @@ function apply(ctx, config) {
5438
6435
  },
5439
6436
  onChange: sync
5440
6437
  });
6438
+ const skillsService = ctx.get("skills");
6439
+ if (skillsService?.register !== void 0) {
6440
+ const disposeSkill = skillsService.register({
6441
+ name: "novel-forge-chapter-batch",
6442
+ description: "批量生成小说章节并值守处理审稿未过的章节(豁免/按意见修订/验证模式/重新生成),依赖 dsh-novel-forge 的 /api/dsh-novel-forge/* 路由。",
6443
+ content: [
6444
+ "# 小说章节批量生产与值守处理",
6445
+ "",
6446
+ "## 0. 值守纪律(最高优先级,违反即空转)",
6447
+ "1. 启动后台任务后立即停下,不轮询、不连续 wait、不做无意义动作——后台任务完成时运行时自动通知。",
6448
+ "2. 只有收到「任务完成」通知或用户新消息时,才继续下一步(读结果→处理未过→汇报)。",
6449
+ "3. 等待期间可做与当前任务无关的其他有用工作,但不得为等任务而空转。",
6450
+ "4. 任务因 web 重启中断时:待 3080 恢复后,复位卡死章节(generating/error → reset)再续跑,并向用户说明中断原因。",
6451
+ "",
6452
+ "## 1. 前置检查",
6453
+ "1. `GET /api/dsh-novel-forge/status` 确认服务在跑,读项目章节数。",
6454
+ "2. 列出目标区间 pending 章节号,避免重复生成已处理章节。",
6455
+ "3. **串行执行,禁止并行写 project.json**(并发会互相覆盖状态,实测教训)。",
6456
+ "",
6457
+ "## 2. 批量生成",
6458
+ "逐章串行调用 `POST /generate` `{ chapterNo, skipReview: false }`(走完整质量门:生成→摘要+编年录→审稿→复盘)。",
6459
+ "响应为 NDJSON,找 review 帧取 score/passed。每章 2-5 分钟;失败重试 1 次;后台跑并告知预计时长。",
6460
+ "用 Node 脚本(fetch 逐章循环),避免 PowerShell 转义坑,用完删除。",
6461
+ "",
6462
+ "## 3. 未过章节分级处理",
6463
+ "### A. 无 high → 豁免通过:`POST /chapter/approve`(主观项不磨)",
6464
+ "### B. 有 high 且明确 → 按意见修订 + 验证模式:",
6465
+ "1. 拼指令 `按审稿意见修订(优先处理):\n[severity] item → suggestion`(high 优先,无 high 取前 3 medium)",
6466
+ "2. `POST /rewrite` 产草稿;3. `POST /chapter/check` 带 `previousReport` 走验证模式(只核对原意见解决+只挑新增 high)",
6467
+ "4. 判定 `passed || 无 high` 可接受;5. `POST /draft/apply` 带 passed 报告落盘 approved",
6468
+ "6. 不可接受 → 第二轮修订(指令更精确)→ 再验证;每章最多修 2 轮,仍不过保留草稿待人工",
6469
+ "### C. 结构性 high(修订改不好)→ `POST /draft/discard` 后 `POST /generate` 重写,剩主观项再走 A",
6470
+ "### D. error 状态 → `POST /chapter/reset` 后 `POST /generate`",
6471
+ "注意:approved 但 review 有 high 的保留不动;修订指令越精确越有效(具体数字/行为一次就过)。",
6472
+ "",
6473
+ "## 4. 收尾",
6474
+ "`GET /status` 验证目标区间全 approved;可选刷新剧情线(`POST /plotlines` op=refresh,注意只读最近 8 章摘要);汇报通过/未过/失败与遗留项。",
6475
+ "",
6476
+ "## 5. 已知陷阱",
6477
+ "- 并发写 project.json 互相覆盖 → 必须串行",
6478
+ "- rewrite 草稿可能偶发不落盘 → 应用前检查 pendingDraft,不存在则重跑",
6479
+ "- PowerShell 调 API 引号/中文转义是坑 → 用 Node 脚本",
6480
+ "- 验证模式 500 偶发 → 重试一次"
6481
+ ].join("\n")
6482
+ });
6483
+ ctx.effect(() => disposeSkill, "dsh-novel-forge: skill");
6484
+ }
5441
6485
  sync();
5442
6486
  }
5443
6487
  //#endregion