@waterwx/dsh-novel-forge 1.1.8 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/lib/client.js +2262 -1242
- package/lib/client.js.map +1 -1
- package/lib/index.js +1017 -551
- package/lib/index.js.map +1 -1
- package/lib/types/bookshelf.d.ts +1 -1
- package/lib/types/client/api.d.ts +6 -0
- package/lib/types/client/locales.d.ts +8 -0
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/client/panel/RunPanel.d.ts +5 -0
- package/lib/types/engine.d.ts +7 -1
- package/lib/types/index.d.ts +3 -1
- package/lib/types/protocol.d.ts +77 -0
- package/lib/types/run.d.ts +32 -0
- package/package.json +2 -1
- package/src/bookshelf.ts +2 -2
- package/src/client/api.ts +23 -0
- package/src/client/locales.ts +16 -0
- package/src/client/panel/AssetsTab.tsx +19 -0
- package/src/client/panel/CreateBookView.tsx +3 -3
- package/src/client/panel/NovelPanel.tsx +528 -225
- package/src/client/panel/RunPanel.tsx +204 -0
- package/src/client/panel/ShelfView.tsx +13 -12
- package/src/client/panel/panel.module.css +192 -68
- package/src/engine.ts +42 -1
- package/src/index.ts +11 -5
- package/src/protocol.ts +78 -0
- package/src/routes.ts +105 -4
- package/src/run.ts +330 -0
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
|
-
|
|
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(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/g, "&").replace(/ /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. */
|
|
@@ -1237,7 +1116,8 @@ async function complete(ctx, config, options) {
|
|
|
1237
1116
|
messages,
|
|
1238
1117
|
system: options.system,
|
|
1239
1118
|
maxTokens: options.maxTokens ?? config.maxTokens,
|
|
1240
|
-
temperature: options.temperature ?? .7
|
|
1119
|
+
temperature: options.temperature ?? .7,
|
|
1120
|
+
reasoningEffort: ReasoningEffortId(config.reasoningEffort ?? "off")
|
|
1241
1121
|
};
|
|
1242
1122
|
const assembler = new BlockAssembler();
|
|
1243
1123
|
for await (const chunk of ctx.llm.stream(request)) assembler.push(chunk);
|
|
@@ -1509,6 +1389,14 @@ function writeSystemPrompt(project) {
|
|
|
1509
1389
|
sections.push("3. 尊重大纲与设定圣经:人设不崩、金手指规则不自相矛盾、战力不随意膨胀。");
|
|
1510
1390
|
sections.push("4. 章末留一个钩子(悬念、反转或新线索),吸引读者读下一章。");
|
|
1511
1391
|
sections.push("5. 语言流畅自然,符合中文网文语感,避免翻译腔与病句。");
|
|
1392
|
+
sections.push("6. 对话与冲突密度:每章至少 1 处实质对话或正面对抗/交锋场面;推理与心理活动必须用动作、环境细节、微表情、对话呈现,禁止整章纯内心独白铺陈(禁止\"解说式\"交代线索)。");
|
|
1393
|
+
sections.push("7. 反派与对手的行动力:本章出现的反派/对手必须有其行动、反制或压迫感(布局、试探、追索、交锋至少占其一),不得作为纯背景板存在。");
|
|
1394
|
+
sections.push("8. 配角辨识度:重要新登场配角应给姓名或可辨识的独有特征;禁止通篇用\"瘦高个/灰衣人/戴面具者\"等身形标签代称同一角色。");
|
|
1395
|
+
sections.push("9. 信息呈现方式:关键线索、设定、局势通过对话、动作、发现物呈现,禁止主角内心\"讲解\"给读者听。");
|
|
1396
|
+
sections.push("");
|
|
1397
|
+
sections.push("==================== 内容合规红线(平台硬性要求,最高优先级,违反即失败) ====================");
|
|
1398
|
+
sections.push(COMPLIANCE_REDLINES.join("\n"));
|
|
1399
|
+
sections.push("以上九条为硬性底线,任何情况下不得以任何形式出现或影射;若剧情确需涉及(如批判、反讽),只能以明确否定、揭露、批判的立场呈现,且不得展开细节。");
|
|
1512
1400
|
return sections.join("\n");
|
|
1513
1401
|
}
|
|
1514
1402
|
/**
|
|
@@ -1592,6 +1480,8 @@ function reviewSystemPrompt(project) {
|
|
|
1592
1480
|
"5. 节奏与爽点:本章是否有推进、有钩子,是否拖沓灌水。",
|
|
1593
1481
|
"6. 逻辑漏洞:前后矛盾、时间线错误、对话失真。",
|
|
1594
1482
|
"7. 反 AI 规则:逐条核对下方「反 AI 规则」清单,命中即列为问题。",
|
|
1483
|
+
"8. 呈现方式:整章是否纯内心推理铺陈(无对话/无对抗,推理全靠解说);反派是否纯背景板无行动;重要配角是否无名标签化(瘦高个/灰衣人全程代称)——命中即列为问题。",
|
|
1484
|
+
"9. 内容合规(最高优先级):逐条核对下方「内容合规红线」,任何一条命中(含影射、暗示、详细描写)必须列为 high,并给出改写建议。",
|
|
1595
1485
|
"输出必须是合法 JSON 对象,不要输出任何其他文字:",
|
|
1596
1486
|
"{\"score\": 0-100的整数, \"verdict\": \"一句话总评\", \"issues\": [{\"severity\": \"high|medium|low\", \"item\": \"问题描述\", \"suggestion\": \"修改建议\"}]}",
|
|
1597
1487
|
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
@@ -1611,6 +1501,9 @@ function reviewSystemPrompt(project) {
|
|
|
1611
1501
|
}
|
|
1612
1502
|
if (bible.redLines.length > 0) sections.push("红线:\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
|
|
1613
1503
|
}
|
|
1504
|
+
sections.push("\n==================== 内容合规红线(平台硬性要求,最高优先级) ====================");
|
|
1505
|
+
sections.push(COMPLIANCE_REDLINES.join("\n"));
|
|
1506
|
+
sections.push("以上九条为硬性底线:正文中任何一条命中(含影射、暗示、详细展开)都必须列为 high,并给出改写建议;作者自定义红线不得豁免这九条。");
|
|
1614
1507
|
return sections.join("\n");
|
|
1615
1508
|
}
|
|
1616
1509
|
/** Run the AI review on one chapter. */
|
|
@@ -2004,6 +1897,7 @@ async function suggestOutlines(ctx, config, idea, count = 3, exclude = []) {
|
|
|
2004
1897
|
].join("\n"),
|
|
2005
1898
|
user: [
|
|
2006
1899
|
`作者的想法:${idea}`,
|
|
1900
|
+
idea.trim().length < 40 ? "作者的想法非常简短(可能只有一句)。请基于通用网文套路合理扩展补全:为每个方案自洽地设计金手指/核心设定、主角人设与动机、主线走向,使其成为完整可开书的大纲;不同方案的方向仍须明显差异。" : "",
|
|
2007
1901
|
exclude.length > 0 ? `需避开的已暂留方案方向(新方案不得与之雷同):\n${exclude.map((e, i) => `${i + 1}. ${e}`).join("\n")}` : "",
|
|
2008
1902
|
`请生成 ${n} 个大纲方案。`,
|
|
2009
1903
|
"只输出 JSON 数组。"
|
|
@@ -2854,15 +2748,21 @@ async function auditBatch(ctx, config, project, outputDir, batch) {
|
|
|
2854
2748
|
return issues;
|
|
2855
2749
|
}
|
|
2856
2750
|
/** 全书一致性质检:LLM 分批扫描已生成章节 + 设定 + 事实库,聚合矛盾清单。 */
|
|
2857
|
-
async function auditBook(ctx, config, project, outputDir) {
|
|
2751
|
+
async function auditBook(ctx, config, project, outputDir, onProgress) {
|
|
2858
2752
|
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
2859
|
-
if (written.length === 0)
|
|
2753
|
+
if (written.length === 0) {
|
|
2754
|
+
onProgress?.(0, 0);
|
|
2755
|
+
return [];
|
|
2756
|
+
}
|
|
2757
|
+
const totalBatches = Math.ceil(written.length / AUDIT_BATCH_SIZE);
|
|
2860
2758
|
const all = [];
|
|
2759
|
+
onProgress?.(0, totalBatches);
|
|
2861
2760
|
for (let i = 0; i < written.length; i += AUDIT_BATCH_SIZE) {
|
|
2862
2761
|
const batch = written.slice(i, i + AUDIT_BATCH_SIZE);
|
|
2863
2762
|
try {
|
|
2864
2763
|
all.push(...await auditBatch(ctx, config, project, outputDir, batch));
|
|
2865
2764
|
} catch {}
|
|
2765
|
+
onProgress?.(Math.min(Math.ceil((i + AUDIT_BATCH_SIZE) / AUDIT_BATCH_SIZE), totalBatches), totalBatches);
|
|
2866
2766
|
}
|
|
2867
2767
|
return all.slice(0, 50);
|
|
2868
2768
|
}
|
|
@@ -3254,87 +3154,671 @@ function exportBook(outputDir, project, format) {
|
|
|
3254
3154
|
};
|
|
3255
3155
|
}
|
|
3256
3156
|
//#endregion
|
|
3257
|
-
//#region src/
|
|
3157
|
+
//#region src/bookshelf.ts
|
|
3258
3158
|
/**
|
|
3259
|
-
*
|
|
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).
|
|
3159
|
+
* 书架(Bookshelf)— 多书管理:一本书记录一个独立输出目录。
|
|
3160
|
+
* 状态持久化到 ~/.dsh/dsh-novel-forge-bookshelf.json(跟随 dsh 配置惯例)。
|
|
3271
3161
|
*/
|
|
3272
|
-
/**
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3162
|
+
/** 书架配置文件路径。 */
|
|
3163
|
+
function bookshelfFile() {
|
|
3164
|
+
return join(homedir(), ".dsh", "dsh-novel-forge-bookshelf.json");
|
|
3165
|
+
}
|
|
3166
|
+
function defaultStore() {
|
|
3167
|
+
return {
|
|
3168
|
+
books: [],
|
|
3169
|
+
activeBookId: null
|
|
3170
|
+
};
|
|
3171
|
+
}
|
|
3172
|
+
/** 读取书架(无则返回空)。 */
|
|
3173
|
+
function loadBookshelf() {
|
|
3174
|
+
const file = bookshelfFile();
|
|
3175
|
+
if (!existsSync(file)) return defaultStore();
|
|
3281
3176
|
try {
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3177
|
+
let raw = readFileSync(file, "utf8");
|
|
3178
|
+
if (raw.charCodeAt(0) === 65279) raw = raw.slice(1);
|
|
3179
|
+
const parsed = JSON.parse(raw);
|
|
3180
|
+
if (!Array.isArray(parsed.books)) return defaultStore();
|
|
3181
|
+
return {
|
|
3182
|
+
books: parsed.books,
|
|
3183
|
+
activeBookId: parsed.activeBookId ?? null
|
|
3184
|
+
};
|
|
3185
|
+
} catch {
|
|
3186
|
+
return defaultStore();
|
|
3187
|
+
}
|
|
3291
3188
|
}
|
|
3292
|
-
/**
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
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");
|
|
3189
|
+
/** 持久化书架。 */
|
|
3190
|
+
function saveBookshelf(store) {
|
|
3191
|
+
const file = bookshelfFile();
|
|
3192
|
+
mkdirSync(join(homedir(), ".dsh"), { recursive: true });
|
|
3193
|
+
writeFileSync(file, JSON.stringify(store, null, 2), "utf8");
|
|
3302
3194
|
}
|
|
3303
|
-
/**
|
|
3304
|
-
function
|
|
3305
|
-
|
|
3306
|
-
if (existsSync(file)) rmSync(file, { force: true });
|
|
3195
|
+
/** 当前激活的书。 */
|
|
3196
|
+
function activeBook(store) {
|
|
3197
|
+
return store.books.find((b) => b.id === store.activeBookId);
|
|
3307
3198
|
}
|
|
3308
|
-
/**
|
|
3309
|
-
function
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3199
|
+
/** 书架快照(含每本书的进度摘要)。 */
|
|
3200
|
+
function bookshelfSnapshot(store) {
|
|
3201
|
+
return {
|
|
3202
|
+
books: store.books.map((book) => {
|
|
3203
|
+
const project = loadProject(book.outputDir);
|
|
3204
|
+
const done = project === void 0 ? 0 : project.chapters.filter((c) => c.status === "approved" || c.status === "written" || c.status === "rejected").length;
|
|
3205
|
+
const hasCover = project?.coverPath !== void 0 && project.coverPath !== "" && existsSync(join(book.outputDir, project.coverPath));
|
|
3206
|
+
return {
|
|
3207
|
+
...book,
|
|
3208
|
+
done,
|
|
3209
|
+
total: project?.chapters.length ?? 0,
|
|
3210
|
+
hasProject: project !== void 0,
|
|
3211
|
+
hasCover,
|
|
3212
|
+
blurb: project?.blurb
|
|
3213
|
+
};
|
|
3214
|
+
}),
|
|
3215
|
+
activeBookId: store.activeBookId
|
|
3216
|
+
};
|
|
3217
|
+
}
|
|
3218
|
+
/** 新建一本书(自动成为当前书)。 */
|
|
3219
|
+
function createBook(bookName, outputDir) {
|
|
3220
|
+
const store = loadBookshelf();
|
|
3221
|
+
const id = `book-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
|
|
3222
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3223
|
+
const book = {
|
|
3224
|
+
id,
|
|
3225
|
+
bookName,
|
|
3226
|
+
outputDir,
|
|
3227
|
+
createdAt: now,
|
|
3228
|
+
updatedAt: now
|
|
3229
|
+
};
|
|
3230
|
+
store.books.push(book);
|
|
3231
|
+
store.activeBookId = id;
|
|
3232
|
+
saveBookshelf(store);
|
|
3233
|
+
return book;
|
|
3234
|
+
}
|
|
3235
|
+
/** 更新某本书的书名(开书向导导入大纲后书名以大纲首行为准)。 */
|
|
3236
|
+
function renameBook(id, bookName) {
|
|
3237
|
+
const store = loadBookshelf();
|
|
3238
|
+
const book = store.books.find((b) => b.id === id);
|
|
3239
|
+
if (book === void 0) return false;
|
|
3240
|
+
book.bookName = bookName;
|
|
3241
|
+
book.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3242
|
+
saveBookshelf(store);
|
|
3243
|
+
return true;
|
|
3244
|
+
}
|
|
3245
|
+
/**
|
|
3246
|
+
* 播种:书架为空时,把指定输出目录下已有的项目自动登记为第一本书。
|
|
3247
|
+
* 兼容升级场景 —— 旧版插件直接在输出目录写项目,从未登记书架。
|
|
3248
|
+
* @param outputDir - 候选输出目录(通常为 settings 的默认输出目录)。
|
|
3249
|
+
* @returns 是否发生了播种。
|
|
3250
|
+
*/
|
|
3251
|
+
function seedBookshelfFromOutputDir(outputDir) {
|
|
3252
|
+
if (loadBookshelf().books.length > 0) return false;
|
|
3253
|
+
if (!existsSync(outputDir)) return false;
|
|
3254
|
+
const hasProject = existsSync(join(outputDir, "novel-project.json"));
|
|
3255
|
+
const hasChapters = existsSync(outputDir);
|
|
3256
|
+
if (!hasProject && !hasChapters) return false;
|
|
3257
|
+
createBook(loadProject(outputDir)?.bookName ?? outputDir.split(/[\\/]/).pop() ?? "未命名小说", outputDir);
|
|
3258
|
+
return true;
|
|
3259
|
+
}
|
|
3260
|
+
/** 激活一本书。 */
|
|
3261
|
+
function activateBook(id) {
|
|
3262
|
+
const store = loadBookshelf();
|
|
3263
|
+
const book = store.books.find((b) => b.id === id);
|
|
3264
|
+
if (book === void 0) return void 0;
|
|
3265
|
+
store.activeBookId = id;
|
|
3266
|
+
book.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3267
|
+
saveBookshelf(store);
|
|
3268
|
+
return book;
|
|
3269
|
+
}
|
|
3270
|
+
/** 移除一本书。 */
|
|
3271
|
+
function removeBook(id) {
|
|
3272
|
+
const store = loadBookshelf();
|
|
3273
|
+
const idx = store.books.findIndex((b) => b.id === id);
|
|
3274
|
+
if (idx === -1) return false;
|
|
3275
|
+
store.books.splice(idx, 1);
|
|
3276
|
+
if (store.activeBookId === id) store.activeBookId = store.books[0]?.id ?? null;
|
|
3277
|
+
saveBookshelf(store);
|
|
3278
|
+
return true;
|
|
3279
|
+
}
|
|
3280
|
+
/** 当前书输出目录(无书架则 undefined,回退 settings)。 */
|
|
3281
|
+
function activeBookOutputDir() {
|
|
3282
|
+
return activeBook(loadBookshelf())?.outputDir;
|
|
3283
|
+
}
|
|
3284
|
+
/** 默认输出目录推断:小说目录/书名。 */
|
|
3285
|
+
function defaultOutputDirFor(bookName) {
|
|
3286
|
+
return join("D:\\ryan work\\harness\\novels", bookName.replace(/[\\/:*?"<>|]/g, "").trim().slice(0, 40) || "未命名小说");
|
|
3287
|
+
}
|
|
3288
|
+
//#endregion
|
|
3289
|
+
//#region src/run.ts
|
|
3290
|
+
/**
|
|
3291
|
+
* 生产单(Production Run):批量章节生产的标准执行器。
|
|
3292
|
+
* 职责:计划补足 → 逐章生成(完整质量门)→ 被拒分级处理(豁免/修订+验证/待人工)→ 断点续跑。
|
|
3293
|
+
* 与路由层解耦:只依赖 engine 导出函数与磁盘状态,自身不碰 HTTP。
|
|
3294
|
+
* 串行纪律:单例执行器 + working 锁;每章重新从磁盘加载,写前合并易变字段。
|
|
3295
|
+
*/
|
|
3296
|
+
/** 生产单 checkpoint 文件名(放在书目录下)。 */
|
|
3297
|
+
function runStateFile(outputDir) {
|
|
3298
|
+
return join(outputDir, "run-state.json");
|
|
3299
|
+
}
|
|
3300
|
+
var ProductionRunner = class {
|
|
3301
|
+
deps;
|
|
3302
|
+
state = null;
|
|
3303
|
+
working = false;
|
|
3304
|
+
pauseRequested = false;
|
|
3305
|
+
stopRequested = false;
|
|
3306
|
+
/** 生产单绑定目录(start 时快照;所有读写固定用它,防止运行中切书导致写错目录)。 */
|
|
3307
|
+
bookDir = null;
|
|
3308
|
+
constructor(deps) {
|
|
3309
|
+
this.deps = deps;
|
|
3310
|
+
}
|
|
3311
|
+
/** 当前生产单状态(内存优先;web 重启后从磁盘恢复)。 */
|
|
3312
|
+
status() {
|
|
3313
|
+
if (this.state !== null) return this.state;
|
|
3314
|
+
const candidates = [this.deps.getConfig().outputDir];
|
|
3315
|
+
const bookshelf = loadBookshelf();
|
|
3316
|
+
for (const b of bookshelf.books) if (!candidates.includes(b.outputDir)) candidates.push(b.outputDir);
|
|
3317
|
+
for (const outputDir of candidates) {
|
|
3318
|
+
const file = runStateFile(outputDir);
|
|
3319
|
+
if (!existsSync(file)) continue;
|
|
3320
|
+
try {
|
|
3321
|
+
const raw = readFileSync(file, "utf8");
|
|
3322
|
+
const parsed = JSON.parse(raw);
|
|
3323
|
+
if (parsed?.runId === void 0) continue;
|
|
3324
|
+
if (parsed.status === "running") parsed.status = "paused";
|
|
3325
|
+
this.state = parsed;
|
|
3326
|
+
this.bookDir = outputDir;
|
|
3327
|
+
return this.state;
|
|
3328
|
+
} catch {}
|
|
3329
|
+
}
|
|
3330
|
+
return null;
|
|
3331
|
+
}
|
|
3332
|
+
persist() {
|
|
3333
|
+
if (this.state === null) return;
|
|
3334
|
+
const outputDir = this.bookDir ?? this.deps.getConfig().outputDir;
|
|
3335
|
+
mkdirSync(outputDir, { recursive: true });
|
|
3336
|
+
writeFileSync(runStateFile(outputDir), JSON.stringify(this.state, null, 2), "utf8");
|
|
3337
|
+
}
|
|
3338
|
+
log(text) {
|
|
3339
|
+
if (this.state === null) return;
|
|
3340
|
+
this.state.log.push({
|
|
3341
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3342
|
+
text
|
|
3343
|
+
});
|
|
3344
|
+
if (this.state.log.length > 300) this.state.log = this.state.log.slice(-300);
|
|
3345
|
+
this.state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3346
|
+
}
|
|
3347
|
+
/** 启动/续跑生产单:startNo..endNo 区间,endNo 超出计划时先自动补计划。 */
|
|
3348
|
+
async start(startNo, endNo) {
|
|
3349
|
+
const config = this.deps.getConfig();
|
|
3350
|
+
if (this.working) throw new Error("生产单正在运行中,请先暂停或停止");
|
|
3351
|
+
let project = loadProject(config.outputDir);
|
|
3352
|
+
if (project === void 0) throw new Error("输出目录中没有项目");
|
|
3353
|
+
if (endNo > project.chapters.length) {
|
|
3354
|
+
const need = endNo - project.chapters.length;
|
|
3355
|
+
this.log(`计划不足,追加 ${need} 章计划…`);
|
|
3356
|
+
const chapters = await planChapters(this.deps.ctx, config, project, need, void 0, config.outputDir);
|
|
3357
|
+
mergeVolatileFromDisk(config.outputDir, project);
|
|
3358
|
+
project.chapters.push(...chapters);
|
|
3359
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3360
|
+
saveProject(config.outputDir, project);
|
|
3361
|
+
this.log(`计划已追加,全书 ${project.chapters.length} 章`);
|
|
3362
|
+
}
|
|
3363
|
+
this.state = {
|
|
3364
|
+
runId: `run-${Date.now().toString(36)}`,
|
|
3365
|
+
startNo,
|
|
3366
|
+
endNo,
|
|
3367
|
+
status: "running",
|
|
3368
|
+
currentNo: startNo,
|
|
3369
|
+
stats: {
|
|
3370
|
+
generated: 0,
|
|
3371
|
+
revised: 0,
|
|
3372
|
+
exempted: 0,
|
|
3373
|
+
regenerated: 0,
|
|
3374
|
+
error: 0
|
|
3375
|
+
},
|
|
3376
|
+
pendingManual: [],
|
|
3377
|
+
log: [],
|
|
3378
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3379
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3380
|
+
};
|
|
3381
|
+
this.pauseRequested = false;
|
|
3382
|
+
this.stopRequested = false;
|
|
3383
|
+
this.bookDir = config.outputDir;
|
|
3384
|
+
this.working = true;
|
|
3385
|
+
this.persist();
|
|
3386
|
+
this.loop();
|
|
3387
|
+
return this.state;
|
|
3388
|
+
}
|
|
3389
|
+
pause() {
|
|
3390
|
+
this.pauseRequested = true;
|
|
3391
|
+
}
|
|
3392
|
+
resume() {
|
|
3393
|
+
if (this.state === null || this.state.status !== "paused") return;
|
|
3394
|
+
this.state.status = "running";
|
|
3395
|
+
this.working = true;
|
|
3396
|
+
this.pauseRequested = false;
|
|
3397
|
+
this.stopRequested = false;
|
|
3398
|
+
this.persist();
|
|
3399
|
+
this.loop();
|
|
3400
|
+
}
|
|
3401
|
+
stop() {
|
|
3402
|
+
this.stopRequested = true;
|
|
3403
|
+
}
|
|
3404
|
+
/** 主循环:从 currentNo 扫描到 endNo,逐章处理;支持暂停/停止。 */
|
|
3405
|
+
async loop() {
|
|
3406
|
+
const config = this.deps.getConfig();
|
|
3407
|
+
const outputDir = this.bookDir ?? config.outputDir;
|
|
3408
|
+
try {
|
|
3409
|
+
while (this.state !== null) {
|
|
3410
|
+
if (this.stopRequested) {
|
|
3411
|
+
this.stopRequested = false;
|
|
3412
|
+
this.state.status = "stopped";
|
|
3413
|
+
this.log("生产单已停止");
|
|
3414
|
+
this.persist();
|
|
3415
|
+
break;
|
|
3416
|
+
}
|
|
3417
|
+
if (this.pauseRequested) {
|
|
3418
|
+
this.pauseRequested = false;
|
|
3419
|
+
this.state.status = "paused";
|
|
3420
|
+
this.log("生产单已暂停(随时可继续)");
|
|
3421
|
+
this.persist();
|
|
3422
|
+
break;
|
|
3423
|
+
}
|
|
3424
|
+
const project = loadProject(outputDir);
|
|
3425
|
+
if (project === void 0) {
|
|
3426
|
+
this.state.status = "error";
|
|
3427
|
+
this.state.error = "项目丢失";
|
|
3428
|
+
this.persist();
|
|
3429
|
+
break;
|
|
3430
|
+
}
|
|
3431
|
+
let next;
|
|
3432
|
+
for (let no = this.state.currentNo; no <= this.state.endNo; no++) {
|
|
3433
|
+
const ch = project.chapters.find((c) => c.no === no);
|
|
3434
|
+
if (ch === void 0) continue;
|
|
3435
|
+
if (ch.status === "approved") {
|
|
3436
|
+
this.state.currentNo = no;
|
|
3437
|
+
continue;
|
|
3438
|
+
}
|
|
3439
|
+
next = ch;
|
|
3440
|
+
break;
|
|
3441
|
+
}
|
|
3442
|
+
if (next === void 0) {
|
|
3443
|
+
this.state.status = "done";
|
|
3444
|
+
this.log(`生产单完成:${this.state.startNo}-${this.state.endNo} 章处理完毕`);
|
|
3445
|
+
this.persist();
|
|
3446
|
+
break;
|
|
3447
|
+
}
|
|
3448
|
+
this.state.currentNo = next.no;
|
|
3449
|
+
await this.processChapter(project, next);
|
|
3450
|
+
this.persist();
|
|
3451
|
+
}
|
|
3452
|
+
} catch (error) {
|
|
3453
|
+
if (this.state !== null) {
|
|
3454
|
+
this.state.status = "error";
|
|
3455
|
+
this.state.error = error.message;
|
|
3456
|
+
this.log(`生产单异常:${error.message}`);
|
|
3457
|
+
this.persist();
|
|
3458
|
+
}
|
|
3459
|
+
} finally {
|
|
3460
|
+
this.working = false;
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
async processChapter(project, chapter) {
|
|
3464
|
+
if (chapter.status === "pending" || chapter.status === "error" || chapter.status === "written") await this.produce(project, chapter);
|
|
3465
|
+
else if (chapter.status === "rejected") await this.handleRejected(project, chapter);
|
|
3466
|
+
else if (chapter.status === "generating") {
|
|
3467
|
+
chapter.status = "pending";
|
|
3468
|
+
await this.produce(project, chapter);
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
/** 完整质量门:生成 → 摘要+事实 → 伏笔标记 → 审稿 → 作者复盘。 */
|
|
3472
|
+
async produce(project, chapter) {
|
|
3473
|
+
const { ctx, getConfig } = this.deps;
|
|
3474
|
+
const config = getConfig();
|
|
3475
|
+
const outputDir = this.bookDir ?? config.outputDir;
|
|
3476
|
+
const no = chapter.no;
|
|
3477
|
+
const wasError = chapter.status === "error";
|
|
3478
|
+
chapter.status = "generating";
|
|
3479
|
+
chapter.error = void 0;
|
|
3480
|
+
chapter.generatingAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3481
|
+
mergeVolatileFromDisk(outputDir, project);
|
|
3482
|
+
saveProject(outputDir, project);
|
|
3483
|
+
this.log(wasError ? `第${no}章《${chapter.title}》重新生成…` : `第${no}章《${chapter.title}》生成…`);
|
|
3484
|
+
try {
|
|
3485
|
+
for await (const step of generateChapterStream(ctx, config, project, outputDir, no));
|
|
3486
|
+
try {
|
|
3487
|
+
await summarizeAndExtractFacts(ctx, config, project, outputDir, no);
|
|
3488
|
+
} catch (e) {
|
|
3489
|
+
console.warn("[dsh-novel-forge] run summary/facts:", e.message);
|
|
3490
|
+
}
|
|
3491
|
+
try {
|
|
3492
|
+
markForeshadowPlanted(project, outputDir, no);
|
|
3493
|
+
} catch (e) {
|
|
3494
|
+
console.warn("[dsh-novel-forge] run foreshadow:", e.message);
|
|
3495
|
+
}
|
|
3496
|
+
if (config.autoReview ?? true) {
|
|
3497
|
+
const report = await reviewChapter(ctx, config, project, outputDir, no);
|
|
3498
|
+
if (this.state !== null) this.state.stats[wasError ? "regenerated" : "generated"]++;
|
|
3499
|
+
this.log(`第${no}章 审稿 ${report.score}分 ${report.passed ? "✅ 通过" : "⚠️ 被拒"}`);
|
|
3500
|
+
} else {
|
|
3501
|
+
chapter.status = "approved";
|
|
3502
|
+
mergeVolatileFromDisk(outputDir, project);
|
|
3503
|
+
saveProject(outputDir, project);
|
|
3504
|
+
}
|
|
3505
|
+
if (config.autoAuthorReview ?? true) try {
|
|
3506
|
+
const body = readChapterFile(outputDir, chapter);
|
|
3507
|
+
if (body !== void 0) {
|
|
3508
|
+
let prevTail = "";
|
|
3509
|
+
if (no > 1) {
|
|
3510
|
+
const prev = project.chapters.find((c) => c.no === no - 1);
|
|
3511
|
+
if (prev !== void 0) prevTail = (readChapterFile(outputDir, prev) ?? "").replace(/^#.*$/m, "").trim().slice(-600);
|
|
3512
|
+
}
|
|
3513
|
+
const review = await authorReviewChapter(ctx, config, project, no, body, prevTail);
|
|
3514
|
+
chapter.authorReview = review;
|
|
3515
|
+
if (review.advancedLines !== void 0) autoLinkPlotlines(project, no, review.advancedLines);
|
|
3516
|
+
mergeVolatileFromDisk(outputDir, project);
|
|
3517
|
+
saveProject(outputDir, project);
|
|
3518
|
+
}
|
|
3519
|
+
} catch (e) {
|
|
3520
|
+
console.warn("[dsh-novel-forge] run author review:", e.message);
|
|
3521
|
+
}
|
|
3522
|
+
} catch (error) {
|
|
3523
|
+
chapter.status = "error";
|
|
3524
|
+
chapter.error = error.message;
|
|
3525
|
+
mergeVolatileFromDisk(outputDir, project);
|
|
3526
|
+
saveProject(outputDir, project);
|
|
3527
|
+
if (this.state !== null) this.state.stats.error++;
|
|
3528
|
+
this.log(`第${no}章 失败:${error.message}`);
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
/** 被拒分级处理:无 high 豁免;有 high 按意见修订(最多 2 轮)+ 验证模式;仍不过 → 待人工。 */
|
|
3532
|
+
async handleRejected(project, chapter) {
|
|
3533
|
+
const { ctx, getConfig } = this.deps;
|
|
3534
|
+
const config = getConfig();
|
|
3535
|
+
const outputDir = this.bookDir ?? config.outputDir;
|
|
3536
|
+
const no = chapter.no;
|
|
3537
|
+
const highs = (chapter.review?.issues ?? []).filter((i) => i.severity === "high");
|
|
3538
|
+
if (highs.length === 0) {
|
|
3539
|
+
chapter.status = "approved";
|
|
3540
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3541
|
+
saveProject(outputDir, project);
|
|
3542
|
+
if (this.state !== null) this.state.stats.exempted++;
|
|
3543
|
+
this.log(`第${no}章 豁免通过(无 high)`);
|
|
3544
|
+
return;
|
|
3545
|
+
}
|
|
3546
|
+
this.log(`第${no}章 修订(${highs.length} 个 high)…`);
|
|
3547
|
+
for (let round = 1; round <= 2; round++) {
|
|
3548
|
+
const instr = "按审稿意见修订(优先处理):\n" + highs.map((h) => `[${h.severity}] ${h.item}${h.suggestion !== "" ? `\n建议:${h.suggestion}` : ""}`).join("\n\n");
|
|
3549
|
+
try {
|
|
3550
|
+
for await (const step of rewriteChapterStream(ctx, config, project, outputDir, no, instr, void 0));
|
|
3551
|
+
} catch (error) {
|
|
3552
|
+
this.log(`第${no}章 第${round}轮修订出错:${error.message}`);
|
|
3553
|
+
continue;
|
|
3554
|
+
}
|
|
3555
|
+
const fresh = loadProject(outputDir);
|
|
3556
|
+
const freshCh = fresh?.chapters.find((c) => c.no === no);
|
|
3557
|
+
const draft = freshCh?.pendingDraft;
|
|
3558
|
+
if (draft === void 0 || draft.length < 50) {
|
|
3559
|
+
this.log(`第${no}章 第${round}轮草稿缺失,重试`);
|
|
3560
|
+
continue;
|
|
3561
|
+
}
|
|
3562
|
+
const verify = await reviewChapterText(ctx, config, fresh, draft, chapter.review);
|
|
3563
|
+
const highs2 = (verify.issues ?? []).filter((i) => i.severity === "high");
|
|
3564
|
+
if (verify.passed || highs2.length === 0) {
|
|
3565
|
+
this.applyDraft(fresh, freshCh, draft, verify);
|
|
3566
|
+
if (this.state !== null) this.state.stats.revised++;
|
|
3567
|
+
this.log(`第${no}章 第${round}轮修订通过(${verify.score}分)`);
|
|
3568
|
+
return;
|
|
3569
|
+
}
|
|
3570
|
+
this.log(`第${no}章 第${round}轮仍不过(${highs2.length} high)`);
|
|
3571
|
+
}
|
|
3572
|
+
if (this.state !== null) this.state.pendingManual.push(no);
|
|
3573
|
+
this.log(`第${no}章 ⚠️ 两轮修订仍不过 → 保留草稿待人工`);
|
|
3574
|
+
}
|
|
3575
|
+
applyDraft(project, chapter, draft, report) {
|
|
3576
|
+
const config = this.deps.getConfig();
|
|
3577
|
+
const fileName = chapterFileName(chapter);
|
|
3578
|
+
mkdirSync(config.outputDir, { recursive: true });
|
|
3579
|
+
const targetPath = join(config.outputDir, fileName);
|
|
3580
|
+
if (existsSync(targetPath)) copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, "")}.bak.md`));
|
|
3581
|
+
writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, "utf8");
|
|
3582
|
+
chapter.pendingDraft = void 0;
|
|
3583
|
+
chapter.chars = draft.length;
|
|
3584
|
+
chapter.file = fileName;
|
|
3585
|
+
if (typeof report.score === "number") {
|
|
3586
|
+
chapter.review = report;
|
|
3587
|
+
chapter.status = report.passed === true ? "approved" : "rejected";
|
|
3588
|
+
} else {
|
|
3589
|
+
chapter.status = "written";
|
|
3590
|
+
chapter.review = void 0;
|
|
3591
|
+
}
|
|
3592
|
+
chapter.error = void 0;
|
|
3593
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3594
|
+
saveProject(config.outputDir, project);
|
|
3595
|
+
}
|
|
3596
|
+
};
|
|
3597
|
+
//#endregion
|
|
3598
|
+
//#region src/protocol.ts
|
|
3599
|
+
/**
|
|
3600
|
+
* dsh-novel-forge — shared protocol between the host half (Node) and the
|
|
3601
|
+
* browser half (web GUI). Route paths, request/response shapes, the project
|
|
3602
|
+
* state file format, and the NDJSON generation stream frames all live here so
|
|
3603
|
+
* both halves spell exactly one vocabulary.
|
|
3604
|
+
*/
|
|
3605
|
+
/** The /api/dsh-novel-forge route family (same-origin, loopback-fenced). */
|
|
3606
|
+
const NOVEL_API = {
|
|
3607
|
+
status: "/api/dsh-novel-forge/status",
|
|
3608
|
+
loadOutline: "/api/dsh-novel-forge/load-outline",
|
|
3609
|
+
saveOutline: "/api/dsh-novel-forge/save-outline",
|
|
3610
|
+
plan: "/api/dsh-novel-forge/plan",
|
|
3611
|
+
volumes: "/api/dsh-novel-forge/volumes",
|
|
3612
|
+
bible: "/api/dsh-novel-forge/bible",
|
|
3613
|
+
assets: "/api/dsh-novel-forge/assets",
|
|
3614
|
+
styleEngine: "/api/dsh-novel-forge/style-engine",
|
|
3615
|
+
generate: "/api/dsh-novel-forge/generate",
|
|
3616
|
+
review: "/api/dsh-novel-forge/review",
|
|
3617
|
+
rewrite: "/api/dsh-novel-forge/rewrite",
|
|
3618
|
+
polish: "/api/dsh-novel-forge/polish",
|
|
3619
|
+
/** 采纳待确认草稿(润色/重写产物)覆盖正文文件。 */
|
|
3620
|
+
draftApply: "/api/dsh-novel-forge/draft/apply",
|
|
3621
|
+
/** 放弃待确认草稿,保留原稿。 */
|
|
3622
|
+
draftDiscard: "/api/dsh-novel-forge/draft/discard",
|
|
3623
|
+
summary: "/api/dsh-novel-forge/summary",
|
|
3624
|
+
foreshadow: "/api/dsh-novel-forge/foreshadow",
|
|
3625
|
+
exportBook: "/api/dsh-novel-forge/export",
|
|
3626
|
+
chapter: "/api/dsh-novel-forge/chapter",
|
|
3627
|
+
/** 审查任意正文文本(作者手动编辑后,不落盘)。 */
|
|
3628
|
+
chapterCheck: "/api/dsh-novel-forge/chapter/check",
|
|
3629
|
+
/** 保存手动编辑的正文(自动备份 .bak)。 */
|
|
3630
|
+
chapterSave: "/api/dsh-novel-forge/chapter/save",
|
|
3631
|
+
assistant: "/api/dsh-novel-forge/assistant",
|
|
3632
|
+
assistantHistory: "/api/dsh-novel-forge/assistant-history",
|
|
3633
|
+
/** 清空助手对话记录。 */
|
|
3634
|
+
assistantClear: "/api/dsh-novel-forge/assistant/clear",
|
|
3635
|
+
bookshelf: "/api/dsh-novel-forge/bookshelf",
|
|
3636
|
+
/** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
|
|
3637
|
+
reset: "/api/dsh-novel-forge/reset",
|
|
3638
|
+
/** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
|
|
3639
|
+
audit: "/api/dsh-novel-forge/audit",
|
|
3640
|
+
/** 角色卡刷新:基于事实库与各章摘要聚合角色当前状态。 */
|
|
3641
|
+
charactersRefresh: "/api/dsh-novel-forge/characters/refresh",
|
|
3642
|
+
/** 事实库回填:对历史已生成章节批量抽取事实(旧章节无事实记录时用)。 */
|
|
3643
|
+
factsBackfill: "/api/dsh-novel-forge/facts/backfill",
|
|
3644
|
+
/** 设定圣经局部修补(如世界观规则编辑)。 */
|
|
3645
|
+
biblePatch: "/api/dsh-novel-forge/bible/patch",
|
|
3646
|
+
/** 小说简介:生成(AI)/补全(AI)/保存。 */
|
|
3647
|
+
blurb: "/api/dsh-novel-forge/blurb",
|
|
3648
|
+
/** 重命名当前书(同步项目与书架条目)。 */
|
|
3649
|
+
rename: "/api/dsh-novel-forge/rename",
|
|
3650
|
+
/** 大世界:AI 提炼 / 保存结构化数据(境界/区域/势力)。 */
|
|
3651
|
+
world: "/api/dsh-novel-forge/world",
|
|
3652
|
+
/** 封面:GET 读取(dataUrl)/ POST 上传或移除。 */
|
|
3653
|
+
cover: "/api/dsh-novel-forge/blurb/cover",
|
|
3654
|
+
/** 剧情线管理:增删改 + 关联章节。 */
|
|
3655
|
+
plotlines: "/api/dsh-novel-forge/plotlines",
|
|
3656
|
+
/** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
|
|
3657
|
+
roles: "/api/dsh-novel-forge/roles",
|
|
3658
|
+
/** 作者复盘补跑:对已写章节补齐 authorReview(全书流式 / 单章 JSON)。 */
|
|
3659
|
+
reviewBackfill: "/api/dsh-novel-forge/review/backfill",
|
|
3660
|
+
/** 章节复位:generating 卡死 → pending(可重新生成)。 */
|
|
3661
|
+
chapterReset: "/api/dsh-novel-forge/chapter/reset",
|
|
3662
|
+
/** 章节直接通过:作者对 rejected/written 章节行使最终决定权。 */
|
|
3663
|
+
chapterApprove: "/api/dsh-novel-forge/chapter/approve",
|
|
3664
|
+
/** 敏感词检查:全书已写章节或指定文本。 */
|
|
3665
|
+
sensitiveCheck: "/api/dsh-novel-forge/sensitive-check",
|
|
3666
|
+
/** 开书想法 → AI 补全大纲:输入一句话想法,生成 2-3 个可选大纲方案。 */
|
|
3667
|
+
outlineSuggest: "/api/dsh-novel-forge/outline/suggest",
|
|
3668
|
+
/** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检(两阶段:源笔记→分节分析)。 */
|
|
3669
|
+
breakdown: "/api/dsh-novel-forge/breakdown",
|
|
3670
|
+
/** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(可适配豆包/Seedance/SD)。 */
|
|
3671
|
+
storyboard: "/api/dsh-novel-forge/storyboard",
|
|
3672
|
+
/** 漫剧分集计划:读一卷 → 按故事弧线分集(高潮拆集/过渡并章)。 */
|
|
3673
|
+
storyboardPlan: "/api/dsh-novel-forge/storyboard/plan",
|
|
3674
|
+
/** 生产单:启动批量生产(计划补足 + 逐章生成 + 被拒分级处理)。 */
|
|
3675
|
+
runStart: "/api/dsh-novel-forge/run/start",
|
|
3676
|
+
/** 生产单控制:pause / resume / stop。 */
|
|
3677
|
+
runControl: "/api/dsh-novel-forge/run/control",
|
|
3678
|
+
/** 生产单状态(含进度统计与日志)。 */
|
|
3679
|
+
runStatus: "/api/dsh-novel-forge/run/status",
|
|
3680
|
+
config: "/api/dsh-novel-forge/config",
|
|
3681
|
+
openFolder: "/api/dsh-novel-forge/open-folder"
|
|
3682
|
+
};
|
|
3683
|
+
//#endregion
|
|
3684
|
+
//#region src/docx.ts
|
|
3685
|
+
/**
|
|
3686
|
+
* docx outline extraction: a .docx is a zip whose word/document.xml holds the
|
|
3687
|
+
* body text in <w:t> runs inside <w:p> paragraphs. We unzip with fflate and
|
|
3688
|
+
* walk the XML with a tiny tokenizer — no heavyweight XML/DOM dependency.
|
|
3689
|
+
*/
|
|
3690
|
+
/** Decode the handful of XML entities docx bodies actually use. */
|
|
3691
|
+
function decodeEntities(text) {
|
|
3692
|
+
return text.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/g, "&").replace(/ /g, " ");
|
|
3693
|
+
}
|
|
3694
|
+
/**
|
|
3695
|
+
* Extract plain text from a docx buffer: one line per <w:p> paragraph, with
|
|
3696
|
+
* <w:tab>/<w:br> preserved as whitespace. Tables and nested structures are
|
|
3697
|
+
* flattened in document order (their paragraphs are just <w:p> too).
|
|
3698
|
+
* @param buffer - the raw .docx bytes.
|
|
3699
|
+
* @returns the body text.
|
|
3700
|
+
*/
|
|
3701
|
+
function extractDocxText(buffer) {
|
|
3702
|
+
let files;
|
|
3703
|
+
try {
|
|
3704
|
+
files = unzipSync(buffer);
|
|
3705
|
+
} catch (error) {
|
|
3706
|
+
throw new Error(`not a valid docx (zip open failed): ${error.message}`);
|
|
3707
|
+
}
|
|
3708
|
+
const document = files["word/document.xml"];
|
|
3709
|
+
if (document === void 0) throw new Error("not a valid docx (word/document.xml missing)");
|
|
3710
|
+
const xml = strFromU8(document);
|
|
3711
|
+
const paragraphs = [];
|
|
3712
|
+
const parts = xml.split(/<w:p\b[^>]*>/);
|
|
3713
|
+
for (let i = 1; i < parts.length; i++) {
|
|
3714
|
+
const segment = parts[i];
|
|
3715
|
+
const runs = [];
|
|
3716
|
+
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(" ");
|
|
3717
|
+
else if (match[0].startsWith("<w:br")) runs.push("\n");
|
|
3718
|
+
else runs.push(decodeEntities(match[1] ?? ""));
|
|
3719
|
+
const line = runs.join("").replace(/\u00a0/g, " ").trimEnd();
|
|
3720
|
+
paragraphs.push(line);
|
|
3721
|
+
}
|
|
3722
|
+
const text = paragraphs.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
3723
|
+
if (text.length === 0) throw new Error("docx contains no extractable text");
|
|
3724
|
+
return text;
|
|
3725
|
+
}
|
|
3726
|
+
/**
|
|
3727
|
+
* Read and extract a docx outline from disk.
|
|
3728
|
+
* @param path - absolute path to the .docx file.
|
|
3729
|
+
* @returns the extracted outline text.
|
|
3730
|
+
*/
|
|
3731
|
+
function readOutlineFromDocx(path) {
|
|
3732
|
+
let buffer;
|
|
3733
|
+
try {
|
|
3734
|
+
buffer = readFileSync(path);
|
|
3735
|
+
} catch (error) {
|
|
3736
|
+
throw new Error(`cannot read outline file "${path}": ${error.message}`);
|
|
3737
|
+
}
|
|
3738
|
+
return extractDocxText(new Uint8Array(buffer));
|
|
3739
|
+
}
|
|
3740
|
+
//#endregion
|
|
3741
|
+
//#region src/assistant.ts
|
|
3742
|
+
/**
|
|
3743
|
+
* AI assistant engine — a conversational editor over the novel project.
|
|
3744
|
+
*
|
|
3745
|
+
* The user talks to the assistant about plot, characters, settings; the
|
|
3746
|
+
* assistant can reply in prose AND emit action directives that the host
|
|
3747
|
+
* executes (rewrite a paragraph, edit the bible, regenerate a chapter,
|
|
3748
|
+
* export the book, ...). Conversation history persists next to the project
|
|
3749
|
+
* as NDJSON, so a reload keeps the thread.
|
|
3750
|
+
*
|
|
3751
|
+
* Action protocol: the model emits a line of the form
|
|
3752
|
+
* <dsh-action name="toolName">{jsonArgs}</dsh-action>
|
|
3753
|
+
* anywhere in its reply. The host strips it, executes the tool, appends the
|
|
3754
|
+
* result as a tool-role message, and continues the loop (bounded rounds).
|
|
3755
|
+
*/
|
|
3756
|
+
/** History file name inside the output dir. */
|
|
3757
|
+
const ASSISTANT_HISTORY_FILE = "novel-assistant.jsonl";
|
|
3758
|
+
/** Max tool-call rounds per user turn (safety bound). */
|
|
3759
|
+
const MAX_TOOL_ROUNDS = 6;
|
|
3760
|
+
/** Load the persisted conversation (empty when none). */
|
|
3761
|
+
function loadAssistantHistory(outputDir) {
|
|
3762
|
+
const file = join(outputDir, ASSISTANT_HISTORY_FILE);
|
|
3763
|
+
if (!existsSync(file)) return [];
|
|
3764
|
+
const messages = [];
|
|
3765
|
+
try {
|
|
3766
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
3767
|
+
if (line.trim() === "") continue;
|
|
3768
|
+
try {
|
|
3769
|
+
const parsed = JSON.parse(line);
|
|
3770
|
+
if (typeof parsed.role === "string" && typeof parsed.content === "string") messages.push(parsed);
|
|
3771
|
+
} catch {}
|
|
3772
|
+
}
|
|
3773
|
+
} catch {}
|
|
3774
|
+
return messages;
|
|
3775
|
+
}
|
|
3776
|
+
/** Append one message to the persisted history. Tool payloads (e.g. full
|
|
3777
|
+
* outline / chapter text) are capped so the jsonl and later LLM context
|
|
3778
|
+
* don't grow unboundedly. */
|
|
3779
|
+
function appendHistory(outputDir, message) {
|
|
3780
|
+
mkdirSync(outputDir, { recursive: true });
|
|
3781
|
+
const entry = message.role === "tool" && message.content.length > 4e3 ? {
|
|
3782
|
+
...message,
|
|
3783
|
+
content: message.content.slice(0, 4e3) + "\n…(已截断,如需完整内容请重新调用工具)"
|
|
3784
|
+
} : message;
|
|
3785
|
+
appendFileSync(join(outputDir, ASSISTANT_HISTORY_FILE), JSON.stringify(entry) + "\n", "utf8");
|
|
3786
|
+
}
|
|
3787
|
+
/** 清空助手对话记录(删除历史文件)。 */
|
|
3788
|
+
function clearAssistantHistory(outputDir) {
|
|
3789
|
+
const file = join(outputDir, ASSISTANT_HISTORY_FILE);
|
|
3790
|
+
if (existsSync(file)) rmSync(file, { force: true });
|
|
3791
|
+
}
|
|
3792
|
+
/** Render the project snapshot the assistant sees. */
|
|
3793
|
+
function renderProjectSnapshot(project) {
|
|
3794
|
+
const sections = [];
|
|
3795
|
+
sections.push(`书名:${project.bookName}`);
|
|
3796
|
+
sections.push(`总纲节选(如需全文用 outline_text 工具):\n${project.outline.slice(0, 2500)}`);
|
|
3797
|
+
const assetNames = [];
|
|
3798
|
+
if (project.assets?.genre !== void 0) assetNames.push(`题材:${project.assets.genre.name}`);
|
|
3799
|
+
if (project.assets?.primaryProgression !== void 0) assetNames.push(`主推进:${project.assets.primaryProgression.name}`);
|
|
3800
|
+
if ((project.assets?.styleAssets?.length ?? 0) > 0) assetNames.push(`写法:${project.assets.styleAssets.map((s) => s.name).join("、")}`);
|
|
3801
|
+
if ((project.assets?.antiAiRules?.length ?? 0) > 0) assetNames.push(`文戒自定义:${project.assets.antiAiRules.map((r) => r.name).join("、")}`);
|
|
3802
|
+
if (assetNames.length > 0) sections.push(`【写作资产】${assetNames.join(" · ")}`);
|
|
3803
|
+
if (project.bible !== void 0) {
|
|
3804
|
+
const bible = project.bible;
|
|
3805
|
+
sections.push("【道藏】");
|
|
3806
|
+
if (bible.genre !== "") sections.push(`题材基调:${bible.genre}`);
|
|
3807
|
+
if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
|
|
3808
|
+
if (bible.characters.length > 0) {
|
|
3809
|
+
sections.push("角色卡:");
|
|
3810
|
+
for (const card of bible.characters) {
|
|
3811
|
+
const roleName = {
|
|
3812
|
+
protagonist: "主角",
|
|
3813
|
+
supporting: "配角",
|
|
3814
|
+
antagonist: "反派",
|
|
3815
|
+
other: "其他"
|
|
3816
|
+
}[card.role];
|
|
3817
|
+
sections.push(`- ${card.name}(${roleName}):${card.traits.join("、")}${card.goals !== "" ? `;目标:${card.goals}` : ""}`);
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
if (bible.redLines.length > 0) sections.push("写作红线:\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
|
|
3821
|
+
}
|
|
3338
3822
|
if (project.world !== void 0) {
|
|
3339
3823
|
const world = project.world;
|
|
3340
3824
|
sections.push("【大世界】");
|
|
@@ -3955,168 +4439,35 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
|
|
|
3955
4439
|
history.push({
|
|
3956
4440
|
role: "tool",
|
|
3957
4441
|
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);
|
|
4442
|
+
tool: name,
|
|
4443
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
4444
|
+
});
|
|
4445
|
+
appendHistory(outputDir, {
|
|
4446
|
+
role: "tool",
|
|
4447
|
+
content: result,
|
|
4448
|
+
tool: name,
|
|
4449
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
4450
|
+
});
|
|
4451
|
+
round++;
|
|
4452
|
+
if (round >= MAX_TOOL_ROUNDS) {
|
|
4453
|
+
const message = `(已连续执行 ${round} 次修改操作,本轮停止。如需继续请再说。)`;
|
|
4454
|
+
history.push({
|
|
4455
|
+
role: "assistant",
|
|
4456
|
+
content: message,
|
|
4457
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
4458
|
+
});
|
|
4459
|
+
appendHistory(outputDir, {
|
|
4460
|
+
role: "assistant",
|
|
4461
|
+
content: message,
|
|
4462
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
4463
|
+
});
|
|
4464
|
+
yield {
|
|
4465
|
+
frame: "delta",
|
|
4466
|
+
text: message
|
|
4467
|
+
};
|
|
4468
|
+
return;
|
|
4469
|
+
}
|
|
4470
|
+
}
|
|
4120
4471
|
}
|
|
4121
4472
|
//#endregion
|
|
4122
4473
|
//#region src/routes.ts
|
|
@@ -4178,6 +4529,12 @@ const DEFAULT_PLAN_COUNT = 30;
|
|
|
4178
4529
|
*/
|
|
4179
4530
|
function makeRoutes(deps) {
|
|
4180
4531
|
const { ctx, getConfig, patchConfig } = deps;
|
|
4532
|
+
/** 全书质检实时状态(内存态,重启后回到 idle;用于 /status 暴露进度)。 */
|
|
4533
|
+
let auditState = {
|
|
4534
|
+
status: "idle",
|
|
4535
|
+
totalBatches: 0,
|
|
4536
|
+
completedBatches: 0
|
|
4537
|
+
};
|
|
4181
4538
|
/** Guard helper: fence + method check. */
|
|
4182
4539
|
const guard = (req, res, method) => {
|
|
4183
4540
|
if (!isLoopbackRequest(req)) {
|
|
@@ -4229,7 +4586,8 @@ function makeRoutes(deps) {
|
|
|
4229
4586
|
...project,
|
|
4230
4587
|
facts: (project.facts ?? []).slice(-80)
|
|
4231
4588
|
} : void 0,
|
|
4232
|
-
generatedFiles: listChapterFiles(config.outputDir)
|
|
4589
|
+
generatedFiles: listChapterFiles(config.outputDir),
|
|
4590
|
+
audit: auditState
|
|
4233
4591
|
});
|
|
4234
4592
|
}
|
|
4235
4593
|
};
|
|
@@ -5134,13 +5492,41 @@ function makeRoutes(deps) {
|
|
|
5134
5492
|
const config = getConfig();
|
|
5135
5493
|
const project = requireProject(res);
|
|
5136
5494
|
if (project === void 0) return;
|
|
5495
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5496
|
+
auditState = {
|
|
5497
|
+
status: "running",
|
|
5498
|
+
startedAt,
|
|
5499
|
+
totalBatches: 0,
|
|
5500
|
+
completedBatches: 0
|
|
5501
|
+
};
|
|
5137
5502
|
try {
|
|
5503
|
+
const issues = await auditBook(ctx, config, project, config.outputDir, (completed, total) => {
|
|
5504
|
+
auditState.totalBatches = total;
|
|
5505
|
+
auditState.completedBatches = completed;
|
|
5506
|
+
});
|
|
5507
|
+
const auditedChapters = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating").length;
|
|
5508
|
+
auditState = {
|
|
5509
|
+
status: "done",
|
|
5510
|
+
startedAt,
|
|
5511
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5512
|
+
totalBatches: auditState.totalBatches,
|
|
5513
|
+
completedBatches: auditState.totalBatches,
|
|
5514
|
+
auditedChapters,
|
|
5515
|
+
issuesCount: issues.length,
|
|
5516
|
+
issues
|
|
5517
|
+
};
|
|
5138
5518
|
writeJson(res, 200, {
|
|
5139
|
-
issues
|
|
5140
|
-
auditedChapters
|
|
5141
|
-
auditedAt:
|
|
5519
|
+
issues,
|
|
5520
|
+
auditedChapters,
|
|
5521
|
+
auditedAt: auditState.finishedAt
|
|
5142
5522
|
});
|
|
5143
5523
|
} catch (error) {
|
|
5524
|
+
auditState = {
|
|
5525
|
+
...auditState,
|
|
5526
|
+
status: "error",
|
|
5527
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5528
|
+
error: error.message
|
|
5529
|
+
};
|
|
5144
5530
|
writeJson(res, 500, { error: error.message });
|
|
5145
5531
|
}
|
|
5146
5532
|
}
|
|
@@ -5651,6 +6037,208 @@ function makeRoutes(deps) {
|
|
|
5651
6037
|
});
|
|
5652
6038
|
}
|
|
5653
6039
|
};
|
|
6040
|
+
/** 作者复盘补跑:对已写章节补齐 authorReview(body.chapterNo=单章 JSON,缺省=全书 NDJSON 流)。 */
|
|
6041
|
+
const reviewBackfillRoute = {
|
|
6042
|
+
kind: "exact",
|
|
6043
|
+
path: NOVEL_API.reviewBackfill,
|
|
6044
|
+
handler: async (req, res) => {
|
|
6045
|
+
if (!guard(req, res, "POST")) return;
|
|
6046
|
+
const config = getConfig();
|
|
6047
|
+
const project = requireProject(res);
|
|
6048
|
+
if (project === void 0) return;
|
|
6049
|
+
const body = await readJsonBody(req);
|
|
6050
|
+
/** 对一章执行作者复盘(读取已落盘正文,不改变章节状态/正文)。 */
|
|
6051
|
+
const runOne = async (chapter) => {
|
|
6052
|
+
const currentBody = readChapterFile(config.outputDir, chapter);
|
|
6053
|
+
if (currentBody === void 0) throw new Error(`章节 ${chapter.no} 的正文文件不存在`);
|
|
6054
|
+
let prevTail = "";
|
|
6055
|
+
if (chapter.no > 1) {
|
|
6056
|
+
const prev = project.chapters.find((c) => c.no === chapter.no - 1);
|
|
6057
|
+
if (prev !== void 0) prevTail = (readChapterFile(config.outputDir, prev) ?? "").replace(/^#.*$/m, "").trim().slice(-600);
|
|
6058
|
+
}
|
|
6059
|
+
return authorReviewChapter(ctx, config, project, chapter.no, currentBody, prevTail);
|
|
6060
|
+
};
|
|
6061
|
+
if (typeof body?.chapterNo === "number" && body.chapterNo > 0) {
|
|
6062
|
+
const chapter = project.chapters.find((c) => c.no === body.chapterNo);
|
|
6063
|
+
if (chapter === void 0) {
|
|
6064
|
+
writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` });
|
|
6065
|
+
return;
|
|
6066
|
+
}
|
|
6067
|
+
if (chapter.status === "pending") {
|
|
6068
|
+
writeJson(res, 400, { error: "该章尚未生成正文,无法复盘" });
|
|
6069
|
+
return;
|
|
6070
|
+
}
|
|
6071
|
+
try {
|
|
6072
|
+
const review = await runOne(chapter);
|
|
6073
|
+
chapter.authorReview = review;
|
|
6074
|
+
if (review.advancedLines !== void 0) autoLinkPlotlines(project, chapter.no, review.advancedLines);
|
|
6075
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6076
|
+
saveProject(config.outputDir, project);
|
|
6077
|
+
writeJson(res, 200, {
|
|
6078
|
+
no: chapter.no,
|
|
6079
|
+
review
|
|
6080
|
+
});
|
|
6081
|
+
return;
|
|
6082
|
+
} catch (error) {
|
|
6083
|
+
writeJson(res, 500, { error: error.message });
|
|
6084
|
+
return;
|
|
6085
|
+
}
|
|
6086
|
+
}
|
|
6087
|
+
const missing = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.status !== "error" && c.authorReview === void 0);
|
|
6088
|
+
if (missing.length === 0) {
|
|
6089
|
+
writeJson(res, 200, { count: 0 });
|
|
6090
|
+
return;
|
|
6091
|
+
}
|
|
6092
|
+
res.writeHead(200, {
|
|
6093
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
6094
|
+
"cache-control": "no-cache",
|
|
6095
|
+
"referrer-policy": "no-referrer"
|
|
6096
|
+
});
|
|
6097
|
+
const send = (frame) => {
|
|
6098
|
+
res.write(JSON.stringify(frame) + "\n");
|
|
6099
|
+
};
|
|
6100
|
+
let done = 0;
|
|
6101
|
+
for (const chapter of missing) try {
|
|
6102
|
+
const review = await runOne(chapter);
|
|
6103
|
+
chapter.authorReview = review;
|
|
6104
|
+
if (review.advancedLines !== void 0) autoLinkPlotlines(project, chapter.no, review.advancedLines);
|
|
6105
|
+
done++;
|
|
6106
|
+
saveProject(config.outputDir, project);
|
|
6107
|
+
send({
|
|
6108
|
+
type: "author-review",
|
|
6109
|
+
no: chapter.no,
|
|
6110
|
+
review
|
|
6111
|
+
});
|
|
6112
|
+
} catch (error) {
|
|
6113
|
+
console.warn(`[dsh-novel-forge] author backfill ch.${chapter.no} failed:`, error.message);
|
|
6114
|
+
}
|
|
6115
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6116
|
+
saveProject(config.outputDir, project);
|
|
6117
|
+
send({
|
|
6118
|
+
type: "author-backfill-done",
|
|
6119
|
+
count: done
|
|
6120
|
+
});
|
|
6121
|
+
res.end();
|
|
6122
|
+
}
|
|
6123
|
+
};
|
|
6124
|
+
const configRoute = {
|
|
6125
|
+
kind: "exact",
|
|
6126
|
+
path: NOVEL_API.config,
|
|
6127
|
+
handler: async (req, res) => {
|
|
6128
|
+
if (!guard(req, res, "POST")) return;
|
|
6129
|
+
const body = await readJsonBody(req);
|
|
6130
|
+
if (body === void 0) {
|
|
6131
|
+
writeJson(res, 400, { error: "无效的配置 JSON" });
|
|
6132
|
+
return;
|
|
6133
|
+
}
|
|
6134
|
+
try {
|
|
6135
|
+
writeJson(res, 200, { config: await patchConfig(body) });
|
|
6136
|
+
} catch (error) {
|
|
6137
|
+
writeJson(res, 400, { error: error.message });
|
|
6138
|
+
}
|
|
6139
|
+
}
|
|
6140
|
+
};
|
|
6141
|
+
const openFolderRoute = {
|
|
6142
|
+
kind: "exact",
|
|
6143
|
+
path: NOVEL_API.openFolder,
|
|
6144
|
+
handler: async (req, res) => {
|
|
6145
|
+
if (!guard(req, res, "POST")) return;
|
|
6146
|
+
const dir = getConfig().outputDir;
|
|
6147
|
+
exec(`explorer "${dir.replace(/"/g, "")}"`, (error) => {
|
|
6148
|
+
if (error) writeJson(res, 500, {
|
|
6149
|
+
ok: false,
|
|
6150
|
+
error: error.message
|
|
6151
|
+
});
|
|
6152
|
+
else writeJson(res, 200, { ok: true });
|
|
6153
|
+
});
|
|
6154
|
+
}
|
|
6155
|
+
};
|
|
6156
|
+
/** 开书想法 → AI 大纲:2-3 个可选方案(支持暂留换批:count 只补空槽 + exclude 避开已留方向)。 */
|
|
6157
|
+
const outlineSuggestRoute = {
|
|
6158
|
+
kind: "exact",
|
|
6159
|
+
path: NOVEL_API.outlineSuggest,
|
|
6160
|
+
handler: async (req, res) => {
|
|
6161
|
+
if (!guard(req, res, "POST")) return;
|
|
6162
|
+
const config = getConfig();
|
|
6163
|
+
const body = await readJsonBody(req);
|
|
6164
|
+
const idea = body?.idea?.trim() ?? "";
|
|
6165
|
+
if (idea.length < 50) {
|
|
6166
|
+
writeJson(res, 400, { error: "想法太短(<50 字),请多写一两句:主角是谁、什么世界、想要什么爽点" });
|
|
6167
|
+
return;
|
|
6168
|
+
}
|
|
6169
|
+
const count = body?.count !== void 0 ? Math.max(1, Math.min(3, Math.floor(body.count))) : 3;
|
|
6170
|
+
const exclude = Array.isArray(body?.exclude) ? body.exclude.filter((e) => typeof e === "string" && e.trim() !== "").map((e) => e.trim().slice(0, 200)) : [];
|
|
6171
|
+
try {
|
|
6172
|
+
writeJson(res, 200, { candidates: await suggestOutlines(ctx, config, idea, count, exclude) });
|
|
6173
|
+
} catch (error) {
|
|
6174
|
+
writeJson(res, 500, { error: `大纲方案生成失败:${error.message}` });
|
|
6175
|
+
}
|
|
6176
|
+
}
|
|
6177
|
+
};
|
|
6178
|
+
/** 拆书分析:对已写章节做结构/人物/文风/卖点体检(两阶段:源笔记→分节分析)。 */
|
|
6179
|
+
const breakdownRoute = {
|
|
6180
|
+
kind: "exact",
|
|
6181
|
+
path: NOVEL_API.breakdown,
|
|
6182
|
+
handler: async (req, res) => {
|
|
6183
|
+
if (!guard(req, res, "POST")) return;
|
|
6184
|
+
const config = getConfig();
|
|
6185
|
+
const project = requireProject(res);
|
|
6186
|
+
if (project === void 0) return;
|
|
6187
|
+
const body = await readJsonBody(req);
|
|
6188
|
+
try {
|
|
6189
|
+
writeJson(res, 200, await breakdownBook(ctx, config, project, config.outputDir, body?.scope ?? "recent", body?.preset ?? "quick", body?.budgetTokens ?? 5e4));
|
|
6190
|
+
} catch (error) {
|
|
6191
|
+
writeJson(res, 500, { error: `拆书分析失败:${error.message}` });
|
|
6192
|
+
}
|
|
6193
|
+
}
|
|
6194
|
+
};
|
|
6195
|
+
/** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(适配豆包/Seedance/SD)。 */
|
|
6196
|
+
const storyboardRoute = {
|
|
6197
|
+
kind: "exact",
|
|
6198
|
+
path: NOVEL_API.storyboard,
|
|
6199
|
+
handler: async (req, res) => {
|
|
6200
|
+
if (!guard(req, res, "POST")) return;
|
|
6201
|
+
const config = getConfig();
|
|
6202
|
+
const project = requireProject(res);
|
|
6203
|
+
if (project === void 0) return;
|
|
6204
|
+
const body = await readJsonBody(req);
|
|
6205
|
+
if (!Number.isInteger(body?.chapterNo)) {
|
|
6206
|
+
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
6207
|
+
return;
|
|
6208
|
+
}
|
|
6209
|
+
try {
|
|
6210
|
+
writeJson(res, 200, await generateStoryboard(ctx, config, project, config.outputDir, body.chapterNo, body?.genre ?? "", body?.platform ?? "抖音", body?.tool ?? "doubao"));
|
|
6211
|
+
} catch (error) {
|
|
6212
|
+
writeJson(res, 500, { error: `分镜生成失败:${error.message}` });
|
|
6213
|
+
}
|
|
6214
|
+
}
|
|
6215
|
+
};
|
|
6216
|
+
/** 漫剧分集计划:读一卷 → 按故事弧线分集(高潮拆集/过渡并章)。 */
|
|
6217
|
+
const storyboardPlanRoute = {
|
|
6218
|
+
kind: "exact",
|
|
6219
|
+
path: NOVEL_API.storyboardPlan,
|
|
6220
|
+
handler: async (req, res) => {
|
|
6221
|
+
if (!guard(req, res, "POST")) return;
|
|
6222
|
+
const config = getConfig();
|
|
6223
|
+
const project = requireProject(res);
|
|
6224
|
+
if (project === void 0) return;
|
|
6225
|
+
const body = await readJsonBody(req);
|
|
6226
|
+
if (!Number.isInteger(body?.volumeNo) || (body?.volumeNo ?? 0) < 1) {
|
|
6227
|
+
writeJson(res, 400, { error: "volumeNo 须为正整数" });
|
|
6228
|
+
return;
|
|
6229
|
+
}
|
|
6230
|
+
try {
|
|
6231
|
+
writeJson(res, 200, await planStoryboardEpisodes(ctx, config, project, body.volumeNo, body?.platform ?? "抖音", body?.maxEpisodes ?? 25));
|
|
6232
|
+
} catch (error) {
|
|
6233
|
+
writeJson(res, 500, { error: `分集计划生成失败:${error.message}` });
|
|
6234
|
+
}
|
|
6235
|
+
}
|
|
6236
|
+
};
|
|
6237
|
+
/** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
|
|
6238
|
+
const runner = new ProductionRunner({
|
|
6239
|
+
ctx,
|
|
6240
|
+
getConfig
|
|
6241
|
+
});
|
|
5654
6242
|
return [
|
|
5655
6243
|
statusRoute,
|
|
5656
6244
|
loadOutlineRoute,
|
|
@@ -5690,198 +6278,67 @@ function makeRoutes(deps) {
|
|
|
5690
6278
|
plotlinesRoute,
|
|
5691
6279
|
rolesRoute,
|
|
5692
6280
|
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
|
-
},
|
|
6281
|
+
reviewBackfillRoute,
|
|
5776
6282
|
chapterResetRoute,
|
|
5777
6283
|
chapterApproveRoute,
|
|
6284
|
+
configRoute,
|
|
6285
|
+
openFolderRoute,
|
|
6286
|
+
outlineSuggestRoute,
|
|
6287
|
+
breakdownRoute,
|
|
6288
|
+
storyboardRoute,
|
|
6289
|
+
storyboardPlanRoute,
|
|
5778
6290
|
{
|
|
5779
6291
|
kind: "exact",
|
|
5780
|
-
path: NOVEL_API.
|
|
6292
|
+
path: NOVEL_API.runStart,
|
|
5781
6293
|
handler: async (req, res) => {
|
|
5782
6294
|
if (!guard(req, res, "POST")) return;
|
|
5783
6295
|
const body = await readJsonBody(req);
|
|
5784
|
-
|
|
5785
|
-
|
|
6296
|
+
const project = loadProject(getConfig().outputDir);
|
|
6297
|
+
if (project === void 0) {
|
|
6298
|
+
writeJson(res, 400, { error: "输出目录中没有项目" });
|
|
5786
6299
|
return;
|
|
5787
6300
|
}
|
|
5788
|
-
|
|
5789
|
-
|
|
5790
|
-
|
|
5791
|
-
|
|
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 字),请多写一两句:主角是谁、什么世界、想要什么爽点" });
|
|
6301
|
+
const startNo = Number.isInteger(body?.startNo) && body.startNo >= 1 ? body.startNo : 1;
|
|
6302
|
+
let endNo;
|
|
6303
|
+
if (Number.isInteger(body?.endNo) && body.endNo >= startNo) endNo = body.endNo;
|
|
6304
|
+
else if (Number.isInteger(body?.count) && body.count >= 1 && body.count <= 200) endNo = Math.max(0, ...project.chapters.map((c) => c.no)) + body.count;
|
|
6305
|
+
else {
|
|
6306
|
+
writeJson(res, 400, { error: "请提供 endNo 或 count(1-200)" });
|
|
5820
6307
|
return;
|
|
5821
6308
|
}
|
|
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
6309
|
try {
|
|
5841
|
-
writeJson(res, 200, await
|
|
6310
|
+
writeJson(res, 200, await runner.start(startNo, endNo));
|
|
5842
6311
|
} catch (error) {
|
|
5843
|
-
writeJson(res,
|
|
6312
|
+
writeJson(res, 409, { error: error.message });
|
|
5844
6313
|
}
|
|
5845
6314
|
}
|
|
5846
6315
|
},
|
|
5847
6316
|
{
|
|
5848
6317
|
kind: "exact",
|
|
5849
|
-
path: NOVEL_API.
|
|
6318
|
+
path: NOVEL_API.runControl,
|
|
5850
6319
|
handler: async (req, res) => {
|
|
5851
6320
|
if (!guard(req, res, "POST")) return;
|
|
5852
|
-
const config = getConfig();
|
|
5853
|
-
const project = requireProject(res);
|
|
5854
|
-
if (project === void 0) return;
|
|
5855
6321
|
const body = await readJsonBody(req);
|
|
5856
|
-
if (
|
|
5857
|
-
writeJson(res, 400, { error: "
|
|
6322
|
+
if (runner.status() === null) {
|
|
6323
|
+
writeJson(res, 400, { error: "没有生产单" });
|
|
5858
6324
|
return;
|
|
5859
6325
|
}
|
|
5860
|
-
|
|
5861
|
-
|
|
5862
|
-
|
|
5863
|
-
|
|
6326
|
+
if (body?.action === "pause") runner.pause();
|
|
6327
|
+
else if (body?.action === "resume") runner.resume();
|
|
6328
|
+
else if (body?.action === "stop") runner.stop();
|
|
6329
|
+
else {
|
|
6330
|
+
writeJson(res, 400, { error: "action 须为 pause / resume / stop" });
|
|
6331
|
+
return;
|
|
5864
6332
|
}
|
|
6333
|
+
writeJson(res, 200, runner.status());
|
|
5865
6334
|
}
|
|
5866
6335
|
},
|
|
5867
6336
|
{
|
|
5868
6337
|
kind: "exact",
|
|
5869
|
-
path: NOVEL_API.
|
|
5870
|
-
handler:
|
|
5871
|
-
if (!guard(req, res, "
|
|
5872
|
-
|
|
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
|
-
}
|
|
6338
|
+
path: NOVEL_API.runStatus,
|
|
6339
|
+
handler: (req, res) => {
|
|
6340
|
+
if (!guard(req, res, "GET")) return;
|
|
6341
|
+
writeJson(res, 200, runner.status());
|
|
5885
6342
|
}
|
|
5886
6343
|
}
|
|
5887
6344
|
];
|
|
@@ -5905,10 +6362,16 @@ const NOVEL_SETTINGS_NAMESPACE = settingsNamespace("dsh-novel-forge");
|
|
|
5905
6362
|
const Config = z.object({
|
|
5906
6363
|
announceToAgent: z.boolean().default(true),
|
|
5907
6364
|
enabled: z.boolean().default(true),
|
|
5908
|
-
outlinePath: z.string().default("
|
|
5909
|
-
outputDir: z.string().default("
|
|
6365
|
+
outlinePath: z.string().default("D:\\ryan work\\harness\\novels\\归墟玉主\\《归墟玉主》全书大纲_重新排版版.docx"),
|
|
6366
|
+
outputDir: z.string().default("D:\\ryan work\\harness\\novels\\归墟玉主"),
|
|
5910
6367
|
provider: z.string().default("deepseek-official"),
|
|
5911
6368
|
model: z.string().default("deepseek-v4-flash"),
|
|
6369
|
+
reasoningEffort: z.union([
|
|
6370
|
+
"off",
|
|
6371
|
+
"low",
|
|
6372
|
+
"high",
|
|
6373
|
+
"max"
|
|
6374
|
+
]).default("off"),
|
|
5912
6375
|
chapterChars: z.number().default(3500),
|
|
5913
6376
|
maxTokens: z.number().default(12e3),
|
|
5914
6377
|
reviewPassScore: z.number().default(70),
|
|
@@ -5918,10 +6381,11 @@ const Config = z.object({
|
|
|
5918
6381
|
});
|
|
5919
6382
|
/** Schema defaults, re-read for hand-built test contexts. */
|
|
5920
6383
|
const DEFAULT_ANNOUNCE = true;
|
|
5921
|
-
const DEFAULT_OUTLINE_PATH = "
|
|
5922
|
-
const DEFAULT_OUTPUT_DIR = "
|
|
6384
|
+
const DEFAULT_OUTLINE_PATH = "D:\\ryan work\\harness\\novels\\归墟玉主\\《归墟玉主》全书大纲_重新排版版.docx";
|
|
6385
|
+
const DEFAULT_OUTPUT_DIR = "D:\\ryan work\\harness\\novels\\归墟玉主";
|
|
5923
6386
|
const DEFAULT_PROVIDER = "deepseek-official";
|
|
5924
6387
|
const DEFAULT_MODEL = "deepseek-v4-flash";
|
|
6388
|
+
const DEFAULT_REASONING_EFFORT = "off";
|
|
5925
6389
|
const DEFAULT_CHAPTER_CHARS = 3500;
|
|
5926
6390
|
const DEFAULT_MAX_TOKENS = 12e3;
|
|
5927
6391
|
const DEFAULT_REVIEW_PASS_SCORE = 70;
|
|
@@ -5931,7 +6395,7 @@ const DEFAULT_AUTO_REVIEW_AFTER_REVISE = true;
|
|
|
5931
6395
|
/** Order of the announcement section within the tool-guidance band. */
|
|
5932
6396
|
const SECTION_ORDER = 160;
|
|
5933
6397
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
5934
|
-
const NOVEL_GUIDANCE = "本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx
|
|
6398
|
+
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
6399
|
/** Resolve a config-like value into the full runtime config. */
|
|
5936
6400
|
function resolveConfig(value) {
|
|
5937
6401
|
return {
|
|
@@ -5939,6 +6403,7 @@ function resolveConfig(value) {
|
|
|
5939
6403
|
outputDir: value?.outputDir ?? DEFAULT_OUTPUT_DIR,
|
|
5940
6404
|
provider: value?.provider ?? DEFAULT_PROVIDER,
|
|
5941
6405
|
model: value?.model ?? DEFAULT_MODEL,
|
|
6406
|
+
reasoningEffort: value?.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
|
|
5942
6407
|
chapterChars: value?.chapterChars ?? DEFAULT_CHAPTER_CHARS,
|
|
5943
6408
|
maxTokens: value?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
5944
6409
|
reviewPassScore: value?.reviewPassScore ?? DEFAULT_REVIEW_PASS_SCORE,
|
|
@@ -5969,6 +6434,7 @@ function apply(ctx, config) {
|
|
|
5969
6434
|
if (patch.outputDir !== void 0) next.outputDir = patch.outputDir;
|
|
5970
6435
|
if (patch.provider !== void 0) next.provider = patch.provider;
|
|
5971
6436
|
if (patch.model !== void 0) next.model = patch.model;
|
|
6437
|
+
if (patch.reasoningEffort !== void 0) next.reasoningEffort = patch.reasoningEffort;
|
|
5972
6438
|
if (patch.chapterChars !== void 0) next.chapterChars = patch.chapterChars;
|
|
5973
6439
|
if (patch.maxTokens !== void 0) next.maxTokens = patch.maxTokens;
|
|
5974
6440
|
if (patch.reviewPassScore !== void 0) next.reviewPassScore = patch.reviewPassScore;
|