@waterwx/dsh-novel-forge 1.10.1 → 2.0.1-alpha

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.
Files changed (56) hide show
  1. package/README.md +8 -3
  2. package/assets/styles/guoman-2d-gufeng.webp +0 -0
  3. package/assets/styles/guoman-3d-tianyuan.webp +0 -0
  4. package/assets/styles/guoman-3d-xuanhuan.webp +0 -0
  5. package/assets/styles/hyperreal-3d.webp +0 -0
  6. package/assets/styles/sandiao-qban.webp +0 -0
  7. package/assets/styles/urban-lightreal-3d.webp +0 -0
  8. package/lib/client.js +3456 -2133
  9. package/lib/index.js +1975 -438
  10. package/lib/types/ai-scan.d.ts +36 -0
  11. package/lib/types/assets.d.ts +1 -1
  12. package/lib/types/client/api.d.ts +8 -1
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/panel/AuthorHome.d.ts +4 -2
  15. package/lib/types/client/panel/FlowGuide.d.ts +6 -5
  16. package/lib/types/client/panel/MangaRoleLibrary.d.ts +3 -1
  17. package/lib/types/client/panel/MangaWorkspace.d.ts +2 -5
  18. package/lib/types/client/panel/PropLibrary.d.ts +8 -0
  19. package/lib/types/client/panel/SceneLibrary.d.ts +3 -1
  20. package/lib/types/client/panel/SettingsView.d.ts +4 -2
  21. package/lib/types/engine.d.ts +50 -21
  22. package/lib/types/index.d.ts +2 -2
  23. package/lib/types/manga-genre-rules.d.ts +22 -0
  24. package/lib/types/novel-context.d.ts +80 -0
  25. package/lib/types/prompt-utils.d.ts +35 -0
  26. package/lib/types/protocol.d.ts +104 -12
  27. package/lib/types/review-policy.d.ts +97 -0
  28. package/lib/types/style-library.d.ts +3 -3
  29. package/package.json +21 -24
  30. package/src/ai-scan.ts +179 -0
  31. package/src/assets.ts +353 -9
  32. package/src/client/api.ts +11 -1
  33. package/src/client/index.ts +1 -1
  34. package/src/client/mount.tsx +1 -1
  35. package/src/client/panel/AdaptModeView.tsx +2 -2
  36. package/src/client/panel/AssistantTab.tsx +1 -1
  37. package/src/client/panel/AuthorHome.tsx +5 -3
  38. package/src/client/panel/FlowGuide.tsx +15 -14
  39. package/src/client/panel/MangaRoleLibrary.tsx +1002 -810
  40. package/src/client/panel/MangaWorkspace.tsx +715 -359
  41. package/src/client/panel/ModelManager.tsx +1 -1
  42. package/src/client/panel/NovelPanel.tsx +4620 -4655
  43. package/src/client/panel/PropLibrary.tsx +175 -0
  44. package/src/client/panel/SceneLibrary.tsx +453 -396
  45. package/src/client/panel/SettingsView.tsx +24 -30
  46. package/src/client/panel/StoryboardTab.tsx +6 -0
  47. package/src/client/panel/panel.module.css +165 -4
  48. package/src/engine.ts +796 -286
  49. package/src/index.ts +9 -7
  50. package/src/manga-genre-rules.ts +87 -0
  51. package/src/novel-context.ts +287 -0
  52. package/src/prompt-utils.ts +73 -0
  53. package/src/protocol.ts +2233 -2148
  54. package/src/review-policy.ts +161 -0
  55. package/src/routes.ts +3419 -3359
  56. package/src/style-library.ts +32 -26
package/lib/index.js CHANGED
@@ -1,9 +1,8 @@
1
1
  import { homedir, tmpdir } from "node:os";
2
2
  import { basename, extname, join } from "node:path";
3
- import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
4
3
  import z from "schemastery";
5
4
  import { spawn } from "node:child_process";
6
- import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
7
6
  import { randomBytes } from "node:crypto";
8
7
  import { fileURLToPath } from "node:url";
9
8
  import { BlockAssembler, ReasoningEffortId, createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
@@ -199,6 +198,237 @@ const BUILTIN_STYLE_TEMPLATES = [
199
198
  "鼓励现实落差",
200
199
  "鼓励嘴硬补偿"
201
200
  ]
201
+ },
202
+ {
203
+ key: "cultivation-breakthrough",
204
+ name: "修炼突破流",
205
+ description: "以境界突破、战力碾压和资源争夺为核心,强调突破前后的反差与爽感。",
206
+ category: "修炼流",
207
+ applicableGenres: [
208
+ "仙侠",
209
+ "玄幻",
210
+ "都市异能"
211
+ ],
212
+ proseRules: [
213
+ "突破前铺垫压抑与困境,突破时释放能量与威压,突破后立即兑现碾压收益。",
214
+ "场景单元按「困境→闭关/机缘→突破→碾压→新目标」推进。",
215
+ "战力体系严格遵守道藏设定,不随意膨胀;每次突破有明确代价或限制。"
216
+ ],
217
+ dialogueRules: ["突破前角色隐忍克制,突破后语气自信但不浮夸。", "对手从轻视到震惊的反应通过对话和动作体现,不直接解说。"],
218
+ languageRules: ["战斗场景用短句和动作词,突破场景用感官描写(光/声/压力)。", "减少修炼过程的流水账,聚焦关键节点和突破瞬间。"],
219
+ rhythmRules: ["突破节奏先抑后扬,压抑段不超过3段,突破段要快且有冲击力。", "每章至少一个战力或境界的明确进展点。"],
220
+ defaultAntiAiRuleKeys: [
221
+ "禁止总结主题",
222
+ "禁止段尾升华",
223
+ "连续三段解释性叙事"
224
+ ]
225
+ },
226
+ {
227
+ key: "face-slapping",
228
+ name: "装逼打脸流",
229
+ description: "身份隐藏→被轻视→展露实力→全场震惊,强调反差爽感与节奏控制。",
230
+ category: "爽文流",
231
+ applicableGenres: [
232
+ "都市",
233
+ "玄幻",
234
+ "仙侠",
235
+ "重生"
236
+ ],
237
+ proseRules: [
238
+ "打脸前三段内必须建立轻视/挑衅,打脸过程不超过两段,震惊反应要充分。",
239
+ "场景单元按「隐藏→挑衅→展露→震惊→新挑衅」循环推进。",
240
+ "主角实力展露要有铺垫和依据,不凭空开挂。"
241
+ ],
242
+ dialogueRules: [
243
+ "挑衅者台词要具体且有针对性,避免泛泛的\"你也配\"。",
244
+ "主角话少而精准,用行动和结果说话,不嘴炮解释。",
245
+ "围观者反应分层:先不信→再震惊→最后讨好/畏惧。"
246
+ ],
247
+ languageRules: ["打脸场景用短句和动作,震惊场景用群像反应。", "避免\"全场寂静\"\"众人哗然\"等套话,用具体人物反应代替。"],
248
+ rhythmRules: ["快节奏,打脸间隔不超过3章,每章至少一个小反转。", "装逼要克制,主角不主动炫耀,被动展露更有爽感。"],
249
+ defaultAntiAiRuleKeys: [
250
+ "禁止总结主题",
251
+ "对话纯功能推进",
252
+ "AI 高频套话"
253
+ ]
254
+ },
255
+ {
256
+ key: "power-struggle",
257
+ name: "权谋博弈流",
258
+ description: "以信息差、布局和反制为核心,强调对话潜台词与多方博弈。",
259
+ category: "权谋流",
260
+ applicableGenres: [
261
+ "历史",
262
+ "宫斗",
263
+ "官场",
264
+ "仙侠争霸"
265
+ ],
266
+ proseRules: [
267
+ "每章至少一次信息不对称的利用或破解,布局要有伏笔和回收。",
268
+ "场景单元按「情报→布局→试探→反制→结果」推进。",
269
+ "多方势力各有目标和底线,不做纯粹的工具人反派。"
270
+ ],
271
+ dialogueRules: [
272
+ "对话充满潜台词,表面客气实则交锋,关键信息藏在半句和停顿里。",
273
+ "不同势力角色的语言风格和立场要明确区分。",
274
+ "避免角色直接说出计划和意图,通过行动和结果揭示。"
275
+ ],
276
+ languageRules: ["正式、克制的语言,避免口语化和现代网络用语。", "用细节(眼神/手势/器物)暗示人物真实想法。"],
277
+ rhythmRules: ["中慢节奏,布局段可以慢,但反转和收网段要快。", "每3-5章一个小高潮(布局见效或反制成功)。"],
278
+ defaultAntiAiRuleKeys: [
279
+ "禁止解释型心理描写",
280
+ "禁止直接说教",
281
+ "对话纯功能推进"
282
+ ]
283
+ },
284
+ {
285
+ key: "sweet-romance",
286
+ name: "甜宠撒糖流",
287
+ description: "高糖互动+宠溺细节+情感升温,少虐多甜,强调心动瞬间。",
288
+ category: "言情流",
289
+ applicableGenres: [
290
+ "现言",
291
+ "古言",
292
+ "甜宠",
293
+ "校园"
294
+ ],
295
+ proseRules: [
296
+ "每章至少一个心动或撒糖细节,情感进展要有明确节点。",
297
+ "场景单元按「日常互动→心动瞬间→关系推进→新暧昧」推进。",
298
+ "误会不超过2章,冲突要小而温馨,不搞虐恋。"
299
+ ],
300
+ dialogueRules: [
301
+ "对话自然亲昵,有专属称呼和互动习惯,避免书面化表白。",
302
+ "男主台词宠溺但不油腻,女主可以害羞但不傻白甜。",
303
+ "用对话中的停顿、转移话题暗示心动,不直接说\"我喜欢你\"。"
304
+ ],
305
+ languageRules: ["温暖、细腻的语言,多用感官细节(温度/气味/触感)。", "避免\"心如鹿撞\"\"脸红心跳\"等套话,用具体动作代替。"],
306
+ rhythmRules: ["中慢节奏,日常段可以慢,但心动瞬间要聚焦和放大。", "每5章一个关系突破(牵手/拥抱/表白等)。"],
307
+ defaultAntiAiRuleKeys: [
308
+ "禁止段尾升华",
309
+ "禁止解释型心理描写",
310
+ "鼓励无意义小动作"
311
+ ]
312
+ },
313
+ {
314
+ key: "competitive-blood",
315
+ name: "竞技热血流",
316
+ description: "操作细节+战术博弈+赛事逆转,强调燃点密集与成长曲线。",
317
+ category: "竞技流",
318
+ applicableGenres: [
319
+ "电竞",
320
+ "体育",
321
+ "网游",
322
+ "卡牌"
323
+ ],
324
+ proseRules: [
325
+ "比赛场景要有具体操作/战术细节,不写\"他很强\"而是写\"他怎么强\"。",
326
+ "场景单元按「训练/准备→劣势→战术调整→逆转→赛后成长」推进。",
327
+ "对手要有实力和特点,不做纯粹的经验包。"
328
+ ],
329
+ dialogueRules: [
330
+ "队友对话有战术讨论和互相鼓励,对手台词有挑衅和认可。",
331
+ "解说/旁白可以有,但不能代替比赛过程本身。",
332
+ "角色在高压下的语言要简短有力,避免长篇大论。"
333
+ ],
334
+ languageRules: ["比赛场景用短句和动作词,节奏快,有画面感。", "操作描述要专业且准确,避免外行话。"],
335
+ rhythmRules: ["快节奏,比赛段要紧凑,日常训练段可以稍缓。", "每场比赛至少一个逆转或高光时刻,每章一个小燃点。"],
336
+ defaultAntiAiRuleKeys: [
337
+ "禁止总结主题",
338
+ "连续三段解释性叙事",
339
+ "AI 高频套话"
340
+ ]
341
+ },
342
+ {
343
+ key: "comedy-roast",
344
+ name: "吐槽搞笑流",
345
+ description: "旁白吐槽+角色反差+无厘头,节奏轻快,强调笑点密度。",
346
+ category: "搞笑流",
347
+ applicableGenres: [
348
+ "轻小说",
349
+ "二次元",
350
+ "都市",
351
+ "无限流"
352
+ ],
353
+ proseRules: [
354
+ "每章至少3个笑点,笑点来自反差、误解或吐槽,不依赖网络梗。",
355
+ "场景单元按「正常展开→反差/误解→吐槽→意外结果」推进。",
356
+ "搞笑不影响主线推进,笑点服务于剧情和人物。"
357
+ ],
358
+ dialogueRules: [
359
+ "主角吐槽要精准且有个人风格,其他角色负责一本正经地制造槽点。",
360
+ "对话节奏快,有来有回,避免冷场。",
361
+ "允许打破第四面墙的吐槽,但不能滥用。"
362
+ ],
363
+ languageRules: ["口语化、轻快的语言,允许夸张和无厘头。", "吐槽用括号或单独段落,不与叙事混淆。"],
364
+ rhythmRules: ["快节奏,笑点密集,不拖沓。", "每段不超过3句,长段落要拆。"],
365
+ defaultAntiAiRuleKeys: [
366
+ "禁止段尾升华",
367
+ "禁止直接说教",
368
+ "鼓励无意义小动作"
369
+ ]
370
+ },
371
+ {
372
+ key: "atmosphere-horror",
373
+ name: "氛围惊悚流",
374
+ description: "环境细节+心理暗示+信息延迟,恐惧不直说,靠氛围营造。",
375
+ category: "恐怖流",
376
+ applicableGenres: [
377
+ "灵异",
378
+ "惊悚",
379
+ "克苏鲁",
380
+ "无限恐怖"
381
+ ],
382
+ proseRules: [
383
+ "恐惧来自未知和异常,不直接描写怪物/鬼魂,用反应和痕迹暗示。",
384
+ "场景单元按「日常→异常细节→误解/忽视→危机爆发→余悸」推进。",
385
+ "信息延迟:读者和主角同时发现异常,不提前剧透。"
386
+ ],
387
+ dialogueRules: [
388
+ "角色对话克制,恐惧通过停顿、重复、语无伦次体现。",
389
+ "避免角色直接说\"好可怕\"\"有鬼\",用行动和反应代替。",
390
+ "关键信息藏在半句和打断里。"
391
+ ],
392
+ languageRules: [
393
+ "冷色调、精确的环境描写,多用听觉和触觉(视觉反而少)。",
394
+ "句子短而碎,制造紧张感;长句用于压抑和拖延。",
395
+ "避免\"毛骨悚然\"\"不寒而栗\"等套话,用具体感官代替。"
396
+ ],
397
+ rhythmRules: ["慢节奏铺垫,快节奏爆发,爆发后留余悸。", "每3章一个小高潮(异常确认或危机爆发)。"],
398
+ defaultAntiAiRuleKeys: [
399
+ "禁止解释型心理描写",
400
+ "禁止总结主题",
401
+ "段落长度过于整齐"
402
+ ]
403
+ },
404
+ {
405
+ key: "hard-scifi",
406
+ name: "硬核科技流",
407
+ description: "技术细节+逻辑推演+文明思辨,信息密度高,强调设定自洽。",
408
+ category: "科幻流",
409
+ applicableGenres: [
410
+ "科幻",
411
+ "星际",
412
+ "赛博朋克",
413
+ "机甲"
414
+ ],
415
+ proseRules: [
416
+ "科技设定要有逻辑自洽的原理,不做\"黑箱\"解释。",
417
+ "场景单元按「问题→技术分析→方案→实施→后果」推进。",
418
+ "技术服务于剧情和人物,不为炫技而炫技。"
419
+ ],
420
+ dialogueRules: [
421
+ "专业对话有术语和逻辑,但要让非专业读者能理解核心。",
422
+ "角色争论要有技术依据,不做情绪化争吵。",
423
+ "避免角色直接解说设定,通过问题和讨论揭示。"
424
+ ],
425
+ languageRules: ["精确、理性的语言,避免模糊和情绪化表达。", "技术描述要具体且可想象,不堆砌名词。"],
426
+ rhythmRules: ["中慢节奏,技术推演段可以慢,但行动段要快。", "每5章一个技术突破或危机解决。"],
427
+ defaultAntiAiRuleKeys: [
428
+ "禁止直接说教",
429
+ "禁止总结主题",
430
+ "连续三段解释性叙事"
431
+ ]
202
432
  }
203
433
  ];
204
434
  /** 内置全局反 AI 规则(来自 AI-Novel-Writing-Assistant 内置 DEFAULT_ANTI_AI_RULES)。 */
@@ -326,6 +556,61 @@ const BUILTIN_ANTI_AI_RULES = [
326
556
  fix: "给角色补一句嘴硬找补或自我合理化,保持人设温度。",
327
557
  detectPatterns: [],
328
558
  builtin: true
559
+ },
560
+ {
561
+ name: "禁止心中暗道/脑海浮现",
562
+ avoid: "用\"心中暗道\"\"脑海中\"\"心里想\"\"暗自思忖\"等句式直接暴露角色内心。",
563
+ fix: "把内心活动改成动作、表情、语气或行为结果,让读者自己推断。",
564
+ detectPatterns: [
565
+ "心中暗道",
566
+ "脑海中",
567
+ "心里想",
568
+ "暗自思忖",
569
+ "心中暗想",
570
+ "心里暗道"
571
+ ],
572
+ builtin: true
573
+ },
574
+ {
575
+ name: "禁止仿佛/好像比喻滥用",
576
+ avoid: "单章\"仿佛\"\"好像\"\"犹如\"\"宛如\"等比喻词超过5次,产生AI套路感。",
577
+ fix: "减少比喻频率,每个比喻必须是新造的、有具体画面的,不用陈词滥调。",
578
+ detectPatterns: [
579
+ "仿佛",
580
+ "好像",
581
+ "犹如",
582
+ "宛如",
583
+ "好似"
584
+ ],
585
+ builtin: true
586
+ },
587
+ {
588
+ name: "禁止过度排比堆砌",
589
+ avoid: "连续3句以上相同句式或排比结构,显得机械和刻意。",
590
+ fix: "打散句式,用长短句交替,排比不超过2句。",
591
+ detectPatterns: [],
592
+ builtin: true
593
+ },
594
+ {
595
+ name: "禁止角色全知泄露",
596
+ avoid: "角色说出或知道他/她不可能知道的信息,破坏视角一致性。",
597
+ fix: "严格遵守视角限制,角色只能基于已有信息行动和判断。",
598
+ detectPatterns: [],
599
+ builtin: true
600
+ },
601
+ {
602
+ name: "鼓励环境细节锚定",
603
+ avoid: "(鼓励类)情绪和氛围缺少具体环境物件锚定,全靠抽象形容词。",
604
+ fix: "用具体物件、光线、声音、温度等环境细节承载情绪,不直接说\"紧张\"\"悲伤\"。",
605
+ detectPatterns: [],
606
+ builtin: true
607
+ },
608
+ {
609
+ name: "鼓励角色语言差异化",
610
+ avoid: "(鼓励类)所有角色说话风格雷同,没有口头禅、用词习惯或句式差异。",
611
+ fix: "给每个主要角色设定独特的语言习惯(口头禅/用词偏好/句式长短),对话时严格区分。",
612
+ detectPatterns: [],
613
+ builtin: true
329
614
  }
330
615
  ];
331
616
  /** 内置题材基底库(常用网文题材树,跨书复用)。 */
@@ -439,6 +724,224 @@ const BUILTIN_GENRE_LIBRARY = [
439
724
  description: "末世异变中觉醒能力不断进化,强调战力成长与危机求生。",
440
725
  children: []
441
726
  }]
727
+ },
728
+ {
729
+ name: "都市生活",
730
+ description: "现代都市日常背景,无超能力或超能力为辅,读者期待身份反差、职场逆袭与生活烟火气。",
731
+ children: [
732
+ {
733
+ name: "赘婿逆袭",
734
+ description: "开局隐忍赘婿,展露真实身份后一路打脸,强调身份反差与爽感。",
735
+ children: []
736
+ },
737
+ {
738
+ name: "神医归来",
739
+ description: "医术高超的主角回归都市,治病救人积累人脉,强调专业碾压与感恩回馈。",
740
+ children: []
741
+ },
742
+ {
743
+ name: "兵王归隐",
744
+ description: "退役兵王/特工回归都市,低调行事却屡被招惹,强调武力碾压与守护。",
745
+ children: []
746
+ },
747
+ {
748
+ name: "奶爸日常",
749
+ description: "主角带娃生活,温馨治愈与成长并行,强调亲子互动与生活细节。",
750
+ children: []
751
+ },
752
+ {
753
+ name: "校园青春",
754
+ description: "校园背景的成长与恋爱,强调青涩感、友情与梦想。",
755
+ children: []
756
+ },
757
+ {
758
+ name: "鉴宝捡漏",
759
+ description: "古玩/收藏/拍卖背景,主角凭眼力捡漏暴富,强调专业知识与反差爽感。",
760
+ children: []
761
+ }
762
+ ]
763
+ },
764
+ {
765
+ name: "言情",
766
+ description: "以情感关系为核心,读者期待心动、拉扯、甜虐交织与情感归宿。",
767
+ children: [
768
+ {
769
+ name: "现言甜宠",
770
+ description: "现代背景甜蜜恋爱,男主强势专一,强调撒糖与日常互动。",
771
+ children: []
772
+ },
773
+ {
774
+ name: "古言宫斗",
775
+ description: "古代后宫/宅斗背景,女主在权谋中求生与上位,强调心机与反转。",
776
+ children: []
777
+ },
778
+ {
779
+ name: "虐恋重生",
780
+ description: "前世被虐重生后改写命运,爱恨交织,强调复仇与情感救赎。",
781
+ children: []
782
+ },
783
+ {
784
+ name: "快穿女配",
785
+ description: "穿梭不同世界完成任务,女主逆袭原剧情,强调多变设定与成长。",
786
+ children: []
787
+ },
788
+ {
789
+ name: "年代文",
790
+ description: "七八十年代背景,家长里短与发家致富,强调时代质感与生活细节。",
791
+ children: []
792
+ }
793
+ ]
794
+ },
795
+ {
796
+ name: "游戏竞技",
797
+ description: "以游戏或体育竞技为舞台,读者期待操作碾压、战术博弈与冠军荣耀。",
798
+ children: [
799
+ {
800
+ name: "电竞荣耀",
801
+ description: "职业电竞选手成长,强调操作细节、团队配合与赛事热血。",
802
+ children: []
803
+ },
804
+ {
805
+ name: "网游重生",
806
+ description: "重生回游戏开服前,凭先知优势抢占资源,强调攻略与碾压。",
807
+ children: []
808
+ },
809
+ {
810
+ name: "体育竞技",
811
+ description: "篮球/足球/赛车等体育项目,强调训练成长、比赛逆转与体育精神。",
812
+ children: []
813
+ },
814
+ {
815
+ name: "卡牌桌游",
816
+ description: "卡牌/桌游/战棋背景,强调策略构筑与阵容搭配。",
817
+ children: []
818
+ }
819
+ ]
820
+ },
821
+ {
822
+ name: "二次元轻小说",
823
+ description: "ACG风格叙事,节奏轻快,设定新奇,读者期待脑洞展开与角色萌点。",
824
+ children: [
825
+ {
826
+ name: "异世界穿越",
827
+ description: "穿越到异世界获得能力/身份,强调新奇设定与冒险展开。",
828
+ children: []
829
+ },
830
+ {
831
+ name: "系统流",
832
+ description: "主角获得系统辅助成长,任务/奖励驱动剧情,强调数值与反馈。",
833
+ children: []
834
+ },
835
+ {
836
+ name: "搞笑日常",
837
+ description: "以吐槽和反差制造笑点,角色个性鲜明,强调轻松愉快。",
838
+ children: []
839
+ },
840
+ {
841
+ name: "反派转生",
842
+ description: "转生成游戏/小说中的反派角色,利用剧情认知规避死亡结局,强调反转与谋略。",
843
+ children: []
844
+ }
845
+ ]
846
+ },
847
+ {
848
+ name: "武侠",
849
+ description: "江湖侠义背景,武功秘籍、门派恩怨、家国情怀,读者期待侠气与江湖质感。",
850
+ children: [
851
+ {
852
+ name: "传统武侠",
853
+ description: "金庸/古龙风格,江湖恩怨与侠义精神,强调武功描写与人情世故。",
854
+ children: []
855
+ },
856
+ {
857
+ name: "江湖恩怨",
858
+ description: "门派/家族/帮会争斗,主角在江湖中成长与抉择,强调义气与复仇。",
859
+ children: []
860
+ },
861
+ {
862
+ name: "庙堂江湖",
863
+ description: "朝堂与江湖交织,武侠与权谋结合,强调格局与抉择。",
864
+ children: []
865
+ }
866
+ ]
867
+ },
868
+ {
869
+ name: "现实题材",
870
+ description: "贴近现实生活的行业/职场故事,读者期待专业质感、人性刻画与现实共鸣。",
871
+ children: [
872
+ {
873
+ name: "职场商战",
874
+ description: "商场/职场博弈,主角从底层崛起,强调商业谋略与人际周旋。",
875
+ children: []
876
+ },
877
+ {
878
+ name: "官场沉浮",
879
+ description: "体制内升迁与抉择,强调政治智慧与现实质感。",
880
+ children: []
881
+ },
882
+ {
883
+ name: "医疗行业",
884
+ description: "医院/医生视角,治病救人与行业生态,强调专业与人性。",
885
+ children: []
886
+ },
887
+ {
888
+ name: "法律政律",
889
+ description: "律师/法官视角,案件辩护与司法博弈,强调逻辑与正义。",
890
+ children: []
891
+ }
892
+ ]
893
+ },
894
+ {
895
+ name: "恐怖灵异",
896
+ description: "超自然/惊悚背景,读者期待氛围营造、悬念反转与心理恐惧。",
897
+ children: [
898
+ {
899
+ name: "灵异惊悚",
900
+ description: "鬼魂/诅咒/灵异事件,强调氛围营造与心理恐惧。",
901
+ children: []
902
+ },
903
+ {
904
+ name: "克苏鲁",
905
+ description: "不可名状的未知存在与疯狂,强调悬疑与绝望感。",
906
+ children: []
907
+ },
908
+ {
909
+ name: "民俗诡异",
910
+ description: "中国民间习俗/禁忌/传说,强调地域文化与诡异氛围。",
911
+ children: []
912
+ },
913
+ {
914
+ name: "无限恐怖",
915
+ description: "穿梭恐怖副本求生,强调规则破解与团队协作。",
916
+ children: []
917
+ }
918
+ ]
919
+ },
920
+ {
921
+ name: "科幻",
922
+ description: "未来/太空/科技背景,读者期待宏大设定、技术想象与文明思辨。",
923
+ children: [
924
+ {
925
+ name: "星际文明",
926
+ description: "太空探索与文明碰撞,强调宇宙尺度与科技发展。",
927
+ children: []
928
+ },
929
+ {
930
+ name: "机甲战争",
931
+ description: "机甲驾驶与星际战争,强调战斗场面与军人成长。",
932
+ children: []
933
+ },
934
+ {
935
+ name: "赛博朋克",
936
+ description: "高科技低生活的近未来,义体/黑客/大企业,强调反差与反叛。",
937
+ children: []
938
+ },
939
+ {
940
+ name: "时间穿梭",
941
+ description: "时间旅行/平行世界,强调因果逻辑与命运抉择。",
942
+ children: []
943
+ }
944
+ ]
442
945
  }
443
946
  ];
444
947
  /** 内置常用推进模式。 */
@@ -694,12 +1197,25 @@ function effectiveAntiAiRules(assets) {
694
1197
  const customNames = new Set(custom.map((r) => r.name));
695
1198
  return [...BUILTIN_ANTI_AI_RULES.filter((r) => !customNames.has(r.name)), ...custom];
696
1199
  }
697
- /** 把生效规则渲染成提示词块(压缩:avoid/fix 截断,省 token)。 */
1200
+ /** 把生效规则渲染成提示词块(禁止/鼓励分列,压缩省 token)。 */
698
1201
  function renderAntiAiRules(assets) {
699
- const rules = effectiveAntiAiRules(assets);
1202
+ const rules = effectiveAntiAiRules(assets).filter((r) => r.enabled !== false);
700
1203
  if (rules.length === 0) return "";
701
1204
  const clip = (value, max) => value.length > max ? value.slice(0, max) + "…" : value;
702
- return ["==================== 反 AI 规则(写作时必须遵守的表达边界) ====================", ...rules.map((r) => `- ${r.name}:避免——${clip(r.avoid, 90)}${r.fix !== "" ? `;修正——${clip(r.fix, 50)}` : ""}`)].join("\n");
1205
+ const isEncourage = (r) => r.severity === "encourage" || r.name.startsWith("鼓励") || r.avoid.startsWith("(鼓励类)");
1206
+ const forbidden = rules.filter((r) => !isEncourage(r));
1207
+ const encourage = rules.filter(isEncourage);
1208
+ const lines = [];
1209
+ lines.push("==================== 反 AI 规则(写作时必须遵守的表达边界) ====================");
1210
+ if (forbidden.length > 0) {
1211
+ lines.push("禁止类(命中即问题,审稿时列为 high/medium):");
1212
+ for (const r of forbidden) lines.push(`- ${r.name}:避免——${clip(r.avoid, 90)}${r.fix !== "" ? `;修正——${clip(r.fix, 50)}` : ""}`);
1213
+ }
1214
+ if (encourage.length > 0) {
1215
+ lines.push("鼓励类(希望出现,不命中不算错,审稿时只作低优先级建议、不阻塞通过):");
1216
+ for (const r of encourage) lines.push(`- ${r.name}:${clip(r.fix !== "" ? r.fix : r.avoid, 90)}`);
1217
+ }
1218
+ return lines.join("\n");
703
1219
  }
704
1220
  /** 渲染题材与推进模式提示词块。 */
705
1221
  function renderGenreAndProgression(assets) {
@@ -766,121 +1282,171 @@ function styleEngineSystemPrompt() {
766
1282
  ].join("\n");
767
1283
  }
768
1284
  //#endregion
769
- //#region src/comic-presets.ts
770
- const COMIC_STYLE_PRESETS = [
771
- {
772
- id: "japan",
773
- label: "日漫 / 黑白",
774
- prompt: "manga style, screentone, bold ink lines, high contrast, black and white, cel shading",
775
- layoutHint: "页漫优先"
776
- },
777
- {
778
- id: "korea",
779
- label: "韩漫 / 条漫",
780
- prompt: "manhwa style, soft cel shading, vibrant colors, clean lineart, webtoon vertical composition, glossy highlights",
781
- layoutHint: "条漫优先"
782
- },
783
- {
784
- id: "china",
785
- label: "国漫 / 古风",
786
- prompt: "chinese ink painting style, flowing brush strokes, traditional palette, ethereal atmosphere, xianxia aesthetic",
787
- layoutHint: "页漫/条漫均可"
1285
+ //#region src/manga-genre-rules.ts
1286
+ /** 内置题材(通用题材 default 为空规则)。 */
1287
+ const GENRES = [
1288
+ {
1289
+ id: "xuanhuan",
1290
+ label: "玄幻 / 仙侠",
1291
+ rules: [
1292
+ "施法必须经法宝媒介(笛/扇/印/笔等实体法宝),写清法宝本体材质+施法动作+特效出口,禁止只写\"施法\"。",
1293
+ "每个角色绑定主属性+双色粒子(雷=蓝白+紫电、冰=冰蓝+银白、金=亮金+暖白、火=赤红+暗金、木=粉白+草木青),属性决定术法形态与配色。",
1294
+ "术法攻防走\"罡气→破罡→防御转攻\":护体罡气被划开、防御术法炸裂化千万飞刃/剑气,防守转攻是高光点,必写。",
1295
+ "能量具象化:光柱/雷龙/法相/领域;\"光柱\"要写成\"洪流/弧光/波纹/光丝雨\"(禁裸光柱,防呆板发光管)。",
1296
+ "终招蓄力必写:天地灵韵向角色/法宝汇聚、能量漩涡成型,再接幂击。",
1297
+ "对军清场:扇形冲击波/剑气暴雨洗地/能量扇镇压大军核心,战场弹坑/碎片/云层撕开反馈。"
1298
+ ]
788
1299
  },
789
1300
  {
790
- id: "america",
791
- label: "美漫 / 超级英雄",
792
- prompt: "american comic style, bold outlines, dramatic lighting, dynamic poses, vibrant colors, halftone shading",
793
- layoutHint: "页漫优先"
1301
+ id: "wuxia",
1302
+ label: "武侠 / 国风",
1303
+ rules: [
1304
+ "打斗用 Hit-Stop 三件套:碰撞瞬间 1~2 帧微停顿 + 接触点火花 + 双刃咬死高频震颤,杜绝滑步/空气假打。",
1305
+ "武术动作链:起手/攻防/收势,标明具体招式(刀/剑/轻功/擒拿),剑走轻灵、刀走厚重。",
1306
+ "镜头强化打击感:碰撞点微环绕、贴身横移、命中定格,禁静止对峙。",
1307
+ "轻功/身法:衣袂翻飞、发丝飘动、残影,身形轻盈不僵硬。"
1308
+ ]
794
1309
  },
795
1310
  {
796
- id: "chibi",
797
- label: "Q版 / 搞笑",
798
- prompt: "chibi style, cute proportions, big head, exaggerated expressions, simple background, bright colors",
799
- layoutHint: "条漫/四格优先"
1311
+ id: "anime",
1312
+ label: "二次元 / 动漫",
1313
+ rules: [
1314
+ "大招三段式:蓄力→释放→爆发,能量形态+爆发特效,段段画质声明复用。",
1315
+ "双色粒子:每角色一套主色+材质+轨迹,双方互补对冲(冷 vs 暖),防混战混淆。",
1316
+ "色指定:明确主色调+高光色+特效撞色,防发灰。",
1317
+ "禁止评价词/裸光效/空泛\"粒子\";能量写\"光丝雨/弧光/波纹\"+方向+材质。"
1318
+ ]
800
1319
  },
801
1320
  {
802
- id: "realistic",
803
- label: "写实 / 电影感",
804
- prompt: "realistic cinematic style, detailed textures, natural lighting, film grain, high detail",
805
- layoutHint: "页漫/全彩"
1321
+ id: "urban",
1322
+ label: "都市 / 现实",
1323
+ rules: [
1324
+ "微表情生理递进:眼神/眉心/唇部/鼻翼/喉头/手指,情绪逐层推进,不夸张不脸谱。",
1325
+ "对白语速 3.5~5 字/秒(台词从声音层按此把控,留 0.3~0.8s 落口发酵)。",
1326
+ "防棚拍假冷光:用真实光线(自然光/路灯/窗光/霓虹),日景不陰间冷蓝、局部留暖光。",
1327
+ "场景真实还原(街头/室内/医院等),人物服装生活化,肢体动作自然不律動化。"
1328
+ ]
806
1329
  },
807
1330
  {
808
- id: "cyber",
809
- label: "赛博 / 暗黑",
810
- prompt: "cyberpunk style, neon lights, dark atmosphere, high contrast, futuristic city, cool tones",
811
- layoutHint: "页漫/全彩"
1331
+ id: "scifi",
1332
+ label: "科幻 / 未来",
1333
+ rules: [
1334
+ "机甲/能量武器设定明确(颜色/材质/发光部位),能量轨迹具体。",
1335
+ "霓虹/屏幕光/激光防眩光,用冷色主调+重点发光处。",
1336
+ "宏大场景(飞船/城市/废墟)用大远景/俯拍,人物相对渺小。"
1337
+ ]
812
1338
  }
813
1339
  ];
814
- function getComicStylePrompt(styleId) {
815
- if (!styleId) return "";
816
- return COMIC_STYLE_PRESETS.find((s) => s.id === styleId)?.prompt ?? "";
1340
+ /** 通用题材(空规则,走通用生成)。 */
1341
+ const DEFAULT_GENRE = {
1342
+ id: "default",
1343
+ label: "通用(不限定)",
1344
+ rules: []
1345
+ };
1346
+ /** 按题材 id 取规则(未找到返回通用)。 */
1347
+ function getGenreRules(id) {
1348
+ if (id === void 0 || id === "") return DEFAULT_GENRE.rules;
1349
+ const g = GENRES.find((x) => x.id === id);
1350
+ return g !== void 0 ? g.rules : DEFAULT_GENRE.rules;
817
1351
  }
818
1352
  //#endregion
819
1353
  //#region src/style-library.ts
820
- /** 内置风格模板(18 个)。 */
1354
+ /** 内置风格模板(基底 21 个 + 滤镜 3 个)。 */
821
1355
  const STYLE_LIBRARY = [
1356
+ {
1357
+ id: "hyperreal-3d",
1358
+ name: "超写实仿真人 3D",
1359
+ category: "3d",
1360
+ traits: "红果2026重点扶持,真人肤质+电影级渲染,国产网剧质感,分成最高",
1361
+ keywords: "超写实3D仿真人,真人肤质纹理,电影级渲染,国产网剧质感,细腻皮肤,真实毛发,8K高清,五官清晰面部稳定,人体结构正常,真实材质",
1362
+ weight: 1
1363
+ },
1364
+ {
1365
+ id: "guoman-3d-xuanhuan",
1366
+ name: "3D 国漫玄幻",
1367
+ category: "3d",
1368
+ traits: "红果主力形态,仙侠建模+东方美学,男性向修仙逆袭爆款画风",
1369
+ keywords: "3D国漫玄幻风格,仙侠建模,东方美学,精致古风服饰,特效氛围感拉满,硬朗立体光影,高饱和色彩,8K超精细建模,五官清晰面部稳定,真实材质",
1370
+ weight: 1
1371
+ },
1372
+ {
1373
+ id: "guoman-3d-tianyuan",
1374
+ name: "3D 古风田园治愈",
1375
+ category: "3d",
1376
+ traits: "当下大火慢节奏长线,开荒经营/美食创业/市井烟火,清新治愈",
1377
+ keywords: "3D古风田园,清新治愈画风,暖色调,市井烟火气,美食场景细节,柔和自然光,温暖通透,8K高清,五官清晰面部稳定,真实材质",
1378
+ weight: .9
1379
+ },
1380
+ {
1381
+ id: "urban-lightreal-3d",
1382
+ name: "现代都市轻写实 3D",
1383
+ category: "3d",
1384
+ traits: "都市言情/职场/甜宠通用,真实街景+时尚穿搭,冷暖对比光影",
1385
+ keywords: "3D现代都市轻写实,都市夜景,时尚穿搭,真实街景,冷暖对比光影,生活质感,8K高清,五官清晰面部稳定,真实材质纹理",
1386
+ weight: .9
1387
+ },
822
1388
  {
823
1389
  id: "pixar-adult-3d",
824
1390
  name: "成人向皮克斯 3D",
825
1391
  category: "3d",
826
1392
  traits: "CG 三维渲染,材质细腻,微表情表现力强;需压制 Q 版可爱感",
827
- keywords: "3D皮克斯动画,成人写实CGI,电影光影,拒绝Q版",
828
- weight: 1
1393
+ keywords: "3D皮克斯动画,成人写实CGI,电影光影,拒绝Q版,8K超精细建模,五官清晰面部稳定,真实材质纹理",
1394
+ weight: .85
829
1395
  },
830
1396
  {
831
1397
  id: "arcane-thick-3d",
832
1398
  name: "双城之战美漫厚涂 3D",
833
1399
  category: "3d",
834
1400
  traits: "粗重轮廓线,高对比硬阴影,材质粗粝,悲剧、压抑、科幻题材适配高",
835
- keywords: "双城之战美术,3D美漫厚涂,强轮廓光影,暗调粗粝渲染",
836
- weight: .95
1401
+ keywords: "双城之战美术,3D美漫厚涂,强轮廓光影,暗调粗粝渲染,8K高清,五官清晰面部稳定,真实材质",
1402
+ weight: .8
837
1403
  },
838
1404
  {
839
1405
  id: "nextgen-cg",
840
1406
  name: "次世代游戏 CG",
841
1407
  category: "3d",
842
1408
  traits: "接近真人的三维渲染,照片级布料、皮肤材质,冷峻写实",
843
- keywords: "次世代游戏CG渲染,超写实3D,真实物理材质,电影布光",
844
- weight: .9
1409
+ keywords: "次世代游戏CG渲染,超写实3D,真实物理材质,电影布光,8K高清,五官清晰面部稳定",
1410
+ weight: .8
845
1411
  },
846
1412
  {
847
1413
  id: "low-cyberpunk-3d",
848
1414
  name: "底层赛博朋克 3D",
849
1415
  category: "3d",
850
1416
  traits: "锈蚀工业、破碎霓虹、屏幕噪点;避开华丽高楼,偏向破败底层",
851
- keywords: "底层赛博朋克3D,废土工业,破碎霓虹,画面噪点色散",
852
- weight: .85
1417
+ keywords: "底层赛博朋克3D,废土工业,破碎霓虹,画面噪点色散,8K高清,五官清晰面部稳定,真实材质",
1418
+ weight: .75
853
1419
  },
854
1420
  {
855
1421
  id: "vaporwave-3d",
856
1422
  name: "复古蒸汽波 3D",
857
1423
  category: "3d",
858
1424
  traits: "粉-蓝-紫霓虹轮廓、90 年代复古网格、胶片色散颗粒,颓废疏离",
859
- keywords: "3D复古蒸汽波,90年代三维渲染,霓虹轮廓光,胶片颗粒",
860
- weight: .8
1425
+ keywords: "3D复古蒸汽波,90年代三维渲染,霓虹轮廓光,胶片颗粒,8K高清,五官清晰面部稳定",
1426
+ weight: .7
861
1427
  },
862
1428
  {
863
1429
  id: "dark-gothic-3d",
864
1430
  name: "暗黑哥特 3D",
865
1431
  category: "3d",
866
1432
  traits: "大面积阴影、冷青色调、衰败破败质感,适合幽暗密闭空间",
867
- keywords: "3D暗黑哥特,冷青暗光,衰败质感,大面积阴影",
868
- weight: .8
1433
+ keywords: "3D暗黑哥特,冷青暗光,衰败质感,大面积阴影,8K高清,五官清晰面部稳定,真实材质",
1434
+ weight: .7
869
1435
  },
870
1436
  {
871
1437
  id: "clay-stopmotion-3d",
872
1438
  name: "黏土 / 定格 3D",
873
1439
  category: "3d",
874
1440
  traits: "捏塑颗粒肌理,手工定格质感;可做压抑向,不局限可爱风",
875
- keywords: "3D黏土定格渲染,泥塑肌理,电影打光",
876
- weight: .7
1441
+ keywords: "3D黏土定格渲染,泥塑肌理,电影打光,8K高清,五官清晰",
1442
+ weight: .6
877
1443
  },
878
1444
  {
879
1445
  id: "openworld-survival",
880
1446
  name: "开放世界生存游戏风",
881
1447
  category: "game",
882
- traits: "场景破败感强,货架、仓库、管线、灰尘、锈蚀明显;人物带一点“生存者”气质;适合探索感、逃亡感、世界观展示",
883
- keywords: "开放世界生存游戏风格,废土场景,破败仓库,锈蚀金属货架,灰尘粒子,冷色写实光影",
1448
+ traits: "场景破败感强,货架、仓库、管线、灰尘、锈蚀明显;人物带一点\"生存者\"气质;适合探索感、逃亡感、世界观展示",
1449
+ keywords: "开放世界生存游戏风格,废土场景,破败仓库,锈蚀金属货架,灰尘粒子,冷色写实光影,8K高清,五官清晰面部稳定,真实材质",
884
1450
  weight: .8
885
1451
  },
886
1452
  {
@@ -888,7 +1454,7 @@ const STYLE_LIBRARY = [
888
1454
  name: "恐怖游戏 3D",
889
1455
  category: "game",
890
1456
  traits: "大面积阴影,荧光灯频闪,空间压抑;适合悬疑、压迫、精神异常段落",
891
- keywords: "恐怖游戏 3D 风格,昏暗仓库,老旧荧光灯,硬阴影,压迫空间,冷灰蓝调,低饱和",
1457
+ keywords: "恐怖游戏 3D 风格,昏暗仓库,老旧荧光灯,硬阴影,压迫空间,冷灰蓝调,低饱和,8K高清,五官清晰面部稳定",
892
1458
  weight: .75
893
1459
  },
894
1460
  {
@@ -896,48 +1462,64 @@ const STYLE_LIBRARY = [
896
1462
  name: "战术潜行游戏风",
897
1463
  category: "game",
898
1464
  traits: "低视角、第三人称镜头感;人物在货架之间移动,光影克制;适合紧张剧情",
899
- keywords: "战术潜行游戏视角,第三人称镜头,货架通道,冷色暗光,真实3D渲染,紧张压迫氛围",
1465
+ keywords: "战术潜行游戏视角,第三人称镜头,货架通道,冷色暗光,真实3D渲染,紧张压迫氛围,8K高清,五官清晰面部稳定,真实材质",
900
1466
  weight: .7
901
1467
  },
1468
+ {
1469
+ id: "guoman-2d-gufeng",
1470
+ name: "2D 国漫古风",
1471
+ category: "2d",
1472
+ traits: "国产2D主流,古言/仙侠/重生虐恋女性向爆款,精致线条+东方色彩",
1473
+ keywords: "2D国漫古风,精致线条,东方色彩美学,仙侠氛围,飘逸服饰,唯美意境,干净勾线,8K高清,五官清晰",
1474
+ weight: 1
1475
+ },
902
1476
  {
903
1477
  id: "korean-webtoon",
904
1478
  name: "韩漫条漫风",
905
1479
  category: "2d",
906
1480
  traits: "修长人物,低饱和高级灰,氛围感强,都市向漫剧爆款画风",
907
- keywords: "韩漫2D绘画,精致人物,低饱和灰调,氛围感插画",
908
- weight: 1
1481
+ keywords: "韩漫2D绘画,精致人物,低饱和灰调,氛围感插画,8K高清,五官清晰,干净线条",
1482
+ weight: .9
909
1483
  },
910
1484
  {
911
1485
  id: "japan-cel-dark",
912
1486
  name: "日系赛璐璐动画",
913
1487
  category: "2d",
914
1488
  traits: "干净勾线,硬阴影;分明亮版、暗调悲剧版(边缘行者质感)",
915
- keywords: "日系赛璐璐动画,清晰轮廓线,硬阴影,暗调都市动画",
916
- weight: .9
1489
+ keywords: "日系赛璐璐动画,清晰轮廓线,硬阴影,暗调都市动画,8K高清,五官清晰",
1490
+ weight: .85
917
1491
  },
918
1492
  {
919
1493
  id: "shinkai-anime",
920
1494
  name: "新海诚动画风",
921
1495
  category: "2d",
922
1496
  traits: "极致环境光影、丁达尔、空气中尘埃粒子,擅长雨夜、夜景外景",
923
- keywords: "新海诚动画渲染,细腻环境光,漂浮尘埃粒子",
924
- weight: .85
1497
+ keywords: "新海诚动画渲染,细腻环境光,漂浮尘埃粒子,8K高清,五官清晰,干净线条",
1498
+ weight: .8
925
1499
  },
926
1500
  {
927
1501
  id: "pop-marvel-2d",
928
1502
  name: "波普美漫",
929
1503
  category: "2d",
930
1504
  traits: "网点半调、强撞色,线条张扬,节奏冲击力强",
931
- keywords: "平行宇宙波普美漫,网点半调,漫画粗线条",
932
- weight: .8
1505
+ keywords: "平行宇宙波普美漫,网点半调,漫画粗线条,8K高清,五官清晰",
1506
+ weight: .75
933
1507
  },
934
1508
  {
935
1509
  id: "dark-ghibli",
936
1510
  name: "暗黑吉卜力手绘",
937
1511
  category: "2d",
938
1512
  traits: "手绘水彩肌理;强制关闭温暖治愈,做残酷冷调版本",
939
- keywords: "吉卜力手绘动画,暗黑写实,水彩肌理,拒绝暖光治愈",
940
- weight: .75
1513
+ keywords: "吉卜力手绘动画,暗黑写实,水彩肌理,拒绝暖光治愈,8K高清,五官清晰,干净线条",
1514
+ weight: .7
1515
+ },
1516
+ {
1517
+ id: "sandiao-qban",
1518
+ name: "沙雕 Q 版搞笑",
1519
+ category: "2d",
1520
+ traits: "表情包动态漫主流,低成本高产量,夸张表情+大头小身,搞笑解压",
1521
+ keywords: "Q版卡通沙雕风格,夸张表情,大头小身,简洁线条,明亮色块,搞笑解压,扁平化设计,8K高清",
1522
+ weight: .7
941
1523
  },
942
1524
  {
943
1525
  id: "noir-film",
@@ -971,7 +1553,7 @@ const STYLE_LIBRARY = [
971
1553
  name: "新中式水墨动画",
972
1554
  category: "craft",
973
1555
  traits: "水墨晕染,留白写意",
974
- keywords: "水墨动画渲染,写意晕染,国风2D",
1556
+ keywords: "水墨动画渲染,写意晕染,国风2D,8K高清,五官清晰,干净线条",
975
1557
  weight: 1
976
1558
  },
977
1559
  {
@@ -979,7 +1561,7 @@ const STYLE_LIBRARY = [
979
1561
  name: "纸雕定格",
980
1562
  category: "craft",
981
1563
  traits: "多层纸张镂空叠层,光影穿透纸面",
982
- keywords: "立体纸雕定格,纸张肌理,层叠镂空光影",
1564
+ keywords: "立体纸雕定格,纸张肌理,层叠镂空光影,8K高清,五官清晰",
983
1565
  weight: .9
984
1566
  },
985
1567
  {
@@ -987,7 +1569,7 @@ const STYLE_LIBRARY = [
987
1569
  name: "工笔重彩国风",
988
1570
  category: "craft",
989
1571
  traits: "精细线条,矿物颜料质感,古典华美",
990
- keywords: "工笔重彩,国风2D绘画,矿物颜料质感,精细勾线",
1572
+ keywords: "工笔重彩,国风2D绘画,矿物颜料质感,精细勾线,8K高清,五官清晰",
991
1573
  weight: .8
992
1574
  }
993
1575
  ];
@@ -1005,6 +1587,283 @@ function styleKeywords(styleId, filterId) {
1005
1587
  return parts.join(",") || "3D动漫,超精细建模,电影光影";
1006
1588
  }
1007
1589
  //#endregion
1590
+ //#region src/ai-scan.ts
1591
+ /** 重灾区套话(高频出现即问题) */
1592
+ const HEAVY_CLICHES = [
1593
+ "不禁",
1594
+ "仿佛",
1595
+ "一时间",
1596
+ "不由得",
1597
+ "顿时",
1598
+ "然而",
1599
+ "缓缓",
1600
+ "轻轻",
1601
+ "微微",
1602
+ "默默",
1603
+ "似乎",
1604
+ "终于",
1605
+ "显然",
1606
+ "其实",
1607
+ "无法形容",
1608
+ "难以言喻",
1609
+ "不由自主"
1610
+ ];
1611
+ /** 轻微套话(偶尔出现可接受,高频才问题) */
1612
+ const LIGHT_CLICHES = [
1613
+ "心中",
1614
+ "脑海",
1615
+ "眼神",
1616
+ "嘴角",
1617
+ "眉头",
1618
+ "身影",
1619
+ "气息",
1620
+ "光芒",
1621
+ "力量",
1622
+ "感觉",
1623
+ "知道",
1624
+ "明白"
1625
+ ];
1626
+ /** 解释性叙事开头模式 */
1627
+ const EXPOSITORY_STARTS = [
1628
+ "原来",
1629
+ "因为",
1630
+ "由于",
1631
+ "所以",
1632
+ "因此",
1633
+ "于是",
1634
+ "这就是",
1635
+ "也就是说",
1636
+ "换句话说",
1637
+ "事实上",
1638
+ "实际上"
1639
+ ];
1640
+ function scanAiFlavor(text) {
1641
+ const paragraphs = text.split(/\n{2,}/).map((p) => p.trim()).filter((p) => p.length > 0);
1642
+ const totalChars = text.length;
1643
+ const clicheHits = [];
1644
+ let clicheTotal = 0;
1645
+ for (const word of [...HEAVY_CLICHES, ...LIGHT_CLICHES]) {
1646
+ const count = countOccurrences(text, word);
1647
+ if (count > 0) {
1648
+ clicheHits.push({
1649
+ word,
1650
+ count
1651
+ });
1652
+ clicheTotal += count;
1653
+ }
1654
+ }
1655
+ clicheHits.sort((a, b) => b.count - a.count);
1656
+ const paraLengths = paragraphs.map((p) => p.length);
1657
+ const avgLen = paraLengths.length > 0 ? paraLengths.reduce((a, b) => a + b, 0) / paraLengths.length : 0;
1658
+ const variance = paraLengths.length > 0 ? paraLengths.reduce((sum, len) => sum + Math.pow(len - avgLen, 2), 0) / paraLengths.length : 0;
1659
+ let maxConsecutive = 0;
1660
+ let currentConsecutive = 0;
1661
+ for (const p of paragraphs) if (EXPOSITORY_STARTS.some((s) => p.startsWith(s)) || p.length > 150 && !p.includes("\"") && !p.includes("「") && !p.includes("『")) {
1662
+ currentConsecutive++;
1663
+ maxConsecutive = Math.max(maxConsecutive, currentConsecutive);
1664
+ } else currentConsecutive = 0;
1665
+ const sentences = text.split(/[。!?!?]/).map((s) => s.trim()).filter((s) => s.length > 0);
1666
+ const starterCounts = /* @__PURE__ */ new Map();
1667
+ for (const s of sentences) {
1668
+ const starter = s.slice(0, 2);
1669
+ starterCounts.set(starter, (starterCounts.get(starter) ?? 0) + 1);
1670
+ }
1671
+ const repeatedStarters = Array.from(starterCounts.values()).filter((c) => c >= 3).reduce((a, b) => a + b, 0);
1672
+ const sentenceRepetitionRate = sentences.length > 0 ? repeatedStarters / sentences.length : 0;
1673
+ const longParagraphCount = paraLengths.filter((l) => l > 300).length;
1674
+ const shortParagraphCount = paraLengths.filter((l) => l < 20).length;
1675
+ const dialogueChars = (text.match(/["「『][^"」』]*["」』]/g) ?? []).join("").length;
1676
+ const dialogueRatio = totalChars > 0 ? dialogueChars / totalChars : 0;
1677
+ let aiScore = 0;
1678
+ const clicheDensity = totalChars > 0 ? clicheTotal / totalChars * 1e3 : 0;
1679
+ aiScore += Math.min(40, clicheDensity * 8);
1680
+ if (variance < 2e3 && paraLengths.length >= 5) aiScore += 15;
1681
+ aiScore += Math.min(20, maxConsecutive * 5);
1682
+ aiScore += Math.min(15, sentenceRepetitionRate * 30);
1683
+ if (dialogueRatio < .05 && totalChars > 1e3) aiScore += 10;
1684
+ aiScore = Math.min(100, Math.round(aiScore));
1685
+ const issues = [];
1686
+ if (clicheDensity > 3) {
1687
+ const topCliches = clicheHits.slice(0, 5).map((h) => `${h.word}×${h.count}`).join("、");
1688
+ issues.push(`套话密度偏高(每千字 ${clicheDensity.toFixed(1)} 次):${topCliches}`);
1689
+ }
1690
+ if (maxConsecutive >= 3) issues.push(`连续 ${maxConsecutive} 段解释性叙事,缺少对话/动作`);
1691
+ if (sentenceRepetitionRate > .15) issues.push(`句式重复率 ${(sentenceRepetitionRate * 100).toFixed(0)}%,开头句式单一`);
1692
+ if (longParagraphCount > 3) issues.push(`${longParagraphCount} 段超过 300 字,段落过长`);
1693
+ if (dialogueRatio < .05 && totalChars > 1e3) issues.push("对话占比过低,整章偏叙述");
1694
+ const summary = issues.length > 0 ? `本地 AI 味扫描(AI 味指数 ${aiScore}/100):\n` + issues.map((i) => `- ${i}`).join("\n") : `本地 AI 味扫描(AI 味指数 ${aiScore}/100):未发现明显问题。`;
1695
+ return {
1696
+ aiScore,
1697
+ clicheHits,
1698
+ paragraphLengthVariance: Math.round(variance),
1699
+ consecutiveExpositoryParagraphs: maxConsecutive,
1700
+ sentenceRepetitionRate: Math.round(sentenceRepetitionRate * 100) / 100,
1701
+ longParagraphCount,
1702
+ shortParagraphCount,
1703
+ dialogueRatio: Math.round(dialogueRatio * 100) / 100,
1704
+ summary
1705
+ };
1706
+ }
1707
+ function countOccurrences(text, word) {
1708
+ if (word.length === 0) return 0;
1709
+ let count = 0;
1710
+ let idx = text.indexOf(word);
1711
+ while (idx !== -1) {
1712
+ count++;
1713
+ idx = text.indexOf(word, idx + word.length);
1714
+ }
1715
+ return count;
1716
+ }
1717
+ //#endregion
1718
+ //#region src/novel-context.ts
1719
+ /**
1720
+ * 相关事实检索:trigram 重合度 + 角色名命中加权 + 近因加权
1721
+ * 从 generateChapterStream 抽取,扩展为通用函数。
1722
+ */
1723
+ function retrieveRelatedFacts(facts, beatsText, roleNames, limit = 15) {
1724
+ if (facts.length === 0) return [];
1725
+ const trigrams = (s) => {
1726
+ const out = /* @__PURE__ */ new Set();
1727
+ for (let i = 0; i + 3 <= s.length; i++) {
1728
+ const tri = s.slice(i, i + 3);
1729
+ if (tri.trim() !== "") out.add(tri);
1730
+ }
1731
+ return out;
1732
+ };
1733
+ const beatsTri = trigrams(beatsText);
1734
+ const beatRoles = roleNames.filter((n) => beatsText.includes(n));
1735
+ const recentTexts = new Set(facts.slice(-20).map((f) => f.text));
1736
+ return facts.map((f, idx) => {
1737
+ const head = f.text.slice(0, 80);
1738
+ let score = 0;
1739
+ for (const tri of trigrams(head)) if (beatsTri.has(tri)) score += 1;
1740
+ if (beatRoles.length > 0) {
1741
+ for (const n of beatRoles) if (head.includes(n)) score += 8;
1742
+ }
1743
+ score += Math.min(idx, 40) / 10;
1744
+ return {
1745
+ f,
1746
+ score
1747
+ };
1748
+ }).filter((x) => x.score >= 3).sort((a, b) => b.score - a.score).slice(0, limit).map((x) => `[第${x.f.chapterNo}章] ${x.f.text}`).filter((t) => !recentTexts.has(t.slice(t.indexOf("]") + 2)));
1749
+ }
1750
+ /**
1751
+ * 相关角色检索:beats 中出现的角色 + 主角/主要反派永远全量
1752
+ */
1753
+ function retrieveRelevantRoles(bible, roles, beatsText) {
1754
+ const result = [];
1755
+ const seen = /* @__PURE__ */ new Set();
1756
+ const alwaysRoles = (bible?.characters ?? []).filter((c) => c.role === "protagonist" || c.role === "antagonist");
1757
+ for (const card of alwaysRoles) {
1758
+ if (seen.has(card.name)) continue;
1759
+ seen.add(card.name);
1760
+ result.push({
1761
+ name: card.name,
1762
+ role: card.role,
1763
+ traits: card.traits,
1764
+ goals: card.goals !== "" ? card.goals : void 0,
1765
+ knowledge: Array.isArray(card.knowledge) && card.knowledge.length > 0 ? card.knowledge : void 0
1766
+ });
1767
+ }
1768
+ const beatRoles = (bible?.characters ?? []).filter((c) => beatsText.includes(c.name));
1769
+ for (const card of beatRoles) {
1770
+ if (seen.has(card.name)) continue;
1771
+ seen.add(card.name);
1772
+ result.push({
1773
+ name: card.name,
1774
+ role: card.role,
1775
+ traits: card.traits,
1776
+ goals: card.goals !== "" ? card.goals : void 0,
1777
+ knowledge: Array.isArray(card.knowledge) && card.knowledge.length > 0 ? card.knowledge : void 0
1778
+ });
1779
+ }
1780
+ for (const r of roles ?? []) {
1781
+ if (seen.has(r.name)) continue;
1782
+ if (!beatsText.includes(r.name) && r.roleLabel !== "protagonist" && r.roleLabel !== "antagonist") continue;
1783
+ seen.add(r.name);
1784
+ result.push({
1785
+ name: r.name,
1786
+ role: r.roleLabel,
1787
+ traits: Array.isArray(r.traits) ? r.traits : [],
1788
+ goals: r.goals !== void 0 && r.goals !== "" ? r.goals : void 0
1789
+ });
1790
+ }
1791
+ return result;
1792
+ }
1793
+ /**
1794
+ * 构建章节统一上下文
1795
+ *
1796
+ * 各阶段调用此函数获取同一来源的事实与设定,消除上下文断层。
1797
+ * 检索结果不足时自动回退全量,保证不丢失关键信息。
1798
+ */
1799
+ function buildChapterContext(project, chapter, outputDir, options = {}) {
1800
+ const { stage = "writing", fullRoleCards = true, relatedFactsLimit = 15, recentFactsLimit = 20 } = options;
1801
+ const bible = project.bible;
1802
+ const allFacts = project.facts ?? [];
1803
+ const roleNames = (project.roles ?? []).map((r) => r.name).filter((n) => typeof n === "string" && n !== "");
1804
+ let prevChapterTail = "";
1805
+ let prevChapterSummary;
1806
+ const prev = project.chapters.find((c) => c.no === chapter.no - 1);
1807
+ if (prev?.file !== void 0) try {
1808
+ const prevPath = join(outputDir, prev.file);
1809
+ if (existsSync(prevPath)) prevChapterTail = readFileSync(prevPath, "utf8").replace(/^#\s+.*$/m, "").trim().slice(-900);
1810
+ } catch {}
1811
+ prevChapterSummary = prev?.summary;
1812
+ const recentFacts = allFacts.slice(-recentFactsLimit).map((f) => `[第${f.chapterNo}章] ${f.text}`);
1813
+ const relatedFacts = retrieveRelatedFacts(allFacts, chapter.beats, roleNames, relatedFactsLimit);
1814
+ const relevantRoles = retrieveRelevantRoles(bible, project.roles, chapter.beats);
1815
+ const relevantWorldRules = bible?.worldRules ?? [];
1816
+ const activePlotlines = (project.plotlines ?? []).filter((l) => l.status === "active" || l.status === "paused");
1817
+ const activeForeshadows = (project.foreshadows ?? []).filter((f) => f.status === "planted" || f.status === "progressing");
1818
+ const volumeNo = chapter.volume ?? 0;
1819
+ const volume = project.volumes?.find((v) => v.no === volumeNo);
1820
+ const currentVolumeOutline = volume !== void 0 ? `第${volume.no}卷《${volume.title}》:${volume.summary}` : "";
1821
+ return {
1822
+ complianceRedLines: [],
1823
+ bookRedLines: bible?.redLines ?? [],
1824
+ relevantRoles: fullRoleCards ? relevantRoles : relevantRoles.map((r) => ({
1825
+ name: r.name,
1826
+ role: r.role,
1827
+ traits: []
1828
+ })),
1829
+ relevantWorldRules,
1830
+ activePlotlines,
1831
+ activeForeshadows,
1832
+ prevChapterTail,
1833
+ prevChapterSummary,
1834
+ recentFacts,
1835
+ relatedFacts,
1836
+ currentVolumeOutline,
1837
+ beats: chapter.beats,
1838
+ chapterTitle: chapter.title
1839
+ };
1840
+ }
1841
+ /**
1842
+ * 将上下文渲染为 prompt 文本块
1843
+ * 各阶段可按需选择注入哪些块。
1844
+ */
1845
+ function renderContextBlocks(ctx) {
1846
+ const roleName = {
1847
+ protagonist: "主角",
1848
+ supporting: "配角",
1849
+ antagonist: "反派",
1850
+ other: "其他",
1851
+ female_lead: "女主",
1852
+ female_support: "女配",
1853
+ support: "配角",
1854
+ extra: "路人"
1855
+ };
1856
+ return {
1857
+ rolesBlock: ctx.relevantRoles.length > 0 ? "==================== 相关角色卡 ====================\n" + ctx.relevantRoles.map((r) => `- ${r.name}(${roleName[r.role] ?? r.role}):${r.traits.join("、")}${r.goals ? `;目标:${r.goals}` : ""}${r.knowledge ? `\n 已知信息:${r.knowledge.join(";")}(未列出的该角色不知道)` : ""}`).join("\n") : "",
1858
+ worldRulesBlock: ctx.relevantWorldRules.length > 0 ? "==================== 世界规则 ====================\n" + ctx.relevantWorldRules.map((r) => `- ${r}`).join("\n") : "",
1859
+ factsBlock: ctx.recentFacts.length > 0 || ctx.relatedFacts.length > 0 ? "==================== 事实库(最近 + 相关) ====================\n" + (ctx.recentFacts.length > 0 ? `【最近 ${ctx.recentFacts.length} 条】\n${ctx.recentFacts.join("\n")}` : "") + (ctx.relatedFacts.length > 0 ? `\n【相关旧事实 ${ctx.relatedFacts.length} 条】\n${ctx.relatedFacts.join("\n")}` : "") : "",
1860
+ plotlinesBlock: ctx.activePlotlines.length > 0 ? "==================== 活跃剧情线 ====================\n" + ctx.activePlotlines.map((l) => `- [${l.kind}${l.status === "paused" ? "·暂停" : ""}] ${l.name}:${l.goal}${l.progress ? `(进度:${l.progress})` : ""}`).join("\n") : "",
1861
+ foreshadowsBlock: ctx.activeForeshadows.length > 0 ? "==================== 活跃伏笔 ====================\n" + ctx.activeForeshadows.map((f) => `- [${f.status === "planted" ? "已埋设" : "推进中"}] ${f.description}${f.targetChapter ? `(预计第${f.targetChapter}章回收)` : ""}`).join("\n") : "",
1862
+ continuityBlock: ctx.prevChapterTail !== "" ? `==================== 上一章结尾(紧接此状态继续) ====================\n${ctx.prevChapterTail}` : "",
1863
+ redLinesBlock: ctx.bookRedLines.length > 0 ? "==================== 本书红线 ====================\n" + ctx.bookRedLines.map((r) => `- ${r}`).join("\n") : ""
1864
+ };
1865
+ }
1866
+ //#endregion
1008
1867
  //#region src/shot-language.ts
1009
1868
  const SHOT_SIZES = [
1010
1869
  {
@@ -1292,6 +2151,129 @@ function lightZh(ids) {
1292
2151
  }
1293
2152
  //#endregion
1294
2153
  //#region src/story-beat-language.ts
2154
+ /** 情绪词表(按能量/阶段排序)。 */
2155
+ const EMOTIONS = [
2156
+ {
2157
+ id: "calm",
2158
+ zh: "平静",
2159
+ en: "calm",
2160
+ hint: "无波澜"
2161
+ },
2162
+ {
2163
+ id: "indifferent",
2164
+ zh: "淡然",
2165
+ en: "indifferent",
2166
+ hint: "不在乎"
2167
+ },
2168
+ {
2169
+ id: "expectant",
2170
+ zh: "期待",
2171
+ en: "expectant",
2172
+ hint: "有所期盼"
2173
+ },
2174
+ {
2175
+ id: "curious",
2176
+ zh: "好奇",
2177
+ en: "curious",
2178
+ hint: "想要探究"
2179
+ },
2180
+ {
2181
+ id: "alert",
2182
+ zh: "警觉",
2183
+ en: "alert",
2184
+ hint: "察觉到异样"
2185
+ },
2186
+ {
2187
+ id: "suppressed",
2188
+ zh: "压抑",
2189
+ en: "suppressed",
2190
+ hint: "情绪被压制"
2191
+ },
2192
+ {
2193
+ id: "enduring",
2194
+ zh: "隐忍",
2195
+ en: "enduring",
2196
+ hint: "强忍着"
2197
+ },
2198
+ {
2199
+ id: "worried",
2200
+ zh: "担忧",
2201
+ en: "worried",
2202
+ hint: "担心后果"
2203
+ },
2204
+ {
2205
+ id: "irritable",
2206
+ zh: "焦躁",
2207
+ en: "irritable",
2208
+ hint: "烦躁不耐"
2209
+ },
2210
+ {
2211
+ id: "uneasy",
2212
+ zh: "不安",
2213
+ en: "uneasy",
2214
+ hint: "心里没底"
2215
+ },
2216
+ {
2217
+ id: "terrified",
2218
+ zh: "惊惧",
2219
+ en: "terrified",
2220
+ hint: "极度恐惧"
2221
+ },
2222
+ {
2223
+ id: "angry",
2224
+ zh: "愤怒",
2225
+ en: "angry",
2226
+ hint: "强烈不满"
2227
+ },
2228
+ {
2229
+ id: "collapsing",
2230
+ zh: "崩溃",
2231
+ en: "collapsing",
2232
+ hint: "情绪失控"
2233
+ },
2234
+ {
2235
+ id: "resolute",
2236
+ zh: "决绝",
2237
+ en: "resolute",
2238
+ hint: "下定狠心"
2239
+ },
2240
+ {
2241
+ id: "grieved",
2242
+ zh: "痛心",
2243
+ en: "grieved",
2244
+ hint: "悲伤心痛"
2245
+ },
2246
+ {
2247
+ id: "relieved",
2248
+ zh: "释然",
2249
+ en: "relieved",
2250
+ hint: "放下包袱"
2251
+ },
2252
+ {
2253
+ id: "bittersweet",
2254
+ zh: "悲凉",
2255
+ en: "bittersweet",
2256
+ hint: "苦涩无奈"
2257
+ },
2258
+ {
2259
+ id: "triumphant",
2260
+ zh: "得意",
2261
+ en: "triumphant",
2262
+ hint: "占据上风"
2263
+ },
2264
+ {
2265
+ id: "reborn",
2266
+ zh: "重生",
2267
+ en: "reborn",
2268
+ hint: "脱胎换骨"
2269
+ },
2270
+ {
2271
+ id: "numb",
2272
+ zh: "麻木",
2273
+ en: "numb",
2274
+ hint: "失去感知"
2275
+ }
2276
+ ];
1295
2277
  /** 从中文归一化叙事功能(未知回退 exposition)。 */
1296
2278
  function normalizeStoryFunction(text) {
1297
2279
  if (text === void 0 || text === "") return "exposition";
@@ -1336,6 +2318,11 @@ function normalizeEmotions(text) {
1336
2318
  if (out.length === 0) push("calm");
1337
2319
  return out;
1338
2320
  }
2321
+ /** 情绪中文(箭头连接)。 */
2322
+ function emotionZh(ids) {
2323
+ if (ids === void 0 || ids.length === 0) return "平静";
2324
+ return EMOTIONS.filter((e) => ids.includes(e.id)).map((e) => e.zh).join("→");
2325
+ }
1339
2326
  //#endregion
1340
2327
  //#region src/protocol.ts
1341
2328
  /** The /api/dsh-novel-forge route family (same-origin, loopback-fenced). */
@@ -1379,6 +2366,10 @@ const NOVEL_API = {
1379
2366
  manhuaPlans: "/api/dsh-novel-forge/manhua/plans",
1380
2367
  /** 漫剧角色库:从分镜提名(规则+LLM两段式)/ 建卡 / 更新 / 删除 / 形象锚点 / 精修提示词。 */
1381
2368
  mangaRoles: "/api/dsh-novel-forge/manga/roles",
2369
+ /** 漫剧道具库:从已写章节提炼常驻道具(跨镜头需一致)/ 保存清单。 */
2370
+ mangaProps: "/api/dsh-novel-forge/manga/props",
2371
+ /** 导出「即梦素材包」落盘到资产库 manga-assets/素材包/。 */
2372
+ exportPackage: "/api/dsh-novel-forge/manga/export-package",
1382
2373
  /** 分镜·编剧级:单章 → 剧情骨架(节拍链)。 */
1383
2374
  storyboardSkeleton: "/api/dsh-novel-forge/storyboard/skeleton",
1384
2375
  /** 分镜·导演级:骨架 → 分镜表(镜头级)。 */
@@ -1407,7 +2398,7 @@ const NOVEL_API = {
1407
2398
  charactersRefresh: "/api/dsh-novel-forge/characters/refresh",
1408
2399
  /** 事实库回填:对历史已生成章节批量抽取事实(旧章节无事实记录时用)。 */
1409
2400
  factsBackfill: "/api/dsh-novel-forge/facts/backfill",
1410
- /** 设定圣经局部修补(如世界观规则编辑)。 */
2401
+ /** 道藏局部修补(如世界观规则编辑)。 */
1411
2402
  biblePatch: "/api/dsh-novel-forge/bible/patch",
1412
2403
  /** 小说简介:生成(AI)/补全(AI)/保存。 */
1413
2404
  blurb: "/api/dsh-novel-forge/blurb",
@@ -1556,7 +2547,7 @@ const LLM_VENDORS = [
1556
2547
  */
1557
2548
  /**
1558
2549
  * 内容合规红线(平台硬性要求):所有书籍、所有章节无条件生效,
1559
- * 优先级高于单书大纲/圣经中的任何设定与作者自定义红线。
2550
+ * 优先级高于单书大纲/道藏中的任何设定与作者自定义红线。
1560
2551
  * 注入点:章节生成系统提示 + 审稿系统提示(命中即 high)。
1561
2552
  */
1562
2553
  const COMPLIANCE_REDLINES = [
@@ -1570,6 +2561,18 @@ const COMPLIANCE_REDLINES = [
1570
2561
  "8. 不得出现侮辱或者诽谤他人、侵害他人合法权益的内容(不得以真实人物、组织为原型进行侮辱或影射攻击)。",
1571
2562
  "9. 不得出现法律法规禁止的其他内容。"
1572
2563
  ];
2564
+ /** 审稿维度取值(与 review-policy.ts 的 REVIEW_DIMENSIONS 对齐,用于归一化模型输出的 dimension 字段)。 */
2565
+ const REVIEW_DIMENSION_IDS = /* @__PURE__ */ new Set([
2566
+ "character",
2567
+ "setting",
2568
+ "redline",
2569
+ "writing",
2570
+ "pacing",
2571
+ "logic",
2572
+ "anti-ai",
2573
+ "presentation",
2574
+ "compliance"
2575
+ ]);
1573
2576
  /** Project state file name inside the output dir. */
1574
2577
  const PROJECT_FILE = "novel-project.json";
1575
2578
  /** 智能解码文本文件:UTF-8 BOM / UTF-16 BOM / UTF-8(严格校验)/ GB18030 回退。 */
@@ -1628,7 +2631,16 @@ function loadProject(outputDir) {
1628
2631
  /** Persist the project state next to the chapters. */
1629
2632
  function saveProject(outputDir, project) {
1630
2633
  mkdirSync(outputDir, { recursive: true });
1631
- writeFileSync(join(outputDir, PROJECT_FILE), JSON.stringify(project, null, 2), "utf8");
2634
+ const target = join(outputDir, PROJECT_FILE);
2635
+ const data = JSON.stringify(project, null, 2);
2636
+ try {
2637
+ if (existsSync(target)) {
2638
+ if (readFileSync(target, "utf8") === data) return;
2639
+ }
2640
+ } catch {}
2641
+ const tmp = target + ".tmp";
2642
+ writeFileSync(tmp, data, "utf8");
2643
+ renameSync(tmp, target);
1632
2644
  }
1633
2645
  /**
1634
2646
  * 并发保护:长任务(章节计划生成/正文生成)在内存中持有旧快照,
@@ -2107,6 +3119,60 @@ function parseJson(text, wantArray) {
2107
3119
  push(trimmed);
2108
3120
  const start2 = trimmed.indexOf(opener);
2109
3121
  if (start2 !== -1) push(trimmed.slice(start2));
3122
+ const repairTruncated = (value) => {
3123
+ const firstOpen = value.indexOf(opener);
3124
+ if (firstOpen === -1) return value;
3125
+ const body = value.slice(firstOpen);
3126
+ let inStr = false;
3127
+ let depth = 0;
3128
+ let lastComplete = -1;
3129
+ for (let i = 0; i < body.length; i++) {
3130
+ const ch = body[i];
3131
+ if (inStr) {
3132
+ if (ch === "\\") {
3133
+ i++;
3134
+ continue;
3135
+ }
3136
+ if (ch === "\"") inStr = false;
3137
+ continue;
3138
+ }
3139
+ if (ch === "\"") {
3140
+ inStr = true;
3141
+ continue;
3142
+ }
3143
+ if (ch === "{" || ch === "[") depth++;
3144
+ if (ch === "}" || ch === "]") {
3145
+ depth--;
3146
+ if (depth === 1) lastComplete = i;
3147
+ }
3148
+ }
3149
+ if (lastComplete === -1 || depth <= 0) return value;
3150
+ let result = body.slice(0, lastComplete + 1);
3151
+ let d = 0;
3152
+ let inS = false;
3153
+ for (let i = 0; i < result.length; i++) {
3154
+ const c = result[i];
3155
+ if (inS) {
3156
+ if (c === "\\") {
3157
+ i++;
3158
+ continue;
3159
+ }
3160
+ if (c === "\"") inS = false;
3161
+ continue;
3162
+ }
3163
+ if (c === "\"") {
3164
+ inS = true;
3165
+ continue;
3166
+ }
3167
+ if (c === "{" || c === "[") d++;
3168
+ if (c === "}" || c === "]") d--;
3169
+ }
3170
+ while (d > 0) {
3171
+ result += closer;
3172
+ d--;
3173
+ }
3174
+ return result;
3175
+ };
2110
3176
  const repair = (value) => {
2111
3177
  let out = "";
2112
3178
  let inString = false;
@@ -2135,7 +3201,11 @@ function parseJson(text, wantArray) {
2135
3201
  }
2136
3202
  return out;
2137
3203
  };
2138
- for (const candidate of candidates) for (const attempt of [candidate, repair(candidate)]) try {
3204
+ for (const candidate of candidates) for (const attempt of [
3205
+ candidate,
3206
+ repair(candidate),
3207
+ repairTruncated(candidate)
3208
+ ]) try {
2139
3209
  const value = JSON.parse(attempt);
2140
3210
  if (!wantArray || Array.isArray(value)) return value;
2141
3211
  if (typeof value === "object" && value !== null) for (const key of Object.keys(value)) {
@@ -2160,7 +3230,7 @@ function parseJsonObject(text) {
2160
3230
  /** System prompt for story-bible extraction. */
2161
3231
  function bibleSystemPrompt() {
2162
3232
  return [
2163
- "你是一位资深网文编辑兼设定架构师。你会收到一份小说大纲,请把它提炼成结构化的「设定圣经」(Story Bible),供后续写作时严格引用。",
3233
+ "你是一位资深网文编辑兼设定架构师。你会收到一份小说大纲,请把它提炼成结构化的「道藏」,供后续写作时严格引用。",
2164
3234
  "要求:",
2165
3235
  "1. 忠于大纲,不自行发明大纲之外的设定。",
2166
3236
  "2. 角色卡覆盖大纲明确出现的角色(主角必含),每个角色给出性格标签、目标、关键关系。",
@@ -2187,7 +3257,7 @@ async function extractBible(ctx, config, outline, project) {
2187
3257
  }
2188
3258
  const facts = (project?.facts ?? []).slice(-40);
2189
3259
  const user = [
2190
- `请为下面这部小说提炼设定圣经:\n\n${outline}`,
3260
+ `请为下面这部小说提炼道藏:\n\n${outline}`,
2191
3261
  facts.length > 0 ? `\n\n【已写章节事实(编年录)】用于确认真实角色姓名与已确立设定;忠于大纲,不要新增大纲外设定:\n${facts.map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 100)}`).join("\n")}` : "",
2192
3262
  excerpts.length > 0 ? `\n\n【已写章节正文摘录】角色姓名、身份以正文为准(大纲未点名时用正文里的真实姓名,禁止用「主角(描述)」占位名):\n${excerpts.join("\n\n")}` : ""
2193
3263
  ].filter((s) => s !== "").join("\n");
@@ -2195,7 +3265,7 @@ async function extractBible(ctx, config, outline, project) {
2195
3265
  system: bibleSystemPrompt(),
2196
3266
  user,
2197
3267
  temperature: .4,
2198
- maxTokens: Math.max(config.maxTokens, 16e3),
3268
+ maxTokens: Math.max(config.maxTokens, 8e3),
2199
3269
  reasoning: config.analysisReasoning ?? "low"
2200
3270
  }));
2201
3271
  const strArray = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string" && v.trim() !== "") : [];
@@ -2290,41 +3360,54 @@ function planSystemPrompt(volumes) {
2290
3360
  "重要:beats 字段内部必须使用 \\n 转义表示换行(JSON 字符串内不得有真实换行符),其余字符串值也不得包含真实换行符,JSON 必须在一段内完整结束。",
2291
3361
  "重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
2292
3362
  volumes !== void 0 && volumes.length > 0 ? ["\n全书分卷结构(规划章节时需落在对应卷内):"].concat(volumes.map((v) => `第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})`)).join("\n") : ""
2293
- ].join("\n");
2294
- }
2295
- /** Build the writing system prompt (bible + outline + active foreshadows). */
2296
- function writeSystemPrompt(project) {
2297
- const bible = project.bible;
2298
- const sections = [];
2299
- if (bible !== void 0) {
2300
- sections.push("==================== 设定圣经(写作时严格遵守) ====================");
2301
- if (bible.genre !== "") sections.push(`题材基调:${bible.genre}`);
2302
- if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
2303
- if (bible.characters.length > 0) {
2304
- sections.push("角色卡:");
2305
- for (const card of bible.characters) {
2306
- const roleName = {
2307
- protagonist: "主角",
2308
- supporting: "配角",
2309
- antagonist: "反派",
2310
- other: "其他"
2311
- }[card.role];
2312
- sections.push(`- ${card.name}(${roleName}):${card.traits.join("、")}${card.goals !== "" ? `;目标:${card.goals}` : ""}${card.relations !== "" ? `;关系:${card.relations}` : ""}`);
2313
- if (Array.isArray(card.knowledge) && card.knowledge.length > 0) sections.push(` 已知信息(该角色知道的:${card.knowledge.join(";")};未列出的信息该角色一律不知道,不得写其知晓或提及)`);
2314
- }
2315
- }
3363
+ ].join("\n");
3364
+ }
3365
+ /** Build the writing system prompt (bible + outline + active foreshadows).
3366
+ * `targetChars` 来自每章计划(规划时快照,= 设置的每章目标字数);无则退回默认 3500。
3367
+ * 字数区间按目标动态生成(±15%,取整到百位),避免系统提示词与设置互相冲突。
3368
+ * `lengthRule` 可覆盖第 1 条字数要求(整章修订/改编时按原文长度为准)。 */
3369
+ function writeSystemPrompt(project, targetChars, lengthRule) {
3370
+ const bible = project.bible;
3371
+ const sections = [];
3372
+ if (bible !== void 0) {
3373
+ sections.push("==================== 道藏(写作时严格遵守) ====================");
3374
+ if (bible.genre !== "") sections.push(`题材基调:${bible.genre}`);
3375
+ if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
2316
3376
  const roleLib = project.roles ?? [];
2317
- if (roleLib.length > 0) {
2318
- const labelName = {
3377
+ const labelName = {
3378
+ protagonist: "主角",
3379
+ female_lead: "女主",
3380
+ female_support: "女配",
3381
+ support: "配角",
3382
+ antagonist: "反派",
3383
+ extra: "路人"
3384
+ };
3385
+ const seenRole = /* @__PURE__ */ new Set();
3386
+ const mergedRoles = [];
3387
+ for (const r of roleLib) {
3388
+ seenRole.add(r.name);
3389
+ const card = bible.characters.find((c) => c.name === r.name);
3390
+ const traits = card !== void 0 ? card.traits : Array.isArray(r.traits) ? r.traits : [];
3391
+ const goals = card !== void 0 && card.goals !== "" ? card.goals : r.goals;
3392
+ const relations = card !== void 0 && card.relations !== "" ? card.relations : Array.isArray(r.relations) && r.relations.length > 0 ? r.relations.join("、") : "";
3393
+ mergedRoles.push(`- ${r.name}(${labelName[r.roleLabel]}):${r.identity}${traits.length > 0 ? `;性格:${traits.join("、")}` : ""}${goals !== "" ? `;目标:${goals}` : ""}${relations !== "" ? `;关系:${relations}` : ""}`);
3394
+ if (card !== void 0 && Array.isArray(card.knowledge) && card.knowledge.length > 0) mergedRoles.push(` 已知信息(该角色知道的:${card.knowledge.join(";")};未列出的信息该角色一律不知道,不得写其知晓或提及)`);
3395
+ }
3396
+ for (const card of bible.characters) {
3397
+ if (seenRole.has(card.name)) continue;
3398
+ seenRole.add(card.name);
3399
+ const roleName = {
2319
3400
  protagonist: "主角",
2320
- female_lead: "女主",
2321
- female_support: "女配",
2322
- support: "配角",
3401
+ supporting: "配角",
2323
3402
  antagonist: "反派",
2324
- extra: "路人"
2325
- };
2326
- sections.push("角色库(出场角色按定位规格刻画互动):");
2327
- for (const r of roleLib) sections.push(`- ${r.name}(${labelName[r.roleLabel]}):${r.identity}${r.relations.length > 0 ? `;关系:${r.relations.join("、")}` : ""}`);
3403
+ other: "其他"
3404
+ }[card.role];
3405
+ mergedRoles.push(`- ${card.name}(${roleName}):${card.traits.join("、")}${card.goals !== "" ? `;目标:${card.goals}` : ""}${card.relations !== "" ? `;关系:${card.relations}` : ""}`);
3406
+ if (Array.isArray(card.knowledge) && card.knowledge.length > 0) mergedRoles.push(` 已知信息(该角色知道的:${card.knowledge.join(";")};未列出的信息该角色一律不知道,不得写其知晓或提及)`);
3407
+ }
3408
+ if (mergedRoles.length > 0) {
3409
+ sections.push("角色卡(角色库与道藏已合并去重):");
3410
+ sections.push(...mergedRoles);
2328
3411
  }
2329
3412
  if (bible.redLines.length > 0) sections.push("写作红线(违反即失败):\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
2330
3413
  if (bible.style.length > 0) sections.push("风格要求:\n" + bible.style.map((r) => `- ${r}`).join("\n"));
@@ -2355,9 +3438,12 @@ function writeSystemPrompt(project) {
2355
3438
  }
2356
3439
  sections.push("");
2357
3440
  sections.push("写作硬性要求:");
2358
- sections.push("1. 每章 3000-4000 字(按中文字符计),只输出章节正文,不要输出标题、章回名、作者的话或任何 Markdown 标记。");
3441
+ const target = targetChars !== void 0 && targetChars > 0 ? targetChars : 3500;
3442
+ const lo = Math.max(1e3, Math.round(target * .85 / 100) * 100);
3443
+ const hi = Math.max(lo + 100, Math.round(target * 1.15 / 100) * 100);
3444
+ sections.push(lengthRule ?? `1. 每章 ${lo}-${hi} 字(目标 ${target} 字,按中文字符计),只输出章节正文,不要输出标题、章回名、作者的话或任何 Markdown 标记。`);
2359
3445
  sections.push("2. 以主角视角展开,动作、对话、心理描写交替推进,禁止大段设定说明。");
2360
- sections.push("3. 尊重大纲与设定圣经:人设不崩、金手指规则不自相矛盾、战力不随意膨胀。");
3446
+ sections.push("3. 尊重大纲与道藏:人设不崩、金手指规则不自相矛盾、战力不随意膨胀。");
2361
3447
  sections.push("4. 章末留一个钩子(悬念、反转或新线索),吸引读者读下一章。");
2362
3448
  sections.push("5. 语言流畅自然,符合中文网文语感,避免翻译腔与病句。");
2363
3449
  sections.push("6. 对话与冲突密度:每章至少 1 处实质对话或正面对抗/交锋场面;推理与心理活动必须用动作、环境细节、微表情、对话呈现,禁止整章纯内心独白铺陈(禁止\"解说式\"交代线索)。");
@@ -2389,14 +3475,22 @@ async function planChapters(ctx, config, project, chapterCount, volumeNo, output
2389
3475
  }
2390
3476
  const outlineBlock = continuation ? (() => {
2391
3477
  if (project.outline.length <= 2e4) return project.outline;
2392
- const cut = project.outline.indexOf("十、全书核心弹幕记忆梗");
2393
- if (cut > 1500) return project.outline.slice(0, cut).trimEnd() + "\n(大纲过长,末尾记忆梗从略)";
2394
- return project.outline.slice(0, 8e3) + "\n…(大纲过长已节选)";
3478
+ const volMarkers = ["第" + (volume?.no ?? "") + "卷", "卷" + (volume?.no ?? "")];
3479
+ let cut = -1;
3480
+ for (const marker of volMarkers) {
3481
+ const idx = project.outline.lastIndexOf(marker);
3482
+ if (idx > cut) cut = idx;
3483
+ }
3484
+ if (cut < 2e3) cut = Math.min(15e3, project.outline.length);
3485
+ return project.outline.slice(0, cut).trimEnd() + "\n…(大纲过长,已保留当前卷及之前内容,后续从略)";
2395
3486
  })() : project.outline;
2396
3487
  const user = [
2397
3488
  "请为下面这部小说规划章节。",
2398
3489
  volume !== void 0 ? `本次只规划第 ${volume.no} 卷《${volume.title}》的章节:\n${volume.summary}` : continuation ? `本书已有 ${existing.length} 章已规划/已写作(见下方「已有章节」)。请规划**后续**章节:从第 ${startNo} 章开始。` : "请规划全书开篇章节。",
2399
- continuation ? "【续写硬性要求】已有章节的剧情不得重写或重复,章节标题也不得与已有章节重复。以下情节均已在已有章节中发生过,后续章节**绝对不得再次出现**:穿越、暴雨送餐、滴血认主/古玉认主、首次进入墟境、用废铁淬炼首件法器、绝境肉身入鼎洗炼(该机缘已用尽)、杀死白袍弟子与灰衣随从、藏尸水沟。\n若本次规划已进入大纲的收尾区间(接近全书规划总章数),最后 5-10 章必须按大纲推进到大结局(终极抉择/清算/双结局等),**禁止以悬念、逃离、未解之谜收尾**——收尾区间按大纲卷定位判断,不以当前剧情是否“感觉像结尾”为准。" : "",
3490
+ continuation ? (() => {
3491
+ const eventLines = existing.slice(-20).filter((c) => c.summary !== void 0 && c.summary.trim() !== "").map((c) => "第" + c.no + "章《" + c.title + "》:" + c.summary.slice(0, 80));
3492
+ return "【续写硬性要求】已有章节的剧情不得重写或重复,章节标题也不得与已有章节重复。\n" + (eventLines.length > 0 ? "以下情节已在已有章节中发生过(最近 " + eventLines.length + " 章摘要),后续章节**绝对不得重写或重复**:\n" + eventLines.join("\n") : "已有章节的剧情不得重写或重复(无章节摘要时以编年录为准)。") + "\n若本次规划已进入大纲的收尾区间(接近全书规划总章数),最后 5-10 章必须按大纲推进到大结局(终极抉择/清算/双结局等),**禁止以悬念、逃离、未解之谜收尾**——收尾区间按大纲卷定位判断,不以当前剧情是否\"感觉像结尾\"为准。";
3493
+ })() : "",
2400
3494
  prevTail !== "" ? `【上一章(第 ${startNo - 1} 章)结尾原文】第 ${startNo} 章必须紧接此状态继续,从新的事件写起,不得回顾重述:\n${prevTail}` : "",
2401
3495
  latestFacts !== "" ? `【最新剧情状态(本书编年录,第 ${startNo - 1} 章结尾的事实)】规划续写时必须以此为起点,时间线、人物状态与地点衔接一致:\n${latestFacts}` : "",
2402
3496
  continuation ? "已有章节(共 " + existing.length + " 章;仅列最近 80 章,更早的以标题计数为准,剧情以「编年录」为权威):\n" + existing.slice(-80).map((c) => {
@@ -2443,26 +3537,27 @@ async function planChapters(ctx, config, project, chapterCount, volumeNo, output
2443
3537
  function reviewSystemPrompt(project) {
2444
3538
  const bible = project.bible;
2445
3539
  const sections = [
2446
- "你是一位严格的网文审稿编辑。你会收到一章正文以及本书的设定圣经与红线。",
3540
+ "你是一位严格的网文审稿编辑。你会收到一章正文以及本书的道藏与红线。",
2447
3541
  "请从以下维度审查本章:",
2448
- "1. 人设一致性:角色行为是否符合角色卡(主角不圣母、不无脑、痞坏有分寸等)。",
2449
- "2. 设定一致性:金手指规则、战力体系、世界观是否与设定圣经冲突。",
2450
- "3. 红线检查:是否触犯写作红线(无后宫、无擦边、无无脑碾压等)。",
3542
+ "1. 人设一致性:角色行为是否符合下方角色卡的设定(性格/目标/知情度/说话方式)。",
3543
+ "2. 设定一致性:金手指规则、战力体系、世界观是否与道藏冲突。",
3544
+ "3. 红线检查:是否触犯下方「本书红线」与「内容合规红线」。",
2451
3545
  "4. 文笔质量:语病、翻译腔、AI 套话(\"不禁\"\"仿佛\"\"一时间\"等高频词滥用)、流水账。",
2452
3546
  "5. 节奏与爽点:本章是否有推进、有钩子,是否拖沓灌水。",
2453
3547
  "6. 逻辑漏洞:前后矛盾、时间线错误、对话失真。",
2454
- "7. 反 AI 规则:逐条核对下方「反 AI 规则」清单,命中即列为问题。",
3548
+ "7. 反 AI 规则:逐条核对下方「反 AI 规则」清单——禁止类命中即列为问题,鼓励类只作低优先级建议、不阻塞通过。",
2455
3549
  "8. 呈现方式:整章是否纯内心推理铺陈(无对话/无对抗,推理全靠解说);反派是否纯背景板无行动;重要配角是否无名标签化(瘦高个/灰衣人全程代称)——命中即列为问题。",
2456
3550
  "9. 内容合规(最高优先级):逐条核对下方「内容合规红线」,任何一条命中(含影射、暗示、详细描写)必须列为 high,并给出改写建议。",
2457
3551
  "输出必须是合法 JSON 对象,不要输出任何其他文字:",
2458
- "{\"score\": 0-100的整数, \"verdict\": \"一句话总评\", \"issues\": [{\"severity\": \"high|medium|low\", \"item\": \"问题描述\", \"suggestion\": \"修改建议\"}]}",
3552
+ "{\"score\": 0-100的整数, \"verdict\": \"一句话总评\", \"issues\": [{\"severity\": \"high|medium|low\", \"dimension\": \"character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance\", \"item\": \"问题描述\", \"suggestion\": \"修改建议\"}]}",
3553
+ "维度 dimension 与上方 9 个审查维度一一对应:人设=character、设定=setting、红线=redline、文笔=writing、节奏=pacing、逻辑=logic、反AI=anti-ai、呈现=presentation、合规=compliance。每条 issue 都必须填 dimension。",
2459
3554
  "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
2460
3555
  "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
2461
3556
  ];
2462
3557
  const assetsBlock = renderAllAssets(project.assets);
2463
3558
  if (assetsBlock !== "") sections.push("\n" + assetsBlock);
2464
3559
  if (bible !== void 0) {
2465
- sections.push("\n==================== 设定圣经 ====================");
3560
+ sections.push("\n==================== 道藏 ====================");
2466
3561
  if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
2467
3562
  if (bible.characters.length > 0) {
2468
3563
  sections.push("角色卡:");
@@ -2484,17 +3579,28 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
2484
3579
  if (chapter === void 0) throw new Error(`章节 ${chapterNo} 不在计划中`);
2485
3580
  const body = readChapterFile(outputDir, chapter);
2486
3581
  if (body === void 0) throw new Error(`章节 ${chapterNo} 的正文文件不存在`);
3582
+ const bodyText = body.replace(/^#\s+.*$/m, "").trim();
3583
+ const aiScan = scanAiFlavor(bodyText);
3584
+ const blocks = renderContextBlocks(buildChapterContext(project, chapter, outputDir, { stage: "review" }));
3585
+ const crossChapter = [
3586
+ blocks.continuityBlock,
3587
+ blocks.factsBlock,
3588
+ blocks.plotlinesBlock,
3589
+ blocks.foreshadowsBlock
3590
+ ].filter((b) => b !== "").join("\n");
2487
3591
  const user = [
2488
3592
  `本章标题:《${chapter.title}》`,
2489
3593
  `本章剧情要点:${chapter.beats}`,
3594
+ `==================== 本地 AI 味扫描(事实锚点,你只需复核判断,不必再逐字统计) ====================\n${aiScan.summary}`,
3595
+ crossChapter,
2490
3596
  "==================== 章节正文 ====================",
2491
- body.replace(/^#\s+.*$/m, "").trim()
2492
- ].join("\n");
3597
+ bodyText
3598
+ ].filter((line) => line !== "").join("\n");
2493
3599
  const raw = parseJsonObject(await complete(ctx, config, {
2494
3600
  system: reviewSystemPrompt(project),
2495
3601
  user,
2496
3602
  temperature: .3,
2497
- maxTokens: Math.max(config.maxTokens, 16e3)
3603
+ maxTokens: Math.max(config.maxTokens, 8e3)
2498
3604
  }));
2499
3605
  const issues = Array.isArray(raw.issues) ? raw.issues.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
2500
3606
  severity: [
@@ -2502,13 +3608,16 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
2502
3608
  "medium",
2503
3609
  "low"
2504
3610
  ].includes(entry.severity) ? entry.severity : "medium",
3611
+ dimension: typeof entry.dimension === "string" && REVIEW_DIMENSION_IDS.has(entry.dimension) ? entry.dimension : void 0,
2505
3612
  item: typeof entry.item === "string" ? entry.item : "",
2506
3613
  suggestion: typeof entry.suggestion === "string" ? entry.suggestion : ""
2507
3614
  })).filter((issue) => issue.item !== "") : [];
2508
3615
  const score = typeof raw.score === "number" ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60;
3616
+ const hasHigh = issues.some((i) => i.severity === "high");
3617
+ const softThreshold = Math.max(65, config.reviewPassScore - 5);
2509
3618
  const report = {
2510
3619
  score,
2511
- passed: score >= config.reviewPassScore || issues.every((i) => i.severity !== "high") && score >= 60,
3620
+ passed: hasHigh ? score >= config.reviewPassScore : score >= softThreshold,
2512
3621
  verdict: typeof raw.verdict === "string" ? raw.verdict.slice(0, 200) : "",
2513
3622
  issues,
2514
3623
  reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -2524,17 +3633,20 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
2524
3633
  * 复用审稿提示词与红线/道藏/反AI规则;仅返回报告,不改文件不改状态。
2525
3634
  */
2526
3635
  async function reviewChapterText(ctx, config, project, text, previousReport) {
3636
+ const bodyText = text.slice(0, 2e4);
3637
+ const aiScan = scanAiFlavor(bodyText);
2527
3638
  const user = [
2528
3639
  `书名:《${project.bookName}》`,
2529
3640
  previousReport !== void 0 ? "==================== 上一轮审稿意见(逐条核对是否已解决) ====================\n" + previousReport.issues.map((it, i) => `${i + 1}. [${it.severity}] ${it.item}${it.suggestion !== "" ? ` → ${it.suggestion}` : ""}`).join("\n") : "",
2530
3641
  previousReport !== void 0 ? "==================== 修订稿(上一轮审稿后按意见修订的正文) ====================" : "==================== 待审查正文 ====================",
2531
- text.slice(0, 2e4)
3642
+ `==================== 本地 AI 味扫描(事实锚点,你只需复核判断,不必再逐字统计) ====================\n${aiScan.summary}`,
3643
+ bodyText
2532
3644
  ].join("\n");
2533
3645
  const raw = parseJsonObject(await complete(ctx, config, {
2534
3646
  system: previousReport !== void 0 ? verifySystemPrompt(project) : reviewSystemPrompt(project),
2535
3647
  user,
2536
3648
  temperature: .3,
2537
- maxTokens: Math.max(config.maxTokens, 16e3)
3649
+ maxTokens: Math.max(config.maxTokens, 8e3)
2538
3650
  }));
2539
3651
  const issues = Array.isArray(raw.issues) ? raw.issues.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
2540
3652
  severity: [
@@ -2542,14 +3654,20 @@ async function reviewChapterText(ctx, config, project, text, previousReport) {
2542
3654
  "medium",
2543
3655
  "low"
2544
3656
  ].includes(entry.severity) ? entry.severity : "medium",
3657
+ dimension: typeof entry.dimension === "string" && REVIEW_DIMENSION_IDS.has(entry.dimension) ? entry.dimension : void 0,
2545
3658
  item: typeof entry.item === "string" ? entry.item : "",
2546
3659
  suggestion: typeof entry.suggestion === "string" ? entry.suggestion : ""
2547
3660
  })).filter((issue) => issue.item !== "") : [];
2548
3661
  const score = typeof raw.score === "number" ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60;
2549
- let passed = score >= config.reviewPassScore || issues.every((i) => i.severity !== "high") && score >= 60;
3662
+ let passed = issues.some((i) => i.severity === "high") ? score >= config.reviewPassScore : score >= Math.max(65, config.reviewPassScore - 5);
2550
3663
  if (previousReport !== void 0) {
2551
3664
  const hasHigh = issues.some((i) => i.severity === "high");
2552
- const prevHighResolved = previousReport.issues.filter((i) => i.severity === "high").every((p) => !issues.some((i) => i.item.includes(p.item.slice(0, 12))));
3665
+ const prevHigh = previousReport.issues.filter((i) => i.severity === "high");
3666
+ const unresolvedIds = Array.isArray(raw.unresolvedIds) ? raw.unresolvedIds.filter((v) => typeof v === "number") : [];
3667
+ const resolvedIds = Array.isArray(raw.resolvedIds) ? raw.resolvedIds.filter((v) => typeof v === "number") : [];
3668
+ let prevHighResolved;
3669
+ if (unresolvedIds.length > 0 || resolvedIds.length > 0) prevHighResolved = prevHigh.every((_, idx) => !unresolvedIds.includes(idx + 1) || resolvedIds.includes(idx + 1));
3670
+ else prevHighResolved = prevHigh.every((p) => !issues.some((i) => i.item.replace(/^未解决\(\d+\):/, "").includes(p.item.replace(/^未解决\(\d+\):/, "").slice(0, 20))));
2553
3671
  passed = !hasHigh && prevHighResolved;
2554
3672
  }
2555
3673
  return {
@@ -2565,16 +3683,20 @@ function verifySystemPrompt(project) {
2565
3683
  return [
2566
3684
  "你是一位网文审稿验证员。作者已按上一轮审稿意见修订了本章,你需要验证修订效果。",
2567
3685
  "你的任务(严格按此执行):",
2568
- "1. 逐条核对「上一轮意见」中的每一条是否已在修订稿中解决——已解决的不再列出;未解决或部分解决的,按原严重度列出(item 需注明\"未解决:原意见 xxx\")。",
3686
+ "1. 逐条核对「上一轮意见」中的每一条(按编号 1、2、3...)是否已在修订稿中解决。",
2569
3687
  "2. 只挑修订【新引入】的 high 级问题(设定矛盾/逻辑硬伤/事实错误)——新引入的 medium/low 主观项(文笔/套话/节奏)不要列。",
2570
3688
  "3. 禁止重复挑剔上一轮已指出且本次已解决的主观项(如\"缓缓/微微\"等套话、错别字)——即使换个说法再提也不行。",
2571
- "4. 严禁为了显得专业而新增\"换一批毛病\"式的意见;如果修订稿已解决全部 high 且无新增 high,输出 issues 为空数组。",
2572
- "score 评分:按修订稿整体质量给 50-90 分(解决全部 high 且无新增 high 时给 70 以上)。",
2573
- "verdict:一句话结论(如\"原 high 已解决,无新增高风险问题\"或\"仍有未解决的 high\")。",
2574
- "输出必须是合法 JSON 对象:{\"score\": 数字, \"verdict\": \"一句话\", \"issues\": [{\"severity\": \"high|medium|low\", \"item\": \"问题\", \"suggestion\": \"建议\"}]}",
3689
+ "4. 严禁为了显得专业而新增\"换一批毛病\"式的意见。",
3690
+ "输出必须是合法 JSON 对象,包含以下字段:",
3691
+ "- resolvedIds:已解决的上一轮意见编号数组(如 [1, 3, 5])。",
3692
+ "- unresolvedIds:未解决或部分解决的上一轮意见编号数组(如 [2, 4])。",
3693
+ "- issues:未解决的原意见 + 新引入的 high 级问题列表(格式同审稿:severity/item/suggestion)。未解决的原意见 item 需注明\"未解决(编号N):原意见摘要\"。",
3694
+ "- score:按修订稿整体质量给 50-90 分(解决全部 high 且无新增 high 时给 70 以上)。",
3695
+ "- verdict:一句话结论。",
3696
+ "完整格式:{\"resolvedIds\": [1,3], \"unresolvedIds\": [2], \"score\": 75, \"verdict\": \"一句话\", \"issues\": [{\"severity\": \"high\", \"dimension\": \"character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance\", \"item\": \"未解决(2):xxx\", \"suggestion\": \"xxx\"}]}",
2575
3697
  "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
2576
3698
  "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。",
2577
- `本书设定圣经(核对设定冲突用):\n${project.bible !== void 0 ? JSON.stringify(project.bible).slice(0, 3e3) : "(无)"}`
3699
+ `本书道藏(核对设定冲突用):\n${project.bible !== void 0 ? JSON.stringify(project.bible).slice(0, 3e3) : "(无)"}`
2578
3700
  ].join("\n");
2579
3701
  }
2580
3702
  /** Build the author-review system prompt (narrative structure, not prose). */
@@ -2717,8 +3839,8 @@ async function extractRoles(ctx, config, project) {
2717
3839
  const system = [
2718
3840
  "你是一位网文角色库管理员。请根据本书的大纲、设定、编年录与章节摘要,提炼完整的角色库。",
2719
3841
  "覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如\"矮胖姑娘\")用其身份简称收录并标注;反复出现且有剧情作用的身份型角色(站长、律师、警察、法官、店主等)必须收录。",
2720
- "数量控制:最多输出 10 个角色;覆盖优先——主角、主要反派、重要配角(女主/关键配角)必须全收,所有有名有姓者必收;只有真正一次性路人(无名字、无剧情作用)才可省略。",
2721
- "重要:正常一部完整故事应提炼 6-10 个角色;若少于 4 个通常说明漏提炼,必须重新核对正文摘录。",
3842
+ "数量控制:最多输出 20 个角色;覆盖优先——主角、主要反派、重要配角(女主/关键配角)必须全收,所有有名有姓者必收;只有真正一次性路人(无名字、无剧情作用)才可省略。",
3843
+ "重要:正常一部完整故事应提炼 8-20 个角色;若少于 6 个通常说明漏提炼,必须重新核对正文摘录。",
2722
3844
  "重要:正文中若有明确的主角与主要反派,必须分别以 protagonist / antagonist 收录,禁止遗漏;反派确实未出场时才可省略。",
2723
3845
  "输出优先级:主角(protagonist)与主要反派(antagonist)必须优先输出并完整刻画,其次女主/重要配角;判断不出名字时用正文中的身份称呼。",
2724
3846
  "每个角色输出:",
@@ -2738,12 +3860,12 @@ async function extractRoles(ctx, config, project) {
2738
3860
  ].join("\n");
2739
3861
  const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
2740
3862
  const existingRoles = project.roles ?? [];
2741
- const sampleChapters = written.length <= 6 ? written : (() => {
3863
+ const sampleChapters = written.length <= 10 ? written : (() => {
2742
3864
  const picked = /* @__PURE__ */ new Set();
2743
3865
  for (let i = 0; i < 3 && i < written.length; i++) picked.add(i);
2744
- const step = Math.max(1, Math.floor(written.length / 4));
2745
- for (let i = step; i < written.length - 2; i += step) picked.add(i);
2746
- for (let i = Math.max(0, written.length - 2); i < written.length; i++) picked.add(i);
3866
+ const step = Math.max(1, Math.floor(written.length / 8));
3867
+ for (let i = step; i < written.length - 3; i += step) picked.add(i);
3868
+ for (let i = Math.max(0, written.length - 3); i < written.length; i++) picked.add(i);
2747
3869
  return [...picked].sort((a, b) => a - b).map((i) => written[i]);
2748
3870
  })();
2749
3871
  const excerptParts = [];
@@ -2751,14 +3873,32 @@ async function extractRoles(ctx, config, project) {
2751
3873
  const body = readChapterFile(config.outputDir, chapter);
2752
3874
  if (body === void 0) continue;
2753
3875
  const text = body.replace(/^#.*$/gm, "").trim();
2754
- if (text.length > 0) excerptParts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0, 1500)}`);
3876
+ if (text.length > 0) excerptParts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0, 3e3)}`);
3877
+ }
3878
+ const freqCandidates = /* @__PURE__ */ new Set();
3879
+ for (const c of project.bible?.characters ?? []) freqCandidates.add(c.name);
3880
+ for (const r of existingRoles) freqCandidates.add(r.name);
3881
+ const freqMap = /* @__PURE__ */ new Map();
3882
+ if (freqCandidates.size > 0) for (const chapter of written) {
3883
+ const body = readChapterFile(config.outputDir, chapter);
3884
+ if (body === void 0) continue;
3885
+ for (const name of freqCandidates) {
3886
+ let idx = 0, n = 0;
3887
+ while ((idx = body.indexOf(name, idx)) !== -1) {
3888
+ n++;
3889
+ idx += name.length;
3890
+ }
3891
+ if (n > 0) freqMap.set(name, (freqMap.get(name) ?? 0) + n);
3892
+ }
2755
3893
  }
3894
+ const freqLines = [...freqMap.entries()].filter(([, n]) => n >= 2).sort((a, b) => b[1] - a[1]).slice(0, 30).map(([name, n]) => name + "(" + n + "次)");
2756
3895
  const user = [
2757
3896
  `书名:《${project.bookName}》`,
2758
3897
  existingRoles.length > 0 ? `已收录角色(跳过,不要输出):${existingRoles.map((r) => r.name).join("、")}` : "",
2759
3898
  `大纲(节选前 3000 字):\n${project.outline.slice(0, 3e3)}`,
2760
3899
  excerptParts.length > 0 ? `已写正文摘录(角色姓名/身份/关系以正文为准):\n${excerptParts.join("\n\n")}` : "",
2761
3900
  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")}` : "",
3901
+ freqLines.length > 0 ? `角色出场频次参考(全书统计,次数越多越重要,优先收录高频角色):${freqLines.join("、")}` : "",
2762
3902
  (project.facts ?? []).length > 0 ? `编年录(最近 30 条):\n${(project.facts ?? []).slice(-30).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 60)}`).join("\n")}` : "",
2763
3903
  written.length > 0 ? `已写章节标题(${written.length} 章):\n${written.map((c) => `第${c.no}章《${c.title}》`).join("、")}` : "",
2764
3904
  "只输出 JSON 数组。"
@@ -2767,18 +3907,18 @@ async function extractRoles(ctx, config, project) {
2767
3907
  system,
2768
3908
  user,
2769
3909
  temperature: .3,
2770
- maxTokens: Math.max(config.maxTokens, 24e3),
3910
+ maxTokens: Math.max(config.maxTokens, 16e3),
2771
3911
  reasoning: config.analysisReasoning ?? "low"
2772
3912
  });
2773
3913
  let raw = parseJsonArray(text);
2774
3914
  const hasProtagonist = raw.some((e) => typeof e === "object" && e !== null && e.roleLabel === "protagonist");
2775
- const tooFew = raw.length > 0 && raw.length < 4;
3915
+ const tooFew = raw.length > 0 && raw.length < 6;
2776
3916
  if (raw.length === 0 || !hasProtagonist || tooFew) {
2777
3917
  text = await complete(ctx, config, {
2778
- system: system + (raw.length === 0 ? "\n上一次输出为空或格式不正确。请直接输出 JSON 数组(即使只有一个角色也要输出),不要输出其他文字。" : tooFew ? "\n上一次输出角色过少(不足 4 个)。这是一部完整故事,请重新核对正文摘录:主角、主要反派、重要配角与所有有名有姓的角色都要收录(宁多勿漏),输出 6-10 个。" : "\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。"),
3918
+ system: system + (raw.length === 0 ? "\n上一次输出为空或格式不正确。请直接输出 JSON 数组(即使只有一个角色也要输出),不要输出其他文字。" : tooFew ? "\n上一次输出角色过少(不足 6 个)。这是一部完整故事,请重新核对正文摘录:主角、主要反派、重要配角与所有有名有姓的角色都要收录(宁多勿漏),输出 8-20 个。" : "\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。"),
2779
3919
  user,
2780
3920
  temperature: .3,
2781
- maxTokens: Math.max(config.maxTokens, 24e3),
3921
+ maxTokens: Math.max(config.maxTokens, 12e3),
2782
3922
  reasoning: config.analysisReasoning ?? "low"
2783
3923
  });
2784
3924
  raw = parseJsonArray(text);
@@ -2829,7 +3969,7 @@ async function extractRoles(ctx, config, project) {
2829
3969
  if (role === void 0 || existing.has(role.name)) continue;
2830
3970
  existing.add(role.name);
2831
3971
  roles.push(role);
2832
- if (roles.length >= 10) break;
3972
+ if (roles.length >= 20) break;
2833
3973
  }
2834
3974
  } catch {}
2835
3975
  }
@@ -2859,7 +3999,30 @@ async function extractRoles(ctx, config, project) {
2859
3999
  "掌门",
2860
4000
  "师父",
2861
4001
  "师傅",
2862
- "管家"
4002
+ "管家",
4003
+ "长老",
4004
+ "宗主",
4005
+ "殿主",
4006
+ "宫主",
4007
+ "师兄",
4008
+ "师姐",
4009
+ "师弟",
4010
+ "师妹",
4011
+ "老祖",
4012
+ "魔尊",
4013
+ "妖王",
4014
+ "将军",
4015
+ "军师",
4016
+ "太监",
4017
+ "宫女",
4018
+ "嬷嬷",
4019
+ "宰相",
4020
+ "尚书",
4021
+ "巡抚",
4022
+ "都督",
4023
+ "祭司",
4024
+ "圣女",
4025
+ "圣子"
2863
4026
  ];
2864
4027
  const existingNames = new Set(roles.map((r) => r.name));
2865
4028
  const titleCount = /* @__PURE__ */ new Map();
@@ -2877,7 +4040,7 @@ async function extractRoles(ctx, config, project) {
2877
4040
  }
2878
4041
  }
2879
4042
  for (const t of ROLE_TITLE_HINTS) {
2880
- if (roles.length >= 10) break;
4043
+ if (roles.length >= 20) break;
2881
4044
  if (!(existingNames.has(t) || [...existingNames].some((n) => n.includes(t))) && (titleCount.get(t) ?? 0) >= 3) {
2882
4045
  existingNames.add(t);
2883
4046
  roles.push({
@@ -2895,18 +4058,18 @@ async function extractRoles(ctx, config, project) {
2895
4058
  return roles;
2896
4059
  }
2897
4060
  /** ✨ AI 从全书提炼场景库:正文/编年录 → 高频重要场景的结构化视觉锚点。 */
2898
- async function extractScenes(ctx, config, project, styleId, filterId) {
4061
+ async function extractScenes(ctx, config, project, chapterNo, styleId, filterId) {
2899
4062
  const styleWords = styleKeywords(styleId, filterId);
2900
4063
  const system = [
2901
- "你是一位网文漫剧场景导演。根据本书的大纲、编年录与已写章节摘录,提炼「镜头场景」——漫剧分镜/生图时每个镜头要知道\"在哪、什么时间光态、拍什么情节、人物什么状态\"。",
2902
- "按五幕结构覆盖全书核心场景(短篇 10 章内可少于五幕,按实际剧情组织):第一幕后场/核心主场、第二幕对外卖场/营业区、第三幕禁忌区域/地下深仓、第四幕外部人间世界(短暂出逃)、第五幕回归与抉择。",
4064
+ "你是一位网文漫剧场景导演。根据指定章节的正文,提炼「镜头场景」——漫剧分镜/生图时每个镜头要知道\"在哪、什么时间光态、拍什么情节、人物什么状态\"。",
4065
+ "当前提取范围:" + (chapterNo !== void 0 ? "第" + chapterNo + "章" : "全书") + "。只提炼本章实际出现的场景,不要提前提取后续章节的场景。",
2903
4066
  "【当前视觉风格】(moment/palette/moods/zh/en 必须按此风格措辞,不能写中性描述):" + styleWords,
2904
4067
  "重要:每个场景的 zh 提示词段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。",
2905
- "每个场景必须是「镜头场景」而非仅场地:给出该场景的关键情节镜头(人物动作+情绪+镜头推进)。",
2906
- "数量控制:最多输出 8 个场景;每个场景必须能对应到正文实际出现过的地点与情节,不得凭空虚构。",
4068
+ "每个场景必须是「镜头场景」而非仅场地:给出该场景的关键情节镜头(人物动作+情绪+镜头推进,只写进 beats,禁止写进 zh);场景生图提示词 zh/en 必须是无人空镜。",
4069
+ "数量控制:本章输出 3-5 个场景;每个场景必须能对应到本章正文实际出现过的地点与情节,不得凭空虚构。",
2907
4070
  "每个场景输出:",
2908
4071
  "1. name:场景名(地点+光态,如「后场通道·装卸口(雨夜)」)。",
2909
- "2. act:幕归属(第一幕后场/第二幕卖场/第三幕深仓/第四幕外界/第五幕抉择)。",
4072
+ "2. act:场景在本章的段落位置(开篇/发展/高潮/结局)。",
2910
4073
  "3. moment:时间光态(夜间闭店后/雨夜/凌晨/频闪灯…)。",
2911
4074
  "4. summary:一句话定位(空间类型/功能/氛围)。",
2912
4075
  "5. beats:关键情节镜头 1-3 条(人物动作+情绪+镜头推进,如\"沈放佝偻站在货架过道,镜头推近,情绪从漠然过渡到压抑悲伤\")。",
@@ -2914,21 +4077,23 @@ async function extractScenes(ctx, config, project, styleId, filterId) {
2914
4077
  "7. elements:环境构成数组 3-6 项(空间结构/陈设/标志物)。",
2915
4078
  "8. palette:色调与光影数组 2-4 项(颜色词,可附 HEX)。",
2916
4079
  "9. moods:氛围关键词 2-4 个(压抑/神秘/空旷/悲凉…)。",
2917
- "10. zh:中文生图提示词(连贯一段,写实电影感,含环境/光线/氛围/人物状态)。",
4080
+ "10. zh:中文生图提示词(连贯一段,写实电影感),必须为【无人物空镜】——只写空间结构/材质/光线/氛围/标志物,严禁任何人物、动作、情节、台词、镜头调度(人物与情节由角色卡和分镜负责,场景底图必须是无人空镜)。",
2918
4081
  "11. en:英文生图提示词(booru 风格逗号分隔,含 photorealistic/cinematic)。",
2919
4082
  "12. tags:3-6 个关键标签。",
2920
4083
  "13. source:依据来源说明(哪几章哪些描写)。",
2921
- "若故事存在结局抉择(如双分支),最后 1-2 个场景应为「抉择场景」,zh 里写明分支 A/B 两种画面。",
2922
- "输出必须是合法 JSON 数组,不要输出其他文字:[{\"name\":\"...\", \"act\":\"...\", \"moment\":\"...\", \"summary\":\"...\", \"beats\":[...], \"characterState\":\"...\", \"elements\":[...], \"palette\":[...], \"moods\":[...], \"zh\":\"...\", \"en\":\"...\", \"tags\":[...], \"source\":\"...\"}]",
4084
+ "14. tier:场景分级——core(核心场景,反复出现≥3次或多章引用,需精修多图)/ secondary(次要场景,出现1-2次,一张全景图够)/ passing(路过场景,只被提到名字,不做图)。按出场重要性自动判断。",
4085
+ "15. negativePrompt:负面提示词(场景专用,必须包含 no people, no characters, no text, no watermark, no logo,可补充场景相关负面词)。",
4086
+ "若本章存在关键转折或抉择,最后 1 个场景应为「转折场景」,beats 里写明关键画面(禁止写进 zh)。",
4087
+ "输出必须是合法 JSON 数组,不要输出其他文字:[{\"name\":\"...\", \"act\":\"...\", \"moment\":\"...\", \"summary\":\"...\", \"beats\":[...], \"characterState\":\"...\", \"elements\":[...], \"palette\":[...], \"moods\":[...], \"zh\":\"...\", \"en\":\"...\", \"tags\":[...], \"source\":\"...\", \"tier\":\"...\", \"negativePrompt\":\"...\"}]",
2923
4088
  "重要:所有字符串值内部不得包含换行符;直接输出 JSON 结果本身。"
2924
4089
  ].join("\n");
2925
- const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
4090
+ const targetChapters = chapterNo !== void 0 ? project.chapters.filter((c) => c.no === chapterNo && c.status !== "pending" && c.status !== "generating") : project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating").slice(0, 4);
2926
4091
  const excerptParts = [];
2927
- for (const chapter of written.slice(0, 4)) {
4092
+ for (const chapter of targetChapters) {
2928
4093
  const body = readChapterFile(config.outputDir, chapter);
2929
4094
  if (body === void 0) continue;
2930
4095
  const text = body.replace(/^#.*$/gm, "").trim();
2931
- if (text.length > 0) excerptParts.push("第" + chapter.no + "章《" + chapter.title + "》\n" + text.slice(0, 1800));
4096
+ if (text.length > 0) excerptParts.push("第" + chapter.no + "章《" + chapter.title + "》\n" + text.slice(0, 3e3));
2932
4097
  }
2933
4098
  const raw = parseJsonArray(await complete(ctx, config, {
2934
4099
  system,
@@ -2939,7 +4104,7 @@ async function extractScenes(ctx, config, project, styleId, filterId) {
2939
4104
  "只输出 JSON 数组。"
2940
4105
  ].filter((s) => s !== "").join("\n"),
2941
4106
  temperature: .4,
2942
- maxTokens: Math.max(config.maxTokens, 12e3),
4107
+ maxTokens: Math.max(config.maxTokens, 32e3),
2943
4108
  reasoning: config.analysisReasoning ?? "low"
2944
4109
  }));
2945
4110
  const strArr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "") : [];
@@ -2963,10 +4128,46 @@ async function extractScenes(ctx, config, project, styleId, filterId) {
2963
4128
  en: ensureStyleEmbedded(str(entry.en).slice(0, 600), styleWords, "en"),
2964
4129
  tags: strArr(entry.tags).map((t) => t.slice(0, 24)).slice(0, 6),
2965
4130
  source: str(entry.source).slice(0, 120),
4131
+ tier: [
4132
+ "core",
4133
+ "secondary",
4134
+ "passing"
4135
+ ].includes(str(entry.tier)) ? str(entry.tier) : "secondary",
4136
+ negativePrompt: str(entry.negativePrompt) !== "" ? str(entry.negativePrompt).slice(0, 300) : "no people, no characters, no text, no watermark, no logo, no subtitles, blurry, low quality, distorted, deformed",
2966
4137
  styleId
2967
4138
  });
2968
4139
  }
2969
- return scenes;
4140
+ const sceneShort = (n) => n.split("·")[0].split("(")[0].split("(")[0].trim();
4141
+ const writtenChapters = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
4142
+ for (const sc of scenes) {
4143
+ const appears = [];
4144
+ const short = sceneShort(sc.name);
4145
+ for (const ch of writtenChapters) {
4146
+ const body = readChapterFile(config.outputDir, ch);
4147
+ if (body === void 0) continue;
4148
+ if (body.includes(sc.name) || short.length >= 2 && body.includes(short)) {
4149
+ if (!appears.includes(ch.no)) appears.push(ch.no);
4150
+ }
4151
+ }
4152
+ appears.sort((a, b) => a - b);
4153
+ sc.appearsInChapters = appears.length > 0 ? appears : sc.appearsInChapters;
4154
+ if (sc.tier === void 0 || sc.tier === "passing" && appears.length > 0) sc.tier = appears.length >= 3 ? "core" : appears.length >= 1 ? "secondary" : "passing";
4155
+ if (sc.negativePrompt === void 0 || sc.negativePrompt === "") sc.negativePrompt = "no people, no characters, no text, no watermark, no logo, no subtitles, blurry, low quality, distorted, deformed";
4156
+ }
4157
+ const existingShorts = new Set((project.scenes ?? []).map((s) => sceneShort(s.name)));
4158
+ let deduped = scenes.filter((sc) => !existingShorts.has(sceneShort(sc.name)));
4159
+ const coreScenes = deduped.filter((s) => s.tier === "core").slice(0, 5);
4160
+ const secondaryScenes = deduped.filter((s) => s.tier === "secondary").slice(0, 5);
4161
+ const passingScenes = deduped.filter((s) => s.tier === "passing").slice(0, 2);
4162
+ deduped = [
4163
+ ...coreScenes,
4164
+ ...secondaryScenes,
4165
+ ...passingScenes
4166
+ ];
4167
+ for (const sc of deduped) try {
4168
+ saveMangaScenePrompt(config.outputDir, sc.name, sc.zh, sc.negativePrompt);
4169
+ } catch {}
4170
+ return deduped;
2970
4171
  }
2971
4172
  /** 从 txt/md 全本文本拆章(纯逻辑,不落盘):识别章节头、剥离重复标题、去重、排序并统一重新编号。 */
2972
4173
  function splitBookText(raw) {
@@ -3239,7 +4440,7 @@ function withStyle(pair, styleWords) {
3239
4440
  * 底层实现:为任意角色源提炼「动漫形象描述词」(不写库,写库由上层调用方负责)。
3240
4441
  * 扫描该角色出场的已写章节正文,截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
3241
4442
  */
3242
- async function extractRoleVisualFrom(ctx, config, project, outputDir, role, styleId, filterId, shortDrama = false) {
4443
+ async function extractRoleVisualFrom(ctx, config, project, outputDir, role, styleId, filterId, shortDrama = false, tier = "protagonist") {
3243
4444
  const roleName = role.name;
3244
4445
  const appearanceHints = /(发|眉|眼|眸|脸|肤|唇|身材|身高|衣|袍|裙|衫|靴|腰带|气质|模样|长相|容貌|披|束|扎|戴|佩|挂|绣|青|白|黑|红|蓝|紫|灰|银|金|少年|青年|少女|汉子|老者|中年|纤细|挺拔|瘦削|壮实|清秀|俊朗|英气|阴鸷|慈眉)/;
3245
4446
  const roleText = `${role.name} ${role.identity ?? ""}`;
@@ -3356,6 +4557,8 @@ async function extractRoleVisualFrom(ctx, config, project, outputDir, role, styl
3356
4557
  "4. 标志物(标签/印记/饰品)必须出现在每段提示词中——它们是一致性的命根子。",
3357
4558
  "5. 立绘/四视图/细节是「角色设定稿」:禁止写瞬间动作与道具使用状态(握手机、看屏幕、未接来电、走路、回头等),禁止写剧情状态与场景背景;只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。",
3358
4559
  ...shortDrama ? ["6. 短剧精简模式·人设极致化:性格标签必须极致化(如「极端偏执」「绝对冷血」,单一维度拉到最满);外貌只保留 1-2 个最强辨识度点,弱化平凡细节;服装/标志物更夸张醒目。"] : [],
4560
+ ...tier === "supporting" ? ["配角模式:只需输出立绘提示词(portrait),不需要四视图/表情/细节,外貌描述精简到80字以内。"] : [],
4561
+ ...tier === "extra" ? ["路人模式:不需要输出精修提示词(promptKit)和表情清单(expressions),只需基础外貌描述(40字以内)和英文标签。"] : [],
3359
4562
  "【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):",
3360
4563
  ...rules.map((r) => "- " + r),
3361
4564
  "输出六部分:",
@@ -3363,14 +4566,15 @@ async function extractRoleVisualFrom(ctx, config, project, outputDir, role, styl
3363
4566
  "- en:英文形象锚点,booru 风格、逗号分隔、小写,30-50 个标签:含性别(1boy/1girl)、发色、发型、瞳色、服装、气质、标志物。不要输出负面提示词。",
3364
4567
  "- tags:中文关键标签数组,5-10 个(如 [\"黑发\",\"束发\",\"青色道袍\",\"清秀\",\"腰悬古玉\"])。",
3365
4568
  "- source:说明依据(如\"第1章/第8章外貌描写;瞳色未明确\")。",
4569
+ "- negativePrompt:即梦/生图通用负面提示词,中文逗号分隔,8-12个词:必须包含 低质量、模糊、变形、多指、断肢、文字、水印、丑陋、比例失调;可根据角色补充(如写实风加 卡通)。",
3366
4570
  "- expressions:该角色在本书剧情中需要的情绪表情清单数组,6-12 个(如 [\"疲惫\",\"麻木\",\"压抑悲伤\",\"皱眉\",\"紧绷\",\"放空\"]),依据正文情绪描写与角色处境推断。",
3367
4571
  "- promptKit:四类精修提示词(字段化+出图约束,参考示例结构):",
3368
4572
  " · portrait 立绘:严格按字段顺序写(参考示例结构):开头构图定位(正面站立全身人像)→ 风格(3D动漫,超精细建模)→ 背景(纯白纯色背景)→ 身份(男子,{角色名},外表{年龄},男性)→ 发型发色 → 胡茬 → 眼眸 → 面部 → 气质 → 上身服装分件(颜色款式+磨损细节)→ 下身 → 鞋 → 标志物(标签/印记,含细节如洇暗翘边)→ 收尾(角色设计稿,细节完整展示,无多余杂物,全身完整无裁切)。",
3369
- " · sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,全身完整,四个视角分别描述,服装与标志物细节完整展示。",
4573
+ " · sheet 角色设定稿:专业角色设计参考图(character design sheet),纯白背景,最高品质细节丰富。结构:1.主视觉区(上方):正面+侧面+背面三视图,直观呈现整体身形、服饰搭配和标志性特征;2.补充信息区(左侧):面部特写+配色板(明确毛发/服饰色值),补充主视角没覆盖的细节与色彩标准;3.局部细节区(底部):小模块单独展示关键部件设计(配饰、点缀、关键身份识别元素),把模糊细节拆分为精准制作参考;4.全身比例照(右侧):黄金比例参考物与人物身高形成对比。画质要求:8K高清纹理,质感光照,自然光线,布料褶皱自然,皮肤纹理细节完整,艺术写实风格,营造震撼视觉效果。人物外观设定按字段:年龄、性别、发型发色、五官(眉/眼/鼻/唇)、脸型、身高、气质、服装分件、标志物。",
3370
4574
  " · expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。",
3371
4575
  " · details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出(如标签/印记/工牌/袖口磨损/鞋),每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。",
3372
4576
  "promptKit 每段 zh:连贯中文 60-150 字;en:booru 标签 30-50 个。",
3373
- "输出必须是合法 JSON 对象:{\"zh\": \"...\", \"en\": \"...\", \"tags\": [...], \"source\": \"...\", \"expressions\": [...], \"promptKit\": {\"portrait\": {\"zh\":\"...\", \"en\":\"...\"}, \"sheet\": {\"zh\":\"...\", \"en\":\"...\"}, \"expressions\": [{\"name\":\"疲惫\",\"zh\":\"...\",\"en\":\"...\"}], \"details\": {\"zh\":\"...\", \"en\":\"...\"}}}",
4577
+ "输出必须是合法 JSON 对象:{\"zh\": \"...\", \"en\": \"...\", \"tags\": [...], \"source\": \"...\", \"negativePrompt\": \"...\", \"expressions\": [...], \"promptKit\": {\"portrait\": {\"zh\":\"...\", \"en\":\"...\"}, \"sheet\": {\"zh\":\"...\", \"en\":\"...\"}, \"expressions\": [{\"name\":\"疲惫\",\"zh\":\"...\",\"en\":\"...\"}], \"details\": {\"zh\":\"...\", \"en\":\"...\"}}}",
3374
4578
  "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
3375
4579
  "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
3376
4580
  ].join("\n"),
@@ -3390,6 +4594,7 @@ async function extractRoleVisualFrom(ctx, config, project, outputDir, role, styl
3390
4594
  let en = typeof raw.en === "string" ? raw.en.trim().slice(0, 1500) : "";
3391
4595
  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) : [];
3392
4596
  const source = typeof raw.source === "string" ? raw.source.trim().slice(0, 300) : "";
4597
+ const negativePrompt = typeof raw.negativePrompt === "string" && raw.negativePrompt.trim() !== "" ? raw.negativePrompt.trim().slice(0, 300) : "低质量,模糊,变形,多指,断肢,文字,水印,丑陋,比例失调";
3393
4598
  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) : [];
3394
4599
  const str = (v) => typeof v === "string" ? v.trim() : "";
3395
4600
  const pair = (v) => {
@@ -3420,19 +4625,38 @@ async function extractRoleVisualFrom(ctx, config, project, outputDir, role, styl
3420
4625
  zh = ensureStyleEmbedded(zh, styleWords, "zh");
3421
4626
  en = ensureStyleEmbedded(en, styleWords, "en");
3422
4627
  if (promptKit !== void 0) promptKit = {
3423
- portrait: withStyle(promptKit.portrait, styleWords),
3424
- sheet: withStyle(promptKit.sheet, styleWords),
3425
- expressions: promptKit.expressions.map((e) => ({
4628
+ portrait: promptKit.portrait !== void 0 ? withStyle(promptKit.portrait, styleWords) : void 0,
4629
+ sheet: promptKit.sheet !== void 0 ? withStyle(promptKit.sheet, styleWords) : void 0,
4630
+ expressions: promptKit.expressions !== void 0 ? promptKit.expressions.map((e) => ({
3426
4631
  ...e,
3427
4632
  ...withStyle(e, styleWords)
3428
- })),
3429
- details: withStyle(promptKit.details, styleWords)
4633
+ })) : void 0,
4634
+ details: promptKit.details !== void 0 ? withStyle(promptKit.details, styleWords) : void 0
4635
+ };
4636
+ if (tier === "supporting") return {
4637
+ zh: zh.slice(0, 100),
4638
+ en,
4639
+ tags,
4640
+ source,
4641
+ negativePrompt,
4642
+ expressions: [],
4643
+ promptKit: promptKit !== void 0 ? { portrait: promptKit.portrait } : void 0
4644
+ };
4645
+ if (tier === "extra") return {
4646
+ zh: zh.slice(0, 60),
4647
+ en,
4648
+ tags,
4649
+ source,
4650
+ negativePrompt,
4651
+ expressions: [],
4652
+ promptKit: void 0
3430
4653
  };
3431
4654
  return {
3432
4655
  zh,
3433
4656
  en,
3434
4657
  tags,
3435
4658
  source,
4659
+ negativePrompt,
3436
4660
  expressions,
3437
4661
  promptKit
3438
4662
  };
@@ -3452,38 +4676,39 @@ async function extractMangaRoleVisual(ctx, config, project, outputDir, cardId, s
3452
4676
  name: card.name,
3453
4677
  identity: card.identity,
3454
4678
  traits: card.traits
3455
- }, styleId, filterId, project.shortDramaMode === true);
4679
+ }, styleId, filterId, project.shortDramaMode === true, card.tier ?? "protagonist");
3456
4680
  card.promptStyleId = styleId;
3457
4681
  card.imagePrompt = visual;
3458
4682
  if ((visual.expressions ?? []).length > 0) card.expressions = visual.expressions;
3459
4683
  if (visual.promptKit !== void 0) card.promptKit = visual.promptKit;
3460
4684
  if (card.status === "imported" || card.status === "pending_confirm") card.status = "anchored";
3461
4685
  card.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4686
+ try {
4687
+ saveMangaRolePrompt(outputDir, card.name, visual.zh, visual.en, visual.negativePrompt);
4688
+ } catch {}
3462
4689
  return visual;
3463
4690
  }
3464
4691
  /**
3465
4692
  * 底层实现:基于角色源的形象锚点 + 表情清单 + 视觉规则产出四类生图提示词(不写库)。
3466
4693
  */
3467
- async function generateRolePromptKitFrom(ctx, config, project, role, styleId, filterId, shortDrama = false) {
4694
+ async function generateRolePromptKitFrom(ctx, config, project, role, styleId, filterId, shortDrama = false, tier = "protagonist") {
3468
4695
  role.name;
3469
4696
  const rules = project.visualRules ?? [];
3470
4697
  const styleWords = styleKeywords(styleId, filterId);
3471
4698
  const raw = parseJsonObject(await complete(ctx, config, {
3472
4699
  system: [
3473
- "你是一位 AI 绘图提示词工程师。基于给定角色的形象锚点、表情清单与本书视觉规则,输出四类生图提示词(每类 zh+en 各一段)。",
4700
+ "你是一位 AI 绘图提示词工程师。基于给定角色的形象锚点、表情清单与本书视觉规则,输出立绘+表情两类生图提示词(每类 zh+en 各一段)。即梦画布支持多角度编辑,无需生成四视图设定稿。",
3474
4701
  "【当前视觉风格】(每类提示词必须内嵌):" + styleWords,
3475
4702
  "重要:每段 zh 提示词的段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。",
3476
- "四类:",
3477
- "1. portrait 立绘:正面站立全身人像,3D动漫超精细建模,纯白纯色背景;按字段流组织:身份(男子,角色名,外表年龄)→ 发型发色 → 胡茬 → 眼眸 → 面部 → 气质 → 上身服装分件(含磨损)→ 下身 → 鞋 → 标志物(含细节)→ 收尾(角色设计稿,细节完整展示,无多余杂物,全身完整无裁切)。",
3478
- "2. sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,四个视角分别描述。",
3479
- "3. expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。",
3480
- "4. details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出,每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。",
3481
- "5. 立绘/四视图/细节为「角色设定稿」:禁止瞬间动作、道具使用状态与剧情状态(握手机、看屏幕、未接来电等),只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。",
4703
+ "两类:",
4704
+ "1. portrait 立绘:正面站立全身人像,纯白纯色背景;按即梦官方7维度公式组织:①[年龄/种族]具体岁数+国籍/人种+风格形容词+脸型名词;②[肤色/皮肤质感]冷暖色调+具体肤色+皮肤质感形容词+保留真实微细毛孔与肌肤纹理;③[面部细节特征]眼型+眉骨+鼻梁+唇形+下颌线(至少3-4点组合);④[眼神/灵魂]眼神形容词+目光传达的信息+透出的底层情绪;⑤[发型/发色]具体发色+头发状态/质感+具体发型+环境互动;⑥[服装/服装质感]版型/剪裁+颜色+具体服装名词+布料材质/新旧状态+穿着细节;⑦[体型/情绪/气质]骨架/肩部特征+整体散发的氛围词。收尾:角色设计稿,细节完整展示,无多余杂物,全身完整无裁切。",
4705
+ "2. expressions 表情(高级可选):每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。",
4706
+ "3. 立绘为「角色设定稿」:禁止瞬间动作、道具使用状态与剧情状态(握手机、看屏幕、未接来电等),只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。即梦画布多角度编辑可生成侧面/背面,无需生成四视图。",
3482
4707
  ...shortDrama ? ["6. 短剧精简模式·人设极致化:性格标签必须极致化(单一维度拉到最满);外貌只保留 1-2 个最强辨识度点;服装/标志物更夸张醒目,一眼可认。"] : [],
3483
4708
  "【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):",
3484
4709
  ...rules.map((r) => "- " + r),
3485
4710
  "zh 要求:连贯中文,写实电影感,60-150 字/段;en 要求:booru 风格逗号分隔标签,30-50 个/段。",
3486
- "输出必须是合法 JSON 对象:{\"portrait\": {\"zh\": \"...\", \"en\": \"...\"}, \"sheet\": {\"zh\": \"...\", \"en\": \"...\"}, \"expressions\": [{\"name\": \"疲惫\", \"zh\": \"...\", \"en\": \"...\"}], \"details\": {\"zh\": \"...\", \"en\": \"...\"}}",
4711
+ "输出必须是合法 JSON 对象:{\"portrait\": {\"zh\": \"...\", \"en\": \"...\"}, \"expressions\": [{\"name\": \"疲惫\", \"zh\": \"...\", \"en\": \"...\"}]}",
3487
4712
  "重要:所有字符串值内部不得包含换行符;直接输出 JSON 结果本身。"
3488
4713
  ].join("\n"),
3489
4714
  user: [
@@ -3509,17 +4734,17 @@ async function generateRolePromptKitFrom(ctx, config, project, role, styleId, fi
3509
4734
  const expressionsRaw = Array.isArray(raw.expressions) ? raw.expressions.filter((e) => typeof e === "object" && e !== null) : [];
3510
4735
  const kit = {
3511
4736
  portrait: withStyle(pair(raw.portrait), styleWords),
3512
- sheet: withStyle(pair(raw.sheet), styleWords),
3513
4737
  expressions: expressionsRaw.map((e) => ({
3514
4738
  name: str(e.name).slice(0, 12) || "表情",
3515
4739
  ...withStyle({
3516
4740
  zh: str(e.zh).slice(0, 800),
3517
4741
  en: str(e.en).slice(0, 1200)
3518
4742
  }, styleWords)
3519
- })).slice(0, 12),
3520
- details: withStyle(pair(raw.details), styleWords)
4743
+ })).slice(0, 12)
3521
4744
  };
3522
- if (kit.portrait.zh === "" || kit.portrait.en === "") throw new Error("提示词精修失败:LLM 未返回有效 JSON");
4745
+ if (kit.portrait === void 0 || kit.portrait.zh === "" || kit.portrait.en === "") throw new Error("提示词精修失败:LLM 未返回有效 JSON");
4746
+ if (tier === "supporting") return { portrait: kit.portrait };
4747
+ if (tier === "extra") return {};
3523
4748
  return kit;
3524
4749
  }
3525
4750
  /** 小说角色库:角色四类生图提示词精修包(写回角色卡)。 */
@@ -3545,7 +4770,7 @@ async function generateMangaRolePromptKit(ctx, config, project, cardId, styleId,
3545
4770
  identity: card.identity,
3546
4771
  imagePrompt: card.imagePrompt,
3547
4772
  expressions: card.expressions
3548
- }, styleId, filterId, project.shortDramaMode === true);
4773
+ }, styleId, filterId, project.shortDramaMode === true, card.tier ?? "protagonist");
3549
4774
  card.promptStyleId = styleId;
3550
4775
  card.promptKit = kit;
3551
4776
  if (card.status === "imported" || card.status === "pending_confirm") card.status = "anchored";
@@ -3576,12 +4801,15 @@ async function nominateMangaRoles(ctx, config, project, outputDir, chapterNo) {
3576
4801
  imported.add(c.name);
3577
4802
  if (c.sourceRoleName !== void 0 && c.sourceRoleName !== "") imported.add(c.sourceRoleName);
3578
4803
  }
3579
- const already = names.filter((n) => imported.has(n)).map((n) => ({
3580
- rawName: n,
3581
- verdict: "already_imported",
3582
- matches: [],
3583
- suggested: emptyCandidateSuggestion(n)
3584
- }));
4804
+ const already = names.filter((n) => imported.has(n)).map((n) => {
4805
+ return {
4806
+ rawName: n,
4807
+ verdict: "already_imported",
4808
+ matches: [],
4809
+ tier: (project.mangaRoles ?? []).find((c) => c.name === n || c.sourceRoleName === n)?.tier ?? "supporting",
4810
+ suggested: emptyCandidateSuggestion(n)
4811
+ };
4812
+ });
3585
4813
  const fresh = names.filter((n) => !imported.has(n));
3586
4814
  if (fresh.length === 0) return already;
3587
4815
  const chapter = project.chapters.find((c) => c.no === chapterNo);
@@ -3617,7 +4845,7 @@ async function nominateMangaRoles(ctx, config, project, outputDir, chapterNo) {
3617
4845
  "你是一位漫剧选角导演。下面给出「分镜角色提名」与「小说角色库候选名单」。",
3618
4846
  "任务:为每个提名判定它对应小说角色库中的哪一个候选(或判定小说库没有对应角色),并预填一张「漫剧角色卡」的建议信息。",
3619
4847
  "规则:",
3620
- "1. roleName 只能从该提名的候选名单中选,禁止输出名单之外的名字(不做开放检索)。",
4848
+ "1. roleName 优先从该提名的候选名单中选;若候选名单为空或都不像,允许从下方「全书角色库」中挑选最符合该身份代称的正式角色名,禁止虚构不存在的角色。",
3621
4849
  "2. verdict:matched=候选名单里确实有对应角色(选最像的那个);ambiguous=名单里有多个候选且无法确定(此时 roleName 可留空或选最可能的一个);not_in_library=候选名单为空或都不对应。",
3622
4850
  "3. matched/ambiguous 时给出漫剧卡建议:name(漫剧用名,默认与角色名一致)、identity(身份一句话,30 字内)、coreFunction(protagonist=主角/mentor=导师/love_interest=感情线/antagonist=反派/sidekick=搭档/informant=线人/functional=功能性)、protagonistRelation(enemy/friend/mentor/lover/exploit=利用/neutral)、speechStyle(口头禅或说话方式)、traits(不超过 3 个极致性格标签)、appearance(1-2 个辨识度外貌点)、keyScenes(本章该角色 1-2 个关键剧情节点,格式「第N章 xxx」)。",
3623
4851
  "4. 身份型提名(如「持枪者」「围观群众」):候选名单有则匹配;名单没有且正文确有该称谓 → not_in_library;正文也没有 → not_in_library。",
@@ -3631,6 +4859,7 @@ async function nominateMangaRoles(ctx, config, project, outputDir, chapterNo) {
3631
4859
  beats !== "" ? beats : "(无骨架节拍)",
3632
4860
  "==== 角色提名与候选名单 ====",
3633
4861
  shortlists.map((s) => s.roleNames.length > 0 ? `[提名] ${s.rawName} → 候选:${s.roleNames.join("、")}` : `[提名] ${s.rawName} → 候选:(无)`).join("\n"),
4862
+ "==== 全书角色库(身份代称归属判定以这里为准,正式名只能从这里取) ====\n" + roles.map((r) => `${r.name}(${r.roleLabel}):${r.identity ?? ""};特征:${(r.traits ?? []).slice(0, 3).join("/")}`).join("\n"),
3634
4863
  body !== void 0 ? "==== 章节正文(前 2500 字,判断称谓归属用) ====\n" + body.replace(/^#.*$/gm, "").trim().slice(0, 2500) : "",
3635
4864
  "只输出 JSON 数组。"
3636
4865
  ].filter((x) => x !== "").join("\n\n"),
@@ -3664,17 +4893,30 @@ async function nominateMangaRoles(ctx, config, project, outputDir, chapterNo) {
3664
4893
  for (const e of raw) if (typeof e === "object" && e !== null && typeof e.rawName === "string") byName.set(e.rawName.trim(), e);
3665
4894
  const str = (v) => typeof v === "string" ? v.trim().slice(0, 100) : "";
3666
4895
  const strArr = (v, n) => Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "").map((x) => x.trim().slice(0, 20)).slice(0, n) : [];
4896
+ const calcTier = (rawName, matched, fn) => {
4897
+ if (matched !== void 0) {
4898
+ const r = roles.find((x) => x.name === matched);
4899
+ if (r?.roleLabel === "protagonist" || r?.roleLabel === "female_lead") return "protagonist";
4900
+ if (r?.roleLabel === "antagonist") return "protagonist";
4901
+ if (r?.roleLabel === "support" || r?.roleLabel === "female_support") return "supporting";
4902
+ }
4903
+ if (fn === "protagonist") return "protagonist";
4904
+ if (fn === "antagonist" || fn === "love_interest") return "protagonist";
4905
+ if (fn === "functional") return "extra";
4906
+ return "supporting";
4907
+ };
3667
4908
  const out = [];
3668
4909
  for (const item of shortlists) {
3669
4910
  const judge = byName.get(item.rawName);
3670
4911
  const judgeVerdict = judge !== void 0 ? str(judge.verdict) : "";
3671
- const verdict = verdicts.has(judgeVerdict) ? judgeVerdict : item.roleNames.length > 0 ? "ambiguous" : "not_in_library";
4912
+ let verdict = verdicts.has(judgeVerdict) ? judgeVerdict : item.roleNames.length > 0 ? "ambiguous" : "not_in_library";
3672
4913
  let roleName;
3673
4914
  if (judge !== void 0) {
3674
4915
  const chosen = str(judge.roleName);
3675
- if (chosen !== "" && item.roleNames.includes(chosen)) roleName = chosen;
4916
+ if (chosen !== "" && (item.roleNames.includes(chosen) || roles.some((r) => r.name === chosen))) roleName = chosen;
3676
4917
  }
3677
4918
  if (verdict === "matched" && roleName === void 0 && item.roleNames.length === 1) roleName = item.roleNames[0];
4919
+ if (roleName !== void 0 && verdict === "not_in_library") verdict = "matched";
3678
4920
  const sug = typeof judge?.suggested === "object" && judge?.suggested !== null ? judge.suggested : {};
3679
4921
  const novelHint = verdict === "not_in_library" && body !== void 0 ? body.includes(item.rawName) ? "backfill" : "manga_new" : void 0;
3680
4922
  const suggested = {
@@ -3696,6 +4938,7 @@ async function nominateMangaRoles(ctx, config, project, outputDir, chapterNo) {
3696
4938
  })),
3697
4939
  matchedRoleName: roleName,
3698
4940
  novelHint,
4941
+ tier: calcTier(item.rawName, roleName, suggested.coreFunction),
3699
4942
  suggested
3700
4943
  });
3701
4944
  }
@@ -3941,55 +5184,6 @@ async function breakdownBook(ctx, config, project, outputDir, scope = "recent",
3941
5184
  usedTokens
3942
5185
  };
3943
5186
  }
3944
- /** 生图接口连通性测试(设置页模型条目用):GET {baseURL}/models,计时返回延迟。 */
3945
- async function testImageEndpoint(baseURL, apiKey, model) {
3946
- const trimmed = baseURL.trim();
3947
- if (trimmed === "") return {
3948
- ok: false,
3949
- ms: 0,
3950
- message: "接口地址不能为空"
3951
- };
3952
- const url = trimmed.replace(/\/+$/, "") + "/models";
3953
- const start = Date.now();
3954
- try {
3955
- const res = await fetch(url, {
3956
- method: "GET",
3957
- headers: {
3958
- Authorization: "Bearer " + apiKey,
3959
- "Content-Type": "application/json"
3960
- }
3961
- });
3962
- const ms = Date.now() - start;
3963
- if (!res.ok) {
3964
- const text = (await res.text().catch(() => "")).slice(0, 200);
3965
- return {
3966
- ok: false,
3967
- ms,
3968
- message: "HTTP " + res.status + (text !== "" ? ":" + text : "")
3969
- };
3970
- }
3971
- let modelFound;
3972
- if (model !== void 0 && model !== "") {
3973
- const list = (await res.json().catch(() => null))?.data;
3974
- if (Array.isArray(list)) modelFound = list.some((m) => {
3975
- if (typeof m !== "object" || m === null) return false;
3976
- const o = m;
3977
- return o.id === model || o.model === model;
3978
- });
3979
- }
3980
- return {
3981
- ok: true,
3982
- ms,
3983
- modelFound
3984
- };
3985
- } catch (err) {
3986
- return {
3987
- ok: false,
3988
- ms: Date.now() - start,
3989
- message: err.message
3990
- };
3991
- }
3992
- }
3993
5187
  /** LLM 连通性失败的错误码 → 人话(供设置页“测试连通”回显)。 */
3994
5188
  const LLM_TEST_ERROR_HINT = {
3995
5189
  NO_ADAPTER: "提供商路由不存在或未启用",
@@ -4209,67 +5403,14 @@ async function registerLlmModel(ctx, req) {
4209
5403
  message
4210
5404
  };
4211
5405
  }
4212
- /** 底层:按形象锚点调用生图接口生成定妆图(不写库;写库由上层调用方负责)。 */
4213
- async function generateReferenceImageFrom(config, anchor, style = "", modelId) {
4214
- if (config.imageApiEnabled !== true) throw new Error("生图未启用,请在设置中启用一个生图模型");
4215
- const activeImage = (modelId !== void 0 && modelId !== "" ? (config.imageModels ?? []).find((m) => m.id === modelId) : void 0) ?? (config.imageModels ?? []).find((m) => m.enabled) ?? (config.imageModels ?? [])[0];
4216
- const apiKey = activeImage?.apiKey ?? config.imageApiKey;
4217
- const model = activeImage?.model ?? config.imageApiModel;
4218
- const baseURL = (activeImage?.baseURL ?? config.imageBaseUrl ?? "").replace(/\/+$/, "");
4219
- if (baseURL === "") throw new Error("请先在设置中填写生图接口地址");
4220
- if (apiKey === void 0 || apiKey === "" || model === void 0 || model === "") throw new Error("请先在设置中配置生图模型的 API Key 和模型 id");
4221
- const stylePrompt = getComicStylePrompt(style);
4222
- const prompt = [
4223
- anchor.zh,
4224
- anchor.en,
4225
- anchor.tags.join(", "),
4226
- stylePrompt,
4227
- "character reference sheet, front view, clean background, consistent design"
4228
- ].filter(Boolean).join(", ");
4229
- const res = await fetch(baseURL + "/images/generations", {
4230
- method: "POST",
4231
- headers: {
4232
- "Content-Type": "application/json",
4233
- Authorization: `Bearer ${apiKey}`
4234
- },
4235
- body: JSON.stringify({
4236
- model,
4237
- prompt,
4238
- response_format: "url",
4239
- size: "2K",
4240
- stream: false,
4241
- watermark: false
4242
- })
4243
- });
4244
- if (!res.ok) {
4245
- const text = await res.text();
4246
- throw new Error(`生图失败(${res.status}):${text}`);
4247
- }
4248
- const url = (await res.json()).data?.[0]?.url;
4249
- if (url === void 0 || url === "") throw new Error("生图接口未返回图片 URL");
4250
- const imgRes = await fetch(url);
4251
- if (!imgRes.ok) throw new Error("下载生图结果失败");
4252
- const buf = Buffer.from(await imgRes.arrayBuffer());
4253
- return `data:${imgRes.headers.get("content-type") || "image/jpeg"};base64,${buf.toString("base64")}`;
4254
- }
4255
- /** 小说角色库:生成角色定妆图并写回角色卡(imageUrl)。 */
4256
- async function generateRoleReferenceImage(ctx, config, project, outputDir, name, style = "", modelId) {
4257
- const role = project.roles?.find((r) => r.name === name);
4258
- if (role === void 0) throw new Error(`角色 ${name} 不存在`);
4259
- if (role.imagePrompt === void 0) throw new Error(`角色 ${name} 还没有形象锚点,请先生成锚点`);
4260
- const dataUrl = await generateReferenceImageFrom(config, role.imagePrompt, style, modelId);
4261
- role.imageUrl = dataUrl;
4262
- return dataUrl;
4263
- }
4264
- /** 漫剧角色卡:生成定妆图并写回漫剧卡(imageUrl)。 */
4265
- async function generateMangaRoleReferenceImage(ctx, config, project, outputDir, cardId, style = "", modelId) {
4266
- const card = (project.mangaRoles ?? []).find((c) => c.id === cardId);
4267
- if (card === void 0) throw new Error(`漫剧角色卡 ${cardId} 不存在`);
4268
- if (card.imagePrompt === void 0) throw new Error(`「${card.name}」还没有形象锚点,请先生成锚点`);
4269
- const dataUrl = await generateReferenceImageFrom(config, card.imagePrompt, style, modelId);
4270
- card.imageUrl = dataUrl;
4271
- card.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4272
- return dataUrl;
5406
+ /** 获取当前激活漫剧方案的风格(styleId + filterId),用于生图/提示词兜底。 */
5407
+ function getActiveMangaStyle(project) {
5408
+ const active = (project.mangaPlans ?? []).find((p) => p.active === true);
5409
+ if (active === void 0) return {};
5410
+ return {
5411
+ styleId: active.styleId,
5412
+ filterId: active.filterId
5413
+ };
4273
5414
  }
4274
5415
  /** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
4275
5416
  async function analyzePlotlineHealth(ctx, config, project) {
@@ -4370,8 +5511,8 @@ async function designPlotlinePlan(ctx, config, project, health) {
4370
5511
  };
4371
5512
  }
4372
5513
  /** Build the rewrite system prompt (fix review issues / instructions). */
4373
- function rewriteSystemPrompt(project) {
4374
- return writeSystemPrompt(project) + "\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。";
5514
+ function rewriteSystemPrompt(project, targetChars) {
5515
+ return writeSystemPrompt(project, targetChars, "1. 输出完整的新正文(不要只输出修改片段、标题、章回名、作者的话或任何 Markdown 标记),字数与原章相当(允许 ±20%)。") + "\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。";
4375
5516
  }
4376
5517
  /**
4377
5518
  * Stream a chapter rewrite. With `target` (a passage of the body), only that
@@ -4417,7 +5558,29 @@ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo,
4417
5558
  "2. 保留该段的情节走向与角色口吻,只按修改要求调整。",
4418
5559
  "3. 段落长度与原文相当。"
4419
5560
  ].filter((line) => line !== "").join("\n");
4420
- const system = localTarget === void 0 ? rewriteSystemPrompt(project) : "你是一位中文网文润色师。你会收到一章中的一个段落,请按修改要求重写该段。只输出新段落文本。";
5561
+ const system = localTarget === void 0 ? rewriteSystemPrompt(project, chapter.targetChars || config.chapterChars) : (() => {
5562
+ const bible = project.bible;
5563
+ const lines = [
5564
+ "你是一位中文网文润色师。你会收到一章中的一个段落,请按修改要求重写该段。",
5565
+ "硬性约束:",
5566
+ "1. 只改表达,不改情节走向、人物设定、已确立事实、对话核心内容。",
5567
+ "2. 必须遵守下方「内容合规红线」,任何一条命中(含影射、暗示)都必须避免。",
5568
+ "3. 必须遵守下方「本书红线」(如有)。",
5569
+ "4. 避免 AI 套话:不禁、仿佛、一时间、不由得、顿时、然而、缓缓、轻轻、微微、默默、似乎、终于等滥用。",
5570
+ "5. 保留角色口吻与性格,角色行为需符合下方角色卡(如有)。",
5571
+ "6. 只输出修改后的【这一个段落】的完整新文本,不要输出任何说明、标题或 Markdown 标记。"
5572
+ ];
5573
+ if (bible !== void 0) {
5574
+ if (bible.redLines.length > 0) lines.push("本书红线:\n" + bible.redLines.map((r) => "- " + r).join("\n"));
5575
+ if (bible.characters.length > 0) {
5576
+ lines.push("相关角色卡:");
5577
+ for (const card of bible.characters) lines.push("- " + card.name + "(" + card.role + "):" + card.traits.join("、"));
5578
+ }
5579
+ }
5580
+ lines.push("内容合规红线(平台硬性要求,最高优先级):");
5581
+ lines.push(COMPLIANCE_REDLINES.join("\n"));
5582
+ return lines.join("\n");
5583
+ })();
4421
5584
  const messages = [createUserMessage({
4422
5585
  content: [{
4423
5586
  type: "text",
@@ -4474,16 +5637,22 @@ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo,
4474
5637
  /** The de-AI-ify polish system prompt (with project writing assets injected). */
4475
5638
  function polishSystemPrompt(project) {
4476
5639
  const assetsBlock = renderAllAssets(project.assets);
4477
- return [
5640
+ const bible = project.bible;
5641
+ const lines = [
4478
5642
  "你是一位中文网文润色师。你会收到一章正文,请做「去 AI 味」润色:",
4479
5643
  "1. 删除/替换 AI 高频套话与模式词:如\"不禁\"\"仿佛\"\"一时间\"\"不由得\"\"顿时\"\"然而\"\"缓缓\"\"轻轻\"\"微微\"\"默默\"\"似乎\"\"终于\"等滥用。",
4480
5644
  "2. 把书面翻译腔改成口语化的中文网文语感。",
4481
5645
  "3. 拆分过长的排比句与堆砌的修饰语。",
4482
- "4. 保留全部情节、人物、对话内容不变,只改表达。",
5646
+ "4. 保留全部情节、人物、对话内容、已确立事实不变,只改表达。",
4483
5647
  "5. 输出完整的新正文,不要输出任何说明文字或 Markdown 标记。",
4484
5648
  "6. 必须遵守下方「反 AI 规则」与「写法资产」的表达边界;写法资产要求保留的风格特征(句式、台词、节奏)不得在润色中丢失。",
4485
- assetsBlock !== "" ? assetsBlock : ""
4486
- ].join("\n");
5649
+ "7. 必须遵守下方「内容合规红线」与「本书红线」(如有),任何一条命中(含影射、暗示)都必须避免。"
5650
+ ];
5651
+ if (bible !== void 0 && bible.redLines.length > 0) lines.push("本书红线:\n" + bible.redLines.map((r) => "- " + r).join("\n"));
5652
+ lines.push("内容合规红线(平台硬性要求,最高优先级):");
5653
+ lines.push(COMPLIANCE_REDLINES.join("\n"));
5654
+ if (assetsBlock !== "") lines.push(assetsBlock);
5655
+ return lines.join("\n");
4487
5656
  }
4488
5657
  /** Stream a chapter polish (de-AI-ify). Draft-mode: the polished body lands
4489
5658
  * in `chapter.pendingDraft` and is only applied on draft/apply. */
@@ -4601,7 +5770,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
4601
5770
  provider: config.provider,
4602
5771
  model: config.model,
4603
5772
  messages,
4604
- system: writeSystemPrompt(project),
5773
+ system: writeSystemPrompt(project, chapter.targetChars || config.chapterChars),
4605
5774
  maxTokens: Math.max(config.maxTokens, 2e4),
4606
5775
  temperature: .85
4607
5776
  };
@@ -4630,10 +5799,13 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
4630
5799
  chapter.error = void 0;
4631
5800
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4632
5801
  saveProject(outputDir, project);
5802
+ const target = chapter.targetChars > 0 ? chapter.targetChars : config.chapterChars;
5803
+ const warn = target > 0 ? body.length < target * .8 ? `第${chapter.no}章实际 ${body.length} 字,明显少于目标 ${target} 字` : body.length > target * 1.25 ? `第${chapter.no}章实际 ${body.length} 字,明显多于目标 ${target} 字` : void 0 : void 0;
4633
5804
  yield {
4634
5805
  frame: "done",
4635
5806
  file: fileName,
4636
- chars: body.length
5807
+ chars: body.length,
5808
+ warn
4637
5809
  };
4638
5810
  }
4639
5811
  /** Generate a chapter summary (narrative memory). */
@@ -4727,7 +5899,8 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
4727
5899
  if (body === void 0) throw new Error(`章节 ${chapterNo} 的正文文件不存在`);
4728
5900
  if ((skeleton.beats ?? []).length === 0) throw new Error("骨架为空,请先生成剧情骨架");
4729
5901
  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 ?? ""}`);
4730
- const usedScenes = (project.scenes ?? []).filter((s) => body.includes(s.name)).slice(0, 6);
5902
+ const sceneShortName = (n) => n.split("·")[0].split("(")[0].split("(")[0].trim();
5903
+ const usedScenes = (project.scenes ?? []).filter((s) => body.includes(s.name) || body.includes(sceneShortName(s.name))).slice(0, 6);
4731
5904
  const scenes = usedScenes.map((s) => `${s.name}:${s.summary ?? ""}`);
4732
5905
  const bindings = buildMangaRoleBindings(project);
4733
5906
  const mangaLines = (bindings.byId.size > 0 ? [...bindings.byId.values()].filter((c) => body.includes(c.name) || c.sourceRoleName !== void 0 && body.includes(c.sourceRoleName)).slice(0, 8) : []).map((c) => {
@@ -4746,7 +5919,7 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
4746
5919
  system: [
4747
5920
  "你是一位从业 10 年的电影导演兼分镜师,专长网文改编影视化。",
4748
5921
  "任务:把「剧情骨架」的每一个节拍展开为 1-3 个电影镜头,输出分镜表——只做画面层,禁止新增或改变剧情(骨架是只读输入)。",
4749
- "输出合法 JSON 对象:{\"shots\": [{\"beatId\": \"骨架节拍id\", \"shot\": \"景别\", \"camera\": \"机位与运镜\", \"composition\": \"构图\", \"duration\": 秒数, \"visual\": \"画面内容\", \"line\": \"台词/旁白\", \"sound\": \"音效\", \"light\": \"光效\", \"prevState\": \"承接上一镜头结尾状态\", \"nextState\": \"本镜头结束状态\", \"characters\": [\"出镜角色名\"]}]}",
5922
+ "输出合法 JSON 对象:{\"shots\": [{\"beatId\": \"骨架节拍id\", \"shot\": \"景别\", \"camera\": \"机位与运镜\", \"composition\": \"构图\", \"duration\": 秒数, \"visual\": \"画面内容\", \"line\": \"台词/旁白\", \"sound\": \"音效\", \"light\": \"光效\", \"prevState\": \"承接上一镜头结尾状态\", \"nextState\": \"本镜头结束状态\", \"jimengCamera\": \"即梦运镜自然语言描述\", \"characters\": [\"出镜角色名\"]}]}",
4750
5923
  "景别取值(只能从这些词中选一):大远景/远景/全景/中景/中近景/近景/特写/大特写。",
4751
5924
  "运镜取值(可组合,用加号连接):固定机位/推近/拉远/左摇/右摇/左横移/右横移/跟随/升镜/降镜/环绕/手持晃动/低机位仰拍/高机位俯拍/过肩镜头。",
4752
5925
  "光效取值(可组合,用加号连接):顺光/侧光/逆光/顶光/伦勃朗光/霓虹光/硬光/柔光/氛围光/高反差。",
@@ -4754,14 +5927,18 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
4754
5927
  "硬性要求:",
4755
5928
  "【视觉风格】(必须内嵌进 visual/light 的画面措辞与光效描述):",
4756
5929
  ...styleLines,
4757
- "1. 按骨架节拍顺序输出镜头,每个节拍至少 1 个镜头,总镜头数 8-25。",
5930
+ "1. 按骨架节拍顺序输出镜头,每个节拍至少 1 个镜头,总镜头数 8-15。",
4758
5931
  "2. 镜头间连续:下一镜头的 prevState 必须与上一镜头的 nextState 一致(人物位置/动作/情绪/服装),禁止瞬移、服装消失、情绪跳变。",
4759
- "3. visual 必须写明:角色动作 + 表情 + 服装/标志物(标志物来自下方视觉规则与角色锚点,逐镜头保持)。",
5932
+ "3. visual 必须写明:主体位置(左/中/右/前景/背景)+ 角色动作 + 表情 + 服装/标志物(标志物来自下方视觉规则与角色锚点,逐镜头保持)。",
4760
5933
  "4. 台词/音效/光效无则空字符串,不要编造。",
4761
- "5. duration 1-12 秒。",
5934
+ "5. duration 只能取 5/6/7/8/10 五个值(即梦单条视频时长上限)。",
4762
5935
  "6. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束,不要输出其他文字。",
4763
5936
  "7. 每个镜头必须输出 characters:本镜头出镜角色名数组(1-4 个),从「出场角色锚点」或正文中选取,使用正文里的确切称谓(如「周野」「周野的律师」),禁止自造名/缩写;路人或群像可用身份词(如「围观群众」)。",
4764
- "8. 人物外观/服装/标志物必须遵守「漫剧定妆卡」与「出场角色锚点」:同一角色逐镜头保持完全一致(服装、发色、标志物禁止更换);定妆卡未覆盖的路人可用通用描述。"
5937
+ "8. 人物外观/服装/标志物必须遵守「漫剧定妆卡」与「出场角色锚点」:同一角色逐镜头保持完全一致(服装、发色、标志物禁止更换);定妆卡未覆盖的路人可用通用描述。",
5938
+ "9. jimengCamera:即梦运镜自然语言描述,必须写具体起止(如「镜头从中景缓慢推进到近景」「镜头从左向右缓慢横移」),禁止只写「推近」「拉远」;静止镜头写「固定机位」。",
5939
+ "10. 单镜只承载一个连续动作,动作必须在5-10秒内可完成;禁止复杂打斗、多人群舞、快速切换场景。",
5940
+ "11. 画面禁止出现任何文字、字幕、水印、符号、UI界面、屏幕显示内容。",
5941
+ "12. 单镜出镜角色不超过2个主要角色,多人同框时必须明确谁是画面主体。"
4765
5942
  ].join("\n"),
4766
5943
  user: [
4767
5944
  `章节《${chapter.title}》(第 ${chapter.no} 章)`,
@@ -4786,14 +5963,24 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
4786
5963
  shot: normalizeShotSize(entry.shot),
4787
5964
  camera: normalizeCameras(entry.camera),
4788
5965
  composition: normalizeComposition(entry.composition),
4789
- duration: typeof entry.duration === "number" && entry.duration >= 1 && entry.duration <= 12 ? Math.round(entry.duration) : 4,
5966
+ duration: (() => {
5967
+ const d = typeof entry.duration === "number" ? Math.round(entry.duration) : 0;
5968
+ return [
5969
+ 5,
5970
+ 6,
5971
+ 7,
5972
+ 8,
5973
+ 10
5974
+ ].includes(d) ? d : 6;
5975
+ })(),
4790
5976
  visual: typeof entry.visual === "string" ? entry.visual.trim().slice(0, 300) : "",
4791
5977
  line: typeof entry.line === "string" ? entry.line.trim().slice(0, 120) : "",
4792
5978
  sound: typeof entry.sound === "string" ? entry.sound.trim().slice(0, 80) : "",
4793
5979
  light: normalizeLightings(entry.light),
4794
5980
  prevState: typeof entry.prevState === "string" ? entry.prevState.trim().slice(0, 150) : "",
4795
5981
  nextState: typeof entry.nextState === "string" ? entry.nextState.trim().slice(0, 150) : "",
4796
- characters: sanitizeCharacters(entry.characters, 4)
5982
+ characters: sanitizeCharacters(entry.characters, 4),
5983
+ jimengCamera: typeof entry.jimengCamera === "string" && entry.jimengCamera.trim() !== "" ? entry.jimengCamera.trim().slice(0, 80) : void 0
4797
5984
  })).filter((s) => s.visual !== "") : [];
4798
5985
  if (shots.length === 0) throw new Error("模型未输出有效镜头,请重试");
4799
5986
  const covered = new Set(shots.map((s) => s.beatId));
@@ -4830,6 +6017,40 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
4830
6017
  return table;
4831
6018
  }
4832
6019
  /**
6020
+ * 提炼常驻道具(跨镜头需一致):从已写章节正文识别反复出现的关键道具 + 一行统一外观描述。
6021
+ * 生成分镜提示词前自动调用,若道具库为空则补齐;道具库存 project.props,注入提示词保持跨镜头一致。
6022
+ */
6023
+ async function extractProps(ctx, config, project) {
6024
+ const excerpt = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating").slice(0, 6).map((c) => {
6025
+ const body = readChapterFile(config.outputDir, c);
6026
+ return body !== void 0 ? "第" + c.no + "章《" + c.title + "》\n" + body.replace(/^#.*$/gm, "").trim().slice(0, 1500) : "";
6027
+ }).filter((s) => s !== "").join("\n\n");
6028
+ if (excerpt.length < 200) return [];
6029
+ const raw = parseJsonArray(await complete(ctx, config, {
6030
+ system: [
6031
+ "你是一位网文漫剧道具导演。从下面的已写章节正文中,识别「常驻道具」——跨多个镜头/场景反复出现、需要保持外观一致的关键道具(如外卖电动车、外卖箱、手机、名片;一次性出现的忽略)。",
6032
+ "每个道具给一行统一外观描述(可辨识、具体的颜色/材质/状态),供每个镜头遵循。",
6033
+ "输出必须是合法 JSON 数组:[{\"name\":\"道具名\",\"desc\":\"一行统一外观描述\"}],3-8 个。",
6034
+ "重要:字符串内不含换行符;直接输出 JSON 结果本身。"
6035
+ ].join("\n"),
6036
+ user: excerpt,
6037
+ temperature: .2,
6038
+ maxTokens: Math.max(config.maxTokens, 8e3),
6039
+ reasoning: config.analysisReasoning ?? "low"
6040
+ }));
6041
+ const props = [];
6042
+ for (const e of raw) {
6043
+ if (typeof e !== "object" || e === null) continue;
6044
+ const n = typeof e.name === "string" ? e.name.trim().slice(0, 20) : "";
6045
+ const d = typeof e.desc === "string" ? e.desc.trim().slice(0, 120) : "";
6046
+ if (n !== "" && d !== "" && !props.some((p) => p.name === n)) props.push({
6047
+ name: n,
6048
+ desc: d
6049
+ });
6050
+ }
6051
+ return props.slice(0, 8);
6052
+ }
6053
+ /**
4833
6054
  * 分镜·提示词级:分镜表 → 即梦可粘贴视频提示词。
4834
6055
  * 每镜头一段:风格词块(基底+滤镜)+ 画面内容(角色动作/服装标志物)+ 机位运镜 + 光效。
4835
6056
  * 提示词聚焦画面与镜头(视频模型无音频,台词/音效不注入)。
@@ -4842,37 +6063,75 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
4842
6063
  const stylePrefix = [baseStyle?.keywords, filterStyle?.keywords].filter((v) => v !== void 0 && v !== "").join(",") || "3D动漫,超精细建模,电影光影";
4843
6064
  const rules = (project.visualRules ?? []).map((r) => "- " + r);
4844
6065
  const bindings = buildMangaRoleBindings(project);
6066
+ let props = project.props ?? [];
6067
+ if (props.length === 0) try {
6068
+ props = await extractProps(ctx, config, project);
6069
+ if (props.length > 0) {
6070
+ project.props = props;
6071
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
6072
+ saveProject(config.outputDir, project);
6073
+ }
6074
+ } catch {}
6075
+ const propsBlock = props.length > 0 ? "常驻道具(每个镜头必须按此外观/状态呈现,保持跨镜头一致):\n" + props.map((p) => `- ${p.name}:${p.desc}`).join("\n") : "";
6076
+ let accTime = 0;
4845
6077
  const shotLines = table.shots.map((s) => {
6078
+ const startSec = accTime;
6079
+ const endSec = accTime + s.duration;
6080
+ accTime = endSec;
4846
6081
  const makeUp = (s.characters ?? []).map((n) => bindings.resolve(n)).filter((c) => c !== void 0).map((c) => {
4847
6082
  const anchor = c.imagePrompt !== void 0 ? c.imagePrompt.zh.slice(0, 60) : c.appearance !== "" ? c.appearance : "";
4848
6083
  const ref = c.imageUrl !== void 0 ? "(参考图)" : "";
4849
6084
  return c.name + ":" + anchor + ref;
4850
6085
  }).join(";");
4851
- return `[${s.id}] 节拍${s.beatId} · ${sizeZh(s.shot)} · ${cameraZh(s.camera)} · ${s.duration}s
6086
+ return `[${s.id}] 时间戳 ${startSec}s-${endSec}s · 节拍${s.beatId} · ${sizeZh(s.shot)} · ${cameraZh(s.camera)} · 时长${s.duration}s
4852
6087
  出场:${s.characters !== void 0 && s.characters.length > 0 ? s.characters.join("、") : "(未标注)"}
4853
6088
  定妆:${makeUp !== "" ? makeUp : "(未绑定漫剧卡)"}
4854
6089
  画面:${s.visual}
4855
6090
  台词:${s.line !== "" ? s.line : "(无)"}
4856
6091
  光效:${lightZh(s.light) !== "" ? lightZh(s.light) : "(无)"}
6092
+ 运镜(即梦):${s.jimengCamera !== void 0 ? s.jimengCamera : cameraZh(s.camera)}
4857
6093
  承接:${s.prevState} → ${s.nextState}`;
4858
6094
  }).join("\n\n");
6095
+ const system = [
6096
+ "你是一位资深影视分镜提示词工程师,精通即梦 Seedance 2.5 视频生成模型的提示词写法与参数调优。",
6097
+ "任务:把分镜表的每个镜头写成「可直接粘贴到即梦」的视频提示词——精简、只留能生成画面的内容,不要混入给用户的说明/元信息(如模型版本、画幅、参考图是否上传)。",
6098
+ "每镜 text 按以下段序组织(内容精简):",
6099
+ "① 风格段:" + stylePrefix + "(一句,放最前)。",
6100
+ "② 场景段:写\"场景:<地点名> — <环境/光线/氛围>\"(如\"场景:雨夜道路 — 暴雨积水、昏黄路灯、雨幕\");场景名标清楚(供用户@对应场景图),不写@。场景名优先复用下方「可用场景名清单」里已有的名字(按镜头地点/氛围就近匹配),只有清单里确实没有该地点时才新建场景名;新建名也要简短、能同时指示地点与氛围。",
6101
+ "③ 人物段:主体写\"@角色名\"(智能角色,如\"@林深\")+ 位置/朝向/简短动作,不写外貌细节(角色一致性靠智能主体多角度),多角色明确谁是主体。",
6102
+ "④ 时间轴段:必须把单镜按动作拆成 2~3 段(每段 3~5s,如\"0s-3s / 3s-7s\"),每段写清画面+动作+运镜,不要整镜单段;有台词用『台词(声音层):\"…\"(音色:…)』;音效用 [SFX: …]。",
6103
+ "⑤ 结尾负面:不要字幕、不要水印、禁止变形 + 行为级禁止项(按镜头定)。",
6104
+ ...getGenreRules((project.mangaPlans ?? []).find((p) => p.active)?.genre).length > 0 ? [
6105
+ "题材专用规则(本题材必须遵守):",
6106
+ ...getGenreRules((project.mangaPlans ?? []).find((p) => p.active)?.genre).map((r) => "- " + r),
6107
+ ""
6108
+ ] : [],
6109
+ "题材无关硬性要求:",
6110
+ "1. 台词走声音层,不写进画面提示词当口型(避免即梦硬配口型);台词用『台词(声音层):\"……\"(音色:低沉平静)』单独标注在时间轴段,画面描述不重复人声。",
6111
+ "2. 音效用原生 [SFX: 具体音效] 标记(如 [SFX: 雨声,心跳声]),叠在对应时间轴段,禁空泛\"震撼音效\"。",
6112
+ "3. 时长预算:单镜 4~15s;text 完整覆盖该镜全部秒数,时间轴写起止。",
6113
+ "4. 服装/发色/标志物沿用镜头表、定妆卡、视觉规则,禁止自行更换(同一角色跨镜一致)。",
6114
+ "4b. 若用户给出「常驻道具」清单,每个涉及该道具的镜头必须按清单里那行统一外观/状态呈现(颜色/材质/特征完全一致),禁止换成别的样子;只出现台词不提道具的镜头可忽略。",
6115
+ "5. 每镜主体锚定:画面主体是[角色名],位于画面[左/中/右/前景],多角色明确谁是主体。",
6116
+ "6. 高动态/动作镜头:时间轴可细化到 0.5s~1s 微步进(运镜/动作/粒子/环境受力四要素)。",
6117
+ "7. text 结尾含\"不要字幕、不要水印、禁止变形\"。",
6118
+ "8. 输出合法 JSON 对象:{\"prompts\": [{\"shotId\":\"s1\",\"text\":\"...\",\"camera\":\"...\",\"motion\":\"low|medium|high\",\"negativePrompt\":\"...\",\"sceneName\":\"...\"}]},所有镜头都有,顺序一致。",
6119
+ "9. 字符串内不得含换行符,JSON 必须在一段内完整结束。"
6120
+ ].join("\n");
6121
+ const speechLines = [...bindings.byId.values()].map((c) => `${c.name}:${(c.speechStyle ?? "").trim()}`).filter((x) => !x.endsWith(":"));
6122
+ const sceneLibLines = (project.scenes ?? []).map((s) => {
6123
+ const tag = [(s.moods ?? []).join("、"), s.moment !== void 0 && s.moment !== "" ? "时间光态:" + s.moment : ""].filter((x) => x !== "").join(";");
6124
+ return `- ${s.name}${s.summary !== "" ? ":" + s.summary : ""}${tag !== "" ? "(" + tag + ")" : ""}`;
6125
+ });
6126
+ const sceneLibBlock = sceneLibLines.length > 0 ? "可用场景名清单(场景名只允许从这里选,或确无此地点时新建):\n" + sceneLibLines.join("\n") : "";
4859
6127
  const raw = parseJsonObject(await complete(ctx, config, {
4860
- system: [
4861
- "你是一位资深影视分镜提示词工程师,熟悉即梦 AI / Seedance 等视频生成模型的中文提示词写法。",
4862
- "任务:把分镜表的每个镜头写成一段可直接粘贴进视频生成工具的提示词。",
4863
- "提示词结构(按顺序):风格词块 → 画面主体(人物动作+表情+服装标志物)→ 机位运镜(含时长感)→ 光效氛围。单段中文 60-140 字,逗号分隔,流畅自然。",
4864
- "风格词块(必须原样放在每段开头):" + stylePrefix,
4865
- "重要:每段提示词开头必须原样嵌入风格词块原文,不得省略、不得改写。",
4866
- "硬性要求:",
4867
- "1. 只写画面与镜头,禁止写音频/台词配音说明(视频模型无音频)。",
4868
- "2. 服装/发色/标志物必须沿用镜头表、定妆卡与视觉规则,禁止自行更换(同一角色跨镜头保持一致)。",
4869
- "3. 运镜用通俗电影词(推近/拉远/跟随/低机位仰拍/过肩/手持晃动/固定机位),不要用分镜术语缩写。",
4870
- "4. 输出合法 JSON 对象:{\"prompts\": [{\"shotId\": \"s1\", \"text\": \"...\"}]},所有镜头都要有,顺序一致。",
4871
- "5. 字符串内不得包含换行符,JSON 必须在一段内完整结束。"
4872
- ].join("\n"),
6128
+ system,
4873
6129
  user: [
4874
6130
  `章节《${chapter?.title ?? ""}》(第 ${chapterNo} 章)`,
4875
6131
  rules.length > 0 ? "本书视觉规则(必须遵守):\n" + rules.join("\n") : "",
6132
+ propsBlock,
6133
+ sceneLibBlock,
6134
+ speechLines.length > 0 ? "出场角色说话方式(写台词音色参考时用):\n" + speechLines.join("\n") : "",
4876
6135
  "==== 分镜表 ====",
4877
6136
  shotLines,
4878
6137
  "只输出 JSON 对象。"
@@ -4882,14 +6141,30 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
4882
6141
  reasoning: config.analysisReasoning ?? "low"
4883
6142
  }));
4884
6143
  const shotIds = new Set(table.shots.map((s) => s.id));
4885
- const shotBinding = new Map(table.shots.map((s) => [s.id, s.mangaRoleIds ?? []]));
6144
+ const shotBinding = new Map(table.shots.map((s) => {
6145
+ const ids = [];
6146
+ for (const n of s.characters ?? []) {
6147
+ const card = bindings.resolve(n);
6148
+ if (card !== void 0 && !ids.includes(card.id)) ids.push(card.id);
6149
+ }
6150
+ return [s.id, ids];
6151
+ }));
4886
6152
  const prompts = Array.isArray(raw.prompts) ? raw.prompts.filter((v) => typeof v === "object" && v !== null).map((entry) => {
4887
6153
  const shotId = shotIds.has(entry.shotId) ? entry.shotId : "";
4888
6154
  const ids = shotBinding.get(shotId) ?? [];
6155
+ const motionVal = typeof entry.motion === "string" ? entry.motion.trim().toLowerCase() : "";
4889
6156
  return {
4890
6157
  shotId,
4891
6158
  text: ensureStyleEmbedded(typeof entry.text === "string" ? entry.text.trim().slice(0, 400) : "", stylePrefix, "zh"),
4892
- mangaRoleIds: ids.length > 0 ? ids : void 0
6159
+ mangaRoleIds: ids.length > 0 ? ids : void 0,
6160
+ camera: typeof entry.camera === "string" && entry.camera.trim() !== "" ? entry.camera.trim().slice(0, 100) : void 0,
6161
+ motion: motionVal in {
6162
+ "low": 1,
6163
+ "medium": 1,
6164
+ "high": 1
6165
+ } ? motionVal : void 0,
6166
+ negativePrompt: typeof entry.negativePrompt === "string" && entry.negativePrompt.trim() !== "" ? entry.negativePrompt.trim().slice(0, 200) : void 0,
6167
+ sceneName: typeof entry.sceneName === "string" && entry.sceneName.trim() !== "" && entry.sceneName.trim() !== "(未标注)" ? entry.sceneName.trim().slice(0, 50) : void 0
4893
6168
  };
4894
6169
  }).filter((x) => x.shotId !== "" && x.text !== "") : [];
4895
6170
  if (prompts.length === 0) throw new Error("模型未输出有效提示词,请重试");
@@ -4898,6 +6173,21 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
4898
6173
  prompts,
4899
6174
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4900
6175
  });
6176
+ try {
6177
+ const chTitle = project.chapters.find((c) => c.no === chapterNo)?.title ?? "";
6178
+ const pLines = [];
6179
+ for (const p of prompts) pLines.push("### 镜头 " + p.shotId + "\n" + (p.text ?? ""));
6180
+ saveMangaChapterPrompts(outputDir, chapterNo, chTitle, pLines.join("\n\n"));
6181
+ } catch {}
6182
+ const sbEntry = (project.storyboards ?? []).find((e) => e.chapterNo === chapterNo);
6183
+ if (sbEntry !== void 0 && sbEntry.table !== void 0) {
6184
+ for (const s of sbEntry.table.shots) {
6185
+ const ids = shotBinding.get(s.id) ?? [];
6186
+ s.mangaRoleIds = ids.length > 0 ? ids : void 0;
6187
+ }
6188
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
6189
+ saveProject(outputDir, project);
6190
+ }
4901
6191
  return prompts;
4902
6192
  }
4903
6193
  /**
@@ -5369,7 +6659,7 @@ function saveMaterializedBook(outDir, bookName, data) {
5369
6659
  function applyAdaptationMappings(text, mappings) {
5370
6660
  return applyAdaptationReplacements(text, mappings).adaptedText;
5371
6661
  }
5372
- /** 改编设定圣经:把人名/术语/势力按映射表替换。 */
6662
+ /** 改编道藏:把人名/术语/势力按映射表替换。 */
5373
6663
  function applyMappingsToBible(bible, mappings) {
5374
6664
  const t = (s) => applyAdaptationMappings(s, mappings);
5375
6665
  return {
@@ -5410,26 +6700,26 @@ function applyMappingsToRoles(roles, mappings) {
5410
6700
  expressions: role.expressions?.map(t),
5411
6701
  promptKit: role.promptKit !== void 0 ? {
5412
6702
  ...role.promptKit,
5413
- portrait: {
6703
+ portrait: role.promptKit.portrait !== void 0 ? {
5414
6704
  ...role.promptKit.portrait,
5415
6705
  zh: t(role.promptKit.portrait.zh),
5416
6706
  en: t(role.promptKit.portrait.en)
5417
- },
5418
- sheet: {
6707
+ } : void 0,
6708
+ sheet: role.promptKit.sheet !== void 0 ? {
5419
6709
  ...role.promptKit.sheet,
5420
6710
  zh: t(role.promptKit.sheet.zh),
5421
6711
  en: t(role.promptKit.sheet.en)
5422
- },
5423
- expressions: role.promptKit.expressions.map((e) => ({
6712
+ } : void 0,
6713
+ expressions: role.promptKit.expressions !== void 0 ? role.promptKit.expressions.map((e) => ({
5424
6714
  ...e,
5425
6715
  zh: t(e.zh),
5426
6716
  en: t(e.en)
5427
- })),
5428
- details: {
6717
+ })) : void 0,
6718
+ details: role.promptKit.details !== void 0 ? {
5429
6719
  ...role.promptKit.details,
5430
6720
  zh: t(role.promptKit.details.zh),
5431
6721
  en: t(role.promptKit.details.en)
5432
- }
6722
+ } : void 0
5433
6723
  } : void 0
5434
6724
  }));
5435
6725
  }
@@ -5464,6 +6754,34 @@ function fallbackSourceOutline(project) {
5464
6754
  * 批量生成时整体开销约省 25%)。
5465
6755
  * @returns 摘要与新增事实条数(失败返回空,调用方 best-effort)。
5466
6756
  */
6757
+ /**
6758
+ * 事实库去重:新事实加入前检查与已有事实的相似度,
6759
+ * 状态类事实(如"主角受伤"→"主角痊愈")覆盖旧状态。
6760
+ */
6761
+ function dedupAndAddFacts(project, chapterNo, newFacts) {
6762
+ const list = project.facts ?? [];
6763
+ const existingTexts = new Set(list.map((f) => f.text));
6764
+ let added = 0;
6765
+ for (const fact of newFacts.slice(0, 8)) {
6766
+ if (existingTexts.has(fact)) continue;
6767
+ if (list.some((f) => {
6768
+ const a = f.text.slice(0, 30);
6769
+ const b = fact.slice(0, 30);
6770
+ if (a.length === 0 || b.length === 0) return false;
6771
+ let common = 0;
6772
+ for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] === b[i]) common++;
6773
+ return common / Math.max(a.length, b.length) > .7;
6774
+ })) continue;
6775
+ list.push({
6776
+ chapterNo,
6777
+ text: fact
6778
+ });
6779
+ existingTexts.add(fact);
6780
+ added++;
6781
+ }
6782
+ project.facts = list.slice(-300);
6783
+ return added;
6784
+ }
5467
6785
  async function summarizeAndExtractFacts(ctx, config, project, outputDir, chapterNo) {
5468
6786
  const chapter = project.chapters.find((c) => c.no === chapterNo);
5469
6787
  if (chapter === void 0) return {
@@ -5489,12 +6807,7 @@ async function summarizeAndExtractFacts(ctx, config, project, outputDir, chapter
5489
6807
  const summary = typeof raw.summary === "string" ? raw.summary.trim().slice(0, 500) : "";
5490
6808
  const factLines = Array.isArray(raw.facts) ? raw.facts.filter((v) => typeof v === "string" && v.trim().length > 8).map((v) => v.trim().slice(0, 140)) : [];
5491
6809
  if (summary !== "") chapter.summary = summary;
5492
- const list = project.facts ?? [];
5493
- for (const line of factLines.slice(0, 8)) list.push({
5494
- chapterNo,
5495
- text: line
5496
- });
5497
- project.facts = list.slice(-300);
6810
+ dedupAndAddFacts(project, chapterNo, factLines);
5498
6811
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5499
6812
  saveProject(outputDir, project);
5500
6813
  return {
@@ -5573,7 +6886,7 @@ const AUDIT_BATCH_SIZE = 10;
5573
6886
  /** 单批质检:设定 + 事实库 + 该批章节节选 → 矛盾清单。 */
5574
6887
  async function auditBatch(ctx, config, project, outputDir, batch) {
5575
6888
  const system = [
5576
- "你是一位严谨的网文连续性审校编辑。你会收到一本小说的设定圣经、事实库和一批章节正文节选。",
6889
+ "你是一位严谨的网文连续性审校编辑。你会收到一本小说的道藏、事实库和一批章节正文节选。",
5577
6890
  "请找出这批章节中的一致性矛盾,例如:",
5578
6891
  "- 人物状态冲突:境界/修为/伤势/资源在同一章内或跨章前后矛盾。",
5579
6892
  "- 设定违背:正文与世界观规则、金手指规则、写作红线冲突。",
@@ -5594,7 +6907,7 @@ async function auditBatch(ctx, config, project, outputDir, batch) {
5594
6907
  system,
5595
6908
  user: [
5596
6909
  "请对以下小说做一致性质检。",
5597
- project.bible !== void 0 ? "设定圣经:\n" + [
6910
+ project.bible !== void 0 ? "道藏:\n" + [
5598
6911
  project.bible.worldRules.length > 0 ? `世界规则:\n${project.bible.worldRules.map((r) => `- ${r}`).join("\n")}` : "",
5599
6912
  project.bible.redLines.length > 0 ? `写作红线:\n${project.bible.redLines.map((r) => `- ${r}`).join("\n")}` : "",
5600
6913
  project.bible.characters.length > 0 ? `角色:\n${project.bible.characters.map((ch) => `- ${ch.name}(${ch.traits.join("、")})`).join("\n")}` : ""
@@ -5679,7 +6992,7 @@ function bookOverview(project, scope = "recent") {
5679
6992
  s.push(`【大纲全文】\n${project.outline}`);
5680
6993
  if (project.bible !== void 0) {
5681
6994
  const bible = project.bible;
5682
- s.push("【设定圣经】");
6995
+ s.push("【道藏】");
5683
6996
  if (bible.genre !== "") s.push(`题材基调:${bible.genre}`);
5684
6997
  if (bible.worldRules.length > 0) s.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
5685
6998
  if (bible.characters.length > 0) {
@@ -5742,7 +7055,7 @@ function bookOverview(project, scope = "recent") {
5742
7055
  async function analyzeImpact(ctx, config, project, outputDir, change) {
5743
7056
  const system = [
5744
7057
  "你是一位网文一致性审校。作者要做一处修改,请找出这次改动会波及的所有位置(设定、大纲、已写章节正文、事实库、简介中可能因此过时或矛盾的内容)。",
5745
- "输出必须是合法 JSON 数组,格式:[{\"location\": \"位置(第N章/大纲/设定圣经-世界规则/大世界-境界/事实库/简介)\", \"quote\": \"原文片段(20-60字)\", \"suggestion\": \"修改建议\", \"kind\": \"must|optional|note\"}]",
7058
+ "输出必须是合法 JSON 数组,格式:[{\"location\": \"位置(第N章/大纲/道藏-世界规则/大世界-境界/事实库/简介)\", \"quote\": \"原文片段(20-60字)\", \"suggestion\": \"修改建议\", \"kind\": \"must|optional|note\"}]",
5746
7059
  "kind 含义:must=必须同步改否则矛盾;optional=建议改(影响观感);note=备注(如旧称保留为古称、或无需改但需知晓)。",
5747
7060
  "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
5748
7061
  ].join("\n");
@@ -5804,16 +7117,16 @@ function renderWorld(world) {
5804
7117
  }
5805
7118
  return sections.join("\n");
5806
7119
  }
5807
- /** AI 提炼大世界:从大纲 + 设定圣经生成结构化境界体系/区域/势力。 */
7120
+ /** AI 提炼大世界:从大纲 + 道藏生成结构化境界体系/区域/势力。 */
5808
7121
  async function extractWorld(ctx, config, project) {
5809
7122
  const system = [
5810
- "你是一位网文世界观架构师。请根据小说大纲与设定圣经,提炼结构化「大世界」数据。",
7123
+ "你是一位网文世界观架构师。请根据小说大纲与道藏,提炼结构化「大世界」数据。",
5811
7124
  "输出必须是合法 JSON 对象:",
5812
7125
  "{\"realms\": [{\"name\": \"境界名\", \"description\": \"突破条件/寿命/标志等\"}], \"regions\": [{\"name\": \"区域名\", \"description\": \"描述\", \"faction\": \"关联势力名或空\"}], \"factions\": [{\"name\": \"势力名\", \"kind\": \"宗门/家族/王朝/组织等\", \"description\": \"描述\", \"region\": \"驻地区域或空\"}]}",
5813
7126
  "要求:",
5814
7127
  "1. realms 按由低到高顺序排列(修仙题材必须含完整境界链;无境界设定的题材可输出空数组)。",
5815
7128
  "2. 数量贴合大纲:realms 3-12 个,regions 2-10 个,factions 2-10 个。",
5816
- "3. 内容严格来自大纲与设定圣经,不要凭空发明与大纲冲突的设定。",
7129
+ "3. 内容严格来自大纲与道藏,不要凭空发明与大纲冲突的设定。",
5817
7130
  "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
5818
7131
  ].join("\n");
5819
7132
  const bibleBlock = project.bible !== void 0 ? [project.bible.genre !== "" ? `题材:${project.bible.genre}` : "", project.bible.worldRules.length > 0 ? `世界规则:\n${project.bible.worldRules.map((r) => `- ${r}`).join("\n")}` : ""].filter((s) => s !== "").join("\n") : "";
@@ -6032,6 +7345,156 @@ function exportBook(outputDir, project, format) {
6032
7345
  chapters: done.length
6033
7346
  };
6034
7347
  }
7348
+ /** 漫剧资产库根目录:outputDir/manga-assets */
7349
+ function mangaAssetsDir(outputDir) {
7350
+ return join(outputDir, "manga-assets");
7351
+ }
7352
+ /** 确保子目录存在,返回完整路径。 */
7353
+ function ensureAssetDir(outputDir, ...sub) {
7354
+ const dir = join(mangaAssetsDir(outputDir), ...sub);
7355
+ mkdirSync(dir, { recursive: true });
7356
+ return dir;
7357
+ }
7358
+ /** 清理文件名中的非法字符。 */
7359
+ function safeAssetName(name) {
7360
+ return name.replace(/[\\/:*?"<>|]/g, "_").slice(0, 40);
7361
+ }
7362
+ /** 保存角色提示词到资产库:manga-assets/角色/角色名/提示词.txt */
7363
+ function saveMangaRolePrompt(outputDir, roleName, zh, en, negative) {
7364
+ const file = join(ensureAssetDir(outputDir, "角色", safeAssetName(roleName)), "提示词.txt");
7365
+ writeFileSync(file, [
7366
+ "【正面提示词(中文)】",
7367
+ zh,
7368
+ "",
7369
+ "【正面提示词(英文)】",
7370
+ en,
7371
+ "",
7372
+ negative !== void 0 && negative !== "" ? [
7373
+ "【负面提示词】",
7374
+ negative,
7375
+ ""
7376
+ ].join("\n") : ""
7377
+ ].filter((x) => x !== "").join("\n"), "utf8");
7378
+ return file;
7379
+ }
7380
+ /** 保存分镜即梦脚本到资产库:manga-assets/分镜脚本/第N章-标题.md */
7381
+ function saveMangaStoryboardScript(outputDir, chapterNo, title, markdown) {
7382
+ const file = join(ensureAssetDir(outputDir, "分镜脚本"), "第" + chapterNo + "章-" + safeAssetName(title) + ".md");
7383
+ writeFileSync(file, markdown, "utf8");
7384
+ return file;
7385
+ }
7386
+ /** 保存「即梦素材包」到资产库:manga-assets/素材包/第N章-标题·即梦素材包.md */
7387
+ function saveMangaAssetPackage(outputDir, chapterNo, title, markdown) {
7388
+ const file = join(ensureAssetDir(outputDir, "素材包"), "第" + chapterNo + "章-" + safeAssetName(title) + "·即梦素材包.md");
7389
+ writeFileSync(file, markdown, "utf8");
7390
+ return file;
7391
+ }
7392
+ /** 保存场景中文生图提示词到资产库:manga-assets/场景/场景名/提示词.txt */
7393
+ function saveMangaScenePrompt(outputDir, sceneName, zh, negative) {
7394
+ const file = join(ensureAssetDir(outputDir, "场景", safeAssetName(sceneName)), "提示词.txt");
7395
+ writeFileSync(file, [
7396
+ "【场景生图提示词(中文)】",
7397
+ zh,
7398
+ "",
7399
+ negative !== void 0 && negative !== "" ? [
7400
+ "【负面提示词】",
7401
+ negative,
7402
+ ""
7403
+ ].join("\n") : ""
7404
+ ].filter((x) => x !== "").join("\n"), "utf8");
7405
+ return file;
7406
+ }
7407
+ /** 保存逐镜即梦提示词到资产库:manga-assets/分镜脚本/第N章-标题-提示词.md */
7408
+ function saveMangaChapterPrompts(outputDir, chapterNo, title, markdown) {
7409
+ const file = join(ensureAssetDir(outputDir, "分镜脚本"), "第" + chapterNo + "章-" + safeAssetName(title) + "-提示词.md");
7410
+ writeFileSync(file, markdown, "utf8");
7411
+ return file;
7412
+ }
7413
+ /**
7414
+ * 一键生成:骨架 → 分镜表 → 角色提名 → 自动导入(匹配成功的)→ 自动分级 → 视频提示词。
7415
+ * 匹配模糊/小说库缺失的角色保留在候选列表,不自动导入。
7416
+ */
7417
+ async function autoGenerateMangaChapter(ctx, config, project, outputDir, chapterNo, styleId, filterId) {
7418
+ let entry = (project.storyboards ?? []).find((e) => e.chapterNo === chapterNo);
7419
+ if (entry === void 0) {
7420
+ entry = {
7421
+ chapterNo,
7422
+ skeleton: void 0,
7423
+ table: void 0,
7424
+ prompts: void 0,
7425
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
7426
+ };
7427
+ if (project.storyboards === void 0) project.storyboards = [];
7428
+ project.storyboards.push(entry);
7429
+ }
7430
+ const sb = entry;
7431
+ if (sb.skeleton === void 0) sb.skeleton = await generateStoryboardSkeleton(ctx, config, project, outputDir, chapterNo);
7432
+ if (sb.table === void 0) sb.table = await generateStoryboardTable(ctx, config, project, outputDir, chapterNo, sb.skeleton, styleId, filterId);
7433
+ const candidates = await nominateMangaRoles(ctx, config, project, outputDir, chapterNo);
7434
+ let imported = 0;
7435
+ for (const cand of candidates) {
7436
+ if (cand.verdict === "already_imported") continue;
7437
+ if ((project.mangaRoles ?? []).some((c) => c.name === cand.rawName || cand.matchedRoleName !== void 0 && c.sourceRoleName === cand.matchedRoleName)) continue;
7438
+ const sug = cand.suggested;
7439
+ const matched = cand.verdict === "matched" && cand.matchedRoleName !== void 0 && cand.matchedRoleName !== "";
7440
+ const name = ((cand.matchedRoleName !== void 0 && cand.matchedRoleName !== "" ? cand.matchedRoleName : sug.name) || cand.rawName).trim().slice(0, 30) || cand.rawName.trim().slice(0, 30);
7441
+ const tier = cand.tier ?? (sug.coreFunction === "functional" && cand.matchedRoleName === void 0 ? "extra" : "supporting");
7442
+ const card = {
7443
+ id: "mr-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6),
7444
+ sourceRoleName: cand.matchedRoleName,
7445
+ name,
7446
+ identity: sug.identity,
7447
+ coreFunction: sug.coreFunction,
7448
+ protagonistRelation: sug.protagonistRelation,
7449
+ speechStyle: sug.speechStyle,
7450
+ traits: sug.traits,
7451
+ appearance: sug.appearance,
7452
+ keyScenes: sug.keyScenes,
7453
+ appearsInEpisodes: [chapterNo],
7454
+ status: matched ? "imported" : "pending_confirm",
7455
+ tier,
7456
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
7457
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
7458
+ };
7459
+ if (project.mangaRoles === void 0) project.mangaRoles = [];
7460
+ project.mangaRoles.push(card);
7461
+ if (matched) imported++;
7462
+ }
7463
+ try {
7464
+ if (sb.table !== void 0) {
7465
+ const chTitle = project.chapters.find((c) => c.no === chapterNo)?.title ?? "";
7466
+ const mdLines = [];
7467
+ mdLines.push("# 第" + chapterNo + "章《" + chTitle + "》· 分镜");
7468
+ if (sb.skeleton !== void 0) {
7469
+ mdLines.push("", "## 剧情骨架");
7470
+ mdLines.push("弧线:" + (sb.skeleton.arc ?? ""));
7471
+ for (const b of sb.skeleton.beats ?? []) mdLines.push(`- [${b.id}] ${b.event}(情绪:${emotionZh(b.emotion)})`);
7472
+ }
7473
+ mdLines.push("", "## 分镜表");
7474
+ for (const s of sb.table.shots ?? []) mdLines.push(`- s${s.id} ${sizeZh(s.shot)} · ${cameraZh(s.camera)} · ${s.duration}s · ${s.visual}`);
7475
+ saveMangaStoryboardScript(outputDir, chapterNo, chTitle, mdLines.join("\n"));
7476
+ }
7477
+ } catch {}
7478
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
7479
+ saveProject(outputDir, project);
7480
+ const allCards = project.mangaRoles ?? [];
7481
+ const needMakeup = allCards.filter((c) => c.tier !== "extra" && (c.appearsInEpisodes ?? []).includes(chapterNo)).length;
7482
+ const extra = allCards.filter((c) => c.tier === "extra" && (c.appearsInEpisodes ?? []).includes(chapterNo)).length;
7483
+ const pendingList = candidates.filter((c) => c.verdict === "ambiguous" || c.verdict === "not_in_library");
7484
+ const pending = pendingList.length;
7485
+ const pendingRoleNames = pendingList.map((c) => c.rawName).slice(0, 10);
7486
+ return {
7487
+ chapterNo,
7488
+ skeletonBeats: sb.skeleton?.beats?.length ?? 0,
7489
+ shotCount: sb.table?.shots?.length ?? 0,
7490
+ promptCount: sb.prompts?.length ?? 0,
7491
+ importedRoles: imported,
7492
+ needMakeupRoles: needMakeup,
7493
+ extraRoles: extra,
7494
+ pendingCandidates: pending,
7495
+ pendingRoleNames
7496
+ };
7497
+ }
6035
7498
  //#endregion
6036
7499
  //#region src/bookshelf.ts
6037
7500
  /**
@@ -7539,12 +9002,34 @@ function makeRoutes(deps) {
7539
9002
  syncProjectWithDisk(project, config.outputDir);
7540
9003
  saveProject(config.outputDir, project);
7541
9004
  }
9005
+ const slim = new URL(req.url ?? "/", "http://localhost").searchParams.get("slim") === "1";
9006
+ let projectPayload;
9007
+ if (project === void 0) projectPayload = void 0;
9008
+ else if (slim) projectPayload = {
9009
+ bookName: project.bookName,
9010
+ outline: project.outline.slice(0, 200),
9011
+ chapters: project.chapters.map((c) => ({
9012
+ no: c.no,
9013
+ volume: c.volume,
9014
+ title: c.title,
9015
+ status: c.status,
9016
+ chars: c.chars,
9017
+ error: c.error,
9018
+ review: c.review !== void 0 ? {
9019
+ score: c.review.score,
9020
+ passed: c.review.passed
9021
+ } : void 0
9022
+ })),
9023
+ createdAt: project.createdAt,
9024
+ updatedAt: project.updatedAt
9025
+ };
9026
+ else projectPayload = {
9027
+ ...project,
9028
+ facts: (project.facts ?? []).slice(-80)
9029
+ };
7542
9030
  writeJson(res, 200, {
7543
9031
  config,
7544
- project: project !== void 0 ? {
7545
- ...project,
7546
- facts: (project.facts ?? []).slice(-80)
7547
- } : void 0,
9032
+ project: projectPayload,
7548
9033
  generatedFiles: listChapterFiles(config.outputDir),
7549
9034
  audit: auditState
7550
9035
  });
@@ -7747,7 +9232,8 @@ function makeRoutes(deps) {
7747
9232
  no,
7748
9233
  file: step.file,
7749
9234
  chars: step.chars,
7750
- title: chapter.title
9235
+ title: chapter.title,
9236
+ warn: step.warn
7751
9237
  });
7752
9238
  try {
7753
9239
  await summarizeAndExtractFacts(ctx, config, project, config.outputDir, no);
@@ -8614,7 +10100,7 @@ function makeRoutes(deps) {
8614
10100
  }
8615
10101
  }
8616
10102
  };
8617
- /** 设定圣经局部修补(世界观规则/红线/风格)。 */
10103
+ /** 道藏局部修补(世界观规则/红线/风格)。 */
8618
10104
  const biblePatchRoute = {
8619
10105
  kind: "exact",
8620
10106
  path: NOVEL_API.biblePatch,
@@ -9038,25 +10524,6 @@ function makeRoutes(deps) {
9038
10524
  saveProject(config.outputDir, project);
9039
10525
  writeJson(res, 200, { roles: project.roles });
9040
10526
  return;
9041
- } else if (op === "imageGenerate") {
9042
- const name = body?.name?.trim();
9043
- if (name === void 0 || name === "") {
9044
- writeJson(res, 400, { error: "name(角色名)必填" });
9045
- return;
9046
- }
9047
- try {
9048
- const imageUrl = await generateRoleReferenceImage(ctx, config, project, config.outputDir, name, body?.style ?? "", body?.modelId);
9049
- project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
9050
- saveProject(config.outputDir, project);
9051
- writeJson(res, 200, {
9052
- roles: project.roles,
9053
- imageUrl
9054
- });
9055
- return;
9056
- } catch (error) {
9057
- writeJson(res, 500, { error: `角色参考图生成失败:${error.message}` });
9058
- return;
9059
- }
9060
10527
  } else if (op === "visual") {
9061
10528
  const name = body?.name?.trim();
9062
10529
  if (name === void 0 || name === "") {
@@ -9202,7 +10669,8 @@ function makeRoutes(deps) {
9202
10669
  return;
9203
10670
  }
9204
10671
  try {
9205
- const visual = await extractMangaRoleVisual(ctx, config, project, config.outputDir, id, body?.styleId, body?.filterId);
10672
+ const activeStyle = getActiveMangaStyle(project);
10673
+ const visual = await extractMangaRoleVisual(ctx, config, project, config.outputDir, id, body?.styleId ?? activeStyle.styleId, body?.filterId ?? activeStyle.filterId);
9206
10674
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
9207
10675
  saveProject(config.outputDir, project);
9208
10676
  writeJson(res, 200, {
@@ -9221,7 +10689,8 @@ function makeRoutes(deps) {
9221
10689
  return;
9222
10690
  }
9223
10691
  try {
9224
- const kit = await generateMangaRolePromptKit(ctx, config, project, id, body?.styleId, body?.filterId);
10692
+ const activeStyle2 = getActiveMangaStyle(project);
10693
+ const kit = await generateMangaRolePromptKit(ctx, config, project, id, body?.styleId ?? activeStyle2.styleId, body?.filterId ?? activeStyle2.filterId);
9225
10694
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
9226
10695
  saveProject(config.outputDir, project);
9227
10696
  writeJson(res, 200, {
@@ -9282,40 +10751,82 @@ function makeRoutes(deps) {
9282
10751
  saveProject(config.outputDir, project);
9283
10752
  writeJson(res, 200, { cards: project.mangaRoles });
9284
10753
  return;
9285
- } else if (op === "imageGenerate") {
9286
- const id = body?.id?.trim();
9287
- if (id === void 0 || id === "") {
9288
- writeJson(res, 400, { error: "id(漫剧卡 id)必填" });
10754
+ } else if (op === "mode") {
10755
+ project.shortDramaMode = body?.shortDramaMode === true;
10756
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
10757
+ saveProject(config.outputDir, project);
10758
+ writeJson(res, 200, {
10759
+ cards: project.mangaRoles,
10760
+ shortDramaMode: project.shortDramaMode
10761
+ });
10762
+ return;
10763
+ } else if (op === "autoGenerate") {
10764
+ const chapterNo = body?.chapterNo;
10765
+ if (typeof chapterNo !== "number" || chapterNo <= 0) {
10766
+ writeJson(res, 400, { error: "chapterNo(章号)必填" });
9289
10767
  return;
9290
10768
  }
9291
10769
  try {
9292
- const imageUrl = await generateMangaRoleReferenceImage(ctx, config, project, config.outputDir, id, body?.style ?? "", body?.modelId);
9293
- project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
9294
- saveProject(config.outputDir, project);
10770
+ const activeStyle3 = getActiveMangaStyle(project);
10771
+ const result = await autoGenerateMangaChapter(ctx, config, project, config.outputDir, chapterNo, body?.styleId ?? activeStyle3.styleId, body?.filterId ?? activeStyle3.filterId);
9295
10772
  writeJson(res, 200, {
9296
10773
  cards: project.mangaRoles,
9297
- imageUrl
10774
+ autoGenerate: result
9298
10775
  });
9299
10776
  return;
9300
10777
  } catch (error) {
9301
- writeJson(res, 500, { error: `定妆图生成失败:${error.message}` });
10778
+ writeJson(res, 500, { error: "一键生成失败:" + error.message });
10779
+ return;
10780
+ }
10781
+ } else if (op === "openAssets") {
10782
+ const dir = mangaAssetsDir(config.outputDir);
10783
+ try {
10784
+ mkdirSync(dir, { recursive: true });
10785
+ if (process.platform === "win32") spawn("explorer", [dir], { detached: true });
10786
+ else if (process.platform === "darwin") spawn("open", [dir], { detached: true });
10787
+ else spawn("xdg-open", [dir], { detached: true });
10788
+ writeJson(res, 200, {
10789
+ ok: true,
10790
+ dir
10791
+ });
10792
+ return;
10793
+ } catch (error) {
10794
+ writeJson(res, 500, {
10795
+ error: "打开资产库失败:" + error.message,
10796
+ dir
10797
+ });
9302
10798
  return;
9303
10799
  }
9304
- } else if (op === "mode") {
9305
- project.shortDramaMode = body?.shortDramaMode === true;
9306
- project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
9307
- saveProject(config.outputDir, project);
9308
- writeJson(res, 200, {
9309
- cards: project.mangaRoles,
9310
- shortDramaMode: project.shortDramaMode
9311
- });
9312
- return;
9313
10800
  } else {
9314
10801
  writeJson(res, 400, { error: "未知的 manga/roles op" });
9315
10802
  return;
9316
10803
  }
9317
10804
  }
9318
10805
  };
10806
+ /** 导出「即梦素材包」落盘到资产库 manga-assets/素材包/(前端组装 markdown,后端写文件)。 */
10807
+ const exportPackageRoute = {
10808
+ kind: "exact",
10809
+ path: NOVEL_API.exportPackage,
10810
+ handler: async (req, res) => {
10811
+ if (!guard(req, res, "POST")) return;
10812
+ const config = getConfig();
10813
+ const body = await readJsonBody(req);
10814
+ const chapterNo = Number(body?.chapterNo);
10815
+ const markdown = body?.markdown ?? "";
10816
+ if (!Number.isInteger(chapterNo) || chapterNo < 1 || markdown.trim() === "") {
10817
+ writeJson(res, 400, { error: "chapterNo 须为正整数,且 markdown 不能为空" });
10818
+ return;
10819
+ }
10820
+ try {
10821
+ writeJson(res, 200, {
10822
+ ok: true,
10823
+ file: saveMangaAssetPackage(config.outputDir, chapterNo, (body?.title ?? "").trim(), markdown)
10824
+ });
10825
+ } catch (error) {
10826
+ writeJson(res, 500, { error: error.message });
10827
+ }
10828
+ }
10829
+ };
9319
10830
  /** 章节复位:generating 卡死 → pending(可重新生成)。 */
9320
10831
  const chapterResetRoute = {
9321
10832
  kind: "exact",
@@ -9626,30 +11137,13 @@ function makeRoutes(deps) {
9626
11137
  return;
9627
11138
  }
9628
11139
  try {
9629
- writeJson(res, 200, { prompts: await generateStoryboardPrompts(ctx, config, project, config.outputDir, no, body.table, body?.styleId, body?.filterId) });
11140
+ const activeStyle5 = getActiveMangaStyle(project);
11141
+ writeJson(res, 200, { prompts: await generateStoryboardPrompts(ctx, config, project, config.outputDir, no, body.table, body?.styleId ?? activeStyle5.styleId, body?.filterId ?? activeStyle5.filterId) });
9630
11142
  } catch (error) {
9631
11143
  writeJson(res, 500, { error: `视频提示词生成失败:${error.message}` });
9632
11144
  }
9633
11145
  }
9634
11146
  };
9635
- /** 生图接口连通性测试:GET {baseURL}/models + 计时(设置页模型条目用)。 */
9636
- const imageTestRoute = {
9637
- kind: "exact",
9638
- path: NOVEL_API.imageTest,
9639
- handler: async (req, res) => {
9640
- if (!guard(req, res, "POST")) return;
9641
- const body = await readJsonBody(req);
9642
- if (body?.baseURL === void 0 || body?.apiKey === void 0) {
9643
- writeJson(res, 400, { error: "baseURL 与 apiKey 必填" });
9644
- return;
9645
- }
9646
- try {
9647
- writeJson(res, 200, await testImageEndpoint(body.baseURL, body.apiKey, body.model));
9648
- } catch (error) {
9649
- writeJson(res, 500, { error: `测试失败:${error.message}` });
9650
- }
9651
- }
9652
- };
9653
11147
  /** LLM 连通性测试:对选中的提供商/模型发一次最小真实调用。 */
9654
11148
  const llmTestRoute = {
9655
11149
  kind: "exact",
@@ -9763,7 +11257,8 @@ function makeRoutes(deps) {
9763
11257
  return;
9764
11258
  }
9765
11259
  try {
9766
- writeJson(res, 200, { table: await generateStoryboardTable(ctx, config, project, config.outputDir, no, body.skeleton, body?.styleId, body?.filterId) });
11260
+ const activeStyle6 = getActiveMangaStyle(project);
11261
+ writeJson(res, 200, { table: await generateStoryboardTable(ctx, config, project, config.outputDir, no, body.skeleton, body?.styleId ?? activeStyle6.styleId, body?.filterId ?? activeStyle6.filterId) });
9767
11262
  } catch (error) {
9768
11263
  writeJson(res, 500, { error: `分镜表生成失败:${error.message}` });
9769
11264
  }
@@ -9797,6 +11292,7 @@ function makeRoutes(deps) {
9797
11292
  name: name.slice(0, 40),
9798
11293
  styleId,
9799
11294
  filterId: body.filterId?.trim() !== "" ? body.filterId?.trim() : void 0,
11295
+ genre: body.genre?.trim() !== "" ? body.genre?.trim() : void 0,
9800
11296
  active: project.mangaPlans.length === 0,
9801
11297
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
9802
11298
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -9805,6 +11301,8 @@ function makeRoutes(deps) {
9805
11301
  const removed = project.mangaPlans.find((p) => p.id === body.id);
9806
11302
  project.mangaPlans = project.mangaPlans.filter((p) => p.id !== body.id);
9807
11303
  if (removed?.active === true && project.mangaPlans.length > 0) project.mangaPlans[0].active = true;
11304
+ project.storyboards = [];
11305
+ project.mangaRoles = [];
9808
11306
  } else if (body?.op === "activate" && body.id !== void 0) project.mangaPlans.forEach((p) => {
9809
11307
  p.active = p.id === body.id;
9810
11308
  });
@@ -9908,7 +11406,8 @@ function makeRoutes(deps) {
9908
11406
  if (project.scenes === void 0) project.scenes = [];
9909
11407
  const op = body?.op;
9910
11408
  if (op === "extract") try {
9911
- const candidates = await extractScenes(ctx, config, project, body?.styleId, body?.filterId);
11409
+ const activeStyle4 = getActiveMangaStyle(project);
11410
+ const candidates = await extractScenes(ctx, config, project, body?.chapterNo, body?.styleId ?? activeStyle4.styleId, body?.filterId ?? activeStyle4.filterId);
9912
11411
  writeJson(res, 200, {
9913
11412
  scenes: project.scenes,
9914
11413
  candidates
@@ -9988,6 +11487,41 @@ function makeRoutes(deps) {
9988
11487
  writeJson(res, 400, { error: "op 须为 extract 或 save" });
9989
11488
  }
9990
11489
  };
11490
+ const mangaPropsRoute = {
11491
+ kind: "exact",
11492
+ path: NOVEL_API.mangaProps,
11493
+ handler: async (req, res) => {
11494
+ if (!guard(req, res, "POST")) return;
11495
+ const config = getConfig();
11496
+ const project = requireProject(res);
11497
+ if (project === void 0) return;
11498
+ const body = await readJsonBody(req);
11499
+ if (body?.op === "extract") try {
11500
+ const props = await extractProps(ctx, config, project);
11501
+ if (props.length > 0) {
11502
+ project.props = props;
11503
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
11504
+ saveProject(config.outputDir, project);
11505
+ }
11506
+ writeJson(res, 200, { props: project.props ?? [] });
11507
+ return;
11508
+ } catch (error) {
11509
+ writeJson(res, 500, { error: `道具提炼失败:${error.message}` });
11510
+ return;
11511
+ }
11512
+ else if (body?.op === "save" && Array.isArray(body.props)) {
11513
+ project.props = body.props.filter((p) => typeof p === "object" && p !== null && typeof p.name === "string" && p.name.trim() !== "").map((p) => ({
11514
+ name: p.name.trim().slice(0, 20),
11515
+ desc: (typeof p.desc === "string" ? p.desc.trim() : "").slice(0, 120)
11516
+ })).slice(0, 8);
11517
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
11518
+ saveProject(config.outputDir, project);
11519
+ writeJson(res, 200, { props: project.props });
11520
+ return;
11521
+ }
11522
+ writeJson(res, 400, { error: "op 须为 extract 或 save" });
11523
+ }
11524
+ };
9991
11525
  /** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
9992
11526
  const runner = new ProductionRunner({
9993
11527
  ctx,
@@ -10426,7 +11960,9 @@ function makeRoutes(deps) {
10426
11960
  plotlinesRoute,
10427
11961
  rolesRoute,
10428
11962
  mangaRolesRoute,
11963
+ exportPackageRoute,
10429
11964
  scenesRoute,
11965
+ mangaPropsRoute,
10430
11966
  visualRulesRoute,
10431
11967
  sensitiveRoute,
10432
11968
  reviewBackfillRoute,
@@ -10440,7 +11976,6 @@ function makeRoutes(deps) {
10440
11976
  manhuaPlansRoute,
10441
11977
  styleImageRoute,
10442
11978
  storyboardSkeletonRoute,
10443
- imageTestRoute,
10444
11979
  llmTestRoute,
10445
11980
  addModelRoute,
10446
11981
  llmVendorsRoute,
@@ -10533,14 +12068,15 @@ const name = "novel-forge";
10533
12068
  const inject = [
10534
12069
  "webServer",
10535
12070
  "llm",
10536
- "systemPrompt"
12071
+ "systemPrompt",
12072
+ "settings"
10537
12073
  ];
10538
12074
  /**
10539
12075
  * Settings namespace of the novel-forge capability — the section the web
10540
12076
  * settings surface edits. Spelled here rather than imported: the browser half
10541
12077
  * spells the same value and must not depend on a Host package.
10542
12078
  */
10543
- const NOVEL_SETTINGS_NAMESPACE = settingsNamespace("dsh-novel-forge");
12079
+ const NOVEL_SETTINGS_NAMESPACE = "dsh-novel-forge";
10544
12080
  const Config = z.object({
10545
12081
  announceToAgent: z.boolean().default(true),
10546
12082
  enabled: z.boolean().default(true),
@@ -10599,7 +12135,7 @@ const DEFAULT_AUTO_REVIEW_AFTER_REVISE = true;
10599
12135
  /** Order of the announcement section within the tool-guidance band. */
10600
12136
  const SECTION_ORDER = 160;
10601
12137
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
10602
- const NOVEL_GUIDANCE = "本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲或粘贴大纲文本;用 LLM 提炼道藏(人设/世界观/金手指规则/写作红线,即设定圣经);生成卷计划与章节计划;逐章调用 LLM 生成 3000-4000 字正文并保存为 Markdown(默认输出到用户主目录 ~/.dsh/novels);每章自动生成摘要(叙事记忆)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑),支持按审稿意见重写、去 AI 味润色、暗线(伏笔)管理、批量连写与全本导出(txt/md)。限制:生成消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色」时即指本插件,请据此协作。";
12138
+ const NOVEL_GUIDANCE = "本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲或粘贴大纲文本;用 LLM 提炼道藏(人设/世界观/金手指规则/写作红线);生成卷计划与章节计划;逐章调用 LLM 生成 3000-4000 字正文并保存为 Markdown(默认输出到用户主目录 ~/.dsh/novels);每章自动生成摘要(叙事记忆)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑),支持按审稿意见重写、去 AI 味润色、暗线(伏笔)管理、批量连写与全本导出(txt/md)。限制:生成消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色」时即指本插件,请据此协作。";
10603
12139
  /** Resolve a config-like value into the full runtime config. */
10604
12140
  function resolveConfig(value) {
10605
12141
  return {
@@ -10633,7 +12169,7 @@ function resolveConfig(value) {
10633
12169
  imageApiModel: (() => {
10634
12170
  return ((value?.imageModels ?? []).find((m) => m.enabled) ?? (value?.imageModels ?? [])[0])?.model ?? value?.imageApiModel;
10635
12171
  })(),
10636
- imageApiEnabled: value?.imageApiEnabled ?? false,
12172
+ imageApiEnabled: value?.imageApiEnabled === true || (value?.imageModels ?? []).some((m) => m.enabled === true),
10637
12173
  themeBackground: value?.themeBackground ?? "",
10638
12174
  themeBackgroundBlur: value?.themeBackgroundBlur ?? 0,
10639
12175
  themeOpacity: value?.themeOpacity ?? 100,
@@ -10718,7 +12254,7 @@ function apply(ctx, config) {
10718
12254
  };
10719
12255
  }, "dsh-novel-forge: routes");
10720
12256
  };
10721
- installSettingsSection(ctx, NOVEL_SETTINGS_NAMESPACE, Config, config ?? {}, {
12257
+ ctx.settings.installSection(ctx, NOVEL_SETTINGS_NAMESPACE, Config, config ?? {}, {
10722
12258
  setSource: (source) => {
10723
12259
  current = source;
10724
12260
  sync();
@@ -10729,6 +12265,7 @@ function apply(ctx, config) {
10729
12265
  if (skillsService?.register !== void 0) {
10730
12266
  const disposeSkill = skillsService.register({
10731
12267
  name: "novel-forge-chapter-batch",
12268
+ provider: "dsh-novel-forge",
10732
12269
  description: "批量生成小说章节并值守处理审稿未过的章节(豁免/按意见修订/验证模式/重新生成),依赖 dsh-novel-forge 的 /api/dsh-novel-forge/* 路由。",
10733
12270
  content: [
10734
12271
  "# 小说章节批量生产与值守处理",