@waterwx/dsh-novel-forge 1.5.0 → 1.7.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
@@ -2116,10 +2116,13 @@ async function extractRoles(ctx, config, project) {
2116
2116
  return roles;
2117
2117
  }
2118
2118
  /** ✨ AI 从全书提炼场景库:正文/编年录 → 高频重要场景的结构化视觉锚点。 */
2119
- async function extractScenes(ctx, config, project) {
2119
+ async function extractScenes(ctx, config, project, styleId, filterId) {
2120
+ const styleWords = styleKeywords(styleId, filterId);
2120
2121
  const system = [
2121
2122
  "你是一位网文漫剧场景导演。根据本书的大纲、编年录与已写章节摘录,提炼「镜头场景」——漫剧分镜/生图时每个镜头要知道\"在哪、什么时间光态、拍什么情节、人物什么状态\"。",
2122
2123
  "按五幕结构覆盖全书核心场景(短篇 10 章内可少于五幕,按实际剧情组织):第一幕后场/核心主场、第二幕对外卖场/营业区、第三幕禁忌区域/地下深仓、第四幕外部人间世界(短暂出逃)、第五幕回归与抉择。",
2124
+ "【当前视觉风格】(moment/palette/moods/zh/en 必须按此风格措辞,不能写中性描述):" + styleWords,
2125
+ "重要:每个场景的 zh 提示词段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。",
2123
2126
  "每个场景必须是「镜头场景」而非仅场地:给出该场景的关键情节镜头(人物动作+情绪+镜头推进)。",
2124
2127
  "数量控制:最多输出 8 个场景;每个场景必须能对应到正文实际出现过的地点与情节,不得凭空虚构。",
2125
2128
  "每个场景输出:",
@@ -2177,10 +2180,11 @@ async function extractScenes(ctx, config, project) {
2177
2180
  elements: strArr(entry.elements).map((t) => t.slice(0, 60)).slice(0, 6),
2178
2181
  palette: strArr(entry.palette).map((t) => t.slice(0, 40)).slice(0, 4),
2179
2182
  moods: strArr(entry.moods).map((t) => t.slice(0, 20)).slice(0, 4),
2180
- zh: str(entry.zh).slice(0, 800),
2181
- en: str(entry.en).slice(0, 600),
2183
+ zh: ensureStyleEmbedded(str(entry.zh).slice(0, 800), styleWords, "zh"),
2184
+ en: ensureStyleEmbedded(str(entry.en).slice(0, 600), styleWords, "en"),
2182
2185
  tags: strArr(entry.tags).map((t) => t.slice(0, 24)).slice(0, 6),
2183
- source: str(entry.source).slice(0, 120)
2186
+ source: str(entry.source).slice(0, 120),
2187
+ styleId
2184
2188
  });
2185
2189
  }
2186
2190
  return scenes;
@@ -2354,6 +2358,104 @@ function importBookTextFromText(raw, outputDir, bookName) {
2354
2358
  function importBookText(filePath, outputDir) {
2355
2359
  return importBookTextFromText(decodeTextSmart(readFileSync(filePath)), outputDir, basename(filePath, extname(filePath)).slice(0, 40) || "导入小说");
2356
2360
  }
2361
+ /** 常见职业/身份尾缀:角色名如「周野律师」正文可能只写「周野」或「周野的律师」。 */
2362
+ const ROLE_NAME_SUFFIXES = [
2363
+ "律师",
2364
+ "辩护律师",
2365
+ "医生",
2366
+ "老师",
2367
+ "教授",
2368
+ "先生",
2369
+ "女士",
2370
+ "小姐",
2371
+ "警官",
2372
+ "警察",
2373
+ "局长",
2374
+ "总经理",
2375
+ "经理",
2376
+ "老板",
2377
+ "师父",
2378
+ "师傅",
2379
+ "道长",
2380
+ "老祖",
2381
+ "长老",
2382
+ "掌门",
2383
+ "少主",
2384
+ "公主",
2385
+ "王子",
2386
+ "王妃",
2387
+ "皇后",
2388
+ "皇帝",
2389
+ "王爷",
2390
+ "公子",
2391
+ "姑娘",
2392
+ "夫人",
2393
+ "太太",
2394
+ "大人",
2395
+ "将军",
2396
+ "丞相",
2397
+ "尚书",
2398
+ "员外"
2399
+ ];
2400
+ /**
2401
+ * 从角色名/身份拆出正文可能使用的检索词:
2402
+ * - specific:具体称谓(含职业尾缀或 3 字以上身份片段),如「周野的律师」「辩护律师」「律师」——优先用,避免误抓到同名主干(周野)的段落;
2403
+ * - stems:名字主干(如「周野」),最后兜底。
2404
+ */
2405
+ function roleFallbackTokens(name, identity) {
2406
+ const specific = /* @__PURE__ */ new Set();
2407
+ const stems = /* @__PURE__ */ new Set();
2408
+ const addName = (s) => {
2409
+ const t = s.trim();
2410
+ if (t.length < 2) return;
2411
+ specific.add(t);
2412
+ for (const q of [
2413
+ "辩护",
2414
+ "助理",
2415
+ "高级",
2416
+ "首席",
2417
+ "御用",
2418
+ "御前",
2419
+ "专职"
2420
+ ]) if (t.includes(q)) specific.add(t.replace(q, ""));
2421
+ for (const suf of ROLE_NAME_SUFFIXES) if (t.endsWith(suf) && t.length > suf.length + 1) {
2422
+ const stem = t.slice(0, -suf.length);
2423
+ specific.add(stem + "的" + suf);
2424
+ specific.add(suf);
2425
+ if (stem.length >= 2) stems.add(stem);
2426
+ }
2427
+ };
2428
+ addName(name);
2429
+ for (const part of (identity ?? "").split(/[的,,。\s]+/)) {
2430
+ const p = part.trim();
2431
+ if (p.length < 2) continue;
2432
+ if (p.length >= 3 || ROLE_NAME_SUFFIXES.some((s) => p.endsWith(s))) {
2433
+ specific.add(p);
2434
+ for (const suf of ROLE_NAME_SUFFIXES) if (p.endsWith(suf) && p.length > suf.length + 1) {
2435
+ specific.add(suf);
2436
+ stems.add(p.slice(0, -suf.length));
2437
+ }
2438
+ } else stems.add(p);
2439
+ }
2440
+ return {
2441
+ specific: [...specific].filter((t) => t.length >= 2),
2442
+ stems: [...stems].filter((t) => t.length >= 2)
2443
+ };
2444
+ }
2445
+ /** 保证风格词块已内嵌(LLM 偶发漏嵌时兜底):zh 段首前缀,en 末尾追加。 */
2446
+ function ensureStyleEmbedded(text, styleWords, lang) {
2447
+ const t = text.trim();
2448
+ if (styleWords === "" || t === "") return t;
2449
+ if (t.includes(styleWords)) return t;
2450
+ return lang === "zh" ? styleWords + "," + t : t + "," + styleWords;
2451
+ }
2452
+ /** 对 zh/en 一对提示词统一补风格词块。 */
2453
+ function withStyle(pair, styleWords) {
2454
+ return {
2455
+ zh: ensureStyleEmbedded(pair.zh, styleWords, "zh"),
2456
+ en: ensureStyleEmbedded(pair.en, styleWords, "en")
2457
+ };
2458
+ }
2357
2459
  /**
2358
2460
  * 为单个角色提炼「动漫形象描述词」:扫描该角色出场的已写章节正文,
2359
2461
  * 截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
@@ -2361,6 +2463,7 @@ function importBookText(filePath, outputDir) {
2361
2463
  async function extractRoleVisual(ctx, config, project, outputDir, roleName, styleId, filterId) {
2362
2464
  const role = (project.roles ?? []).find((r) => r.name === roleName);
2363
2465
  if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
2466
+ role.promptStyleId = styleId;
2364
2467
  const appearanceHints = /(发|眉|眼|眸|脸|肤|唇|身材|身高|衣|袍|裙|衫|靴|腰带|气质|模样|长相|容貌|披|束|扎|戴|佩|挂|绣|青|白|黑|红|蓝|紫|灰|银|金|少年|青年|少女|汉子|老者|中年|纤细|挺拔|瘦削|壮实|清秀|俊朗|英气|阴鸷|慈眉)/;
2365
2468
  const roleText = `${role.name} ${role.identity ?? ""}`;
2366
2469
  const colorWords = [
@@ -2427,33 +2530,48 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName, styl
2427
2530
  ...roleWords.filter((w) => roleText.includes(w)),
2428
2531
  ...locationWords.filter((w) => roleText.includes(w))
2429
2532
  ])).filter((t) => t.length >= 2);
2430
- const matchesRole = (para) => para.includes(roleName) || searchTokens.some((tok) => para.includes(tok));
2533
+ const matchesRole = (para, tokens) => para.includes(roleName) || tokens.some((tok) => para.includes(tok));
2431
2534
  const excerpts = [];
2432
- const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.file !== void 0).slice(-60);
2433
- for (const chapter of written) {
2434
- const body = readChapterFile(outputDir, chapter);
2435
- if (body === void 0) continue;
2436
- const paras = body.split(/\n{2,}/).map((p) => p.trim()).filter((p) => p.length > 0);
2437
- let perChapter = 0;
2438
- for (const para of paras) {
2439
- if (perChapter >= 2 || excerpts.length >= 12) break;
2440
- if (!matchesRole(para)) continue;
2441
- if (appearanceHints.test(para) || excerpts.length < 4) {
2442
- excerpts.push({
2443
- no: chapter.no,
2444
- text: para.slice(0, 220)
2445
- });
2446
- perChapter++;
2535
+ const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.file !== void 0);
2536
+ /** 扫描一批章节,收集该角色出场且可能含外貌描写的段落(每章最多 2 段,共 12 段)。 */
2537
+ const scanChapters = (list, tokens) => {
2538
+ for (const chapter of list) {
2539
+ if (excerpts.length >= 12) break;
2540
+ const body = readChapterFile(outputDir, chapter);
2541
+ if (body === void 0) continue;
2542
+ const paras = body.split(/\n{2,}/).map((p) => p.trim()).filter((p) => p.length > 0);
2543
+ let perChapter = 0;
2544
+ for (const para of paras) {
2545
+ if (perChapter >= 2 || excerpts.length >= 12) break;
2546
+ if (!matchesRole(para, tokens)) continue;
2547
+ if (appearanceHints.test(para) || excerpts.length < 4) {
2548
+ excerpts.push({
2549
+ no: chapter.no,
2550
+ text: para.slice(0, 220)
2551
+ });
2552
+ perChapter++;
2553
+ }
2447
2554
  }
2448
2555
  }
2449
- if (excerpts.length >= 12) break;
2556
+ };
2557
+ scanChapters(written.slice(-60), searchTokens);
2558
+ if (excerpts.length === 0) scanChapters(written, searchTokens);
2559
+ if (excerpts.length === 0) {
2560
+ const fb = roleFallbackTokens(role.name, role.identity);
2561
+ if (fb.specific.length > 0) scanChapters(written, fb.specific);
2562
+ }
2563
+ if (excerpts.length === 0) {
2564
+ const fb = roleFallbackTokens(role.name, role.identity);
2565
+ if (fb.stems.length > 0) scanChapters(written, fb.stems);
2450
2566
  }
2451
- if (excerpts.length === 0) throw new Error(`正文中未找到「${roleName}」的出场描写(仅搜索最近 60 章),请确认角色名与正文一致`);
2567
+ if (excerpts.length === 0) throw new Error(`正文中未找到「${roleName}」的出场描写(已扫描全书及角色名拆分词),请确认角色名与正文一致,或该角色尚未在正文出场`);
2452
2568
  const rules = project.visualRules ?? [];
2569
+ const styleWords = styleKeywords(styleId, filterId);
2453
2570
  const raw = parseJsonObject(await complete(ctx, config, {
2454
2571
  system: [
2455
2572
  "你是一位动漫角色设定师与 AI 绘图提示词工程师。根据网文正文中该角色的实际外貌描写,输出「形象锚点」与「四类生图提示词」——一次完成,用于 AI 绘图(NovelAI / Stable Diffusion / Midjourney / 豆包等)生成一致的角色立绘。",
2456
- "【当前视觉风格】(portrait 立绘的「风格」字段必须原样使用,sheet/expressions/details 同样内嵌):" + styleKeywords(styleId, filterId),
2573
+ "【当前视觉风格】(portrait 立绘的「风格」字段必须原样使用,sheet/expressions/details 同样内嵌):" + styleWords,
2574
+ "重要:每段 zh 提示词的段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。",
2457
2575
  "硬性要求(依据优先):",
2458
2576
  "1. 发色/发型/瞳色/服装/气质/标志物必须来自提供的正文段落,不得凭空发明。",
2459
2577
  "2. 正文未明确写到的项目(如瞳色没写、身高没写),用「未定」标注或直接不写,不要编造数值。",
@@ -2489,8 +2607,8 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName, styl
2489
2607
  maxTokens: Math.max(config.maxTokens, 12e3),
2490
2608
  reasoning: config.analysisReasoning ?? "low"
2491
2609
  }));
2492
- const zh = typeof raw.zh === "string" ? raw.zh.trim().slice(0, 500) : "";
2493
- const en = typeof raw.en === "string" ? raw.en.trim().slice(0, 1500) : "";
2610
+ let zh = typeof raw.zh === "string" ? raw.zh.trim().slice(0, 500) : "";
2611
+ let en = typeof raw.en === "string" ? raw.en.trim().slice(0, 1500) : "";
2494
2612
  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) : [];
2495
2613
  const source = typeof raw.source === "string" ? raw.source.trim().slice(0, 300) : "";
2496
2614
  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) : [];
@@ -2520,6 +2638,17 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName, styl
2520
2638
  promptKit = void 0;
2521
2639
  }
2522
2640
  if (zh === "" || en === "") throw new Error("形象描述提炼失败:LLM 未返回有效 JSON");
2641
+ zh = ensureStyleEmbedded(zh, styleWords, "zh");
2642
+ en = ensureStyleEmbedded(en, styleWords, "en");
2643
+ if (promptKit !== void 0) promptKit = {
2644
+ portrait: withStyle(promptKit.portrait, styleWords),
2645
+ sheet: withStyle(promptKit.sheet, styleWords),
2646
+ expressions: promptKit.expressions.map((e) => ({
2647
+ ...e,
2648
+ ...withStyle(e, styleWords)
2649
+ })),
2650
+ details: withStyle(promptKit.details, styleWords)
2651
+ };
2523
2652
  return {
2524
2653
  zh,
2525
2654
  en,
@@ -2537,11 +2666,14 @@ async function generateRolePromptKit(ctx, config, project, roleName, styleId, fi
2537
2666
  const role = (project.roles ?? []).find((r) => r.name === roleName);
2538
2667
  if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
2539
2668
  if (role.imagePrompt === void 0) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`);
2669
+ role.promptStyleId = styleId;
2540
2670
  const rules = project.visualRules ?? [];
2671
+ const styleWords = styleKeywords(styleId, filterId);
2541
2672
  const raw = parseJsonObject(await complete(ctx, config, {
2542
2673
  system: [
2543
2674
  "你是一位 AI 绘图提示词工程师。基于给定角色的形象锚点、表情清单与本书视觉规则,输出四类生图提示词(每类 zh+en 各一段)。",
2544
- "【当前视觉风格】(每类提示词必须内嵌):" + styleKeywords(styleId, filterId),
2675
+ "【当前视觉风格】(每类提示词必须内嵌):" + styleWords,
2676
+ "重要:每段 zh 提示词的段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。",
2545
2677
  "四类:",
2546
2678
  "1. portrait 立绘:正面站立全身人像,3D动漫超精细建模,纯白纯色背景;按字段流组织:身份(男子,角色名,外表年龄)→ 发型发色 → 胡茬 → 眼眸 → 面部 → 气质 → 上身服装分件(含磨损)→ 下身 → 鞋 → 标志物(含细节)→ 收尾(角色设计稿,细节完整展示,无多余杂物,全身完整无裁切)。",
2547
2679
  "2. sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,四个视角分别描述。",
@@ -2575,14 +2707,16 @@ async function generateRolePromptKit(ctx, config, project, roleName, styleId, fi
2575
2707
  };
2576
2708
  const expressionsRaw = Array.isArray(raw.expressions) ? raw.expressions.filter((e) => typeof e === "object" && e !== null) : [];
2577
2709
  const kit = {
2578
- portrait: pair(raw.portrait),
2579
- sheet: pair(raw.sheet),
2710
+ portrait: withStyle(pair(raw.portrait), styleWords),
2711
+ sheet: withStyle(pair(raw.sheet), styleWords),
2580
2712
  expressions: expressionsRaw.map((e) => ({
2581
2713
  name: str(e.name).slice(0, 12) || "表情",
2582
- zh: str(e.zh).slice(0, 800),
2583
- en: str(e.en).slice(0, 1200)
2714
+ ...withStyle({
2715
+ zh: str(e.zh).slice(0, 800),
2716
+ en: str(e.en).slice(0, 1200)
2717
+ }, styleWords)
2584
2718
  })).slice(0, 12),
2585
- details: pair(raw.details)
2719
+ details: withStyle(pair(raw.details), styleWords)
2586
2720
  };
2587
2721
  if (kit.portrait.zh === "" || kit.portrait.en === "") throw new Error("提示词精修失败:LLM 未返回有效 JSON");
2588
2722
  return kit;
@@ -2814,14 +2948,66 @@ async function breakdownBook(ctx, config, project, outputDir, scope = "recent",
2814
2948
  usedTokens
2815
2949
  };
2816
2950
  }
2817
- async function generateRoleReferenceImage(ctx, config, project, outputDir, name, style = "") {
2951
+ /** 生图接口连通性测试(设置页模型条目用):GET {baseURL}/models,计时返回延迟。 */
2952
+ async function testImageEndpoint(baseURL, apiKey, model) {
2953
+ const trimmed = baseURL.trim();
2954
+ if (trimmed === "") return {
2955
+ ok: false,
2956
+ ms: 0,
2957
+ message: "接口地址不能为空"
2958
+ };
2959
+ const url = trimmed.replace(/\/+$/, "") + "/models";
2960
+ const start = Date.now();
2961
+ try {
2962
+ const res = await fetch(url, {
2963
+ method: "GET",
2964
+ headers: {
2965
+ Authorization: "Bearer " + apiKey,
2966
+ "Content-Type": "application/json"
2967
+ }
2968
+ });
2969
+ const ms = Date.now() - start;
2970
+ if (!res.ok) {
2971
+ const text = (await res.text().catch(() => "")).slice(0, 200);
2972
+ return {
2973
+ ok: false,
2974
+ ms,
2975
+ message: "HTTP " + res.status + (text !== "" ? ":" + text : "")
2976
+ };
2977
+ }
2978
+ let modelFound;
2979
+ if (model !== void 0 && model !== "") {
2980
+ const list = (await res.json().catch(() => null))?.data;
2981
+ if (Array.isArray(list)) modelFound = list.some((m) => {
2982
+ if (typeof m !== "object" || m === null) return false;
2983
+ const o = m;
2984
+ return o.id === model || o.model === model;
2985
+ });
2986
+ }
2987
+ return {
2988
+ ok: true,
2989
+ ms,
2990
+ modelFound
2991
+ };
2992
+ } catch (err) {
2993
+ return {
2994
+ ok: false,
2995
+ ms: Date.now() - start,
2996
+ message: err.message
2997
+ };
2998
+ }
2999
+ }
3000
+ async function generateRoleReferenceImage(ctx, config, project, outputDir, name, style = "", modelId) {
2818
3001
  const role = project.roles?.find((r) => r.name === name);
2819
3002
  if (role === void 0) throw new Error(`角色 ${name} 不存在`);
2820
3003
  if (role.imagePrompt === void 0) throw new Error(`角色 ${name} 还没有形象锚点,请先生成锚点`);
2821
- if (config.imageApiEnabled !== true) throw new Error("豆包生图未启用,请在设置中开启");
2822
- const apiKey = config.imageApiKey;
2823
- const model = config.imageApiModel;
2824
- if (apiKey === void 0 || apiKey === "" || model === void 0 || model === "") throw new Error("请先在设置中配置豆包 API Key 和生图模型");
3004
+ if (config.imageApiEnabled !== true) throw new Error("生图未启用,请在设置中启用一个生图模型");
3005
+ const activeImage = (modelId !== void 0 && modelId !== "" ? (config.imageModels ?? []).find((m) => m.id === modelId) : void 0) ?? (config.imageModels ?? []).find((m) => m.enabled) ?? (config.imageModels ?? [])[0];
3006
+ const apiKey = activeImage?.apiKey ?? config.imageApiKey;
3007
+ const model = activeImage?.model ?? config.imageApiModel;
3008
+ const baseURL = (activeImage?.baseURL ?? config.imageBaseUrl ?? "").replace(/\/+$/, "");
3009
+ if (baseURL === "") throw new Error("请先在设置中填写生图接口地址");
3010
+ if (apiKey === void 0 || apiKey === "" || model === void 0 || model === "") throw new Error("请先在设置中配置生图模型的 API Key 和模型 id");
2825
3011
  const stylePrompt = getComicStylePrompt(style);
2826
3012
  const prompt = [
2827
3013
  role.imagePrompt.zh,
@@ -2830,7 +3016,7 @@ async function generateRoleReferenceImage(ctx, config, project, outputDir, name,
2830
3016
  stylePrompt,
2831
3017
  "character reference sheet, front view, clean background, consistent design"
2832
3018
  ].filter(Boolean).join(", ");
2833
- const res = await fetch("https://ark.cn-beijing.volces.com/api/v3/images/generations", {
3019
+ const res = await fetch(baseURL + "/images/generations", {
2834
3020
  method: "POST",
2835
3021
  headers: {
2836
3022
  "Content-Type": "application/json",
@@ -2847,12 +3033,12 @@ async function generateRoleReferenceImage(ctx, config, project, outputDir, name,
2847
3033
  });
2848
3034
  if (!res.ok) {
2849
3035
  const text = await res.text();
2850
- throw new Error(`豆包生图失败(${res.status}):${text}`);
3036
+ throw new Error(`生图失败(${res.status}):${text}`);
2851
3037
  }
2852
3038
  const url = (await res.json()).data?.[0]?.url;
2853
- if (url === void 0 || url === "") throw new Error("豆包生图未返回图片 URL");
3039
+ if (url === void 0 || url === "") throw new Error("生图接口未返回图片 URL");
2854
3040
  const imgRes = await fetch(url);
2855
- if (!imgRes.ok) throw new Error("下载豆包图片失败");
3041
+ if (!imgRes.ok) throw new Error("下载生图结果失败");
2856
3042
  const buf = Buffer.from(await imgRes.arrayBuffer());
2857
3043
  const dataUrl = `data:${imgRes.headers.get("content-type") || "image/jpeg"};base64,${buf.toString("base64")}`;
2858
3044
  role.imageUrl = dataUrl;
@@ -3243,15 +3429,22 @@ async function summarizeChapter(ctx, config, project, outputDir, chapterNo) {
3243
3429
  saveProject(outputDir, project);
3244
3430
  return chapter.summary;
3245
3431
  }
3246
- /** 把分镜产出写入项目持久化(按章 upsert)。 */
3432
+ /** 把分镜产出写入项目持久化(按章 upsert)。重新生成上游时级联清掉下游旧产物:
3433
+ * 新骨架 → 清分镜表+提示词;新分镜表 → 清提示词;新提示词不动上游。 */
3247
3434
  function saveChapterStoryboard(project, outputDir, entry) {
3248
3435
  if (project.storyboards === void 0) project.storyboards = [];
3249
3436
  const idx = project.storyboards.findIndex((e) => e.chapterNo === entry.chapterNo);
3250
- if (idx === -1) project.storyboards.push(entry);
3251
- else project.storyboards[idx] = {
3252
- ...project.storyboards[idx],
3437
+ const next = {
3438
+ ...(idx === -1 ? void 0 : project.storyboards[idx]) ?? {},
3253
3439
  ...entry
3254
3440
  };
3441
+ if (entry.skeleton !== void 0) {
3442
+ next.table = void 0;
3443
+ next.prompts = void 0;
3444
+ }
3445
+ if (entry.table !== void 0) next.prompts = void 0;
3446
+ if (idx === -1) project.storyboards.push(next);
3447
+ else project.storyboards[idx] = next;
3255
3448
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3256
3449
  saveProject(outputDir, project);
3257
3450
  }
@@ -3266,7 +3459,8 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
3266
3459
  if (body === void 0) throw new Error(`章节 ${chapterNo} 的正文文件不存在`);
3267
3460
  if ((skeleton.beats ?? []).length === 0) throw new Error("骨架为空,请先生成剧情骨架");
3268
3461
  const roles = (project.roles ?? []).filter((r) => body.includes(r.name)).slice(0, 8).map((r) => `${r.name}(${r.roleLabel === "protagonist" ? "主角" : r.roleLabel === "antagonist" ? "反派" : r.roleLabel === "female_lead" ? "女主" : "配角"}):${r.identity ?? ""}`);
3269
- const scenes = (project.scenes ?? []).filter((s) => body.includes(s.name)).slice(0, 6).map((s) => `${s.name}:${s.summary ?? ""}`);
3462
+ const usedScenes = (project.scenes ?? []).filter((s) => body.includes(s.name)).slice(0, 6);
3463
+ const scenes = usedScenes.map((s) => `${s.name}:${s.summary ?? ""}`);
3270
3464
  const rules = (project.visualRules ?? []).map((r) => "- " + r);
3271
3465
  const baseStyle = styleId !== void 0 ? findStyle(styleId) : void 0;
3272
3466
  const filterStyle = filterId !== void 0 ? findStyle(filterId) : void 0;
@@ -3332,7 +3526,8 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
3332
3526
  if (missing.length > 0) console.warn(`[dsh-novel-forge] storyboard: beat ${missing.join(",")} 无镜头覆盖`);
3333
3527
  const table = {
3334
3528
  chapterNo,
3335
- shots
3529
+ shots,
3530
+ usedScenes: usedScenes.map((s) => s.name)
3336
3531
  };
3337
3532
  saveChapterStoryboard(project, outputDir, {
3338
3533
  chapterNo,
@@ -3364,6 +3559,7 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
3364
3559
  "任务:把分镜表的每个镜头写成一段可直接粘贴进视频生成工具的提示词。",
3365
3560
  "提示词结构(按顺序):风格词块 → 画面主体(人物动作+表情+服装标志物)→ 机位运镜(含时长感)→ 光效氛围。单段中文 60-140 字,逗号分隔,流畅自然。",
3366
3561
  "风格词块(必须原样放在每段开头):" + stylePrefix,
3562
+ "重要:每段提示词开头必须原样嵌入风格词块原文,不得省略、不得改写。",
3367
3563
  "硬性要求:",
3368
3564
  "1. 只写画面与镜头,禁止写音频/台词配音说明(视频模型无音频)。",
3369
3565
  "2. 服装/发色/标志物必须沿用镜头表与视觉规则,禁止自行更换。",
@@ -3385,7 +3581,7 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
3385
3581
  const shotIds = new Set(table.shots.map((s) => s.id));
3386
3582
  const prompts = Array.isArray(raw.prompts) ? raw.prompts.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
3387
3583
  shotId: shotIds.has(entry.shotId) ? entry.shotId : "",
3388
- text: typeof entry.text === "string" ? entry.text.trim().slice(0, 400) : ""
3584
+ text: ensureStyleEmbedded(typeof entry.text === "string" ? entry.text.trim().slice(0, 400) : "", stylePrefix, "zh")
3389
3585
  })).filter((x) => x.shotId !== "" && x.text !== "") : [];
3390
3586
  if (prompts.length === 0) throw new Error("模型未输出有效提示词,请重试");
3391
3587
  saveChapterStoryboard(project, outputDir, {
@@ -4585,6 +4781,8 @@ const NOVEL_API = {
4585
4781
  storyboardTable: "/api/dsh-novel-forge/storyboard/table",
4586
4782
  /** 分镜·提示词级:分镜表 → 即梦可粘贴视频提示词。 */
4587
4783
  storyboardPrompts: "/api/dsh-novel-forge/storyboard/prompts",
4784
+ /** 生图接口连通性测试(设置页每个模型条目用)。 */
4785
+ imageTest: "/api/dsh-novel-forge/image-test",
4588
4786
  /** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
4589
4787
  reset: "/api/dsh-novel-forge/reset",
4590
4788
  /** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
@@ -7042,7 +7240,7 @@ function makeRoutes(deps) {
7042
7240
  return;
7043
7241
  }
7044
7242
  try {
7045
- const imageUrl = await generateRoleReferenceImage(ctx, config, project, config.outputDir, name, body?.style ?? "");
7243
+ const imageUrl = await generateRoleReferenceImage(ctx, config, project, config.outputDir, name, body?.style ?? "", body?.modelId);
7046
7244
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
7047
7245
  saveProject(config.outputDir, project);
7048
7246
  writeJson(res, 200, {
@@ -7377,6 +7575,24 @@ function makeRoutes(deps) {
7377
7575
  }
7378
7576
  }
7379
7577
  };
7578
+ /** 生图接口连通性测试:GET {baseURL}/models + 计时(设置页模型条目用)。 */
7579
+ const imageTestRoute = {
7580
+ kind: "exact",
7581
+ path: NOVEL_API.imageTest,
7582
+ handler: async (req, res) => {
7583
+ if (!guard(req, res, "POST")) return;
7584
+ const body = await readJsonBody(req);
7585
+ if (body?.baseURL === void 0 || body?.apiKey === void 0) {
7586
+ writeJson(res, 400, { error: "baseURL 与 apiKey 必填" });
7587
+ return;
7588
+ }
7589
+ try {
7590
+ writeJson(res, 200, await testImageEndpoint(body.baseURL, body.apiKey, body.model));
7591
+ } catch (error) {
7592
+ writeJson(res, 500, { error: `测试失败:${error.message}` });
7593
+ }
7594
+ }
7595
+ };
7380
7596
  /** 分镜·导演级:骨架 → 分镜表(镜头级)。 */
7381
7597
  const storyboardTableRoute = {
7382
7598
  kind: "exact",
@@ -7542,7 +7758,7 @@ function makeRoutes(deps) {
7542
7758
  if (project.scenes === void 0) project.scenes = [];
7543
7759
  const op = body?.op;
7544
7760
  if (op === "extract") try {
7545
- const candidates = await extractScenes(ctx, config, project);
7761
+ const candidates = await extractScenes(ctx, config, project, body?.styleId, body?.filterId);
7546
7762
  writeJson(res, 200, {
7547
7763
  scenes: project.scenes,
7548
7764
  candidates
@@ -7681,6 +7897,7 @@ function makeRoutes(deps) {
7681
7897
  manhuaPlansRoute,
7682
7898
  styleImageRoute,
7683
7899
  storyboardSkeletonRoute,
7900
+ imageTestRoute,
7684
7901
  storyboardTableRoute,
7685
7902
  storyboardPromptsRoute,
7686
7903
  breakdownRoute,
@@ -7789,6 +8006,14 @@ const Config = z.object({
7789
8006
  autoReview: z.boolean().default(true),
7790
8007
  autoAuthorReview: z.boolean().default(true),
7791
8008
  autoReviewAfterRevise: z.boolean().default(true),
8009
+ imageModels: z.array(z.object({
8010
+ id: z.string().default(""),
8011
+ name: z.string().default(""),
8012
+ baseURL: z.string().default(""),
8013
+ apiKey: z.string().default(""),
8014
+ model: z.string().default(""),
8015
+ enabled: z.boolean().default(false)
8016
+ })).default([]),
7792
8017
  imageApiKey: z.string().default(""),
7793
8018
  imageApiModel: z.string().default(""),
7794
8019
  imageApiEnabled: z.boolean().default(false)
@@ -7826,11 +8051,29 @@ function resolveConfig(value) {
7826
8051
  autoReview: value?.autoReview ?? DEFAULT_AUTO_REVIEW,
7827
8052
  autoAuthorReview: value?.autoAuthorReview ?? DEFAULT_AUTO_AUTHOR_REVIEW,
7828
8053
  autoReviewAfterRevise: value?.autoReviewAfterRevise ?? DEFAULT_AUTO_REVIEW_AFTER_REVISE,
7829
- imageApiKey: value?.imageApiKey,
7830
- imageApiModel: value?.imageApiModel,
8054
+ imageModels: value?.imageModels !== void 0 ? value.imageModels : value?.imageApiKey !== void 0 && value.imageApiKey !== "" ? [{
8055
+ id: "img-legacy",
8056
+ name: "豆包(旧配置)",
8057
+ baseURL: DEFAULT_IMAGE_BASE,
8058
+ apiKey: value.imageApiKey,
8059
+ model: value.imageApiModel ?? "",
8060
+ enabled: true
8061
+ }] : [],
8062
+ imageBaseUrl: (() => {
8063
+ const active = (value?.imageModels ?? []).find((m) => m.enabled) ?? (value?.imageModels ?? [])[0];
8064
+ return active?.baseURL !== void 0 && active.baseURL !== "" ? active.baseURL : value?.imageApiKey !== void 0 && value.imageApiKey !== "" ? DEFAULT_IMAGE_BASE : void 0;
8065
+ })(),
8066
+ imageApiKey: (() => {
8067
+ return ((value?.imageModels ?? []).find((m) => m.enabled) ?? (value?.imageModels ?? [])[0])?.apiKey ?? value?.imageApiKey;
8068
+ })(),
8069
+ imageApiModel: (() => {
8070
+ return ((value?.imageModels ?? []).find((m) => m.enabled) ?? (value?.imageModels ?? [])[0])?.model ?? value?.imageApiModel;
8071
+ })(),
7831
8072
  imageApiEnabled: value?.imageApiEnabled ?? false
7832
8073
  };
7833
8074
  }
8075
+ /** 生图默认接口地址(豆包 ark,OpenAI 兼容)。 */
8076
+ const DEFAULT_IMAGE_BASE = "https://ark.cn-beijing.volces.com/api/v3";
7834
8077
  /**
7835
8078
  * Mount the routes and announcement.
7836
8079
  * @param ctx - host plugin context carrying webServer/llm/systemPrompt.