@waterwx/dsh-novel-forge 2.0.0-alpha.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 (206) hide show
  1. package/README.md +286 -285
  2. package/lib/client.js +19561 -19439
  3. package/lib/index.js +8159 -4456
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +0 -6
  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 +136 -27
  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/llmLive.d.ts +31 -0
  14. package/lib/types/client/locales.d.ts +15 -70
  15. package/lib/types/client/mount.d.ts +0 -2
  16. package/lib/types/client/panel/AiTag.d.ts +1 -0
  17. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  18. package/lib/types/client/panel/AuthorHome.d.ts +13 -11
  19. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  20. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  21. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  22. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  23. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  24. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  25. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  26. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  27. package/lib/types/client/panel/ModelManager.d.ts +2 -2
  28. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  29. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  30. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  31. package/lib/types/client/panel/SettingsView.d.ts +12 -9
  32. package/lib/types/client/panel/ShelfView.d.ts +10 -1
  33. package/lib/types/client/panel/SubPage.d.ts +27 -0
  34. package/lib/types/client/panel/TensionView.d.ts +18 -0
  35. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  36. package/lib/types/client/panel/helpers.d.ts +5 -3
  37. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  38. package/lib/types/client/panel/toast.d.ts +2 -0
  39. package/lib/types/client/panel/views.d.ts +21 -35
  40. package/lib/types/client/run-status.d.ts +21 -0
  41. package/lib/types/client/sidebar-entry.d.ts +0 -2
  42. package/lib/types/docx-core.d.ts +16 -0
  43. package/lib/types/docx.d.ts +2 -3
  44. package/lib/types/engine.d.ts +78 -103
  45. package/lib/types/global-assets.d.ts +6 -0
  46. package/lib/types/home.d.ts +11 -0
  47. package/lib/types/index.d.ts +26 -12
  48. package/lib/types/llm-live.d.ts +70 -0
  49. package/lib/types/llm-retry.d.ts +38 -0
  50. package/lib/types/market-radar-scan.d.ts +18 -0
  51. package/lib/types/market-radar-sources.d.ts +31 -0
  52. package/lib/types/migrate-output.d.ts +25 -0
  53. package/lib/types/model-capability.d.ts +93 -0
  54. package/lib/types/output-budget.d.ts +86 -0
  55. package/lib/types/prompt-slots.d.ts +57 -0
  56. package/lib/types/prompting.d.ts +10 -0
  57. package/lib/types/protocol.d.ts +816 -601
  58. package/lib/types/revision.d.ts +76 -0
  59. package/lib/types/routes.d.ts +9 -3
  60. package/lib/types/run.d.ts +23 -1
  61. package/lib/types/settings-persist.d.ts +59 -0
  62. package/lib/types/snapshots.d.ts +46 -0
  63. package/lib/types/stage-contract.d.ts +58 -0
  64. package/lib/types/tension.d.ts +53 -0
  65. package/lib/types/timeline.d.ts +37 -0
  66. package/package.json +50 -25
  67. package/src/action-guard.ts +127 -0
  68. package/src/ai-scan.ts +0 -24
  69. package/src/assets.ts +1801 -879
  70. package/src/assistant.ts +1098 -770
  71. package/src/author-assets.ts +131 -147
  72. package/src/bookshelf.ts +193 -167
  73. package/src/client/api.ts +767 -617
  74. package/src/client/chip-prefs.ts +121 -0
  75. package/src/client/docx.ts +32 -69
  76. package/src/client/index.ts +2 -0
  77. package/src/client/llmLive.ts +111 -0
  78. package/src/client/locales.ts +15 -274
  79. package/src/client/mount.tsx +3 -2
  80. package/src/client/panel/AdaptModeView.tsx +535 -520
  81. package/src/client/panel/AiTag.tsx +7 -0
  82. package/src/client/panel/AssetsTab.tsx +448 -458
  83. package/src/client/panel/AssistantTab.tsx +355 -301
  84. package/src/client/panel/AuthorAssetsView.tsx +205 -207
  85. package/src/client/panel/AuthorHome.tsx +309 -204
  86. package/src/client/panel/BookAnalysisView.tsx +63 -0
  87. package/src/client/panel/CmdPalette.tsx +102 -0
  88. package/src/client/panel/CreateBookView.tsx +366 -352
  89. package/src/client/panel/DirectorView.tsx +200 -0
  90. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -172
  91. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  92. package/src/client/panel/ImportModal.tsx +302 -302
  93. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  94. package/src/client/panel/LiveFeedLog.tsx +204 -0
  95. package/src/client/panel/MarketRadarView.tsx +415 -0
  96. package/src/client/panel/ModelManager.tsx +205 -205
  97. package/src/client/panel/NovelPanel.tsx +3144 -2205
  98. package/src/client/panel/PageHeader.tsx +37 -0
  99. package/src/client/panel/ProgressConsole.tsx +63 -64
  100. package/src/client/panel/PromptSlotsView.tsx +97 -0
  101. package/src/client/panel/ReaderView.tsx +306 -306
  102. package/src/client/panel/RunPanel.tsx +56 -24
  103. package/src/client/panel/SettingsView.tsx +175 -106
  104. package/src/client/panel/ShelfView.tsx +63 -39
  105. package/src/client/panel/SubPage.tsx +42 -0
  106. package/src/client/panel/TensionView.tsx +212 -0
  107. package/src/client/panel/TimelineView.tsx +208 -0
  108. package/src/client/panel/WorldTab.tsx +202 -202
  109. package/src/client/panel/helpers.ts +7 -5
  110. package/src/client/panel/market-radar-state.ts +79 -0
  111. package/src/client/panel/panel.module.css +5767 -4360
  112. package/src/client/panel/toast.ts +51 -0
  113. package/src/client/panel/views.tsx +316 -314
  114. package/src/client/run-status.ts +171 -0
  115. package/src/client/sidebar-entry.ts +3 -3
  116. package/src/docx-core.ts +53 -0
  117. package/src/docx.ts +44 -83
  118. package/src/engine.ts +1197 -1638
  119. package/src/global-assets.ts +58 -0
  120. package/src/home.ts +33 -0
  121. package/src/index.ts +181 -101
  122. package/src/llm-live.ts +245 -0
  123. package/src/llm-retry.ts +77 -0
  124. package/src/market-radar-scan.ts +35 -0
  125. package/src/market-radar-sources.ts +173 -0
  126. package/src/migrate-output.ts +76 -0
  127. package/src/model-capability.ts +215 -0
  128. package/src/novel-context.ts +286 -287
  129. package/src/output-budget.ts +147 -0
  130. package/src/prompt-slots.ts +125 -0
  131. package/src/prompting.ts +26 -0
  132. package/src/protocol.ts +835 -614
  133. package/src/revision.ts +283 -0
  134. package/src/routes.ts +1286 -910
  135. package/src/run.ts +113 -17
  136. package/src/settings-persist.ts +114 -0
  137. package/src/snapshots.ts +174 -0
  138. package/src/stage-contract.ts +186 -0
  139. package/src/tension.ts +251 -0
  140. package/src/timeline.ts +159 -0
  141. package/assets/styles/arcane-thick-3d.webp +0 -0
  142. package/assets/styles/chinese-ink.webp +0 -0
  143. package/assets/styles/clay-stopmotion-3d.webp +0 -0
  144. package/assets/styles/dark-ghibli.webp +0 -0
  145. package/assets/styles/dark-gothic-3d.webp +0 -0
  146. package/assets/styles/film-photo.webp +0 -0
  147. package/assets/styles/guofeng-heavy.webp +0 -0
  148. package/assets/styles/guoman-2d-gufeng.webp +0 -0
  149. package/assets/styles/guoman-3d-tianyuan.webp +0 -0
  150. package/assets/styles/guoman-3d-xuanhuan.webp +0 -0
  151. package/assets/styles/horror-game-3d.webp +0 -0
  152. package/assets/styles/hyperreal-3d.webp +0 -0
  153. package/assets/styles/japan-cel-dark.webp +0 -0
  154. package/assets/styles/korean-webtoon.webp +0 -0
  155. package/assets/styles/low-cyberpunk-3d.webp +0 -0
  156. package/assets/styles/nextgen-cg.webp +0 -0
  157. package/assets/styles/noir-film.webp +0 -0
  158. package/assets/styles/openworld-survival.webp +0 -0
  159. package/assets/styles/paper-cut-3d.webp +0 -0
  160. package/assets/styles/pixar-adult-3d.webp +0 -0
  161. package/assets/styles/pop-marvel-2d.webp +0 -0
  162. package/assets/styles/sandiao-qban.webp +0 -0
  163. package/assets/styles/shinkai-anime.webp +0 -0
  164. package/assets/styles/tactical-stealth.webp +0 -0
  165. package/assets/styles/urban-lightreal-3d.webp +0 -0
  166. package/assets/styles/vaporwave-3d.webp +0 -0
  167. package/assets/styles/vhs90.webp +0 -0
  168. package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
  169. package/lib/types/client/panel/FlowGuide.d.ts +0 -22
  170. package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -20
  171. package/lib/types/client/panel/MangaWorkspace.d.ts +0 -12
  172. package/lib/types/client/panel/ModelPicker.d.ts +0 -11
  173. package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
  174. package/lib/types/client/panel/PropLibrary.d.ts +0 -8
  175. package/lib/types/client/panel/SceneLibrary.d.ts +0 -16
  176. package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
  177. package/lib/types/client/panel/StyleCard.d.ts +0 -8
  178. package/lib/types/client/panel/StyleGallery.d.ts +0 -1
  179. package/lib/types/comic-presets.d.ts +0 -12
  180. package/lib/types/manga-genre-rules.d.ts +0 -22
  181. package/lib/types/prompt-utils.d.ts +0 -35
  182. package/lib/types/review-policy.d.ts +0 -97
  183. package/lib/types/shot-language.d.ts +0 -46
  184. package/lib/types/story-beat-language.d.ts +0 -35
  185. package/lib/types/style-library.d.ts +0 -33
  186. package/skills/chapter-batch/SKILL.md +0 -81
  187. package/src/client/mount.tsx._new +0 -97
  188. package/src/client/mount.tsx._nf +0 -97
  189. package/src/client/mount.tsx._tmp +0 -97
  190. package/src/client/panel/BookshelfBar.tsx +0 -170
  191. package/src/client/panel/FlowGuide.tsx +0 -84
  192. package/src/client/panel/MangaRoleLibrary.tsx +0 -1002
  193. package/src/client/panel/MangaWorkspace.tsx +0 -715
  194. package/src/client/panel/ProgressHomeView.tsx +0 -87
  195. package/src/client/panel/PropLibrary.tsx +0 -175
  196. package/src/client/panel/SceneLibrary.tsx +0 -453
  197. package/src/client/panel/StoryboardTab.tsx +0 -472
  198. package/src/client/panel/StyleCard.tsx +0 -90
  199. package/src/client/panel/StyleGallery.tsx +0 -58
  200. package/src/comic-presets.ts +0 -67
  201. package/src/manga-genre-rules.ts +0 -87
  202. package/src/prompt-utils.ts +0 -73
  203. package/src/review-policy.ts +0 -161
  204. package/src/shot-language.ts +0 -181
  205. package/src/story-beat-language.ts +0 -130
  206. package/src/style-library.ts +0 -84
package/src/routes.ts CHANGED
@@ -12,13 +12,12 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
12
12
  import { spawn } from 'node:child_process'
13
13
  import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
14
14
  import { join, basename, extname } from 'node:path'
15
- import { homedir } from 'node:os'
16
15
  import { randomBytes } from 'node:crypto'
17
- import { fileURLToPath } from 'node:url'
18
16
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
19
17
  import type { Context } from '@deepseek-ai/cordis'
20
18
  import { ProductionRunner } from './run.ts'
21
19
  import {
20
+ EXPORT_SCOPE_VALUES,
22
21
  NOVEL_API,
23
22
  type AssetsPatch,
24
23
  type AssetsResponse,
@@ -46,11 +45,8 @@ import {
46
45
  type BookActivateRequest,
47
46
  type BookCreateRequest,
48
47
  type BookImportDirRequest,
49
- type BookImportDirResponse,
50
48
  type BookImportTextRequest,
51
- type BookImportTextResponse,
52
49
  type BookImportTextPreviewRequest,
53
- type BookImportTextPreviewResponse,
54
50
  type BookRemoveRequest,
55
51
  type BookshelfSnapshot,
56
52
  type ChapterResponse,
@@ -58,6 +54,13 @@ import {
58
54
  type ChapterSaveResponse,
59
55
  type ChapterTextRequest,
60
56
  type ConfigPatch,
57
+ type ConfigResponse,
58
+ type GenerateRequest,
59
+ type MoveOutputDirRequest,
60
+ type MoveOutputDirResponse,
61
+ type BookSettings,
62
+ type BookSettingsRequest,
63
+ type BookSettingsResponse,
61
64
  type DraftDecisionRequest,
62
65
  type ExportRequest,
63
66
  type ExportResponse,
@@ -65,13 +68,18 @@ import {
65
68
  type ForeshadowResponse,
66
69
  type JobFrame,
67
70
  type LoadOutlineRequest,
68
- type MangaPlan,
69
- type MangaPlansRequest,
70
- type MangaPlansResponse,
71
- type MangaRoleCard,
72
- type MangaRolesRequest,
73
- type MangaRolesResponse,
74
71
  type LoadOutlineResponse,
72
+ type ExportScope,
73
+ type PromptSlotsRequest,
74
+ type PromptSlotsResponse,
75
+ type SnapshotRequest,
76
+ type SnapshotResponse,
77
+ type RevisionPlanRequest,
78
+ type RevisionPlanResponse,
79
+ type TensionRequest,
80
+ type TensionResponse,
81
+ type TimelineRequest,
82
+ type TimelineResponse,
75
83
  type NovelConfig,
76
84
  type PlotlinesRequest,
77
85
  type PlotlinesResponse,
@@ -87,13 +95,6 @@ import {
87
95
  type RewriteRequest,
88
96
  type RolesRequest,
89
97
  type RolesResponse,
90
- type SceneCard,
91
- type ScenesRequest,
92
- type ScenesResponse,
93
- type MangaPropsRequest,
94
- type MangaPropsResponse,
95
- type VisualRulesRequest,
96
- type VisualRulesResponse,
97
98
  type OutlineSuggestRequest,
98
99
  type OutlineSuggestResponse,
99
100
  type BreakdownRequest,
@@ -104,16 +105,8 @@ import {
104
105
  type ProjectState,
105
106
  type StatusResponse,
106
107
  type StoryBible,
107
- type StoryboardSkeletonRequest,
108
- type StoryboardSkeletonResponse,
109
- type StoryboardTableRequest,
110
- type StoryboardTableResponse,
111
- type StoryboardPromptsRequest,
112
- type ImageTestRequest,
113
- type ImageTestResponse,
114
108
  type LlmTestRequest,
115
109
  type LlmTestResponse,
116
- type StoryboardPromptsResponse,
117
110
  type StyleEngineRequest,
118
111
  type SummaryRequest,
119
112
  type VolumesRequest,
@@ -135,12 +128,27 @@ import {
135
128
  type AdaptMaterializeSaveResponse,
136
129
  type AdaptRewriteFrame,
137
130
  type PluginUpdateResponse,
131
+ type MarketRadarRequest,
132
+ type MarketRadarBriefRequest,
133
+ type ProductionFoundation,
134
+ type ProgressionMode,
135
+ type GenreNode,
136
+ type KnowledgeRequest,
137
+ type BookAnalysisRequest,
138
+ type IdeaInspirationRequest,
139
+ type DirectorRequest,
140
+ type DirectorTodo,
138
141
  } from './protocol.ts'
139
142
  import { readOutlineFromDocx } from './docx.ts'
140
143
  import { clearAssistantHistory, loadAssistantHistory, runAssistantTurn } from './assistant.ts'
141
- import { activateBook, bookshelfSnapshot, createBook, defaultOutputDirFor, importDir, loadBookshelf, removeBook, renameBook, seedBookshelfFromOutputDir } from './bookshelf.ts'
144
+ import { activateBook, bookshelfSnapshot, createBook, defaultOutputDirFor, importDir, loadBookshelf, removeBook, renameBook, repointBookDirs, seedBookshelfFromOutputDir } from './bookshelf.ts'
145
+ import { copyDirContents, listDirContents, normalizeDir, removeDir } from './migrate-output.ts'
142
146
  import { loadAuthorAssets, upsertAuthorAsset, removeAuthorAsset, importDefaultAuthorAssets } from './author-assets.ts'
143
- import { BUILTIN_ANTI_AI_RULES, BUILTIN_GENRE_LIBRARY, BUILTIN_PLOT_BEATS, BUILTIN_PROGRESSION_MODES, BUILTIN_STYLE_TEMPLATES, emptyProjectAssets } from './assets.ts'
147
+ import { BUILTIN_ANTI_AI_RULES, BUILTIN_GENRE_LIBRARY, BUILTIN_PLOT_BEATS, BUILTIN_PROGRESSION_MODES, BUILTIN_STARTER_STYLE_PROFILES, BUILTIN_STYLE_TEMPLATES, emptyProjectAssets, ensureBuiltinAssets } from './assets.ts'
148
+ import { scanAiFlavor } from './ai-scan.ts'
149
+ import { emitLive, livePrompt, liveUsage, nextSessionId, resetLiveUsage, subscribeLiveFeed } from './llm-live.ts'
150
+ import { scanMarketRanking } from './market-radar-scan.ts'
151
+ import { addGlobalGenre, addGlobalMode, globalGenreLibrary, globalProgressionLibrary } from './global-assets.ts'
144
152
  import {
145
153
  chapterFileName,
146
154
  auditBook,
@@ -148,12 +156,19 @@ import {
148
156
  autoLinkPlotlines,
149
157
  backfillFacts,
150
158
  createProject,
151
- exportBook,
159
+ exportScope,
152
160
  importBookText,
153
161
  importBookTextFromText,
154
162
  previewBookText,
155
163
  extractBible,
156
164
  extractStyleAsset,
165
+ extractStyleFormula,
166
+ runMarketRadar,
167
+ runMarketCreativeBrief,
168
+ runBookAnalysis,
169
+ runIdeaInspiration,
170
+ runMarketIdeaInspiration,
171
+ runDirectorAdvice,
157
172
  extractWorld,
158
173
  generateBlurb,
159
174
  generateChapterStream,
@@ -174,14 +189,6 @@ import {
174
189
  analyzePlotlineHealth,
175
190
  designPlotlinePlan,
176
191
  extractRoles,
177
- extractScenes,
178
- generateRolePromptKit,
179
- generateMangaRolePromptKit,
180
- extractVisualRules,
181
- extractProps,
182
- extractRoleVisual,
183
- extractMangaRoleVisual,
184
- nominateMangaRoles,
185
192
  suggestOutlines,
186
193
  reverseOutlineFromChapters,
187
194
  breakdownBook,
@@ -191,32 +198,43 @@ import {
191
198
  rewriteAdaptationBook,
192
199
  materializeAdaptedBook,
193
200
  saveMaterializedBook,
194
- getActiveMangaStyle,
195
201
  testLlmModel,
196
202
  registerLlmModel,
197
203
  listLlmVendors,
198
204
  listLlmModels,
199
205
  listLlmProviders,
200
206
  removeLlmProvider,
201
- generateStoryboardSkeleton,
202
- generateStoryboardTable,
203
- generateStoryboardPrompts,
204
- autoGenerateMangaChapter,
205
- mangaAssetsDir,
206
- saveMangaAssetPackage,
207
207
  checkSensitiveText,
208
208
  suggestForeshadows,
209
209
  suggestPlotlines,
210
210
  summarizeAndExtractFacts,
211
+ extractTimelineForChapter,
212
+ reviewTimeline,
211
213
  summarizeChapter,
212
214
  syncProjectWithDisk,
213
215
  } from './engine.ts'
216
+ import { countHanzi } from './engine.ts'
217
+ import { computeBookStage } from './stage-contract.ts'
218
+ import { createSnapshot, loadSnapshots, pruneSnapshots, removeSnapshot, restoreSnapshot, snapshotsBytes } from './snapshots.ts'
219
+ import { detectTimelineIssues } from './timeline.ts'
220
+ import { buildCurveData, detectTensionIssues, TENSION_MAX, TENSION_MIN, TENSION_PRESETS, clampTension } from './tension.ts'
221
+ import { PROMPT_SLOTS, normalizeSlotValue, readPromptSlots } from './prompt-slots.ts'
222
+ import { buildRevisionPlan, describeRevisionPlan, REVISION_ITEM_CAP } from './revision.ts'
223
+ import { dshHomePath } from './home.ts'
214
224
 
215
225
  /** Cap on JSON request bodies (generous: cover images travel as base64). */
216
226
  const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024
217
227
 
218
228
  /** 包内置风格效果图目录(assets/styles,随 npm 包分发)。 */
219
- const builtinStyleDir = fileURLToPath(new URL('../assets/styles/', import.meta.url))
229
+
230
+ /** 解析请求级目标书目录:优先按请求携带的 bookId 查书架,否则回退全局 active 书目录。 */
231
+ function resolveOutputDir(config: NovelConfig, bookId?: string): string {
232
+ if (bookId !== undefined && bookId !== '') {
233
+ const book = loadBookshelf().books.find(b => b.id === bookId)
234
+ if (book !== undefined && book.outputDir !== '') return book.outputDir
235
+ }
236
+ return config.outputDir
237
+ }
220
238
 
221
239
  /** Loopback-only fence (mirrors the family plugins' pairing routes). */
222
240
  function isLoopbackRequest(request: IncomingMessage): boolean {
@@ -270,13 +288,29 @@ export interface NovelRoutesDeps {
270
288
  ctx: Context
271
289
  /** Resolve the live plugin config (settings-aware). */
272
290
  getConfig: () => NovelConfig
273
- /** Persist a config patch through the settings seam. */
274
- patchConfig: (patch: ConfigPatch) => Promise<NovelConfig>
291
+ /** Persist a config patch through the settings seam (memory fallback included). */
292
+ patchConfig: (patch: ConfigPatch) => Promise<ConfigResponse>
293
+ /** Raw settings config(未与激活书合并)——目录迁移判断默认目录是否需同步用。 */
294
+ rawConfig?: () => { outputDir?: string }
295
+ /** Loader entry id the settings section is addressed by (undefined when unknown). */
296
+ settingsNs?: string
275
297
  }
276
298
 
277
299
  /** Default chapter count for planning when the request omits it. */
278
300
  const DEFAULT_PLAN_COUNT = 30
279
301
 
302
+ /**
303
+ * The profile this host booted (`--profile <name>` on the dsh CLI invocation).
304
+ * Profile-scoped paths must not assume a name: the shipped template is `web`,
305
+ * but a custom profile (`web-alpha`) is the one actually running.
306
+ * @returns the profile name, or undefined when the flag is absent.
307
+ */
308
+ function argvProfile(): string | undefined {
309
+ const flag = process.argv.indexOf('--profile')
310
+ const value = flag === -1 ? undefined : process.argv[flag + 1]
311
+ return value !== undefined && !value.startsWith('-') ? value : undefined
312
+ }
313
+
280
314
  /**
281
315
  * Build every /api/dsh-novel-forge route.
282
316
  * @param deps - context, config resolver, config patcher.
@@ -285,6 +319,13 @@ const DEFAULT_PLAN_COUNT = 30
285
319
  export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
286
320
  const { ctx, getConfig, patchConfig } = deps
287
321
 
322
+ /**
323
+ * 新书默认目录的基准:settings 里配置的输出目录(未经激活书合并,激活书的
324
+ * outputDir 指向它自己,不能当作新书的父目录)。未配置时 `defaultOutputDirFor`
325
+ * 回退到 `<DSH_HOME>/novels`。
326
+ */
327
+ const defaultBookDir = (bookName: string): string => defaultOutputDirFor(bookName, deps.rawConfig?.().outputDir)
328
+
288
329
  /** 全书质检实时状态(内存态,重启后回到 idle;用于 /status 暴露进度)。 */
289
330
  let auditState: AuditStatus = { status: 'idle', totalBatches: 0, completedBatches: 0 }
290
331
 
@@ -321,9 +362,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
321
362
  handler: (req, res) => {
322
363
  if (!guard(req, res, 'GET')) return
323
364
  const config = getConfig()
365
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
366
+ const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
324
367
  // 书架为空时播种 settings 默认输出目录里的已有项目。
325
- seedBookshelfFromOutputDir(config.outputDir)
326
- const project = loadProject(config.outputDir)
368
+ seedBookshelfFromOutputDir(outputDir)
369
+ const project = loadProject(outputDir)
327
370
  if (project !== undefined) {
328
371
  // 自动兜底:generating 超过 10 分钟(正常一章 2-5 分钟)视为卡死/中断,
329
372
  // 复位为 pending,避免"生成中"状态卡死无法重新生成。
@@ -338,14 +381,18 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
338
381
  }
339
382
  }
340
383
  }
341
- syncProjectWithDisk(project, config.outputDir)
342
- saveProject(config.outputDir, project)
384
+ syncProjectWithDisk(project, outputDir)
385
+ saveProject(outputDir, project)
343
386
  }
344
387
  const slim = new URL(req.url ?? '/', 'http://localhost').searchParams.get('slim') === '1'
388
+ // 截断显式化:任何被裁掉的字段都要在响应里声明,客户端/模型不能
389
+ // 把"看到的"当成"全部"。静默截断会让一致性判断建立在不完整事实上。
390
+ const truncations: string[] = []
345
391
  let projectPayload: StatusResponse['project']
346
392
  if (project === undefined) {
347
393
  projectPayload = undefined
348
394
  } else if (slim) {
395
+ truncations.push('slim=1:正文/beats/图集/道藏/大世界/资产等重字段未返回,大纲仅返回前 200 字')
349
396
  // slim 瘦身:轮询/角标只需章节状态与分数 + 轻量元数据,
350
397
  // 去掉正文、beats、图集(base64)、道藏/大世界/资产等重字段,避免长书轮询反复传输大体积。
351
398
  projectPayload = {
@@ -366,13 +413,23 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
366
413
  } else {
367
414
  // 瘦身:facts 只回最近 80 条(客户端最多展示 60),避免长篇后
368
415
  // status 响应体随编年录无限膨胀。
369
- projectPayload = { ...project, facts: (project.facts ?? []).slice(-80) }
416
+ const allFacts = project.facts ?? []
417
+ const shownFacts = allFacts.slice(-80)
418
+ if (allFacts.length > shownFacts.length) {
419
+ truncations.push(`编年录:共 ${allFacts.length} 条,仅返回最近 ${shownFacts.length} 条(较早 ${allFacts.length - shownFacts.length} 条未返回)`)
420
+ }
421
+ projectPayload = { ...project, facts: shownFacts }
370
422
  }
371
423
  const response: StatusResponse = {
372
424
  config,
373
425
  project: projectPayload,
374
426
  generatedFiles: listChapterFiles(config.outputDir),
375
427
  audit: auditState,
428
+ // 阶段契约:宿主算一次,面板角标、助手提示词、外部自动化共用同一结论。
429
+ stage: computeBookStage(project),
430
+ // 本次运行(进程内)的 LLM 用量:调用次数 / 输入·输出·思考 token / 耗时。
431
+ usage: liveUsage(),
432
+ ...(truncations.length > 0 ? { truncations } : {}),
376
433
  }
377
434
  writeJson(res, 200, response)
378
435
  },
@@ -477,9 +534,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
477
534
  path: NOVEL_API.volumes,
478
535
  handler: async (req, res) => {
479
536
  if (!guard(req, res, 'POST')) return
480
- const body = await readJsonBody<VolumesRequest>(req)
481
- const config = getConfig()
482
- const project = loadProject(config.outputDir)
537
+ const body = await readJsonBody<VolumesRequest & { bookId?: string }>(req)
538
+ const config0 = getConfig()
539
+ const outputDir = resolveOutputDir(config0, body?.bookId)
540
+ const config = { ...config0, outputDir }
541
+ const project = loadProject(outputDir)
483
542
  const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
484
543
  ? body.outline
485
544
  : project?.outline
@@ -493,7 +552,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
493
552
  const next = project ?? createProject(outline)
494
553
  next.volumes = volumes
495
554
  next.updatedAt = now
496
- saveProject(config.outputDir, next)
555
+ saveProject(outputDir, next)
497
556
  const response: VolumesResponse = { volumes }
498
557
  writeJson(res, 200, response)
499
558
  } catch (error) {
@@ -508,9 +567,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
508
567
  path: NOVEL_API.plan,
509
568
  handler: async (req, res) => {
510
569
  if (!guard(req, res, 'POST')) return
511
- const body = await readJsonBody<PlanRequest>(req)
512
- const config = getConfig()
513
- const project = loadProject(config.outputDir)
570
+ const body = await readJsonBody<PlanRequest & { bookId?: string }>(req)
571
+ const config0 = getConfig()
572
+ const outputDir = resolveOutputDir(config0, body?.bookId)
573
+ const config = { ...config0, outputDir }
574
+ const project = loadProject(outputDir)
514
575
  const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
515
576
  ? body.outline
516
577
  : project?.outline
@@ -546,10 +607,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
546
607
  path: NOVEL_API.generate,
547
608
  handler: async (req, res) => {
548
609
  if (!guard(req, res, 'POST')) return
549
- const config = getConfig()
550
- const project = requireProject(res)
551
- if (project === undefined) return
552
- const body = await readJsonBody<{ chapterNo?: number; skipReview?: boolean }>(req)
610
+ const config0 = getConfig()
611
+ const body = await readJsonBody<GenerateRequest & { bookId?: string }>(req)
612
+ const outputDir = resolveOutputDir(config0, body?.bookId)
613
+ const config = { ...config0, outputDir }
614
+ const project = loadProject(outputDir)
615
+ if (project === undefined) {
616
+ writeJson(res, 400, { error: '输出目录中没有项目' })
617
+ return
618
+ }
553
619
  const rawNo = body?.chapterNo
554
620
  if (!Number.isInteger(rawNo) || rawNo === undefined || rawNo < 1) {
555
621
  writeJson(res, 400, { error: 'chapterNo 须为正整数' })
@@ -584,21 +650,38 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
584
650
  res.write(JSON.stringify(frame) + '\n')
585
651
  }
586
652
 
653
+ const liveSession = nextSessionId()
654
+ emitLive({ type: 'session_started', sessionId: liveSession, label: '正文生成', model: config.generateModel || config.model, at: new Date().toISOString(), context: { interactionId: liveSession, taskId: `ch-${no}` } })
655
+ emitLive({ type: 'phase_changed', sessionId: liveSession, phase: 'streaming', phaseMessage: '模型正在返回正文', at: new Date().toISOString() })
656
+
587
657
  try {
588
658
  send({ type: 'start', no, title: chapter.title })
659
+ let genChars = 0
589
660
  for await (const step of generateChapterStream(ctx, config, project, config.outputDir, no)) {
590
661
  if (step.frame === 'delta') {
591
662
  send({ type: 'delta', text: step.text })
663
+ genChars += step.text.length
664
+ emitLive({ type: 'output_delta', sessionId: liveSession, content: step.text, totalChars: genChars, at: new Date().toISOString() })
592
665
  } else if (step.frame === 'done') {
593
666
  send({ type: 'done', no, file: step.file, chars: step.chars, title: chapter.title, warn: step.warn })
594
667
  }
595
668
  }
596
- // Auto pipeline: summary + facts(一次调用)-> review(unless skipped)。
669
+ emitLive({ type: 'session_completed', sessionId: liveSession, totalChars: genChars, preview: '', at: new Date().toISOString(), phase: 'completed' })
670
+ // Auto pipeline: summary + facts(一次调用)-> timeline -> review(unless skipped)。
597
671
  try {
598
672
  await summarizeAndExtractFacts(ctx, config, project, config.outputDir, no)
599
673
  } catch (error) {
600
674
  console.warn('[dsh-novel-forge] summary/facts failed:', (error as Error).message)
601
675
  }
676
+ // 故事时间线:抽取本章事件(失败不阻断出章,作者可在时间线页手工补)。
677
+ if (config.autoTimeline ?? true) {
678
+ try {
679
+ await extractTimelineForChapter(ctx, config, project, config.outputDir, no)
680
+ saveProject(config.outputDir, project)
681
+ } catch (error) {
682
+ console.warn('[dsh-novel-forge] timeline extract failed:', (error as Error).message)
683
+ }
684
+ }
602
685
  // 伏笔落地标记:正文中命中 planned 伏笔关键词 → 自动标为 planted(暗线管理页与正文同步)。
603
686
  try {
604
687
  const marked = markForeshadowPlanted(project, config.outputDir, no)
@@ -662,17 +745,22 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
662
745
  path: NOVEL_API.review,
663
746
  handler: async (req, res) => {
664
747
  if (!guard(req, res, 'POST')) return
665
- const config = getConfig()
666
- const project = requireProject(res)
667
- if (project === undefined) return
668
- const body = await readJsonBody<ReviewRequest>(req)
748
+ const config0 = getConfig()
749
+ const body = await readJsonBody<ReviewRequest & { bookId?: string }>(req)
750
+ const outputDir = resolveOutputDir(config0, body?.bookId)
751
+ const config = { ...config0, outputDir }
752
+ const project = loadProject(outputDir)
753
+ if (project === undefined) {
754
+ writeJson(res, 400, { error: '输出目录中没有项目' })
755
+ return
756
+ }
669
757
  if (!Number.isInteger(body?.chapterNo)) {
670
758
  writeJson(res, 400, { error: 'chapterNo 须为正整数' })
671
759
  return
672
760
  }
673
761
  const no = body!.chapterNo!
674
762
  try {
675
- const report = await reviewChapter(ctx, config, project, config.outputDir, no)
763
+ const report = await reviewChapter(ctx, config, project, outputDir, no)
676
764
  writeJson(res, 200, { report })
677
765
  } catch (error) {
678
766
  writeJson(res, 500, { error: (error as Error).message })
@@ -686,10 +774,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
686
774
  path: NOVEL_API.rewrite,
687
775
  handler: async (req, res) => {
688
776
  if (!guard(req, res, 'POST')) return
689
- const config = getConfig()
690
- const project = requireProject(res)
691
- if (project === undefined) return
692
- const body = await readJsonBody<RewriteRequest>(req)
777
+ const config0 = getConfig()
778
+ const body = await readJsonBody<RewriteRequest & { bookId?: string }>(req)
779
+ const outputDir = resolveOutputDir(config0, body?.bookId)
780
+ const config = { ...config0, outputDir }
781
+ const project = loadProject(outputDir)
782
+ if (project === undefined) {
783
+ writeJson(res, 400, { error: '输出目录中没有项目' })
784
+ return
785
+ }
693
786
  if (!Number.isInteger(body?.chapterNo)) {
694
787
  writeJson(res, 400, { error: 'chapterNo 须为正整数' })
695
788
  return
@@ -725,10 +818,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
725
818
  path: NOVEL_API.polish,
726
819
  handler: async (req, res) => {
727
820
  if (!guard(req, res, 'POST')) return
728
- const config = getConfig()
729
- const project = requireProject(res)
730
- if (project === undefined) return
731
- const body = await readJsonBody<PolishRequest>(req)
821
+ const config0 = getConfig()
822
+ const body = await readJsonBody<PolishRequest & { bookId?: string }>(req)
823
+ const outputDir = resolveOutputDir(config0, body?.bookId)
824
+ const config = { ...config0, outputDir }
825
+ const project = loadProject(outputDir)
826
+ if (project === undefined) {
827
+ writeJson(res, 400, { error: '输出目录中没有项目' })
828
+ return
829
+ }
732
830
  if (!Number.isInteger(body?.chapterNo)) {
733
831
  writeJson(res, 400, { error: 'chapterNo 须为正整数' })
734
832
  return
@@ -790,7 +888,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
790
888
  }
791
889
  writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
792
890
  chapter.pendingDraft = undefined
793
- chapter.chars = draft.length
891
+ chapter.chars = countHanzi(draft)
794
892
  chapter.file = fileName
795
893
  // 携带审查报告则沿用结论定状态(修订后审查通过 → approved);否则置 written 待审。
796
894
  const carried = body?.report
@@ -865,10 +963,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
865
963
  path: NOVEL_API.foreshadow,
866
964
  handler: async (req, res) => {
867
965
  if (!guard(req, res, 'POST')) return
868
- const config = getConfig()
869
- const project = requireProject(res)
870
- if (project === undefined) return
871
- const body = await readJsonBody<ForeshadowRequest>(req)
966
+ const config0 = getConfig()
967
+ const body = await readJsonBody<ForeshadowRequest & { bookId?: string }>(req)
968
+ const outputDir = resolveOutputDir(config0, body?.bookId)
969
+ const config = { ...config0, outputDir }
970
+ const project = loadProject(outputDir)
971
+ if (project === undefined) {
972
+ writeJson(res, 400, { error: '输出目录中没有项目' })
973
+ return
974
+ }
872
975
  try {
873
976
  if (body?.suggest === true) {
874
977
  // AI suggestion pass: create several foreshadows from the outline.
@@ -922,14 +1025,21 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
922
1025
  path: NOVEL_API.exportBook,
923
1026
  handler: async (req, res) => {
924
1027
  if (!guard(req, res, 'POST')) return
925
- const config = getConfig()
926
- const project = requireProject(res)
927
- if (project === undefined) return
928
- const body = await readJsonBody<ExportRequest>(req)
929
- const format = body?.format === 'md' ? 'md' : 'txt'
1028
+ const config0 = getConfig()
1029
+ const body = await readJsonBody<ExportRequest & { bookId?: string }>(req)
1030
+ const outputDir = resolveOutputDir(config0, body?.bookId)
1031
+ const config = { ...config0, outputDir }
1032
+ const project = loadProject(outputDir)
1033
+ if (project === undefined) {
1034
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1035
+ return
1036
+ }
1037
+ const format: 'txt' | 'md' | 'json' = body?.format === 'json' ? 'json' : body?.format === 'md' ? 'md' : 'txt'
1038
+ // 导出范围:整本正文 / 设定 / 规划 / 角色 / 质检记录 / 项目 JSON。
1039
+ const scope: ExportScope = body?.scope !== undefined && EXPORT_SCOPE_VALUES.includes(body.scope) ? body.scope : 'book'
930
1040
  try {
931
- const result = exportBook(config.outputDir, project, format)
932
- const response: ExportResponse = { ...result }
1041
+ const result = exportScope(config.outputDir, project, scope, format)
1042
+ const response: ExportResponse = { ...result, scope, format }
933
1043
  writeJson(res, 200, response)
934
1044
  } catch (error) {
935
1045
  writeJson(res, 500, { error: (error as Error).message })
@@ -944,9 +1054,13 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
944
1054
  handler: async (req, res) => {
945
1055
  if (!guard(req, res, 'GET')) return
946
1056
  const config = getConfig()
947
- const project = requireProject(res)
948
- if (project === undefined) return
949
1057
  const url = new URL(req.url ?? '/', 'http://localhost')
1058
+ const outputDir = resolveOutputDir(config, url.searchParams.get('bookId') ?? undefined)
1059
+ const project = loadProject(outputDir)
1060
+ if (project === undefined) {
1061
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1062
+ return
1063
+ }
950
1064
  const rawNo = Number(url.searchParams.get('no') ?? '0')
951
1065
  if (!Number.isInteger(rawNo) || rawNo < 1) {
952
1066
  writeJson(res, 400, { error: 'no 须为正整数' })
@@ -957,7 +1071,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
957
1071
  writeJson(res, 404, { error: `章节 ${rawNo} 不在计划中` })
958
1072
  return
959
1073
  }
960
- const markdown = readChapterFile(config.outputDir, chapter)
1074
+ const markdown = readChapterFile(outputDir, chapter)
961
1075
  if (markdown === undefined) {
962
1076
  writeJson(res, 404, { error: `章节 ${rawNo} 尚未生成` })
963
1077
  return
@@ -1023,9 +1137,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1023
1137
  if (existsSync(targetPath)) {
1024
1138
  copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
1025
1139
  }
1140
+ // 覆盖前留一份历史版本:采纳草稿改坏了可以回滚。
1141
+ createSnapshot(config.outputDir, chapter, '采纳草稿前')
1026
1142
  writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${text}\n`, 'utf8')
1027
1143
  chapter.status = 'written'
1028
- chapter.chars = text.length
1144
+ chapter.chars = countHanzi(text)
1029
1145
  chapter.file = fileName
1030
1146
  chapter.pendingDraft = undefined
1031
1147
  // 保存即审稿:携带工作区审查报告则沿用(不重复审);否则自动正式审稿一次。
@@ -1052,9 +1168,13 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1052
1168
  handler: async (req, res) => {
1053
1169
  if (!guard(req, res, 'POST')) return
1054
1170
  const config = getConfig()
1055
- const project = requireProject(res)
1056
- if (project === undefined) return
1057
- const body = await readJsonBody<AssistantRequest>(req)
1171
+ const body = await readJsonBody<AssistantRequest & { bookId?: string }>(req)
1172
+ const outputDir = resolveOutputDir(config, body?.bookId)
1173
+ const project = loadProject(outputDir)
1174
+ if (project === undefined) {
1175
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1176
+ return
1177
+ }
1058
1178
  const message = body?.message?.trim()
1059
1179
  if (message === undefined || message === '') {
1060
1180
  writeJson(res, 400, { error: '消息不能为空' })
@@ -1068,10 +1188,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1068
1188
  })
1069
1189
  const send = (frame: AssistantFrame): void => { res.write(JSON.stringify(frame) + '\n') }
1070
1190
  try {
1071
- for await (const step of runAssistantTurn(ctx, config, project, config.outputDir, message)) {
1191
+ for await (const step of runAssistantTurn(ctx, config, project, outputDir, message)) {
1072
1192
  if (step.frame === 'delta') send({ type: 'delta', text: step.text })
1073
1193
  else if (step.frame === 'tool') send({ type: 'tool', name: step.name, status: step.status, detail: step.detail })
1074
1194
  else if (step.frame === 'toolDelta') send({ type: 'toolDelta', name: step.name, text: step.text })
1195
+ else if (step.frame === 'toolResult') send({ type: 'toolResult', name: step.name, text: step.text })
1075
1196
  }
1076
1197
  send({ type: 'done' })
1077
1198
  res.end()
@@ -1091,7 +1212,9 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1091
1212
  handler: (req, res) => {
1092
1213
  if (!guard(req, res, 'GET')) return
1093
1214
  const config = getConfig()
1094
- const response: AssistantHistoryResponse = { messages: loadAssistantHistory(config.outputDir) }
1215
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1216
+ const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
1217
+ const response: AssistantHistoryResponse = { messages: loadAssistantHistory(outputDir) }
1095
1218
  writeJson(res, 200, response)
1096
1219
  },
1097
1220
  }
@@ -1101,10 +1224,12 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1101
1224
  const assistantClearRoute: WebRoute = {
1102
1225
  kind: 'exact',
1103
1226
  path: NOVEL_API.assistantClear,
1104
- handler: (req, res) => {
1227
+ handler: async (req, res) => {
1105
1228
  if (!guard(req, res, 'POST')) return
1106
1229
  const config = getConfig()
1107
- clearAssistantHistory(config.outputDir)
1230
+ const body = await readJsonBody<{ bookId?: string }>(req)
1231
+ const outputDir = resolveOutputDir(config, body?.bookId)
1232
+ clearAssistantHistory(outputDir)
1108
1233
  writeJson(res, 200, { ok: true })
1109
1234
  },
1110
1235
  }
@@ -1125,10 +1250,21 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1125
1250
  return
1126
1251
  }
1127
1252
  const config = getConfig()
1128
- const project = loadProject(config.outputDir)
1129
- const projectAssets = project?.assets ?? emptyProjectAssets()
1253
+ let postBody: (AssetsPatch & { bookId?: string }) | undefined
1254
+ if (req.method === 'POST') {
1255
+ postBody = await readJsonBody<AssetsPatch & { bookId?: string }>(req)
1256
+ if (postBody === undefined) {
1257
+ writeJson(res, 400, { error: '无效的 JSON' })
1258
+ return
1259
+ }
1260
+ }
1261
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1262
+ const queryBookId = qurl.searchParams.get('bookId') ?? undefined
1263
+ const outputDir = resolveOutputDir(config, postBody?.bookId ?? queryBookId)
1264
+ const project = loadProject(outputDir)
1265
+ const projectAssets = ensureBuiltinAssets(project?.assets ?? emptyProjectAssets())
1130
1266
  if (req.method === 'POST') {
1131
- const body = await readJsonBody<AssetsPatch>(req)
1267
+ const body = postBody
1132
1268
  if (body === undefined) {
1133
1269
  writeJson(res, 400, { error: '无效的 JSON' })
1134
1270
  return
@@ -1145,14 +1281,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1145
1281
  projectAssets.updatedAt = new Date().toISOString()
1146
1282
  project.assets = projectAssets
1147
1283
  project.updatedAt = new Date().toISOString()
1148
- saveProject(config.outputDir, project)
1284
+ saveProject(outputDir, project)
1149
1285
  }
1150
1286
  const response: AssetsResponse = {
1151
1287
  projectAssets,
1152
- genreLibrary: BUILTIN_GENRE_LIBRARY,
1288
+ genreLibrary: [...BUILTIN_GENRE_LIBRARY, ...globalGenreLibrary()],
1153
1289
  antiAiLibrary: BUILTIN_ANTI_AI_RULES,
1154
1290
  styleTemplates: BUILTIN_STYLE_TEMPLATES,
1155
- progressionLibrary: BUILTIN_PROGRESSION_MODES,
1291
+ progressionLibrary: [...BUILTIN_PROGRESSION_MODES, ...globalProgressionLibrary()],
1292
+ starterStyleProfiles: BUILTIN_STARTER_STYLE_PROFILES,
1156
1293
  plotBeatLibrary: BUILTIN_PLOT_BEATS,
1157
1294
  }
1158
1295
  writeJson(res, 200, response)
@@ -1197,161 +1334,885 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1197
1334
  },
1198
1335
  }
1199
1336
 
1200
- // ------------------------------------------------------------- bookshelf
1201
- const bookshelfRoute: WebRoute = {
1337
+ // ----------------------------------------------------------- style-formula
1338
+ const styleFormulaRoute: WebRoute = {
1202
1339
  kind: 'exact',
1203
- path: NOVEL_API.bookshelf,
1340
+ path: NOVEL_API.styleFormula,
1204
1341
  handler: async (req, res) => {
1205
- // GET = snapshot; POST = create book.
1206
- if (req.method === 'GET') {
1207
- if (!isLoopbackRequest(req)) {
1208
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
1209
- return
1210
- }
1211
- // 书架为空时,把 settings 默认输出目录里已有的项目播种为第一本书。
1212
- seedBookshelfFromOutputDir(getConfig().outputDir)
1213
- const snapshot: BookshelfSnapshot = bookshelfSnapshot(loadBookshelf())
1214
- writeJson(res, 200, snapshot)
1342
+ if (!guard(req, res, 'POST')) return
1343
+ const config = getConfig()
1344
+ const project = loadProject(config.outputDir)
1345
+ const body = await readJsonBody<StyleEngineRequest & { depth?: string }>(req)
1346
+ const sample = body?.sampleText?.trim()
1347
+ if (sample === undefined || sample.length < 50) {
1348
+ writeJson(res, 400, { error: '样本文本过短(<50 字符),请粘贴一段能代表目标风格的文字' })
1215
1349
  return
1216
1350
  }
1217
- if (req.method === 'POST') {
1218
- if (!isLoopbackRequest(req)) {
1219
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
1220
- return
1221
- }
1222
- const body = await readJsonBody<BookCreateRequest>(req)
1223
- const bookName = body?.bookName?.trim()
1224
- if (bookName === undefined || bookName === '') {
1225
- writeJson(res, 400, { error: 'bookName 不能为空' })
1226
- return
1227
- }
1228
- const outputDir = body?.outputDir?.trim() !== '' && body?.outputDir !== undefined
1229
- ? body.outputDir
1230
- : defaultOutputDirFor(bookName)
1231
- const book = createBook(bookName, outputDir)
1232
- // 开书向导:创建时带大纲 → 立即建立项目(书名以大纲首行为准)。
1233
- const outline = body?.outline?.trim()
1234
- if (outline !== undefined && outline.length >= 50) {
1235
- const project = createProject(outline)
1236
- saveProject(outputDir, project)
1237
- renameBook(book.id, project.bookName)
1351
+ const depth = (['basic', 'standard', 'deep'] as const).includes(body?.depth as never)
1352
+ ? body!.depth! as 'basic' | 'standard' | 'deep'
1353
+ : 'standard'
1354
+ try {
1355
+ const formula = await extractStyleFormula(ctx, config, sample, depth)
1356
+ const key = `sf-${Date.now().toString(36)}`
1357
+ const styleFormula = { key, ...formula, depth, createdAt: new Date().toISOString() }
1358
+ if (project !== undefined) {
1359
+ project.assets ??= emptyProjectAssets()
1360
+ project.assets.styleFormulas ??= []
1361
+ project.assets.styleFormulas.push(styleFormula)
1362
+ project.assets.updatedAt = new Date().toISOString()
1363
+ project.updatedAt = new Date().toISOString()
1364
+ saveProject(config.outputDir, project)
1238
1365
  }
1239
- writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
1366
+ writeJson(res, 200, { styleFormula })
1367
+ } catch (error) {
1368
+ writeJson(res, 500, { error: (error as Error).message })
1369
+ }
1370
+ },
1371
+ }
1372
+
1373
+ // ----------------------------------------------------------- style-detect
1374
+ /** 正文实时 AI 味检测(本地确定性扫描,免 LLM)。 */
1375
+ const styleDetectRoute: WebRoute = {
1376
+ kind: 'exact',
1377
+ path: NOVEL_API.styleDetect,
1378
+ handler: async (req, res) => {
1379
+ if (!guard(req, res, 'POST')) return
1380
+ const body = await readJsonBody<{ text?: string }>(req)
1381
+ const text = body?.text?.trim() ?? ''
1382
+ if (text.length < 20) {
1383
+ writeJson(res, 400, { error: '文本过短(<20 字符)' })
1240
1384
  return
1241
1385
  }
1242
- writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
1386
+ const scan = scanAiFlavor(text.slice(0, 20000))
1387
+ writeJson(res, 200, { scan })
1243
1388
  },
1244
1389
  }
1245
1390
 
1246
- // --------------------------------------------------------------- reset
1247
- /** 重置项目:清空设定/卷/章节计划/正文/伏笔/资产/事实库(可携带新大纲)。 */
1248
- const resetRoute: WebRoute = {
1391
+ // --------------------------------------------------------- market-radar
1392
+ /** 热门题材雷达:输入平台/题材/榜单文本 → 信号 + 生产底座 + 开书创意。 */
1393
+ const marketRadarRoute: WebRoute = {
1249
1394
  kind: 'exact',
1250
- path: NOVEL_API.reset,
1395
+ path: NOVEL_API.marketRadar,
1251
1396
  handler: async (req, res) => {
1252
1397
  if (!guard(req, res, 'POST')) return
1253
1398
  const config = getConfig()
1254
- const project = loadProject(config.outputDir)
1399
+ const body = await readJsonBody<MarketRadarRequest>(req)
1400
+ try {
1401
+ const result = await runMarketRadar(ctx, config, body ?? {})
1402
+ writeJson(res, 200, { result })
1403
+ } catch (error) {
1404
+ writeJson(res, 500, { error: (error as Error).message })
1405
+ }
1406
+ },
1407
+ }
1408
+
1409
+ // ------------------------------------------------- market-radar scan
1410
+ /** 真实榜单扫榜:抓取公开榜单(容错),返回分组候选。 */
1411
+ const marketRadarScanRoute: WebRoute = {
1412
+ kind: 'exact',
1413
+ path: NOVEL_API.marketRadarScan,
1414
+ handler: async (req, res) => {
1415
+ if (!guard(req, res, 'POST')) return
1416
+ const body = await readJsonBody<{ platforms?: string[] }>(req)
1417
+ try {
1418
+ const result = await scanMarketRanking(body?.platforms)
1419
+ writeJson(res, 200, { result })
1420
+ } catch (error) {
1421
+ writeJson(res, 500, { error: (error as Error).message })
1422
+ }
1423
+ },
1424
+ }
1425
+
1426
+ // ------------------------------------------------- market-radar apply
1427
+ /** 把雷达生产底座一键应用到某本书(写入项目资产/开书定盘,供后续规划与生成遵守)。 */
1428
+ const marketRadarApplyRoute: WebRoute = {
1429
+ kind: 'exact',
1430
+ path: NOVEL_API.marketRadarApply,
1431
+ handler: async (req, res) => {
1432
+ if (!guard(req, res, 'POST')) return
1433
+ const body = await readJsonBody<{ bookId?: string; foundation?: ProductionFoundation }>(req)
1434
+ const pf = body?.foundation
1435
+ if (pf === undefined) {
1436
+ writeJson(res, 400, { error: '缺少 foundation' })
1437
+ return
1438
+ }
1439
+ const config = getConfig()
1440
+ const outputDir = resolveOutputDir(config, body?.bookId)
1441
+ const project = loadProject(outputDir)
1255
1442
  if (project === undefined) {
1256
- writeJson(res, 400, { error: '输出目录中没有项目,无需重置' })
1443
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1257
1444
  return
1258
1445
  }
1259
- const body = await readJsonBody<ResetRequest>(req)
1260
- const outline = body?.outline?.trim()
1261
- if (outline !== undefined && outline.length >= 50) {
1262
- project.outline = outline
1263
- project.bookName = createProject(outline).bookName
1446
+ const genreBuiltin = BUILTIN_GENRE_LIBRARY.find(g => g.name === (pf.genre.existingId || pf.genre.name))
1447
+ const genreNode: GenreNode = genreBuiltin !== undefined
1448
+ ? { ...genreBuiltin }
1449
+ : { name: pf.genre.name.slice(0, 40), description: pf.genre.description, template: pf.genre.template, children: [] }
1450
+ const findMode = (m: { existingId?: string; name: string }): ProgressionMode | undefined => BUILTIN_PROGRESSION_MODES.find(x => x.name === (m.existingId || m.name))
1451
+ const primary = findMode(pf.primaryStoryMode) ?? {
1452
+ name: pf.primaryStoryMode.name.slice(0, 40),
1453
+ driver: pf.primaryStoryMode.driver,
1454
+ readerExpectation: pf.primaryStoryMode.readerExpectation,
1455
+ payoffs: [],
1456
+ risks: [],
1457
+ primary: true,
1458
+ }
1459
+ const aux: ProgressionMode[] = []
1460
+ if (pf.secondaryStoryMode !== undefined) {
1461
+ const sec = findMode(pf.secondaryStoryMode) ?? {
1462
+ name: pf.secondaryStoryMode.name.slice(0, 40),
1463
+ driver: pf.secondaryStoryMode.driver,
1464
+ readerExpectation: pf.secondaryStoryMode.readerExpectation,
1465
+ payoffs: [],
1466
+ risks: [],
1467
+ primary: false,
1468
+ }
1469
+ aux.push(sec)
1470
+ }
1471
+ project.assets ??= emptyProjectAssets()
1472
+ project.assets.genre = genreNode
1473
+ project.assets.primaryProgression = primary
1474
+ project.assets.auxiliaryProgressions = aux
1475
+ project.bookContract = {
1476
+ promise: pf.genre.description.slice(0, 120),
1477
+ primaryModeName: pf.primaryStoryMode.name,
1478
+ secondaryModeNames: pf.secondaryStoryMode !== undefined ? [pf.secondaryStoryMode.name] : [],
1479
+ tone: pf.genre.template,
1480
+ targetPlatform: undefined,
1264
1481
  }
1265
- project.bible = undefined
1266
- project.volumes = undefined
1267
- project.chapters = []
1268
- project.foreshadows = []
1269
- project.assets = emptyProjectAssets()
1270
- project.facts = []
1271
1482
  project.updatedAt = new Date().toISOString()
1272
- saveProject(config.outputDir, project)
1483
+ saveProject(outputDir, project)
1273
1484
  writeJson(res, 200, { ok: true, bookName: project.bookName })
1274
1485
  },
1275
1486
  }
1276
1487
 
1277
- // --------------------------------------------------- bookshelf activate
1278
- const bookshelfActivateRoute: WebRoute = {
1488
+ // ------------------------------------------------- market-radar sync
1489
+ /** 把雷达生产底座里的「新资产」同步进全局资源库(跨书复用;已有名字跳过)。 */
1490
+ const marketRadarSyncRoute: WebRoute = {
1279
1491
  kind: 'exact',
1280
- path: '/api/dsh-novel-forge/bookshelf/activate',
1492
+ path: NOVEL_API.marketRadarSync,
1281
1493
  handler: async (req, res) => {
1282
1494
  if (!guard(req, res, 'POST')) return
1283
- const body = await readJsonBody<BookActivateRequest>(req)
1284
- if (body?.id === undefined || body.id === '') {
1285
- writeJson(res, 400, { error: 'id 不能为空' })
1495
+ const body = await readJsonBody<{ foundation?: ProductionFoundation }>(req)
1496
+ const pf = body?.foundation
1497
+ if (pf === undefined) {
1498
+ writeJson(res, 400, { error: '缺少 foundation' })
1286
1499
  return
1287
1500
  }
1288
- const book = activateBook(body.id)
1289
- if (book === undefined) {
1290
- writeJson(res, 404, { error: `书 ${body.id} 不存在` })
1291
- return
1501
+ const synced = { genre: false, primaryMode: false, secondaryMode: false }
1502
+ if (pf.genre.existingId === undefined && pf.genre.name !== '') {
1503
+ synced.genre = addGlobalGenre({ name: pf.genre.name.slice(0, 40), description: pf.genre.description, template: pf.genre.template, children: [] })
1292
1504
  }
1293
- writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
1505
+ if (pf.primaryStoryMode.existingId === undefined && pf.primaryStoryMode.name !== '') {
1506
+ synced.primaryMode = addGlobalMode({ name: pf.primaryStoryMode.name.slice(0, 40), driver: pf.primaryStoryMode.driver, readerExpectation: pf.primaryStoryMode.readerExpectation, payoffs: [], risks: [], primary: true })
1507
+ }
1508
+ if (pf.secondaryStoryMode !== undefined && pf.secondaryStoryMode !== null && pf.secondaryStoryMode.existingId === undefined && pf.secondaryStoryMode.name !== '') {
1509
+ synced.secondaryMode = addGlobalMode({ name: pf.secondaryStoryMode.name.slice(0, 40), driver: pf.secondaryStoryMode.driver, readerExpectation: pf.secondaryStoryMode.readerExpectation, payoffs: [], risks: [], primary: false })
1510
+ }
1511
+ writeJson(res, 200, { ok: true, synced })
1294
1512
  },
1295
1513
  }
1296
1514
 
1297
- // ---------------------------------------------------- bookshelf remove
1298
- const bookshelfRemoveRoute: WebRoute = {
1515
+ // ------------------------------------------------- market-radar brief
1516
+ /** 开书创意简报:用选中的市场信号 + 影响模式生成。 */
1517
+ const marketRadarBriefRoute: WebRoute = {
1299
1518
  kind: 'exact',
1300
- path: '/api/dsh-novel-forge/bookshelf/remove',
1519
+ path: NOVEL_API.marketRadarBrief,
1301
1520
  handler: async (req, res) => {
1302
1521
  if (!guard(req, res, 'POST')) return
1303
- const body = await readJsonBody<BookRemoveRequest>(req)
1304
- if (body?.id === undefined || body.id === '') {
1305
- writeJson(res, 400, { error: 'id 不能为空' })
1522
+ const config = getConfig()
1523
+ const body = await readJsonBody<MarketRadarBriefRequest>(req)
1524
+ if (body === undefined || body.influenceMode === undefined) {
1525
+ writeJson(res, 400, { error: '缺少 influenceMode' })
1306
1526
  return
1307
1527
  }
1308
- const removed = removeBook(body.id)
1309
- if (!removed) {
1310
- writeJson(res, 404, { error: `书 ${body.id} 不存在` })
1528
+ try {
1529
+ const creativeBrief = await runMarketCreativeBrief(ctx, config, body)
1530
+ writeJson(res, 200, { creativeBrief })
1531
+ } catch (error) {
1532
+ writeJson(res, 500, { error: (error as Error).message })
1533
+ }
1534
+ },
1535
+ }
1536
+
1537
+ // ------------------------------------------------------------- knowledge
1538
+ /** 书内知识库:GET 读取 / POST 增删改(按书路由)。 */
1539
+ const knowledgeRoute: WebRoute = {
1540
+ kind: 'exact',
1541
+ path: NOVEL_API.knowledge,
1542
+ handler: async (req, res) => {
1543
+ if (req.method === 'POST') {
1544
+ if (!guard(req, res, 'POST')) return
1545
+ } else {
1546
+ if (!guard(req, res, 'GET')) return
1547
+ }
1548
+ const config = getConfig()
1549
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1550
+ const body = req.method === 'POST' ? await readJsonBody<KnowledgeRequest & { bookId?: string }>(req) : undefined
1551
+ const outputDir = resolveOutputDir(config, body?.bookId ?? qurl.searchParams.get('bookId') ?? undefined)
1552
+ const project = loadProject(outputDir)
1553
+ if (project === undefined) {
1554
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1311
1555
  return
1312
1556
  }
1313
- writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
1557
+ project.knowledgeDocs ??= []
1558
+ if (req.method === 'POST' && body !== undefined) {
1559
+ if (body.action === 'add' && body.doc !== undefined) {
1560
+ project.knowledgeDocs.push({ id: body.doc.id ?? `kd-${Date.now().toString(36)}`, title: body.doc.title, content: body.doc.content, updatedAt: new Date().toISOString() })
1561
+ } else if (body.action === 'remove' && body.id !== undefined) {
1562
+ project.knowledgeDocs = project.knowledgeDocs.filter(d => d.id !== body.id)
1563
+ } else if (body.action === 'replace' && body.doc !== undefined) {
1564
+ project.knowledgeDocs = project.knowledgeDocs.map(d => d.id === body.doc!.id ? { ...d, title: body.doc!.title, content: body.doc!.content, updatedAt: new Date().toISOString() } : d)
1565
+ }
1566
+ project.updatedAt = new Date().toISOString()
1567
+ saveProject(outputDir, project)
1568
+ }
1569
+ writeJson(res, 200, { docs: project.knowledgeDocs })
1314
1570
  },
1315
1571
  }
1316
1572
 
1317
- // ------------------------------------------- bookshelf import-dir
1318
- /** 导入已有项目目录(Mode A):校验 novel-project.json,登记/激活书架。 */
1319
- const bookshelfImportDirRoute: WebRoute = {
1573
+ // ---------------------------------------------------------- book-analysis
1574
+ /** 书分析/拆书:输入文本 → 卖点/结构/可借鉴点/风险。 */
1575
+ const bookAnalysisRoute: WebRoute = {
1320
1576
  kind: 'exact',
1321
- path: NOVEL_API.bookshelfImportDir,
1577
+ path: NOVEL_API.bookAnalysis,
1322
1578
  handler: async (req, res) => {
1323
1579
  if (!guard(req, res, 'POST')) return
1324
- const body = await readJsonBody<BookImportDirRequest>(req)
1325
- const outputDir = body?.outputDir?.trim()
1326
- if (outputDir === undefined || outputDir === '') {
1327
- writeJson(res, 400, { error: 'outputDir 不能为空' })
1580
+ const config = getConfig()
1581
+ const body = await readJsonBody<BookAnalysisRequest>(req)
1582
+ if (body === undefined || (body.text ?? '').trim().length < 50) {
1583
+ writeJson(res, 400, { error: '文本过短(<50 字符)' })
1328
1584
  return
1329
1585
  }
1330
1586
  try {
1331
- const { book, existed } = importDir(outputDir)
1332
- writeJson(res, 200, { book, existed })
1333
- } catch (err) {
1334
- writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
1587
+ const result = await runBookAnalysis(ctx, config, body)
1588
+ writeJson(res, 200, { result })
1589
+ } catch (error) {
1590
+ writeJson(res, 500, { error: (error as Error).message })
1335
1591
  }
1336
1592
  },
1337
1593
  }
1338
1594
 
1339
- // ------------------------------------------- bookshelf import-text
1340
- /** 导入 txt/md 全本(Mode B):拆章落盘建项目,登记书架。支持浏览器上传(text+fileName)与服务器本地文件(filePath)两种模式。 */
1341
- const bookshelfImportTextRoute: WebRoute = {
1595
+ // ---------------------------------------------------------- idea-inspiration
1596
+ /** 创意灵感:一句话/题材 → 多方向开书灵感。 */
1597
+ const ideaInspirationRoute: WebRoute = {
1342
1598
  kind: 'exact',
1343
- path: NOVEL_API.bookshelfImportText,
1599
+ path: NOVEL_API.ideaInspiration,
1344
1600
  handler: async (req, res) => {
1345
1601
  if (!guard(req, res, 'POST')) return
1346
- const body = await readJsonBody<BookImportTextRequest>(req)
1602
+ const config = getConfig()
1603
+ const body = await readJsonBody<IdeaInspirationRequest>(req)
1604
+ if (body === undefined || (body.idea ?? '').trim().length < 2) {
1605
+ writeJson(res, 400, { error: '请填写你的方向/一句话想法' })
1606
+ return
1607
+ }
1347
1608
  try {
1348
- // 模式二:浏览器上传全文内容
1349
- if (body?.text !== undefined && body.text.length > 0) {
1350
- const bookName = (body.fileName ?? '').replace(/\.[^.]+$/, '').trim().slice(0, 40) || '导入小说'
1351
- const outDir = body?.outputDir?.trim() !== undefined && body.outputDir.trim() !== ''
1352
- ? body.outputDir.trim()
1353
- : defaultOutputDirFor(bookName)
1354
- const result = importBookTextFromText(body.text, outDir, bookName)
1609
+ const result = await runIdeaInspiration(ctx, config, body)
1610
+ writeJson(res, 200, { result })
1611
+ } catch (error) {
1612
+ writeJson(res, 500, { error: (error as Error).message })
1613
+ }
1614
+ },
1615
+ }
1616
+
1617
+ // ------------------------------------------------------- idea-inspiration/market
1618
+ /** 雷达→灵感:基于市场信号/生产底座/创意简报生成开书灵感。 */
1619
+ const marketIdeaInspirationRoute: WebRoute = {
1620
+ kind: 'exact',
1621
+ path: NOVEL_API.ideaInspirationMarket,
1622
+ handler: async (req, res) => {
1623
+ if (!guard(req, res, 'POST')) return
1624
+ const config = getConfig()
1625
+ const body = await readJsonBody<{ signals?: import('./protocol.ts').MarketRadarSignal[]; foundation?: import('./protocol.ts').ProductionFoundation; brief?: import('./protocol.ts').MarketCreativeBrief; count?: number }>(req)
1626
+ if (body === undefined) {
1627
+ writeJson(res, 400, { error: '缺少市场分析参数' })
1628
+ return
1629
+ }
1630
+ try {
1631
+ const result = await runMarketIdeaInspiration(ctx, config, body)
1632
+ writeJson(res, 200, { result })
1633
+ } catch (error) {
1634
+ writeJson(res, 500, { error: (error as Error).message })
1635
+ }
1636
+ },
1637
+ }
1638
+
1639
+ // ------------------------------------------------------------- director
1640
+ /** 自动导演编排建议:基于全书上下文。 */
1641
+ const directorRoute: WebRoute = {
1642
+ kind: 'exact',
1643
+ path: NOVEL_API.director,
1644
+ handler: async (req, res) => {
1645
+ if (!guard(req, res, 'POST')) return
1646
+ const config = getConfig()
1647
+ const body = await readJsonBody<DirectorRequest & { bookId?: string }>(req)
1648
+ const outputDir = resolveOutputDir(config, body?.bookId)
1649
+ const project = loadProject(outputDir)
1650
+ if (project === undefined) {
1651
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1652
+ return
1653
+ }
1654
+ try {
1655
+ const result = await runDirectorAdvice(ctx, config, project, body ?? {})
1656
+ writeJson(res, 200, { result })
1657
+ } catch (error) {
1658
+ writeJson(res, 500, { error: (error as Error).message })
1659
+ }
1660
+ },
1661
+ }
1662
+
1663
+ // ------------------------------------------------------------- director/todos
1664
+ /** 自动导演「采纳」出的书内待办:GET 读取 / POST 增删勾选(按书路由)。 */
1665
+ const directorTodosRoute: WebRoute = {
1666
+ kind: 'exact',
1667
+ path: NOVEL_API.directorTodos,
1668
+ handler: async (req, res) => {
1669
+ type DirectorTodosReq = { op?: 'add' | 'toggle' | 'remove'; text?: string; source?: DirectorTodo['source']; id?: string; bookId?: string }
1670
+ let body: DirectorTodosReq | undefined
1671
+ if (req.method === 'POST') {
1672
+ if (!guard(req, res, 'POST')) return
1673
+ body = await readJsonBody<DirectorTodosReq>(req)
1674
+ } else {
1675
+ if (!guard(req, res, 'GET')) return
1676
+ }
1677
+ const config = getConfig()
1678
+ const outputDir = resolveOutputDir(config, body?.bookId)
1679
+ const project = loadProject(outputDir)
1680
+ if (project === undefined) {
1681
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1682
+ return
1683
+ }
1684
+ project.todos ??= []
1685
+ if (req.method === 'POST' && body !== undefined) {
1686
+ if (body.op === 'add' && (body.text ?? '').trim() !== '') {
1687
+ project.todos.unshift({ id: `td-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, text: body.text!.trim(), source: body.source ?? 'fix', done: false, createdAt: new Date().toISOString() })
1688
+ } else if (body.op === 'toggle' && body.id !== undefined) {
1689
+ project.todos = project.todos.map(t => t.id === body.id ? { ...t, done: !t.done } : t)
1690
+ } else if (body.op === 'remove' && body.id !== undefined) {
1691
+ project.todos = project.todos.filter(t => t.id !== body.id)
1692
+ }
1693
+ project.updatedAt = new Date().toISOString()
1694
+ saveProject(outputDir, project)
1695
+ }
1696
+ writeJson(res, 200, { todos: project.todos })
1697
+ },
1698
+ }
1699
+
1700
+ // ------------------------------------------------------------- llm-live
1701
+ /** AI 创作实况:订阅 LLM 实时事件流(NDJSON;订阅时先重放最近缓冲)。 */
1702
+ const llmLiveRoute: WebRoute = {
1703
+ kind: 'exact',
1704
+ path: NOVEL_API.llmLive,
1705
+ handler: async (req, res) => {
1706
+ if (req.method !== 'GET') {
1707
+ writeJson(res, 405, { error: 'GET only' })
1708
+ return
1709
+ }
1710
+ if (!isLoopbackRequest(req)) {
1711
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
1712
+ return
1713
+ }
1714
+ res.writeHead(200, {
1715
+ 'content-type': 'text/event-stream; charset=utf-8',
1716
+ 'cache-control': 'no-cache',
1717
+ 'x-accel-buffering': 'no',
1718
+ 'referrer-policy': 'no-referrer',
1719
+ })
1720
+ const unsubscribe = subscribeLiveFeed((frame) => {
1721
+ try { res.write('data: ' + JSON.stringify(frame) + '\n\n') } catch { /* client closed */ }
1722
+ })
1723
+ const hb = setInterval(() => { try { res.write(': ping\n\n') } catch { /* closed */ } }, 25000)
1724
+ // 关键:让 handler 一直挂起,直到客户端关闭,避免 WebRoute 框架在 handler 返回后关闭响应。
1725
+ await new Promise<void>((resolve) => {
1726
+ res.on('close', () => {
1727
+ clearInterval(hb)
1728
+ unsubscribe()
1729
+ resolve()
1730
+ })
1731
+ })
1732
+ },
1733
+ }
1734
+
1735
+ // --------------------------------------------------- llm-live prompt/usage
1736
+ /** 查看某次调用实际发送的 Prompt(面板「查看 Prompt」按钮用)。 */
1737
+ const llmPromptRoute: WebRoute = {
1738
+ kind: 'exact',
1739
+ path: NOVEL_API.llmPrompt,
1740
+ handler: (req, res) => {
1741
+ if (!guard(req, res, 'GET')) return
1742
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1743
+ const sessionId = qurl.searchParams.get('sessionId') ?? ''
1744
+ const record = livePrompt(sessionId)
1745
+ if (record === undefined) {
1746
+ writeJson(res, 404, { error: '该调用的 Prompt 记录已过期(只保留最近 40 次调用)' })
1747
+ return
1748
+ }
1749
+ writeJson(res, 200, record)
1750
+ },
1751
+ }
1752
+
1753
+ /** 清零本次运行的用量账本。 */
1754
+ const usageResetRoute: WebRoute = {
1755
+ kind: 'exact',
1756
+ path: NOVEL_API.usageReset,
1757
+ handler: (req, res) => {
1758
+ if (!guard(req, res, 'POST')) return
1759
+ resetLiveUsage()
1760
+ writeJson(res, 200, { ok: true, usage: liveUsage() })
1761
+ },
1762
+ }
1763
+
1764
+ // ------------------------------------------------------------- timeline
1765
+ /**
1766
+ * 故事时间线:GET 列表(含规则初筛问题);POST op=extract|check|update|remove|clear。
1767
+ */
1768
+ const timelineRoute: WebRoute = {
1769
+ kind: 'exact',
1770
+ path: NOVEL_API.timeline,
1771
+ handler: async (req, res) => {
1772
+ const config = getConfig()
1773
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1774
+ if (req.method === 'GET') {
1775
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
1776
+ const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
1777
+ const project = loadProject(outputDir)
1778
+ if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
1779
+ const response: TimelineResponse = {
1780
+ events: project.timeline ?? [],
1781
+ issues: detectTimelineIssues(project),
1782
+ }
1783
+ writeJson(res, 200, response)
1784
+ return
1785
+ }
1786
+ if (!guard(req, res, 'POST')) return
1787
+ const body = await readJsonBody<TimelineRequest & { bookId?: string }>(req)
1788
+ const outputDir = resolveOutputDir(config, body?.bookId)
1789
+ const project = loadProject(outputDir)
1790
+ if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
1791
+ try {
1792
+ if (body?.op === 'extract') {
1793
+ const no = body.chapterNo
1794
+ if (typeof no !== 'number' || !Number.isFinite(no)) { writeJson(res, 400, { error: 'extract 需要 chapterNo' }); return }
1795
+ const events = await extractTimelineForChapter(ctx, config, project, outputDir, no)
1796
+ saveProject(outputDir, project)
1797
+ writeJson(res, 200, { events, all: project.timeline ?? [] })
1798
+ return
1799
+ }
1800
+ if (body?.op === 'check') {
1801
+ const ruleIssues = detectTimelineIssues(project)
1802
+ let aiIssues: typeof ruleIssues = []
1803
+ try {
1804
+ aiIssues = await reviewTimeline(ctx, config, project)
1805
+ } catch (error) {
1806
+ // AI 复核失败不阻断:规则初筛结果照常返回,并说明原因。
1807
+ writeJson(res, 200, { issues: ruleIssues, aiError: (error as Error).message })
1808
+ return
1809
+ }
1810
+ writeJson(res, 200, { issues: [...ruleIssues, ...aiIssues] })
1811
+ return
1812
+ }
1813
+ if (body?.op === 'update') {
1814
+ const event = body.event
1815
+ if (event === undefined || typeof event.id !== 'string') { writeJson(res, 400, { error: 'update 需要 event' }); return }
1816
+ const list = project.timeline ?? []
1817
+ const idx = list.findIndex(e => e.id === event.id)
1818
+ if (idx === -1) { writeJson(res, 404, { error: '时间线事件不存在' }); return }
1819
+ list[idx] = { ...event, source: 'manual' }
1820
+ project.timeline = list
1821
+ project.updatedAt = new Date().toISOString()
1822
+ saveProject(outputDir, project)
1823
+ writeJson(res, 200, { events: project.timeline })
1824
+ return
1825
+ }
1826
+ if (body?.op === 'remove') {
1827
+ project.timeline = (project.timeline ?? []).filter(e => e.id !== body.id)
1828
+ project.updatedAt = new Date().toISOString()
1829
+ saveProject(outputDir, project)
1830
+ writeJson(res, 200, { events: project.timeline })
1831
+ return
1832
+ }
1833
+ if (body?.op === 'clear') {
1834
+ project.timeline = []
1835
+ project.updatedAt = new Date().toISOString()
1836
+ saveProject(outputDir, project)
1837
+ writeJson(res, 200, { events: [] })
1838
+ return
1839
+ }
1840
+ writeJson(res, 400, { error: `未知 op:${String(body?.op)}` })
1841
+ } catch (error) {
1842
+ writeJson(res, 500, { error: (error as Error).message })
1843
+ }
1844
+ },
1845
+ }
1846
+
1847
+ // ------------------------------------------------------------ revision plan
1848
+ /** 修订指令合并:POST { op: 'plan', chapterNo, reviewIssues? } → 一份合并指令 + 验证基准。
1849
+ * 面板与生产单共用,保证「同一章只改一轮」;张力/时间线问题默认只是建议(advisory)。 */
1850
+ const revisionPlanRoute: WebRoute = {
1851
+ kind: 'exact',
1852
+ path: NOVEL_API.revisionPlan,
1853
+ handler: async (req, res) => {
1854
+ if (!guard(req, res, 'POST')) return
1855
+ const config = getConfig()
1856
+ const body = await readJsonBody<RevisionPlanRequest>(req)
1857
+ const outputDir = resolveOutputDir(config, body?.bookId)
1858
+ const project = loadProject(outputDir)
1859
+ if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
1860
+ const chapterNo = body?.chapterNo
1861
+ if (chapterNo !== undefined && !project.chapters.some(c => c.no === chapterNo)) {
1862
+ writeJson(res, 404, { error: `章节 ${String(chapterNo)} 不在计划中` })
1863
+ return
1864
+ }
1865
+ const chapter = chapterNo === undefined ? undefined : project.chapters.find(c => c.no === chapterNo)
1866
+ try {
1867
+ const plan = buildRevisionPlan(project, {
1868
+ chapterNo,
1869
+ reviewIssues: body?.reviewIssues,
1870
+ includeTimeline: body?.includeTimeline,
1871
+ includeTension: body?.includeTension,
1872
+ baseReport: chapter?.review,
1873
+ cap: REVISION_ITEM_CAP,
1874
+ })
1875
+ const response: RevisionPlanResponse = {
1876
+ chapterNo: chapterNo ?? 0,
1877
+ items: plan.items,
1878
+ instruction: plan.instruction,
1879
+ counts: plan.counts,
1880
+ omitted: plan.omitted,
1881
+ baseline: plan.baseline,
1882
+ summary: describeRevisionPlan(plan),
1883
+ }
1884
+ writeJson(res, 200, response)
1885
+ } catch (error) {
1886
+ writeJson(res, 500, { error: (error as Error).message })
1887
+ }
1888
+ },
1889
+ }
1890
+
1891
+ // ------------------------------------------------------------ snapshots
1892
+ /** 章节历史版本:GET 列表;POST op=create|restore|remove|prune。 */
1893
+ const snapshotRoute: WebRoute = {
1894
+ kind: 'exact',
1895
+ path: NOVEL_API.snapshot,
1896
+ handler: async (req, res) => {
1897
+ const config = getConfig()
1898
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1899
+ if (req.method === 'GET') {
1900
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
1901
+ const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
1902
+ const response: SnapshotResponse = { snapshots: loadSnapshots(outputDir), bytes: snapshotsBytes(outputDir) }
1903
+ writeJson(res, 200, response)
1904
+ return
1905
+ }
1906
+ if (!guard(req, res, 'POST')) return
1907
+ const body = await readJsonBody<SnapshotRequest & { bookId?: string }>(req)
1908
+ const outputDir = resolveOutputDir(config, body?.bookId)
1909
+ const project = loadProject(outputDir)
1910
+ try {
1911
+ if (body?.op === 'create') {
1912
+ if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
1913
+ const chapter = project.chapters.find(c => c.no === body.chapterNo)
1914
+ if (chapter === undefined) { writeJson(res, 404, { error: `章节 ${String(body.chapterNo)} 不在计划中` }); return }
1915
+ const snapshot = createSnapshot(outputDir, chapter, '手工存档')
1916
+ if (snapshot === undefined) { writeJson(res, 400, { error: '该章暂无正文文件,无法存档' }); return }
1917
+ writeJson(res, 200, { snapshot, snapshots: loadSnapshots(outputDir) })
1918
+ return
1919
+ }
1920
+ if (body?.op === 'restore') {
1921
+ if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
1922
+ const id = body.id ?? ''
1923
+ const snap = loadSnapshots(outputDir).find(x => x.id === id)
1924
+ if (snap === undefined) { writeJson(res, 404, { error: '快照不存在' }); return }
1925
+ const chapter = project.chapters.find(c => c.no === snap.chapterNo)
1926
+ if (chapter === undefined) { writeJson(res, 404, { error: `章节 ${snap.chapterNo} 不在计划中` }); return }
1927
+ const chars = restoreSnapshot(outputDir, chapter, id)
1928
+ chapter.chars = countHanzi(readChapterFile(outputDir, chapter) ?? '')
1929
+ chapter.status = chapter.status === 'approved' ? 'written' : chapter.status
1930
+ chapter.review = undefined
1931
+ project.updatedAt = new Date().toISOString()
1932
+ saveProject(outputDir, project)
1933
+ writeJson(res, 200, { ok: true, chapterNo: chapter.no, chars, snapshots: loadSnapshots(outputDir) })
1934
+ return
1935
+ }
1936
+ if (body?.op === 'remove') {
1937
+ const ok = removeSnapshot(outputDir, body.id ?? '')
1938
+ writeJson(res, ok ? 200 : 404, { ok, snapshots: loadSnapshots(outputDir) })
1939
+ return
1940
+ }
1941
+ if (body?.op === 'prune') {
1942
+ const removed = pruneSnapshots(outputDir, body.keep)
1943
+ writeJson(res, 200, { removed, snapshots: loadSnapshots(outputDir), bytes: snapshotsBytes(outputDir) })
1944
+ return
1945
+ }
1946
+ writeJson(res, 400, { error: `未知 op:${String(body?.op)}` })
1947
+ } catch (error) {
1948
+ writeJson(res, 500, { error: (error as Error).message })
1949
+ }
1950
+ },
1951
+ }
1952
+
1953
+ // --------------------------------------------------------------- tension
1954
+ /** 张力曲线:GET 数据(含规则核对);POST op=preset|set|clear。 */
1955
+ const tensionRoute: WebRoute = {
1956
+ kind: 'exact',
1957
+ path: NOVEL_API.tension,
1958
+ handler: async (req, res) => {
1959
+ const config = getConfig()
1960
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1961
+ if (req.method === 'GET') {
1962
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
1963
+ const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
1964
+ const project = loadProject(outputDir)
1965
+ if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
1966
+ const curve = buildCurveData(project)
1967
+ const response: TensionResponse = { preset: curve.preset, points: curve.points, issues: detectTensionIssues(project) }
1968
+ writeJson(res, 200, response)
1969
+ return
1970
+ }
1971
+ if (!guard(req, res, 'POST')) return
1972
+ const body = await readJsonBody<TensionRequest & { bookId?: string }>(req)
1973
+ const outputDir = resolveOutputDir(config, body?.bookId)
1974
+ const project = loadProject(outputDir)
1975
+ if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
1976
+ if (body?.op === 'preset') {
1977
+ const preset = body.preset
1978
+ if (preset === undefined || !TENSION_PRESETS.includes(preset)) { writeJson(res, 400, { error: '未知的张力曲线预设' }); return }
1979
+ project.tensionCurve = { ...(project.tensionCurve ?? {}), preset }
1980
+ project.updatedAt = new Date().toISOString()
1981
+ saveProject(outputDir, project)
1982
+ writeJson(res, 200, { ok: true, preset })
1983
+ return
1984
+ }
1985
+ if (body?.op === 'set') {
1986
+ const no = body.chapterNo
1987
+ if (typeof no !== 'number' || !Number.isFinite(no)) { writeJson(res, 400, { error: 'set 需要 chapterNo' }); return }
1988
+ const chapter = project.chapters.find(c => c.no === no)
1989
+ if (chapter === undefined) { writeJson(res, 404, { error: `章节 ${no} 不在计划中` }); return }
1990
+ if (body.tension === undefined) {
1991
+ chapter.tension = undefined
1992
+ } else {
1993
+ if (body.tension < TENSION_MIN || body.tension > TENSION_MAX) { writeJson(res, 400, { error: `张力需在 ${TENSION_MIN}-${TENSION_MAX} 之间` }); return }
1994
+ chapter.tension = clampTension(body.tension)
1995
+ }
1996
+ project.updatedAt = new Date().toISOString()
1997
+ saveProject(outputDir, project)
1998
+ writeJson(res, 200, { ok: true, chapterNo: no, tension: chapter.tension })
1999
+ return
2000
+ }
2001
+ if (body?.op === 'clear') {
2002
+ for (const c of project.chapters) c.tension = undefined
2003
+ project.updatedAt = new Date().toISOString()
2004
+ saveProject(outputDir, project)
2005
+ writeJson(res, 200, { ok: true })
2006
+ return
2007
+ }
2008
+ writeJson(res, 400, { error: `未知 op:${String(body?.op)}` })
2009
+ },
2010
+ }
2011
+
2012
+ // ---------------------------------------------------------- prompt slots
2013
+ /** 提示词槽位:GET 列表(含说明与上限);POST 写入或清空单个槽位。 */
2014
+ const promptSlotsRoute: WebRoute = {
2015
+ kind: 'exact',
2016
+ path: NOVEL_API.promptSlots,
2017
+ handler: async (req, res) => {
2018
+ const config = getConfig()
2019
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
2020
+ if (req.method === 'GET') {
2021
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
2022
+ const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
2023
+ const project = loadProject(outputDir)
2024
+ if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
2025
+ const values = new Map(readPromptSlots(project).map(s => [s.id, s.text] as const))
2026
+ const response: PromptSlotsResponse = {
2027
+ slots: PROMPT_SLOTS.map(spec => ({
2028
+ id: spec.id,
2029
+ label: spec.label,
2030
+ hint: spec.hint,
2031
+ placeholder: spec.placeholder,
2032
+ maxChars: spec.maxChars,
2033
+ value: values.get(spec.id) ?? '',
2034
+ })),
2035
+ }
2036
+ writeJson(res, 200, response)
2037
+ return
2038
+ }
2039
+ if (!guard(req, res, 'POST')) return
2040
+ const body = await readJsonBody<PromptSlotsRequest & { bookId?: string }>(req)
2041
+ const outputDir = resolveOutputDir(config, body?.bookId)
2042
+ const project = loadProject(outputDir)
2043
+ if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
2044
+ let value: string
2045
+ try {
2046
+ value = normalizeSlotValue(String(body?.id ?? ''), String(body?.value ?? ''))
2047
+ } catch (error) {
2048
+ writeJson(res, 400, { error: (error as Error).message })
2049
+ return
2050
+ }
2051
+ const slots = { ...(project.promptSlots ?? {}) }
2052
+ if (value === '') delete slots[body!.id]
2053
+ else slots[body!.id] = value
2054
+ project.promptSlots = slots
2055
+ project.updatedAt = new Date().toISOString()
2056
+ saveProject(outputDir, project)
2057
+ writeJson(res, 200, { ok: true, id: body!.id, chars: value.length })
2058
+ },
2059
+ }
2060
+
2061
+ // ------------------------------------------------------------- bookshelf
2062
+ const bookshelfRoute: WebRoute = {
2063
+ kind: 'exact',
2064
+ path: NOVEL_API.bookshelf,
2065
+ handler: async (req, res) => {
2066
+ // GET = snapshot; POST = create book.
2067
+ if (req.method === 'GET') {
2068
+ if (!isLoopbackRequest(req)) {
2069
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
2070
+ return
2071
+ }
2072
+ // 书架为空时,把 settings 默认输出目录里已有的项目播种为第一本书。
2073
+ seedBookshelfFromOutputDir(getConfig().outputDir)
2074
+ const snapshot: BookshelfSnapshot = bookshelfSnapshot(loadBookshelf())
2075
+ writeJson(res, 200, snapshot)
2076
+ return
2077
+ }
2078
+ if (req.method === 'POST') {
2079
+ if (!isLoopbackRequest(req)) {
2080
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
2081
+ return
2082
+ }
2083
+ const body = await readJsonBody<BookCreateRequest>(req)
2084
+ const bookName = body?.bookName?.trim()
2085
+ if (bookName === undefined || bookName === '') {
2086
+ writeJson(res, 400, { error: 'bookName 不能为空' })
2087
+ return
2088
+ }
2089
+ const outputDir = body?.outputDir?.trim() !== '' && body?.outputDir !== undefined
2090
+ ? body.outputDir
2091
+ : defaultBookDir(bookName)
2092
+ const book = createBook(bookName, outputDir)
2093
+ // 开书向导:创建时带大纲 → 立即建立项目(书名以大纲首行为准)。
2094
+ const outline = body?.outline?.trim()
2095
+ if (outline !== undefined && outline.length >= 50) {
2096
+ const project = createProject(outline)
2097
+ saveProject(outputDir, project)
2098
+ renameBook(book.id, project.bookName)
2099
+ }
2100
+ writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
2101
+ return
2102
+ }
2103
+ writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
2104
+ },
2105
+ }
2106
+
2107
+ // --------------------------------------------------------------- reset
2108
+ /** 重置项目:清空设定/卷/章节计划/正文/伏笔/资产/事实库(可携带新大纲)。 */
2109
+ const resetRoute: WebRoute = {
2110
+ kind: 'exact',
2111
+ path: NOVEL_API.reset,
2112
+ handler: async (req, res) => {
2113
+ if (!guard(req, res, 'POST')) return
2114
+ const config = getConfig()
2115
+ const project = loadProject(config.outputDir)
2116
+ if (project === undefined) {
2117
+ writeJson(res, 400, { error: '输出目录中没有项目,无需重置' })
2118
+ return
2119
+ }
2120
+ const body = await readJsonBody<ResetRequest>(req)
2121
+ const outline = body?.outline?.trim()
2122
+ if (outline !== undefined && outline.length >= 50) {
2123
+ project.outline = outline
2124
+ project.bookName = createProject(outline).bookName
2125
+ }
2126
+ project.bible = undefined
2127
+ project.volumes = undefined
2128
+ project.chapters = []
2129
+ project.foreshadows = []
2130
+ project.assets = emptyProjectAssets()
2131
+ project.facts = []
2132
+ project.updatedAt = new Date().toISOString()
2133
+ saveProject(config.outputDir, project)
2134
+ writeJson(res, 200, { ok: true, bookName: project.bookName })
2135
+ },
2136
+ }
2137
+
2138
+ // --------------------------------------------------- bookshelf activate
2139
+ const bookshelfActivateRoute: WebRoute = {
2140
+ kind: 'exact',
2141
+ path: '/api/dsh-novel-forge/bookshelf/activate',
2142
+ handler: async (req, res) => {
2143
+ if (!guard(req, res, 'POST')) return
2144
+ const body = await readJsonBody<BookActivateRequest>(req)
2145
+ if (body?.id === undefined || body.id === '') {
2146
+ writeJson(res, 400, { error: 'id 不能为空' })
2147
+ return
2148
+ }
2149
+ const book = activateBook(body.id)
2150
+ if (book === undefined) {
2151
+ writeJson(res, 404, { error: `书 ${body.id} 不存在` })
2152
+ return
2153
+ }
2154
+ writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
2155
+ },
2156
+ }
2157
+
2158
+ // ---------------------------------------------------- bookshelf remove
2159
+ const bookshelfRemoveRoute: WebRoute = {
2160
+ kind: 'exact',
2161
+ path: '/api/dsh-novel-forge/bookshelf/remove',
2162
+ handler: async (req, res) => {
2163
+ if (!guard(req, res, 'POST')) return
2164
+ const body = await readJsonBody<BookRemoveRequest>(req)
2165
+ if (body?.id === undefined || body.id === '') {
2166
+ writeJson(res, 400, { error: 'id 不能为空' })
2167
+ return
2168
+ }
2169
+ const removed = removeBook(body.id)
2170
+ if (!removed) {
2171
+ writeJson(res, 404, { error: `书 ${body.id} 不存在` })
2172
+ return
2173
+ }
2174
+ writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
2175
+ },
2176
+ }
2177
+
2178
+ // ------------------------------------------- bookshelf import-dir
2179
+ /** 导入已有项目目录(Mode A):校验 novel-project.json,登记/激活书架。 */
2180
+ const bookshelfImportDirRoute: WebRoute = {
2181
+ kind: 'exact',
2182
+ path: NOVEL_API.bookshelfImportDir,
2183
+ handler: async (req, res) => {
2184
+ if (!guard(req, res, 'POST')) return
2185
+ const body = await readJsonBody<BookImportDirRequest>(req)
2186
+ const outputDir = body?.outputDir?.trim()
2187
+ if (outputDir === undefined || outputDir === '') {
2188
+ writeJson(res, 400, { error: 'outputDir 不能为空' })
2189
+ return
2190
+ }
2191
+ try {
2192
+ const { book, existed } = importDir(outputDir)
2193
+ writeJson(res, 200, { book, existed })
2194
+ } catch (err) {
2195
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
2196
+ }
2197
+ },
2198
+ }
2199
+
2200
+ // ------------------------------------------- bookshelf import-text
2201
+ /** 导入 txt/md 全本(Mode B):拆章落盘建项目,登记书架。支持浏览器上传(text+fileName)与服务器本地文件(filePath)两种模式。 */
2202
+ const bookshelfImportTextRoute: WebRoute = {
2203
+ kind: 'exact',
2204
+ path: NOVEL_API.bookshelfImportText,
2205
+ handler: async (req, res) => {
2206
+ if (!guard(req, res, 'POST')) return
2207
+ const body = await readJsonBody<BookImportTextRequest>(req)
2208
+ try {
2209
+ // 模式二:浏览器上传全文内容
2210
+ if (body?.text !== undefined && body.text.length > 0) {
2211
+ const bookName = (body.fileName ?? '').replace(/\.[^.]+$/, '').trim().slice(0, 40) || '导入小说'
2212
+ const outDir = body?.outputDir?.trim() !== undefined && body.outputDir.trim() !== ''
2213
+ ? body.outputDir.trim()
2214
+ : defaultBookDir(bookName)
2215
+ const result = importBookTextFromText(body.text, outDir, bookName)
1355
2216
  const { book } = importDir(outDir)
1356
2217
  writeJson(res, 200, { ...result, book })
1357
2218
  return
@@ -1369,7 +2230,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1369
2230
  const bookName = basename(filePath, extname(filePath)).slice(0, 40) || '导入小说'
1370
2231
  const outDir = body?.outputDir?.trim() !== undefined && body.outputDir.trim() !== ''
1371
2232
  ? body.outputDir.trim()
1372
- : defaultOutputDirFor(bookName)
2233
+ : defaultBookDir(bookName)
1373
2234
  const result = importBookText(filePath, outDir)
1374
2235
  const { book } = importDir(outDir)
1375
2236
  writeJson(res, 200, { ...result, book })
@@ -1408,9 +2269,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1408
2269
  path: NOVEL_API.audit,
1409
2270
  handler: async (req, res) => {
1410
2271
  if (!guard(req, res, 'POST')) return
1411
- const config = getConfig()
1412
- const project = requireProject(res)
1413
- if (project === undefined) return
2272
+ const body = await readJsonBody<{ bookId?: string }>(req)
2273
+ const config0 = getConfig()
2274
+ const outputDir = resolveOutputDir(config0, body?.bookId)
2275
+ const config = { ...config0, outputDir }
2276
+ const project = loadProject(outputDir)
2277
+ if (project === undefined) {
2278
+ writeJson(res, 400, { error: '输出目录中没有项目' })
2279
+ return
2280
+ }
1414
2281
  const startedAt = new Date().toISOString()
1415
2282
  auditState = { status: 'running', startedAt, totalBatches: 0, completedBatches: 0 }
1416
2283
  try {
@@ -1433,6 +2300,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1433
2300
  issues,
1434
2301
  auditedChapters,
1435
2302
  auditedAt: auditState.finishedAt!,
2303
+ model: config.auditModel || config.model,
1436
2304
  }
1437
2305
  writeJson(res, 200, response)
1438
2306
  } catch (error) {
@@ -1530,7 +2398,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1530
2398
  }
1531
2399
 
1532
2400
  // ---------------------------------------------------------------- blurb
1533
- /** 小说简介:AI 生成/补全,或手动保存。 */
2401
+ /** 小说简介:生成/补全,或手动保存。 */
1534
2402
  const blurbRoute: WebRoute = {
1535
2403
  kind: 'exact',
1536
2404
  path: NOVEL_API.blurb,
@@ -1644,7 +2512,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1644
2512
  }
1645
2513
 
1646
2514
  // ---------------------------------------------------------------- world
1647
- /** 大世界:AI 提炼或手动保存(境界体系/区域/势力)。 */
2515
+ /** 大世界:提炼或手动保存(境界体系/区域/势力)。 */
1648
2516
  const worldRoute: WebRoute = {
1649
2517
  kind: 'exact',
1650
2518
  path: NOVEL_API.world,
@@ -1756,7 +2624,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1756
2624
  writeJson(res, 200, { plotlines: project.plotlines, suggestions } satisfies PlotlinesResponse)
1757
2625
  return
1758
2626
  } catch (error) {
1759
- writeJson(res, 500, { error: `AI 建议失败:${(error as Error).message}` })
2627
+ writeJson(res, 500, { error: `建议失败:${(error as Error).message}` })
1760
2628
  return
1761
2629
  }
1762
2630
  } else if (op === 'refresh' && body?.id !== undefined) {
@@ -1779,424 +2647,114 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1779
2647
  } else if (op === 'health') {
1780
2648
  try {
1781
2649
  const health = await analyzePlotlineHealth(ctx, config, project)
1782
- writeJson(res, 200, { plotlines: project.plotlines, health } satisfies PlotlinesResponse)
1783
- return
1784
- } catch (error) {
1785
- writeJson(res, 500, { error: `健康检查失败:${(error as Error).message}` })
1786
- return
1787
- }
1788
- } else if (op === 'plan') {
1789
- try {
1790
- const health = await analyzePlotlineHealth(ctx, config, project)
1791
- const plan = await designPlotlinePlan(ctx, config, project, health)
1792
- writeJson(res, 200, { plotlines: project.plotlines, health, plan } satisfies PlotlinesResponse)
1793
- return
1794
- } catch (error) {
1795
- writeJson(res, 500, { error: `剧情方案生成失败:${(error as Error).message}` })
1796
- return
1797
- }
1798
- }
1799
- project.updatedAt = new Date().toISOString()
1800
- saveProject(config.outputDir, project)
1801
- const response: PlotlinesResponse = { plotlines: project.plotlines }
1802
- writeJson(res, 200, response)
1803
- },
1804
- }
1805
-
1806
- // --------------------------------------------------------- sensitive
1807
- /** 敏感词检查:指定章节 / 任意文本 / 全书已写章节。 */
1808
- const sensitiveRoute: WebRoute = {
1809
- kind: 'exact',
1810
- path: NOVEL_API.sensitiveCheck,
1811
- handler: async (req, res) => {
1812
- if (!guard(req, res, 'POST')) return
1813
- const config = getConfig()
1814
- const project = requireProject(res)
1815
- if (project === undefined) return
1816
- const body = await readJsonBody<SensitiveCheckRequest>(req)
1817
- const hits: SensitiveHit[] = []
1818
- let scanned = 0
1819
- if (body?.text !== undefined) {
1820
- for (const hit of checkSensitiveText(body.text)) {
1821
- hits.push({ chapterNo: 0, word: hit.word, category: hit.category, count: hit.count })
1822
- }
1823
- } else if (typeof body?.chapterNo === 'number') {
1824
- const chapter = project.chapters.find(c => c.no === body.chapterNo)
1825
- if (chapter !== undefined) {
1826
- const text = readChapterFile(config.outputDir, chapter)
1827
- if (text !== undefined) {
1828
- scanned = 1
1829
- for (const hit of checkSensitiveText(text)) {
1830
- hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
1831
- }
1832
- }
1833
- }
1834
- } else if (body?.all === true) {
1835
- for (const chapter of project.chapters) {
1836
- if (chapter.status === 'pending' || chapter.status === 'generating') continue
1837
- const text = readChapterFile(config.outputDir, chapter)
1838
- if (text === undefined) continue
1839
- scanned++
1840
- for (const hit of checkSensitiveText(text)) {
1841
- hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
1842
- }
1843
- }
1844
- } else {
1845
- writeJson(res, 400, { error: '请提供 chapterNo / text / all 之一' })
1846
- return
1847
- }
1848
- const response: SensitiveCheckResponse = { hits, scannedChapters: scanned }
1849
- writeJson(res, 200, response)
1850
- },
1851
- }
1852
-
1853
- // ---------------------------------------------------------------- roles
1854
- /** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
1855
- const rolesRoute: WebRoute = {
1856
- kind: 'exact',
1857
- path: NOVEL_API.roles,
1858
- handler: async (req, res) => {
1859
- if (!guard(req, res, 'POST')) return
1860
- const config = getConfig()
1861
- const project = requireProject(res)
1862
- if (project === undefined) return
1863
- const body = await readJsonBody<RolesRequest>(req)
1864
- if (project.roles === undefined) project.roles = []
1865
- const op = body?.op
1866
- if (op === 'extract') {
1867
- try {
1868
- const candidates = await extractRoles(ctx, config, project)
1869
- writeJson(res, 200, { roles: project.roles, candidates } satisfies RolesResponse)
1870
- return
1871
- } catch (error) {
1872
- writeJson(res, 500, { error: `角色提炼失败:${(error as Error).message}` })
1873
- return
1874
- }
1875
- } else if ((op === 'adopt' || op === 'update') && body?.role !== undefined) {
1876
- const r = body.role
1877
- const idx = project.roles.findIndex(x => x.name === r.name)
1878
- if (idx === -1) project.roles.push(r)
1879
- else project.roles[idx] = r
1880
- } else if (op === 'remove' && body?.name !== undefined) {
1881
- project.roles = project.roles.filter(x => x.name !== body.name)
1882
- } else if (op === 'image') {
1883
- const name = body?.name?.trim()
1884
- const dataUrl = body?.dataUrl?.trim()
1885
- if (name === undefined || name === '') {
1886
- writeJson(res, 400, { error: 'name(角色名)必填' })
1887
- return
1888
- }
1889
- if (dataUrl === undefined || dataUrl === '') {
1890
- writeJson(res, 400, { error: 'dataUrl(参考图)必填' })
1891
- return
1892
- }
1893
- const role = project.roles.find(r => r.name === name)
1894
- if (role === undefined) {
1895
- writeJson(res, 404, { error: `当前激活《${project.bookName}》,角色 ${name} 不存在——若期望的书不对,请先在书架切换该书并刷新` })
1896
- return
1897
- }
1898
- const label = body?.label?.trim() ?? ''
1899
- if (label !== '') {
1900
- // 图集上传:带用途标签。标签「立绘」时同步为角色参考图(imageUrl)。
1901
- role.gallery ??= []
1902
- role.gallery = role.gallery.filter(g => g.label !== label)
1903
- role.gallery.push({ label, dataUrl })
1904
- if (label === '立绘' || label.includes('立绘')) role.imageUrl = dataUrl
1905
- } else {
1906
- // 兼容旧行为:不带标签 = 角色参考图。
1907
- role.imageUrl = dataUrl
1908
- }
1909
- project.updatedAt = new Date().toISOString()
1910
- saveProject(config.outputDir, project)
1911
- writeJson(res, 200, { roles: project.roles, imageUrl: role.imageUrl } satisfies RolesResponse)
1912
- return
1913
- } else if (op === 'removeImage') {
1914
- const name = body?.name?.trim()
1915
- const label = body?.label?.trim() ?? ''
1916
- const role = project.roles.find(r => r.name === name)
1917
- if (role !== undefined) {
1918
- if (label !== '') {
1919
- role.gallery = (role.gallery ?? []).filter(g => g.label !== label)
1920
- if (label === '立绘' || label.includes('立绘')) role.imageUrl = undefined
1921
- } else {
1922
- role.imageUrl = undefined
1923
- }
1924
- }
1925
- project.updatedAt = new Date().toISOString()
1926
- saveProject(config.outputDir, project)
1927
- writeJson(res, 200, { roles: project.roles } satisfies RolesResponse)
1928
- return
1929
- } else if (op === 'visual') {
1930
- // 动漫形象描述词提炼:扫描正文外貌描写 → LLM 提炼 → 写入角色卡。
1931
- const name = body?.name?.trim()
1932
- if (name === undefined || name === '') {
1933
- writeJson(res, 400, { error: 'name(角色名)必填' })
1934
- return
1935
- }
1936
- try {
1937
- const visual = await extractRoleVisual(ctx, config, project, config.outputDir, name, body?.styleId, body?.filterId)
1938
- const role = project.roles.find(r => r.name === name)
1939
- if (role !== undefined) {
1940
- role.imagePrompt = visual
1941
- if ((visual.expressions ?? []).length > 0) role.expressions = visual.expressions
1942
- if (visual.promptKit !== undefined) role.promptKit = visual.promptKit
1943
- }
1944
- project.updatedAt = new Date().toISOString()
1945
- saveProject(config.outputDir, project)
1946
- writeJson(res, 200, { roles: project.roles, visual } satisfies RolesResponse)
1947
- return
1948
- } catch (error) {
1949
- writeJson(res, 500, { error: `形象提炼失败:${(error as Error).message}` })
1950
- return
1951
- }
1952
- } else if (op === 'promptKit') {
1953
- // 角色四类提示词精修包(立绘/四视图/表情/细节)
1954
- const name = body?.name?.trim()
1955
- if (name === undefined || name === '') {
1956
- writeJson(res, 400, { error: 'name(角色名)必填' })
1957
- return
1958
- }
1959
- try {
1960
- const kit = await generateRolePromptKit(ctx, config, project, name, body?.styleId, body?.filterId)
1961
- const role = project.roles.find(r => r.name === name)
1962
- if (role !== undefined) role.promptKit = kit
1963
- project.updatedAt = new Date().toISOString()
1964
- saveProject(config.outputDir, project)
1965
- writeJson(res, 200, { roles: project.roles, promptKit: kit } satisfies RolesResponse)
1966
- return
1967
- } catch (error) {
1968
- writeJson(res, 500, { error: `提示词精修失败:${(error as Error).message}` })
1969
- return
1970
- }
1971
- } else {
1972
- writeJson(res, 400, { error: '未知的 roles op' })
1973
- return
1974
- }
1975
- project.updatedAt = new Date().toISOString()
1976
- saveProject(config.outputDir, project)
1977
- const response: RolesResponse = { roles: project.roles }
1978
- writeJson(res, 200, response)
1979
- },
1980
- }
1981
-
1982
- // ------------------------------------------------------- manga roles
1983
- /** 漫剧角色库:从分镜提名(规则+LLM 两段式)/ 建卡 / 更新 / 删除 / 形象锚点 / 精修提示词。 */
1984
- const mangaRolesRoute: WebRoute = {
1985
- kind: 'exact',
1986
- path: NOVEL_API.mangaRoles,
1987
- handler: async (req, res) => {
1988
- if (!guard(req, res, 'POST')) return
1989
- const config = getConfig()
1990
- const project = requireProject(res)
1991
- if (project === undefined) return
1992
- const body = await readJsonBody<MangaRolesRequest>(req)
1993
- if (project.mangaRoles === undefined) project.mangaRoles = []
1994
- const op = body?.op
1995
- if (op === 'nominate') {
1996
- const chapterNo = body?.chapterNo
1997
- if (typeof chapterNo !== 'number' || chapterNo <= 0) {
1998
- writeJson(res, 400, { error: 'chapterNo(章号)必填' })
1999
- return
2000
- }
2001
- try {
2002
- const candidates = await nominateMangaRoles(ctx, config, project, config.outputDir, chapterNo)
2003
- writeJson(res, 200, { cards: project.mangaRoles, candidates } satisfies MangaRolesResponse)
2004
- return
2005
- } catch (error) {
2006
- writeJson(res, 500, { error: `提名失败:${(error as Error).message}` })
2007
- return
2008
- }
2009
- } else if (op === 'adopt' || op === 'update') {
2010
- const card = body?.card
2011
- if (card === undefined || typeof card !== 'object') {
2012
- writeJson(res, 400, { error: 'card(漫剧角色卡)必填' })
2013
- return
2014
- }
2015
- const name = (card.name ?? '').trim()
2016
- if (name === '') {
2017
- writeJson(res, 400, { error: '漫剧角色名不能为空' })
2018
- return
2019
- }
2020
- const now = new Date().toISOString()
2021
- const existing = card.id !== '' ? project.mangaRoles.find(c => c.id === card.id) : undefined
2022
- const next: MangaRoleCard = {
2023
- id: card.id !== '' ? card.id : 'mr-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6),
2024
- sourceRoleName: card.sourceRoleName !== undefined && card.sourceRoleName !== '' ? card.sourceRoleName : undefined,
2025
- name,
2026
- identity: card.identity ?? '',
2027
- coreFunction: card.coreFunction ?? 'functional',
2028
- protagonistRelation: card.protagonistRelation ?? 'neutral',
2029
- speechStyle: card.speechStyle ?? '',
2030
- traits: Array.isArray(card.traits) ? card.traits.map(t => String(t).slice(0, 20)).filter(t => t !== '').slice(0, 3) : [],
2031
- appearance: card.appearance ?? '',
2032
- keyScenes: Array.isArray(card.keyScenes) ? card.keyScenes.map(k => String(k).slice(0, 120)).filter(k => k !== '').slice(0, 6) : [],
2033
- appearsInEpisodes: Array.isArray(card.appearsInEpisodes)
2034
- ? [...new Set(card.appearsInEpisodes.map(n => Number(n)).filter(n => Number.isFinite(n) && n > 0))].sort((a, b) => a - b).slice(0, 200)
2035
- : [],
2036
- status: card.status ?? 'imported',
2037
- imagePrompt: card.imagePrompt,
2038
- expressions: card.expressions,
2039
- promptKit: card.promptKit,
2040
- imageUrl: card.imageUrl,
2041
- gallery: card.gallery,
2042
- promptStyleId: card.promptStyleId,
2043
- createdAt: existing?.createdAt ?? now,
2044
- updatedAt: now,
2045
- }
2046
- if (existing !== undefined) {
2047
- const idx = project.mangaRoles.findIndex(c => c.id === card.id)
2048
- project.mangaRoles[idx] = next
2049
- } else {
2050
- project.mangaRoles.push(next)
2051
- }
2052
- project.updatedAt = now
2053
- saveProject(config.outputDir, project)
2054
- writeJson(res, 200, { cards: project.mangaRoles } satisfies MangaRolesResponse)
2055
- return
2056
- } else if (op === 'remove') {
2057
- const id = body?.id
2058
- if (typeof id !== 'string' || id === '') {
2059
- writeJson(res, 400, { error: 'id(漫剧卡 id)必填' })
2060
- return
2061
- }
2062
- project.mangaRoles = project.mangaRoles.filter(c => c.id !== id)
2063
- project.updatedAt = new Date().toISOString()
2064
- saveProject(config.outputDir, project)
2065
- writeJson(res, 200, { cards: project.mangaRoles } satisfies MangaRolesResponse)
2066
- return
2067
- } else if (op === 'visual') {
2068
- const id = body?.id?.trim()
2069
- if (id === undefined || id === '') {
2070
- writeJson(res, 400, { error: 'id(漫剧卡 id)必填' })
2071
- return
2072
- }
2073
- try {
2074
- const activeStyle = getActiveMangaStyle(project); const visual = await extractMangaRoleVisual(ctx, config, project, config.outputDir, id, body?.styleId ?? activeStyle.styleId, body?.filterId ?? activeStyle.filterId)
2075
- project.updatedAt = new Date().toISOString()
2076
- saveProject(config.outputDir, project)
2077
- writeJson(res, 200, { cards: project.mangaRoles, visual } satisfies MangaRolesResponse)
2078
- return
2079
- } catch (error) {
2080
- writeJson(res, 500, { error: `形象锚点生成失败:${(error as Error).message}` })
2081
- return
2082
- }
2083
- } else if (op === 'promptKit') {
2084
- const id = body?.id?.trim()
2085
- if (id === undefined || id === '') {
2086
- writeJson(res, 400, { error: 'id(漫剧卡 id)必填' })
2087
- return
2088
- }
2089
- try {
2090
- const activeStyle2 = getActiveMangaStyle(project); const kit = await generateMangaRolePromptKit(ctx, config, project, id, body?.styleId ?? activeStyle2.styleId, body?.filterId ?? activeStyle2.filterId)
2091
- project.updatedAt = new Date().toISOString()
2092
- saveProject(config.outputDir, project)
2093
- writeJson(res, 200, { cards: project.mangaRoles, promptKit: kit } satisfies MangaRolesResponse)
2650
+ writeJson(res, 200, { plotlines: project.plotlines, health } satisfies PlotlinesResponse)
2094
2651
  return
2095
2652
  } catch (error) {
2096
- writeJson(res, 500, { error: `提示词精修失败:${(error as Error).message}` })
2653
+ writeJson(res, 500, { error: `健康检查失败:${(error as Error).message}` })
2097
2654
  return
2098
2655
  }
2099
- } else if (op === 'image') {
2100
- // 定妆图上传:带标签进图集;标签含「立绘」时同步为参考图(imageUrl)。
2101
- const id = body?.id?.trim()
2102
- const dataUrl = body?.dataUrl?.trim()
2103
- if (id === undefined || id === '') {
2104
- writeJson(res, 400, { error: 'id(漫剧卡 id)必填' })
2656
+ } else if (op === 'plan') {
2657
+ try {
2658
+ const health = await analyzePlotlineHealth(ctx, config, project)
2659
+ const plan = await designPlotlinePlan(ctx, config, project, health)
2660
+ writeJson(res, 200, { plotlines: project.plotlines, health, plan } satisfies PlotlinesResponse)
2105
2661
  return
2106
- }
2107
- if (dataUrl === undefined || dataUrl === '') {
2108
- writeJson(res, 400, { error: 'dataUrl(定妆图)必填' })
2662
+ } catch (error) {
2663
+ writeJson(res, 500, { error: `剧情方案生成失败:${(error as Error).message}` })
2109
2664
  return
2110
2665
  }
2111
- const card = project.mangaRoles.find(c => c.id === id)
2112
- if (card === undefined) {
2113
- writeJson(res, 404, { error: '漫剧角色卡不存在' })
2114
- return
2666
+ }
2667
+ project.updatedAt = new Date().toISOString()
2668
+ saveProject(config.outputDir, project)
2669
+ const response: PlotlinesResponse = { plotlines: project.plotlines }
2670
+ writeJson(res, 200, response)
2671
+ },
2672
+ }
2673
+
2674
+ // --------------------------------------------------------- sensitive
2675
+ /** 敏感词检查:指定章节 / 任意文本 / 全书已写章节。 */
2676
+ const sensitiveRoute: WebRoute = {
2677
+ kind: 'exact',
2678
+ path: NOVEL_API.sensitiveCheck,
2679
+ handler: async (req, res) => {
2680
+ if (!guard(req, res, 'POST')) return
2681
+ const config = getConfig()
2682
+ const project = requireProject(res)
2683
+ if (project === undefined) return
2684
+ const body = await readJsonBody<SensitiveCheckRequest>(req)
2685
+ const hits: SensitiveHit[] = []
2686
+ let scanned = 0
2687
+ if (body?.text !== undefined) {
2688
+ for (const hit of checkSensitiveText(body.text)) {
2689
+ hits.push({ chapterNo: 0, word: hit.word, category: hit.category, count: hit.count })
2115
2690
  }
2116
- const label = body?.label?.trim() ?? ''
2117
- if (label !== '') {
2118
- card.gallery ??= []
2119
- card.gallery = card.gallery.filter(g => g.label !== label)
2120
- card.gallery.push({ label, dataUrl })
2121
- if (label.includes('立绘')) card.imageUrl = dataUrl
2122
- } else {
2123
- card.imageUrl = dataUrl
2691
+ } else if (typeof body?.chapterNo === 'number') {
2692
+ const chapter = project.chapters.find(c => c.no === body.chapterNo)
2693
+ if (chapter !== undefined) {
2694
+ const text = readChapterFile(config.outputDir, chapter)
2695
+ if (text !== undefined) {
2696
+ scanned = 1
2697
+ for (const hit of checkSensitiveText(text)) {
2698
+ hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
2699
+ }
2700
+ }
2124
2701
  }
2125
- card.updatedAt = new Date().toISOString()
2126
- project.updatedAt = new Date().toISOString()
2127
- saveProject(config.outputDir, project)
2128
- writeJson(res, 200, { cards: project.mangaRoles, imageUrl: card.imageUrl } satisfies MangaRolesResponse)
2129
- return
2130
- } else if (op === 'removeImage') {
2131
- const id = body?.id?.trim()
2132
- const label = body?.label?.trim() ?? ''
2133
- const card = project.mangaRoles.find(c => c.id === id)
2134
- if (card !== undefined) {
2135
- if (label !== '') {
2136
- card.gallery = (card.gallery ?? []).filter(g => g.label !== label)
2137
- if (label.includes('立绘')) card.imageUrl = undefined
2138
- } else {
2139
- card.imageUrl = undefined
2702
+ } else if (body?.all === true) {
2703
+ for (const chapter of project.chapters) {
2704
+ if (chapter.status === 'pending' || chapter.status === 'generating') continue
2705
+ const text = readChapterFile(config.outputDir, chapter)
2706
+ if (text === undefined) continue
2707
+ scanned++
2708
+ for (const hit of checkSensitiveText(text)) {
2709
+ hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
2140
2710
  }
2141
- card.updatedAt = new Date().toISOString()
2142
2711
  }
2143
- project.updatedAt = new Date().toISOString()
2144
- saveProject(config.outputDir, project)
2145
- writeJson(res, 200, { cards: project.mangaRoles } satisfies MangaRolesResponse)
2146
- return
2147
- } else if (op === 'mode') {
2148
- // 短剧精简模式开关(漫剧角色库 5-8 人 / 功能标签 / 关系闭环 / 人设极致化)。
2149
- project.shortDramaMode = body?.shortDramaMode === true
2150
- project.updatedAt = new Date().toISOString()
2151
- saveProject(config.outputDir, project)
2152
- writeJson(res, 200, { cards: project.mangaRoles, shortDramaMode: project.shortDramaMode } satisfies MangaRolesResponse)
2153
- return
2154
- } else if (op === 'autoGenerate') {
2155
- const chapterNo = body?.chapterNo
2156
- if (typeof chapterNo !== 'number' || chapterNo <= 0) { writeJson(res, 400, { error: 'chapterNo(章号)必填' }); return }
2157
- try {
2158
- const activeStyle3 = getActiveMangaStyle(project); const result = await autoGenerateMangaChapter(ctx, config, project, config.outputDir, chapterNo, body?.styleId ?? activeStyle3.styleId, body?.filterId ?? activeStyle3.filterId)
2159
- writeJson(res, 200, { cards: project.mangaRoles, autoGenerate: result } satisfies MangaRolesResponse)
2160
- return
2161
- } catch (error) { writeJson(res, 500, { error: '一键生成失败:' + (error as Error).message }); return }
2162
- } else if (op === 'openAssets') {
2163
- const dir = mangaAssetsDir(config.outputDir)
2164
- try {
2165
- mkdirSync(dir, { recursive: true })
2166
- if (process.platform === 'win32') { spawn('explorer', [dir], { detached: true }) }
2167
- else if (process.platform === 'darwin') { spawn('open', [dir], { detached: true }) }
2168
- else { spawn('xdg-open', [dir], { detached: true }) }
2169
- writeJson(res, 200, { ok: true, dir })
2170
- return
2171
- } catch (error) { writeJson(res, 500, { error: '打开资产库失败:' + (error as Error).message, dir }); return }
2172
2712
  } else {
2173
- writeJson(res, 400, { error: '未知的 manga/roles op' })
2713
+ writeJson(res, 400, { error: '请提供 chapterNo / text / all 之一' })
2174
2714
  return
2175
2715
  }
2716
+ const response: SensitiveCheckResponse = { hits, scannedChapters: scanned }
2717
+ writeJson(res, 200, response)
2176
2718
  },
2177
2719
  }
2178
2720
 
2179
- // --------------------------------------------------- manga export-package
2180
- /** 导出「即梦素材包」落盘到资产库 manga-assets/素材包/(前端组装 markdown,后端写文件)。 */
2181
- const exportPackageRoute: WebRoute = {
2721
+ // ---------------------------------------------------------------- roles
2722
+ /** 角色库:提炼 / 采纳 / 更新 / 删除。 */
2723
+ const rolesRoute: WebRoute = {
2182
2724
  kind: 'exact',
2183
- path: NOVEL_API.exportPackage,
2725
+ path: NOVEL_API.roles,
2184
2726
  handler: async (req, res) => {
2185
2727
  if (!guard(req, res, 'POST')) return
2186
2728
  const config = getConfig()
2187
- const body = await readJsonBody<{ chapterNo?: number; title?: string; markdown?: string }>(req)
2188
- const chapterNo = Number(body?.chapterNo)
2189
- const markdown = body?.markdown ?? ''
2190
- if (!Number.isInteger(chapterNo) || chapterNo < 1 || markdown.trim() === '') {
2191
- writeJson(res, 400, { error: 'chapterNo 须为正整数,且 markdown 不能为空' })
2729
+ const project = requireProject(res)
2730
+ if (project === undefined) return
2731
+ const body = await readJsonBody<RolesRequest>(req)
2732
+ if (project.roles === undefined) project.roles = []
2733
+ const op = body?.op
2734
+ if (op === 'extract') {
2735
+ try {
2736
+ const candidates = await extractRoles(ctx, config, project)
2737
+ writeJson(res, 200, { roles: project.roles, candidates } satisfies RolesResponse)
2738
+ return
2739
+ } catch (error) {
2740
+ writeJson(res, 500, { error: `角色提炼失败:${(error as Error).message}` })
2741
+ return
2742
+ }
2743
+ } else if ((op === 'adopt' || op === 'update') && body?.role !== undefined) {
2744
+ const r = body.role
2745
+ const idx = project.roles.findIndex(x => x.name === r.name)
2746
+ if (idx === -1) project.roles.push(r)
2747
+ else project.roles[idx] = r
2748
+ } else if (op === 'remove' && body?.name !== undefined) {
2749
+ project.roles = project.roles.filter(x => x.name !== body.name)
2750
+ } else {
2751
+ writeJson(res, 400, { error: '未知的 roles op' })
2192
2752
  return
2193
2753
  }
2194
- try {
2195
- const file = saveMangaAssetPackage(config.outputDir, chapterNo, (body?.title ?? '').trim(), markdown)
2196
- writeJson(res, 200, { ok: true, file })
2197
- } catch (error) {
2198
- writeJson(res, 500, { error: (error as Error).message })
2199
- }
2754
+ project.updatedAt = new Date().toISOString()
2755
+ saveProject(config.outputDir, project)
2756
+ const response: RolesResponse = { roles: project.roles }
2757
+ writeJson(res, 200, response)
2200
2758
  },
2201
2759
  }
2202
2760
 
@@ -2359,8 +2917,8 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2359
2917
  return
2360
2918
  }
2361
2919
  try {
2362
- const next = await patchConfig(body)
2363
- writeJson(res, 200, { config: next })
2920
+ const result = await patchConfig(body)
2921
+ writeJson(res, 200, result satisfies ConfigResponse)
2364
2922
  } catch (error) {
2365
2923
  writeJson(res, 400, { error: (error as Error).message })
2366
2924
  }
@@ -2398,7 +2956,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2398
2956
  path: NOVEL_API.pluginUpdate,
2399
2957
  handler: async (req, res) => {
2400
2958
  if (!guard(req, res, 'POST')) return
2401
- const profileDir = join(homedir(), '.dsh', 'profiles', 'web')
2959
+ const profileDir = dshHomePath('profiles', argvProfile() ?? 'web')
2402
2960
  const response: PluginUpdateResponse = await new Promise((resolve) => {
2403
2961
  const child = spawn('pnpm', ['add', '@waterwx/dsh-novel-forge@latest'], { cwd: profileDir, shell: true })
2404
2962
  let acc = ''
@@ -2476,35 +3034,6 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2476
3034
  },
2477
3035
  }
2478
3036
 
2479
- // ------------------------------------------------- storyboard prompts
2480
- /** 分镜·提示词级:分镜表 → 即梦可粘贴视频提示词。 */
2481
- const storyboardPromptsRoute: WebRoute = {
2482
- kind: 'exact',
2483
- path: NOVEL_API.storyboardPrompts,
2484
- handler: async (req, res) => {
2485
- if (!guard(req, res, 'POST')) return
2486
- const config = getConfig()
2487
- const project = requireProject(res)
2488
- if (project === undefined) return
2489
- const body = await readJsonBody<StoryboardPromptsRequest>(req)
2490
- const no = body?.chapterNo
2491
- if (!Number.isInteger(no) || no === undefined || no < 1) {
2492
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
2493
- return
2494
- }
2495
- if (body?.table === undefined || (body.table.shots ?? []).length === 0) {
2496
- writeJson(res, 400, { error: 'table 不能为空,请先生成分镜表' })
2497
- return
2498
- }
2499
- try {
2500
- const activeStyle5 = getActiveMangaStyle(project); const prompts = await generateStoryboardPrompts(ctx, config, project, config.outputDir, no, body.table, body?.styleId ?? activeStyle5.styleId, body?.filterId ?? activeStyle5.filterId)
2501
- writeJson(res, 200, { prompts } satisfies StoryboardPromptsResponse)
2502
- } catch (error) {
2503
- writeJson(res, 500, { error: `视频提示词生成失败:${(error as Error).message}` })
2504
- }
2505
- },
2506
- }
2507
-
2508
3037
  // ------------------------------------------------------------- llm-test
2509
3038
  /** LLM 连通性测试:对选中的提供商/模型发一次最小真实调用。 */
2510
3039
  const llmTestRoute: WebRoute = {
@@ -2616,149 +3145,6 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2616
3145
  },
2617
3146
  }
2618
3147
 
2619
- // ---------------------------------------------------- storyboard table
2620
- /** 分镜·导演级:骨架 → 分镜表(镜头级)。 */
2621
- const storyboardTableRoute: WebRoute = {
2622
- kind: 'exact',
2623
- path: NOVEL_API.storyboardTable,
2624
- handler: async (req, res) => {
2625
- if (!guard(req, res, 'POST')) return
2626
- const config = getConfig()
2627
- const project = requireProject(res)
2628
- if (project === undefined) return
2629
- const body = await readJsonBody<StoryboardTableRequest>(req)
2630
- const no = body?.chapterNo
2631
- if (!Number.isInteger(no) || no === undefined || no < 1) {
2632
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
2633
- return
2634
- }
2635
- if (body?.skeleton === undefined || (body.skeleton.beats ?? []).length === 0) {
2636
- writeJson(res, 400, { error: 'skeleton 不能为空,请先生成剧情骨架' })
2637
- return
2638
- }
2639
- try {
2640
- const activeStyle6 = getActiveMangaStyle(project); const table = await generateStoryboardTable(ctx, config, project, config.outputDir, no, body.skeleton, body?.styleId ?? activeStyle6.styleId, body?.filterId ?? activeStyle6.filterId)
2641
- writeJson(res, 200, { table } satisfies StoryboardTableResponse)
2642
- } catch (error) {
2643
- writeJson(res, 500, { error: `分镜表生成失败:${(error as Error).message}` })
2644
- }
2645
- },
2646
- }
2647
-
2648
- // ------------------------------------------------------ manhua plans
2649
- /** 漫剧方案管理:create / remove / activate。 */
2650
- const manhuaPlansRoute: WebRoute = {
2651
- kind: 'exact',
2652
- path: NOVEL_API.manhuaPlans,
2653
- handler: async (req, res) => {
2654
- if (!guard(req, res, 'POST')) return
2655
- const config = getConfig()
2656
- const project = requireProject(res)
2657
- if (project === undefined) return
2658
- const body = await readJsonBody<MangaPlansRequest>(req)
2659
- if (project.mangaPlans === undefined) project.mangaPlans = []
2660
- if (body?.op === 'create') {
2661
- const name = body.name?.trim()
2662
- const styleId = body.styleId?.trim()
2663
- if (name === undefined || name === '' || styleId === undefined || styleId === '') {
2664
- writeJson(res, 400, { error: 'name 与 styleId 不能为空' })
2665
- return
2666
- }
2667
- if (project.mangaPlans.some(p => p.name === name)) {
2668
- writeJson(res, 400, { error: `方案名「${name}」已存在` })
2669
- return
2670
- }
2671
- const id = `manga-${Date.now().toString(36)}`
2672
- project.mangaPlans.push({
2673
- id,
2674
- name: name.slice(0, 40),
2675
- styleId,
2676
- filterId: body.filterId?.trim() !== '' ? body.filterId?.trim() : undefined,
2677
- genre: body.genre?.trim() !== '' ? body.genre?.trim() : undefined,
2678
- active: project.mangaPlans.length === 0,
2679
- createdAt: new Date().toISOString(),
2680
- updatedAt: new Date().toISOString(),
2681
- })
2682
- } else if (body?.op === 'remove' && body.id !== undefined) {
2683
- const removed = project.mangaPlans.find(p => p.id === body.id)
2684
- project.mangaPlans = project.mangaPlans.filter(p => p.id !== body.id)
2685
- if (removed?.active === true && project.mangaPlans.length > 0) {
2686
- project.mangaPlans[0].active = true
2687
- }
2688
- // 删除方案时同时清除该方案的漫剧产出:分镜(骨架/表/提示词)、角色卡
2689
- // 场景库与视觉规则是全书资产,保留供后续方案复用
2690
- project.storyboards = []
2691
- project.mangaRoles = []
2692
- } else if (body?.op === 'activate' && body.id !== undefined) {
2693
- project.mangaPlans.forEach(p => { p.active = p.id === body.id })
2694
- } else {
2695
- writeJson(res, 400, { error: 'op 须为 create/remove/activate' })
2696
- return
2697
- }
2698
- project.updatedAt = new Date().toISOString()
2699
- saveProject(config.outputDir, project)
2700
- writeJson(res, 200, { plans: project.mangaPlans } satisfies MangaPlansResponse)
2701
- },
2702
- }
2703
-
2704
- // ------------------------------------------------------ style image
2705
- /** 风格库效果图:GET /styles/image?id=<styleId>,从 ~/.dsh/dsh-novel-forge-styles/<id>.png 读取。 */
2706
- const styleImageRoute: WebRoute = {
2707
- kind: 'exact',
2708
- path: '/api/dsh-novel-forge/styles/image',
2709
- handler: async (req, res) => {
2710
- if (!isLoopbackRequest(req)) {
2711
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
2712
- return
2713
- }
2714
- const url = new URL(req.url ?? '', 'http://localhost')
2715
- const id = (url.searchParams.get('id') ?? '').replace(/[^a-z0-9-]/gi, '')
2716
- if (id === '') {
2717
- writeJson(res, 404, { error: 'style image not found' })
2718
- return
2719
- }
2720
- // 图片来源:用户数据目录缩略图(自定义覆盖)→ 原图 → 包内置缩略图(随插件分发)。
2721
- const base = join(homedir(), '.dsh', 'dsh-novel-forge-styles')
2722
- const candidates: Array<{ file: string; type: string }> = [
2723
- { file: join(base, 'thumbs', id + '.webp'), type: 'image/webp' },
2724
- { file: join(base, id + '.png'), type: 'image/png' },
2725
- { file: join(builtinStyleDir, id + '.webp'), type: 'image/webp' },
2726
- ]
2727
- for (const c of candidates) {
2728
- if (existsSync(c.file)) {
2729
- res.writeHead(200, { 'content-type': c.type, 'cache-control': 'public, max-age=86400' })
2730
- res.end(readFileSync(c.file))
2731
- return
2732
- }
2733
- }
2734
- writeJson(res, 404, { error: 'style image not found' })
2735
- },
2736
- }
2737
-
2738
- // ------------------------------------------------------- storyboard
2739
- /** 分镜·编剧级:单章 → 剧情骨架(节拍链)。 */
2740
- const storyboardSkeletonRoute: WebRoute = {
2741
- kind: 'exact',
2742
- path: NOVEL_API.storyboardSkeleton,
2743
- handler: async (req, res) => {
2744
- if (!guard(req, res, 'POST')) return
2745
- const config = getConfig()
2746
- const project = requireProject(res)
2747
- if (project === undefined) return
2748
- const body = await readJsonBody<StoryboardSkeletonRequest>(req)
2749
- const no = body?.chapterNo
2750
- if (!Number.isInteger(no) || no === undefined || no < 1) {
2751
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
2752
- return
2753
- }
2754
- try {
2755
- const skeleton = await generateStoryboardSkeleton(ctx, config, project, config.outputDir, no)
2756
- writeJson(res, 200, { skeleton } satisfies StoryboardSkeletonResponse)
2757
- } catch (error) {
2758
- writeJson(res, 500, { error: `剧情骨架生成失败:${(error as Error).message}` })
2759
- }
2760
- },
2761
- }
2762
3148
 
2763
3149
  // ----------------------------------------------------------- breakdown
2764
3150
  /** 拆书分析:对已写章节做结构/人物/文风/卖点体检(两阶段:源笔记→分节分析)。 */
@@ -2788,151 +3174,128 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2788
3174
  },
2789
3175
  }
2790
3176
 
2791
- // --------------------------------------------------------------- scenes
2792
- /** 场景库:AI 提炼 / 采纳 / 更新 / 删除 / 图集。 */
2793
- const scenesRoute: WebRoute = {
3177
+
3178
+ // ----------------------------------------------------------- production run
3179
+ /** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
3180
+ const runner = new ProductionRunner({ ctx, getConfig })
3181
+
3182
+ // --------------------------------------------------------- move-output-dir
3183
+ /**
3184
+ * 输出目录迁移:dryRun 预览;确认后 copy 全部内容 → 书架重指向 → settings
3185
+ * 默认目录同步(仅当默认目录就是被搬目录)→ 最后删源目录(数据零丢失排序)。
3186
+ */
3187
+ const moveOutputDirRoute: WebRoute = {
2794
3188
  kind: 'exact',
2795
- path: NOVEL_API.scenes,
3189
+ path: NOVEL_API.moveOutputDir,
2796
3190
  handler: async (req, res) => {
2797
3191
  if (!guard(req, res, 'POST')) return
2798
- const config = getConfig()
2799
- const project = requireProject(res)
2800
- if (project === undefined) return
2801
- const body = await readJsonBody<ScenesRequest>(req)
2802
- if (project.scenes === undefined) project.scenes = []
2803
- const op = body?.op
2804
- if (op === 'extract') {
3192
+ const from = getConfig().outputDir
3193
+ const body = await readJsonBody<MoveOutputDirRequest>(req)
3194
+ // 预览:只列内容,不搬移。
3195
+ if (body?.dryRun === true) {
2805
3196
  try {
2806
- const activeStyle4 = getActiveMangaStyle(project); const candidates = await extractScenes(ctx, config, project, body?.chapterNo, body?.styleId ?? activeStyle4.styleId, body?.filterId ?? activeStyle4.filterId)
2807
- writeJson(res, 200, { scenes: project.scenes, candidates } satisfies ScenesResponse)
2808
- return
3197
+ const contents = listDirContents(from)
3198
+ writeJson(res, 200, { from, files: contents.files.slice(0, 300), bytes: contents.bytes, movedBooks: 0, defaultUpdated: false } satisfies MoveOutputDirResponse)
2809
3199
  } catch (error) {
2810
- writeJson(res, 500, { error: `场景提炼失败:${(error as Error).message}` })
2811
- return
2812
- }
2813
- } else if ((op === 'adopt' || op === 'update') && body?.scene !== undefined) {
2814
- const s = body.scene
2815
- const idx = project.scenes.findIndex(x => x.name === s.name)
2816
- if (idx === -1) project.scenes.push(s)
2817
- else project.scenes[idx] = s
2818
- } else if (op === 'remove' && body?.name !== undefined) {
2819
- project.scenes = project.scenes.filter(x => x.name !== body.name)
2820
- } else if (op === 'image') {
2821
- const name = body?.name?.trim()
2822
- const dataUrl = body?.dataUrl?.trim()
2823
- if (name === undefined || name === '' || dataUrl === undefined || dataUrl === '') {
2824
- writeJson(res, 400, { error: 'name(场景名)与 dataUrl 必填' })
2825
- return
2826
- }
2827
- const scene = project.scenes.find(x => x.name === name)
2828
- if (scene === undefined) {
2829
- writeJson(res, 404, { error: `当前激活《${project.bookName}》,场景 ${name} 不存在——若期望的书不对,请先在书架切换该书并刷新` })
2830
- return
3200
+ writeJson(res, 400, { error: (error as Error).message })
2831
3201
  }
2832
- const label = body?.label?.trim() ?? '全景'
2833
- scene.gallery ??= []
2834
- scene.gallery = scene.gallery.filter(g => g.label !== label)
2835
- scene.gallery.push({ label, dataUrl })
2836
- } else if (op === 'removeImage') {
2837
- const name = body?.name?.trim()
2838
- const label = body?.label?.trim() ?? ''
2839
- const scene = project.scenes.find(x => x.name === name)
2840
- if (scene !== undefined && label !== '') {
2841
- scene.gallery = (scene.gallery ?? []).filter(g => g.label !== label)
2842
- }
2843
- } else {
2844
- writeJson(res, 400, { error: `未知操作 ${op}` })
2845
3202
  return
2846
3203
  }
2847
- project.updatedAt = new Date().toISOString()
2848
- saveProject(config.outputDir, project)
2849
- writeJson(res, 200, { scenes: project.scenes } satisfies ScenesResponse)
2850
- },
2851
- }
2852
-
2853
- // ----------------------------------------------------------- visual rules
2854
- /** 视觉世界观规则:从道藏提炼(生图/生视频纠偏),或手动保存。 */
2855
- const visualRulesRoute: WebRoute = {
2856
- kind: 'exact',
2857
- path: NOVEL_API.visualRules,
2858
- handler: async (req, res) => {
2859
- if (!guard(req, res, 'POST')) return
2860
- const config = getConfig()
2861
- const project = requireProject(res)
2862
- if (project === undefined) return
2863
- const body = await readJsonBody<VisualRulesRequest>(req)
2864
- if (body?.op === 'extract') {
2865
- try {
2866
- const rules = await extractVisualRules(ctx, config, project)
2867
- project.visualRules = rules
2868
- project.updatedAt = new Date().toISOString()
2869
- saveProject(config.outputDir, project)
2870
- writeJson(res, 200, { rules } satisfies VisualRulesResponse)
2871
- return
2872
- } catch (error) {
2873
- writeJson(res, 500, { error: `视觉规则提炼失败:${(error as Error).message}` })
2874
- return
2875
- }
2876
- } else if (body?.op === 'save' && Array.isArray(body.rules)) {
2877
- project.visualRules = body.rules.filter(r => typeof r === 'string' && r.trim() !== '').map(r => r.trim().slice(0, 80)).slice(0, 12)
2878
- project.updatedAt = new Date().toISOString()
2879
- saveProject(config.outputDir, project)
2880
- writeJson(res, 200, { rules: project.visualRules } satisfies VisualRulesResponse)
3204
+ if (runner.isWorking()) {
3205
+ writeJson(res, 409, { error: '生产单正在运行中,请先停止后再迁移目录' })
3206
+ return
3207
+ }
3208
+ const to = body?.to?.trim() ?? ''
3209
+ if (to === '') {
3210
+ writeJson(res, 400, { error: '新目录路径不能为空' })
2881
3211
  return
2882
3212
  }
2883
- writeJson(res, 400, { error: 'op 须为 extract 或 save' })
3213
+ if (normalizeDir(to) === normalizeDir(from)) {
3214
+ writeJson(res, 400, { error: '新目录与当前目录相同' })
3215
+ return
3216
+ }
3217
+ try {
3218
+ // 1. copy 全部内容(源目录保留;失败即中止,源数据无损)。
3219
+ copyDirContents(from, to)
3220
+ } catch (error) {
3221
+ writeJson(res, 400, { error: `迁移中止:${(error as Error).message}` })
3222
+ return
3223
+ }
3224
+ // 2. 书架重指向(outputDir === from 的条目全部改到 to)。
3225
+ let movedBooks = 0
3226
+ let repointError = ''
3227
+ try { movedBooks = repointBookDirs(from, to) } catch (error) { repointError = (error as Error).message }
3228
+ // 3. settings 默认目录同步(仅当默认目录就是被搬的这个)。
3229
+ let defaultUpdated = false
3230
+ let defaultError = ''
3231
+ const raw = deps.rawConfig?.()
3232
+ if (raw !== undefined && raw.outputDir !== undefined && raw.outputDir !== '' && normalizeDir(raw.outputDir) === normalizeDir(from)) {
3233
+ try {
3234
+ await patchConfig({ outputDir: to })
3235
+ defaultUpdated = true
3236
+ } catch (error) { defaultError = (error as Error).message }
3237
+ }
3238
+ // 4. 收尾:重指向全部成功才删源目录;否则保留源目录兜底并如实上报。
3239
+ if (repointError === '' && defaultError === '') {
3240
+ removeDir(from)
3241
+ const contents = listDirContents(to)
3242
+ writeJson(res, 200, { from, to, files: contents.files.slice(0, 300), bytes: contents.bytes, movedBooks, defaultUpdated } satisfies MoveOutputDirResponse)
3243
+ return
3244
+ }
3245
+ writeJson(res, 500, {
3246
+ error: `文件已复制到 ${to},但收尾未完成${repointError !== '' ? `(书架重指向失败:${repointError})` : ''}${defaultError !== '' ? `(默认目录更新失败:${defaultError})` : ''}。源目录已保留,请检查后重试或手动处理。`,
3247
+ from,
3248
+ to,
3249
+ movedBooks,
3250
+ defaultUpdated,
3251
+ })
2884
3252
  },
2885
3253
  }
2886
3254
 
2887
- const mangaPropsRoute: WebRoute = {
3255
+ // ----------------------------------------------------------- book-settings
3256
+ /**
3257
+ * 本书参数:POST {} 读取当前书的 settings + 生效配置(全局+本书回退链);
3258
+ * POST {patch} 白名单合并保存到 novel-project.json.bookSettings。
3259
+ */
3260
+ const bookSettingsRoute: WebRoute = {
2888
3261
  kind: 'exact',
2889
- path: NOVEL_API.mangaProps,
3262
+ path: NOVEL_API.bookSettings,
2890
3263
  handler: async (req, res) => {
2891
3264
  if (!guard(req, res, 'POST')) return
2892
- const config = getConfig()
2893
- const project = requireProject(res)
2894
- if (project === undefined) return
2895
- const body = await readJsonBody<MangaPropsRequest>(req)
2896
- if (body?.op === 'extract') {
2897
- try {
2898
- const props = await extractProps(ctx, config, project)
2899
- if (props.length > 0) {
2900
- project.props = props
2901
- project.updatedAt = new Date().toISOString()
2902
- saveProject(config.outputDir, project)
2903
- }
2904
- writeJson(res, 200, { props: project.props ?? [] } satisfies MangaPropsResponse)
2905
- return
2906
- } catch (error) {
2907
- writeJson(res, 500, { error: `道具提炼失败:${(error as Error).message}` })
3265
+ const outputDir = getConfig().outputDir
3266
+ const body = await readJsonBody<BookSettingsRequest>(req)
3267
+ if (body?.patch !== undefined) {
3268
+ const project = loadProject(outputDir)
3269
+ if (project === undefined) {
3270
+ writeJson(res, 400, { error: '当前没有打开的书(未找到 novel-project.json)' })
2908
3271
  return
2909
3272
  }
2910
- } else if (body?.op === 'save' && Array.isArray(body.props)) {
2911
- project.props = body.props
2912
- .filter(p => typeof p === 'object' && p !== null && typeof p.name === 'string' && p.name.trim() !== '')
2913
- .map(p => ({ name: p.name.trim().slice(0, 20), desc: (typeof p.desc === 'string' ? p.desc.trim() : '').slice(0, 120) }))
2914
- .slice(0, 8)
3273
+ const cur: BookSettings = project.bookSettings ?? {}
3274
+ const p = body.patch
3275
+ const next: Record<string, unknown> = { ...cur }
3276
+ for (const key of ['provider', 'model', 'reasoningEffort', 'analysisReasoning', 'chapterChars', 'maxTokens', 'reviewPassScore', 'autoReview', 'autoAuthorReview', 'autoReviewAfterRevise'] as const) {
3277
+ const v = (p as Record<string, unknown>)[key]
3278
+ if (v === null) delete next[key]
3279
+ else if (v !== undefined) next[key] = v
3280
+ }
3281
+ project.bookSettings = next as BookSettings
2915
3282
  project.updatedAt = new Date().toISOString()
2916
- saveProject(config.outputDir, project)
2917
- writeJson(res, 200, { props: project.props } satisfies MangaPropsResponse)
2918
- return
3283
+ saveProject(outputDir, project)
2919
3284
  }
2920
- writeJson(res, 400, { error: 'op 须为 extract 或 save' })
3285
+ const project = loadProject(outputDir)
3286
+ writeJson(res, 200, { settings: project?.bookSettings ?? {}, effective: getConfig(), hasBook: project !== undefined } satisfies BookSettingsResponse)
2921
3287
  },
2922
3288
  }
2923
3289
 
2924
- // ----------------------------------------------------------- production run
2925
- /** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
2926
- const runner = new ProductionRunner({ ctx, getConfig })
2927
-
2928
3290
  const runStartRoute: WebRoute = {
2929
3291
  kind: 'exact',
2930
3292
  path: NOVEL_API.runStart,
2931
3293
  handler: async (req, res) => {
2932
3294
  if (!guard(req, res, 'POST')) return
2933
- const body = await readJsonBody<RunStartRequest>(req)
3295
+ const body = await readJsonBody<RunStartRequest & { bookId?: string }>(req)
2934
3296
  const config = getConfig()
2935
- const project = loadProject(config.outputDir)
3297
+ const outputDir = resolveOutputDir(config, body?.bookId)
3298
+ const project = loadProject(outputDir)
2936
3299
  if (project === undefined) {
2937
3300
  writeJson(res, 400, { error: '输出目录中没有项目' })
2938
3301
  return
@@ -2949,7 +3312,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2949
3312
  return
2950
3313
  }
2951
3314
  try {
2952
- const state = await runner.start(startNo, endNo)
3315
+ const state = await runner.start(startNo, endNo, outputDir)
2953
3316
  writeJson(res, 200, state satisfies RunState)
2954
3317
  } catch (error) {
2955
3318
  writeJson(res, 409, { error: (error as Error).message })
@@ -3279,7 +3642,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
3279
3642
  }
3280
3643
 
3281
3644
  // ------------------------------------------ theme custom background (upload + serve)
3282
- const THEME_BG_DIR = join(homedir(), '.dsh', 'dsh-novel-forge-assets')
3645
+ const THEME_BG_DIR = dshHomePath('dsh-novel-forge-assets')
3283
3646
  const MIME_BY_EXT: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }
3284
3647
 
3285
3648
  const themeBackgroundUploadRoute: WebRoute = {
@@ -3340,8 +3703,29 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
3340
3703
  chapterSaveRoute,
3341
3704
  assetsRoute,
3342
3705
  styleEngineRoute,
3706
+ styleFormulaRoute,
3707
+ styleDetectRoute,
3708
+ marketRadarRoute,
3709
+ marketRadarScanRoute,
3710
+ marketRadarApplyRoute,
3711
+ marketRadarSyncRoute,
3712
+ marketRadarBriefRoute,
3713
+ knowledgeRoute,
3714
+ bookAnalysisRoute,
3715
+ ideaInspirationRoute,
3716
+ marketIdeaInspirationRoute,
3717
+ directorRoute,
3718
+ directorTodosRoute,
3719
+ llmLiveRoute,
3343
3720
  assistantRoute,
3344
3721
  assistantHistoryRoute,
3722
+ llmPromptRoute,
3723
+ usageResetRoute,
3724
+ timelineRoute,
3725
+ revisionPlanRoute,
3726
+ snapshotRoute,
3727
+ tensionRoute,
3728
+ promptSlotsRoute,
3345
3729
  assistantClearRoute,
3346
3730
  bookshelfRoute,
3347
3731
  bookshelfActivateRoute,
@@ -3360,31 +3744,23 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
3360
3744
  renameRoute,
3361
3745
  plotlinesRoute,
3362
3746
  rolesRoute,
3363
- mangaRolesRoute,
3364
- exportPackageRoute,
3365
- scenesRoute,
3366
- mangaPropsRoute,
3367
- visualRulesRoute,
3368
3747
  sensitiveRoute,
3369
3748
  reviewBackfillRoute,
3370
3749
  chapterResetRoute,
3371
3750
  chapterApproveRoute,
3372
3751
  configRoute,
3752
+ moveOutputDirRoute,
3753
+ bookSettingsRoute,
3373
3754
  openFolderRoute,
3374
3755
  pluginUpdateRoute,
3375
3756
  outlineSuggestRoute,
3376
3757
  outlineReverseRoute,
3377
- manhuaPlansRoute,
3378
- styleImageRoute,
3379
- storyboardSkeletonRoute,
3380
3758
  llmTestRoute,
3381
3759
  addModelRoute,
3382
3760
  llmVendorsRoute,
3383
3761
  llmModelsRoute,
3384
3762
  llmProvidersRoute,
3385
3763
  llmRemoveRoute,
3386
- storyboardTableRoute,
3387
- storyboardPromptsRoute,
3388
3764
  breakdownRoute,
3389
3765
  runStartRoute,
3390
3766
  runControlRoute,