@waterwx/dsh-novel-forge 1.1.8 → 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,147 +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
- /** 开书想法 → AI 补全大纲:输入一句话想法,生成 2-3 个可选大纲方案。 */
79
- outlineSuggest: "/api/dsh-novel-forge/outline/suggest",
80
- /** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检(两阶段:源笔记→分节分析)。 */
81
- breakdown: "/api/dsh-novel-forge/breakdown",
82
- /** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(可适配豆包/Seedance/SD)。 */
83
- storyboard: "/api/dsh-novel-forge/storyboard",
84
- /** 漫剧分集计划:读一卷 → 按故事弧线分集(高潮拆集/过渡并章)。 */
85
- storyboardPlan: "/api/dsh-novel-forge/storyboard/plan",
86
- config: "/api/dsh-novel-forge/config",
87
- openFolder: "/api/dsh-novel-forge/open-folder"
88
- };
89
- //#endregion
90
- //#region src/docx.ts
91
- /**
92
- * docx outline extraction: a .docx is a zip whose word/document.xml holds the
93
- * body text in <w:t> runs inside <w:p> paragraphs. We unzip with fflate and
94
- * walk the XML with a tiny tokenizer — no heavyweight XML/DOM dependency.
95
- */
96
- /** Decode the handful of XML entities docx bodies actually use. */
97
- function decodeEntities(text) {
98
- return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&apos;/g, "'").replace(/&amp;/g, "&").replace(/&nbsp;/g, " ");
99
- }
100
- /**
101
- * Extract plain text from a docx buffer: one line per <w:p> paragraph, with
102
- * <w:tab>/<w:br> preserved as whitespace. Tables and nested structures are
103
- * flattened in document order (their paragraphs are just <w:p> too).
104
- * @param buffer - the raw .docx bytes.
105
- * @returns the body text.
106
- */
107
- function extractDocxText(buffer) {
108
- let files;
109
- try {
110
- files = unzipSync(buffer);
111
- } catch (error) {
112
- throw new Error(`not a valid docx (zip open failed): ${error.message}`);
113
- }
114
- const document = files["word/document.xml"];
115
- if (document === void 0) throw new Error("not a valid docx (word/document.xml missing)");
116
- const xml = strFromU8(document);
117
- const paragraphs = [];
118
- const parts = xml.split(/<w:p\b[^>]*>/);
119
- for (let i = 1; i < parts.length; i++) {
120
- const segment = parts[i];
121
- const runs = [];
122
- 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(" ");
123
- else if (match[0].startsWith("<w:br")) runs.push("\n");
124
- else runs.push(decodeEntities(match[1] ?? ""));
125
- const line = runs.join("").replace(/\u00a0/g, " ").trimEnd();
126
- paragraphs.push(line);
127
- }
128
- const text = paragraphs.join("\n").replace(/\n{3,}/g, "\n\n").trim();
129
- if (text.length === 0) throw new Error("docx contains no extractable text");
130
- return text;
131
- }
132
- /**
133
- * Read and extract a docx outline from disk.
134
- * @param path - absolute path to the .docx file.
135
- * @returns the extracted outline text.
136
- */
137
- function readOutlineFromDocx(path) {
138
- let buffer;
139
- try {
140
- buffer = readFileSync(path);
141
- } catch (error) {
142
- throw new Error(`cannot read outline file "${path}": ${error.message}`);
143
- }
144
- return extractDocxText(new Uint8Array(buffer));
145
- }
146
- //#endregion
9
+ import { strFromU8, unzipSync } from "fflate";
147
10
  //#region src/assets.ts
148
11
  /** 预置写法模板(来自 AI-Novel-Writing-Assistant 内置 DEFAULT_STYLE_TEMPLATES)。 */
149
12
  const BUILTIN_STYLE_TEMPLATES = [
@@ -759,6 +622,22 @@ function styleEngineSystemPrompt() {
759
622
  * tracking, project persistence, and whole-book export. Pure Node (no
760
623
  * web-server dependencies), so routes stay thin and logic is testable.
761
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
+ ];
762
641
  /** Project state file name inside the output dir. */
763
642
  const PROJECT_FILE = "novel-project.json";
764
643
  /** Sanitize a file name: keep CJK/alphanumerics/space/dash/underscore. */
@@ -1509,6 +1388,14 @@ function writeSystemPrompt(project) {
1509
1388
  sections.push("3. 尊重大纲与设定圣经:人设不崩、金手指规则不自相矛盾、战力不随意膨胀。");
1510
1389
  sections.push("4. 章末留一个钩子(悬念、反转或新线索),吸引读者读下一章。");
1511
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("以上九条为硬性底线,任何情况下不得以任何形式出现或影射;若剧情确需涉及(如批判、反讽),只能以明确否定、揭露、批判的立场呈现,且不得展开细节。");
1512
1399
  return sections.join("\n");
1513
1400
  }
1514
1401
  /**
@@ -1592,6 +1479,8 @@ function reviewSystemPrompt(project) {
1592
1479
  "5. 节奏与爽点:本章是否有推进、有钩子,是否拖沓灌水。",
1593
1480
  "6. 逻辑漏洞:前后矛盾、时间线错误、对话失真。",
1594
1481
  "7. 反 AI 规则:逐条核对下方「反 AI 规则」清单,命中即列为问题。",
1482
+ "8. 呈现方式:整章是否纯内心推理铺陈(无对话/无对抗,推理全靠解说);反派是否纯背景板无行动;重要配角是否无名标签化(瘦高个/灰衣人全程代称)——命中即列为问题。",
1483
+ "9. 内容合规(最高优先级):逐条核对下方「内容合规红线」,任何一条命中(含影射、暗示、详细描写)必须列为 high,并给出改写建议。",
1595
1484
  "输出必须是合法 JSON 对象,不要输出任何其他文字:",
1596
1485
  "{\"score\": 0-100的整数, \"verdict\": \"一句话总评\", \"issues\": [{\"severity\": \"high|medium|low\", \"item\": \"问题描述\", \"suggestion\": \"修改建议\"}]}",
1597
1486
  "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
@@ -1611,6 +1500,9 @@ function reviewSystemPrompt(project) {
1611
1500
  }
1612
1501
  if (bible.redLines.length > 0) sections.push("红线:\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
1613
1502
  }
1503
+ sections.push("\n==================== 内容合规红线(平台硬性要求,最高优先级) ====================");
1504
+ sections.push(COMPLIANCE_REDLINES.join("\n"));
1505
+ sections.push("以上九条为硬性底线:正文中任何一条命中(含影射、暗示、详细展开)都必须列为 high,并给出改写建议;作者自定义红线不得豁免这九条。");
1614
1506
  return sections.join("\n");
1615
1507
  }
1616
1508
  /** Run the AI review on one chapter. */
@@ -2004,6 +1896,7 @@ async function suggestOutlines(ctx, config, idea, count = 3, exclude = []) {
2004
1896
  ].join("\n"),
2005
1897
  user: [
2006
1898
  `作者的想法:${idea}`,
1899
+ idea.trim().length < 40 ? "作者的想法非常简短(可能只有一句)。请基于通用网文套路合理扩展补全:为每个方案自洽地设计金手指/核心设定、主角人设与动机、主线走向,使其成为完整可开书的大纲;不同方案的方向仍须明显差异。" : "",
2007
1900
  exclude.length > 0 ? `需避开的已暂留方案方向(新方案不得与之雷同):\n${exclude.map((e, i) => `${i + 1}. ${e}`).join("\n")}` : "",
2008
1901
  `请生成 ${n} 个大纲方案。`,
2009
1902
  "只输出 JSON 数组。"
@@ -3254,89 +3147,673 @@ function exportBook(outputDir, project, format) {
3254
3147
  };
3255
3148
  }
3256
3149
  //#endregion
3257
- //#region src/assistant.ts
3150
+ //#region src/bookshelf.ts
3258
3151
  /**
3259
- * AI assistant engine — a conversational editor over the novel project.
3260
- *
3261
- * The user talks to the assistant about plot, characters, settings; the
3262
- * assistant can reply in prose AND emit action directives that the host
3263
- * executes (rewrite a paragraph, edit the bible, regenerate a chapter,
3264
- * export the book, ...). Conversation history persists next to the project
3265
- * as NDJSON, so a reload keeps the thread.
3266
- *
3267
- * Action protocol: the model emits a line of the form
3268
- * <dsh-action name="toolName">{jsonArgs}</dsh-action>
3269
- * anywhere in its reply. The host strips it, executes the tool, appends the
3270
- * result as a tool-role message, and continues the loop (bounded rounds).
3152
+ * 书架(Bookshelf)— 多书管理:一本书记录一个独立输出目录。
3153
+ * 状态持久化到 ~/.dsh/dsh-novel-forge-bookshelf.json(跟随 dsh 配置惯例)。
3271
3154
  */
3272
- /** History file name inside the output dir. */
3273
- const ASSISTANT_HISTORY_FILE = "novel-assistant.jsonl";
3274
- /** Max tool-call rounds per user turn (safety bound). */
3275
- const MAX_TOOL_ROUNDS = 6;
3276
- /** Load the persisted conversation (empty when none). */
3277
- function loadAssistantHistory(outputDir) {
3278
- const file = join(outputDir, ASSISTANT_HISTORY_FILE);
3279
- if (!existsSync(file)) return [];
3280
- const messages = [];
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();
3281
3169
  try {
3282
- for (const line of readFileSync(file, "utf8").split("\n")) {
3283
- if (line.trim() === "") continue;
3284
- try {
3285
- const parsed = JSON.parse(line);
3286
- if (typeof parsed.role === "string" && typeof parsed.content === "string") messages.push(parsed);
3287
- } catch {}
3288
- }
3289
- } catch {}
3290
- return messages;
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
+ }
3291
3181
  }
3292
- /** Append one message to the persisted history. Tool payloads (e.g. full
3293
- * outline / chapter text) are capped so the jsonl and later LLM context
3294
- * don't grow unboundedly. */
3295
- function appendHistory(outputDir, message) {
3296
- mkdirSync(outputDir, { recursive: true });
3297
- const entry = message.role === "tool" && message.content.length > 4e3 ? {
3298
- ...message,
3299
- content: message.content.slice(0, 4e3) + "\n…(已截断,如需完整内容请重新调用工具)"
3300
- } : message;
3301
- appendFileSync(join(outputDir, ASSISTANT_HISTORY_FILE), JSON.stringify(entry) + "\n", "utf8");
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");
3302
3187
  }
3303
- /** 清空助手对话记录(删除历史文件)。 */
3304
- function clearAssistantHistory(outputDir) {
3305
- const file = join(outputDir, ASSISTANT_HISTORY_FILE);
3306
- if (existsSync(file)) rmSync(file, { force: true });
3188
+ /** 当前激活的书。 */
3189
+ function activeBook(store) {
3190
+ return store.books.find((b) => b.id === store.activeBookId);
3307
3191
  }
3308
- /** Render the project snapshot the assistant sees. */
3309
- function renderProjectSnapshot(project) {
3310
- const sections = [];
3311
- sections.push(`书名:${project.bookName}`);
3312
- sections.push(`总纲节选(如需全文用 outline_text 工具):\n${project.outline.slice(0, 2500)}`);
3313
- const assetNames = [];
3314
- if (project.assets?.genre !== void 0) assetNames.push(`题材:${project.assets.genre.name}`);
3315
- if (project.assets?.primaryProgression !== void 0) assetNames.push(`主推进:${project.assets.primaryProgression.name}`);
3316
- if ((project.assets?.styleAssets?.length ?? 0) > 0) assetNames.push(`写法:${project.assets.styleAssets.map((s) => s.name).join("、")}`);
3317
- if ((project.assets?.antiAiRules?.length ?? 0) > 0) assetNames.push(`文戒自定义:${project.assets.antiAiRules.map((r) => r.name).join("、")}`);
3318
- if (assetNames.length > 0) sections.push(`【写作资产】${assetNames.join(" · ")}`);
3319
- if (project.bible !== void 0) {
3320
- const bible = project.bible;
3321
- sections.push("【道藏】");
3322
- if (bible.genre !== "") sections.push(`题材基调:${bible.genre}`);
3323
- if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
3324
- if (bible.characters.length > 0) {
3325
- sections.push("角色卡:");
3326
- for (const card of bible.characters) {
3327
- const roleName = {
3328
- protagonist: "主角",
3329
- supporting: "配角",
3330
- antagonist: "反派",
3331
- other: "其他"
3332
- }[card.role];
3333
- sections.push(`- ${card.name}(${roleName}):${card.traits.join("、")}${card.goals !== "" ? `;目标:${card.goals}` : ""}`);
3334
- }
3335
- }
3336
- if (bible.redLines.length > 0) sections.push("写作红线:\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
3337
- }
3338
- if (project.world !== void 0) {
3339
- const world = project.world;
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.
3693
+ */
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;
3718
+ }
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}`);
3730
+ }
3731
+ return extractDocxText(new Uint8Array(buffer));
3732
+ }
3733
+ //#endregion
3734
+ //#region src/assistant.ts
3735
+ /**
3736
+ * AI assistant engine — a conversational editor over the novel project.
3737
+ *
3738
+ * The user talks to the assistant about plot, characters, settings; the
3739
+ * assistant can reply in prose AND emit action directives that the host
3740
+ * executes (rewrite a paragraph, edit the bible, regenerate a chapter,
3741
+ * export the book, ...). Conversation history persists next to the project
3742
+ * as NDJSON, so a reload keeps the thread.
3743
+ *
3744
+ * Action protocol: the model emits a line of the form
3745
+ * <dsh-action name="toolName">{jsonArgs}</dsh-action>
3746
+ * anywhere in its reply. The host strips it, executes the tool, appends the
3747
+ * result as a tool-role message, and continues the loop (bounded rounds).
3748
+ */
3749
+ /** History file name inside the output dir. */
3750
+ const ASSISTANT_HISTORY_FILE = "novel-assistant.jsonl";
3751
+ /** Max tool-call rounds per user turn (safety bound). */
3752
+ const MAX_TOOL_ROUNDS = 6;
3753
+ /** Load the persisted conversation (empty when none). */
3754
+ function loadAssistantHistory(outputDir) {
3755
+ const file = join(outputDir, ASSISTANT_HISTORY_FILE);
3756
+ if (!existsSync(file)) return [];
3757
+ const messages = [];
3758
+ try {
3759
+ for (const line of readFileSync(file, "utf8").split("\n")) {
3760
+ if (line.trim() === "") continue;
3761
+ try {
3762
+ const parsed = JSON.parse(line);
3763
+ if (typeof parsed.role === "string" && typeof parsed.content === "string") messages.push(parsed);
3764
+ } catch {}
3765
+ }
3766
+ } catch {}
3767
+ return messages;
3768
+ }
3769
+ /** Append one message to the persisted history. Tool payloads (e.g. full
3770
+ * outline / chapter text) are capped so the jsonl and later LLM context
3771
+ * don't grow unboundedly. */
3772
+ function appendHistory(outputDir, message) {
3773
+ mkdirSync(outputDir, { recursive: true });
3774
+ const entry = message.role === "tool" && message.content.length > 4e3 ? {
3775
+ ...message,
3776
+ content: message.content.slice(0, 4e3) + "\n…(已截断,如需完整内容请重新调用工具)"
3777
+ } : message;
3778
+ appendFileSync(join(outputDir, ASSISTANT_HISTORY_FILE), JSON.stringify(entry) + "\n", "utf8");
3779
+ }
3780
+ /** 清空助手对话记录(删除历史文件)。 */
3781
+ function clearAssistantHistory(outputDir) {
3782
+ const file = join(outputDir, ASSISTANT_HISTORY_FILE);
3783
+ if (existsSync(file)) rmSync(file, { force: true });
3784
+ }
3785
+ /** Render the project snapshot the assistant sees. */
3786
+ function renderProjectSnapshot(project) {
3787
+ const sections = [];
3788
+ sections.push(`书名:${project.bookName}`);
3789
+ sections.push(`总纲节选(如需全文用 outline_text 工具):\n${project.outline.slice(0, 2500)}`);
3790
+ const assetNames = [];
3791
+ if (project.assets?.genre !== void 0) assetNames.push(`题材:${project.assets.genre.name}`);
3792
+ if (project.assets?.primaryProgression !== void 0) assetNames.push(`主推进:${project.assets.primaryProgression.name}`);
3793
+ if ((project.assets?.styleAssets?.length ?? 0) > 0) assetNames.push(`写法:${project.assets.styleAssets.map((s) => s.name).join("、")}`);
3794
+ if ((project.assets?.antiAiRules?.length ?? 0) > 0) assetNames.push(`文戒自定义:${project.assets.antiAiRules.map((r) => r.name).join("、")}`);
3795
+ if (assetNames.length > 0) sections.push(`【写作资产】${assetNames.join(" · ")}`);
3796
+ if (project.bible !== void 0) {
3797
+ const bible = project.bible;
3798
+ sections.push("【道藏】");
3799
+ if (bible.genre !== "") sections.push(`题材基调:${bible.genre}`);
3800
+ if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
3801
+ if (bible.characters.length > 0) {
3802
+ sections.push("角色卡:");
3803
+ for (const card of bible.characters) {
3804
+ const roleName = {
3805
+ protagonist: "主角",
3806
+ supporting: "配角",
3807
+ antagonist: "反派",
3808
+ other: "其他"
3809
+ }[card.role];
3810
+ sections.push(`- ${card.name}(${roleName}):${card.traits.join("、")}${card.goals !== "" ? `;目标:${card.goals}` : ""}`);
3811
+ }
3812
+ }
3813
+ if (bible.redLines.length > 0) sections.push("写作红线:\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
3814
+ }
3815
+ if (project.world !== void 0) {
3816
+ const world = project.world;
3340
3817
  sections.push("【大世界】");
3341
3818
  if (world.realms.length > 0) sections.push("境界体系:" + world.realms.map((r, i) => `${i + 1}.${r.name}(${r.description.slice(0, 40)})`).join(" → "));
3342
3819
  if (world.regions.length > 0) sections.push("区域:" + world.regions.map((r) => r.name).join("、"));
@@ -3950,173 +4427,40 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
3950
4427
  role: "assistant",
3951
4428
  content: prose,
3952
4429
  ts: (/* @__PURE__ */ new Date()).toISOString()
3953
- });
3954
- }
3955
- history.push({
3956
- role: "tool",
3957
- content: result,
3958
- tool: name,
3959
- ts: (/* @__PURE__ */ new Date()).toISOString()
3960
- });
3961
- appendHistory(outputDir, {
3962
- role: "tool",
3963
- content: result,
3964
- tool: name,
3965
- ts: (/* @__PURE__ */ new Date()).toISOString()
3966
- });
3967
- round++;
3968
- if (round >= MAX_TOOL_ROUNDS) {
3969
- const message = `(已连续执行 ${round} 次修改操作,本轮停止。如需继续请再说。)`;
3970
- history.push({
3971
- role: "assistant",
3972
- content: message,
3973
- ts: (/* @__PURE__ */ new Date()).toISOString()
3974
- });
3975
- appendHistory(outputDir, {
3976
- role: "assistant",
3977
- content: message,
3978
- ts: (/* @__PURE__ */ new Date()).toISOString()
3979
- });
3980
- yield {
3981
- frame: "delta",
3982
- text: message
3983
- };
3984
- return;
3985
- }
3986
- }
3987
- }
3988
- //#endregion
3989
- //#region src/bookshelf.ts
3990
- /**
3991
- * 书架(Bookshelf)— 多书管理:一本书记录一个独立输出目录。
3992
- * 状态持久化到 ~/.dsh/dsh-novel-forge-bookshelf.json(跟随 dsh 配置惯例)。
3993
- */
3994
- /** 书架配置文件路径。 */
3995
- function bookshelfFile() {
3996
- return join(homedir(), ".dsh", "dsh-novel-forge-bookshelf.json");
3997
- }
3998
- function defaultStore() {
3999
- return {
4000
- books: [],
4001
- activeBookId: null
4002
- };
4003
- }
4004
- /** 读取书架(无则返回空)。 */
4005
- function loadBookshelf() {
4006
- const file = bookshelfFile();
4007
- if (!existsSync(file)) return defaultStore();
4008
- try {
4009
- let raw = readFileSync(file, "utf8");
4010
- if (raw.charCodeAt(0) === 65279) raw = raw.slice(1);
4011
- const parsed = JSON.parse(raw);
4012
- if (!Array.isArray(parsed.books)) return defaultStore();
4013
- return {
4014
- books: parsed.books,
4015
- activeBookId: parsed.activeBookId ?? null
4016
- };
4017
- } catch {
4018
- return defaultStore();
4019
- }
4020
- }
4021
- /** 持久化书架。 */
4022
- function saveBookshelf(store) {
4023
- const file = bookshelfFile();
4024
- mkdirSync(join(homedir(), ".dsh"), { recursive: true });
4025
- writeFileSync(file, JSON.stringify(store, null, 2), "utf8");
4026
- }
4027
- /** 当前激活的书。 */
4028
- function activeBook(store) {
4029
- return store.books.find((b) => b.id === store.activeBookId);
4030
- }
4031
- /** 书架快照(含每本书的进度摘要)。 */
4032
- function bookshelfSnapshot(store) {
4033
- return {
4034
- books: store.books.map((book) => {
4035
- const project = loadProject(book.outputDir);
4036
- const done = project === void 0 ? 0 : project.chapters.filter((c) => c.status === "approved" || c.status === "written" || c.status === "rejected").length;
4037
- const hasCover = project?.coverPath !== void 0 && project.coverPath !== "" && existsSync(join(book.outputDir, project.coverPath));
4038
- return {
4039
- ...book,
4040
- done,
4041
- total: project?.chapters.length ?? 0,
4042
- hasProject: project !== void 0,
4043
- hasCover,
4044
- blurb: project?.blurb
4045
- };
4046
- }),
4047
- activeBookId: store.activeBookId
4048
- };
4049
- }
4050
- /** 新建一本书(自动成为当前书)。 */
4051
- function createBook(bookName, outputDir) {
4052
- const store = loadBookshelf();
4053
- const id = `book-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
4054
- const now = (/* @__PURE__ */ new Date()).toISOString();
4055
- const book = {
4056
- id,
4057
- bookName,
4058
- outputDir,
4059
- createdAt: now,
4060
- updatedAt: now
4061
- };
4062
- store.books.push(book);
4063
- store.activeBookId = id;
4064
- saveBookshelf(store);
4065
- return book;
4066
- }
4067
- /** 更新某本书的书名(开书向导导入大纲后书名以大纲首行为准)。 */
4068
- function renameBook(id, bookName) {
4069
- const store = loadBookshelf();
4070
- const book = store.books.find((b) => b.id === id);
4071
- if (book === void 0) return false;
4072
- book.bookName = bookName;
4073
- book.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4074
- saveBookshelf(store);
4075
- return true;
4076
- }
4077
- /**
4078
- * 播种:书架为空时,把指定输出目录下已有的项目自动登记为第一本书。
4079
- * 兼容升级场景 —— 旧版插件直接在输出目录写项目,从未登记书架。
4080
- * @param outputDir - 候选输出目录(通常为 settings 的默认输出目录)。
4081
- * @returns 是否发生了播种。
4082
- */
4083
- function seedBookshelfFromOutputDir(outputDir) {
4084
- if (loadBookshelf().books.length > 0) return false;
4085
- if (!existsSync(outputDir)) return false;
4086
- const hasProject = existsSync(join(outputDir, "novel-project.json"));
4087
- const hasChapters = existsSync(outputDir);
4088
- if (!hasProject && !hasChapters) return false;
4089
- createBook(loadProject(outputDir)?.bookName ?? outputDir.split(/[\\/]/).pop() ?? "未命名小说", outputDir);
4090
- return true;
4091
- }
4092
- /** 激活一本书。 */
4093
- function activateBook(id) {
4094
- const store = loadBookshelf();
4095
- const book = store.books.find((b) => b.id === id);
4096
- if (book === void 0) return void 0;
4097
- store.activeBookId = id;
4098
- book.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4099
- saveBookshelf(store);
4100
- return book;
4101
- }
4102
- /** 移除一本书。 */
4103
- function removeBook(id) {
4104
- const store = loadBookshelf();
4105
- const idx = store.books.findIndex((b) => b.id === id);
4106
- if (idx === -1) return false;
4107
- store.books.splice(idx, 1);
4108
- if (store.activeBookId === id) store.activeBookId = store.books[0]?.id ?? null;
4109
- saveBookshelf(store);
4110
- return true;
4111
- }
4112
- /** 当前书输出目录(无书架则 undefined,回退 settings)。 */
4113
- function activeBookOutputDir() {
4114
- return activeBook(loadBookshelf())?.outputDir;
4115
- }
4116
- /** 默认输出目录推断:桌面/书名。 */
4117
- function defaultOutputDirFor(bookName) {
4118
- const clean = bookName.replace(/[\\/:*?"<>|]/g, "").trim().slice(0, 40) || "未命名小说";
4119
- return join(homedir(), "Desktop", clean);
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
+ }
4120
4464
  }
4121
4465
  //#endregion
4122
4466
  //#region src/routes.ts
@@ -5651,6 +5995,208 @@ function makeRoutes(deps) {
5651
5995
  });
5652
5996
  }
5653
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
+ });
5654
6200
  return [
5655
6201
  statusRoute,
5656
6202
  loadOutlineRoute,
@@ -5690,198 +6236,67 @@ function makeRoutes(deps) {
5690
6236
  plotlinesRoute,
5691
6237
  rolesRoute,
5692
6238
  sensitiveRoute,
5693
- {
5694
- kind: "exact",
5695
- path: NOVEL_API.reviewBackfill,
5696
- handler: async (req, res) => {
5697
- if (!guard(req, res, "POST")) return;
5698
- const config = getConfig();
5699
- const project = requireProject(res);
5700
- if (project === void 0) return;
5701
- const body = await readJsonBody(req);
5702
- /** 对一章执行作者复盘(读取已落盘正文,不改变章节状态/正文)。 */
5703
- const runOne = async (chapter) => {
5704
- const currentBody = readChapterFile(config.outputDir, chapter);
5705
- if (currentBody === void 0) throw new Error(`章节 ${chapter.no} 的正文文件不存在`);
5706
- let prevTail = "";
5707
- if (chapter.no > 1) {
5708
- const prev = project.chapters.find((c) => c.no === chapter.no - 1);
5709
- if (prev !== void 0) prevTail = (readChapterFile(config.outputDir, prev) ?? "").replace(/^#.*$/m, "").trim().slice(-600);
5710
- }
5711
- return authorReviewChapter(ctx, config, project, chapter.no, currentBody, prevTail);
5712
- };
5713
- if (typeof body?.chapterNo === "number" && body.chapterNo > 0) {
5714
- const chapter = project.chapters.find((c) => c.no === body.chapterNo);
5715
- if (chapter === void 0) {
5716
- writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` });
5717
- return;
5718
- }
5719
- if (chapter.status === "pending") {
5720
- writeJson(res, 400, { error: "该章尚未生成正文,无法复盘" });
5721
- return;
5722
- }
5723
- try {
5724
- const review = await runOne(chapter);
5725
- chapter.authorReview = review;
5726
- if (review.advancedLines !== void 0) autoLinkPlotlines(project, chapter.no, review.advancedLines);
5727
- project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5728
- saveProject(config.outputDir, project);
5729
- writeJson(res, 200, {
5730
- no: chapter.no,
5731
- review
5732
- });
5733
- return;
5734
- } catch (error) {
5735
- writeJson(res, 500, { error: error.message });
5736
- return;
5737
- }
5738
- }
5739
- const missing = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.authorReview === void 0);
5740
- if (missing.length === 0) {
5741
- writeJson(res, 200, { count: 0 });
5742
- return;
5743
- }
5744
- res.writeHead(200, {
5745
- "content-type": "application/x-ndjson; charset=utf-8",
5746
- "cache-control": "no-cache",
5747
- "referrer-policy": "no-referrer"
5748
- });
5749
- const send = (frame) => {
5750
- res.write(JSON.stringify(frame) + "\n");
5751
- };
5752
- let done = 0;
5753
- for (const chapter of missing) try {
5754
- const review = await runOne(chapter);
5755
- chapter.authorReview = review;
5756
- if (review.advancedLines !== void 0) autoLinkPlotlines(project, chapter.no, review.advancedLines);
5757
- done++;
5758
- saveProject(config.outputDir, project);
5759
- send({
5760
- type: "author-review",
5761
- no: chapter.no,
5762
- review
5763
- });
5764
- } catch (error) {
5765
- console.warn(`[dsh-novel-forge] author backfill ch.${chapter.no} failed:`, error.message);
5766
- }
5767
- project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5768
- saveProject(config.outputDir, project);
5769
- send({
5770
- type: "author-backfill-done",
5771
- count: done
5772
- });
5773
- res.end();
5774
- }
5775
- },
6239
+ reviewBackfillRoute,
5776
6240
  chapterResetRoute,
5777
6241
  chapterApproveRoute,
6242
+ configRoute,
6243
+ openFolderRoute,
6244
+ outlineSuggestRoute,
6245
+ breakdownRoute,
6246
+ storyboardRoute,
6247
+ storyboardPlanRoute,
5778
6248
  {
5779
6249
  kind: "exact",
5780
- path: NOVEL_API.config,
6250
+ path: NOVEL_API.runStart,
5781
6251
  handler: async (req, res) => {
5782
6252
  if (!guard(req, res, "POST")) return;
5783
6253
  const body = await readJsonBody(req);
5784
- if (body === void 0) {
5785
- writeJson(res, 400, { error: "无效的配置 JSON" });
6254
+ const project = loadProject(getConfig().outputDir);
6255
+ if (project === void 0) {
6256
+ writeJson(res, 400, { error: "输出目录中没有项目" });
5786
6257
  return;
5787
6258
  }
5788
- try {
5789
- writeJson(res, 200, { config: await patchConfig(body) });
5790
- } catch (error) {
5791
- writeJson(res, 400, { error: error.message });
5792
- }
5793
- }
5794
- },
5795
- {
5796
- kind: "exact",
5797
- path: NOVEL_API.openFolder,
5798
- handler: async (req, res) => {
5799
- if (!guard(req, res, "POST")) return;
5800
- const dir = getConfig().outputDir;
5801
- exec(`explorer "${dir.replace(/"/g, "")}"`, (error) => {
5802
- if (error) writeJson(res, 500, {
5803
- ok: false,
5804
- error: error.message
5805
- });
5806
- else writeJson(res, 200, { ok: true });
5807
- });
5808
- }
5809
- },
5810
- {
5811
- kind: "exact",
5812
- path: NOVEL_API.outlineSuggest,
5813
- handler: async (req, res) => {
5814
- if (!guard(req, res, "POST")) return;
5815
- const config = getConfig();
5816
- const body = await readJsonBody(req);
5817
- const idea = body?.idea?.trim() ?? "";
5818
- if (idea.length < 50) {
5819
- writeJson(res, 400, { error: "想法太短(<50 字),请多写一两句:主角是谁、什么世界、想要什么爽点" });
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)" });
5820
6265
  return;
5821
6266
  }
5822
- const count = body?.count !== void 0 ? Math.max(1, Math.min(3, Math.floor(body.count))) : 3;
5823
- const exclude = Array.isArray(body?.exclude) ? body.exclude.filter((e) => typeof e === "string" && e.trim() !== "").map((e) => e.trim().slice(0, 200)) : [];
5824
- try {
5825
- writeJson(res, 200, { candidates: await suggestOutlines(ctx, config, idea, count, exclude) });
5826
- } catch (error) {
5827
- writeJson(res, 500, { error: `大纲方案生成失败:${error.message}` });
5828
- }
5829
- }
5830
- },
5831
- {
5832
- kind: "exact",
5833
- path: NOVEL_API.breakdown,
5834
- handler: async (req, res) => {
5835
- if (!guard(req, res, "POST")) return;
5836
- const config = getConfig();
5837
- const project = requireProject(res);
5838
- if (project === void 0) return;
5839
- const body = await readJsonBody(req);
5840
6267
  try {
5841
- writeJson(res, 200, await breakdownBook(ctx, config, project, config.outputDir, body?.scope ?? "recent", body?.preset ?? "quick", body?.budgetTokens ?? 5e4));
6268
+ writeJson(res, 200, await runner.start(startNo, endNo));
5842
6269
  } catch (error) {
5843
- writeJson(res, 500, { error: `拆书分析失败:${error.message}` });
6270
+ writeJson(res, 409, { error: error.message });
5844
6271
  }
5845
6272
  }
5846
6273
  },
5847
6274
  {
5848
6275
  kind: "exact",
5849
- path: NOVEL_API.storyboard,
6276
+ path: NOVEL_API.runControl,
5850
6277
  handler: async (req, res) => {
5851
6278
  if (!guard(req, res, "POST")) return;
5852
- const config = getConfig();
5853
- const project = requireProject(res);
5854
- if (project === void 0) return;
5855
6279
  const body = await readJsonBody(req);
5856
- if (!Number.isInteger(body?.chapterNo)) {
5857
- writeJson(res, 400, { error: "chapterNo 须为正整数" });
6280
+ if (runner.status() === null) {
6281
+ writeJson(res, 400, { error: "没有生产单" });
5858
6282
  return;
5859
6283
  }
5860
- try {
5861
- writeJson(res, 200, await generateStoryboard(ctx, config, project, config.outputDir, body.chapterNo, body?.genre ?? "", body?.platform ?? "抖音", body?.tool ?? "doubao"));
5862
- } catch (error) {
5863
- writeJson(res, 500, { 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;
5864
6290
  }
6291
+ writeJson(res, 200, runner.status());
5865
6292
  }
5866
6293
  },
5867
6294
  {
5868
6295
  kind: "exact",
5869
- path: NOVEL_API.storyboardPlan,
5870
- handler: async (req, res) => {
5871
- if (!guard(req, res, "POST")) return;
5872
- const config = getConfig();
5873
- const project = requireProject(res);
5874
- if (project === void 0) return;
5875
- const body = await readJsonBody(req);
5876
- if (!Number.isInteger(body?.volumeNo) || (body?.volumeNo ?? 0) < 1) {
5877
- writeJson(res, 400, { error: "volumeNo 须为正整数" });
5878
- return;
5879
- }
5880
- try {
5881
- writeJson(res, 200, await planStoryboardEpisodes(ctx, config, project, body.volumeNo, body?.platform ?? "抖音", body?.maxEpisodes ?? 25));
5882
- } catch (error) {
5883
- writeJson(res, 500, { error: `分集计划生成失败:${error.message}` });
5884
- }
6296
+ path: NOVEL_API.runStatus,
6297
+ handler: (req, res) => {
6298
+ if (!guard(req, res, "GET")) return;
6299
+ writeJson(res, 200, runner.status());
5885
6300
  }
5886
6301
  }
5887
6302
  ];
@@ -5905,8 +6320,8 @@ const NOVEL_SETTINGS_NAMESPACE = settingsNamespace("dsh-novel-forge");
5905
6320
  const Config = z.object({
5906
6321
  announceToAgent: z.boolean().default(true),
5907
6322
  enabled: z.boolean().default(true),
5908
- outlinePath: z.string().default("C:\\Users\\Ryan\\Desktop\\《归墟玉主》全书大纲_重新排版版.docx"),
5909
- 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\\归墟玉主"),
5910
6325
  provider: z.string().default("deepseek-official"),
5911
6326
  model: z.string().default("deepseek-v4-flash"),
5912
6327
  chapterChars: z.number().default(3500),
@@ -5918,8 +6333,8 @@ const Config = z.object({
5918
6333
  });
5919
6334
  /** Schema defaults, re-read for hand-built test contexts. */
5920
6335
  const DEFAULT_ANNOUNCE = true;
5921
- const DEFAULT_OUTLINE_PATH = "C:\\Users\\Ryan\\Desktop\\《归墟玉主》全书大纲_重新排版版.docx";
5922
- 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\\归墟玉主";
5923
6338
  const DEFAULT_PROVIDER = "deepseek-official";
5924
6339
  const DEFAULT_MODEL = "deepseek-v4-flash";
5925
6340
  const DEFAULT_CHAPTER_CHARS = 3500;
@@ -5931,7 +6346,7 @@ const DEFAULT_AUTO_REVIEW_AFTER_REVISE = true;
5931
6346
  /** Order of the announcement section within the tool-guidance band. */
5932
6347
  const SECTION_ORDER = 160;
5933
6348
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
5934
- 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 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色 / 归墟玉主」时即指本插件,请据此协作。";
5935
6350
  /** Resolve a config-like value into the full runtime config. */
5936
6351
  function resolveConfig(value) {
5937
6352
  return {