@waterwx/dsh-novel-forge 1.10.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +287 -281
  2. package/lib/client.js +19572 -18127
  3. package/lib/index.js +12171 -6932
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +30 -0
  6. package/lib/types/assets.d.ts +20 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +1 -3
  9. package/lib/types/bookshelf.d.ts +13 -3
  10. package/lib/types/client/api.d.ts +137 -21
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -2
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +15 -70
  16. package/lib/types/client/mount.d.ts +0 -2
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  19. package/lib/types/client/panel/AuthorHome.d.ts +13 -9
  20. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  21. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  22. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  23. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  24. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  25. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  26. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  27. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  28. package/lib/types/client/panel/ModelManager.d.ts +2 -2
  29. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  30. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  31. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  32. package/lib/types/client/panel/SettingsView.d.ts +12 -7
  33. package/lib/types/client/panel/ShelfView.d.ts +10 -1
  34. package/lib/types/client/panel/SubPage.d.ts +27 -0
  35. package/lib/types/client/panel/TensionView.d.ts +18 -0
  36. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  37. package/lib/types/client/panel/helpers.d.ts +5 -3
  38. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  39. package/lib/types/client/panel/toast.d.ts +2 -0
  40. package/lib/types/client/panel/views.d.ts +21 -35
  41. package/lib/types/client/run-status.d.ts +21 -0
  42. package/lib/types/client/sidebar-entry.d.ts +0 -2
  43. package/lib/types/docx-core.d.ts +16 -0
  44. package/lib/types/docx.d.ts +2 -3
  45. package/lib/types/engine.d.ts +83 -79
  46. package/lib/types/global-assets.d.ts +6 -0
  47. package/lib/types/home.d.ts +11 -0
  48. package/lib/types/index.d.ts +27 -13
  49. package/lib/types/llm-live.d.ts +70 -0
  50. package/lib/types/llm-retry.d.ts +38 -0
  51. package/lib/types/market-radar-scan.d.ts +18 -0
  52. package/lib/types/market-radar-sources.d.ts +31 -0
  53. package/lib/types/migrate-output.d.ts +25 -0
  54. package/lib/types/model-capability.d.ts +93 -0
  55. package/lib/types/novel-context.d.ts +80 -0
  56. package/lib/types/output-budget.d.ts +86 -0
  57. package/lib/types/prompt-slots.d.ts +57 -0
  58. package/lib/types/prompting.d.ts +10 -0
  59. package/lib/types/protocol.d.ts +829 -522
  60. package/lib/types/revision.d.ts +76 -0
  61. package/lib/types/routes.d.ts +9 -3
  62. package/lib/types/run.d.ts +23 -1
  63. package/lib/types/settings-persist.d.ts +59 -0
  64. package/lib/types/snapshots.d.ts +46 -0
  65. package/lib/types/stage-contract.d.ts +58 -0
  66. package/lib/types/tension.d.ts +53 -0
  67. package/lib/types/timeline.d.ts +37 -0
  68. package/package.json +50 -28
  69. package/src/action-guard.ts +127 -0
  70. package/src/ai-scan.ts +155 -0
  71. package/src/assets.ts +1801 -535
  72. package/src/assistant.ts +1098 -770
  73. package/src/author-assets.ts +131 -147
  74. package/src/bookshelf.ts +193 -167
  75. package/src/client/api.ts +767 -607
  76. package/src/client/chip-prefs.ts +121 -0
  77. package/src/client/docx.ts +32 -69
  78. package/src/client/index.ts +3 -1
  79. package/src/client/llmLive.ts +111 -0
  80. package/src/client/locales.ts +15 -274
  81. package/src/client/mount.tsx +4 -3
  82. package/src/client/panel/AdaptModeView.tsx +535 -520
  83. package/src/client/panel/AiTag.tsx +7 -0
  84. package/src/client/panel/AssetsTab.tsx +448 -458
  85. package/src/client/panel/AssistantTab.tsx +355 -301
  86. package/src/client/panel/AuthorAssetsView.tsx +205 -207
  87. package/src/client/panel/AuthorHome.tsx +309 -202
  88. package/src/client/panel/BookAnalysisView.tsx +63 -0
  89. package/src/client/panel/CmdPalette.tsx +102 -0
  90. package/src/client/panel/CreateBookView.tsx +366 -352
  91. package/src/client/panel/DirectorView.tsx +200 -0
  92. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -172
  93. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  94. package/src/client/panel/ImportModal.tsx +302 -302
  95. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  96. package/src/client/panel/LiveFeedLog.tsx +204 -0
  97. package/src/client/panel/MarketRadarView.tsx +415 -0
  98. package/src/client/panel/ModelManager.tsx +205 -205
  99. package/src/client/panel/NovelPanel.tsx +5559 -4655
  100. package/src/client/panel/PageHeader.tsx +37 -0
  101. package/src/client/panel/ProgressConsole.tsx +63 -64
  102. package/src/client/panel/PromptSlotsView.tsx +97 -0
  103. package/src/client/panel/ReaderView.tsx +306 -306
  104. package/src/client/panel/RunPanel.tsx +56 -24
  105. package/src/client/panel/SettingsView.tsx +175 -112
  106. package/src/client/panel/ShelfView.tsx +63 -39
  107. package/src/client/panel/SubPage.tsx +42 -0
  108. package/src/client/panel/TensionView.tsx +212 -0
  109. package/src/client/panel/TimelineView.tsx +208 -0
  110. package/src/client/panel/WorldTab.tsx +202 -202
  111. package/src/client/panel/helpers.ts +7 -5
  112. package/src/client/panel/market-radar-state.ts +79 -0
  113. package/src/client/panel/panel.module.css +5767 -4199
  114. package/src/client/panel/toast.ts +51 -0
  115. package/src/client/panel/views.tsx +316 -314
  116. package/src/client/run-status.ts +171 -0
  117. package/src/client/sidebar-entry.ts +3 -3
  118. package/src/docx-core.ts +53 -0
  119. package/src/docx.ts +44 -83
  120. package/src/engine.ts +1610 -1541
  121. package/src/global-assets.ts +58 -0
  122. package/src/home.ts +33 -0
  123. package/src/index.ts +184 -103
  124. package/src/llm-live.ts +245 -0
  125. package/src/llm-retry.ts +77 -0
  126. package/src/market-radar-scan.ts +35 -0
  127. package/src/market-radar-sources.ts +173 -0
  128. package/src/migrate-output.ts +76 -0
  129. package/src/model-capability.ts +215 -0
  130. package/src/novel-context.ts +286 -0
  131. package/src/output-budget.ts +147 -0
  132. package/src/prompt-slots.ts +125 -0
  133. package/src/prompting.ts +26 -0
  134. package/src/protocol.ts +2454 -2148
  135. package/src/revision.ts +283 -0
  136. package/src/routes.ts +3795 -3359
  137. package/src/run.ts +113 -17
  138. package/src/settings-persist.ts +114 -0
  139. package/src/snapshots.ts +174 -0
  140. package/src/stage-contract.ts +186 -0
  141. package/src/tension.ts +251 -0
  142. package/src/timeline.ts +159 -0
  143. package/assets/styles/arcane-thick-3d.webp +0 -0
  144. package/assets/styles/chinese-ink.webp +0 -0
  145. package/assets/styles/clay-stopmotion-3d.webp +0 -0
  146. package/assets/styles/dark-ghibli.webp +0 -0
  147. package/assets/styles/dark-gothic-3d.webp +0 -0
  148. package/assets/styles/film-photo.webp +0 -0
  149. package/assets/styles/guofeng-heavy.webp +0 -0
  150. package/assets/styles/horror-game-3d.webp +0 -0
  151. package/assets/styles/japan-cel-dark.webp +0 -0
  152. package/assets/styles/korean-webtoon.webp +0 -0
  153. package/assets/styles/low-cyberpunk-3d.webp +0 -0
  154. package/assets/styles/nextgen-cg.webp +0 -0
  155. package/assets/styles/noir-film.webp +0 -0
  156. package/assets/styles/openworld-survival.webp +0 -0
  157. package/assets/styles/paper-cut-3d.webp +0 -0
  158. package/assets/styles/pixar-adult-3d.webp +0 -0
  159. package/assets/styles/pop-marvel-2d.webp +0 -0
  160. package/assets/styles/shinkai-anime.webp +0 -0
  161. package/assets/styles/tactical-stealth.webp +0 -0
  162. package/assets/styles/vaporwave-3d.webp +0 -0
  163. package/assets/styles/vhs90.webp +0 -0
  164. package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
  165. package/lib/types/client/panel/FlowGuide.d.ts +0 -21
  166. package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -18
  167. package/lib/types/client/panel/MangaWorkspace.d.ts +0 -15
  168. package/lib/types/client/panel/ModelPicker.d.ts +0 -11
  169. package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
  170. package/lib/types/client/panel/SceneLibrary.d.ts +0 -14
  171. package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
  172. package/lib/types/client/panel/StyleCard.d.ts +0 -8
  173. package/lib/types/client/panel/StyleGallery.d.ts +0 -1
  174. package/lib/types/comic-presets.d.ts +0 -12
  175. package/lib/types/shot-language.d.ts +0 -46
  176. package/lib/types/story-beat-language.d.ts +0 -35
  177. package/lib/types/style-library.d.ts +0 -33
  178. package/skills/chapter-batch/SKILL.md +0 -81
  179. package/src/client/mount.tsx._new +0 -97
  180. package/src/client/mount.tsx._nf +0 -97
  181. package/src/client/mount.tsx._tmp +0 -97
  182. package/src/client/panel/BookshelfBar.tsx +0 -170
  183. package/src/client/panel/FlowGuide.tsx +0 -83
  184. package/src/client/panel/MangaRoleLibrary.tsx +0 -810
  185. package/src/client/panel/MangaWorkspace.tsx +0 -359
  186. package/src/client/panel/ProgressHomeView.tsx +0 -87
  187. package/src/client/panel/SceneLibrary.tsx +0 -396
  188. package/src/client/panel/StoryboardTab.tsx +0 -466
  189. package/src/client/panel/StyleCard.tsx +0 -90
  190. package/src/client/panel/StyleGallery.tsx +0 -58
  191. package/src/comic-presets.ts +0 -67
  192. package/src/shot-language.ts +0 -181
  193. package/src/story-beat-language.ts +0 -130
  194. package/src/style-library.ts +0 -78
package/src/engine.ts CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  /**
10
10
  * 内容合规红线(平台硬性要求):所有书籍、所有章节无条件生效,
11
- * 优先级高于单书大纲/圣经中的任何设定与作者自定义红线。
11
+ * 优先级高于单书大纲/道藏中的任何设定与作者自定义红线。
12
12
  * 注入点:章节生成系统提示 + 审稿系统提示(命中即 high)。
13
13
  */
14
14
  export const COMPLIANCE_REDLINES: ReadonlyArray<string> = [
@@ -23,39 +23,38 @@ export const COMPLIANCE_REDLINES: ReadonlyArray<string> = [
23
23
  '9. 不得出现法律法规禁止的其他内容。',
24
24
  ]
25
25
 
26
- import { mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync, rmSync } from 'node:fs'
27
- import { join, basename, extname, dirname } from 'node:path'
28
- import { homedir, tmpdir } from 'node:os'
26
+ /** 审稿维度取值:归一化模型输出的 dimension 字段。维度清单以 reviewSystemPrompt 的九条为准。 */
27
+ const REVIEW_DIMENSION_IDS = new Set(['character', 'setting', 'redline', 'writing', 'pacing', 'logic', 'anti-ai', 'presentation', 'compliance'])
28
+
29
+ import { mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync, rmSync, renameSync } from 'node:fs'
30
+ import { join, basename, extname } from 'node:path'
31
+ import { tmpdir } from 'node:os'
29
32
  import { createUserMessage, BlockAssembler, ReasoningEffortId, type GenerateOptions, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
30
33
  import type { Context } from '@deepseek-ai/cordis'
31
- import { emptyProjectAssets, renderAllAssets, styleEngineSystemPrompt } from './assets.ts'
32
- import { getComicStylePrompt } from './comic-presets.ts'
33
- import { findStyle, styleKeywords } from './style-library.ts'
34
- import {
35
- normalizeShotSize,
36
- normalizeCameras,
37
- normalizeComposition,
38
- normalizeLightings,
39
- sizeZh,
40
- cameraZh,
41
- lightZh,
42
- } from './shot-language.ts'
43
- import {
44
- normalizeStoryFunction,
45
- normalizeEmotions,
46
- functionZh,
47
- emotionZh,
48
- } from './story-beat-language.ts'
34
+ import { BUILTIN_GENRE_LIBRARY, BUILTIN_PROGRESSION_MODES, emptyProjectAssets, recommendStylePreset, renderAllAssets, styleEngineSystemPrompt, styleFormulaSystemPrompt } from './assets.ts'
35
+ import { scanAiFlavor } from './ai-scan.ts'
36
+ import { beginLiveCall, endLiveCall, markFirstToken } from './llm-live.ts'
37
+ import { shouldSwitchModel, withModelFallback } from './llm-retry.ts'
38
+ import { convergeRequestParams } from './model-capability.ts'
39
+ import { renderLengthContract, resolveOutputBudget, STRUCTURED_FIELD_LIMITS } from './output-budget.ts'
40
+ import { renderTensionBlock } from './tension.ts'
41
+ import { renderPromptSlots } from './prompt-slots.ts'
42
+ import { normalizeTimelineEvent, renderTimelineBlock, sortTimeline } from './timeline.ts'
43
+ import { createSnapshot } from './snapshots.ts'
44
+ import { renderChapterWriterSkeleton } from './prompting.ts'
45
+ import { NovelActionError } from './action-guard.ts'
46
+ import { buildChapterContext, renderContextBlocks } from './novel-context.ts'
49
47
  import type {
50
48
  AdaptationDimension,
51
49
  AdaptAnalyzeResponse,
52
50
  AdaptationMapping,
53
- AdaptationProposal,
54
51
  AdaptationRules,
55
52
  AdaptProposeResponse,
56
- AdaptExecuteResponse,
57
53
  AuditIssue,
58
54
  AuthorReview,
55
+ ExportScope,
56
+ TimelineEvent,
57
+ TimelineIssue,
59
58
  BreakdownResponse,
60
59
  ChapterPlan,
61
60
  Foreshadow,
@@ -65,37 +64,39 @@ import type {
65
64
  PlotlineHealthReport,
66
65
  PlotlinePlan,
67
66
  ProjectState,
67
+ ReviewDimension,
68
68
  ReviewReport,
69
69
  RoleRecord,
70
70
  RoleStatusCard,
71
- SceneCard,
72
71
  StoryBible,
73
- StoryboardSkeleton,
74
- StoryboardBeat,
75
- StoryboardTable,
76
- StoryboardShot,
77
- StoryboardPrompt,
78
- ChapterStoryboard,
79
72
  AddModelRequest,
80
73
  AddModelResponse,
81
74
  SavedModel,
82
- LlmModelOption,
83
75
  LlmModelsResponse,
84
76
  LlmVendorOption,
85
77
  LlmVendorsResponse,
86
78
  LlmProvidersResponse,
87
79
  RemoveProviderRequest,
88
80
  RemoveProviderResponse,
89
- LlmTestRequest,
90
81
  LlmTestResponse,
91
- MangaRoleCandidate,
92
- MangaRoleCard,
93
82
  Volume,
94
83
  WorldState,
95
84
  AdaptMaterializeRequest,
96
85
  AdaptMaterializeResponse,
97
86
  AdaptMaterializeSaveRequest,
98
87
  AdaptMaterializeSaveResponse,
88
+ MarketRadarRequest,
89
+ MarketRadarResult,
90
+ MarketRadarSignal,
91
+ MarketRadarBriefRequest,
92
+ ProductionFoundation,
93
+ MarketCreativeBrief,
94
+ BookAnalysisRequest,
95
+ BookAnalysisResult,
96
+ IdeaInspirationRequest,
97
+ IdeaInspirationResult,
98
+ DirectorRequest,
99
+ DirectorAdvice,
99
100
  } from './protocol.ts'
100
101
  import { LLM_VENDORS } from './protocol.ts'
101
102
 
@@ -170,7 +171,9 @@ export function loadProject(outputDir: string): ProjectState | undefined {
170
171
  if (!Array.isArray(raw.facts)) raw.facts = []
171
172
  if (!Array.isArray(raw.plotlines)) raw.plotlines = []
172
173
  return raw
173
- } catch {
174
+ } catch (error) {
175
+ // 项目文件损坏被静默当"无项目"会遮蔽数据丢失,留痕到服务端日志。
176
+ console.error(`[novel-forge] project file unreadable/corrupt: ${file} — ${(error as Error).message}`)
174
177
  return undefined
175
178
  }
176
179
  }
@@ -178,9 +181,20 @@ export function loadProject(outputDir: string): ProjectState | undefined {
178
181
  /** Persist the project state next to the chapters. */
179
182
  export function saveProject(outputDir: string, project: ProjectState): void {
180
183
  mkdirSync(outputDir, { recursive: true })
181
- writeFileSync(join(outputDir, PROJECT_FILE), JSON.stringify(project, null, 2), 'utf8')
184
+ const target = join(outputDir, PROJECT_FILE)
185
+ const data = JSON.stringify(project, null, 2)
186
+ // no-op 检测:内容未变化则跳过写盘,减少连续保存的 I/O
187
+ try {
188
+ if (existsSync(target)) {
189
+ const existing = readFileSync(target, 'utf8')
190
+ if (existing === data) return
191
+ }
192
+ } catch { /* 读取失败时正常写入 */ }
193
+ // 原子写:先写临时文件再 rename,降低中途崩溃导致项目文件损坏的风险
194
+ const tmp = target + '.tmp'
195
+ writeFileSync(tmp, data, 'utf8')
196
+ renameSync(tmp, target)
182
197
  }
183
-
184
198
  /**
185
199
  * 并发保护:长任务(章节计划生成/正文生成)在内存中持有旧快照,
186
200
  * 期间其他请求可能修改了「易变字段」(道藏/角色库/剧情线/人物志存档/简介/封面)。
@@ -193,7 +207,6 @@ export function mergeVolatileFromDisk(outputDir: string, project: ProjectState):
193
207
  if (disk === undefined) return
194
208
  project.bible = disk.bible
195
209
  project.roles = disk.roles
196
- project.mangaRoles = disk.mangaRoles
197
210
  project.plotlines = disk.plotlines
198
211
  project.roleStatus = disk.roleStatus
199
212
  project.blurb = disk.blurb
@@ -336,59 +349,158 @@ export function createProject(outline: string, outlinePath?: string): ProjectSta
336
349
 
337
350
  // ------------------------------------------------------------------- llm
338
351
 
352
+ /** 一次非流式 LLM 调用的入参。 */
353
+ interface CompleteOptions {
354
+ system: string
355
+ user: string
356
+ temperature?: number
357
+ maxTokens?: number
358
+ reasoning?: 'off' | 'low' | 'high' | 'max'
359
+ model?: string
360
+ liveLabel?: string
361
+ /**
362
+ * 该调用是否要求严格结构化输出(JSON)。为 true 时,模型参数能力层可以在
363
+ * 结构化稳定性优先的前提下覆盖用户的思考档位。
364
+ */
365
+ structured?: boolean
366
+ }
367
+
368
+ /**
369
+ * 把已经拼好的请求过一遍模型参数能力层。
370
+ *
371
+ * **凡是自己构造 `GenerateOptions`、不走 {@link complete} 的调用点都必须经过它**,
372
+ * 否则那条路径会绕过厂商参数约束(固定温度、不支持的 reasoning 等),对 Kimi
373
+ * 这类模型直接报参数错误 —— 而"测试连通"恰恰是用户用来判断配置对不对的地方。
374
+ *
375
+ * 抽成函数而不是在每个调用点内联:流式调用点有 5 处,还要各自处理备用模型切换,
376
+ * 内联五遍就是同一件事写五份,必然漂移。
377
+ * @param base - 已拼好的请求(调用方已确定 temperature / reasoningEffort)。
378
+ * @param model - **本次实际要调用的模型**(切换备用模型后必须重算,故在循环内调用)。
379
+ * @param options - `structured` 表示严格结构化输出任务。
380
+ * @returns 收敛后的请求(固定温度的模型会省略 temperature / 不支持的参数会被移除)。
381
+ */
382
+ function applyModelCapability(
383
+ base: GenerateOptions,
384
+ model: string,
385
+ options: { structured?: boolean } = {},
386
+ ): GenerateOptions {
387
+ const converged = convergeRequestParams(
388
+ {
389
+ temperature: base.temperature ?? 0.7,
390
+ reasoningEffort: base.reasoningEffort === undefined
391
+ ? undefined
392
+ : String(base.reasoningEffort) as 'off' | 'low' | 'high' | 'max',
393
+ },
394
+ model,
395
+ { structured: options.structured === true, omitFixedTemperature: true },
396
+ )
397
+ return {
398
+ ...base,
399
+ model,
400
+ ...(converged.temperature === undefined ? { temperature: undefined } : { temperature: converged.temperature }),
401
+ ...(converged.reasoningEffort === undefined ? { reasoningEffort: undefined } : { reasoningEffort: ReasoningEffortId(converged.reasoningEffort) }),
402
+ }
403
+ }
404
+
339
405
  /** One complete non-streaming LLM call. */
340
406
  async function complete(
341
407
  ctx: Context,
342
408
  config: NovelConfig,
343
- options: { system: string; user: string; temperature?: number; maxTokens?: number; reasoning?: 'off' | 'low' | 'high' | 'max' },
409
+ options: CompleteOptions,
344
410
  ): Promise<string> {
411
+ const liveLabel = options.liveLabel ?? 'LLM 调用'
345
412
  const messages: Message[] = [createUserMessage({
346
413
  content: [{ type: 'text', text: options.user }],
347
414
  source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
348
415
  })]
349
- const request: GenerateOptions = {
350
- provider: config.provider,
351
- model: config.model,
352
- messages,
353
- system: options.system,
354
- maxTokens: options.maxTokens ?? config.maxTokens,
355
- temperature: options.temperature ?? 0.7,
356
- reasoningEffort: ReasoningEffortId(options.reasoning ?? config.reasoningEffort ?? 'off'),
357
- }
358
- const assembler = new BlockAssembler()
359
- for await (const chunk of ctx.llm.stream(request)) {
360
- assembler.push(chunk)
361
- }
362
- const finish = assembler.finish
363
- if (finish.kind === 'error' || finish.kind === 'aborted') {
364
- throw new Error(`LLM 调用失败(${finish.kind}): ${finish.failure.message}`)
365
- }
366
- if (finish.kind === 'max-tokens') {
367
- throw new Error('LLM 输出达到 maxTokens 上限,请增大配置后重试')
416
+ /** 用指定模型跑一次完整调用(含实况打点、token 记账与诊断日志)。 */
417
+ const runOnce = async (model: string): Promise<string> => {
418
+ const live = beginLiveCall({ label: liveLabel, model, system: options.system, user: options.user })
419
+ // 用户偏好先成型,再经能力层按**实际模型**收敛:同一模型走不同 provider/中转
420
+ // 时约束不变,换备用模型后约束随新模型重算。
421
+ const converged = convergeRequestParams(
422
+ {
423
+ temperature: options.temperature ?? 0.7,
424
+ reasoningEffort: options.reasoning ?? config.reasoningEffort ?? 'off',
425
+ },
426
+ model,
427
+ {
428
+ structured: options.structured === true,
429
+ // 厂商建议固定温度的模型不要显式传入,优先省略,省得多余参数被拒。
430
+ omitFixedTemperature: true,
431
+ },
432
+ )
433
+ if (converged.adjustments.length > 0) {
434
+ ctx.logger('novel-forge').info('模型参数收敛(%s @ %s):%s', liveLabel, model, converged.adjustments.join(';'))
435
+ }
436
+ const request: GenerateOptions = {
437
+ provider: config.provider,
438
+ model,
439
+ messages,
440
+ system: options.system,
441
+ maxTokens: options.maxTokens ?? config.maxTokens,
442
+ ...(converged.temperature === undefined ? {} : { temperature: converged.temperature }),
443
+ ...(converged.reasoningEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(converged.reasoningEffort) }),
444
+ }
445
+ const assembler = new BlockAssembler()
446
+ for await (const chunk of ctx.llm.stream(request)) {
447
+ if (chunk.type === 'text-delta') markFirstToken(live)
448
+ assembler.push(chunk)
449
+ }
450
+ const finish = assembler.finish
451
+ if (finish.kind === 'error' || finish.kind === 'aborted') {
452
+ const message = `LLM 调用失败(${finish.kind}): ${finish.failure.message}`
453
+ endLiveCall(live, assembler, { phase: 'failed', error: message })
454
+ throw new Error(message)
455
+ }
456
+ if (finish.kind === 'max-tokens') {
457
+ const message = 'LLM 输出达到 maxTokens 上限,请增大配置后重试'
458
+ endLiveCall(live, assembler, { phase: 'failed', error: message })
459
+ throw new Error(message)
460
+ }
461
+ // Diagnostics: log the assembled block shape (reasoning-only turns yield no
462
+ // text blocks — some models answer entirely in the reasoning channel).
463
+ if (process.env.DSH_NOVEL_DEBUG === '1') {
464
+ console.error('[dsh-novel-forge] complete: finish=%j blocks=%j', JSON.stringify(finish), assembler.blocks().map(b => `${b.type}:${'text' in b ? b.text.length : '?'}`))
465
+ }
466
+ const text = textFromAssembler(assembler)
467
+ endLiveCall(live, assembler, { chars: text.length, preview: text.slice(0, 320), phase: text === '' ? 'failed' : 'completed' })
468
+ return text
368
469
  }
470
+ // 主模型失败(网络/限流/额度类)且配置了备用模型时,自动换模型重试一次。
471
+ return withModelFallback({ model: options.model || config.model, fallbackModel: config.fallbackModel }, runOnce)
472
+ }
473
+
474
+ /**
475
+ * 从 assembler 取模型文本;无文本块时回落到 reasoning 通道
476
+ * (部分模型会把答案整个放在思考通道,适配器以 reasoning 块呈现)。
477
+ */
478
+ function textFromAssembler(assembler: BlockAssembler): string {
369
479
  const blocks = assembler.blocks()
370
- // Diagnostics: log the assembled block shape (reasoning-only turns yield no
371
- // text blocks — the v4-flash model can answer entirely in the reasoning
372
- // channel, which the adapter surfaces as a reasoning block).
373
- if (process.env.DSH_NOVEL_DEBUG === '1') {
374
- console.error('[dsh-novel-forge] complete: finish=%j blocks=%j', JSON.stringify(finish), blocks.map(b => `${b.type}:${'text' in b ? b.text.length : '?'}`))
375
- }
376
480
  const textBlocks = blocks
377
481
  .filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
378
482
  .map(block => block.text)
379
- let text = textBlocks.join('\n').trim()
380
- // v4-flash can answer entirely in the reasoning channel (the adapter
381
- // surfaces that as a 'reasoning' block). Fall back to it when no text came
382
- // back — the reasoning content is the model's actual answer here.
383
- if (text === '') {
384
- const reasoning = blocks
385
- .filter((block): block is { type: 'reasoning'; text: string } => block.type === 'reasoning')
386
- .map(block => block.text)
387
- .join('\n')
388
- .trim()
389
- if (reasoning !== '') text = reasoning
390
- }
391
- return text
483
+ const text = textBlocks.join('\n').trim()
484
+ if (text !== '') return text
485
+ return blocks
486
+ .filter((block): block is { type: 'reasoning'; text: string } => block.type === 'reasoning')
487
+ .map(block => block.text)
488
+ .join('\n')
489
+ .trim()
490
+ }
491
+
492
+ /** 解析 JSON 数组;失败或为空时给模型一次修复重试(对齐上游 structuredInvokeRepair 精神)。 */
493
+ async function completeJsonArray(
494
+ ctx: Context,
495
+ config: NovelConfig,
496
+ options: CompleteOptions,
497
+ parse: (text: string) => unknown[],
498
+ ): Promise<unknown[]> {
499
+ let parsed = parse(await complete(ctx, config, options))
500
+ if (Array.isArray(parsed) && parsed.length > 0) return parsed
501
+ const repairOptions = { ...options, user: options.user + '\n\n注意:你上一次输出不是合法且非空的 JSON 数组。请重新输出:只输出一个合法 JSON 数组(不要 Markdown、不要解释、不要思考过程、不要代码块标记)。' }
502
+ const parsed2 = parse(await complete(ctx, config, repairOptions))
503
+ return Array.isArray(parsed2) ? parsed2 : []
392
504
  }
393
505
 
394
506
  /**
@@ -419,6 +531,44 @@ function parseJson<T>(text: string, wantArray: boolean): T {
419
531
  const start2 = trimmed.indexOf(opener)
420
532
  if (start2 !== -1) push(trimmed.slice(start2))
421
533
 
534
+ // Repair truncated JSON: find the last complete object/array element, close brackets.
535
+ const repairTruncated = (value: string): string => {
536
+ const firstOpen = value.indexOf(opener)
537
+ if (firstOpen === -1) return value
538
+ const body = value.slice(firstOpen)
539
+ let inStr = false
540
+ let depth = 0
541
+ let lastComplete = -1
542
+ for (let i = 0; i < body.length; i++) {
543
+ const ch = body[i]!
544
+ if (inStr) {
545
+ if (ch === '\\') { i++; continue }
546
+ if (ch === '"') inStr = false
547
+ continue
548
+ }
549
+ if (ch === '"') { inStr = true; continue }
550
+ if (ch === '{' || ch === '[') depth++
551
+ if (ch === '}' || ch === ']') {
552
+ depth--
553
+ if (depth === 1) lastComplete = i
554
+ }
555
+ }
556
+ if (lastComplete === -1 || depth <= 0) return value
557
+ const truncated = body.slice(0, lastComplete + 1)
558
+ let result = truncated
559
+ let d = 0
560
+ let inS = false
561
+ for (let i = 0; i < result.length; i++) {
562
+ const c = result[i]!
563
+ if (inS) { if (c === '\\') { i++; continue } if (c === '"') inS = false; continue }
564
+ if (c === '"') { inS = true; continue }
565
+ if (c === '{' || c === '[') d++
566
+ if (c === '}' || c === ']') d--
567
+ }
568
+ while (d > 0) { result += closer; d-- }
569
+ return result
570
+ }
571
+
422
572
  // Repair: models love raw newlines inside string values, which JSON forbids.
423
573
  const repair = (value: string): string => {
424
574
  let out = ''
@@ -450,7 +600,7 @@ function parseJson<T>(text: string, wantArray: boolean): T {
450
600
  }
451
601
 
452
602
  for (const candidate of candidates) {
453
- for (const attempt of [candidate, repair(candidate)]) {
603
+ for (const attempt of [candidate, repair(candidate), repairTruncated(candidate)]) {
454
604
  try {
455
605
  const value = JSON.parse(attempt) as unknown
456
606
  if (!wantArray || Array.isArray(value)) return value as T
@@ -490,7 +640,7 @@ function parseJsonObject<T>(text: string): T {
490
640
  /** System prompt for story-bible extraction. */
491
641
  function bibleSystemPrompt(): string {
492
642
  return [
493
- '你是一位资深网文编辑兼设定架构师。你会收到一份小说大纲,请把它提炼成结构化的「设定圣经」(Story Bible),供后续写作时严格引用。',
643
+ '你是一位资深网文编辑兼设定架构师。你会收到一份小说大纲,请把它提炼成结构化的「道藏」,供后续写作时严格引用。',
494
644
  '要求:',
495
645
  '1. 忠于大纲,不自行发明大纲之外的设定。',
496
646
  '2. 角色卡覆盖大纲明确出现的角色(主角必含),每个角色给出性格标签、目标、关键关系。',
@@ -519,7 +669,7 @@ export async function extractBible(ctx: Context, config: NovelConfig, outline: s
519
669
  }
520
670
  const facts = (project?.facts ?? []).slice(-40)
521
671
  const user = [
522
- `请为下面这部小说提炼设定圣经:\n\n${outline}`,
672
+ `请为下面这部小说提炼道藏:\n\n${outline}`,
523
673
  facts.length > 0
524
674
  ? `\n\n【已写章节事实(编年录)】用于确认真实角色姓名与已确立设定;忠于大纲,不要新增大纲外设定:\n${facts.map(f => `[第${f.chapterNo}章] ${f.text.slice(0, 100)}`).join('\n')}`
525
675
  : '',
@@ -531,7 +681,7 @@ export async function extractBible(ctx: Context, config: NovelConfig, outline: s
531
681
  system: bibleSystemPrompt(),
532
682
  user,
533
683
  temperature: 0.4,
534
- maxTokens: Math.max(config.maxTokens, 16000),
684
+ maxTokens: Math.max(config.maxTokens, 8000),
535
685
  reasoning: config.analysisReasoning ?? 'low',
536
686
  })
537
687
  const raw = parseJsonObject<{
@@ -593,13 +743,21 @@ function volumeSystemPrompt(): string {
593
743
  '[{"no": 1, "title": "卷名", "summary": "卷定位与剧情重心", "chapterStart": 1, "chapterEnd": 80}]',
594
744
  '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
595
745
  '重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。',
746
+ // 长度合同与 resolve 端的截断口径共用一份声明,避免 prompt 与解析漂移。
747
+ renderLengthContract([
748
+ ['卷名', STRUCTURED_FIELD_LIMITS.chapterTitle],
749
+ ['每卷定位', STRUCTURED_FIELD_LIMITS.chapterSummary],
750
+ ]),
596
751
  ].join('\n')
597
752
  }
598
753
 
599
754
  /** Plan volumes from an outline. */
600
755
  export async function planVolumes(ctx: Context, config: NovelConfig, outline: string): Promise<Volume[]> {
601
756
  const user = `请为下面这部小说划分卷:\n\n${outline}`
602
- const text = await complete(ctx, config, { system: volumeSystemPrompt(), user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 12000) })
757
+ // 卷数由大纲决定、此处未知,按「大纲暗示的规模」估算:约每 1200 字大纲对应一卷,
758
+ // 夹在 3-24 卷之间(与卷规划自身的 3-8 卷建议不冲突,只是给预算一个量级)。
759
+ const expectedVolumes = Math.min(24, Math.max(3, Math.round(outline.length / 1_200)))
760
+ const text = await complete(ctx, config, { system: volumeSystemPrompt(), user, temperature: 0.4, maxTokens: resolveOutputBudget('volume-strategy', { volumes: expectedVolumes }) })
603
761
  const parsed = parseJsonArray<Record<string, unknown>>(text)
604
762
  const volumes: Volume[] = []
605
763
  for (let i = 0; i < parsed.length; i++) {
@@ -612,8 +770,8 @@ export async function planVolumes(ctx: Context, config: NovelConfig, outline: st
612
770
  const end = typeof entry.chapterEnd === 'number' ? entry.chapterEnd : undefined
613
771
  volumes.push({
614
772
  no,
615
- title: title.slice(0, 40),
616
- summary: summary.slice(0, 300),
773
+ title: title.slice(0, STRUCTURED_FIELD_LIMITS.chapterTitle),
774
+ summary: summary.slice(0, STRUCTURED_FIELD_LIMITS.chapterSummary),
617
775
  chapterStart: start ?? 1,
618
776
  chapterEnd: end ?? 1,
619
777
  })
@@ -637,7 +795,9 @@ function volumeOf(chapterNo: number, volumes: Volume[] | undefined): number {
637
795
  function planSystemPrompt(volumes: Volume[] | undefined): string {
638
796
  const volumeBlock = volumes !== undefined && volumes.length > 0
639
797
  ? ['\n全书分卷结构(规划章节时需落在对应卷内):']
640
- .concat(volumes.map(v => `第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})`))
798
+ .concat(volumes.map(v => `第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})` +
799
+ (v.strategy !== undefined && v.strategy !== '' ? `\n 卷战略:${v.strategy}` : '') +
800
+ (v.pacing !== undefined && v.pacing !== '' ? `\n 卷节奏板:${v.pacing}` : '')))
641
801
  .join('\n')
642
802
  : ''
643
803
  return [
@@ -649,39 +809,53 @@ function planSystemPrompt(volumes: Volume[] | undefined): string {
649
809
  '3. 严格遵循大纲的人设、金手指规则、战力体系与世界观设定,不得自行发明冲突设定。',
650
810
  '4. 输出必须是合法的 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记。',
651
811
  '5. 数组每个元素格式:{"title": "章节标题(10字以内,有网文感)", "beats": "结构化剧情要点(150-250字,必须包含四段,段间用换行分隔):\\n本章目标:本章要完成的核心推进;\\n剧情要点:主要情节的起承转合(2-4 句);\\n爽点/钩子:本章的爽点兑现或情绪钩子;\\n结尾钩子:本章结尾为下一章埋下的悬念"}',
812
+ '6. 每个章节对象可额外包含以下可选字段(尽量给出,缺失则跳过):tension(0-100 整数:本章张力目标,对抗/揭示/抉择/代价的强度;铺垫与日常章明显偏低,与相邻章节形成起伏)、mustAdvance(数组,本章必须推进的局面/关系/信息/风险/决策变化);mustPreserve(数组,本章必须保持不破坏的项);characterHardFacts(数组,本章不可违背的人物硬事实:身份/阵营/境界/当前位置/知情度);endingHook(字符串,本章结尾钩子要求);obligation(字符串,本章义务合约一句话)。',
652
813
  '重要:beats 字段内部必须使用 \\n 转义表示换行(JSON 字符串内不得有真实换行符),其余字符串值也不得包含真实换行符,JSON 必须在一段内完整结束。',
653
814
  '重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。',
654
815
  volumeBlock,
655
816
  ].join('\n')
656
817
  }
657
818
 
658
- /** Build the writing system prompt (bible + outline + active foreshadows). */
659
- function writeSystemPrompt(project: ProjectState): string {
819
+ /** Build the writing system prompt (bible + outline + active foreshadows).
820
+ * `targetChars` 来自每章计划(规划时快照,= 设置的每章目标字数);无则退回默认 3500。
821
+ * 字数区间按目标动态生成(±15%,取整到百位),避免系统提示词与设置互相冲突。
822
+ * `lengthRule` 可覆盖第 1 条字数要求(整章修订/改编时按原文长度为准)。 */
823
+ function writeSystemPrompt(project: ProjectState, targetChars?: number, lengthRule?: string, beforeChapter?: number): string {
660
824
  const bible = project.bible
661
825
  const sections: string[] = []
662
826
  if (bible !== undefined) {
663
- sections.push('==================== 设定圣经(写作时严格遵守) ====================')
827
+ sections.push('==================== 道藏(写作时严格遵守) ====================')
664
828
  if (bible.genre !== '') sections.push(`题材基调:${bible.genre}`)
665
829
  if (bible.worldRules.length > 0) sections.push('世界规则:\n' + bible.worldRules.map(r => `- ${r}`).join('\n'))
666
- if (bible.characters.length > 0) {
667
- sections.push('角色卡:')
668
- for (const card of bible.characters) {
669
- const roleName = { protagonist: '主角', supporting: '配角', antagonist: '反派', other: '其他' }[card.role]
670
- sections.push(`- ${card.name}(${roleName}):${card.traits.join('、')}${card.goals !== '' ? `;目标:${card.goals}` : ''}${card.relations !== '' ? `;关系:${card.relations}` : ''}`)
671
- if (Array.isArray(card.knowledge) && card.knowledge.length > 0) {
672
- sections.push(` 已知信息(该角色知道的:${card.knowledge.join(';')};未列出的信息该角色一律不知道,不得写其知晓或提及)`)
673
- }
830
+ // 角色上下文:角色库(主表)与道藏角色卡合并去重,避免两份重复注入、互相打架。
831
+ const roleLib = project.roles ?? []
832
+ const labelName = { protagonist: '主角', female_lead: '女主', female_support: '女配', support: '配角', antagonist: '反派', extra: '路人' }
833
+ const seenRole = new Set<string>()
834
+ const mergedRoles: string[] = []
835
+ for (const r of roleLib) {
836
+ seenRole.add(r.name)
837
+ const card = bible.characters.find(c => c.name === r.name)
838
+ const traits = card !== undefined ? card.traits : (Array.isArray(r.traits) ? r.traits : [])
839
+ const goals = card !== undefined && card.goals !== '' ? card.goals : r.goals
840
+ const relations = card !== undefined && card.relations !== '' ? card.relations : (Array.isArray(r.relations) && r.relations.length > 0 ? r.relations.join('、') : '')
841
+ mergedRoles.push(`- ${r.name}(${labelName[r.roleLabel]}):${r.identity}${traits.length > 0 ? `;性格:${traits.join('、')}` : ''}${goals !== '' ? `;目标:${goals}` : ''}${relations !== '' ? `;关系:${relations}` : ''}`)
842
+ if (card !== undefined && Array.isArray(card.knowledge) && card.knowledge.length > 0) {
843
+ mergedRoles.push(` 已知信息(该角色知道的:${card.knowledge.join(';')};未列出的信息该角色一律不知道,不得写其知晓或提及)`)
674
844
  }
675
845
  }
676
- // 角色库注入:定位 + 身份 + 关系(角色互动按定位规格写)。
677
- const roleLib = project.roles ?? []
678
- if (roleLib.length > 0) {
679
- const labelName = { protagonist: '主角', female_lead: '女主', female_support: '女配', support: '配角', antagonist: '反派', extra: '路人' }
680
- sections.push('角色库(出场角色按定位规格刻画互动):')
681
- for (const r of roleLib) {
682
- sections.push(`- ${r.name}(${labelName[r.roleLabel]}):${r.identity}${r.relations.length > 0 ? `;关系:${r.relations.join('、')}` : ''}`)
846
+ for (const card of bible.characters) {
847
+ if (seenRole.has(card.name)) continue
848
+ seenRole.add(card.name)
849
+ const roleName = { protagonist: '主角', supporting: '配角', antagonist: '反派', other: '其他' }[card.role]
850
+ mergedRoles.push(`- ${card.name}(${roleName}):${card.traits.join('、')}${card.goals !== '' ? `;目标:${card.goals}` : ''}${card.relations !== '' ? `;关系:${card.relations}` : ''}`)
851
+ if (Array.isArray(card.knowledge) && card.knowledge.length > 0) {
852
+ mergedRoles.push(` 已知信息(该角色知道的:${card.knowledge.join(';')};未列出的信息该角色一律不知道,不得写其知晓或提及)`)
683
853
  }
684
854
  }
855
+ if (mergedRoles.length > 0) {
856
+ sections.push('角色卡(角色库与道藏已合并去重):')
857
+ sections.push(...mergedRoles)
858
+ }
685
859
  if (bible.redLines.length > 0) sections.push('写作红线(违反即失败):\n' + bible.redLines.map(r => `- ${r}`).join('\n'))
686
860
  if (bible.style.length > 0) sections.push('风格要求:\n' + bible.style.map(r => `- ${r}`).join('\n'))
687
861
  }
@@ -713,9 +887,12 @@ function writeSystemPrompt(project: ProjectState): string {
713
887
  }
714
888
  sections.push('')
715
889
  sections.push('写作硬性要求:')
716
- sections.push('1. 每章 3000-4000 字(按中文字符计),只输出章节正文,不要输出标题、章回名、作者的话或任何 Markdown 标记。')
890
+ const target = targetChars !== undefined && targetChars > 0 ? targetChars : 3500
891
+ const lo = Math.max(1000, Math.round((target * 0.85) / 100) * 100)
892
+ const hi = Math.max(lo + 100, Math.round((target * 1.15) / 100) * 100)
893
+ sections.push(lengthRule ?? `1. 每章 ${lo}-${hi} 字(目标 ${target} 字,按中文字符计),只输出章节正文,不要输出标题、章回名、作者的话或任何 Markdown 标记。`)
717
894
  sections.push('2. 以主角视角展开,动作、对话、心理描写交替推进,禁止大段设定说明。')
718
- sections.push('3. 尊重大纲与设定圣经:人设不崩、金手指规则不自相矛盾、战力不随意膨胀。')
895
+ sections.push('3. 尊重大纲与道藏:人设不崩、金手指规则不自相矛盾、战力不随意膨胀。')
719
896
  sections.push('4. 章末留一个钩子(悬念、反转或新线索),吸引读者读下一章。')
720
897
  sections.push('5. 语言流畅自然,符合中文网文语感,避免翻译腔与病句。')
721
898
  sections.push('6. 对话与冲突密度:每章至少 1 处实质对话或正面对抗/交锋场面;推理与心理活动必须用动作、环境细节、微表情、对话呈现,禁止整章纯内心独白铺陈(禁止"解说式"交代线索)。')
@@ -726,6 +903,26 @@ function writeSystemPrompt(project: ProjectState): string {
726
903
  sections.push('==================== 内容合规红线(平台硬性要求,最高优先级,违反即失败) ====================')
727
904
  sections.push(COMPLIANCE_REDLINES.join('\n'))
728
905
  sections.push('以上九条为硬性底线,任何情况下不得以任何形式出现或影射;若剧情确需涉及(如批判、反讽),只能以明确否定、揭露、批判的立场呈现,且不得展开细节。')
906
+ sections.push(renderChapterWriterSkeleton({
907
+ targetChars: target,
908
+ minChars: lo,
909
+ maxChars: hi,
910
+ pov: '第三人称有限视角,严格跟随主角所见所知。',
911
+ endingHookPreference: '章末留一个明确钩子(新信息、新风险或未闭合选择)。',
912
+ tonePreference: '动作、对话、心理交替推进;重大信息用对话/动作/发现呈现。',
913
+ antiAiRules: '严格遵循上方「反 AI 规则」与写法资产,避免套话与 AI 腔。',
914
+ }))
915
+ // 故事时间线锚点:只注入本章之前的事件(回写旧章时不被后续章节剧透)。
916
+ const timelineBlock = renderTimelineBlock(project, beforeChapter ?? Number.MAX_SAFE_INTEGER)
917
+ if (timelineBlock !== '') sections.push(timelineBlock)
918
+ // 本章张力(目标值 + 参考曲线位置):写作时把握松紧。
919
+ if (beforeChapter !== undefined) {
920
+ const tensionBlock = renderTensionBlock(project, beforeChapter)
921
+ if (tensionBlock !== '') sections.push(tensionBlock)
922
+ }
923
+ // 作者自定义槽位(只影响表达方式,不覆盖道藏/红线/合规)。
924
+ const slotBlock = renderPromptSlots(project)
925
+ if (slotBlock !== '') sections.push(slotBlock)
729
926
  return sections.join('\n')
730
927
  }
731
928
 
@@ -745,8 +942,14 @@ export async function planChapters(
745
942
  const startNo = existing.length === 0 ? 1 : Math.max(...existing.map(c => c.no)) + 1
746
943
  const NL = String.fromCharCode(10)
747
944
  const continuation = existing.length > 0
748
- const latestFacts = continuation && Array.isArray(project.facts)
749
- ? project.facts.slice(-15).map(f => `[第${f.chapterNo}章] ${f.text.slice(0, 150)}`).join('\n')
945
+ // 截断显式化:注入给模型的编年录必须自带条数声明,否则模型会把"注入的 15 条"
946
+ // 当成"全部事实",从而在续写规划时漏掉更早的设定锚点。
947
+ const allFacts = Array.isArray(project.facts) ? project.facts : []
948
+ const latestFacts = continuation && allFacts.length > 0
949
+ ? (allFacts.length > 15
950
+ ? `(本书编年录共 ${allFacts.length} 条,此处只注入最近 15 条,较早 ${allFacts.length - 15} 条未注入)\n`
951
+ : '')
952
+ + allFacts.slice(-15).map(f => `[第${f.chapterNo}章] ${f.text.slice(0, 150)}`).join('\n')
750
953
  : ''
751
954
  // 上一章(已写章节中章号最大者)结尾原文,作为续写剧情起点。
752
955
  let prevTail = ''
@@ -762,14 +965,24 @@ export async function planChapters(
762
965
  }
763
966
  // 续写模式大纲注入:精简大纲(≤2 万字)直接全量,避免误伤「分卷收官规划」(如第五卷双结局);
764
967
  // 超大 docx 大纲才节选——截到「记忆梗」之前,保留分卷大纲与红线,去掉后续冗余。
968
+ // 续写模式大纲注入:精简大纲(≤2 万字)直接全量;超大大纲按优先级裁剪——
969
+ // 优先保留当前卷/红线/道藏规则,不绑定任何特定书的章节标题。
765
970
  const outlineBlock = continuation
766
971
  ? (() => {
767
972
  if (project.outline.length <= 20000) return project.outline
768
- const cut = project.outline.indexOf('十、全书核心弹幕记忆梗')
769
- if (cut > 1500) return project.outline.slice(0, cut).trimEnd() + '\n(大纲过长,末尾记忆梗从略)'
770
- return project.outline.slice(0, 8000) + '\n…(大纲过长已节选)'
973
+ // 找最后一个卷标记(支持「第X卷」「卷X」),截到该位置
974
+ const volMarkers = ['第' + (volume?.no ?? '') + '卷', '卷' + (volume?.no ?? '')]
975
+ let cut = -1
976
+ for (const marker of volMarkers) {
977
+ const idx = project.outline.lastIndexOf(marker)
978
+ if (idx > cut) cut = idx
979
+ }
980
+ // 没找到卷标记时,截取前 15000 字(保留足够设定,去掉末尾冗余)
981
+ if (cut < 2000) cut = Math.min(15000, project.outline.length)
982
+ return project.outline.slice(0, cut).trimEnd() + '\n…(大纲过长,已保留当前卷及之前内容,后续从略)'
771
983
  })()
772
984
  : project.outline
985
+
773
986
  const user = [
774
987
  '请为下面这部小说规划章节。',
775
988
  volume !== undefined
@@ -777,8 +990,54 @@ export async function planChapters(
777
990
  : continuation
778
991
  ? `本书已有 ${existing.length} 章已规划/已写作(见下方「已有章节」)。请规划**后续**章节:从第 ${startNo} 章开始。`
779
992
  : '请规划全书开篇章节。',
993
+ (() => {
994
+ const c = project.bookContract
995
+ const primary = project.assets?.primaryProgression
996
+ const aux = project.assets?.auxiliaryProgressions ?? []
997
+ const genre = project.assets?.genre
998
+ const parts: string[] = []
999
+ if (c?.promise !== undefined && c.promise !== '') parts.push(`书籍承诺:${c.promise}`)
1000
+ if ((c?.primaryModeName ?? primary?.name) !== undefined) parts.push(`主推进模式:${c?.primaryModeName ?? primary!.name}`)
1001
+ if (aux.length > 0) parts.push(`辅助推进模式:${aux.map(a => a.name).join('、')}`)
1002
+ if ((c?.tone ?? genre?.template) !== undefined) parts.push(`文风基调:${c?.tone ?? genre!.template}`)
1003
+ if (c?.targetPlatform !== undefined && c.targetPlatform !== '') parts.push(`目标平台:${c.targetPlatform}`)
1004
+ return parts.length > 0 ? '【开书定盘】(章节规划须符合此定位)\n' + parts.join('\n') : ''
1005
+ })(),
780
1006
  continuation
781
- ? '【续写硬性要求】已有章节的剧情不得重写或重复,章节标题也不得与已有章节重复。以下情节均已在已有章节中发生过,后续章节**绝对不得再次出现**:穿越、暴雨送餐、滴血认主/古玉认主、首次进入墟境、用废铁淬炼首件法器、绝境肉身入鼎洗炼(该机缘已用尽)、杀死白袍弟子与灰衣随从、藏尸水沟。' + NL + '若本次规划已进入大纲的收尾区间(接近全书规划总章数),最后 5-10 章必须按大纲推进到大结局(终极抉择/清算/双结局等),**禁止以悬念、逃离、未解之谜收尾**——收尾区间按大纲卷定位判断,不以当前剧情是否“感觉像结尾”为准。'
1007
+ ? (() => {
1008
+ // 从最近章节摘要动态生成「已发生事件禁令」,不绑定任何特定书
1009
+ const recentChapters = existing.slice(-20)
1010
+ const eventLines = recentChapters
1011
+ .filter(c => c.summary !== undefined && c.summary.trim() !== '')
1012
+ .map(c => '第' + c.no + '章《' + c.title + '》:' + c.summary!.slice(0, 80))
1013
+ // 截断显式化:只列最近 20 章时说明全书规模,避免模型据此认为"前面没有内容"。
1014
+ const scopeNote = existing.length > recentChapters.length
1015
+ ? `(全书已规划 ${existing.length} 章,此处只列最近 ${recentChapters.length} 章)`
1016
+ : ''
1017
+ const eventsText = eventLines.length > 0
1018
+ ? '以下情节已在已有章节中发生过(最近 ' + eventLines.length + ' 章摘要)' + scopeNote + ',后续章节**绝对不得重写或重复**:\n' + eventLines.join('\n')
1019
+ : '已有章节的剧情不得重写或重复(无章节摘要时以编年录为准)。'
1020
+ // 位置感知:把「全书总章数 / 当前所处卷 / 距收尾还有多远」算清楚直接喂给模型。
1021
+ // 原先只模糊地说「接近全书规划总章数」,模型看到近期剧情是高光就自己收尾了
1022
+ // (实测:600 章规划的书在第 81-100 章被规划成大结局)。
1023
+ const vols = (project.volumes ?? []).slice().sort((a, b) => a.no - b.no)
1024
+ const totalCh = vols.length > 0 ? Math.max(...vols.map(v => v.chapterEnd ?? 0)) : 0
1025
+ const curVol = vols.find(v => startNo >= (v.chapterStart ?? 0) && startNo <= (v.chapterEnd ?? 0))
1026
+ const lastVol = vols[vols.length - 1]
1027
+ const remaining = totalCh > 0 ? totalCh - startNo + 1 : 0
1028
+ const inEndgame = totalCh > 0 && lastVol !== undefined &&
1029
+ startNo >= (lastVol.chapterStart ?? 0) + Math.floor(((lastVol.chapterEnd ?? 0) - (lastVol.chapterStart ?? 0)) * 0.8)
1030
+ const posText = totalCh > 0 && curVol !== undefined
1031
+ ? '【全书体量与当前进度(硬约束,优先级高于一切"感觉像结尾"的判断)】' + NL
1032
+ + `全书规划共 ${totalCh} 章、${vols.length} 卷(` + vols.map(v => `第${v.no}卷《${v.title}》${v.chapterStart}-${v.chapterEnd}章`).join(';') + ')。' + NL
1033
+ + `本次从第 ${startNo} 章开始规划,目前处于第${curVol.no}卷《${curVol.title}》(第${curVol.chapterStart}-${curVol.chapterEnd}章)。` + NL
1034
+ + `距全书收尾尚余约 ${remaining} 章。` + NL
1035
+ + (inEndgame
1036
+ ? '当前已进入最后一卷末段,可以按大纲推进大结局。'
1037
+ : `**当前处于全书前段,距离收尾还有约 ${remaining} 章——本次规划绝对禁止出现大结局、系统消失、真相揭开后完结、主角退隐、"全剧终"式收束。** 必须按第${curVol.no}卷的卷战略正常推进剧情,只完成本卷内的一段推进,并为后续留下足够的上升空间。近期章节即使处于高潮(打斗/揭秘/对抗),也只是本卷的中段高潮,不是全书高潮。`)
1038
+ : ''
1039
+ return '【续写硬性要求】已有章节的剧情不得重写或重复,章节标题也不得与已有章节重复。' + NL + eventsText + NL + posText
1040
+ })()
782
1041
  : '',
783
1042
  prevTail !== ''
784
1043
  ? `【上一章(第 ${startNo - 1} 章)结尾原文】第 ${startNo} 章必须紧接此状态继续,从新的事件写起,不得回顾重述:\n${prevTail}`
@@ -800,11 +1059,16 @@ export async function planChapters(
800
1059
  const system = planSystemPrompt(project.volumes) + (continuation
801
1060
  ? '\n重要:本次是**续写规划**——已有章节的剧情不得重写或重复,新章节标题不得与已有章节标题相同,新章节的剧情必须从上一章结尾自然接续(人物状态、时间线、地点衔接一致)。'
802
1061
  : '')
803
- const text = await complete(ctx, config, { system, user, temperature: 0.7, maxTokens: Math.max(config.maxTokens, 40000) })
804
- const parsed = parseJsonArray<Record<string, unknown>>(text)
1062
+ const parsed = (await completeJsonArray(
1063
+ ctx, config,
1064
+ { system, user, temperature: 0.7, maxTokens: resolveOutputBudget('chapter-list', { chapters: chapterCount }), liveLabel: '章节规划' },
1065
+ t => parseJsonArray<Record<string, unknown>>(t),
1066
+ )) as Record<string, unknown>[]
805
1067
  const chapters: ChapterPlan[] = []
806
1068
  const existingNos = new Set(existing.map(c => c.no))
807
1069
  const existingTitles = new Set(existing.map(c => c.title))
1070
+ const strArr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '') : []
1071
+ const str = (v: unknown): string => typeof v === 'string' ? v.trim() : ''
808
1072
  let cursor = startNo
809
1073
  for (const item of parsed) {
810
1074
  if (chapters.length >= chapterCount) break
@@ -817,6 +1081,19 @@ export async function planChapters(
817
1081
  if (title !== '' && existingTitles.has(title)) continue
818
1082
  while (existingNos.has(cursor)) cursor++
819
1083
  const no = cursor++
1084
+ const pd: ChapterPlan['payoffDirectives'] = Array.isArray(entry.payoffDirectives)
1085
+ ? entry.payoffDirectives
1086
+ .filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
1087
+ .map(p => ({
1088
+ no: typeof p.no === 'number' ? p.no : undefined,
1089
+ operation: (['seed', 'touch', 'pressure', 'partial_reveal', 'payoff', 'forbid'] as const).includes(p.operation as never)
1090
+ ? p.operation as 'seed' | 'touch' | 'pressure' | 'partial_reveal' | 'payoff' | 'forbid'
1091
+ : undefined,
1092
+ text: str(p.text).slice(0, 120),
1093
+ }))
1094
+ .filter(p => p.text !== '' || p.operation !== undefined)
1095
+ .slice(0, 4)
1096
+ : undefined
820
1097
  chapters.push({
821
1098
  no,
822
1099
  volume: volumeOf(no, project.volumes),
@@ -824,6 +1101,15 @@ export async function planChapters(
824
1101
  beats,
825
1102
  targetChars: config.chapterChars,
826
1103
  status: 'pending',
1104
+ tension: typeof entry.tension === 'number' && Number.isFinite(entry.tension)
1105
+ ? Math.max(0, Math.min(100, Math.round(entry.tension)))
1106
+ : undefined,
1107
+ mustAdvance: strArr(entry.mustAdvance).slice(0, 4),
1108
+ mustPreserve: strArr(entry.mustPreserve).slice(0, 4),
1109
+ characterHardFacts: strArr(entry.characterHardFacts).slice(0, 6),
1110
+ payoffDirectives: pd,
1111
+ endingHook: str(entry.endingHook).slice(0, 120) || undefined,
1112
+ obligation: str(entry.obligation).slice(0, 200) || undefined,
827
1113
  })
828
1114
  }
829
1115
  if (chapters.length === 0) {
@@ -832,32 +1118,58 @@ export async function planChapters(
832
1118
  return chapters
833
1119
  }
834
1120
 
1121
+ /** 书内知识库检索:按查询词匹配文档标题/内容,返回相关片段(供生成/规划注入)。 */
1122
+ function retrieveKnowledge(project: ProjectState, query: string, topN = 3): string {
1123
+ const docs = project.knowledgeDocs ?? []
1124
+ if (docs.length === 0) return ''
1125
+ const terms = (query ?? '').split(/[\s,,。;;、/]+/).map(t => t.trim()).filter(t => t.length >= 2)
1126
+ if (terms.length === 0) return ''
1127
+ const scored = docs.map(d => {
1128
+ let score = 0
1129
+ for (const t of terms) {
1130
+ if (d.title.includes(t)) score += 4
1131
+ if (d.content.includes(t)) score += 1
1132
+ }
1133
+ return { d, score }
1134
+ })
1135
+ const hits = scored.filter(x => x.score > 0).sort((a, b) => b.score - a.score)
1136
+ const top = hits.slice(0, topN)
1137
+ if (top.length === 0) return ''
1138
+ // 截断显式化:命中多于注入条数时说明总量,避免模型以为知识库只有这几条。
1139
+ const scopeNote = hits.length > top.length ? `(命中 ${hits.length} 条,此处只注入最相关的 ${top.length} 条)` : ''
1140
+ return '【书内知识库参考(与本章相关,写作时须遵守/可引用)】' + scopeNote + '\n'
1141
+ + top.map(x => `- 《${x.d.title}》:${x.d.content.slice(0, 300)}`).join('\n')
1142
+ }
1143
+
835
1144
  // ------------------------------------------------------------------ writing
836
1145
 
837
1146
  /** The review system prompt. */
838
1147
  function reviewSystemPrompt(project: ProjectState): string {
839
1148
  const bible = project.bible
840
1149
  const sections: string[] = [
841
- '你是一位严格的网文审稿编辑。你会收到一章正文以及本书的设定圣经与红线。',
1150
+ '你是一位严格的网文审稿编辑。你会收到一章正文以及本书的道藏与红线。',
842
1151
  '请从以下维度审查本章:',
843
- '1. 人设一致性:角色行为是否符合角色卡(主角不圣母、不无脑、痞坏有分寸等)。',
844
- '2. 设定一致性:金手指规则、战力体系、世界观是否与设定圣经冲突。',
845
- '3. 红线检查:是否触犯写作红线(无后宫、无擦边、无无脑碾压等)。',
1152
+ '1. 人设一致性:角色行为是否符合下方角色卡的设定(性格/目标/知情度/说话方式)。',
1153
+ '2. 设定一致性:金手指规则、战力体系、世界观是否与道藏冲突。',
1154
+ '3. 红线检查:是否触犯下方「本书红线」与「内容合规红线」。',
846
1155
  '4. 文笔质量:语病、翻译腔、AI 套话("不禁""仿佛""一时间"等高频词滥用)、流水账。',
847
1156
  '5. 节奏与爽点:本章是否有推进、有钩子,是否拖沓灌水。',
848
1157
  '6. 逻辑漏洞:前后矛盾、时间线错误、对话失真。',
849
- '7. 反 AI 规则:逐条核对下方「反 AI 规则」清单,命中即列为问题。',
1158
+ '7. 反 AI 规则:逐条核对下方「反 AI 规则」清单——禁止类命中即列为问题,鼓励类只作低优先级建议、不阻塞通过。',
850
1159
  '8. 呈现方式:整章是否纯内心推理铺陈(无对话/无对抗,推理全靠解说);反派是否纯背景板无行动;重要配角是否无名标签化(瘦高个/灰衣人全程代称)——命中即列为问题。',
851
1160
  '9. 内容合规(最高优先级):逐条核对下方「内容合规红线」,任何一条命中(含影射、暗示、详细描写)必须列为 high,并给出改写建议。',
852
1161
  '输出必须是合法 JSON 对象,不要输出任何其他文字:',
853
- '{"score": 0-100的整数, "verdict": "一句话总评", "issues": [{"severity": "high|medium|low", "item": "问题描述", "suggestion": "修改建议"}]}',
1162
+ '{"score": 0-100的整数, "riskScore": 0-100的整数(越高越需人工处理,可结合本地AI味指数), "tension": 0-100的整数(本章张力强度:对抗/揭示/抉择/代价;铺垫与日常章应明显偏低), "verdict": "一句话总评", "issues": [{"severity": "high|medium|low", "dimension": "character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance", "item": "问题描述", "suggestion": "修改建议", "ruleName": "命中的反AI规则名(见反AI规则清单)", "ruleType": "forbidden|risk|encourage", "category": "套话|句式|段落|心理|设定|节奏|对话|其他", "excerpt": "命中的原文摘录(不超过50字)", "reason": "判定理由", "canAutoRewrite": true|false}]}',
1163
+ 'tension 是本章张力强度评分(不是质量分):对决/揭秘/生死抉择偏高,铺垫章、日常章、过渡章偏低;同一本书内保持同一尺度。',
1164
+ '反 AI 类 issue 尽量给出 ruleName/ruleType/category/excerpt/reason/canAutoRewrite,便于统计与自动改写。',
1165
+ 'AI 套话高频模板词示例(集中出现必须整体降密度):仿佛、似乎、极其、完美、深不见底、形成了、莫名、无法形容、难以言喻、精心雕琢、肤光胜雪、眉目如画、歌舞升平、觥筹交错、妙语连珠、不可名状、另一层真相、命运、真相。',
854
1166
  '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
855
1167
  '重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
856
1168
  ]
857
1169
  const assetsBlock = renderAllAssets(project.assets)
858
1170
  if (assetsBlock !== '') sections.push('\n' + assetsBlock)
859
1171
  if (bible !== undefined) {
860
- sections.push('\n==================== 设定圣经 ====================')
1172
+ sections.push('\n==================== 道藏 ====================')
861
1173
  if (bible.worldRules.length > 0) sections.push('世界规则:\n' + bible.worldRules.map(r => `- ${r}`).join('\n'))
862
1174
  if (bible.characters.length > 0) {
863
1175
  sections.push('角色卡:')
@@ -885,17 +1197,26 @@ export async function reviewChapter(
885
1197
  chapterNo: number,
886
1198
  ): Promise<ReviewReport> {
887
1199
  const chapter = project.chapters.find(c => c.no === chapterNo)
888
- if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
1200
+ if (chapter === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 不在计划中`)
889
1201
  const body = readChapterFile(outputDir, chapter)
890
- if (body === undefined) throw new Error(`章节 ${chapterNo} 的正文文件不存在`)
1202
+ if (body === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 的正文文件不存在`)
1203
+ const bodyText = body.replace(/^#\s+.*$/m, '').trim()
1204
+ // 本地 AI 味扫描(事实锚点,让 LLM 复核判断而非逐字统计)
1205
+ const aiScan = scanAiFlavor(bodyText)
1206
+ // 跨章上下文:上一章结尾 + 最近/相关事实 + 活跃剧情线/伏笔(审稿不再只看本章内部)
1207
+ const chapterCtx = buildChapterContext(project, chapter, outputDir, { stage: 'review' })
1208
+ const blocks = renderContextBlocks(chapterCtx)
1209
+ const crossChapter = [blocks.continuityBlock, blocks.factsBlock, blocks.plotlinesBlock, blocks.foreshadowsBlock].filter(b => b !== '').join('\n')
891
1210
  const user = [
892
1211
  `本章标题:《${chapter.title}》`,
893
1212
  `本章剧情要点:${chapter.beats}`,
1213
+ `==================== 本地 AI 味扫描(事实锚点,你只需复核判断,不必再逐字统计) ====================\n${aiScan.summary}`,
1214
+ crossChapter,
894
1215
  '==================== 章节正文 ====================',
895
- body.replace(/^#\s+.*$/m, '').trim(),
896
- ].join('\n')
897
- const text = await complete(ctx, config, { system: reviewSystemPrompt(project), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 16000) })
898
- const raw = parseJsonObject<{ score?: unknown; verdict?: unknown; issues?: unknown }>(text)
1216
+ bodyText,
1217
+ ].filter(line => line !== '').join('\n')
1218
+ const text = await complete(ctx, config, { system: reviewSystemPrompt(project), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 8000), reasoning: config.analysisReasoning ?? 'low', model: config.reviewModel, liveLabel: '审稿' })
1219
+ const raw = parseJsonObject<{ score?: unknown; riskScore?: unknown; tension?: unknown; verdict?: unknown; issues?: unknown; resolvedIds?: unknown; unresolvedIds?: unknown }>(text)
899
1220
  const issues = Array.isArray(raw.issues)
900
1221
  ? raw.issues
901
1222
  .filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
@@ -903,19 +1224,39 @@ export async function reviewChapter(
903
1224
  severity: (['high', 'medium', 'low'] as const).includes(entry.severity as never)
904
1225
  ? entry.severity as 'high' | 'medium' | 'low'
905
1226
  : 'medium',
1227
+ dimension: (typeof entry.dimension === 'string' && REVIEW_DIMENSION_IDS.has(entry.dimension))
1228
+ ? entry.dimension as ReviewDimension
1229
+ : undefined,
906
1230
  item: typeof entry.item === 'string' ? entry.item : '',
907
1231
  suggestion: typeof entry.suggestion === 'string' ? entry.suggestion : '',
1232
+ ruleName: typeof entry.ruleName === 'string' ? entry.ruleName : undefined,
1233
+ ruleType: (['forbidden', 'risk', 'encourage'] as const).includes(entry.ruleType as never)
1234
+ ? entry.ruleType as 'forbidden' | 'risk' | 'encourage'
1235
+ : undefined,
1236
+ category: typeof entry.category === 'string' ? entry.category : undefined,
1237
+ excerpt: typeof entry.excerpt === 'string' ? entry.excerpt.slice(0, 80) : undefined,
1238
+ reason: typeof entry.reason === 'string' ? entry.reason.slice(0, 200) : undefined,
1239
+ canAutoRewrite: typeof entry.canAutoRewrite === 'boolean' ? entry.canAutoRewrite : undefined,
908
1240
  }))
909
1241
  .filter(issue => issue.item !== '')
910
1242
  : []
911
1243
  const score = typeof raw.score === 'number' ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60
912
- // 无 high 问题且评分 ≥60 → 直接通过(medium/low 主观项记录但不阻塞,避免“72 分卡线”空转修订)
913
- const passed = score >= config.reviewPassScore || (issues.every(i => i.severity !== 'high') && score >= 60)
1244
+ const riskScore = typeof raw.riskScore === 'number' ? Math.max(0, Math.min(100, Math.round(raw.riskScore))) : undefined
1245
+ // 通过条件:有 high 必须 ≥ reviewPassScore;无 high 可降 5 分但最低 65(避免低分稿直接放行)
1246
+ const hasHigh = issues.some(i => i.severity === 'high')
1247
+ const softThreshold = Math.max(65, config.reviewPassScore - 5)
1248
+ const passed = hasHigh ? score >= config.reviewPassScore : score >= softThreshold
914
1249
  const report: ReviewReport = {
915
1250
  score,
916
1251
  passed,
1252
+ tension: typeof raw.tension === 'number' && Number.isFinite(raw.tension)
1253
+ ? Math.max(0, Math.min(100, Math.round(raw.tension)))
1254
+ : undefined,
917
1255
  verdict: typeof raw.verdict === 'string' ? raw.verdict.slice(0, 200) : '',
918
1256
  issues,
1257
+ riskScore,
1258
+ aiFlavor: aiScan.aiScore,
1259
+ aiPhrases: aiScan.clicheHits.slice(0, 8),
919
1260
  reviewedAt: new Date().toISOString(),
920
1261
  }
921
1262
  chapter.review = report
@@ -936,6 +1277,8 @@ export async function reviewChapterText(
936
1277
  text: string,
937
1278
  previousReport?: ReviewReport,
938
1279
  ): Promise<ReviewReport> {
1280
+ const bodyText = text.slice(0, 20000)
1281
+ const aiScan = scanAiFlavor(bodyText)
939
1282
  const user = [
940
1283
  `书名:《${project.bookName}》`,
941
1284
  previousReport !== undefined
@@ -945,12 +1288,13 @@ export async function reviewChapterText(
945
1288
  previousReport !== undefined
946
1289
  ? '==================== 修订稿(上一轮审稿后按意见修订的正文) ===================='
947
1290
  : '==================== 待审查正文 ====================',
948
- text.slice(0, 20000),
1291
+ `==================== 本地 AI 味扫描(事实锚点,你只需复核判断,不必再逐字统计) ====================\n${aiScan.summary}`,
1292
+ bodyText,
949
1293
  ].join('\n')
950
1294
  // 验证模式:携带上一轮报告时,逐条核对原意见是否解决 + 只挑新增 high,不再全新找茬。
951
1295
  const system = previousReport !== undefined ? verifySystemPrompt(project) : reviewSystemPrompt(project)
952
- const raw = parseJsonObject<{ score?: unknown; verdict?: unknown; issues?: unknown }>(
953
- await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 16000) }),
1296
+ const raw = parseJsonObject<{ score?: unknown; riskScore?: unknown; verdict?: unknown; issues?: unknown; resolvedIds?: unknown; unresolvedIds?: unknown }>(
1297
+ await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 8000) }),
954
1298
  )
955
1299
  const issues = Array.isArray(raw.issues)
956
1300
  ? raw.issues
@@ -959,18 +1303,39 @@ export async function reviewChapterText(
959
1303
  severity: (['high', 'medium', 'low'] as const).includes(entry.severity as never)
960
1304
  ? entry.severity as 'high' | 'medium' | 'low'
961
1305
  : 'medium',
1306
+ dimension: (typeof entry.dimension === 'string' && REVIEW_DIMENSION_IDS.has(entry.dimension))
1307
+ ? entry.dimension as ReviewDimension
1308
+ : undefined,
962
1309
  item: typeof entry.item === 'string' ? entry.item : '',
963
1310
  suggestion: typeof entry.suggestion === 'string' ? entry.suggestion : '',
1311
+ ruleName: typeof entry.ruleName === 'string' ? entry.ruleName : undefined,
1312
+ ruleType: (['forbidden', 'risk', 'encourage'] as const).includes(entry.ruleType as never)
1313
+ ? entry.ruleType as 'forbidden' | 'risk' | 'encourage'
1314
+ : undefined,
1315
+ category: typeof entry.category === 'string' ? entry.category : undefined,
1316
+ excerpt: typeof entry.excerpt === 'string' ? entry.excerpt.slice(0, 80) : undefined,
1317
+ reason: typeof entry.reason === 'string' ? entry.reason.slice(0, 200) : undefined,
1318
+ canAutoRewrite: typeof entry.canAutoRewrite === 'boolean' ? entry.canAutoRewrite : undefined,
964
1319
  }))
965
1320
  .filter(issue => issue.item !== '')
966
1321
  : []
967
1322
  const score = typeof raw.score === 'number' ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60
968
- // 非验证模式:无 high 且评分 ≥60 → 通过;验证模式判定:原 high 全部解决 + 无新增 high → 通过(主观项不再卡修订)。
969
- let passed = score >= config.reviewPassScore || (issues.every(i => i.severity !== 'high') && score >= 60)
1323
+ const riskScore = typeof raw.riskScore === 'number' ? Math.max(0, Math.min(100, Math.round(raw.riskScore))) : undefined
1324
+ // 非验证模式:有 high 必须 ≥ reviewPassScore;无 high 可降 5 分但最低 65
1325
+ const hasHighAny = issues.some(i => i.severity === 'high')
1326
+ let passed = hasHighAny ? score >= config.reviewPassScore : score >= Math.max(65, config.reviewPassScore - 5)
970
1327
  if (previousReport !== undefined) {
971
1328
  const hasHigh = issues.some(i => i.severity === 'high')
972
1329
  const prevHigh = previousReport.issues.filter(i => i.severity === 'high')
973
- const prevHighResolved = prevHigh.every(p => !issues.some(i => i.item.includes(p.item.slice(0, 12))))
1330
+ // 优先用模型输出的 unresolvedIds 精确判定(按编号),兼容旧报告回退到增强字符串匹配
1331
+ const unresolvedIds = Array.isArray(raw.unresolvedIds) ? raw.unresolvedIds.filter((v: unknown): v is number => typeof v === 'number') : []
1332
+ const resolvedIds = Array.isArray(raw.resolvedIds) ? raw.resolvedIds.filter((v: unknown): v is number => typeof v === 'number') : []
1333
+ let prevHighResolved: boolean
1334
+ if (unresolvedIds.length > 0 || resolvedIds.length > 0) {
1335
+ prevHighResolved = prevHigh.every((_, idx) => !unresolvedIds.includes(idx + 1) || resolvedIds.includes(idx + 1))
1336
+ } else {
1337
+ prevHighResolved = prevHigh.every(p => !issues.some(i => i.item.replace(/^未解决\(\d+\):/, '').includes(p.item.replace(/^未解决\(\d+\):/, '').slice(0, 20))))
1338
+ }
974
1339
  passed = !hasHigh && prevHighResolved
975
1340
  }
976
1341
  return {
@@ -978,6 +1343,9 @@ export async function reviewChapterText(
978
1343
  passed,
979
1344
  verdict: typeof raw.verdict === 'string' ? raw.verdict.slice(0, 200) : '',
980
1345
  issues,
1346
+ riskScore,
1347
+ aiFlavor: aiScan.aiScore,
1348
+ aiPhrases: aiScan.clicheHits.slice(0, 8),
981
1349
  reviewedAt: new Date().toISOString(),
982
1350
  }
983
1351
  }
@@ -987,16 +1355,20 @@ function verifySystemPrompt(project: ProjectState): string {
987
1355
  return [
988
1356
  '你是一位网文审稿验证员。作者已按上一轮审稿意见修订了本章,你需要验证修订效果。',
989
1357
  '你的任务(严格按此执行):',
990
- '1. 逐条核对「上一轮意见」中的每一条是否已在修订稿中解决——已解决的不再列出;未解决或部分解决的,按原严重度列出(item 需注明"未解决:原意见 xxx")。',
1358
+ '1. 逐条核对「上一轮意见」中的每一条(按编号 1、2、3...)是否已在修订稿中解决。',
991
1359
  '2. 只挑修订【新引入】的 high 级问题(设定矛盾/逻辑硬伤/事实错误)——新引入的 medium/low 主观项(文笔/套话/节奏)不要列。',
992
1360
  '3. 禁止重复挑剔上一轮已指出且本次已解决的主观项(如"缓缓/微微"等套话、错别字)——即使换个说法再提也不行。',
993
- '4. 严禁为了显得专业而新增"换一批毛病"式的意见;如果修订稿已解决全部 high 且无新增 high,输出 issues 为空数组。',
994
- 'score 评分:按修订稿整体质量给 50-90 分(解决全部 high 且无新增 high 时给 70 以上)。',
995
- 'verdict:一句话结论(如"原 high 已解决,无新增高风险问题"或"仍有未解决的 high")。',
996
- '输出必须是合法 JSON 对象:{"score": 数字, "verdict": "一句话", "issues": [{"severity": "high|medium|low", "item": "问题", "suggestion": "建议"}]}',
1361
+ '4. 严禁为了显得专业而新增"换一批毛病"式的意见。',
1362
+ '输出必须是合法 JSON 对象,包含以下字段:',
1363
+ '- resolvedIds:已解决的上一轮意见编号数组(如 [1, 3, 5])。',
1364
+ '- unresolvedIds:未解决或部分解决的上一轮意见编号数组(如 [2, 4])。',
1365
+ '- issues:未解决的原意见 + 新引入的 high 级问题列表(格式同审稿:severity/item/suggestion)。未解决的原意见 item 需注明"未解决(编号N):原意见摘要"。',
1366
+ '- score:按修订稿整体质量给 50-90 分(解决全部 high 且无新增 high 时给 70 以上)。',
1367
+ '- verdict:一句话结论。',
1368
+ '完整格式:{"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"}]}',
997
1369
  '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
998
1370
  '重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
999
- `本书设定圣经(核对设定冲突用):\n${project.bible !== undefined ? JSON.stringify(project.bible).slice(0, 3000) : '(无)'}`,
1371
+ `本书道藏(核对设定冲突用):\n${project.bible !== undefined ? JSON.stringify(project.bible).slice(0, 3000) : '(无)'}`,
1000
1372
  ].join('\n')
1001
1373
  }
1002
1374
 
@@ -1012,8 +1384,12 @@ function authorReviewSystemPrompt(): string {
1012
1384
  '5. advancedLines:本章实际推进的剧情线名称数组——从「活跃剧情线」清单中选出推进了的线(名称必须与清单中的线名一字不差;没推进任何线则输出空数组)。',
1013
1385
  '6. continuity:与上一章结尾的衔接检查(人物位置/时间/伤势/资源/对话状态),发现问题要指出。',
1014
1386
  '7. trend:结合上一章复盘看近期节奏趋势(是否连续拖沓、爽点密度是否下降、是否需要调整)。',
1387
+ '8. stateChanges:本章发生的关键状态变化数组(人物状态/世界局面/关系/资源,每条约 20 字,最多 6 条),供整本与事实库回灌。',
1388
+ '9. newConflicts:本章新引入或明显升级的冲突数组(最多 4 条)。',
1389
+ '10. clues:本章埋下或推进的新线索/伏笔数组(最多 4 条)。',
1390
+ '11. absentRisks:本章缺席但按卷级职责应出场/该推进关系的重要角色,及其缺席带来的风险(最多 4 条)。',
1015
1391
  '输出必须是合法 JSON 对象,不要输出任何其他文字:',
1016
- '{"hookHonored": true或false, "hookNote": "一句话", "endingHook": 0-10整数, "plotlineProgress": "一句话", "advancedLines": ["线名"], "continuity": "一句话", "trend": "一句话"}',
1392
+ '{"hookHonored": true或false, "hookNote": "一句话", "endingHook": 0-10整数, "plotlineProgress": "一句话", "advancedLines": ["线名"], "continuity": "一句话", "trend": "一句话", "stateChanges": ["状态变化"], "newConflicts": ["新冲突"], "clues": ["新线索"], "absentRisks": ["本章缺席但值得注意的角色及风险(卷级职责/缺席风险,最多4条)"]}',
1017
1393
  '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
1018
1394
  '重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
1019
1395
  ].join('\n')
@@ -1060,6 +1436,10 @@ export async function authorReviewChapter(
1060
1436
  advancedLines?: unknown
1061
1437
  continuity?: unknown
1062
1438
  trend?: unknown
1439
+ stateChanges?: unknown
1440
+ newConflicts?: unknown
1441
+ clues?: unknown
1442
+ absentRisks?: unknown
1063
1443
  }>(
1064
1444
  await complete(ctx, config, { system: authorReviewSystemPrompt(), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000) }),
1065
1445
  )
@@ -1068,6 +1448,7 @@ export async function authorReviewChapter(
1068
1448
  const advancedLines = Array.isArray(raw.advancedLines)
1069
1449
  ? raw.advancedLines.filter((n): n is string => typeof n === 'string' && n.trim() !== '' && knownLineNames.has(n.trim())).map(n => n.trim())
1070
1450
  : []
1451
+ const strArr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.slice(0, 120)) : []
1071
1452
  return {
1072
1453
  hookHonored: raw.hookHonored === true,
1073
1454
  hookNote: typeof raw.hookNote === 'string' ? raw.hookNote.slice(0, 200) : '',
@@ -1076,6 +1457,10 @@ export async function authorReviewChapter(
1076
1457
  advancedLines,
1077
1458
  continuity: typeof raw.continuity === 'string' ? raw.continuity.slice(0, 200) : '',
1078
1459
  trend: typeof raw.trend === 'string' ? raw.trend.slice(0, 200) : '',
1460
+ stateChanges: strArr(raw.stateChanges).slice(0, 6),
1461
+ newConflicts: strArr(raw.newConflicts).slice(0, 4),
1462
+ clues: strArr(raw.clues).slice(0, 4),
1463
+ absentRisks: strArr(raw.absentRisks).slice(0, 4),
1079
1464
  reviewedAt: new Date().toISOString(),
1080
1465
  }
1081
1466
  }
@@ -1090,7 +1475,7 @@ export function autoLinkPlotlines(project: ProjectState, chapterNo: number, adva
1090
1475
  }
1091
1476
  }
1092
1477
 
1093
- /** AI 建议剧情线:基于大纲/卷计划/已写章节/编年录,提炼候选线。 */
1478
+ /** 建议剧情线:基于大纲/卷计划/已写章节/编年录,提炼候选线。 */
1094
1479
  export async function suggestPlotlines(ctx: Context, config: NovelConfig, project: ProjectState): Promise<Plotline[]> {
1095
1480
  const system = [
1096
1481
  '你是一位网文剧情架构师。根据本书的大纲、卷计划、已写章节标题与编年录,为作者提炼建议的剧情线(主线/支线/人物线/悬念线)。',
@@ -1137,7 +1522,7 @@ export async function suggestPlotlines(ctx: Context, config: NovelConfig, projec
1137
1522
  return lines
1138
1523
  }
1139
1524
 
1140
- /** AI 刷新单条剧情线的进度:结合编年录与各章摘要分析该线推进到哪。 */
1525
+ /** 刷新单条剧情线的进度:结合编年录与各章摘要分析该线推进到哪。 */
1141
1526
  export async function refreshPlotlineProgress(
1142
1527
  ctx: Context,
1143
1528
  config: NovelConfig,
@@ -1168,7 +1553,7 @@ export async function refreshPlotlineProgress(
1168
1553
  return typeof raw.progress === 'string' ? raw.progress.trim().slice(0, 300) : ''
1169
1554
  }
1170
1555
 
1171
- /** ✨ AI 从全书提炼角色库:大纲 + 道藏 + 编年录 + 章节摘要 → 结构化角色清单。 */
1556
+ /** AI 从全书提炼角色库:大纲 + 道藏 + 编年录 + 章节摘要 → 结构化角色清单。 */
1172
1557
  export async function extractRoles(
1173
1558
  ctx: Context,
1174
1559
  config: NovelConfig,
@@ -1177,8 +1562,8 @@ export async function extractRoles(
1177
1562
  const system = [
1178
1563
  '你是一位网文角色库管理员。请根据本书的大纲、设定、编年录与章节摘要,提炼完整的角色库。',
1179
1564
  '覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如"矮胖姑娘")用其身份简称收录并标注;反复出现且有剧情作用的身份型角色(站长、律师、警察、法官、店主等)必须收录。',
1180
- '数量控制:最多输出 10 个角色;覆盖优先——主角、主要反派、重要配角(女主/关键配角)必须全收,所有有名有姓者必收;只有真正一次性路人(无名字、无剧情作用)才可省略。',
1181
- '重要:正常一部完整故事应提炼 6-10 个角色;若少于 4 个通常说明漏提炼,必须重新核对正文摘录。',
1565
+ '数量控制:最多输出 20 个角色;覆盖优先——主角、主要反派、重要配角(女主/关键配角)必须全收,所有有名有姓者必收;只有真正一次性路人(无名字、无剧情作用)才可省略。',
1566
+ '重要:正常一部完整故事应提炼 8-20 个角色;若少于 6 个通常说明漏提炼,必须重新核对正文摘录。',
1182
1567
  '重要:正文中若有明确的主角与主要反派,必须分别以 protagonist / antagonist 收录,禁止遗漏;反派确实未出场时才可省略。',
1183
1568
  '输出优先级:主角(protagonist)与主要反派(antagonist)必须优先输出并完整刻画,其次女主/重要配角;判断不出名字时用正文中的身份称呼。',
1184
1569
  '每个角色输出:',
@@ -1199,14 +1584,14 @@ export async function extractRoles(
1199
1584
  const written = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating')
1200
1585
  const existingRoles = project.roles ?? []
1201
1586
  // 正文摘录:均匀采样覆盖全书(开头/中间/结尾),避免只取前几章漏掉后期才出场的重要角色。
1202
- const sampleChapters: ChapterPlan[] = written.length <= 6
1587
+ const sampleChapters: ChapterPlan[] = written.length <= 10
1203
1588
  ? written
1204
1589
  : (() => {
1205
1590
  const picked = new Set<number>()
1206
1591
  for (let i = 0; i < 3 && i < written.length; i++) picked.add(i)
1207
- const step = Math.max(1, Math.floor(written.length / 4))
1208
- for (let i = step; i < written.length - 2; i += step) picked.add(i)
1209
- for (let i = Math.max(0, written.length - 2); i < written.length; i++) picked.add(i)
1592
+ const step = Math.max(1, Math.floor(written.length / 8))
1593
+ for (let i = step; i < written.length - 3; i += step) picked.add(i)
1594
+ for (let i = Math.max(0, written.length - 3); i < written.length; i++) picked.add(i)
1210
1595
  return [...picked].sort((a, b) => a - b).map(i => written[i])
1211
1596
  })()
1212
1597
  const excerptParts: string[] = []
@@ -1214,8 +1599,25 @@ export async function extractRoles(
1214
1599
  const body = readChapterFile(config.outputDir, chapter)
1215
1600
  if (body === undefined) continue
1216
1601
  const text = body.replace(/^#.*$/gm, '').trim()
1217
- if (text.length > 0) excerptParts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0, 1500)}`)
1602
+ if (text.length > 0) excerptParts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0, 3000)}`)
1603
+ }
1604
+ // 出场频次统计:道藏角色名 + 已收录角色,扫全书统计出现次数,传给 LLM 做重要性判断参考
1605
+ const freqCandidates = new Set<string>()
1606
+ for (const c of project.bible?.characters ?? []) freqCandidates.add(c.name)
1607
+ for (const r of existingRoles) freqCandidates.add(r.name)
1608
+ const freqMap = new Map<string, number>()
1609
+ if (freqCandidates.size > 0) {
1610
+ for (const chapter of written) {
1611
+ const body = readChapterFile(config.outputDir, chapter)
1612
+ if (body === undefined) continue
1613
+ for (const name of freqCandidates) {
1614
+ let idx = 0, n = 0
1615
+ while ((idx = body.indexOf(name, idx)) !== -1) { n++; idx += name.length }
1616
+ if (n > 0) freqMap.set(name, (freqMap.get(name) ?? 0) + n)
1617
+ }
1618
+ }
1218
1619
  }
1620
+ const freqLines = [...freqMap.entries()].filter(([, n]) => n >= 2).sort((a, b) => b[1] - a[1]).slice(0, 30).map(([name, n]) => name + '(' + n + '次)')
1219
1621
  const user = [
1220
1622
  `书名:《${project.bookName}》`,
1221
1623
  existingRoles.length > 0
@@ -1228,6 +1630,9 @@ export async function extractRoles(
1228
1630
  project.bible !== undefined && project.bible.characters.length > 0
1229
1631
  ? `已有角色卡(补充信息):\n${project.bible.characters.map(c => `- ${c.name}(${c.role}):${c.traits.join('、')}${c.goals !== '' ? `;目标:${c.goals}` : ''}`).join('\n')}`
1230
1632
  : '',
1633
+ freqLines.length > 0
1634
+ ? `角色出场频次参考(全书统计,次数越多越重要,优先收录高频角色):${freqLines.join('、')}`
1635
+ : '',
1231
1636
  (project.facts ?? []).length > 0
1232
1637
  ? `编年录(最近 30 条):\n${(project.facts ?? []).slice(-30).map(f => `[第${f.chapterNo}章] ${f.text.slice(0, 60)}`).join('\n')}`
1233
1638
  : '',
@@ -1236,18 +1641,19 @@ export async function extractRoles(
1236
1641
  : '',
1237
1642
  '只输出 JSON 数组。',
1238
1643
  ].join('\n\n')
1239
- let text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 24000), reasoning: config.analysisReasoning ?? 'low' })
1644
+ // 角色阵容:一次要产出 8-20 个角色,预算按预期角色数推算。
1645
+ let text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: resolveOutputBudget('character-cast', { characters: 20 }), structured: true, reasoning: config.analysisReasoning ?? 'low' })
1240
1646
  let raw = parseJsonArray<Record<string, unknown>>(text)
1241
1647
  const hasProtagonist = raw.some(e => typeof e === 'object' && e !== null && e.roleLabel === 'protagonist')
1242
- const tooFew = raw.length > 0 && raw.length < 4
1648
+ const tooFew = raw.length > 0 && raw.length < 6
1243
1649
  if (raw.length === 0 || !hasProtagonist || tooFew) {
1244
1650
  // LLM 偶发输出非 JSON / 空数组 / 漏主角 / 角色过少:重试一次(追加明确指令),避免静默返回空或缺角色的候选。
1245
1651
  const hint = raw.length === 0
1246
1652
  ? '\n上一次输出为空或格式不正确。请直接输出 JSON 数组(即使只有一个角色也要输出),不要输出其他文字。'
1247
1653
  : tooFew
1248
- ? '\n上一次输出角色过少(不足 4 个)。这是一部完整故事,请重新核对正文摘录:主角、主要反派、重要配角与所有有名有姓的角色都要收录(宁多勿漏),输出 6-10 个。'
1654
+ ? '\n上一次输出角色过少(不足 6 个)。这是一部完整故事,请重新核对正文摘录:主角、主要反派、重要配角与所有有名有姓的角色都要收录(宁多勿漏),输出 8-20 个。'
1249
1655
  : '\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。'
1250
- text = await complete(ctx, config, { system: system + hint, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 24000), reasoning: config.analysisReasoning ?? 'low' })
1656
+ text = await complete(ctx, config, { system: system + hint, user, temperature: 0.3, maxTokens: resolveOutputBudget('character-cast', { characters: 24 }), structured: true, reasoning: config.analysisReasoning ?? 'low' })
1251
1657
  raw = parseJsonArray<Record<string, unknown>>(text)
1252
1658
  }
1253
1659
  const labels = new Set(['protagonist', 'female_lead', 'female_support', 'support', 'antagonist', 'extra'])
@@ -1285,12 +1691,12 @@ export async function extractRoles(
1285
1691
  if (role === undefined || existing.has(role.name)) continue
1286
1692
  existing.add(role.name)
1287
1693
  roles.push(role)
1288
- if (roles.length >= 10) break
1694
+ if (roles.length >= 20) break
1289
1695
  }
1290
1696
  } catch { /* 补漏失败不阻塞主结果 */ }
1291
1697
  }
1292
1698
  // 确定性兜底:正文中反复出现的身份型称呼(站长/律师/警察等)若 LLM 仍漏掉,按出现次数直接补条(通用、不依赖名单)。
1293
- const ROLE_TITLE_HINTS = ['站长', '律师', '检察官', '法官', '警察', '店主', '老板', '经理', '局长', '医生', '老师', '护士', '房东', '司机', '保安', '主管', '队长', '厂长', '董事长', '总裁', '市长', '道长', '掌门', '师父', '师傅', '管家']
1699
+ const ROLE_TITLE_HINTS = ['站长', '律师', '检察官', '法官', '警察', '店主', '老板', '经理', '局长', '医生', '老师', '护士', '房东', '司机', '保安', '主管', '队长', '厂长', '董事长', '总裁', '市长', '道长', '掌门', '师父', '师傅', '管家', '长老', '宗主', '殿主', '宫主', '师兄', '师姐', '师弟', '师妹', '老祖', '魔尊', '妖王', '将军', '军师', '太监', '宫女', '嬷嬷', '宰相', '尚书', '巡抚', '都督', '祭司', '圣女', '圣子']
1294
1700
  const existingNames = new Set(roles.map(r => r.name))
1295
1701
  const titleCount = new Map<string, number>()
1296
1702
  for (const chapter of written) {
@@ -1307,7 +1713,7 @@ export async function extractRoles(
1307
1713
  }
1308
1714
  }
1309
1715
  for (const t of ROLE_TITLE_HINTS) {
1310
- if (roles.length >= 10) break
1716
+ if (roles.length >= 20) break
1311
1717
  const covered = existingNames.has(t) || [...existingNames].some(n => n.includes(t))
1312
1718
  if (!covered && (titleCount.get(t) ?? 0) >= 3) {
1313
1719
  existingNames.add(t)
@@ -1317,92 +1723,7 @@ export async function extractRoles(
1317
1723
  return roles
1318
1724
  }
1319
1725
 
1320
- /** ✨ AI 从全书提炼场景库:正文/编年录 → 高频重要场景的结构化视觉锚点。 */
1321
- export async function extractScenes(
1322
- ctx: Context,
1323
- config: NovelConfig,
1324
- project: ProjectState,
1325
- styleId?: string,
1326
- filterId?: string,
1327
- ): Promise<SceneCard[]> {
1328
- const styleWords = styleKeywords(styleId, filterId)
1329
- const system = [
1330
- '你是一位网文漫剧场景导演。根据本书的大纲、编年录与已写章节摘录,提炼「镜头场景」——漫剧分镜/生图时每个镜头要知道"在哪、什么时间光态、拍什么情节、人物什么状态"。',
1331
- '按五幕结构覆盖全书核心场景(短篇 10 章内可少于五幕,按实际剧情组织):第一幕后场/核心主场、第二幕对外卖场/营业区、第三幕禁忌区域/地下深仓、第四幕外部人间世界(短暂出逃)、第五幕回归与抉择。',
1332
- '【当前视觉风格】(moment/palette/moods/zh/en 必须按此风格措辞,不能写中性描述):' + styleWords,
1333
- '重要:每个场景的 zh 提示词段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。',
1334
- '每个场景必须是「镜头场景」而非仅场地:给出该场景的关键情节镜头(人物动作+情绪+镜头推进)。',
1335
- '数量控制:最多输出 8 个场景;每个场景必须能对应到正文实际出现过的地点与情节,不得凭空虚构。',
1336
- '每个场景输出:',
1337
- '1. name:场景名(地点+光态,如「后场通道·装卸口(雨夜)」)。',
1338
- '2. act:幕归属(第一幕后场/第二幕卖场/第三幕深仓/第四幕外界/第五幕抉择)。',
1339
- '3. moment:时间光态(夜间闭店后/雨夜/凌晨/频闪灯…)。',
1340
- '4. summary:一句话定位(空间类型/功能/氛围)。',
1341
- '5. beats:关键情节镜头 1-3 条(人物动作+情绪+镜头推进,如"沈放佝偻站在货架过道,镜头推近,情绪从漠然过渡到压抑悲伤")。',
1342
- '6. characterState:主角/关键角色在该场景的状态一句话(含标志物细节如标签磨损、手腕红痕)。',
1343
- '7. elements:环境构成数组 3-6 项(空间结构/陈设/标志物)。',
1344
- '8. palette:色调与光影数组 2-4 项(颜色词,可附 HEX)。',
1345
- '9. moods:氛围关键词 2-4 个(压抑/神秘/空旷/悲凉…)。',
1346
- '10. zh:中文生图提示词(连贯一段,写实电影感,含环境/光线/氛围/人物状态)。',
1347
- '11. en:英文生图提示词(booru 风格逗号分隔,含 photorealistic/cinematic)。',
1348
- '12. tags:3-6 个关键标签。',
1349
- '13. source:依据来源说明(哪几章哪些描写)。',
1350
- '若故事存在结局抉择(如双分支),最后 1-2 个场景应为「抉择场景」,zh 里写明分支 A/B 两种画面。',
1351
- '输出必须是合法 JSON 数组,不要输出其他文字:[{"name":"...", "act":"...", "moment":"...", "summary":"...", "beats":[...], "characterState":"...", "elements":[...], "palette":[...], "moods":[...], "zh":"...", "en":"...", "tags":[...], "source":"..."}]',
1352
- '重要:所有字符串值内部不得包含换行符;直接输出 JSON 结果本身。',
1353
- ].join('\n')
1354
- const written = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating')
1355
- const excerptParts: string[] = []
1356
- for (const chapter of written.slice(0, 4)) {
1357
- const body = readChapterFile(config.outputDir, chapter)
1358
- if (body === undefined) continue
1359
- const text = body.replace(/^#.*$/gm, '').trim()
1360
- if (text.length > 0) excerptParts.push('第' + chapter.no + '章《' + chapter.title + '》' + '\n' + text.slice(0, 1800))
1361
- }
1362
- const user = [
1363
- '书名:《' + project.bookName + '》',
1364
- (project.facts ?? []).length > 0
1365
- ? '编年录(最近 80 条,场景地点以这里为准):' + '\n' + (project.facts ?? []).slice(-80).map(f => '[第' + f.chapterNo + '章] ' + f.text.slice(0, 80)).join('\n')
1366
- : '',
1367
- excerptParts.length > 0
1368
- ? '已写正文摘录(场景描写以正文为准):' + '\n' + excerptParts.join('\n')
1369
- : '',
1370
- '只输出 JSON 数组。',
1371
- ].filter(s => s !== '').join('\n')
1372
- const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
1373
- const raw = parseJsonArray<Record<string, unknown>>(text)
1374
- const strArr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '') : []
1375
- const str = (v: unknown): string => typeof v === 'string' ? v.trim() : ''
1376
- const scenes: SceneCard[] = []
1377
- for (const entry of raw) {
1378
- if (typeof entry !== 'object' || entry === null) continue
1379
- const name = str(entry.name).slice(0, 40)
1380
- if (name === '') continue
1381
- scenes.push({
1382
- name,
1383
- act: str(entry.act).slice(0, 40),
1384
- moment: str(entry.moment).slice(0, 60),
1385
- summary: str(entry.summary).slice(0, 120),
1386
- beats: strArr(entry.beats).map(t => t.slice(0, 120)).slice(0, 3),
1387
- characterState: str(entry.characterState).slice(0, 160),
1388
- elements: strArr(entry.elements).map(t => t.slice(0, 60)).slice(0, 6),
1389
- palette: strArr(entry.palette).map(t => t.slice(0, 40)).slice(0, 4),
1390
- moods: strArr(entry.moods).map(t => t.slice(0, 20)).slice(0, 4),
1391
- zh: ensureStyleEmbedded(str(entry.zh).slice(0, 800), styleWords, 'zh'),
1392
- en: ensureStyleEmbedded(str(entry.en).slice(0, 600), styleWords, 'en'),
1393
- tags: strArr(entry.tags).map(t => t.slice(0, 24)).slice(0, 6),
1394
- source: str(entry.source).slice(0, 120),
1395
- styleId,
1396
- })
1397
- }
1398
- return scenes
1399
- }
1400
-
1401
-
1402
-
1403
-
1404
-
1405
- /** 从 txt/md 全本文本拆章(纯逻辑,不落盘):识别章节头、剥离重复标题、去重、排序并统一重新编号。 */
1726
+ /** AI 从全书提炼场景库:正文/编年录 → 高频重要场景的结构化视觉锚点。 */
1406
1727
  export function splitBookText(raw: string): Array<{ no: number; title: string; body: string }> {
1407
1728
  const lines = raw.split(/\r?\n/)
1408
1729
  // 拆章:中文「第X章/回/节/卷」、中文数字章节(一、二、三…)、特殊章节(序章/楔子/尾声/番外…)、英文 Chapter N(均可带 # 前缀)。
@@ -1514,7 +1835,7 @@ export function importBookTextFromText(
1514
1835
  }
1515
1836
  chapter.file = chapterFileName(chapter)
1516
1837
  writeFileSync(join(outputDir, chapter.file), '# 第' + c.no + '章 ' + chapter.title + '\n\n' + body + '\n', 'utf8')
1517
- chapter.chars = body.length
1838
+ chapter.chars = countHanzi(body)
1518
1839
  project.chapters.push(chapter)
1519
1840
  }
1520
1841
  project.updatedAt = new Date().toISOString()
@@ -1532,674 +1853,104 @@ export function importBookText(
1532
1853
  return importBookTextFromText(raw, outputDir, bookName)
1533
1854
  }
1534
1855
 
1535
- /** 常见职业/身份尾缀:角色名如「周野律师」正文可能只写「周野」或「周野的律师」。 */
1536
- const ROLE_NAME_SUFFIXES = ['律师', '辩护律师', '医生', '老师', '教授', '先生', '女士', '小姐', '警官', '警察', '局长', '总经理', '经理', '老板', '师父', '师傅', '道长', '老祖', '长老', '掌门', '少主', '公主', '王子', '王妃', '皇后', '皇帝', '王爷', '公子', '姑娘', '夫人', '太太', '大人', '将军', '丞相', '尚书', '员外']
1537
-
1538
- /**
1539
- * 从角色名/身份拆出正文可能使用的检索词:
1540
- * - specific:具体称谓(含职业尾缀或 3 字以上身份片段),如「周野的律师」「辩护律师」「律师」——优先用,避免误抓到同名主干(周野)的段落;
1541
- * - stems:名字主干(如「周野」),最后兜底。
1542
- */
1543
- function roleFallbackTokens(name: string, identity: string | undefined): { specific: string[]; stems: string[] } {
1544
- const specific = new Set<string>()
1545
- const stems = new Set<string>()
1546
- const addName = (s: string): void => {
1547
- const t = s.trim()
1548
- if (t.length < 2) return
1549
- specific.add(t)
1550
- for (const q of ['辩护', '助理', '高级', '首席', '御用', '御前', '专职']) {
1551
- if (t.includes(q)) specific.add(t.replace(q, ''))
1552
- }
1553
- for (const suf of ROLE_NAME_SUFFIXES) {
1554
- if (t.endsWith(suf) && t.length > suf.length + 1) {
1555
- const stem = t.slice(0, -suf.length)
1556
- // 「周野律师」→「周野的律师」(正文常见写法)
1557
- specific.add(stem + '的' + suf)
1558
- specific.add(suf)
1559
- if (stem.length >= 2) stems.add(stem)
1560
- }
1561
- }
1562
- }
1563
- addName(name)
1564
- for (const part of (identity ?? '').split(/[的,,。\s]+/)) {
1565
- const p = part.trim()
1566
- if (p.length < 2) continue
1567
- if (p.length >= 3 || ROLE_NAME_SUFFIXES.some(s => p.endsWith(s))) {
1568
- specific.add(p)
1569
- for (const suf of ROLE_NAME_SUFFIXES) {
1570
- if (p.endsWith(suf) && p.length > suf.length + 1) {
1571
- specific.add(suf)
1572
- stems.add(p.slice(0, -suf.length))
1573
- }
1574
- }
1575
- } else {
1576
- stems.add(p)
1577
- }
1578
- }
1579
- return { specific: [...specific].filter(t => t.length >= 2), stems: [...stems].filter(t => t.length >= 2) }
1580
- }
1581
-
1582
1856
  /** 保证风格词块已内嵌(LLM 偶发漏嵌时兜底):zh 段首前缀,en 末尾追加。 */
1583
- function ensureStyleEmbedded(text: string, styleWords: string, lang: 'zh' | 'en'): string {
1584
- const t = text.trim()
1585
- if (styleWords === '' || t === '') return t
1586
- if (t.includes(styleWords)) return t
1587
- return lang === 'zh' ? styleWords + ',' + t : t + ',' + styleWords
1588
- }
1589
-
1590
- /** 对 zh/en 一对提示词统一补风格词块。 */
1591
- function withStyle(pair: { zh: string; en: string }, styleWords: string): { zh: string; en: string } {
1592
- return { zh: ensureStyleEmbedded(pair.zh, styleWords, 'zh'), en: ensureStyleEmbedded(pair.en, styleWords, 'en') }
1593
- }
1594
-
1595
- /** 动漫形象描述词(中文描述 + 英文 booru 标签 + 关键外貌标签)。 */
1596
- export interface RoleVisualPrompt {
1597
- zh: string
1598
- en: string
1599
- tags: string[]
1600
- source: string
1601
- /** 该角色所需情绪表情清单(6-12 个,如 疲惫/麻木/压抑悲伤)。 */
1602
- expressions?: string[]
1603
- /** 四类精修提示词(立绘/四视图/表情/细节,一次提炼直接产出)。 */
1604
- promptKit?: RoleRecord['promptKit']
1605
- }
1606
-
1607
- /** 可提炼形象的「角色源」:小说角色卡或漫剧角色卡的最小公共面。 */
1608
- interface RoleVisualSource {
1609
- name: string
1610
- identity?: string
1611
- traits?: string[]
1612
- }
1613
-
1614
- /**
1615
- * 底层实现:为任意角色源提炼「动漫形象描述词」(不写库,写库由上层调用方负责)。
1616
- * 扫描该角色出场的已写章节正文,截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
1617
- */
1618
- async function extractRoleVisualFrom(
1857
+ export async function suggestOutlines(
1619
1858
  ctx: Context,
1620
1859
  config: NovelConfig,
1621
- project: ProjectState,
1622
- outputDir: string,
1623
- role: RoleVisualSource,
1624
- styleId?: string,
1625
- filterId?: string,
1626
- shortDrama = false,
1627
- ): Promise<RoleVisualPrompt> {
1628
- const roleName = role.name
1629
-
1630
- // 1. 扫描正文:收集该角色出场且可能含外貌描写的段落(最近 60 章内,每章最多 2 段,共 12 段)。
1631
- // 支持“描述型角色名”(如「灰蓝工装女人」):先精确匹配,匹配不到时用角色名拆分出的关键词兜底。
1632
- const appearanceHints = /(发|眉|眼|眸|脸|肤|唇|身材|身高|衣|袍|裙|衫|靴|腰带|气质|模样|长相|容貌|披|束|扎|戴|佩|挂|绣|青|白|黑|红|蓝|紫|灰|银|金|少年|青年|少女|汉子|老者|中年|纤细|挺拔|瘦削|壮实|清秀|俊朗|英气|阴鸷|慈眉)/
1633
- // 从角色名 + 身份描述中提取可检索关键词,支持“描述型角色名”(灰蓝工装女人)和“身份型角色名”(富商)。
1634
- const roleText = `${role.name} ${role.identity ?? ''}`
1635
- const colorWords = ['深灰', '灰蓝', '灰', '蓝', '白', '黑', '红', '青', '紫', '银', '金', '黄', '绿', '粉', '棕']
1636
- const garmentWords = ['工装', '西装', '定制西装', '袍', '裙', '衫', '衣', '裤', '靴', '鞋', '帽', '腰带', '制服', '外套', '马甲', '夹克']
1637
- const roleWords = ['女人', '男人', '老者', '老人', '中年', '青年', '少女', '姑娘', '男子', '女子', '工人', '路人', '购买者']
1638
- const locationWords = ['别墅', '小区', '街区']
1639
- const identityTokens = (role.identity ?? '').split(/[,,。\s]+/).filter(t => t.length >= 2)
1640
- const searchTokens = Array.from(new Set([
1641
- roleName,
1642
- ...identityTokens,
1643
- ...colorWords.filter(w => roleText.includes(w)),
1644
- ...garmentWords.filter(w => roleText.includes(w)),
1645
- ...roleWords.filter(w => roleText.includes(w)),
1646
- ...locationWords.filter(w => roleText.includes(w)),
1647
- ])).filter(t => t.length >= 2)
1648
- const matchesRole = (para: string, tokens: string[]): boolean => para.includes(roleName) || tokens.some(tok => para.includes(tok))
1649
- const excerpts: Array<{ no: number; text: string }> = []
1650
- const written = project.chapters
1651
- .filter(c => c.status !== 'pending' && c.status !== 'generating' && c.file !== undefined)
1652
- /** 扫描一批章节,收集该角色出场且可能含外貌描写的段落(每章最多 2 段,共 12 段)。 */
1653
- const scanChapters = (list: ChapterPlan[], tokens: string[]): void => {
1654
- for (const chapter of list) {
1655
- if (excerpts.length >= 12) break
1656
- const body = readChapterFile(outputDir, chapter)
1657
- if (body === undefined) continue
1658
- const paras = body.split(/\n{2,}/).map(p => p.trim()).filter(p => p.length > 0)
1659
- let perChapter = 0
1660
- for (const para of paras) {
1661
- if (perChapter >= 2 || excerpts.length >= 12) break
1662
- if (!matchesRole(para, tokens)) continue
1663
- // 优先外貌描写段(含外貌关键词),否则纯动作段也收(LLM 自己判断)。
1664
- if (appearanceHints.test(para) || excerpts.length < 4) {
1665
- excerpts.push({ no: chapter.no, text: para.slice(0, 220) })
1666
- perChapter++
1667
- }
1668
- }
1669
- }
1670
- }
1671
- // 第 1 优先:最近 60 章(快路径)。
1672
- scanChapters(written.slice(-60), searchTokens)
1673
- // 第 2 优先:角色可能只在早期出场(全书 >60 章时),全书回扫。
1674
- if (excerpts.length === 0) scanChapters(written, searchTokens)
1675
- // 第 3 优先:角色名/身份带职业尾缀(如「周野律师」→ 正文写「周野的律师/律师」),用具体称谓扫。
1676
- if (excerpts.length === 0) {
1677
- const fb = roleFallbackTokens(role.name, role.identity)
1678
- if (fb.specific.length > 0) scanChapters(written, fb.specific)
1679
- }
1680
- // 第 4 兜底:具体称谓也没找到时,退回名字主干(如「周野」),交给 LLM 判断是否目标角色。
1681
- if (excerpts.length === 0) {
1682
- const fb = roleFallbackTokens(role.name, role.identity)
1683
- if (fb.stems.length > 0) scanChapters(written, fb.stems)
1684
- }
1685
- if (excerpts.length === 0) {
1686
- throw new Error(`正文中未找到「${roleName}」的出场描写(已扫描全书及角色名拆分词),请确认角色名与正文一致,或该角色尚未在正文出场`)
1687
- }
1688
-
1689
- // 2. LLM 提炼:一次输出 锚点 + 表情清单 + 四类精修提示词。
1690
- const rules = project.visualRules ?? []
1691
- const styleWords = styleKeywords(styleId, filterId)
1860
+ idea: string,
1861
+ count = 3,
1862
+ exclude: string[] = [],
1863
+ ): Promise<OutlineCandidate[]> {
1864
+ const n = Math.max(1, Math.min(3, Math.floor(count)))
1692
1865
  const system = [
1693
- '你是一位动漫角色设定师与 AI 绘图提示词工程师。根据网文正文中该角色的实际外貌描写,输出「形象锚点」与「四类生图提示词」——一次完成,用于 AI 绘图(NovelAI / Stable Diffusion / Midjourney / 豆包等)生成一致的角色立绘。',
1694
- '【当前视觉风格】(portrait 立绘的「风格」字段必须原样使用,sheet/expressions/details 同样内嵌):' + styleWords,
1695
- '重要:每段 zh 提示词的段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。',
1696
- '硬性要求(依据优先):',
1697
- '1. 发色/发型/瞳色/服装/气质/标志物必须来自提供的正文段落,不得凭空发明。',
1698
- '2. 正文未明确写到的项目(如瞳色没写、身高没写),用「未定」标注或直接不写,不要编造数值。',
1699
- '3. 服装优先取正文明确出现的(颜色+款式),多次出现取最常穿的组合;服装按分件组织(上身/下身/鞋/配饰)。',
1700
- '4. 标志物(标签/印记/饰品)必须出现在每段提示词中——它们是一致性的命根子。',
1701
- '5. 立绘/四视图/细节是「角色设定稿」:禁止写瞬间动作与道具使用状态(握手机、看屏幕、未接来电、走路、回头等),禁止写剧情状态与场景背景;只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。',
1702
- ...(shortDrama ? ['6. 短剧精简模式·人设极致化:性格标签必须极致化(如「极端偏执」「绝对冷血」,单一维度拉到最满);外貌只保留 1-2 个最强辨识度点,弱化平凡细节;服装/标志物更夸张醒目。'] : []),
1703
- '【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):',
1704
- ...rules.map(r => '- ' + r),
1705
- '输出六部分:',
1706
- '- zh:中文形象锚点,一段连贯文字(60-150 字):发色发型、瞳色、脸型气质、服装(颜色款式)、身材、标志性物件。',
1707
- '- en:英文形象锚点,booru 风格、逗号分隔、小写,30-50 个标签:含性别(1boy/1girl)、发色、发型、瞳色、服装、气质、标志物。不要输出负面提示词。',
1708
- '- tags:中文关键标签数组,5-10 个(如 ["黑发","束发","青色道袍","清秀","腰悬古玉"])。',
1709
- '- source:说明依据(如"第1章/第8章外貌描写;瞳色未明确")。',
1710
- '- expressions:该角色在本书剧情中需要的情绪表情清单数组,6-12 个(如 ["疲惫","麻木","压抑悲伤","皱眉","紧绷","放空"]),依据正文情绪描写与角色处境推断。',
1711
- '- promptKit:四类精修提示词(字段化+出图约束,参考示例结构):',
1712
- ' · portrait 立绘:严格按字段顺序写(参考示例结构):开头构图定位(正面站立全身人像)→ 风格(3D动漫,超精细建模)→ 背景(纯白纯色背景)→ 身份(男子,{角色名},外表{年龄},男性)→ 发型发色 → 胡茬 → 眼眸 → 面部 → 气质 → 上身服装分件(颜色款式+磨损细节)→ 下身 → 鞋 → 标志物(标签/印记,含细节如洇暗翘边)→ 收尾(角色设计稿,细节完整展示,无多余杂物,全身完整无裁切)。',
1713
- ' · sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,全身完整,四个视角分别描述,服装与标志物细节完整展示。',
1714
- ' · expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。',
1715
- ' · details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出(如标签/印记/工牌/袖口磨损/鞋),每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。',
1716
- 'promptKit 每段 zh:连贯中文 60-150 字;en:booru 标签 30-50 个。',
1717
- '输出必须是合法 JSON 对象:{"zh": "...", "en": "...", "tags": [...], "source": "...", "expressions": [...], "promptKit": {"portrait": {"zh":"...", "en":"..."}, "sheet": {"zh":"...", "en":"..."}, "expressions": [{"name":"疲惫","zh":"...","en":"..."}], "details": {"zh":"...", "en":"..."}}}',
1718
- '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
1866
+ '你是一位资深网文策划。作者只给了一句「想法」,你需要把它扩展成 2-3 个【方向差异明显】的完整小说大纲方案,供作者挑选。',
1867
+ '每个方案必须满足:',
1868
+ '1. bookName:书名(6 字以内,抓眼球、点题)。',
1869
+ '2. genre:题材(如 仙侠修真 / 都市异能 / 玄幻 / 悬疑)。',
1870
+ '3. sellingPoint:核心卖点一句话(金手指/爽点/差异化,40 字内)。',
1871
+ '4. outline:完整大纲文本(至少 800 字,可直接作为开书大纲),结构包含:书名与题材、金手指/核心设定、主角人设与动机、主线剧情走向(至少 5 个阶段)、关键配角与势力、卖点与爽点设计、预计分卷(3-5 卷)。',
1872
+ '方向差异要求:',
1873
+ '- 方案之间的金手指/剧情走向必须明显不同(如:苟道发育流 vs 随身老爷爷流 vs 群像争霸流),不能只是换书名。',
1874
+ '- 忠实于作者想法的核心要素,但允许在不同方向上进行合理演绎。',
1875
+ '- 不输出任何与已列「需避开的方向」雷同的方案。',
1876
+ '输出必须是合法 JSON 数组,只输出数组本身:',
1877
+ '[{"id": "唯一id", "bookName": "...", "genre": "...", "sellingPoint": "...", "outline": "..."}]',
1878
+ `本次只输出 ${n} 个方案。`,
1879
+ '重要:所有字符串值内部不得包含换行符(大纲内部分段请用「。\n」或「;」自然断句),JSON 必须在一段内完整结束。',
1719
1880
  '重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
1720
1881
  ].join('\n')
1721
1882
  const user = [
1722
- `书名:《${project.bookName}》`,
1723
- `目标角色:${role.name}(${role.identity})`,
1724
- (role.traits ?? []).length > 0 ? `性格标签:${role.traits!.join('、')}` : '',
1725
- `正文出场描写(含外貌线索的段落):`,
1726
- ...excerpts.map(e => `[第${e.no}章] ${e.text}`),
1727
- '只输出 JSON 对象。',
1883
+ `作者的想法:${idea}`,
1884
+ idea.trim().length < 40
1885
+ ? '作者的想法非常简短(可能只有一句)。请基于通用网文套路合理扩展补全:为每个方案自洽地设计金手指/核心设定、主角人设与动机、主线走向,使其成为完整可开书的大纲;不同方案的方向仍须明显差异。'
1886
+ : '',
1887
+ exclude.length > 0
1888
+ ? `需避开的已暂留方案方向(新方案不得与之雷同):\n${exclude.map((e, i) => `${i + 1}. ${e}`).join('\n')}`
1889
+ : '',
1890
+ `请生成 ${n} 个大纲方案。`,
1891
+ '只输出 JSON 数组。',
1728
1892
  ].join('\n\n')
1729
- const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
1730
- const raw = parseJsonObject<{ zh?: unknown; en?: unknown; tags?: unknown; source?: unknown; expressions?: unknown; promptKit?: unknown }>(text)
1731
- let zh = typeof raw.zh === 'string' ? raw.zh.trim().slice(0, 500) : ''
1732
- let en = typeof raw.en === 'string' ? raw.en.trim().slice(0, 1500) : ''
1733
- const tags = Array.isArray(raw.tags)
1734
- ? raw.tags.filter((t): t is string => typeof t === 'string' && t.trim() !== '').map(t => t.trim().slice(0, 20)).slice(0, 12)
1735
- : []
1736
- const source = typeof raw.source === 'string' ? raw.source.trim().slice(0, 300) : ''
1737
- const expressions = Array.isArray(raw.expressions)
1738
- ? raw.expressions.filter((e): e is string => typeof e === 'string' && e.trim() !== '').map(e => e.trim().slice(0, 12)).slice(0, 12)
1739
- : []
1740
- // 四类精修提示词(一次提炼直接产出)
1741
- const str = (v: unknown): string => typeof v === 'string' ? v.trim() : ''
1742
- const pair = (v: unknown): { zh: string; en: string } => {
1743
- const o = (typeof v === 'object' && v !== null) ? v as Record<string, unknown> : {}
1744
- return { zh: str(o.zh).slice(0, 800), en: str(o.en).slice(0, 1200) }
1893
+ const text = await complete(ctx, config, { system, user, temperature: 0.85, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
1894
+ const parsed = parseJsonArray<Record<string, unknown>>(text)
1895
+ const candidates: OutlineCandidate[] = []
1896
+ for (const entry of parsed) {
1897
+ if (typeof entry !== 'object' || entry === null) continue
1898
+ const bookName = typeof entry.bookName === 'string' ? entry.bookName.trim().slice(0, 30) : ''
1899
+ const outline = typeof entry.outline === 'string' ? entry.outline.trim() : ''
1900
+ if (bookName === '' || outline.length < 300) continue
1901
+ candidates.push({
1902
+ id: typeof entry.id === 'string' && entry.id !== '' ? entry.id : `oc-${Date.now().toString(36)}-${candidates.length}`,
1903
+ bookName,
1904
+ genre: typeof entry.genre === 'string' ? entry.genre.trim().slice(0, 20) : '',
1905
+ sellingPoint: typeof entry.sellingPoint === 'string' ? entry.sellingPoint.trim().slice(0, 120) : '',
1906
+ outline,
1907
+ })
1745
1908
  }
1746
- const kitRaw = (typeof raw.promptKit === 'object' && raw.promptKit !== null) ? raw.promptKit as Record<string, unknown> : {}
1747
- const kitExpr = Array.isArray(kitRaw.expressions) ? kitRaw.expressions.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null) : []
1748
- let promptKit: RoleRecord['promptKit']
1749
- try {
1750
- promptKit = {
1751
- portrait: pair(kitRaw.portrait),
1752
- sheet: pair(kitRaw.sheet),
1753
- expressions: kitExpr.map(e => ({
1754
- name: str(e.name).slice(0, 12) || '表情',
1755
- zh: str(e.zh).slice(0, 800),
1756
- en: str(e.en).slice(0, 1200),
1757
- })).slice(0, 12),
1758
- details: pair(kitRaw.details),
1759
- }
1760
- } catch {
1761
- promptKit = undefined
1762
- }
1763
- if (zh === '' || en === '') {
1764
- throw new Error('形象描述提炼失败:LLM 未返回有效 JSON')
1765
- }
1766
- // 风格兜底:LLM 漏嵌时强制补上(zh 段首 / en 末尾),保证出图风格与当前方案一致。
1767
- zh = ensureStyleEmbedded(zh, styleWords, 'zh')
1768
- en = ensureStyleEmbedded(en, styleWords, 'en')
1769
- if (promptKit !== undefined) {
1770
- promptKit = {
1771
- portrait: withStyle(promptKit.portrait, styleWords),
1772
- sheet: withStyle(promptKit.sheet, styleWords),
1773
- expressions: promptKit.expressions.map(e => ({ ...e, ...withStyle(e, styleWords) })),
1774
- details: withStyle(promptKit.details, styleWords),
1775
- }
1909
+ if (candidates.length === 0) {
1910
+ throw new Error('大纲方案生成失败:LLM 未返回有效 JSON(可重试)')
1776
1911
  }
1777
- return { zh, en, tags, source, expressions, promptKit }
1778
- }
1779
-
1780
- /** 小说角色库:提炼单个角色的形象锚点并写回角色卡。 */
1781
- export async function extractRoleVisual(
1782
- ctx: Context,
1783
- config: NovelConfig,
1784
- project: ProjectState,
1785
- outputDir: string,
1786
- roleName: string,
1787
- styleId?: string,
1788
- filterId?: string,
1789
- ): Promise<RoleVisualPrompt> {
1790
- const role = (project.roles ?? []).find(r => r.name === roleName)
1791
- if (role === undefined) throw new Error(`角色「${roleName}」不在角色库中`)
1792
- role.promptStyleId = styleId
1793
- return extractRoleVisualFrom(ctx, config, project, outputDir, role, styleId, filterId)
1912
+ return candidates.slice(0, n)
1794
1913
  }
1795
1914
 
1796
- /** 漫剧角色卡:提炼形象锚点并写回漫剧卡(status → anchored)。 */
1797
- export async function extractMangaRoleVisual(
1915
+ /** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检。
1916
+ * 两阶段管道(先抽取候选、再落库):
1917
+ * ① 源片段笔记:每章抽取结构化笔记(剧情/人物/设定/写法/卖点/短板信号)
1918
+ * ② 分节分析:按维度各跑一次 LLM,输出可读分析稿 + 结构化数据 + 证据链。
1919
+ * @param scope 'recent'(默认最近20章) | 'volume:N' | 'all'
1920
+ * @param preset 'quick'(总览/剧情/人物/文风) | 'standard'(+卖点)
1921
+ * @param budgetTokens token 预算上限(超过即截断章节取样)。
1922
+ */
1923
+ export async function breakdownBook(
1798
1924
  ctx: Context,
1799
1925
  config: NovelConfig,
1800
1926
  project: ProjectState,
1801
1927
  outputDir: string,
1802
- cardId: string,
1803
- styleId?: string,
1804
- filterId?: string,
1805
- ): Promise<RoleVisualPrompt> {
1806
- const card = (project.mangaRoles ?? []).find(c => c.id === cardId)
1807
- if (card === undefined) throw new Error(`漫剧角色卡 ${cardId} 不存在`)
1808
- const visual = await extractRoleVisualFrom(ctx, config, project, outputDir, { name: card.name, identity: card.identity, traits: card.traits }, styleId, filterId, project.shortDramaMode === true)
1809
- card.promptStyleId = styleId
1810
- card.imagePrompt = visual
1811
- if ((visual.expressions ?? []).length > 0) card.expressions = visual.expressions
1812
- if (visual.promptKit !== undefined) card.promptKit = visual.promptKit
1813
- if (card.status === 'imported' || card.status === 'pending_confirm') card.status = 'anchored'
1814
- card.updatedAt = new Date().toISOString()
1815
- return visual
1816
- }
1928
+ scope = 'recent',
1929
+ preset: 'quick' | 'standard' = 'quick',
1930
+ budgetTokens = 50000,
1931
+ ): Promise<BreakdownResponse> {
1932
+ // 1. 选章节范围。
1933
+ const written = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.summary !== undefined && c.summary !== '')
1934
+ let selected = written
1935
+ if (scope === 'recent') selected = written.slice(-20)
1936
+ else if (/^volume:\d+$/.test(scope)) {
1937
+ const v = Number(scope.slice(7))
1938
+ selected = written.filter(c => c.volume === v)
1939
+ }
1940
+ if (selected.length === 0) throw new NovelActionError('contract', '没有可分析的已写章节(需要已生成并带摘要)')
1817
1941
 
1818
- /** 可精修提示词包的「角色源」:小说角色卡或漫剧角色卡的最小公共面。 */
1819
- interface RoleKitSource {
1820
- name: string
1821
- identity?: string
1822
- imagePrompt: NonNullable<RoleRecord['imagePrompt']>
1823
- expressions?: string[]
1824
- }
1825
-
1826
- /**
1827
- * 底层实现:基于角色源的形象锚点 + 表情清单 + 视觉规则产出四类生图提示词(不写库)。
1828
- */
1829
- async function generateRolePromptKitFrom(
1830
- ctx: Context,
1831
- config: NovelConfig,
1832
- project: ProjectState,
1833
- role: RoleKitSource,
1834
- styleId?: string,
1835
- filterId?: string,
1836
- shortDrama = false,
1837
- ): Promise<RoleRecord['promptKit']> {
1838
- const roleName = role.name
1839
- const rules = project.visualRules ?? []
1840
- const styleWords = styleKeywords(styleId, filterId)
1841
- const system = [
1842
- '你是一位 AI 绘图提示词工程师。基于给定角色的形象锚点、表情清单与本书视觉规则,输出四类生图提示词(每类 zh+en 各一段)。',
1843
- '【当前视觉风格】(每类提示词必须内嵌):' + styleWords,
1844
- '重要:每段 zh 提示词的段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。',
1845
- '四类:',
1846
- '1. portrait 立绘:正面站立全身人像,3D动漫超精细建模,纯白纯色背景;按字段流组织:身份(男子,角色名,外表年龄)→ 发型发色 → 胡茬 → 眼眸 → 面部 → 气质 → 上身服装分件(含磨损)→ 下身 → 鞋 → 标志物(含细节)→ 收尾(角色设计稿,细节完整展示,无多余杂物,全身完整无裁切)。',
1847
- '2. sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,四个视角分别描述。',
1848
- '3. expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。',
1849
- '4. details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出,每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。',
1850
- '5. 立绘/四视图/细节为「角色设定稿」:禁止瞬间动作、道具使用状态与剧情状态(握手机、看屏幕、未接来电等),只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。',
1851
- ...(shortDrama ? ['6. 短剧精简模式·人设极致化:性格标签必须极致化(单一维度拉到最满);外貌只保留 1-2 个最强辨识度点;服装/标志物更夸张醒目,一眼可认。'] : []),
1852
- '【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):',
1853
- ...rules.map(r => '- ' + r),
1854
- 'zh 要求:连贯中文,写实电影感,60-150 字/段;en 要求:booru 风格逗号分隔标签,30-50 个/段。',
1855
- '输出必须是合法 JSON 对象:{"portrait": {"zh": "...", "en": "..."}, "sheet": {"zh": "...", "en": "..."}, "expressions": [{"name": "疲惫", "zh": "...", "en": "..."}], "details": {"zh": "...", "en": "..."}}',
1856
- '重要:所有字符串值内部不得包含换行符;直接输出 JSON 结果本身。',
1857
- ].join('\n')
1858
- const user = [
1859
- `角色:${role.name}(${role.identity ?? ''})`,
1860
- `中文锚点:${role.imagePrompt.zh}`,
1861
- `英文锚点:${role.imagePrompt.en}`,
1862
- `关键标签:${role.imagePrompt.tags.join('、')}`,
1863
- `表情清单:${(role.expressions ?? []).join('、') || '(未提供,按角色气质推断 6 个)'}`,
1864
- '只输出 JSON 对象。',
1865
- ].join('\n\n')
1866
- const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
1867
- const raw = parseJsonObject<Record<string, unknown>>(text)
1868
- const str = (v: unknown): string => typeof v === 'string' ? v.trim() : ''
1869
- const pair = (v: unknown): { zh: string; en: string } => {
1870
- const o = (typeof v === 'object' && v !== null) ? v as Record<string, unknown> : {}
1871
- return { zh: str(o.zh).slice(0, 800), en: str(o.en).slice(0, 1200) }
1872
- }
1873
- const expressionsRaw = Array.isArray(raw.expressions) ? raw.expressions.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null) : []
1874
- const kit: RoleRecord['promptKit'] = {
1875
- portrait: withStyle(pair(raw.portrait), styleWords),
1876
- sheet: withStyle(pair(raw.sheet), styleWords),
1877
- expressions: expressionsRaw.map(e => ({
1878
- name: str(e.name).slice(0, 12) || '表情',
1879
- ...withStyle({ zh: str(e.zh).slice(0, 800), en: str(e.en).slice(0, 1200) }, styleWords),
1880
- })).slice(0, 12),
1881
- details: withStyle(pair(raw.details), styleWords),
1882
- }
1883
- if (kit.portrait.zh === '' || kit.portrait.en === '') {
1884
- throw new Error('提示词精修失败:LLM 未返回有效 JSON')
1885
- }
1886
- return kit
1887
- }
1888
-
1889
- /** 小说角色库:角色四类生图提示词精修包(写回角色卡)。 */
1890
- export async function generateRolePromptKit(
1891
- ctx: Context,
1892
- config: NovelConfig,
1893
- project: ProjectState,
1894
- roleName: string,
1895
- styleId?: string,
1896
- filterId?: string,
1897
- ): Promise<RoleRecord['promptKit']> {
1898
- const role = (project.roles ?? []).find(r => r.name === roleName)
1899
- if (role === undefined) throw new Error(`角色「${roleName}」不在角色库中`)
1900
- if (role.imagePrompt === undefined) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`)
1901
- role.promptStyleId = styleId
1902
- return generateRolePromptKitFrom(ctx, config, project, { name: role.name, identity: role.identity, imagePrompt: role.imagePrompt, expressions: role.expressions }, styleId, filterId)
1903
- }
1904
-
1905
- /** 漫剧角色卡:四类生图提示词精修包(写回漫剧卡,status → anchored)。 */
1906
- export async function generateMangaRolePromptKit(
1907
- ctx: Context,
1908
- config: NovelConfig,
1909
- project: ProjectState,
1910
- cardId: string,
1911
- styleId?: string,
1912
- filterId?: string,
1913
- ): Promise<RoleRecord['promptKit']> {
1914
- const card = (project.mangaRoles ?? []).find(c => c.id === cardId)
1915
- if (card === undefined) throw new Error(`漫剧角色卡 ${cardId} 不存在`)
1916
- if (card.imagePrompt === undefined) throw new Error(`「${card.name}」还没有形象锚点,请先生成锚点`)
1917
- const kit = await generateRolePromptKitFrom(ctx, config, project, { name: card.name, identity: card.identity, imagePrompt: card.imagePrompt, expressions: card.expressions }, styleId, filterId, project.shortDramaMode === true)
1918
- card.promptStyleId = styleId
1919
- card.promptKit = kit
1920
- if (card.status === 'imported' || card.status === 'pending_confirm') card.status = 'anchored'
1921
- card.updatedAt = new Date().toISOString()
1922
- return kit
1923
- }
1924
-
1925
- /**
1926
- * 漫剧角色库·提名(两段式):从某章分镜的 characters 提名候选角色名 →
1927
- * 规则过滤(精确名 + 身份/简称匹配,短名单 ≤5)→ LLM 确认(是/否 + 选哪个,不做开放检索)→
1928
- * 返回带漫剧卡建议的候选(未匹配时给出「回小说库补提炼 / 漫剧直接创建」判定)。
1929
- */
1930
- export async function nominateMangaRoles(
1931
- ctx: Context,
1932
- config: NovelConfig,
1933
- project: ProjectState,
1934
- outputDir: string,
1935
- chapterNo: number,
1936
- ): Promise<MangaRoleCandidate[]> {
1937
- const entry = (project.storyboards ?? []).find(e => e.chapterNo === chapterNo)
1938
- if (entry === undefined) throw new Error(`第 ${chapterNo} 章还没有分镜产出,请先在「分镜」页生成剧情骨架/分镜表`)
1939
-
1940
- // 收集该章分镜的结构化角色引用(骨架 + 表级 + 镜头级,去重)。
1941
- const names: string[] = []
1942
- const pushNames = (list: string[] | undefined): void => {
1943
- for (const n of list ?? []) {
1944
- const t = n.trim().slice(0, 20)
1945
- if (t !== '' && !names.includes(t)) names.push(t)
1946
- }
1947
- }
1948
- pushNames(entry.skeleton?.characters)
1949
- pushNames(entry.table?.characters)
1950
- for (const s of entry.table?.shots ?? []) pushNames(s.characters)
1951
- if (names.length === 0) throw new Error(`第 ${chapterNo} 章的分镜还没有结构化角色(characters 为空),请重新生成剧情骨架/分镜表`)
1952
-
1953
- // 已建漫剧卡的直接标 already_imported(按漫剧名或来源名匹配)。
1954
- const imported = new Set<string>()
1955
- for (const c of project.mangaRoles ?? []) {
1956
- imported.add(c.name)
1957
- if (c.sourceRoleName !== undefined && c.sourceRoleName !== '') imported.add(c.sourceRoleName)
1958
- }
1959
- const already: MangaRoleCandidate[] = names.filter(n => imported.has(n)).map(n => ({
1960
- rawName: n,
1961
- verdict: 'already_imported' as const,
1962
- matches: [],
1963
- suggested: emptyCandidateSuggestion(n),
1964
- }))
1965
- const fresh = names.filter(n => !imported.has(n))
1966
- if (fresh.length === 0) return already
1967
-
1968
- const chapter = project.chapters.find(c => c.no === chapterNo)
1969
- const body = chapter !== undefined ? readChapterFile(outputDir, chapter) : undefined
1970
- const roles = project.roles ?? []
1971
-
1972
- // 规则阶段:精确名 + 身份/简称匹配,短名单 ≤5。
1973
- const shortlists: Array<{ rawName: string; roleNames: string[] }> = fresh.map(rawName => {
1974
- const shortlist: string[] = []
1975
- const push = (n: string): void => { if (!shortlist.includes(n)) shortlist.push(n) }
1976
- for (const r of roles) {
1977
- if (shortlist.length >= 5) break
1978
- const n = r.name
1979
- if (n === rawName) { push(n); continue }
1980
- if (n.includes(rawName) || rawName.includes(n)) { push(n); continue }
1981
- const ident = r.identity ?? ''
1982
- if (ident !== '' && (ident === rawName || ident.includes(rawName) || rawName.includes(ident))) push(n)
1983
- }
1984
- return { rawName, roleNames: shortlist }
1985
- })
1986
-
1987
- // LLM 阶段:确认是/否 + 选哪个 + 漫剧卡建议(只能从短名单里选,不做开放检索)。
1988
- const beats = (entry.skeleton?.beats ?? []).map(b => `[${b.id}] ${b.event}`).join('\n')
1989
- const system = [
1990
- '你是一位漫剧选角导演。下面给出「分镜角色提名」与「小说角色库候选名单」。',
1991
- '任务:为每个提名判定它对应小说角色库中的哪一个候选(或判定小说库没有对应角色),并预填一张「漫剧角色卡」的建议信息。',
1992
- '规则:',
1993
- '1. roleName 只能从该提名的候选名单中选,禁止输出名单之外的名字(不做开放检索)。',
1994
- '2. verdict:matched=候选名单里确实有对应角色(选最像的那个);ambiguous=名单里有多个候选且无法确定(此时 roleName 可留空或选最可能的一个);not_in_library=候选名单为空或都不对应。',
1995
- '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」)。',
1996
- '4. 身份型提名(如「持枪者」「围观群众」):候选名单有则匹配;名单没有且正文确有该称谓 → not_in_library;正文也没有 → not_in_library。',
1997
- ...(project.shortDramaMode === true
1998
- ? ['5. 短剧精简模式(本书已开启):只保留 5-8 个上镜角色——主角/反派/感情线/关键配角;功能性路人不上卡(此类提名直接 not_in_library)。每个角色必须给出明确的 coreFunction 与 protagonistRelation;性格标签极致化;候选超过 8 个时按戏份重要性裁剪到最核心 8 个。']
1999
- : []),
2000
- '输出必须是合法 JSON 数组:[{"rawName": "...", "verdict": "...", "roleName": "...或省略", "suggested": {...}}],每个提名都要有,数组长度必须等于提名数。',
2001
- '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
2002
- ].join('\n')
2003
- const user = [
2004
- `章节:第 ${chapterNo} 章《${chapter?.title ?? ''}》`,
2005
- '==== 剧情骨架(节拍) ====',
2006
- beats !== '' ? beats : '(无骨架节拍)',
2007
- '==== 角色提名与候选名单 ====',
2008
- shortlists.map(s => s.roleNames.length > 0 ? `[提名] ${s.rawName} → 候选:${s.roleNames.join('、')}` : `[提名] ${s.rawName} → 候选:(无)`).join('\n'),
2009
- body !== undefined ? '==== 章节正文(前 2500 字,判断称谓归属用) ====\n' + body.replace(/^#.*$/gm, '').trim().slice(0, 2500) : '',
2010
- '只输出 JSON 数组。',
2011
- ].filter(x => x !== '').join('\n\n')
2012
- const text = await complete(ctx, config, { system, user, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 8000), reasoning: config.analysisReasoning ?? 'low' })
2013
- const raw = parseJsonArray<Record<string, unknown>>(text)
2014
-
2015
- const verdicts = new Set(['matched', 'ambiguous', 'not_in_library'])
2016
- const fnMap: Record<string, MangaRoleCard['coreFunction']> = { protagonist: 'protagonist', mentor: 'mentor', love_interest: 'love_interest', antagonist: 'antagonist', sidekick: 'sidekick', informant: 'informant', functional: 'functional' }
2017
- const relMap: Record<string, MangaRoleCard['protagonistRelation']> = { enemy: 'enemy', friend: 'friend', mentor: 'mentor', lover: 'lover', exploit: 'exploit', neutral: 'neutral' }
2018
- const byName = new Map<string, Record<string, unknown>>()
2019
- for (const e of raw) {
2020
- if (typeof e === 'object' && e !== null && typeof e.rawName === 'string') byName.set(e.rawName.trim(), e)
2021
- }
2022
- const str = (v: unknown): string => typeof v === 'string' ? v.trim().slice(0, 100) : ''
2023
- const strArr = (v: unknown, n: number): string[] => Array.isArray(v)
2024
- ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.trim().slice(0, 20)).slice(0, n)
2025
- : []
2026
-
2027
- const out: MangaRoleCandidate[] = []
2028
- for (const item of shortlists) {
2029
- const judge = byName.get(item.rawName)
2030
- const judgeVerdict = judge !== undefined ? str(judge.verdict) : ''
2031
- const verdict = verdicts.has(judgeVerdict) ? judgeVerdict as MangaRoleCandidate['verdict'] : (item.roleNames.length > 0 ? 'ambiguous' : 'not_in_library')
2032
- let roleName: string | undefined
2033
- if (judge !== undefined) {
2034
- const chosen = str(judge.roleName)
2035
- if (chosen !== '' && item.roleNames.includes(chosen)) roleName = chosen
2036
- }
2037
- if (verdict === 'matched' && roleName === undefined && item.roleNames.length === 1) roleName = item.roleNames[0]
2038
- const sug = (typeof judge?.suggested === 'object' && judge?.suggested !== null) ? judge.suggested as Record<string, unknown> : {}
2039
- const novelHint: MangaRoleCandidate['novelHint'] = verdict === 'not_in_library' && body !== undefined
2040
- ? (body.includes(item.rawName) ? 'backfill' : 'manga_new')
2041
- : undefined
2042
- const suggested = {
2043
- name: (str(sug.name) !== '' ? str(sug.name) : (roleName ?? item.rawName)).slice(0, 30),
2044
- identity: str(sug.identity).slice(0, 60),
2045
- coreFunction: fnMap[str(sug.coreFunction)] ?? 'functional',
2046
- protagonistRelation: relMap[str(sug.protagonistRelation)] ?? 'neutral',
2047
- speechStyle: str(sug.speechStyle).slice(0, 60),
2048
- traits: strArr(sug.traits, 3),
2049
- appearance: str(sug.appearance).slice(0, 100),
2050
- keyScenes: strArr(sug.keyScenes, 3),
2051
- }
2052
- out.push({
2053
- rawName: item.rawName,
2054
- verdict,
2055
- matches: item.roleNames.map(n => ({ roleName: n, reason: roleName === n ? 'LLM 确认' : '规则候选' })),
2056
- matchedRoleName: roleName,
2057
- novelHint,
2058
- suggested,
2059
- })
2060
- }
2061
- return [...already, ...out]
2062
- }
2063
-
2064
- /** 空建议(already_imported 等无需 LLM 的候选用)。 */
2065
- function emptyCandidateSuggestion(name: string): MangaRoleCandidate['suggested'] {
2066
- return { name, identity: '', coreFunction: 'functional', protagonistRelation: 'neutral', speechStyle: '', traits: [], appearance: '', keyScenes: [] }
2067
- }
2068
-
2069
- /** ✨ 从道藏/红线提炼「视觉世界观规则」:生图/生视频必须遵守的设定纠偏(如"商品=人,禁止常规超市商品")。 */
2070
- export async function extractVisualRules(
2071
- ctx: Context,
2072
- config: NovelConfig,
2073
- project: ProjectState,
2074
- ): Promise<string[]> {
2075
- const system = [
2076
- '你是一位 AI 绘图的视觉规则设计师。本书的设定是"反常识"的,生图/生视频模型默认会画成现实世界的样子,你需要提炼 3-6 条「视觉规则」钉住本书的视觉世界观。',
2077
- '规则要求:',
2078
- '1. 每条必须可执行:明确"画面里必须出现什么/禁止出现什么"(如"货架上的一切商品都是活人,禁止画成罐头/饮料/日用品")。',
2079
- '2. 覆盖本书最容易被模型画错的 2-4 个核心反常识点。',
2080
- '3. 每条 40 字内,禁止泛泛而谈。',
2081
- '输出必须是合法 JSON 数组:["规则1", "规则2", ...],不要输出其他文字。',
2082
- ].join('\n')
2083
- const bible = project.bible
2084
- const user = [
2085
- `书名:《${project.bookName}》`,
2086
- `题材:${bible?.genre ?? ''}`,
2087
- bible !== undefined && bible.worldRules.length > 0
2088
- ? `世界规则:\n${bible.worldRules.map(r => '- ' + r).join('\n')}`
2089
- : '',
2090
- bible !== undefined && bible.redLines.length > 0
2091
- ? `红线:\n${bible.redLines.map(r => '- ' + r).join('\n')}`
2092
- : '',
2093
- '只输出 JSON 数组。',
2094
- ].filter(s => s !== '').join('\n\n')
2095
- const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000), reasoning: config.analysisReasoning ?? 'low' })
2096
- const raw = parseJsonArray<string>(text)
2097
- return raw.map(r => typeof r === 'string' ? r.trim().slice(0, 80) : '').filter(r => r !== '').slice(0, 8)
2098
- }
2099
-
2100
-
2101
- /**
2102
- * 开书想法 → AI 大纲:输入一句话想法,生成 2-3 个方向不同、可直接开书的完整大纲方案。
2103
- * @param count 本次生成几个(默认 3,最多 3)
2104
- * @param exclude 已暂留方案的剧情方向/卖点摘要(换批时避开,防止重复)
2105
- */
2106
- export async function suggestOutlines(
2107
- ctx: Context,
2108
- config: NovelConfig,
2109
- idea: string,
2110
- count = 3,
2111
- exclude: string[] = [],
2112
- ): Promise<OutlineCandidate[]> {
2113
- const n = Math.max(1, Math.min(3, Math.floor(count)))
2114
- const system = [
2115
- '你是一位资深网文策划。作者只给了一句「想法」,你需要把它扩展成 2-3 个【方向差异明显】的完整小说大纲方案,供作者挑选。',
2116
- '每个方案必须满足:',
2117
- '1. bookName:书名(6 字以内,抓眼球、点题)。',
2118
- '2. genre:题材(如 仙侠修真 / 都市异能 / 玄幻 / 悬疑)。',
2119
- '3. sellingPoint:核心卖点一句话(金手指/爽点/差异化,40 字内)。',
2120
- '4. outline:完整大纲文本(至少 800 字,可直接作为开书大纲),结构包含:书名与题材、金手指/核心设定、主角人设与动机、主线剧情走向(至少 5 个阶段)、关键配角与势力、卖点与爽点设计、预计分卷(3-5 卷)。',
2121
- '方向差异要求:',
2122
- '- 方案之间的金手指/剧情走向必须明显不同(如:苟道发育流 vs 随身老爷爷流 vs 群像争霸流),不能只是换书名。',
2123
- '- 忠实于作者想法的核心要素,但允许在不同方向上进行合理演绎。',
2124
- '- 不输出任何与已列「需避开的方向」雷同的方案。',
2125
- '输出必须是合法 JSON 数组,只输出数组本身:',
2126
- '[{"id": "唯一id", "bookName": "...", "genre": "...", "sellingPoint": "...", "outline": "..."}]',
2127
- `本次只输出 ${n} 个方案。`,
2128
- '重要:所有字符串值内部不得包含换行符(大纲内部分段请用「。\n」或「;」自然断句),JSON 必须在一段内完整结束。',
2129
- '重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
2130
- ].join('\n')
2131
- const user = [
2132
- `作者的想法:${idea}`,
2133
- idea.trim().length < 40
2134
- ? '作者的想法非常简短(可能只有一句)。请基于通用网文套路合理扩展补全:为每个方案自洽地设计金手指/核心设定、主角人设与动机、主线走向,使其成为完整可开书的大纲;不同方案的方向仍须明显差异。'
2135
- : '',
2136
- exclude.length > 0
2137
- ? `需避开的已暂留方案方向(新方案不得与之雷同):\n${exclude.map((e, i) => `${i + 1}. ${e}`).join('\n')}`
2138
- : '',
2139
- `请生成 ${n} 个大纲方案。`,
2140
- '只输出 JSON 数组。',
2141
- ].join('\n\n')
2142
- const text = await complete(ctx, config, { system, user, temperature: 0.85, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
2143
- const parsed = parseJsonArray<Record<string, unknown>>(text)
2144
- const candidates: OutlineCandidate[] = []
2145
- for (const entry of parsed) {
2146
- if (typeof entry !== 'object' || entry === null) continue
2147
- const bookName = typeof entry.bookName === 'string' ? entry.bookName.trim().slice(0, 30) : ''
2148
- const outline = typeof entry.outline === 'string' ? entry.outline.trim() : ''
2149
- if (bookName === '' || outline.length < 300) continue
2150
- candidates.push({
2151
- id: typeof entry.id === 'string' && entry.id !== '' ? entry.id : `oc-${Date.now().toString(36)}-${candidates.length}`,
2152
- bookName,
2153
- genre: typeof entry.genre === 'string' ? entry.genre.trim().slice(0, 20) : '',
2154
- sellingPoint: typeof entry.sellingPoint === 'string' ? entry.sellingPoint.trim().slice(0, 120) : '',
2155
- outline,
2156
- })
2157
- }
2158
- if (candidates.length === 0) {
2159
- throw new Error('大纲方案生成失败:LLM 未返回有效 JSON(可重试)')
2160
- }
2161
- return candidates.slice(0, n)
2162
- }
2163
-
2164
- /** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检。
2165
- * 两阶段管道(借鉴 AI-Novel-Writing-Assistant):
2166
- * ① 源片段笔记:每章抽取结构化笔记(剧情/人物/设定/写法/卖点/短板信号)
2167
- * ② 分节分析:按维度各跑一次 LLM,输出可读分析稿 + 结构化数据 + 证据链。
2168
- * @param scope 'recent'(默认最近20章) | 'volume:N' | 'all'
2169
- * @param preset 'quick'(总览/剧情/人物/文风) | 'standard'(+卖点)
2170
- * @param budgetTokens token 预算上限(超过即截断章节取样)。
2171
- */
2172
- export async function breakdownBook(
2173
- ctx: Context,
2174
- config: NovelConfig,
2175
- project: ProjectState,
2176
- outputDir: string,
2177
- scope = 'recent',
2178
- preset: 'quick' | 'standard' = 'quick',
2179
- budgetTokens = 50000,
2180
- ): Promise<BreakdownResponse> {
2181
- // 1. 选章节范围。
2182
- const written = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.summary !== undefined && c.summary !== '')
2183
- let selected = written
2184
- if (scope === 'recent') selected = written.slice(-20)
2185
- else if (/^volume:\d+$/.test(scope)) {
2186
- const v = Number(scope.slice(7))
2187
- selected = written.filter(c => c.volume === v)
2188
- }
2189
- if (selected.length === 0) throw new Error('没有可分析的已写章节(需要已生成并带摘要)')
2190
-
2191
- // 2. token 预算:估算每章正文+摘要成本,超预算则只取最近的章节。
2192
- let budget = budgetTokens
2193
- const chunks: Array<{ no: number; title: string; summary: string; body: string }> = []
2194
- for (const c of selected.slice().reverse()) {
2195
- const body = readChapterFile(outputDir, c) ?? ''
2196
- // 粗估:每 4 字符 ≈ 1 token(中文),章节正文截 4000 字上限。
2197
- const bodySlice = body.replace(/^#\s+.*$/m, '').trim().slice(0, 4000)
2198
- const est = Math.ceil((bodySlice.length + (c.summary?.length ?? 0)) / 4) + 400
2199
- if (est > budget && chunks.length > 0) break
2200
- chunks.unshift({ no: c.no, title: c.title, summary: c.summary ?? '', body: bodySlice })
2201
- budget -= est
2202
- }
1942
+ // 2. token 预算:估算每章正文+摘要成本,超预算则只取最近的章节。
1943
+ let budget = budgetTokens
1944
+ const chunks: Array<{ no: number; title: string; summary: string; body: string }> = []
1945
+ for (const c of selected.slice().reverse()) {
1946
+ const body = readChapterFile(outputDir, c) ?? ''
1947
+ // 粗估:每 4 字符 ≈ 1 token(中文),章节正文截 4000 字上限。
1948
+ const bodySlice = body.replace(/^#\s+.*$/m, '').trim().slice(0, 4000)
1949
+ const est = Math.ceil((bodySlice.length + (c.summary?.length ?? 0)) / 4) + 400
1950
+ if (est > budget && chunks.length > 0) break
1951
+ chunks.unshift({ no: c.no, title: c.title, summary: c.summary ?? '', body: bodySlice })
1952
+ budget -= est
1953
+ }
2203
1954
 
2204
1955
  // 3. 阶段一:源片段笔记(每章一次 LLM,串行控制 token)。
2205
1956
  const notes: string[] = []
@@ -2327,40 +2078,6 @@ export async function breakdownBook(
2327
2078
  }
2328
2079
  }
2329
2080
 
2330
- /** 生图接口连通性测试(设置页模型条目用):GET {baseURL}/models,计时返回延迟。 */
2331
- export async function testImageEndpoint(baseURL: string, apiKey: string, model?: string): Promise<{ ok: boolean; ms: number; message?: string; modelFound?: boolean }> {
2332
- const trimmed = baseURL.trim()
2333
- if (trimmed === '') return { ok: false, ms: 0, message: '接口地址不能为空' }
2334
- const url = trimmed.replace(/\/+$/, '') + '/models'
2335
- const start = Date.now()
2336
- try {
2337
- const res = await fetch(url, {
2338
- method: 'GET',
2339
- headers: { Authorization: 'Bearer ' + apiKey, 'Content-Type': 'application/json' },
2340
- })
2341
- const ms = Date.now() - start
2342
- if (!res.ok) {
2343
- const text = (await res.text().catch(() => '')).slice(0, 200)
2344
- return { ok: false, ms, message: 'HTTP ' + res.status + (text !== '' ? ':' + text : '') }
2345
- }
2346
- let modelFound: boolean | undefined
2347
- if (model !== undefined && model !== '') {
2348
- const json = await res.json().catch(() => null)
2349
- const list = (json as { data?: unknown } | null)?.data
2350
- if (Array.isArray(list)) {
2351
- modelFound = list.some(m => {
2352
- if (typeof m !== 'object' || m === null) return false
2353
- const o = m as Record<string, unknown>
2354
- return o.id === model || o.model === model
2355
- })
2356
- }
2357
- }
2358
- return { ok: true, ms, modelFound }
2359
- } catch (err) {
2360
- return { ok: false, ms: Date.now() - start, message: (err as Error).message }
2361
- }
2362
- }
2363
-
2364
2081
  // ---------------------------------------------------------- llm catalog/test
2365
2082
 
2366
2083
  /** LLM 连通性失败的错误码 → 人话(供设置页“测试连通”回显)。 */
@@ -2393,13 +2110,16 @@ export async function testLlmModel(ctx: Context, provider: string, model: string
2393
2110
  content: [{ type: 'text', text: '只回复两个字:OK' }],
2394
2111
  source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
2395
2112
  })]
2396
- const request: GenerateOptions = {
2113
+ // 这里自己构造请求(为省 token 拿到首块即停),所以**必须显式过一遍能力层**:
2114
+ // 否则「测试连通」会原样下发参数,对固定温度的模型必然报错 —— 而测试连通
2115
+ // 正是用户用来判断配置对不对的地方。
2116
+ const request = applyModelCapability({
2397
2117
  provider,
2398
2118
  model,
2399
2119
  messages,
2400
2120
  maxTokens: 16,
2401
2121
  temperature: 0,
2402
- }
2122
+ }, model)
2403
2123
  // 真实流式调用 + 30 秒超时(GenerateOptions.signal 由适配器响应并中止)。
2404
2124
  const controller = new AbortController()
2405
2125
  const timer = setTimeout(() => controller.abort(), 30_000)
@@ -2454,11 +2174,22 @@ export async function listLlmVendors(ctx: Context): Promise<LlmVendorsResponse>
2454
2174
  }
2455
2175
  }
2456
2176
  } catch { /* 单个目录读取失败跳过 */ }
2457
- // 已注册的适配器路由(如 deepseek-official)
2177
+ // 已注册的适配器路由(如 deepseek-official):以 DSH 运行时目录为准刷新模型列表,
2178
+ // 否则 LLM_VENDORS 里的预置模型一旦过期(模型改名/下线),下拉里就会挂着不存在的模型。
2458
2179
  try {
2459
2180
  for (const p of ctx.llm.listProviders()) {
2460
- if (!map.has(p.id)) {
2461
- map.set(p.id, { id: p.id, name: p.name !== '' && p.name !== p.id ? p.name : p.id, models: [], builtin: true })
2181
+ const name = p.name !== '' && p.name !== p.id ? p.name : p.id
2182
+ let models: string[] = []
2183
+ try {
2184
+ models = (await ctx.llm.listModels(p.id)).map(m => m.id).filter(id => id !== '')
2185
+ } catch { /* 该 provider 目录不可用 → 保留预置兜底 */ }
2186
+ const existing = map.get(p.id)
2187
+ if (existing === undefined) {
2188
+ map.set(p.id, { id: p.id, name, models, builtin: true })
2189
+ } else if (models.length > 0) {
2190
+ // 运行时目录优先:预置列表只作为目录不可用时的兜底
2191
+ existing.models = models
2192
+ if (existing.name === '' || existing.name === p.id) existing.name = name
2462
2193
  }
2463
2194
  }
2464
2195
  } catch { /* ignore */ }
@@ -2569,101 +2300,7 @@ export async function registerLlmModel(ctx: Context, req: AddModelRequest): Prom
2569
2300
  return { ok: true, saved, provider: route, message }
2570
2301
  }
2571
2302
 
2572
- /** 底层:按形象锚点调用生图接口生成定妆图(不写库;写库由上层调用方负责)。 */
2573
- async function generateReferenceImageFrom(
2574
- config: NovelConfig,
2575
- anchor: NonNullable<RoleRecord['imagePrompt']>,
2576
- style = '',
2577
- modelId?: string,
2578
- ): Promise<string> {
2579
- if (config.imageApiEnabled !== true) throw new Error('生图未启用,请在设置中启用一个生图模型')
2580
- const activeImage = (modelId !== undefined && modelId !== '' ? (config.imageModels ?? []).find(m => m.id === modelId) : undefined)
2581
- ?? (config.imageModels ?? []).find(m => m.enabled)
2582
- ?? (config.imageModels ?? [])[0]
2583
- const apiKey = activeImage?.apiKey ?? config.imageApiKey
2584
- const model = activeImage?.model ?? config.imageApiModel
2585
- const baseURL = (activeImage?.baseURL ?? config.imageBaseUrl ?? '').replace(/\/+$/, '')
2586
- if (baseURL === '') throw new Error('请先在设置中填写生图接口地址')
2587
- if (apiKey === undefined || apiKey === '' || model === undefined || model === '') {
2588
- throw new Error('请先在设置中配置生图模型的 API Key 和模型 id')
2589
- }
2590
- const stylePrompt = getComicStylePrompt(style)
2591
- const prompt = [
2592
- anchor.zh,
2593
- anchor.en,
2594
- anchor.tags.join(', '),
2595
- stylePrompt,
2596
- 'character reference sheet, front view, clean background, consistent design',
2597
- ].filter(Boolean).join(', ')
2598
-
2599
- const res = await fetch(baseURL + '/images/generations', {
2600
- method: 'POST',
2601
- headers: {
2602
- 'Content-Type': 'application/json',
2603
- Authorization: `Bearer ${apiKey}`,
2604
- },
2605
- body: JSON.stringify({
2606
- model,
2607
- prompt,
2608
- response_format: 'url',
2609
- size: '2K',
2610
- stream: false,
2611
- watermark: false,
2612
- }),
2613
- })
2614
- if (!res.ok) {
2615
- const text = await res.text()
2616
- throw new Error(`生图失败(${res.status}):${text}`)
2617
- }
2618
- const json = (await res.json()) as { data?: Array<{ url?: string }> }
2619
- const url = json.data?.[0]?.url
2620
- if (url === undefined || url === '') throw new Error('生图接口未返回图片 URL')
2621
-
2622
- const imgRes = await fetch(url)
2623
- if (!imgRes.ok) throw new Error('下载生图结果失败')
2624
- const buf = Buffer.from(await imgRes.arrayBuffer())
2625
- const contentType = imgRes.headers.get('content-type') || 'image/jpeg'
2626
- return `data:${contentType};base64,${buf.toString('base64')}`
2627
- }
2628
-
2629
- /** 小说角色库:生成角色定妆图并写回角色卡(imageUrl)。 */
2630
- export async function generateRoleReferenceImage(
2631
- ctx: Context,
2632
- config: NovelConfig,
2633
- project: ProjectState,
2634
- outputDir: string,
2635
- name: string,
2636
- style = '',
2637
- modelId?: string,
2638
- ): Promise<string> {
2639
- const role = project.roles?.find(r => r.name === name)
2640
- if (role === undefined) throw new Error(`角色 ${name} 不存在`)
2641
- if (role.imagePrompt === undefined) throw new Error(`角色 ${name} 还没有形象锚点,请先生成锚点`)
2642
- const dataUrl = await generateReferenceImageFrom(config, role.imagePrompt, style, modelId)
2643
- role.imageUrl = dataUrl
2644
- return dataUrl
2645
- }
2646
-
2647
- /** 漫剧角色卡:生成定妆图并写回漫剧卡(imageUrl)。 */
2648
- export async function generateMangaRoleReferenceImage(
2649
- ctx: Context,
2650
- config: NovelConfig,
2651
- project: ProjectState,
2652
- outputDir: string,
2653
- cardId: string,
2654
- style = '',
2655
- modelId?: string,
2656
- ): Promise<string> {
2657
- const card = (project.mangaRoles ?? []).find(c => c.id === cardId)
2658
- if (card === undefined) throw new Error(`漫剧角色卡 ${cardId} 不存在`)
2659
- if (card.imagePrompt === undefined) throw new Error(`「${card.name}」还没有形象锚点,请先生成锚点`)
2660
- const dataUrl = await generateReferenceImageFrom(config, card.imagePrompt, style, modelId)
2661
- card.imageUrl = dataUrl
2662
- card.updatedAt = new Date().toISOString()
2663
- return dataUrl
2664
- }
2665
-
2666
- /** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
2303
+ /** 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
2667
2304
  export async function analyzePlotlineHealth(
2668
2305
  ctx: Context,
2669
2306
  config: NovelConfig,
@@ -2722,7 +2359,7 @@ export async function analyzePlotlineHealth(
2722
2359
  }
2723
2360
  }
2724
2361
 
2725
- /** ✨ AI 剧情方案:基于健康检查结果设计下一阶段方向与建议新线。 */
2362
+ /** 剧情方案:基于健康检查结果设计下一阶段方向与建议新线。 */
2726
2363
  export async function designPlotlinePlan(
2727
2364
  ctx: Context,
2728
2365
  config: NovelConfig,
@@ -2782,8 +2419,9 @@ export async function designPlotlinePlan(
2782
2419
  }
2783
2420
 
2784
2421
  /** Build the rewrite system prompt (fix review issues / instructions). */
2785
- function rewriteSystemPrompt(project: ProjectState): string {
2786
- const base = writeSystemPrompt(project)
2422
+ function rewriteSystemPrompt(project: ProjectState, targetChars?: number, beforeChapter?: number): string {
2423
+ // 整章修订以「与原文相当」为准,不套用目标字数区间(避免与原文长度冲突)。
2424
+ const base = writeSystemPrompt(project, targetChars, '1. 输出完整的新正文(不要只输出修改片段、标题、章回名、作者的话或任何 Markdown 标记),字数与原章相当(允许 ±20%)。', beforeChapter)
2787
2425
  return base + '\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。'
2788
2426
  }
2789
2427
 
@@ -2803,9 +2441,9 @@ export async function* rewriteChapterStream(
2803
2441
  target?: string,
2804
2442
  ): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'drafted'; chars: number; draft: string }, void, unknown> {
2805
2443
  const chapter = project.chapters.find(c => c.no === chapterNo)
2806
- if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
2444
+ if (chapter === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 不在计划中`)
2807
2445
  const body = readChapterFile(outputDir, chapter)
2808
- if (body === undefined) throw new Error(`章节 ${chapterNo} 的正文文件不存在`)
2446
+ if (body === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 的正文文件不存在`)
2809
2447
 
2810
2448
  const reviewBlock = chapter.review !== undefined
2811
2449
  ? '审稿意见:\n' + chapter.review.issues.map(i => `[${i.severity}] ${i.item} → ${i.suggestion}`).join('\n')
@@ -2824,7 +2462,7 @@ export async function* rewriteChapterStream(
2824
2462
  const paragraphs = bodyText.split(/\n{2,}/)
2825
2463
  const idx = paragraphs.findIndex(p => normalize(p).includes(wantedFlat))
2826
2464
  if (idx === -1) {
2827
- throw new Error(`在正文中未找到要修改的片段:「${wanted.slice(0, 40)}…」。请从正文中复制原文片段(无需整段,取片段即可)。`)
2465
+ throw new NovelActionError('contract', `在正文中未找到要修改的片段:「${wanted.slice(0, 40)}…」。请从正文中复制原文片段(无需整段,取片段即可)。`)
2828
2466
  }
2829
2467
  localTarget = {
2830
2468
  paragraph: paragraphs[idx]!,
@@ -2854,8 +2492,33 @@ export async function* rewriteChapterStream(
2854
2492
  ].filter(line => line !== '').join('\n')
2855
2493
 
2856
2494
  const system = localTarget === undefined
2857
- ? rewriteSystemPrompt(project)
2858
- : '你是一位中文网文润色师。你会收到一章中的一个段落,请按修改要求重写该段。只输出新段落文本。'
2495
+ ? rewriteSystemPrompt(project, chapter.targetChars || config.chapterChars, chapter.no)
2496
+ : (() => {
2497
+ // 局部修订:补齐合规红线/本书红线/反AI规则/角色卡,加「只改表达不改情节」约束
2498
+ const bible = project.bible
2499
+ const lines = [
2500
+ '你是一位中文网文润色师。你会收到一章中的一个段落,请按修改要求重写该段。',
2501
+ '硬性约束:',
2502
+ '1. 只改表达,不改情节走向、人物设定、已确立事实、对话核心内容。',
2503
+ '2. 必须遵守下方「内容合规红线」,任何一条命中(含影射、暗示)都必须避免。',
2504
+ '3. 必须遵守下方「本书红线」(如有)。',
2505
+ '4. 避免 AI 套话:不禁、仿佛、一时间、不由得、顿时、然而、缓缓、轻轻、微微、默默、似乎、终于等滥用。',
2506
+ '5. 保留角色口吻与性格,角色行为需符合下方角色卡(如有)。',
2507
+ '6. 只输出修改后的【这一个段落】的完整新文本,不要输出任何说明、标题或 Markdown 标记。',
2508
+ ]
2509
+ if (bible !== undefined) {
2510
+ if (bible.redLines.length > 0) lines.push('本书红线:\n' + bible.redLines.map(r => '- ' + r).join('\n'))
2511
+ if (bible.characters.length > 0) {
2512
+ lines.push('相关角色卡:')
2513
+ for (const card of bible.characters) {
2514
+ lines.push('- ' + card.name + '(' + card.role + '):' + card.traits.join('、'))
2515
+ }
2516
+ }
2517
+ }
2518
+ lines.push('内容合规红线(平台硬性要求,最高优先级):')
2519
+ lines.push(COMPLIANCE_REDLINES.join('\n'))
2520
+ return lines.join('\n')
2521
+ })()
2859
2522
 
2860
2523
  const messages: Message[] = [createUserMessage({
2861
2524
  content: [{ type: 'text', text: user }],
@@ -2874,24 +2537,43 @@ export async function* rewriteChapterStream(
2874
2537
  }
2875
2538
 
2876
2539
  yield { frame: 'start' }
2877
- const assembler = new BlockAssembler()
2540
+ const primaryModel = request.model ?? config.model
2541
+ let rewritten = ''
2878
2542
  let streamError: Error | undefined
2879
- for await (const chunk of ctx.llm.stream(request)) {
2880
- assembler.push(chunk)
2881
- if (chunk.type === 'text-delta') yield { frame: 'delta', text: chunk.text }
2882
- }
2883
- const finish = assembler.finish
2884
- if (finish.kind === 'error' || finish.kind === 'aborted') {
2885
- streamError = new Error(`修订失败(${finish.kind}): ${finish.failure.message}`)
2886
- } else if (finish.kind === 'max-tokens') {
2887
- streamError = new Error('修订输出达到 maxTokens 上限,请增大配置后重试')
2888
- }
2889
- const rewritten = assembler
2890
- .blocks()
2891
- .filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
2892
- .map(block => block.text)
2893
- .join('\n')
2894
- .trim()
2543
+ for (let attempt = 0; attempt < 2; attempt++) {
2544
+ const model = attempt === 0 ? primaryModel : (config.fallbackModel ?? '').trim()
2545
+ if (model === '') break
2546
+ const live = beginLiveCall({
2547
+ label: `修订 · 第${chapterNo}章`,
2548
+ model,
2549
+ system: request.system,
2550
+ user: messages[0]?.content !== undefined ? JSON.stringify(messages[0].content).slice(0, 2000) : undefined,
2551
+ })
2552
+ const assembler = new BlockAssembler()
2553
+ let produced = 0
2554
+ for await (const chunk of ctx.llm.stream(applyModelCapability(request, model))) {
2555
+ assembler.push(chunk)
2556
+ if (chunk.type === 'text-delta') { produced += chunk.text.length; markFirstToken(live); yield { frame: 'delta', text: chunk.text } }
2557
+ }
2558
+ const finish = assembler.finish
2559
+ streamError = undefined
2560
+ if (finish.kind === 'error' || finish.kind === 'aborted') {
2561
+ streamError = new Error(`修订失败(${finish.kind}): ${finish.failure.message}`)
2562
+ } else if (finish.kind === 'max-tokens') {
2563
+ streamError = new Error('修订输出达到 maxTokens 上限,请增大配置后重试')
2564
+ }
2565
+ rewritten = assembler
2566
+ .blocks()
2567
+ .filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
2568
+ .map(block => block.text)
2569
+ .join('\n')
2570
+ .trim()
2571
+ endLiveCall(live, assembler, streamError === undefined
2572
+ ? { chars: rewritten.length, preview: rewritten.slice(0, 320), phase: rewritten === '' ? 'failed' : 'completed' }
2573
+ : { chars: rewritten.length, phase: 'failed', error: streamError.message })
2574
+ if (streamError === undefined) break
2575
+ if (produced > 0 || !shouldSwitchModel(config.fallbackModel, model, streamError)) break
2576
+ }
2895
2577
  if (streamError !== undefined) throw streamError
2896
2578
  if (rewritten.length < 20) throw new Error('修订结果过短,可能失败,请重试')
2897
2579
 
@@ -2917,16 +2599,24 @@ export async function* rewriteChapterStream(
2917
2599
  /** The de-AI-ify polish system prompt (with project writing assets injected). */
2918
2600
  function polishSystemPrompt(project: ProjectState): string {
2919
2601
  const assetsBlock = renderAllAssets(project.assets)
2920
- return [
2602
+ const bible = project.bible
2603
+ const lines = [
2921
2604
  '你是一位中文网文润色师。你会收到一章正文,请做「去 AI 味」润色:',
2922
2605
  '1. 删除/替换 AI 高频套话与模式词:如"不禁""仿佛""一时间""不由得""顿时""然而""缓缓""轻轻""微微""默默""似乎""终于"等滥用。',
2923
2606
  '2. 把书面翻译腔改成口语化的中文网文语感。',
2924
2607
  '3. 拆分过长的排比句与堆砌的修饰语。',
2925
- '4. 保留全部情节、人物、对话内容不变,只改表达。',
2608
+ '4. 保留全部情节、人物、对话内容、已确立事实不变,只改表达。',
2926
2609
  '5. 输出完整的新正文,不要输出任何说明文字或 Markdown 标记。',
2927
2610
  '6. 必须遵守下方「反 AI 规则」与「写法资产」的表达边界;写法资产要求保留的风格特征(句式、台词、节奏)不得在润色中丢失。',
2928
- assetsBlock !== '' ? assetsBlock : '',
2929
- ].join('\n')
2611
+ '7. 必须遵守下方「内容合规红线」与「本书红线」(如有),任何一条命中(含影射、暗示)都必须避免。',
2612
+ ]
2613
+ if (bible !== undefined && bible.redLines.length > 0) {
2614
+ lines.push('本书红线:\n' + bible.redLines.map(r => '- ' + r).join('\n'))
2615
+ }
2616
+ lines.push('内容合规红线(平台硬性要求,最高优先级):')
2617
+ lines.push(COMPLIANCE_REDLINES.join('\n'))
2618
+ if (assetsBlock !== '') lines.push(assetsBlock)
2619
+ return lines.join('\n')
2930
2620
  }
2931
2621
 
2932
2622
  /** Stream a chapter polish (de-AI-ify). Draft-mode: the polished body lands
@@ -2939,9 +2629,9 @@ export async function* polishChapterStream(
2939
2629
  chapterNo: number,
2940
2630
  ): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'drafted'; chars: number; draft: string }, void, unknown> {
2941
2631
  const chapter = project.chapters.find(c => c.no === chapterNo)
2942
- if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
2632
+ if (chapter === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 不在计划中`)
2943
2633
  const body = readChapterFile(outputDir, chapter)
2944
- if (body === undefined) throw new Error(`章节 ${chapterNo} 的正文文件不存在`)
2634
+ if (body === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 的正文文件不存在`)
2945
2635
  const messages: Message[] = [createUserMessage({
2946
2636
  content: [{ type: 'text', text: body.replace(/^#\s+.*$/m, '').trim() }],
2947
2637
  source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
@@ -2958,24 +2648,43 @@ export async function* polishChapterStream(
2958
2648
  reasoningEffort: ReasoningEffortId('off'),
2959
2649
  }
2960
2650
  yield { frame: 'start' }
2961
- const assembler = new BlockAssembler()
2651
+ const primaryModel = request.model ?? config.model
2652
+ let newBody = ''
2962
2653
  let streamError: Error | undefined
2963
- for await (const chunk of ctx.llm.stream(request)) {
2964
- assembler.push(chunk)
2965
- if (chunk.type === 'text-delta') yield { frame: 'delta', text: chunk.text }
2966
- }
2967
- const finish = assembler.finish
2968
- if (finish.kind === 'error' || finish.kind === 'aborted') {
2969
- streamError = new Error(`润色失败(${finish.kind}): ${finish.failure.message}`)
2970
- } else if (finish.kind === 'max-tokens') {
2971
- streamError = new Error('润色输出达到 maxTokens 上限')
2972
- }
2973
- const newBody = assembler
2974
- .blocks()
2975
- .filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
2976
- .map(block => block.text)
2977
- .join('\n')
2978
- .trim()
2654
+ for (let attempt = 0; attempt < 2; attempt++) {
2655
+ const model = attempt === 0 ? primaryModel : (config.fallbackModel ?? '').trim()
2656
+ if (model === '') break
2657
+ const live = beginLiveCall({
2658
+ label: `去 AI 味润色 · 第${chapterNo}章`,
2659
+ model,
2660
+ system: request.system,
2661
+ user: body.slice(0, 2000),
2662
+ })
2663
+ const assembler = new BlockAssembler()
2664
+ let produced = 0
2665
+ for await (const chunk of ctx.llm.stream(applyModelCapability(request, model))) {
2666
+ assembler.push(chunk)
2667
+ if (chunk.type === 'text-delta') { produced += chunk.text.length; markFirstToken(live); yield { frame: 'delta', text: chunk.text } }
2668
+ }
2669
+ const finish = assembler.finish
2670
+ streamError = undefined
2671
+ if (finish.kind === 'error' || finish.kind === 'aborted') {
2672
+ streamError = new Error(`润色失败(${finish.kind}): ${finish.failure.message}`)
2673
+ } else if (finish.kind === 'max-tokens') {
2674
+ streamError = new Error('润色输出达到 maxTokens 上限')
2675
+ }
2676
+ newBody = assembler
2677
+ .blocks()
2678
+ .filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
2679
+ .map(block => block.text)
2680
+ .join('\n')
2681
+ .trim()
2682
+ endLiveCall(live, assembler, streamError === undefined
2683
+ ? { chars: newBody.length, preview: newBody.slice(0, 320), phase: newBody === '' ? 'failed' : 'completed' }
2684
+ : { chars: newBody.length, phase: 'failed', error: streamError.message })
2685
+ if (streamError === undefined) break
2686
+ if (produced > 0 || !shouldSwitchModel(config.fallbackModel, model, streamError)) break
2687
+ }
2979
2688
  if (streamError !== undefined) throw streamError
2980
2689
  if (newBody.length < 100) throw new Error('润色结果过短,可能失败,请重试')
2981
2690
 
@@ -2986,6 +2695,12 @@ export async function* polishChapterStream(
2986
2695
  yield { frame: 'drafted', chars: newBody.length, draft: newBody }
2987
2696
  }
2988
2697
 
2698
+ /** 统计中文字符数(汉字),与「每章字数」红线口径一致(不含标点/换行)。 */
2699
+ export function countHanzi(text: string): number {
2700
+ const m = text.match(/[\u4e00-\u9fff]/g)
2701
+ return m === null ? 0 : m.length
2702
+ }
2703
+
2989
2704
  /** Generate one chapter (streaming). Yields progress frames; persists when done. */
2990
2705
  export async function* generateChapterStream(
2991
2706
  ctx: Context,
@@ -2993,9 +2708,9 @@ export async function* generateChapterStream(
2993
2708
  project: ProjectState,
2994
2709
  outputDir: string,
2995
2710
  chapterNo: number,
2996
- ): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'done'; file: string; chars: number }, void, unknown> {
2711
+ ): AsyncGenerator<{ frame: 'start' } | { frame: 'delta'; text: string } | { frame: 'done'; file: string; chars: number; warn?: string }, void, unknown> {
2997
2712
  const chapter = project.chapters.find(c => c.no === chapterNo)
2998
- if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
2713
+ if (chapter === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 不在计划中`)
2999
2714
  // Note: the route layer owns the 'generating' status + concurrency guard;
3000
2715
  // this function must not refuse when status is 'generating' (the route sets
3001
2716
  // it before calling us).
@@ -3055,10 +2770,22 @@ export async function* generateChapterStream(
3055
2770
  .filter(f => f.status === 'planned' && f.targetChapter !== undefined && f.targetChapter > 0)
3056
2771
  .filter(f => Math.abs((f.targetChapter as number) - chapterNo) <= 12)
3057
2772
  .map(f => `- ${f.description.slice(0, 120)}${f.targetChapter !== undefined ? `(计划回收于第 ${f.targetChapter} 章)` : ''}`)
3058
-
2773
+ const contractBlock = (() => {
2774
+ const parts: string[] = []
2775
+ if ((chapter.mustAdvance?.length ?? 0) > 0) parts.push(`本章必达(必须推进):${chapter.mustAdvance!.join(';')}`)
2776
+ if ((chapter.mustPreserve?.length ?? 0) > 0) parts.push(`本章保持(不得破坏):${chapter.mustPreserve!.join(';')}`)
2777
+ if ((chapter.characterHardFacts?.length ?? 0) > 0) parts.push(`人物硬事实(不得违背):${chapter.characterHardFacts!.join(';')}`)
2778
+ if ((chapter.payoffDirectives?.length ?? 0) > 0) parts.push(`伏笔指令:${chapter.payoffDirectives!.map(p => `${p.operation ?? 'touch'}${p.no !== undefined ? `(第${p.no}章)` : ''}${p.text !== undefined && p.text !== '' ? ':' + p.text : ''}`).join(';')}`)
2779
+ if (chapter.endingHook !== undefined && chapter.endingHook !== '') parts.push(`章末钩子要求:${chapter.endingHook}`)
2780
+ if (chapter.obligation !== undefined && chapter.obligation !== '') parts.push(`本章义务合约:${chapter.obligation}`)
2781
+ return parts.length > 0 ? '==================== 本章合同(硬约束,必须满足) ====================\n' + parts.join('\n') : ''
2782
+ })()
2783
+ const knowledgeBlock = retrieveKnowledge(project, `${chapter.title} ${chapter.beats}`)
3059
2784
  const user = [
3060
2785
  `现在写第 ${chapter.no} 章,标题《${chapter.title}》。`,
3061
2786
  `本章剧情要点:${chapter.beats}`,
2787
+ contractBlock,
2788
+ knowledgeBlock,
3062
2789
  '',
3063
2790
  foreshadowHints.length > 0
3064
2791
  ? `本章附近需顺势埋下以下暗线(自然带过,不喧宾夺主,1-2 句即可,但细节要可辨识、与描述吻合):\n${foreshadowHints.join('\n')}`
@@ -3081,446 +2808,140 @@ export async function* generateChapterStream(
3081
2808
 
3082
2809
  const messages: Message[] = [createUserMessage({
3083
2810
  content: [{ type: 'text', text: user }],
3084
- source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
3085
- })]
3086
- const request: GenerateOptions = {
3087
- provider: config.provider,
3088
- model: config.model,
3089
- messages,
3090
- system: writeSystemPrompt(project),
3091
- // Full-chapter output: budget generously (4000 chars ≈ 8-12k tokens,
3092
- // plus the model's reasoning channel).
3093
- maxTokens: Math.max(config.maxTokens, 20000),
3094
- temperature: 0.85,
3095
- }
3096
-
3097
- yield { frame: 'start' }
3098
-
3099
- const assembler = new BlockAssembler()
3100
- let streamError: Error | undefined
3101
- for await (const chunk of ctx.llm.stream(request)) {
3102
- assembler.push(chunk)
3103
- if (chunk.type === 'text-delta') {
3104
- yield { frame: 'delta', text: chunk.text }
3105
- }
3106
- }
3107
- const finish = assembler.finish
3108
- if (finish.kind === 'error' || finish.kind === 'aborted') {
3109
- streamError = new Error(`生成失败(${finish.kind}): ${finish.failure.message}`)
3110
- } else if (finish.kind === 'max-tokens') {
3111
- streamError = new Error('达到 maxTokens 上限,正文可能不完整,请增大 maxTokens 后重试')
3112
- }
3113
- const body = assembler
3114
- .blocks()
3115
- .filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
3116
- .map(block => block.text)
3117
- .join('\n')
3118
- .trim()
3119
- if (streamError !== undefined) throw streamError
3120
- if (body.length < 100) throw new Error('生成内容过短,可能失败,请重试')
3121
-
3122
- // Write the chapter file.
3123
- const fileName = chapterFileName(chapter)
3124
- mkdirSync(outputDir, { recursive: true })
3125
- writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${body}\n`, 'utf8')
3126
-
3127
- chapter.status = 'written'
3128
- chapter.chars = body.length
3129
- chapter.file = fileName
3130
- chapter.error = undefined
3131
- project.updatedAt = new Date().toISOString()
3132
- saveProject(outputDir, project)
3133
-
3134
- yield { frame: 'done', file: fileName, chars: body.length }
3135
- }
3136
-
3137
- /** Generate a chapter summary (narrative memory). */
3138
- export async function summarizeChapter(
3139
- ctx: Context,
3140
- config: NovelConfig,
3141
- project: ProjectState,
3142
- outputDir: string,
3143
- chapterNo: number,
3144
- ): Promise<string> {
3145
- const chapter = project.chapters.find(c => c.no === chapterNo)
3146
- if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
3147
- const body = readChapterFile(outputDir, chapter)
3148
- if (body === undefined) throw new Error(`章节 ${chapterNo} 的正文文件不存在`)
3149
- const system = [
3150
- '你是一位网文编辑。请为下面一章写一段 120-200 字的摘要,供后续章节写作时保持连贯性。',
3151
- '摘要必须包含:本章发生的关键事件、主角状态变化(境界/资源/伤势/心境)、新增的伏笔或线索、角色关系变化。',
3152
- '用客观陈述句,不要评价,不要剧透式感叹。只输出摘要正文。',
3153
- ].join('\n')
3154
- const user = body.replace(/^#\s+.*$/m, '').trim()
3155
- const summary = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000) })
3156
- chapter.summary = summary.slice(0, 500)
3157
- project.updatedAt = new Date().toISOString()
3158
- saveProject(outputDir, project)
3159
- return chapter.summary
3160
- }
3161
-
3162
- /** 把分镜产出写入项目持久化(按章 upsert)。重新生成上游时级联清掉下游旧产物:
3163
- * 新骨架 → 清分镜表+提示词;新分镜表 → 清提示词;新提示词不动上游。 */
3164
- function saveChapterStoryboard(project: ProjectState, outputDir: string, entry: ChapterStoryboard): void {
3165
- if (project.storyboards === undefined) project.storyboards = []
3166
- const idx = project.storyboards.findIndex(e => e.chapterNo === entry.chapterNo)
3167
- const prev = idx === -1 ? undefined : project.storyboards[idx]
3168
- const next: ChapterStoryboard = { ...(prev ?? {}), ...entry }
3169
- if (entry.skeleton !== undefined) {
3170
- next.table = undefined
3171
- next.prompts = undefined
3172
- }
3173
- if (entry.table !== undefined) {
3174
- next.prompts = undefined
3175
- }
3176
- if (idx === -1) project.storyboards.push(next)
3177
- else project.storyboards[idx] = next
3178
- project.updatedAt = new Date().toISOString()
3179
- saveProject(outputDir, project)
3180
- }
3181
-
3182
- /** 确定性兜底:正文中出现的角色库角色名(LLM 漏填 characters 时使用)。 */
3183
- function guessCharactersFromRoles(project: ProjectState, body: string, limit = 12): string[] {
3184
- const out: string[] = []
3185
- for (const r of project.roles ?? []) {
3186
- if (out.length >= limit) break
3187
- if (r.name !== '' && !out.includes(r.name) && body.includes(r.name)) out.push(r.name)
3188
- }
3189
- return out
3190
- }
3191
-
3192
- /** 清洗 LLM 输出的角色名数组:去空 / 去重 / 限长 / 限字数。 */
3193
- function sanitizeCharacters(raw: unknown, limit: number): string[] {
3194
- if (!Array.isArray(raw)) return []
3195
- const out: string[] = []
3196
- for (const v of raw) {
3197
- if (typeof v !== 'string') continue
3198
- const name = v.trim().slice(0, 20)
3199
- if (name === '' || out.includes(name)) continue
3200
- out.push(name)
3201
- if (out.length >= limit) break
3202
- }
3203
- return out
3204
- }
3205
-
3206
- /** 漫剧卡绑定索引:按分镜称谓(正文确切名)解析漫剧卡(卡名/来源名精确优先,包含兜底)。 */
3207
- interface MangaRoleBindingIndex {
3208
- byId: Map<string, MangaRoleCard>
3209
- resolve: (name: string) => MangaRoleCard | undefined
3210
- }
3211
- function buildMangaRoleBindings(project: ProjectState): MangaRoleBindingIndex {
3212
- const cards = project.mangaRoles ?? []
3213
- const byName = new Map<string, MangaRoleCard>()
3214
- for (const c of cards) {
3215
- if (c.name !== '') byName.set(c.name, c)
3216
- if (c.sourceRoleName !== undefined && c.sourceRoleName !== '') byName.set(c.sourceRoleName, c)
3217
- }
3218
- const byId = new Map<string, MangaRoleCard>(cards.map(c => [c.id, c]))
3219
- return {
3220
- byId,
3221
- resolve: (name: string): MangaRoleCard | undefined => {
3222
- const exact = byName.get(name)
3223
- if (exact !== undefined) return exact
3224
- for (const c of cards) {
3225
- if (c.name !== '' && (c.name.includes(name) || name.includes(c.name))) return c
3226
- if (c.sourceRoleName !== undefined && c.sourceRoleName !== '' && (c.sourceRoleName.includes(name) || name.includes(c.sourceRoleName))) return c
3227
- }
3228
- return undefined
3229
- },
3230
- }
3231
- }
3232
-
3233
- /**
3234
- * 分镜·导演级:剧情骨架 → 分镜表(镜头级)。
3235
- * 只做画面层:景别/机位运镜/时长/画面/台词/音效/光效 + 状态连续;禁止改剧情(骨架只读)。
3236
- */
3237
- export async function generateStoryboardTable(
3238
- ctx: Context,
3239
- config: NovelConfig,
3240
- project: ProjectState,
3241
- outputDir: string,
3242
- chapterNo: number,
3243
- skeleton: StoryboardSkeleton,
3244
- styleId?: string,
3245
- filterId?: string,
3246
- ): Promise<StoryboardTable> {
3247
- const chapter = project.chapters.find(c => c.no === chapterNo)
3248
- if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
3249
- const body = readChapterFile(outputDir, chapter)
3250
- if (body === undefined) throw new Error(`章节 ${chapterNo} 的正文文件不存在`)
3251
- if ((skeleton.beats ?? []).length === 0) throw new Error('骨架为空,请先生成剧情骨架')
3252
-
3253
-
3254
- // 出场角色锚点:正文中出现过的角色名 + 身份(简短)。
3255
- const roles = (project.roles ?? [])
3256
- .filter(r => body.includes(r.name))
3257
- .slice(0, 8)
3258
- .map(r => `${r.name}(${r.roleLabel === 'protagonist' ? '主角' : r.roleLabel === 'antagonist' ? '反派' : r.roleLabel === 'female_lead' ? '女主' : '配角'}):${r.identity ?? ''}`)
3259
- // 场景卡:仅列名称与定位(正文命中优先,至多 6 个)。
3260
- const usedScenes = (project.scenes ?? []).filter(s => body.includes(s.name)).slice(0, 6)
3261
- const scenes = usedScenes.map(s => `${s.name}:${s.summary ?? ''}`)
3262
- // 漫剧定妆卡:本章正文命中漫剧卡(卡名或来源名),注入定妆锚点供画面遵守。
3263
- const bindings = buildMangaRoleBindings(project)
3264
- const mangaCardsInBody = bindings.byId.size > 0
3265
- ? [...bindings.byId.values()].filter(c => body.includes(c.name) || (c.sourceRoleName !== undefined && body.includes(c.sourceRoleName))).slice(0, 8)
3266
- : []
3267
- const mangaLines = mangaCardsInBody.map(c => {
3268
- const anchor = c.imagePrompt !== undefined ? c.imagePrompt.zh.slice(0, 90) : (c.appearance !== '' ? c.appearance : '')
3269
- const ref = c.imageUrl !== undefined ? ';参考图:有' : (c.gallery ?? []).some(g => g.label.includes('立绘')) ? ';参考图:有(立绘)' : ''
3270
- return `${c.name}(${c.identity ?? ''}):定妆${anchor !== '' ? '「' + anchor + '」' : '(锚点未生成)'}${ref}`
3271
- })
3272
- const rules = (project.visualRules ?? []).map(r => '- ' + r)
2811
+ source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
2812
+ })]
2813
+ const request: GenerateOptions = {
2814
+ provider: config.provider,
2815
+ model: config.generateModel || config.model,
2816
+ messages,
2817
+ system: writeSystemPrompt(project, chapter.targetChars || config.chapterChars, undefined, chapter.no),
2818
+ // Full-chapter output: budget generously (4000 chars ≈ 8-12k tokens,
2819
+ // plus the model's reasoning channel).
2820
+ maxTokens: Math.max(config.maxTokens, 20000),
2821
+ temperature: 0.85,
2822
+ }
3273
2823
 
3274
- const baseStyle = styleId !== undefined ? findStyle(styleId) : undefined
3275
- const filterStyle = filterId !== undefined ? findStyle(filterId) : undefined
3276
- const styleLines: string[] = []
3277
- if (baseStyle !== undefined) styleLines.push('- 基底风格「' + baseStyle.name + '」:' + baseStyle.keywords)
3278
- if (filterStyle !== undefined) styleLines.push('- 叠加滤镜「' + filterStyle.name + '」:' + filterStyle.keywords)
3279
- if (styleLines.length === 0) styleLines.push('- (未选择风格,默认 3D 动漫超精细建模质感)')
2824
+ yield { frame: 'start' }
3280
2825
 
3281
- const system = [
3282
- '你是一位从业 10 年的电影导演兼分镜师,专长网文改编影视化。',
3283
- '任务:把「剧情骨架」的每一个节拍展开为 1-3 个电影镜头,输出分镜表——只做画面层,禁止新增或改变剧情(骨架是只读输入)。',
3284
- '输出合法 JSON 对象:{"shots": [{"beatId": "骨架节拍id", "shot": "景别", "camera": "机位与运镜", "composition": "构图", "duration": 秒数, "visual": "画面内容", "line": "台词/旁白", "sound": "音效", "light": "光效", "prevState": "承接上一镜头结尾状态", "nextState": "本镜头结束状态", "characters": ["出镜角色名"]}]}',
3285
- '景别取值(只能从这些词中选一):大远景/远景/全景/中景/中近景/近景/特写/大特写。',
3286
- '运镜取值(可组合,用加号连接):固定机位/推近/拉远/左摇/右摇/左横移/右横移/跟随/升镜/降镜/环绕/手持晃动/低机位仰拍/高机位俯拍/过肩镜头。',
3287
- '光效取值(可组合,用加号连接):顺光/侧光/逆光/顶光/伦勃朗光/霓虹光/硬光/柔光/氛围光/高反差。',
3288
- '构图取值(可选,只能从这些词中选一):三分法/中心对称/引导线/前景遮挡/低机位/俯拍/对称构图。',
3289
- '硬性要求:',
3290
- '【视觉风格】(必须内嵌进 visual/light 的画面措辞与光效描述):',
3291
- ...styleLines,
3292
- '1. 按骨架节拍顺序输出镜头,每个节拍至少 1 个镜头,总镜头数 8-25。',
3293
- '2. 镜头间连续:下一镜头的 prevState 必须与上一镜头的 nextState 一致(人物位置/动作/情绪/服装),禁止瞬移、服装消失、情绪跳变。',
3294
- '3. visual 必须写明:角色动作 + 表情 + 服装/标志物(标志物来自下方视觉规则与角色锚点,逐镜头保持)。',
3295
- '4. 台词/音效/光效无则空字符串,不要编造。',
3296
- '5. duration 1-12 秒。',
3297
- '6. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束,不要输出其他文字。',
3298
- '7. 每个镜头必须输出 characters:本镜头出镜角色名数组(1-4 个),从「出场角色锚点」或正文中选取,使用正文里的确切称谓(如「周野」「周野的律师」),禁止自造名/缩写;路人或群像可用身份词(如「围观群众」)。',
3299
- '8. 人物外观/服装/标志物必须遵守「漫剧定妆卡」与「出场角色锚点」:同一角色逐镜头保持完全一致(服装、发色、标志物禁止更换);定妆卡未覆盖的路人可用通用描述。',
3300
- ].join('\n')
3301
- const user = [
3302
- `章节《${chapter.title}》(第 ${chapter.no} 章)`,
3303
- '==== 剧情骨架(只读,禁止改动) ====',
3304
- `弧线:${skeleton.arc}`,
3305
- skeleton.beats.map(b => `[${b.id}] [${b.function}] ${b.event}(情绪:${b.emotion}${b.cause !== undefined ? ';承接:' + b.cause : ''})`).join('\n'),
3306
- roles.length > 0 ? '==== 出场角色锚点 ====\n' + roles.join('\n') : '',
3307
- mangaLines.length > 0 ? '==== 漫剧定妆卡(角色外观以此为准,禁止改换服装/发色/标志物) ====\n' + mangaLines.join('\n') : '',
3308
- scenes.length > 0 ? '==== 相关场景 ====\n' + scenes.join('\n') : '',
3309
- rules.length > 0 ? '==== 本书视觉规则(必须内嵌进 visual) ====\n' + rules.join('\n') : '',
3310
- '==== 章节正文(画面细节以此为准) ====',
3311
- body.replace(/^#\s+.*$/m, '').trim().slice(0, 3000),
3312
- ].filter(s => s !== '').join('\n\n')
3313
- const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 16000), reasoning: config.analysisReasoning ?? 'low' })
3314
- const raw = parseJsonObject<{ shots?: unknown }>(text)
3315
- const beatIds = new Set(skeleton.beats.map(b => b.id))
3316
- const shots: StoryboardShot[] = Array.isArray(raw.shots)
3317
- ? raw.shots
3318
- .filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
3319
- .map((entry, i) => ({
3320
- id: 's' + (i + 1),
3321
- beatId: beatIds.has(entry.beatId as string) ? entry.beatId as string : skeleton.beats[0]!.id,
3322
- shot: normalizeShotSize(entry.shot as string | undefined),
3323
- camera: normalizeCameras(entry.camera as string | undefined),
3324
- composition: normalizeComposition(entry.composition as string | undefined),
3325
- duration: typeof entry.duration === 'number' && entry.duration >= 1 && entry.duration <= 12 ? Math.round(entry.duration) : 4,
3326
- visual: typeof entry.visual === 'string' ? entry.visual.trim().slice(0, 300) : '',
3327
- line: typeof entry.line === 'string' ? entry.line.trim().slice(0, 120) : '',
3328
- sound: typeof entry.sound === 'string' ? entry.sound.trim().slice(0, 80) : '',
3329
- light: normalizeLightings(entry.light as string | undefined),
3330
- prevState: typeof entry.prevState === 'string' ? entry.prevState.trim().slice(0, 150) : '',
3331
- nextState: typeof entry.nextState === 'string' ? entry.nextState.trim().slice(0, 150) : '',
3332
- characters: sanitizeCharacters(entry.characters, 4),
3333
- }))
3334
- .filter(s => s.visual !== '')
3335
- : []
3336
- if (shots.length === 0) throw new Error('模型未输出有效镜头,请重试')
3337
- // 覆盖自检:每个节拍至少 1 个镜头(缺失节拍告警)。
3338
- const covered = new Set(shots.map(s => s.beatId))
3339
- const missing = skeleton.beats.filter(b => !covered.has(b.id)).map(b => b.id)
3340
- if (missing.length > 0) {
3341
- console.warn(`[dsh-novel-forge] storyboard: beat ${missing.join(',')} 无镜头覆盖`)
3342
- }
3343
- // 表级 characters:各镜头去重汇总;LLM 漏标时用骨架/角色库兜底。
3344
- const tableChars: string[] = []
3345
- const tableRoleIds: string[] = []
3346
- for (const s of shots) {
3347
- // 定妆绑定:镜头 characters 称谓 → 漫剧卡 id(供出图/生视频时按卡取定妆图)。
3348
- const ids: string[] = []
3349
- for (const n of s.characters ?? []) {
3350
- const card = bindings.resolve(n)
3351
- if (card !== undefined && !ids.includes(card.id)) ids.push(card.id)
3352
- }
3353
- s.mangaRoleIds = ids.length > 0 ? ids : undefined
3354
- for (const c of s.characters ?? []) {
3355
- if (!tableChars.includes(c)) tableChars.push(c)
3356
- if (tableChars.length >= 12) break
2826
+ const primaryModel = request.model ?? config.model
2827
+ let body = ''
2828
+ let streamError: Error | undefined
2829
+ // 最多两次尝试:主模型 → (未产出任何文字且失败可重试时)备用模型。
2830
+ for (let attempt = 0; attempt < 2; attempt++) {
2831
+ const model = attempt === 0 ? primaryModel : (config.fallbackModel ?? '').trim()
2832
+ if (model === '') break
2833
+ const live = beginLiveCall({
2834
+ label: `正文生成 · 第${chapter.no}章`,
2835
+ model,
2836
+ system: request.system,
2837
+ user,
2838
+ })
2839
+ const assembler = new BlockAssembler()
2840
+ let produced = 0
2841
+ for await (const chunk of ctx.llm.stream(applyModelCapability(request, model))) {
2842
+ assembler.push(chunk)
2843
+ if (chunk.type === 'text-delta') {
2844
+ produced += chunk.text.length
2845
+ markFirstToken(live)
2846
+ yield { frame: 'delta', text: chunk.text }
2847
+ }
3357
2848
  }
3358
- for (const id of ids) {
3359
- if (!tableRoleIds.includes(id)) tableRoleIds.push(id)
2849
+ const finish = assembler.finish
2850
+ streamError = undefined
2851
+ if (finish.kind === 'error' || finish.kind === 'aborted') {
2852
+ streamError = new Error(`生成失败(${finish.kind}): ${finish.failure.message}`)
2853
+ } else if (finish.kind === 'max-tokens') {
2854
+ streamError = new Error('达到 maxTokens 上限,正文可能不完整,请增大 maxTokens 后重试')
3360
2855
  }
2856
+ body = assembler
2857
+ .blocks()
2858
+ .filter((block): block is Extract<StreamChunk, { type: 'block-end' }>['block'] & { type: 'text' } => block.type === 'text')
2859
+ .map(block => block.text)
2860
+ .join('\n')
2861
+ .trim()
2862
+ endLiveCall(live, assembler, streamError === undefined
2863
+ ? { chars: body.length, preview: body.slice(0, 320), phase: body === '' ? 'failed' : 'completed' }
2864
+ : { chars: body.length, phase: 'failed', error: streamError.message })
2865
+ if (streamError === undefined) break
2866
+ // 只有「一个字都没产出」时才换模型重试——已有正文绝不能重复生成。
2867
+ if (produced > 0 || !shouldSwitchModel(config.fallbackModel, model, streamError)) break
3361
2868
  }
3362
- const fallbackChars = (skeleton.characters ?? guessCharactersFromRoles(project, body, 12)).slice(0, 12)
3363
- const table: StoryboardTable = {
3364
- chapterNo,
3365
- shots,
3366
- usedScenes: usedScenes.map(s => s.name),
3367
- characters: tableChars.length > 0 ? tableChars : fallbackChars,
3368
- mangaRoleIds: tableRoleIds.length > 0 ? tableRoleIds : undefined,
2869
+ if (streamError !== undefined) throw streamError
2870
+ if (body.length < 100) throw new Error('生成内容过短,可能失败,请重试')
2871
+
2872
+ // 字数口径统一为「汉字数」(与每章字数红线一致)。不足目标 90% 时自动续写补齐(最多 2 轮)。
2873
+ const target = chapter.targetChars > 0 ? chapter.targetChars : config.chapterChars
2874
+ let hanzi = countHanzi(body)
2875
+ for (let round = 0; round < 2 && target > 0 && hanzi < target * 0.9; round++) {
2876
+ const need = Math.max(400, Math.round(target * 0.95) - hanzi)
2877
+ const contUser = [
2878
+ `第 ${chapter.no} 章《${chapter.title}》已写正文末尾如下:`,
2879
+ body.slice(-2500),
2880
+ '',
2881
+ `请承接上文继续写约 ${need} 字,不要重复已有内容,不要重写开头,不要总结前文,直接续写新情节,结尾留一个章末钩子。只输出续写的正文,不要输出标题或任何说明。`,
2882
+ ].join('\n')
2883
+ const cont = await complete(ctx, config, {
2884
+ system: writeSystemPrompt(project, target, undefined, chapter.no) + '\n\n【续写任务】严格承接已有正文往下写,不要重复已有内容、不要重写开头、不要总结前文,直接续写新情节,并在结尾留一个章末钩子。',
2885
+ user: contUser,
2886
+ temperature: 0.85,
2887
+ maxTokens: Math.max(config.maxTokens, 12000),
2888
+ model: config.generateModel || config.model,
2889
+ liveLabel: '续写补齐',
2890
+ })
2891
+ const add = cont.trim()
2892
+ if (add.length < 100) break
2893
+ body = `${body}\n\n${add}`
2894
+ hanzi = countHanzi(body)
3369
2895
  }
3370
- saveChapterStoryboard(project, outputDir, { chapterNo, table, updatedAt: new Date().toISOString() })
3371
- return table
3372
- }
3373
2896
 
3374
- /**
3375
- * 分镜·提示词级:分镜表 → 即梦可粘贴视频提示词。
3376
- * 每镜头一段:风格词块(基底+滤镜)+ 画面内容(角色动作/服装标志物)+ 机位运镜 + 光效。
3377
- * 提示词聚焦画面与镜头(视频模型无音频,台词/音效不注入)。
3378
- */
3379
- export async function generateStoryboardPrompts(
3380
- ctx: Context,
3381
- config: NovelConfig,
3382
- project: ProjectState,
3383
- outputDir: string,
3384
- chapterNo: number,
3385
- table: StoryboardTable,
3386
- styleId?: string,
3387
- filterId?: string,
3388
- ): Promise<StoryboardPrompt[]> {
3389
- if ((table.shots ?? []).length === 0) throw new Error('分镜表为空,请先生成分镜表')
3390
- const chapter = project.chapters.find(c => c.no === chapterNo)
3391
- const baseStyle = styleId !== undefined ? findStyle(styleId) : undefined
3392
- const filterStyle = filterId !== undefined ? findStyle(filterId) : undefined
3393
- const stylePrefix = [
3394
- baseStyle?.keywords,
3395
- filterStyle?.keywords,
3396
- ].filter((v): v is string => v !== undefined && v !== '').join(',') || '3D动漫,超精细建模,电影光影'
3397
- const rules = (project.visualRules ?? []).map(r => '- ' + r)
3398
- const bindings = buildMangaRoleBindings(project)
3399
- const shotLines = table.shots.map(s => {
3400
- const bound = (s.characters ?? []).map(n => bindings.resolve(n)).filter((c): c is MangaRoleCard => c !== undefined)
3401
- const makeUp = bound.map(c => {
3402
- const anchor = c.imagePrompt !== undefined ? c.imagePrompt.zh.slice(0, 60) : (c.appearance !== '' ? c.appearance : '')
3403
- const ref = c.imageUrl !== undefined ? '(参考图)' : ''
3404
- return c.name + ':' + anchor + ref
3405
- }).join(';')
3406
- return `[${s.id}] 节拍${s.beatId} · ${sizeZh(s.shot)} · ${cameraZh(s.camera)} · ${s.duration}s
3407
- 出场:${s.characters !== undefined && s.characters.length > 0 ? s.characters.join('、') : '(未标注)'}
3408
- 定妆:${makeUp !== '' ? makeUp : '(未绑定漫剧卡)'}
3409
- 画面:${s.visual}
3410
- 台词:${s.line !== '' ? s.line : '(无)'}
3411
- 光效:${lightZh(s.light) !== '' ? lightZh(s.light) : '(无)'}
3412
- 承接:${s.prevState} → ${s.nextState}`
3413
- }).join('\n\n')
3414
- const system = [
3415
- '你是一位资深影视分镜提示词工程师,熟悉即梦 AI / Seedance 等视频生成模型的中文提示词写法。',
3416
- '任务:把分镜表的每个镜头写成一段可直接粘贴进视频生成工具的提示词。',
3417
- '提示词结构(按顺序):风格词块 → 画面主体(人物动作+表情+服装标志物)→ 机位运镜(含时长感)→ 光效氛围。单段中文 60-140 字,逗号分隔,流畅自然。',
3418
- '风格词块(必须原样放在每段开头):' + stylePrefix,
3419
- '重要:每段提示词开头必须原样嵌入风格词块原文,不得省略、不得改写。',
3420
- '硬性要求:',
3421
- '1. 只写画面与镜头,禁止写音频/台词配音说明(视频模型无音频)。',
3422
- '2. 服装/发色/标志物必须沿用镜头表、定妆卡与视觉规则,禁止自行更换(同一角色跨镜头保持一致)。',
3423
- '3. 运镜用通俗电影词(推近/拉远/跟随/低机位仰拍/过肩/手持晃动/固定机位),不要用分镜术语缩写。',
3424
- '4. 输出合法 JSON 对象:{"prompts": [{"shotId": "s1", "text": "..."}]},所有镜头都要有,顺序一致。',
3425
- '5. 字符串内不得包含换行符,JSON 必须在一段内完整结束。',
3426
- ].join('\n')
3427
- const user = [
3428
- `章节《${chapter?.title ?? ''}》(第 ${chapterNo} 章)`,
3429
- rules.length > 0 ? '本书视觉规则(必须遵守):\n' + rules.join('\n') : '',
3430
- '==== 分镜表 ====',
3431
- shotLines,
3432
- '只输出 JSON 对象。',
3433
- ].filter(x => x !== '').join('\n\n')
3434
- const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 16000), reasoning: config.analysisReasoning ?? 'low' })
3435
- const raw = parseJsonObject<{ prompts?: unknown }>(text)
3436
- const shotIds = new Set(table.shots.map(s => s.id))
3437
- const shotBinding = new Map<string, string[]>(table.shots.map(s => [s.id, s.mangaRoleIds ?? []]))
3438
- const prompts: StoryboardPrompt[] = Array.isArray(raw.prompts)
3439
- ? raw.prompts
3440
- .filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
3441
- .map(entry => {
3442
- const shotId = shotIds.has(entry.shotId as string) ? entry.shotId as string : ''
3443
- const ids = shotBinding.get(shotId) ?? []
3444
- return {
3445
- shotId,
3446
- text: ensureStyleEmbedded(typeof entry.text === 'string' ? entry.text.trim().slice(0, 400) : '', stylePrefix, 'zh'),
3447
- mangaRoleIds: ids.length > 0 ? ids : undefined,
3448
- }
3449
- })
3450
- .filter(x => x.shotId !== '' && x.text !== '')
3451
- : []
3452
- if (prompts.length === 0) throw new Error('模型未输出有效提示词,请重试')
3453
- saveChapterStoryboard(project, outputDir, { chapterNo, prompts, updatedAt: new Date().toISOString() })
3454
- return prompts
3455
- }
2897
+ // 覆盖前留存历史版本:重新生成把好稿改坏时可以回滚(首次生成无旧稿,不产生空快照)。
2898
+ if (chapter.file !== undefined && chapter.file !== '') createSnapshot(outputDir, chapter, '重新生成前')
2899
+ // Write the chapter file.
2900
+ const fileName = chapterFileName(chapter)
2901
+ mkdirSync(outputDir, { recursive: true })
2902
+ writeFileSync(join(outputDir, fileName), `# 第${chapter.no}章 ${chapter.title}\n\n${body}\n`, 'utf8')
3456
2903
 
2904
+ chapter.status = 'written'
2905
+ chapter.chars = hanzi
2906
+ chapter.file = fileName
2907
+ chapter.error = undefined
2908
+ project.updatedAt = new Date().toISOString()
2909
+ saveProject(outputDir, project)
3457
2910
 
3458
- /**
3459
- * 分镜·编剧级:单章 → 剧情骨架(节拍链)。
3460
- * 只做故事层(事件/情绪/功能/因果),不做画面;导演级分镜在其上展开。
3461
- */
3462
- export async function generateStoryboardSkeleton(
2911
+ const warn = target > 0
2912
+ ? (hanzi < target * 0.85
2913
+ ? `第${chapter.no}章实际 ${hanzi} 汉字,少于目标 ${target} 汉字`
2914
+ : hanzi > target * 1.25
2915
+ ? `第${chapter.no}章实际 ${hanzi} 汉字,多于目标 ${target} 汉字`
2916
+ : undefined)
2917
+ : undefined
2918
+ yield { frame: 'done', file: fileName, chars: hanzi, warn }
2919
+ }
2920
+
2921
+ /** Generate a chapter summary (narrative memory). */
2922
+ export async function summarizeChapter(
3463
2923
  ctx: Context,
3464
2924
  config: NovelConfig,
3465
2925
  project: ProjectState,
3466
2926
  outputDir: string,
3467
2927
  chapterNo: number,
3468
- ): Promise<StoryboardSkeleton> {
2928
+ ): Promise<string> {
3469
2929
  const chapter = project.chapters.find(c => c.no === chapterNo)
3470
- if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
2930
+ if (chapter === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 不在计划中`)
3471
2931
  const body = readChapterFile(outputDir, chapter)
3472
- if (body === undefined) throw new Error(`章节 ${chapterNo} 的正文文件不存在`)
2932
+ if (body === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 的正文文件不存在`)
3473
2933
  const system = [
3474
- '你是一位从业 15 年的电影编剧,专长网文改编影视化,深谙三幕结构与节拍(beat)写作。',
3475
- '任务:把这一章改编成影视化「剧情骨架」——只做故事层,不做画面。',
3476
- '输出合法 JSON 对象:{"arc": "本章弧线一句话(起承转合,20-60字)", "beats": [{"event": "事件一句话(发生了什么)", "emotion": "人物情绪走向(情绪词数组)", "function": "铺垫|冲突|转折|高潮|收束|伏笔|人物塑造", "cause": "承接上一节拍的原因(可省略)"}], "characters": ["出镜角色名1", "出镜角色名2"]}',
3477
- '情绪词取值(从这些词中选 1-3 个,用数组输出,按情绪发展顺序):平静/淡然/期待/好奇/警觉/压抑/隐忍/担忧/焦躁/不安/惊惧/愤怒/崩溃/决绝/痛心/释然/悲凉/得意/重生/麻木。',
3478
- '功能取值(只能从这些词中选一):铺垫/冲突/转折/高潮/收束/伏笔/人物塑造。',
3479
- '硬性要求:',
3480
- '1. beats 数量 4-9 个,严格按时间顺序,因果链完整:前一个 beat 的结果是后一个 beat 的原因。',
3481
- '2. 必须覆盖本章全部剧情要点与正文关键事件,遗漏关键事件视为失败。',
3482
- '3. 只输出剧情骨架,禁止写画面描写、机位运镜、台词细节、音效(那是导演阶段的事)。',
3483
- '4. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
3484
- '5. 直接输出 JSON 本身,不要输出思考过程或其他文字。',
3485
- '6. 额外输出 characters:本章真正出镜(说话/行动/被正面描写)的角色名数组,全部使用正文中的确切称谓(如「周野」「周野的律师」,不要改名或拼接),去重,3-10 个;路人或群像可用身份词(如「围观群众」)。',
3486
- ].join('\n')
3487
- const user = [
3488
- `章节《${chapter.title}》(第 ${chapter.no} 章)`,
3489
- `剧情要点:${chapter.beats !== undefined && chapter.beats !== '' ? chapter.beats : '(未填写)'}`,
3490
- '==================== 章节正文 ====================',
3491
- body.replace(/^#\s+.*$/m, '').trim(),
2934
+ '你是一位网文编辑。请为下面一章写一段 120-200 字的摘要,供后续章节写作时保持连贯性。',
2935
+ '摘要必须包含:本章发生的关键事件、主角状态变化(境界/资源/伤势/心境)、新增的伏笔或线索、角色关系变化。',
2936
+ '用客观陈述句,不要评价,不要剧透式感叹。只输出摘要正文。',
3492
2937
  ].join('\n')
3493
- const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000), reasoning: config.analysisReasoning ?? 'low' })
3494
- const raw = parseJsonObject<{ arc?: unknown; beats?: unknown; characters?: unknown }>(text)
3495
- const arc = typeof raw.arc === 'string' ? raw.arc.trim().slice(0, 200) : ''
3496
- const beats: StoryboardBeat[] = Array.isArray(raw.beats)
3497
- ? raw.beats
3498
- .filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
3499
- .map((entry, i) => ({
3500
- id: 'b' + (i + 1),
3501
- event: typeof entry.event === 'string' ? entry.event.trim().slice(0, 200) : '',
3502
- emotion: Array.isArray(entry.emotion)
3503
- ? normalizeEmotions(entry.emotion.filter((x): x is string => typeof x === 'string').join('→'))
3504
- : normalizeEmotions(typeof entry.emotion === 'string' ? entry.emotion : undefined),
3505
- function: normalizeStoryFunction(typeof entry.function === 'string' ? entry.function : undefined),
3506
- cause: typeof entry.cause === 'string' && entry.cause.trim() !== '' ? entry.cause.trim().slice(0, 150) : undefined,
3507
- }))
3508
- .filter(b => b.event !== '')
3509
- : []
3510
- if (beats.length === 0) throw new Error('模型未输出有效节拍链,请重试')
3511
- // 出场角色:LLM 结构化输出优先,漏填时用角色库命中正文兜底。
3512
- const skeletonChars = sanitizeCharacters(raw.characters, 12)
3513
- const chars = skeletonChars.length > 0 ? skeletonChars : guessCharactersFromRoles(project, body, 12)
3514
- const skeleton: StoryboardSkeleton = {
3515
- chapterNo,
3516
- arc: arc !== '' ? arc : '(本章弧线未生成)',
3517
- beats,
3518
- characters: chars.length > 0 ? chars : undefined,
3519
- }
3520
- saveChapterStoryboard(project, outputDir, { chapterNo, skeleton, updatedAt: new Date().toISOString() })
3521
- return skeleton
2938
+ const user = body.replace(/^#\s+.*$/m, '').trim()
2939
+ const summary = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000) })
2940
+ chapter.summary = summary.slice(0, 500)
2941
+ project.updatedAt = new Date().toISOString()
2942
+ saveProject(outputDir, project)
2943
+ return chapter.summary
3522
2944
  }
3523
-
3524
2945
  /**
3525
2946
  * 反向推大纲:从已写章节正文反推出全书总纲(分卷 + 章节要点 + 主线/人物弧线/伏笔清单)。
3526
2947
  * 两阶段:分批提取章节事件摘要 → 汇总生成大纲。不修改章节/设定,只返回大纲文本。
@@ -3536,7 +2957,7 @@ export async function reverseOutlineFromChapters(
3536
2957
  .filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error')
3537
2958
  .filter(c => readChapterFile(outputDir, c) !== undefined)
3538
2959
  .sort((a, b) => a.no - b.no)
3539
- if (written.length === 0) throw new Error('本书还没有已写章节,无法反推大纲')
2960
+ if (written.length === 0) throw new NovelActionError('contract', '本书还没有已写章节,无法反推大纲')
3540
2961
 
3541
2962
  // 阶段 1:分批提取章节事件摘要(每批 10 章,正文各取前 1000 字控制成本)。
3542
2963
  const BATCH = 10
@@ -3597,7 +3018,7 @@ export async function analyzeAdaptation(
3597
3018
  const user = [
3598
3019
  '你是一位资深网文编辑兼改编策划。下面给你一部已完结/连载小说的若干章正文节选。请通读并输出该书的「原文设定卡片」与「可改范围矩阵」。',
3599
3020
  '输出合法 JSON 对象:',
3600
- '{"bookName": "书名", "outline": "一句话主线梗概(100字内)", "dimensions": [{"key":"realm","title":"大世界","mutability":"big|small|free|locked|visual","current":"当前值","evidence":"证据(出现章节/频次)","candidates":["候选1","候选2"],"impact":"改了会影响什么","risk":"high|medium|low"}]}',
3021
+ '{"bookName": "书名", "outline": "一句话主线梗概(100字内)", "dimensions": [{"key":"realm","title":"大世界","mutability":"big|small|free|locked|visual","current":"当前值","evidence":"证据(出现章节/频次)","candidates":[{"name":"候选体系名","desc":"该体系一句话说明"}],"impact":"改了会影响什么","risk":"high|medium|low"}]}',
3601
3022
  'dimensions 至少覆盖以下维度(key/title):realm 大世界、cultivation 修为体系、protagonist 主角、goldenFinger 金手指、supporting 配角与势力人物名、faction 势力/组织、style 文风与叙事、ending 结局走向、timeline 时间线/编年、foreshadow 伏笔/暗线。',
3602
3023
  'mutability 取值:locked=建议保留、big=可改影响大、small=可改影响小、free=可自由改、visual=仅视觉包装。',
3603
3024
  '每个 dimension.current 必须忠于文本,能引用原文就用原文(尤其角色名/境界名/势力名/金手指名)。',
@@ -3645,13 +3066,24 @@ function normalizeAdaptationDimension(d: Record<string, unknown>): AdaptationDim
3645
3066
  const risk = (['high', 'medium', 'low'] as const).includes(d.risk as AdaptationDimension['risk'])
3646
3067
  ? d.risk as AdaptationDimension['risk']
3647
3068
  : 'medium'
3069
+ const rawCands = Array.isArray(d.candidates) ? d.candidates : []
3070
+ const candidates = rawCands.map((x): { name: string; desc?: string } | null => {
3071
+ if (typeof x === 'string') return x.trim() !== '' ? { name: x.trim() } : null
3072
+ if (typeof x === 'object' && x !== null) {
3073
+ const o = x as { name?: unknown; desc?: unknown }
3074
+ const name = typeof o.name === 'string' ? o.name.trim() : ''
3075
+ const desc = typeof o.desc === 'string' ? o.desc.trim() : undefined
3076
+ return name !== '' ? { name, desc } : null
3077
+ }
3078
+ return null
3079
+ }).filter((x): x is { name: string; desc?: string } => x !== null)
3648
3080
  return {
3649
3081
  key,
3650
3082
  title,
3651
3083
  mutability,
3652
3084
  current: typeof d.current === 'string' ? d.current : '',
3653
3085
  evidence: typeof d.evidence === 'string' ? d.evidence : undefined,
3654
- candidates: Array.isArray(d.candidates) ? d.candidates.filter((x): x is string => typeof x === 'string') : [],
3086
+ candidates,
3655
3087
  impact: typeof d.impact === 'string' ? d.impact : '',
3656
3088
  risk,
3657
3089
  }
@@ -3691,7 +3123,7 @@ async function reverseOutlineFromAdaptationText(
3691
3123
  export async function proposeAdaptation(
3692
3124
  ctx: Context,
3693
3125
  config: NovelConfig,
3694
- text: string,
3126
+ _text: string,
3695
3127
  selections: Array<{ key: string; title: string; current: string; target: string; mutability: string }>,
3696
3128
  dimensions?: AdaptationDimension[],
3697
3129
  ): Promise<AdaptProposeResponse> {
@@ -3834,7 +3266,7 @@ export async function rewriteAdaptationBook(
3834
3266
  adaptedParts.push('# 第' + c.no + '章 ' + title + '\n\n' + c.body.trim() + '\n')
3835
3267
  continue
3836
3268
  }
3837
- const user = [
3269
+ const user = [
3838
3270
  mappingBlock,
3839
3271
  ruleBlock,
3840
3272
  '第 ' + c.no + ' 章《' + c.title + '》:',
@@ -3884,7 +3316,7 @@ export async function materializeAdaptedBook(
3884
3316
  ): Promise<Omit<AdaptMaterializeResponse, 'book'>> {
3885
3317
  const bookName = (args.bookName ?? '').trim().slice(0, 40) || '改编新书'
3886
3318
  const outDir = (args.outputDir ?? '').trim()
3887
- if (outDir === '') throw new Error('未指定新书输出目录')
3319
+ if (outDir === '') throw new NovelActionError('contract', '未指定新书输出目录')
3888
3320
  const mappings = args.proposal?.mappings ?? []
3889
3321
  // 源文导入临时项目(复用角色/道藏/世界提炼:这些函数读取 config.outputDir 下的章节文件)。
3890
3322
  const tmpDir = join(tmpdir(), 'dsh-novel-forge-adapt-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8))
@@ -3894,7 +3326,7 @@ export async function materializeAdaptedBook(
3894
3326
  if (sourceProject === undefined) throw new Error('临时项目创建失败')
3895
3327
  const sourceConfig: NovelConfig = { ...config, outputDir: tmpDir }
3896
3328
 
3897
- // 源书设定圣经 + 角色库 + 大世界。
3329
+ // 源书道藏 + 角色库 + 大世界。
3898
3330
  // 注意:extractWorld/extractRoles 内部读取 project.outline,所以要先把源文大纲写进临时项目。
3899
3331
  const srcOutline = (args.outline ?? '').trim() !== '' ? (args.outline ?? '').trim() : fallbackSourceOutline(sourceProject)
3900
3332
  sourceProject.outline = srcOutline
@@ -3959,7 +3391,7 @@ function applyAdaptationMappings(text: string, mappings: AdaptationMapping[]): s
3959
3391
  return applyAdaptationReplacements(text, mappings).adaptedText
3960
3392
  }
3961
3393
 
3962
- /** 改编设定圣经:把人名/术语/势力按映射表替换。 */
3394
+ /** 改编道藏:把人名/术语/势力按映射表替换。 */
3963
3395
  function applyMappingsToBible(bible: StoryBible, mappings: AdaptationMapping[]): StoryBible {
3964
3396
  const t = (s: string): string => applyAdaptationMappings(s, mappings)
3965
3397
  return {
@@ -3984,13 +3416,6 @@ function applyMappingsToRoles(roles: RoleRecord[], mappings: AdaptationMapping[]
3984
3416
  relations: role.relations.map(t),
3985
3417
  arc: role.arc.map(t),
3986
3418
  knowledge: role.knowledge.map(t),
3987
- imagePrompt: role.imagePrompt !== undefined
3988
- ? { ...role.imagePrompt, zh: t(role.imagePrompt.zh), en: t(role.imagePrompt.en), tags: role.imagePrompt.tags.map(t), source: role.imagePrompt.source !== undefined ? t(role.imagePrompt.source) : undefined }
3989
- : undefined,
3990
- expressions: role.expressions?.map(t),
3991
- promptKit: role.promptKit !== undefined
3992
- ? { ...role.promptKit, portrait: { ...role.promptKit.portrait, zh: t(role.promptKit.portrait.zh), en: t(role.promptKit.portrait.en) }, sheet: { ...role.promptKit.sheet, zh: t(role.promptKit.sheet.zh), en: t(role.promptKit.sheet.en) }, expressions: role.promptKit.expressions.map(e => ({ ...e, zh: t(e.zh), en: t(e.en) })), details: { ...role.promptKit.details, zh: t(role.promptKit.details.zh), en: t(role.promptKit.details.en) } }
3993
- : undefined,
3994
3419
  }))
3995
3420
  }
3996
3421
 
@@ -4015,6 +3440,33 @@ function fallbackSourceOutline(project: ProjectState): string {
4015
3440
  * 批量生成时整体开销约省 25%)。
4016
3441
  * @returns 摘要与新增事实条数(失败返回空,调用方 best-effort)。
4017
3442
  */
3443
+
3444
+ /**
3445
+ * 事实库去重:新事实加入前检查与已有事实的相似度,
3446
+ * 状态类事实(如"主角受伤"→"主角痊愈")覆盖旧状态。
3447
+ */
3448
+ function dedupAndAddFacts(project: ProjectState, chapterNo: number, newFacts: string[]): number {
3449
+ const list = project.facts ?? []
3450
+ const existingTexts = new Set(list.map(f => f.text))
3451
+ let added = 0
3452
+ for (const fact of newFacts.slice(0, 8)) {
3453
+ if (existingTexts.has(fact)) continue
3454
+ const isDuplicate = list.some(f => {
3455
+ const a = f.text.slice(0, 30)
3456
+ const b = fact.slice(0, 30)
3457
+ if (a.length === 0 || b.length === 0) return false
3458
+ let common = 0
3459
+ for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] === b[i]) common++
3460
+ return common / Math.max(a.length, b.length) > 0.7
3461
+ })
3462
+ if (isDuplicate) continue
3463
+ list.push({ chapterNo, text: fact })
3464
+ existingTexts.add(fact)
3465
+ added++
3466
+ }
3467
+ project.facts = list.slice(-300)
3468
+ return added
3469
+ }
4018
3470
  export async function summarizeAndExtractFacts(
4019
3471
  ctx: Context,
4020
3472
  config: NovelConfig,
@@ -4042,9 +3494,7 @@ export async function summarizeAndExtractFacts(
4042
3494
  .map(v => v.trim().slice(0, 140))
4043
3495
  : []
4044
3496
  if (summary !== '') chapter.summary = summary
4045
- const list = project.facts ?? []
4046
- for (const line of factLines.slice(0, 8)) list.push({ chapterNo, text: line })
4047
- project.facts = list.slice(-300)
3497
+ dedupAndAddFacts(project, chapterNo, factLines)
4048
3498
  project.updatedAt = new Date().toISOString()
4049
3499
  saveProject(outputDir, project)
4050
3500
  return { summary, factCount: factLines.length }
@@ -4148,7 +3598,7 @@ async function auditBatch(
4148
3598
  batch: ChapterPlan[],
4149
3599
  ): Promise<AuditIssue[]> {
4150
3600
  const system = [
4151
- '你是一位严谨的网文连续性审校编辑。你会收到一本小说的设定圣经、事实库和一批章节正文节选。',
3601
+ '你是一位严谨的网文连续性审校编辑。你会收到一本小说的道藏、事实库和一批章节正文节选。',
4152
3602
  '请找出这批章节中的一致性矛盾,例如:',
4153
3603
  '- 人物状态冲突:境界/修为/伤势/资源在同一章内或跨章前后矛盾。',
4154
3604
  '- 设定违背:正文与世界观规则、金手指规则、写作红线冲突。',
@@ -4169,7 +3619,7 @@ async function auditBatch(
4169
3619
  const user = [
4170
3620
  '请对以下小说做一致性质检。',
4171
3621
  project.bible !== undefined
4172
- ? '设定圣经:\n' + [
3622
+ ? '道藏:\n' + [
4173
3623
  project.bible.worldRules.length > 0 ? `世界规则:\n${project.bible.worldRules.map(r => `- ${r}`).join('\n')}` : '',
4174
3624
  project.bible.redLines.length > 0 ? `写作红线:\n${project.bible.redLines.map(r => `- ${r}`).join('\n')}` : '',
4175
3625
  project.bible.characters.length > 0 ? `角色:\n${project.bible.characters.map(ch => `- ${ch.name}(${ch.traits.join('、')})`).join('\n')}` : '',
@@ -4179,7 +3629,7 @@ async function auditBatch(
4179
3629
  `正文节选(每章前 700 字):\n${chapterBlocks}`,
4180
3630
  '只输出 JSON 数组。',
4181
3631
  ].filter(s => s !== '').join('\n\n')
4182
- const text = await complete(ctx, config, { system, user, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 12000) })
3632
+ const text = await complete(ctx, config, { system, user, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low', model: config.auditModel, liveLabel: '质检查询' })
4183
3633
  const parsed = parseJsonArray<Record<string, unknown>>(text)
4184
3634
  const issues: AuditIssue[] = []
4185
3635
  for (const entry of parsed) {
@@ -4224,7 +3674,7 @@ export async function auditBook(
4224
3674
  return all.slice(0, 50)
4225
3675
  }
4226
3676
 
4227
- /** 小说简介:AI 生成或按已写开头补全(面向读者的作品门面)。 */
3677
+ /** 小说简介:生成或按已写开头补全(面向读者的作品门面)。 */
4228
3678
  export async function generateBlurb(
4229
3679
  ctx: Context,
4230
3680
  config: NovelConfig,
@@ -4268,7 +3718,7 @@ export function bookOverview(project: ProjectState, scope: 'recent' | 'full' | n
4268
3718
  s.push(`【大纲全文】\n${project.outline}`)
4269
3719
  if (project.bible !== undefined) {
4270
3720
  const bible = project.bible
4271
- s.push('【设定圣经】')
3721
+ s.push('【道藏】')
4272
3722
  if (bible.genre !== '') s.push(`题材基调:${bible.genre}`)
4273
3723
  if (bible.worldRules.length > 0) s.push('世界规则:\n' + bible.worldRules.map(r => `- ${r}`).join('\n'))
4274
3724
  if (bible.characters.length > 0) {
@@ -4325,7 +3775,7 @@ export function bookOverview(project: ProjectState, scope: 'recent' | 'full' | n
4325
3775
 
4326
3776
  /** 一条影响分析结果(改动波及处)。 */
4327
3777
  export interface ImpactItem {
4328
- /** 位置:章节号 / 大纲 / 设定圣经 / 大世界 / 事实库 / 简介。 */
3778
+ /** 位置:章节号 / 大纲 / 道藏 / 大世界 / 事实库 / 简介。 */
4329
3779
  location: string
4330
3780
  /** 原文片段(定位用)。 */
4331
3781
  quote: string
@@ -4348,7 +3798,7 @@ export async function analyzeImpact(
4348
3798
  ): Promise<ImpactItem[]> {
4349
3799
  const system = [
4350
3800
  '你是一位网文一致性审校。作者要做一处修改,请找出这次改动会波及的所有位置(设定、大纲、已写章节正文、事实库、简介中可能因此过时或矛盾的内容)。',
4351
- '输出必须是合法 JSON 数组,格式:[{"location": "位置(第N章/大纲/设定圣经-世界规则/大世界-境界/事实库/简介)", "quote": "原文片段(20-60字)", "suggestion": "修改建议", "kind": "must|optional|note"}]',
3801
+ '输出必须是合法 JSON 数组,格式:[{"location": "位置(第N章/大纲/道藏-世界规则/大世界-境界/事实库/简介)", "quote": "原文片段(20-60字)", "suggestion": "修改建议", "kind": "must|optional|note"}]',
4352
3802
  'kind 含义:must=必须同步改否则矛盾;optional=建议改(影响观感);note=备注(如旧称保留为古称、或无需改但需知晓)。',
4353
3803
  '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
4354
3804
  ].join('\n')
@@ -4419,20 +3869,20 @@ export function renderWorld(world: WorldState | undefined): string {
4419
3869
  return sections.join('\n')
4420
3870
  }
4421
3871
 
4422
- /** AI 提炼大世界:从大纲 + 设定圣经生成结构化境界体系/区域/势力。 */
3872
+ /** 提炼大世界:从大纲 + 道藏生成结构化境界体系/区域/势力。 */
4423
3873
  export async function extractWorld(
4424
3874
  ctx: Context,
4425
3875
  config: NovelConfig,
4426
3876
  project: ProjectState,
4427
3877
  ): Promise<WorldState> {
4428
3878
  const system = [
4429
- '你是一位网文世界观架构师。请根据小说大纲与设定圣经,提炼结构化「大世界」数据。',
3879
+ '你是一位网文世界观架构师。请根据小说大纲与道藏,提炼结构化「大世界」数据。',
4430
3880
  '输出必须是合法 JSON 对象:',
4431
3881
  '{"realms": [{"name": "境界名", "description": "突破条件/寿命/标志等"}], "regions": [{"name": "区域名", "description": "描述", "faction": "关联势力名或空"}], "factions": [{"name": "势力名", "kind": "宗门/家族/王朝/组织等", "description": "描述", "region": "驻地区域或空"}]}',
4432
3882
  '要求:',
4433
3883
  '1. realms 按由低到高顺序排列(修仙题材必须含完整境界链;无境界设定的题材可输出空数组)。',
4434
3884
  '2. 数量贴合大纲:realms 3-12 个,regions 2-10 个,factions 2-10 个。',
4435
- '3. 内容严格来自大纲与设定圣经,不要凭空发明与大纲冲突的设定。',
3885
+ '3. 内容严格来自大纲与道藏,不要凭空发明与大纲冲突的设定。',
4436
3886
  '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
4437
3887
  ].join('\n')
4438
3888
  const bibleBlock = project.bible !== undefined
@@ -4544,7 +3994,7 @@ export async function refreshCharacters(
4544
3994
  ].join('\n')
4545
3995
  const rosterBlock = roster.map(ch => `- ${ch.name}(${ch.traits.join('、')})`).join('\n')
4546
3996
  const factsBlock = facts.map(f => `[第${f.chapterNo}章] ${f.text}`).join('\n')
4547
- const user = [
3997
+ const user = [
4548
3998
  `角色名单:\n${rosterBlock}`,
4549
3999
  `已确立事实库(${facts.length} 条):\n${factsBlock.slice(-6000)}`,
4550
4000
  '只输出 JSON 数组。',
@@ -4571,7 +4021,7 @@ export async function refreshCharacters(
4571
4021
  appearances: entry?.chapters.size ?? 0,
4572
4022
  })
4573
4023
  }
4574
- // 名单外的角色(从事实库中识别到但不在设定圣经名单)仅当有出场统计时补充。
4024
+ // 名单外的角色(从事实库中识别到但不在道藏名单)仅当有出场统计时补充。
4575
4025
  for (const [name, entry] of stat) {
4576
4026
  if (!cards.some(c => c.name === name)) {
4577
4027
  cards.push({
@@ -4613,7 +4063,7 @@ export async function suggestForeshadows(
4613
4063
  `大纲:\n${project.outline}`,
4614
4064
  `已规划章节数:${project.chapters.length}`,
4615
4065
  ].join('\n')
4616
- const text = await complete(ctx, config, { system: foreshadowSystemPrompt(), user, temperature: 0.5, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
4066
+ const text = await complete(ctx, config, { system: foreshadowSystemPrompt(), user, temperature: 0.5, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low', liveLabel: '伏笔建议' })
4617
4067
  const parsed = parseJsonArray<Record<string, unknown>>(text)
4618
4068
  const existing = new Set(project.foreshadows.map(f => f.description))
4619
4069
  const created: Foreshadow[] = []
@@ -4645,17 +4095,34 @@ export async function extractStyleAsset(
4645
4095
  ctx: Context,
4646
4096
  config: NovelConfig,
4647
4097
  sampleText: string,
4648
- ): Promise<{ proseRules: string[]; dialogueRules: string[]; descriptionRules: string[]; boundaries: string[] }> {
4098
+ ): Promise<{
4099
+ proseRules: string[]; dialogueRules: string[]; descriptionRules: string[]; boundaries: string[];
4100
+ preset?: 'imitate' | 'balanced' | 'transfer'; fingerprintRisk?: 'low' | 'medium' | 'high';
4101
+ writingGuidance?: string[]; forbiddenEntities?: string[];
4102
+ }> {
4649
4103
  const user = `请分析下面这段样本文本,提炼其叙事风格规则:\n\n${sampleText}`
4650
- const text = await complete(ctx, config, { system: styleEngineSystemPrompt(), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 12000) })
4651
- const raw = parseJsonObject<{ proseRules?: unknown; dialogueRules?: unknown; descriptionRules?: unknown; boundaries?: unknown }>(text)
4104
+ const text = await complete(ctx, config, { system: styleEngineSystemPrompt(), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 12000), liveLabel: '写法提取' })
4105
+ const raw = parseJsonObject<{
4106
+ proseRules?: unknown; dialogueRules?: unknown; descriptionRules?: unknown; boundaries?: unknown;
4107
+ preset?: unknown; fingerprintRisk?: unknown; writingGuidance?: unknown; forbiddenEntities?: unknown;
4108
+ }>(text)
4652
4109
  const strArray = (value: unknown): string[] =>
4653
4110
  Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string' && v.trim() !== '') : []
4111
+ // 结构化输出校验(轻量 schema):归一 enum、截断、去空;preset 缺失时按指纹风险自动推荐。
4112
+ const fingerprintRisk = (['low', 'medium', 'high'] as const).includes(raw.fingerprintRisk as never)
4113
+ ? raw.fingerprintRisk as 'low' | 'medium' | 'high'
4114
+ : 'medium'
4654
4115
  const result = {
4655
- proseRules: strArray(raw.proseRules),
4656
- dialogueRules: strArray(raw.dialogueRules),
4657
- descriptionRules: strArray(raw.descriptionRules),
4658
- boundaries: strArray(raw.boundaries),
4116
+ proseRules: strArray(raw.proseRules).slice(0, 40),
4117
+ dialogueRules: strArray(raw.dialogueRules).slice(0, 40),
4118
+ descriptionRules: strArray(raw.descriptionRules).slice(0, 40),
4119
+ boundaries: strArray(raw.boundaries).slice(0, 30),
4120
+ preset: (['imitate', 'balanced', 'transfer'] as const).includes(raw.preset as never)
4121
+ ? raw.preset as 'imitate' | 'balanced' | 'transfer'
4122
+ : recommendStylePreset(fingerprintRisk),
4123
+ fingerprintRisk,
4124
+ writingGuidance: strArray(raw.writingGuidance).slice(0, 20),
4125
+ forbiddenEntities: strArray(raw.forbiddenEntities).slice(0, 30),
4659
4126
  }
4660
4127
  if (result.proseRules.length + result.dialogueRules.length + result.descriptionRules.length + result.boundaries.length === 0) {
4661
4128
  throw new Error('写法提取失败:模型没有返回有效规则')
@@ -4663,6 +4130,404 @@ export async function extractStyleAsset(
4663
4130
  return result
4664
4131
  }
4665
4132
 
4133
+ /** 分层提取写作公式(basic/standard/deep)。 */
4134
+ export async function extractStyleFormula(
4135
+ ctx: Context,
4136
+ config: NovelConfig,
4137
+ sampleText: string,
4138
+ depth: 'basic' | 'standard' | 'deep',
4139
+ ): Promise<{ name: string; focusAreas: string[]; formula: string; applyGuidance: string }> {
4140
+ const user = `请提炼下面这段样本文本的写作公式:\n\n${sampleText}`
4141
+ const text = await complete(ctx, config, { system: styleFormulaSystemPrompt(depth), user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 8000), liveLabel: '公式提取' })
4142
+ const raw = parseJsonObject<{ name?: unknown; focusAreas?: unknown; formula?: unknown; applyGuidance?: unknown }>(text)
4143
+ const formula = typeof raw.formula === 'string' ? raw.formula.trim() : ''
4144
+ if (formula === '') throw new Error('公式提取失败:模型没有返回有效 formula')
4145
+ return {
4146
+ name: typeof raw.name === 'string' ? raw.name.trim().slice(0, 40) : `公式 ${Date.now().toString(36)}`,
4147
+ focusAreas: Array.isArray(raw.focusAreas) ? raw.focusAreas.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.slice(0, 20)).slice(0, 6) : [],
4148
+ formula,
4149
+ applyGuidance: typeof raw.applyGuidance === 'string' ? raw.applyGuidance.trim().slice(0, 300) : '',
4150
+ }
4151
+ }
4152
+
4153
+ /** 市场雷达系统提示(对齐上游 marketRadar 分析师角色)。 */
4154
+ function marketRadarSystemPrompt(): string {
4155
+ return [
4156
+ '你是中文网络文学市场分析师。只分析输入中的公开榜单元数据或用户给出的市场线索,不补写作品正文,不假装知道未提供的信息。',
4157
+ '只根据输入归纳热门题材和市场信号;对未提供的信息不要臆测。',
4158
+ '重点分析:热门题材组合、主角身份、金手指机制、开篇危机、关系卖点、标题句式、拥挤套路和差异化机会。',
4159
+ 'kind 只能使用 genre、protagonist、advantage、opening、relationship、title_pattern、opportunity、crowding,不得创造近义枚举值。',
4160
+ '榜单高频不等于适合照搬。机会建议必须说明读者满足点,同时避开直接复制具体作品。',
4161
+ 'productionFoundation 的题材/推进模式优先引用「资源库」中已有的 existingId(名称一字不差);只有确无合适资产时才给出新资产(existingId 为 null)。',
4162
+ 'creativeBrief 严禁复用榜单作品的人名、专有设定、简介句子和完整书名;只能提炼读者需求、爽点机制和结构机会,不得输出任何具体人名/作品名,一律使用身份或通用场景称谓。',
4163
+ ].join('\n')
4164
+ }
4165
+
4166
+ /** 题材雷达:输入平台/题材/榜单文本 → 信号 + 生产底座 + 开书创意。 */
4167
+ export async function runMarketRadar(
4168
+ ctx: Context,
4169
+ config: NovelConfig,
4170
+ req: MarketRadarRequest,
4171
+ ): Promise<MarketRadarResult> {
4172
+ const genreCatalog = BUILTIN_GENRE_LIBRARY.map((g, i) => `- [${i}] ${g.name}:${g.description}${g.template !== undefined ? `(写法:${g.template})` : ''}`).join('\n')
4173
+ const modeCatalog = BUILTIN_PROGRESSION_MODES.map((m, i) => `- [${i}] ${m.name}:${m.driver} / 期待:${m.readerExpectation}`).join('\n')
4174
+ const user = [
4175
+ req.platform !== undefined && req.platform !== '' ? `目标平台:${req.platform}` : '',
4176
+ req.genre !== undefined && req.genre !== '' ? `目标题材:${req.genre}` : '',
4177
+ req.keywords !== undefined && req.keywords !== '' ? `关键词:${req.keywords}` : '',
4178
+ (req.candidates !== undefined && req.candidates.length > 0)
4179
+ ? '【上榜记录(真实榜单)】\n' + req.candidates.map((c, i) => `- ${i + 1}.《${c.title}》${c.author !== undefined && c.author !== '' ? `(${c.author})` : ''}${(c.tags?.length ?? 0) > 0 ? ` [${c.tags!.join('、')}]` : ''}${c.category !== undefined && c.category !== '' ? ` ${c.category}` : ''}${c.synopsis !== undefined && c.synopsis !== '' ? `:${c.synopsis.slice(0, 140)}` : ''}`).join('\n')
4180
+ : (req.feedText !== undefined && req.feedText !== '')
4181
+ ? '【榜单/市场线索】\n' + req.feedText.slice(0, 6000)
4182
+ : '(无榜单数据,请基于平台/题材/关键词与你的市场知识归纳)',
4183
+ '',
4184
+ '现有题材基底库:\n' + (genreCatalog || '空'),
4185
+ '',
4186
+ '现有推进模式库:\n' + (modeCatalog || '空'),
4187
+ '',
4188
+ '输出必须是合法 JSON 对象,不要输出任何其他文字。',
4189
+ 'JSON 结构:{"signals":[{"id":"短横线稳定id","kind":"genre|protagonist|advantage|opening|relationship|title_pattern|opportunity|crowding","title":"一句话","detail":"说明(含读者满足点)","direction":"current|rising|stable|falling","recommended":true|false}], "productionFoundation":{"genre":{"existingId":null或资源名,"name":"题材名","description":"说明","template":"写法指引"},"primaryStoryMode":{"existingId":null或资源名,"name":"模式名","driver":"驱动力","readerExpectation":"读者期待"},"secondaryStoryMode":{同primary,可null或省略}}}',
4190
+ ].filter(s => s !== '').join('\n\n')
4191
+ const text = await complete(ctx, config, { system: marketRadarSystemPrompt(), user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 8000), liveLabel: '题材雷达' })
4192
+ const raw = parseJsonObject<Record<string, unknown>>(text)
4193
+ const signalAs = (v: unknown): MarketRadarSignal[] => Array.isArray(v)
4194
+ ? v.filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null)
4195
+ .map(x => ({
4196
+ id: typeof x.id === 'string' ? x.id.slice(0, 40) : `sig-${Math.random().toString(36).slice(2, 7)}`,
4197
+ kind: (['genre', 'protagonist', 'advantage', 'opening', 'relationship', 'title_pattern', 'opportunity', 'crowding'] as const).includes(x.kind as never)
4198
+ ? x.kind as MarketRadarSignal['kind']
4199
+ : 'genre',
4200
+ title: typeof x.title === 'string' ? x.title.slice(0, 60) : '未命名信号',
4201
+ detail: typeof x.detail === 'string' ? x.detail.slice(0, 300) : '',
4202
+ direction: (['current', 'rising', 'stable', 'falling'] as const).includes(x.direction as never)
4203
+ ? x.direction as MarketRadarSignal['direction']
4204
+ : undefined,
4205
+ recommended: typeof x.recommended === 'boolean' ? x.recommended : undefined,
4206
+ }))
4207
+ : []
4208
+ const pf = (raw.productionFoundation ?? {}) as Record<string, unknown>
4209
+ const genre = (pf.genre ?? {}) as Record<string, unknown>
4210
+ const primary = (pf.primaryStoryMode ?? {}) as Record<string, unknown>
4211
+ const secondary = (pf.secondaryStoryMode ?? {}) as Record<string, unknown>
4212
+ const genreIds = new Set(BUILTIN_GENRE_LIBRARY.map(g => g.name))
4213
+ const modeIds = new Set(BUILTIN_PROGRESSION_MODES.map(m => m.name))
4214
+ const productionFoundation: ProductionFoundation = {
4215
+ genre: {
4216
+ existingId: typeof genre.existingId === 'string' && genre.existingId !== '' && genreIds.has(genre.existingId) ? genre.existingId : undefined,
4217
+ name: typeof genre.name === 'string' ? genre.name.slice(0, 40) : '未定题材',
4218
+ description: typeof genre.description === 'string' ? genre.description.slice(0, 300) : '',
4219
+ template: typeof genre.template === 'string' ? genre.template.slice(0, 200) : undefined,
4220
+ },
4221
+ primaryStoryMode: {
4222
+ existingId: typeof primary.existingId === 'string' && primary.existingId !== '' && modeIds.has(primary.existingId) ? primary.existingId : undefined,
4223
+ name: typeof primary.name === 'string' ? primary.name.slice(0, 40) : '未定模式',
4224
+ driver: typeof primary.driver === 'string' ? primary.driver.slice(0, 200) : '',
4225
+ readerExpectation: typeof primary.readerExpectation === 'string' ? primary.readerExpectation.slice(0, 200) : '',
4226
+ },
4227
+ secondaryStoryMode: Object.keys(secondary).length > 0 && typeof secondary.name === 'string' && secondary.name !== ''
4228
+ ? {
4229
+ existingId: typeof secondary.existingId === 'string' && secondary.existingId !== '' && modeIds.has(secondary.existingId) ? secondary.existingId : undefined,
4230
+ name: secondary.name.slice(0, 40),
4231
+ driver: typeof secondary.driver === 'string' ? secondary.driver.slice(0, 200) : '',
4232
+ readerExpectation: typeof secondary.readerExpectation === 'string' ? secondary.readerExpectation.slice(0, 200) : '',
4233
+ }
4234
+ : undefined,
4235
+ }
4236
+ return {
4237
+ signals: signalAs(raw.signals),
4238
+ productionFoundation,
4239
+ // 报告 id 让前端能区分「同一份报告的重渲染」与「换了新报告」:只有后者才重置
4240
+ // 作者手选的信号。用短随机 id 即可,无需持久化。
4241
+ reportId: `mr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
4242
+ }
4243
+ }
4244
+
4245
+ const MARKET_MODE_HINT: Record<string, string> = {
4246
+ follow_hot: '优先贴合当前热门满足点,但仍禁止复制具体作品。',
4247
+ differentiate: '保留热门读者满足点,同时至少替换主角身份、舞台或金手指机制中的一项。',
4248
+ light: '市场信号只作次要参考,用户自身想法和已选题材优先。',
4249
+ }
4250
+
4251
+ /** 开书创意简报:用选中的市场信号 + 影响模式生成可执行 constraint / creative seed。 */
4252
+ export async function runMarketCreativeBrief(ctx: Context, config: NovelConfig, req: MarketRadarBriefRequest): Promise<MarketCreativeBrief> {
4253
+ const user = [
4254
+ `影响模式:${req.influenceMode}`,
4255
+ MARKET_MODE_HINT[req.influenceMode] ?? '',
4256
+ req.signals.length > 0
4257
+ ? '选中的市场信号:\n' + req.signals.map(s => `- [${s.kind}] ${s.title}:${s.detail}`).join('\n')
4258
+ : '(无选中信号,请结合题材与推进模式补齐)',
4259
+ '',
4260
+ '输出必须是合法 JSON 对象,不要输出任何其他文字。',
4261
+ 'JSON 结构:{"promptBlock":"可直接指导题材/金手指/首章爆点/整书方向的约束","openingIdea":"一段可直接开书的中文起始想法(主角身份、金手指或核心优势、开局事件、近期目标;不输出标题/大纲/Markdown)","coreAdvantage":"主角能做什么(含触发条件/使用边界/成长方向或代价至少一项)","bookSellingPoint":"读者持续追读的核心满足点","first30ChapterPromise":"前30章必须兑现的阶段结果/关系变化/能力成长"}',
4262
+ '严禁复用榜单作品的人名、专有设定、简介句子和完整书名;一律使用身份或通用场景称谓,不得输出具体人名/作品名。',
4263
+ ].filter(s => s !== '').join('\n\n')
4264
+ const text = await complete(ctx, config, { system: marketRadarSystemPrompt(), user, temperature: 0.5, maxTokens: Math.max(config.maxTokens, 4000), liveLabel: '开书创意' })
4265
+ const cb = parseJsonObject<Record<string, unknown>>(text)
4266
+ return {
4267
+ promptBlock: typeof cb.promptBlock === 'string' ? cb.promptBlock.slice(0, 800) : '',
4268
+ openingIdea: typeof cb.openingIdea === 'string' ? cb.openingIdea.slice(0, 800) : '',
4269
+ coreAdvantage: typeof cb.coreAdvantage === 'string' ? cb.coreAdvantage.slice(0, 600) : '',
4270
+ bookSellingPoint: typeof cb.bookSellingPoint === 'string' ? cb.bookSellingPoint.slice(0, 400) : '',
4271
+ first30ChapterPromise: typeof cb.first30ChapterPromise === 'string' ? cb.first30ChapterPromise.slice(0, 400) : '',
4272
+ }
4273
+ }
4274
+
4275
+ /** 书分析/拆书:输入一本书/章节文本 → 卖点/结构/可借鉴点/风险。 */
4276
+ export async function runBookAnalysis(ctx: Context, config: NovelConfig, req: BookAnalysisRequest): Promise<BookAnalysisResult> {
4277
+ const system = [
4278
+ '你是资深中文网文拆书编辑。分析输入的一本书或章节文本,提炼可复用的创作思路。',
4279
+ '严禁照搬具体作品的人名/专有设定/简介句子;只能提炼读者需求、结构手法和风险。',
4280
+ ].join('\n')
4281
+ const user = [
4282
+ '请分析下面文本:\n' + (req.text ?? '').slice(0, 8000),
4283
+ '输出必须是合法 JSON 对象。',
4284
+ 'JSON 结构:{"sellingPoints":["读者为什么追(卖点)"],"structure":["可复用叙事结构/节奏/单元"],"lessons":["可借鉴手法/套路"],"risks":["易踩坑/风险"]}',
4285
+ ].filter(s => s !== '').join('\n\n')
4286
+ const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 4000), liveLabel: '书分析' })
4287
+ const raw = parseJsonObject<Record<string, unknown>>(text)
4288
+ const arr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.slice(0, 200)) : []
4289
+ return {
4290
+ sellingPoints: arr(raw.sellingPoints),
4291
+ structure: arr(raw.structure),
4292
+ lessons: arr(raw.lessons),
4293
+ risks: arr(raw.risks),
4294
+ }
4295
+ }
4296
+
4297
+ /** 创意灵感:一句话/题材 → 多方向开书灵感。 */
4298
+ export async function runIdeaInspiration(ctx: Context, config: NovelConfig, req: IdeaInspirationRequest): Promise<IdeaInspirationResult> {
4299
+ const count = Math.max(1, Math.min(10, req.count ?? 5))
4300
+ const system = [
4301
+ '你是中文网文开书灵感策划。根据用户一句话/题材方向,给出多个可开书的差异化创意。',
4302
+ '严禁输出具体人名/作品名;用身份/通用称谓。每个创意都要有明确钩子、题材、视角、长期兑现。',
4303
+ ].join('\n')
4304
+ const user = [
4305
+ `我的方向:${req.idea ?? ''}`,
4306
+ `请给 ${count} 个开书创意。`,
4307
+ '输出必须是合法 JSON 对象。',
4308
+ 'JSON 结构:{"ideas":[{"title":"书名/门面","hook":"一句话钩子/开局爆点","genre":"题材","pov":"主角视角/身份","payoff":"长期追读兑现"}]}',
4309
+ ].filter(s => s !== '').join('\n\n')
4310
+ const text = await complete(ctx, config, { system, user, temperature: 0.8, maxTokens: Math.max(config.maxTokens, 4000), liveLabel: '创意灵感' })
4311
+ const raw = parseJsonObject<Record<string, unknown>>(text)
4312
+ const str = (v: unknown): string => typeof v === 'string' ? v.trim().slice(0, 200) : ''
4313
+ const arr = (v: unknown): Array<{ title: string; hook: string; genre: string; pov: string; payoff: string }> => Array.isArray(v)
4314
+ ? v.filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null).map(x => ({ title: str(x.title), hook: str(x.hook), genre: str(x.genre), pov: str(x.pov), payoff: str(x.payoff) }))
4315
+ : []
4316
+ return { ideas: arr(raw.ideas).slice(0, count) }
4317
+ }
4318
+
4319
+ /** 雷达→灵感:基于市场信号/生产底座/创意简报,生成贴合市场的开书灵感。 */
4320
+ export async function runMarketIdeaInspiration(
4321
+ ctx: Context,
4322
+ config: NovelConfig,
4323
+ req: { signals?: MarketRadarSignal[]; foundation?: ProductionFoundation; brief?: MarketCreativeBrief; count?: number },
4324
+ ): Promise<IdeaInspirationResult> {
4325
+ const count = Math.max(1, Math.min(10, req.count ?? 5))
4326
+ const system = [
4327
+ '你是中文网文开书灵感策划,擅长从市场榜单分析中提炼可开书方向。',
4328
+ '严禁输出具体人名/作品名;用身份/通用称谓。每个创意都要有明确钩子、题材、视角、长期兑现,且要与市场分析呼应但做差异化。',
4329
+ ].join('\n')
4330
+ const signalBlock = (req.signals ?? []).map(s => `- [${s.kind}] ${s.title}:${s.detail}`).join('\n') || '(无)'
4331
+ const f = req.foundation
4332
+ const foundationBlock = f !== undefined
4333
+ ? `题材:${f.genre.name}(${f.genre.description})\n主推进:${f.primaryStoryMode.name}(${f.primaryStoryMode.driver})${f.secondaryStoryMode !== undefined ? `\n副推进:${f.secondaryStoryMode.name}` : ''}`
4334
+ : '(无)'
4335
+ const b = req.brief
4336
+ const briefBlock = b !== undefined
4337
+ ? `创作约束:${b.promptBlock}\n开篇想法:${b.openingIdea}\n核心优势:${b.coreAdvantage}\n追读卖点:${b.bookSellingPoint}\n前30章承诺:${b.first30ChapterPromise}`
4338
+ : '(无)'
4339
+ const user = [
4340
+ '基于以下市场分析,给出多个【差异化、可开书】的灵感:',
4341
+ `市场信号:\n${signalBlock}`,
4342
+ `生产底座:\n${foundationBlock}`,
4343
+ `创意简报:\n${briefBlock}`,
4344
+ `请给 ${count} 个开书灵感。`,
4345
+ '输出必须是合法 JSON 对象。',
4346
+ 'JSON 结构:{"ideas":[{"title":"书名/门面","hook":"一句话钩子/开局爆点","genre":"题材","pov":"主角视角/身份","payoff":"长期追读兑现"}]}',
4347
+ ].filter(s => s !== '').join('\n\n')
4348
+ const text = await complete(ctx, config, { system, user, temperature: 0.8, maxTokens: Math.max(config.maxTokens, 4000), liveLabel: '市场灵感' })
4349
+ const raw = parseJsonObject<Record<string, unknown>>(text)
4350
+ const str = (v: unknown): string => typeof v === 'string' ? v.trim().slice(0, 200) : ''
4351
+ const arr = (v: unknown): Array<{ title: string; hook: string; genre: string; pov: string; payoff: string }> => Array.isArray(v)
4352
+ ? v.filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null).map(x => ({ title: str(x.title), hook: str(x.hook), genre: str(x.genre), pov: str(x.pov), payoff: str(x.payoff) }))
4353
+ : []
4354
+ return { ideas: arr(raw.ideas).slice(0, count) }
4355
+ }
4356
+
4357
+ /** 自动导演编排建议:基于全书上下文,给出下一卷/阶段编排 + 修复再平衡。 */
4358
+ export async function runDirectorAdvice(ctx: Context, config: NovelConfig, project: ProjectState, req: DirectorRequest): Promise<DirectorAdvice> {
4359
+ const done = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating')
4360
+ const last = done[done.length - 1]
4361
+ const volumesBlock = (project.volumes ?? []).map(v => `第${v.no}卷《${v.title}》:${v.summary}`).join('\n') || '无'
4362
+ const plotlinesBlock = (project.plotlines ?? []).filter(l => l.status === 'active' || l.status === 'paused').map(l => `[${l.kind}] ${l.name}:${l.goal}${l.progress ? `(${l.progress})` : ''}`).join('\n') || '无'
4363
+ const foreBlock = (project.foreshadows ?? []).filter(f => f.status === 'planted' || f.status === 'progressing').map(f => `- ${f.description}${f.targetChapter ? `(约${f.targetChapter}章回收)` : ''}`).join('\n') || '无'
4364
+ const recentFacts = (project.facts ?? []).slice(-8).map(f => `[第${f.chapterNo}章] ${f.text}`).join('\n')
4365
+ const user = [
4366
+ `书名:《${project.bookName}》`,
4367
+ req.focus !== undefined && req.focus !== '' ? `聚焦:${req.focus}` : '',
4368
+ `当前进度:已写 ${done.length} 章,最后一章《${last?.title ?? '无'}》摘要:${last?.summary ?? ''}`,
4369
+ '',
4370
+ `分卷:\n${volumesBlock}`,
4371
+ `剧情线:\n${plotlinesBlock}`,
4372
+ `活跃伏笔:\n${foreBlock}`,
4373
+ `最近事实:\n${recentFacts}`,
4374
+ '',
4375
+ '请作为本书的自动导演,给出下一卷/下一阶段的编排建议:续写方向、阶段弧光、节奏板、风险提示、需要修复/再平衡的点。',
4376
+ '输出必须是合法 JSON 对象。',
4377
+ 'JSON 结构:{"summary":"总体判断一句话","nextArc":["下一阶段关键剧情节点(每条约一句话)"],"pacing":"节奏板(起承转合/爽点密度/章节节奏)","risks":["风险/跑偏提示"],"fixes":["需要修复/再平衡的点"]}',
4378
+ ].filter(s => s !== '').join('\n\n')
4379
+ const text = await complete(ctx, config, { system: '你是一位长篇网文自动导演,负责整本编排、节奏、风险与再平衡。只给出可执行建议,不输出正文。', user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 4000), liveLabel: '自动导演' })
4380
+ const raw = parseJsonObject<Record<string, unknown>>(text)
4381
+ const arr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.slice(0, 200)) : []
4382
+ return {
4383
+ summary: typeof raw.summary === 'string' ? raw.summary.slice(0, 300) : '',
4384
+ nextArc: arr(raw.nextArc),
4385
+ pacing: typeof raw.pacing === 'string' ? raw.pacing.slice(0, 400) : '',
4386
+ risks: arr(raw.risks),
4387
+ fixes: arr(raw.fixes),
4388
+ }
4389
+ }
4390
+
4391
+ // ---------------------------------------------------------------- timeline
4392
+
4393
+ /**
4394
+ * 从一章正文抽取时间线事件(时间点 / 地点 / 参与角色 / 事件)。
4395
+ *
4396
+ * 会**替换**该章已有事件(重复抽取幂等);抽取结果按 order 递增排序后写回项目。
4397
+ * 失败时抛错,由调用方决定是否降级(自动流程用 try/catch 包住,不阻断出章)。
4398
+ *
4399
+ * @param ctx 宿主上下文。
4400
+ * @param config 运行时配置。
4401
+ * @param project 项目状态(就地更新 `project.timeline`)。
4402
+ * @param outputDir 书目录。
4403
+ * @param chapterNo 章节号。
4404
+ * @returns 本次抽取到的事件(已写入 project)。
4405
+ */
4406
+ export async function extractTimelineForChapter(
4407
+ ctx: Context,
4408
+ config: NovelConfig,
4409
+ project: ProjectState,
4410
+ outputDir: string,
4411
+ chapterNo: number,
4412
+ ): Promise<TimelineEvent[]> {
4413
+ const chapter = project.chapters.find(c => c.no === chapterNo)
4414
+ if (chapter === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 不在计划中`)
4415
+ const body = readChapterFile(outputDir, chapter)
4416
+ if (body === undefined) throw new NovelActionError('contract', `章节 ${chapterNo} 的正文文件不存在`)
4417
+ const NL = String.fromCharCode(10)
4418
+ const system = [
4419
+ '你是小说连续性编辑。任务:把一章正文压缩成「故事时间线」事件,供后续章节保持时间与地点的连续性。',
4420
+ '要求:',
4421
+ '1. 只抽取**正文里真实发生**的场景节点(3-8 条),不要抽取预告、回忆性的泛泛陈述;纯粹的回忆/插叙请在 event 里注明「(回忆)」并给出其故事内时间。',
4422
+ '2. time 用正文里的说法(如「第三日黄昏」「入宗三个月后」「同日夜」);正文没写时间就填相对说法(如「紧接上一场景」)。',
4423
+ '3. order 是本章内的先后序号,从 1 递增;跨章我会用章号排序,你只需要保证章内顺序正确。',
4424
+ '4. place 是场景地点(一处一个词,如「青云宗外门」);characters 只列**在场**的角色名(用正文里的名字,不要写「主角」)。',
4425
+ '5. event 一句话说清发生了什么(不超过 40 字)。',
4426
+ '输出必须是合法 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记:',
4427
+ '[{"time":"第三日黄昏","order":1,"place":"青云宗外门","characters":["沈青"],"event":"沈青被外门弟子当众羞辱"}]',
4428
+ '重要:字符串值内部不得包含换行符;JSON 必须在一段内完整结束。',
4429
+ ].join('@NL@').replace('@NL@', NL)
4430
+ const text = await complete(ctx, config, {
4431
+ system,
4432
+ user: `第 ${chapterNo} 章《${chapter.title}》正文:@NL@@NL@${body.replace(/^#\s+.*$/m, '').trim().slice(0, 24000)}`.replace('@NL@', NL),
4433
+ temperature: 0.2,
4434
+ maxTokens: Math.max(config.maxTokens, 4000),
4435
+ model: config.auditModel || config.model,
4436
+ liveLabel: `时间线抽取 · 第${chapterNo}章`,
4437
+ })
4438
+ let parsed: unknown = []
4439
+ try {
4440
+ parsed = parseJson<unknown>(text, true)
4441
+ } catch {
4442
+ parsed = []
4443
+ }
4444
+ const rawList = Array.isArray(parsed) ? parsed : []
4445
+ const now = new Date().toISOString()
4446
+ const extracted = rawList
4447
+ .filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
4448
+ .map((item, index) => normalizeTimelineEvent({
4449
+ time: typeof item.time === 'string' ? item.time : '',
4450
+ order: typeof item.order === 'number' ? item.order : index + 1,
4451
+ place: typeof item.place === 'string' ? item.place : '',
4452
+ characters: Array.isArray(item.characters) ? item.characters.filter((c): c is string => typeof c === 'string') : [],
4453
+ event: typeof item.event === 'string' ? item.event : '',
4454
+ }, chapterNo, index, now))
4455
+ .filter(e => e.event !== '')
4456
+ // 保留模型给出的**叙述顺序**(不按 order 重排):时间线检查要靠这个顺序发现
4457
+ // 「自报 order 与叙述顺序打架」;渲染/注入时再用 sortTimeline 按 order 排。
4458
+ const kept = (project.timeline ?? []).filter(e => e.chapterNo !== chapterNo)
4459
+ project.timeline = [...kept, ...extracted]
4460
+ project.updatedAt = now
4461
+ return extracted
4462
+ }
4463
+
4464
+ /**
4465
+ * AI 复核时间线:在规则初筛之外,让模型找规则抓不到的时序问题
4466
+ * (年龄/季节错位、回忆与当前时间混淆、跨章跨度不合理等)。
4467
+ *
4468
+ * @param ctx 宿主上下文。
4469
+ * @param config 运行时配置。
4470
+ * @param project 项目状态。
4471
+ * @returns 模型给出的问题清单(与规则初筛结果合并由调用方决定)。
4472
+ */
4473
+ export async function reviewTimeline(
4474
+ ctx: Context,
4475
+ config: NovelConfig,
4476
+ project: ProjectState,
4477
+ ): Promise<TimelineIssue[]> {
4478
+ const events = sortTimeline(project.timeline ?? [])
4479
+ if (events.length === 0) return []
4480
+ const NL = String.fromCharCode(10)
4481
+ const lines = events.map(e => {
4482
+ const parts = [`第${e.chapterNo}章`]
4483
+ if (e.order !== undefined) parts.push(`#${e.order}`)
4484
+ if (e.time !== '') parts.push(e.time)
4485
+ if (e.place !== '') parts.push(`@${e.place}`)
4486
+ if (e.characters.length > 0) parts.push(`(${e.characters.join('、')})`)
4487
+ parts.push(e.event)
4488
+ return '- ' + parts.join(' ')
4489
+ })
4490
+ const system = [
4491
+ '你是小说连续性编辑,只做一件事:找**时间线**上的矛盾。',
4492
+ '检查项:1) 时间倒流(后一章早于前一章且没有明确插叙标注);2) 地点瞬移(同一人物无移动交代就换场景);',
4493
+ '3) 时间跨度与事件量明显不匹配(如「一日之内」发生跨越数月的剧情);4) 季节/年龄/节日前后矛盾;5) 回忆与现实混淆。',
4494
+ '只报**能在给定事件表里指认**的问题,不要臆测;没有问题就返回空数组。',
4495
+ '输出必须是合法 JSON 数组,不要任何其他文字:',
4496
+ '[{"severity":"high|medium|low","chapters":[3,7],"item":"问题一句话","suggestion":"怎么修"}]',
4497
+ '重要:字符串值内部不得包含换行符。',
4498
+ ].join(NL)
4499
+ const text = await complete(ctx, config, {
4500
+ system,
4501
+ user: `故事时间线(按章节顺序):@NL@${lines.join(NL)}`.replace('@NL@', NL),
4502
+ temperature: 0.2,
4503
+ maxTokens: Math.max(config.maxTokens, 4000),
4504
+ model: config.auditModel || config.model,
4505
+ liveLabel: '时间线复核',
4506
+ })
4507
+ let parsed: unknown = []
4508
+ try {
4509
+ parsed = parseJson<unknown>(text, true)
4510
+ } catch {
4511
+ parsed = []
4512
+ }
4513
+ if (!Array.isArray(parsed)) return []
4514
+ return parsed
4515
+ .filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
4516
+ .map(item => {
4517
+ const severity = item.severity === 'high' || item.severity === 'low' ? item.severity : 'medium'
4518
+ const chapters = Array.isArray(item.chapters)
4519
+ ? item.chapters.filter((c): c is number => typeof c === 'number' && Number.isFinite(c))
4520
+ : []
4521
+ return {
4522
+ severity,
4523
+ chapters,
4524
+ item: typeof item.item === 'string' ? item.item.slice(0, 300) : '',
4525
+ suggestion: typeof item.suggestion === 'string' ? item.suggestion.slice(0, 300) : '',
4526
+ } satisfies TimelineIssue
4527
+ })
4528
+ .filter(issue => issue.item !== '')
4529
+ }
4530
+
4666
4531
  // ------------------------------------------------------------------ export
4667
4532
 
4668
4533
  /** Export the whole book as one txt/md file. */
@@ -4688,3 +4553,207 @@ export function exportBook(outputDir: string, project: ProjectState, format: 'tx
4688
4553
  writeFileSync(join(outputDir, file), content, 'utf8')
4689
4554
  return { file, chars: content.length, chapters: done.length }
4690
4555
  }
4556
+
4557
+ /** 逐行渲染「- 名称:值」清单,跳过空值。 */
4558
+ function renderPairs(pairs: Array<[string, string | undefined]>): string {
4559
+ return pairs
4560
+ .filter(([, v]) => v !== undefined && v !== '')
4561
+ .map(([k, v]) => `- ${k}:${v}`)
4562
+ .join('\n')
4563
+ }
4564
+
4565
+ /** 渲染项目设定(卷首语 / 开书定盘 / 总纲 / 道藏 / 大世界 / 写作资产)。 */
4566
+ function renderSettings(project: ProjectState): string {
4567
+ const out: string[] = [`# ${project.bookName} · 项目设定`, '']
4568
+ out.push('## 卷首语 / 简介', '', project.blurb !== undefined && project.blurb !== '' ? project.blurb : '(未填写)', '')
4569
+ const c = project.bookContract
4570
+ if (c !== undefined) {
4571
+ out.push('## 开书定盘', '', renderPairs([
4572
+ ['书籍承诺', c.promise],
4573
+ ['主推进模式', c.primaryModeName],
4574
+ ['辅助推进模式', (c.secondaryModeNames ?? []).join('、')],
4575
+ ['文风基调', c.tone],
4576
+ ['目标平台', c.targetPlatform],
4577
+ ]), '')
4578
+ }
4579
+ out.push('## 总纲', '', project.outline.trim() === '' ? '(未导入)' : project.outline.trim(), '')
4580
+ const bible = project.bible
4581
+ if (bible !== undefined) {
4582
+ out.push('## 道藏', '')
4583
+ if (bible.worldRules.length > 0) out.push('### 世界规则', '', bible.worldRules.map((r, i) => `${i + 1}. ${r}`).join('\n'), '')
4584
+ if (bible.redLines.length > 0) out.push('### 写作红线', '', bible.redLines.map(r => `- ${r}`).join('\n'), '')
4585
+ if (bible.style.length > 0) out.push('### 文风', '', bible.style.map(x => `- ${x}`).join('\n'), '')
4586
+ if (bible.characters.length > 0) {
4587
+ out.push('### 角色(道藏)', '')
4588
+ for (const ch of bible.characters) {
4589
+ out.push(`- **${ch.name}**(${ch.role})`)
4590
+ if (ch.traits.length > 0) out.push(` - 标签:${ch.traits.join('、')}`)
4591
+ if (ch.goals !== '') out.push(` - 目标:${ch.goals}`)
4592
+ if (ch.relations !== '') out.push(` - 关系:${ch.relations}`)
4593
+ if ((ch.knowledge ?? []).length > 0) out.push(` - 知情:${(ch.knowledge ?? []).join(';')}`)
4594
+ }
4595
+ out.push('')
4596
+ }
4597
+ }
4598
+ const world = project.world
4599
+ if (world !== undefined) {
4600
+ out.push('## 大世界', '')
4601
+ if (world.realms.length > 0) out.push('### 境界体系', '', world.realms.map((r, i) => `${i + 1}. ${r.name}${r.description !== '' ? `:${r.description}` : ''}`).join('\n'), '')
4602
+ if (world.regions.length > 0) out.push('### 区域', '', world.regions.map(r => `- ${r.name}${r.description !== '' ? `:${r.description}` : ''}`).join('\n'), '')
4603
+ if (world.factions.length > 0) out.push('### 势力', '', world.factions.map(f => `- ${f.name}(${f.kind})${f.description !== '' ? `:${f.description}` : ''}`).join('\n'), '')
4604
+ }
4605
+ const assets = project.assets
4606
+ if (assets !== undefined) {
4607
+ const head: Array<[string, string | undefined]> = []
4608
+ if (assets.genre !== undefined) head.push(['题材', assets.genre.name])
4609
+ if (assets.primaryProgression !== undefined) head.push(['主推进', assets.primaryProgression.name])
4610
+ if ((assets.auxiliaryProgressions ?? []).length > 0) head.push(['辅助推进', assets.auxiliaryProgressions.map(a => a.name).join('、')])
4611
+ if (head.length > 0) out.push('## 写作资产', '', renderPairs(head), '')
4612
+ if ((assets.antiAiRules ?? []).length > 0) {
4613
+ out.push('### 反 AI 规则', '')
4614
+ out.push(assets.antiAiRules.map(r => `- ${r.name}:避免「${r.avoid}」→ ${r.fix}`).join('\n'), '')
4615
+ }
4616
+ if ((assets.styleAssets ?? []).length > 0) {
4617
+ out.push('### 笔法帖 / 写法资产', '')
4618
+ out.push(assets.styleAssets.map(x => `- ${x.name}`).join('\n'), '')
4619
+ }
4620
+ }
4621
+ return out.join('\n').replace(/\n(\n)+/g, '\n\n')
4622
+ }
4623
+
4624
+ /** 渲染规划(卷计划 + 章节计划)。 */
4625
+ function renderPlan(project: ProjectState): string {
4626
+ const out: string[] = [`# ${project.bookName} · 规划`, '']
4627
+ const volumes = (project.volumes ?? []).slice().sort((a, b) => a.no - b.no)
4628
+ if (volumes.length > 0) {
4629
+ out.push('## 卷计划', '')
4630
+ for (const v of volumes) {
4631
+ out.push(`### 第${v.no}卷 ${v.title}`, '')
4632
+ if (v.summary !== '') out.push(v.summary, '')
4633
+ }
4634
+ }
4635
+ out.push(`## 章节计划(共 ${project.chapters.length} 章)`, '')
4636
+ for (const c of project.chapters.slice().sort((a, b) => a.no - b.no)) {
4637
+ out.push(`### 第${c.no}章 ${c.title} [${c.status}]`, '')
4638
+ if (c.beats !== '') out.push(c.beats.trim(), '')
4639
+ const extra = renderPairs([
4640
+ ['本章必达', (c.mustAdvance ?? []).join(';')],
4641
+ ['不可破坏', (c.mustPreserve ?? []).join(';')],
4642
+ ['人物硬事实', (c.characterHardFacts ?? []).join(';')],
4643
+ ['结尾钩子', c.endingHook],
4644
+ ['义务合约', c.obligation],
4645
+ ])
4646
+ if (extra !== '') out.push(extra, '')
4647
+ if (c.summary !== undefined && c.summary !== '') out.push(`- 摘要:${c.summary}`, '')
4648
+ }
4649
+ return out.join('\n')
4650
+ }
4651
+
4652
+ /** 渲染角色(角色库 + 人物志)。 */
4653
+ function renderCharacters(project: ProjectState): string {
4654
+ const out: string[] = [`# ${project.bookName} · 角色`, '']
4655
+ const roles = project.roles ?? []
4656
+ out.push(`## 角色库(${roles.length})`, '')
4657
+ if (roles.length === 0) out.push('(空)', '')
4658
+ for (const r of roles) {
4659
+ out.push(`### ${r.name}(${r.roleLabel})`, '')
4660
+ const info = renderPairs([
4661
+ ['身份', r.identity],
4662
+ ['性格', r.traits.join('、')],
4663
+ ['目标', r.goals],
4664
+ ['关系网', r.relations.join(';')],
4665
+ ['成长线', r.arc.join(' → ')],
4666
+ ['知情度', r.knowledge.join(';')],
4667
+ ['首次出场', r.firstChapter !== undefined ? `第${r.firstChapter}章` : undefined],
4668
+ ])
4669
+ if (info !== '') out.push(info, '')
4670
+ }
4671
+ const status = project.roleStatus ?? []
4672
+ if (status.length > 0) {
4673
+ out.push('## 人物志(当前状态)', '')
4674
+ for (const card of status) {
4675
+ out.push(`- **${card.name}**(${card.role})${card.status} — 最近出场第 ${card.lastChapter} 章,累计 ${card.appearances} 次`)
4676
+ }
4677
+ }
4678
+ return out.join('\n')
4679
+ }
4680
+
4681
+ /** 渲染质检记录(逐章审稿 + 作者复盘 + 暗线 + 剧情线 + 待办)。 */
4682
+ function renderReview(project: ProjectState): string {
4683
+ const out: string[] = [`# ${project.bookName} · 质检记录`, '']
4684
+ const reviewed = project.chapters.filter(c => c.review !== undefined).slice().sort((a, b) => a.no - b.no)
4685
+ out.push(`## 审稿(${reviewed.length} / ${project.chapters.length} 章)`, '')
4686
+ for (const c of reviewed) {
4687
+ const r = c.review!
4688
+ out.push(`### 第${c.no}章 ${c.title} ${r.score} 分 · ${r.passed ? '通过' : '未过'}`, '')
4689
+ if (r.verdict !== '') out.push(r.verdict, '')
4690
+ if (r.issues.length > 0) {
4691
+ out.push(r.issues.map(i => `- [${i.severity}] ${i.item}${i.suggestion !== '' ? ` → ${i.suggestion}` : ''}`).join('\n'), '')
4692
+ }
4693
+ }
4694
+ const author = project.chapters.filter(c => c.authorReview !== undefined).slice().sort((a, b) => a.no - b.no)
4695
+ if (author.length > 0) {
4696
+ out.push('## 作者复盘', '')
4697
+ for (const c of author) {
4698
+ const a = c.authorReview!
4699
+ out.push(`### 第${c.no}章 ${c.title}`, '')
4700
+ out.push(renderPairs([
4701
+ ['上一章钩子兑现', a.hookHonored ? '是' : '否'],
4702
+ ['钩子说明', a.hookNote],
4703
+ ['结尾钩子强度', String(a.endingHook)],
4704
+ ['剧情线推进', a.plotlineProgress],
4705
+ ['连续性', a.continuity],
4706
+ ]), '')
4707
+ }
4708
+ }
4709
+ const fores = project.foreshadows ?? []
4710
+ if (fores.length > 0) {
4711
+ out.push('## 暗线 / 伏笔', '')
4712
+ out.push(fores.map(f => `- [${f.status}] ${f.description}${f.targetChapter !== undefined ? `(预计第 ${f.targetChapter} 章回收)` : ''}`).join('\n'), '')
4713
+ }
4714
+ const plotlines = project.plotlines ?? []
4715
+ if (plotlines.length > 0) {
4716
+ out.push('## 剧情线', '')
4717
+ out.push(plotlines.map(l => `- [${l.status}] ${l.name}:${l.goal}(${l.progress})`).join('\n'), '')
4718
+ }
4719
+ const todos = project.todos ?? []
4720
+ if (todos.length > 0) {
4721
+ out.push('## 编辑待办', '')
4722
+ out.push(todos.map(t => `- [${t.done ? 'x' : ' '}] ${t.text}`).join('\n'), '')
4723
+ }
4724
+ return out.join('\n')
4725
+ }
4726
+
4727
+ /**
4728
+ * 按范围导出:整本正文 / 设定 / 规划 / 角色 / 质检记录 / 项目 JSON。
4729
+ *
4730
+ * @param outputDir 书目录。
4731
+ * @param project 项目状态。
4732
+ * @param scope 导出范围。
4733
+ * @param format txt / md / json(json 只对 `project` 范围有独立含义,其余按 md 落盘)。
4734
+ * @returns 文件名、字符数与章节数。
4735
+ */
4736
+ export function exportScope(
4737
+ outputDir: string,
4738
+ project: ProjectState,
4739
+ scope: ExportScope,
4740
+ format: 'txt' | 'md' | 'json',
4741
+ ): { file: string; chars: number; chapters: number } {
4742
+ if (scope === 'book') return exportBook(outputDir, project, format === 'json' ? 'md' : format)
4743
+ if (scope === 'project') {
4744
+ const payload = { schema: 'dsh-novel-forge/project@1', exportedAt: new Date().toISOString(), project }
4745
+ const content = JSON.stringify(payload, null, 2)
4746
+ const file = `《${safeFileName(project.bookName)}》项目备份.json`
4747
+ writeFileSync(join(outputDir, file), content, 'utf8')
4748
+ return { file, chars: content.length, chapters: project.chapters.length }
4749
+ }
4750
+ const body = scope === 'settings' ? renderSettings(project)
4751
+ : scope === 'plan' ? renderPlan(project)
4752
+ : scope === 'characters' ? renderCharacters(project)
4753
+ : renderReview(project)
4754
+ const ext = format === 'json' ? 'md' : format
4755
+ const label = scope === 'settings' ? '项目设定' : scope === 'plan' ? '规划' : scope === 'characters' ? '角色' : '质检记录'
4756
+ const file = `《${safeFileName(project.bookName)}》${label}.${ext}`
4757
+ writeFileSync(join(outputDir, file), body, 'utf8')
4758
+ return { file, chars: body.length, chapters: 0 }
4759
+ }