@waterwx/dsh-novel-forge 1.2.0 → 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 +2100 -651
- package/lib/client.js.map +1 -1
- package/lib/index.js +567 -173
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +4 -4
- 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 +16 -13
- package/lib/types/index.d.ts +6 -0
- package/lib/types/protocol.d.ts +128 -89
- package/package.json +1 -1
- package/src/client/api.ts +10 -10
- package/src/client/panel/NovelPanel.tsx +650 -172
- package/src/comic-presets.ts +67 -0
- package/src/engine.ts +344 -188
- package/src/index.ts +15 -0
- package/src/protocol.ts +121 -96
- package/src/routes.ts +173 -52
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,
|
|
@@ -1215,6 +1265,7 @@ function bibleSystemPrompt() {
|
|
|
1215
1265
|
"3. 世界规则覆盖力量体系、金手指机制、势力、地理等所有硬性规则,逐条列出。",
|
|
1216
1266
|
"4. 红线列出大纲中明确禁止的内容(如无后宫、不圣母、无无脑碾压等)。",
|
|
1217
1267
|
"5. 风格列出叙事基调、节奏、POV 等写作风格要点。",
|
|
1268
|
+
"6. 角色名必须用正文/编年录中的真实姓名;若大纲只写「主角」未点名,而已写章节或编年录中有名字,则用该真实姓名;禁止输出「主角(描述)」这类把身份塞进名字的占位名。",
|
|
1218
1269
|
"输出必须是合法 JSON 对象,不要输出任何其他文字或 Markdown 代码块标记。",
|
|
1219
1270
|
"重要:所有字符串值内部不得包含换行符(不要用多行字符串),JSON 必须在一段内完整结束。",
|
|
1220
1271
|
"重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
|
|
@@ -1223,8 +1274,21 @@ function bibleSystemPrompt() {
|
|
|
1223
1274
|
].join("\n");
|
|
1224
1275
|
}
|
|
1225
1276
|
/** Extract the story bible from an outline. */
|
|
1226
|
-
async function extractBible(ctx, config, outline) {
|
|
1227
|
-
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");
|
|
1228
1292
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1229
1293
|
system: bibleSystemPrompt(),
|
|
1230
1294
|
user,
|
|
@@ -1245,6 +1309,10 @@ async function extractBible(ctx, config, outline) {
|
|
|
1245
1309
|
relations: typeof entry.relations === "string" ? entry.relations : "",
|
|
1246
1310
|
knowledge: strArray(entry.knowledge)
|
|
1247
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
|
+
}
|
|
1248
1316
|
const bible = {
|
|
1249
1317
|
genre: typeof raw.genre === "string" ? raw.genre : "",
|
|
1250
1318
|
worldRules: strArray(raw.worldRules),
|
|
@@ -1417,17 +1485,18 @@ async function planChapters(ctx, config, project, chapterCount, volumeNo, output
|
|
|
1417
1485
|
} catch {}
|
|
1418
1486
|
}
|
|
1419
1487
|
const outlineBlock = continuation ? (() => {
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
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…(大纲过长已节选)";
|
|
1423
1492
|
})() : project.outline;
|
|
1424
1493
|
const user = [
|
|
1425
1494
|
"请为下面这部小说规划章节。",
|
|
1426
1495
|
volume !== void 0 ? `本次只规划第 ${volume.no} 卷《${volume.title}》的章节:\n${volume.summary}` : continuation ? `本书已有 ${existing.length} 章已规划/已写作(见下方「已有章节」)。请规划**后续**章节:从第 ${startNo} 章开始。` : "请规划全书开篇章节。",
|
|
1427
|
-
continuation ? "
|
|
1496
|
+
continuation ? "【续写硬性要求】已有章节的剧情不得重写或重复,章节标题也不得与已有章节重复。以下情节均已在已有章节中发生过,后续章节**绝对不得再次出现**:穿越、暴雨送餐、滴血认主/古玉认主、首次进入墟境、用废铁淬炼首件法器、绝境肉身入鼎洗炼(该机缘已用尽)、杀死白袍弟子与灰衣随从、藏尸水沟。\n若本次规划已进入大纲的收尾区间(接近全书规划总章数),最后 5-10 章必须按大纲推进到大结局(终极抉择/清算/双结局等),**禁止以悬念、逃离、未解之谜收尾**——收尾区间按大纲卷定位判断,不以当前剧情是否“感觉像结尾”为准。" : "",
|
|
1428
1497
|
prevTail !== "" ? `【上一章(第 ${startNo - 1} 章)结尾原文】第 ${startNo} 章必须紧接此状态继续,从新的事件写起,不得回顾重述:\n${prevTail}` : "",
|
|
1429
1498
|
latestFacts !== "" ? `【最新剧情状态(本书编年录,第 ${startNo - 1} 章结尾的事实)】规划续写时必须以此为起点,时间线、人物状态与地点衔接一致:\n${latestFacts}` : "",
|
|
1430
|
-
continuation ? "
|
|
1499
|
+
continuation ? "已有章节(共 " + existing.length + " 章;仅列最近 80 章,更早的以标题计数为准,剧情以「编年录」为权威):\n" + existing.slice(-80).map((c) => {
|
|
1431
1500
|
const sm = c.summary !== void 0 && c.summary !== "" ? `(${c.summary.slice(0, 120)})` : "";
|
|
1432
1501
|
return `第${c.no}章《${c.title}》${sm}`;
|
|
1433
1502
|
}).join("\n") : "",
|
|
@@ -1536,7 +1605,7 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
1536
1605
|
const score = typeof raw.score === "number" ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60;
|
|
1537
1606
|
const report = {
|
|
1538
1607
|
score,
|
|
1539
|
-
passed: score >= config.reviewPassScore,
|
|
1608
|
+
passed: score >= config.reviewPassScore || issues.every((i) => i.severity !== "high") && score >= 60,
|
|
1540
1609
|
verdict: typeof raw.verdict === "string" ? raw.verdict.slice(0, 200) : "",
|
|
1541
1610
|
issues,
|
|
1542
1611
|
reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -1574,7 +1643,7 @@ async function reviewChapterText(ctx, config, project, text, previousReport) {
|
|
|
1574
1643
|
suggestion: typeof entry.suggestion === "string" ? entry.suggestion : ""
|
|
1575
1644
|
})).filter((issue) => issue.item !== "") : [];
|
|
1576
1645
|
const score = typeof raw.score === "number" ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60;
|
|
1577
|
-
let passed = score >= config.reviewPassScore;
|
|
1646
|
+
let passed = score >= config.reviewPassScore || issues.every((i) => i.severity !== "high") && score >= 60;
|
|
1578
1647
|
if (previousReport !== void 0) {
|
|
1579
1648
|
const hasHigh = issues.some((i) => i.severity === "high");
|
|
1580
1649
|
const prevHighResolved = previousReport.issues.filter((i) => i.severity === "high").every((p) => !issues.some((i) => i.item.includes(p.item.slice(0, 12))));
|
|
@@ -1745,7 +1814,7 @@ async function extractRoles(ctx, config, project) {
|
|
|
1745
1814
|
"覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如\"矮胖姑娘\")用其身份简称收录并标注。",
|
|
1746
1815
|
"数量控制:最多输出 10 个角色,宁缺毋滥;路人级一次带过的不要收录。",
|
|
1747
1816
|
"每个角色输出:",
|
|
1748
|
-
"1. name
|
|
1817
|
+
"1. name:角色名(或身份简称)。主角/有名配角必须用正文中实际出现的人名(如「沈放」),禁止用「主角(38岁超市理货员)」这类把身份塞进名字的占位名;正文确实没点名时才可用身份简称(如「富商」「灰衣老人」)。",
|
|
1749
1818
|
"2. roleLabel:定位——protagonist=主角;female_lead=女主(唯一知己/感情线核心,无后宫前提下只此一位);female_support=重要女配;support=普通配角;antagonist=反派;extra=路人/背景。",
|
|
1750
1819
|
"3. identity:身份一句话(宗门/势力/血脉/职业)。",
|
|
1751
1820
|
"4. traits:3-6 个性格标签。",
|
|
@@ -1761,12 +1830,20 @@ async function extractRoles(ctx, config, project) {
|
|
|
1761
1830
|
].join("\n");
|
|
1762
1831
|
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
1763
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
|
+
}
|
|
1764
1840
|
const raw = parseJsonArray(await complete(ctx, config, {
|
|
1765
1841
|
system,
|
|
1766
1842
|
user: [
|
|
1767
1843
|
`书名:《${project.bookName}》`,
|
|
1768
1844
|
existingRoles.length > 0 ? `已收录角色(跳过,不要输出):${existingRoles.map((r) => r.name).join("、")}` : "",
|
|
1769
1845
|
`大纲(节选前 3000 字):\n${project.outline.slice(0, 3e3)}`,
|
|
1846
|
+
excerptParts.length > 0 ? `已写正文摘录(角色姓名/身份/关系以正文为准):\n${excerptParts.join("\n\n")}` : "",
|
|
1770
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")}` : "",
|
|
1771
1848
|
(project.facts ?? []).length > 0 ? `编年录(最近 60 条):\n${(project.facts ?? []).slice(-60).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 80)}`).join("\n")}` : "",
|
|
1772
1849
|
written.length > 0 ? `已写章节标题(${written.length} 章):\n${written.map((c) => `第${c.no}章《${c.title}》`).join("、")}` : "",
|
|
@@ -1802,6 +1879,75 @@ async function extractRoles(ctx, config, project) {
|
|
|
1802
1879
|
}
|
|
1803
1880
|
return roles;
|
|
1804
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
|
+
}
|
|
1805
1951
|
/**
|
|
1806
1952
|
* 为单个角色提炼「动漫形象描述词」:扫描该角色出场的已写章节正文,
|
|
1807
1953
|
* 截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
|
|
@@ -1810,6 +1956,72 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
|
|
|
1810
1956
|
const role = (project.roles ?? []).find((r) => r.name === roleName);
|
|
1811
1957
|
if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
|
|
1812
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));
|
|
1813
2025
|
const excerpts = [];
|
|
1814
2026
|
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.file !== void 0).slice(-60);
|
|
1815
2027
|
for (const chapter of written) {
|
|
@@ -1819,7 +2031,7 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
|
|
|
1819
2031
|
let perChapter = 0;
|
|
1820
2032
|
for (const para of paras) {
|
|
1821
2033
|
if (perChapter >= 2 || excerpts.length >= 12) break;
|
|
1822
|
-
if (!para
|
|
2034
|
+
if (!matchesRole(para)) continue;
|
|
1823
2035
|
if (appearanceHints.test(para) || excerpts.length < 4) {
|
|
1824
2036
|
excerpts.push({
|
|
1825
2037
|
no: chapter.no,
|
|
@@ -1833,17 +2045,27 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
|
|
|
1833
2045
|
if (excerpts.length === 0) throw new Error(`正文中未找到「${roleName}」的出场描写(仅搜索最近 60 章),请确认角色名与正文一致`);
|
|
1834
2046
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
1835
2047
|
system: [
|
|
1836
|
-
"
|
|
2048
|
+
"你是一位动漫角色设定师与 AI 绘图提示词工程师。根据网文正文中该角色的实际外貌描写,输出「形象锚点」与「四类生图提示词」——一次完成,用于 AI 绘图(NovelAI / Stable Diffusion / Midjourney / 豆包等)生成一致的角色立绘。",
|
|
1837
2049
|
"硬性要求(依据优先):",
|
|
1838
2050
|
"1. 发色/发型/瞳色/服装/气质/标志物必须来自提供的正文段落,不得凭空发明。",
|
|
1839
|
-
"2.
|
|
1840
|
-
"3.
|
|
1841
|
-
"
|
|
1842
|
-
"
|
|
1843
|
-
|
|
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)、发色、发型、瞳色、服装、气质、标志物。不要输出负面提示词。",
|
|
1844
2059
|
"- tags:中文关键标签数组,5-10 个(如 [\"黑发\",\"束发\",\"青色道袍\",\"清秀\",\"腰悬古玉\"])。",
|
|
1845
2060
|
"- source:说明依据(如\"第1章/第8章外貌描写;瞳色未明确\")。",
|
|
1846
|
-
"
|
|
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\":\"...\"}}}",
|
|
1847
2069
|
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
1848
2070
|
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
1849
2071
|
].join("\n"),
|
|
@@ -1856,19 +2078,126 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
|
|
|
1856
2078
|
"只输出 JSON 对象。"
|
|
1857
2079
|
].join("\n\n"),
|
|
1858
2080
|
temperature: .4,
|
|
1859
|
-
maxTokens: Math.max(config.maxTokens,
|
|
2081
|
+
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
1860
2082
|
}));
|
|
1861
2083
|
const zh = typeof raw.zh === "string" ? raw.zh.trim().slice(0, 500) : "";
|
|
1862
2084
|
const en = typeof raw.en === "string" ? raw.en.trim().slice(0, 1500) : "";
|
|
1863
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) : [];
|
|
1864
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
|
+
}
|
|
1865
2113
|
if (zh === "" || en === "") throw new Error("形象描述提炼失败:LLM 未返回有效 JSON");
|
|
1866
2114
|
return {
|
|
1867
2115
|
zh,
|
|
1868
2116
|
en,
|
|
1869
2117
|
tags,
|
|
1870
|
-
source
|
|
2118
|
+
source,
|
|
2119
|
+
expressions,
|
|
2120
|
+
promptKit
|
|
2121
|
+
};
|
|
2122
|
+
}
|
|
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)
|
|
1871
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);
|
|
1872
2201
|
}
|
|
1873
2202
|
/**
|
|
1874
2203
|
* 开书想法 → AI 大纲:输入一句话想法,生成 2-3 个方向不同、可直接开书的完整大纲方案。
|
|
@@ -2069,134 +2398,49 @@ async function breakdownBook(ctx, config, project, outputDir, scope = "recent",
|
|
|
2069
2398
|
usedTokens
|
|
2070
2399
|
};
|
|
2071
2400
|
}
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
const
|
|
2079
|
-
if (
|
|
2080
|
-
const
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
"
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
`书名:《${project.bookName}》`,
|
|
2103
|
-
`章节:第 ${chapter.no} 章《${chapter.title}》`,
|
|
2104
|
-
chapter.summary !== void 0 ? `章节摘要:${chapter.summary}` : "",
|
|
2105
|
-
`目标平台:${platform}(默认抖音竖屏 9:16)`,
|
|
2106
|
-
`赛道:${genre !== "" ? genre : "(由你按内容判断:爽文/甜宠/悬疑/搞笑)"}`,
|
|
2107
|
-
`AI 工具:${tool}(影响提示词格式)`,
|
|
2108
|
-
roleCards.length > 0 ? `角色视觉锚点(英文标签全分镜强制复用):\n${roleCards.map((r) => `- ${r.name}:${r.tags}`).join("\n")}` : "(无角色锚点卡,从正文提炼 1-2 个核心角色并给出标签)",
|
|
2109
|
-
"==================== 正文 ====================",
|
|
2110
|
-
body.replace(/^#\s+.*$/m, "").trim().slice(0, 6e3),
|
|
2111
|
-
"只输出 JSON 对象。"
|
|
2112
|
-
].join("\n\n"),
|
|
2113
|
-
temperature: .5,
|
|
2114
|
-
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
2115
|
-
}));
|
|
2116
|
-
const str = (v, fallback = "") => typeof v === "string" ? v.trim() : fallback;
|
|
2117
|
-
const panels = (Array.isArray(raw.panels) ? raw.panels.filter((p) => typeof p === "object" && p !== null) : []).map((p, i) => ({
|
|
2118
|
-
index: i + 1,
|
|
2119
|
-
timecode: str(p.timecode, `0:${String(i * 10).padStart(2, "0")}`),
|
|
2120
|
-
shot: str(p.shot, "中景"),
|
|
2121
|
-
visual: str(p.visual),
|
|
2122
|
-
dialogue: str(p.dialogue),
|
|
2123
|
-
transition: str(p.transition, "硬切"),
|
|
2124
|
-
prompt: str(p.prompt)
|
|
2125
|
-
})).filter((p) => p.visual !== "").slice(0, 12);
|
|
2126
|
-
return {
|
|
2127
|
-
title: str(raw.title, `第 ${chapter.no} 集`),
|
|
2128
|
-
pacingNote: str(raw.pacingNote, "(按内容判断赛道节奏)"),
|
|
2129
|
-
hook: str(raw.hook),
|
|
2130
|
-
characters: roleCards.map((r) => ({
|
|
2131
|
-
name: r.name,
|
|
2132
|
-
visualAnchor: r.anchor,
|
|
2133
|
-
tags: r.tags,
|
|
2134
|
-
expressions: []
|
|
2135
|
-
})),
|
|
2136
|
-
panels,
|
|
2137
|
-
endingHook: str(raw.endingHook)
|
|
2138
|
-
};
|
|
2139
|
-
}
|
|
2140
|
-
/**
|
|
2141
|
-
* 漫剧分集计划:AI 通读一卷的章节标题+摘要+beats,按故事弧线把章节分组为漫剧集。
|
|
2142
|
-
* 原则(吸收 manga-script 节奏模型):高潮章单独成集、过渡章合并、断点选在钩子最强处。
|
|
2143
|
-
*/
|
|
2144
|
-
async function planStoryboardEpisodes(ctx, config, project, volumeNo, platform, maxEpisodes) {
|
|
2145
|
-
const volume = project.volumes?.find((v) => v.no === volumeNo);
|
|
2146
|
-
if (volume === void 0) throw new Error(`第 ${volumeNo} 卷不存在(卷计划未生成或卷号错误)`);
|
|
2147
|
-
const chapters = project.chapters.filter((c) => c.volume === volumeNo && c.status !== "pending" && c.status !== "generating").sort((a, b) => a.no - b.no);
|
|
2148
|
-
if (chapters.length === 0) throw new Error(`第 ${volumeNo} 卷没有已写章节`);
|
|
2149
|
-
const chapterBriefs = chapters.map((c) => {
|
|
2150
|
-
const beatsHead = (c.beats ?? "").split("\n")[0] ?? "";
|
|
2151
|
-
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
|
+
})
|
|
2152
2431
|
});
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
"分集原则:",
|
|
2157
|
-
"1. 高潮章(大冲突/身份揭晓/大收获)单独成集或两章一集;过渡章(赶路/准备/日常)2-3 章合并为一集;支线穿插章并入相邻主线圈。",
|
|
2158
|
-
"2. 每集 60-120 秒 ≈ 1-2 章正文,但以\"叙事任务完整\"为准,不被章节号束缚。",
|
|
2159
|
-
"3. 断点选在钩子最强处:每集结尾必须留下让观众点下集的悬念。",
|
|
2160
|
-
"4. 全集数控制在 8 到上限之间(上限由输入给出),宁少勿碎。",
|
|
2161
|
-
"5. 卷的开局集要强(3 秒钩子),卷的结尾集要收束本卷弧线并埋下卷间钩子。",
|
|
2162
|
-
"输出 JSON:",
|
|
2163
|
-
"{\"strategy\": \"本卷漫剧化策略一句话(赛道判断+整体节奏安排)\", \"episodes\": [{\"title\": \"集标题(冲突+悬念句式)\", \"chapters\": [章号数组], \"narrativeJob\": \"这集讲什么、为什么这么分(1-2句)\", \"openingHook\": \"开头3秒钩子\", \"endingHook\": \"结尾钩子\"}]}",
|
|
2164
|
-
"硬性要求:episodes 按章号顺序排列且覆盖全部给定章节(不得遗漏);chapters 数组必须连续(如 [81,82,83]);每集 chapters 至少 1 章、最多 4 章;所有文字用简体中文。",
|
|
2165
|
-
"重要:直接输出 JSON,字符串内不含换行。"
|
|
2166
|
-
].join("\n"),
|
|
2167
|
-
user: [
|
|
2168
|
-
`书名:《${project.bookName}》`,
|
|
2169
|
-
`第 ${volumeNo} 卷《${volume.title}》(${volume.chapterStart}-${volume.chapterEnd} 章)`,
|
|
2170
|
-
`卷定位:${volume.summary}`,
|
|
2171
|
-
`目标平台:${platform}(竖屏 9:16)`,
|
|
2172
|
-
`本卷已写章节(${chapters.length} 章,按序):\n${chapterBriefs.join("\n")}`,
|
|
2173
|
-
`全集数上限:${maxEpisodes}`,
|
|
2174
|
-
"只输出 JSON 对象。"
|
|
2175
|
-
].join("\n\n"),
|
|
2176
|
-
temperature: .4,
|
|
2177
|
-
maxTokens: Math.max(config.maxTokens, 12e3)
|
|
2178
|
-
}));
|
|
2179
|
-
const episodesRaw = Array.isArray(raw.episodes) ? raw.episodes.filter((e) => typeof e === "object" && e !== null) : [];
|
|
2180
|
-
const str = (v, fallback = "") => typeof v === "string" ? v.trim() : fallback;
|
|
2181
|
-
const episodes = [];
|
|
2182
|
-
for (const e of episodesRaw) {
|
|
2183
|
-
const chaptersArr = Array.isArray(e.chapters) ? e.chapters.filter((n) => typeof n === "number" && Number.isInteger(n) && n > 0) : [];
|
|
2184
|
-
if (chaptersArr.length === 0) continue;
|
|
2185
|
-
episodes.push({
|
|
2186
|
-
index: episodes.length + 1,
|
|
2187
|
-
title: str(e.title, `第 ${episodes.length + 1} 集`),
|
|
2188
|
-
chapters: chaptersArr,
|
|
2189
|
-
narrativeJob: str(e.narrativeJob),
|
|
2190
|
-
openingHook: str(e.openingHook),
|
|
2191
|
-
endingHook: str(e.endingHook)
|
|
2192
|
-
});
|
|
2432
|
+
if (!res.ok) {
|
|
2433
|
+
const text = await res.text();
|
|
2434
|
+
throw new Error(`豆包生图失败(${res.status}):${text}`);
|
|
2193
2435
|
}
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
};
|
|
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;
|
|
2200
2444
|
}
|
|
2201
2445
|
/** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
|
|
2202
2446
|
async function analyzePlotlineHealth(ctx, config, project) {
|
|
@@ -3655,6 +3899,8 @@ const NOVEL_API = {
|
|
|
3655
3899
|
plotlines: "/api/dsh-novel-forge/plotlines",
|
|
3656
3900
|
/** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
|
|
3657
3901
|
roles: "/api/dsh-novel-forge/roles",
|
|
3902
|
+
scenes: "/api/dsh-novel-forge/scenes",
|
|
3903
|
+
visualRules: "/api/dsh-novel-forge/visual-rules",
|
|
3658
3904
|
/** 作者复盘补跑:对已写章节补齐 authorReview(全书流式 / 单章 JSON)。 */
|
|
3659
3905
|
reviewBackfill: "/api/dsh-novel-forge/review/backfill",
|
|
3660
3906
|
/** 章节复位:generating 卡死 → pending(可重新生成)。 */
|
|
@@ -3668,9 +3914,8 @@ const NOVEL_API = {
|
|
|
3668
3914
|
/** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检(两阶段:源笔记→分节分析)。 */
|
|
3669
3915
|
breakdown: "/api/dsh-novel-forge/breakdown",
|
|
3670
3916
|
/** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(可适配豆包/Seedance/SD)。 */
|
|
3671
|
-
storyboard: "/api/dsh-novel-forge/storyboard",
|
|
3672
3917
|
/** 漫剧分集计划:读一卷 → 按故事弧线分集(高潮拆集/过渡并章)。 */
|
|
3673
|
-
|
|
3918
|
+
/** 漫画脚本:章节 → 分页分格漫画脚本(含角色视觉锚点)。 */
|
|
3674
3919
|
/** 生产单:启动批量生产(计划补足 + 逐章生成 + 被拒分级处理)。 */
|
|
3675
3920
|
runStart: "/api/dsh-novel-forge/run/start",
|
|
3676
3921
|
/** 生产单控制:pause / resume / stop。 */
|
|
@@ -4663,7 +4908,7 @@ function makeRoutes(deps) {
|
|
|
4663
4908
|
return;
|
|
4664
4909
|
}
|
|
4665
4910
|
try {
|
|
4666
|
-
const bible = await extractBible(ctx, config, outline);
|
|
4911
|
+
const bible = await extractBible(ctx, config, outline, project);
|
|
4667
4912
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4668
4913
|
const next = project ?? createProject(outline);
|
|
4669
4914
|
next.bible = bible;
|
|
@@ -5949,7 +6194,71 @@ function makeRoutes(deps) {
|
|
|
5949
6194
|
if (idx === -1) project.roles.push(r);
|
|
5950
6195
|
else project.roles[idx] = r;
|
|
5951
6196
|
} else if (op === "remove" && body?.name !== void 0) project.roles = project.roles.filter((x) => x.name !== body.name);
|
|
5952
|
-
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") {
|
|
5953
6262
|
const name = body?.name?.trim();
|
|
5954
6263
|
if (name === void 0 || name === "") {
|
|
5955
6264
|
writeJson(res, 400, { error: "name(角色名)必填" });
|
|
@@ -5958,7 +6267,11 @@ function makeRoutes(deps) {
|
|
|
5958
6267
|
try {
|
|
5959
6268
|
const visual = await extractRoleVisual(ctx, config, project, config.outputDir, name);
|
|
5960
6269
|
const role = project.roles.find((r) => r.name === name);
|
|
5961
|
-
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
|
+
}
|
|
5962
6275
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5963
6276
|
saveProject(config.outputDir, project);
|
|
5964
6277
|
writeJson(res, 200, {
|
|
@@ -5970,6 +6283,27 @@ function makeRoutes(deps) {
|
|
|
5970
6283
|
writeJson(res, 500, { error: `形象提炼失败:${error.message}` });
|
|
5971
6284
|
return;
|
|
5972
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
|
+
}
|
|
5973
6307
|
} else {
|
|
5974
6308
|
writeJson(res, 400, { error: "未知的 roles op" });
|
|
5975
6309
|
return;
|
|
@@ -6192,46 +6526,97 @@ function makeRoutes(deps) {
|
|
|
6192
6526
|
}
|
|
6193
6527
|
}
|
|
6194
6528
|
};
|
|
6195
|
-
/**
|
|
6196
|
-
const
|
|
6529
|
+
/** 场景库:AI 提炼 / 采纳 / 更新 / 删除 / 图集。 */
|
|
6530
|
+
const scenesRoute = {
|
|
6197
6531
|
kind: "exact",
|
|
6198
|
-
path: NOVEL_API.
|
|
6532
|
+
path: NOVEL_API.scenes,
|
|
6199
6533
|
handler: async (req, res) => {
|
|
6200
6534
|
if (!guard(req, res, "POST")) return;
|
|
6201
6535
|
const config = getConfig();
|
|
6202
6536
|
const project = requireProject(res);
|
|
6203
6537
|
if (project === void 0) return;
|
|
6204
6538
|
const body = await readJsonBody(req);
|
|
6205
|
-
if (
|
|
6206
|
-
|
|
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
|
+
});
|
|
6207
6547
|
return;
|
|
6208
|
-
}
|
|
6209
|
-
try {
|
|
6210
|
-
writeJson(res, 200, await generateStoryboard(ctx, config, project, config.outputDir, body.chapterNo, body?.genre ?? "", body?.platform ?? "抖音", body?.tool ?? "doubao"));
|
|
6211
6548
|
} catch (error) {
|
|
6212
|
-
writeJson(res, 500, { error:
|
|
6549
|
+
writeJson(res, 500, { error: `场景提炼失败:${error.message}` });
|
|
6550
|
+
return;
|
|
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;
|
|
6213
6585
|
}
|
|
6586
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6587
|
+
saveProject(config.outputDir, project);
|
|
6588
|
+
writeJson(res, 200, { scenes: project.scenes });
|
|
6214
6589
|
}
|
|
6215
6590
|
};
|
|
6216
|
-
/**
|
|
6217
|
-
const
|
|
6591
|
+
/** 视觉世界观规则:从道藏提炼(生图/生视频纠偏),或手动保存。 */
|
|
6592
|
+
const visualRulesRoute = {
|
|
6218
6593
|
kind: "exact",
|
|
6219
|
-
path: NOVEL_API.
|
|
6594
|
+
path: NOVEL_API.visualRules,
|
|
6220
6595
|
handler: async (req, res) => {
|
|
6221
6596
|
if (!guard(req, res, "POST")) return;
|
|
6222
6597
|
const config = getConfig();
|
|
6223
6598
|
const project = requireProject(res);
|
|
6224
6599
|
if (project === void 0) return;
|
|
6225
6600
|
const body = await readJsonBody(req);
|
|
6226
|
-
if (
|
|
6227
|
-
|
|
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 });
|
|
6228
6607
|
return;
|
|
6229
|
-
}
|
|
6230
|
-
try {
|
|
6231
|
-
writeJson(res, 200, await planStoryboardEpisodes(ctx, config, project, body.volumeNo, body?.platform ?? "抖音", body?.maxEpisodes ?? 25));
|
|
6232
6608
|
} catch (error) {
|
|
6233
|
-
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;
|
|
6234
6618
|
}
|
|
6619
|
+
writeJson(res, 400, { error: "op 须为 extract 或 save" });
|
|
6235
6620
|
}
|
|
6236
6621
|
};
|
|
6237
6622
|
/** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
|
|
@@ -6277,6 +6662,8 @@ function makeRoutes(deps) {
|
|
|
6277
6662
|
renameRoute,
|
|
6278
6663
|
plotlinesRoute,
|
|
6279
6664
|
rolesRoute,
|
|
6665
|
+
scenesRoute,
|
|
6666
|
+
visualRulesRoute,
|
|
6280
6667
|
sensitiveRoute,
|
|
6281
6668
|
reviewBackfillRoute,
|
|
6282
6669
|
chapterResetRoute,
|
|
@@ -6285,8 +6672,6 @@ function makeRoutes(deps) {
|
|
|
6285
6672
|
openFolderRoute,
|
|
6286
6673
|
outlineSuggestRoute,
|
|
6287
6674
|
breakdownRoute,
|
|
6288
|
-
storyboardRoute,
|
|
6289
|
-
storyboardPlanRoute,
|
|
6290
6675
|
{
|
|
6291
6676
|
kind: "exact",
|
|
6292
6677
|
path: NOVEL_API.runStart,
|
|
@@ -6377,7 +6762,10 @@ const Config = z.object({
|
|
|
6377
6762
|
reviewPassScore: z.number().default(70),
|
|
6378
6763
|
autoReview: z.boolean().default(true),
|
|
6379
6764
|
autoAuthorReview: z.boolean().default(true),
|
|
6380
|
-
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)
|
|
6381
6769
|
});
|
|
6382
6770
|
/** Schema defaults, re-read for hand-built test contexts. */
|
|
6383
6771
|
const DEFAULT_ANNOUNCE = true;
|
|
@@ -6409,7 +6797,10 @@ function resolveConfig(value) {
|
|
|
6409
6797
|
reviewPassScore: value?.reviewPassScore ?? DEFAULT_REVIEW_PASS_SCORE,
|
|
6410
6798
|
autoReview: value?.autoReview ?? DEFAULT_AUTO_REVIEW,
|
|
6411
6799
|
autoAuthorReview: value?.autoAuthorReview ?? DEFAULT_AUTO_AUTHOR_REVIEW,
|
|
6412
|
-
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
|
|
6413
6804
|
};
|
|
6414
6805
|
}
|
|
6415
6806
|
/**
|
|
@@ -6441,6 +6832,9 @@ function apply(ctx, config) {
|
|
|
6441
6832
|
if (patch.autoReview !== void 0) next.autoReview = patch.autoReview;
|
|
6442
6833
|
if (patch.autoAuthorReview !== void 0) next.autoAuthorReview = patch.autoAuthorReview;
|
|
6443
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;
|
|
6444
6838
|
const settings = ctx.get("settings");
|
|
6445
6839
|
if (settings !== void 0) await settings.update(NOVEL_SETTINGS_NAMESPACE, next);
|
|
6446
6840
|
else current = () => ({
|