@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/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 user = `请为下面这部小说提炼设定圣经:\n\n${outline}`;
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
- const cut = project.outline.indexOf("七、关键剧情桥段");
1420
- if (cut > 1500) return project.outline.slice(0, cut).trimEnd() + "\n(大纲后续剧情桥段与分卷细节从略;续写请以「上一章结尾原文」与「最新剧情状态」为剧情起点)";
1421
- return project.outline.slice(0, 3e3) + "\n…(大纲过长已节选)";
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 ? "已有章节:\n" + existing.map((c) => {
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.includes(roleName)) continue;
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
- "你是一位动漫角色设定师。根据网文正文中该角色的实际外貌描写,提炼「动漫形象描述词」,用于 AI 绘图(NovelAI / Stable Diffusion / Midjourney / 豆包等)生成一致的角色立绘。",
2048
+ "你是一位动漫角色设定师与 AI 绘图提示词工程师。根据网文正文中该角色的实际外貌描写,输出「形象锚点」与「四类生图提示词」——一次完成,用于 AI 绘图(NovelAI / Stable Diffusion / Midjourney / 豆包等)生成一致的角色立绘。",
1836
2049
  "硬性要求(依据优先):",
1837
2050
  "1. 发色/发型/瞳色/服装/气质/标志物必须来自提供的正文段落,不得凭空发明。",
1838
- "2. 正文未明确写到的项目(如瞳色没写),用「未定」标注,不要编造。",
1839
- "3. 服装优先取正文明确出现的(颜色+款式),多次出现取最常穿的组合。",
1840
- "输出三个部分:",
1841
- "- zh:中文外貌描述,一段连贯文字(60-150 字):发色发型、瞳色、脸型气质、服装(颜色款式)、身材、标志性物件。",
1842
- "- en:英文绘图标签,booru 风格、逗号分隔、小写,30-50 个标签:含性别(1boy/1girl)、发色、发型、瞳色、服装(如 chinese hanfu / daoist robe)、气质、背景无关项。不要输出负面提示词。",
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
- "输出必须是合法 JSON 对象:{\"zh\": \"...\", \"en\": \"...\", \"tags\": [...], \"source\": \"...\"}",
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, 4e3)
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
- * 漫剧分镜生成:把一章正文改编为短视频漫剧分镜(吸收 manga-script-master 方法论)。
2073
- * 产出:本集标题 + 赛道节奏说明 + 角色视觉锚点卡 + 分镜表(8-12 格)+ 结尾钩子。
2074
- * 角色锚点优先复用角色库 imagePrompt(无则从正文提炼)。
2075
- */
2076
- async function generateStoryboard(ctx, config, project, outputDir, chapterNo, genre, platform, tool) {
2077
- const chapter = project.chapters.find((c) => c.no === chapterNo);
2078
- if (chapter === void 0) throw new Error(`章节 ${chapterNo} 不在计划中`);
2079
- const body = readChapterFile(outputDir, chapter);
2080
- if (body === void 0) throw new Error(`章节 ${chapterNo} 尚未生成正文`);
2081
- const roleCards = (project.roles ?? []).filter((r) => r.imagePrompt !== void 0 && (body.includes(r.name) || chapter.summary?.includes(r.name) === true)).map((r) => ({
2082
- name: r.name,
2083
- anchor: r.imagePrompt.zh.slice(0, 120),
2084
- tags: r.imagePrompt.en.slice(0, 300)
2085
- })).slice(0, 3);
2086
- const raw = parseJsonObject(await complete(ctx, config, {
2087
- system: [
2088
- "你是一位精通短视频算法与 AIGC 的顶级漫剧导演。把给定的一章网文改编为高完播率的漫剧分镜。",
2089
- "硬性要求:",
2090
- "1. 前 3 秒必须有强冲突或视觉冲击(黄金开局);每 10 秒至少一个小高潮;结尾必须有悬念钩子。",
2091
- "2. 所有情感必须具象化为动作(不能写\"他很愤怒\",要写\"他拳头砸碎桌角,碎片飞溅\")。",
2092
- "3. 台词每句 ≤15 字,用画面推进叙事。",
2093
- "4. 总时长 60-120 秒,分镜 8-12 格,每格标注时间码、景别、转场/动效。",
2094
- "5. 输出 JSON:",
2095
- "{\"title\": \"本集标题(核心设定+冲突+悬念句式)\", \"pacingNote\": \"赛道判断与节奏说明(按爽文/甜宠/悬疑/搞笑模型)\", \"hook\": \"本集钩子一句话\", \"panels\": [{\"timecode\": \"0:00-0:03\", \"shot\": \"特写\", \"visual\": \"具象画面描述\", \"dialogue\": \"(情绪)台词\", \"transition\": \"硬切/闪白等\", \"prompt\": \"英文AI提示词:角色标签+表情+动作+场景+景别+光影+风格+9:16\"}], \"endingHook\": \"结尾钩子台词\"}",
2096
- "6. 英文 prompt 里的角色标签必须复用提供的角色锚点标签;多角色同框用 left/right/foreground 分隔。",
2097
- "7. 严禁在提示词里出现文字/水印(画面内字幕后期加)。",
2098
- "重要:直接输出 JSON,字符串内不含换行。"
2099
- ].join("\n"),
2100
- user: [
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
- const raw = parseJsonObject(await complete(ctx, config, {
2153
- system: [
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
- if (episodes.length === 0) throw new Error("分集计划生成失败:LLM 未返回有效集数");
2194
- return {
2195
- strategy: str(raw.strategy, "(按内容判断赛道节奏)"),
2196
- episodes,
2197
- chaptersScanned: chapters.length
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) return [];
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
- storyboardPlan: "/api/dsh-novel-forge/storyboard/plan",
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: await auditBook(ctx, config, project, config.outputDir),
5484
- auditedChapters: project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating").length,
5485
- auditedAt: (/* @__PURE__ */ new Date()).toISOString()
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 === "visual") {
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) role.imagePrompt = visual;
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
- /** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(适配豆包/Seedance/SD)。 */
6154
- const storyboardRoute = {
6529
+ /** 场景库:AI 提炼 / 采纳 / 更新 / 删除 / 图集。 */
6530
+ const scenesRoute = {
6155
6531
  kind: "exact",
6156
- path: NOVEL_API.storyboard,
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 (!Number.isInteger(body?.chapterNo)) {
6164
- writeJson(res, 400, { error: "chapterNo 须为正整数" });
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: `分镜生成失败:${error.message}` });
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 storyboardPlanRoute = {
6591
+ /** 视觉世界观规则:从道藏提炼(生图/生视频纠偏),或手动保存。 */
6592
+ const visualRulesRoute = {
6176
6593
  kind: "exact",
6177
- path: NOVEL_API.storyboardPlan,
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 (!Number.isInteger(body?.volumeNo) || (body?.volumeNo ?? 0) < 1) {
6185
- writeJson(res, 400, { error: "volumeNo 须为正整数" });
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: `分集计划生成失败:${error.message}` });
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 = () => ({