@waterwx/dsh-novel-forge 1.1.9 → 1.3.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 +5 -0
- package/lib/client.js +3645 -1589
- package/lib/client.js.map +1 -1
- package/lib/index.js +626 -181
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +4 -4
- package/lib/types/client/locales.d.ts +8 -0
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/comic-presets.d.ts +12 -0
- package/lib/types/engine.d.ts +17 -14
- package/lib/types/index.d.ts +8 -0
- package/lib/types/protocol.d.ts +153 -89
- package/package.json +1 -1
- package/src/client/api.ts +10 -10
- package/src/client/locales.ts +16 -0
- package/src/client/panel/AssetsTab.tsx +19 -0
- package/src/client/panel/NovelPanel.tsx +1165 -390
- package/src/client/panel/ShelfView.tsx +13 -12
- package/src/client/panel/panel.module.css +192 -68
- package/src/comic-presets.ts +67 -0
- package/src/engine.ts +351 -187
- package/src/index.ts +21 -0
- package/src/protocol.ts +147 -96
- package/src/routes.ts +204 -56
package/lib/index.js
CHANGED
|
@@ -614,6 +614,56 @@ function styleEngineSystemPrompt() {
|
|
|
614
614
|
].join("\n");
|
|
615
615
|
}
|
|
616
616
|
//#endregion
|
|
617
|
+
//#region src/comic-presets.ts
|
|
618
|
+
const COMIC_STYLE_PRESETS = [
|
|
619
|
+
{
|
|
620
|
+
id: "japan",
|
|
621
|
+
label: "日漫 / 黑白",
|
|
622
|
+
prompt: "manga style, screentone, bold ink lines, high contrast, black and white, cel shading",
|
|
623
|
+
layoutHint: "页漫优先"
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
id: "korea",
|
|
627
|
+
label: "韩漫 / 条漫",
|
|
628
|
+
prompt: "manhwa style, soft cel shading, vibrant colors, clean lineart, webtoon vertical composition, glossy highlights",
|
|
629
|
+
layoutHint: "条漫优先"
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
id: "china",
|
|
633
|
+
label: "国漫 / 古风",
|
|
634
|
+
prompt: "chinese ink painting style, flowing brush strokes, traditional palette, ethereal atmosphere, xianxia aesthetic",
|
|
635
|
+
layoutHint: "页漫/条漫均可"
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
id: "america",
|
|
639
|
+
label: "美漫 / 超级英雄",
|
|
640
|
+
prompt: "american comic style, bold outlines, dramatic lighting, dynamic poses, vibrant colors, halftone shading",
|
|
641
|
+
layoutHint: "页漫优先"
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
id: "chibi",
|
|
645
|
+
label: "Q版 / 搞笑",
|
|
646
|
+
prompt: "chibi style, cute proportions, big head, exaggerated expressions, simple background, bright colors",
|
|
647
|
+
layoutHint: "条漫/四格优先"
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
id: "realistic",
|
|
651
|
+
label: "写实 / 电影感",
|
|
652
|
+
prompt: "realistic cinematic style, detailed textures, natural lighting, film grain, high detail",
|
|
653
|
+
layoutHint: "页漫/全彩"
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
id: "cyber",
|
|
657
|
+
label: "赛博 / 暗黑",
|
|
658
|
+
prompt: "cyberpunk style, neon lights, dark atmosphere, high contrast, futuristic city, cool tones",
|
|
659
|
+
layoutHint: "页漫/全彩"
|
|
660
|
+
}
|
|
661
|
+
];
|
|
662
|
+
function getComicStylePrompt(styleId) {
|
|
663
|
+
if (!styleId) return "";
|
|
664
|
+
return COMIC_STYLE_PRESETS.find((s) => s.id === styleId)?.prompt ?? "";
|
|
665
|
+
}
|
|
666
|
+
//#endregion
|
|
617
667
|
//#region src/engine.ts
|
|
618
668
|
/**
|
|
619
669
|
* Novel engine — the host half's core: LLM-driven story-bible extraction,
|
|
@@ -1116,7 +1166,8 @@ async function complete(ctx, config, options) {
|
|
|
1116
1166
|
messages,
|
|
1117
1167
|
system: options.system,
|
|
1118
1168
|
maxTokens: options.maxTokens ?? config.maxTokens,
|
|
1119
|
-
temperature: options.temperature ?? .7
|
|
1169
|
+
temperature: options.temperature ?? .7,
|
|
1170
|
+
reasoningEffort: ReasoningEffortId(config.reasoningEffort ?? "off")
|
|
1120
1171
|
};
|
|
1121
1172
|
const assembler = new BlockAssembler();
|
|
1122
1173
|
for await (const chunk of ctx.llm.stream(request)) assembler.push(chunk);
|
|
@@ -1214,6 +1265,7 @@ function bibleSystemPrompt() {
|
|
|
1214
1265
|
"3. 世界规则覆盖力量体系、金手指机制、势力、地理等所有硬性规则,逐条列出。",
|
|
1215
1266
|
"4. 红线列出大纲中明确禁止的内容(如无后宫、不圣母、无无脑碾压等)。",
|
|
1216
1267
|
"5. 风格列出叙事基调、节奏、POV 等写作风格要点。",
|
|
1268
|
+
"6. 角色名必须用正文/编年录中的真实姓名;若大纲只写「主角」未点名,而已写章节或编年录中有名字,则用该真实姓名;禁止输出「主角(描述)」这类把身份塞进名字的占位名。",
|
|
1217
1269
|
"输出必须是合法 JSON 对象,不要输出任何其他文字或 Markdown 代码块标记。",
|
|
1218
1270
|
"重要:所有字符串值内部不得包含换行符(不要用多行字符串),JSON 必须在一段内完整结束。",
|
|
1219
1271
|
"重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
|
|
@@ -1222,8 +1274,21 @@ function bibleSystemPrompt() {
|
|
|
1222
1274
|
].join("\n");
|
|
1223
1275
|
}
|
|
1224
1276
|
/** Extract the story bible from an outline. */
|
|
1225
|
-
async function extractBible(ctx, config, outline) {
|
|
1226
|
-
const
|
|
1277
|
+
async function extractBible(ctx, config, outline, project) {
|
|
1278
|
+
const written = (project?.chapters ?? []).filter((c) => c.status !== "pending" && c.status !== "generating" && c.file !== void 0);
|
|
1279
|
+
const excerpts = [];
|
|
1280
|
+
for (const chapter of written.slice(0, 3)) {
|
|
1281
|
+
const body = readChapterFile(config.outputDir, chapter);
|
|
1282
|
+
if (body === void 0) continue;
|
|
1283
|
+
const text = body.replace(/^#.*$/gm, "").trim();
|
|
1284
|
+
if (text.length > 0) excerpts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0, 2200)}`);
|
|
1285
|
+
}
|
|
1286
|
+
const facts = (project?.facts ?? []).slice(-40);
|
|
1287
|
+
const user = [
|
|
1288
|
+
`请为下面这部小说提炼设定圣经:\n\n${outline}`,
|
|
1289
|
+
facts.length > 0 ? `\n\n【已写章节事实(编年录)】用于确认真实角色姓名与已确立设定;忠于大纲,不要新增大纲外设定:\n${facts.map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 100)}`).join("\n")}` : "",
|
|
1290
|
+
excerpts.length > 0 ? `\n\n【已写章节正文摘录】角色姓名、身份以正文为准(大纲未点名时用正文里的真实姓名,禁止用「主角(描述)」占位名):\n${excerpts.join("\n\n")}` : ""
|
|
1291
|
+
].filter((s) => s !== "").join("\n");
|
|
1227
1292
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1228
1293
|
system: bibleSystemPrompt(),
|
|
1229
1294
|
user,
|
|
@@ -1244,6 +1309,10 @@ async function extractBible(ctx, config, outline) {
|
|
|
1244
1309
|
relations: typeof entry.relations === "string" ? entry.relations : "",
|
|
1245
1310
|
knowledge: strArray(entry.knowledge)
|
|
1246
1311
|
})).filter((card) => card.name !== "") : [];
|
|
1312
|
+
const realProtagonist = (project?.roles ?? []).find((r) => r.roleLabel === "protagonist")?.name?.trim();
|
|
1313
|
+
if (realProtagonist !== void 0 && realProtagonist !== "") {
|
|
1314
|
+
for (const card of characters) if (card.role === "protagonist" && /^(主角|未命名)/.test(card.name)) card.name = realProtagonist;
|
|
1315
|
+
}
|
|
1247
1316
|
const bible = {
|
|
1248
1317
|
genre: typeof raw.genre === "string" ? raw.genre : "",
|
|
1249
1318
|
worldRules: strArray(raw.worldRules),
|
|
@@ -1416,17 +1485,18 @@ async function planChapters(ctx, config, project, chapterCount, volumeNo, output
|
|
|
1416
1485
|
} catch {}
|
|
1417
1486
|
}
|
|
1418
1487
|
const outlineBlock = continuation ? (() => {
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
return project.outline.slice(0,
|
|
1488
|
+
if (project.outline.length <= 2e4) return project.outline;
|
|
1489
|
+
const cut = project.outline.indexOf("十、全书核心弹幕记忆梗");
|
|
1490
|
+
if (cut > 1500) return project.outline.slice(0, cut).trimEnd() + "\n(大纲过长,末尾记忆梗从略)";
|
|
1491
|
+
return project.outline.slice(0, 8e3) + "\n…(大纲过长已节选)";
|
|
1422
1492
|
})() : project.outline;
|
|
1423
1493
|
const user = [
|
|
1424
1494
|
"请为下面这部小说规划章节。",
|
|
1425
1495
|
volume !== void 0 ? `本次只规划第 ${volume.no} 卷《${volume.title}》的章节:\n${volume.summary}` : continuation ? `本书已有 ${existing.length} 章已规划/已写作(见下方「已有章节」)。请规划**后续**章节:从第 ${startNo} 章开始。` : "请规划全书开篇章节。",
|
|
1426
|
-
continuation ? "
|
|
1496
|
+
continuation ? "【续写硬性要求】已有章节的剧情不得重写或重复,章节标题也不得与已有章节重复。以下情节均已在已有章节中发生过,后续章节**绝对不得再次出现**:穿越、暴雨送餐、滴血认主/古玉认主、首次进入墟境、用废铁淬炼首件法器、绝境肉身入鼎洗炼(该机缘已用尽)、杀死白袍弟子与灰衣随从、藏尸水沟。\n若本次规划已进入大纲的收尾区间(接近全书规划总章数),最后 5-10 章必须按大纲推进到大结局(终极抉择/清算/双结局等),**禁止以悬念、逃离、未解之谜收尾**——收尾区间按大纲卷定位判断,不以当前剧情是否“感觉像结尾”为准。" : "",
|
|
1427
1497
|
prevTail !== "" ? `【上一章(第 ${startNo - 1} 章)结尾原文】第 ${startNo} 章必须紧接此状态继续,从新的事件写起,不得回顾重述:\n${prevTail}` : "",
|
|
1428
1498
|
latestFacts !== "" ? `【最新剧情状态(本书编年录,第 ${startNo - 1} 章结尾的事实)】规划续写时必须以此为起点,时间线、人物状态与地点衔接一致:\n${latestFacts}` : "",
|
|
1429
|
-
continuation ? "
|
|
1499
|
+
continuation ? "已有章节(共 " + existing.length + " 章;仅列最近 80 章,更早的以标题计数为准,剧情以「编年录」为权威):\n" + existing.slice(-80).map((c) => {
|
|
1430
1500
|
const sm = c.summary !== void 0 && c.summary !== "" ? `(${c.summary.slice(0, 120)})` : "";
|
|
1431
1501
|
return `第${c.no}章《${c.title}》${sm}`;
|
|
1432
1502
|
}).join("\n") : "",
|
|
@@ -1535,7 +1605,7 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
1535
1605
|
const score = typeof raw.score === "number" ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60;
|
|
1536
1606
|
const report = {
|
|
1537
1607
|
score,
|
|
1538
|
-
passed: score >= config.reviewPassScore,
|
|
1608
|
+
passed: score >= config.reviewPassScore || issues.every((i) => i.severity !== "high") && score >= 60,
|
|
1539
1609
|
verdict: typeof raw.verdict === "string" ? raw.verdict.slice(0, 200) : "",
|
|
1540
1610
|
issues,
|
|
1541
1611
|
reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -1573,7 +1643,7 @@ async function reviewChapterText(ctx, config, project, text, previousReport) {
|
|
|
1573
1643
|
suggestion: typeof entry.suggestion === "string" ? entry.suggestion : ""
|
|
1574
1644
|
})).filter((issue) => issue.item !== "") : [];
|
|
1575
1645
|
const score = typeof raw.score === "number" ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60;
|
|
1576
|
-
let passed = score >= config.reviewPassScore;
|
|
1646
|
+
let passed = score >= config.reviewPassScore || issues.every((i) => i.severity !== "high") && score >= 60;
|
|
1577
1647
|
if (previousReport !== void 0) {
|
|
1578
1648
|
const hasHigh = issues.some((i) => i.severity === "high");
|
|
1579
1649
|
const prevHighResolved = previousReport.issues.filter((i) => i.severity === "high").every((p) => !issues.some((i) => i.item.includes(p.item.slice(0, 12))));
|
|
@@ -1744,7 +1814,7 @@ async function extractRoles(ctx, config, project) {
|
|
|
1744
1814
|
"覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如\"矮胖姑娘\")用其身份简称收录并标注。",
|
|
1745
1815
|
"数量控制:最多输出 10 个角色,宁缺毋滥;路人级一次带过的不要收录。",
|
|
1746
1816
|
"每个角色输出:",
|
|
1747
|
-
"1. name
|
|
1817
|
+
"1. name:角色名(或身份简称)。主角/有名配角必须用正文中实际出现的人名(如「沈放」),禁止用「主角(38岁超市理货员)」这类把身份塞进名字的占位名;正文确实没点名时才可用身份简称(如「富商」「灰衣老人」)。",
|
|
1748
1818
|
"2. roleLabel:定位——protagonist=主角;female_lead=女主(唯一知己/感情线核心,无后宫前提下只此一位);female_support=重要女配;support=普通配角;antagonist=反派;extra=路人/背景。",
|
|
1749
1819
|
"3. identity:身份一句话(宗门/势力/血脉/职业)。",
|
|
1750
1820
|
"4. traits:3-6 个性格标签。",
|
|
@@ -1760,12 +1830,20 @@ async function extractRoles(ctx, config, project) {
|
|
|
1760
1830
|
].join("\n");
|
|
1761
1831
|
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
1762
1832
|
const existingRoles = project.roles ?? [];
|
|
1833
|
+
const excerptParts = [];
|
|
1834
|
+
for (const chapter of written.slice(0, 3)) {
|
|
1835
|
+
const body = readChapterFile(config.outputDir, chapter);
|
|
1836
|
+
if (body === void 0) continue;
|
|
1837
|
+
const text = body.replace(/^#.*$/gm, "").trim();
|
|
1838
|
+
if (text.length > 0) excerptParts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0, 2400)}`);
|
|
1839
|
+
}
|
|
1763
1840
|
const raw = parseJsonArray(await complete(ctx, config, {
|
|
1764
1841
|
system,
|
|
1765
1842
|
user: [
|
|
1766
1843
|
`书名:《${project.bookName}》`,
|
|
1767
1844
|
existingRoles.length > 0 ? `已收录角色(跳过,不要输出):${existingRoles.map((r) => r.name).join("、")}` : "",
|
|
1768
1845
|
`大纲(节选前 3000 字):\n${project.outline.slice(0, 3e3)}`,
|
|
1846
|
+
excerptParts.length > 0 ? `已写正文摘录(角色姓名/身份/关系以正文为准):\n${excerptParts.join("\n\n")}` : "",
|
|
1769
1847
|
project.bible !== void 0 && project.bible.characters.length > 0 ? `已有角色卡(补充信息):\n${project.bible.characters.map((c) => `- ${c.name}(${c.role}):${c.traits.join("、")}${c.goals !== "" ? `;目标:${c.goals}` : ""}`).join("\n")}` : "",
|
|
1770
1848
|
(project.facts ?? []).length > 0 ? `编年录(最近 60 条):\n${(project.facts ?? []).slice(-60).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 80)}`).join("\n")}` : "",
|
|
1771
1849
|
written.length > 0 ? `已写章节标题(${written.length} 章):\n${written.map((c) => `第${c.no}章《${c.title}》`).join("、")}` : "",
|
|
@@ -1801,6 +1879,75 @@ async function extractRoles(ctx, config, project) {
|
|
|
1801
1879
|
}
|
|
1802
1880
|
return roles;
|
|
1803
1881
|
}
|
|
1882
|
+
/** ✨ AI 从全书提炼场景库:正文/编年录 → 高频重要场景的结构化视觉锚点。 */
|
|
1883
|
+
async function extractScenes(ctx, config, project) {
|
|
1884
|
+
const system = [
|
|
1885
|
+
"你是一位网文漫剧场景导演。根据本书的大纲、编年录与已写章节摘录,提炼「镜头场景」——漫剧分镜/生图时每个镜头要知道\"在哪、什么时间光态、拍什么情节、人物什么状态\"。",
|
|
1886
|
+
"按五幕结构覆盖全书核心场景(短篇 10 章内可少于五幕,按实际剧情组织):第一幕后场/核心主场、第二幕对外卖场/营业区、第三幕禁忌区域/地下深仓、第四幕外部人间世界(短暂出逃)、第五幕回归与抉择。",
|
|
1887
|
+
"每个场景必须是「镜头场景」而非仅场地:给出该场景的关键情节镜头(人物动作+情绪+镜头推进)。",
|
|
1888
|
+
"数量控制:最多输出 8 个场景;每个场景必须能对应到正文实际出现过的地点与情节,不得凭空虚构。",
|
|
1889
|
+
"每个场景输出:",
|
|
1890
|
+
"1. name:场景名(地点+光态,如「后场通道·装卸口(雨夜)」)。",
|
|
1891
|
+
"2. act:幕归属(第一幕后场/第二幕卖场/第三幕深仓/第四幕外界/第五幕抉择)。",
|
|
1892
|
+
"3. moment:时间光态(夜间闭店后/雨夜/凌晨/频闪灯…)。",
|
|
1893
|
+
"4. summary:一句话定位(空间类型/功能/氛围)。",
|
|
1894
|
+
"5. beats:关键情节镜头 1-3 条(人物动作+情绪+镜头推进,如\"沈放佝偻站在货架过道,镜头推近,情绪从漠然过渡到压抑悲伤\")。",
|
|
1895
|
+
"6. characterState:主角/关键角色在该场景的状态一句话(含标志物细节如标签磨损、手腕红痕)。",
|
|
1896
|
+
"7. elements:环境构成数组 3-6 项(空间结构/陈设/标志物)。",
|
|
1897
|
+
"8. palette:色调与光影数组 2-4 项(颜色词,可附 HEX)。",
|
|
1898
|
+
"9. moods:氛围关键词 2-4 个(压抑/神秘/空旷/悲凉…)。",
|
|
1899
|
+
"10. zh:中文生图提示词(连贯一段,写实电影感,含环境/光线/氛围/人物状态)。",
|
|
1900
|
+
"11. en:英文生图提示词(booru 风格逗号分隔,含 photorealistic/cinematic)。",
|
|
1901
|
+
"12. tags:3-6 个关键标签。",
|
|
1902
|
+
"13. source:依据来源说明(哪几章哪些描写)。",
|
|
1903
|
+
"若故事存在结局抉择(如双分支),最后 1-2 个场景应为「抉择场景」,zh 里写明分支 A/B 两种画面。",
|
|
1904
|
+
"输出必须是合法 JSON 数组,不要输出其他文字:[{\"name\":\"...\", \"act\":\"...\", \"moment\":\"...\", \"summary\":\"...\", \"beats\":[...], \"characterState\":\"...\", \"elements\":[...], \"palette\":[...], \"moods\":[...], \"zh\":\"...\", \"en\":\"...\", \"tags\":[...], \"source\":\"...\"}]",
|
|
1905
|
+
"重要:所有字符串值内部不得包含换行符;直接输出 JSON 结果本身。"
|
|
1906
|
+
].join("\n");
|
|
1907
|
+
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
1908
|
+
const excerptParts = [];
|
|
1909
|
+
for (const chapter of written.slice(0, 4)) {
|
|
1910
|
+
const body = readChapterFile(config.outputDir, chapter);
|
|
1911
|
+
if (body === void 0) continue;
|
|
1912
|
+
const text = body.replace(/^#.*$/gm, "").trim();
|
|
1913
|
+
if (text.length > 0) excerptParts.push("第" + chapter.no + "章《" + chapter.title + "》\n" + text.slice(0, 1800));
|
|
1914
|
+
}
|
|
1915
|
+
const raw = parseJsonArray(await complete(ctx, config, {
|
|
1916
|
+
system,
|
|
1917
|
+
user: [
|
|
1918
|
+
"书名:《" + project.bookName + "》",
|
|
1919
|
+
(project.facts ?? []).length > 0 ? "编年录(最近 80 条,场景地点以这里为准):\n" + (project.facts ?? []).slice(-80).map((f) => "[第" + f.chapterNo + "章] " + f.text.slice(0, 80)).join("\n") : "",
|
|
1920
|
+
excerptParts.length > 0 ? "已写正文摘录(场景描写以正文为准):\n" + excerptParts.join("\n") : "",
|
|
1921
|
+
"只输出 JSON 数组。"
|
|
1922
|
+
].filter((s) => s !== "").join("\n"),
|
|
1923
|
+
temperature: .4,
|
|
1924
|
+
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
1925
|
+
}));
|
|
1926
|
+
const strArr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "") : [];
|
|
1927
|
+
const str = (v) => typeof v === "string" ? v.trim() : "";
|
|
1928
|
+
const scenes = [];
|
|
1929
|
+
for (const entry of raw) {
|
|
1930
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1931
|
+
const name = str(entry.name).slice(0, 40);
|
|
1932
|
+
if (name === "") continue;
|
|
1933
|
+
scenes.push({
|
|
1934
|
+
name,
|
|
1935
|
+
act: str(entry.act).slice(0, 40),
|
|
1936
|
+
moment: str(entry.moment).slice(0, 60),
|
|
1937
|
+
summary: str(entry.summary).slice(0, 120),
|
|
1938
|
+
beats: strArr(entry.beats).map((t) => t.slice(0, 120)).slice(0, 3),
|
|
1939
|
+
characterState: str(entry.characterState).slice(0, 160),
|
|
1940
|
+
elements: strArr(entry.elements).map((t) => t.slice(0, 60)).slice(0, 6),
|
|
1941
|
+
palette: strArr(entry.palette).map((t) => t.slice(0, 40)).slice(0, 4),
|
|
1942
|
+
moods: strArr(entry.moods).map((t) => t.slice(0, 20)).slice(0, 4),
|
|
1943
|
+
zh: str(entry.zh).slice(0, 800),
|
|
1944
|
+
en: str(entry.en).slice(0, 600),
|
|
1945
|
+
tags: strArr(entry.tags).map((t) => t.slice(0, 24)).slice(0, 6),
|
|
1946
|
+
source: str(entry.source).slice(0, 120)
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
return scenes;
|
|
1950
|
+
}
|
|
1804
1951
|
/**
|
|
1805
1952
|
* 为单个角色提炼「动漫形象描述词」:扫描该角色出场的已写章节正文,
|
|
1806
1953
|
* 截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
|
|
@@ -1809,6 +1956,72 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
|
|
|
1809
1956
|
const role = (project.roles ?? []).find((r) => r.name === roleName);
|
|
1810
1957
|
if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
|
|
1811
1958
|
const appearanceHints = /(发|眉|眼|眸|脸|肤|唇|身材|身高|衣|袍|裙|衫|靴|腰带|气质|模样|长相|容貌|披|束|扎|戴|佩|挂|绣|青|白|黑|红|蓝|紫|灰|银|金|少年|青年|少女|汉子|老者|中年|纤细|挺拔|瘦削|壮实|清秀|俊朗|英气|阴鸷|慈眉)/;
|
|
1959
|
+
const roleText = `${role.name} ${role.identity ?? ""}`;
|
|
1960
|
+
const colorWords = [
|
|
1961
|
+
"深灰",
|
|
1962
|
+
"灰蓝",
|
|
1963
|
+
"灰",
|
|
1964
|
+
"蓝",
|
|
1965
|
+
"白",
|
|
1966
|
+
"黑",
|
|
1967
|
+
"红",
|
|
1968
|
+
"青",
|
|
1969
|
+
"紫",
|
|
1970
|
+
"银",
|
|
1971
|
+
"金",
|
|
1972
|
+
"黄",
|
|
1973
|
+
"绿",
|
|
1974
|
+
"粉",
|
|
1975
|
+
"棕"
|
|
1976
|
+
];
|
|
1977
|
+
const garmentWords = [
|
|
1978
|
+
"工装",
|
|
1979
|
+
"西装",
|
|
1980
|
+
"定制西装",
|
|
1981
|
+
"袍",
|
|
1982
|
+
"裙",
|
|
1983
|
+
"衫",
|
|
1984
|
+
"衣",
|
|
1985
|
+
"裤",
|
|
1986
|
+
"靴",
|
|
1987
|
+
"鞋",
|
|
1988
|
+
"帽",
|
|
1989
|
+
"腰带",
|
|
1990
|
+
"制服",
|
|
1991
|
+
"外套",
|
|
1992
|
+
"马甲",
|
|
1993
|
+
"夹克"
|
|
1994
|
+
];
|
|
1995
|
+
const roleWords = [
|
|
1996
|
+
"女人",
|
|
1997
|
+
"男人",
|
|
1998
|
+
"老者",
|
|
1999
|
+
"老人",
|
|
2000
|
+
"中年",
|
|
2001
|
+
"青年",
|
|
2002
|
+
"少女",
|
|
2003
|
+
"姑娘",
|
|
2004
|
+
"男子",
|
|
2005
|
+
"女子",
|
|
2006
|
+
"工人",
|
|
2007
|
+
"路人",
|
|
2008
|
+
"购买者"
|
|
2009
|
+
];
|
|
2010
|
+
const locationWords = [
|
|
2011
|
+
"别墅",
|
|
2012
|
+
"小区",
|
|
2013
|
+
"街区"
|
|
2014
|
+
];
|
|
2015
|
+
const identityTokens = (role.identity ?? "").split(/[,,。\s]+/).filter((t) => t.length >= 2);
|
|
2016
|
+
const searchTokens = Array.from(/* @__PURE__ */ new Set([
|
|
2017
|
+
roleName,
|
|
2018
|
+
...identityTokens,
|
|
2019
|
+
...colorWords.filter((w) => roleText.includes(w)),
|
|
2020
|
+
...garmentWords.filter((w) => roleText.includes(w)),
|
|
2021
|
+
...roleWords.filter((w) => roleText.includes(w)),
|
|
2022
|
+
...locationWords.filter((w) => roleText.includes(w))
|
|
2023
|
+
])).filter((t) => t.length >= 2);
|
|
2024
|
+
const matchesRole = (para) => para.includes(roleName) || searchTokens.some((tok) => para.includes(tok));
|
|
1812
2025
|
const excerpts = [];
|
|
1813
2026
|
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.file !== void 0).slice(-60);
|
|
1814
2027
|
for (const chapter of written) {
|
|
@@ -1818,7 +2031,7 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
|
|
|
1818
2031
|
let perChapter = 0;
|
|
1819
2032
|
for (const para of paras) {
|
|
1820
2033
|
if (perChapter >= 2 || excerpts.length >= 12) break;
|
|
1821
|
-
if (!para
|
|
2034
|
+
if (!matchesRole(para)) continue;
|
|
1822
2035
|
if (appearanceHints.test(para) || excerpts.length < 4) {
|
|
1823
2036
|
excerpts.push({
|
|
1824
2037
|
no: chapter.no,
|
|
@@ -1832,17 +2045,27 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
|
|
|
1832
2045
|
if (excerpts.length === 0) throw new Error(`正文中未找到「${roleName}」的出场描写(仅搜索最近 60 章),请确认角色名与正文一致`);
|
|
1833
2046
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1834
2047
|
system: [
|
|
1835
|
-
"
|
|
2048
|
+
"你是一位动漫角色设定师与 AI 绘图提示词工程师。根据网文正文中该角色的实际外貌描写,输出「形象锚点」与「四类生图提示词」——一次完成,用于 AI 绘图(NovelAI / Stable Diffusion / Midjourney / 豆包等)生成一致的角色立绘。",
|
|
1836
2049
|
"硬性要求(依据优先):",
|
|
1837
2050
|
"1. 发色/发型/瞳色/服装/气质/标志物必须来自提供的正文段落,不得凭空发明。",
|
|
1838
|
-
"2.
|
|
1839
|
-
"3.
|
|
1840
|
-
"
|
|
1841
|
-
"
|
|
1842
|
-
|
|
2051
|
+
"2. 正文未明确写到的项目(如瞳色没写、身高没写),用「未定」标注或直接不写,不要编造数值。",
|
|
2052
|
+
"3. 服装优先取正文明确出现的(颜色+款式),多次出现取最常穿的组合;服装按分件组织(上身/下身/鞋/配饰)。",
|
|
2053
|
+
"4. 标志物(标签/印记/饰品)必须出现在每段提示词中——它们是一致性的命根子。",
|
|
2054
|
+
"【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):",
|
|
2055
|
+
...(project.visualRules ?? []).map((r) => "- " + r),
|
|
2056
|
+
"输出六部分:",
|
|
2057
|
+
"- zh:中文形象锚点,一段连贯文字(60-150 字):发色发型、瞳色、脸型气质、服装(颜色款式)、身材、标志性物件。",
|
|
2058
|
+
"- en:英文形象锚点,booru 风格、逗号分隔、小写,30-50 个标签:含性别(1boy/1girl)、发色、发型、瞳色、服装、气质、标志物。不要输出负面提示词。",
|
|
1843
2059
|
"- tags:中文关键标签数组,5-10 个(如 [\"黑发\",\"束发\",\"青色道袍\",\"清秀\",\"腰悬古玉\"])。",
|
|
1844
2060
|
"- source:说明依据(如\"第1章/第8章外貌描写;瞳色未明确\")。",
|
|
1845
|
-
"
|
|
2061
|
+
"- expressions:该角色在本书剧情中需要的情绪表情清单数组,6-12 个(如 [\"疲惫\",\"麻木\",\"压抑悲伤\",\"皱眉\",\"紧绷\",\"放空\"]),依据正文情绪描写与角色处境推断。",
|
|
2062
|
+
"- promptKit:四类精修提示词(字段化+出图约束,参考示例结构):",
|
|
2063
|
+
" · portrait 立绘:角色设计稿定位,纯白纯色背景,全身完整无裁切,无多余杂物;字段流:年龄感、身高(有依据才写)、发型发色、胡茬、眼眸、面部气质、上身服装分件(含磨损)、下身、鞋、标志物细节。",
|
|
2064
|
+
" · sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,全身完整,四个视角分别描述,服装与标志物细节完整展示。",
|
|
2065
|
+
" · expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。",
|
|
2066
|
+
" · details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出(如标签/印记/工牌/袖口磨损/鞋),每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。",
|
|
2067
|
+
"promptKit 每段 zh:连贯中文 60-150 字;en:booru 标签 30-50 个。",
|
|
2068
|
+
"输出必须是合法 JSON 对象:{\"zh\": \"...\", \"en\": \"...\", \"tags\": [...], \"source\": \"...\", \"expressions\": [...], \"promptKit\": {\"portrait\": {\"zh\":\"...\", \"en\":\"...\"}, \"sheet\": {\"zh\":\"...\", \"en\":\"...\"}, \"expressions\": [{\"name\":\"疲惫\",\"zh\":\"...\",\"en\":\"...\"}], \"details\": {\"zh\":\"...\", \"en\":\"...\"}}}",
|
|
1846
2069
|
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
1847
2070
|
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
1848
2071
|
].join("\n"),
|
|
@@ -1855,21 +2078,128 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
|
|
|
1855
2078
|
"只输出 JSON 对象。"
|
|
1856
2079
|
].join("\n\n"),
|
|
1857
2080
|
temperature: .4,
|
|
1858
|
-
maxTokens: Math.max(config.maxTokens,
|
|
2081
|
+
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
1859
2082
|
}));
|
|
1860
2083
|
const zh = typeof raw.zh === "string" ? raw.zh.trim().slice(0, 500) : "";
|
|
1861
2084
|
const en = typeof raw.en === "string" ? raw.en.trim().slice(0, 1500) : "";
|
|
1862
2085
|
const tags = Array.isArray(raw.tags) ? raw.tags.filter((t) => typeof t === "string" && t.trim() !== "").map((t) => t.trim().slice(0, 20)).slice(0, 12) : [];
|
|
1863
2086
|
const source = typeof raw.source === "string" ? raw.source.trim().slice(0, 300) : "";
|
|
2087
|
+
const expressions = Array.isArray(raw.expressions) ? raw.expressions.filter((e) => typeof e === "string" && e.trim() !== "").map((e) => e.trim().slice(0, 12)).slice(0, 12) : [];
|
|
2088
|
+
const str = (v) => typeof v === "string" ? v.trim() : "";
|
|
2089
|
+
const pair = (v) => {
|
|
2090
|
+
const o = typeof v === "object" && v !== null ? v : {};
|
|
2091
|
+
return {
|
|
2092
|
+
zh: str(o.zh).slice(0, 800),
|
|
2093
|
+
en: str(o.en).slice(0, 1200)
|
|
2094
|
+
};
|
|
2095
|
+
};
|
|
2096
|
+
const kitRaw = typeof raw.promptKit === "object" && raw.promptKit !== null ? raw.promptKit : {};
|
|
2097
|
+
const kitExpr = Array.isArray(kitRaw.expressions) ? kitRaw.expressions.filter((e) => typeof e === "object" && e !== null) : [];
|
|
2098
|
+
let promptKit;
|
|
2099
|
+
try {
|
|
2100
|
+
promptKit = {
|
|
2101
|
+
portrait: pair(kitRaw.portrait),
|
|
2102
|
+
sheet: pair(kitRaw.sheet),
|
|
2103
|
+
expressions: kitExpr.map((e) => ({
|
|
2104
|
+
name: str(e.name).slice(0, 12) || "表情",
|
|
2105
|
+
zh: str(e.zh).slice(0, 800),
|
|
2106
|
+
en: str(e.en).slice(0, 1200)
|
|
2107
|
+
})).slice(0, 12),
|
|
2108
|
+
details: pair(kitRaw.details)
|
|
2109
|
+
};
|
|
2110
|
+
} catch {
|
|
2111
|
+
promptKit = void 0;
|
|
2112
|
+
}
|
|
1864
2113
|
if (zh === "" || en === "") throw new Error("形象描述提炼失败:LLM 未返回有效 JSON");
|
|
1865
2114
|
return {
|
|
1866
2115
|
zh,
|
|
1867
2116
|
en,
|
|
1868
2117
|
tags,
|
|
1869
|
-
source
|
|
2118
|
+
source,
|
|
2119
|
+
expressions,
|
|
2120
|
+
promptKit
|
|
1870
2121
|
};
|
|
1871
2122
|
}
|
|
1872
2123
|
/**
|
|
2124
|
+
* 角色四类生图提示词精修包(promptKit):以锚点 + 表情清单 + 视觉规则为输入,
|
|
2125
|
+
* LLM 产出 立绘/四视图/表情×N/细节 四套 zh+en 提示词(比前端拼装更精炼)。
|
|
2126
|
+
*/
|
|
2127
|
+
async function generateRolePromptKit(ctx, config, project, roleName) {
|
|
2128
|
+
const role = (project.roles ?? []).find((r) => r.name === roleName);
|
|
2129
|
+
if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
|
|
2130
|
+
if (role.imagePrompt === void 0) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`);
|
|
2131
|
+
const raw = parseJsonObject(await complete(ctx, config, {
|
|
2132
|
+
system: [
|
|
2133
|
+
"你是一位 AI 绘图提示词工程师。基于给定角色的形象锚点、表情清单与本书视觉规则,输出四类生图提示词(每类 zh+en 各一段)。",
|
|
2134
|
+
"四类:",
|
|
2135
|
+
"1. portrait 立绘:全身/半身正视图,含完整服装与标志物。",
|
|
2136
|
+
"2. sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,四个视角分别描述。",
|
|
2137
|
+
"3. expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。",
|
|
2138
|
+
"4. details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出,每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。",
|
|
2139
|
+
"【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):",
|
|
2140
|
+
...(project.visualRules ?? []).map((r) => "- " + r),
|
|
2141
|
+
"zh 要求:连贯中文,写实电影感,60-150 字/段;en 要求:booru 风格逗号分隔标签,30-50 个/段。",
|
|
2142
|
+
"输出必须是合法 JSON 对象:{\"portrait\": {\"zh\": \"...\", \"en\": \"...\"}, \"sheet\": {\"zh\": \"...\", \"en\": \"...\"}, \"expressions\": [{\"name\": \"疲惫\", \"zh\": \"...\", \"en\": \"...\"}], \"details\": {\"zh\": \"...\", \"en\": \"...\"}}",
|
|
2143
|
+
"重要:所有字符串值内部不得包含换行符;直接输出 JSON 结果本身。"
|
|
2144
|
+
].join("\n"),
|
|
2145
|
+
user: [
|
|
2146
|
+
`角色:${role.name}(${role.identity ?? ""})`,
|
|
2147
|
+
`中文锚点:${role.imagePrompt.zh}`,
|
|
2148
|
+
`英文锚点:${role.imagePrompt.en}`,
|
|
2149
|
+
`关键标签:${role.imagePrompt.tags.join("、")}`,
|
|
2150
|
+
`表情清单:${(role.expressions ?? []).join("、") || "(未提供,按角色气质推断 6 个)"}`,
|
|
2151
|
+
"只输出 JSON 对象。"
|
|
2152
|
+
].join("\n\n"),
|
|
2153
|
+
temperature: .4,
|
|
2154
|
+
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
2155
|
+
}));
|
|
2156
|
+
const str = (v) => typeof v === "string" ? v.trim() : "";
|
|
2157
|
+
const pair = (v) => {
|
|
2158
|
+
const o = typeof v === "object" && v !== null ? v : {};
|
|
2159
|
+
return {
|
|
2160
|
+
zh: str(o.zh).slice(0, 800),
|
|
2161
|
+
en: str(o.en).slice(0, 1200)
|
|
2162
|
+
};
|
|
2163
|
+
};
|
|
2164
|
+
const expressionsRaw = Array.isArray(raw.expressions) ? raw.expressions.filter((e) => typeof e === "object" && e !== null) : [];
|
|
2165
|
+
const kit = {
|
|
2166
|
+
portrait: pair(raw.portrait),
|
|
2167
|
+
sheet: pair(raw.sheet),
|
|
2168
|
+
expressions: expressionsRaw.map((e) => ({
|
|
2169
|
+
name: str(e.name).slice(0, 12) || "表情",
|
|
2170
|
+
zh: str(e.zh).slice(0, 800),
|
|
2171
|
+
en: str(e.en).slice(0, 1200)
|
|
2172
|
+
})).slice(0, 12),
|
|
2173
|
+
details: pair(raw.details)
|
|
2174
|
+
};
|
|
2175
|
+
if (kit.portrait.zh === "" || kit.portrait.en === "") throw new Error("提示词精修失败:LLM 未返回有效 JSON");
|
|
2176
|
+
return kit;
|
|
2177
|
+
}
|
|
2178
|
+
/** ✨ 从道藏/红线提炼「视觉世界观规则」:生图/生视频必须遵守的设定纠偏(如"商品=人,禁止常规超市商品")。 */
|
|
2179
|
+
async function extractVisualRules(ctx, config, project) {
|
|
2180
|
+
const system = [
|
|
2181
|
+
"你是一位 AI 绘图的视觉规则设计师。本书的设定是\"反常识\"的,生图/生视频模型默认会画成现实世界的样子,你需要提炼 3-6 条「视觉规则」钉住本书的视觉世界观。",
|
|
2182
|
+
"规则要求:",
|
|
2183
|
+
"1. 每条必须可执行:明确\"画面里必须出现什么/禁止出现什么\"(如\"货架上的一切商品都是活人,禁止画成罐头/饮料/日用品\")。",
|
|
2184
|
+
"2. 覆盖本书最容易被模型画错的 2-4 个核心反常识点。",
|
|
2185
|
+
"3. 每条 40 字内,禁止泛泛而谈。",
|
|
2186
|
+
"输出必须是合法 JSON 数组:[\"规则1\", \"规则2\", ...],不要输出其他文字。"
|
|
2187
|
+
].join("\n");
|
|
2188
|
+
const bible = project.bible;
|
|
2189
|
+
return parseJsonArray(await complete(ctx, config, {
|
|
2190
|
+
system,
|
|
2191
|
+
user: [
|
|
2192
|
+
`书名:《${project.bookName}》`,
|
|
2193
|
+
`题材:${bible?.genre ?? ""}`,
|
|
2194
|
+
bible !== void 0 && bible.worldRules.length > 0 ? `世界规则:\n${bible.worldRules.map((r) => "- " + r).join("\n")}` : "",
|
|
2195
|
+
bible !== void 0 && bible.redLines.length > 0 ? `红线:\n${bible.redLines.map((r) => "- " + r).join("\n")}` : "",
|
|
2196
|
+
"只输出 JSON 数组。"
|
|
2197
|
+
].filter((s) => s !== "").join("\n\n"),
|
|
2198
|
+
temperature: .3,
|
|
2199
|
+
maxTokens: Math.max(config.maxTokens, 4e3)
|
|
2200
|
+
})).map((r) => typeof r === "string" ? r.trim().slice(0, 80) : "").filter((r) => r !== "").slice(0, 8);
|
|
2201
|
+
}
|
|
2202
|
+
/**
|
|
1873
2203
|
* 开书想法 → AI 大纲:输入一句话想法,生成 2-3 个方向不同、可直接开书的完整大纲方案。
|
|
1874
2204
|
* @param count 本次生成几个(默认 3,最多 3)
|
|
1875
2205
|
* @param exclude 已暂留方案的剧情方向/卖点摘要(换批时避开,防止重复)
|
|
@@ -2068,134 +2398,49 @@ async function breakdownBook(ctx, config, project, outputDir, scope = "recent",
|
|
|
2068
2398
|
usedTokens
|
|
2069
2399
|
};
|
|
2070
2400
|
}
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
const
|
|
2078
|
-
if (
|
|
2079
|
-
const
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
"
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
`书名:《${project.bookName}》`,
|
|
2102
|
-
`章节:第 ${chapter.no} 章《${chapter.title}》`,
|
|
2103
|
-
chapter.summary !== void 0 ? `章节摘要:${chapter.summary}` : "",
|
|
2104
|
-
`目标平台:${platform}(默认抖音竖屏 9:16)`,
|
|
2105
|
-
`赛道:${genre !== "" ? genre : "(由你按内容判断:爽文/甜宠/悬疑/搞笑)"}`,
|
|
2106
|
-
`AI 工具:${tool}(影响提示词格式)`,
|
|
2107
|
-
roleCards.length > 0 ? `角色视觉锚点(英文标签全分镜强制复用):\n${roleCards.map((r) => `- ${r.name}:${r.tags}`).join("\n")}` : "(无角色锚点卡,从正文提炼 1-2 个核心角色并给出标签)",
|
|
2108
|
-
"==================== 正文 ====================",
|
|
2109
|
-
body.replace(/^#\s+.*$/m, "").trim().slice(0, 6e3),
|
|
2110
|
-
"只输出 JSON 对象。"
|
|
2111
|
-
].join("\n\n"),
|
|
2112
|
-
temperature: .5,
|
|
2113
|
-
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
2114
|
-
}));
|
|
2115
|
-
const str = (v, fallback = "") => typeof v === "string" ? v.trim() : fallback;
|
|
2116
|
-
const panels = (Array.isArray(raw.panels) ? raw.panels.filter((p) => typeof p === "object" && p !== null) : []).map((p, i) => ({
|
|
2117
|
-
index: i + 1,
|
|
2118
|
-
timecode: str(p.timecode, `0:${String(i * 10).padStart(2, "0")}`),
|
|
2119
|
-
shot: str(p.shot, "中景"),
|
|
2120
|
-
visual: str(p.visual),
|
|
2121
|
-
dialogue: str(p.dialogue),
|
|
2122
|
-
transition: str(p.transition, "硬切"),
|
|
2123
|
-
prompt: str(p.prompt)
|
|
2124
|
-
})).filter((p) => p.visual !== "").slice(0, 12);
|
|
2125
|
-
return {
|
|
2126
|
-
title: str(raw.title, `第 ${chapter.no} 集`),
|
|
2127
|
-
pacingNote: str(raw.pacingNote, "(按内容判断赛道节奏)"),
|
|
2128
|
-
hook: str(raw.hook),
|
|
2129
|
-
characters: roleCards.map((r) => ({
|
|
2130
|
-
name: r.name,
|
|
2131
|
-
visualAnchor: r.anchor,
|
|
2132
|
-
tags: r.tags,
|
|
2133
|
-
expressions: []
|
|
2134
|
-
})),
|
|
2135
|
-
panels,
|
|
2136
|
-
endingHook: str(raw.endingHook)
|
|
2137
|
-
};
|
|
2138
|
-
}
|
|
2139
|
-
/**
|
|
2140
|
-
* 漫剧分集计划:AI 通读一卷的章节标题+摘要+beats,按故事弧线把章节分组为漫剧集。
|
|
2141
|
-
* 原则(吸收 manga-script 节奏模型):高潮章单独成集、过渡章合并、断点选在钩子最强处。
|
|
2142
|
-
*/
|
|
2143
|
-
async function planStoryboardEpisodes(ctx, config, project, volumeNo, platform, maxEpisodes) {
|
|
2144
|
-
const volume = project.volumes?.find((v) => v.no === volumeNo);
|
|
2145
|
-
if (volume === void 0) throw new Error(`第 ${volumeNo} 卷不存在(卷计划未生成或卷号错误)`);
|
|
2146
|
-
const chapters = project.chapters.filter((c) => c.volume === volumeNo && c.status !== "pending" && c.status !== "generating").sort((a, b) => a.no - b.no);
|
|
2147
|
-
if (chapters.length === 0) throw new Error(`第 ${volumeNo} 卷没有已写章节`);
|
|
2148
|
-
const chapterBriefs = chapters.map((c) => {
|
|
2149
|
-
const beatsHead = (c.beats ?? "").split("\n")[0] ?? "";
|
|
2150
|
-
return `第${c.no}章《${c.title}》|${beatsHead.slice(0, 60)}|${(c.summary ?? "").slice(0, 100)}`;
|
|
2401
|
+
async function generateRoleReferenceImage(ctx, config, project, outputDir, name, style = "") {
|
|
2402
|
+
const role = project.roles?.find((r) => r.name === name);
|
|
2403
|
+
if (role === void 0) throw new Error(`角色 ${name} 不存在`);
|
|
2404
|
+
if (role.imagePrompt === void 0) throw new Error(`角色 ${name} 还没有形象锚点,请先生成锚点`);
|
|
2405
|
+
if (config.imageApiEnabled !== true) throw new Error("豆包生图未启用,请在设置中开启");
|
|
2406
|
+
const apiKey = config.imageApiKey;
|
|
2407
|
+
const model = config.imageApiModel;
|
|
2408
|
+
if (apiKey === void 0 || apiKey === "" || model === void 0 || model === "") throw new Error("请先在设置中配置豆包 API Key 和生图模型");
|
|
2409
|
+
const stylePrompt = getComicStylePrompt(style);
|
|
2410
|
+
const prompt = [
|
|
2411
|
+
role.imagePrompt.zh,
|
|
2412
|
+
role.imagePrompt.en,
|
|
2413
|
+
role.imagePrompt.tags.join(", "),
|
|
2414
|
+
stylePrompt,
|
|
2415
|
+
"character reference sheet, front view, clean background, consistent design"
|
|
2416
|
+
].filter(Boolean).join(", ");
|
|
2417
|
+
const res = await fetch("https://ark.cn-beijing.volces.com/api/v3/images/generations", {
|
|
2418
|
+
method: "POST",
|
|
2419
|
+
headers: {
|
|
2420
|
+
"Content-Type": "application/json",
|
|
2421
|
+
Authorization: `Bearer ${apiKey}`
|
|
2422
|
+
},
|
|
2423
|
+
body: JSON.stringify({
|
|
2424
|
+
model,
|
|
2425
|
+
prompt,
|
|
2426
|
+
response_format: "url",
|
|
2427
|
+
size: "2K",
|
|
2428
|
+
stream: false,
|
|
2429
|
+
watermark: false
|
|
2430
|
+
})
|
|
2151
2431
|
});
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
"分集原则:",
|
|
2156
|
-
"1. 高潮章(大冲突/身份揭晓/大收获)单独成集或两章一集;过渡章(赶路/准备/日常)2-3 章合并为一集;支线穿插章并入相邻主线圈。",
|
|
2157
|
-
"2. 每集 60-120 秒 ≈ 1-2 章正文,但以\"叙事任务完整\"为准,不被章节号束缚。",
|
|
2158
|
-
"3. 断点选在钩子最强处:每集结尾必须留下让观众点下集的悬念。",
|
|
2159
|
-
"4. 全集数控制在 8 到上限之间(上限由输入给出),宁少勿碎。",
|
|
2160
|
-
"5. 卷的开局集要强(3 秒钩子),卷的结尾集要收束本卷弧线并埋下卷间钩子。",
|
|
2161
|
-
"输出 JSON:",
|
|
2162
|
-
"{\"strategy\": \"本卷漫剧化策略一句话(赛道判断+整体节奏安排)\", \"episodes\": [{\"title\": \"集标题(冲突+悬念句式)\", \"chapters\": [章号数组], \"narrativeJob\": \"这集讲什么、为什么这么分(1-2句)\", \"openingHook\": \"开头3秒钩子\", \"endingHook\": \"结尾钩子\"}]}",
|
|
2163
|
-
"硬性要求:episodes 按章号顺序排列且覆盖全部给定章节(不得遗漏);chapters 数组必须连续(如 [81,82,83]);每集 chapters 至少 1 章、最多 4 章;所有文字用简体中文。",
|
|
2164
|
-
"重要:直接输出 JSON,字符串内不含换行。"
|
|
2165
|
-
].join("\n"),
|
|
2166
|
-
user: [
|
|
2167
|
-
`书名:《${project.bookName}》`,
|
|
2168
|
-
`第 ${volumeNo} 卷《${volume.title}》(${volume.chapterStart}-${volume.chapterEnd} 章)`,
|
|
2169
|
-
`卷定位:${volume.summary}`,
|
|
2170
|
-
`目标平台:${platform}(竖屏 9:16)`,
|
|
2171
|
-
`本卷已写章节(${chapters.length} 章,按序):\n${chapterBriefs.join("\n")}`,
|
|
2172
|
-
`全集数上限:${maxEpisodes}`,
|
|
2173
|
-
"只输出 JSON 对象。"
|
|
2174
|
-
].join("\n\n"),
|
|
2175
|
-
temperature: .4,
|
|
2176
|
-
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
2177
|
-
}));
|
|
2178
|
-
const episodesRaw = Array.isArray(raw.episodes) ? raw.episodes.filter((e) => typeof e === "object" && e !== null) : [];
|
|
2179
|
-
const str = (v, fallback = "") => typeof v === "string" ? v.trim() : fallback;
|
|
2180
|
-
const episodes = [];
|
|
2181
|
-
for (const e of episodesRaw) {
|
|
2182
|
-
const chaptersArr = Array.isArray(e.chapters) ? e.chapters.filter((n) => typeof n === "number" && Number.isInteger(n) && n > 0) : [];
|
|
2183
|
-
if (chaptersArr.length === 0) continue;
|
|
2184
|
-
episodes.push({
|
|
2185
|
-
index: episodes.length + 1,
|
|
2186
|
-
title: str(e.title, `第 ${episodes.length + 1} 集`),
|
|
2187
|
-
chapters: chaptersArr,
|
|
2188
|
-
narrativeJob: str(e.narrativeJob),
|
|
2189
|
-
openingHook: str(e.openingHook),
|
|
2190
|
-
endingHook: str(e.endingHook)
|
|
2191
|
-
});
|
|
2432
|
+
if (!res.ok) {
|
|
2433
|
+
const text = await res.text();
|
|
2434
|
+
throw new Error(`豆包生图失败(${res.status}):${text}`);
|
|
2192
2435
|
}
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
};
|
|
2436
|
+
const url = (await res.json()).data?.[0]?.url;
|
|
2437
|
+
if (url === void 0 || url === "") throw new Error("豆包生图未返回图片 URL");
|
|
2438
|
+
const imgRes = await fetch(url);
|
|
2439
|
+
if (!imgRes.ok) throw new Error("下载豆包图片失败");
|
|
2440
|
+
const buf = Buffer.from(await imgRes.arrayBuffer());
|
|
2441
|
+
const dataUrl = `data:${imgRes.headers.get("content-type") || "image/jpeg"};base64,${buf.toString("base64")}`;
|
|
2442
|
+
role.imageUrl = dataUrl;
|
|
2443
|
+
return dataUrl;
|
|
2199
2444
|
}
|
|
2200
2445
|
/** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
|
|
2201
2446
|
async function analyzePlotlineHealth(ctx, config, project) {
|
|
@@ -2747,15 +2992,21 @@ async function auditBatch(ctx, config, project, outputDir, batch) {
|
|
|
2747
2992
|
return issues;
|
|
2748
2993
|
}
|
|
2749
2994
|
/** 全书一致性质检:LLM 分批扫描已生成章节 + 设定 + 事实库,聚合矛盾清单。 */
|
|
2750
|
-
async function auditBook(ctx, config, project, outputDir) {
|
|
2995
|
+
async function auditBook(ctx, config, project, outputDir, onProgress) {
|
|
2751
2996
|
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
2752
|
-
if (written.length === 0)
|
|
2997
|
+
if (written.length === 0) {
|
|
2998
|
+
onProgress?.(0, 0);
|
|
2999
|
+
return [];
|
|
3000
|
+
}
|
|
3001
|
+
const totalBatches = Math.ceil(written.length / AUDIT_BATCH_SIZE);
|
|
2753
3002
|
const all = [];
|
|
3003
|
+
onProgress?.(0, totalBatches);
|
|
2754
3004
|
for (let i = 0; i < written.length; i += AUDIT_BATCH_SIZE) {
|
|
2755
3005
|
const batch = written.slice(i, i + AUDIT_BATCH_SIZE);
|
|
2756
3006
|
try {
|
|
2757
3007
|
all.push(...await auditBatch(ctx, config, project, outputDir, batch));
|
|
2758
3008
|
} catch {}
|
|
3009
|
+
onProgress?.(Math.min(Math.ceil((i + AUDIT_BATCH_SIZE) / AUDIT_BATCH_SIZE), totalBatches), totalBatches);
|
|
2759
3010
|
}
|
|
2760
3011
|
return all.slice(0, 50);
|
|
2761
3012
|
}
|
|
@@ -3648,6 +3899,8 @@ const NOVEL_API = {
|
|
|
3648
3899
|
plotlines: "/api/dsh-novel-forge/plotlines",
|
|
3649
3900
|
/** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
|
|
3650
3901
|
roles: "/api/dsh-novel-forge/roles",
|
|
3902
|
+
scenes: "/api/dsh-novel-forge/scenes",
|
|
3903
|
+
visualRules: "/api/dsh-novel-forge/visual-rules",
|
|
3651
3904
|
/** 作者复盘补跑:对已写章节补齐 authorReview(全书流式 / 单章 JSON)。 */
|
|
3652
3905
|
reviewBackfill: "/api/dsh-novel-forge/review/backfill",
|
|
3653
3906
|
/** 章节复位:generating 卡死 → pending(可重新生成)。 */
|
|
@@ -3661,9 +3914,8 @@ const NOVEL_API = {
|
|
|
3661
3914
|
/** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检(两阶段:源笔记→分节分析)。 */
|
|
3662
3915
|
breakdown: "/api/dsh-novel-forge/breakdown",
|
|
3663
3916
|
/** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(可适配豆包/Seedance/SD)。 */
|
|
3664
|
-
storyboard: "/api/dsh-novel-forge/storyboard",
|
|
3665
3917
|
/** 漫剧分集计划:读一卷 → 按故事弧线分集(高潮拆集/过渡并章)。 */
|
|
3666
|
-
|
|
3918
|
+
/** 漫画脚本:章节 → 分页分格漫画脚本(含角色视觉锚点)。 */
|
|
3667
3919
|
/** 生产单:启动批量生产(计划补足 + 逐章生成 + 被拒分级处理)。 */
|
|
3668
3920
|
runStart: "/api/dsh-novel-forge/run/start",
|
|
3669
3921
|
/** 生产单控制:pause / resume / stop。 */
|
|
@@ -4522,6 +4774,12 @@ const DEFAULT_PLAN_COUNT = 30;
|
|
|
4522
4774
|
*/
|
|
4523
4775
|
function makeRoutes(deps) {
|
|
4524
4776
|
const { ctx, getConfig, patchConfig } = deps;
|
|
4777
|
+
/** 全书质检实时状态(内存态,重启后回到 idle;用于 /status 暴露进度)。 */
|
|
4778
|
+
let auditState = {
|
|
4779
|
+
status: "idle",
|
|
4780
|
+
totalBatches: 0,
|
|
4781
|
+
completedBatches: 0
|
|
4782
|
+
};
|
|
4525
4783
|
/** Guard helper: fence + method check. */
|
|
4526
4784
|
const guard = (req, res, method) => {
|
|
4527
4785
|
if (!isLoopbackRequest(req)) {
|
|
@@ -4573,7 +4831,8 @@ function makeRoutes(deps) {
|
|
|
4573
4831
|
...project,
|
|
4574
4832
|
facts: (project.facts ?? []).slice(-80)
|
|
4575
4833
|
} : void 0,
|
|
4576
|
-
generatedFiles: listChapterFiles(config.outputDir)
|
|
4834
|
+
generatedFiles: listChapterFiles(config.outputDir),
|
|
4835
|
+
audit: auditState
|
|
4577
4836
|
});
|
|
4578
4837
|
}
|
|
4579
4838
|
};
|
|
@@ -4649,7 +4908,7 @@ function makeRoutes(deps) {
|
|
|
4649
4908
|
return;
|
|
4650
4909
|
}
|
|
4651
4910
|
try {
|
|
4652
|
-
const bible = await extractBible(ctx, config, outline);
|
|
4911
|
+
const bible = await extractBible(ctx, config, outline, project);
|
|
4653
4912
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4654
4913
|
const next = project ?? createProject(outline);
|
|
4655
4914
|
next.bible = bible;
|
|
@@ -5478,13 +5737,41 @@ function makeRoutes(deps) {
|
|
|
5478
5737
|
const config = getConfig();
|
|
5479
5738
|
const project = requireProject(res);
|
|
5480
5739
|
if (project === void 0) return;
|
|
5740
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5741
|
+
auditState = {
|
|
5742
|
+
status: "running",
|
|
5743
|
+
startedAt,
|
|
5744
|
+
totalBatches: 0,
|
|
5745
|
+
completedBatches: 0
|
|
5746
|
+
};
|
|
5481
5747
|
try {
|
|
5748
|
+
const issues = await auditBook(ctx, config, project, config.outputDir, (completed, total) => {
|
|
5749
|
+
auditState.totalBatches = total;
|
|
5750
|
+
auditState.completedBatches = completed;
|
|
5751
|
+
});
|
|
5752
|
+
const auditedChapters = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating").length;
|
|
5753
|
+
auditState = {
|
|
5754
|
+
status: "done",
|
|
5755
|
+
startedAt,
|
|
5756
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5757
|
+
totalBatches: auditState.totalBatches,
|
|
5758
|
+
completedBatches: auditState.totalBatches,
|
|
5759
|
+
auditedChapters,
|
|
5760
|
+
issuesCount: issues.length,
|
|
5761
|
+
issues
|
|
5762
|
+
};
|
|
5482
5763
|
writeJson(res, 200, {
|
|
5483
|
-
issues
|
|
5484
|
-
auditedChapters
|
|
5485
|
-
auditedAt:
|
|
5764
|
+
issues,
|
|
5765
|
+
auditedChapters,
|
|
5766
|
+
auditedAt: auditState.finishedAt
|
|
5486
5767
|
});
|
|
5487
5768
|
} catch (error) {
|
|
5769
|
+
auditState = {
|
|
5770
|
+
...auditState,
|
|
5771
|
+
status: "error",
|
|
5772
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5773
|
+
error: error.message
|
|
5774
|
+
};
|
|
5488
5775
|
writeJson(res, 500, { error: error.message });
|
|
5489
5776
|
}
|
|
5490
5777
|
}
|
|
@@ -5907,7 +6194,71 @@ function makeRoutes(deps) {
|
|
|
5907
6194
|
if (idx === -1) project.roles.push(r);
|
|
5908
6195
|
else project.roles[idx] = r;
|
|
5909
6196
|
} else if (op === "remove" && body?.name !== void 0) project.roles = project.roles.filter((x) => x.name !== body.name);
|
|
5910
|
-
else if (op === "
|
|
6197
|
+
else if (op === "image") {
|
|
6198
|
+
const name = body?.name?.trim();
|
|
6199
|
+
const dataUrl = body?.dataUrl?.trim();
|
|
6200
|
+
if (name === void 0 || name === "") {
|
|
6201
|
+
writeJson(res, 400, { error: "name(角色名)必填" });
|
|
6202
|
+
return;
|
|
6203
|
+
}
|
|
6204
|
+
if (dataUrl === void 0 || dataUrl === "") {
|
|
6205
|
+
writeJson(res, 400, { error: "dataUrl(参考图)必填" });
|
|
6206
|
+
return;
|
|
6207
|
+
}
|
|
6208
|
+
const role = project.roles.find((r) => r.name === name);
|
|
6209
|
+
if (role === void 0) {
|
|
6210
|
+
writeJson(res, 404, { error: `角色 ${name} 不存在` });
|
|
6211
|
+
return;
|
|
6212
|
+
}
|
|
6213
|
+
const label = body?.label?.trim() ?? "";
|
|
6214
|
+
if (label !== "") {
|
|
6215
|
+
role.gallery ??= [];
|
|
6216
|
+
role.gallery = role.gallery.filter((g) => g.label !== label);
|
|
6217
|
+
role.gallery.push({
|
|
6218
|
+
label,
|
|
6219
|
+
dataUrl
|
|
6220
|
+
});
|
|
6221
|
+
if (label === "立绘" || label.includes("立绘")) role.imageUrl = dataUrl;
|
|
6222
|
+
} else role.imageUrl = dataUrl;
|
|
6223
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6224
|
+
saveProject(config.outputDir, project);
|
|
6225
|
+
writeJson(res, 200, {
|
|
6226
|
+
roles: project.roles,
|
|
6227
|
+
imageUrl: role.imageUrl
|
|
6228
|
+
});
|
|
6229
|
+
return;
|
|
6230
|
+
} else if (op === "removeImage") {
|
|
6231
|
+
const name = body?.name?.trim();
|
|
6232
|
+
const label = body?.label?.trim() ?? "";
|
|
6233
|
+
const role = project.roles.find((r) => r.name === name);
|
|
6234
|
+
if (role !== void 0) if (label !== "") {
|
|
6235
|
+
role.gallery = (role.gallery ?? []).filter((g) => g.label !== label);
|
|
6236
|
+
if (label === "立绘" || label.includes("立绘")) role.imageUrl = void 0;
|
|
6237
|
+
} else role.imageUrl = void 0;
|
|
6238
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6239
|
+
saveProject(config.outputDir, project);
|
|
6240
|
+
writeJson(res, 200, { roles: project.roles });
|
|
6241
|
+
return;
|
|
6242
|
+
} else if (op === "imageGenerate") {
|
|
6243
|
+
const name = body?.name?.trim();
|
|
6244
|
+
if (name === void 0 || name === "") {
|
|
6245
|
+
writeJson(res, 400, { error: "name(角色名)必填" });
|
|
6246
|
+
return;
|
|
6247
|
+
}
|
|
6248
|
+
try {
|
|
6249
|
+
const imageUrl = await generateRoleReferenceImage(ctx, config, project, config.outputDir, name, body?.style ?? "");
|
|
6250
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6251
|
+
saveProject(config.outputDir, project);
|
|
6252
|
+
writeJson(res, 200, {
|
|
6253
|
+
roles: project.roles,
|
|
6254
|
+
imageUrl
|
|
6255
|
+
});
|
|
6256
|
+
return;
|
|
6257
|
+
} catch (error) {
|
|
6258
|
+
writeJson(res, 500, { error: `角色参考图生成失败:${error.message}` });
|
|
6259
|
+
return;
|
|
6260
|
+
}
|
|
6261
|
+
} else if (op === "visual") {
|
|
5911
6262
|
const name = body?.name?.trim();
|
|
5912
6263
|
if (name === void 0 || name === "") {
|
|
5913
6264
|
writeJson(res, 400, { error: "name(角色名)必填" });
|
|
@@ -5916,7 +6267,11 @@ function makeRoutes(deps) {
|
|
|
5916
6267
|
try {
|
|
5917
6268
|
const visual = await extractRoleVisual(ctx, config, project, config.outputDir, name);
|
|
5918
6269
|
const role = project.roles.find((r) => r.name === name);
|
|
5919
|
-
if (role !== void 0)
|
|
6270
|
+
if (role !== void 0) {
|
|
6271
|
+
role.imagePrompt = visual;
|
|
6272
|
+
if ((visual.expressions ?? []).length > 0) role.expressions = visual.expressions;
|
|
6273
|
+
if (visual.promptKit !== void 0) role.promptKit = visual.promptKit;
|
|
6274
|
+
}
|
|
5920
6275
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5921
6276
|
saveProject(config.outputDir, project);
|
|
5922
6277
|
writeJson(res, 200, {
|
|
@@ -5928,6 +6283,27 @@ function makeRoutes(deps) {
|
|
|
5928
6283
|
writeJson(res, 500, { error: `形象提炼失败:${error.message}` });
|
|
5929
6284
|
return;
|
|
5930
6285
|
}
|
|
6286
|
+
} else if (op === "promptKit") {
|
|
6287
|
+
const name = body?.name?.trim();
|
|
6288
|
+
if (name === void 0 || name === "") {
|
|
6289
|
+
writeJson(res, 400, { error: "name(角色名)必填" });
|
|
6290
|
+
return;
|
|
6291
|
+
}
|
|
6292
|
+
try {
|
|
6293
|
+
const kit = await generateRolePromptKit(ctx, config, project, name);
|
|
6294
|
+
const role = project.roles.find((r) => r.name === name);
|
|
6295
|
+
if (role !== void 0) role.promptKit = kit;
|
|
6296
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6297
|
+
saveProject(config.outputDir, project);
|
|
6298
|
+
writeJson(res, 200, {
|
|
6299
|
+
roles: project.roles,
|
|
6300
|
+
promptKit: kit
|
|
6301
|
+
});
|
|
6302
|
+
return;
|
|
6303
|
+
} catch (error) {
|
|
6304
|
+
writeJson(res, 500, { error: `提示词精修失败:${error.message}` });
|
|
6305
|
+
return;
|
|
6306
|
+
}
|
|
5931
6307
|
} else {
|
|
5932
6308
|
writeJson(res, 400, { error: "未知的 roles op" });
|
|
5933
6309
|
return;
|
|
@@ -6042,7 +6418,7 @@ function makeRoutes(deps) {
|
|
|
6042
6418
|
return;
|
|
6043
6419
|
}
|
|
6044
6420
|
}
|
|
6045
|
-
const missing = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.authorReview === void 0);
|
|
6421
|
+
const missing = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.status !== "error" && c.authorReview === void 0);
|
|
6046
6422
|
if (missing.length === 0) {
|
|
6047
6423
|
writeJson(res, 200, { count: 0 });
|
|
6048
6424
|
return;
|
|
@@ -6150,46 +6526,97 @@ function makeRoutes(deps) {
|
|
|
6150
6526
|
}
|
|
6151
6527
|
}
|
|
6152
6528
|
};
|
|
6153
|
-
/**
|
|
6154
|
-
const
|
|
6529
|
+
/** 场景库:AI 提炼 / 采纳 / 更新 / 删除 / 图集。 */
|
|
6530
|
+
const scenesRoute = {
|
|
6155
6531
|
kind: "exact",
|
|
6156
|
-
path: NOVEL_API.
|
|
6532
|
+
path: NOVEL_API.scenes,
|
|
6157
6533
|
handler: async (req, res) => {
|
|
6158
6534
|
if (!guard(req, res, "POST")) return;
|
|
6159
6535
|
const config = getConfig();
|
|
6160
6536
|
const project = requireProject(res);
|
|
6161
6537
|
if (project === void 0) return;
|
|
6162
6538
|
const body = await readJsonBody(req);
|
|
6163
|
-
if (
|
|
6164
|
-
|
|
6539
|
+
if (project.scenes === void 0) project.scenes = [];
|
|
6540
|
+
const op = body?.op;
|
|
6541
|
+
if (op === "extract") try {
|
|
6542
|
+
const candidates = await extractScenes(ctx, config, project);
|
|
6543
|
+
writeJson(res, 200, {
|
|
6544
|
+
scenes: project.scenes,
|
|
6545
|
+
candidates
|
|
6546
|
+
});
|
|
6165
6547
|
return;
|
|
6166
|
-
}
|
|
6167
|
-
try {
|
|
6168
|
-
writeJson(res, 200, await generateStoryboard(ctx, config, project, config.outputDir, body.chapterNo, body?.genre ?? "", body?.platform ?? "抖音", body?.tool ?? "doubao"));
|
|
6169
6548
|
} catch (error) {
|
|
6170
|
-
writeJson(res, 500, { error:
|
|
6549
|
+
writeJson(res, 500, { error: `场景提炼失败:${error.message}` });
|
|
6550
|
+
return;
|
|
6171
6551
|
}
|
|
6552
|
+
else if ((op === "adopt" || op === "update") && body?.scene !== void 0) {
|
|
6553
|
+
const s = body.scene;
|
|
6554
|
+
const idx = project.scenes.findIndex((x) => x.name === s.name);
|
|
6555
|
+
if (idx === -1) project.scenes.push(s);
|
|
6556
|
+
else project.scenes[idx] = s;
|
|
6557
|
+
} else if (op === "remove" && body?.name !== void 0) project.scenes = project.scenes.filter((x) => x.name !== body.name);
|
|
6558
|
+
else if (op === "image") {
|
|
6559
|
+
const name = body?.name?.trim();
|
|
6560
|
+
const dataUrl = body?.dataUrl?.trim();
|
|
6561
|
+
if (name === void 0 || name === "" || dataUrl === void 0 || dataUrl === "") {
|
|
6562
|
+
writeJson(res, 400, { error: "name(场景名)与 dataUrl 必填" });
|
|
6563
|
+
return;
|
|
6564
|
+
}
|
|
6565
|
+
const scene = project.scenes.find((x) => x.name === name);
|
|
6566
|
+
if (scene === void 0) {
|
|
6567
|
+
writeJson(res, 404, { error: `场景 ${name} 不存在` });
|
|
6568
|
+
return;
|
|
6569
|
+
}
|
|
6570
|
+
const label = body?.label?.trim() ?? "全景";
|
|
6571
|
+
scene.gallery ??= [];
|
|
6572
|
+
scene.gallery = scene.gallery.filter((g) => g.label !== label);
|
|
6573
|
+
scene.gallery.push({
|
|
6574
|
+
label,
|
|
6575
|
+
dataUrl
|
|
6576
|
+
});
|
|
6577
|
+
} else if (op === "removeImage") {
|
|
6578
|
+
const name = body?.name?.trim();
|
|
6579
|
+
const label = body?.label?.trim() ?? "";
|
|
6580
|
+
const scene = project.scenes.find((x) => x.name === name);
|
|
6581
|
+
if (scene !== void 0 && label !== "") scene.gallery = (scene.gallery ?? []).filter((g) => g.label !== label);
|
|
6582
|
+
} else {
|
|
6583
|
+
writeJson(res, 400, { error: `未知操作 ${op}` });
|
|
6584
|
+
return;
|
|
6585
|
+
}
|
|
6586
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6587
|
+
saveProject(config.outputDir, project);
|
|
6588
|
+
writeJson(res, 200, { scenes: project.scenes });
|
|
6172
6589
|
}
|
|
6173
6590
|
};
|
|
6174
|
-
/**
|
|
6175
|
-
const
|
|
6591
|
+
/** 视觉世界观规则:从道藏提炼(生图/生视频纠偏),或手动保存。 */
|
|
6592
|
+
const visualRulesRoute = {
|
|
6176
6593
|
kind: "exact",
|
|
6177
|
-
path: NOVEL_API.
|
|
6594
|
+
path: NOVEL_API.visualRules,
|
|
6178
6595
|
handler: async (req, res) => {
|
|
6179
6596
|
if (!guard(req, res, "POST")) return;
|
|
6180
6597
|
const config = getConfig();
|
|
6181
6598
|
const project = requireProject(res);
|
|
6182
6599
|
if (project === void 0) return;
|
|
6183
6600
|
const body = await readJsonBody(req);
|
|
6184
|
-
if (
|
|
6185
|
-
|
|
6601
|
+
if (body?.op === "extract") try {
|
|
6602
|
+
const rules = await extractVisualRules(ctx, config, project);
|
|
6603
|
+
project.visualRules = rules;
|
|
6604
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6605
|
+
saveProject(config.outputDir, project);
|
|
6606
|
+
writeJson(res, 200, { rules });
|
|
6186
6607
|
return;
|
|
6187
|
-
}
|
|
6188
|
-
try {
|
|
6189
|
-
writeJson(res, 200, await planStoryboardEpisodes(ctx, config, project, body.volumeNo, body?.platform ?? "抖音", body?.maxEpisodes ?? 25));
|
|
6190
6608
|
} catch (error) {
|
|
6191
|
-
writeJson(res, 500, { error:
|
|
6609
|
+
writeJson(res, 500, { error: `视觉规则提炼失败:${error.message}` });
|
|
6610
|
+
return;
|
|
6611
|
+
}
|
|
6612
|
+
else if (body?.op === "save" && Array.isArray(body.rules)) {
|
|
6613
|
+
project.visualRules = body.rules.filter((r) => typeof r === "string" && r.trim() !== "").map((r) => r.trim().slice(0, 80)).slice(0, 12);
|
|
6614
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6615
|
+
saveProject(config.outputDir, project);
|
|
6616
|
+
writeJson(res, 200, { rules: project.visualRules });
|
|
6617
|
+
return;
|
|
6192
6618
|
}
|
|
6619
|
+
writeJson(res, 400, { error: "op 须为 extract 或 save" });
|
|
6193
6620
|
}
|
|
6194
6621
|
};
|
|
6195
6622
|
/** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
|
|
@@ -6235,6 +6662,8 @@ function makeRoutes(deps) {
|
|
|
6235
6662
|
renameRoute,
|
|
6236
6663
|
plotlinesRoute,
|
|
6237
6664
|
rolesRoute,
|
|
6665
|
+
scenesRoute,
|
|
6666
|
+
visualRulesRoute,
|
|
6238
6667
|
sensitiveRoute,
|
|
6239
6668
|
reviewBackfillRoute,
|
|
6240
6669
|
chapterResetRoute,
|
|
@@ -6243,8 +6672,6 @@ function makeRoutes(deps) {
|
|
|
6243
6672
|
openFolderRoute,
|
|
6244
6673
|
outlineSuggestRoute,
|
|
6245
6674
|
breakdownRoute,
|
|
6246
|
-
storyboardRoute,
|
|
6247
|
-
storyboardPlanRoute,
|
|
6248
6675
|
{
|
|
6249
6676
|
kind: "exact",
|
|
6250
6677
|
path: NOVEL_API.runStart,
|
|
@@ -6324,12 +6751,21 @@ const Config = z.object({
|
|
|
6324
6751
|
outputDir: z.string().default("D:\\ryan work\\harness\\novels\\归墟玉主"),
|
|
6325
6752
|
provider: z.string().default("deepseek-official"),
|
|
6326
6753
|
model: z.string().default("deepseek-v4-flash"),
|
|
6754
|
+
reasoningEffort: z.union([
|
|
6755
|
+
"off",
|
|
6756
|
+
"low",
|
|
6757
|
+
"high",
|
|
6758
|
+
"max"
|
|
6759
|
+
]).default("off"),
|
|
6327
6760
|
chapterChars: z.number().default(3500),
|
|
6328
6761
|
maxTokens: z.number().default(12e3),
|
|
6329
6762
|
reviewPassScore: z.number().default(70),
|
|
6330
6763
|
autoReview: z.boolean().default(true),
|
|
6331
6764
|
autoAuthorReview: z.boolean().default(true),
|
|
6332
|
-
autoReviewAfterRevise: z.boolean().default(true)
|
|
6765
|
+
autoReviewAfterRevise: z.boolean().default(true),
|
|
6766
|
+
imageApiKey: z.string().default(""),
|
|
6767
|
+
imageApiModel: z.string().default(""),
|
|
6768
|
+
imageApiEnabled: z.boolean().default(false)
|
|
6333
6769
|
});
|
|
6334
6770
|
/** Schema defaults, re-read for hand-built test contexts. */
|
|
6335
6771
|
const DEFAULT_ANNOUNCE = true;
|
|
@@ -6337,6 +6773,7 @@ const DEFAULT_OUTLINE_PATH = "D:\\ryan work\\harness\\novels\\归墟玉主\\《
|
|
|
6337
6773
|
const DEFAULT_OUTPUT_DIR = "D:\\ryan work\\harness\\novels\\归墟玉主";
|
|
6338
6774
|
const DEFAULT_PROVIDER = "deepseek-official";
|
|
6339
6775
|
const DEFAULT_MODEL = "deepseek-v4-flash";
|
|
6776
|
+
const DEFAULT_REASONING_EFFORT = "off";
|
|
6340
6777
|
const DEFAULT_CHAPTER_CHARS = 3500;
|
|
6341
6778
|
const DEFAULT_MAX_TOKENS = 12e3;
|
|
6342
6779
|
const DEFAULT_REVIEW_PASS_SCORE = 70;
|
|
@@ -6354,12 +6791,16 @@ function resolveConfig(value) {
|
|
|
6354
6791
|
outputDir: value?.outputDir ?? DEFAULT_OUTPUT_DIR,
|
|
6355
6792
|
provider: value?.provider ?? DEFAULT_PROVIDER,
|
|
6356
6793
|
model: value?.model ?? DEFAULT_MODEL,
|
|
6794
|
+
reasoningEffort: value?.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
|
|
6357
6795
|
chapterChars: value?.chapterChars ?? DEFAULT_CHAPTER_CHARS,
|
|
6358
6796
|
maxTokens: value?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
6359
6797
|
reviewPassScore: value?.reviewPassScore ?? DEFAULT_REVIEW_PASS_SCORE,
|
|
6360
6798
|
autoReview: value?.autoReview ?? DEFAULT_AUTO_REVIEW,
|
|
6361
6799
|
autoAuthorReview: value?.autoAuthorReview ?? DEFAULT_AUTO_AUTHOR_REVIEW,
|
|
6362
|
-
autoReviewAfterRevise: value?.autoReviewAfterRevise ?? DEFAULT_AUTO_REVIEW_AFTER_REVISE
|
|
6800
|
+
autoReviewAfterRevise: value?.autoReviewAfterRevise ?? DEFAULT_AUTO_REVIEW_AFTER_REVISE,
|
|
6801
|
+
imageApiKey: value?.imageApiKey,
|
|
6802
|
+
imageApiModel: value?.imageApiModel,
|
|
6803
|
+
imageApiEnabled: value?.imageApiEnabled ?? false
|
|
6363
6804
|
};
|
|
6364
6805
|
}
|
|
6365
6806
|
/**
|
|
@@ -6384,12 +6825,16 @@ function apply(ctx, config) {
|
|
|
6384
6825
|
if (patch.outputDir !== void 0) next.outputDir = patch.outputDir;
|
|
6385
6826
|
if (patch.provider !== void 0) next.provider = patch.provider;
|
|
6386
6827
|
if (patch.model !== void 0) next.model = patch.model;
|
|
6828
|
+
if (patch.reasoningEffort !== void 0) next.reasoningEffort = patch.reasoningEffort;
|
|
6387
6829
|
if (patch.chapterChars !== void 0) next.chapterChars = patch.chapterChars;
|
|
6388
6830
|
if (patch.maxTokens !== void 0) next.maxTokens = patch.maxTokens;
|
|
6389
6831
|
if (patch.reviewPassScore !== void 0) next.reviewPassScore = patch.reviewPassScore;
|
|
6390
6832
|
if (patch.autoReview !== void 0) next.autoReview = patch.autoReview;
|
|
6391
6833
|
if (patch.autoAuthorReview !== void 0) next.autoAuthorReview = patch.autoAuthorReview;
|
|
6392
6834
|
if (patch.autoReviewAfterRevise !== void 0) next.autoReviewAfterRevise = patch.autoReviewAfterRevise;
|
|
6835
|
+
if (patch.imageApiKey !== void 0) next.imageApiKey = patch.imageApiKey;
|
|
6836
|
+
if (patch.imageApiModel !== void 0) next.imageApiModel = patch.imageApiModel;
|
|
6837
|
+
if (patch.imageApiEnabled !== void 0) next.imageApiEnabled = patch.imageApiEnabled;
|
|
6393
6838
|
const settings = ctx.get("settings");
|
|
6394
6839
|
if (settings !== void 0) await settings.update(NOVEL_SETTINGS_NAMESPACE, next);
|
|
6395
6840
|
else current = () => ({
|