@waterwx/dsh-novel-forge 0.1.0 → 1.0.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 +51 -25
- package/cordis.patch.yml +1 -1
- package/lib/client.js +3456 -1316
- package/lib/client.js.map +1 -1
- package/lib/index.js +436 -37
- package/lib/index.js.map +1 -1
- package/lib/types/bookshelf.d.ts +2 -0
- package/lib/types/client/api.d.ts +32 -2
- package/lib/types/client/panel/AssetsTab.d.ts +6 -1
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/engine.d.ts +25 -6
- package/lib/types/protocol.d.ts +82 -0
- package/package.json +1 -13
- package/src/assistant.ts +24 -3
- package/src/bookshelf.ts +11 -0
- package/src/client/api.ts +38 -3
- package/src/client/panel/AssetsTab.tsx +132 -17
- package/src/client/panel/BookshelfBar.tsx +62 -8
- package/src/client/panel/NovelPanel.tsx +1341 -172
- package/src/client/panel/panel.module.css +863 -59
- package/src/engine.ts +235 -21
- package/src/protocol.ts +84 -0
- package/src/routes.ts +225 -8
package/lib/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
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, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { strFromU8, unzipSync } from "fflate";
|
|
4
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
6
5
|
import { join } from "node:path";
|
|
6
|
+
import { strFromU8, unzipSync } from "fflate";
|
|
7
7
|
import { BlockAssembler, ReasoningEffortId, createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { randomBytes } from "node:crypto";
|
|
@@ -28,6 +28,10 @@ const NOVEL_API = {
|
|
|
28
28
|
review: "/api/dsh-novel-forge/review",
|
|
29
29
|
rewrite: "/api/dsh-novel-forge/rewrite",
|
|
30
30
|
polish: "/api/dsh-novel-forge/polish",
|
|
31
|
+
/** 采纳待确认草稿(润色/重写产物)覆盖正文文件。 */
|
|
32
|
+
draftApply: "/api/dsh-novel-forge/draft/apply",
|
|
33
|
+
/** 放弃待确认草稿,保留原稿。 */
|
|
34
|
+
draftDiscard: "/api/dsh-novel-forge/draft/discard",
|
|
31
35
|
summary: "/api/dsh-novel-forge/summary",
|
|
32
36
|
foreshadow: "/api/dsh-novel-forge/foreshadow",
|
|
33
37
|
exportBook: "/api/dsh-novel-forge/export",
|
|
@@ -35,6 +39,16 @@ const NOVEL_API = {
|
|
|
35
39
|
assistant: "/api/dsh-novel-forge/assistant",
|
|
36
40
|
assistantHistory: "/api/dsh-novel-forge/assistant-history",
|
|
37
41
|
bookshelf: "/api/dsh-novel-forge/bookshelf",
|
|
42
|
+
/** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
|
|
43
|
+
reset: "/api/dsh-novel-forge/reset",
|
|
44
|
+
/** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
|
|
45
|
+
audit: "/api/dsh-novel-forge/audit",
|
|
46
|
+
/** 角色卡刷新:基于事实库与各章摘要聚合角色当前状态。 */
|
|
47
|
+
charactersRefresh: "/api/dsh-novel-forge/characters/refresh",
|
|
48
|
+
/** 事实库回填:对历史已生成章节批量抽取事实(旧章节无事实记录时用)。 */
|
|
49
|
+
factsBackfill: "/api/dsh-novel-forge/facts/backfill",
|
|
50
|
+
/** 设定圣经局部修补(如世界观规则编辑)。 */
|
|
51
|
+
biblePatch: "/api/dsh-novel-forge/bible/patch",
|
|
38
52
|
config: "/api/dsh-novel-forge/config",
|
|
39
53
|
openFolder: "/api/dsh-novel-forge/open-folder"
|
|
40
54
|
};
|
|
@@ -738,6 +752,7 @@ function loadProject(outputDir) {
|
|
|
738
752
|
if (!Array.isArray(raw.assets.antiAiRules)) raw.assets.antiAiRules = [];
|
|
739
753
|
if (!Array.isArray(raw.assets.auxiliaryProgressions)) raw.assets.auxiliaryProgressions = [];
|
|
740
754
|
if (!Array.isArray(raw.assets.styleAssets)) raw.assets.styleAssets = [];
|
|
755
|
+
if (!Array.isArray(raw.facts)) raw.facts = [];
|
|
741
756
|
return raw;
|
|
742
757
|
} catch {
|
|
743
758
|
return;
|
|
@@ -752,7 +767,7 @@ function saveProject(outputDir, project) {
|
|
|
752
767
|
function listChapterFiles(outputDir) {
|
|
753
768
|
if (!existsSync(outputDir)) return [];
|
|
754
769
|
try {
|
|
755
|
-
return readdirSync(outputDir).filter((name) => /^第\d+章_.*\.md$/.test(name)).sort((a, b) => {
|
|
770
|
+
return readdirSync(outputDir).filter((name) => /^第\d+章_.*\.md$/.test(name) && !name.endsWith(".bak.md")).sort((a, b) => {
|
|
756
771
|
return Number(/^第(\d+)章/.exec(a)?.[1] ?? 0) - Number(/^第(\d+)章/.exec(b)?.[1] ?? 0);
|
|
757
772
|
});
|
|
758
773
|
} catch {
|
|
@@ -792,6 +807,7 @@ function createProject(outline, outlinePath) {
|
|
|
792
807
|
chapters: [],
|
|
793
808
|
foreshadows: [],
|
|
794
809
|
assets: emptyProjectAssets(),
|
|
810
|
+
facts: [],
|
|
795
811
|
createdAt: now,
|
|
796
812
|
updatedAt: now
|
|
797
813
|
};
|
|
@@ -1005,8 +1021,8 @@ function planSystemPrompt(volumes) {
|
|
|
1005
1021
|
"2. 章节之间要衔接自然,前章结尾为后章埋下钩子。",
|
|
1006
1022
|
"3. 严格遵循大纲的人设、金手指规则、战力体系与世界观设定,不得自行发明冲突设定。",
|
|
1007
1023
|
"4. 输出必须是合法的 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记。",
|
|
1008
|
-
"5. 数组每个元素格式:{\"title\": \"章节标题(10字以内,有网文感)\", \"beats\": \"
|
|
1009
|
-
"
|
|
1024
|
+
"5. 数组每个元素格式:{\"title\": \"章节标题(10字以内,有网文感)\", \"beats\": \"结构化剧情要点(150-250字,必须包含四段,段间用换行分隔):\\n本章目标:本章要完成的核心推进;\\n剧情要点:主要情节的起承转合(2-4 句);\\n爽点/钩子:本章的爽点兑现或情绪钩子;\\n结尾钩子:本章结尾为下一章埋下的悬念\"}",
|
|
1025
|
+
"重要:beats 字段内部必须使用 \\n 转义表示换行(JSON 字符串内不得有真实换行符),其余字符串值也不得包含真实换行符,JSON 必须在一段内完整结束。",
|
|
1010
1026
|
"重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
|
|
1011
1027
|
volumes !== void 0 && volumes.length > 0 ? ["\n全书分卷结构(规划章节时需落在对应卷内):"].concat(volumes.map((v) => `第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})`)).join("\n") : ""
|
|
1012
1028
|
].join("\n");
|
|
@@ -1257,19 +1273,14 @@ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo,
|
|
|
1257
1273
|
].filter((part) => part !== "").join("\n\n");
|
|
1258
1274
|
else newBody = rewritten;
|
|
1259
1275
|
if (newBody.length < 100) throw new Error("修订结果过短,可能失败,请重试");
|
|
1260
|
-
|
|
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;
|
|
1276
|
+
chapter.pendingDraft = newBody;
|
|
1265
1277
|
chapter.error = void 0;
|
|
1266
|
-
chapter.review = void 0;
|
|
1267
1278
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1268
1279
|
saveProject(outputDir, project);
|
|
1269
1280
|
yield {
|
|
1270
|
-
frame: "
|
|
1271
|
-
|
|
1272
|
-
|
|
1281
|
+
frame: "drafted",
|
|
1282
|
+
chars: newBody.length,
|
|
1283
|
+
draft: newBody
|
|
1273
1284
|
};
|
|
1274
1285
|
}
|
|
1275
1286
|
/** The de-AI-ify polish system prompt (with project writing assets injected). */
|
|
@@ -1286,7 +1297,8 @@ function polishSystemPrompt(project) {
|
|
|
1286
1297
|
assetsBlock !== "" ? assetsBlock : ""
|
|
1287
1298
|
].join("\n");
|
|
1288
1299
|
}
|
|
1289
|
-
/** Stream a chapter polish (de-AI-ify).
|
|
1300
|
+
/** Stream a chapter polish (de-AI-ify). Draft-mode: the polished body lands
|
|
1301
|
+
* in `chapter.pendingDraft` and is only applied on draft/apply. */
|
|
1290
1302
|
async function* polishChapterStream(ctx, config, project, outputDir, chapterNo) {
|
|
1291
1303
|
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
1292
1304
|
if (chapter === void 0) throw new Error(`章节 ${chapterNo} 不在计划中`);
|
|
@@ -1327,16 +1339,13 @@ async function* polishChapterStream(ctx, config, project, outputDir, chapterNo)
|
|
|
1327
1339
|
const newBody = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
|
|
1328
1340
|
if (streamError !== void 0) throw streamError;
|
|
1329
1341
|
if (newBody.length < 100) throw new Error("润色结果过短,可能失败,请重试");
|
|
1330
|
-
|
|
1331
|
-
writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${newBody}\n`, "utf8");
|
|
1332
|
-
chapter.status = "written";
|
|
1333
|
-
chapter.chars = newBody.length;
|
|
1342
|
+
chapter.pendingDraft = newBody;
|
|
1334
1343
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1335
1344
|
saveProject(outputDir, project);
|
|
1336
1345
|
yield {
|
|
1337
|
-
frame: "
|
|
1338
|
-
|
|
1339
|
-
|
|
1346
|
+
frame: "drafted",
|
|
1347
|
+
chars: newBody.length,
|
|
1348
|
+
draft: newBody
|
|
1340
1349
|
};
|
|
1341
1350
|
}
|
|
1342
1351
|
/** Generate one chapter (streaming). Yields progress frames; persists when done. */
|
|
@@ -1350,6 +1359,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
|
|
|
1350
1359
|
if (existsSync(prevPath)) continuity = readFileSync(prevPath, "utf8").slice(-900);
|
|
1351
1360
|
}
|
|
1352
1361
|
const prevSummary = prev?.summary;
|
|
1362
|
+
const recentFacts = (project.facts ?? []).slice(-20).map((f) => f.text);
|
|
1353
1363
|
const messages = [createUserMessage({
|
|
1354
1364
|
content: [{
|
|
1355
1365
|
type: "text",
|
|
@@ -1357,6 +1367,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
|
|
|
1357
1367
|
`现在写第 ${chapter.no} 章,标题《${chapter.title}》。`,
|
|
1358
1368
|
`本章剧情要点:${chapter.beats}`,
|
|
1359
1369
|
"",
|
|
1370
|
+
recentFacts.length > 0 ? `本书已确立的事实(新写内容不得与之矛盾):\n${recentFacts.join("\n")}` : "",
|
|
1360
1371
|
prevSummary !== void 0 && prevSummary !== "" ? `上一章摘要:${prevSummary}` : "",
|
|
1361
1372
|
continuity !== "" ? `上一章结尾(用于衔接,不要复述):\n${continuity}` : "这是第一章,注意开篇要有吸引力。",
|
|
1362
1373
|
"",
|
|
@@ -1427,6 +1438,184 @@ async function summarizeChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
1427
1438
|
saveProject(outputDir, project);
|
|
1428
1439
|
return chapter.summary;
|
|
1429
1440
|
}
|
|
1441
|
+
/**
|
|
1442
|
+
* 抽取本章「已确立事实」追加到事实库/时间线(最多 300 条,最新优先)。
|
|
1443
|
+
* 事实注入后续章节生成提示词,保证人物状态/境界/资源/关系长期一致。
|
|
1444
|
+
* @returns 新增事实条数(失败返回 0,调用方 best-effort)。
|
|
1445
|
+
*/
|
|
1446
|
+
async function extractFacts(ctx, config, project, outputDir, chapterNo) {
|
|
1447
|
+
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
1448
|
+
if (chapter === void 0) return 0;
|
|
1449
|
+
const body = readChapterFile(outputDir, chapter);
|
|
1450
|
+
if (body === void 0) return 0;
|
|
1451
|
+
const lines = (await complete(ctx, config, {
|
|
1452
|
+
system: [
|
|
1453
|
+
"你是一位网文编辑。请从本章正文中抽取「已确立事实」,供后续章节保持一致。",
|
|
1454
|
+
"事实指:人物当前状态(境界/修为/伤势/资源/心境)、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。",
|
|
1455
|
+
"要求:",
|
|
1456
|
+
"1. 只抽取本章明确写出的、对后续有约束力的内容;纯心理活动与无关细节不要。",
|
|
1457
|
+
"2. 每行一条事实,用客观陈述句,不含主观评价。",
|
|
1458
|
+
"3. 输出 3-6 条,每行一条,不要编号、不要前缀、不要解释。"
|
|
1459
|
+
].join("\n"),
|
|
1460
|
+
user: body.replace(/^#\s+.*$/m, "").trim(),
|
|
1461
|
+
temperature: .2,
|
|
1462
|
+
maxTokens: Math.max(config.maxTokens, 4e3)
|
|
1463
|
+
})).split("\n").map((line) => line.replace(/^[-*\d.\s]+/, "").trim()).filter((line) => line.length > 8).slice(0, 8);
|
|
1464
|
+
if (lines.length === 0) return 0;
|
|
1465
|
+
const facts = project.facts ?? [];
|
|
1466
|
+
for (const line of lines) facts.push({
|
|
1467
|
+
chapterNo,
|
|
1468
|
+
text: line.slice(0, 140)
|
|
1469
|
+
});
|
|
1470
|
+
project.facts = facts.slice(-300);
|
|
1471
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1472
|
+
saveProject(outputDir, project);
|
|
1473
|
+
return lines.length;
|
|
1474
|
+
}
|
|
1475
|
+
/** 全书一致性质检:LLM 扫描已生成章节 + 设定 + 事实库,输出矛盾清单。 */
|
|
1476
|
+
async function auditBook(ctx, config, project, outputDir) {
|
|
1477
|
+
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
1478
|
+
if (written.length === 0) return [];
|
|
1479
|
+
const system = [
|
|
1480
|
+
"你是一位严谨的网文连续性审校编辑。你会收到一本小说的设定圣经、事实库和各章正文节选。",
|
|
1481
|
+
"请找出全书的一致性矛盾,例如:",
|
|
1482
|
+
"- 人物状态冲突:境界/修为/伤势/资源在同一章内或跨章前后矛盾。",
|
|
1483
|
+
"- 设定违背:正文与世界观规则、金手指规则、写作红线冲突。",
|
|
1484
|
+
"- 时间线错乱:事件顺序、时间跨度、地点移动不合逻辑。",
|
|
1485
|
+
"- 细节穿帮:人名/地名/物品/数字前后不一致。",
|
|
1486
|
+
"要求:",
|
|
1487
|
+
"1. 只报告有实质证据的矛盾,不要泛泛而谈写作质量问题。",
|
|
1488
|
+
"2. 每条必须定位到具体章节号。",
|
|
1489
|
+
"3. 输出必须是合法 JSON 数组,格式:[{\"chapterNo\": 章节号, \"severity\": \"high|medium|low\", \"item\": \"矛盾描述\", \"suggestion\": \"修改建议\"}]",
|
|
1490
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
1491
|
+
].join("\n");
|
|
1492
|
+
const factsBlock = (project.facts ?? []).slice(-80).map((f) => `[第${f.chapterNo}章] ${f.text}`).join("\n");
|
|
1493
|
+
const chapterBlocks = written.map((c) => {
|
|
1494
|
+
const excerpt = (readChapterFile(outputDir, c) ?? "").replace(/^#\s+.*$/m, "").trim().slice(0, 700);
|
|
1495
|
+
return `【第${c.no}章《${c.title}》】\n${excerpt}`;
|
|
1496
|
+
}).join("\n\n");
|
|
1497
|
+
const parsed = parseJsonArray(await complete(ctx, config, {
|
|
1498
|
+
system,
|
|
1499
|
+
user: [
|
|
1500
|
+
"请对以下小说做全书一致性质检。",
|
|
1501
|
+
project.bible !== void 0 ? "设定圣经:\n" + [
|
|
1502
|
+
project.bible.worldRules.length > 0 ? `世界规则:\n${project.bible.worldRules.map((r) => `- ${r}`).join("\n")}` : "",
|
|
1503
|
+
project.bible.redLines.length > 0 ? `写作红线:\n${project.bible.redLines.map((r) => `- ${r}`).join("\n")}` : "",
|
|
1504
|
+
project.bible.characters.length > 0 ? `角色:\n${project.bible.characters.map((ch) => `- ${ch.name}(${ch.traits.join("、")})`).join("\n")}` : ""
|
|
1505
|
+
].filter((s) => s !== "").join("\n") : "",
|
|
1506
|
+
factsBlock !== "" ? `已确立事实库:\n${factsBlock}` : "",
|
|
1507
|
+
`正文节选(每章前 700 字):\n${chapterBlocks}`,
|
|
1508
|
+
"只输出 JSON 数组。"
|
|
1509
|
+
].filter((s) => s !== "").join("\n\n"),
|
|
1510
|
+
temperature: .2,
|
|
1511
|
+
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
1512
|
+
}));
|
|
1513
|
+
const issues = [];
|
|
1514
|
+
for (const entry of parsed) {
|
|
1515
|
+
const item = typeof entry.item === "string" ? entry.item : "";
|
|
1516
|
+
if (item === "") continue;
|
|
1517
|
+
issues.push({
|
|
1518
|
+
chapterNo: Number(entry.chapterNo) || 0,
|
|
1519
|
+
severity: [
|
|
1520
|
+
"high",
|
|
1521
|
+
"medium",
|
|
1522
|
+
"low"
|
|
1523
|
+
].includes(entry.severity) ? entry.severity : "medium",
|
|
1524
|
+
item,
|
|
1525
|
+
suggestion: typeof entry.suggestion === "string" ? entry.suggestion : ""
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
return issues.slice(0, 30);
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* 事实库回填:对历史已生成章节批量抽取事实(无事实记录的旧章节)。
|
|
1532
|
+
* @returns 回填的章节数。
|
|
1533
|
+
*/
|
|
1534
|
+
async function backfillFacts(ctx, config, project, outputDir) {
|
|
1535
|
+
const have = new Set((project.facts ?? []).map((f) => f.chapterNo));
|
|
1536
|
+
let filled = 0;
|
|
1537
|
+
for (const chapter of project.chapters) {
|
|
1538
|
+
if (chapter.status === "pending" || chapter.status === "generating") continue;
|
|
1539
|
+
if (chapter.file === void 0 || have.has(chapter.no)) continue;
|
|
1540
|
+
try {
|
|
1541
|
+
if (await extractFacts(ctx, config, project, outputDir, chapter.no) > 0) filled++;
|
|
1542
|
+
} catch {}
|
|
1543
|
+
have.add(chapter.no);
|
|
1544
|
+
}
|
|
1545
|
+
return filled;
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* 角色卡刷新:出场统计由服务端从正文精确计算(角色名出现过的章节数、
|
|
1549
|
+
* 最近出现章节),LLM 只负责聚合「当前状态」一句话。
|
|
1550
|
+
*/
|
|
1551
|
+
async function refreshCharacters(ctx, config, project, outputDir) {
|
|
1552
|
+
const roster = project.bible?.characters ?? [];
|
|
1553
|
+
const facts = project.facts ?? [];
|
|
1554
|
+
if (roster.length === 0 && facts.length === 0) return [];
|
|
1555
|
+
const stat = /* @__PURE__ */ new Map();
|
|
1556
|
+
const known = roster.map((card) => card.name);
|
|
1557
|
+
for (const chapter of project.chapters) {
|
|
1558
|
+
if (chapter.status === "pending" || chapter.status === "generating") continue;
|
|
1559
|
+
const body = readChapterFile(outputDir, chapter);
|
|
1560
|
+
if (body === void 0) continue;
|
|
1561
|
+
for (const name of known) if (body.includes(name)) {
|
|
1562
|
+
const entry = stat.get(name) ?? {
|
|
1563
|
+
chapters: /* @__PURE__ */ new Set(),
|
|
1564
|
+
last: 0
|
|
1565
|
+
};
|
|
1566
|
+
entry.chapters.add(chapter.no);
|
|
1567
|
+
if (chapter.no > entry.last) entry.last = chapter.no;
|
|
1568
|
+
stat.set(name, entry);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
let statuses = /* @__PURE__ */ new Map();
|
|
1572
|
+
if (facts.length > 0) {
|
|
1573
|
+
const system = [
|
|
1574
|
+
"你是一位网文角色档案管理员。请根据「角色名单」与「已确立事实库」,为每个角色输出「当前状态」一句话(境界/修为/伤势/资源/心境)。",
|
|
1575
|
+
"输出必须是合法 JSON 数组,格式:[{\"name\": \"角色名\", \"status\": \"当前状态一句话\"}]",
|
|
1576
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
1577
|
+
].join("\n");
|
|
1578
|
+
const rosterBlock = roster.map((ch) => `- ${ch.name}(${ch.traits.join("、")})`).join("\n");
|
|
1579
|
+
const factsBlock = facts.map((f) => `[第${f.chapterNo}章] ${f.text}`).join("\n");
|
|
1580
|
+
const user = [
|
|
1581
|
+
`角色名单:\n${rosterBlock}`,
|
|
1582
|
+
`已确立事实库(${facts.length} 条):\n${factsBlock.slice(-6e3)}`,
|
|
1583
|
+
"只输出 JSON 数组。"
|
|
1584
|
+
].join("\n\n");
|
|
1585
|
+
try {
|
|
1586
|
+
const text = await complete(ctx, config, {
|
|
1587
|
+
system,
|
|
1588
|
+
user,
|
|
1589
|
+
temperature: .2,
|
|
1590
|
+
maxTokens: Math.max(config.maxTokens, 8e3)
|
|
1591
|
+
});
|
|
1592
|
+
for (const entry of parseJsonArray(text)) {
|
|
1593
|
+
const name = typeof entry.name === "string" ? entry.name : "";
|
|
1594
|
+
if (name !== "" && typeof entry.status === "string") statuses.set(name, entry.status);
|
|
1595
|
+
}
|
|
1596
|
+
} catch {}
|
|
1597
|
+
}
|
|
1598
|
+
const cards = [];
|
|
1599
|
+
const roleOf = (name) => roster.find((c) => c.name === name)?.role ?? "other";
|
|
1600
|
+
for (const card of roster) {
|
|
1601
|
+
const entry = stat.get(card.name);
|
|
1602
|
+
cards.push({
|
|
1603
|
+
name: card.name,
|
|
1604
|
+
role: card.role,
|
|
1605
|
+
status: statuses.get(card.name) ?? "",
|
|
1606
|
+
lastChapter: entry?.last ?? 0,
|
|
1607
|
+
appearances: entry?.chapters.size ?? 0
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
for (const [name, entry] of stat) if (!cards.some((c) => c.name === name)) cards.push({
|
|
1611
|
+
name,
|
|
1612
|
+
role: roleOf(name),
|
|
1613
|
+
status: statuses.get(name) ?? "",
|
|
1614
|
+
lastChapter: entry.last,
|
|
1615
|
+
appearances: entry.chapters.size
|
|
1616
|
+
});
|
|
1617
|
+
return cards;
|
|
1618
|
+
}
|
|
1430
1619
|
/** System prompt for foreshadow suggestions. */
|
|
1431
1620
|
function foreshadowSystemPrompt() {
|
|
1432
1621
|
return [
|
|
@@ -1707,7 +1896,21 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
|
|
|
1707
1896
|
const instructions = str(args.instructions);
|
|
1708
1897
|
const target = str(args.target);
|
|
1709
1898
|
for await (const chunk of forward(rewriteChapterStream(ctx, config, project, outputDir, no, instructions, target === "" ? void 0 : target))) yield chunk;
|
|
1710
|
-
|
|
1899
|
+
const chapter = project.chapters.find((c) => c.no === no);
|
|
1900
|
+
const draft = chapter?.pendingDraft;
|
|
1901
|
+
if (chapter === void 0 || draft === void 0 || draft === "") throw new Error(`章节 ${no} 修订后没有产出草稿`);
|
|
1902
|
+
const fileName = chapterFileName(chapter);
|
|
1903
|
+
mkdirSync(outputDir, { recursive: true });
|
|
1904
|
+
writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, "utf8");
|
|
1905
|
+
chapter.pendingDraft = void 0;
|
|
1906
|
+
chapter.status = "written";
|
|
1907
|
+
chapter.chars = draft.length;
|
|
1908
|
+
chapter.file = fileName;
|
|
1909
|
+
chapter.review = void 0;
|
|
1910
|
+
chapter.error = void 0;
|
|
1911
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1912
|
+
saveProject(outputDir, project);
|
|
1913
|
+
yield "(已采纳修订稿,正在生成章节摘要…)";
|
|
1711
1914
|
try {
|
|
1712
1915
|
await summarizeChapter(ctx, config, project, outputDir, no);
|
|
1713
1916
|
} catch {}
|
|
@@ -1723,6 +1926,9 @@ async function* executeAction(ctx, config, project, outputDir, name, args) {
|
|
|
1723
1926
|
try {
|
|
1724
1927
|
await summarizeChapter(ctx, config, project, outputDir, no);
|
|
1725
1928
|
} catch {}
|
|
1929
|
+
try {
|
|
1930
|
+
await extractFacts(ctx, config, project, outputDir, no);
|
|
1931
|
+
} catch {}
|
|
1726
1932
|
yield "(正在 AI 审稿…)";
|
|
1727
1933
|
const report = await reviewChapter(ctx, config, project, outputDir, no);
|
|
1728
1934
|
return `章节 ${no} 已生成(${project.chapters.find((c) => c.no === no)?.chars ?? "?"} 字)。审稿:${report.score} 分 — ${report.verdict}`;
|
|
@@ -2122,6 +2328,16 @@ function createBook(bookName, outputDir) {
|
|
|
2122
2328
|
saveBookshelf(store);
|
|
2123
2329
|
return book;
|
|
2124
2330
|
}
|
|
2331
|
+
/** 更新某本书的书名(开书向导导入大纲后书名以大纲首行为准)。 */
|
|
2332
|
+
function renameBook(id, bookName) {
|
|
2333
|
+
const store = loadBookshelf();
|
|
2334
|
+
const book = store.books.find((b) => b.id === id);
|
|
2335
|
+
if (book === void 0) return false;
|
|
2336
|
+
book.bookName = bookName;
|
|
2337
|
+
book.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2338
|
+
saveBookshelf(store);
|
|
2339
|
+
return true;
|
|
2340
|
+
}
|
|
2125
2341
|
/**
|
|
2126
2342
|
* 播种:书架为空时,把指定输出目录下已有的项目自动登记为第一本书。
|
|
2127
2343
|
* 兼容升级场景 —— 旧版插件直接在输出目录写项目,从未登记书架。
|
|
@@ -2470,6 +2686,11 @@ function makeRoutes(deps) {
|
|
|
2470
2686
|
} catch (error) {
|
|
2471
2687
|
console.warn("[dsh-novel-forge] summary failed:", error.message);
|
|
2472
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);
|
|
2693
|
+
}
|
|
2473
2694
|
if (!(body?.skipReview === true) && (config.autoReview ?? true)) send({
|
|
2474
2695
|
type: "review",
|
|
2475
2696
|
no,
|
|
@@ -2544,16 +2765,11 @@ function makeRoutes(deps) {
|
|
|
2544
2765
|
type: "delta",
|
|
2545
2766
|
text: step.text
|
|
2546
2767
|
});
|
|
2547
|
-
else if (step.frame === "
|
|
2548
|
-
type: "
|
|
2549
|
-
no,
|
|
2550
|
-
file: step.file,
|
|
2551
|
-
chars: step.chars
|
|
2552
|
-
});
|
|
2553
|
-
send({
|
|
2554
|
-
type: "review",
|
|
2768
|
+
else if (step.frame === "drafted") send({
|
|
2769
|
+
type: "drafted",
|
|
2555
2770
|
no,
|
|
2556
|
-
|
|
2771
|
+
chars: step.chars,
|
|
2772
|
+
draft: step.draft
|
|
2557
2773
|
});
|
|
2558
2774
|
res.end();
|
|
2559
2775
|
} catch (error) {
|
|
@@ -2596,11 +2812,11 @@ function makeRoutes(deps) {
|
|
|
2596
2812
|
type: "delta",
|
|
2597
2813
|
text: step.text
|
|
2598
2814
|
});
|
|
2599
|
-
else if (step.frame === "
|
|
2600
|
-
type: "
|
|
2815
|
+
else if (step.frame === "drafted") send({
|
|
2816
|
+
type: "drafted",
|
|
2601
2817
|
no,
|
|
2602
|
-
|
|
2603
|
-
|
|
2818
|
+
chars: step.chars,
|
|
2819
|
+
draft: step.draft
|
|
2604
2820
|
});
|
|
2605
2821
|
res.end();
|
|
2606
2822
|
} catch (error) {
|
|
@@ -2615,6 +2831,77 @@ function makeRoutes(deps) {
|
|
|
2615
2831
|
}
|
|
2616
2832
|
}
|
|
2617
2833
|
};
|
|
2834
|
+
/** 采纳待确认草稿:覆盖正文文件 + 状态回 written + 清空草稿。 */
|
|
2835
|
+
const draftApplyRoute = {
|
|
2836
|
+
kind: "exact",
|
|
2837
|
+
path: NOVEL_API.draftApply,
|
|
2838
|
+
handler: async (req, res) => {
|
|
2839
|
+
if (!guard(req, res, "POST")) return;
|
|
2840
|
+
const config = getConfig();
|
|
2841
|
+
const project = requireProject(res);
|
|
2842
|
+
if (project === void 0) return;
|
|
2843
|
+
const body = await readJsonBody(req);
|
|
2844
|
+
if (!Number.isInteger(body?.chapterNo)) {
|
|
2845
|
+
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
2846
|
+
return;
|
|
2847
|
+
}
|
|
2848
|
+
const chapter = project.chapters.find((c) => c.no === body.chapterNo);
|
|
2849
|
+
if (chapter === void 0) {
|
|
2850
|
+
writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` });
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
2853
|
+
if (chapter.pendingDraft === void 0 || chapter.pendingDraft === "") {
|
|
2854
|
+
writeJson(res, 400, { error: `章节 ${chapter.no} 没有待确认的草稿` });
|
|
2855
|
+
return;
|
|
2856
|
+
}
|
|
2857
|
+
const draft = chapter.pendingDraft;
|
|
2858
|
+
const fileName = chapterFileName(chapter);
|
|
2859
|
+
mkdirSync(config.outputDir, { recursive: true });
|
|
2860
|
+
const targetPath = join(config.outputDir, fileName);
|
|
2861
|
+
if (existsSync(targetPath)) copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, "")}.bak.md`));
|
|
2862
|
+
writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, "utf8");
|
|
2863
|
+
chapter.pendingDraft = void 0;
|
|
2864
|
+
chapter.status = "written";
|
|
2865
|
+
chapter.chars = draft.length;
|
|
2866
|
+
chapter.file = fileName;
|
|
2867
|
+
chapter.review = void 0;
|
|
2868
|
+
chapter.error = void 0;
|
|
2869
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2870
|
+
saveProject(config.outputDir, project);
|
|
2871
|
+
writeJson(res, 200, {
|
|
2872
|
+
ok: true,
|
|
2873
|
+
chars: draft.length,
|
|
2874
|
+
file: fileName
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2877
|
+
};
|
|
2878
|
+
/** 放弃待确认草稿:保留原稿,仅清空草稿字段。 */
|
|
2879
|
+
const draftDiscardRoute = {
|
|
2880
|
+
kind: "exact",
|
|
2881
|
+
path: NOVEL_API.draftDiscard,
|
|
2882
|
+
handler: async (req, res) => {
|
|
2883
|
+
if (!guard(req, res, "POST")) return;
|
|
2884
|
+
const config = getConfig();
|
|
2885
|
+
const project = requireProject(res);
|
|
2886
|
+
if (project === void 0) return;
|
|
2887
|
+
const body = await readJsonBody(req);
|
|
2888
|
+
if (!Number.isInteger(body?.chapterNo)) {
|
|
2889
|
+
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
2890
|
+
return;
|
|
2891
|
+
}
|
|
2892
|
+
const chapter = project.chapters.find((c) => c.no === body.chapterNo);
|
|
2893
|
+
if (chapter === void 0) {
|
|
2894
|
+
writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` });
|
|
2895
|
+
return;
|
|
2896
|
+
}
|
|
2897
|
+
if (chapter.pendingDraft !== void 0) {
|
|
2898
|
+
chapter.pendingDraft = void 0;
|
|
2899
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2900
|
+
saveProject(config.outputDir, project);
|
|
2901
|
+
}
|
|
2902
|
+
writeJson(res, 200, { ok: true });
|
|
2903
|
+
}
|
|
2904
|
+
};
|
|
2618
2905
|
const summaryRoute = {
|
|
2619
2906
|
kind: "exact",
|
|
2620
2907
|
path: NOVEL_API.summary,
|
|
@@ -2802,6 +3089,8 @@ function makeRoutes(deps) {
|
|
|
2802
3089
|
reviewRoute,
|
|
2803
3090
|
rewriteRoute,
|
|
2804
3091
|
polishRoute,
|
|
3092
|
+
draftApplyRoute,
|
|
3093
|
+
draftDiscardRoute,
|
|
2805
3094
|
summaryRoute,
|
|
2806
3095
|
foreshadowRoute,
|
|
2807
3096
|
exportRoute,
|
|
@@ -2911,7 +3200,14 @@ function makeRoutes(deps) {
|
|
|
2911
3200
|
writeJson(res, 400, { error: "bookName 不能为空" });
|
|
2912
3201
|
return;
|
|
2913
3202
|
}
|
|
2914
|
-
|
|
3203
|
+
const outputDir = body?.outputDir?.trim() !== "" && body?.outputDir !== void 0 ? body.outputDir : defaultOutputDirFor(bookName);
|
|
3204
|
+
const book = createBook(bookName, outputDir);
|
|
3205
|
+
const outline = body?.outline?.trim();
|
|
3206
|
+
if (outline !== void 0 && outline.length >= 50) {
|
|
3207
|
+
const project = createProject(outline);
|
|
3208
|
+
saveProject(outputDir, project);
|
|
3209
|
+
renameBook(book.id, project.bookName);
|
|
3210
|
+
}
|
|
2915
3211
|
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
|
|
2916
3212
|
return;
|
|
2917
3213
|
}
|
|
@@ -2952,6 +3248,109 @@ function makeRoutes(deps) {
|
|
|
2952
3248
|
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
|
|
2953
3249
|
}
|
|
2954
3250
|
},
|
|
3251
|
+
{
|
|
3252
|
+
kind: "exact",
|
|
3253
|
+
path: NOVEL_API.reset,
|
|
3254
|
+
handler: async (req, res) => {
|
|
3255
|
+
if (!guard(req, res, "POST")) return;
|
|
3256
|
+
const config = getConfig();
|
|
3257
|
+
const project = loadProject(config.outputDir);
|
|
3258
|
+
if (project === void 0) {
|
|
3259
|
+
writeJson(res, 400, { error: "输出目录中没有项目,无需重置" });
|
|
3260
|
+
return;
|
|
3261
|
+
}
|
|
3262
|
+
const outline = (await readJsonBody(req))?.outline?.trim();
|
|
3263
|
+
if (outline !== void 0 && outline.length >= 50) {
|
|
3264
|
+
project.outline = outline;
|
|
3265
|
+
project.bookName = createProject(outline).bookName;
|
|
3266
|
+
}
|
|
3267
|
+
project.bible = void 0;
|
|
3268
|
+
project.volumes = void 0;
|
|
3269
|
+
project.chapters = [];
|
|
3270
|
+
project.foreshadows = [];
|
|
3271
|
+
project.assets = emptyProjectAssets();
|
|
3272
|
+
project.facts = [];
|
|
3273
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3274
|
+
saveProject(config.outputDir, project);
|
|
3275
|
+
writeJson(res, 200, {
|
|
3276
|
+
ok: true,
|
|
3277
|
+
bookName: project.bookName
|
|
3278
|
+
});
|
|
3279
|
+
}
|
|
3280
|
+
},
|
|
3281
|
+
{
|
|
3282
|
+
kind: "exact",
|
|
3283
|
+
path: NOVEL_API.audit,
|
|
3284
|
+
handler: async (req, res) => {
|
|
3285
|
+
if (!guard(req, res, "POST")) return;
|
|
3286
|
+
const config = getConfig();
|
|
3287
|
+
const project = requireProject(res);
|
|
3288
|
+
if (project === void 0) return;
|
|
3289
|
+
try {
|
|
3290
|
+
writeJson(res, 200, {
|
|
3291
|
+
issues: await auditBook(ctx, config, project, config.outputDir),
|
|
3292
|
+
auditedChapters: project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating").length,
|
|
3293
|
+
auditedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3294
|
+
});
|
|
3295
|
+
} catch (error) {
|
|
3296
|
+
writeJson(res, 500, { error: error.message });
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
},
|
|
3300
|
+
{
|
|
3301
|
+
kind: "exact",
|
|
3302
|
+
path: NOVEL_API.charactersRefresh,
|
|
3303
|
+
handler: async (req, res) => {
|
|
3304
|
+
if (!guard(req, res, "POST")) return;
|
|
3305
|
+
const config = getConfig();
|
|
3306
|
+
const project = requireProject(res);
|
|
3307
|
+
if (project === void 0) return;
|
|
3308
|
+
try {
|
|
3309
|
+
writeJson(res, 200, { cards: await refreshCharacters(ctx, config, project, config.outputDir) });
|
|
3310
|
+
} catch (error) {
|
|
3311
|
+
writeJson(res, 500, { error: error.message });
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
},
|
|
3315
|
+
{
|
|
3316
|
+
kind: "exact",
|
|
3317
|
+
path: NOVEL_API.factsBackfill,
|
|
3318
|
+
handler: async (req, res) => {
|
|
3319
|
+
if (!guard(req, res, "POST")) return;
|
|
3320
|
+
const config = getConfig();
|
|
3321
|
+
const project = requireProject(res);
|
|
3322
|
+
if (project === void 0) return;
|
|
3323
|
+
try {
|
|
3324
|
+
writeJson(res, 200, {
|
|
3325
|
+
ok: true,
|
|
3326
|
+
filled: await backfillFacts(ctx, config, project, config.outputDir)
|
|
3327
|
+
});
|
|
3328
|
+
} catch (error) {
|
|
3329
|
+
writeJson(res, 500, { error: error.message });
|
|
3330
|
+
}
|
|
3331
|
+
}
|
|
3332
|
+
},
|
|
3333
|
+
{
|
|
3334
|
+
kind: "exact",
|
|
3335
|
+
path: NOVEL_API.biblePatch,
|
|
3336
|
+
handler: async (req, res) => {
|
|
3337
|
+
if (!guard(req, res, "POST")) return;
|
|
3338
|
+
const config = getConfig();
|
|
3339
|
+
const project = requireProject(res);
|
|
3340
|
+
if (project === void 0) return;
|
|
3341
|
+
if (project.bible === void 0) {
|
|
3342
|
+
writeJson(res, 400, { error: "尚未生成设定圣经,请先生成" });
|
|
3343
|
+
return;
|
|
3344
|
+
}
|
|
3345
|
+
const body = await readJsonBody(req);
|
|
3346
|
+
if (Array.isArray(body?.worldRules)) project.bible.worldRules = body.worldRules.filter((r) => r.trim() !== "");
|
|
3347
|
+
if (Array.isArray(body?.redLines)) project.bible.redLines = body.redLines.filter((r) => r.trim() !== "");
|
|
3348
|
+
if (Array.isArray(body?.style)) project.bible.style = body.style.filter((r) => r.trim() !== "");
|
|
3349
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3350
|
+
saveProject(config.outputDir, project);
|
|
3351
|
+
writeJson(res, 200, { bible: project.bible });
|
|
3352
|
+
}
|
|
3353
|
+
},
|
|
2955
3354
|
{
|
|
2956
3355
|
kind: "exact",
|
|
2957
3356
|
path: NOVEL_API.config,
|