@waterwx/dsh-novel-forge 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -58,6 +58,12 @@ export interface Config {
58
58
  autoAuthorReview?: boolean
59
59
  /** 修订/润色产出草稿后自动附带一次 AI 审查(默认开,可在设置页关闭省 token)。 */
60
60
  autoReviewAfterRevise?: boolean
61
+ /** 豆包/Seedream 生图 API Key。 */
62
+ imageApiKey?: string
63
+ /** 豆包/Seedream 生图模型 ID。 */
64
+ imageApiModel?: string
65
+ /** 是否启用豆包生图(默认关)。 */
66
+ imageApiEnabled?: boolean
61
67
  }
62
68
 
63
69
  export const Config: z<Config> = z.object({
@@ -74,6 +80,9 @@ export const Config: z<Config> = z.object({
74
80
  autoReview: z.boolean().default(true),
75
81
  autoAuthorReview: z.boolean().default(true),
76
82
  autoReviewAfterRevise: z.boolean().default(true),
83
+ imageApiKey: z.string().default(''),
84
+ imageApiModel: z.string().default(''),
85
+ imageApiEnabled: z.boolean().default(false),
77
86
  })
78
87
 
79
88
  /** Schema defaults, re-read for hand-built test contexts. */
@@ -110,6 +119,9 @@ export function resolveConfig(value: Partial<Config> | undefined): NovelConfig {
110
119
  autoReview: value?.autoReview ?? DEFAULT_AUTO_REVIEW,
111
120
  autoAuthorReview: value?.autoAuthorReview ?? DEFAULT_AUTO_AUTHOR_REVIEW,
112
121
  autoReviewAfterRevise: value?.autoReviewAfterRevise ?? DEFAULT_AUTO_REVIEW_AFTER_REVISE,
122
+ imageApiKey: value?.imageApiKey,
123
+ imageApiModel: value?.imageApiModel,
124
+ imageApiEnabled: value?.imageApiEnabled ?? false,
113
125
  }
114
126
  }
115
127
 
@@ -145,6 +157,9 @@ export function apply(ctx: Context, config?: Config): void {
145
157
  if (patch.autoReview !== undefined) next.autoReview = patch.autoReview
146
158
  if (patch.autoAuthorReview !== undefined) next.autoAuthorReview = patch.autoAuthorReview
147
159
  if (patch.autoReviewAfterRevise !== undefined) next.autoReviewAfterRevise = patch.autoReviewAfterRevise
160
+ if (patch.imageApiKey !== undefined) next.imageApiKey = patch.imageApiKey
161
+ if (patch.imageApiModel !== undefined) next.imageApiModel = patch.imageApiModel
162
+ if (patch.imageApiEnabled !== undefined) next.imageApiEnabled = patch.imageApiEnabled
148
163
  // Persist through the settings seam when available; otherwise keep in memory.
149
164
  // (ctx.get is the non-strict service access — no inject requirement, same
150
165
  // pattern installSettingsSection itself uses.)
package/src/protocol.ts CHANGED
@@ -58,6 +58,8 @@ export const NOVEL_API = {
58
58
  plotlines: '/api/dsh-novel-forge/plotlines',
59
59
  /** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
60
60
  roles: '/api/dsh-novel-forge/roles',
61
+ scenes: '/api/dsh-novel-forge/scenes',
62
+ visualRules: '/api/dsh-novel-forge/visual-rules',
61
63
  /** 作者复盘补跑:对已写章节补齐 authorReview(全书流式 / 单章 JSON)。 */
62
64
  reviewBackfill: '/api/dsh-novel-forge/review/backfill',
63
65
  /** 章节复位:generating 卡死 → pending(可重新生成)。 */
@@ -71,9 +73,8 @@ export const NOVEL_API = {
71
73
  /** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检(两阶段:源笔记→分节分析)。 */
72
74
  breakdown: '/api/dsh-novel-forge/breakdown',
73
75
  /** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(可适配豆包/Seedance/SD)。 */
74
- storyboard: '/api/dsh-novel-forge/storyboard',
75
76
  /** 漫剧分集计划:读一卷 → 按故事弧线分集(高潮拆集/过渡并章)。 */
76
- storyboardPlan: '/api/dsh-novel-forge/storyboard/plan',
77
+ /** 漫画脚本:章节 → 分页分格漫画脚本(含角色视觉锚点)。 */
77
78
  /** 生产单:启动批量生产(计划补足 + 逐章生成 + 被拒分级处理)。 */
78
79
  runStart: '/api/dsh-novel-forge/run/start',
79
80
  /** 生产单控制:pause / resume / stop。 */
@@ -494,98 +495,6 @@ export interface BreakdownResponse {
494
495
  usedTokens: number
495
496
  }
496
497
 
497
- /** 漫剧分镜:一个角色锚点卡。 */
498
- export interface StoryboardCharacterCard {
499
- name: string
500
- /** 视觉锚点:发型/五官/体型/标志物(中文描述)。 */
501
- visualAnchor: string
502
- /** AI 标签组(英文,全分镜强制复用)。 */
503
- tags: string
504
- /** 情绪表情库(英文,按需调用)。 */
505
- expressions: string[]
506
- }
507
-
508
- /** 漫剧分镜:一个分镜格。 */
509
- export interface StoryboardPanel {
510
- /** 序号。 */
511
- index: number
512
- /** 时间码(如 0:00-0:03)。 */
513
- timecode: string
514
- /** 景别(特写/中景/全景…)。 */
515
- shot: string
516
- /** 画面描述(具象动作,非抽象情感)。 */
517
- visual: string
518
- /** 台词(情绪标签 + 内容)。 */
519
- dialogue: string
520
- /** 转场/动效。 */
521
- transition: string
522
- /** AI 提示词(英文标签,豆包/SD 通用)。 */
523
- prompt: string
524
- }
525
-
526
- /** POST /storyboard 请求:章节 → 漫剧分镜。 */
527
- export interface StoryboardRequest {
528
- chapterNo: number
529
- /** 赛道类型:爽文/甜宠/悬疑/搞笑(留空由 AI 推断)。 */
530
- genre?: string
531
- /** 目标平台(抖音/快手/B站…,默认抖音)。 */
532
- platform?: string
533
- /** AI 工具偏好:doubao / seedance / sd / mj(影响提示词格式,默认 doubao)。 */
534
- tool?: string
535
- }
536
-
537
- /** POST /storyboard 响应。 */
538
- export interface StoryboardResponse {
539
- /** 本集标题。 */
540
- title: string
541
- /** 赛道与节奏说明。 */
542
- pacingNote: string
543
- /** 本集钩子(结尾悬念)。 */
544
- hook: string
545
- /** 角色锚点卡。 */
546
- characters: StoryboardCharacterCard[]
547
- /** 分镜表。 */
548
- panels: StoryboardPanel[]
549
- /** 结尾钩子台词。 */
550
- endingHook: string
551
- }
552
-
553
- /** 漫剧分集计划:一集(含 1-N 章)。 */
554
- export interface StoryboardEpisode {
555
- /** 集号。 */
556
- index: number
557
- /** 集标题(冲突+悬念句式)。 */
558
- title: string
559
- /** 涵盖章节号。 */
560
- chapters: number[]
561
- /** 叙事任务:这集讲什么、为什么这么分。 */
562
- narrativeJob: string
563
- /** 开头钩子。 */
564
- openingHook: string
565
- /** 结尾钩子(逼看下集)。 */
566
- endingHook: string
567
- }
568
-
569
- /** POST /storyboard/plan 请求:读一卷 → 输出漫剧分集计划。 */
570
- export interface StoryboardPlanRequest {
571
- /** 卷号(1-5)。 */
572
- volumeNo: number
573
- /** 目标平台(默认抖音)。 */
574
- platform?: string
575
- /** 每集约 1-2 分钟,最多多少集(默认 25)。 */
576
- maxEpisodes?: number
577
- }
578
-
579
- /** POST /storyboard/plan 响应。 */
580
- export interface StoryboardPlanResponse {
581
- /** 本卷漫剧化的叙事策略说明。 */
582
- strategy: string
583
- /** 分集计划。 */
584
- episodes: StoryboardEpisode[]
585
- /** 参与规划的章节数。 */
586
- chaptersScanned: number
587
- }
588
-
589
498
  /** 角色卡:角色当前状态(从事实库聚合)。 */
590
499
  export interface RoleStatusCard {
591
500
  name: string
@@ -630,15 +539,111 @@ export interface RoleRecord {
630
539
  /** 依据来源说明(哪几章哪些描写)。 */
631
540
  source?: string
632
541
  }
542
+ /** 角色参考图(dataURL 或 URL)。用于漫画/漫剧出图时锁定角色一致性。 */
543
+ imageUrl?: string
544
+ /** 角色图集(图像锚点):立绘/四视图/表情/场景/细节等,label 标注用途。 */
545
+ gallery?: RoleImage[]
546
+ /** 该角色所需的情绪表情清单(提炼时 LLM 补全 6-12 个,如"疲惫/麻木/压抑悲伤")。 */
547
+ expressions?: string[]
548
+ /** LLM 精修的四类生图提示词包(前端拼装版之外的高质量版,promptKit 接口生成)。 */
549
+ promptKit?: {
550
+ /** 立绘:全身/半身正视图。 */
551
+ portrait: { zh: string; en: string }
552
+ /** 四视图:正面/侧面/背面设定表。 */
553
+ sheet: { zh: string; en: string }
554
+ /** 表情:同一脸部锚换情绪,每表情一段。 */
555
+ expressions: { name: string; zh: string; en: string }[]
556
+ /** 细节:标志物局部特写。 */
557
+ details: { zh: string; en: string }
558
+ }
559
+ /** 漫画/漫剧重要性:main=必须建卡保证一致;support=有锚点即可;extra=路人随机生成不约束。 */
560
+ importance?: 'main' | 'support' | 'extra'
561
+ }
562
+
563
+ /** 角色图集条目:一张已定型的角色图(图像锚点)。 */
564
+ export interface RoleImage {
565
+ /** 用途标签:立绘 / 四视图 / 表情-疲惫 / 场景 / 细节 等。 */
566
+ label: string
567
+ /** dataURL 或 URL。 */
568
+ dataUrl: string
569
+ }
570
+
571
+ /** 场景库条目:从正文提炼的「镜头场景」视觉锚点(漫剧分镜/生图用)。 */
572
+ export interface SceneCard {
573
+ /** 场景名(唯一键),如「旧茶渡·地下石殿」。 */
574
+ name: string
575
+ /** 一句话定位:空间/氛围/功能。 */
576
+ summary: string
577
+ /** 幕归属:第一幕·后场 / 第二幕·卖场 / …(便于按剧情段落组织)。 */
578
+ act?: string
579
+ /** 时间光态:夜间闭店后 / 雨夜 / 凌晨 / 频闪灯… */
580
+ moment?: string
581
+ /** 关键情节镜头 1-3 条:该场景拍什么(人物动作+情绪+镜头推进)。 */
582
+ beats?: string[]
583
+ /** 人物在场状态:主角/关键角色在该场景的状态一句话。 */
584
+ characterState?: string
585
+ /** 环境构成:空间结构/陈设/标志物。 */
586
+ elements: string[]
587
+ /** 色调与光影(含 HEX 色板,可空)。 */
588
+ palette: string[]
589
+ /** 氛围关键词:压抑/神秘/空旷… */
590
+ moods: string[]
591
+ /** 中文生图提示词(连贯一段,写实电影感)。 */
592
+ zh: string
593
+ /** 英文生图提示词(逗号分隔标签)。 */
594
+ en: string
595
+ /** 关键标签。 */
596
+ tags: string[]
597
+ /** 依据来源(哪几章哪些描写)。 */
598
+ source?: string
599
+ /** 场景图集(图像锚点):全景/局部/氛围 等。 */
600
+ gallery?: RoleImage[]
601
+ }
602
+
603
+ /** POST /scenes 请求:场景库提炼 / 采纳 / 更新 / 删除 / 图集。 */
604
+ export interface ScenesRequest {
605
+ op: 'extract' | 'adopt' | 'update' | 'remove' | 'image' | 'removeImage'
606
+ /** adopt / update 时传入的场景卡(adopt 可修改后采纳)。 */
607
+ scene?: SceneCard
608
+ /** remove / image 时的场景名。 */
609
+ name?: string
610
+ /** op='image' 时的图片 dataURL 与用途标签(全景/局部/氛围)。 */
611
+ dataUrl?: string
612
+ label?: string
633
613
  }
634
614
 
635
- /** POST /roles 请求:角色库增删改 + AI 提炼。 */
615
+ /** POST /visual-rules 请求:视觉世界观规则提炼 / 保存。 */
616
+ export interface VisualRulesRequest {
617
+ op: 'extract' | 'save'
618
+ /** op=save 时的规则数组。 */
619
+ rules?: string[]
620
+ }
621
+
622
+ /** POST /visual-rules 响应。 */
623
+ export interface VisualRulesResponse {
624
+ rules: string[]
625
+ }
626
+
627
+ /** POST /scenes 响应。 */
628
+ export interface ScenesResponse {
629
+ scenes: SceneCard[]
630
+ /** op=extract 时的 AI 候选场景。 */
631
+ candidates?: SceneCard[]
632
+ }
633
+
634
+ /** POST /roles 请求:角色库增删改 + AI 提炼 + 参考图上传。 */
636
635
  export interface RolesRequest {
637
- op: 'extract' | 'adopt' | 'update' | 'remove' | 'visual'
636
+ op: 'extract' | 'adopt' | 'update' | 'remove' | 'visual' | 'image' | 'removeImage' | 'imageGenerate' | 'promptKit'
638
637
  /** adopt / update 时传入的角色(adopt 可修改后采纳)。 */
639
638
  role?: RoleRecord
640
639
  /** remove 时的角色名。 */
641
640
  name?: string
641
+ /** op='image' 时的参考图 dataURL。 */
642
+ dataUrl?: string
643
+ /** op='image' 时的图集用途标签(立绘/四视图/表情-x/场景/细节);缺省视为角色参考图(imageUrl)。 */
644
+ label?: string
645
+ /** op='imageGenerate' 时的漫画风格预设 id(用于统一角色图与漫画页风格)。 */
646
+ style?: string
642
647
  }
643
648
 
644
649
  /** POST /roles 响应。 */
@@ -648,6 +653,10 @@ export interface RolesResponse {
648
653
  candidates?: RoleRecord[]
649
654
  /** op=visual 时的动漫形象描述词(已写入角色卡)。 */
650
655
  visual?: RoleRecord['imagePrompt']
656
+ /** op=promptKit 时的四类提示词精修包(已写入角色卡)。 */
657
+ promptKit?: RoleRecord['promptKit']
658
+ /** op=image 时的角色参考图 dataURL。 */
659
+ imageUrl?: string
651
660
  }
652
661
 
653
662
  /** POST /bible/patch 请求:局部修补设定圣经。 */
@@ -776,8 +785,15 @@ export interface ProjectState {
776
785
  plotlines?: Plotline[]
777
786
  /** 角色库(作者维护 + AI 提炼的主表)。 */
778
787
  roles?: RoleRecord[]
788
+ /** 场景库(作者维护 + AI 提炼的主表)。 */
789
+ scenes?: SceneCard[]
790
+ /** 视觉世界观规则(生图/生视频必须遵守,从道藏提炼,注入所有提示词)。 */
791
+ visualRules?: string[]
779
792
  /** 人物志:角色当前状态聚合结果(从编年录刷新后存档,打开页面直接显示)。 */
780
793
  roleStatus?: RoleStatusCard[]
794
+ /** 漫剧分镜结果缓存:章号 → 分镜(持久化,刷新不丢)。 */
795
+ /** 漫剧分集计划缓存:卷号/0=全书 → 分集计划(持久化,刷新不丢)。 */
796
+ /** 漫画脚本缓存:章号 → 漫画脚本(持久化,刷新不丢)。 */
781
797
  /** ISO timestamps. */
782
798
  createdAt: string
783
799
  updatedAt: string
@@ -807,6 +823,12 @@ export interface NovelConfig {
807
823
  autoAuthorReview: boolean
808
824
  /** 修订/润色产出草稿后是否自动附带一次 AI 审查(工作区显示新稿评分与剩余问题)。 */
809
825
  autoReviewAfterRevise: boolean
826
+ /** 豆包/Seedream 生图 API Key(保存在服务端配置,不暴露给前端明文回显)。 */
827
+ imageApiKey?: string
828
+ /** 豆包/Seedream 生图模型 ID(如 doubao-seedream-5-0-pro-260628)。 */
829
+ imageApiModel?: string
830
+ /** 是否启用豆包生图(默认关,避免误调用付费 API)。 */
831
+ imageApiEnabled?: boolean
810
832
  }
811
833
 
812
834
  /** GET /status response. */
@@ -1017,6 +1039,9 @@ export interface ConfigPatch {
1017
1039
  autoReview?: boolean
1018
1040
  autoAuthorReview?: boolean
1019
1041
  autoReviewAfterRevise?: boolean
1042
+ imageApiKey?: string
1043
+ imageApiModel?: string
1044
+ imageApiEnabled?: boolean
1020
1045
  }
1021
1046
 
1022
1047
  // ------------------------------------------------------------ assistant
package/src/routes.ts CHANGED
@@ -65,14 +65,15 @@ import {
65
65
  type RewriteRequest,
66
66
  type RolesRequest,
67
67
  type RolesResponse,
68
+ type SceneCard,
69
+ type ScenesRequest,
70
+ type ScenesResponse,
71
+ type VisualRulesRequest,
72
+ type VisualRulesResponse,
68
73
  type OutlineSuggestRequest,
69
74
  type OutlineSuggestResponse,
70
75
  type BreakdownRequest,
71
76
  type BreakdownResponse,
72
- type StoryboardRequest,
73
- type StoryboardResponse,
74
- type StoryboardPlanRequest,
75
- type StoryboardPlanResponse,
76
77
  type SensitiveCheckRequest,
77
78
  type SensitiveCheckResponse,
78
79
  type SensitiveHit,
@@ -117,11 +118,13 @@ import {
117
118
  analyzePlotlineHealth,
118
119
  designPlotlinePlan,
119
120
  extractRoles,
121
+ extractScenes,
122
+ generateRolePromptKit,
123
+ extractVisualRules,
120
124
  extractRoleVisual,
121
125
  suggestOutlines,
122
126
  breakdownBook,
123
- generateStoryboard,
124
- planStoryboardEpisodes,
127
+ generateRoleReferenceImage,
125
128
  checkSensitiveText,
126
129
  suggestForeshadows,
127
130
  suggestPlotlines,
@@ -349,7 +352,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
349
352
  return
350
353
  }
351
354
  try {
352
- const bible = await extractBible(ctx, config, outline)
355
+ const bible = await extractBible(ctx, config, outline, project)
353
356
  const now = new Date().toISOString()
354
357
  const next = project ?? createProject(outline)
355
358
  next.bible = bible
@@ -1683,6 +1686,69 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1683
1686
  else project.roles[idx] = r
1684
1687
  } else if (op === 'remove' && body?.name !== undefined) {
1685
1688
  project.roles = project.roles.filter(x => x.name !== body.name)
1689
+ } else if (op === 'image') {
1690
+ const name = body?.name?.trim()
1691
+ const dataUrl = body?.dataUrl?.trim()
1692
+ if (name === undefined || name === '') {
1693
+ writeJson(res, 400, { error: 'name(角色名)必填' })
1694
+ return
1695
+ }
1696
+ if (dataUrl === undefined || dataUrl === '') {
1697
+ writeJson(res, 400, { error: 'dataUrl(参考图)必填' })
1698
+ return
1699
+ }
1700
+ const role = project.roles.find(r => r.name === name)
1701
+ if (role === undefined) {
1702
+ writeJson(res, 404, { error: `角色 ${name} 不存在` })
1703
+ return
1704
+ }
1705
+ const label = body?.label?.trim() ?? ''
1706
+ if (label !== '') {
1707
+ // 图集上传:带用途标签。标签「立绘」时同步为角色参考图(imageUrl)。
1708
+ role.gallery ??= []
1709
+ role.gallery = role.gallery.filter(g => g.label !== label)
1710
+ role.gallery.push({ label, dataUrl })
1711
+ if (label === '立绘' || label.includes('立绘')) role.imageUrl = dataUrl
1712
+ } else {
1713
+ // 兼容旧行为:不带标签 = 角色参考图。
1714
+ role.imageUrl = dataUrl
1715
+ }
1716
+ project.updatedAt = new Date().toISOString()
1717
+ saveProject(config.outputDir, project)
1718
+ writeJson(res, 200, { roles: project.roles, imageUrl: role.imageUrl } satisfies RolesResponse)
1719
+ return
1720
+ } else if (op === 'removeImage') {
1721
+ const name = body?.name?.trim()
1722
+ const label = body?.label?.trim() ?? ''
1723
+ const role = project.roles.find(r => r.name === name)
1724
+ if (role !== undefined) {
1725
+ if (label !== '') {
1726
+ role.gallery = (role.gallery ?? []).filter(g => g.label !== label)
1727
+ if (label === '立绘' || label.includes('立绘')) role.imageUrl = undefined
1728
+ } else {
1729
+ role.imageUrl = undefined
1730
+ }
1731
+ }
1732
+ project.updatedAt = new Date().toISOString()
1733
+ saveProject(config.outputDir, project)
1734
+ writeJson(res, 200, { roles: project.roles } satisfies RolesResponse)
1735
+ return
1736
+ } else if (op === 'imageGenerate') {
1737
+ const name = body?.name?.trim()
1738
+ if (name === undefined || name === '') {
1739
+ writeJson(res, 400, { error: 'name(角色名)必填' })
1740
+ return
1741
+ }
1742
+ try {
1743
+ const imageUrl = await generateRoleReferenceImage(ctx, config, project, config.outputDir, name, body?.style ?? '')
1744
+ project.updatedAt = new Date().toISOString()
1745
+ saveProject(config.outputDir, project)
1746
+ writeJson(res, 200, { roles: project.roles, imageUrl } satisfies RolesResponse)
1747
+ return
1748
+ } catch (error) {
1749
+ writeJson(res, 500, { error: `角色参考图生成失败:${(error as Error).message}` })
1750
+ return
1751
+ }
1686
1752
  } else if (op === 'visual') {
1687
1753
  // 动漫形象描述词提炼:扫描正文外貌描写 → LLM 提炼 → 写入角色卡。
1688
1754
  const name = body?.name?.trim()
@@ -1693,7 +1759,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1693
1759
  try {
1694
1760
  const visual = await extractRoleVisual(ctx, config, project, config.outputDir, name)
1695
1761
  const role = project.roles.find(r => r.name === name)
1696
- if (role !== undefined) role.imagePrompt = visual
1762
+ if (role !== undefined) {
1763
+ role.imagePrompt = visual
1764
+ if ((visual.expressions ?? []).length > 0) role.expressions = visual.expressions
1765
+ if (visual.promptKit !== undefined) role.promptKit = visual.promptKit
1766
+ }
1697
1767
  project.updatedAt = new Date().toISOString()
1698
1768
  saveProject(config.outputDir, project)
1699
1769
  writeJson(res, 200, { roles: project.roles, visual } satisfies RolesResponse)
@@ -1702,6 +1772,25 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1702
1772
  writeJson(res, 500, { error: `形象提炼失败:${(error as Error).message}` })
1703
1773
  return
1704
1774
  }
1775
+ } else if (op === 'promptKit') {
1776
+ // 角色四类提示词精修包(立绘/四视图/表情/细节)
1777
+ const name = body?.name?.trim()
1778
+ if (name === undefined || name === '') {
1779
+ writeJson(res, 400, { error: 'name(角色名)必填' })
1780
+ return
1781
+ }
1782
+ try {
1783
+ const kit = await generateRolePromptKit(ctx, config, project, name)
1784
+ const role = project.roles.find(r => r.name === name)
1785
+ if (role !== undefined) role.promptKit = kit
1786
+ project.updatedAt = new Date().toISOString()
1787
+ saveProject(config.outputDir, project)
1788
+ writeJson(res, 200, { roles: project.roles, promptKit: kit } satisfies RolesResponse)
1789
+ return
1790
+ } catch (error) {
1791
+ writeJson(res, 500, { error: `提示词精修失败:${(error as Error).message}` })
1792
+ return
1793
+ }
1705
1794
  } else {
1706
1795
  writeJson(res, 400, { error: '未知的 roles op' })
1707
1796
  return
@@ -1952,67 +2041,99 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1952
2041
  },
1953
2042
  }
1954
2043
 
1955
- // ---------------------------------------------------------- storyboard
1956
- /** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(适配豆包/Seedance/SD)。 */
1957
- const storyboardRoute: WebRoute = {
2044
+ // --------------------------------------------------------------- scenes
2045
+ /** 场景库:AI 提炼 / 采纳 / 更新 / 删除 / 图集。 */
2046
+ const scenesRoute: WebRoute = {
1958
2047
  kind: 'exact',
1959
- path: NOVEL_API.storyboard,
2048
+ path: NOVEL_API.scenes,
1960
2049
  handler: async (req, res) => {
1961
2050
  if (!guard(req, res, 'POST')) return
1962
2051
  const config = getConfig()
1963
2052
  const project = requireProject(res)
1964
2053
  if (project === undefined) return
1965
- const body = await readJsonBody<StoryboardRequest>(req)
1966
- if (!Number.isInteger(body?.chapterNo)) {
1967
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
2054
+ const body = await readJsonBody<ScenesRequest>(req)
2055
+ if (project.scenes === undefined) project.scenes = []
2056
+ const op = body?.op
2057
+ if (op === 'extract') {
2058
+ try {
2059
+ const candidates = await extractScenes(ctx, config, project)
2060
+ writeJson(res, 200, { scenes: project.scenes, candidates } satisfies ScenesResponse)
2061
+ return
2062
+ } catch (error) {
2063
+ writeJson(res, 500, { error: `场景提炼失败:${(error as Error).message}` })
2064
+ return
2065
+ }
2066
+ } else if ((op === 'adopt' || op === 'update') && body?.scene !== undefined) {
2067
+ const s = body.scene
2068
+ const idx = project.scenes.findIndex(x => x.name === s.name)
2069
+ if (idx === -1) project.scenes.push(s)
2070
+ else project.scenes[idx] = s
2071
+ } else if (op === 'remove' && body?.name !== undefined) {
2072
+ project.scenes = project.scenes.filter(x => x.name !== body.name)
2073
+ } else if (op === 'image') {
2074
+ const name = body?.name?.trim()
2075
+ const dataUrl = body?.dataUrl?.trim()
2076
+ if (name === undefined || name === '' || dataUrl === undefined || dataUrl === '') {
2077
+ writeJson(res, 400, { error: 'name(场景名)与 dataUrl 必填' })
2078
+ return
2079
+ }
2080
+ const scene = project.scenes.find(x => x.name === name)
2081
+ if (scene === undefined) {
2082
+ writeJson(res, 404, { error: `场景 ${name} 不存在` })
2083
+ return
2084
+ }
2085
+ const label = body?.label?.trim() ?? '全景'
2086
+ scene.gallery ??= []
2087
+ scene.gallery = scene.gallery.filter(g => g.label !== label)
2088
+ scene.gallery.push({ label, dataUrl })
2089
+ } else if (op === 'removeImage') {
2090
+ const name = body?.name?.trim()
2091
+ const label = body?.label?.trim() ?? ''
2092
+ const scene = project.scenes.find(x => x.name === name)
2093
+ if (scene !== undefined && label !== '') {
2094
+ scene.gallery = (scene.gallery ?? []).filter(g => g.label !== label)
2095
+ }
2096
+ } else {
2097
+ writeJson(res, 400, { error: `未知操作 ${op}` })
1968
2098
  return
1969
2099
  }
1970
- try {
1971
- const result = await generateStoryboard(
1972
- ctx,
1973
- config,
1974
- project,
1975
- config.outputDir,
1976
- body!.chapterNo!,
1977
- body?.genre ?? '',
1978
- body?.platform ?? '抖音',
1979
- body?.tool ?? 'doubao',
1980
- )
1981
- writeJson(res, 200, result satisfies StoryboardResponse)
1982
- } catch (error) {
1983
- writeJson(res, 500, { error: `分镜生成失败:${(error as Error).message}` })
1984
- }
2100
+ project.updatedAt = new Date().toISOString()
2101
+ saveProject(config.outputDir, project)
2102
+ writeJson(res, 200, { scenes: project.scenes } satisfies ScenesResponse)
1985
2103
  },
1986
2104
  }
1987
2105
 
1988
- // --------------------------------------------------- storyboard plan
1989
- /** 漫剧分集计划:读一卷 → 按故事弧线分集(高潮拆集/过渡并章)。 */
1990
- const storyboardPlanRoute: WebRoute = {
2106
+ // ----------------------------------------------------------- visual rules
2107
+ /** 视觉世界观规则:从道藏提炼(生图/生视频纠偏),或手动保存。 */
2108
+ const visualRulesRoute: WebRoute = {
1991
2109
  kind: 'exact',
1992
- path: NOVEL_API.storyboardPlan,
2110
+ path: NOVEL_API.visualRules,
1993
2111
  handler: async (req, res) => {
1994
2112
  if (!guard(req, res, 'POST')) return
1995
2113
  const config = getConfig()
1996
2114
  const project = requireProject(res)
1997
2115
  if (project === undefined) return
1998
- const body = await readJsonBody<StoryboardPlanRequest>(req)
1999
- if (!Number.isInteger(body?.volumeNo) || (body?.volumeNo ?? 0) < 1) {
2000
- writeJson(res, 400, { error: 'volumeNo 须为正整数' })
2116
+ const body = await readJsonBody<VisualRulesRequest>(req)
2117
+ if (body?.op === 'extract') {
2118
+ try {
2119
+ const rules = await extractVisualRules(ctx, config, project)
2120
+ project.visualRules = rules
2121
+ project.updatedAt = new Date().toISOString()
2122
+ saveProject(config.outputDir, project)
2123
+ writeJson(res, 200, { rules } satisfies VisualRulesResponse)
2124
+ return
2125
+ } catch (error) {
2126
+ writeJson(res, 500, { error: `视觉规则提炼失败:${(error as Error).message}` })
2127
+ return
2128
+ }
2129
+ } else if (body?.op === 'save' && Array.isArray(body.rules)) {
2130
+ project.visualRules = body.rules.filter(r => typeof r === 'string' && r.trim() !== '').map(r => r.trim().slice(0, 80)).slice(0, 12)
2131
+ project.updatedAt = new Date().toISOString()
2132
+ saveProject(config.outputDir, project)
2133
+ writeJson(res, 200, { rules: project.visualRules } satisfies VisualRulesResponse)
2001
2134
  return
2002
2135
  }
2003
- try {
2004
- const result = await planStoryboardEpisodes(
2005
- ctx,
2006
- config,
2007
- project,
2008
- body!.volumeNo!,
2009
- body?.platform ?? '抖音',
2010
- body?.maxEpisodes ?? 25,
2011
- )
2012
- writeJson(res, 200, result satisfies StoryboardPlanResponse)
2013
- } catch (error) {
2014
- writeJson(res, 500, { error: `分集计划生成失败:${(error as Error).message}` })
2015
- }
2136
+ writeJson(res, 400, { error: 'op 须为 extract 或 save' })
2016
2137
  },
2017
2138
  }
2018
2139
 
@@ -2121,6 +2242,8 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2121
2242
  renameRoute,
2122
2243
  plotlinesRoute,
2123
2244
  rolesRoute,
2245
+ scenesRoute,
2246
+ visualRulesRoute,
2124
2247
  sensitiveRoute,
2125
2248
  reviewBackfillRoute,
2126
2249
  chapterResetRoute,
@@ -2129,8 +2252,6 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2129
2252
  openFolderRoute,
2130
2253
  outlineSuggestRoute,
2131
2254
  breakdownRoute,
2132
- storyboardRoute,
2133
- storyboardPlanRoute,
2134
2255
  runStartRoute,
2135
2256
  runControlRoute,
2136
2257
  runStatusRoute,