@waterwx/dsh-novel-forge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +180 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.js +3924 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3120 -0
- package/lib/index.js.map +1 -0
- package/lib/types/assets.d.ts +35 -0
- package/lib/types/assistant.d.ts +43 -0
- package/lib/types/bookshelf.d.ts +35 -0
- package/lib/types/client/api.d.ts +68 -0
- package/lib/types/client/docx.d.ts +15 -0
- package/lib/types/client/index.d.ts +14 -0
- package/lib/types/client/locales.d.ts +139 -0
- package/lib/types/client/mount.d.ts +9 -0
- package/lib/types/client/panel/AssetsTab.d.ts +7 -0
- package/lib/types/client/panel/AssistantTab.d.ts +7 -0
- package/lib/types/client/panel/BookshelfBar.d.ts +11 -0
- package/lib/types/client/panel/NovelPanel.d.ts +13 -0
- package/lib/types/client/panel/controller.d.ts +19 -0
- package/lib/types/client/panel/helpers.d.ts +8 -0
- package/lib/types/client/sidebar-entry.d.ts +13 -0
- package/lib/types/docx.d.ts +19 -0
- package/lib/types/engine.d.ts +95 -0
- package/lib/types/index.d.ts +55 -0
- package/lib/types/protocol.d.ts +521 -0
- package/lib/types/routes.d.ts +29 -0
- package/package.json +105 -0
- package/src/assets.ts +518 -0
- package/src/assistant.ts +547 -0
- package/src/bookshelf.ts +137 -0
- package/src/client/api.ts +254 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/docx.ts +69 -0
- package/src/client/index.ts +34 -0
- package/src/client/locales.ts +271 -0
- package/src/client/mount.tsx +97 -0
- package/src/client/panel/AssetsTab.tsx +341 -0
- package/src/client/panel/AssistantTab.tsx +188 -0
- package/src/client/panel/BookshelfBar.tsx +116 -0
- package/src/client/panel/NovelPanel.tsx +990 -0
- package/src/client/panel/controller.ts +45 -0
- package/src/client/panel/helpers.ts +17 -0
- package/src/client/panel/panel.module.css +894 -0
- package/src/client/sidebar-entry.ts +122 -0
- package/src/docx.ts +83 -0
- package/src/engine.ts +1019 -0
- package/src/index.ts +184 -0
- package/src/protocol.ts +539 -0
- package/src/routes.ts +955 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,3120 @@
|
|
|
1
|
+
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
2
|
+
import z from "schemastery";
|
|
3
|
+
import { exec } from "node:child_process";
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { strFromU8, unzipSync } from "fflate";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { BlockAssembler, ReasoningEffortId, createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
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
|
+
summary: "/api/dsh-novel-forge/summary",
|
|
32
|
+
foreshadow: "/api/dsh-novel-forge/foreshadow",
|
|
33
|
+
exportBook: "/api/dsh-novel-forge/export",
|
|
34
|
+
chapter: "/api/dsh-novel-forge/chapter",
|
|
35
|
+
assistant: "/api/dsh-novel-forge/assistant",
|
|
36
|
+
assistantHistory: "/api/dsh-novel-forge/assistant-history",
|
|
37
|
+
bookshelf: "/api/dsh-novel-forge/bookshelf",
|
|
38
|
+
config: "/api/dsh-novel-forge/config",
|
|
39
|
+
openFolder: "/api/dsh-novel-forge/open-folder"
|
|
40
|
+
};
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/docx.ts
|
|
43
|
+
/**
|
|
44
|
+
* docx outline extraction: a .docx is a zip whose word/document.xml holds the
|
|
45
|
+
* body text in <w:t> runs inside <w:p> paragraphs. We unzip with fflate and
|
|
46
|
+
* walk the XML with a tiny tokenizer — no heavyweight XML/DOM dependency.
|
|
47
|
+
*/
|
|
48
|
+
/** Decode the handful of XML entities docx bodies actually use. */
|
|
49
|
+
function decodeEntities(text) {
|
|
50
|
+
return text.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/g, "&").replace(/ /g, " ");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Extract plain text from a docx buffer: one line per <w:p> paragraph, with
|
|
54
|
+
* <w:tab>/<w:br> preserved as whitespace. Tables and nested structures are
|
|
55
|
+
* flattened in document order (their paragraphs are just <w:p> too).
|
|
56
|
+
* @param buffer - the raw .docx bytes.
|
|
57
|
+
* @returns the body text.
|
|
58
|
+
*/
|
|
59
|
+
function extractDocxText(buffer) {
|
|
60
|
+
let files;
|
|
61
|
+
try {
|
|
62
|
+
files = unzipSync(buffer);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw new Error(`not a valid docx (zip open failed): ${error.message}`);
|
|
65
|
+
}
|
|
66
|
+
const document = files["word/document.xml"];
|
|
67
|
+
if (document === void 0) throw new Error("not a valid docx (word/document.xml missing)");
|
|
68
|
+
const xml = strFromU8(document);
|
|
69
|
+
const paragraphs = [];
|
|
70
|
+
const parts = xml.split(/<w:p\b[^>]*>/);
|
|
71
|
+
for (let i = 1; i < parts.length; i++) {
|
|
72
|
+
const segment = parts[i];
|
|
73
|
+
const runs = [];
|
|
74
|
+
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(" ");
|
|
75
|
+
else if (match[0].startsWith("<w:br")) runs.push("\n");
|
|
76
|
+
else runs.push(decodeEntities(match[1] ?? ""));
|
|
77
|
+
const line = runs.join("").replace(/\u00a0/g, " ").trimEnd();
|
|
78
|
+
paragraphs.push(line);
|
|
79
|
+
}
|
|
80
|
+
const text = paragraphs.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
81
|
+
if (text.length === 0) throw new Error("docx contains no extractable text");
|
|
82
|
+
return text;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Read and extract a docx outline from disk.
|
|
86
|
+
* @param path - absolute path to the .docx file.
|
|
87
|
+
* @returns the extracted outline text.
|
|
88
|
+
*/
|
|
89
|
+
function readOutlineFromDocx(path) {
|
|
90
|
+
let buffer;
|
|
91
|
+
try {
|
|
92
|
+
buffer = readFileSync(path);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw new Error(`cannot read outline file "${path}": ${error.message}`);
|
|
95
|
+
}
|
|
96
|
+
return extractDocxText(new Uint8Array(buffer));
|
|
97
|
+
}
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region src/assets.ts
|
|
100
|
+
/** 预置写法模板(来自 AI-Novel-Writing-Assistant 内置 DEFAULT_STYLE_TEMPLATES)。 */
|
|
101
|
+
const BUILTIN_STYLE_TEMPLATES = [
|
|
102
|
+
{
|
|
103
|
+
key: "power-up-escalation",
|
|
104
|
+
name: "爽文递进推进流",
|
|
105
|
+
description: "持续升级冲突和收益点,强化目标推进与爽点兑现。",
|
|
106
|
+
category: "爽文流",
|
|
107
|
+
applicableGenres: [
|
|
108
|
+
"都市",
|
|
109
|
+
"玄幻",
|
|
110
|
+
"热血"
|
|
111
|
+
],
|
|
112
|
+
proseRules: [
|
|
113
|
+
"围绕目标推进,尽快兑现局部收益;每段都要有目标推进或爽点兑现。",
|
|
114
|
+
"保持明确因果和节奏抬升,场景单元按「目标→阻碍→压制→反转收益」推进。",
|
|
115
|
+
"优先冲突和结果,少停留;段尾用钩子收束。"
|
|
116
|
+
],
|
|
117
|
+
dialogueRules: ["角色表达直接,情绪跟随胜负切换。", "对话承担推进与信息功能,但保留角色自己的语气差异。"],
|
|
118
|
+
languageRules: ["句式清晰,减少无效分散信息。", "直接、明确,不做无谓铺垫。"],
|
|
119
|
+
rhythmRules: ["快节奏,段落密度中等,动作先于解释。", "尽快兑现局部收益,避免拖沓。"],
|
|
120
|
+
defaultAntiAiRuleKeys: [
|
|
121
|
+
"禁止总结主题",
|
|
122
|
+
"对话纯功能推进",
|
|
123
|
+
"连续三段解释性叙事"
|
|
124
|
+
]
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
key: "bottom-loop-reality",
|
|
128
|
+
name: "底层循环现实流",
|
|
129
|
+
description: "通过碎片化生活与反复落空表现人物困境。",
|
|
130
|
+
category: "现实流",
|
|
131
|
+
applicableGenres: [
|
|
132
|
+
"都市",
|
|
133
|
+
"现实",
|
|
134
|
+
"成长"
|
|
135
|
+
],
|
|
136
|
+
proseRules: [
|
|
137
|
+
"以时间推进和现实落差构成叙事张力,结尾不解决核心困境。",
|
|
138
|
+
"场景单元按「行为→落差→自我合理化」推进。",
|
|
139
|
+
"以碎片化生活推进,不做总括式回顾。"
|
|
140
|
+
],
|
|
141
|
+
dialogueRules: ["人物情绪通过动作和嘴硬表达,允许短促口语化台词。", "对话保留生活杂音与无效信息。"],
|
|
142
|
+
languageRules: ["语言粗粝、口语化,允许生活杂音与不完整句。", "句子变化度高,允许无意义细节。"],
|
|
143
|
+
rhythmRules: ["段落密实,动作先于解释。", "中快节奏,允许碎片化流动。"],
|
|
144
|
+
defaultAntiAiRuleKeys: [
|
|
145
|
+
"禁止解释型心理描写",
|
|
146
|
+
"禁止段尾升华",
|
|
147
|
+
"鼓励无意义小动作",
|
|
148
|
+
"鼓励现实落差",
|
|
149
|
+
"鼓励嘴硬补偿"
|
|
150
|
+
]
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
key: "suspense-pressure",
|
|
154
|
+
name: "悬疑压迫递增流",
|
|
155
|
+
description: "通过信息遮蔽、细节异常和压力叠加制造不安感。",
|
|
156
|
+
category: "悬疑流",
|
|
157
|
+
applicableGenres: [
|
|
158
|
+
"悬疑",
|
|
159
|
+
"惊悚",
|
|
160
|
+
"现实"
|
|
161
|
+
],
|
|
162
|
+
proseRules: [
|
|
163
|
+
"以异常细节、信息差和节奏收束推动悬念层层加压。",
|
|
164
|
+
"场景单元按「现场细节→异常→误判→新风险」推进。",
|
|
165
|
+
"优先制造信息缺口和压迫氛围。"
|
|
166
|
+
],
|
|
167
|
+
dialogueRules: ["角色反应克制,恐惧通过反应显现。", "对话保留克制感,不解释恐惧来源。"],
|
|
168
|
+
languageRules: ["细节精确,保留少量噪音增强现场感。", "克制、中等偏高句变化。"],
|
|
169
|
+
rhythmRules: ["通过节奏收束和信息延迟制造压力。", "中速,段落密度中等偏高。"],
|
|
170
|
+
defaultAntiAiRuleKeys: [
|
|
171
|
+
"禁止解释型心理描写",
|
|
172
|
+
"禁止总结主题",
|
|
173
|
+
"段落长度过于整齐",
|
|
174
|
+
"鼓励现实落差"
|
|
175
|
+
]
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
key: "emotional-tension",
|
|
179
|
+
name: "情绪拉扯流",
|
|
180
|
+
description: "通过错位表达、停顿和误读制造关系张力。",
|
|
181
|
+
category: "情感流",
|
|
182
|
+
applicableGenres: [
|
|
183
|
+
"言情",
|
|
184
|
+
"都市",
|
|
185
|
+
"群像"
|
|
186
|
+
],
|
|
187
|
+
proseRules: [
|
|
188
|
+
"人物不直说核心情绪,靠误读、停顿和反应推动关系变化。",
|
|
189
|
+
"场景单元按「动作→言外之意→误读→回避」推进。",
|
|
190
|
+
"以关系错位推进,而非直接说明。"
|
|
191
|
+
],
|
|
192
|
+
dialogueRules: ["情绪通过停顿、动作和言外之意体现。", "对话充满潜台词与试探。"],
|
|
193
|
+
languageRules: ["语言自然,允许留白与停顿。", "句子变化度高,允许无意义细节。"],
|
|
194
|
+
rhythmRules: ["给关系反应留空间,但避免空洞抒情。", "中慢节奏,段落密度中等。"],
|
|
195
|
+
defaultAntiAiRuleKeys: [
|
|
196
|
+
"禁止直接说教",
|
|
197
|
+
"禁止段尾升华",
|
|
198
|
+
"对话纯功能推进",
|
|
199
|
+
"鼓励无意义小动作"
|
|
200
|
+
]
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
key: "ensemble-weave",
|
|
204
|
+
name: "群像交织流",
|
|
205
|
+
description: "以多人行动线和视角差异交织推进事件。",
|
|
206
|
+
category: "群像流",
|
|
207
|
+
applicableGenres: [
|
|
208
|
+
"群像",
|
|
209
|
+
"都市",
|
|
210
|
+
"悬疑"
|
|
211
|
+
],
|
|
212
|
+
proseRules: ["多角色并行推进,但每个角色的表达和认知范围必须区分清楚。", "多线并进,但视角切换要受控。"],
|
|
213
|
+
dialogueRules: ["不同角色口吻必须拉开差异,避免所有人说话一样。"],
|
|
214
|
+
languageRules: ["保持角色差异,句式变化度高。", "减少无效分散信息。"],
|
|
215
|
+
rhythmRules: ["多线交织但节奏不乱,平衡推进。", "动作先于解释。"],
|
|
216
|
+
defaultAntiAiRuleKeys: [
|
|
217
|
+
"对话纯功能推进",
|
|
218
|
+
"句式重复率过高",
|
|
219
|
+
"禁止总结主题"
|
|
220
|
+
]
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
key: "immersive-daily",
|
|
224
|
+
name: "日常浸没流",
|
|
225
|
+
description: "通过生活细节和细微情绪变化建立持续沉浸感。",
|
|
226
|
+
category: "日常流",
|
|
227
|
+
applicableGenres: [
|
|
228
|
+
"日常",
|
|
229
|
+
"治愈",
|
|
230
|
+
"都市"
|
|
231
|
+
],
|
|
232
|
+
proseRules: ["重场景体验和关系温度,核心情绪通过场景自然流出。", "允许保留生活性动作和无效信息。"],
|
|
233
|
+
dialogueRules: ["人物表达自然,不用高强度戏剧句。", "对话保留生活气息。"],
|
|
234
|
+
languageRules: ["保留生活细节和杂音,不追求工整。", "口语化,句子变化中等偏高。"],
|
|
235
|
+
rhythmRules: ["慢节奏沉浸,但避免空转。", "允许碎片化流动。"],
|
|
236
|
+
defaultAntiAiRuleKeys: [
|
|
237
|
+
"禁止段尾升华",
|
|
238
|
+
"段落长度过于整齐",
|
|
239
|
+
"鼓励无意义小动作"
|
|
240
|
+
]
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
key: "cold-professional",
|
|
244
|
+
name: "冷峻专业流",
|
|
245
|
+
description: "以专业事实和行业细节压住情绪,形成克制压力感。",
|
|
246
|
+
category: "专业流",
|
|
247
|
+
applicableGenres: [
|
|
248
|
+
"职场",
|
|
249
|
+
"现实",
|
|
250
|
+
"悬疑"
|
|
251
|
+
],
|
|
252
|
+
proseRules: [
|
|
253
|
+
"行业事实和程序细节优先,情绪不直说,信息密度高于抒情密度。",
|
|
254
|
+
"场景单元按「事实→动作→专业判断→后果」推进。",
|
|
255
|
+
"让专业事实承担叙事重量。"
|
|
256
|
+
],
|
|
257
|
+
dialogueRules: ["情绪藏在专业动作和事实选择里。", "对话以信息性表达为主,克制。"],
|
|
258
|
+
languageRules: ["术语和事实优先,避免廉价金句。", "正式、克制的语言。"],
|
|
259
|
+
rhythmRules: ["信息密度高,但不铺张解释。", "平衡节奏,段落密度中等偏高。"],
|
|
260
|
+
defaultAntiAiRuleKeys: [
|
|
261
|
+
"禁止直接说教",
|
|
262
|
+
"禁止总结主题",
|
|
263
|
+
"句式重复率过高"
|
|
264
|
+
]
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
key: "absurd-dark-humor",
|
|
268
|
+
name: "荒诞黑色幽默流",
|
|
269
|
+
description: "通过反差、冷感观察和荒诞细节制造黑色幽默。",
|
|
270
|
+
category: "黑色幽默",
|
|
271
|
+
applicableGenres: [
|
|
272
|
+
"都市",
|
|
273
|
+
"黑色幽默",
|
|
274
|
+
"现实"
|
|
275
|
+
],
|
|
276
|
+
proseRules: [
|
|
277
|
+
"用反差和荒诞细节放大现实困境,笑点和压迫感同时存在。",
|
|
278
|
+
"场景单元按「现实细节→荒诞偏差→冷反应」推进。",
|
|
279
|
+
"依赖反差和冷感观察,而非热闹吐槽。"
|
|
280
|
+
],
|
|
281
|
+
dialogueRules: ["情绪藏在冷反应和嘴硬里。", "台词冷面、口语化,允许自嘲与转移。"],
|
|
282
|
+
languageRules: ["允许夹带荒诞杂质和冷幽默节奏。", "口语化,句子变化度高。"],
|
|
283
|
+
rhythmRules: ["反差点要快落地,不要解释笑点。", "平衡节奏,段落密度中等偏高。"],
|
|
284
|
+
defaultAntiAiRuleKeys: [
|
|
285
|
+
"禁止解释型心理描写",
|
|
286
|
+
"禁止段尾升华",
|
|
287
|
+
"鼓励现实落差",
|
|
288
|
+
"鼓励嘴硬补偿"
|
|
289
|
+
]
|
|
290
|
+
}
|
|
291
|
+
];
|
|
292
|
+
/** 内置全局反 AI 规则(来自 AI-Novel-Writing-Assistant 内置 DEFAULT_ANTI_AI_RULES)。 */
|
|
293
|
+
const BUILTIN_ANTI_AI_RULES = [
|
|
294
|
+
{
|
|
295
|
+
name: "禁止解释型心理描写",
|
|
296
|
+
avoid: "直接使用\"他感到\"\"他意识到\"\"他明白了\"等句式解释人物心理。",
|
|
297
|
+
fix: "把心理解释改成动作、语气、停顿、环境反应或结果。",
|
|
298
|
+
detectPatterns: [
|
|
299
|
+
"他感到",
|
|
300
|
+
"她感到",
|
|
301
|
+
"他意识到",
|
|
302
|
+
"她意识到",
|
|
303
|
+
"他明白了",
|
|
304
|
+
"她明白了"
|
|
305
|
+
],
|
|
306
|
+
builtin: true
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
name: "禁止段尾升华",
|
|
310
|
+
avoid: "在段尾或收尾处用总结句升华主题(如\"生活就是\"\"命运总会\"\"说到底\")。",
|
|
311
|
+
fix: "删除升华句,回到具体动作、现场或悬而未决的处境。",
|
|
312
|
+
detectPatterns: [
|
|
313
|
+
"生活就是",
|
|
314
|
+
"命运总会",
|
|
315
|
+
"归根结底",
|
|
316
|
+
"说到底",
|
|
317
|
+
"这就是"
|
|
318
|
+
],
|
|
319
|
+
builtin: true
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
name: "禁止总结主题",
|
|
323
|
+
avoid: "把段落写成总结中心思想或提炼人生道理(如\"这说明\"\"这意味着\")。",
|
|
324
|
+
fix: "删掉主题总结,让信息通过事件和结果自然显现。",
|
|
325
|
+
detectPatterns: [
|
|
326
|
+
"这说明",
|
|
327
|
+
"这意味着",
|
|
328
|
+
"归根结底",
|
|
329
|
+
"其实就是"
|
|
330
|
+
],
|
|
331
|
+
builtin: true
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
name: "禁止直接说教",
|
|
335
|
+
avoid: "作者替角色或读者做直接价值判断和说教(如\"我们都应该\"\"人总要学会\")。",
|
|
336
|
+
fix: "改成角色具体处境或对话,不做抽象说教。",
|
|
337
|
+
detectPatterns: [
|
|
338
|
+
"我们都应该",
|
|
339
|
+
"人总要学会",
|
|
340
|
+
"真正重要的是"
|
|
341
|
+
],
|
|
342
|
+
builtin: true
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
name: "段落长度过于整齐",
|
|
346
|
+
avoid: "段落长度和节奏过于平均,产生 AI 作文感。",
|
|
347
|
+
fix: "打破段落长度均衡,让句子和段落有自然起伏。",
|
|
348
|
+
detectPatterns: [],
|
|
349
|
+
builtin: true
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
name: "连续三段解释性叙事",
|
|
353
|
+
avoid: "连续几段只有解释没有动作,削弱现场感。",
|
|
354
|
+
fix: "插入动作、对话、环境反馈,减少连段说明。",
|
|
355
|
+
detectPatterns: [],
|
|
356
|
+
builtin: true
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
name: "对话纯功能推进",
|
|
360
|
+
avoid: "对话只有信息推进,没有人物语气和生活噪音(如\"告诉你\"\"我们现在要\")。",
|
|
361
|
+
fix: "补入停顿、绕弯、语气差异和无效信息。",
|
|
362
|
+
detectPatterns: [
|
|
363
|
+
"告诉你",
|
|
364
|
+
"我们现在要",
|
|
365
|
+
"接下来就"
|
|
366
|
+
],
|
|
367
|
+
builtin: true
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
name: "句式重复率过高",
|
|
371
|
+
avoid: "连续句式过于整齐(如\"首先\"\"然后\"\"接着\"\"最后\"),显得机械。",
|
|
372
|
+
fix: "拉开句式长度和起句方式,打散结构。",
|
|
373
|
+
detectPatterns: [
|
|
374
|
+
"首先",
|
|
375
|
+
"然后",
|
|
376
|
+
"接着",
|
|
377
|
+
"最后"
|
|
378
|
+
],
|
|
379
|
+
builtin: true
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
name: "AI 高频套话",
|
|
383
|
+
avoid: "滥用\"不禁\"\"仿佛\"\"一时间\"\"不由得\"\"顿时\"\"然而\"\"缓缓\"\"轻轻\"\"微微\"\"似乎\"\"终于\"等模式词及套路比喻。",
|
|
384
|
+
fix: "用具体、有画面感的表达替换套话;每个比喻都应当是新造的。",
|
|
385
|
+
detectPatterns: [
|
|
386
|
+
"不禁",
|
|
387
|
+
"仿佛",
|
|
388
|
+
"一时间",
|
|
389
|
+
"不由得",
|
|
390
|
+
"顿时",
|
|
391
|
+
"缓缓",
|
|
392
|
+
"轻轻",
|
|
393
|
+
"微微"
|
|
394
|
+
],
|
|
395
|
+
builtin: true
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
name: "鼓励无意义小动作",
|
|
399
|
+
avoid: "(鼓励类)全篇缺少真实但不推动主线的小动作,人物显得空洞。",
|
|
400
|
+
fix: "补入挠头、点烟、抠包装、挪椅子等小动作,增加人味与生活感。",
|
|
401
|
+
detectPatterns: [],
|
|
402
|
+
builtin: true
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
name: "鼓励现实落差",
|
|
406
|
+
avoid: "(鼓励类)人物预期和现实结果完全一致,缺少落差。",
|
|
407
|
+
fix: "补出人物预期与实际结果之间的差距,制造张力。",
|
|
408
|
+
detectPatterns: [],
|
|
409
|
+
builtin: true
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
name: "鼓励嘴硬补偿",
|
|
413
|
+
avoid: "(鼓励类)人物吃瘪后没有维持体面的反应。",
|
|
414
|
+
fix: "给角色补一句嘴硬找补或自我合理化,保持人设温度。",
|
|
415
|
+
detectPatterns: [],
|
|
416
|
+
builtin: true
|
|
417
|
+
}
|
|
418
|
+
];
|
|
419
|
+
/** 内置题材基底库(常用网文题材树,跨书复用)。 */
|
|
420
|
+
const BUILTIN_GENRE_LIBRARY = [
|
|
421
|
+
{
|
|
422
|
+
name: "仙侠修真",
|
|
423
|
+
description: "以修仙境界、宗门斗争、法宝丹药为核心,读者期待从凡人到强者的成长与长生问道。",
|
|
424
|
+
children: [
|
|
425
|
+
{
|
|
426
|
+
name: "凡人流",
|
|
427
|
+
description: "资质平凡、步步为营,靠资源积累与心机博弈逆袭,强调真实感与代入感。",
|
|
428
|
+
children: []
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
name: "苟道流",
|
|
432
|
+
description: "主角苟且发育、藏锋敛芒,坐收渔利,强调生存智慧与反差爽点。",
|
|
433
|
+
children: []
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
name: "争霸流",
|
|
437
|
+
description: "宗门、王朝或大陆争锋,主角由弱到强整合势力,强调格局与权谋。",
|
|
438
|
+
children: []
|
|
439
|
+
}
|
|
440
|
+
]
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
name: "都市异能",
|
|
444
|
+
description: "现代都市背景叠加超能力,读者期待隐藏身份、扮猪吃虎与日常反差。",
|
|
445
|
+
children: [
|
|
446
|
+
{
|
|
447
|
+
name: "异能升级",
|
|
448
|
+
description: "觉醒超能力后不断变强,隐藏于都市,遇敌碾压。",
|
|
449
|
+
children: []
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
name: "重生复仇",
|
|
453
|
+
description: "重生回到过去,利用先知先觉改变命运、清算仇敌。",
|
|
454
|
+
children: []
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
name: "商业经营",
|
|
458
|
+
description: "以超能力或见识经商扩张,建立商业帝国,强调经营爽感。",
|
|
459
|
+
children: []
|
|
460
|
+
}
|
|
461
|
+
]
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
name: "悬疑推理",
|
|
465
|
+
description: "以谜题、案件与真相揭露为核心,读者期待线索层层展开与反转。",
|
|
466
|
+
children: [
|
|
467
|
+
{
|
|
468
|
+
name: "本格推理",
|
|
469
|
+
description: "公平线索、逻辑推演,读者可与主角一同解谜。",
|
|
470
|
+
children: []
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
name: "刑侦探案",
|
|
474
|
+
description: "警察或侦探视角连续破案,案件串联主线,强调现实与人性。",
|
|
475
|
+
children: []
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
name: "无限流",
|
|
479
|
+
description: "主角穿梭于不同副本世界解谜求生,副本之间累积成长。",
|
|
480
|
+
children: []
|
|
481
|
+
}
|
|
482
|
+
]
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
name: "玄幻奇幻",
|
|
486
|
+
description: "异世界或架空大陆的冒险成长,读者期待宏大世界观、奇遇与战力突破。",
|
|
487
|
+
children: [
|
|
488
|
+
{
|
|
489
|
+
name: "学院流",
|
|
490
|
+
description: "入学修炼、同窗竞争、大赛扬名,强调青春感与阶梯式打脸。",
|
|
491
|
+
children: []
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
name: "废柴逆袭",
|
|
495
|
+
description: "开局废柴受辱,觉醒金手指后一路逆袭打脸,强调反差与爽点。",
|
|
496
|
+
children: []
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
name: "诸天万界",
|
|
500
|
+
description: "穿越诸天世界收集资源与能力,强调世界多样性与成长曲线。",
|
|
501
|
+
children: []
|
|
502
|
+
}
|
|
503
|
+
]
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
name: "历史军事",
|
|
507
|
+
description: "以历史时代为背景的争霸、谋略或军旅故事,读者期待权谋博弈与时代质感。",
|
|
508
|
+
children: [{
|
|
509
|
+
name: "王朝争霸",
|
|
510
|
+
description: "乱世崛起、招贤纳士、逐鹿天下,强调战略与人心。",
|
|
511
|
+
children: []
|
|
512
|
+
}, {
|
|
513
|
+
name: "穿越种田",
|
|
514
|
+
description: "穿越古代发展生产、经营家族,强调建设感与生活细节。",
|
|
515
|
+
children: []
|
|
516
|
+
}]
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
name: "末世科幻",
|
|
520
|
+
description: "末世危机或科幻设定下的生存与重建,读者期待资源管理、危机升级与人性考验。",
|
|
521
|
+
children: [{
|
|
522
|
+
name: "基地经营",
|
|
523
|
+
description: "建立基地、收集资源、抵御危机,强调建设与扩张。",
|
|
524
|
+
children: []
|
|
525
|
+
}, {
|
|
526
|
+
name: "进化觉醒",
|
|
527
|
+
description: "末世异变中觉醒能力不断进化,强调战力成长与危机求生。",
|
|
528
|
+
children: []
|
|
529
|
+
}]
|
|
530
|
+
}
|
|
531
|
+
];
|
|
532
|
+
/** 内置常用推进模式。 */
|
|
533
|
+
const BUILTIN_PROGRESSION_MODES = [
|
|
534
|
+
{
|
|
535
|
+
name: "升级变强",
|
|
536
|
+
driver: "主角的实力、境界或能力持续增长,读者期待每次突破带来的碾压与认可。",
|
|
537
|
+
readerExpectation: "每隔几章有一次明确的实力提升或打脸兑现;大境界突破要有仪式感。",
|
|
538
|
+
payoffs: [
|
|
539
|
+
"突破境界",
|
|
540
|
+
"学会新技能",
|
|
541
|
+
"越级战胜强敌",
|
|
542
|
+
"当众打脸质疑者"
|
|
543
|
+
],
|
|
544
|
+
risks: [
|
|
545
|
+
"升级重复套路",
|
|
546
|
+
"战力膨胀失控",
|
|
547
|
+
"无铺垫强行突破"
|
|
548
|
+
],
|
|
549
|
+
primary: false
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
name: "经营扩张",
|
|
553
|
+
driver: "主角的产业、势力或领地不断扩张,资源复利滚雪球。",
|
|
554
|
+
readerExpectation: "经营投入有可感知的回报,扩张遇到新挑战并解决。",
|
|
555
|
+
payoffs: [
|
|
556
|
+
"新产业上线",
|
|
557
|
+
"规模翻倍",
|
|
558
|
+
"吞并对手",
|
|
559
|
+
"资源闭环成型"
|
|
560
|
+
],
|
|
561
|
+
risks: [
|
|
562
|
+
"过程枯燥",
|
|
563
|
+
"扩张无阻力",
|
|
564
|
+
"数值失衡"
|
|
565
|
+
],
|
|
566
|
+
primary: false
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
name: "解谜揭露",
|
|
570
|
+
driver: "主线谜团(身世、阴谋、世界观真相)持续牵引读者,每揭开一层又引出更深一层。",
|
|
571
|
+
readerExpectation: "定期有真相碎片放出,回收旧伏笔、埋设新伏笔。",
|
|
572
|
+
payoffs: [
|
|
573
|
+
"伏笔回收",
|
|
574
|
+
"身份揭露",
|
|
575
|
+
"阴谋浮出水面",
|
|
576
|
+
"反转打脸"
|
|
577
|
+
],
|
|
578
|
+
risks: [
|
|
579
|
+
"谜题拖太久",
|
|
580
|
+
"伏笔忘记回收",
|
|
581
|
+
"反转生硬"
|
|
582
|
+
],
|
|
583
|
+
primary: false
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
name: "渔翁得利",
|
|
587
|
+
driver: "强敌相互厮杀,主角躲在暗处观察、收割,风险由他人承担、果实由主角获取。",
|
|
588
|
+
readerExpectation: "冲突升级时主角以最小代价获取最大收益,且不暴露自身。",
|
|
589
|
+
payoffs: [
|
|
590
|
+
"坐收渔利",
|
|
591
|
+
"捡漏宝物",
|
|
592
|
+
"敌人两败俱伤",
|
|
593
|
+
"信息差获利"
|
|
594
|
+
],
|
|
595
|
+
risks: [
|
|
596
|
+
"重复套路",
|
|
597
|
+
"收割太轻易",
|
|
598
|
+
"主角全程无风险"
|
|
599
|
+
],
|
|
600
|
+
primary: false
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
name: "关系拉扯",
|
|
604
|
+
driver: "人物关系(知己、对手、师徒、情感线)的张力与变化持续推动剧情。",
|
|
605
|
+
readerExpectation: "关系有进有退、有误会与和解,情绪起伏带动阅读欲。",
|
|
606
|
+
payoffs: [
|
|
607
|
+
"关系升温",
|
|
608
|
+
"信任建立",
|
|
609
|
+
"背叛与挽回",
|
|
610
|
+
"并肩作战"
|
|
611
|
+
],
|
|
612
|
+
risks: [
|
|
613
|
+
"情感线停滞",
|
|
614
|
+
"工业糖精",
|
|
615
|
+
"为虐而虐"
|
|
616
|
+
],
|
|
617
|
+
primary: false
|
|
618
|
+
}
|
|
619
|
+
];
|
|
620
|
+
/** 默认(空)项目写作资产。 */
|
|
621
|
+
function emptyProjectAssets() {
|
|
622
|
+
return {
|
|
623
|
+
auxiliaryProgressions: [],
|
|
624
|
+
antiAiRules: [],
|
|
625
|
+
styleAssets: []
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
/** 合并项目资产与内置库:返回「生效的反 AI 规则」(内置全局 + 项目自定义)。 */
|
|
629
|
+
function effectiveAntiAiRules(assets) {
|
|
630
|
+
const custom = assets?.antiAiRules ?? [];
|
|
631
|
+
const customNames = new Set(custom.map((r) => r.name));
|
|
632
|
+
return [...BUILTIN_ANTI_AI_RULES.filter((r) => !customNames.has(r.name)), ...custom];
|
|
633
|
+
}
|
|
634
|
+
/** 把生效规则渲染成提示词块。 */
|
|
635
|
+
function renderAntiAiRules(assets) {
|
|
636
|
+
const rules = effectiveAntiAiRules(assets);
|
|
637
|
+
if (rules.length === 0) return "";
|
|
638
|
+
return ["==================== 反 AI 规则(写作时必须遵守的表达边界) ====================", ...rules.map((r) => `- ${r.name}:避免——${r.avoid};修正——${r.fix}`)].join("\n");
|
|
639
|
+
}
|
|
640
|
+
/** 渲染题材与推进模式提示词块。 */
|
|
641
|
+
function renderGenreAndProgression(assets) {
|
|
642
|
+
const sections = [];
|
|
643
|
+
if (assets?.genre !== void 0) {
|
|
644
|
+
sections.push("==================== 题材基底(本书的题材定位与读者期待) ====================");
|
|
645
|
+
sections.push(`题材:${assets.genre.name}`);
|
|
646
|
+
if (assets.genre.description !== "") sections.push(`读者期待:${assets.genre.description}`);
|
|
647
|
+
const walk = (node, depth) => {
|
|
648
|
+
for (const child of node.children) {
|
|
649
|
+
sections.push(`${" ".repeat(depth)}- ${child.name}:${child.description}`);
|
|
650
|
+
walk(child, depth + 1);
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
walk(assets.genre, 1);
|
|
654
|
+
}
|
|
655
|
+
const modes = [...assets?.primaryProgression !== void 0 ? [assets.primaryProgression] : [], ...assets?.auxiliaryProgressions ?? []];
|
|
656
|
+
if (modes.length > 0) {
|
|
657
|
+
sections.push("==================== 推进模式(读者为什么继续看) ====================");
|
|
658
|
+
for (const mode of modes) {
|
|
659
|
+
const tag = mode.primary ? "(主推进)" : "(辅助)";
|
|
660
|
+
sections.push(`- 模式「${mode.name}」${tag}:驱动力——${mode.driver}`);
|
|
661
|
+
sections.push(` 读者期待:${mode.readerExpectation}`);
|
|
662
|
+
if (mode.payoffs.length > 0) sections.push(` 常见兑现:${mode.payoffs.join("、")}`);
|
|
663
|
+
if (mode.risks.length > 0) sections.push(` 节奏风险(避免):${mode.risks.join("、")}`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return sections.join("\n");
|
|
667
|
+
}
|
|
668
|
+
/** 渲染写法资产提示词块。 */
|
|
669
|
+
function renderStyleAssets(assets) {
|
|
670
|
+
const styles = assets?.styleAssets ?? [];
|
|
671
|
+
if (styles.length === 0) return "";
|
|
672
|
+
const sections = ["==================== 写法资产(本书的叙事风格约束) ===================="];
|
|
673
|
+
for (const style of styles) {
|
|
674
|
+
sections.push(`【${style.name}】`);
|
|
675
|
+
if (style.proseRules.length > 0) sections.push("叙述与节奏:\n" + style.proseRules.map((r) => `- ${r}`).join("\n"));
|
|
676
|
+
if (style.dialogueRules.length > 0) sections.push("台词风格:\n" + style.dialogueRules.map((r) => `- ${r}`).join("\n"));
|
|
677
|
+
if (style.descriptionRules.length > 0) sections.push("描写与情绪:\n" + style.descriptionRules.map((r) => `- ${r}`).join("\n"));
|
|
678
|
+
if (style.boundaries.length > 0) sections.push("表达边界:\n" + style.boundaries.map((r) => `- ${r}`).join("\n"));
|
|
679
|
+
}
|
|
680
|
+
return sections.join("\n");
|
|
681
|
+
}
|
|
682
|
+
/** 渲染全部写作资产提示词(供生成/规划/审稿注入)。 */
|
|
683
|
+
function renderAllAssets(assets) {
|
|
684
|
+
return [
|
|
685
|
+
renderGenreAndProgression(assets),
|
|
686
|
+
renderStyleAssets(assets),
|
|
687
|
+
renderAntiAiRules(assets)
|
|
688
|
+
].filter((part) => part !== "").join("\n\n");
|
|
689
|
+
}
|
|
690
|
+
/** 写法引擎:从样本文本提取风格资产的系统提示词。 */
|
|
691
|
+
function styleEngineSystemPrompt() {
|
|
692
|
+
return [
|
|
693
|
+
"你是一位资深网文文风分析师。你会收到一段样本文本,请提炼出可复用的叙事风格规则,供后续章节保持同一种味道。",
|
|
694
|
+
"要求:",
|
|
695
|
+
"1. 从样本中归纳,不要泛泛而谈;每条规则都要能落到具体写法(句式、用词、视角、节奏、对话方式、描写密度)。",
|
|
696
|
+
"2. 台词风格要说明角色说话的语气特征与常用表达方式。",
|
|
697
|
+
"3. 表达边界要写明这段风格「不会怎么做」(如:不用华丽辞藻、不写长段心理独白、不用成语堆砌)。",
|
|
698
|
+
"4. 输出必须是合法 JSON 对象,不要输出任何其他文字。",
|
|
699
|
+
"JSON 结构:",
|
|
700
|
+
"{\"proseRules\": [\"叙述视角与句式节奏规则\"], \"dialogueRules\": [\"台词风格规则\"], \"descriptionRules\": [\"描写密度与情绪表达规则\"], \"boundaries\": [\"表达边界\"]}"
|
|
701
|
+
].join("\n");
|
|
702
|
+
}
|
|
703
|
+
//#endregion
|
|
704
|
+
//#region src/engine.ts
|
|
705
|
+
/**
|
|
706
|
+
* Novel engine — the host half's core: LLM-driven story-bible extraction,
|
|
707
|
+
* volume planning, chapter planning, chapter-by-chapter writing with
|
|
708
|
+
* auto-review + rewrite, polish (de-AI-ify), narrative summaries, foreshadow
|
|
709
|
+
* tracking, project persistence, and whole-book export. Pure Node (no
|
|
710
|
+
* web-server dependencies), so routes stay thin and logic is testable.
|
|
711
|
+
*/
|
|
712
|
+
/** Project state file name inside the output dir. */
|
|
713
|
+
const PROJECT_FILE = "novel-project.json";
|
|
714
|
+
/** Sanitize a file name: keep CJK/alphanumerics/space/dash/underscore. */
|
|
715
|
+
function safeFileName(name) {
|
|
716
|
+
return name.replace(/[\\/:*?"<>|]/g, "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
717
|
+
}
|
|
718
|
+
/** Chapter output file name, e.g. 第001章_开篇.md */
|
|
719
|
+
function chapterFileName(chapter) {
|
|
720
|
+
const title = safeFileName(chapter.title) || `第${chapter.no}章`;
|
|
721
|
+
return `第${String(chapter.no).padStart(3, "0")}章_${title}.md`;
|
|
722
|
+
}
|
|
723
|
+
/** Infer a book name from the outline's first non-empty line. */
|
|
724
|
+
function inferBookName(outline) {
|
|
725
|
+
return (outline.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "未命名小说").replace(/^《/, "").replace(/》.*$/, "").slice(0, 40);
|
|
726
|
+
}
|
|
727
|
+
/** Read the persisted project from the output dir (undefined when absent). */
|
|
728
|
+
function loadProject(outputDir) {
|
|
729
|
+
const file = join(outputDir, PROJECT_FILE);
|
|
730
|
+
if (!existsSync(file)) return void 0;
|
|
731
|
+
try {
|
|
732
|
+
let rawText = readFileSync(file, "utf8");
|
|
733
|
+
if (rawText.charCodeAt(0) === 65279) rawText = rawText.slice(1);
|
|
734
|
+
const raw = JSON.parse(rawText);
|
|
735
|
+
if (typeof raw.outline !== "string" || !Array.isArray(raw.chapters)) return void 0;
|
|
736
|
+
if (!Array.isArray(raw.foreshadows)) raw.foreshadows = [];
|
|
737
|
+
if (raw.assets === void 0 || typeof raw.assets !== "object") raw.assets = emptyProjectAssets();
|
|
738
|
+
if (!Array.isArray(raw.assets.antiAiRules)) raw.assets.antiAiRules = [];
|
|
739
|
+
if (!Array.isArray(raw.assets.auxiliaryProgressions)) raw.assets.auxiliaryProgressions = [];
|
|
740
|
+
if (!Array.isArray(raw.assets.styleAssets)) raw.assets.styleAssets = [];
|
|
741
|
+
return raw;
|
|
742
|
+
} catch {
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
/** Persist the project state next to the chapters. */
|
|
747
|
+
function saveProject(outputDir, project) {
|
|
748
|
+
mkdirSync(outputDir, { recursive: true });
|
|
749
|
+
writeFileSync(join(outputDir, PROJECT_FILE), JSON.stringify(project, null, 2), "utf8");
|
|
750
|
+
}
|
|
751
|
+
/** List generated chapter files in the output dir (sorted). */
|
|
752
|
+
function listChapterFiles(outputDir) {
|
|
753
|
+
if (!existsSync(outputDir)) return [];
|
|
754
|
+
try {
|
|
755
|
+
return readdirSync(outputDir).filter((name) => /^第\d+章_.*\.md$/.test(name)).sort((a, b) => {
|
|
756
|
+
return Number(/^第(\d+)章/.exec(a)?.[1] ?? 0) - Number(/^第(\d+)章/.exec(b)?.[1] ?? 0);
|
|
757
|
+
});
|
|
758
|
+
} catch {
|
|
759
|
+
return [];
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
/** Re-sync chapter status against files on disk (a file may exist without state). */
|
|
763
|
+
function syncProjectWithDisk(project, outputDir) {
|
|
764
|
+
const files = /* @__PURE__ */ new Map();
|
|
765
|
+
for (const file of listChapterFiles(outputDir)) {
|
|
766
|
+
const no = Number(/^第(\d+)章/.exec(file)?.[1] ?? 0);
|
|
767
|
+
if (no > 0) files.set(String(no), file);
|
|
768
|
+
}
|
|
769
|
+
for (const chapter of project.chapters) {
|
|
770
|
+
const file = files.get(String(chapter.no));
|
|
771
|
+
if (file !== void 0 && (chapter.status === "pending" || chapter.status === "generating")) {
|
|
772
|
+
chapter.status = "written";
|
|
773
|
+
chapter.file = file;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
777
|
+
}
|
|
778
|
+
/** Read a chapter's markdown body from disk (undefined when missing). */
|
|
779
|
+
function readChapterFile(outputDir, chapter) {
|
|
780
|
+
if (chapter.file === void 0) return void 0;
|
|
781
|
+
const path = join(outputDir, chapter.file);
|
|
782
|
+
if (!existsSync(path)) return void 0;
|
|
783
|
+
return readFileSync(path, "utf8");
|
|
784
|
+
}
|
|
785
|
+
/** Create a fresh project from an outline. */
|
|
786
|
+
function createProject(outline, outlinePath) {
|
|
787
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
788
|
+
return {
|
|
789
|
+
bookName: inferBookName(outline),
|
|
790
|
+
outline,
|
|
791
|
+
outlinePath,
|
|
792
|
+
chapters: [],
|
|
793
|
+
foreshadows: [],
|
|
794
|
+
assets: emptyProjectAssets(),
|
|
795
|
+
createdAt: now,
|
|
796
|
+
updatedAt: now
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
/** One complete non-streaming LLM call. */
|
|
800
|
+
async function complete(ctx, config, options) {
|
|
801
|
+
const messages = [createUserMessage({
|
|
802
|
+
content: [{
|
|
803
|
+
type: "text",
|
|
804
|
+
text: options.user
|
|
805
|
+
}],
|
|
806
|
+
source: {
|
|
807
|
+
kind: "plugin",
|
|
808
|
+
plugin: "dsh-novel-forge"
|
|
809
|
+
}
|
|
810
|
+
})];
|
|
811
|
+
const request = {
|
|
812
|
+
provider: config.provider,
|
|
813
|
+
model: config.model,
|
|
814
|
+
messages,
|
|
815
|
+
system: options.system,
|
|
816
|
+
maxTokens: options.maxTokens ?? config.maxTokens,
|
|
817
|
+
temperature: options.temperature ?? .7
|
|
818
|
+
};
|
|
819
|
+
const assembler = new BlockAssembler();
|
|
820
|
+
for await (const chunk of ctx.llm.stream(request)) assembler.push(chunk);
|
|
821
|
+
const finish = assembler.finish;
|
|
822
|
+
if (finish.kind === "error" || finish.kind === "aborted") throw new Error(`LLM 调用失败(${finish.kind}): ${finish.failure.message}`);
|
|
823
|
+
if (finish.kind === "max-tokens") throw new Error("LLM 输出达到 maxTokens 上限,请增大配置后重试");
|
|
824
|
+
const blocks = assembler.blocks();
|
|
825
|
+
if (process.env.DSH_NOVEL_DEBUG === "1") console.error("[dsh-novel-forge] complete: finish=%j blocks=%j", JSON.stringify(finish), blocks.map((b) => `${b.type}:${"text" in b ? b.text.length : "?"}`));
|
|
826
|
+
let text = blocks.filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
|
|
827
|
+
if (text === "") {
|
|
828
|
+
const reasoning = blocks.filter((block) => block.type === "reasoning").map((block) => block.text).join("\n").trim();
|
|
829
|
+
if (reasoning !== "") text = reasoning;
|
|
830
|
+
}
|
|
831
|
+
return text;
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Parse a JSON value out of a model response. Multi-level tolerance because
|
|
835
|
+
* models are sloppy: prose around the JSON, ```json fences, a truncated tail,
|
|
836
|
+
* or raw newlines inside string values all defeat a single JSON.parse. We
|
|
837
|
+
* walk candidates from strictest to loosest.
|
|
838
|
+
*/
|
|
839
|
+
function parseJson(text, wantArray) {
|
|
840
|
+
const candidates = [];
|
|
841
|
+
const push = (value) => {
|
|
842
|
+
if (value !== void 0 && value.trim() !== "") candidates.push(value.trim());
|
|
843
|
+
};
|
|
844
|
+
push(text);
|
|
845
|
+
push(/```(?:json)?\s*([\s\S]*?)```/.exec(text)?.[1]);
|
|
846
|
+
const opener = wantArray ? "[" : "{";
|
|
847
|
+
const closer = wantArray ? "]" : "}";
|
|
848
|
+
const start = text.indexOf(opener);
|
|
849
|
+
const end = text.lastIndexOf(closer);
|
|
850
|
+
if (start !== -1 && end > start) push(text.slice(start, end + 1));
|
|
851
|
+
const trimmed = text.replace(new RegExp(`${closer}[\\s\\S]*$`), closer);
|
|
852
|
+
push(trimmed);
|
|
853
|
+
const start2 = trimmed.indexOf(opener);
|
|
854
|
+
if (start2 !== -1) push(trimmed.slice(start2));
|
|
855
|
+
const repair = (value) => {
|
|
856
|
+
let out = "";
|
|
857
|
+
let inString = false;
|
|
858
|
+
for (let i = 0; i < value.length; i++) {
|
|
859
|
+
const ch = value[i];
|
|
860
|
+
if (inString) {
|
|
861
|
+
if (ch === "\\") {
|
|
862
|
+
out += ch + (value[i + 1] ?? "");
|
|
863
|
+
i++;
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
if (ch === "\"") {
|
|
867
|
+
inString = false;
|
|
868
|
+
out += ch;
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
if (ch === "\n" || ch === "\r") {
|
|
872
|
+
out += "\\n";
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
out += ch;
|
|
876
|
+
} else {
|
|
877
|
+
if (ch === "\"") inString = true;
|
|
878
|
+
out += ch;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return out;
|
|
882
|
+
};
|
|
883
|
+
for (const candidate of candidates) for (const attempt of [candidate, repair(candidate)]) try {
|
|
884
|
+
return JSON.parse(attempt);
|
|
885
|
+
} catch {}
|
|
886
|
+
const preview = text.length > 300 ? text.slice(0, 300) + "…" : text;
|
|
887
|
+
throw new Error(`模型输出中未找到 JSON 数据。模型原始输出:${preview}`);
|
|
888
|
+
}
|
|
889
|
+
/** Parse a JSON array (chapters, volumes, issues...). */
|
|
890
|
+
function parseJsonArray(text) {
|
|
891
|
+
const value = parseJson(text, true);
|
|
892
|
+
return Array.isArray(value) ? value : [];
|
|
893
|
+
}
|
|
894
|
+
/** Parse a JSON object. */
|
|
895
|
+
function parseJsonObject(text) {
|
|
896
|
+
const value = parseJson(text, false);
|
|
897
|
+
if (typeof value !== "object" || value === null) throw new Error("模型输出不是 JSON 对象");
|
|
898
|
+
return value;
|
|
899
|
+
}
|
|
900
|
+
/** System prompt for story-bible extraction. */
|
|
901
|
+
function bibleSystemPrompt() {
|
|
902
|
+
return [
|
|
903
|
+
"你是一位资深网文编辑兼设定架构师。你会收到一份小说大纲,请把它提炼成结构化的「设定圣经」(Story Bible),供后续写作时严格引用。",
|
|
904
|
+
"要求:",
|
|
905
|
+
"1. 忠于大纲,不自行发明大纲之外的设定。",
|
|
906
|
+
"2. 角色卡覆盖大纲明确出现的角色(主角必含),每个角色给出性格标签、目标、关键关系。",
|
|
907
|
+
"3. 世界规则覆盖力量体系、金手指机制、势力、地理等所有硬性规则,逐条列出。",
|
|
908
|
+
"4. 红线列出大纲中明确禁止的内容(如无后宫、不圣母、无无脑碾压等)。",
|
|
909
|
+
"5. 风格列出叙事基调、节奏、POV 等写作风格要点。",
|
|
910
|
+
"输出必须是合法 JSON 对象,不要输出任何其他文字或 Markdown 代码块标记。",
|
|
911
|
+
"重要:所有字符串值内部不得包含换行符(不要用多行字符串),JSON 必须在一段内完整结束。",
|
|
912
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
|
|
913
|
+
"JSON 结构:",
|
|
914
|
+
"{\"genre\": \"题材与基调一句话\", \"worldRules\": [\"规则1\", \"规则2\", ...], \"characters\": [{\"name\": \"角色名\", \"role\": \"protagonist|supporting|antagonist|other\", \"traits\": [\"标签1\", ...], \"goals\": \"目标与动机\", \"relations\": \"关键关系\"}], \"redLines\": [\"红线1\", ...], \"style\": [\"风格1\", ...]}"
|
|
915
|
+
].join("\n");
|
|
916
|
+
}
|
|
917
|
+
/** Extract the story bible from an outline. */
|
|
918
|
+
async function extractBible(ctx, config, outline) {
|
|
919
|
+
const user = `请为下面这部小说提炼设定圣经:\n\n${outline}`;
|
|
920
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
921
|
+
system: bibleSystemPrompt(),
|
|
922
|
+
user,
|
|
923
|
+
temperature: .4,
|
|
924
|
+
maxTokens: Math.max(config.maxTokens, 16e3)
|
|
925
|
+
}));
|
|
926
|
+
const strArray = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string" && v.trim() !== "") : [];
|
|
927
|
+
const characters = Array.isArray(raw.characters) ? raw.characters.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
|
|
928
|
+
name: typeof entry.name === "string" ? entry.name.trim() : "未命名",
|
|
929
|
+
role: [
|
|
930
|
+
"protagonist",
|
|
931
|
+
"supporting",
|
|
932
|
+
"antagonist",
|
|
933
|
+
"other"
|
|
934
|
+
].includes(entry.role) ? entry.role : "other",
|
|
935
|
+
traits: strArray(entry.traits),
|
|
936
|
+
goals: typeof entry.goals === "string" ? entry.goals : "",
|
|
937
|
+
relations: typeof entry.relations === "string" ? entry.relations : ""
|
|
938
|
+
})).filter((card) => card.name !== "") : [];
|
|
939
|
+
const bible = {
|
|
940
|
+
genre: typeof raw.genre === "string" ? raw.genre : "",
|
|
941
|
+
worldRules: strArray(raw.worldRules),
|
|
942
|
+
characters,
|
|
943
|
+
redLines: strArray(raw.redLines),
|
|
944
|
+
style: strArray(raw.style),
|
|
945
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
946
|
+
};
|
|
947
|
+
if (bible.worldRules.length === 0 && bible.characters.length === 0 && bible.redLines.length === 0) throw new Error("设定圣经生成失败:模型没有返回有效内容");
|
|
948
|
+
return bible;
|
|
949
|
+
}
|
|
950
|
+
/** System prompt for volume planning. */
|
|
951
|
+
function volumeSystemPrompt() {
|
|
952
|
+
return [
|
|
953
|
+
"你是一位资深网文总编。你会收到一份小说大纲,请把全书划分为若干「卷」(分卷),每卷有明确的剧情定位与起止章节。",
|
|
954
|
+
"要求:",
|
|
955
|
+
"1. 大纲已有分卷时,严格遵循大纲的分卷结构;没有时按剧情弧线合理划分(3-8 卷)。",
|
|
956
|
+
"2. 卷定位一句话说明该卷的剧情重心。",
|
|
957
|
+
"3. chapterStart/chapterEnd 给出该卷覆盖的章节区间(从 1 开始连续编号)。",
|
|
958
|
+
"输出必须是合法 JSON 数组,不要输出任何其他文字:",
|
|
959
|
+
"[{\"no\": 1, \"title\": \"卷名\", \"summary\": \"卷定位与剧情重心\", \"chapterStart\": 1, \"chapterEnd\": 80}]",
|
|
960
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
961
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。"
|
|
962
|
+
].join("\n");
|
|
963
|
+
}
|
|
964
|
+
/** Plan volumes from an outline. */
|
|
965
|
+
async function planVolumes(ctx, config, outline) {
|
|
966
|
+
const user = `请为下面这部小说划分卷:\n\n${outline}`;
|
|
967
|
+
const parsed = parseJsonArray(await complete(ctx, config, {
|
|
968
|
+
system: volumeSystemPrompt(),
|
|
969
|
+
user,
|
|
970
|
+
temperature: .4
|
|
971
|
+
}));
|
|
972
|
+
const volumes = [];
|
|
973
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
974
|
+
const entry = parsed[i];
|
|
975
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
976
|
+
const no = typeof entry.no === "number" ? entry.no : i + 1;
|
|
977
|
+
const title = typeof entry.title === "string" ? entry.title.trim() : `第${no}卷`;
|
|
978
|
+
const summary = typeof entry.summary === "string" ? entry.summary.trim() : "";
|
|
979
|
+
const start = typeof entry.chapterStart === "number" ? entry.chapterStart : void 0;
|
|
980
|
+
const end = typeof entry.chapterEnd === "number" ? entry.chapterEnd : void 0;
|
|
981
|
+
volumes.push({
|
|
982
|
+
no,
|
|
983
|
+
title: title.slice(0, 40),
|
|
984
|
+
summary: summary.slice(0, 300),
|
|
985
|
+
chapterStart: start ?? 1,
|
|
986
|
+
chapterEnd: end ?? 1
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
if (volumes.length === 0) throw new Error("卷计划生成失败:模型没有返回有效卷");
|
|
990
|
+
return volumes;
|
|
991
|
+
}
|
|
992
|
+
/** Assign a chapter to its volume by number. */
|
|
993
|
+
function volumeOf(chapterNo, volumes) {
|
|
994
|
+
if (volumes === void 0 || volumes.length === 0) return 0;
|
|
995
|
+
for (const volume of volumes) if (chapterNo >= volume.chapterStart && chapterNo <= volume.chapterEnd) return volume.no;
|
|
996
|
+
return volumes[volumes.length - 1]?.no ?? 0;
|
|
997
|
+
}
|
|
998
|
+
/** The chapter-planning prompt template. */
|
|
999
|
+
function planSystemPrompt(volumes) {
|
|
1000
|
+
return [
|
|
1001
|
+
"你是一位资深中文网文策划编辑,擅长把小说大纲拆解为可执行的章节计划。",
|
|
1002
|
+
"你会收到一份小说大纲。请根据大纲的设定、主线与节奏,规划出一份章节计划。",
|
|
1003
|
+
"要求:",
|
|
1004
|
+
"1. 每章必须有明确的核心剧情推进(不能只是过渡或凑字数)。",
|
|
1005
|
+
"2. 章节之间要衔接自然,前章结尾为后章埋下钩子。",
|
|
1006
|
+
"3. 严格遵循大纲的人设、金手指规则、战力体系与世界观设定,不得自行发明冲突设定。",
|
|
1007
|
+
"4. 输出必须是合法的 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记。",
|
|
1008
|
+
"5. 数组每个元素格式:{\"title\": \"章节标题(10字以内,有网文感)\", \"beats\": \"本章剧情要点(150-250字,含起承转合与钩子)\"}",
|
|
1009
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
1010
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
|
|
1011
|
+
volumes !== void 0 && volumes.length > 0 ? ["\n全书分卷结构(规划章节时需落在对应卷内):"].concat(volumes.map((v) => `第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})`)).join("\n") : ""
|
|
1012
|
+
].join("\n");
|
|
1013
|
+
}
|
|
1014
|
+
/** Build the writing system prompt (bible + outline + active foreshadows). */
|
|
1015
|
+
function writeSystemPrompt(project) {
|
|
1016
|
+
const bible = project.bible;
|
|
1017
|
+
const sections = [];
|
|
1018
|
+
if (bible !== void 0) {
|
|
1019
|
+
sections.push("==================== 设定圣经(写作时严格遵守) ====================");
|
|
1020
|
+
if (bible.genre !== "") sections.push(`题材基调:${bible.genre}`);
|
|
1021
|
+
if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
|
|
1022
|
+
if (bible.characters.length > 0) {
|
|
1023
|
+
sections.push("角色卡:");
|
|
1024
|
+
for (const card of bible.characters) {
|
|
1025
|
+
const roleName = {
|
|
1026
|
+
protagonist: "主角",
|
|
1027
|
+
supporting: "配角",
|
|
1028
|
+
antagonist: "反派",
|
|
1029
|
+
other: "其他"
|
|
1030
|
+
}[card.role];
|
|
1031
|
+
sections.push(`- ${card.name}(${roleName}):${card.traits.join("、")}${card.goals !== "" ? `;目标:${card.goals}` : ""}${card.relations !== "" ? `;关系:${card.relations}` : ""}`);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
if (bible.redLines.length > 0) sections.push("写作红线(违反即失败):\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
|
|
1035
|
+
if (bible.style.length > 0) sections.push("风格要求:\n" + bible.style.map((r) => `- ${r}`).join("\n"));
|
|
1036
|
+
}
|
|
1037
|
+
sections.push("==================== 全书大纲 ====================");
|
|
1038
|
+
sections.push(project.outline);
|
|
1039
|
+
sections.push("==================== 大纲结束 ====================");
|
|
1040
|
+
const assetsBlock = renderAllAssets(project.assets);
|
|
1041
|
+
if (assetsBlock !== "") sections.push(assetsBlock);
|
|
1042
|
+
const active = project.foreshadows.filter((f) => f.status === "planted" || f.status === "progressing");
|
|
1043
|
+
if (active.length > 0) {
|
|
1044
|
+
sections.push("==================== 活跃伏笔(近期需推进或回收的线索) ====================");
|
|
1045
|
+
for (const f of active) sections.push(`- [${f.status === "planted" ? "已埋设" : "推进中"}] ${f.description}${f.targetChapter !== void 0 ? `(预计 ${f.targetChapter} 章回收)` : ""}`);
|
|
1046
|
+
}
|
|
1047
|
+
sections.push("");
|
|
1048
|
+
sections.push("写作硬性要求:");
|
|
1049
|
+
sections.push("1. 每章 3000-4000 字(按中文字符计),只输出章节正文,不要输出标题、章回名、作者的话或任何 Markdown 标记。");
|
|
1050
|
+
sections.push("2. 以主角视角展开,动作、对话、心理描写交替推进,禁止大段设定说明。");
|
|
1051
|
+
sections.push("3. 尊重大纲与设定圣经:人设不崩、金手指规则不自相矛盾、战力不随意膨胀。");
|
|
1052
|
+
sections.push("4. 章末留一个钩子(悬念、反转或新线索),吸引读者读下一章。");
|
|
1053
|
+
sections.push("5. 语言流畅自然,符合中文网文语感,避免翻译腔与病句。");
|
|
1054
|
+
return sections.join("\n");
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Plan chapters from an outline (optionally for one volume).
|
|
1058
|
+
*/
|
|
1059
|
+
async function planChapters(ctx, config, project, chapterCount, volumeNo) {
|
|
1060
|
+
const volume = project.volumes?.find((v) => v.no === volumeNo);
|
|
1061
|
+
const user = [
|
|
1062
|
+
"请为下面这部小说规划章节。",
|
|
1063
|
+
volume !== void 0 ? `本次只规划第 ${volume.no} 卷《${volume.title}》的章节:\n${volume.summary}` : "请规划全书开篇章节。",
|
|
1064
|
+
`大纲如下:\n${project.outline}`,
|
|
1065
|
+
"",
|
|
1066
|
+
`请规划 ${chapterCount} 章。输出 JSON 数组(不要输出其他文字):`
|
|
1067
|
+
].join("\n");
|
|
1068
|
+
const parsed = parseJsonArray(await complete(ctx, config, {
|
|
1069
|
+
system: planSystemPrompt(project.volumes),
|
|
1070
|
+
user,
|
|
1071
|
+
temperature: .7
|
|
1072
|
+
}));
|
|
1073
|
+
const chapters = [];
|
|
1074
|
+
const existing = new Set(project.chapters.map((c) => c.no));
|
|
1075
|
+
const startNo = project.chapters.length + 1;
|
|
1076
|
+
for (let i = 0; i < Math.min(parsed.length, chapterCount); i++) {
|
|
1077
|
+
const item = parsed[i];
|
|
1078
|
+
if (typeof item !== "object" || item === null) continue;
|
|
1079
|
+
const entry = item;
|
|
1080
|
+
const title = typeof entry.title === "string" ? entry.title.trim().slice(0, 30) : "";
|
|
1081
|
+
const beats = typeof entry.beats === "string" ? entry.beats.trim() : "";
|
|
1082
|
+
if (title === "" && beats === "") continue;
|
|
1083
|
+
const no = startNo + i;
|
|
1084
|
+
if (existing.has(no)) continue;
|
|
1085
|
+
chapters.push({
|
|
1086
|
+
no,
|
|
1087
|
+
volume: volumeOf(no, project.volumes),
|
|
1088
|
+
title: title || `第${no}章`,
|
|
1089
|
+
beats,
|
|
1090
|
+
targetChars: config.chapterChars,
|
|
1091
|
+
status: "pending"
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
if (chapters.length === 0) throw new Error("章节计划生成失败:模型没有返回有效章节");
|
|
1095
|
+
return chapters;
|
|
1096
|
+
}
|
|
1097
|
+
/** The review system prompt. */
|
|
1098
|
+
function reviewSystemPrompt(project) {
|
|
1099
|
+
const bible = project.bible;
|
|
1100
|
+
const sections = [
|
|
1101
|
+
"你是一位严格的网文审稿编辑。你会收到一章正文以及本书的设定圣经与红线。",
|
|
1102
|
+
"请从以下维度审查本章:",
|
|
1103
|
+
"1. 人设一致性:角色行为是否符合角色卡(主角不圣母、不无脑、痞坏有分寸等)。",
|
|
1104
|
+
"2. 设定一致性:金手指规则、战力体系、世界观是否与设定圣经冲突。",
|
|
1105
|
+
"3. 红线检查:是否触犯写作红线(无后宫、无擦边、无无脑碾压等)。",
|
|
1106
|
+
"4. 文笔质量:语病、翻译腔、AI 套话(\"不禁\"\"仿佛\"\"一时间\"等高频词滥用)、流水账。",
|
|
1107
|
+
"5. 节奏与爽点:本章是否有推进、有钩子,是否拖沓灌水。",
|
|
1108
|
+
"6. 逻辑漏洞:前后矛盾、时间线错误、对话失真。",
|
|
1109
|
+
"7. 反 AI 规则:逐条核对下方「反 AI 规则」清单,命中即列为问题。",
|
|
1110
|
+
"输出必须是合法 JSON 对象,不要输出任何其他文字:",
|
|
1111
|
+
"{\"score\": 0-100的整数, \"verdict\": \"一句话总评\", \"issues\": [{\"severity\": \"high|medium|low\", \"item\": \"问题描述\", \"suggestion\": \"修改建议\"}]}",
|
|
1112
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
1113
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
1114
|
+
];
|
|
1115
|
+
const assetsBlock = renderAllAssets(project.assets);
|
|
1116
|
+
if (assetsBlock !== "") sections.push("\n" + assetsBlock);
|
|
1117
|
+
if (bible !== void 0) {
|
|
1118
|
+
sections.push("\n==================== 设定圣经 ====================");
|
|
1119
|
+
if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
|
|
1120
|
+
if (bible.characters.length > 0) {
|
|
1121
|
+
sections.push("角色卡:");
|
|
1122
|
+
for (const card of bible.characters) sections.push(`- ${card.name}(${card.role}):${card.traits.join("、")}`);
|
|
1123
|
+
}
|
|
1124
|
+
if (bible.redLines.length > 0) sections.push("红线:\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
|
|
1125
|
+
}
|
|
1126
|
+
return sections.join("\n");
|
|
1127
|
+
}
|
|
1128
|
+
/** Run the AI review on one chapter. */
|
|
1129
|
+
async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
|
|
1130
|
+
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
1131
|
+
if (chapter === void 0) throw new Error(`章节 ${chapterNo} 不在计划中`);
|
|
1132
|
+
const body = readChapterFile(outputDir, chapter);
|
|
1133
|
+
if (body === void 0) throw new Error(`章节 ${chapterNo} 的正文文件不存在`);
|
|
1134
|
+
const user = [
|
|
1135
|
+
`本章标题:《${chapter.title}》`,
|
|
1136
|
+
`本章剧情要点:${chapter.beats}`,
|
|
1137
|
+
"==================== 章节正文 ====================",
|
|
1138
|
+
body.replace(/^#\s+.*$/m, "").trim()
|
|
1139
|
+
].join("\n");
|
|
1140
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1141
|
+
system: reviewSystemPrompt(project),
|
|
1142
|
+
user,
|
|
1143
|
+
temperature: .3
|
|
1144
|
+
}));
|
|
1145
|
+
const issues = Array.isArray(raw.issues) ? raw.issues.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
|
|
1146
|
+
severity: [
|
|
1147
|
+
"high",
|
|
1148
|
+
"medium",
|
|
1149
|
+
"low"
|
|
1150
|
+
].includes(entry.severity) ? entry.severity : "medium",
|
|
1151
|
+
item: typeof entry.item === "string" ? entry.item : "",
|
|
1152
|
+
suggestion: typeof entry.suggestion === "string" ? entry.suggestion : ""
|
|
1153
|
+
})).filter((issue) => issue.item !== "") : [];
|
|
1154
|
+
const score = typeof raw.score === "number" ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60;
|
|
1155
|
+
const report = {
|
|
1156
|
+
score,
|
|
1157
|
+
passed: score >= config.reviewPassScore,
|
|
1158
|
+
verdict: typeof raw.verdict === "string" ? raw.verdict.slice(0, 200) : "",
|
|
1159
|
+
issues,
|
|
1160
|
+
reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1161
|
+
};
|
|
1162
|
+
chapter.review = report;
|
|
1163
|
+
chapter.status = report.passed ? "approved" : "rejected";
|
|
1164
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1165
|
+
saveProject(outputDir, project);
|
|
1166
|
+
return report;
|
|
1167
|
+
}
|
|
1168
|
+
/** Build the rewrite system prompt (fix review issues / instructions). */
|
|
1169
|
+
function rewriteSystemPrompt(project) {
|
|
1170
|
+
return writeSystemPrompt(project) + "\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。";
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Stream a chapter rewrite. With `target` (a passage of the body), only that
|
|
1174
|
+
* passage's paragraph is rewritten and spliced back — everything else stays
|
|
1175
|
+
* untouched (local revision). Without `target`, the whole chapter is
|
|
1176
|
+
* rewritten. Yields delta text; persists when done.
|
|
1177
|
+
*/
|
|
1178
|
+
async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo, instructions, target) {
|
|
1179
|
+
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
1180
|
+
if (chapter === void 0) throw new Error(`章节 ${chapterNo} 不在计划中`);
|
|
1181
|
+
const body = readChapterFile(outputDir, chapter);
|
|
1182
|
+
if (body === void 0) throw new Error(`章节 ${chapterNo} 的正文文件不存在`);
|
|
1183
|
+
const reviewBlock = chapter.review !== void 0 ? "审稿意见:\n" + chapter.review.issues.map((i) => `[${i.severity}] ${i.item} → ${i.suggestion}`).join("\n") : "";
|
|
1184
|
+
const bodyText = body.replace(/^#\s+.*$/m, "").trim();
|
|
1185
|
+
let localTarget;
|
|
1186
|
+
if (target !== void 0 && target.trim() !== "") {
|
|
1187
|
+
const wanted = target.trim();
|
|
1188
|
+
const normalize = (value) => value.replace(/\s+/g, " ").replace(/[“”"'‘’]/g, "");
|
|
1189
|
+
const wantedFlat = normalize(wanted);
|
|
1190
|
+
const paragraphs = bodyText.split(/\n{2,}/);
|
|
1191
|
+
const idx = paragraphs.findIndex((p) => normalize(p).includes(wantedFlat));
|
|
1192
|
+
if (idx === -1) throw new Error(`在正文中未找到要修改的片段:「${wanted.slice(0, 40)}…」。请从正文中复制原文片段(无需整段,取片段即可)。`);
|
|
1193
|
+
localTarget = {
|
|
1194
|
+
paragraph: paragraphs[idx],
|
|
1195
|
+
before: paragraphs.slice(0, idx).join("\n\n"),
|
|
1196
|
+
after: paragraphs.slice(idx + 1).join("\n\n")
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
const user = localTarget === void 0 ? [
|
|
1200
|
+
`请修订第 ${chapter.no} 章《${chapter.title}》。`,
|
|
1201
|
+
reviewBlock,
|
|
1202
|
+
instructions !== "" ? `本次修订重点:${instructions}` : "",
|
|
1203
|
+
"==================== 原正文 ====================",
|
|
1204
|
+
bodyText
|
|
1205
|
+
].filter((line) => line !== "").join("\n") : [
|
|
1206
|
+
`请修订第 ${chapter.no} 章《${chapter.title}》中的一个自然段。`,
|
|
1207
|
+
instructions !== "" ? `修改要求:${instructions}` : "",
|
|
1208
|
+
"==================== 需要修改的原文段落 ====================",
|
|
1209
|
+
localTarget.paragraph,
|
|
1210
|
+
"",
|
|
1211
|
+
"要求:",
|
|
1212
|
+
"1. 只输出修改后的【这一个段落】的完整新文本,不要输出任何说明、标题或 Markdown 标记。",
|
|
1213
|
+
"2. 保留该段的情节走向与角色口吻,只按修改要求调整。",
|
|
1214
|
+
"3. 段落长度与原文相当。"
|
|
1215
|
+
].filter((line) => line !== "").join("\n");
|
|
1216
|
+
const system = localTarget === void 0 ? rewriteSystemPrompt(project) : "你是一位中文网文润色师。你会收到一章中的一个段落,请按修改要求重写该段。只输出新段落文本。";
|
|
1217
|
+
const messages = [createUserMessage({
|
|
1218
|
+
content: [{
|
|
1219
|
+
type: "text",
|
|
1220
|
+
text: user
|
|
1221
|
+
}],
|
|
1222
|
+
source: {
|
|
1223
|
+
kind: "plugin",
|
|
1224
|
+
plugin: "dsh-novel-forge"
|
|
1225
|
+
}
|
|
1226
|
+
})];
|
|
1227
|
+
const request = {
|
|
1228
|
+
provider: config.provider,
|
|
1229
|
+
model: config.model,
|
|
1230
|
+
messages,
|
|
1231
|
+
system,
|
|
1232
|
+
maxTokens: Math.max(config.maxTokens, 2e4),
|
|
1233
|
+
temperature: .7,
|
|
1234
|
+
reasoningEffort: ReasoningEffortId("off")
|
|
1235
|
+
};
|
|
1236
|
+
yield { frame: "start" };
|
|
1237
|
+
const assembler = new BlockAssembler();
|
|
1238
|
+
let streamError;
|
|
1239
|
+
for await (const chunk of ctx.llm.stream(request)) {
|
|
1240
|
+
assembler.push(chunk);
|
|
1241
|
+
if (chunk.type === "text-delta") yield {
|
|
1242
|
+
frame: "delta",
|
|
1243
|
+
text: chunk.text
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
const finish = assembler.finish;
|
|
1247
|
+
if (finish.kind === "error" || finish.kind === "aborted") streamError = /* @__PURE__ */ new Error(`修订失败(${finish.kind}): ${finish.failure.message}`);
|
|
1248
|
+
else if (finish.kind === "max-tokens") streamError = /* @__PURE__ */ new Error("修订输出达到 maxTokens 上限,请增大配置后重试");
|
|
1249
|
+
const rewritten = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
|
|
1250
|
+
if (streamError !== void 0) throw streamError;
|
|
1251
|
+
if (rewritten.length < 20) throw new Error("修订结果过短,可能失败,请重试");
|
|
1252
|
+
let newBody;
|
|
1253
|
+
if (localTarget !== void 0) newBody = [
|
|
1254
|
+
localTarget.before,
|
|
1255
|
+
rewritten,
|
|
1256
|
+
localTarget.after
|
|
1257
|
+
].filter((part) => part !== "").join("\n\n");
|
|
1258
|
+
else newBody = rewritten;
|
|
1259
|
+
if (newBody.length < 100) throw new Error("修订结果过短,可能失败,请重试");
|
|
1260
|
+
const fileName = chapterFileName(chapter);
|
|
1261
|
+
const markdown = `# 第${chapter.no}章 ${chapter.title}\n\n${newBody}\n`;
|
|
1262
|
+
writeFileSync(join(outputDir, fileName), markdown, "utf8");
|
|
1263
|
+
chapter.status = "written";
|
|
1264
|
+
chapter.chars = newBody.length;
|
|
1265
|
+
chapter.error = void 0;
|
|
1266
|
+
chapter.review = void 0;
|
|
1267
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1268
|
+
saveProject(outputDir, project);
|
|
1269
|
+
yield {
|
|
1270
|
+
frame: "done",
|
|
1271
|
+
file: fileName,
|
|
1272
|
+
chars: newBody.length
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
/** The de-AI-ify polish system prompt (with project writing assets injected). */
|
|
1276
|
+
function polishSystemPrompt(project) {
|
|
1277
|
+
const assetsBlock = renderAllAssets(project.assets);
|
|
1278
|
+
return [
|
|
1279
|
+
"你是一位中文网文润色师。你会收到一章正文,请做「去 AI 味」润色:",
|
|
1280
|
+
"1. 删除/替换 AI 高频套话与模式词:如\"不禁\"\"仿佛\"\"一时间\"\"不由得\"\"顿时\"\"然而\"\"缓缓\"\"轻轻\"\"微微\"\"默默\"\"似乎\"\"终于\"等滥用。",
|
|
1281
|
+
"2. 把书面翻译腔改成口语化的中文网文语感。",
|
|
1282
|
+
"3. 拆分过长的排比句与堆砌的修饰语。",
|
|
1283
|
+
"4. 保留全部情节、人物、对话内容不变,只改表达。",
|
|
1284
|
+
"5. 输出完整的新正文,不要输出任何说明文字或 Markdown 标记。",
|
|
1285
|
+
"6. 必须遵守下方「反 AI 规则」与「写法资产」的表达边界;写法资产要求保留的风格特征(句式、台词、节奏)不得在润色中丢失。",
|
|
1286
|
+
assetsBlock !== "" ? assetsBlock : ""
|
|
1287
|
+
].join("\n");
|
|
1288
|
+
}
|
|
1289
|
+
/** Stream a chapter polish (de-AI-ify). */
|
|
1290
|
+
async function* polishChapterStream(ctx, config, project, outputDir, chapterNo) {
|
|
1291
|
+
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
1292
|
+
if (chapter === void 0) throw new Error(`章节 ${chapterNo} 不在计划中`);
|
|
1293
|
+
const body = readChapterFile(outputDir, chapter);
|
|
1294
|
+
if (body === void 0) throw new Error(`章节 ${chapterNo} 的正文文件不存在`);
|
|
1295
|
+
const messages = [createUserMessage({
|
|
1296
|
+
content: [{
|
|
1297
|
+
type: "text",
|
|
1298
|
+
text: body.replace(/^#\s+.*$/m, "").trim()
|
|
1299
|
+
}],
|
|
1300
|
+
source: {
|
|
1301
|
+
kind: "plugin",
|
|
1302
|
+
plugin: "dsh-novel-forge"
|
|
1303
|
+
}
|
|
1304
|
+
})];
|
|
1305
|
+
const request = {
|
|
1306
|
+
provider: config.provider,
|
|
1307
|
+
model: config.model,
|
|
1308
|
+
messages,
|
|
1309
|
+
system: polishSystemPrompt(project),
|
|
1310
|
+
maxTokens: Math.max(config.maxTokens, 2e4),
|
|
1311
|
+
temperature: .5,
|
|
1312
|
+
reasoningEffort: ReasoningEffortId("off")
|
|
1313
|
+
};
|
|
1314
|
+
yield { frame: "start" };
|
|
1315
|
+
const assembler = new BlockAssembler();
|
|
1316
|
+
let streamError;
|
|
1317
|
+
for await (const chunk of ctx.llm.stream(request)) {
|
|
1318
|
+
assembler.push(chunk);
|
|
1319
|
+
if (chunk.type === "text-delta") yield {
|
|
1320
|
+
frame: "delta",
|
|
1321
|
+
text: chunk.text
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
const finish = assembler.finish;
|
|
1325
|
+
if (finish.kind === "error" || finish.kind === "aborted") streamError = /* @__PURE__ */ new Error(`润色失败(${finish.kind}): ${finish.failure.message}`);
|
|
1326
|
+
else if (finish.kind === "max-tokens") streamError = /* @__PURE__ */ new Error("润色输出达到 maxTokens 上限");
|
|
1327
|
+
const newBody = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
|
|
1328
|
+
if (streamError !== void 0) throw streamError;
|
|
1329
|
+
if (newBody.length < 100) throw new Error("润色结果过短,可能失败,请重试");
|
|
1330
|
+
const fileName = chapterFileName(chapter);
|
|
1331
|
+
writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${newBody}\n`, "utf8");
|
|
1332
|
+
chapter.status = "written";
|
|
1333
|
+
chapter.chars = newBody.length;
|
|
1334
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1335
|
+
saveProject(outputDir, project);
|
|
1336
|
+
yield {
|
|
1337
|
+
frame: "done",
|
|
1338
|
+
file: fileName,
|
|
1339
|
+
chars: newBody.length
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
/** Generate one chapter (streaming). Yields progress frames; persists when done. */
|
|
1343
|
+
async function* generateChapterStream(ctx, config, project, outputDir, chapterNo) {
|
|
1344
|
+
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
1345
|
+
if (chapter === void 0) throw new Error(`章节 ${chapterNo} 不在计划中`);
|
|
1346
|
+
let continuity = "";
|
|
1347
|
+
const prev = project.chapters.find((c) => c.no === chapterNo - 1);
|
|
1348
|
+
if (prev?.file !== void 0) {
|
|
1349
|
+
const prevPath = join(outputDir, prev.file);
|
|
1350
|
+
if (existsSync(prevPath)) continuity = readFileSync(prevPath, "utf8").slice(-900);
|
|
1351
|
+
}
|
|
1352
|
+
const prevSummary = prev?.summary;
|
|
1353
|
+
const messages = [createUserMessage({
|
|
1354
|
+
content: [{
|
|
1355
|
+
type: "text",
|
|
1356
|
+
text: [
|
|
1357
|
+
`现在写第 ${chapter.no} 章,标题《${chapter.title}》。`,
|
|
1358
|
+
`本章剧情要点:${chapter.beats}`,
|
|
1359
|
+
"",
|
|
1360
|
+
prevSummary !== void 0 && prevSummary !== "" ? `上一章摘要:${prevSummary}` : "",
|
|
1361
|
+
continuity !== "" ? `上一章结尾(用于衔接,不要复述):\n${continuity}` : "这是第一章,注意开篇要有吸引力。",
|
|
1362
|
+
"",
|
|
1363
|
+
`请写 ${chapter.targetChars} 字左右的正文,只输出正文。`
|
|
1364
|
+
].filter((line) => line !== "").join("\n")
|
|
1365
|
+
}],
|
|
1366
|
+
source: {
|
|
1367
|
+
kind: "plugin",
|
|
1368
|
+
plugin: "dsh-novel-forge"
|
|
1369
|
+
}
|
|
1370
|
+
})];
|
|
1371
|
+
const request = {
|
|
1372
|
+
provider: config.provider,
|
|
1373
|
+
model: config.model,
|
|
1374
|
+
messages,
|
|
1375
|
+
system: writeSystemPrompt(project),
|
|
1376
|
+
maxTokens: Math.max(config.maxTokens, 2e4),
|
|
1377
|
+
temperature: .85
|
|
1378
|
+
};
|
|
1379
|
+
yield { frame: "start" };
|
|
1380
|
+
const assembler = new BlockAssembler();
|
|
1381
|
+
let streamError;
|
|
1382
|
+
for await (const chunk of ctx.llm.stream(request)) {
|
|
1383
|
+
assembler.push(chunk);
|
|
1384
|
+
if (chunk.type === "text-delta") yield {
|
|
1385
|
+
frame: "delta",
|
|
1386
|
+
text: chunk.text
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
const finish = assembler.finish;
|
|
1390
|
+
if (finish.kind === "error" || finish.kind === "aborted") streamError = /* @__PURE__ */ new Error(`生成失败(${finish.kind}): ${finish.failure.message}`);
|
|
1391
|
+
else if (finish.kind === "max-tokens") streamError = /* @__PURE__ */ new Error("达到 maxTokens 上限,正文可能不完整,请增大 maxTokens 后重试");
|
|
1392
|
+
const body = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
|
|
1393
|
+
if (streamError !== void 0) throw streamError;
|
|
1394
|
+
if (body.length < 100) throw new Error("生成内容过短,可能失败,请重试");
|
|
1395
|
+
const fileName = chapterFileName(chapter);
|
|
1396
|
+
mkdirSync(outputDir, { recursive: true });
|
|
1397
|
+
writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${body}\n`, "utf8");
|
|
1398
|
+
chapter.status = "written";
|
|
1399
|
+
chapter.chars = body.length;
|
|
1400
|
+
chapter.file = fileName;
|
|
1401
|
+
chapter.error = void 0;
|
|
1402
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1403
|
+
saveProject(outputDir, project);
|
|
1404
|
+
yield {
|
|
1405
|
+
frame: "done",
|
|
1406
|
+
file: fileName,
|
|
1407
|
+
chars: body.length
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
/** Generate a chapter summary (narrative memory). */
|
|
1411
|
+
async function summarizeChapter(ctx, config, project, outputDir, chapterNo) {
|
|
1412
|
+
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
1413
|
+
if (chapter === void 0) throw new Error(`章节 ${chapterNo} 不在计划中`);
|
|
1414
|
+
const body = readChapterFile(outputDir, chapter);
|
|
1415
|
+
if (body === void 0) throw new Error(`章节 ${chapterNo} 的正文文件不存在`);
|
|
1416
|
+
chapter.summary = (await complete(ctx, config, {
|
|
1417
|
+
system: [
|
|
1418
|
+
"你是一位网文编辑。请为下面一章写一段 120-200 字的摘要,供后续章节写作时保持连贯性。",
|
|
1419
|
+
"摘要必须包含:本章发生的关键事件、主角状态变化(境界/资源/伤势/心境)、新增的伏笔或线索、角色关系变化。",
|
|
1420
|
+
"用客观陈述句,不要评价,不要剧透式感叹。只输出摘要正文。"
|
|
1421
|
+
].join("\n"),
|
|
1422
|
+
user: body.replace(/^#\s+.*$/m, "").trim(),
|
|
1423
|
+
temperature: .3,
|
|
1424
|
+
maxTokens: 800
|
|
1425
|
+
})).slice(0, 500);
|
|
1426
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1427
|
+
saveProject(outputDir, project);
|
|
1428
|
+
return chapter.summary;
|
|
1429
|
+
}
|
|
1430
|
+
/** System prompt for foreshadow suggestions. */
|
|
1431
|
+
function foreshadowSystemPrompt() {
|
|
1432
|
+
return [
|
|
1433
|
+
"你是一位网文伏笔设计师。你会收到大纲和已写的章节信息,请为小说建议 3-8 条值得埋设的伏笔。",
|
|
1434
|
+
"要求:",
|
|
1435
|
+
"1. 伏笔必须有明确的回收价值(推动主线、人物弧光、世界观揭秘)。",
|
|
1436
|
+
"2. 描述要具体,指出埋设章节与预计回收章节(可空缺)。",
|
|
1437
|
+
"3. 优先从大纲的暗线(如记忆代价、残片收集、身世谜团)中提炼。",
|
|
1438
|
+
"输出必须是合法 JSON 数组:",
|
|
1439
|
+
"[{\"description\": \"伏笔描述\", \"plantedChapter\": 章节号或null, \"targetChapter\": 章节号或null}]",
|
|
1440
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
1441
|
+
].join("\n");
|
|
1442
|
+
}
|
|
1443
|
+
/** Suggest foreshadows from the outline + plan. */
|
|
1444
|
+
async function suggestForeshadows(ctx, config, project) {
|
|
1445
|
+
const user = [
|
|
1446
|
+
"请为下面这部小说设计伏笔。",
|
|
1447
|
+
`大纲:\n${project.outline}`,
|
|
1448
|
+
`已规划章节数:${project.chapters.length}`
|
|
1449
|
+
].join("\n");
|
|
1450
|
+
const parsed = parseJsonArray(await complete(ctx, config, {
|
|
1451
|
+
system: foreshadowSystemPrompt(),
|
|
1452
|
+
user,
|
|
1453
|
+
temperature: .5
|
|
1454
|
+
}));
|
|
1455
|
+
const existing = new Set(project.foreshadows.map((f) => f.description));
|
|
1456
|
+
const created = [];
|
|
1457
|
+
for (const entry of parsed) {
|
|
1458
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1459
|
+
const description = typeof entry.description === "string" ? entry.description.trim() : "";
|
|
1460
|
+
if (description === "" || existing.has(description)) continue;
|
|
1461
|
+
existing.add(description);
|
|
1462
|
+
created.push({
|
|
1463
|
+
id: `fs-${Date.now().toString(36)}-${created.length}`,
|
|
1464
|
+
description: description.slice(0, 200),
|
|
1465
|
+
plantedChapter: typeof entry.plantedChapter === "number" ? entry.plantedChapter : void 0,
|
|
1466
|
+
targetChapter: typeof entry.targetChapter === "number" ? entry.targetChapter : void 0,
|
|
1467
|
+
status: "planned"
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
project.foreshadows.push(...created);
|
|
1471
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1472
|
+
return created;
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* 写法引擎:从样本文本提取一份写法资产(叙事风格规则)。
|
|
1476
|
+
* @returns 提取出的风格规则(未持久化,由调用方存入 project.assets)。
|
|
1477
|
+
*/
|
|
1478
|
+
async function extractStyleAsset(ctx, config, sampleText) {
|
|
1479
|
+
const user = `请分析下面这段样本文本,提炼其叙事风格规则:\n\n${sampleText}`;
|
|
1480
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1481
|
+
system: styleEngineSystemPrompt(),
|
|
1482
|
+
user,
|
|
1483
|
+
temperature: .3
|
|
1484
|
+
}));
|
|
1485
|
+
const strArray = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string" && v.trim() !== "") : [];
|
|
1486
|
+
const result = {
|
|
1487
|
+
proseRules: strArray(raw.proseRules),
|
|
1488
|
+
dialogueRules: strArray(raw.dialogueRules),
|
|
1489
|
+
descriptionRules: strArray(raw.descriptionRules),
|
|
1490
|
+
boundaries: strArray(raw.boundaries)
|
|
1491
|
+
};
|
|
1492
|
+
if (result.proseRules.length + result.dialogueRules.length + result.descriptionRules.length + result.boundaries.length === 0) throw new Error("写法提取失败:模型没有返回有效规则");
|
|
1493
|
+
return result;
|
|
1494
|
+
}
|
|
1495
|
+
/** Export the whole book as one txt/md file. */
|
|
1496
|
+
function exportBook(outputDir, project, format) {
|
|
1497
|
+
const parts = [];
|
|
1498
|
+
if (format === "md") parts.push(`# ${project.bookName}\n`);
|
|
1499
|
+
else parts.push(project.bookName, "");
|
|
1500
|
+
const done = project.chapters.filter((c) => c.file !== void 0);
|
|
1501
|
+
for (const chapter of done) {
|
|
1502
|
+
const body = readChapterFile(outputDir, chapter) ?? "";
|
|
1503
|
+
if (format === "md") parts.push(`\n## 第${chapter.no}章 ${chapter.title}\n`, body.trim(), "");
|
|
1504
|
+
else parts.push("", `第${chapter.no}章 ${chapter.title}`, "", body.trim(), "");
|
|
1505
|
+
}
|
|
1506
|
+
const content = parts.join("\n");
|
|
1507
|
+
const ext = format === "md" ? "md" : "txt";
|
|
1508
|
+
const file = `《${safeFileName(project.bookName)}》全本.${ext}`;
|
|
1509
|
+
writeFileSync(join(outputDir, file), content, "utf8");
|
|
1510
|
+
return {
|
|
1511
|
+
file,
|
|
1512
|
+
chars: content.length,
|
|
1513
|
+
chapters: done.length
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
//#endregion
|
|
1517
|
+
//#region src/assistant.ts
|
|
1518
|
+
/**
|
|
1519
|
+
* AI assistant engine — a conversational editor over the novel project.
|
|
1520
|
+
*
|
|
1521
|
+
* The user talks to the assistant about plot, characters, settings; the
|
|
1522
|
+
* assistant can reply in prose AND emit action directives that the host
|
|
1523
|
+
* executes (rewrite a paragraph, edit the bible, regenerate a chapter,
|
|
1524
|
+
* export the book, ...). Conversation history persists next to the project
|
|
1525
|
+
* as NDJSON, so a reload keeps the thread.
|
|
1526
|
+
*
|
|
1527
|
+
* Action protocol: the model emits a line of the form
|
|
1528
|
+
* <dsh-action name="toolName">{jsonArgs}</dsh-action>
|
|
1529
|
+
* anywhere in its reply. The host strips it, executes the tool, appends the
|
|
1530
|
+
* result as a tool-role message, and continues the loop (bounded rounds).
|
|
1531
|
+
*/
|
|
1532
|
+
/** History file name inside the output dir. */
|
|
1533
|
+
const ASSISTANT_HISTORY_FILE = "novel-assistant.jsonl";
|
|
1534
|
+
/** Max tool-call rounds per user turn (safety bound). */
|
|
1535
|
+
const MAX_TOOL_ROUNDS = 6;
|
|
1536
|
+
/** Load the persisted conversation (empty when none). */
|
|
1537
|
+
function loadAssistantHistory(outputDir) {
|
|
1538
|
+
const file = join(outputDir, ASSISTANT_HISTORY_FILE);
|
|
1539
|
+
if (!existsSync(file)) return [];
|
|
1540
|
+
const messages = [];
|
|
1541
|
+
try {
|
|
1542
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
1543
|
+
if (line.trim() === "") continue;
|
|
1544
|
+
try {
|
|
1545
|
+
const parsed = JSON.parse(line);
|
|
1546
|
+
if (typeof parsed.role === "string" && typeof parsed.content === "string") messages.push(parsed);
|
|
1547
|
+
} catch {}
|
|
1548
|
+
}
|
|
1549
|
+
} catch {}
|
|
1550
|
+
return messages;
|
|
1551
|
+
}
|
|
1552
|
+
/** Append one message to the persisted history. */
|
|
1553
|
+
function appendHistory(outputDir, message) {
|
|
1554
|
+
mkdirSync(outputDir, { recursive: true });
|
|
1555
|
+
appendFileSync(join(outputDir, ASSISTANT_HISTORY_FILE), JSON.stringify(message) + "\n", "utf8");
|
|
1556
|
+
}
|
|
1557
|
+
/** Render the project snapshot the assistant sees. */
|
|
1558
|
+
function renderProjectSnapshot(project) {
|
|
1559
|
+
const sections = [];
|
|
1560
|
+
sections.push(`书名:${project.bookName}`);
|
|
1561
|
+
if (project.bible !== void 0) {
|
|
1562
|
+
const bible = project.bible;
|
|
1563
|
+
sections.push("【设定圣经】");
|
|
1564
|
+
if (bible.genre !== "") sections.push(`题材基调:${bible.genre}`);
|
|
1565
|
+
if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
|
|
1566
|
+
if (bible.characters.length > 0) {
|
|
1567
|
+
sections.push("角色卡:");
|
|
1568
|
+
for (const card of bible.characters) {
|
|
1569
|
+
const roleName = {
|
|
1570
|
+
protagonist: "主角",
|
|
1571
|
+
supporting: "配角",
|
|
1572
|
+
antagonist: "反派",
|
|
1573
|
+
other: "其他"
|
|
1574
|
+
}[card.role];
|
|
1575
|
+
sections.push(`- ${card.name}(${roleName}):${card.traits.join("、")}${card.goals !== "" ? `;目标:${card.goals}` : ""}`);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
if (bible.redLines.length > 0) sections.push("写作红线:\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
|
|
1579
|
+
}
|
|
1580
|
+
if (project.volumes !== void 0 && project.volumes.length > 0) {
|
|
1581
|
+
sections.push("【卷结构】");
|
|
1582
|
+
for (const v of project.volumes) sections.push(`第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})`);
|
|
1583
|
+
}
|
|
1584
|
+
if (project.chapters.length > 0) {
|
|
1585
|
+
sections.push("【章节计划与进度】");
|
|
1586
|
+
for (const c of project.chapters) {
|
|
1587
|
+
const statusText = {
|
|
1588
|
+
pending: "待生成",
|
|
1589
|
+
generating: "生成中",
|
|
1590
|
+
written: "待审稿",
|
|
1591
|
+
reviewing: "审稿中",
|
|
1592
|
+
approved: "已通过",
|
|
1593
|
+
rejected: "待修订",
|
|
1594
|
+
error: "失败"
|
|
1595
|
+
}[c.status];
|
|
1596
|
+
sections.push(`第${c.no}章《${c.title}》[${statusText}]${c.chars !== void 0 ? ` ${c.chars}字` : ""}${c.summary !== void 0 && c.summary !== "" ? ` 摘要:${c.summary}` : ""}`);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
if (project.foreshadows.length > 0) {
|
|
1600
|
+
sections.push("【伏笔】");
|
|
1601
|
+
for (const f of project.foreshadows) sections.push(`- [${f.status}] ${f.description}${f.targetChapter !== void 0 ? `(预计 ${f.targetChapter} 章回收)` : ""}`);
|
|
1602
|
+
}
|
|
1603
|
+
return sections.join("\n");
|
|
1604
|
+
}
|
|
1605
|
+
/** The assistant system prompt. */
|
|
1606
|
+
function assistantSystemPrompt(project) {
|
|
1607
|
+
return [
|
|
1608
|
+
"你是这部小说的 AI 编辑助理,负责陪作者讨论剧情、人设、世界观,并把讨论结果落实到项目里。",
|
|
1609
|
+
"==================== 当前项目快照 ====================",
|
|
1610
|
+
renderProjectSnapshot(project),
|
|
1611
|
+
"==================== 快照结束 ====================",
|
|
1612
|
+
"",
|
|
1613
|
+
"你可以:",
|
|
1614
|
+
"1. 与作者讨论剧情走向、人物动机、爽点节奏、伏笔安排等,给出专业建议(直接文字回答)。",
|
|
1615
|
+
"2. 讨论达成一致后,用动作指令实际修改内容。动作指令格式(放在回复末尾单独一行):",
|
|
1616
|
+
" <dsh-action name=\"工具名\">{\"参数名\": 值}</dsh-action>",
|
|
1617
|
+
"",
|
|
1618
|
+
"可用工具:",
|
|
1619
|
+
"- outline_text:无参数。返回当前大纲全文。",
|
|
1620
|
+
"- outline_replace:{\"old\": \"要替换的原文片段\", \"new\": \"新文本\"}。在大纲中替换一段文字(old 必须能在大纲中找到)。",
|
|
1621
|
+
"- bible_set_rule:{\"index\": 序号(0起), \"text\": \"新规则文本\"} 或 {\"append\": \"追加的规则\"}。修改设定圣经的世界规则。",
|
|
1622
|
+
"- bible_set_redline:同上,修改写作红线。",
|
|
1623
|
+
"- chapter_text:{\"no\": 章节号}。返回该章正文。",
|
|
1624
|
+
"- chapter_rewrite:{\"no\": 章节号, \"instructions\": \"修改要求\", \"target\": \"原文片段(可选,留空整章)\"}。按讨论结果修订章节;给了 target 只改该自然段。",
|
|
1625
|
+
"- chapter_generate:{\"no\": 章节号}。重新生成该章。",
|
|
1626
|
+
"- chapter_review:{\"no\": 章节号}。对该章执行 AI 审稿。",
|
|
1627
|
+
"- foreshadow_add:{\"description\": \"伏笔描述\", \"targetChapter\": 预计回收章(可选)}。新增伏笔。",
|
|
1628
|
+
"- foreshadow_update:{\"id\": \"伏笔id\", \"status\": \"planned|planted|progressing|resolved|abandoned\"}。更新伏笔状态。",
|
|
1629
|
+
"- export_txt:无参数。导出全本 TXT。",
|
|
1630
|
+
"- assets_status:无参数。查看本书当前写作资产(题材/推进模式/反AI规则/写法)。",
|
|
1631
|
+
"- assets_set_genre:{\"name\": \"题材名\", \"description\": \"题材说明(可选)\"}。设置本书题材基底。",
|
|
1632
|
+
"- assets_set_progression:{\"name\": \"模式名\", \"driver\": \"驱动力\", \"primary\": true/false}。设置主/辅助推进模式。",
|
|
1633
|
+
"- assets_add_rule:{\"name\": \"规则名(可选)\", \"avoid\": \"要避免的表达问题\", \"fix\": \"修正方向(可选)\"}。新增反 AI 规则。",
|
|
1634
|
+
"",
|
|
1635
|
+
"使用规则(非常重要):",
|
|
1636
|
+
"- 当你想执行任何工具时,你的【整个回复】必须只包含动作指令标签,格式如下(不要有任何解释文字、不要用自然语言说\"我要去改\",直接输出标签):",
|
|
1637
|
+
" 正确示例:<dsh-action name=\"outline_replace\">{\"old\":\"要替换的原文\",\"new\":\"新文本\"}</dsh-action>",
|
|
1638
|
+
" 正确示例:<dsh-action name=\"chapter_text\">{\"no\":1}</dsh-action>",
|
|
1639
|
+
" 错误示例(绝对不要这样回复):\"好的,我先看一下大纲,马上改。\" ← 这只是文字,不会执行任何操作",
|
|
1640
|
+
"- 工具调用是自动的:你输出标签后,宿主会执行并把结果反馈给你,你再基于结果继续。",
|
|
1641
|
+
"- 每次回复最多调用 1 个动作;执行结果会反馈给你,你可以继续讨论或再调用。",
|
|
1642
|
+
"- 需要先看大纲/章节再决定怎么改?那就先输出一个 outline_text / chapter_text 的标签,等结果回来。",
|
|
1643
|
+
"- chapter_rewrite 的 target 参数:从章节正文中复制一小段(一句话或几句话即可),不要带换行、不要带引号,取连续文本片段。",
|
|
1644
|
+
"- 如果工具执行失败(例如片段未找到),根据错误信息修正参数后自动重试一次,不要直接放弃或让作者手动操作。",
|
|
1645
|
+
"- 修改前先向作者说明你要改什么、为什么;动作执行后简要汇报结果。",
|
|
1646
|
+
"- 涉及删除类操作(删除章节、清空设定)必须等作者明确同意。",
|
|
1647
|
+
"- 严格忠于设定圣经与大刚;不得自行发明与既有设定冲突的内容。",
|
|
1648
|
+
"- 用中文回复。"
|
|
1649
|
+
].join("\n");
|
|
1650
|
+
}
|
|
1651
|
+
/** Execute one action directive. Returns a text result (or throws). */
|
|
1652
|
+
/**
|
|
1653
|
+
* Execute one action directive as an async generator: yields live progress
|
|
1654
|
+
* text (chapter text being generated/rewritten), then yields the final result
|
|
1655
|
+
* string. Throws on failure.
|
|
1656
|
+
*/
|
|
1657
|
+
async function* executeAction(ctx, config, project, outputDir, name, args) {
|
|
1658
|
+
const str = (value) => typeof value === "string" ? value : "";
|
|
1659
|
+
const num = (value) => typeof value === "number" ? value : void 0;
|
|
1660
|
+
/** Forward live text deltas from a streaming chapter job (text only). */
|
|
1661
|
+
const forward = async function* (stream) {
|
|
1662
|
+
for await (const step of stream) if (step.frame === "delta") yield step.text;
|
|
1663
|
+
};
|
|
1664
|
+
switch (name) {
|
|
1665
|
+
case "outline_text": return project.outline;
|
|
1666
|
+
case "outline_replace": {
|
|
1667
|
+
const old = str(args.old);
|
|
1668
|
+
const next = str(args.new);
|
|
1669
|
+
if (old === "" || !project.outline.includes(old)) throw new Error(`大纲中未找到片段「${old.slice(0, 40)}…」`);
|
|
1670
|
+
project.outline = project.outline.replace(old, next);
|
|
1671
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1672
|
+
saveProject(outputDir, project);
|
|
1673
|
+
return `大纲已修改:替换了 ${old.length} 字符的片段。`;
|
|
1674
|
+
}
|
|
1675
|
+
case "bible_set_rule": {
|
|
1676
|
+
if (project.bible === void 0) throw new Error("尚无设定圣经,请先提炼");
|
|
1677
|
+
const index = num(args.index);
|
|
1678
|
+
if (index !== void 0) project.bible.worldRules[index] = str(args.text);
|
|
1679
|
+
else if (str(args.append) !== "") project.bible.worldRules.push(str(args.append));
|
|
1680
|
+
else throw new Error("bible_set_rule 需要 index+text 或 append");
|
|
1681
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1682
|
+
saveProject(outputDir, project);
|
|
1683
|
+
return `世界规则已更新(当前 ${project.bible.worldRules.length} 条)。`;
|
|
1684
|
+
}
|
|
1685
|
+
case "bible_set_redline": {
|
|
1686
|
+
if (project.bible === void 0) throw new Error("尚无设定圣经,请先提炼");
|
|
1687
|
+
const index = num(args.index);
|
|
1688
|
+
if (index !== void 0) project.bible.redLines[index] = str(args.text);
|
|
1689
|
+
else if (str(args.append) !== "") project.bible.redLines.push(str(args.append));
|
|
1690
|
+
else throw new Error("bible_set_redline 需要 index+text 或 append");
|
|
1691
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1692
|
+
saveProject(outputDir, project);
|
|
1693
|
+
return `写作红线已更新(当前 ${project.bible.redLines.length} 条)。`;
|
|
1694
|
+
}
|
|
1695
|
+
case "chapter_text": {
|
|
1696
|
+
const no = num(args.no);
|
|
1697
|
+
if (no === void 0) throw new Error("chapter_text 需要 no");
|
|
1698
|
+
const chapter = project.chapters.find((c) => c.no === no);
|
|
1699
|
+
if (chapter === void 0) throw new Error(`章节 ${no} 不存在`);
|
|
1700
|
+
const body = readChapterFile(outputDir, chapter);
|
|
1701
|
+
if (body === void 0) throw new Error(`章节 ${no} 尚未生成`);
|
|
1702
|
+
return body;
|
|
1703
|
+
}
|
|
1704
|
+
case "chapter_rewrite": {
|
|
1705
|
+
const no = num(args.no);
|
|
1706
|
+
if (no === void 0) throw new Error("chapter_rewrite 需要 no");
|
|
1707
|
+
const instructions = str(args.instructions);
|
|
1708
|
+
const target = str(args.target);
|
|
1709
|
+
for await (const chunk of forward(rewriteChapterStream(ctx, config, project, outputDir, no, instructions, target === "" ? void 0 : target))) yield chunk;
|
|
1710
|
+
yield "(正在生成章节摘要…)";
|
|
1711
|
+
try {
|
|
1712
|
+
await summarizeChapter(ctx, config, project, outputDir, no);
|
|
1713
|
+
} catch {}
|
|
1714
|
+
yield "(正在 AI 审稿…)";
|
|
1715
|
+
const report = await reviewChapter(ctx, config, project, outputDir, no);
|
|
1716
|
+
return `章节 ${no} 已${target === "" ? "整章" : "局部"}修订完成(${project.chapters.find((c) => c.no === no)?.chars ?? "?"} 字)。重新审稿:${report.score} 分 — ${report.verdict}`;
|
|
1717
|
+
}
|
|
1718
|
+
case "chapter_generate": {
|
|
1719
|
+
const no = num(args.no);
|
|
1720
|
+
if (no === void 0) throw new Error("chapter_generate 需要 no");
|
|
1721
|
+
for await (const chunk of forward(generateChapterStream(ctx, config, project, outputDir, no))) yield chunk;
|
|
1722
|
+
yield "(正在生成章节摘要…)";
|
|
1723
|
+
try {
|
|
1724
|
+
await summarizeChapter(ctx, config, project, outputDir, no);
|
|
1725
|
+
} catch {}
|
|
1726
|
+
yield "(正在 AI 审稿…)";
|
|
1727
|
+
const report = await reviewChapter(ctx, config, project, outputDir, no);
|
|
1728
|
+
return `章节 ${no} 已生成(${project.chapters.find((c) => c.no === no)?.chars ?? "?"} 字)。审稿:${report.score} 分 — ${report.verdict}`;
|
|
1729
|
+
}
|
|
1730
|
+
case "chapter_review": {
|
|
1731
|
+
const no = num(args.no);
|
|
1732
|
+
if (no === void 0) throw new Error("chapter_review 需要 no");
|
|
1733
|
+
const report = await reviewChapter(ctx, config, project, outputDir, no);
|
|
1734
|
+
const issues = report.issues.map((i) => `[${i.severity}] ${i.item} → ${i.suggestion}`).join("\n");
|
|
1735
|
+
return `章节 ${no} 审稿:${report.score} 分 — ${report.verdict}\n${issues}`;
|
|
1736
|
+
}
|
|
1737
|
+
case "foreshadow_add": {
|
|
1738
|
+
const description = str(args.description);
|
|
1739
|
+
if (description === "") throw new Error("foreshadow_add 需要 description");
|
|
1740
|
+
const targetChapter = num(args.targetChapter);
|
|
1741
|
+
project.foreshadows.push({
|
|
1742
|
+
id: `fs-${Date.now().toString(36)}`,
|
|
1743
|
+
description,
|
|
1744
|
+
targetChapter,
|
|
1745
|
+
status: "planned"
|
|
1746
|
+
});
|
|
1747
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1748
|
+
saveProject(outputDir, project);
|
|
1749
|
+
return `已新增伏笔:「${description.slice(0, 50)}」`;
|
|
1750
|
+
}
|
|
1751
|
+
case "foreshadow_update": {
|
|
1752
|
+
const id = str(args.id);
|
|
1753
|
+
const status = str(args.status);
|
|
1754
|
+
const target = project.foreshadows.find((f) => f.id === id);
|
|
1755
|
+
if (target === void 0) throw new Error(`伏笔 ${id} 不存在`);
|
|
1756
|
+
if (![
|
|
1757
|
+
"planned",
|
|
1758
|
+
"planted",
|
|
1759
|
+
"progressing",
|
|
1760
|
+
"resolved",
|
|
1761
|
+
"abandoned"
|
|
1762
|
+
].includes(status)) throw new Error(`非法状态 ${status}`);
|
|
1763
|
+
target.status = status;
|
|
1764
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1765
|
+
saveProject(outputDir, project);
|
|
1766
|
+
return `伏笔已更新为 ${status}:「${target.description.slice(0, 50)}」`;
|
|
1767
|
+
}
|
|
1768
|
+
case "export_txt": {
|
|
1769
|
+
const result = exportBook(outputDir, project, "txt");
|
|
1770
|
+
return `已导出 TXT:${result.file}(${result.chars} 字,${result.chapters} 章)`;
|
|
1771
|
+
}
|
|
1772
|
+
case "assets_status": {
|
|
1773
|
+
const assets = project.assets;
|
|
1774
|
+
if (assets === void 0) return "本书尚未配置写作资产。";
|
|
1775
|
+
const parts = [];
|
|
1776
|
+
if (assets.genre !== void 0) parts.push(`题材:${assets.genre.name}`);
|
|
1777
|
+
if (assets.primaryProgression !== void 0) parts.push(`主推进:${assets.primaryProgression.name}`);
|
|
1778
|
+
if (assets.auxiliaryProgressions.length > 0) parts.push(`辅助推进:${assets.auxiliaryProgressions.map((m) => m.name).join("、")}`);
|
|
1779
|
+
if (assets.antiAiRules.length > 0) parts.push(`自定义反AI规则:${assets.antiAiRules.map((r) => r.name).join("、")}`);
|
|
1780
|
+
if (assets.styleAssets.length > 0) parts.push(`写法资产:${assets.styleAssets.map((s) => s.name).join("、")}`);
|
|
1781
|
+
return parts.length > 0 ? parts.join("\n") : "本书尚未配置写作资产。";
|
|
1782
|
+
}
|
|
1783
|
+
case "assets_set_genre": {
|
|
1784
|
+
const name = str(args.name);
|
|
1785
|
+
const description = str(args.description);
|
|
1786
|
+
if (name === "") throw new Error("assets_set_genre 需要 name");
|
|
1787
|
+
if (project.assets === void 0) project.assets = emptyProjectAssets();
|
|
1788
|
+
project.assets.genre = {
|
|
1789
|
+
name,
|
|
1790
|
+
description,
|
|
1791
|
+
children: []
|
|
1792
|
+
};
|
|
1793
|
+
project.assets.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1794
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1795
|
+
saveProject(outputDir, project);
|
|
1796
|
+
return `题材已设为「${name}」`;
|
|
1797
|
+
}
|
|
1798
|
+
case "assets_set_progression": {
|
|
1799
|
+
const name = str(args.name);
|
|
1800
|
+
const driver = str(args.driver);
|
|
1801
|
+
const primary = args.primary !== false;
|
|
1802
|
+
if (name === "") throw new Error("assets_set_progression 需要 name");
|
|
1803
|
+
if (project.assets === void 0) project.assets = emptyProjectAssets();
|
|
1804
|
+
const mode = {
|
|
1805
|
+
name,
|
|
1806
|
+
driver: driver !== "" ? driver : name,
|
|
1807
|
+
readerExpectation: str(args.readerExpectation),
|
|
1808
|
+
payoffs: Array.isArray(args.payoffs) ? args.payoffs.filter((v) => typeof v === "string") : [],
|
|
1809
|
+
risks: Array.isArray(args.risks) ? args.risks.filter((v) => typeof v === "string") : [],
|
|
1810
|
+
primary
|
|
1811
|
+
};
|
|
1812
|
+
if (primary) project.assets.primaryProgression = mode;
|
|
1813
|
+
else {
|
|
1814
|
+
if (project.assets.auxiliaryProgressions === void 0) project.assets.auxiliaryProgressions = [];
|
|
1815
|
+
project.assets.auxiliaryProgressions.push(mode);
|
|
1816
|
+
}
|
|
1817
|
+
project.assets.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1818
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1819
|
+
saveProject(outputDir, project);
|
|
1820
|
+
return `推进模式${primary ? "(主)" : "(辅助)"}已设置:「${name}」`;
|
|
1821
|
+
}
|
|
1822
|
+
case "assets_add_rule": {
|
|
1823
|
+
const name = str(args.name);
|
|
1824
|
+
const avoid = str(args.avoid);
|
|
1825
|
+
if (avoid === "") throw new Error("assets_add_rule 需要 avoid(要避免的表达问题)");
|
|
1826
|
+
if (project.assets === void 0) project.assets = emptyProjectAssets();
|
|
1827
|
+
if (project.assets.antiAiRules === void 0) project.assets.antiAiRules = [];
|
|
1828
|
+
project.assets.antiAiRules.push({
|
|
1829
|
+
name: name !== "" ? name : `自定义规则 ${project.assets.antiAiRules.length + 1}`,
|
|
1830
|
+
avoid,
|
|
1831
|
+
fix: str(args.fix)
|
|
1832
|
+
});
|
|
1833
|
+
project.assets.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1834
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1835
|
+
saveProject(outputDir, project);
|
|
1836
|
+
return `已新增反 AI 规则「${name !== "" ? name : avoid.slice(0, 20)}」`;
|
|
1837
|
+
}
|
|
1838
|
+
default: throw new Error(`未知工具 ${name}`);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
/** Extract the first action directive from a reply. */
|
|
1842
|
+
function extractAction(reply) {
|
|
1843
|
+
const match = /<dsh-action\s+name="([^"]+)"\s*>([\s\S]*?)<\/dsh-action>/.exec(reply);
|
|
1844
|
+
if (match === null) return void 0;
|
|
1845
|
+
const rawArgs = match[2]?.trim() ?? "";
|
|
1846
|
+
let args;
|
|
1847
|
+
try {
|
|
1848
|
+
args = rawArgs === "" ? {} : JSON.parse(rawArgs);
|
|
1849
|
+
} catch {
|
|
1850
|
+
throw new Error(`动作参数不是合法 JSON:${rawArgs.slice(0, 80)}`);
|
|
1851
|
+
}
|
|
1852
|
+
return {
|
|
1853
|
+
name: match[1] ?? "",
|
|
1854
|
+
args,
|
|
1855
|
+
index: match.index
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
/** Render the recent history as LLM messages (skipping tool chatter in early rounds). */
|
|
1859
|
+
function historyToMessages(history) {
|
|
1860
|
+
const recent = history.slice(-24);
|
|
1861
|
+
const messages = [];
|
|
1862
|
+
for (const entry of recent) if (entry.role === "user") messages.push(createUserMessage({
|
|
1863
|
+
content: [{
|
|
1864
|
+
type: "text",
|
|
1865
|
+
text: entry.content
|
|
1866
|
+
}],
|
|
1867
|
+
source: {
|
|
1868
|
+
kind: "plugin",
|
|
1869
|
+
plugin: "dsh-novel-forge"
|
|
1870
|
+
}
|
|
1871
|
+
}));
|
|
1872
|
+
else if (entry.role === "assistant") messages.push(createAssistantMessage({
|
|
1873
|
+
content: [{
|
|
1874
|
+
type: "text",
|
|
1875
|
+
text: entry.content
|
|
1876
|
+
}],
|
|
1877
|
+
source: {
|
|
1878
|
+
provider: "deepseek-official",
|
|
1879
|
+
model: "deepseek-v4-flash"
|
|
1880
|
+
}
|
|
1881
|
+
}));
|
|
1882
|
+
else if (entry.role === "tool") messages.push(createUserMessage({
|
|
1883
|
+
content: [{
|
|
1884
|
+
type: "text",
|
|
1885
|
+
text: `【工具 ${entry.tool ?? ""} 的执行结果】\n${entry.content}`
|
|
1886
|
+
}],
|
|
1887
|
+
source: {
|
|
1888
|
+
kind: "plugin",
|
|
1889
|
+
plugin: "dsh-novel-forge"
|
|
1890
|
+
}
|
|
1891
|
+
}));
|
|
1892
|
+
return messages;
|
|
1893
|
+
}
|
|
1894
|
+
/** One non-streaming LLM chat turn (used inside the tool loop). */
|
|
1895
|
+
async function chatOnce(ctx, config, system, history) {
|
|
1896
|
+
const messages = historyToMessages(history);
|
|
1897
|
+
const request = {
|
|
1898
|
+
provider: config.provider,
|
|
1899
|
+
model: config.model,
|
|
1900
|
+
messages,
|
|
1901
|
+
system,
|
|
1902
|
+
maxTokens: config.maxTokens,
|
|
1903
|
+
temperature: .7
|
|
1904
|
+
};
|
|
1905
|
+
const assembler = new BlockAssembler();
|
|
1906
|
+
for await (const chunk of ctx.llm.stream(request)) assembler.push(chunk);
|
|
1907
|
+
const finish = assembler.finish;
|
|
1908
|
+
if (finish.kind === "error" || finish.kind === "aborted") throw new Error(`助手调用失败(${finish.kind}): ${finish.failure.message}`);
|
|
1909
|
+
const blocks = assembler.blocks();
|
|
1910
|
+
let text = blocks.filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
|
|
1911
|
+
if (text === "") {
|
|
1912
|
+
const reasoning = blocks.filter((block) => block.type === "reasoning").map((block) => block.text).join("\n").trim();
|
|
1913
|
+
if (reasoning !== "") text = reasoning;
|
|
1914
|
+
}
|
|
1915
|
+
return text;
|
|
1916
|
+
}
|
|
1917
|
+
/** Run one user turn. Yields stream frames; persists history. */
|
|
1918
|
+
async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
|
|
1919
|
+
const history = loadAssistantHistory(outputDir);
|
|
1920
|
+
const system = assistantSystemPrompt(project);
|
|
1921
|
+
const userEntry = {
|
|
1922
|
+
role: "user",
|
|
1923
|
+
content: userMessage,
|
|
1924
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
1925
|
+
};
|
|
1926
|
+
history.push(userEntry);
|
|
1927
|
+
appendHistory(outputDir, userEntry);
|
|
1928
|
+
let round = 0;
|
|
1929
|
+
/** Whether we already nudged the model to emit an action tag (avoid loops). */
|
|
1930
|
+
let nudged = false;
|
|
1931
|
+
for (;;) {
|
|
1932
|
+
const reply = await chatOnce(ctx, config, system, history);
|
|
1933
|
+
const action = extractAction(reply);
|
|
1934
|
+
if (action === void 0) {
|
|
1935
|
+
if (/(改|修改|修订|重写|替换|调整|生成|新增|删除|导出|看看|查看|调出|读一下|加上|加一个|去掉|删掉|把.+改成)/.test(reply) && !nudged) {
|
|
1936
|
+
nudged = true;
|
|
1937
|
+
const nudge = "你的上一条回复表达了想操作项目的意图(如查看/修改大纲、章节等),但没有输出动作指令标签,因此没有执行任何操作。请直接输出 <dsh-action name=\"工具名\">{\"参数\":值}</dsh-action> 标签来执行,不要用文字描述意图。如果需要先看内容,先输出 outline_text 或 chapter_text 标签。";
|
|
1938
|
+
history.push({
|
|
1939
|
+
role: "tool",
|
|
1940
|
+
content: nudge,
|
|
1941
|
+
tool: "format-hint",
|
|
1942
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
1943
|
+
});
|
|
1944
|
+
appendHistory(outputDir, {
|
|
1945
|
+
role: "tool",
|
|
1946
|
+
content: nudge,
|
|
1947
|
+
tool: "format-hint",
|
|
1948
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
1949
|
+
});
|
|
1950
|
+
continue;
|
|
1951
|
+
}
|
|
1952
|
+
const assistantEntry = {
|
|
1953
|
+
role: "assistant",
|
|
1954
|
+
content: reply,
|
|
1955
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
1956
|
+
};
|
|
1957
|
+
history.push(assistantEntry);
|
|
1958
|
+
appendHistory(outputDir, assistantEntry);
|
|
1959
|
+
yield {
|
|
1960
|
+
frame: "delta",
|
|
1961
|
+
text: reply
|
|
1962
|
+
};
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
const { name, args, index } = action;
|
|
1966
|
+
const prose = reply.slice(0, index).trim();
|
|
1967
|
+
yield {
|
|
1968
|
+
frame: "tool",
|
|
1969
|
+
name,
|
|
1970
|
+
status: "start"
|
|
1971
|
+
};
|
|
1972
|
+
let result;
|
|
1973
|
+
try {
|
|
1974
|
+
const iterator = executeAction(ctx, config, project, outputDir, name, args)[Symbol.asyncIterator]();
|
|
1975
|
+
result = "";
|
|
1976
|
+
for (;;) {
|
|
1977
|
+
const step = await iterator.next();
|
|
1978
|
+
if (step.done === true) {
|
|
1979
|
+
result = typeof step.value === "string" ? step.value : "";
|
|
1980
|
+
break;
|
|
1981
|
+
}
|
|
1982
|
+
const chunk = step.value;
|
|
1983
|
+
if (typeof chunk === "string" && chunk !== "") yield {
|
|
1984
|
+
frame: "toolDelta",
|
|
1985
|
+
name,
|
|
1986
|
+
text: chunk
|
|
1987
|
+
};
|
|
1988
|
+
}
|
|
1989
|
+
yield {
|
|
1990
|
+
frame: "tool",
|
|
1991
|
+
name,
|
|
1992
|
+
status: "done",
|
|
1993
|
+
detail: result.slice(0, 200)
|
|
1994
|
+
};
|
|
1995
|
+
} catch (error) {
|
|
1996
|
+
result = `执行失败:${error.message}`;
|
|
1997
|
+
yield {
|
|
1998
|
+
frame: "tool",
|
|
1999
|
+
name,
|
|
2000
|
+
status: "error",
|
|
2001
|
+
detail: error.message
|
|
2002
|
+
};
|
|
2003
|
+
}
|
|
2004
|
+
if (prose !== "") {
|
|
2005
|
+
history.push({
|
|
2006
|
+
role: "assistant",
|
|
2007
|
+
content: prose,
|
|
2008
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
2009
|
+
});
|
|
2010
|
+
appendHistory(outputDir, {
|
|
2011
|
+
role: "assistant",
|
|
2012
|
+
content: prose,
|
|
2013
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
history.push({
|
|
2017
|
+
role: "tool",
|
|
2018
|
+
content: result,
|
|
2019
|
+
tool: name,
|
|
2020
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
2021
|
+
});
|
|
2022
|
+
appendHistory(outputDir, {
|
|
2023
|
+
role: "tool",
|
|
2024
|
+
content: result,
|
|
2025
|
+
tool: name,
|
|
2026
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
2027
|
+
});
|
|
2028
|
+
round++;
|
|
2029
|
+
if (round >= MAX_TOOL_ROUNDS) {
|
|
2030
|
+
const message = `(已连续执行 ${round} 次修改操作,本轮停止。如需继续请再说。)`;
|
|
2031
|
+
history.push({
|
|
2032
|
+
role: "assistant",
|
|
2033
|
+
content: message,
|
|
2034
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
2035
|
+
});
|
|
2036
|
+
appendHistory(outputDir, {
|
|
2037
|
+
role: "assistant",
|
|
2038
|
+
content: message,
|
|
2039
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
2040
|
+
});
|
|
2041
|
+
yield {
|
|
2042
|
+
frame: "delta",
|
|
2043
|
+
text: message
|
|
2044
|
+
};
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
//#endregion
|
|
2050
|
+
//#region src/bookshelf.ts
|
|
2051
|
+
/**
|
|
2052
|
+
* 书架(Bookshelf)— 多书管理:一本书记录一个独立输出目录。
|
|
2053
|
+
* 状态持久化到 ~/.dsh/dsh-novel-forge-bookshelf.json(跟随 dsh 配置惯例)。
|
|
2054
|
+
*/
|
|
2055
|
+
/** 书架配置文件路径。 */
|
|
2056
|
+
function bookshelfFile() {
|
|
2057
|
+
return join(homedir(), ".dsh", "dsh-novel-forge-bookshelf.json");
|
|
2058
|
+
}
|
|
2059
|
+
function defaultStore() {
|
|
2060
|
+
return {
|
|
2061
|
+
books: [],
|
|
2062
|
+
activeBookId: null
|
|
2063
|
+
};
|
|
2064
|
+
}
|
|
2065
|
+
/** 读取书架(无则返回空)。 */
|
|
2066
|
+
function loadBookshelf() {
|
|
2067
|
+
const file = bookshelfFile();
|
|
2068
|
+
if (!existsSync(file)) return defaultStore();
|
|
2069
|
+
try {
|
|
2070
|
+
let raw = readFileSync(file, "utf8");
|
|
2071
|
+
if (raw.charCodeAt(0) === 65279) raw = raw.slice(1);
|
|
2072
|
+
const parsed = JSON.parse(raw);
|
|
2073
|
+
if (!Array.isArray(parsed.books)) return defaultStore();
|
|
2074
|
+
return {
|
|
2075
|
+
books: parsed.books,
|
|
2076
|
+
activeBookId: parsed.activeBookId ?? null
|
|
2077
|
+
};
|
|
2078
|
+
} catch {
|
|
2079
|
+
return defaultStore();
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
/** 持久化书架。 */
|
|
2083
|
+
function saveBookshelf(store) {
|
|
2084
|
+
const file = bookshelfFile();
|
|
2085
|
+
mkdirSync(join(homedir(), ".dsh"), { recursive: true });
|
|
2086
|
+
writeFileSync(file, JSON.stringify(store, null, 2), "utf8");
|
|
2087
|
+
}
|
|
2088
|
+
/** 当前激活的书。 */
|
|
2089
|
+
function activeBook(store) {
|
|
2090
|
+
return store.books.find((b) => b.id === store.activeBookId);
|
|
2091
|
+
}
|
|
2092
|
+
/** 书架快照(含每本书的进度摘要)。 */
|
|
2093
|
+
function bookshelfSnapshot(store) {
|
|
2094
|
+
return {
|
|
2095
|
+
books: store.books.map((book) => {
|
|
2096
|
+
const project = loadProject(book.outputDir);
|
|
2097
|
+
const done = project === void 0 ? 0 : project.chapters.filter((c) => c.status === "approved" || c.status === "written" || c.status === "rejected").length;
|
|
2098
|
+
return {
|
|
2099
|
+
...book,
|
|
2100
|
+
done,
|
|
2101
|
+
total: project?.chapters.length ?? 0,
|
|
2102
|
+
hasProject: project !== void 0
|
|
2103
|
+
};
|
|
2104
|
+
}),
|
|
2105
|
+
activeBookId: store.activeBookId
|
|
2106
|
+
};
|
|
2107
|
+
}
|
|
2108
|
+
/** 新建一本书(自动成为当前书)。 */
|
|
2109
|
+
function createBook(bookName, outputDir) {
|
|
2110
|
+
const store = loadBookshelf();
|
|
2111
|
+
const id = `book-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
|
|
2112
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2113
|
+
const book = {
|
|
2114
|
+
id,
|
|
2115
|
+
bookName,
|
|
2116
|
+
outputDir,
|
|
2117
|
+
createdAt: now,
|
|
2118
|
+
updatedAt: now
|
|
2119
|
+
};
|
|
2120
|
+
store.books.push(book);
|
|
2121
|
+
store.activeBookId = id;
|
|
2122
|
+
saveBookshelf(store);
|
|
2123
|
+
return book;
|
|
2124
|
+
}
|
|
2125
|
+
/**
|
|
2126
|
+
* 播种:书架为空时,把指定输出目录下已有的项目自动登记为第一本书。
|
|
2127
|
+
* 兼容升级场景 —— 旧版插件直接在输出目录写项目,从未登记书架。
|
|
2128
|
+
* @param outputDir - 候选输出目录(通常为 settings 的默认输出目录)。
|
|
2129
|
+
* @returns 是否发生了播种。
|
|
2130
|
+
*/
|
|
2131
|
+
function seedBookshelfFromOutputDir(outputDir) {
|
|
2132
|
+
if (loadBookshelf().books.length > 0) return false;
|
|
2133
|
+
if (!existsSync(outputDir)) return false;
|
|
2134
|
+
const hasProject = existsSync(join(outputDir, "novel-project.json"));
|
|
2135
|
+
const hasChapters = existsSync(outputDir);
|
|
2136
|
+
if (!hasProject && !hasChapters) return false;
|
|
2137
|
+
createBook(loadProject(outputDir)?.bookName ?? outputDir.split(/[\\/]/).pop() ?? "未命名小说", outputDir);
|
|
2138
|
+
return true;
|
|
2139
|
+
}
|
|
2140
|
+
/** 激活一本书。 */
|
|
2141
|
+
function activateBook(id) {
|
|
2142
|
+
const store = loadBookshelf();
|
|
2143
|
+
const book = store.books.find((b) => b.id === id);
|
|
2144
|
+
if (book === void 0) return void 0;
|
|
2145
|
+
store.activeBookId = id;
|
|
2146
|
+
book.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2147
|
+
saveBookshelf(store);
|
|
2148
|
+
return book;
|
|
2149
|
+
}
|
|
2150
|
+
/** 移除一本书。 */
|
|
2151
|
+
function removeBook(id) {
|
|
2152
|
+
const store = loadBookshelf();
|
|
2153
|
+
const idx = store.books.findIndex((b) => b.id === id);
|
|
2154
|
+
if (idx === -1) return false;
|
|
2155
|
+
store.books.splice(idx, 1);
|
|
2156
|
+
if (store.activeBookId === id) store.activeBookId = store.books[0]?.id ?? null;
|
|
2157
|
+
saveBookshelf(store);
|
|
2158
|
+
return true;
|
|
2159
|
+
}
|
|
2160
|
+
/** 当前书输出目录(无书架则 undefined,回退 settings)。 */
|
|
2161
|
+
function activeBookOutputDir() {
|
|
2162
|
+
return activeBook(loadBookshelf())?.outputDir;
|
|
2163
|
+
}
|
|
2164
|
+
/** 默认输出目录推断:桌面/书名。 */
|
|
2165
|
+
function defaultOutputDirFor(bookName) {
|
|
2166
|
+
const clean = bookName.replace(/[\\/:*?"<>|]/g, "").trim().slice(0, 40) || "未命名小说";
|
|
2167
|
+
return join(homedir(), "Desktop", clean);
|
|
2168
|
+
}
|
|
2169
|
+
//#endregion
|
|
2170
|
+
//#region src/routes.ts
|
|
2171
|
+
/** Cap on JSON request bodies. */
|
|
2172
|
+
const MAX_JSON_BODY_BYTES = 4 * 1024 * 1024;
|
|
2173
|
+
/** Loopback-only fence (mirrors the family plugins' pairing routes). */
|
|
2174
|
+
function isLoopbackRequest(request) {
|
|
2175
|
+
const address = request.socket.remoteAddress;
|
|
2176
|
+
if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
|
|
2177
|
+
const host = request.headers.host;
|
|
2178
|
+
if (typeof host !== "string") return false;
|
|
2179
|
+
let hostUrl;
|
|
2180
|
+
try {
|
|
2181
|
+
hostUrl = new URL(`http://${host}`);
|
|
2182
|
+
} catch {
|
|
2183
|
+
return false;
|
|
2184
|
+
}
|
|
2185
|
+
if (hostUrl.hostname !== "127.0.0.1" && hostUrl.hostname !== "localhost" && hostUrl.hostname !== "[::1]") return false;
|
|
2186
|
+
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
2187
|
+
const origin = request.headers.origin;
|
|
2188
|
+
if (origin === void 0) return true;
|
|
2189
|
+
try {
|
|
2190
|
+
return new URL(origin).host === hostUrl.host;
|
|
2191
|
+
} catch {
|
|
2192
|
+
return false;
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
/** One JSON response. */
|
|
2196
|
+
function writeJson(res, status, body) {
|
|
2197
|
+
const payload = JSON.stringify(body);
|
|
2198
|
+
res.writeHead(status, {
|
|
2199
|
+
"content-type": "application/json; charset=utf-8",
|
|
2200
|
+
"referrer-policy": "no-referrer"
|
|
2201
|
+
});
|
|
2202
|
+
res.end(payload);
|
|
2203
|
+
}
|
|
2204
|
+
/** Read a JSON request body. */
|
|
2205
|
+
async function readJsonBody(req) {
|
|
2206
|
+
const chunks = [];
|
|
2207
|
+
let size = 0;
|
|
2208
|
+
for await (const chunk of req) {
|
|
2209
|
+
const buffer = chunk;
|
|
2210
|
+
size += buffer.length;
|
|
2211
|
+
if (size > MAX_JSON_BODY_BYTES) return void 0;
|
|
2212
|
+
chunks.push(buffer);
|
|
2213
|
+
}
|
|
2214
|
+
try {
|
|
2215
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
2216
|
+
} catch {
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
/** Default chapter count for planning when the request omits it. */
|
|
2221
|
+
const DEFAULT_PLAN_COUNT = 30;
|
|
2222
|
+
/**
|
|
2223
|
+
* Build every /api/dsh-novel-forge route.
|
|
2224
|
+
* @param deps - context, config resolver, config patcher.
|
|
2225
|
+
* @returns the route list.
|
|
2226
|
+
*/
|
|
2227
|
+
function makeRoutes(deps) {
|
|
2228
|
+
const { ctx, getConfig, patchConfig } = deps;
|
|
2229
|
+
/** Guard helper: fence + method check. */
|
|
2230
|
+
const guard = (req, res, method) => {
|
|
2231
|
+
if (!isLoopbackRequest(req)) {
|
|
2232
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
2233
|
+
return false;
|
|
2234
|
+
}
|
|
2235
|
+
if (req.method !== method) {
|
|
2236
|
+
writeJson(res, 405, { error: `method not allowed (expected ${method})` });
|
|
2237
|
+
return false;
|
|
2238
|
+
}
|
|
2239
|
+
return true;
|
|
2240
|
+
};
|
|
2241
|
+
/** Load (and sync) the project, or respond 400. */
|
|
2242
|
+
const requireProject = (res) => {
|
|
2243
|
+
const config = getConfig();
|
|
2244
|
+
const project = loadProject(config.outputDir);
|
|
2245
|
+
if (project === void 0) {
|
|
2246
|
+
writeJson(res, 400, { error: "输出目录中没有项目,请先加载大纲" });
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
syncProjectWithDisk(project, config.outputDir);
|
|
2250
|
+
saveProject(config.outputDir, project);
|
|
2251
|
+
return project;
|
|
2252
|
+
};
|
|
2253
|
+
const statusRoute = {
|
|
2254
|
+
kind: "exact",
|
|
2255
|
+
path: NOVEL_API.status,
|
|
2256
|
+
handler: (req, res) => {
|
|
2257
|
+
if (!guard(req, res, "GET")) return;
|
|
2258
|
+
const config = getConfig();
|
|
2259
|
+
seedBookshelfFromOutputDir(config.outputDir);
|
|
2260
|
+
const project = loadProject(config.outputDir);
|
|
2261
|
+
if (project !== void 0) {
|
|
2262
|
+
syncProjectWithDisk(project, config.outputDir);
|
|
2263
|
+
saveProject(config.outputDir, project);
|
|
2264
|
+
}
|
|
2265
|
+
writeJson(res, 200, {
|
|
2266
|
+
config,
|
|
2267
|
+
project: project ?? void 0,
|
|
2268
|
+
generatedFiles: listChapterFiles(config.outputDir)
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
};
|
|
2272
|
+
const loadOutlineRoute = {
|
|
2273
|
+
kind: "exact",
|
|
2274
|
+
path: NOVEL_API.loadOutline,
|
|
2275
|
+
handler: async (req, res) => {
|
|
2276
|
+
if (!guard(req, res, "POST")) return;
|
|
2277
|
+
const body = await readJsonBody(req);
|
|
2278
|
+
const config = getConfig();
|
|
2279
|
+
try {
|
|
2280
|
+
let outline;
|
|
2281
|
+
let path;
|
|
2282
|
+
if (body?.text !== void 0 && body.text.trim() !== "") outline = body.text.trim();
|
|
2283
|
+
else {
|
|
2284
|
+
const target = body?.path?.trim() !== "" && body?.path !== void 0 ? body.path : config.outlinePath;
|
|
2285
|
+
outline = readOutlineFromDocx(target);
|
|
2286
|
+
path = target;
|
|
2287
|
+
}
|
|
2288
|
+
if (outline.length < 50) {
|
|
2289
|
+
writeJson(res, 400, { error: "大纲内容过短(<50 字符),请检查文件或直接粘贴大纲文本" });
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
writeJson(res, 200, {
|
|
2293
|
+
outline,
|
|
2294
|
+
bookName: createProject(outline).bookName,
|
|
2295
|
+
chars: outline.length,
|
|
2296
|
+
path
|
|
2297
|
+
});
|
|
2298
|
+
} catch (error) {
|
|
2299
|
+
writeJson(res, 400, { error: error.message });
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
};
|
|
2303
|
+
const saveOutlineRoute = {
|
|
2304
|
+
kind: "exact",
|
|
2305
|
+
path: NOVEL_API.saveOutline,
|
|
2306
|
+
handler: async (req, res) => {
|
|
2307
|
+
if (!guard(req, res, "POST")) return;
|
|
2308
|
+
const body = await readJsonBody(req);
|
|
2309
|
+
const config = getConfig();
|
|
2310
|
+
const outline = body?.text ?? "";
|
|
2311
|
+
if (outline.trim().length < 50) {
|
|
2312
|
+
writeJson(res, 400, { error: "大纲内容过短(<50 字符)" });
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
let project = loadProject(config.outputDir);
|
|
2316
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2317
|
+
if (project === void 0) project = createProject(outline);
|
|
2318
|
+
else {
|
|
2319
|
+
project.outline = outline;
|
|
2320
|
+
project.bookName = createProject(outline).bookName;
|
|
2321
|
+
project.updatedAt = now;
|
|
2322
|
+
}
|
|
2323
|
+
saveProject(config.outputDir, project);
|
|
2324
|
+
writeJson(res, 200, {
|
|
2325
|
+
ok: true,
|
|
2326
|
+
bookName: project.bookName
|
|
2327
|
+
});
|
|
2328
|
+
}
|
|
2329
|
+
};
|
|
2330
|
+
const bibleRoute = {
|
|
2331
|
+
kind: "exact",
|
|
2332
|
+
path: NOVEL_API.bible,
|
|
2333
|
+
handler: async (req, res) => {
|
|
2334
|
+
if (!guard(req, res, "POST")) return;
|
|
2335
|
+
const body = await readJsonBody(req);
|
|
2336
|
+
const config = getConfig();
|
|
2337
|
+
const project = loadProject(config.outputDir);
|
|
2338
|
+
const outline = body?.outline?.trim() !== "" && body?.outline !== void 0 ? body.outline : project?.outline;
|
|
2339
|
+
if (outline === void 0 || outline.length < 50) {
|
|
2340
|
+
writeJson(res, 400, { error: "请先加载大纲" });
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
try {
|
|
2344
|
+
const bible = await extractBible(ctx, config, outline);
|
|
2345
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2346
|
+
const next = project ?? createProject(outline);
|
|
2347
|
+
next.bible = bible;
|
|
2348
|
+
next.updatedAt = now;
|
|
2349
|
+
saveProject(config.outputDir, next);
|
|
2350
|
+
writeJson(res, 200, { bible });
|
|
2351
|
+
} catch (error) {
|
|
2352
|
+
writeJson(res, 500, { error: error.message });
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
};
|
|
2356
|
+
const volumesRoute = {
|
|
2357
|
+
kind: "exact",
|
|
2358
|
+
path: NOVEL_API.volumes,
|
|
2359
|
+
handler: async (req, res) => {
|
|
2360
|
+
if (!guard(req, res, "POST")) return;
|
|
2361
|
+
const body = await readJsonBody(req);
|
|
2362
|
+
const config = getConfig();
|
|
2363
|
+
const project = loadProject(config.outputDir);
|
|
2364
|
+
const outline = body?.outline?.trim() !== "" && body?.outline !== void 0 ? body.outline : project?.outline;
|
|
2365
|
+
if (outline === void 0 || outline.length < 50) {
|
|
2366
|
+
writeJson(res, 400, { error: "请先加载大纲" });
|
|
2367
|
+
return;
|
|
2368
|
+
}
|
|
2369
|
+
try {
|
|
2370
|
+
const volumes = await planVolumes(ctx, config, outline);
|
|
2371
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2372
|
+
const next = project ?? createProject(outline);
|
|
2373
|
+
next.volumes = volumes;
|
|
2374
|
+
next.updatedAt = now;
|
|
2375
|
+
saveProject(config.outputDir, next);
|
|
2376
|
+
writeJson(res, 200, { volumes });
|
|
2377
|
+
} catch (error) {
|
|
2378
|
+
writeJson(res, 500, { error: error.message });
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
};
|
|
2382
|
+
const planRoute = {
|
|
2383
|
+
kind: "exact",
|
|
2384
|
+
path: NOVEL_API.plan,
|
|
2385
|
+
handler: async (req, res) => {
|
|
2386
|
+
if (!guard(req, res, "POST")) return;
|
|
2387
|
+
const body = await readJsonBody(req);
|
|
2388
|
+
const config = getConfig();
|
|
2389
|
+
const project = loadProject(config.outputDir);
|
|
2390
|
+
const outline = body?.outline?.trim() !== "" && body?.outline !== void 0 ? body.outline : project?.outline;
|
|
2391
|
+
if (outline === void 0 || outline.length < 50) {
|
|
2392
|
+
writeJson(res, 400, { error: "请先加载大纲(或粘贴大纲文本)" });
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
const count = body?.chapterCount ?? DEFAULT_PLAN_COUNT;
|
|
2396
|
+
if (!Number.isInteger(count) || count < 1 || count > 200) {
|
|
2397
|
+
writeJson(res, 400, { error: "chapterCount 须为 1-200 的整数" });
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
try {
|
|
2401
|
+
const next = project ?? createProject(outline);
|
|
2402
|
+
const chapters = await planChapters(ctx, config, next, count, body?.volume);
|
|
2403
|
+
next.chapters.push(...chapters);
|
|
2404
|
+
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2405
|
+
saveProject(config.outputDir, next);
|
|
2406
|
+
writeJson(res, 200, {
|
|
2407
|
+
chapters,
|
|
2408
|
+
volumes: next.volumes
|
|
2409
|
+
});
|
|
2410
|
+
} catch (error) {
|
|
2411
|
+
writeJson(res, 500, { error: error.message });
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
};
|
|
2415
|
+
const generateRoute = {
|
|
2416
|
+
kind: "exact",
|
|
2417
|
+
path: NOVEL_API.generate,
|
|
2418
|
+
handler: async (req, res) => {
|
|
2419
|
+
if (!guard(req, res, "POST")) return;
|
|
2420
|
+
const config = getConfig();
|
|
2421
|
+
const project = requireProject(res);
|
|
2422
|
+
if (project === void 0) return;
|
|
2423
|
+
const body = await readJsonBody(req);
|
|
2424
|
+
const rawNo = body?.chapterNo;
|
|
2425
|
+
if (!Number.isInteger(rawNo) || rawNo === void 0 || rawNo < 1) {
|
|
2426
|
+
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
const no = rawNo;
|
|
2430
|
+
const chapter = project.chapters.find((c) => c.no === no);
|
|
2431
|
+
if (chapter === void 0) {
|
|
2432
|
+
writeJson(res, 404, { error: `章节 ${no} 不在计划中` });
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2435
|
+
if (chapter.status === "generating") {
|
|
2436
|
+
writeJson(res, 409, { error: `章节 ${no} 正在生成中` });
|
|
2437
|
+
return;
|
|
2438
|
+
}
|
|
2439
|
+
res.writeHead(200, {
|
|
2440
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
2441
|
+
"cache-control": "no-cache",
|
|
2442
|
+
"x-accel-buffering": "no",
|
|
2443
|
+
"referrer-policy": "no-referrer"
|
|
2444
|
+
});
|
|
2445
|
+
chapter.status = "generating";
|
|
2446
|
+
chapter.error = void 0;
|
|
2447
|
+
saveProject(config.outputDir, project);
|
|
2448
|
+
const send = (frame) => {
|
|
2449
|
+
res.write(JSON.stringify(frame) + "\n");
|
|
2450
|
+
};
|
|
2451
|
+
try {
|
|
2452
|
+
send({
|
|
2453
|
+
type: "start",
|
|
2454
|
+
no,
|
|
2455
|
+
title: chapter.title
|
|
2456
|
+
});
|
|
2457
|
+
for await (const step of generateChapterStream(ctx, config, project, config.outputDir, no)) if (step.frame === "delta") send({
|
|
2458
|
+
type: "delta",
|
|
2459
|
+
text: step.text
|
|
2460
|
+
});
|
|
2461
|
+
else if (step.frame === "done") send({
|
|
2462
|
+
type: "done",
|
|
2463
|
+
no,
|
|
2464
|
+
file: step.file,
|
|
2465
|
+
chars: step.chars,
|
|
2466
|
+
title: chapter.title
|
|
2467
|
+
});
|
|
2468
|
+
try {
|
|
2469
|
+
await summarizeChapter(ctx, config, project, config.outputDir, no);
|
|
2470
|
+
} catch (error) {
|
|
2471
|
+
console.warn("[dsh-novel-forge] summary failed:", error.message);
|
|
2472
|
+
}
|
|
2473
|
+
if (!(body?.skipReview === true) && (config.autoReview ?? true)) send({
|
|
2474
|
+
type: "review",
|
|
2475
|
+
no,
|
|
2476
|
+
report: await reviewChapter(ctx, config, project, config.outputDir, no)
|
|
2477
|
+
});
|
|
2478
|
+
else {
|
|
2479
|
+
chapter.status = "approved";
|
|
2480
|
+
saveProject(config.outputDir, project);
|
|
2481
|
+
}
|
|
2482
|
+
res.end();
|
|
2483
|
+
} catch (error) {
|
|
2484
|
+
chapter.status = "error";
|
|
2485
|
+
chapter.error = error.message;
|
|
2486
|
+
saveProject(config.outputDir, project);
|
|
2487
|
+
if (!res.writableEnded) {
|
|
2488
|
+
send({
|
|
2489
|
+
type: "error",
|
|
2490
|
+
no,
|
|
2491
|
+
message: error.message
|
|
2492
|
+
});
|
|
2493
|
+
res.end();
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
};
|
|
2498
|
+
const reviewRoute = {
|
|
2499
|
+
kind: "exact",
|
|
2500
|
+
path: NOVEL_API.review,
|
|
2501
|
+
handler: async (req, res) => {
|
|
2502
|
+
if (!guard(req, res, "POST")) return;
|
|
2503
|
+
const config = getConfig();
|
|
2504
|
+
const project = requireProject(res);
|
|
2505
|
+
if (project === void 0) return;
|
|
2506
|
+
const body = await readJsonBody(req);
|
|
2507
|
+
if (!Number.isInteger(body?.chapterNo)) {
|
|
2508
|
+
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
const no = body.chapterNo;
|
|
2512
|
+
try {
|
|
2513
|
+
writeJson(res, 200, { report: await reviewChapter(ctx, config, project, config.outputDir, no) });
|
|
2514
|
+
} catch (error) {
|
|
2515
|
+
writeJson(res, 500, { error: error.message });
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
};
|
|
2519
|
+
const rewriteRoute = {
|
|
2520
|
+
kind: "exact",
|
|
2521
|
+
path: NOVEL_API.rewrite,
|
|
2522
|
+
handler: async (req, res) => {
|
|
2523
|
+
if (!guard(req, res, "POST")) return;
|
|
2524
|
+
const config = getConfig();
|
|
2525
|
+
const project = requireProject(res);
|
|
2526
|
+
if (project === void 0) return;
|
|
2527
|
+
const body = await readJsonBody(req);
|
|
2528
|
+
if (!Number.isInteger(body?.chapterNo)) {
|
|
2529
|
+
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
const no = body.chapterNo;
|
|
2533
|
+
res.writeHead(200, {
|
|
2534
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
2535
|
+
"cache-control": "no-cache",
|
|
2536
|
+
"x-accel-buffering": "no",
|
|
2537
|
+
"referrer-policy": "no-referrer"
|
|
2538
|
+
});
|
|
2539
|
+
const send = (frame) => {
|
|
2540
|
+
res.write(JSON.stringify(frame) + "\n");
|
|
2541
|
+
};
|
|
2542
|
+
try {
|
|
2543
|
+
for await (const step of rewriteChapterStream(ctx, config, project, config.outputDir, no, body?.instructions ?? "", body?.target)) if (step.frame === "delta") send({
|
|
2544
|
+
type: "delta",
|
|
2545
|
+
text: step.text
|
|
2546
|
+
});
|
|
2547
|
+
else if (step.frame === "done") send({
|
|
2548
|
+
type: "rewritten",
|
|
2549
|
+
no,
|
|
2550
|
+
file: step.file,
|
|
2551
|
+
chars: step.chars
|
|
2552
|
+
});
|
|
2553
|
+
send({
|
|
2554
|
+
type: "review",
|
|
2555
|
+
no,
|
|
2556
|
+
report: await reviewChapter(ctx, config, project, config.outputDir, no)
|
|
2557
|
+
});
|
|
2558
|
+
res.end();
|
|
2559
|
+
} catch (error) {
|
|
2560
|
+
if (!res.writableEnded) {
|
|
2561
|
+
send({
|
|
2562
|
+
type: "error",
|
|
2563
|
+
no,
|
|
2564
|
+
message: error.message
|
|
2565
|
+
});
|
|
2566
|
+
res.end();
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
};
|
|
2571
|
+
const polishRoute = {
|
|
2572
|
+
kind: "exact",
|
|
2573
|
+
path: NOVEL_API.polish,
|
|
2574
|
+
handler: async (req, res) => {
|
|
2575
|
+
if (!guard(req, res, "POST")) return;
|
|
2576
|
+
const config = getConfig();
|
|
2577
|
+
const project = requireProject(res);
|
|
2578
|
+
if (project === void 0) return;
|
|
2579
|
+
const body = await readJsonBody(req);
|
|
2580
|
+
if (!Number.isInteger(body?.chapterNo)) {
|
|
2581
|
+
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
2582
|
+
return;
|
|
2583
|
+
}
|
|
2584
|
+
const no = body.chapterNo;
|
|
2585
|
+
res.writeHead(200, {
|
|
2586
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
2587
|
+
"cache-control": "no-cache",
|
|
2588
|
+
"x-accel-buffering": "no",
|
|
2589
|
+
"referrer-policy": "no-referrer"
|
|
2590
|
+
});
|
|
2591
|
+
const send = (frame) => {
|
|
2592
|
+
res.write(JSON.stringify(frame) + "\n");
|
|
2593
|
+
};
|
|
2594
|
+
try {
|
|
2595
|
+
for await (const step of polishChapterStream(ctx, config, project, config.outputDir, no)) if (step.frame === "delta") send({
|
|
2596
|
+
type: "delta",
|
|
2597
|
+
text: step.text
|
|
2598
|
+
});
|
|
2599
|
+
else if (step.frame === "done") send({
|
|
2600
|
+
type: "rewritten",
|
|
2601
|
+
no,
|
|
2602
|
+
file: step.file,
|
|
2603
|
+
chars: step.chars
|
|
2604
|
+
});
|
|
2605
|
+
res.end();
|
|
2606
|
+
} catch (error) {
|
|
2607
|
+
if (!res.writableEnded) {
|
|
2608
|
+
send({
|
|
2609
|
+
type: "error",
|
|
2610
|
+
no,
|
|
2611
|
+
message: error.message
|
|
2612
|
+
});
|
|
2613
|
+
res.end();
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
};
|
|
2618
|
+
const summaryRoute = {
|
|
2619
|
+
kind: "exact",
|
|
2620
|
+
path: NOVEL_API.summary,
|
|
2621
|
+
handler: async (req, res) => {
|
|
2622
|
+
if (!guard(req, res, "POST")) return;
|
|
2623
|
+
const config = getConfig();
|
|
2624
|
+
const project = requireProject(res);
|
|
2625
|
+
if (project === void 0) return;
|
|
2626
|
+
const body = await readJsonBody(req);
|
|
2627
|
+
if (!Number.isInteger(body?.chapterNo)) {
|
|
2628
|
+
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
try {
|
|
2632
|
+
writeJson(res, 200, { summary: await summarizeChapter(ctx, config, project, config.outputDir, body.chapterNo) });
|
|
2633
|
+
} catch (error) {
|
|
2634
|
+
writeJson(res, 500, { error: error.message });
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
};
|
|
2638
|
+
const foreshadowRoute = {
|
|
2639
|
+
kind: "exact",
|
|
2640
|
+
path: NOVEL_API.foreshadow,
|
|
2641
|
+
handler: async (req, res) => {
|
|
2642
|
+
if (!guard(req, res, "POST")) return;
|
|
2643
|
+
const config = getConfig();
|
|
2644
|
+
const project = requireProject(res);
|
|
2645
|
+
if (project === void 0) return;
|
|
2646
|
+
const body = await readJsonBody(req);
|
|
2647
|
+
try {
|
|
2648
|
+
if (body?.suggest === true) {
|
|
2649
|
+
const created = await suggestForeshadows(ctx, config, project);
|
|
2650
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2651
|
+
saveProject(config.outputDir, project);
|
|
2652
|
+
writeJson(res, 200, { foreshadows: created });
|
|
2653
|
+
return;
|
|
2654
|
+
}
|
|
2655
|
+
if (body?.id !== void 0) {
|
|
2656
|
+
const target = project.foreshadows.find((f) => f.id === body.id);
|
|
2657
|
+
if (target === void 0) {
|
|
2658
|
+
writeJson(res, 404, { error: `伏笔 ${body.id} 不存在` });
|
|
2659
|
+
return;
|
|
2660
|
+
}
|
|
2661
|
+
if (body.description !== void 0) target.description = body.description;
|
|
2662
|
+
if (body.plantedChapter !== void 0) target.plantedChapter = body.plantedChapter;
|
|
2663
|
+
if (body.targetChapter !== void 0) target.targetChapter = body.targetChapter;
|
|
2664
|
+
if (body.status !== void 0) target.status = body.status;
|
|
2665
|
+
if (body.resolvedNote !== void 0) target.resolvedNote = body.resolvedNote;
|
|
2666
|
+
} else {
|
|
2667
|
+
const description = body?.description?.trim();
|
|
2668
|
+
if (description === void 0 || description === "") {
|
|
2669
|
+
writeJson(res, 400, { error: "description 必填" });
|
|
2670
|
+
return;
|
|
2671
|
+
}
|
|
2672
|
+
project.foreshadows.push({
|
|
2673
|
+
id: `fs-${Date.now().toString(36)}`,
|
|
2674
|
+
description,
|
|
2675
|
+
plantedChapter: body?.plantedChapter,
|
|
2676
|
+
targetChapter: body?.targetChapter,
|
|
2677
|
+
status: body?.status ?? "planned"
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2681
|
+
saveProject(config.outputDir, project);
|
|
2682
|
+
writeJson(res, 200, { foreshadows: project.foreshadows });
|
|
2683
|
+
} catch (error) {
|
|
2684
|
+
writeJson(res, 500, { error: error.message });
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
};
|
|
2688
|
+
const exportRoute = {
|
|
2689
|
+
kind: "exact",
|
|
2690
|
+
path: NOVEL_API.exportBook,
|
|
2691
|
+
handler: async (req, res) => {
|
|
2692
|
+
if (!guard(req, res, "POST")) return;
|
|
2693
|
+
const config = getConfig();
|
|
2694
|
+
const project = requireProject(res);
|
|
2695
|
+
if (project === void 0) return;
|
|
2696
|
+
const format = (await readJsonBody(req))?.format === "md" ? "md" : "txt";
|
|
2697
|
+
try {
|
|
2698
|
+
writeJson(res, 200, { ...exportBook(config.outputDir, project, format) });
|
|
2699
|
+
} catch (error) {
|
|
2700
|
+
writeJson(res, 500, { error: error.message });
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
};
|
|
2704
|
+
const chapterRoute = {
|
|
2705
|
+
kind: "exact",
|
|
2706
|
+
path: NOVEL_API.chapter,
|
|
2707
|
+
handler: async (req, res) => {
|
|
2708
|
+
if (!guard(req, res, "GET")) return;
|
|
2709
|
+
const config = getConfig();
|
|
2710
|
+
const project = requireProject(res);
|
|
2711
|
+
if (project === void 0) return;
|
|
2712
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2713
|
+
const rawNo = Number(url.searchParams.get("no") ?? "0");
|
|
2714
|
+
if (!Number.isInteger(rawNo) || rawNo < 1) {
|
|
2715
|
+
writeJson(res, 400, { error: "no 须为正整数" });
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2718
|
+
const chapter = project.chapters.find((c) => c.no === rawNo);
|
|
2719
|
+
if (chapter === void 0) {
|
|
2720
|
+
writeJson(res, 404, { error: `章节 ${rawNo} 不在计划中` });
|
|
2721
|
+
return;
|
|
2722
|
+
}
|
|
2723
|
+
const markdown = readChapterFile(config.outputDir, chapter);
|
|
2724
|
+
if (markdown === void 0) {
|
|
2725
|
+
writeJson(res, 404, { error: `章节 ${rawNo} 尚未生成` });
|
|
2726
|
+
return;
|
|
2727
|
+
}
|
|
2728
|
+
writeJson(res, 200, {
|
|
2729
|
+
no: chapter.no,
|
|
2730
|
+
title: chapter.title,
|
|
2731
|
+
markdown
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
};
|
|
2735
|
+
const assistantRoute = {
|
|
2736
|
+
kind: "exact",
|
|
2737
|
+
path: NOVEL_API.assistant,
|
|
2738
|
+
handler: async (req, res) => {
|
|
2739
|
+
if (!guard(req, res, "POST")) return;
|
|
2740
|
+
const config = getConfig();
|
|
2741
|
+
const project = requireProject(res);
|
|
2742
|
+
if (project === void 0) return;
|
|
2743
|
+
const message = (await readJsonBody(req))?.message?.trim();
|
|
2744
|
+
if (message === void 0 || message === "") {
|
|
2745
|
+
writeJson(res, 400, { error: "消息不能为空" });
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
res.writeHead(200, {
|
|
2749
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
2750
|
+
"cache-control": "no-cache",
|
|
2751
|
+
"x-accel-buffering": "no",
|
|
2752
|
+
"referrer-policy": "no-referrer"
|
|
2753
|
+
});
|
|
2754
|
+
const send = (frame) => {
|
|
2755
|
+
res.write(JSON.stringify(frame) + "\n");
|
|
2756
|
+
};
|
|
2757
|
+
try {
|
|
2758
|
+
for await (const step of runAssistantTurn(ctx, config, project, config.outputDir, message)) if (step.frame === "delta") send({
|
|
2759
|
+
type: "delta",
|
|
2760
|
+
text: step.text
|
|
2761
|
+
});
|
|
2762
|
+
else if (step.frame === "tool") send({
|
|
2763
|
+
type: "tool",
|
|
2764
|
+
name: step.name,
|
|
2765
|
+
status: step.status,
|
|
2766
|
+
detail: step.detail
|
|
2767
|
+
});
|
|
2768
|
+
else if (step.frame === "toolDelta") send({
|
|
2769
|
+
type: "toolDelta",
|
|
2770
|
+
name: step.name,
|
|
2771
|
+
text: step.text
|
|
2772
|
+
});
|
|
2773
|
+
send({ type: "done" });
|
|
2774
|
+
res.end();
|
|
2775
|
+
} catch (error) {
|
|
2776
|
+
if (!res.writableEnded) {
|
|
2777
|
+
send({
|
|
2778
|
+
type: "error",
|
|
2779
|
+
message: error.message
|
|
2780
|
+
});
|
|
2781
|
+
res.end();
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
};
|
|
2786
|
+
const assistantHistoryRoute = {
|
|
2787
|
+
kind: "exact",
|
|
2788
|
+
path: NOVEL_API.assistantHistory,
|
|
2789
|
+
handler: (req, res) => {
|
|
2790
|
+
if (!guard(req, res, "GET")) return;
|
|
2791
|
+
writeJson(res, 200, { messages: loadAssistantHistory(getConfig().outputDir) });
|
|
2792
|
+
}
|
|
2793
|
+
};
|
|
2794
|
+
return [
|
|
2795
|
+
statusRoute,
|
|
2796
|
+
loadOutlineRoute,
|
|
2797
|
+
saveOutlineRoute,
|
|
2798
|
+
bibleRoute,
|
|
2799
|
+
volumesRoute,
|
|
2800
|
+
planRoute,
|
|
2801
|
+
generateRoute,
|
|
2802
|
+
reviewRoute,
|
|
2803
|
+
rewriteRoute,
|
|
2804
|
+
polishRoute,
|
|
2805
|
+
summaryRoute,
|
|
2806
|
+
foreshadowRoute,
|
|
2807
|
+
exportRoute,
|
|
2808
|
+
chapterRoute,
|
|
2809
|
+
{
|
|
2810
|
+
kind: "exact",
|
|
2811
|
+
path: NOVEL_API.assets,
|
|
2812
|
+
handler: async (req, res) => {
|
|
2813
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
2814
|
+
writeJson(res, 405, { error: "method not allowed (expected GET or POST)" });
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
if (!isLoopbackRequest(req)) {
|
|
2818
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
2819
|
+
return;
|
|
2820
|
+
}
|
|
2821
|
+
const config = getConfig();
|
|
2822
|
+
const project = loadProject(config.outputDir);
|
|
2823
|
+
const projectAssets = project?.assets ?? emptyProjectAssets();
|
|
2824
|
+
if (req.method === "POST") {
|
|
2825
|
+
const body = await readJsonBody(req);
|
|
2826
|
+
if (body === void 0) {
|
|
2827
|
+
writeJson(res, 400, { error: "无效的 JSON" });
|
|
2828
|
+
return;
|
|
2829
|
+
}
|
|
2830
|
+
if (project === void 0) {
|
|
2831
|
+
writeJson(res, 400, { error: "请先加载大纲创建项目" });
|
|
2832
|
+
return;
|
|
2833
|
+
}
|
|
2834
|
+
if (body.genre !== void 0) projectAssets.genre = body.genre;
|
|
2835
|
+
if (body.primaryProgression !== void 0) projectAssets.primaryProgression = body.primaryProgression;
|
|
2836
|
+
if (body.auxiliaryProgressions !== void 0) projectAssets.auxiliaryProgressions = body.auxiliaryProgressions;
|
|
2837
|
+
if (body.antiAiRules !== void 0) projectAssets.antiAiRules = body.antiAiRules;
|
|
2838
|
+
if (body.styleAssets !== void 0) projectAssets.styleAssets = body.styleAssets;
|
|
2839
|
+
projectAssets.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2840
|
+
project.assets = projectAssets;
|
|
2841
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2842
|
+
saveProject(config.outputDir, project);
|
|
2843
|
+
}
|
|
2844
|
+
writeJson(res, 200, {
|
|
2845
|
+
projectAssets,
|
|
2846
|
+
genreLibrary: BUILTIN_GENRE_LIBRARY,
|
|
2847
|
+
antiAiLibrary: BUILTIN_ANTI_AI_RULES,
|
|
2848
|
+
styleTemplates: BUILTIN_STYLE_TEMPLATES,
|
|
2849
|
+
progressionLibrary: BUILTIN_PROGRESSION_MODES
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
},
|
|
2853
|
+
{
|
|
2854
|
+
kind: "exact",
|
|
2855
|
+
path: NOVEL_API.styleEngine,
|
|
2856
|
+
handler: async (req, res) => {
|
|
2857
|
+
if (!guard(req, res, "POST")) return;
|
|
2858
|
+
const config = getConfig();
|
|
2859
|
+
const project = loadProject(config.outputDir);
|
|
2860
|
+
const body = await readJsonBody(req);
|
|
2861
|
+
const sample = body?.sampleText?.trim();
|
|
2862
|
+
if (sample === void 0 || sample.length < 50) {
|
|
2863
|
+
writeJson(res, 400, { error: "样本文本过短(<50 字符),请粘贴一段能代表目标风格的文字" });
|
|
2864
|
+
return;
|
|
2865
|
+
}
|
|
2866
|
+
try {
|
|
2867
|
+
const rules = await extractStyleAsset(ctx, config, sample);
|
|
2868
|
+
const styleAsset = {
|
|
2869
|
+
name: (body?.name?.trim() !== "" && body?.name !== void 0 ? body.name : `风格资产 ${Date.now().toString(36)}`).slice(0, 40),
|
|
2870
|
+
...rules,
|
|
2871
|
+
sourceText: sample.slice(0, 3e3),
|
|
2872
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2873
|
+
};
|
|
2874
|
+
if (project !== void 0) {
|
|
2875
|
+
project.assets ??= emptyProjectAssets();
|
|
2876
|
+
project.assets.styleAssets ??= [];
|
|
2877
|
+
project.assets.styleAssets.push(styleAsset);
|
|
2878
|
+
project.assets.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2879
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2880
|
+
saveProject(config.outputDir, project);
|
|
2881
|
+
}
|
|
2882
|
+
writeJson(res, 200, { styleAsset });
|
|
2883
|
+
} catch (error) {
|
|
2884
|
+
writeJson(res, 500, { error: error.message });
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
},
|
|
2888
|
+
assistantRoute,
|
|
2889
|
+
assistantHistoryRoute,
|
|
2890
|
+
{
|
|
2891
|
+
kind: "exact",
|
|
2892
|
+
path: NOVEL_API.bookshelf,
|
|
2893
|
+
handler: async (req, res) => {
|
|
2894
|
+
if (req.method === "GET") {
|
|
2895
|
+
if (!isLoopbackRequest(req)) {
|
|
2896
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
2897
|
+
return;
|
|
2898
|
+
}
|
|
2899
|
+
seedBookshelfFromOutputDir(getConfig().outputDir);
|
|
2900
|
+
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2903
|
+
if (req.method === "POST") {
|
|
2904
|
+
if (!isLoopbackRequest(req)) {
|
|
2905
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
2906
|
+
return;
|
|
2907
|
+
}
|
|
2908
|
+
const body = await readJsonBody(req);
|
|
2909
|
+
const bookName = body?.bookName?.trim();
|
|
2910
|
+
if (bookName === void 0 || bookName === "") {
|
|
2911
|
+
writeJson(res, 400, { error: "bookName 不能为空" });
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2914
|
+
createBook(bookName, body?.outputDir?.trim() !== "" && body?.outputDir !== void 0 ? body.outputDir : defaultOutputDirFor(bookName));
|
|
2915
|
+
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
|
|
2916
|
+
return;
|
|
2917
|
+
}
|
|
2918
|
+
writeJson(res, 405, { error: "method not allowed (expected GET or POST)" });
|
|
2919
|
+
}
|
|
2920
|
+
},
|
|
2921
|
+
{
|
|
2922
|
+
kind: "exact",
|
|
2923
|
+
path: "/api/dsh-novel-forge/bookshelf/activate",
|
|
2924
|
+
handler: async (req, res) => {
|
|
2925
|
+
if (!guard(req, res, "POST")) return;
|
|
2926
|
+
const body = await readJsonBody(req);
|
|
2927
|
+
if (body?.id === void 0 || body.id === "") {
|
|
2928
|
+
writeJson(res, 400, { error: "id 不能为空" });
|
|
2929
|
+
return;
|
|
2930
|
+
}
|
|
2931
|
+
if (activateBook(body.id) === void 0) {
|
|
2932
|
+
writeJson(res, 404, { error: `书 ${body.id} 不存在` });
|
|
2933
|
+
return;
|
|
2934
|
+
}
|
|
2935
|
+
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
|
|
2936
|
+
}
|
|
2937
|
+
},
|
|
2938
|
+
{
|
|
2939
|
+
kind: "exact",
|
|
2940
|
+
path: "/api/dsh-novel-forge/bookshelf/remove",
|
|
2941
|
+
handler: async (req, res) => {
|
|
2942
|
+
if (!guard(req, res, "POST")) return;
|
|
2943
|
+
const body = await readJsonBody(req);
|
|
2944
|
+
if (body?.id === void 0 || body.id === "") {
|
|
2945
|
+
writeJson(res, 400, { error: "id 不能为空" });
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
if (!removeBook(body.id)) {
|
|
2949
|
+
writeJson(res, 404, { error: `书 ${body.id} 不存在` });
|
|
2950
|
+
return;
|
|
2951
|
+
}
|
|
2952
|
+
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
|
|
2953
|
+
}
|
|
2954
|
+
},
|
|
2955
|
+
{
|
|
2956
|
+
kind: "exact",
|
|
2957
|
+
path: NOVEL_API.config,
|
|
2958
|
+
handler: async (req, res) => {
|
|
2959
|
+
if (!guard(req, res, "POST")) return;
|
|
2960
|
+
const body = await readJsonBody(req);
|
|
2961
|
+
if (body === void 0) {
|
|
2962
|
+
writeJson(res, 400, { error: "无效的配置 JSON" });
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
try {
|
|
2966
|
+
writeJson(res, 200, { config: await patchConfig(body) });
|
|
2967
|
+
} catch (error) {
|
|
2968
|
+
writeJson(res, 400, { error: error.message });
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
},
|
|
2972
|
+
{
|
|
2973
|
+
kind: "exact",
|
|
2974
|
+
path: NOVEL_API.openFolder,
|
|
2975
|
+
handler: async (req, res) => {
|
|
2976
|
+
if (!guard(req, res, "POST")) return;
|
|
2977
|
+
const dir = getConfig().outputDir;
|
|
2978
|
+
exec(`explorer "${dir.replace(/"/g, "")}"`, (error) => {
|
|
2979
|
+
if (error) writeJson(res, 500, {
|
|
2980
|
+
ok: false,
|
|
2981
|
+
error: error.message
|
|
2982
|
+
});
|
|
2983
|
+
else writeJson(res, 200, { ok: true });
|
|
2984
|
+
});
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
];
|
|
2988
|
+
}
|
|
2989
|
+
//#endregion
|
|
2990
|
+
//#region src/index.ts
|
|
2991
|
+
/** Stable cordis plugin name. */
|
|
2992
|
+
const name = "novel-forge";
|
|
2993
|
+
/** Services required before the novel-forge surfaces can mount. */
|
|
2994
|
+
const inject = [
|
|
2995
|
+
"webServer",
|
|
2996
|
+
"llm",
|
|
2997
|
+
"systemPrompt"
|
|
2998
|
+
];
|
|
2999
|
+
/**
|
|
3000
|
+
* Settings namespace of the novel-forge capability — the section the web
|
|
3001
|
+
* settings surface edits. Spelled here rather than imported: the browser half
|
|
3002
|
+
* spells the same value and must not depend on a Host package.
|
|
3003
|
+
*/
|
|
3004
|
+
const NOVEL_SETTINGS_NAMESPACE = settingsNamespace("dsh-novel-forge");
|
|
3005
|
+
const Config = z.object({
|
|
3006
|
+
announceToAgent: z.boolean().default(true),
|
|
3007
|
+
enabled: z.boolean().default(true),
|
|
3008
|
+
outlinePath: z.string().default("C:\\Users\\Ryan\\Desktop\\《归墟玉主》全书大纲_重新排版版.docx"),
|
|
3009
|
+
outputDir: z.string().default("C:\\Users\\Ryan\\Desktop\\归墟玉主"),
|
|
3010
|
+
provider: z.string().default("deepseek-official"),
|
|
3011
|
+
model: z.string().default("deepseek-v4-flash"),
|
|
3012
|
+
chapterChars: z.number().default(3500),
|
|
3013
|
+
maxTokens: z.number().default(12e3),
|
|
3014
|
+
reviewPassScore: z.number().default(70),
|
|
3015
|
+
autoReview: z.boolean().default(true)
|
|
3016
|
+
});
|
|
3017
|
+
/** Schema defaults, re-read for hand-built test contexts. */
|
|
3018
|
+
const DEFAULT_ANNOUNCE = true;
|
|
3019
|
+
const DEFAULT_OUTLINE_PATH = "C:\\Users\\Ryan\\Desktop\\《归墟玉主》全书大纲_重新排版版.docx";
|
|
3020
|
+
const DEFAULT_OUTPUT_DIR = "C:\\Users\\Ryan\\Desktop\\归墟玉主";
|
|
3021
|
+
const DEFAULT_PROVIDER = "deepseek-official";
|
|
3022
|
+
const DEFAULT_MODEL = "deepseek-v4-flash";
|
|
3023
|
+
const DEFAULT_CHAPTER_CHARS = 3500;
|
|
3024
|
+
const DEFAULT_MAX_TOKENS = 12e3;
|
|
3025
|
+
const DEFAULT_REVIEW_PASS_SCORE = 70;
|
|
3026
|
+
const DEFAULT_AUTO_REVIEW = true;
|
|
3027
|
+
/** Order of the announcement section within the tool-guidance band. */
|
|
3028
|
+
const SECTION_ORDER = 160;
|
|
3029
|
+
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
3030
|
+
const NOVEL_GUIDANCE = "本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲(默认桌面《归墟玉主》大纲)或粘贴大纲文本;用 LLM 提炼设定圣经(人设/世界观/金手指规则/写作红线);生成卷计划与章节计划;逐章调用 LLM 生成 3000-4000 字正文并保存为 Markdown(默认输出到 桌面\\归墟玉主);每章自动生成摘要(叙事记忆)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑),支持按审稿意见重写、去 AI 味润色、伏笔管理、批量连写与全本导出(txt/md)。限制:生成消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 伏笔 / 润色 / 归墟玉主」时即指本插件,请据此协作。";
|
|
3031
|
+
/** Resolve a config-like value into the full runtime config. */
|
|
3032
|
+
function resolveConfig(value) {
|
|
3033
|
+
return {
|
|
3034
|
+
outlinePath: value?.outlinePath ?? DEFAULT_OUTLINE_PATH,
|
|
3035
|
+
outputDir: value?.outputDir ?? DEFAULT_OUTPUT_DIR,
|
|
3036
|
+
provider: value?.provider ?? DEFAULT_PROVIDER,
|
|
3037
|
+
model: value?.model ?? DEFAULT_MODEL,
|
|
3038
|
+
chapterChars: value?.chapterChars ?? DEFAULT_CHAPTER_CHARS,
|
|
3039
|
+
maxTokens: value?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
3040
|
+
reviewPassScore: value?.reviewPassScore ?? DEFAULT_REVIEW_PASS_SCORE,
|
|
3041
|
+
autoReview: value?.autoReview ?? DEFAULT_AUTO_REVIEW
|
|
3042
|
+
};
|
|
3043
|
+
}
|
|
3044
|
+
/**
|
|
3045
|
+
* Mount the routes and announcement.
|
|
3046
|
+
* @param ctx - host plugin context carrying webServer/llm/systemPrompt.
|
|
3047
|
+
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
3048
|
+
*/
|
|
3049
|
+
function apply(ctx, config) {
|
|
3050
|
+
let current = () => config ?? {};
|
|
3051
|
+
const resolve = () => {
|
|
3052
|
+
const resolved = resolveConfig(current());
|
|
3053
|
+
const shelfDir = activeBookOutputDir();
|
|
3054
|
+
if (shelfDir !== void 0) return {
|
|
3055
|
+
...resolved,
|
|
3056
|
+
outputDir: shelfDir
|
|
3057
|
+
};
|
|
3058
|
+
return resolved;
|
|
3059
|
+
};
|
|
3060
|
+
const patchConfig = async (patch) => {
|
|
3061
|
+
const next = {};
|
|
3062
|
+
if (patch.outlinePath !== void 0) next.outlinePath = patch.outlinePath;
|
|
3063
|
+
if (patch.outputDir !== void 0) next.outputDir = patch.outputDir;
|
|
3064
|
+
if (patch.provider !== void 0) next.provider = patch.provider;
|
|
3065
|
+
if (patch.model !== void 0) next.model = patch.model;
|
|
3066
|
+
if (patch.chapterChars !== void 0) next.chapterChars = patch.chapterChars;
|
|
3067
|
+
if (patch.maxTokens !== void 0) next.maxTokens = patch.maxTokens;
|
|
3068
|
+
if (patch.reviewPassScore !== void 0) next.reviewPassScore = patch.reviewPassScore;
|
|
3069
|
+
if (patch.autoReview !== void 0) next.autoReview = patch.autoReview;
|
|
3070
|
+
const settings = ctx.get("settings");
|
|
3071
|
+
if (settings !== void 0) await settings.update(NOVEL_SETTINGS_NAMESPACE, next);
|
|
3072
|
+
else current = () => ({
|
|
3073
|
+
...current(),
|
|
3074
|
+
...next
|
|
3075
|
+
});
|
|
3076
|
+
return resolve();
|
|
3077
|
+
};
|
|
3078
|
+
let disposeSection;
|
|
3079
|
+
let disposeRoutes;
|
|
3080
|
+
const sync = () => {
|
|
3081
|
+
if (disposeSection !== void 0) {
|
|
3082
|
+
disposeSection();
|
|
3083
|
+
disposeSection = void 0;
|
|
3084
|
+
}
|
|
3085
|
+
if (disposeRoutes !== void 0) {
|
|
3086
|
+
disposeRoutes();
|
|
3087
|
+
disposeRoutes = void 0;
|
|
3088
|
+
}
|
|
3089
|
+
resolve();
|
|
3090
|
+
if (!(current().enabled ?? true)) return;
|
|
3091
|
+
if (current().announceToAgent ?? DEFAULT_ANNOUNCE) disposeSection = ctx.systemPrompt.section({
|
|
3092
|
+
name: "plugin:dsh-novel-forge",
|
|
3093
|
+
order: SECTION_ORDER,
|
|
3094
|
+
text: NOVEL_GUIDANCE
|
|
3095
|
+
});
|
|
3096
|
+
const routes = makeRoutes({
|
|
3097
|
+
ctx,
|
|
3098
|
+
getConfig: resolve,
|
|
3099
|
+
patchConfig
|
|
3100
|
+
});
|
|
3101
|
+
disposeRoutes = ctx.effect(() => {
|
|
3102
|
+
const disposers = routes.map((route) => ctx.webServer.register(route));
|
|
3103
|
+
return () => {
|
|
3104
|
+
for (const dispose of disposers) dispose();
|
|
3105
|
+
};
|
|
3106
|
+
}, "dsh-novel-forge: routes");
|
|
3107
|
+
};
|
|
3108
|
+
installSettingsSection(ctx, NOVEL_SETTINGS_NAMESPACE, Config, config ?? {}, {
|
|
3109
|
+
setSource: (source) => {
|
|
3110
|
+
current = source;
|
|
3111
|
+
sync();
|
|
3112
|
+
},
|
|
3113
|
+
onChange: sync
|
|
3114
|
+
});
|
|
3115
|
+
sync();
|
|
3116
|
+
}
|
|
3117
|
+
//#endregion
|
|
3118
|
+
export { Config, NOVEL_GUIDANCE, NOVEL_SETTINGS_NAMESPACE, apply, inject, name, resolveConfig };
|
|
3119
|
+
|
|
3120
|
+
//# sourceMappingURL=index.js.map
|