@waterwx/dsh-novel-forge 1.3.2 → 1.3.3-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/README.md +288 -279
  2. package/lib/client.js +56288 -12061
  3. package/lib/index.js +14655 -5573
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +30 -0
  6. package/lib/types/assets.d.ts +22 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +23 -0
  9. package/lib/types/bookshelf.d.ts +18 -3
  10. package/lib/types/client/api.d.ts +187 -10
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -2
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +29 -67
  16. package/lib/types/client/mount.d.ts +0 -2
  17. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  18. package/lib/types/client/panel/AiTag.d.ts +1 -0
  19. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  20. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  21. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  22. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  23. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  24. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  25. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  26. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  27. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  28. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  29. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  30. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  31. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  32. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  33. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  34. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  35. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  36. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  37. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  38. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  39. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  40. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  41. package/lib/types/client/panel/SubPage.d.ts +27 -0
  42. package/lib/types/client/panel/TensionView.d.ts +18 -0
  43. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  44. package/lib/types/client/panel/helpers.d.ts +5 -3
  45. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  46. package/lib/types/client/panel/toast.d.ts +2 -0
  47. package/lib/types/client/panel/views.d.ts +21 -35
  48. package/lib/types/client/run-status.d.ts +21 -0
  49. package/lib/types/client/sidebar-entry.d.ts +0 -2
  50. package/lib/types/client/text.d.ts +9 -0
  51. package/lib/types/docx-core.d.ts +16 -0
  52. package/lib/types/docx.d.ts +2 -3
  53. package/lib/types/engine.d.ts +186 -45
  54. package/lib/types/global-assets.d.ts +6 -0
  55. package/lib/types/home.d.ts +11 -0
  56. package/lib/types/index.d.ts +36 -10
  57. package/lib/types/llm-live.d.ts +70 -0
  58. package/lib/types/llm-retry.d.ts +38 -0
  59. package/lib/types/market-radar-scan.d.ts +18 -0
  60. package/lib/types/market-radar-sources.d.ts +31 -0
  61. package/lib/types/migrate-output.d.ts +25 -0
  62. package/lib/types/model-capability.d.ts +93 -0
  63. package/lib/types/novel-context.d.ts +80 -0
  64. package/lib/types/output-budget.d.ts +86 -0
  65. package/lib/types/prompt-slots.d.ts +57 -0
  66. package/lib/types/prompt-utils.d.ts +35 -0
  67. package/lib/types/prompting.d.ts +10 -0
  68. package/lib/types/protocol.d.ts +1378 -166
  69. package/lib/types/review-policy.d.ts +97 -0
  70. package/lib/types/revision.d.ts +76 -0
  71. package/lib/types/routes.d.ts +9 -3
  72. package/lib/types/run.d.ts +23 -1
  73. package/lib/types/settings-persist.d.ts +59 -0
  74. package/lib/types/snapshots.d.ts +46 -0
  75. package/lib/types/stage-contract.d.ts +58 -0
  76. package/lib/types/tension.d.ts +53 -0
  77. package/lib/types/timeline.d.ts +37 -0
  78. package/package.json +54 -28
  79. package/src/action-guard.ts +127 -0
  80. package/src/ai-scan.ts +155 -0
  81. package/src/assets.ts +1801 -520
  82. package/src/assistant.ts +1098 -770
  83. package/src/author-assets.ts +131 -0
  84. package/src/bookshelf.ts +193 -151
  85. package/src/client/api.ts +380 -23
  86. package/src/client/chip-prefs.ts +121 -0
  87. package/src/client/docx.ts +32 -69
  88. package/src/client/index.ts +3 -1
  89. package/src/client/llmLive.ts +111 -0
  90. package/src/client/locales.ts +29 -253
  91. package/src/client/mount.tsx +4 -3
  92. package/src/client/panel/AdaptModeView.tsx +535 -0
  93. package/src/client/panel/AiTag.tsx +7 -0
  94. package/src/client/panel/AssetsTab.tsx +448 -458
  95. package/src/client/panel/AssistantTab.tsx +355 -301
  96. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  97. package/src/client/panel/AuthorHome.tsx +309 -0
  98. package/src/client/panel/BookAnalysisView.tsx +63 -0
  99. package/src/client/panel/CmdPalette.tsx +102 -0
  100. package/src/client/panel/CreateBookView.tsx +366 -348
  101. package/src/client/panel/DirectorView.tsx +200 -0
  102. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  103. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  104. package/src/client/panel/ImportModal.tsx +303 -0
  105. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  106. package/src/client/panel/LiveFeedLog.tsx +204 -0
  107. package/src/client/panel/MarketRadarView.tsx +415 -0
  108. package/src/client/panel/ModelManager.tsx +205 -0
  109. package/src/client/panel/NovelPanel.tsx +3013 -2500
  110. package/src/client/panel/PageHeader.tsx +37 -0
  111. package/src/client/panel/ProgressConsole.tsx +63 -0
  112. package/src/client/panel/PromptSlotsView.tsx +97 -0
  113. package/src/client/panel/ReaderView.tsx +306 -306
  114. package/src/client/panel/ReasoningSection.tsx +31 -0
  115. package/src/client/panel/RunPanel.tsx +64 -32
  116. package/src/client/panel/SettingsView.tsx +355 -0
  117. package/src/client/panel/ShelfView.tsx +266 -236
  118. package/src/client/panel/SubPage.tsx +42 -0
  119. package/src/client/panel/TensionView.tsx +212 -0
  120. package/src/client/panel/TimelineView.tsx +208 -0
  121. package/src/client/panel/WorldTab.tsx +202 -202
  122. package/src/client/panel/helpers.ts +7 -5
  123. package/src/client/panel/market-radar-state.ts +79 -0
  124. package/src/client/panel/panel.module.css +4481 -1655
  125. package/src/client/panel/toast.ts +51 -0
  126. package/src/client/panel/views.tsx +316 -314
  127. package/src/client/run-status.ts +171 -0
  128. package/src/client/sidebar-entry.ts +4 -4
  129. package/src/client/text.ts +28 -0
  130. package/src/docx-core.ts +53 -0
  131. package/src/docx.ts +44 -83
  132. package/src/engine.ts +4759 -2943
  133. package/src/global-assets.ts +58 -0
  134. package/src/home.ts +33 -0
  135. package/src/index.ts +206 -65
  136. package/src/llm-live.ts +245 -0
  137. package/src/llm-retry.ts +77 -0
  138. package/src/market-radar-scan.ts +35 -0
  139. package/src/market-radar-sources.ts +173 -0
  140. package/src/migrate-output.ts +76 -0
  141. package/src/model-capability.ts +215 -0
  142. package/src/novel-context.ts +286 -0
  143. package/src/output-budget.ts +147 -0
  144. package/src/prompt-slots.ts +125 -0
  145. package/src/prompting.ts +26 -0
  146. package/src/protocol.ts +1405 -158
  147. package/src/revision.ts +283 -0
  148. package/src/routes.ts +1959 -436
  149. package/src/run.ts +118 -21
  150. package/src/settings-persist.ts +114 -0
  151. package/src/snapshots.ts +174 -0
  152. package/src/stage-contract.ts +186 -0
  153. package/src/tension.ts +251 -0
  154. package/src/timeline.ts +159 -0
  155. package/lib/client.js.map +0 -1
  156. package/lib/index.js.map +0 -1
  157. package/lib/types/comic-presets.d.ts +0 -12
  158. package/scripts/assistant-frames.txt +0 -8
  159. package/scripts/diagnose-adapter.mts +0 -81
  160. package/scripts/diagnose-assistant.mjs +0 -58
  161. package/scripts/diagnose-bible.mjs +0 -72
  162. package/scripts/diagnose-harness.mts +0 -75
  163. package/scripts/novel-forge-restart.log +0 -10
  164. package/scripts/novel-forge-web.stderr.log +0 -2
  165. package/scripts/novel-forge-web.stdout.log +0 -1
  166. package/scripts/outline-sample.txt +0 -449
  167. package/scripts/probe.mjs +0 -6
  168. package/scripts/restart-web.ps1 +0 -88
  169. package/scripts/smoke.mts +0 -72
  170. package/scripts/upload-github.ps1 +0 -51
  171. package/src/client/panel/BookshelfBar.tsx +0 -170
  172. package/src/comic-presets.ts +0 -67
package/src/routes.ts CHANGED
@@ -9,16 +9,25 @@
9
9
  */
10
10
 
11
11
  import type { IncomingMessage, ServerResponse } from 'node:http'
12
- import { exec } from 'node:child_process'
12
+ import { spawn } from 'node:child_process'
13
13
  import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
14
- import { join } from 'node:path'
14
+ import { join, basename, extname } from 'node:path'
15
+ import { randomBytes } from 'node:crypto'
15
16
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
16
17
  import type { Context } from '@deepseek-ai/cordis'
17
18
  import { ProductionRunner } from './run.ts'
18
19
  import {
20
+ EXPORT_SCOPE_VALUES,
19
21
  NOVEL_API,
20
22
  type AssetsPatch,
21
23
  type AssetsResponse,
24
+ type AddModelRequest,
25
+ type AddModelResponse,
26
+ type LlmModelsResponse,
27
+ type LlmProvidersResponse,
28
+ type LlmVendorsResponse,
29
+ type RemoveProviderRequest,
30
+ type RemoveProviderResponse,
22
31
  type AssistantFrame,
23
32
  type AssistantHistoryResponse,
24
33
  type AssistantRequest,
@@ -35,6 +44,9 @@ import {
35
44
  type BibleResponse,
36
45
  type BookActivateRequest,
37
46
  type BookCreateRequest,
47
+ type BookImportDirRequest,
48
+ type BookImportTextRequest,
49
+ type BookImportTextPreviewRequest,
38
50
  type BookRemoveRequest,
39
51
  type BookshelfSnapshot,
40
52
  type ChapterResponse,
@@ -42,6 +54,13 @@ import {
42
54
  type ChapterSaveResponse,
43
55
  type ChapterTextRequest,
44
56
  type ConfigPatch,
57
+ type ConfigResponse,
58
+ type GenerateRequest,
59
+ type MoveOutputDirRequest,
60
+ type MoveOutputDirResponse,
61
+ type BookSettings,
62
+ type BookSettingsRequest,
63
+ type BookSettingsResponse,
45
64
  type DraftDecisionRequest,
46
65
  type ExportRequest,
47
66
  type ExportResponse,
@@ -50,6 +69,17 @@ import {
50
69
  type JobFrame,
51
70
  type LoadOutlineRequest,
52
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,
53
83
  type NovelConfig,
54
84
  type PlotlinesRequest,
55
85
  type PlotlinesResponse,
@@ -65,11 +95,6 @@ import {
65
95
  type RewriteRequest,
66
96
  type RolesRequest,
67
97
  type RolesResponse,
68
- type SceneCard,
69
- type ScenesRequest,
70
- type ScenesResponse,
71
- type VisualRulesRequest,
72
- type VisualRulesResponse,
73
98
  type OutlineSuggestRequest,
74
99
  type OutlineSuggestResponse,
75
100
  type BreakdownRequest,
@@ -77,17 +102,53 @@ import {
77
102
  type SensitiveCheckRequest,
78
103
  type SensitiveCheckResponse,
79
104
  type SensitiveHit,
105
+ type ProjectState,
80
106
  type StatusResponse,
81
107
  type StoryBible,
108
+ type LlmTestRequest,
109
+ type LlmTestResponse,
82
110
  type StyleEngineRequest,
83
111
  type SummaryRequest,
84
112
  type VolumesRequest,
85
113
  type VolumesResponse,
114
+ type AuthorAssetsResponse,
115
+ type AuthorAssetUpsertRequest,
116
+ type AuthorAssetRemoveRequest,
117
+ type AdaptAnalyzeRequest,
118
+ type AdaptAnalyzeResponse,
119
+ type AdaptProposeRequest,
120
+ type AdaptProposeResponse,
121
+ type AdaptExecuteRequest,
122
+ type AdaptExecuteResponse,
123
+ type AdaptSaveRequest,
124
+ type AdaptSaveResponse,
125
+ type AdaptMaterializeRequest,
126
+ type AdaptMaterializeResponse,
127
+ type AdaptMaterializeSaveRequest,
128
+ type AdaptMaterializeSaveResponse,
129
+ type AdaptRewriteFrame,
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,
86
141
  } from './protocol.ts'
87
142
  import { readOutlineFromDocx } from './docx.ts'
88
143
  import { clearAssistantHistory, loadAssistantHistory, runAssistantTurn } from './assistant.ts'
89
- import { activateBook, bookshelfSnapshot, createBook, defaultOutputDirFor, loadBookshelf, removeBook, renameBook, seedBookshelfFromOutputDir } from './bookshelf.ts'
90
- import { BUILTIN_ANTI_AI_RULES, BUILTIN_GENRE_LIBRARY, BUILTIN_PROGRESSION_MODES, BUILTIN_STYLE_TEMPLATES, emptyProjectAssets } from './assets.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'
146
+ import { loadAuthorAssets, upsertAuthorAsset, removeAuthorAsset, importDefaultAuthorAssets } from './author-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'
91
152
  import {
92
153
  chapterFileName,
93
154
  auditBook,
@@ -95,9 +156,19 @@ import {
95
156
  autoLinkPlotlines,
96
157
  backfillFacts,
97
158
  createProject,
98
- exportBook,
159
+ exportScope,
160
+ importBookText,
161
+ importBookTextFromText,
162
+ previewBookText,
99
163
  extractBible,
100
164
  extractStyleAsset,
165
+ extractStyleFormula,
166
+ runMarketRadar,
167
+ runMarketCreativeBrief,
168
+ runBookAnalysis,
169
+ runIdeaInspiration,
170
+ runMarketIdeaInspiration,
171
+ runDirectorAdvice,
101
172
  extractWorld,
102
173
  generateBlurb,
103
174
  generateChapterStream,
@@ -118,23 +189,52 @@ import {
118
189
  analyzePlotlineHealth,
119
190
  designPlotlinePlan,
120
191
  extractRoles,
121
- extractScenes,
122
- generateRolePromptKit,
123
- extractVisualRules,
124
- extractRoleVisual,
125
192
  suggestOutlines,
193
+ reverseOutlineFromChapters,
126
194
  breakdownBook,
127
- generateRoleReferenceImage,
195
+ analyzeAdaptation,
196
+ proposeAdaptation,
197
+ applyAdaptationReplacements,
198
+ rewriteAdaptationBook,
199
+ materializeAdaptedBook,
200
+ saveMaterializedBook,
201
+ testLlmModel,
202
+ registerLlmModel,
203
+ listLlmVendors,
204
+ listLlmModels,
205
+ listLlmProviders,
206
+ removeLlmProvider,
128
207
  checkSensitiveText,
129
208
  suggestForeshadows,
130
209
  suggestPlotlines,
131
210
  summarizeAndExtractFacts,
211
+ extractTimelineForChapter,
212
+ reviewTimeline,
132
213
  summarizeChapter,
133
214
  syncProjectWithDisk,
134
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'
135
224
 
136
225
  /** Cap on JSON request bodies (generous: cover images travel as base64). */
137
- const MAX_JSON_BODY_BYTES = 16 * 1024 * 1024
226
+ const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024
227
+
228
+ /** 包内置风格效果图目录(assets/styles,随 npm 包分发)。 */
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
+ }
138
238
 
139
239
  /** Loopback-only fence (mirrors the family plugins' pairing routes). */
140
240
  function isLoopbackRequest(request: IncomingMessage): boolean {
@@ -188,13 +288,29 @@ export interface NovelRoutesDeps {
188
288
  ctx: Context
189
289
  /** Resolve the live plugin config (settings-aware). */
190
290
  getConfig: () => NovelConfig
191
- /** Persist a config patch through the settings seam. */
192
- 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
193
297
  }
194
298
 
195
299
  /** Default chapter count for planning when the request omits it. */
196
300
  const DEFAULT_PLAN_COUNT = 30
197
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
+
198
314
  /**
199
315
  * Build every /api/dsh-novel-forge route.
200
316
  * @param deps - context, config resolver, config patcher.
@@ -203,6 +319,13 @@ const DEFAULT_PLAN_COUNT = 30
203
319
  export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
204
320
  const { ctx, getConfig, patchConfig } = deps
205
321
 
322
+ /**
323
+ * 新书默认目录的基准:settings 里配置的输出目录(未经激活书合并,激活书的
324
+ * outputDir 指向它自己,不能当作新书的父目录)。未配置时 `defaultOutputDirFor`
325
+ * 回退到 `<DSH_HOME>/novels`。
326
+ */
327
+ const defaultBookDir = (bookName: string): string => defaultOutputDirFor(bookName, deps.rawConfig?.().outputDir)
328
+
206
329
  /** 全书质检实时状态(内存态,重启后回到 idle;用于 /status 暴露进度)。 */
207
330
  let auditState: AuditStatus = { status: 'idle', totalBatches: 0, completedBatches: 0 }
208
331
 
@@ -239,9 +362,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
239
362
  handler: (req, res) => {
240
363
  if (!guard(req, res, 'GET')) return
241
364
  const config = getConfig()
365
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
366
+ const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
242
367
  // 书架为空时播种 settings 默认输出目录里的已有项目。
243
- seedBookshelfFromOutputDir(config.outputDir)
244
- const project = loadProject(config.outputDir)
368
+ seedBookshelfFromOutputDir(outputDir)
369
+ const project = loadProject(outputDir)
245
370
  if (project !== undefined) {
246
371
  // 自动兜底:generating 超过 10 分钟(正常一章 2-5 分钟)视为卡死/中断,
247
372
  // 复位为 pending,避免"生成中"状态卡死无法重新生成。
@@ -256,18 +381,55 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
256
381
  }
257
382
  }
258
383
  }
259
- syncProjectWithDisk(project, config.outputDir)
260
- saveProject(config.outputDir, project)
384
+ syncProjectWithDisk(project, outputDir)
385
+ saveProject(outputDir, project)
261
386
  }
262
- const response: StatusResponse = {
263
- config,
387
+ const slim = new URL(req.url ?? '/', 'http://localhost').searchParams.get('slim') === '1'
388
+ // 截断显式化:任何被裁掉的字段都要在响应里声明,客户端/模型不能
389
+ // 把"看到的"当成"全部"。静默截断会让一致性判断建立在不完整事实上。
390
+ const truncations: string[] = []
391
+ let projectPayload: StatusResponse['project']
392
+ if (project === undefined) {
393
+ projectPayload = undefined
394
+ } else if (slim) {
395
+ truncations.push('slim=1:正文/beats/图集/道藏/大世界/资产等重字段未返回,大纲仅返回前 200 字')
396
+ // slim 瘦身:轮询/角标只需章节状态与分数 + 轻量元数据,
397
+ // 去掉正文、beats、图集(base64)、道藏/大世界/资产等重字段,避免长书轮询反复传输大体积。
398
+ projectPayload = {
399
+ bookName: project.bookName,
400
+ outline: project.outline.slice(0, 200),
401
+ chapters: project.chapters.map(c => ({
402
+ no: c.no,
403
+ volume: c.volume,
404
+ title: c.title,
405
+ status: c.status,
406
+ chars: c.chars,
407
+ error: c.error,
408
+ review: c.review !== undefined ? { score: c.review.score, passed: c.review.passed } : undefined,
409
+ })) as unknown as ProjectState['chapters'],
410
+ createdAt: project.createdAt,
411
+ updatedAt: project.updatedAt,
412
+ } as unknown as ProjectState
413
+ } else {
264
414
  // 瘦身:facts 只回最近 80 条(客户端最多展示 60),避免长篇后
265
415
  // status 响应体随编年录无限膨胀。
266
- project: project !== undefined
267
- ? { ...project, facts: (project.facts ?? []).slice(-80) }
268
- : undefined,
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 }
422
+ }
423
+ const response: StatusResponse = {
424
+ config,
425
+ project: projectPayload,
269
426
  generatedFiles: listChapterFiles(config.outputDir),
270
427
  audit: auditState,
428
+ // 阶段契约:宿主算一次,面板角标、助手提示词、外部自动化共用同一结论。
429
+ stage: computeBookStage(project),
430
+ // 本次运行(进程内)的 LLM 用量:调用次数 / 输入·输出·思考 token / 耗时。
431
+ usage: liveUsage(),
432
+ ...(truncations.length > 0 ? { truncations } : {}),
271
433
  }
272
434
  writeJson(res, 200, response)
273
435
  },
@@ -372,9 +534,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
372
534
  path: NOVEL_API.volumes,
373
535
  handler: async (req, res) => {
374
536
  if (!guard(req, res, 'POST')) return
375
- const body = await readJsonBody<VolumesRequest>(req)
376
- const config = getConfig()
377
- 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)
378
542
  const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
379
543
  ? body.outline
380
544
  : project?.outline
@@ -388,7 +552,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
388
552
  const next = project ?? createProject(outline)
389
553
  next.volumes = volumes
390
554
  next.updatedAt = now
391
- saveProject(config.outputDir, next)
555
+ saveProject(outputDir, next)
392
556
  const response: VolumesResponse = { volumes }
393
557
  writeJson(res, 200, response)
394
558
  } catch (error) {
@@ -403,9 +567,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
403
567
  path: NOVEL_API.plan,
404
568
  handler: async (req, res) => {
405
569
  if (!guard(req, res, 'POST')) return
406
- const body = await readJsonBody<PlanRequest>(req)
407
- const config = getConfig()
408
- 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)
409
575
  const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
410
576
  ? body.outline
411
577
  : project?.outline
@@ -441,10 +607,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
441
607
  path: NOVEL_API.generate,
442
608
  handler: async (req, res) => {
443
609
  if (!guard(req, res, 'POST')) return
444
- const config = getConfig()
445
- const project = requireProject(res)
446
- if (project === undefined) return
447
- 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
+ }
448
619
  const rawNo = body?.chapterNo
449
620
  if (!Number.isInteger(rawNo) || rawNo === undefined || rawNo < 1) {
450
621
  writeJson(res, 400, { error: 'chapterNo 须为正整数' })
@@ -479,21 +650,38 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
479
650
  res.write(JSON.stringify(frame) + '\n')
480
651
  }
481
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
+
482
657
  try {
483
658
  send({ type: 'start', no, title: chapter.title })
659
+ let genChars = 0
484
660
  for await (const step of generateChapterStream(ctx, config, project, config.outputDir, no)) {
485
661
  if (step.frame === 'delta') {
486
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() })
487
665
  } else if (step.frame === 'done') {
488
- send({ type: 'done', no, file: step.file, chars: step.chars, title: chapter.title })
666
+ send({ type: 'done', no, file: step.file, chars: step.chars, title: chapter.title, warn: step.warn })
489
667
  }
490
668
  }
491
- // 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)。
492
671
  try {
493
672
  await summarizeAndExtractFacts(ctx, config, project, config.outputDir, no)
494
673
  } catch (error) {
495
674
  console.warn('[dsh-novel-forge] summary/facts failed:', (error as Error).message)
496
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
+ }
497
685
  // 伏笔落地标记:正文中命中 planned 伏笔关键词 → 自动标为 planted(暗线管理页与正文同步)。
498
686
  try {
499
687
  const marked = markForeshadowPlanted(project, config.outputDir, no)
@@ -557,17 +745,22 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
557
745
  path: NOVEL_API.review,
558
746
  handler: async (req, res) => {
559
747
  if (!guard(req, res, 'POST')) return
560
- const config = getConfig()
561
- const project = requireProject(res)
562
- if (project === undefined) return
563
- 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
+ }
564
757
  if (!Number.isInteger(body?.chapterNo)) {
565
758
  writeJson(res, 400, { error: 'chapterNo 须为正整数' })
566
759
  return
567
760
  }
568
761
  const no = body!.chapterNo!
569
762
  try {
570
- const report = await reviewChapter(ctx, config, project, config.outputDir, no)
763
+ const report = await reviewChapter(ctx, config, project, outputDir, no)
571
764
  writeJson(res, 200, { report })
572
765
  } catch (error) {
573
766
  writeJson(res, 500, { error: (error as Error).message })
@@ -581,10 +774,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
581
774
  path: NOVEL_API.rewrite,
582
775
  handler: async (req, res) => {
583
776
  if (!guard(req, res, 'POST')) return
584
- const config = getConfig()
585
- const project = requireProject(res)
586
- if (project === undefined) return
587
- 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
+ }
588
786
  if (!Number.isInteger(body?.chapterNo)) {
589
787
  writeJson(res, 400, { error: 'chapterNo 须为正整数' })
590
788
  return
@@ -620,10 +818,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
620
818
  path: NOVEL_API.polish,
621
819
  handler: async (req, res) => {
622
820
  if (!guard(req, res, 'POST')) return
623
- const config = getConfig()
624
- const project = requireProject(res)
625
- if (project === undefined) return
626
- 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
+ }
627
830
  if (!Number.isInteger(body?.chapterNo)) {
628
831
  writeJson(res, 400, { error: 'chapterNo 须为正整数' })
629
832
  return
@@ -685,7 +888,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
685
888
  }
686
889
  writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
687
890
  chapter.pendingDraft = undefined
688
- chapter.chars = draft.length
891
+ chapter.chars = countHanzi(draft)
689
892
  chapter.file = fileName
690
893
  // 携带审查报告则沿用结论定状态(修订后审查通过 → approved);否则置 written 待审。
691
894
  const carried = body?.report
@@ -760,10 +963,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
760
963
  path: NOVEL_API.foreshadow,
761
964
  handler: async (req, res) => {
762
965
  if (!guard(req, res, 'POST')) return
763
- const config = getConfig()
764
- const project = requireProject(res)
765
- if (project === undefined) return
766
- 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
+ }
767
975
  try {
768
976
  if (body?.suggest === true) {
769
977
  // AI suggestion pass: create several foreshadows from the outline.
@@ -817,14 +1025,21 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
817
1025
  path: NOVEL_API.exportBook,
818
1026
  handler: async (req, res) => {
819
1027
  if (!guard(req, res, 'POST')) return
820
- const config = getConfig()
821
- const project = requireProject(res)
822
- if (project === undefined) return
823
- const body = await readJsonBody<ExportRequest>(req)
824
- 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'
825
1040
  try {
826
- const result = exportBook(config.outputDir, project, format)
827
- const response: ExportResponse = { ...result }
1041
+ const result = exportScope(config.outputDir, project, scope, format)
1042
+ const response: ExportResponse = { ...result, scope, format }
828
1043
  writeJson(res, 200, response)
829
1044
  } catch (error) {
830
1045
  writeJson(res, 500, { error: (error as Error).message })
@@ -839,9 +1054,13 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
839
1054
  handler: async (req, res) => {
840
1055
  if (!guard(req, res, 'GET')) return
841
1056
  const config = getConfig()
842
- const project = requireProject(res)
843
- if (project === undefined) return
844
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
+ }
845
1064
  const rawNo = Number(url.searchParams.get('no') ?? '0')
846
1065
  if (!Number.isInteger(rawNo) || rawNo < 1) {
847
1066
  writeJson(res, 400, { error: 'no 须为正整数' })
@@ -852,7 +1071,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
852
1071
  writeJson(res, 404, { error: `章节 ${rawNo} 不在计划中` })
853
1072
  return
854
1073
  }
855
- const markdown = readChapterFile(config.outputDir, chapter)
1074
+ const markdown = readChapterFile(outputDir, chapter)
856
1075
  if (markdown === undefined) {
857
1076
  writeJson(res, 404, { error: `章节 ${rawNo} 尚未生成` })
858
1077
  return
@@ -918,9 +1137,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
918
1137
  if (existsSync(targetPath)) {
919
1138
  copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
920
1139
  }
1140
+ // 覆盖前留一份历史版本:采纳草稿改坏了可以回滚。
1141
+ createSnapshot(config.outputDir, chapter, '采纳草稿前')
921
1142
  writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${text}\n`, 'utf8')
922
1143
  chapter.status = 'written'
923
- chapter.chars = text.length
1144
+ chapter.chars = countHanzi(text)
924
1145
  chapter.file = fileName
925
1146
  chapter.pendingDraft = undefined
926
1147
  // 保存即审稿:携带工作区审查报告则沿用(不重复审);否则自动正式审稿一次。
@@ -947,9 +1168,13 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
947
1168
  handler: async (req, res) => {
948
1169
  if (!guard(req, res, 'POST')) return
949
1170
  const config = getConfig()
950
- const project = requireProject(res)
951
- if (project === undefined) return
952
- 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
+ }
953
1178
  const message = body?.message?.trim()
954
1179
  if (message === undefined || message === '') {
955
1180
  writeJson(res, 400, { error: '消息不能为空' })
@@ -963,10 +1188,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
963
1188
  })
964
1189
  const send = (frame: AssistantFrame): void => { res.write(JSON.stringify(frame) + '\n') }
965
1190
  try {
966
- for await (const step of runAssistantTurn(ctx, config, project, config.outputDir, message)) {
1191
+ for await (const step of runAssistantTurn(ctx, config, project, outputDir, message)) {
967
1192
  if (step.frame === 'delta') send({ type: 'delta', text: step.text })
968
1193
  else if (step.frame === 'tool') send({ type: 'tool', name: step.name, status: step.status, detail: step.detail })
969
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 })
970
1196
  }
971
1197
  send({ type: 'done' })
972
1198
  res.end()
@@ -986,7 +1212,9 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
986
1212
  handler: (req, res) => {
987
1213
  if (!guard(req, res, 'GET')) return
988
1214
  const config = getConfig()
989
- 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) }
990
1218
  writeJson(res, 200, response)
991
1219
  },
992
1220
  }
@@ -996,10 +1224,12 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
996
1224
  const assistantClearRoute: WebRoute = {
997
1225
  kind: 'exact',
998
1226
  path: NOVEL_API.assistantClear,
999
- handler: (req, res) => {
1227
+ handler: async (req, res) => {
1000
1228
  if (!guard(req, res, 'POST')) return
1001
1229
  const config = getConfig()
1002
- clearAssistantHistory(config.outputDir)
1230
+ const body = await readJsonBody<{ bookId?: string }>(req)
1231
+ const outputDir = resolveOutputDir(config, body?.bookId)
1232
+ clearAssistantHistory(outputDir)
1003
1233
  writeJson(res, 200, { ok: true })
1004
1234
  },
1005
1235
  }
@@ -1020,10 +1250,21 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1020
1250
  return
1021
1251
  }
1022
1252
  const config = getConfig()
1023
- const project = loadProject(config.outputDir)
1024
- 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())
1025
1266
  if (req.method === 'POST') {
1026
- const body = await readJsonBody<AssetsPatch>(req)
1267
+ const body = postBody
1027
1268
  if (body === undefined) {
1028
1269
  writeJson(res, 400, { error: '无效的 JSON' })
1029
1270
  return
@@ -1040,14 +1281,16 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1040
1281
  projectAssets.updatedAt = new Date().toISOString()
1041
1282
  project.assets = projectAssets
1042
1283
  project.updatedAt = new Date().toISOString()
1043
- saveProject(config.outputDir, project)
1284
+ saveProject(outputDir, project)
1044
1285
  }
1045
1286
  const response: AssetsResponse = {
1046
1287
  projectAssets,
1047
- genreLibrary: BUILTIN_GENRE_LIBRARY,
1288
+ genreLibrary: [...BUILTIN_GENRE_LIBRARY, ...globalGenreLibrary()],
1048
1289
  antiAiLibrary: BUILTIN_ANTI_AI_RULES,
1049
1290
  styleTemplates: BUILTIN_STYLE_TEMPLATES,
1050
- progressionLibrary: BUILTIN_PROGRESSION_MODES,
1291
+ progressionLibrary: [...BUILTIN_PROGRESSION_MODES, ...globalProgressionLibrary()],
1292
+ starterStyleProfiles: BUILTIN_STARTER_STYLE_PROFILES,
1293
+ plotBeatLibrary: BUILTIN_PLOT_BEATS,
1051
1294
  }
1052
1295
  writeJson(res, 200, response)
1053
1296
  },
@@ -1091,177 +1334,995 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1091
1334
  },
1092
1335
  }
1093
1336
 
1094
- // ------------------------------------------------------------- bookshelf
1095
- const bookshelfRoute: WebRoute = {
1337
+ // ----------------------------------------------------------- style-formula
1338
+ const styleFormulaRoute: WebRoute = {
1096
1339
  kind: 'exact',
1097
- path: NOVEL_API.bookshelf,
1340
+ path: NOVEL_API.styleFormula,
1098
1341
  handler: async (req, res) => {
1099
- // GET = snapshot; POST = create book.
1100
- if (req.method === 'GET') {
1101
- if (!isLoopbackRequest(req)) {
1102
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
1103
- return
1104
- }
1105
- // 书架为空时,把 settings 默认输出目录里已有的项目播种为第一本书。
1106
- seedBookshelfFromOutputDir(getConfig().outputDir)
1107
- const snapshot: BookshelfSnapshot = bookshelfSnapshot(loadBookshelf())
1108
- 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 字符),请粘贴一段能代表目标风格的文字' })
1109
1349
  return
1110
1350
  }
1111
- if (req.method === 'POST') {
1112
- if (!isLoopbackRequest(req)) {
1113
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
1114
- return
1115
- }
1116
- const body = await readJsonBody<BookCreateRequest>(req)
1117
- const bookName = body?.bookName?.trim()
1118
- if (bookName === undefined || bookName === '') {
1119
- writeJson(res, 400, { error: 'bookName 不能为空' })
1120
- return
1121
- }
1122
- const outputDir = body?.outputDir?.trim() !== '' && body?.outputDir !== undefined
1123
- ? body.outputDir
1124
- : defaultOutputDirFor(bookName)
1125
- const book = createBook(bookName, outputDir)
1126
- // 开书向导:创建时带大纲 → 立即建立项目(书名以大纲首行为准)。
1127
- const outline = body?.outline?.trim()
1128
- if (outline !== undefined && outline.length >= 50) {
1129
- const project = createProject(outline)
1130
- saveProject(outputDir, project)
1131
- 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)
1132
1365
  }
1133
- 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 字符)' })
1134
1384
  return
1135
1385
  }
1136
- writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
1386
+ const scan = scanAiFlavor(text.slice(0, 20000))
1387
+ writeJson(res, 200, { scan })
1137
1388
  },
1138
1389
  }
1139
1390
 
1140
- // --------------------------------------------------------------- reset
1141
- /** 重置项目:清空设定/卷/章节计划/正文/伏笔/资产/事实库(可携带新大纲)。 */
1142
- const resetRoute: WebRoute = {
1391
+ // --------------------------------------------------------- market-radar
1392
+ /** 热门题材雷达:输入平台/题材/榜单文本 → 信号 + 生产底座 + 开书创意。 */
1393
+ const marketRadarRoute: WebRoute = {
1143
1394
  kind: 'exact',
1144
- path: NOVEL_API.reset,
1395
+ path: NOVEL_API.marketRadar,
1145
1396
  handler: async (req, res) => {
1146
1397
  if (!guard(req, res, 'POST')) return
1147
1398
  const config = getConfig()
1148
- const project = loadProject(config.outputDir)
1149
- if (project === undefined) {
1150
- writeJson(res, 400, { error: '输出目录中没有项目,无需重置' })
1151
- return
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 })
1152
1405
  }
1153
- const body = await readJsonBody<ResetRequest>(req)
1154
- const outline = body?.outline?.trim()
1155
- if (outline !== undefined && outline.length >= 50) {
1156
- project.outline = outline
1157
- project.bookName = createProject(outline).bookName
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 })
1158
1422
  }
1159
- project.bible = undefined
1160
- project.volumes = undefined
1161
- project.chapters = []
1162
- project.foreshadows = []
1163
- project.assets = emptyProjectAssets()
1164
- project.facts = []
1165
- project.updatedAt = new Date().toISOString()
1166
- saveProject(config.outputDir, project)
1167
- writeJson(res, 200, { ok: true, bookName: project.bookName })
1168
1423
  },
1169
1424
  }
1170
1425
 
1171
- // --------------------------------------------------- bookshelf activate
1172
- const bookshelfActivateRoute: WebRoute = {
1426
+ // ------------------------------------------------- market-radar apply
1427
+ /** 把雷达生产底座一键应用到某本书(写入项目资产/开书定盘,供后续规划与生成遵守)。 */
1428
+ const marketRadarApplyRoute: WebRoute = {
1173
1429
  kind: 'exact',
1174
- path: '/api/dsh-novel-forge/bookshelf/activate',
1430
+ path: NOVEL_API.marketRadarApply,
1175
1431
  handler: async (req, res) => {
1176
1432
  if (!guard(req, res, 'POST')) return
1177
- const body = await readJsonBody<BookActivateRequest>(req)
1178
- if (body?.id === undefined || body.id === '') {
1179
- writeJson(res, 400, { error: 'id 不能为空' })
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' })
1180
1437
  return
1181
1438
  }
1182
- const book = activateBook(body.id)
1183
- if (book === undefined) {
1184
- writeJson(res, 404, { error: `书 ${body.id} 不存在` })
1439
+ const config = getConfig()
1440
+ const outputDir = resolveOutputDir(config, body?.bookId)
1441
+ const project = loadProject(outputDir)
1442
+ if (project === undefined) {
1443
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1185
1444
  return
1186
1445
  }
1187
- writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
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,
1481
+ }
1482
+ project.updatedAt = new Date().toISOString()
1483
+ saveProject(outputDir, project)
1484
+ writeJson(res, 200, { ok: true, bookName: project.bookName })
1188
1485
  },
1189
1486
  }
1190
1487
 
1191
- // ---------------------------------------------------- bookshelf remove
1192
- const bookshelfRemoveRoute: WebRoute = {
1488
+ // ------------------------------------------------- market-radar sync
1489
+ /** 把雷达生产底座里的「新资产」同步进全局资源库(跨书复用;已有名字跳过)。 */
1490
+ const marketRadarSyncRoute: WebRoute = {
1193
1491
  kind: 'exact',
1194
- path: '/api/dsh-novel-forge/bookshelf/remove',
1492
+ path: NOVEL_API.marketRadarSync,
1195
1493
  handler: async (req, res) => {
1196
1494
  if (!guard(req, res, 'POST')) return
1197
- const body = await readJsonBody<BookRemoveRequest>(req)
1198
- if (body?.id === undefined || body.id === '') {
1199
- 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' })
1200
1499
  return
1201
1500
  }
1202
- const removed = removeBook(body.id)
1203
- if (!removed) {
1204
- writeJson(res, 404, { error: `书 ${body.id} 不存在` })
1205
- 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: [] })
1206
1504
  }
1207
- 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 })
1208
1512
  },
1209
1513
  }
1210
1514
 
1211
- // ---------------------------------------------------------------- audit
1212
- /** 全书一致性质检。 */
1213
- const auditRoute: WebRoute = {
1515
+ // ------------------------------------------------- market-radar brief
1516
+ /** 开书创意简报:用选中的市场信号 + 影响模式生成。 */
1517
+ const marketRadarBriefRoute: WebRoute = {
1214
1518
  kind: 'exact',
1215
- path: NOVEL_API.audit,
1519
+ path: NOVEL_API.marketRadarBrief,
1216
1520
  handler: async (req, res) => {
1217
1521
  if (!guard(req, res, 'POST')) return
1218
1522
  const config = getConfig()
1219
- const project = requireProject(res)
1220
- if (project === undefined) return
1221
- const startedAt = new Date().toISOString()
1222
- auditState = { status: 'running', startedAt, totalBatches: 0, completedBatches: 0 }
1523
+ const body = await readJsonBody<MarketRadarBriefRequest>(req)
1524
+ if (body === undefined || body.influenceMode === undefined) {
1525
+ writeJson(res, 400, { error: '缺少 influenceMode' })
1526
+ return
1527
+ }
1223
1528
  try {
1224
- const issues = await auditBook(ctx, config, project, config.outputDir, (completed, total) => {
1225
- auditState.totalBatches = total
1226
- auditState.completedBatches = completed
1227
- })
1228
- const auditedChapters = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating').length
1229
- auditState = {
1230
- status: 'done',
1231
- startedAt,
1232
- finishedAt: new Date().toISOString(),
1233
- totalBatches: auditState.totalBatches,
1234
- completedBatches: auditState.totalBatches,
1235
- auditedChapters,
1236
- issuesCount: issues.length,
1237
- issues,
1238
- }
1239
- const response: AuditResponse = {
1240
- issues,
1241
- auditedChapters,
1242
- auditedAt: auditState.finishedAt!,
1243
- }
1244
- writeJson(res, 200, response)
1529
+ const creativeBrief = await runMarketCreativeBrief(ctx, config, body)
1530
+ writeJson(res, 200, { creativeBrief })
1245
1531
  } catch (error) {
1246
- auditState = {
1247
- ...auditState,
1248
- status: 'error',
1249
- finishedAt: new Date().toISOString(),
1250
- error: (error as Error).message,
1251
- }
1252
1532
  writeJson(res, 500, { error: (error as Error).message })
1253
1533
  }
1254
1534
  },
1255
1535
  }
1256
1536
 
1257
- // ----------------------------------------------------- characters refresh
1258
- /** 角色卡刷新(出场统计精确化 + LLM 聚合状态)。 */
1259
- const charactersRefreshRoute: WebRoute = {
1537
+ // ------------------------------------------------------------- knowledge
1538
+ /** 书内知识库:GET 读取 / POST 增删改(按书路由)。 */
1539
+ const knowledgeRoute: WebRoute = {
1260
1540
  kind: 'exact',
1261
- path: NOVEL_API.charactersRefresh,
1541
+ path: NOVEL_API.knowledge,
1262
1542
  handler: async (req, res) => {
1263
- if (!guard(req, res, 'POST')) return
1264
- const config = getConfig()
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: '输出目录中没有项目' })
1555
+ return
1556
+ }
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 })
1570
+ },
1571
+ }
1572
+
1573
+ // ---------------------------------------------------------- book-analysis
1574
+ /** 书分析/拆书:输入文本 → 卖点/结构/可借鉴点/风险。 */
1575
+ const bookAnalysisRoute: WebRoute = {
1576
+ kind: 'exact',
1577
+ path: NOVEL_API.bookAnalysis,
1578
+ handler: async (req, res) => {
1579
+ if (!guard(req, res, 'POST')) return
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 字符)' })
1584
+ return
1585
+ }
1586
+ try {
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 })
1591
+ }
1592
+ },
1593
+ }
1594
+
1595
+ // ---------------------------------------------------------- idea-inspiration
1596
+ /** 创意灵感:一句话/题材 → 多方向开书灵感。 */
1597
+ const ideaInspirationRoute: WebRoute = {
1598
+ kind: 'exact',
1599
+ path: NOVEL_API.ideaInspiration,
1600
+ handler: async (req, res) => {
1601
+ if (!guard(req, res, 'POST')) return
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
+ }
1608
+ try {
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)
2216
+ const { book } = importDir(outDir)
2217
+ writeJson(res, 200, { ...result, book })
2218
+ return
2219
+ }
2220
+ // 模式一:服务器本地文件
2221
+ const filePath = body?.filePath?.trim()
2222
+ if (filePath === undefined || filePath === '') {
2223
+ writeJson(res, 400, { error: 'filePath 不能为空(或请上传文件内容)' })
2224
+ return
2225
+ }
2226
+ if (!existsSync(filePath)) {
2227
+ writeJson(res, 400, { error: `文件不存在:${filePath}` })
2228
+ return
2229
+ }
2230
+ const bookName = basename(filePath, extname(filePath)).slice(0, 40) || '导入小说'
2231
+ const outDir = body?.outputDir?.trim() !== undefined && body.outputDir.trim() !== ''
2232
+ ? body.outputDir.trim()
2233
+ : defaultBookDir(bookName)
2234
+ const result = importBookText(filePath, outDir)
2235
+ const { book } = importDir(outDir)
2236
+ writeJson(res, 200, { ...result, book })
2237
+ } catch (err) {
2238
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
2239
+ }
2240
+ },
2241
+ }
2242
+
2243
+ // --------------------------------------- bookshelf import-text preview
2244
+ /** 上传全文做拆章预览(不落盘、不登记书架)。 */
2245
+ const bookshelfImportTextPreviewRoute: WebRoute = {
2246
+ kind: 'exact',
2247
+ path: NOVEL_API.bookshelfImportTextPreview,
2248
+ handler: async (req, res) => {
2249
+ if (!guard(req, res, 'POST')) return
2250
+ const body = await readJsonBody<BookImportTextPreviewRequest>(req)
2251
+ if (body?.text === undefined || body.text.length === 0) {
2252
+ writeJson(res, 400, { error: 'text 不能为空' })
2253
+ return
2254
+ }
2255
+ try {
2256
+ const bookName = (body.fileName ?? '').replace(/\.[^.]+$/, '').trim().slice(0, 40) || '导入小说'
2257
+ const preview = previewBookText(body.text)
2258
+ writeJson(res, 200, { bookName, ...preview })
2259
+ } catch (err) {
2260
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
2261
+ }
2262
+ },
2263
+ }
2264
+
2265
+ // ---------------------------------------------------------------- audit
2266
+ /** 全书一致性质检。 */
2267
+ const auditRoute: WebRoute = {
2268
+ kind: 'exact',
2269
+ path: NOVEL_API.audit,
2270
+ handler: async (req, res) => {
2271
+ if (!guard(req, res, 'POST')) 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
+ }
2281
+ const startedAt = new Date().toISOString()
2282
+ auditState = { status: 'running', startedAt, totalBatches: 0, completedBatches: 0 }
2283
+ try {
2284
+ const issues = await auditBook(ctx, config, project, config.outputDir, (completed, total) => {
2285
+ auditState.totalBatches = total
2286
+ auditState.completedBatches = completed
2287
+ })
2288
+ const auditedChapters = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating').length
2289
+ auditState = {
2290
+ status: 'done',
2291
+ startedAt,
2292
+ finishedAt: new Date().toISOString(),
2293
+ totalBatches: auditState.totalBatches,
2294
+ completedBatches: auditState.totalBatches,
2295
+ auditedChapters,
2296
+ issuesCount: issues.length,
2297
+ issues,
2298
+ }
2299
+ const response: AuditResponse = {
2300
+ issues,
2301
+ auditedChapters,
2302
+ auditedAt: auditState.finishedAt!,
2303
+ model: config.auditModel || config.model,
2304
+ }
2305
+ writeJson(res, 200, response)
2306
+ } catch (error) {
2307
+ auditState = {
2308
+ ...auditState,
2309
+ status: 'error',
2310
+ finishedAt: new Date().toISOString(),
2311
+ error: (error as Error).message,
2312
+ }
2313
+ writeJson(res, 500, { error: (error as Error).message })
2314
+ }
2315
+ },
2316
+ }
2317
+
2318
+ // ----------------------------------------------------- characters refresh
2319
+ /** 角色卡刷新(出场统计精确化 + LLM 聚合状态)。 */
2320
+ const charactersRefreshRoute: WebRoute = {
2321
+ kind: 'exact',
2322
+ path: NOVEL_API.charactersRefresh,
2323
+ handler: async (req, res) => {
2324
+ if (!guard(req, res, 'POST')) return
2325
+ const config = getConfig()
1265
2326
  const project = requireProject(res)
1266
2327
  if (project === undefined) return
1267
2328
  try {
@@ -1297,7 +2358,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1297
2358
  }
1298
2359
 
1299
2360
  // ---------------------------------------------------------- bible patch
1300
- /** 设定圣经局部修补(世界观规则/红线/风格)。 */
2361
+ /** 道藏局部修补(世界观规则/红线/风格)。 */
1301
2362
  const biblePatchRoute: WebRoute = {
1302
2363
  kind: 'exact',
1303
2364
  path: NOVEL_API.biblePatch,
@@ -1337,7 +2398,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1337
2398
  }
1338
2399
 
1339
2400
  // ---------------------------------------------------------------- blurb
1340
- /** 小说简介:AI 生成/补全,或手动保存。 */
2401
+ /** 小说简介:生成/补全,或手动保存。 */
1341
2402
  const blurbRoute: WebRoute = {
1342
2403
  kind: 'exact',
1343
2404
  path: NOVEL_API.blurb,
@@ -1451,7 +2512,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1451
2512
  }
1452
2513
 
1453
2514
  // ---------------------------------------------------------------- world
1454
- /** 大世界:AI 提炼或手动保存(境界体系/区域/势力)。 */
2515
+ /** 大世界:提炼或手动保存(境界体系/区域/势力)。 */
1455
2516
  const worldRoute: WebRoute = {
1456
2517
  kind: 'exact',
1457
2518
  path: NOVEL_API.world,
@@ -1563,7 +2624,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1563
2624
  writeJson(res, 200, { plotlines: project.plotlines, suggestions } satisfies PlotlinesResponse)
1564
2625
  return
1565
2626
  } catch (error) {
1566
- writeJson(res, 500, { error: `AI 建议失败:${(error as Error).message}` })
2627
+ writeJson(res, 500, { error: `建议失败:${(error as Error).message}` })
1567
2628
  return
1568
2629
  }
1569
2630
  } else if (op === 'refresh' && body?.id !== undefined) {
@@ -1658,7 +2719,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1658
2719
  }
1659
2720
 
1660
2721
  // ---------------------------------------------------------------- roles
1661
- /** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
2722
+ /** 角色库:提炼 / 采纳 / 更新 / 删除。 */
1662
2723
  const rolesRoute: WebRoute = {
1663
2724
  kind: 'exact',
1664
2725
  path: NOVEL_API.roles,
@@ -1686,111 +2747,6 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1686
2747
  else project.roles[idx] = r
1687
2748
  } else if (op === 'remove' && body?.name !== undefined) {
1688
2749
  project.roles = project.roles.filter(x => x.name !== body.name)
1689
- } else if (op === 'image') {
1690
- const name = body?.name?.trim()
1691
- const dataUrl = body?.dataUrl?.trim()
1692
- if (name === undefined || name === '') {
1693
- writeJson(res, 400, { error: 'name(角色名)必填' })
1694
- return
1695
- }
1696
- if (dataUrl === undefined || dataUrl === '') {
1697
- writeJson(res, 400, { error: 'dataUrl(参考图)必填' })
1698
- return
1699
- }
1700
- const role = project.roles.find(r => r.name === name)
1701
- if (role === undefined) {
1702
- writeJson(res, 404, { error: `当前激活《${project.bookName}》,角色 ${name} 不存在——若期望的书不对,请先在书架切换该书并刷新` })
1703
- return
1704
- }
1705
- const label = body?.label?.trim() ?? ''
1706
- if (label !== '') {
1707
- // 图集上传:带用途标签。标签「立绘」时同步为角色参考图(imageUrl)。
1708
- role.gallery ??= []
1709
- role.gallery = role.gallery.filter(g => g.label !== label)
1710
- role.gallery.push({ label, dataUrl })
1711
- if (label === '立绘' || label.includes('立绘')) role.imageUrl = dataUrl
1712
- } else {
1713
- // 兼容旧行为:不带标签 = 角色参考图。
1714
- role.imageUrl = dataUrl
1715
- }
1716
- project.updatedAt = new Date().toISOString()
1717
- saveProject(config.outputDir, project)
1718
- writeJson(res, 200, { roles: project.roles, imageUrl: role.imageUrl } satisfies RolesResponse)
1719
- return
1720
- } else if (op === 'removeImage') {
1721
- const name = body?.name?.trim()
1722
- const label = body?.label?.trim() ?? ''
1723
- const role = project.roles.find(r => r.name === name)
1724
- if (role !== undefined) {
1725
- if (label !== '') {
1726
- role.gallery = (role.gallery ?? []).filter(g => g.label !== label)
1727
- if (label === '立绘' || label.includes('立绘')) role.imageUrl = undefined
1728
- } else {
1729
- role.imageUrl = undefined
1730
- }
1731
- }
1732
- project.updatedAt = new Date().toISOString()
1733
- saveProject(config.outputDir, project)
1734
- writeJson(res, 200, { roles: project.roles } satisfies RolesResponse)
1735
- return
1736
- } else if (op === 'imageGenerate') {
1737
- const name = body?.name?.trim()
1738
- if (name === undefined || name === '') {
1739
- writeJson(res, 400, { error: 'name(角色名)必填' })
1740
- return
1741
- }
1742
- try {
1743
- const imageUrl = await generateRoleReferenceImage(ctx, config, project, config.outputDir, name, body?.style ?? '')
1744
- project.updatedAt = new Date().toISOString()
1745
- saveProject(config.outputDir, project)
1746
- writeJson(res, 200, { roles: project.roles, imageUrl } satisfies RolesResponse)
1747
- return
1748
- } catch (error) {
1749
- writeJson(res, 500, { error: `角色参考图生成失败:${(error as Error).message}` })
1750
- return
1751
- }
1752
- } else if (op === 'visual') {
1753
- // 动漫形象描述词提炼:扫描正文外貌描写 → LLM 提炼 → 写入角色卡。
1754
- const name = body?.name?.trim()
1755
- if (name === undefined || name === '') {
1756
- writeJson(res, 400, { error: 'name(角色名)必填' })
1757
- return
1758
- }
1759
- try {
1760
- const visual = await extractRoleVisual(ctx, config, project, config.outputDir, name)
1761
- const role = project.roles.find(r => r.name === name)
1762
- if (role !== undefined) {
1763
- role.imagePrompt = visual
1764
- if ((visual.expressions ?? []).length > 0) role.expressions = visual.expressions
1765
- if (visual.promptKit !== undefined) role.promptKit = visual.promptKit
1766
- }
1767
- project.updatedAt = new Date().toISOString()
1768
- saveProject(config.outputDir, project)
1769
- writeJson(res, 200, { roles: project.roles, visual } satisfies RolesResponse)
1770
- return
1771
- } catch (error) {
1772
- writeJson(res, 500, { error: `形象提炼失败:${(error as Error).message}` })
1773
- return
1774
- }
1775
- } else if (op === 'promptKit') {
1776
- // 角色四类提示词精修包(立绘/四视图/表情/细节)
1777
- const name = body?.name?.trim()
1778
- if (name === undefined || name === '') {
1779
- writeJson(res, 400, { error: 'name(角色名)必填' })
1780
- return
1781
- }
1782
- try {
1783
- const kit = await generateRolePromptKit(ctx, config, project, name)
1784
- const role = project.roles.find(r => r.name === name)
1785
- if (role !== undefined) role.promptKit = kit
1786
- project.updatedAt = new Date().toISOString()
1787
- saveProject(config.outputDir, project)
1788
- writeJson(res, 200, { roles: project.roles, promptKit: kit } satisfies RolesResponse)
1789
- return
1790
- } catch (error) {
1791
- writeJson(res, 500, { error: `提示词精修失败:${(error as Error).message}` })
1792
- return
1793
- }
1794
2750
  } else {
1795
2751
  writeJson(res, 400, { error: '未知的 roles op' })
1796
2752
  return
@@ -1942,77 +2898,254 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1942
2898
  console.warn(`[dsh-novel-forge] author backfill ch.${chapter.no} failed:`, (error as Error).message)
1943
2899
  }
1944
2900
  }
1945
- project.updatedAt = new Date().toISOString()
1946
- saveProject(config.outputDir, project)
1947
- send({ type: 'author-backfill-done', count: done })
1948
- res.end()
2901
+ project.updatedAt = new Date().toISOString()
2902
+ saveProject(config.outputDir, project)
2903
+ send({ type: 'author-backfill-done', count: done })
2904
+ res.end()
2905
+ },
2906
+ }
2907
+
2908
+ // --------------------------------------------------------------- config
2909
+ const configRoute: WebRoute = {
2910
+ kind: 'exact',
2911
+ path: NOVEL_API.config,
2912
+ handler: async (req, res) => {
2913
+ if (!guard(req, res, 'POST')) return
2914
+ const body = await readJsonBody<ConfigPatch>(req)
2915
+ if (body === undefined) {
2916
+ writeJson(res, 400, { error: '无效的配置 JSON' })
2917
+ return
2918
+ }
2919
+ try {
2920
+ const result = await patchConfig(body)
2921
+ writeJson(res, 200, result satisfies ConfigResponse)
2922
+ } catch (error) {
2923
+ writeJson(res, 400, { error: (error as Error).message })
2924
+ }
2925
+ },
2926
+ }
2927
+
2928
+ // ---------------------------------------------------------- open-folder
2929
+ const openFolderRoute: WebRoute = {
2930
+ kind: 'exact',
2931
+ path: NOVEL_API.openFolder,
2932
+ handler: async (req, res) => {
2933
+ if (!guard(req, res, 'POST')) return
2934
+ const config = getConfig()
2935
+ const dir = config.outputDir
2936
+ const child = spawn('explorer', [dir], { shell: false })
2937
+ let responded = false
2938
+ child.on('error', (error) => {
2939
+ if (responded) return
2940
+ responded = true
2941
+ writeJson(res, 500, { ok: false, error: error.message })
2942
+ })
2943
+ child.on('exit', (code) => {
2944
+ if (responded) return
2945
+ responded = true
2946
+ if (code === 0) writeJson(res, 200, { ok: true })
2947
+ else writeJson(res, 500, { ok: false, error: `explorer 退出码 ${code}` })
2948
+ })
2949
+ },
2950
+ }
2951
+
2952
+ // ---------------------------------------------------------- plugin update
2953
+ /** 插件自更新:在 DSH profile 目录拉取最新 npm 版(仅下载,需重启 DSH 生效)。 */
2954
+ const pluginUpdateRoute: WebRoute = {
2955
+ kind: 'exact',
2956
+ path: NOVEL_API.pluginUpdate,
2957
+ handler: async (req, res) => {
2958
+ if (!guard(req, res, 'POST')) return
2959
+ const profileDir = dshHomePath('profiles', argvProfile() ?? 'web')
2960
+ const response: PluginUpdateResponse = await new Promise((resolve) => {
2961
+ const child = spawn('pnpm', ['add', '@waterwx/dsh-novel-forge@latest'], { cwd: profileDir, shell: true })
2962
+ let acc = ''
2963
+ child.stdout.on('data', (d: Buffer) => { acc += String(d) })
2964
+ child.stderr.on('data', (d: Buffer) => { acc += String(d) })
2965
+ child.on('error', (error) => resolve({ ok: false, message: '无法执行更新:' + error.message }))
2966
+ child.on('close', (code) => {
2967
+ if (code === 0) resolve({ ok: true, message: '已更新到最新版本,请重启 DSH 生效。' })
2968
+ else resolve({ ok: false, message: '更新失败(退出码 ' + code + '):' + acc.slice(-400) })
2969
+ })
2970
+ })
2971
+ writeJson(res, 200, response)
2972
+ },
2973
+ }
2974
+
2975
+ // ------------------------------------------------------- outline suggest
2976
+ /** 开书想法 → AI 大纲:2-3 个可选方案(支持暂留换批:count 只补空槽 + exclude 避开已留方向)。 */
2977
+ const outlineSuggestRoute: WebRoute = {
2978
+ kind: 'exact',
2979
+ path: NOVEL_API.outlineSuggest,
2980
+ handler: async (req, res) => {
2981
+ if (!guard(req, res, 'POST')) return
2982
+ const config = getConfig()
2983
+ const body = await readJsonBody<OutlineSuggestRequest>(req)
2984
+ const idea = body?.idea?.trim() ?? ''
2985
+ if (idea.length < 50) {
2986
+ writeJson(res, 400, { error: '想法太短(<50 字),请多写一两句:主角是谁、什么世界、想要什么爽点' })
2987
+ return
2988
+ }
2989
+ const count = body?.count !== undefined ? Math.max(1, Math.min(3, Math.floor(body.count))) : 3
2990
+ const exclude = Array.isArray(body?.exclude) ? body.exclude.filter((e): e is string => typeof e === 'string' && e.trim() !== '').map(e => e.trim().slice(0, 200)) : []
2991
+ try {
2992
+ const candidates = await suggestOutlines(ctx, config, idea, count, exclude)
2993
+ const response: OutlineSuggestResponse = { candidates }
2994
+ writeJson(res, 200, response)
2995
+ } catch (error) {
2996
+ writeJson(res, 500, { error: `大纲方案生成失败:${(error as Error).message}` })
2997
+ }
2998
+ },
2999
+ }
3000
+
3001
+ // ------------------------------------------------- outline reverse
3002
+ /** 反推大纲:从已写章节正文反向生成全书总纲(NDJSON 流式进度)。 */
3003
+ const outlineReverseRoute: WebRoute = {
3004
+ kind: 'exact',
3005
+ path: NOVEL_API.outlineReverse,
3006
+ handler: async (req, res) => {
3007
+ if (!guard(req, res, 'POST')) return
3008
+ const config = getConfig()
3009
+ const project = requireProject(res)
3010
+ if (project === undefined) return
3011
+ res.writeHead(200, {
3012
+ 'content-type': 'application/x-ndjson; charset=utf-8',
3013
+ 'cache-control': 'no-cache',
3014
+ 'x-accel-buffering': 'no',
3015
+ 'referrer-policy': 'no-referrer',
3016
+ })
3017
+ const send = (frame: JobFrame): void => {
3018
+ res.write(JSON.stringify(frame) + '\n')
3019
+ }
3020
+ try {
3021
+ const outline = await reverseOutlineFromChapters(ctx, config, project, config.outputDir, (done, total, phase) => {
3022
+ send({ type: 'outline-progress', done, total, phase })
3023
+ })
3024
+ // 保存总纲(仅更新文本,不动书名/进度)。
3025
+ project.outline = outline
3026
+ project.updatedAt = new Date().toISOString()
3027
+ saveProject(config.outputDir, project)
3028
+ send({ type: 'outline-done', outline, chars: outline.length })
3029
+ } catch (error) {
3030
+ send({ type: 'error', no: 0, message: (error as Error).message })
3031
+ } finally {
3032
+ res.end()
3033
+ }
3034
+ },
3035
+ }
3036
+
3037
+ // ------------------------------------------------------------- llm-test
3038
+ /** LLM 连通性测试:对选中的提供商/模型发一次最小真实调用。 */
3039
+ const llmTestRoute: WebRoute = {
3040
+ kind: 'exact',
3041
+ path: NOVEL_API.llmTest,
3042
+ handler: async (req, res) => {
3043
+ if (!guard(req, res, 'POST')) return
3044
+ const body = await readJsonBody<LlmTestRequest>(req)
3045
+ const provider = (body?.provider ?? '').trim()
3046
+ const model = (body?.model ?? '').trim()
3047
+ if (provider === '' || model === '') {
3048
+ writeJson(res, 400, { error: 'provider 与 model 必填' })
3049
+ return
3050
+ }
3051
+ const result = await testLlmModel(ctx, provider, model)
3052
+ writeJson(res, 200, result satisfies LlmTestResponse)
1949
3053
  },
1950
3054
  }
1951
3055
 
1952
- // --------------------------------------------------------------- config
1953
- const configRoute: WebRoute = {
3056
+ // ------------------------------------------------------------- llm-add
3057
+ /** 添加模型:厂商直填 API Key,或自定义 OpenAI 兼容路由(写 DSH 凭据 + router)。 */
3058
+ const addModelRoute: WebRoute = {
1954
3059
  kind: 'exact',
1955
- path: NOVEL_API.config,
3060
+ path: NOVEL_API.addModel,
1956
3061
  handler: async (req, res) => {
1957
3062
  if (!guard(req, res, 'POST')) return
1958
- const body = await readJsonBody<ConfigPatch>(req)
3063
+ const body = await readJsonBody<AddModelRequest>(req)
1959
3064
  if (body === undefined) {
1960
- writeJson(res, 400, { error: '无效的配置 JSON' })
3065
+ writeJson(res, 400, { error: '缺少请求体' })
1961
3066
  return
1962
3067
  }
1963
3068
  try {
1964
- const next = await patchConfig(body)
1965
- writeJson(res, 200, { config: next })
3069
+ const result = await registerLlmModel(ctx, body)
3070
+ writeJson(res, 200, result satisfies AddModelResponse)
1966
3071
  } catch (error) {
1967
- writeJson(res, 400, { error: (error as Error).message })
3072
+ writeJson(res, 500, { error: `添加模型失败:${(error as Error).message}` })
1968
3073
  }
1969
3074
  },
1970
3075
  }
1971
3076
 
1972
- // ---------------------------------------------------------- open-folder
1973
- const openFolderRoute: WebRoute = {
3077
+ // ---------------------------------------------------------- llm-vendors
3078
+ /** 运行时厂商目录(DSH pi-ai 可配置提供方 + 内置适配器)。 */
3079
+ const llmVendorsRoute: WebRoute = {
1974
3080
  kind: 'exact',
1975
- path: NOVEL_API.openFolder,
3081
+ path: NOVEL_API.llmVendors,
1976
3082
  handler: async (req, res) => {
1977
- if (!guard(req, res, 'POST')) return
1978
- const config = getConfig()
1979
- const dir = config.outputDir
1980
- exec(`explorer "${dir.replace(/"/g, '')}"`, (error) => {
1981
- if (error) {
1982
- writeJson(res, 500, { ok: false, error: error.message })
1983
- } else {
1984
- writeJson(res, 200, { ok: true })
1985
- }
1986
- })
3083
+ if (!guard(req, res, 'GET')) return
3084
+ try {
3085
+ const result = await listLlmVendors(ctx)
3086
+ writeJson(res, 200, result satisfies LlmVendorsResponse)
3087
+ } catch (error) {
3088
+ writeJson(res, 500, { error: `读取厂商目录失败:${(error as Error).message}` })
3089
+ }
1987
3090
  },
1988
3091
  }
1989
3092
 
1990
- // ------------------------------------------------------- outline suggest
1991
- /** 开书想法 → AI 大纲:2-3 个可选方案(支持暂留换批:count 只补空槽 + exclude 避开已留方向)。 */
1992
- const outlineSuggestRoute: WebRoute = {
3093
+ // ---------------------------------------------------------- llm-models
3094
+ /** 查询某个 provider 当前可用的模型(添加成功后可即时刷新下拉)。 */
3095
+ const llmModelsRoute: WebRoute = {
1993
3096
  kind: 'exact',
1994
- path: NOVEL_API.outlineSuggest,
3097
+ path: NOVEL_API.llmModels,
3098
+ handler: async (req, res) => {
3099
+ if (!guard(req, res, 'GET')) return
3100
+ const url = new URL(req.url ?? '', 'http://localhost')
3101
+ const provider = decodeURIComponent((url.searchParams.get('provider') ?? '').trim())
3102
+ try {
3103
+ const result = await listLlmModels(ctx, provider)
3104
+ writeJson(res, 200, result satisfies LlmModelsResponse)
3105
+ } catch (error) {
3106
+ writeJson(res, 500, { error: `读取模型列表失败:${(error as Error).message}` })
3107
+ }
3108
+ },
3109
+ }
3110
+
3111
+ // ---------------------------------------------------------- llm-providers
3112
+ /** 已注册的提供方路由列表(提供方管理卡片)。 */
3113
+ const llmProvidersRoute: WebRoute = {
3114
+ kind: 'exact',
3115
+ path: NOVEL_API.llmProviders,
3116
+ handler: async (req, res) => {
3117
+ if (!guard(req, res, 'GET')) return
3118
+ try {
3119
+ const result = await listLlmProviders(ctx)
3120
+ writeJson(res, 200, result satisfies LlmProvidersResponse)
3121
+ } catch (error) {
3122
+ writeJson(res, 500, { error: `读取提供方列表失败:${(error as Error).message}` })
3123
+ }
3124
+ },
3125
+ }
3126
+
3127
+ // ------------------------------------------------------------- llm-remove
3128
+ /** 移除一个提供方(unset key + 移除 llm-pi-ai 路由)。 */
3129
+ const llmRemoveRoute: WebRoute = {
3130
+ kind: 'exact',
3131
+ path: NOVEL_API.llmRemove,
1995
3132
  handler: async (req, res) => {
1996
3133
  if (!guard(req, res, 'POST')) return
1997
- const config = getConfig()
1998
- const body = await readJsonBody<OutlineSuggestRequest>(req)
1999
- const idea = body?.idea?.trim() ?? ''
2000
- if (idea.length < 50) {
2001
- writeJson(res, 400, { error: '想法太短(<50 字),请多写一两句:主角是谁、什么世界、想要什么爽点' })
3134
+ const body = await readJsonBody<RemoveProviderRequest>(req)
3135
+ if (body === undefined) {
3136
+ writeJson(res, 400, { error: '缺少请求体' })
2002
3137
  return
2003
3138
  }
2004
- const count = body?.count !== undefined ? Math.max(1, Math.min(3, Math.floor(body.count))) : 3
2005
- const exclude = Array.isArray(body?.exclude) ? body.exclude.filter((e): e is string => typeof e === 'string' && e.trim() !== '').map(e => e.trim().slice(0, 200)) : []
2006
3139
  try {
2007
- const candidates = await suggestOutlines(ctx, config, idea, count, exclude)
2008
- const response: OutlineSuggestResponse = { candidates }
2009
- writeJson(res, 200, response)
3140
+ const result = await removeLlmProvider(ctx, body)
3141
+ writeJson(res, 200, result satisfies RemoveProviderResponse)
2010
3142
  } catch (error) {
2011
- writeJson(res, 500, { error: `大纲方案生成失败:${(error as Error).message}` })
3143
+ writeJson(res, 500, { error: `移除提供方失败:${(error as Error).message}` })
2012
3144
  }
2013
3145
  },
2014
3146
  }
2015
3147
 
3148
+
2016
3149
  // ----------------------------------------------------------- breakdown
2017
3150
  /** 拆书分析:对已写章节做结构/人物/文风/卖点体检(两阶段:源笔记→分节分析)。 */
2018
3151
  const breakdownRoute: WebRoute = {
@@ -2041,114 +3174,128 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2041
3174
  },
2042
3175
  }
2043
3176
 
2044
- // --------------------------------------------------------------- scenes
2045
- /** 场景库:AI 提炼 / 采纳 / 更新 / 删除 / 图集。 */
2046
- 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 = {
2047
3188
  kind: 'exact',
2048
- path: NOVEL_API.scenes,
3189
+ path: NOVEL_API.moveOutputDir,
2049
3190
  handler: async (req, res) => {
2050
3191
  if (!guard(req, res, 'POST')) return
2051
- const config = getConfig()
2052
- const project = requireProject(res)
2053
- if (project === undefined) return
2054
- const body = await readJsonBody<ScenesRequest>(req)
2055
- if (project.scenes === undefined) project.scenes = []
2056
- const op = body?.op
2057
- if (op === 'extract') {
3192
+ const from = getConfig().outputDir
3193
+ const body = await readJsonBody<MoveOutputDirRequest>(req)
3194
+ // 预览:只列内容,不搬移。
3195
+ if (body?.dryRun === true) {
2058
3196
  try {
2059
- const candidates = await extractScenes(ctx, config, project)
2060
- writeJson(res, 200, { scenes: project.scenes, candidates } satisfies ScenesResponse)
2061
- 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)
2062
3199
  } catch (error) {
2063
- writeJson(res, 500, { error: `场景提炼失败:${(error as Error).message}` })
2064
- return
2065
- }
2066
- } else if ((op === 'adopt' || op === 'update') && body?.scene !== undefined) {
2067
- const s = body.scene
2068
- const idx = project.scenes.findIndex(x => x.name === s.name)
2069
- if (idx === -1) project.scenes.push(s)
2070
- else project.scenes[idx] = s
2071
- } else if (op === 'remove' && body?.name !== undefined) {
2072
- project.scenes = project.scenes.filter(x => x.name !== body.name)
2073
- } else if (op === 'image') {
2074
- const name = body?.name?.trim()
2075
- const dataUrl = body?.dataUrl?.trim()
2076
- if (name === undefined || name === '' || dataUrl === undefined || dataUrl === '') {
2077
- writeJson(res, 400, { error: 'name(场景名)与 dataUrl 必填' })
2078
- return
2079
- }
2080
- const scene = project.scenes.find(x => x.name === name)
2081
- if (scene === undefined) {
2082
- writeJson(res, 404, { error: `当前激活《${project.bookName}》,场景 ${name} 不存在——若期望的书不对,请先在书架切换该书并刷新` })
2083
- return
3200
+ writeJson(res, 400, { error: (error as Error).message })
2084
3201
  }
2085
- const label = body?.label?.trim() ?? '全景'
2086
- scene.gallery ??= []
2087
- scene.gallery = scene.gallery.filter(g => g.label !== label)
2088
- scene.gallery.push({ label, dataUrl })
2089
- } else if (op === 'removeImage') {
2090
- const name = body?.name?.trim()
2091
- const label = body?.label?.trim() ?? ''
2092
- const scene = project.scenes.find(x => x.name === name)
2093
- if (scene !== undefined && label !== '') {
2094
- scene.gallery = (scene.gallery ?? []).filter(g => g.label !== label)
2095
- }
2096
- } else {
2097
- writeJson(res, 400, { error: `未知操作 ${op}` })
2098
3202
  return
2099
3203
  }
2100
- project.updatedAt = new Date().toISOString()
2101
- saveProject(config.outputDir, project)
2102
- writeJson(res, 200, { scenes: project.scenes } satisfies ScenesResponse)
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: '新目录路径不能为空' })
3211
+ return
3212
+ }
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
+ })
2103
3252
  },
2104
3253
  }
2105
3254
 
2106
- // ----------------------------------------------------------- visual rules
2107
- /** 视觉世界观规则:从道藏提炼(生图/生视频纠偏),或手动保存。 */
2108
- const visualRulesRoute: WebRoute = {
3255
+ // ----------------------------------------------------------- book-settings
3256
+ /**
3257
+ * 本书参数:POST {} 读取当前书的 settings + 生效配置(全局+本书回退链);
3258
+ * POST {patch} 白名单合并保存到 novel-project.json.bookSettings。
3259
+ */
3260
+ const bookSettingsRoute: WebRoute = {
2109
3261
  kind: 'exact',
2110
- path: NOVEL_API.visualRules,
3262
+ path: NOVEL_API.bookSettings,
2111
3263
  handler: async (req, res) => {
2112
3264
  if (!guard(req, res, 'POST')) return
2113
- const config = getConfig()
2114
- const project = requireProject(res)
2115
- if (project === undefined) return
2116
- const body = await readJsonBody<VisualRulesRequest>(req)
2117
- if (body?.op === 'extract') {
2118
- try {
2119
- const rules = await extractVisualRules(ctx, config, project)
2120
- project.visualRules = rules
2121
- project.updatedAt = new Date().toISOString()
2122
- saveProject(config.outputDir, project)
2123
- writeJson(res, 200, { rules } satisfies VisualRulesResponse)
2124
- return
2125
- } catch (error) {
2126
- writeJson(res, 500, { error: `视觉规则提炼失败:${(error as Error).message}` })
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)' })
2127
3271
  return
2128
3272
  }
2129
- } else if (body?.op === 'save' && Array.isArray(body.rules)) {
2130
- project.visualRules = body.rules.filter(r => typeof r === 'string' && r.trim() !== '').map(r => r.trim().slice(0, 80)).slice(0, 12)
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
2131
3282
  project.updatedAt = new Date().toISOString()
2132
- saveProject(config.outputDir, project)
2133
- writeJson(res, 200, { rules: project.visualRules } satisfies VisualRulesResponse)
2134
- return
3283
+ saveProject(outputDir, project)
2135
3284
  }
2136
- 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)
2137
3287
  },
2138
3288
  }
2139
3289
 
2140
- // ----------------------------------------------------------- production run
2141
- /** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
2142
- const runner = new ProductionRunner({ ctx, getConfig })
2143
-
2144
3290
  const runStartRoute: WebRoute = {
2145
3291
  kind: 'exact',
2146
3292
  path: NOVEL_API.runStart,
2147
3293
  handler: async (req, res) => {
2148
3294
  if (!guard(req, res, 'POST')) return
2149
- const body = await readJsonBody<RunStartRequest>(req)
3295
+ const body = await readJsonBody<RunStartRequest & { bookId?: string }>(req)
2150
3296
  const config = getConfig()
2151
- const project = loadProject(config.outputDir)
3297
+ const outputDir = resolveOutputDir(config, body?.bookId)
3298
+ const project = loadProject(outputDir)
2152
3299
  if (project === undefined) {
2153
3300
  writeJson(res, 400, { error: '输出目录中没有项目' })
2154
3301
  return
@@ -2165,7 +3312,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2165
3312
  return
2166
3313
  }
2167
3314
  try {
2168
- const state = await runner.start(startNo, endNo)
3315
+ const state = await runner.start(startNo, endNo, outputDir)
2169
3316
  writeJson(res, 200, state satisfies RunState)
2170
3317
  } catch (error) {
2171
3318
  writeJson(res, 409, { error: (error as Error).message })
@@ -2204,6 +3351,337 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2204
3351
  },
2205
3352
  }
2206
3353
 
3354
+ // -------------------------------------------------- author assets (总数据)
3355
+ const authorAssetsRoute: WebRoute = {
3356
+ kind: 'exact',
3357
+ path: NOVEL_API.authorAssets,
3358
+ handler: (req, res) => {
3359
+ if (!guard(req, res, 'GET')) return
3360
+ const response: AuthorAssetsResponse = { assets: loadAuthorAssets() }
3361
+ writeJson(res, 200, response)
3362
+ },
3363
+ }
3364
+
3365
+ const authorAssetsUpsertRoute: WebRoute = {
3366
+ kind: 'exact',
3367
+ path: NOVEL_API.authorAssetsUpsert,
3368
+ handler: async (req, res) => {
3369
+ if (!guard(req, res, 'POST')) return
3370
+ const body = await readJsonBody<AuthorAssetUpsertRequest>(req)
3371
+ const asset = body?.asset
3372
+ if (asset === undefined || asset.name.trim() === '' || asset.content.trim() === '') {
3373
+ writeJson(res, 400, { error: 'asset 须含 name / content' })
3374
+ return
3375
+ }
3376
+ const response: AuthorAssetsResponse = { assets: upsertAuthorAsset(asset) }
3377
+ writeJson(res, 200, response)
3378
+ },
3379
+ }
3380
+
3381
+ const authorAssetsRemoveRoute: WebRoute = {
3382
+ kind: 'exact',
3383
+ path: NOVEL_API.authorAssetsRemove,
3384
+ handler: async (req, res) => {
3385
+ if (!guard(req, res, 'POST')) return
3386
+ const body = await readJsonBody<AuthorAssetRemoveRequest>(req)
3387
+ const id = body?.id ?? ''
3388
+ if (id === '') { writeJson(res, 400, { error: 'id 必填' }); return }
3389
+ const response: AuthorAssetsResponse = { assets: removeAuthorAsset(id) }
3390
+ writeJson(res, 200, response)
3391
+ },
3392
+ }
3393
+
3394
+ const authorAssetsImportDefaultRoute: WebRoute = {
3395
+ kind: 'exact',
3396
+ path: NOVEL_API.authorAssetsImportDefault,
3397
+ handler: (req, res) => {
3398
+ if (!guard(req, res, 'POST')) return
3399
+ const response: AuthorAssetsResponse = { assets: importDefaultAuthorAssets() }
3400
+ writeJson(res, 200, response)
3401
+ },
3402
+ }
3403
+
3404
+ // --------------------------------------------------- adaptation (改编 P0 分析)
3405
+ const adaptAnalyzeRoute: WebRoute = {
3406
+ kind: 'exact',
3407
+ path: NOVEL_API.adaptAnalyze,
3408
+ handler: async (req, res) => {
3409
+ if (!guard(req, res, 'POST')) return
3410
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
3411
+ const body = await readJsonBody<AdaptAnalyzeRequest>(req)
3412
+ const config = getConfig()
3413
+ let text = body?.text?.trim() ?? ''
3414
+ if (text === '' && body?.filePath !== undefined && body.filePath !== '') {
3415
+ try {
3416
+ text = readFileSync(body.filePath, 'utf8')
3417
+ } catch (err) {
3418
+ writeJson(res, 400, { error: '读取文件失败:' + (err as Error).message })
3419
+ return
3420
+ }
3421
+ }
3422
+ if (text.length < 200) {
3423
+ writeJson(res, 400, { error: '全文内容过短(<200 字符),请上传完整小说文本' })
3424
+ return
3425
+ }
3426
+ try {
3427
+ const response: AdaptAnalyzeResponse = await analyzeAdaptation(ctx, config, text)
3428
+ writeJson(res, 200, response)
3429
+ } catch (err) {
3430
+ writeJson(res, 400, { error: (err as Error).message })
3431
+ }
3432
+ },
3433
+ }
3434
+
3435
+ const adaptProposeRoute: WebRoute = {
3436
+ kind: 'exact',
3437
+ path: NOVEL_API.adaptPropose,
3438
+ handler: async (req, res) => {
3439
+ if (!guard(req, res, 'POST')) return
3440
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
3441
+ const body = await readJsonBody<AdaptProposeRequest>(req)
3442
+ const config = getConfig()
3443
+ const text = (body?.text ?? '').trim()
3444
+ const selections = body?.selections ?? []
3445
+ if (text.length < 200 || selections.length === 0) {
3446
+ writeJson(res, 400, { error: '请提供全文与至少一条要改的维度' })
3447
+ return
3448
+ }
3449
+ try {
3450
+ const response: AdaptProposeResponse = await proposeAdaptation(ctx, config, text, selections, body?.dimensions)
3451
+ writeJson(res, 200, response)
3452
+ } catch (err) {
3453
+ writeJson(res, 400, { error: (err as Error).message })
3454
+ }
3455
+ },
3456
+ }
3457
+
3458
+ const adaptExecuteRoute: WebRoute = {
3459
+ kind: 'exact',
3460
+ path: NOVEL_API.adaptExecute,
3461
+ handler: async (req, res) => {
3462
+ if (!guard(req, res, 'POST')) return
3463
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
3464
+ const body = await readJsonBody<AdaptExecuteRequest>(req)
3465
+ const text = body?.text ?? ''
3466
+ const mappings = body?.mappings ?? []
3467
+ if (text.length < 200 || mappings.length === 0) {
3468
+ writeJson(res, 400, { error: '请提供全文与映射表' })
3469
+ return
3470
+ }
3471
+ if (body?.mode === 'rewrite') {
3472
+ try {
3473
+ const result = await rewriteAdaptationBook(ctx, getConfig(), text, mappings, body?.rules, { maxChapters: body?.maxChapters, startNo: body?.startNo, endNo: body?.endNo })
3474
+ const response: AdaptExecuteResponse = { adaptedText: result.adaptedText, mappings: mappings.length, hits: result.hits, mode: 'rewrite', rewritten: result.rewritten, skipped: result.skipped }
3475
+ writeJson(res, 200, response)
3476
+ } catch (err) {
3477
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
3478
+ }
3479
+ return
3480
+ }
3481
+ const { adaptedText, hits } = applyAdaptationReplacements(text, mappings)
3482
+ const response: AdaptExecuteResponse = { adaptedText, mappings: mappings.length, hits, mode: 'replace' }
3483
+ writeJson(res, 200, response)
3484
+ },
3485
+ }
3486
+
3487
+ /** rewrite 逐章重写:NDJSON 流式进度(支持分段 startNo/endNo)。 */
3488
+ const adaptRewriteStreamRoute: WebRoute = {
3489
+ kind: 'exact',
3490
+ path: NOVEL_API.adaptRewriteStream,
3491
+ handler: async (req, res) => {
3492
+ if (!guard(req, res, 'POST')) return
3493
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
3494
+ const body = await readJsonBody<AdaptExecuteRequest>(req)
3495
+ const text = body?.text ?? ''
3496
+ const mappings = body?.mappings ?? []
3497
+ if (text.length < 200 || mappings.length === 0) { writeJson(res, 400, { error: '请提供全文与映射表' }); return }
3498
+ res.writeHead(200, {
3499
+ 'content-type': 'application/x-ndjson; charset=utf-8',
3500
+ 'cache-control': 'no-cache',
3501
+ 'x-accel-buffering': 'no',
3502
+ 'referrer-policy': 'no-referrer',
3503
+ })
3504
+ const send = (frame: AdaptRewriteFrame): void => { res.write(JSON.stringify(frame) + '\n') }
3505
+ try {
3506
+ const result = await rewriteAdaptationBook(ctx, getConfig(), text, mappings, body?.rules, {
3507
+ maxChapters: body?.maxChapters,
3508
+ startNo: body?.startNo,
3509
+ endNo: body?.endNo,
3510
+ onProgress: (info) => send({ type: 'progress', completed: info.completed, total: info.total, no: info.no, title: info.title }),
3511
+ })
3512
+ const response: AdaptExecuteResponse = { adaptedText: result.adaptedText, mappings: mappings.length, hits: result.hits, mode: 'rewrite', rewritten: result.rewritten, skipped: result.skipped }
3513
+ send({ type: 'done', result: response })
3514
+ } catch (err) {
3515
+ send({ type: 'error', message: err instanceof Error ? err.message : String(err) })
3516
+ } finally {
3517
+ res.end()
3518
+ }
3519
+ },
3520
+ }
3521
+
3522
+ /** 保存改编全文为新书(原书保留,登记书架)。 */
3523
+ const adaptSaveRoute: WebRoute = {
3524
+ kind: 'exact',
3525
+ path: NOVEL_API.adaptSave,
3526
+ handler: async (req, res) => {
3527
+ if (!guard(req, res, 'POST')) return
3528
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
3529
+ const body = await readJsonBody<AdaptSaveRequest>(req)
3530
+ const text = (body?.text ?? '').trim()
3531
+ if (text.length < 200) {
3532
+ writeJson(res, 400, { error: '改编全文内容过短(<200 字符)' })
3533
+ return
3534
+ }
3535
+ const bookName = (body?.bookName ?? '').trim().slice(0, 40) || '改编新书'
3536
+ const outDir = (body?.outputDir ?? '').trim() !== '' && body?.outputDir !== undefined && body.outputDir.trim() !== ''
3537
+ ? body.outputDir.trim()
3538
+ : defaultOutputDirFor(bookName)
3539
+ // 原书保留:若目录已被另一本书占用则拒绝,避免误覆盖原书/别的书。
3540
+ const occupied = loadBookshelf().books.find(b => b.outputDir === outDir)
3541
+ if (occupied !== undefined && occupied.bookName !== bookName) {
3542
+ writeJson(res, 409, { error: `目录已被《${occupied.bookName}》占用,请更换书名或输出目录` })
3543
+ return
3544
+ }
3545
+ try {
3546
+ const result = importBookTextFromText(text, outDir, bookName)
3547
+ // 有反推大纲时写进新项目,便于后续续写/编辑(importBookTextFromText 默认 outline=书名)。
3548
+ const outline = (body?.outline ?? '').trim()
3549
+ if (outline !== '') {
3550
+ const project = loadProject(outDir)
3551
+ if (project !== undefined) {
3552
+ project.outline = outline
3553
+ saveProject(outDir, project)
3554
+ }
3555
+ }
3556
+ const { book } = importDir(outDir)
3557
+ const response: AdaptSaveResponse = { book, bookName: result.bookName, chapters: result.chapters, skipped: result.skipped, outputDir: outDir }
3558
+ writeJson(res, 200, response)
3559
+ } catch (err) {
3560
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
3561
+ }
3562
+ },
3563
+ }
3564
+
3565
+ /** 从源全文 + 编辑后方案提炼新书资料并保存为「待写新书」。 */
3566
+ const adaptMaterializeRoute: WebRoute = {
3567
+ kind: 'exact',
3568
+ path: NOVEL_API.adaptMaterialize,
3569
+ handler: async (req, res) => {
3570
+ if (!guard(req, res, 'POST')) return
3571
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
3572
+ const body = await readJsonBody<AdaptMaterializeRequest>(req)
3573
+ const text = (body?.text ?? '').trim()
3574
+ if (text.length < 200) { writeJson(res, 400, { error: '源书全文内容过短(<200 字符)' }); return }
3575
+ const proposal = body?.proposal
3576
+ if (proposal === undefined || proposal.mappings.length === 0) { writeJson(res, 400, { error: '请先提供至少一条映射的改编方案' }); return }
3577
+ const bookName = (body?.bookName ?? '').trim().slice(0, 40) || '改编新书'
3578
+ const outDir = (body?.outputDir ?? '').trim() !== '' && body?.outputDir !== undefined && body.outputDir.trim() !== ''
3579
+ ? body.outputDir.trim()
3580
+ : defaultOutputDirFor(bookName)
3581
+ // 原书保留:若目录已被另一本书占用则拒绝。
3582
+ const occupied = loadBookshelf().books.find(b => b.outputDir === outDir)
3583
+ if (occupied !== undefined && occupied.bookName !== bookName) {
3584
+ writeJson(res, 409, { error: `目录已被《${occupied.bookName}》占用,请更换书名或输出目录` })
3585
+ return
3586
+ }
3587
+ try {
3588
+ const result = await materializeAdaptedBook(ctx, getConfig(), {
3589
+ text,
3590
+ bookName,
3591
+ outputDir: outDir,
3592
+ outline: body?.outline,
3593
+ proposal,
3594
+ chapterCount: body?.chapterCount,
3595
+ })
3596
+ // 预览:只返回提炼资料,不落盘;保存走 /adapt/materialize-save。
3597
+ const response: AdaptMaterializeResponse = result
3598
+ writeJson(res, 200, response)
3599
+ } catch (err) {
3600
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
3601
+ }
3602
+ },
3603
+ }
3604
+
3605
+ /** 保存预览/微调后的新书资料为新书(原书保留,登记书架)。 */
3606
+ const adaptMaterializeSaveRoute: WebRoute = {
3607
+ kind: 'exact',
3608
+ path: NOVEL_API.adaptMaterializeSave,
3609
+ handler: async (req, res) => {
3610
+ if (!guard(req, res, 'POST')) return
3611
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
3612
+ const body = await readJsonBody<AdaptMaterializeSaveRequest>(req)
3613
+ if (body === undefined || body === null || (body.outline ?? '').trim() === '') {
3614
+ writeJson(res, 400, { error: '请提供改编后总纲' })
3615
+ return
3616
+ }
3617
+ const bookName = (body.bookName ?? '').trim().slice(0, 40) || '改编新书'
3618
+ const outDir = (body.outputDir ?? '').trim() !== '' && body.outputDir !== undefined && body.outputDir.trim() !== ''
3619
+ ? body.outputDir.trim()
3620
+ : defaultOutputDirFor(bookName)
3621
+ const occupied = loadBookshelf().books.find(b => b.outputDir === outDir)
3622
+ if (occupied !== undefined && occupied.bookName !== bookName) {
3623
+ writeJson(res, 409, { error: `目录已被《${occupied.bookName}》占用,请更换书名或输出目录` })
3624
+ return
3625
+ }
3626
+ try {
3627
+ const result = saveMaterializedBook(outDir, bookName, {
3628
+ outline: body.outline,
3629
+ bible: body.bible,
3630
+ roles: body.roles,
3631
+ world: body.world,
3632
+ volumes: body.volumes,
3633
+ chapters: body.chapters,
3634
+ })
3635
+ const { book } = importDir(outDir)
3636
+ const response: AdaptMaterializeSaveResponse = { ...result, book }
3637
+ writeJson(res, 200, response)
3638
+ } catch (err) {
3639
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
3640
+ }
3641
+ },
3642
+ }
3643
+
3644
+ // ------------------------------------------ theme custom background (upload + serve)
3645
+ const THEME_BG_DIR = dshHomePath('dsh-novel-forge-assets')
3646
+ const MIME_BY_EXT: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }
3647
+
3648
+ const themeBackgroundUploadRoute: WebRoute = {
3649
+ kind: 'exact',
3650
+ path: NOVEL_API.themeBackgroundUpload,
3651
+ handler: async (req, res) => {
3652
+ if (!guard(req, res, 'POST')) return
3653
+ const body = await readJsonBody<{ dataUrl?: string }>(req)
3654
+ const dataUrl = body?.dataUrl ?? ''
3655
+ const m = /^data:image\/(png|jpeg|jpg|webp|gif);base64,(.+)$/i.exec(dataUrl)
3656
+ if (m === null) { writeJson(res, 400, { error: '仅支持 PNG/JPG/WebP/GIF 图片' }); return }
3657
+ const ext = m[1]!.toLowerCase() === 'jpg' ? 'jpeg' : m[1]!.toLowerCase()
3658
+ const buf = Buffer.from(m[2]!, 'base64')
3659
+ if (buf.length === 0) { writeJson(res, 400, { error: '图片数据为空' }); return }
3660
+ mkdirSync(THEME_BG_DIR, { recursive: true })
3661
+ const name = 'theme-bg-' + Date.now().toString(36) + '-' + randomBytes(3).toString('hex') + '.' + ext
3662
+ writeFileSync(join(THEME_BG_DIR, name), buf)
3663
+ writeJson(res, 200, { url: NOVEL_API.themeBackgroundGet + '/' + name })
3664
+ },
3665
+ }
3666
+
3667
+ const themeBackgroundGetRoute: WebRoute = {
3668
+ kind: 'prefix',
3669
+ path: NOVEL_API.themeBackgroundGet,
3670
+ handler: (req, res) => {
3671
+ if (!guard(req, res, 'GET')) return
3672
+ let pathName = ''
3673
+ try { pathName = new URL(req.url ?? '', 'http://localhost').pathname } catch { /* ignore */ }
3674
+ const name = basename(pathName)
3675
+ if (!name.startsWith('theme-bg-')) { writeJson(res, 404, { error: 'not found' }); return }
3676
+ const file = join(THEME_BG_DIR, name)
3677
+ if (!existsSync(file)) { writeJson(res, 404, { error: 'not found' }); return }
3678
+ const ext = extname(file).slice(1)
3679
+ const type = MIME_BY_EXT[ext] ?? 'application/octet-stream'
3680
+ res.writeHead(200, { 'content-type': type, 'cache-control': 'public, max-age=31536000' })
3681
+ res.end(readFileSync(file))
3682
+ },
3683
+ }
3684
+
2207
3685
  return [
2208
3686
  statusRoute,
2209
3687
  loadOutlineRoute,
@@ -2225,12 +3703,36 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2225
3703
  chapterSaveRoute,
2226
3704
  assetsRoute,
2227
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,
2228
3720
  assistantRoute,
2229
3721
  assistantHistoryRoute,
3722
+ llmPromptRoute,
3723
+ usageResetRoute,
3724
+ timelineRoute,
3725
+ revisionPlanRoute,
3726
+ snapshotRoute,
3727
+ tensionRoute,
3728
+ promptSlotsRoute,
2230
3729
  assistantClearRoute,
2231
3730
  bookshelfRoute,
2232
3731
  bookshelfActivateRoute,
2233
3732
  bookshelfRemoveRoute,
3733
+ bookshelfImportDirRoute,
3734
+ bookshelfImportTextRoute,
3735
+ bookshelfImportTextPreviewRoute,
2234
3736
  resetRoute,
2235
3737
  auditRoute,
2236
3738
  charactersRefreshRoute,
@@ -2242,19 +3744,40 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2242
3744
  renameRoute,
2243
3745
  plotlinesRoute,
2244
3746
  rolesRoute,
2245
- scenesRoute,
2246
- visualRulesRoute,
2247
3747
  sensitiveRoute,
2248
3748
  reviewBackfillRoute,
2249
3749
  chapterResetRoute,
2250
3750
  chapterApproveRoute,
2251
3751
  configRoute,
3752
+ moveOutputDirRoute,
3753
+ bookSettingsRoute,
2252
3754
  openFolderRoute,
3755
+ pluginUpdateRoute,
2253
3756
  outlineSuggestRoute,
3757
+ outlineReverseRoute,
3758
+ llmTestRoute,
3759
+ addModelRoute,
3760
+ llmVendorsRoute,
3761
+ llmModelsRoute,
3762
+ llmProvidersRoute,
3763
+ llmRemoveRoute,
2254
3764
  breakdownRoute,
2255
3765
  runStartRoute,
2256
3766
  runControlRoute,
2257
3767
  runStatusRoute,
3768
+ authorAssetsRoute,
3769
+ authorAssetsUpsertRoute,
3770
+ authorAssetsRemoveRoute,
3771
+ authorAssetsImportDefaultRoute,
3772
+ adaptAnalyzeRoute,
3773
+ adaptProposeRoute,
3774
+ adaptExecuteRoute,
3775
+ adaptSaveRoute,
3776
+ adaptMaterializeRoute,
3777
+ adaptMaterializeSaveRoute,
3778
+ adaptRewriteStreamRoute,
3779
+ themeBackgroundUploadRoute,
3780
+ themeBackgroundGetRoute,
2258
3781
  ]
2259
3782
  }
2260
3783