@waterwx/dsh-novel-forge 1.0.0 → 1.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/README.md +23 -7
- package/lib/client.js +3595 -2265
- package/lib/client.js.map +1 -1
- package/lib/index.js +724 -77
- package/lib/index.js.map +1 -1
- package/lib/types/assets.d.ts +2 -2
- package/lib/types/assistant.d.ts +2 -0
- package/lib/types/client/api.d.ts +29 -0
- package/lib/types/client/locales.d.ts +24 -24
- package/lib/types/client/panel/CreateBookView.d.ts +9 -0
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/client/panel/ShelfView.d.ts +11 -0
- package/lib/types/client/panel/WorldTab.d.ts +10 -0
- package/lib/types/engine.d.ts +44 -2
- package/lib/types/index.d.ts +1 -1
- package/lib/types/protocol.d.ts +95 -0
- package/package.json +1 -1
- package/src/assets.ts +9 -7
- package/src/assistant.ts +133 -34
- package/src/bookshelf.ts +3 -0
- package/src/client/api.ts +42 -0
- package/src/client/locales.ts +24 -24
- package/src/client/panel/AssetsTab.tsx +11 -28
- package/src/client/panel/AssistantTab.tsx +136 -23
- package/src/client/panel/CreateBookView.tsx +189 -0
- package/src/client/panel/NovelPanel.tsx +516 -52
- package/src/client/panel/ShelfView.tsx +112 -0
- package/src/client/panel/WorldTab.tsx +202 -0
- package/src/client/panel/panel.module.css +347 -0
- package/src/engine.ts +395 -20
- package/src/index.ts +1 -1
- package/src/protocol.ts +105 -1
- package/src/routes.ts +295 -15
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
2
2
|
import z from "schemastery";
|
|
3
3
|
import { exec } from "node:child_process";
|
|
4
|
-
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { strFromU8, unzipSync } from "fflate";
|
|
7
7
|
import { BlockAssembler, ReasoningEffortId, createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
@@ -36,8 +36,14 @@ const NOVEL_API = {
|
|
|
36
36
|
foreshadow: "/api/dsh-novel-forge/foreshadow",
|
|
37
37
|
exportBook: "/api/dsh-novel-forge/export",
|
|
38
38
|
chapter: "/api/dsh-novel-forge/chapter",
|
|
39
|
+
/** 审查任意正文文本(作者手动编辑后,不落盘)。 */
|
|
40
|
+
chapterCheck: "/api/dsh-novel-forge/chapter/check",
|
|
41
|
+
/** 保存手动编辑的正文(自动备份 .bak)。 */
|
|
42
|
+
chapterSave: "/api/dsh-novel-forge/chapter/save",
|
|
39
43
|
assistant: "/api/dsh-novel-forge/assistant",
|
|
40
44
|
assistantHistory: "/api/dsh-novel-forge/assistant-history",
|
|
45
|
+
/** 清空助手对话记录。 */
|
|
46
|
+
assistantClear: "/api/dsh-novel-forge/assistant/clear",
|
|
41
47
|
bookshelf: "/api/dsh-novel-forge/bookshelf",
|
|
42
48
|
/** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
|
|
43
49
|
reset: "/api/dsh-novel-forge/reset",
|
|
@@ -49,6 +55,14 @@ const NOVEL_API = {
|
|
|
49
55
|
factsBackfill: "/api/dsh-novel-forge/facts/backfill",
|
|
50
56
|
/** 设定圣经局部修补(如世界观规则编辑)。 */
|
|
51
57
|
biblePatch: "/api/dsh-novel-forge/bible/patch",
|
|
58
|
+
/** 小说简介:生成(AI)/补全(AI)/保存。 */
|
|
59
|
+
blurb: "/api/dsh-novel-forge/blurb",
|
|
60
|
+
/** 重命名当前书(同步项目与书架条目)。 */
|
|
61
|
+
rename: "/api/dsh-novel-forge/rename",
|
|
62
|
+
/** 大世界:AI 提炼 / 保存结构化数据(境界/区域/势力)。 */
|
|
63
|
+
world: "/api/dsh-novel-forge/world",
|
|
64
|
+
/** 封面:GET 读取(dataUrl)/ POST 上传或移除。 */
|
|
65
|
+
cover: "/api/dsh-novel-forge/blurb/cover",
|
|
52
66
|
config: "/api/dsh-novel-forge/config",
|
|
53
67
|
openFolder: "/api/dsh-novel-forge/open-folder"
|
|
54
68
|
};
|
|
@@ -645,11 +659,12 @@ function effectiveAntiAiRules(assets) {
|
|
|
645
659
|
const customNames = new Set(custom.map((r) => r.name));
|
|
646
660
|
return [...BUILTIN_ANTI_AI_RULES.filter((r) => !customNames.has(r.name)), ...custom];
|
|
647
661
|
}
|
|
648
|
-
/**
|
|
662
|
+
/** 把生效规则渲染成提示词块(压缩:avoid/fix 截断,省 token)。 */
|
|
649
663
|
function renderAntiAiRules(assets) {
|
|
650
664
|
const rules = effectiveAntiAiRules(assets);
|
|
651
665
|
if (rules.length === 0) return "";
|
|
652
|
-
|
|
666
|
+
const clip = (value, max) => value.length > max ? value.slice(0, max) + "…" : value;
|
|
667
|
+
return ["==================== 反 AI 规则(写作时必须遵守的表达边界) ====================", ...rules.map((r) => `- ${r.name}:避免——${clip(r.avoid, 90)}${r.fix !== "" ? `;修正——${clip(r.fix, 50)}` : ""}`)].join("\n");
|
|
653
668
|
}
|
|
654
669
|
/** 渲染题材与推进模式提示词块。 */
|
|
655
670
|
function renderGenreAndProgression(assets) {
|
|
@@ -679,17 +694,18 @@ function renderGenreAndProgression(assets) {
|
|
|
679
694
|
}
|
|
680
695
|
return sections.join("\n");
|
|
681
696
|
}
|
|
682
|
-
/**
|
|
697
|
+
/** 渲染写法资产提示词块(规则去重,省 token)。 */
|
|
683
698
|
function renderStyleAssets(assets) {
|
|
684
699
|
const styles = assets?.styleAssets ?? [];
|
|
685
700
|
if (styles.length === 0) return "";
|
|
686
701
|
const sections = ["==================== 写法资产(本书的叙事风格约束) ===================="];
|
|
687
702
|
for (const style of styles) {
|
|
688
703
|
sections.push(`【${style.name}】`);
|
|
689
|
-
|
|
690
|
-
if (style.
|
|
691
|
-
if (style.
|
|
692
|
-
if (style.
|
|
704
|
+
const unique = (rules) => [...new Set(rules)];
|
|
705
|
+
if (style.proseRules.length > 0) sections.push("叙述与节奏:\n" + unique(style.proseRules).map((r) => `- ${r}`).join("\n"));
|
|
706
|
+
if (style.dialogueRules.length > 0) sections.push("台词风格:\n" + unique(style.dialogueRules).map((r) => `- ${r}`).join("\n"));
|
|
707
|
+
if (style.descriptionRules.length > 0) sections.push("描写与情绪:\n" + unique(style.descriptionRules).map((r) => `- ${r}`).join("\n"));
|
|
708
|
+
if (style.boundaries.length > 0) sections.push("表达边界:\n" + unique(style.boundaries).map((r) => `- ${r}`).join("\n"));
|
|
693
709
|
}
|
|
694
710
|
return sections.join("\n");
|
|
695
711
|
}
|
|
@@ -960,7 +976,7 @@ async function extractBible(ctx, config, outline) {
|
|
|
960
976
|
style: strArray(raw.style),
|
|
961
977
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
962
978
|
};
|
|
963
|
-
if (bible.worldRules.length === 0 && bible.characters.length === 0 && bible.redLines.length === 0) throw new Error("
|
|
979
|
+
if (bible.worldRules.length === 0 && bible.characters.length === 0 && bible.redLines.length === 0) throw new Error("道藏生成失败:模型没有返回有效内容");
|
|
964
980
|
return bible;
|
|
965
981
|
}
|
|
966
982
|
/** System prompt for volume planning. */
|
|
@@ -983,7 +999,8 @@ async function planVolumes(ctx, config, outline) {
|
|
|
983
999
|
const parsed = parseJsonArray(await complete(ctx, config, {
|
|
984
1000
|
system: volumeSystemPrompt(),
|
|
985
1001
|
user,
|
|
986
|
-
temperature: .4
|
|
1002
|
+
temperature: .4,
|
|
1003
|
+
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
987
1004
|
}));
|
|
988
1005
|
const volumes = [];
|
|
989
1006
|
for (let i = 0; i < parsed.length; i++) {
|
|
@@ -1050,8 +1067,11 @@ function writeSystemPrompt(project) {
|
|
|
1050
1067
|
if (bible.redLines.length > 0) sections.push("写作红线(违反即失败):\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
|
|
1051
1068
|
if (bible.style.length > 0) sections.push("风格要求:\n" + bible.style.map((r) => `- ${r}`).join("\n"));
|
|
1052
1069
|
}
|
|
1070
|
+
const worldBlock = renderWorld(project.world);
|
|
1071
|
+
if (worldBlock !== "") sections.push(worldBlock);
|
|
1053
1072
|
sections.push("==================== 全书大纲 ====================");
|
|
1054
|
-
|
|
1073
|
+
const outlineBlock = project.outline.length > 6e3 ? project.outline.slice(0, 6e3) + "\n…(大纲过长已节选,完整内容见总纲页)" : project.outline;
|
|
1074
|
+
sections.push(outlineBlock);
|
|
1055
1075
|
sections.push("==================== 大纲结束 ====================");
|
|
1056
1076
|
const assetsBlock = renderAllAssets(project.assets);
|
|
1057
1077
|
if (assetsBlock !== "") sections.push(assetsBlock);
|
|
@@ -1084,7 +1104,8 @@ async function planChapters(ctx, config, project, chapterCount, volumeNo) {
|
|
|
1084
1104
|
const parsed = parseJsonArray(await complete(ctx, config, {
|
|
1085
1105
|
system: planSystemPrompt(project.volumes),
|
|
1086
1106
|
user,
|
|
1087
|
-
temperature: .7
|
|
1107
|
+
temperature: .7,
|
|
1108
|
+
maxTokens: Math.max(config.maxTokens, 2e4)
|
|
1088
1109
|
}));
|
|
1089
1110
|
const chapters = [];
|
|
1090
1111
|
const existing = new Set(project.chapters.map((c) => c.no));
|
|
@@ -1156,7 +1177,8 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
1156
1177
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1157
1178
|
system: reviewSystemPrompt(project),
|
|
1158
1179
|
user,
|
|
1159
|
-
temperature: .3
|
|
1180
|
+
temperature: .3,
|
|
1181
|
+
maxTokens: Math.max(config.maxTokens, 16e3)
|
|
1160
1182
|
}));
|
|
1161
1183
|
const issues = Array.isArray(raw.issues) ? raw.issues.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
|
|
1162
1184
|
severity: [
|
|
@@ -1181,6 +1203,40 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
1181
1203
|
saveProject(outputDir, project);
|
|
1182
1204
|
return report;
|
|
1183
1205
|
}
|
|
1206
|
+
/**
|
|
1207
|
+
* 审查「任意正文文本」(作者手动编辑后的草稿,不落盘)。
|
|
1208
|
+
* 复用审稿提示词与红线/道藏/反AI规则;仅返回报告,不改文件不改状态。
|
|
1209
|
+
*/
|
|
1210
|
+
async function reviewChapterText(ctx, config, project, text) {
|
|
1211
|
+
const user = [
|
|
1212
|
+
`书名:《${project.bookName}》`,
|
|
1213
|
+
"==================== 待审查正文 ====================",
|
|
1214
|
+
text.slice(0, 2e4)
|
|
1215
|
+
].join("\n");
|
|
1216
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1217
|
+
system: reviewSystemPrompt(project),
|
|
1218
|
+
user,
|
|
1219
|
+
temperature: .3,
|
|
1220
|
+
maxTokens: Math.max(config.maxTokens, 8e3)
|
|
1221
|
+
}));
|
|
1222
|
+
const issues = Array.isArray(raw.issues) ? raw.issues.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
|
|
1223
|
+
severity: [
|
|
1224
|
+
"high",
|
|
1225
|
+
"medium",
|
|
1226
|
+
"low"
|
|
1227
|
+
].includes(entry.severity) ? entry.severity : "medium",
|
|
1228
|
+
item: typeof entry.item === "string" ? entry.item : "",
|
|
1229
|
+
suggestion: typeof entry.suggestion === "string" ? entry.suggestion : ""
|
|
1230
|
+
})).filter((issue) => issue.item !== "") : [];
|
|
1231
|
+
const score = typeof raw.score === "number" ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60;
|
|
1232
|
+
return {
|
|
1233
|
+
score,
|
|
1234
|
+
passed: score >= config.reviewPassScore,
|
|
1235
|
+
verdict: typeof raw.verdict === "string" ? raw.verdict.slice(0, 200) : "",
|
|
1236
|
+
issues,
|
|
1237
|
+
reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1184
1240
|
/** Build the rewrite system prompt (fix review issues / instructions). */
|
|
1185
1241
|
function rewriteSystemPrompt(project) {
|
|
1186
1242
|
return writeSystemPrompt(project) + "\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。";
|
|
@@ -1359,7 +1415,17 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
|
|
|
1359
1415
|
if (existsSync(prevPath)) continuity = readFileSync(prevPath, "utf8").slice(-900);
|
|
1360
1416
|
}
|
|
1361
1417
|
const prevSummary = prev?.summary;
|
|
1362
|
-
const
|
|
1418
|
+
const allFacts = project.facts ?? [];
|
|
1419
|
+
const recentFacts = allFacts.slice(-20).map((f) => f.text);
|
|
1420
|
+
const beatsText = chapter.beats;
|
|
1421
|
+
const relatedFacts = allFacts.slice(-120).filter((f) => {
|
|
1422
|
+
const head = f.text.slice(0, 24);
|
|
1423
|
+
for (let i = 0; i + 3 <= head.length; i++) {
|
|
1424
|
+
const tri = head.slice(i, i + 3);
|
|
1425
|
+
if (tri.trim() !== "" && beatsText.includes(tri)) return true;
|
|
1426
|
+
}
|
|
1427
|
+
return false;
|
|
1428
|
+
}).slice(-15).map((f) => `[第${f.chapterNo}章] ${f.text}`);
|
|
1363
1429
|
const messages = [createUserMessage({
|
|
1364
1430
|
content: [{
|
|
1365
1431
|
type: "text",
|
|
@@ -1368,6 +1434,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
|
|
|
1368
1434
|
`本章剧情要点:${chapter.beats}`,
|
|
1369
1435
|
"",
|
|
1370
1436
|
recentFacts.length > 0 ? `本书已确立的事实(新写内容不得与之矛盾):\n${recentFacts.join("\n")}` : "",
|
|
1437
|
+
relatedFacts.length > 0 ? `本章相关的既往事实(同样不得违背):\n${relatedFacts.join("\n")}` : "",
|
|
1371
1438
|
prevSummary !== void 0 && prevSummary !== "" ? `上一章摘要:${prevSummary}` : "",
|
|
1372
1439
|
continuity !== "" ? `上一章结尾(用于衔接,不要复述):\n${continuity}` : "这是第一章,注意开篇要有吸引力。",
|
|
1373
1440
|
"",
|
|
@@ -1432,13 +1499,56 @@ async function summarizeChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
1432
1499
|
].join("\n"),
|
|
1433
1500
|
user: body.replace(/^#\s+.*$/m, "").trim(),
|
|
1434
1501
|
temperature: .3,
|
|
1435
|
-
maxTokens:
|
|
1502
|
+
maxTokens: Math.max(config.maxTokens, 4e3)
|
|
1436
1503
|
})).slice(0, 500);
|
|
1437
1504
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1438
1505
|
saveProject(outputDir, project);
|
|
1439
1506
|
return chapter.summary;
|
|
1440
1507
|
}
|
|
1441
1508
|
/**
|
|
1509
|
+
* 摘要 + 事实抽取合并为一次 LLM 调用(省一次调用与一次正文输入,
|
|
1510
|
+
* 批量生成时整体开销约省 25%)。
|
|
1511
|
+
* @returns 摘要与新增事实条数(失败返回空,调用方 best-effort)。
|
|
1512
|
+
*/
|
|
1513
|
+
async function summarizeAndExtractFacts(ctx, config, project, outputDir, chapterNo) {
|
|
1514
|
+
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
1515
|
+
if (chapter === void 0) return {
|
|
1516
|
+
summary: "",
|
|
1517
|
+
factCount: 0
|
|
1518
|
+
};
|
|
1519
|
+
const body = readChapterFile(outputDir, chapter);
|
|
1520
|
+
if (body === void 0) return {
|
|
1521
|
+
summary: "",
|
|
1522
|
+
factCount: 0
|
|
1523
|
+
};
|
|
1524
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1525
|
+
system: [
|
|
1526
|
+
"你是一位网文编辑。请为下面一章做两件事,输出合法 JSON 对象:",
|
|
1527
|
+
"{\"summary\": \"120-200字摘要,含关键事件/主角状态变化(境界资源伤势心境)/新增伏笔线索/角色关系变化,客观陈述不评价\", \"facts\": [\"已确立事实1\", \"…3-6条\"]}",
|
|
1528
|
+
"facts 指:本章明确写出的、对后续有约束力的事实——人物当前状态、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。",
|
|
1529
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
1530
|
+
].join("\n"),
|
|
1531
|
+
user: body.replace(/^#\s+.*$/m, "").trim(),
|
|
1532
|
+
temperature: .2,
|
|
1533
|
+
maxTokens: Math.max(config.maxTokens, 5e3)
|
|
1534
|
+
}));
|
|
1535
|
+
const summary = typeof raw.summary === "string" ? raw.summary.trim().slice(0, 500) : "";
|
|
1536
|
+
const factLines = Array.isArray(raw.facts) ? raw.facts.filter((v) => typeof v === "string" && v.trim().length > 8).map((v) => v.trim().slice(0, 140)) : [];
|
|
1537
|
+
if (summary !== "") chapter.summary = summary;
|
|
1538
|
+
const list = project.facts ?? [];
|
|
1539
|
+
for (const line of factLines.slice(0, 8)) list.push({
|
|
1540
|
+
chapterNo,
|
|
1541
|
+
text: line
|
|
1542
|
+
});
|
|
1543
|
+
project.facts = list.slice(-300);
|
|
1544
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1545
|
+
saveProject(outputDir, project);
|
|
1546
|
+
return {
|
|
1547
|
+
summary,
|
|
1548
|
+
factCount: factLines.length
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1442
1552
|
* 抽取本章「已确立事实」追加到事实库/时间线(最多 300 条,最新优先)。
|
|
1443
1553
|
* 事实注入后续章节生成提示词,保证人物状态/境界/资源/关系长期一致。
|
|
1444
1554
|
* @returns 新增事实条数(失败返回 0,调用方 best-effort)。
|
|
@@ -1472,13 +1582,12 @@ async function extractFacts(ctx, config, project, outputDir, chapterNo) {
|
|
|
1472
1582
|
saveProject(outputDir, project);
|
|
1473
1583
|
return lines.length;
|
|
1474
1584
|
}
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
if (written.length === 0) return [];
|
|
1585
|
+
const AUDIT_BATCH_SIZE = 10;
|
|
1586
|
+
/** 单批质检:设定 + 事实库 + 该批章节节选 → 矛盾清单。 */
|
|
1587
|
+
async function auditBatch(ctx, config, project, outputDir, batch) {
|
|
1479
1588
|
const system = [
|
|
1480
|
-
"
|
|
1481
|
-
"
|
|
1589
|
+
"你是一位严谨的网文连续性审校编辑。你会收到一本小说的设定圣经、事实库和一批章节正文节选。",
|
|
1590
|
+
"请找出这批章节中的一致性矛盾,例如:",
|
|
1482
1591
|
"- 人物状态冲突:境界/修为/伤势/资源在同一章内或跨章前后矛盾。",
|
|
1483
1592
|
"- 设定违背:正文与世界观规则、金手指规则、写作红线冲突。",
|
|
1484
1593
|
"- 时间线错乱:事件顺序、时间跨度、地点移动不合逻辑。",
|
|
@@ -1489,15 +1598,15 @@ async function auditBook(ctx, config, project, outputDir) {
|
|
|
1489
1598
|
"3. 输出必须是合法 JSON 数组,格式:[{\"chapterNo\": 章节号, \"severity\": \"high|medium|low\", \"item\": \"矛盾描述\", \"suggestion\": \"修改建议\"}]",
|
|
1490
1599
|
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
1491
1600
|
].join("\n");
|
|
1492
|
-
const factsBlock = (project.facts ?? []).slice(-
|
|
1493
|
-
const chapterBlocks =
|
|
1601
|
+
const factsBlock = (project.facts ?? []).slice(-60).map((f) => `[第${f.chapterNo}章] ${f.text}`).join("\n");
|
|
1602
|
+
const chapterBlocks = batch.map((c) => {
|
|
1494
1603
|
const excerpt = (readChapterFile(outputDir, c) ?? "").replace(/^#\s+.*$/m, "").trim().slice(0, 700);
|
|
1495
1604
|
return `【第${c.no}章《${c.title}》】\n${excerpt}`;
|
|
1496
1605
|
}).join("\n\n");
|
|
1497
1606
|
const parsed = parseJsonArray(await complete(ctx, config, {
|
|
1498
1607
|
system,
|
|
1499
1608
|
user: [
|
|
1500
|
-
"
|
|
1609
|
+
"请对以下小说做一致性质检。",
|
|
1501
1610
|
project.bible !== void 0 ? "设定圣经:\n" + [
|
|
1502
1611
|
project.bible.worldRules.length > 0 ? `世界规则:\n${project.bible.worldRules.map((r) => `- ${r}`).join("\n")}` : "",
|
|
1503
1612
|
project.bible.redLines.length > 0 ? `写作红线:\n${project.bible.redLines.map((r) => `- ${r}`).join("\n")}` : "",
|
|
@@ -1525,7 +1634,226 @@ async function auditBook(ctx, config, project, outputDir) {
|
|
|
1525
1634
|
suggestion: typeof entry.suggestion === "string" ? entry.suggestion : ""
|
|
1526
1635
|
});
|
|
1527
1636
|
}
|
|
1528
|
-
return issues
|
|
1637
|
+
return issues;
|
|
1638
|
+
}
|
|
1639
|
+
/** 全书一致性质检:LLM 分批扫描已生成章节 + 设定 + 事实库,聚合矛盾清单。 */
|
|
1640
|
+
async function auditBook(ctx, config, project, outputDir) {
|
|
1641
|
+
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
1642
|
+
if (written.length === 0) return [];
|
|
1643
|
+
const all = [];
|
|
1644
|
+
for (let i = 0; i < written.length; i += AUDIT_BATCH_SIZE) {
|
|
1645
|
+
const batch = written.slice(i, i + AUDIT_BATCH_SIZE);
|
|
1646
|
+
try {
|
|
1647
|
+
all.push(...await auditBatch(ctx, config, project, outputDir, batch));
|
|
1648
|
+
} catch {}
|
|
1649
|
+
}
|
|
1650
|
+
return all.slice(0, 50);
|
|
1651
|
+
}
|
|
1652
|
+
/** 小说简介:AI 生成或按已写开头补全(面向读者的作品门面)。 */
|
|
1653
|
+
async function generateBlurb(ctx, config, project, partial = "") {
|
|
1654
|
+
const system = [
|
|
1655
|
+
"你是一位网文平台编辑,擅长写抓人的作品简介。",
|
|
1656
|
+
"要求:",
|
|
1657
|
+
"1. 120-250 字,突出核心卖点(金手指/题材/爽点/人设反差),用一两句抛出开局钩子。",
|
|
1658
|
+
"2. 不剧透结局与关键反转;语气贴合题材(热血/悬疑/轻松/虐心)。",
|
|
1659
|
+
"3. 中文,直接输出简介正文,不要 Markdown、不要引号包裹、不要「简介:」前缀。"
|
|
1660
|
+
].join("\n");
|
|
1661
|
+
const genreBlock = project.bible?.genre !== void 0 ? `题材:${project.bible.genre}` : "";
|
|
1662
|
+
const volumeBlock = (project.volumes ?? []).slice(0, 3).map((v) => v.title).join("、");
|
|
1663
|
+
return (await complete(ctx, config, {
|
|
1664
|
+
system,
|
|
1665
|
+
user: [
|
|
1666
|
+
`书名:《${project.bookName}》`,
|
|
1667
|
+
genreBlock,
|
|
1668
|
+
volumeBlock !== "" ? `卷结构:${volumeBlock}` : "",
|
|
1669
|
+
`已写章节数:${project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating").length}`,
|
|
1670
|
+
"大纲节选:\n" + project.outline.slice(0, 2500),
|
|
1671
|
+
partial.trim() !== "" ? `已有开头草稿(请保留其内容与语气,续写补全为完整简介):\n${partial.trim()}` : "请全量生成一份完整简介。"
|
|
1672
|
+
].filter((s) => s !== "").join("\n\n"),
|
|
1673
|
+
temperature: .7,
|
|
1674
|
+
maxTokens: Math.max(config.maxTokens, 4e3)
|
|
1675
|
+
})).replace(/^["'「『]|["'」』]$/g, "").replace(/^简介[::]\s*/, "").trim().slice(0, 600);
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* 组装全书上下文包(AI 助手 book_overview 工具)。
|
|
1679
|
+
* 分片策略:章节要点默认只给最近 30 章(避免超长后爆上下文);
|
|
1680
|
+
* scope='full' 全量;scope=数字 只给该卷章节。
|
|
1681
|
+
*/
|
|
1682
|
+
function bookOverview(project, scope = "recent") {
|
|
1683
|
+
const s = [];
|
|
1684
|
+
s.push(`书名:${project.bookName}`);
|
|
1685
|
+
s.push(`【大纲全文】\n${project.outline}`);
|
|
1686
|
+
if (project.bible !== void 0) {
|
|
1687
|
+
const bible = project.bible;
|
|
1688
|
+
s.push("【设定圣经】");
|
|
1689
|
+
if (bible.genre !== "") s.push(`题材基调:${bible.genre}`);
|
|
1690
|
+
if (bible.worldRules.length > 0) s.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
|
|
1691
|
+
if (bible.characters.length > 0) {
|
|
1692
|
+
s.push("角色卡:");
|
|
1693
|
+
for (const card of bible.characters) {
|
|
1694
|
+
const roleName = {
|
|
1695
|
+
protagonist: "主角",
|
|
1696
|
+
supporting: "配角",
|
|
1697
|
+
antagonist: "反派",
|
|
1698
|
+
other: "其他"
|
|
1699
|
+
}[card.role];
|
|
1700
|
+
s.push(`- ${card.name}(${roleName}):${card.traits.join("、")}${card.goals !== "" ? `;目标:${card.goals}` : ""}${card.relations !== "" ? `;关系:${card.relations}` : ""}`);
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
if (bible.redLines.length > 0) s.push("写作红线:\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
|
|
1704
|
+
if (bible.style.length > 0) s.push("风格要求:\n" + bible.style.map((r) => `- ${r}`).join("\n"));
|
|
1705
|
+
}
|
|
1706
|
+
const worldBlock = renderWorld(project.world);
|
|
1707
|
+
if (worldBlock !== "") s.push(worldBlock);
|
|
1708
|
+
if (project.volumes !== void 0 && project.volumes.length > 0) {
|
|
1709
|
+
s.push("【卷结构】");
|
|
1710
|
+
for (const v of project.volumes) s.push(`第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})`);
|
|
1711
|
+
}
|
|
1712
|
+
if (project.chapters.length > 0) {
|
|
1713
|
+
const maxNo = project.chapters.reduce((m, c) => Math.max(m, c.no), 0);
|
|
1714
|
+
const shown = project.chapters.filter((c) => {
|
|
1715
|
+
if (scope === "full") return true;
|
|
1716
|
+
if (typeof scope === "number") return c.volume === scope;
|
|
1717
|
+
return c.no > Math.max(0, maxNo - 30);
|
|
1718
|
+
});
|
|
1719
|
+
const label = scope === "full" ? "全部章节(标题/状态/剧情要点/摘要)" : typeof scope === "number" ? `第 ${scope} 卷章节(标题/状态/剧情要点/摘要)` : `最近 ${shown.length} 章(标题/状态/剧情要点/摘要)`;
|
|
1720
|
+
s.push(`【${label}】`);
|
|
1721
|
+
const statusText = {
|
|
1722
|
+
pending: "待生成",
|
|
1723
|
+
generating: "生成中",
|
|
1724
|
+
written: "待审稿",
|
|
1725
|
+
reviewing: "审稿中",
|
|
1726
|
+
approved: "已通过",
|
|
1727
|
+
rejected: "待修订",
|
|
1728
|
+
error: "失败"
|
|
1729
|
+
};
|
|
1730
|
+
for (const c of shown) s.push(`第${c.no}章《${c.title}》[${statusText[c.status] ?? c.status}]${c.chars !== void 0 ? ` ${c.chars}字` : ""}\n剧情要点:${c.beats}\n摘要:${c.summary ?? "无"}`);
|
|
1731
|
+
if (scope !== "full" && project.chapters.length > shown.length) s.push(`(还有 ${project.chapters.length - shown.length} 章未列出,可用 scope=volume:N 查看指定卷)`);
|
|
1732
|
+
}
|
|
1733
|
+
if ((project.facts ?? []).length > 0) {
|
|
1734
|
+
s.push("【事实库(最近 40 条;更多用 facts_query 检索)】");
|
|
1735
|
+
for (const f of (project.facts ?? []).slice(-40)) s.push(`- [第${f.chapterNo}章] ${f.text}`);
|
|
1736
|
+
}
|
|
1737
|
+
if (project.foreshadows.length > 0) {
|
|
1738
|
+
s.push("【伏笔】");
|
|
1739
|
+
for (const f of project.foreshadows) s.push(`- [${f.status}] ${f.description}${f.targetChapter !== void 0 ? `(预计 ${f.targetChapter} 章回收)` : ""}`);
|
|
1740
|
+
}
|
|
1741
|
+
if (project.blurb !== void 0 && project.blurb !== "") s.push(`【小说简介】${project.blurb}`);
|
|
1742
|
+
return s.join("\n\n");
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* 影响分析:LLM 扫描全书(大纲/设定/大世界/事实库/已写章节),
|
|
1746
|
+
* 定位一次改动波及的所有位置。助手在修改后主动调用,做连锁维护。
|
|
1747
|
+
*/
|
|
1748
|
+
async function analyzeImpact(ctx, config, project, outputDir, change) {
|
|
1749
|
+
const system = [
|
|
1750
|
+
"你是一位网文一致性审校。作者要做一处修改,请找出这次改动会波及的所有位置(设定、大纲、已写章节正文、事实库、简介中可能因此过时或矛盾的内容)。",
|
|
1751
|
+
"输出必须是合法 JSON 数组,格式:[{\"location\": \"位置(第N章/大纲/设定圣经-世界规则/大世界-境界/事实库/简介)\", \"quote\": \"原文片段(20-60字)\", \"suggestion\": \"修改建议\", \"kind\": \"must|optional|note\"}]",
|
|
1752
|
+
"kind 含义:must=必须同步改否则矛盾;optional=建议改(影响观感);note=备注(如旧称保留为古称、或无需改但需知晓)。",
|
|
1753
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
1754
|
+
].join("\n");
|
|
1755
|
+
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
1756
|
+
const base = [
|
|
1757
|
+
`要做的修改:${change}`,
|
|
1758
|
+
"以下为全书设定与规则要点(章节为分批节选):",
|
|
1759
|
+
`大纲节选:\n${project.outline.slice(0, 2e3)}`,
|
|
1760
|
+
project.bible !== void 0 ? `道藏:${project.bible.worldRules.length} 条世界规则 / ${project.bible.redLines.length} 条红线 / 人物 ${project.bible.characters.map((c) => c.name).join("、")}` : "",
|
|
1761
|
+
(project.facts ?? []).length > 0 ? `编年录最近 40 条:\n${(project.facts ?? []).slice(-40).map((f) => `[第${f.chapterNo}章] ${f.text}`).join("\n")}` : ""
|
|
1762
|
+
].filter((s) => s !== "").join("\n\n");
|
|
1763
|
+
const items = [];
|
|
1764
|
+
const IMPACT_BATCH_SIZE = 8;
|
|
1765
|
+
for (let i = 0; i < written.length; i += IMPACT_BATCH_SIZE) {
|
|
1766
|
+
const batch = written.slice(i, i + IMPACT_BATCH_SIZE);
|
|
1767
|
+
const chapterBlock = batch.map((c) => {
|
|
1768
|
+
const excerpt = (readChapterFile(outputDir, c) ?? "").replace(/^#\s+.*$/m, "").trim().slice(0, 500);
|
|
1769
|
+
return `【第${c.no}章《${c.title}》】\n${excerpt}`;
|
|
1770
|
+
}).join("\n\n");
|
|
1771
|
+
const user = `${base}\n\n本批章节(第 ${batch[0].no}-${batch[batch.length - 1].no} 章):\n${chapterBlock}\n\n只输出 JSON 数组。`;
|
|
1772
|
+
try {
|
|
1773
|
+
const text = await complete(ctx, config, {
|
|
1774
|
+
system,
|
|
1775
|
+
user,
|
|
1776
|
+
temperature: .2,
|
|
1777
|
+
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
1778
|
+
});
|
|
1779
|
+
for (const entry of parseJsonArray(text)) {
|
|
1780
|
+
const quote = typeof entry.quote === "string" ? entry.quote.trim() : "";
|
|
1781
|
+
if (quote === "") continue;
|
|
1782
|
+
items.push({
|
|
1783
|
+
location: typeof entry.location === "string" ? entry.location : "未定位",
|
|
1784
|
+
quote: quote.slice(0, 120),
|
|
1785
|
+
suggestion: typeof entry.suggestion === "string" ? entry.suggestion : "",
|
|
1786
|
+
kind: entry.kind === "must" || entry.kind === "optional" || entry.kind === "note" ? entry.kind : "optional"
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
} catch {}
|
|
1790
|
+
}
|
|
1791
|
+
return items.slice(0, 30);
|
|
1792
|
+
}
|
|
1793
|
+
/** 把大世界结构化数据渲染成提示词块(境界体系按顺序强约束)。 */
|
|
1794
|
+
function renderWorld(world) {
|
|
1795
|
+
if (world === void 0) return "";
|
|
1796
|
+
const sections = ["==================== 大世界(结构化设定,写作时严格遵守) ===================="];
|
|
1797
|
+
if (world.realms.length > 0) {
|
|
1798
|
+
sections.push("境界体系(由低到高,不得随意跳级或自创境界):");
|
|
1799
|
+
world.realms.forEach((realm, i) => {
|
|
1800
|
+
sections.push(`${i + 1}. ${realm.name}${realm.description !== "" ? ` — ${realm.description}` : ""}`);
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
if (world.regions.length > 0) {
|
|
1804
|
+
sections.push("地理区域:");
|
|
1805
|
+
for (const region of world.regions) sections.push(`- ${region.name}${region.description !== "" ? `:${region.description}` : ""}${region.faction !== void 0 && region.faction !== "" ? `(势力:${region.faction})` : ""}`);
|
|
1806
|
+
}
|
|
1807
|
+
if (world.factions.length > 0) {
|
|
1808
|
+
sections.push("势力分布:");
|
|
1809
|
+
for (const faction of world.factions) sections.push(`- ${faction.name}(${faction.kind})${faction.description !== "" ? `:${faction.description}` : ""}${faction.region !== void 0 && faction.region !== "" ? `(驻地:${faction.region})` : ""}`);
|
|
1810
|
+
}
|
|
1811
|
+
return sections.join("\n");
|
|
1812
|
+
}
|
|
1813
|
+
/** AI 提炼大世界:从大纲 + 设定圣经生成结构化境界体系/区域/势力。 */
|
|
1814
|
+
async function extractWorld(ctx, config, project) {
|
|
1815
|
+
const system = [
|
|
1816
|
+
"你是一位网文世界观架构师。请根据小说大纲与设定圣经,提炼结构化「大世界」数据。",
|
|
1817
|
+
"输出必须是合法 JSON 对象:",
|
|
1818
|
+
"{\"realms\": [{\"name\": \"境界名\", \"description\": \"突破条件/寿命/标志等\"}], \"regions\": [{\"name\": \"区域名\", \"description\": \"描述\", \"faction\": \"关联势力名或空\"}], \"factions\": [{\"name\": \"势力名\", \"kind\": \"宗门/家族/王朝/组织等\", \"description\": \"描述\", \"region\": \"驻地区域或空\"}]}",
|
|
1819
|
+
"要求:",
|
|
1820
|
+
"1. realms 按由低到高顺序排列(修仙题材必须含完整境界链;无境界设定的题材可输出空数组)。",
|
|
1821
|
+
"2. 数量贴合大纲:realms 3-12 个,regions 2-10 个,factions 2-10 个。",
|
|
1822
|
+
"3. 内容严格来自大纲与设定圣经,不要凭空发明与大纲冲突的设定。",
|
|
1823
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
1824
|
+
].join("\n");
|
|
1825
|
+
const bibleBlock = project.bible !== void 0 ? [project.bible.genre !== "" ? `题材:${project.bible.genre}` : "", project.bible.worldRules.length > 0 ? `世界规则:\n${project.bible.worldRules.map((r) => `- ${r}`).join("\n")}` : ""].filter((s) => s !== "").join("\n") : "";
|
|
1826
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1827
|
+
system,
|
|
1828
|
+
user: [
|
|
1829
|
+
"请为这部小说提炼大世界数据。",
|
|
1830
|
+
`书名:《${project.bookName}》`,
|
|
1831
|
+
bibleBlock !== "" ? bibleBlock : "",
|
|
1832
|
+
"大纲:\n" + project.outline.slice(0, 5e3),
|
|
1833
|
+
"只输出 JSON 对象。"
|
|
1834
|
+
].filter((s) => s !== "").join("\n\n"),
|
|
1835
|
+
temperature: .3,
|
|
1836
|
+
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
1837
|
+
}));
|
|
1838
|
+
const str = (value) => typeof value === "string" ? value.trim() : "";
|
|
1839
|
+
const objArray = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "object" && v !== null) : [];
|
|
1840
|
+
return {
|
|
1841
|
+
realms: objArray(raw.realms).map((entry) => ({
|
|
1842
|
+
name: str(entry.name).slice(0, 20) || "未命名境界",
|
|
1843
|
+
description: str(entry.description).slice(0, 200)
|
|
1844
|
+
})).filter((r) => r.name !== "未命名境界" || r.description !== ""),
|
|
1845
|
+
regions: objArray(raw.regions).map((entry) => ({
|
|
1846
|
+
name: str(entry.name).slice(0, 30) || "未命名区域",
|
|
1847
|
+
description: str(entry.description).slice(0, 200),
|
|
1848
|
+
faction: str(entry.faction).slice(0, 30)
|
|
1849
|
+
})).filter((r) => r.name !== "未命名区域" || r.description !== ""),
|
|
1850
|
+
factions: objArray(raw.factions).map((entry) => ({
|
|
1851
|
+
name: str(entry.name).slice(0, 30) || "未命名势力",
|
|
1852
|
+
kind: str(entry.kind).slice(0, 20) || "组织",
|
|
1853
|
+
description: str(entry.description).slice(0, 200),
|
|
1854
|
+
region: str(entry.region).slice(0, 30)
|
|
1855
|
+
})).filter((f) => f.name !== "未命名势力" || f.description !== "")
|
|
1856
|
+
};
|
|
1529
1857
|
}
|
|
1530
1858
|
/**
|
|
1531
1859
|
* 事实库回填:对历史已生成章节批量抽取事实(无事实记录的旧章节)。
|
|
@@ -1639,7 +1967,8 @@ async function suggestForeshadows(ctx, config, project) {
|
|
|
1639
1967
|
const parsed = parseJsonArray(await complete(ctx, config, {
|
|
1640
1968
|
system: foreshadowSystemPrompt(),
|
|
1641
1969
|
user,
|
|
1642
|
-
temperature: .5
|
|
1970
|
+
temperature: .5,
|
|
1971
|
+
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
1643
1972
|
}));
|
|
1644
1973
|
const existing = new Set(project.foreshadows.map((f) => f.description));
|
|
1645
1974
|
const created = [];
|
|
@@ -1669,7 +1998,8 @@ async function extractStyleAsset(ctx, config, sampleText) {
|
|
|
1669
1998
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1670
1999
|
system: styleEngineSystemPrompt(),
|
|
1671
2000
|
user,
|
|
1672
|
-
temperature: .3
|
|
2001
|
+
temperature: .3,
|
|
2002
|
+
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
1673
2003
|
}));
|
|
1674
2004
|
const strArray = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string" && v.trim() !== "") : [];
|
|
1675
2005
|
const result = {
|
|
@@ -1738,15 +2068,33 @@ function loadAssistantHistory(outputDir) {
|
|
|
1738
2068
|
} catch {}
|
|
1739
2069
|
return messages;
|
|
1740
2070
|
}
|
|
1741
|
-
/** Append one message to the persisted history.
|
|
2071
|
+
/** Append one message to the persisted history. Tool payloads (e.g. full
|
|
2072
|
+
* outline / chapter text) are capped so the jsonl and later LLM context
|
|
2073
|
+
* don't grow unboundedly. */
|
|
1742
2074
|
function appendHistory(outputDir, message) {
|
|
1743
2075
|
mkdirSync(outputDir, { recursive: true });
|
|
1744
|
-
|
|
2076
|
+
const entry = message.role === "tool" && message.content.length > 4e3 ? {
|
|
2077
|
+
...message,
|
|
2078
|
+
content: message.content.slice(0, 4e3) + "\n…(已截断,如需完整内容请重新调用工具)"
|
|
2079
|
+
} : message;
|
|
2080
|
+
appendFileSync(join(outputDir, ASSISTANT_HISTORY_FILE), JSON.stringify(entry) + "\n", "utf8");
|
|
2081
|
+
}
|
|
2082
|
+
/** 清空助手对话记录(删除历史文件)。 */
|
|
2083
|
+
function clearAssistantHistory(outputDir) {
|
|
2084
|
+
const file = join(outputDir, ASSISTANT_HISTORY_FILE);
|
|
2085
|
+
if (existsSync(file)) rmSync(file, { force: true });
|
|
1745
2086
|
}
|
|
1746
2087
|
/** Render the project snapshot the assistant sees. */
|
|
1747
2088
|
function renderProjectSnapshot(project) {
|
|
1748
2089
|
const sections = [];
|
|
1749
2090
|
sections.push(`书名:${project.bookName}`);
|
|
2091
|
+
sections.push(`大纲节选(如需全文用 outline_text 工具):\n${project.outline.slice(0, 2500)}`);
|
|
2092
|
+
const assetNames = [];
|
|
2093
|
+
if (project.assets?.genre !== void 0) assetNames.push(`题材:${project.assets.genre.name}`);
|
|
2094
|
+
if (project.assets?.primaryProgression !== void 0) assetNames.push(`主推进:${project.assets.primaryProgression.name}`);
|
|
2095
|
+
if ((project.assets?.styleAssets?.length ?? 0) > 0) assetNames.push(`写法:${project.assets.styleAssets.map((s) => s.name).join("、")}`);
|
|
2096
|
+
if ((project.assets?.antiAiRules?.length ?? 0) > 0) assetNames.push(`文戒自定义:${project.assets.antiAiRules.map((r) => r.name).join("、")}`);
|
|
2097
|
+
if (assetNames.length > 0) sections.push(`【写作资产】${assetNames.join(" · ")}`);
|
|
1750
2098
|
if (project.bible !== void 0) {
|
|
1751
2099
|
const bible = project.bible;
|
|
1752
2100
|
sections.push("【设定圣经】");
|
|
@@ -1766,13 +2114,21 @@ function renderProjectSnapshot(project) {
|
|
|
1766
2114
|
}
|
|
1767
2115
|
if (bible.redLines.length > 0) sections.push("写作红线:\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
|
|
1768
2116
|
}
|
|
2117
|
+
if (project.world !== void 0) {
|
|
2118
|
+
const world = project.world;
|
|
2119
|
+
sections.push("【大世界】");
|
|
2120
|
+
if (world.realms.length > 0) sections.push("境界体系:" + world.realms.map((r, i) => `${i + 1}.${r.name}(${r.description.slice(0, 40)})`).join(" → "));
|
|
2121
|
+
if (world.regions.length > 0) sections.push("区域:" + world.regions.map((r) => r.name).join("、"));
|
|
2122
|
+
if (world.factions.length > 0) sections.push("势力:" + world.factions.map((f) => `${f.name}(${f.kind})`).join("、"));
|
|
2123
|
+
}
|
|
1769
2124
|
if (project.volumes !== void 0 && project.volumes.length > 0) {
|
|
1770
2125
|
sections.push("【卷结构】");
|
|
1771
2126
|
for (const v of project.volumes) sections.push(`第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})`);
|
|
1772
2127
|
}
|
|
1773
2128
|
if (project.chapters.length > 0) {
|
|
1774
|
-
|
|
1775
|
-
|
|
2129
|
+
const shown = project.chapters.slice(-30);
|
|
2130
|
+
sections.push(`【章节计划与进度(最近 ${shown.length} 章)】`);
|
|
2131
|
+
for (const c of shown) {
|
|
1776
2132
|
const statusText = {
|
|
1777
2133
|
pending: "待生成",
|
|
1778
2134
|
generating: "生成中",
|
|
@@ -1784,27 +2140,48 @@ function renderProjectSnapshot(project) {
|
|
|
1784
2140
|
}[c.status];
|
|
1785
2141
|
sections.push(`第${c.no}章《${c.title}》[${statusText}]${c.chars !== void 0 ? ` ${c.chars}字` : ""}${c.summary !== void 0 && c.summary !== "" ? ` 摘要:${c.summary}` : ""}`);
|
|
1786
2142
|
}
|
|
2143
|
+
if (project.chapters.length > shown.length) sections.push(`(还有 ${project.chapters.length - shown.length} 章未列出,可用 book_overview scope=volume:N 查看)`);
|
|
2144
|
+
const recent = project.chapters.filter((c) => c.status === "approved" || c.status === "written").slice(-2);
|
|
2145
|
+
if (recent.length > 0) {
|
|
2146
|
+
sections.push("【最近章节正文节选】");
|
|
2147
|
+
for (const c of recent) sections.push(`第${c.no}章《${c.title}》(节选,如需全文用 chapter_text):${c.beats.slice(0, 300)}`);
|
|
2148
|
+
}
|
|
1787
2149
|
}
|
|
1788
2150
|
if (project.foreshadows.length > 0) {
|
|
1789
2151
|
sections.push("【伏笔】");
|
|
1790
2152
|
for (const f of project.foreshadows) sections.push(`- [${f.status}] ${f.description}${f.targetChapter !== void 0 ? `(预计 ${f.targetChapter} 章回收)` : ""}`);
|
|
1791
2153
|
}
|
|
2154
|
+
if ((project.facts ?? []).length > 0) {
|
|
2155
|
+
sections.push("【已确立事实库(最近 40 条,回答设定问题必须遵守)】");
|
|
2156
|
+
for (const f of (project.facts ?? []).slice(-40)) sections.push(`- [第${f.chapterNo}章] ${f.text}`);
|
|
2157
|
+
}
|
|
2158
|
+
if (project.blurb !== void 0 && project.blurb !== "") sections.push(`【小说简介】${project.blurb}`);
|
|
1792
2159
|
return sections.join("\n");
|
|
1793
2160
|
}
|
|
1794
2161
|
/** The assistant system prompt. */
|
|
1795
2162
|
function assistantSystemPrompt(project) {
|
|
1796
2163
|
return [
|
|
1797
|
-
"
|
|
2164
|
+
"你是「编辑老师」——服务这本书作者的资深中文网文编辑。",
|
|
2165
|
+
"人设:二十年网文老编辑,懂套路、懂市场、懂节奏,说话直接但句句有用。",
|
|
2166
|
+
"座右铭:「书是你的,但坑我替你盯着。」",
|
|
2167
|
+
"职责边界:陪作者讨论剧情/人设/世界观/爽点节奏并落地修改、维护全书一致性;不闲聊、不彩虹屁、不无意义长篇大论。",
|
|
1798
2168
|
"==================== 当前项目快照 ====================",
|
|
1799
2169
|
renderProjectSnapshot(project),
|
|
1800
2170
|
"==================== 快照结束 ====================",
|
|
1801
2171
|
"",
|
|
1802
|
-
"
|
|
1803
|
-
"1.
|
|
1804
|
-
"2.
|
|
1805
|
-
"
|
|
2172
|
+
"工作规则(严格遵守):",
|
|
2173
|
+
"1. 全量知情:回答和修改必须基于项目真实数据,禁止编造书中不存在的设定。需要完整信息时,先调用 book_overview 获取全书上下文(大纲全文/设定圣经/大世界/事实库/全部章节要点/伏笔/简介);需要某章正文用 chapter_text。",
|
|
2174
|
+
"2. 修改流程:改前用一句话说明意图 → 执行工具 → 改后简要汇报。",
|
|
2175
|
+
"3. 连锁维护:改动可能波及其它位置(其它章节、设定、事实库、简介)时,执行后主动调用 impact_analysis 分析影响面,并把「必须同步」的项一并处理或明确提示作者逐项确认。",
|
|
2176
|
+
"4. 删除红线:删除章节、清空设定等破坏性操作必须等作者明确同意。",
|
|
2177
|
+
"5. 品质门槛:建议必须具体——指出问题在哪一章、哪一段、哪一句,并给出可落地的改法;禁止\"建议增强冲突\"这类空话。",
|
|
2178
|
+
"6. 设定忠诚:忠于大纲、设定圣经、大世界、事实库;发现书中已有内容与设定冲突时,主动指出并给修正方案。",
|
|
2179
|
+
"7. 中文回复,简洁有干货。",
|
|
1806
2180
|
"",
|
|
1807
2181
|
"可用工具:",
|
|
2182
|
+
"- book_overview:{\"scope\": \"recent|full|volume:2\"(可选,默认 recent)}。返回全书上下文包(大纲/道藏/大世界/章节要点/事实库/伏笔/简介)。recent=最近30章;full=全部章节(书很长时慎用);volume:N=只看第N卷。",
|
|
2183
|
+
"- facts_query:{\"keyword\": \"关键词\"}。从编年录(事实库)按关键词检索相关事实(如灵石、境界名、人物名)。",
|
|
2184
|
+
"- impact_analysis:{\"change\": \"要做的修改描述\"}。分析这次改动会波及哪些位置,返回影响清单(定位到章节/设定/事实库)。",
|
|
1808
2185
|
"- outline_text:无参数。返回当前大纲全文。",
|
|
1809
2186
|
"- outline_replace:{\"old\": \"要替换的原文片段\", \"new\": \"新文本\"}。在大纲中替换一段文字(old 必须能在大纲中找到)。",
|
|
1810
2187
|
"- bible_set_rule:{\"index\": 序号(0起), \"text\": \"新规则文本\"} 或 {\"append\": \"追加的规则\"}。修改设定圣经的世界规则。",
|
|
@@ -1819,7 +2196,14 @@ function assistantSystemPrompt(project) {
|
|
|
1819
2196
|
"- assets_status:无参数。查看本书当前写作资产(题材/推进模式/反AI规则/写法)。",
|
|
1820
2197
|
"- assets_set_genre:{\"name\": \"题材名\", \"description\": \"题材说明(可选)\"}。设置本书题材基底。",
|
|
1821
2198
|
"- assets_set_progression:{\"name\": \"模式名\", \"driver\": \"驱动力\", \"primary\": true/false}。设置主/辅助推进模式。",
|
|
1822
|
-
"- assets_add_rule:{\"name\": \"规则名(可选)\", \"avoid\": \"要避免的表达问题\", \"fix\": \"修正方向(可选)
|
|
2199
|
+
"- assets_add_rule:{\"name\": \"规则名(可选)\", \"avoid\": \"要避免的表达问题\", \"fix\": \"修正方向(可选)}。新增反 AI 规则。",
|
|
2200
|
+
"",
|
|
2201
|
+
"回答质量要求(非常重要):",
|
|
2202
|
+
"- 具体:回答必须引用项目里的真实内容(人名、境界、章节、伏笔、设定),禁止空泛套话。快照里没有的信息,先调用工具获取(chapter_text / outline_text)再回答。",
|
|
2203
|
+
"- 专业:给建议时说明理由,指出问题所在章节/段落,给出可直接落地的修改方案(改什么、怎么改)。",
|
|
2204
|
+
"- 主动:作者说\"改一下\",主动调用对应工具执行,不要只给建议不动手;执行前用一句话说明意图,执行后简短汇报结果。",
|
|
2205
|
+
"- 忠于设定:以大纲、设定圣经、事实库为准,不得自相矛盾;发现问题(如剧情与设定冲突)主动指出。",
|
|
2206
|
+
"- 中文回复;文字量适中,别啰嗦。",
|
|
1823
2207
|
"",
|
|
1824
2208
|
"使用规则(非常重要):",
|
|
1825
2209
|
"- 当你想执行任何工具时,你的【整个回复】必须只包含动作指令标签,格式如下(不要有任何解释文字、不要用自然语言说\"我要去改\",直接输出标签):",
|
|
@@ -1833,7 +2217,7 @@ function assistantSystemPrompt(project) {
|
|
|
1833
2217
|
"- 如果工具执行失败(例如片段未找到),根据错误信息修正参数后自动重试一次,不要直接放弃或让作者手动操作。",
|
|
1834
2218
|
"- 修改前先向作者说明你要改什么、为什么;动作执行后简要汇报结果。",
|
|
1835
2219
|
"- 涉及删除类操作(删除章节、清空设定)必须等作者明确同意。",
|
|
1836
|
-
"-
|
|
2220
|
+
"- 严格忠于设定圣经与大纲;不得自行发明与既有设定冲突的内容。",
|
|
1837
2221
|
"- 用中文回复。"
|
|
1838
2222
|
].join("\n");
|
|
1839
2223
|
}
|
|
@@ -1851,6 +2235,25 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
|
|
|
1851
2235
|
for await (const step of stream) if (step.frame === "delta") yield step.text;
|
|
1852
2236
|
};
|
|
1853
2237
|
switch (name) {
|
|
2238
|
+
case "book_overview": {
|
|
2239
|
+
const scopeArg = str(args.scope);
|
|
2240
|
+
return bookOverview(project, scopeArg === "full" ? "full" : /^volume:(\d+)$/.test(scopeArg) ? Number(scopeArg.slice(7)) : "recent");
|
|
2241
|
+
}
|
|
2242
|
+
case "facts_query": {
|
|
2243
|
+
const keyword = str(args.keyword).trim();
|
|
2244
|
+
if (keyword === "") throw new Error("facts_query 需要 keyword");
|
|
2245
|
+
const hits = (project.facts ?? []).filter((f) => f.text.includes(keyword)).slice(-30);
|
|
2246
|
+
if (hits.length === 0) return `编年录中未找到与「${keyword}」相关的事实记录。`;
|
|
2247
|
+
return `编年录中与「${keyword}」相关的事实(${hits.length} 条):\n` + hits.map((f) => `- [第${f.chapterNo}章] ${f.text}`).join("\n");
|
|
2248
|
+
}
|
|
2249
|
+
case "impact_analysis": {
|
|
2250
|
+
const change = str(args.change);
|
|
2251
|
+
if (change === "") throw new Error("impact_analysis 需要 change(要做的修改描述)");
|
|
2252
|
+
const items = await analyzeImpact(ctx, config, project, outputDir, change);
|
|
2253
|
+
if (items.length === 0) return "影响分析:未发现需要同步修改的位置。";
|
|
2254
|
+
const lines = items.map((it, i) => `${i + 1}. [${it.location}]「${it.quote}」${it.suggestion !== "" ? ` → ${it.suggestion}` : ""}(${it.kind === "must" ? "必须同步" : it.kind === "optional" ? "建议" : "备注"})`);
|
|
2255
|
+
return `影响分析:这次改动波及 ${items.length} 处——\n${lines.join("\n")}\n请据此提示作者逐项处理;章节内的修改可引导作者在工作区查看。`;
|
|
2256
|
+
}
|
|
1854
2257
|
case "outline_text": return project.outline;
|
|
1855
2258
|
case "outline_replace": {
|
|
1856
2259
|
const old = str(args.old);
|
|
@@ -1862,7 +2265,7 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
|
|
|
1862
2265
|
return `大纲已修改:替换了 ${old.length} 字符的片段。`;
|
|
1863
2266
|
}
|
|
1864
2267
|
case "bible_set_rule": {
|
|
1865
|
-
if (project.bible === void 0) throw new Error("
|
|
2268
|
+
if (project.bible === void 0) throw new Error("尚无道藏,请先提炼");
|
|
1866
2269
|
const index = num(args.index);
|
|
1867
2270
|
if (index !== void 0) project.bible.worldRules[index] = str(args.text);
|
|
1868
2271
|
else if (str(args.append) !== "") project.bible.worldRules.push(str(args.append));
|
|
@@ -1872,7 +2275,7 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
|
|
|
1872
2275
|
return `世界规则已更新(当前 ${project.bible.worldRules.length} 条)。`;
|
|
1873
2276
|
}
|
|
1874
2277
|
case "bible_set_redline": {
|
|
1875
|
-
if (project.bible === void 0) throw new Error("
|
|
2278
|
+
if (project.bible === void 0) throw new Error("尚无道藏,请先提炼");
|
|
1876
2279
|
const index = num(args.index);
|
|
1877
2280
|
if (index !== void 0) project.bible.redLines[index] = str(args.text);
|
|
1878
2281
|
else if (str(args.append) !== "") project.bible.redLines.push(str(args.append));
|
|
@@ -1910,9 +2313,9 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
|
|
|
1910
2313
|
chapter.error = void 0;
|
|
1911
2314
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1912
2315
|
saveProject(outputDir, project);
|
|
1913
|
-
yield "
|
|
2316
|
+
yield "(已采纳修订稿,正在生成章节摘要与编年录…)";
|
|
1914
2317
|
try {
|
|
1915
|
-
await
|
|
2318
|
+
await summarizeAndExtractFacts(ctx, config, project, outputDir, no);
|
|
1916
2319
|
} catch {}
|
|
1917
2320
|
yield "(正在 AI 审稿…)";
|
|
1918
2321
|
const report = await reviewChapter(ctx, config, project, outputDir, no);
|
|
@@ -1922,12 +2325,9 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
|
|
|
1922
2325
|
const no = num(args.no);
|
|
1923
2326
|
if (no === void 0) throw new Error("chapter_generate 需要 no");
|
|
1924
2327
|
for await (const chunk of forward(generateChapterStream(ctx, config, project, outputDir, no))) yield chunk;
|
|
1925
|
-
yield "
|
|
1926
|
-
try {
|
|
1927
|
-
await summarizeChapter(ctx, config, project, outputDir, no);
|
|
1928
|
-
} catch {}
|
|
2328
|
+
yield "(正在生成章节摘要与编年录…)";
|
|
1929
2329
|
try {
|
|
1930
|
-
await
|
|
2330
|
+
await summarizeAndExtractFacts(ctx, config, project, outputDir, no);
|
|
1931
2331
|
} catch {}
|
|
1932
2332
|
yield "(正在 AI 审稿…)";
|
|
1933
2333
|
const report = await reviewChapter(ctx, config, project, outputDir, no);
|
|
@@ -2044,11 +2444,11 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
|
|
|
2044
2444
|
default: throw new Error(`未知工具 ${name}`);
|
|
2045
2445
|
}
|
|
2046
2446
|
}
|
|
2047
|
-
/** Extract the first action directive from a reply. */
|
|
2447
|
+
/** Extract the first action directive from a reply (tolerant to common tag misspellings). */
|
|
2048
2448
|
function extractAction(reply) {
|
|
2049
|
-
const match = /<
|
|
2449
|
+
const match = /<([a-z_]*d[a-z]?sh?-action)\s+name="([^"]+)"\s*>([\s\S]*?)<\/\1>/.exec(reply);
|
|
2050
2450
|
if (match === null) return void 0;
|
|
2051
|
-
const rawArgs = match[
|
|
2451
|
+
const rawArgs = match[3]?.trim() ?? "";
|
|
2052
2452
|
let args;
|
|
2053
2453
|
try {
|
|
2054
2454
|
args = rawArgs === "" ? {} : JSON.parse(rawArgs);
|
|
@@ -2056,7 +2456,7 @@ function extractAction(reply) {
|
|
|
2056
2456
|
throw new Error(`动作参数不是合法 JSON:${rawArgs.slice(0, 80)}`);
|
|
2057
2457
|
}
|
|
2058
2458
|
return {
|
|
2059
|
-
name: match[
|
|
2459
|
+
name: match[2] ?? "",
|
|
2060
2460
|
args,
|
|
2061
2461
|
index: match.index
|
|
2062
2462
|
};
|
|
@@ -2085,16 +2485,19 @@ function historyToMessages(history) {
|
|
|
2085
2485
|
model: "deepseek-v4-flash"
|
|
2086
2486
|
}
|
|
2087
2487
|
}));
|
|
2088
|
-
else if (entry.role === "tool")
|
|
2089
|
-
content:
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2488
|
+
else if (entry.role === "tool") {
|
|
2489
|
+
const body = entry.content.length > 2e3 ? entry.content.slice(0, 2e3) + "\n…(结果过长已截断,需要完整内容请重新调用工具)" : entry.content;
|
|
2490
|
+
messages.push(createUserMessage({
|
|
2491
|
+
content: [{
|
|
2492
|
+
type: "text",
|
|
2493
|
+
text: `【工具 ${entry.tool ?? ""} 的执行结果】\n${body}`
|
|
2494
|
+
}],
|
|
2495
|
+
source: {
|
|
2496
|
+
kind: "plugin",
|
|
2497
|
+
plugin: "dsh-novel-forge"
|
|
2498
|
+
}
|
|
2499
|
+
}));
|
|
2500
|
+
}
|
|
2098
2501
|
return messages;
|
|
2099
2502
|
}
|
|
2100
2503
|
/** One non-streaming LLM chat turn (used inside the tool loop). */
|
|
@@ -2105,7 +2508,7 @@ async function chatOnce(ctx, config, system, history) {
|
|
|
2105
2508
|
model: config.model,
|
|
2106
2509
|
messages,
|
|
2107
2510
|
system,
|
|
2108
|
-
maxTokens: config.maxTokens,
|
|
2511
|
+
maxTokens: Math.max(config.maxTokens, 16e3),
|
|
2109
2512
|
temperature: .7
|
|
2110
2513
|
};
|
|
2111
2514
|
const assembler = new BlockAssembler();
|
|
@@ -2138,9 +2541,11 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
|
|
|
2138
2541
|
const reply = await chatOnce(ctx, config, system, history);
|
|
2139
2542
|
const action = extractAction(reply);
|
|
2140
2543
|
if (action === void 0) {
|
|
2141
|
-
|
|
2544
|
+
const intendsAction = /(改|修改|修订|重写|替换|调整|生成|新增|删除|导出|看看|查看|调出|读一下|加上|加一个|去掉|删掉|把.+改成)/.test(reply);
|
|
2545
|
+
const strayTag = /<[a-z_-]*action[^>]*>/.test(reply);
|
|
2546
|
+
if ((intendsAction || strayTag) && !nudged) {
|
|
2142
2547
|
nudged = true;
|
|
2143
|
-
const nudge = "
|
|
2548
|
+
const nudge = "你的上一条回复表达了想操作项目的意图(或动作标签格式有误),因此没有执行任何操作。请直接输出动作标签来执行,格式必须为 <dsh-action name=\"工具名\">{\"参数\":值}</dsh-action>(注意拼写是 dsh-action,不是 dash-action;标签成对出现,参数为合法 JSON)。如果需要先看内容,先输出 outline_text 或 chapter_text 标签。";
|
|
2144
2549
|
history.push({
|
|
2145
2550
|
role: "tool",
|
|
2146
2551
|
content: nudge,
|
|
@@ -2301,11 +2706,14 @@ function bookshelfSnapshot(store) {
|
|
|
2301
2706
|
books: store.books.map((book) => {
|
|
2302
2707
|
const project = loadProject(book.outputDir);
|
|
2303
2708
|
const done = project === void 0 ? 0 : project.chapters.filter((c) => c.status === "approved" || c.status === "written" || c.status === "rejected").length;
|
|
2709
|
+
const hasCover = project?.coverPath !== void 0 && project.coverPath !== "" && existsSync(join(book.outputDir, project.coverPath));
|
|
2304
2710
|
return {
|
|
2305
2711
|
...book,
|
|
2306
2712
|
done,
|
|
2307
2713
|
total: project?.chapters.length ?? 0,
|
|
2308
|
-
hasProject: project !== void 0
|
|
2714
|
+
hasProject: project !== void 0,
|
|
2715
|
+
hasCover,
|
|
2716
|
+
blurb: project?.blurb
|
|
2309
2717
|
};
|
|
2310
2718
|
}),
|
|
2311
2719
|
activeBookId: store.activeBookId
|
|
@@ -2384,8 +2792,8 @@ function defaultOutputDirFor(bookName) {
|
|
|
2384
2792
|
}
|
|
2385
2793
|
//#endregion
|
|
2386
2794
|
//#region src/routes.ts
|
|
2387
|
-
/** Cap on JSON request bodies. */
|
|
2388
|
-
const MAX_JSON_BODY_BYTES =
|
|
2795
|
+
/** Cap on JSON request bodies (generous: cover images travel as base64). */
|
|
2796
|
+
const MAX_JSON_BODY_BYTES = 16 * 1024 * 1024;
|
|
2389
2797
|
/** Loopback-only fence (mirrors the family plugins' pairing routes). */
|
|
2390
2798
|
function isLoopbackRequest(request) {
|
|
2391
2799
|
const address = request.socket.remoteAddress;
|
|
@@ -2480,7 +2888,10 @@ function makeRoutes(deps) {
|
|
|
2480
2888
|
}
|
|
2481
2889
|
writeJson(res, 200, {
|
|
2482
2890
|
config,
|
|
2483
|
-
project: project
|
|
2891
|
+
project: project !== void 0 ? {
|
|
2892
|
+
...project,
|
|
2893
|
+
facts: (project.facts ?? []).slice(-80)
|
|
2894
|
+
} : void 0,
|
|
2484
2895
|
generatedFiles: listChapterFiles(config.outputDir)
|
|
2485
2896
|
});
|
|
2486
2897
|
}
|
|
@@ -2682,14 +3093,9 @@ function makeRoutes(deps) {
|
|
|
2682
3093
|
title: chapter.title
|
|
2683
3094
|
});
|
|
2684
3095
|
try {
|
|
2685
|
-
await
|
|
3096
|
+
await summarizeAndExtractFacts(ctx, config, project, config.outputDir, no);
|
|
2686
3097
|
} catch (error) {
|
|
2687
|
-
console.warn("[dsh-novel-forge] summary failed:", error.message);
|
|
2688
|
-
}
|
|
2689
|
-
try {
|
|
2690
|
-
await extractFacts(ctx, config, project, config.outputDir, no);
|
|
2691
|
-
} catch (error) {
|
|
2692
|
-
console.warn("[dsh-novel-forge] facts extraction failed:", error.message);
|
|
3098
|
+
console.warn("[dsh-novel-forge] summary/facts failed:", error.message);
|
|
2693
3099
|
}
|
|
2694
3100
|
if (!(body?.skipReview === true) && (config.autoReview ?? true)) send({
|
|
2695
3101
|
type: "review",
|
|
@@ -3019,6 +3425,77 @@ function makeRoutes(deps) {
|
|
|
3019
3425
|
});
|
|
3020
3426
|
}
|
|
3021
3427
|
};
|
|
3428
|
+
/** 审查手动编辑的正文(不落盘,返回审稿报告)。 */
|
|
3429
|
+
const chapterCheckRoute = {
|
|
3430
|
+
kind: "exact",
|
|
3431
|
+
path: NOVEL_API.chapterCheck,
|
|
3432
|
+
handler: async (req, res) => {
|
|
3433
|
+
if (!guard(req, res, "POST")) return;
|
|
3434
|
+
const config = getConfig();
|
|
3435
|
+
const project = requireProject(res);
|
|
3436
|
+
if (project === void 0) return;
|
|
3437
|
+
const text = (await readJsonBody(req))?.text?.trim() ?? "";
|
|
3438
|
+
if (text.length < 50) {
|
|
3439
|
+
writeJson(res, 400, { error: "正文过短(<50 字),请先编辑内容" });
|
|
3440
|
+
return;
|
|
3441
|
+
}
|
|
3442
|
+
try {
|
|
3443
|
+
writeJson(res, 200, { report: await reviewChapterText(ctx, config, project, text) });
|
|
3444
|
+
} catch (error) {
|
|
3445
|
+
writeJson(res, 500, { error: error.message });
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
};
|
|
3449
|
+
/** 保存手动编辑的正文(自动备份 .bak,状态回 written)。 */
|
|
3450
|
+
const chapterSaveRoute = {
|
|
3451
|
+
kind: "exact",
|
|
3452
|
+
path: NOVEL_API.chapterSave,
|
|
3453
|
+
handler: async (req, res) => {
|
|
3454
|
+
if (!guard(req, res, "POST")) return;
|
|
3455
|
+
const config = getConfig();
|
|
3456
|
+
const project = requireProject(res);
|
|
3457
|
+
if (project === void 0) return;
|
|
3458
|
+
const body = await readJsonBody(req);
|
|
3459
|
+
if (!Number.isInteger(body?.chapterNo)) {
|
|
3460
|
+
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
3461
|
+
return;
|
|
3462
|
+
}
|
|
3463
|
+
const chapter = project.chapters.find((c) => c.no === body.chapterNo);
|
|
3464
|
+
if (chapter === void 0) {
|
|
3465
|
+
writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` });
|
|
3466
|
+
return;
|
|
3467
|
+
}
|
|
3468
|
+
const text = body?.text?.trim() ?? "";
|
|
3469
|
+
if (text.length < 50) {
|
|
3470
|
+
writeJson(res, 400, { error: "正文过短(<50 字),未保存" });
|
|
3471
|
+
return;
|
|
3472
|
+
}
|
|
3473
|
+
const fileName = chapterFileName(chapter);
|
|
3474
|
+
mkdirSync(config.outputDir, { recursive: true });
|
|
3475
|
+
const targetPath = join(config.outputDir, fileName);
|
|
3476
|
+
if (existsSync(targetPath)) copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, "")}.bak.md`));
|
|
3477
|
+
writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${text}\n`, "utf8");
|
|
3478
|
+
chapter.status = "written";
|
|
3479
|
+
chapter.chars = text.length;
|
|
3480
|
+
chapter.file = fileName;
|
|
3481
|
+
chapter.pendingDraft = void 0;
|
|
3482
|
+
let report;
|
|
3483
|
+
const carried = body?.report;
|
|
3484
|
+
if (carried !== void 0 && typeof carried.score === "number") {
|
|
3485
|
+
report = carried;
|
|
3486
|
+
chapter.review = report;
|
|
3487
|
+
chapter.status = report.passed ? "approved" : "rejected";
|
|
3488
|
+
} else report = await reviewChapter(ctx, config, project, config.outputDir, chapter.no);
|
|
3489
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3490
|
+
saveProject(config.outputDir, project);
|
|
3491
|
+
writeJson(res, 200, {
|
|
3492
|
+
ok: true,
|
|
3493
|
+
chars: text.length,
|
|
3494
|
+
file: fileName,
|
|
3495
|
+
report
|
|
3496
|
+
});
|
|
3497
|
+
}
|
|
3498
|
+
};
|
|
3022
3499
|
const assistantRoute = {
|
|
3023
3500
|
kind: "exact",
|
|
3024
3501
|
path: NOVEL_API.assistant,
|
|
@@ -3078,6 +3555,16 @@ function makeRoutes(deps) {
|
|
|
3078
3555
|
writeJson(res, 200, { messages: loadAssistantHistory(getConfig().outputDir) });
|
|
3079
3556
|
}
|
|
3080
3557
|
};
|
|
3558
|
+
/** 清空助手对话记录。 */
|
|
3559
|
+
const assistantClearRoute = {
|
|
3560
|
+
kind: "exact",
|
|
3561
|
+
path: NOVEL_API.assistantClear,
|
|
3562
|
+
handler: (req, res) => {
|
|
3563
|
+
if (!guard(req, res, "POST")) return;
|
|
3564
|
+
clearAssistantHistory(getConfig().outputDir);
|
|
3565
|
+
writeJson(res, 200, { ok: true });
|
|
3566
|
+
}
|
|
3567
|
+
};
|
|
3081
3568
|
return [
|
|
3082
3569
|
statusRoute,
|
|
3083
3570
|
loadOutlineRoute,
|
|
@@ -3095,6 +3582,8 @@ function makeRoutes(deps) {
|
|
|
3095
3582
|
foreshadowRoute,
|
|
3096
3583
|
exportRoute,
|
|
3097
3584
|
chapterRoute,
|
|
3585
|
+
chapterCheckRoute,
|
|
3586
|
+
chapterSaveRoute,
|
|
3098
3587
|
{
|
|
3099
3588
|
kind: "exact",
|
|
3100
3589
|
path: NOVEL_API.assets,
|
|
@@ -3176,6 +3665,7 @@ function makeRoutes(deps) {
|
|
|
3176
3665
|
},
|
|
3177
3666
|
assistantRoute,
|
|
3178
3667
|
assistantHistoryRoute,
|
|
3668
|
+
assistantClearRoute,
|
|
3179
3669
|
{
|
|
3180
3670
|
kind: "exact",
|
|
3181
3671
|
path: NOVEL_API.bookshelf,
|
|
@@ -3339,7 +3829,7 @@ function makeRoutes(deps) {
|
|
|
3339
3829
|
const project = requireProject(res);
|
|
3340
3830
|
if (project === void 0) return;
|
|
3341
3831
|
if (project.bible === void 0) {
|
|
3342
|
-
writeJson(res, 400, { error: "
|
|
3832
|
+
writeJson(res, 400, { error: "尚未生成道藏,请先生成" });
|
|
3343
3833
|
return;
|
|
3344
3834
|
}
|
|
3345
3835
|
const body = await readJsonBody(req);
|
|
@@ -3351,6 +3841,163 @@ function makeRoutes(deps) {
|
|
|
3351
3841
|
writeJson(res, 200, { bible: project.bible });
|
|
3352
3842
|
}
|
|
3353
3843
|
},
|
|
3844
|
+
{
|
|
3845
|
+
kind: "exact",
|
|
3846
|
+
path: NOVEL_API.blurb,
|
|
3847
|
+
handler: async (req, res) => {
|
|
3848
|
+
if (!guard(req, res, "POST")) return;
|
|
3849
|
+
const config = getConfig();
|
|
3850
|
+
const project = requireProject(res);
|
|
3851
|
+
if (project === void 0) return;
|
|
3852
|
+
const body = await readJsonBody(req);
|
|
3853
|
+
try {
|
|
3854
|
+
if (body?.action === "save") {
|
|
3855
|
+
const text = body.text?.trim() ?? "";
|
|
3856
|
+
project.blurb = text;
|
|
3857
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3858
|
+
saveProject(config.outputDir, project);
|
|
3859
|
+
writeJson(res, 200, { blurb: text });
|
|
3860
|
+
return;
|
|
3861
|
+
}
|
|
3862
|
+
const partial = body?.partial?.trim() ?? "";
|
|
3863
|
+
const blurb = await generateBlurb(ctx, config, project, partial);
|
|
3864
|
+
project.blurb = blurb;
|
|
3865
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3866
|
+
saveProject(config.outputDir, project);
|
|
3867
|
+
writeJson(res, 200, { blurb });
|
|
3868
|
+
} catch (error) {
|
|
3869
|
+
writeJson(res, 500, { error: error.message });
|
|
3870
|
+
}
|
|
3871
|
+
}
|
|
3872
|
+
},
|
|
3873
|
+
{
|
|
3874
|
+
kind: "exact",
|
|
3875
|
+
path: NOVEL_API.cover,
|
|
3876
|
+
handler: async (req, res) => {
|
|
3877
|
+
const config = getConfig();
|
|
3878
|
+
if (req.method === "GET") {
|
|
3879
|
+
if (!isLoopbackRequest(req)) {
|
|
3880
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
3881
|
+
return;
|
|
3882
|
+
}
|
|
3883
|
+
const dirParam = new URL(req.url ?? "/", "http://localhost").searchParams.get("dir");
|
|
3884
|
+
const targetDir = dirParam !== null && dirParam !== "" ? dirParam : config.outputDir;
|
|
3885
|
+
const coverPath = loadProject(targetDir)?.coverPath;
|
|
3886
|
+
if (coverPath === void 0 || coverPath === "") {
|
|
3887
|
+
writeJson(res, 200, { dataUrl: null });
|
|
3888
|
+
return;
|
|
3889
|
+
}
|
|
3890
|
+
const file = join(targetDir, coverPath);
|
|
3891
|
+
if (!existsSync(file)) {
|
|
3892
|
+
writeJson(res, 200, { dataUrl: null });
|
|
3893
|
+
return;
|
|
3894
|
+
}
|
|
3895
|
+
writeJson(res, 200, { dataUrl: `data:${coverPath.toLowerCase().endsWith(".png") ? "image/png" : coverPath.toLowerCase().endsWith(".jpg") || coverPath.toLowerCase().endsWith(".jpeg") ? "image/jpeg" : coverPath.toLowerCase().endsWith(".webp") ? "image/webp" : "image/png"};base64,${readFileSync(file).toString("base64")}` });
|
|
3896
|
+
return;
|
|
3897
|
+
}
|
|
3898
|
+
if (req.method === "POST") {
|
|
3899
|
+
if (!guard(req, res, "POST")) return;
|
|
3900
|
+
const project = requireProject(res);
|
|
3901
|
+
if (project === void 0) return;
|
|
3902
|
+
const body = await readJsonBody(req);
|
|
3903
|
+
try {
|
|
3904
|
+
if (body?.action === "remove") {
|
|
3905
|
+
if (project.coverPath !== void 0 && project.coverPath !== "") {
|
|
3906
|
+
const oldFile = join(config.outputDir, project.coverPath);
|
|
3907
|
+
if (existsSync(oldFile)) rmSync(oldFile, { force: true });
|
|
3908
|
+
}
|
|
3909
|
+
project.coverPath = void 0;
|
|
3910
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3911
|
+
saveProject(config.outputDir, project);
|
|
3912
|
+
writeJson(res, 200, {
|
|
3913
|
+
ok: true,
|
|
3914
|
+
coverPath: null
|
|
3915
|
+
});
|
|
3916
|
+
return;
|
|
3917
|
+
}
|
|
3918
|
+
const dataUrl = body?.dataUrl ?? "";
|
|
3919
|
+
const match = /^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/s.exec(dataUrl);
|
|
3920
|
+
if (match === null) {
|
|
3921
|
+
writeJson(res, 400, { error: "封面须为 PNG/JPEG/WebP 的 base64 data URL" });
|
|
3922
|
+
return;
|
|
3923
|
+
}
|
|
3924
|
+
const mime = match[1];
|
|
3925
|
+
const fileName = `cover.${mime === "image/png" ? "png" : mime === "image/webp" ? "webp" : "jpg"}`;
|
|
3926
|
+
const targetPath = join(config.outputDir, fileName);
|
|
3927
|
+
const oldPath = project.coverPath;
|
|
3928
|
+
mkdirSync(config.outputDir, { recursive: true });
|
|
3929
|
+
writeFileSync(targetPath, Buffer.from(match[2], "base64"));
|
|
3930
|
+
if (oldPath !== void 0 && oldPath !== "" && oldPath !== fileName) {
|
|
3931
|
+
const oldFile = join(config.outputDir, oldPath);
|
|
3932
|
+
if (existsSync(oldFile)) rmSync(oldFile, { force: true });
|
|
3933
|
+
}
|
|
3934
|
+
project.coverPath = fileName;
|
|
3935
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3936
|
+
saveProject(config.outputDir, project);
|
|
3937
|
+
writeJson(res, 200, {
|
|
3938
|
+
ok: true,
|
|
3939
|
+
coverPath: fileName
|
|
3940
|
+
});
|
|
3941
|
+
} catch (error) {
|
|
3942
|
+
writeJson(res, 500, { error: error.message });
|
|
3943
|
+
}
|
|
3944
|
+
return;
|
|
3945
|
+
}
|
|
3946
|
+
writeJson(res, 405, { error: "method not allowed (expected GET or POST)" });
|
|
3947
|
+
}
|
|
3948
|
+
},
|
|
3949
|
+
{
|
|
3950
|
+
kind: "exact",
|
|
3951
|
+
path: NOVEL_API.world,
|
|
3952
|
+
handler: async (req, res) => {
|
|
3953
|
+
if (!guard(req, res, "POST")) return;
|
|
3954
|
+
const config = getConfig();
|
|
3955
|
+
const project = requireProject(res);
|
|
3956
|
+
if (project === void 0) return;
|
|
3957
|
+
const body = await readJsonBody(req);
|
|
3958
|
+
try {
|
|
3959
|
+
if (body?.action === "save" && body.world !== void 0) {
|
|
3960
|
+
project.world = {
|
|
3961
|
+
realms: Array.isArray(body.world.realms) ? body.world.realms : [],
|
|
3962
|
+
regions: Array.isArray(body.world.regions) ? body.world.regions : [],
|
|
3963
|
+
factions: Array.isArray(body.world.factions) ? body.world.factions : []
|
|
3964
|
+
};
|
|
3965
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3966
|
+
saveProject(config.outputDir, project);
|
|
3967
|
+
writeJson(res, 200, { world: project.world });
|
|
3968
|
+
return;
|
|
3969
|
+
}
|
|
3970
|
+
const world = await extractWorld(ctx, config, project);
|
|
3971
|
+
project.world = world;
|
|
3972
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3973
|
+
saveProject(config.outputDir, project);
|
|
3974
|
+
writeJson(res, 200, { world });
|
|
3975
|
+
} catch (error) {
|
|
3976
|
+
writeJson(res, 500, { error: error.message });
|
|
3977
|
+
}
|
|
3978
|
+
}
|
|
3979
|
+
},
|
|
3980
|
+
{
|
|
3981
|
+
kind: "exact",
|
|
3982
|
+
path: NOVEL_API.rename,
|
|
3983
|
+
handler: async (req, res) => {
|
|
3984
|
+
if (!guard(req, res, "POST")) return;
|
|
3985
|
+
const config = getConfig();
|
|
3986
|
+
const project = requireProject(res);
|
|
3987
|
+
if (project === void 0) return;
|
|
3988
|
+
const bookName = (await readJsonBody(req))?.bookName?.trim();
|
|
3989
|
+
if (bookName === void 0 || bookName === "") {
|
|
3990
|
+
writeJson(res, 400, { error: "书名不能为空" });
|
|
3991
|
+
return;
|
|
3992
|
+
}
|
|
3993
|
+
project.bookName = bookName.slice(0, 60);
|
|
3994
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3995
|
+
saveProject(config.outputDir, project);
|
|
3996
|
+
const store = loadBookshelf();
|
|
3997
|
+
if (store.activeBookId !== null) renameBook(store.activeBookId, project.bookName);
|
|
3998
|
+
writeJson(res, 200, { bookName: project.bookName });
|
|
3999
|
+
}
|
|
4000
|
+
},
|
|
3354
4001
|
{
|
|
3355
4002
|
kind: "exact",
|
|
3356
4003
|
path: NOVEL_API.config,
|
|
@@ -3426,7 +4073,7 @@ const DEFAULT_AUTO_REVIEW = true;
|
|
|
3426
4073
|
/** Order of the announcement section within the tool-guidance band. */
|
|
3427
4074
|
const SECTION_ORDER = 160;
|
|
3428
4075
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
3429
|
-
const NOVEL_GUIDANCE = "本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲(默认桌面《归墟玉主》大纲)或粘贴大纲文本;用 LLM
|
|
4076
|
+
const NOVEL_GUIDANCE = "本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲(默认桌面《归墟玉主》大纲)或粘贴大纲文本;用 LLM 提炼道藏(人设/世界观/金手指规则/写作红线,即设定圣经);生成卷计划与章节计划;逐章调用 LLM 生成 3000-4000 字正文并保存为 Markdown(默认输出到 桌面\\归墟玉主);每章自动生成摘要(叙事记忆)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑),支持按审稿意见重写、去 AI 味润色、暗线(伏笔)管理、批量连写与全本导出(txt/md)。限制:生成消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色 / 归墟玉主」时即指本插件,请据此协作。";
|
|
3430
4077
|
/** Resolve a config-like value into the full runtime config. */
|
|
3431
4078
|
function resolveConfig(value) {
|
|
3432
4079
|
return {
|