@waterwx/dsh-novel-forge 1.3.1 → 1.3.2-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 (161) hide show
  1. package/README.md +288 -279
  2. package/lib/client.js +56309 -12221
  3. package/lib/index.js +14239 -5595
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +36 -0
  6. package/lib/types/assets.d.ts +24 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +25 -0
  9. package/lib/types/bookshelf.d.ts +11 -1
  10. package/lib/types/client/api.d.ts +186 -10
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -0
  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 +28 -67
  16. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  19. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  20. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  21. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  22. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  23. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  24. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  25. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  26. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  27. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  28. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  29. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  30. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  31. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  32. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  33. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  34. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  35. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  36. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  37. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  38. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  39. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  40. package/lib/types/client/panel/SubPage.d.ts +27 -0
  41. package/lib/types/client/panel/TensionView.d.ts +18 -0
  42. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  43. package/lib/types/client/panel/helpers.d.ts +5 -3
  44. package/lib/types/client/panel/toast.d.ts +2 -0
  45. package/lib/types/client/panel/views.d.ts +25 -7
  46. package/lib/types/client/run-status.d.ts +21 -0
  47. package/lib/types/client/text.d.ts +9 -0
  48. package/lib/types/docx-core.d.ts +16 -0
  49. package/lib/types/docx.d.ts +2 -3
  50. package/lib/types/engine.d.ts +186 -45
  51. package/lib/types/global-assets.d.ts +6 -0
  52. package/lib/types/index.d.ts +22 -8
  53. package/lib/types/llm-live.d.ts +75 -0
  54. package/lib/types/llm-retry.d.ts +38 -0
  55. package/lib/types/market-radar-scan.d.ts +19 -0
  56. package/lib/types/market-radar-sources.d.ts +31 -0
  57. package/lib/types/migrate-output.d.ts +25 -0
  58. package/lib/types/novel-context.d.ts +80 -0
  59. package/lib/types/prompt-slots.d.ts +57 -0
  60. package/lib/types/prompt-utils.d.ts +35 -0
  61. package/lib/types/prompting.d.ts +10 -0
  62. package/lib/types/protocol.d.ts +1362 -166
  63. package/lib/types/review-policy.d.ts +97 -0
  64. package/lib/types/revision.d.ts +76 -0
  65. package/lib/types/routes.d.ts +4 -0
  66. package/lib/types/run.d.ts +23 -1
  67. package/lib/types/snapshots.d.ts +46 -0
  68. package/lib/types/stage-contract.d.ts +60 -0
  69. package/lib/types/tension.d.ts +53 -0
  70. package/lib/types/timeline.d.ts +37 -0
  71. package/package.json +52 -28
  72. package/src/action-guard.ts +127 -0
  73. package/src/ai-scan.ts +179 -0
  74. package/src/assets.ts +1806 -520
  75. package/src/assistant.ts +1098 -770
  76. package/src/author-assets.ts +149 -0
  77. package/src/bookshelf.ts +187 -151
  78. package/src/client/api.ts +378 -21
  79. package/src/client/chip-prefs.ts +121 -0
  80. package/src/client/docx.ts +37 -69
  81. package/src/client/index.ts +3 -1
  82. package/src/client/llmLive.ts +111 -0
  83. package/src/client/locales.ts +28 -253
  84. package/src/client/mount.tsx +1 -1
  85. package/src/client/panel/AdaptModeView.tsx +535 -0
  86. package/src/client/panel/AiTag.tsx +7 -0
  87. package/src/client/panel/AssetsTab.tsx +448 -458
  88. package/src/client/panel/AssistantTab.tsx +355 -301
  89. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  90. package/src/client/panel/AuthorHome.tsx +309 -0
  91. package/src/client/panel/BookAnalysisView.tsx +63 -0
  92. package/src/client/panel/CmdPalette.tsx +102 -0
  93. package/src/client/panel/CreateBookView.tsx +366 -348
  94. package/src/client/panel/DirectorView.tsx +200 -0
  95. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  96. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  97. package/src/client/panel/ImportModal.tsx +303 -0
  98. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  99. package/src/client/panel/LiveFeedLog.tsx +204 -0
  100. package/src/client/panel/MarketRadarView.tsx +377 -0
  101. package/src/client/panel/ModelManager.tsx +205 -0
  102. package/src/client/panel/NovelPanel.tsx +3135 -2651
  103. package/src/client/panel/PageHeader.tsx +37 -0
  104. package/src/client/panel/ProgressConsole.tsx +63 -0
  105. package/src/client/panel/PromptSlotsView.tsx +97 -0
  106. package/src/client/panel/ReaderView.tsx +306 -306
  107. package/src/client/panel/ReasoningSection.tsx +31 -0
  108. package/src/client/panel/RunPanel.tsx +64 -32
  109. package/src/client/panel/SettingsView.tsx +354 -0
  110. package/src/client/panel/ShelfView.tsx +266 -236
  111. package/src/client/panel/SubPage.tsx +42 -0
  112. package/src/client/panel/TensionView.tsx +212 -0
  113. package/src/client/panel/TimelineView.tsx +208 -0
  114. package/src/client/panel/WorldTab.tsx +202 -202
  115. package/src/client/panel/helpers.ts +7 -5
  116. package/src/client/panel/panel.module.css +4481 -1655
  117. package/src/client/panel/toast.ts +51 -0
  118. package/src/client/panel/views.tsx +401 -314
  119. package/src/client/run-status.ts +171 -0
  120. package/src/client/sidebar-entry.ts +1 -1
  121. package/src/client/text.ts +28 -0
  122. package/src/docx-core.ts +53 -0
  123. package/src/docx.ts +44 -83
  124. package/src/engine.ts +4672 -2943
  125. package/src/global-assets.ts +59 -0
  126. package/src/index.ts +89 -33
  127. package/src/llm-live.ts +260 -0
  128. package/src/llm-retry.ts +77 -0
  129. package/src/market-radar-scan.ts +41 -0
  130. package/src/market-radar-sources.ts +173 -0
  131. package/src/migrate-output.ts +76 -0
  132. package/src/novel-context.ts +286 -0
  133. package/src/prompt-slots.ts +125 -0
  134. package/src/prompting.ts +26 -0
  135. package/src/protocol.ts +1388 -158
  136. package/src/review-policy.ts +161 -0
  137. package/src/revision.ts +283 -0
  138. package/src/routes.ts +1934 -434
  139. package/src/run.ts +118 -21
  140. package/src/snapshots.ts +174 -0
  141. package/src/stage-contract.ts +189 -0
  142. package/src/tension.ts +251 -0
  143. package/src/timeline.ts +159 -0
  144. package/lib/client.js.map +0 -1
  145. package/lib/index.js.map +0 -1
  146. package/lib/types/comic-presets.d.ts +0 -12
  147. package/scripts/assistant-frames.txt +0 -8
  148. package/scripts/diagnose-adapter.mts +0 -81
  149. package/scripts/diagnose-assistant.mjs +0 -58
  150. package/scripts/diagnose-bible.mjs +0 -72
  151. package/scripts/diagnose-harness.mts +0 -75
  152. package/scripts/novel-forge-restart.log +0 -10
  153. package/scripts/novel-forge-web.stderr.log +0 -2
  154. package/scripts/novel-forge-web.stdout.log +0 -1
  155. package/scripts/outline-sample.txt +0 -449
  156. package/scripts/probe.mjs +0 -6
  157. package/scripts/restart-web.ps1 +0 -88
  158. package/scripts/smoke.mts +0 -72
  159. package/scripts/upload-github.ps1 +0 -51
  160. package/src/client/panel/BookshelfBar.tsx +0 -170
  161. package/src/comic-presets.ts +0 -67
package/src/routes.ts CHANGED
@@ -9,16 +9,26 @@
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 { homedir } from 'node:os'
16
+ import { randomBytes } from 'node:crypto'
15
17
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
16
18
  import type { Context } from '@deepseek-ai/cordis'
17
19
  import { ProductionRunner } from './run.ts'
18
20
  import {
21
+ EXPORT_SCOPE_VALUES,
19
22
  NOVEL_API,
20
23
  type AssetsPatch,
21
24
  type AssetsResponse,
25
+ type AddModelRequest,
26
+ type AddModelResponse,
27
+ type LlmModelsResponse,
28
+ type LlmProvidersResponse,
29
+ type LlmVendorsResponse,
30
+ type RemoveProviderRequest,
31
+ type RemoveProviderResponse,
22
32
  type AssistantFrame,
23
33
  type AssistantHistoryResponse,
24
34
  type AssistantRequest,
@@ -35,6 +45,9 @@ import {
35
45
  type BibleResponse,
36
46
  type BookActivateRequest,
37
47
  type BookCreateRequest,
48
+ type BookImportDirRequest,
49
+ type BookImportTextRequest,
50
+ type BookImportTextPreviewRequest,
38
51
  type BookRemoveRequest,
39
52
  type BookshelfSnapshot,
40
53
  type ChapterResponse,
@@ -42,6 +55,11 @@ import {
42
55
  type ChapterSaveResponse,
43
56
  type ChapterTextRequest,
44
57
  type ConfigPatch,
58
+ type MoveOutputDirRequest,
59
+ type MoveOutputDirResponse,
60
+ type BookSettings,
61
+ type BookSettingsRequest,
62
+ type BookSettingsResponse,
45
63
  type DraftDecisionRequest,
46
64
  type ExportRequest,
47
65
  type ExportResponse,
@@ -50,6 +68,17 @@ import {
50
68
  type JobFrame,
51
69
  type LoadOutlineRequest,
52
70
  type LoadOutlineResponse,
71
+ type ExportScope,
72
+ type PromptSlotsRequest,
73
+ type PromptSlotsResponse,
74
+ type SnapshotRequest,
75
+ type SnapshotResponse,
76
+ type RevisionPlanRequest,
77
+ type RevisionPlanResponse,
78
+ type TensionRequest,
79
+ type TensionResponse,
80
+ type TimelineRequest,
81
+ type TimelineResponse,
53
82
  type NovelConfig,
54
83
  type PlotlinesRequest,
55
84
  type PlotlinesResponse,
@@ -65,11 +94,6 @@ import {
65
94
  type RewriteRequest,
66
95
  type RolesRequest,
67
96
  type RolesResponse,
68
- type SceneCard,
69
- type ScenesRequest,
70
- type ScenesResponse,
71
- type VisualRulesRequest,
72
- type VisualRulesResponse,
73
97
  type OutlineSuggestRequest,
74
98
  type OutlineSuggestResponse,
75
99
  type BreakdownRequest,
@@ -77,17 +101,53 @@ import {
77
101
  type SensitiveCheckRequest,
78
102
  type SensitiveCheckResponse,
79
103
  type SensitiveHit,
104
+ type ProjectState,
80
105
  type StatusResponse,
81
106
  type StoryBible,
107
+ type LlmTestRequest,
108
+ type LlmTestResponse,
82
109
  type StyleEngineRequest,
83
110
  type SummaryRequest,
84
111
  type VolumesRequest,
85
112
  type VolumesResponse,
113
+ type AuthorAssetsResponse,
114
+ type AuthorAssetUpsertRequest,
115
+ type AuthorAssetRemoveRequest,
116
+ type AdaptAnalyzeRequest,
117
+ type AdaptAnalyzeResponse,
118
+ type AdaptProposeRequest,
119
+ type AdaptProposeResponse,
120
+ type AdaptExecuteRequest,
121
+ type AdaptExecuteResponse,
122
+ type AdaptSaveRequest,
123
+ type AdaptSaveResponse,
124
+ type AdaptMaterializeRequest,
125
+ type AdaptMaterializeResponse,
126
+ type AdaptMaterializeSaveRequest,
127
+ type AdaptMaterializeSaveResponse,
128
+ type AdaptRewriteFrame,
129
+ type PluginUpdateResponse,
130
+ type MarketRadarRequest,
131
+ type MarketRadarBriefRequest,
132
+ type ProductionFoundation,
133
+ type ProgressionMode,
134
+ type GenreNode,
135
+ type KnowledgeRequest,
136
+ type BookAnalysisRequest,
137
+ type IdeaInspirationRequest,
138
+ type DirectorRequest,
139
+ type DirectorTodo,
86
140
  } from './protocol.ts'
87
141
  import { readOutlineFromDocx } from './docx.ts'
88
142
  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'
143
+ import { activateBook, bookshelfSnapshot, createBook, defaultOutputDirFor, importDir, loadBookshelf, removeBook, renameBook, repointBookDirs, seedBookshelfFromOutputDir } from './bookshelf.ts'
144
+ import { copyDirContents, listDirContents, normalizeDir, removeDir } from './migrate-output.ts'
145
+ import { loadAuthorAssets, upsertAuthorAsset, removeAuthorAsset, importDefaultAuthorAssets } from './author-assets.ts'
146
+ 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'
147
+ import { scanAiFlavor } from './ai-scan.ts'
148
+ import { emitLive, livePrompt, liveUsage, nextSessionId, resetLiveUsage, subscribeLiveFeed } from './llm-live.ts'
149
+ import { scanMarketRanking } from './market-radar-scan.ts'
150
+ import { addGlobalGenre, addGlobalMode, globalGenreLibrary, globalProgressionLibrary } from './global-assets.ts'
91
151
  import {
92
152
  chapterFileName,
93
153
  auditBook,
@@ -95,9 +155,19 @@ import {
95
155
  autoLinkPlotlines,
96
156
  backfillFacts,
97
157
  createProject,
98
- exportBook,
158
+ exportScope,
159
+ importBookText,
160
+ importBookTextFromText,
161
+ previewBookText,
99
162
  extractBible,
100
163
  extractStyleAsset,
164
+ extractStyleFormula,
165
+ runMarketRadar,
166
+ runMarketCreativeBrief,
167
+ runBookAnalysis,
168
+ runIdeaInspiration,
169
+ runMarketIdeaInspiration,
170
+ runDirectorAdvice,
101
171
  extractWorld,
102
172
  generateBlurb,
103
173
  generateChapterStream,
@@ -118,23 +188,51 @@ import {
118
188
  analyzePlotlineHealth,
119
189
  designPlotlinePlan,
120
190
  extractRoles,
121
- extractScenes,
122
- generateRolePromptKit,
123
- extractVisualRules,
124
- extractRoleVisual,
125
191
  suggestOutlines,
192
+ reverseOutlineFromChapters,
126
193
  breakdownBook,
127
- generateRoleReferenceImage,
194
+ analyzeAdaptation,
195
+ proposeAdaptation,
196
+ applyAdaptationReplacements,
197
+ rewriteAdaptationBook,
198
+ materializeAdaptedBook,
199
+ saveMaterializedBook,
200
+ testLlmModel,
201
+ registerLlmModel,
202
+ listLlmVendors,
203
+ listLlmModels,
204
+ listLlmProviders,
205
+ removeLlmProvider,
128
206
  checkSensitiveText,
129
207
  suggestForeshadows,
130
208
  suggestPlotlines,
131
209
  summarizeAndExtractFacts,
210
+ extractTimelineForChapter,
211
+ reviewTimeline,
132
212
  summarizeChapter,
133
213
  syncProjectWithDisk,
134
214
  } from './engine.ts'
215
+ import { countHanzi } from './engine.ts'
216
+ import { computeBookStage } from './stage-contract.ts'
217
+ import { createSnapshot, loadSnapshots, pruneSnapshots, removeSnapshot, restoreSnapshot, snapshotsBytes } from './snapshots.ts'
218
+ import { detectTimelineIssues } from './timeline.ts'
219
+ import { buildCurveData, detectTensionIssues, TENSION_MAX, TENSION_MIN, TENSION_PRESETS, clampTension } from './tension.ts'
220
+ import { PROMPT_SLOTS, normalizeSlotValue, readPromptSlots } from './prompt-slots.ts'
221
+ import { buildRevisionPlan, describeRevisionPlan, REVISION_ITEM_CAP } from './revision.ts'
135
222
 
136
223
  /** Cap on JSON request bodies (generous: cover images travel as base64). */
137
- const MAX_JSON_BODY_BYTES = 16 * 1024 * 1024
224
+ const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024
225
+
226
+ /** 包内置风格效果图目录(assets/styles,随 npm 包分发)。 */
227
+
228
+ /** 解析请求级目标书目录:优先按请求携带的 bookId 查书架,否则回退全局 active 书目录。 */
229
+ function resolveOutputDir(config: NovelConfig, bookId?: string): string {
230
+ if (bookId !== undefined && bookId !== '') {
231
+ const book = loadBookshelf().books.find(b => b.id === bookId)
232
+ if (book !== undefined && book.outputDir !== '') return book.outputDir
233
+ }
234
+ return config.outputDir
235
+ }
138
236
 
139
237
  /** Loopback-only fence (mirrors the family plugins' pairing routes). */
140
238
  function isLoopbackRequest(request: IncomingMessage): boolean {
@@ -190,6 +288,8 @@ export interface NovelRoutesDeps {
190
288
  getConfig: () => NovelConfig
191
289
  /** Persist a config patch through the settings seam. */
192
290
  patchConfig: (patch: ConfigPatch) => Promise<NovelConfig>
291
+ /** Raw settings config(未与激活书合并)——目录迁移判断默认目录是否需同步用。 */
292
+ rawConfig?: () => { outputDir?: string }
193
293
  }
194
294
 
195
295
  /** Default chapter count for planning when the request omits it. */
@@ -239,9 +339,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
239
339
  handler: (req, res) => {
240
340
  if (!guard(req, res, 'GET')) return
241
341
  const config = getConfig()
342
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
343
+ const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
242
344
  // 书架为空时播种 settings 默认输出目录里的已有项目。
243
- seedBookshelfFromOutputDir(config.outputDir)
244
- const project = loadProject(config.outputDir)
345
+ seedBookshelfFromOutputDir(outputDir)
346
+ const project = loadProject(outputDir)
245
347
  if (project !== undefined) {
246
348
  // 自动兜底:generating 超过 10 分钟(正常一章 2-5 分钟)视为卡死/中断,
247
349
  // 复位为 pending,避免"生成中"状态卡死无法重新生成。
@@ -256,18 +358,55 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
256
358
  }
257
359
  }
258
360
  }
259
- syncProjectWithDisk(project, config.outputDir)
260
- saveProject(config.outputDir, project)
361
+ syncProjectWithDisk(project, outputDir)
362
+ saveProject(outputDir, project)
261
363
  }
262
- const response: StatusResponse = {
263
- config,
364
+ const slim = new URL(req.url ?? '/', 'http://localhost').searchParams.get('slim') === '1'
365
+ // 截断显式化:任何被裁掉的字段都要在响应里声明,客户端/模型不能
366
+ // 把"看到的"当成"全部"。静默截断会让一致性判断建立在不完整事实上。
367
+ const truncations: string[] = []
368
+ let projectPayload: StatusResponse['project']
369
+ if (project === undefined) {
370
+ projectPayload = undefined
371
+ } else if (slim) {
372
+ truncations.push('slim=1:正文/beats/图集/道藏/大世界/资产等重字段未返回,大纲仅返回前 200 字')
373
+ // slim 瘦身:轮询/角标只需章节状态与分数 + 轻量元数据,
374
+ // 去掉正文、beats、图集(base64)、道藏/大世界/资产等重字段,避免长书轮询反复传输大体积。
375
+ projectPayload = {
376
+ bookName: project.bookName,
377
+ outline: project.outline.slice(0, 200),
378
+ chapters: project.chapters.map(c => ({
379
+ no: c.no,
380
+ volume: c.volume,
381
+ title: c.title,
382
+ status: c.status,
383
+ chars: c.chars,
384
+ error: c.error,
385
+ review: c.review !== undefined ? { score: c.review.score, passed: c.review.passed } : undefined,
386
+ })) as unknown as ProjectState['chapters'],
387
+ createdAt: project.createdAt,
388
+ updatedAt: project.updatedAt,
389
+ } as unknown as ProjectState
390
+ } else {
264
391
  // 瘦身:facts 只回最近 80 条(客户端最多展示 60),避免长篇后
265
392
  // status 响应体随编年录无限膨胀。
266
- project: project !== undefined
267
- ? { ...project, facts: (project.facts ?? []).slice(-80) }
268
- : undefined,
393
+ const allFacts = project.facts ?? []
394
+ const shownFacts = allFacts.slice(-80)
395
+ if (allFacts.length > shownFacts.length) {
396
+ truncations.push(`编年录:共 ${allFacts.length} 条,仅返回最近 ${shownFacts.length} 条(较早 ${allFacts.length - shownFacts.length} 条未返回)`)
397
+ }
398
+ projectPayload = { ...project, facts: shownFacts }
399
+ }
400
+ const response: StatusResponse = {
401
+ config,
402
+ project: projectPayload,
269
403
  generatedFiles: listChapterFiles(config.outputDir),
270
404
  audit: auditState,
405
+ // 阶段契约:宿主算一次,面板角标、助手提示词、外部自动化共用同一结论。
406
+ stage: computeBookStage(project),
407
+ // 本次运行(进程内)的 LLM 用量:调用次数 / 输入·输出·思考 token / 耗时。
408
+ usage: liveUsage(),
409
+ ...(truncations.length > 0 ? { truncations } : {}),
271
410
  }
272
411
  writeJson(res, 200, response)
273
412
  },
@@ -372,9 +511,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
372
511
  path: NOVEL_API.volumes,
373
512
  handler: async (req, res) => {
374
513
  if (!guard(req, res, 'POST')) return
375
- const body = await readJsonBody<VolumesRequest>(req)
376
- const config = getConfig()
377
- const project = loadProject(config.outputDir)
514
+ const body = await readJsonBody<VolumesRequest & { bookId?: string }>(req)
515
+ const config0 = getConfig()
516
+ const outputDir = resolveOutputDir(config0, body?.bookId)
517
+ const config = { ...config0, outputDir }
518
+ const project = loadProject(outputDir)
378
519
  const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
379
520
  ? body.outline
380
521
  : project?.outline
@@ -388,7 +529,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
388
529
  const next = project ?? createProject(outline)
389
530
  next.volumes = volumes
390
531
  next.updatedAt = now
391
- saveProject(config.outputDir, next)
532
+ saveProject(outputDir, next)
392
533
  const response: VolumesResponse = { volumes }
393
534
  writeJson(res, 200, response)
394
535
  } catch (error) {
@@ -403,9 +544,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
403
544
  path: NOVEL_API.plan,
404
545
  handler: async (req, res) => {
405
546
  if (!guard(req, res, 'POST')) return
406
- const body = await readJsonBody<PlanRequest>(req)
407
- const config = getConfig()
408
- const project = loadProject(config.outputDir)
547
+ const body = await readJsonBody<PlanRequest & { bookId?: string }>(req)
548
+ const config0 = getConfig()
549
+ const outputDir = resolveOutputDir(config0, body?.bookId)
550
+ const config = { ...config0, outputDir }
551
+ const project = loadProject(outputDir)
409
552
  const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
410
553
  ? body.outline
411
554
  : project?.outline
@@ -441,10 +584,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
441
584
  path: NOVEL_API.generate,
442
585
  handler: async (req, res) => {
443
586
  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)
587
+ const config0 = getConfig()
588
+ const body = await readJsonBody<{ chapterNo?: number; skipReview?: boolean; bookId?: string }>(req)
589
+ const outputDir = resolveOutputDir(config0, body?.bookId)
590
+ const config = { ...config0, outputDir }
591
+ const project = loadProject(outputDir)
592
+ if (project === undefined) {
593
+ writeJson(res, 400, { error: '输出目录中没有项目' })
594
+ return
595
+ }
448
596
  const rawNo = body?.chapterNo
449
597
  if (!Number.isInteger(rawNo) || rawNo === undefined || rawNo < 1) {
450
598
  writeJson(res, 400, { error: 'chapterNo 须为正整数' })
@@ -479,21 +627,38 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
479
627
  res.write(JSON.stringify(frame) + '\n')
480
628
  }
481
629
 
630
+ const liveSession = nextSessionId()
631
+ emitLive({ type: 'session_started', sessionId: liveSession, label: '正文生成', model: config.generateModel || config.model, at: new Date().toISOString(), context: { interactionId: liveSession, taskId: `ch-${no}` } })
632
+ emitLive({ type: 'phase_changed', sessionId: liveSession, phase: 'streaming', phaseMessage: '模型正在返回正文', at: new Date().toISOString() })
633
+
482
634
  try {
483
635
  send({ type: 'start', no, title: chapter.title })
636
+ let genChars = 0
484
637
  for await (const step of generateChapterStream(ctx, config, project, config.outputDir, no)) {
485
638
  if (step.frame === 'delta') {
486
639
  send({ type: 'delta', text: step.text })
640
+ genChars += step.text.length
641
+ emitLive({ type: 'output_delta', sessionId: liveSession, content: step.text, totalChars: genChars, at: new Date().toISOString() })
487
642
  } else if (step.frame === 'done') {
488
- send({ type: 'done', no, file: step.file, chars: step.chars, title: chapter.title })
643
+ send({ type: 'done', no, file: step.file, chars: step.chars, title: chapter.title, warn: step.warn })
489
644
  }
490
645
  }
491
- // Auto pipeline: summary + facts(一次调用)-> review(unless skipped)。
646
+ emitLive({ type: 'session_completed', sessionId: liveSession, totalChars: genChars, preview: '', at: new Date().toISOString(), phase: 'completed' })
647
+ // Auto pipeline: summary + facts(一次调用)-> timeline -> review(unless skipped)。
492
648
  try {
493
649
  await summarizeAndExtractFacts(ctx, config, project, config.outputDir, no)
494
650
  } catch (error) {
495
651
  console.warn('[dsh-novel-forge] summary/facts failed:', (error as Error).message)
496
652
  }
653
+ // 故事时间线:抽取本章事件(失败不阻断出章,作者可在时间线页手工补)。
654
+ if (config.autoTimeline ?? true) {
655
+ try {
656
+ await extractTimelineForChapter(ctx, config, project, config.outputDir, no)
657
+ saveProject(config.outputDir, project)
658
+ } catch (error) {
659
+ console.warn('[dsh-novel-forge] timeline extract failed:', (error as Error).message)
660
+ }
661
+ }
497
662
  // 伏笔落地标记:正文中命中 planned 伏笔关键词 → 自动标为 planted(暗线管理页与正文同步)。
498
663
  try {
499
664
  const marked = markForeshadowPlanted(project, config.outputDir, no)
@@ -557,17 +722,22 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
557
722
  path: NOVEL_API.review,
558
723
  handler: async (req, res) => {
559
724
  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)
725
+ const config0 = getConfig()
726
+ const body = await readJsonBody<ReviewRequest & { bookId?: string }>(req)
727
+ const outputDir = resolveOutputDir(config0, body?.bookId)
728
+ const config = { ...config0, outputDir }
729
+ const project = loadProject(outputDir)
730
+ if (project === undefined) {
731
+ writeJson(res, 400, { error: '输出目录中没有项目' })
732
+ return
733
+ }
564
734
  if (!Number.isInteger(body?.chapterNo)) {
565
735
  writeJson(res, 400, { error: 'chapterNo 须为正整数' })
566
736
  return
567
737
  }
568
738
  const no = body!.chapterNo!
569
739
  try {
570
- const report = await reviewChapter(ctx, config, project, config.outputDir, no)
740
+ const report = await reviewChapter(ctx, config, project, outputDir, no)
571
741
  writeJson(res, 200, { report })
572
742
  } catch (error) {
573
743
  writeJson(res, 500, { error: (error as Error).message })
@@ -581,10 +751,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
581
751
  path: NOVEL_API.rewrite,
582
752
  handler: async (req, res) => {
583
753
  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)
754
+ const config0 = getConfig()
755
+ const body = await readJsonBody<RewriteRequest & { bookId?: string }>(req)
756
+ const outputDir = resolveOutputDir(config0, body?.bookId)
757
+ const config = { ...config0, outputDir }
758
+ const project = loadProject(outputDir)
759
+ if (project === undefined) {
760
+ writeJson(res, 400, { error: '输出目录中没有项目' })
761
+ return
762
+ }
588
763
  if (!Number.isInteger(body?.chapterNo)) {
589
764
  writeJson(res, 400, { error: 'chapterNo 须为正整数' })
590
765
  return
@@ -620,10 +795,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
620
795
  path: NOVEL_API.polish,
621
796
  handler: async (req, res) => {
622
797
  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)
798
+ const config0 = getConfig()
799
+ const body = await readJsonBody<PolishRequest & { bookId?: string }>(req)
800
+ const outputDir = resolveOutputDir(config0, body?.bookId)
801
+ const config = { ...config0, outputDir }
802
+ const project = loadProject(outputDir)
803
+ if (project === undefined) {
804
+ writeJson(res, 400, { error: '输出目录中没有项目' })
805
+ return
806
+ }
627
807
  if (!Number.isInteger(body?.chapterNo)) {
628
808
  writeJson(res, 400, { error: 'chapterNo 须为正整数' })
629
809
  return
@@ -685,7 +865,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
685
865
  }
686
866
  writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
687
867
  chapter.pendingDraft = undefined
688
- chapter.chars = draft.length
868
+ chapter.chars = countHanzi(draft)
689
869
  chapter.file = fileName
690
870
  // 携带审查报告则沿用结论定状态(修订后审查通过 → approved);否则置 written 待审。
691
871
  const carried = body?.report
@@ -760,10 +940,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
760
940
  path: NOVEL_API.foreshadow,
761
941
  handler: async (req, res) => {
762
942
  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)
943
+ const config0 = getConfig()
944
+ const body = await readJsonBody<ForeshadowRequest & { bookId?: string }>(req)
945
+ const outputDir = resolveOutputDir(config0, body?.bookId)
946
+ const config = { ...config0, outputDir }
947
+ const project = loadProject(outputDir)
948
+ if (project === undefined) {
949
+ writeJson(res, 400, { error: '输出目录中没有项目' })
950
+ return
951
+ }
767
952
  try {
768
953
  if (body?.suggest === true) {
769
954
  // AI suggestion pass: create several foreshadows from the outline.
@@ -817,14 +1002,21 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
817
1002
  path: NOVEL_API.exportBook,
818
1003
  handler: async (req, res) => {
819
1004
  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'
1005
+ const config0 = getConfig()
1006
+ const body = await readJsonBody<ExportRequest & { bookId?: string }>(req)
1007
+ const outputDir = resolveOutputDir(config0, body?.bookId)
1008
+ const config = { ...config0, outputDir }
1009
+ const project = loadProject(outputDir)
1010
+ if (project === undefined) {
1011
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1012
+ return
1013
+ }
1014
+ const format: 'txt' | 'md' | 'json' = body?.format === 'json' ? 'json' : body?.format === 'md' ? 'md' : 'txt'
1015
+ // 导出范围:整本正文 / 设定 / 规划 / 角色 / 质检记录 / 项目 JSON。
1016
+ const scope: ExportScope = body?.scope !== undefined && EXPORT_SCOPE_VALUES.includes(body.scope) ? body.scope : 'book'
825
1017
  try {
826
- const result = exportBook(config.outputDir, project, format)
827
- const response: ExportResponse = { ...result }
1018
+ const result = exportScope(config.outputDir, project, scope, format)
1019
+ const response: ExportResponse = { ...result, scope, format }
828
1020
  writeJson(res, 200, response)
829
1021
  } catch (error) {
830
1022
  writeJson(res, 500, { error: (error as Error).message })
@@ -839,9 +1031,13 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
839
1031
  handler: async (req, res) => {
840
1032
  if (!guard(req, res, 'GET')) return
841
1033
  const config = getConfig()
842
- const project = requireProject(res)
843
- if (project === undefined) return
844
1034
  const url = new URL(req.url ?? '/', 'http://localhost')
1035
+ const outputDir = resolveOutputDir(config, url.searchParams.get('bookId') ?? undefined)
1036
+ const project = loadProject(outputDir)
1037
+ if (project === undefined) {
1038
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1039
+ return
1040
+ }
845
1041
  const rawNo = Number(url.searchParams.get('no') ?? '0')
846
1042
  if (!Number.isInteger(rawNo) || rawNo < 1) {
847
1043
  writeJson(res, 400, { error: 'no 须为正整数' })
@@ -852,7 +1048,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
852
1048
  writeJson(res, 404, { error: `章节 ${rawNo} 不在计划中` })
853
1049
  return
854
1050
  }
855
- const markdown = readChapterFile(config.outputDir, chapter)
1051
+ const markdown = readChapterFile(outputDir, chapter)
856
1052
  if (markdown === undefined) {
857
1053
  writeJson(res, 404, { error: `章节 ${rawNo} 尚未生成` })
858
1054
  return
@@ -918,9 +1114,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
918
1114
  if (existsSync(targetPath)) {
919
1115
  copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
920
1116
  }
1117
+ // 覆盖前留一份历史版本:采纳草稿改坏了可以回滚。
1118
+ createSnapshot(config.outputDir, chapter, '采纳草稿前')
921
1119
  writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${text}\n`, 'utf8')
922
1120
  chapter.status = 'written'
923
- chapter.chars = text.length
1121
+ chapter.chars = countHanzi(text)
924
1122
  chapter.file = fileName
925
1123
  chapter.pendingDraft = undefined
926
1124
  // 保存即审稿:携带工作区审查报告则沿用(不重复审);否则自动正式审稿一次。
@@ -947,9 +1145,13 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
947
1145
  handler: async (req, res) => {
948
1146
  if (!guard(req, res, 'POST')) return
949
1147
  const config = getConfig()
950
- const project = requireProject(res)
951
- if (project === undefined) return
952
- const body = await readJsonBody<AssistantRequest>(req)
1148
+ const body = await readJsonBody<AssistantRequest & { bookId?: string }>(req)
1149
+ const outputDir = resolveOutputDir(config, body?.bookId)
1150
+ const project = loadProject(outputDir)
1151
+ if (project === undefined) {
1152
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1153
+ return
1154
+ }
953
1155
  const message = body?.message?.trim()
954
1156
  if (message === undefined || message === '') {
955
1157
  writeJson(res, 400, { error: '消息不能为空' })
@@ -963,10 +1165,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
963
1165
  })
964
1166
  const send = (frame: AssistantFrame): void => { res.write(JSON.stringify(frame) + '\n') }
965
1167
  try {
966
- for await (const step of runAssistantTurn(ctx, config, project, config.outputDir, message)) {
1168
+ for await (const step of runAssistantTurn(ctx, config, project, outputDir, message)) {
967
1169
  if (step.frame === 'delta') send({ type: 'delta', text: step.text })
968
1170
  else if (step.frame === 'tool') send({ type: 'tool', name: step.name, status: step.status, detail: step.detail })
969
1171
  else if (step.frame === 'toolDelta') send({ type: 'toolDelta', name: step.name, text: step.text })
1172
+ else if (step.frame === 'toolResult') send({ type: 'toolResult', name: step.name, text: step.text })
970
1173
  }
971
1174
  send({ type: 'done' })
972
1175
  res.end()
@@ -986,7 +1189,9 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
986
1189
  handler: (req, res) => {
987
1190
  if (!guard(req, res, 'GET')) return
988
1191
  const config = getConfig()
989
- const response: AssistantHistoryResponse = { messages: loadAssistantHistory(config.outputDir) }
1192
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1193
+ const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
1194
+ const response: AssistantHistoryResponse = { messages: loadAssistantHistory(outputDir) }
990
1195
  writeJson(res, 200, response)
991
1196
  },
992
1197
  }
@@ -996,10 +1201,12 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
996
1201
  const assistantClearRoute: WebRoute = {
997
1202
  kind: 'exact',
998
1203
  path: NOVEL_API.assistantClear,
999
- handler: (req, res) => {
1204
+ handler: async (req, res) => {
1000
1205
  if (!guard(req, res, 'POST')) return
1001
1206
  const config = getConfig()
1002
- clearAssistantHistory(config.outputDir)
1207
+ const body = await readJsonBody<{ bookId?: string }>(req)
1208
+ const outputDir = resolveOutputDir(config, body?.bookId)
1209
+ clearAssistantHistory(outputDir)
1003
1210
  writeJson(res, 200, { ok: true })
1004
1211
  },
1005
1212
  }
@@ -1020,10 +1227,21 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1020
1227
  return
1021
1228
  }
1022
1229
  const config = getConfig()
1023
- const project = loadProject(config.outputDir)
1024
- const projectAssets = project?.assets ?? emptyProjectAssets()
1230
+ let postBody: (AssetsPatch & { bookId?: string }) | undefined
1231
+ if (req.method === 'POST') {
1232
+ postBody = await readJsonBody<AssetsPatch & { bookId?: string }>(req)
1233
+ if (postBody === undefined) {
1234
+ writeJson(res, 400, { error: '无效的 JSON' })
1235
+ return
1236
+ }
1237
+ }
1238
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1239
+ const queryBookId = qurl.searchParams.get('bookId') ?? undefined
1240
+ const outputDir = resolveOutputDir(config, postBody?.bookId ?? queryBookId)
1241
+ const project = loadProject(outputDir)
1242
+ const projectAssets = ensureBuiltinAssets(project?.assets ?? emptyProjectAssets())
1025
1243
  if (req.method === 'POST') {
1026
- const body = await readJsonBody<AssetsPatch>(req)
1244
+ const body = postBody
1027
1245
  if (body === undefined) {
1028
1246
  writeJson(res, 400, { error: '无效的 JSON' })
1029
1247
  return
@@ -1040,14 +1258,16 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1040
1258
  projectAssets.updatedAt = new Date().toISOString()
1041
1259
  project.assets = projectAssets
1042
1260
  project.updatedAt = new Date().toISOString()
1043
- saveProject(config.outputDir, project)
1261
+ saveProject(outputDir, project)
1044
1262
  }
1045
1263
  const response: AssetsResponse = {
1046
1264
  projectAssets,
1047
- genreLibrary: BUILTIN_GENRE_LIBRARY,
1265
+ genreLibrary: [...BUILTIN_GENRE_LIBRARY, ...globalGenreLibrary()],
1048
1266
  antiAiLibrary: BUILTIN_ANTI_AI_RULES,
1049
1267
  styleTemplates: BUILTIN_STYLE_TEMPLATES,
1050
- progressionLibrary: BUILTIN_PROGRESSION_MODES,
1268
+ progressionLibrary: [...BUILTIN_PROGRESSION_MODES, ...globalProgressionLibrary()],
1269
+ starterStyleProfiles: BUILTIN_STARTER_STYLE_PROFILES,
1270
+ plotBeatLibrary: BUILTIN_PLOT_BEATS,
1051
1271
  }
1052
1272
  writeJson(res, 200, response)
1053
1273
  },
@@ -1091,178 +1311,996 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1091
1311
  },
1092
1312
  }
1093
1313
 
1094
- // ------------------------------------------------------------- bookshelf
1095
- const bookshelfRoute: WebRoute = {
1314
+ // ----------------------------------------------------------- style-formula
1315
+ const styleFormulaRoute: WebRoute = {
1096
1316
  kind: 'exact',
1097
- path: NOVEL_API.bookshelf,
1317
+ path: NOVEL_API.styleFormula,
1098
1318
  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)
1319
+ if (!guard(req, res, 'POST')) return
1320
+ const config = getConfig()
1321
+ const project = loadProject(config.outputDir)
1322
+ const body = await readJsonBody<StyleEngineRequest & { depth?: string }>(req)
1323
+ const sample = body?.sampleText?.trim()
1324
+ if (sample === undefined || sample.length < 50) {
1325
+ writeJson(res, 400, { error: '样本文本过短(<50 字符),请粘贴一段能代表目标风格的文字' })
1109
1326
  return
1110
1327
  }
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)
1328
+ const depth = (['basic', 'standard', 'deep'] as const).includes(body?.depth as never)
1329
+ ? body!.depth! as 'basic' | 'standard' | 'deep'
1330
+ : 'standard'
1331
+ try {
1332
+ const formula = await extractStyleFormula(ctx, config, sample, depth)
1333
+ const key = `sf-${Date.now().toString(36)}`
1334
+ const styleFormula = { key, ...formula, depth, createdAt: new Date().toISOString() }
1335
+ if (project !== undefined) {
1336
+ project.assets ??= emptyProjectAssets()
1337
+ project.assets.styleFormulas ??= []
1338
+ project.assets.styleFormulas.push(styleFormula)
1339
+ project.assets.updatedAt = new Date().toISOString()
1340
+ project.updatedAt = new Date().toISOString()
1341
+ saveProject(config.outputDir, project)
1132
1342
  }
1133
- writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
1343
+ writeJson(res, 200, { styleFormula })
1344
+ } catch (error) {
1345
+ writeJson(res, 500, { error: (error as Error).message })
1346
+ }
1347
+ },
1348
+ }
1349
+
1350
+ // ----------------------------------------------------------- style-detect
1351
+ /** 正文实时 AI 味检测(本地确定性扫描,免 LLM)。 */
1352
+ const styleDetectRoute: WebRoute = {
1353
+ kind: 'exact',
1354
+ path: NOVEL_API.styleDetect,
1355
+ handler: async (req, res) => {
1356
+ if (!guard(req, res, 'POST')) return
1357
+ const body = await readJsonBody<{ text?: string }>(req)
1358
+ const text = body?.text?.trim() ?? ''
1359
+ if (text.length < 20) {
1360
+ writeJson(res, 400, { error: '文本过短(<20 字符)' })
1134
1361
  return
1135
1362
  }
1136
- writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
1363
+ const scan = scanAiFlavor(text.slice(0, 20000))
1364
+ writeJson(res, 200, { scan })
1137
1365
  },
1138
1366
  }
1139
1367
 
1140
- // --------------------------------------------------------------- reset
1141
- /** 重置项目:清空设定/卷/章节计划/正文/伏笔/资产/事实库(可携带新大纲)。 */
1142
- const resetRoute: WebRoute = {
1368
+ // --------------------------------------------------------- market-radar
1369
+ /** 热门题材雷达:输入平台/题材/榜单文本 → 信号 + 生产底座 + 开书创意。 */
1370
+ const marketRadarRoute: WebRoute = {
1143
1371
  kind: 'exact',
1144
- path: NOVEL_API.reset,
1372
+ path: NOVEL_API.marketRadar,
1145
1373
  handler: async (req, res) => {
1146
1374
  if (!guard(req, res, 'POST')) return
1147
1375
  const config = getConfig()
1148
- const project = loadProject(config.outputDir)
1149
- if (project === undefined) {
1150
- writeJson(res, 400, { error: '输出目录中没有项目,无需重置' })
1151
- return
1376
+ const body = await readJsonBody<MarketRadarRequest>(req)
1377
+ try {
1378
+ const result = await runMarketRadar(ctx, config, body ?? {})
1379
+ writeJson(res, 200, { result })
1380
+ } catch (error) {
1381
+ writeJson(res, 500, { error: (error as Error).message })
1152
1382
  }
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
1383
+ },
1384
+ }
1385
+
1386
+ // ------------------------------------------------- market-radar scan
1387
+ /** 真实榜单扫榜:抓取公开榜单(容错),返回分组候选。 */
1388
+ const marketRadarScanRoute: WebRoute = {
1389
+ kind: 'exact',
1390
+ path: NOVEL_API.marketRadarScan,
1391
+ handler: async (req, res) => {
1392
+ if (!guard(req, res, 'POST')) return
1393
+ const body = await readJsonBody<{ platforms?: string[] }>(req)
1394
+ try {
1395
+ const result = await scanMarketRanking(body?.platforms)
1396
+ writeJson(res, 200, { result })
1397
+ } catch (error) {
1398
+ writeJson(res, 500, { error: (error as Error).message })
1158
1399
  }
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
1400
  },
1169
1401
  }
1170
1402
 
1171
- // --------------------------------------------------- bookshelf activate
1172
- const bookshelfActivateRoute: WebRoute = {
1403
+ // ------------------------------------------------- market-radar apply
1404
+ /** 把雷达生产底座一键应用到某本书(写入项目资产/开书定盘,供后续规划与生成遵守)。 */
1405
+ const marketRadarApplyRoute: WebRoute = {
1173
1406
  kind: 'exact',
1174
- path: '/api/dsh-novel-forge/bookshelf/activate',
1407
+ path: NOVEL_API.marketRadarApply,
1175
1408
  handler: async (req, res) => {
1176
1409
  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 不能为空' })
1410
+ const body = await readJsonBody<{ bookId?: string; foundation?: ProductionFoundation }>(req)
1411
+ const pf = body?.foundation
1412
+ if (pf === undefined) {
1413
+ writeJson(res, 400, { error: '缺少 foundation' })
1180
1414
  return
1181
1415
  }
1182
- const book = activateBook(body.id)
1183
- if (book === undefined) {
1184
- writeJson(res, 404, { error: `书 ${body.id} 不存在` })
1416
+ const config = getConfig()
1417
+ const outputDir = resolveOutputDir(config, body?.bookId)
1418
+ const project = loadProject(outputDir)
1419
+ if (project === undefined) {
1420
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1185
1421
  return
1186
1422
  }
1187
- writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
1423
+ const genreBuiltin = BUILTIN_GENRE_LIBRARY.find(g => g.name === (pf.genre.existingId || pf.genre.name))
1424
+ const genreNode: GenreNode = genreBuiltin !== undefined
1425
+ ? { ...genreBuiltin }
1426
+ : { name: pf.genre.name.slice(0, 40), description: pf.genre.description, template: pf.genre.template, children: [] }
1427
+ const findMode = (m: { existingId?: string; name: string }): ProgressionMode | undefined => BUILTIN_PROGRESSION_MODES.find(x => x.name === (m.existingId || m.name))
1428
+ const primary = findMode(pf.primaryStoryMode) ?? {
1429
+ name: pf.primaryStoryMode.name.slice(0, 40),
1430
+ driver: pf.primaryStoryMode.driver,
1431
+ readerExpectation: pf.primaryStoryMode.readerExpectation,
1432
+ payoffs: [],
1433
+ risks: [],
1434
+ primary: true,
1435
+ }
1436
+ const aux: ProgressionMode[] = []
1437
+ if (pf.secondaryStoryMode !== undefined) {
1438
+ const sec = findMode(pf.secondaryStoryMode) ?? {
1439
+ name: pf.secondaryStoryMode.name.slice(0, 40),
1440
+ driver: pf.secondaryStoryMode.driver,
1441
+ readerExpectation: pf.secondaryStoryMode.readerExpectation,
1442
+ payoffs: [],
1443
+ risks: [],
1444
+ primary: false,
1445
+ }
1446
+ aux.push(sec)
1447
+ }
1448
+ project.assets ??= emptyProjectAssets()
1449
+ project.assets.genre = genreNode
1450
+ project.assets.primaryProgression = primary
1451
+ project.assets.auxiliaryProgressions = aux
1452
+ project.bookContract = {
1453
+ promise: pf.genre.description.slice(0, 120),
1454
+ primaryModeName: pf.primaryStoryMode.name,
1455
+ secondaryModeNames: pf.secondaryStoryMode !== undefined ? [pf.secondaryStoryMode.name] : [],
1456
+ tone: pf.genre.template,
1457
+ targetPlatform: undefined,
1458
+ }
1459
+ project.updatedAt = new Date().toISOString()
1460
+ saveProject(outputDir, project)
1461
+ writeJson(res, 200, { ok: true, bookName: project.bookName })
1188
1462
  },
1189
1463
  }
1190
1464
 
1191
- // ---------------------------------------------------- bookshelf remove
1192
- const bookshelfRemoveRoute: WebRoute = {
1465
+ // ------------------------------------------------- market-radar sync
1466
+ /** 把雷达生产底座里的「新资产」同步进全局资源库(跨书复用;已有名字跳过)。 */
1467
+ const marketRadarSyncRoute: WebRoute = {
1193
1468
  kind: 'exact',
1194
- path: '/api/dsh-novel-forge/bookshelf/remove',
1469
+ path: NOVEL_API.marketRadarSync,
1195
1470
  handler: async (req, res) => {
1196
1471
  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 不能为空' })
1472
+ const body = await readJsonBody<{ foundation?: ProductionFoundation }>(req)
1473
+ const pf = body?.foundation
1474
+ if (pf === undefined) {
1475
+ writeJson(res, 400, { error: '缺少 foundation' })
1200
1476
  return
1201
1477
  }
1202
- const removed = removeBook(body.id)
1203
- if (!removed) {
1204
- writeJson(res, 404, { error: `书 ${body.id} 不存在` })
1205
- return
1478
+ const synced = { genre: false, primaryMode: false, secondaryMode: false }
1479
+ if (pf.genre.existingId === undefined && pf.genre.name !== '') {
1480
+ synced.genre = addGlobalGenre({ name: pf.genre.name.slice(0, 40), description: pf.genre.description, template: pf.genre.template, children: [] })
1206
1481
  }
1207
- writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
1482
+ if (pf.primaryStoryMode.existingId === undefined && pf.primaryStoryMode.name !== '') {
1483
+ synced.primaryMode = addGlobalMode({ name: pf.primaryStoryMode.name.slice(0, 40), driver: pf.primaryStoryMode.driver, readerExpectation: pf.primaryStoryMode.readerExpectation, payoffs: [], risks: [], primary: true })
1484
+ }
1485
+ if (pf.secondaryStoryMode !== undefined && pf.secondaryStoryMode !== null && pf.secondaryStoryMode.existingId === undefined && pf.secondaryStoryMode.name !== '') {
1486
+ synced.secondaryMode = addGlobalMode({ name: pf.secondaryStoryMode.name.slice(0, 40), driver: pf.secondaryStoryMode.driver, readerExpectation: pf.secondaryStoryMode.readerExpectation, payoffs: [], risks: [], primary: false })
1487
+ }
1488
+ writeJson(res, 200, { ok: true, synced })
1208
1489
  },
1209
1490
  }
1210
1491
 
1211
- // ---------------------------------------------------------------- audit
1212
- /** 全书一致性质检。 */
1213
- const auditRoute: WebRoute = {
1492
+ // ------------------------------------------------- market-radar brief
1493
+ /** 开书创意简报:用选中的市场信号 + 影响模式生成。 */
1494
+ const marketRadarBriefRoute: WebRoute = {
1214
1495
  kind: 'exact',
1215
- path: NOVEL_API.audit,
1496
+ path: NOVEL_API.marketRadarBrief,
1216
1497
  handler: async (req, res) => {
1217
1498
  if (!guard(req, res, 'POST')) return
1218
1499
  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 }
1500
+ const body = await readJsonBody<MarketRadarBriefRequest>(req)
1501
+ if (body === undefined || body.influenceMode === undefined) {
1502
+ writeJson(res, 400, { error: '缺少 influenceMode' })
1503
+ return
1504
+ }
1223
1505
  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)
1506
+ const creativeBrief = await runMarketCreativeBrief(ctx, config, body)
1507
+ writeJson(res, 200, { creativeBrief })
1245
1508
  } catch (error) {
1246
- auditState = {
1247
- ...auditState,
1248
- status: 'error',
1249
- finishedAt: new Date().toISOString(),
1250
- error: (error as Error).message,
1251
- }
1252
1509
  writeJson(res, 500, { error: (error as Error).message })
1253
1510
  }
1254
1511
  },
1255
1512
  }
1256
1513
 
1257
- // ----------------------------------------------------- characters refresh
1258
- /** 角色卡刷新(出场统计精确化 + LLM 聚合状态)。 */
1259
- const charactersRefreshRoute: WebRoute = {
1514
+ // ------------------------------------------------------------- knowledge
1515
+ /** 书内知识库:GET 读取 / POST 增删改(按书路由)。 */
1516
+ const knowledgeRoute: WebRoute = {
1260
1517
  kind: 'exact',
1261
- path: NOVEL_API.charactersRefresh,
1518
+ path: NOVEL_API.knowledge,
1262
1519
  handler: async (req, res) => {
1263
- if (!guard(req, res, 'POST')) return
1520
+ if (req.method === 'POST') {
1521
+ if (!guard(req, res, 'POST')) return
1522
+ } else {
1523
+ if (!guard(req, res, 'GET')) return
1524
+ }
1264
1525
  const config = getConfig()
1265
- const project = requireProject(res)
1526
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1527
+ const body = req.method === 'POST' ? await readJsonBody<KnowledgeRequest & { bookId?: string }>(req) : undefined
1528
+ const outputDir = resolveOutputDir(config, body?.bookId ?? qurl.searchParams.get('bookId') ?? undefined)
1529
+ const project = loadProject(outputDir)
1530
+ if (project === undefined) {
1531
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1532
+ return
1533
+ }
1534
+ project.knowledgeDocs ??= []
1535
+ if (req.method === 'POST' && body !== undefined) {
1536
+ if (body.action === 'add' && body.doc !== undefined) {
1537
+ project.knowledgeDocs.push({ id: body.doc.id ?? `kd-${Date.now().toString(36)}`, title: body.doc.title, content: body.doc.content, updatedAt: new Date().toISOString() })
1538
+ } else if (body.action === 'remove' && body.id !== undefined) {
1539
+ project.knowledgeDocs = project.knowledgeDocs.filter(d => d.id !== body.id)
1540
+ } else if (body.action === 'replace' && body.doc !== undefined) {
1541
+ 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)
1542
+ }
1543
+ project.updatedAt = new Date().toISOString()
1544
+ saveProject(outputDir, project)
1545
+ }
1546
+ writeJson(res, 200, { docs: project.knowledgeDocs })
1547
+ },
1548
+ }
1549
+
1550
+ // ---------------------------------------------------------- book-analysis
1551
+ /** 书分析/拆书:输入文本 → 卖点/结构/可借鉴点/风险。 */
1552
+ const bookAnalysisRoute: WebRoute = {
1553
+ kind: 'exact',
1554
+ path: NOVEL_API.bookAnalysis,
1555
+ handler: async (req, res) => {
1556
+ if (!guard(req, res, 'POST')) return
1557
+ const config = getConfig()
1558
+ const body = await readJsonBody<BookAnalysisRequest>(req)
1559
+ if (body === undefined || (body.text ?? '').trim().length < 50) {
1560
+ writeJson(res, 400, { error: '文本过短(<50 字符)' })
1561
+ return
1562
+ }
1563
+ try {
1564
+ const result = await runBookAnalysis(ctx, config, body)
1565
+ writeJson(res, 200, { result })
1566
+ } catch (error) {
1567
+ writeJson(res, 500, { error: (error as Error).message })
1568
+ }
1569
+ },
1570
+ }
1571
+
1572
+ // ---------------------------------------------------------- idea-inspiration
1573
+ /** 创意灵感:一句话/题材 → 多方向开书灵感。 */
1574
+ const ideaInspirationRoute: WebRoute = {
1575
+ kind: 'exact',
1576
+ path: NOVEL_API.ideaInspiration,
1577
+ handler: async (req, res) => {
1578
+ if (!guard(req, res, 'POST')) return
1579
+ const config = getConfig()
1580
+ const body = await readJsonBody<IdeaInspirationRequest>(req)
1581
+ if (body === undefined || (body.idea ?? '').trim().length < 2) {
1582
+ writeJson(res, 400, { error: '请填写你的方向/一句话想法' })
1583
+ return
1584
+ }
1585
+ try {
1586
+ const result = await runIdeaInspiration(ctx, config, body)
1587
+ writeJson(res, 200, { result })
1588
+ } catch (error) {
1589
+ writeJson(res, 500, { error: (error as Error).message })
1590
+ }
1591
+ },
1592
+ }
1593
+
1594
+ // ------------------------------------------------------- idea-inspiration/market
1595
+ /** 雷达→灵感:基于市场信号/生产底座/创意简报生成开书灵感。 */
1596
+ const marketIdeaInspirationRoute: WebRoute = {
1597
+ kind: 'exact',
1598
+ path: NOVEL_API.ideaInspirationMarket,
1599
+ handler: async (req, res) => {
1600
+ if (!guard(req, res, 'POST')) return
1601
+ const config = getConfig()
1602
+ const body = await readJsonBody<{ signals?: import('./protocol.ts').MarketRadarSignal[]; foundation?: import('./protocol.ts').ProductionFoundation; brief?: import('./protocol.ts').MarketCreativeBrief; count?: number }>(req)
1603
+ if (body === undefined) {
1604
+ writeJson(res, 400, { error: '缺少市场分析参数' })
1605
+ return
1606
+ }
1607
+ try {
1608
+ const result = await runMarketIdeaInspiration(ctx, config, body)
1609
+ writeJson(res, 200, { result })
1610
+ } catch (error) {
1611
+ writeJson(res, 500, { error: (error as Error).message })
1612
+ }
1613
+ },
1614
+ }
1615
+
1616
+ // ------------------------------------------------------------- director
1617
+ /** 自动导演编排建议:基于全书上下文。 */
1618
+ const directorRoute: WebRoute = {
1619
+ kind: 'exact',
1620
+ path: NOVEL_API.director,
1621
+ handler: async (req, res) => {
1622
+ if (!guard(req, res, 'POST')) return
1623
+ const config = getConfig()
1624
+ const body = await readJsonBody<DirectorRequest & { bookId?: string }>(req)
1625
+ const outputDir = resolveOutputDir(config, body?.bookId)
1626
+ const project = loadProject(outputDir)
1627
+ if (project === undefined) {
1628
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1629
+ return
1630
+ }
1631
+ try {
1632
+ const result = await runDirectorAdvice(ctx, config, project, body ?? {})
1633
+ writeJson(res, 200, { result })
1634
+ } catch (error) {
1635
+ writeJson(res, 500, { error: (error as Error).message })
1636
+ }
1637
+ },
1638
+ }
1639
+
1640
+ // ------------------------------------------------------------- director/todos
1641
+ /** 自动导演「采纳」出的书内待办:GET 读取 / POST 增删勾选(按书路由)。 */
1642
+ const directorTodosRoute: WebRoute = {
1643
+ kind: 'exact',
1644
+ path: NOVEL_API.directorTodos,
1645
+ handler: async (req, res) => {
1646
+ type DirectorTodosReq = { op?: 'add' | 'toggle' | 'remove'; text?: string; source?: DirectorTodo['source']; id?: string; bookId?: string }
1647
+ let body: DirectorTodosReq | undefined
1648
+ if (req.method === 'POST') {
1649
+ if (!guard(req, res, 'POST')) return
1650
+ body = await readJsonBody<DirectorTodosReq>(req)
1651
+ } else {
1652
+ if (!guard(req, res, 'GET')) return
1653
+ }
1654
+ const config = getConfig()
1655
+ const outputDir = resolveOutputDir(config, body?.bookId)
1656
+ const project = loadProject(outputDir)
1657
+ if (project === undefined) {
1658
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1659
+ return
1660
+ }
1661
+ project.todos ??= []
1662
+ if (req.method === 'POST' && body !== undefined) {
1663
+ if (body.op === 'add' && (body.text ?? '').trim() !== '') {
1664
+ 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() })
1665
+ } else if (body.op === 'toggle' && body.id !== undefined) {
1666
+ project.todos = project.todos.map(t => t.id === body.id ? { ...t, done: !t.done } : t)
1667
+ } else if (body.op === 'remove' && body.id !== undefined) {
1668
+ project.todos = project.todos.filter(t => t.id !== body.id)
1669
+ }
1670
+ project.updatedAt = new Date().toISOString()
1671
+ saveProject(outputDir, project)
1672
+ }
1673
+ writeJson(res, 200, { todos: project.todos })
1674
+ },
1675
+ }
1676
+
1677
+ // ------------------------------------------------------------- llm-live
1678
+ /** AI 创作实况:订阅 LLM 实时事件流(NDJSON;订阅时先重放最近缓冲)。 */
1679
+ const llmLiveRoute: WebRoute = {
1680
+ kind: 'exact',
1681
+ path: NOVEL_API.llmLive,
1682
+ handler: async (req, res) => {
1683
+ if (req.method !== 'GET') {
1684
+ writeJson(res, 405, { error: 'GET only' })
1685
+ return
1686
+ }
1687
+ if (!isLoopbackRequest(req)) {
1688
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
1689
+ return
1690
+ }
1691
+ res.writeHead(200, {
1692
+ 'content-type': 'text/event-stream; charset=utf-8',
1693
+ 'cache-control': 'no-cache',
1694
+ 'x-accel-buffering': 'no',
1695
+ 'referrer-policy': 'no-referrer',
1696
+ })
1697
+ const unsubscribe = subscribeLiveFeed((frame) => {
1698
+ try { res.write('data: ' + JSON.stringify(frame) + '\n\n') } catch { /* client closed */ }
1699
+ })
1700
+ const hb = setInterval(() => { try { res.write(': ping\n\n') } catch { /* closed */ } }, 25000)
1701
+ // 关键:让 handler 一直挂起,直到客户端关闭,避免 WebRoute 框架在 handler 返回后关闭响应。
1702
+ await new Promise<void>((resolve) => {
1703
+ res.on('close', () => {
1704
+ clearInterval(hb)
1705
+ unsubscribe()
1706
+ resolve()
1707
+ })
1708
+ })
1709
+ },
1710
+ }
1711
+
1712
+ // --------------------------------------------------- llm-live prompt/usage
1713
+ /** 查看某次调用实际发送的 Prompt(面板「查看 Prompt」按钮用)。 */
1714
+ const llmPromptRoute: WebRoute = {
1715
+ kind: 'exact',
1716
+ path: NOVEL_API.llmPrompt,
1717
+ handler: (req, res) => {
1718
+ if (!guard(req, res, 'GET')) return
1719
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1720
+ const sessionId = qurl.searchParams.get('sessionId') ?? ''
1721
+ const record = livePrompt(sessionId)
1722
+ if (record === undefined) {
1723
+ writeJson(res, 404, { error: '该调用的 Prompt 记录已过期(只保留最近 40 次调用)' })
1724
+ return
1725
+ }
1726
+ writeJson(res, 200, record)
1727
+ },
1728
+ }
1729
+
1730
+ /** 清零本次运行的用量账本。 */
1731
+ const usageResetRoute: WebRoute = {
1732
+ kind: 'exact',
1733
+ path: NOVEL_API.usageReset,
1734
+ handler: (req, res) => {
1735
+ if (!guard(req, res, 'POST')) return
1736
+ resetLiveUsage()
1737
+ writeJson(res, 200, { ok: true, usage: liveUsage() })
1738
+ },
1739
+ }
1740
+
1741
+ // ------------------------------------------------------------- timeline
1742
+ /**
1743
+ * 故事时间线:GET 列表(含规则初筛问题);POST op=extract|check|update|remove|clear。
1744
+ */
1745
+ const timelineRoute: WebRoute = {
1746
+ kind: 'exact',
1747
+ path: NOVEL_API.timeline,
1748
+ handler: async (req, res) => {
1749
+ const config = getConfig()
1750
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1751
+ if (req.method === 'GET') {
1752
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
1753
+ const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
1754
+ const project = loadProject(outputDir)
1755
+ if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
1756
+ const response: TimelineResponse = {
1757
+ events: project.timeline ?? [],
1758
+ issues: detectTimelineIssues(project),
1759
+ }
1760
+ writeJson(res, 200, response)
1761
+ return
1762
+ }
1763
+ if (!guard(req, res, 'POST')) return
1764
+ const body = await readJsonBody<TimelineRequest & { bookId?: string }>(req)
1765
+ const outputDir = resolveOutputDir(config, body?.bookId)
1766
+ const project = loadProject(outputDir)
1767
+ if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
1768
+ try {
1769
+ if (body?.op === 'extract') {
1770
+ const no = body.chapterNo
1771
+ if (typeof no !== 'number' || !Number.isFinite(no)) { writeJson(res, 400, { error: 'extract 需要 chapterNo' }); return }
1772
+ const events = await extractTimelineForChapter(ctx, config, project, outputDir, no)
1773
+ saveProject(outputDir, project)
1774
+ writeJson(res, 200, { events, all: project.timeline ?? [] })
1775
+ return
1776
+ }
1777
+ if (body?.op === 'check') {
1778
+ const ruleIssues = detectTimelineIssues(project)
1779
+ let aiIssues: typeof ruleIssues = []
1780
+ try {
1781
+ aiIssues = await reviewTimeline(ctx, config, project)
1782
+ } catch (error) {
1783
+ // AI 复核失败不阻断:规则初筛结果照常返回,并说明原因。
1784
+ writeJson(res, 200, { issues: ruleIssues, aiError: (error as Error).message })
1785
+ return
1786
+ }
1787
+ writeJson(res, 200, { issues: [...ruleIssues, ...aiIssues] })
1788
+ return
1789
+ }
1790
+ if (body?.op === 'update') {
1791
+ const event = body.event
1792
+ if (event === undefined || typeof event.id !== 'string') { writeJson(res, 400, { error: 'update 需要 event' }); return }
1793
+ const list = project.timeline ?? []
1794
+ const idx = list.findIndex(e => e.id === event.id)
1795
+ if (idx === -1) { writeJson(res, 404, { error: '时间线事件不存在' }); return }
1796
+ list[idx] = { ...event, source: 'manual' }
1797
+ project.timeline = list
1798
+ project.updatedAt = new Date().toISOString()
1799
+ saveProject(outputDir, project)
1800
+ writeJson(res, 200, { events: project.timeline })
1801
+ return
1802
+ }
1803
+ if (body?.op === 'remove') {
1804
+ project.timeline = (project.timeline ?? []).filter(e => e.id !== body.id)
1805
+ project.updatedAt = new Date().toISOString()
1806
+ saveProject(outputDir, project)
1807
+ writeJson(res, 200, { events: project.timeline })
1808
+ return
1809
+ }
1810
+ if (body?.op === 'clear') {
1811
+ project.timeline = []
1812
+ project.updatedAt = new Date().toISOString()
1813
+ saveProject(outputDir, project)
1814
+ writeJson(res, 200, { events: [] })
1815
+ return
1816
+ }
1817
+ writeJson(res, 400, { error: `未知 op:${String(body?.op)}` })
1818
+ } catch (error) {
1819
+ writeJson(res, 500, { error: (error as Error).message })
1820
+ }
1821
+ },
1822
+ }
1823
+
1824
+ // ------------------------------------------------------------ revision plan
1825
+ /** 修订指令合并:POST { op: 'plan', chapterNo, reviewIssues? } → 一份合并指令 + 验证基准。
1826
+ * 面板与生产单共用,保证「同一章只改一轮」;张力/时间线问题默认只是建议(advisory)。 */
1827
+ const revisionPlanRoute: WebRoute = {
1828
+ kind: 'exact',
1829
+ path: NOVEL_API.revisionPlan,
1830
+ handler: async (req, res) => {
1831
+ if (!guard(req, res, 'POST')) return
1832
+ const config = getConfig()
1833
+ const body = await readJsonBody<RevisionPlanRequest>(req)
1834
+ const outputDir = resolveOutputDir(config, body?.bookId)
1835
+ const project = loadProject(outputDir)
1836
+ if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
1837
+ const chapterNo = body?.chapterNo
1838
+ if (chapterNo !== undefined && !project.chapters.some(c => c.no === chapterNo)) {
1839
+ writeJson(res, 404, { error: `章节 ${String(chapterNo)} 不在计划中` })
1840
+ return
1841
+ }
1842
+ const chapter = chapterNo === undefined ? undefined : project.chapters.find(c => c.no === chapterNo)
1843
+ try {
1844
+ const plan = buildRevisionPlan(project, {
1845
+ chapterNo,
1846
+ reviewIssues: body?.reviewIssues,
1847
+ includeTimeline: body?.includeTimeline,
1848
+ includeTension: body?.includeTension,
1849
+ baseReport: chapter?.review,
1850
+ cap: REVISION_ITEM_CAP,
1851
+ })
1852
+ const response: RevisionPlanResponse = {
1853
+ chapterNo: chapterNo ?? 0,
1854
+ items: plan.items,
1855
+ instruction: plan.instruction,
1856
+ counts: plan.counts,
1857
+ omitted: plan.omitted,
1858
+ baseline: plan.baseline,
1859
+ summary: describeRevisionPlan(plan),
1860
+ }
1861
+ writeJson(res, 200, response)
1862
+ } catch (error) {
1863
+ writeJson(res, 500, { error: (error as Error).message })
1864
+ }
1865
+ },
1866
+ }
1867
+
1868
+ // ------------------------------------------------------------ snapshots
1869
+ /** 章节历史版本:GET 列表;POST op=create|restore|remove|prune。 */
1870
+ const snapshotRoute: WebRoute = {
1871
+ kind: 'exact',
1872
+ path: NOVEL_API.snapshot,
1873
+ handler: async (req, res) => {
1874
+ const config = getConfig()
1875
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1876
+ if (req.method === 'GET') {
1877
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
1878
+ const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
1879
+ const response: SnapshotResponse = { snapshots: loadSnapshots(outputDir), bytes: snapshotsBytes(outputDir) }
1880
+ writeJson(res, 200, response)
1881
+ return
1882
+ }
1883
+ if (!guard(req, res, 'POST')) return
1884
+ const body = await readJsonBody<SnapshotRequest & { bookId?: string }>(req)
1885
+ const outputDir = resolveOutputDir(config, body?.bookId)
1886
+ const project = loadProject(outputDir)
1887
+ try {
1888
+ if (body?.op === 'create') {
1889
+ if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
1890
+ const chapter = project.chapters.find(c => c.no === body.chapterNo)
1891
+ if (chapter === undefined) { writeJson(res, 404, { error: `章节 ${String(body.chapterNo)} 不在计划中` }); return }
1892
+ const snapshot = createSnapshot(outputDir, chapter, '手工存档')
1893
+ if (snapshot === undefined) { writeJson(res, 400, { error: '该章暂无正文文件,无法存档' }); return }
1894
+ writeJson(res, 200, { snapshot, snapshots: loadSnapshots(outputDir) })
1895
+ return
1896
+ }
1897
+ if (body?.op === 'restore') {
1898
+ if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
1899
+ const id = body.id ?? ''
1900
+ const snap = loadSnapshots(outputDir).find(x => x.id === id)
1901
+ if (snap === undefined) { writeJson(res, 404, { error: '快照不存在' }); return }
1902
+ const chapter = project.chapters.find(c => c.no === snap.chapterNo)
1903
+ if (chapter === undefined) { writeJson(res, 404, { error: `章节 ${snap.chapterNo} 不在计划中` }); return }
1904
+ const chars = restoreSnapshot(outputDir, chapter, id)
1905
+ chapter.chars = countHanzi(readChapterFile(outputDir, chapter) ?? '')
1906
+ chapter.status = chapter.status === 'approved' ? 'written' : chapter.status
1907
+ chapter.review = undefined
1908
+ project.updatedAt = new Date().toISOString()
1909
+ saveProject(outputDir, project)
1910
+ writeJson(res, 200, { ok: true, chapterNo: chapter.no, chars, snapshots: loadSnapshots(outputDir) })
1911
+ return
1912
+ }
1913
+ if (body?.op === 'remove') {
1914
+ const ok = removeSnapshot(outputDir, body.id ?? '')
1915
+ writeJson(res, ok ? 200 : 404, { ok, snapshots: loadSnapshots(outputDir) })
1916
+ return
1917
+ }
1918
+ if (body?.op === 'prune') {
1919
+ const removed = pruneSnapshots(outputDir, body.keep)
1920
+ writeJson(res, 200, { removed, snapshots: loadSnapshots(outputDir), bytes: snapshotsBytes(outputDir) })
1921
+ return
1922
+ }
1923
+ writeJson(res, 400, { error: `未知 op:${String(body?.op)}` })
1924
+ } catch (error) {
1925
+ writeJson(res, 500, { error: (error as Error).message })
1926
+ }
1927
+ },
1928
+ }
1929
+
1930
+ // --------------------------------------------------------------- tension
1931
+ /** 张力曲线:GET 数据(含规则核对);POST op=preset|set|clear。 */
1932
+ const tensionRoute: WebRoute = {
1933
+ kind: 'exact',
1934
+ path: NOVEL_API.tension,
1935
+ handler: async (req, res) => {
1936
+ const config = getConfig()
1937
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1938
+ if (req.method === 'GET') {
1939
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
1940
+ const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
1941
+ const project = loadProject(outputDir)
1942
+ if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
1943
+ const curve = buildCurveData(project)
1944
+ const response: TensionResponse = { preset: curve.preset, points: curve.points, issues: detectTensionIssues(project) }
1945
+ writeJson(res, 200, response)
1946
+ return
1947
+ }
1948
+ if (!guard(req, res, 'POST')) return
1949
+ const body = await readJsonBody<TensionRequest & { bookId?: string }>(req)
1950
+ const outputDir = resolveOutputDir(config, body?.bookId)
1951
+ const project = loadProject(outputDir)
1952
+ if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
1953
+ if (body?.op === 'preset') {
1954
+ const preset = body.preset
1955
+ if (preset === undefined || !TENSION_PRESETS.includes(preset)) { writeJson(res, 400, { error: '未知的张力曲线预设' }); return }
1956
+ project.tensionCurve = { ...(project.tensionCurve ?? {}), preset }
1957
+ project.updatedAt = new Date().toISOString()
1958
+ saveProject(outputDir, project)
1959
+ writeJson(res, 200, { ok: true, preset })
1960
+ return
1961
+ }
1962
+ if (body?.op === 'set') {
1963
+ const no = body.chapterNo
1964
+ if (typeof no !== 'number' || !Number.isFinite(no)) { writeJson(res, 400, { error: 'set 需要 chapterNo' }); return }
1965
+ const chapter = project.chapters.find(c => c.no === no)
1966
+ if (chapter === undefined) { writeJson(res, 404, { error: `章节 ${no} 不在计划中` }); return }
1967
+ if (body.tension === undefined) {
1968
+ chapter.tension = undefined
1969
+ } else {
1970
+ if (body.tension < TENSION_MIN || body.tension > TENSION_MAX) { writeJson(res, 400, { error: `张力需在 ${TENSION_MIN}-${TENSION_MAX} 之间` }); return }
1971
+ chapter.tension = clampTension(body.tension)
1972
+ }
1973
+ project.updatedAt = new Date().toISOString()
1974
+ saveProject(outputDir, project)
1975
+ writeJson(res, 200, { ok: true, chapterNo: no, tension: chapter.tension })
1976
+ return
1977
+ }
1978
+ if (body?.op === 'clear') {
1979
+ for (const c of project.chapters) c.tension = undefined
1980
+ project.updatedAt = new Date().toISOString()
1981
+ saveProject(outputDir, project)
1982
+ writeJson(res, 200, { ok: true })
1983
+ return
1984
+ }
1985
+ writeJson(res, 400, { error: `未知 op:${String(body?.op)}` })
1986
+ },
1987
+ }
1988
+
1989
+ // ---------------------------------------------------------- prompt slots
1990
+ /** 提示词槽位:GET 列表(含说明与上限);POST 写入或清空单个槽位。 */
1991
+ const promptSlotsRoute: WebRoute = {
1992
+ kind: 'exact',
1993
+ path: NOVEL_API.promptSlots,
1994
+ handler: async (req, res) => {
1995
+ const config = getConfig()
1996
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1997
+ if (req.method === 'GET') {
1998
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
1999
+ const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
2000
+ const project = loadProject(outputDir)
2001
+ if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
2002
+ const values = new Map(readPromptSlots(project).map(s => [s.id, s.text] as const))
2003
+ const response: PromptSlotsResponse = {
2004
+ slots: PROMPT_SLOTS.map(spec => ({
2005
+ id: spec.id,
2006
+ label: spec.label,
2007
+ hint: spec.hint,
2008
+ placeholder: spec.placeholder,
2009
+ maxChars: spec.maxChars,
2010
+ value: values.get(spec.id) ?? '',
2011
+ })),
2012
+ }
2013
+ writeJson(res, 200, response)
2014
+ return
2015
+ }
2016
+ if (!guard(req, res, 'POST')) return
2017
+ const body = await readJsonBody<PromptSlotsRequest & { bookId?: string }>(req)
2018
+ const outputDir = resolveOutputDir(config, body?.bookId)
2019
+ const project = loadProject(outputDir)
2020
+ if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
2021
+ let value: string
2022
+ try {
2023
+ value = normalizeSlotValue(String(body?.id ?? ''), String(body?.value ?? ''))
2024
+ } catch (error) {
2025
+ writeJson(res, 400, { error: (error as Error).message })
2026
+ return
2027
+ }
2028
+ const slots = { ...(project.promptSlots ?? {}) }
2029
+ if (value === '') delete slots[body!.id]
2030
+ else slots[body!.id] = value
2031
+ project.promptSlots = slots
2032
+ project.updatedAt = new Date().toISOString()
2033
+ saveProject(outputDir, project)
2034
+ writeJson(res, 200, { ok: true, id: body!.id, chars: value.length })
2035
+ },
2036
+ }
2037
+
2038
+ // ------------------------------------------------------------- bookshelf
2039
+ const bookshelfRoute: WebRoute = {
2040
+ kind: 'exact',
2041
+ path: NOVEL_API.bookshelf,
2042
+ handler: async (req, res) => {
2043
+ // GET = snapshot; POST = create book.
2044
+ if (req.method === 'GET') {
2045
+ if (!isLoopbackRequest(req)) {
2046
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
2047
+ return
2048
+ }
2049
+ // 书架为空时,把 settings 默认输出目录里已有的项目播种为第一本书。
2050
+ seedBookshelfFromOutputDir(getConfig().outputDir)
2051
+ const snapshot: BookshelfSnapshot = bookshelfSnapshot(loadBookshelf())
2052
+ writeJson(res, 200, snapshot)
2053
+ return
2054
+ }
2055
+ if (req.method === 'POST') {
2056
+ if (!isLoopbackRequest(req)) {
2057
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
2058
+ return
2059
+ }
2060
+ const body = await readJsonBody<BookCreateRequest>(req)
2061
+ const bookName = body?.bookName?.trim()
2062
+ if (bookName === undefined || bookName === '') {
2063
+ writeJson(res, 400, { error: 'bookName 不能为空' })
2064
+ return
2065
+ }
2066
+ const outputDir = body?.outputDir?.trim() !== '' && body?.outputDir !== undefined
2067
+ ? body.outputDir
2068
+ : defaultOutputDirFor(bookName)
2069
+ const book = createBook(bookName, outputDir)
2070
+ // 开书向导:创建时带大纲 → 立即建立项目(书名以大纲首行为准)。
2071
+ const outline = body?.outline?.trim()
2072
+ if (outline !== undefined && outline.length >= 50) {
2073
+ const project = createProject(outline)
2074
+ saveProject(outputDir, project)
2075
+ renameBook(book.id, project.bookName)
2076
+ }
2077
+ writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
2078
+ return
2079
+ }
2080
+ writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
2081
+ },
2082
+ }
2083
+
2084
+ // --------------------------------------------------------------- reset
2085
+ /** 重置项目:清空设定/卷/章节计划/正文/伏笔/资产/事实库(可携带新大纲)。 */
2086
+ const resetRoute: WebRoute = {
2087
+ kind: 'exact',
2088
+ path: NOVEL_API.reset,
2089
+ handler: async (req, res) => {
2090
+ if (!guard(req, res, 'POST')) return
2091
+ const config = getConfig()
2092
+ const project = loadProject(config.outputDir)
2093
+ if (project === undefined) {
2094
+ writeJson(res, 400, { error: '输出目录中没有项目,无需重置' })
2095
+ return
2096
+ }
2097
+ const body = await readJsonBody<ResetRequest>(req)
2098
+ const outline = body?.outline?.trim()
2099
+ if (outline !== undefined && outline.length >= 50) {
2100
+ project.outline = outline
2101
+ project.bookName = createProject(outline).bookName
2102
+ }
2103
+ project.bible = undefined
2104
+ project.volumes = undefined
2105
+ project.chapters = []
2106
+ project.foreshadows = []
2107
+ project.assets = emptyProjectAssets()
2108
+ project.facts = []
2109
+ project.updatedAt = new Date().toISOString()
2110
+ saveProject(config.outputDir, project)
2111
+ writeJson(res, 200, { ok: true, bookName: project.bookName })
2112
+ },
2113
+ }
2114
+
2115
+ // --------------------------------------------------- bookshelf activate
2116
+ const bookshelfActivateRoute: WebRoute = {
2117
+ kind: 'exact',
2118
+ path: '/api/dsh-novel-forge/bookshelf/activate',
2119
+ handler: async (req, res) => {
2120
+ if (!guard(req, res, 'POST')) return
2121
+ const body = await readJsonBody<BookActivateRequest>(req)
2122
+ if (body?.id === undefined || body.id === '') {
2123
+ writeJson(res, 400, { error: 'id 不能为空' })
2124
+ return
2125
+ }
2126
+ const book = activateBook(body.id)
2127
+ if (book === undefined) {
2128
+ writeJson(res, 404, { error: `书 ${body.id} 不存在` })
2129
+ return
2130
+ }
2131
+ writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
2132
+ },
2133
+ }
2134
+
2135
+ // ---------------------------------------------------- bookshelf remove
2136
+ const bookshelfRemoveRoute: WebRoute = {
2137
+ kind: 'exact',
2138
+ path: '/api/dsh-novel-forge/bookshelf/remove',
2139
+ handler: async (req, res) => {
2140
+ if (!guard(req, res, 'POST')) return
2141
+ const body = await readJsonBody<BookRemoveRequest>(req)
2142
+ if (body?.id === undefined || body.id === '') {
2143
+ writeJson(res, 400, { error: 'id 不能为空' })
2144
+ return
2145
+ }
2146
+ const removed = removeBook(body.id)
2147
+ if (!removed) {
2148
+ writeJson(res, 404, { error: `书 ${body.id} 不存在` })
2149
+ return
2150
+ }
2151
+ writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
2152
+ },
2153
+ }
2154
+
2155
+ // ------------------------------------------- bookshelf import-dir
2156
+ /** 导入已有项目目录(Mode A):校验 novel-project.json,登记/激活书架。 */
2157
+ const bookshelfImportDirRoute: WebRoute = {
2158
+ kind: 'exact',
2159
+ path: NOVEL_API.bookshelfImportDir,
2160
+ handler: async (req, res) => {
2161
+ if (!guard(req, res, 'POST')) return
2162
+ const body = await readJsonBody<BookImportDirRequest>(req)
2163
+ const outputDir = body?.outputDir?.trim()
2164
+ if (outputDir === undefined || outputDir === '') {
2165
+ writeJson(res, 400, { error: 'outputDir 不能为空' })
2166
+ return
2167
+ }
2168
+ try {
2169
+ const { book, existed } = importDir(outputDir)
2170
+ writeJson(res, 200, { book, existed })
2171
+ } catch (err) {
2172
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
2173
+ }
2174
+ },
2175
+ }
2176
+
2177
+ // ------------------------------------------- bookshelf import-text
2178
+ /** 导入 txt/md 全本(Mode B):拆章落盘建项目,登记书架。支持浏览器上传(text+fileName)与服务器本地文件(filePath)两种模式。 */
2179
+ const bookshelfImportTextRoute: WebRoute = {
2180
+ kind: 'exact',
2181
+ path: NOVEL_API.bookshelfImportText,
2182
+ handler: async (req, res) => {
2183
+ if (!guard(req, res, 'POST')) return
2184
+ const body = await readJsonBody<BookImportTextRequest>(req)
2185
+ try {
2186
+ // 模式二:浏览器上传全文内容
2187
+ if (body?.text !== undefined && body.text.length > 0) {
2188
+ const bookName = (body.fileName ?? '').replace(/\.[^.]+$/, '').trim().slice(0, 40) || '导入小说'
2189
+ const outDir = body?.outputDir?.trim() !== undefined && body.outputDir.trim() !== ''
2190
+ ? body.outputDir.trim()
2191
+ : defaultOutputDirFor(bookName)
2192
+ const result = importBookTextFromText(body.text, outDir, bookName)
2193
+ const { book } = importDir(outDir)
2194
+ writeJson(res, 200, { ...result, book })
2195
+ return
2196
+ }
2197
+ // 模式一:服务器本地文件
2198
+ const filePath = body?.filePath?.trim()
2199
+ if (filePath === undefined || filePath === '') {
2200
+ writeJson(res, 400, { error: 'filePath 不能为空(或请上传文件内容)' })
2201
+ return
2202
+ }
2203
+ if (!existsSync(filePath)) {
2204
+ writeJson(res, 400, { error: `文件不存在:${filePath}` })
2205
+ return
2206
+ }
2207
+ const bookName = basename(filePath, extname(filePath)).slice(0, 40) || '导入小说'
2208
+ const outDir = body?.outputDir?.trim() !== undefined && body.outputDir.trim() !== ''
2209
+ ? body.outputDir.trim()
2210
+ : defaultOutputDirFor(bookName)
2211
+ const result = importBookText(filePath, outDir)
2212
+ const { book } = importDir(outDir)
2213
+ writeJson(res, 200, { ...result, book })
2214
+ } catch (err) {
2215
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
2216
+ }
2217
+ },
2218
+ }
2219
+
2220
+ // --------------------------------------- bookshelf import-text preview
2221
+ /** 上传全文做拆章预览(不落盘、不登记书架)。 */
2222
+ const bookshelfImportTextPreviewRoute: WebRoute = {
2223
+ kind: 'exact',
2224
+ path: NOVEL_API.bookshelfImportTextPreview,
2225
+ handler: async (req, res) => {
2226
+ if (!guard(req, res, 'POST')) return
2227
+ const body = await readJsonBody<BookImportTextPreviewRequest>(req)
2228
+ if (body?.text === undefined || body.text.length === 0) {
2229
+ writeJson(res, 400, { error: 'text 不能为空' })
2230
+ return
2231
+ }
2232
+ try {
2233
+ const bookName = (body.fileName ?? '').replace(/\.[^.]+$/, '').trim().slice(0, 40) || '导入小说'
2234
+ const preview = previewBookText(body.text)
2235
+ writeJson(res, 200, { bookName, ...preview })
2236
+ } catch (err) {
2237
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
2238
+ }
2239
+ },
2240
+ }
2241
+
2242
+ // ---------------------------------------------------------------- audit
2243
+ /** 全书一致性质检。 */
2244
+ const auditRoute: WebRoute = {
2245
+ kind: 'exact',
2246
+ path: NOVEL_API.audit,
2247
+ handler: async (req, res) => {
2248
+ if (!guard(req, res, 'POST')) return
2249
+ const body = await readJsonBody<{ bookId?: string }>(req)
2250
+ const config0 = getConfig()
2251
+ const outputDir = resolveOutputDir(config0, body?.bookId)
2252
+ const config = { ...config0, outputDir }
2253
+ const project = loadProject(outputDir)
2254
+ if (project === undefined) {
2255
+ writeJson(res, 400, { error: '输出目录中没有项目' })
2256
+ return
2257
+ }
2258
+ const startedAt = new Date().toISOString()
2259
+ auditState = { status: 'running', startedAt, totalBatches: 0, completedBatches: 0 }
2260
+ try {
2261
+ const issues = await auditBook(ctx, config, project, config.outputDir, (completed, total) => {
2262
+ auditState.totalBatches = total
2263
+ auditState.completedBatches = completed
2264
+ })
2265
+ const auditedChapters = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating').length
2266
+ auditState = {
2267
+ status: 'done',
2268
+ startedAt,
2269
+ finishedAt: new Date().toISOString(),
2270
+ totalBatches: auditState.totalBatches,
2271
+ completedBatches: auditState.totalBatches,
2272
+ auditedChapters,
2273
+ issuesCount: issues.length,
2274
+ issues,
2275
+ }
2276
+ const response: AuditResponse = {
2277
+ issues,
2278
+ auditedChapters,
2279
+ auditedAt: auditState.finishedAt!,
2280
+ model: config.auditModel || config.model,
2281
+ }
2282
+ writeJson(res, 200, response)
2283
+ } catch (error) {
2284
+ auditState = {
2285
+ ...auditState,
2286
+ status: 'error',
2287
+ finishedAt: new Date().toISOString(),
2288
+ error: (error as Error).message,
2289
+ }
2290
+ writeJson(res, 500, { error: (error as Error).message })
2291
+ }
2292
+ },
2293
+ }
2294
+
2295
+ // ----------------------------------------------------- characters refresh
2296
+ /** 角色卡刷新(出场统计精确化 + LLM 聚合状态)。 */
2297
+ const charactersRefreshRoute: WebRoute = {
2298
+ kind: 'exact',
2299
+ path: NOVEL_API.charactersRefresh,
2300
+ handler: async (req, res) => {
2301
+ if (!guard(req, res, 'POST')) return
2302
+ const config = getConfig()
2303
+ const project = requireProject(res)
1266
2304
  if (project === undefined) return
1267
2305
  try {
1268
2306
  const cards = await refreshCharacters(ctx, config, project, config.outputDir)
@@ -1297,7 +2335,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1297
2335
  }
1298
2336
 
1299
2337
  // ---------------------------------------------------------- bible patch
1300
- /** 设定圣经局部修补(世界观规则/红线/风格)。 */
2338
+ /** 道藏局部修补(世界观规则/红线/风格)。 */
1301
2339
  const biblePatchRoute: WebRoute = {
1302
2340
  kind: 'exact',
1303
2341
  path: NOVEL_API.biblePatch,
@@ -1337,7 +2375,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1337
2375
  }
1338
2376
 
1339
2377
  // ---------------------------------------------------------------- blurb
1340
- /** 小说简介:AI 生成/补全,或手动保存。 */
2378
+ /** 小说简介:生成/补全,或手动保存。 */
1341
2379
  const blurbRoute: WebRoute = {
1342
2380
  kind: 'exact',
1343
2381
  path: NOVEL_API.blurb,
@@ -1451,7 +2489,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1451
2489
  }
1452
2490
 
1453
2491
  // ---------------------------------------------------------------- world
1454
- /** 大世界:AI 提炼或手动保存(境界体系/区域/势力)。 */
2492
+ /** 大世界:提炼或手动保存(境界体系/区域/势力)。 */
1455
2493
  const worldRoute: WebRoute = {
1456
2494
  kind: 'exact',
1457
2495
  path: NOVEL_API.world,
@@ -1563,7 +2601,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1563
2601
  writeJson(res, 200, { plotlines: project.plotlines, suggestions } satisfies PlotlinesResponse)
1564
2602
  return
1565
2603
  } catch (error) {
1566
- writeJson(res, 500, { error: `AI 建议失败:${(error as Error).message}` })
2604
+ writeJson(res, 500, { error: `建议失败:${(error as Error).message}` })
1567
2605
  return
1568
2606
  }
1569
2607
  } else if (op === 'refresh' && body?.id !== undefined) {
@@ -1658,7 +2696,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1658
2696
  }
1659
2697
 
1660
2698
  // ---------------------------------------------------------------- roles
1661
- /** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
2699
+ /** 角色库:提炼 / 采纳 / 更新 / 删除。 */
1662
2700
  const rolesRoute: WebRoute = {
1663
2701
  kind: 'exact',
1664
2702
  path: NOVEL_API.roles,
@@ -1686,111 +2724,6 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1686
2724
  else project.roles[idx] = r
1687
2725
  } else if (op === 'remove' && body?.name !== undefined) {
1688
2726
  project.roles = project.roles.filter(x => x.name !== body.name)
1689
- } else if (op === 'image') {
1690
- const name = body?.name?.trim()
1691
- const dataUrl = body?.dataUrl?.trim()
1692
- if (name === undefined || name === '') {
1693
- writeJson(res, 400, { error: 'name(角色名)必填' })
1694
- return
1695
- }
1696
- if (dataUrl === undefined || dataUrl === '') {
1697
- writeJson(res, 400, { error: 'dataUrl(参考图)必填' })
1698
- return
1699
- }
1700
- const role = project.roles.find(r => r.name === name)
1701
- if (role === undefined) {
1702
- writeJson(res, 404, { error: `角色 ${name} 不存在` })
1703
- return
1704
- }
1705
- const label = body?.label?.trim() ?? ''
1706
- if (label !== '') {
1707
- // 图集上传:带用途标签。标签「立绘」时同步为角色参考图(imageUrl)。
1708
- role.gallery ??= []
1709
- role.gallery = role.gallery.filter(g => g.label !== label)
1710
- role.gallery.push({ label, dataUrl })
1711
- if (label === '立绘' || label.includes('立绘')) role.imageUrl = dataUrl
1712
- } else {
1713
- // 兼容旧行为:不带标签 = 角色参考图。
1714
- role.imageUrl = dataUrl
1715
- }
1716
- project.updatedAt = new Date().toISOString()
1717
- saveProject(config.outputDir, project)
1718
- writeJson(res, 200, { roles: project.roles, imageUrl: role.imageUrl } satisfies RolesResponse)
1719
- return
1720
- } else if (op === 'removeImage') {
1721
- const name = body?.name?.trim()
1722
- const label = body?.label?.trim() ?? ''
1723
- const role = project.roles.find(r => r.name === name)
1724
- if (role !== undefined) {
1725
- if (label !== '') {
1726
- role.gallery = (role.gallery ?? []).filter(g => g.label !== label)
1727
- if (label === '立绘' || label.includes('立绘')) role.imageUrl = undefined
1728
- } else {
1729
- role.imageUrl = undefined
1730
- }
1731
- }
1732
- project.updatedAt = new Date().toISOString()
1733
- saveProject(config.outputDir, project)
1734
- writeJson(res, 200, { roles: project.roles } satisfies RolesResponse)
1735
- return
1736
- } else if (op === 'imageGenerate') {
1737
- const name = body?.name?.trim()
1738
- if (name === undefined || name === '') {
1739
- writeJson(res, 400, { error: 'name(角色名)必填' })
1740
- return
1741
- }
1742
- try {
1743
- const imageUrl = await generateRoleReferenceImage(ctx, config, project, config.outputDir, name, body?.style ?? '')
1744
- project.updatedAt = new Date().toISOString()
1745
- saveProject(config.outputDir, project)
1746
- writeJson(res, 200, { roles: project.roles, imageUrl } satisfies RolesResponse)
1747
- return
1748
- } catch (error) {
1749
- writeJson(res, 500, { error: `角色参考图生成失败:${(error as Error).message}` })
1750
- return
1751
- }
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
2727
  } else {
1795
2728
  writeJson(res, 400, { error: '未知的 roles op' })
1796
2729
  return
@@ -1942,77 +2875,254 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1942
2875
  console.warn(`[dsh-novel-forge] author backfill ch.${chapter.no} failed:`, (error as Error).message)
1943
2876
  }
1944
2877
  }
1945
- project.updatedAt = new Date().toISOString()
1946
- saveProject(config.outputDir, project)
1947
- send({ type: 'author-backfill-done', count: done })
1948
- res.end()
2878
+ project.updatedAt = new Date().toISOString()
2879
+ saveProject(config.outputDir, project)
2880
+ send({ type: 'author-backfill-done', count: done })
2881
+ res.end()
2882
+ },
2883
+ }
2884
+
2885
+ // --------------------------------------------------------------- config
2886
+ const configRoute: WebRoute = {
2887
+ kind: 'exact',
2888
+ path: NOVEL_API.config,
2889
+ handler: async (req, res) => {
2890
+ if (!guard(req, res, 'POST')) return
2891
+ const body = await readJsonBody<ConfigPatch>(req)
2892
+ if (body === undefined) {
2893
+ writeJson(res, 400, { error: '无效的配置 JSON' })
2894
+ return
2895
+ }
2896
+ try {
2897
+ const next = await patchConfig(body)
2898
+ writeJson(res, 200, { config: next })
2899
+ } catch (error) {
2900
+ writeJson(res, 400, { error: (error as Error).message })
2901
+ }
2902
+ },
2903
+ }
2904
+
2905
+ // ---------------------------------------------------------- open-folder
2906
+ const openFolderRoute: WebRoute = {
2907
+ kind: 'exact',
2908
+ path: NOVEL_API.openFolder,
2909
+ handler: async (req, res) => {
2910
+ if (!guard(req, res, 'POST')) return
2911
+ const config = getConfig()
2912
+ const dir = config.outputDir
2913
+ const child = spawn('explorer', [dir], { shell: false })
2914
+ let responded = false
2915
+ child.on('error', (error) => {
2916
+ if (responded) return
2917
+ responded = true
2918
+ writeJson(res, 500, { ok: false, error: error.message })
2919
+ })
2920
+ child.on('exit', (code) => {
2921
+ if (responded) return
2922
+ responded = true
2923
+ if (code === 0) writeJson(res, 200, { ok: true })
2924
+ else writeJson(res, 500, { ok: false, error: `explorer 退出码 ${code}` })
2925
+ })
2926
+ },
2927
+ }
2928
+
2929
+ // ---------------------------------------------------------- plugin update
2930
+ /** 插件自更新:在 DSH profile 目录拉取最新 npm 版(仅下载,需重启 DSH 生效)。 */
2931
+ const pluginUpdateRoute: WebRoute = {
2932
+ kind: 'exact',
2933
+ path: NOVEL_API.pluginUpdate,
2934
+ handler: async (req, res) => {
2935
+ if (!guard(req, res, 'POST')) return
2936
+ const profileDir = join(homedir(), '.dsh', 'profiles', 'web')
2937
+ const response: PluginUpdateResponse = await new Promise((resolve) => {
2938
+ const child = spawn('pnpm', ['add', '@waterwx/dsh-novel-forge@latest'], { cwd: profileDir, shell: true })
2939
+ let acc = ''
2940
+ child.stdout.on('data', (d: Buffer) => { acc += String(d) })
2941
+ child.stderr.on('data', (d: Buffer) => { acc += String(d) })
2942
+ child.on('error', (error) => resolve({ ok: false, message: '无法执行更新:' + error.message }))
2943
+ child.on('close', (code) => {
2944
+ if (code === 0) resolve({ ok: true, message: '已更新到最新版本,请重启 DSH 生效。' })
2945
+ else resolve({ ok: false, message: '更新失败(退出码 ' + code + '):' + acc.slice(-400) })
2946
+ })
2947
+ })
2948
+ writeJson(res, 200, response)
2949
+ },
2950
+ }
2951
+
2952
+ // ------------------------------------------------------- outline suggest
2953
+ /** 开书想法 → AI 大纲:2-3 个可选方案(支持暂留换批:count 只补空槽 + exclude 避开已留方向)。 */
2954
+ const outlineSuggestRoute: WebRoute = {
2955
+ kind: 'exact',
2956
+ path: NOVEL_API.outlineSuggest,
2957
+ handler: async (req, res) => {
2958
+ if (!guard(req, res, 'POST')) return
2959
+ const config = getConfig()
2960
+ const body = await readJsonBody<OutlineSuggestRequest>(req)
2961
+ const idea = body?.idea?.trim() ?? ''
2962
+ if (idea.length < 50) {
2963
+ writeJson(res, 400, { error: '想法太短(<50 字),请多写一两句:主角是谁、什么世界、想要什么爽点' })
2964
+ return
2965
+ }
2966
+ const count = body?.count !== undefined ? Math.max(1, Math.min(3, Math.floor(body.count))) : 3
2967
+ 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)) : []
2968
+ try {
2969
+ const candidates = await suggestOutlines(ctx, config, idea, count, exclude)
2970
+ const response: OutlineSuggestResponse = { candidates }
2971
+ writeJson(res, 200, response)
2972
+ } catch (error) {
2973
+ writeJson(res, 500, { error: `大纲方案生成失败:${(error as Error).message}` })
2974
+ }
2975
+ },
2976
+ }
2977
+
2978
+ // ------------------------------------------------- outline reverse
2979
+ /** 反推大纲:从已写章节正文反向生成全书总纲(NDJSON 流式进度)。 */
2980
+ const outlineReverseRoute: WebRoute = {
2981
+ kind: 'exact',
2982
+ path: NOVEL_API.outlineReverse,
2983
+ handler: async (req, res) => {
2984
+ if (!guard(req, res, 'POST')) return
2985
+ const config = getConfig()
2986
+ const project = requireProject(res)
2987
+ if (project === undefined) return
2988
+ res.writeHead(200, {
2989
+ 'content-type': 'application/x-ndjson; charset=utf-8',
2990
+ 'cache-control': 'no-cache',
2991
+ 'x-accel-buffering': 'no',
2992
+ 'referrer-policy': 'no-referrer',
2993
+ })
2994
+ const send = (frame: JobFrame): void => {
2995
+ res.write(JSON.stringify(frame) + '\n')
2996
+ }
2997
+ try {
2998
+ const outline = await reverseOutlineFromChapters(ctx, config, project, config.outputDir, (done, total, phase) => {
2999
+ send({ type: 'outline-progress', done, total, phase })
3000
+ })
3001
+ // 保存总纲(仅更新文本,不动书名/进度)。
3002
+ project.outline = outline
3003
+ project.updatedAt = new Date().toISOString()
3004
+ saveProject(config.outputDir, project)
3005
+ send({ type: 'outline-done', outline, chars: outline.length })
3006
+ } catch (error) {
3007
+ send({ type: 'error', no: 0, message: (error as Error).message })
3008
+ } finally {
3009
+ res.end()
3010
+ }
3011
+ },
3012
+ }
3013
+
3014
+ // ------------------------------------------------------------- llm-test
3015
+ /** LLM 连通性测试:对选中的提供商/模型发一次最小真实调用。 */
3016
+ const llmTestRoute: WebRoute = {
3017
+ kind: 'exact',
3018
+ path: NOVEL_API.llmTest,
3019
+ handler: async (req, res) => {
3020
+ if (!guard(req, res, 'POST')) return
3021
+ const body = await readJsonBody<LlmTestRequest>(req)
3022
+ const provider = (body?.provider ?? '').trim()
3023
+ const model = (body?.model ?? '').trim()
3024
+ if (provider === '' || model === '') {
3025
+ writeJson(res, 400, { error: 'provider 与 model 必填' })
3026
+ return
3027
+ }
3028
+ const result = await testLlmModel(ctx, provider, model)
3029
+ writeJson(res, 200, result satisfies LlmTestResponse)
1949
3030
  },
1950
3031
  }
1951
3032
 
1952
- // --------------------------------------------------------------- config
1953
- const configRoute: WebRoute = {
3033
+ // ------------------------------------------------------------- llm-add
3034
+ /** 添加模型:厂商直填 API Key,或自定义 OpenAI 兼容路由(写 DSH 凭据 + router)。 */
3035
+ const addModelRoute: WebRoute = {
1954
3036
  kind: 'exact',
1955
- path: NOVEL_API.config,
3037
+ path: NOVEL_API.addModel,
1956
3038
  handler: async (req, res) => {
1957
3039
  if (!guard(req, res, 'POST')) return
1958
- const body = await readJsonBody<ConfigPatch>(req)
3040
+ const body = await readJsonBody<AddModelRequest>(req)
1959
3041
  if (body === undefined) {
1960
- writeJson(res, 400, { error: '无效的配置 JSON' })
3042
+ writeJson(res, 400, { error: '缺少请求体' })
1961
3043
  return
1962
3044
  }
1963
3045
  try {
1964
- const next = await patchConfig(body)
1965
- writeJson(res, 200, { config: next })
3046
+ const result = await registerLlmModel(ctx, body)
3047
+ writeJson(res, 200, result satisfies AddModelResponse)
1966
3048
  } catch (error) {
1967
- writeJson(res, 400, { error: (error as Error).message })
3049
+ writeJson(res, 500, { error: `添加模型失败:${(error as Error).message}` })
1968
3050
  }
1969
3051
  },
1970
3052
  }
1971
3053
 
1972
- // ---------------------------------------------------------- open-folder
1973
- const openFolderRoute: WebRoute = {
3054
+ // ---------------------------------------------------------- llm-vendors
3055
+ /** 运行时厂商目录(DSH pi-ai 可配置提供方 + 内置适配器)。 */
3056
+ const llmVendorsRoute: WebRoute = {
1974
3057
  kind: 'exact',
1975
- path: NOVEL_API.openFolder,
3058
+ path: NOVEL_API.llmVendors,
1976
3059
  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
- })
3060
+ if (!guard(req, res, 'GET')) return
3061
+ try {
3062
+ const result = await listLlmVendors(ctx)
3063
+ writeJson(res, 200, result satisfies LlmVendorsResponse)
3064
+ } catch (error) {
3065
+ writeJson(res, 500, { error: `读取厂商目录失败:${(error as Error).message}` })
3066
+ }
1987
3067
  },
1988
3068
  }
1989
3069
 
1990
- // ------------------------------------------------------- outline suggest
1991
- /** 开书想法 → AI 大纲:2-3 个可选方案(支持暂留换批:count 只补空槽 + exclude 避开已留方向)。 */
1992
- const outlineSuggestRoute: WebRoute = {
3070
+ // ---------------------------------------------------------- llm-models
3071
+ /** 查询某个 provider 当前可用的模型(添加成功后可即时刷新下拉)。 */
3072
+ const llmModelsRoute: WebRoute = {
1993
3073
  kind: 'exact',
1994
- path: NOVEL_API.outlineSuggest,
3074
+ path: NOVEL_API.llmModels,
3075
+ handler: async (req, res) => {
3076
+ if (!guard(req, res, 'GET')) return
3077
+ const url = new URL(req.url ?? '', 'http://localhost')
3078
+ const provider = decodeURIComponent((url.searchParams.get('provider') ?? '').trim())
3079
+ try {
3080
+ const result = await listLlmModels(ctx, provider)
3081
+ writeJson(res, 200, result satisfies LlmModelsResponse)
3082
+ } catch (error) {
3083
+ writeJson(res, 500, { error: `读取模型列表失败:${(error as Error).message}` })
3084
+ }
3085
+ },
3086
+ }
3087
+
3088
+ // ---------------------------------------------------------- llm-providers
3089
+ /** 已注册的提供方路由列表(提供方管理卡片)。 */
3090
+ const llmProvidersRoute: WebRoute = {
3091
+ kind: 'exact',
3092
+ path: NOVEL_API.llmProviders,
3093
+ handler: async (req, res) => {
3094
+ if (!guard(req, res, 'GET')) return
3095
+ try {
3096
+ const result = await listLlmProviders(ctx)
3097
+ writeJson(res, 200, result satisfies LlmProvidersResponse)
3098
+ } catch (error) {
3099
+ writeJson(res, 500, { error: `读取提供方列表失败:${(error as Error).message}` })
3100
+ }
3101
+ },
3102
+ }
3103
+
3104
+ // ------------------------------------------------------------- llm-remove
3105
+ /** 移除一个提供方(unset key + 移除 llm-pi-ai 路由)。 */
3106
+ const llmRemoveRoute: WebRoute = {
3107
+ kind: 'exact',
3108
+ path: NOVEL_API.llmRemove,
1995
3109
  handler: async (req, res) => {
1996
3110
  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 字),请多写一两句:主角是谁、什么世界、想要什么爽点' })
3111
+ const body = await readJsonBody<RemoveProviderRequest>(req)
3112
+ if (body === undefined) {
3113
+ writeJson(res, 400, { error: '缺少请求体' })
2002
3114
  return
2003
3115
  }
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
3116
  try {
2007
- const candidates = await suggestOutlines(ctx, config, idea, count, exclude)
2008
- const response: OutlineSuggestResponse = { candidates }
2009
- writeJson(res, 200, response)
3117
+ const result = await removeLlmProvider(ctx, body)
3118
+ writeJson(res, 200, result satisfies RemoveProviderResponse)
2010
3119
  } catch (error) {
2011
- writeJson(res, 500, { error: `大纲方案生成失败:${(error as Error).message}` })
3120
+ writeJson(res, 500, { error: `移除提供方失败:${(error as Error).message}` })
2012
3121
  }
2013
3122
  },
2014
3123
  }
2015
3124
 
3125
+
2016
3126
  // ----------------------------------------------------------- breakdown
2017
3127
  /** 拆书分析:对已写章节做结构/人物/文风/卖点体检(两阶段:源笔记→分节分析)。 */
2018
3128
  const breakdownRoute: WebRoute = {
@@ -2041,114 +3151,128 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2041
3151
  },
2042
3152
  }
2043
3153
 
2044
- // --------------------------------------------------------------- scenes
2045
- /** 场景库:AI 提炼 / 采纳 / 更新 / 删除 / 图集。 */
2046
- const scenesRoute: WebRoute = {
3154
+
3155
+ // ----------------------------------------------------------- production run
3156
+ /** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
3157
+ const runner = new ProductionRunner({ ctx, getConfig })
3158
+
3159
+ // --------------------------------------------------------- move-output-dir
3160
+ /**
3161
+ * 输出目录迁移:dryRun 预览;确认后 copy 全部内容 → 书架重指向 → settings
3162
+ * 默认目录同步(仅当默认目录就是被搬目录)→ 最后删源目录(数据零丢失排序)。
3163
+ */
3164
+ const moveOutputDirRoute: WebRoute = {
2047
3165
  kind: 'exact',
2048
- path: NOVEL_API.scenes,
3166
+ path: NOVEL_API.moveOutputDir,
2049
3167
  handler: async (req, res) => {
2050
3168
  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') {
3169
+ const from = getConfig().outputDir
3170
+ const body = await readJsonBody<MoveOutputDirRequest>(req)
3171
+ // 预览:只列内容,不搬移。
3172
+ if (body?.dryRun === true) {
2058
3173
  try {
2059
- const candidates = await extractScenes(ctx, config, project)
2060
- writeJson(res, 200, { scenes: project.scenes, candidates } satisfies ScenesResponse)
2061
- return
3174
+ const contents = listDirContents(from)
3175
+ writeJson(res, 200, { from, files: contents.files.slice(0, 300), bytes: contents.bytes, movedBooks: 0, defaultUpdated: false } satisfies MoveOutputDirResponse)
2062
3176
  } catch (error) {
2063
- writeJson(res, 500, { error: `场景提炼失败:${(error as Error).message}` })
2064
- return
2065
- }
2066
- } else if ((op === 'adopt' || op === 'update') && body?.scene !== undefined) {
2067
- const s = body.scene
2068
- const idx = project.scenes.findIndex(x => x.name === s.name)
2069
- if (idx === -1) project.scenes.push(s)
2070
- else project.scenes[idx] = s
2071
- } else if (op === 'remove' && body?.name !== undefined) {
2072
- project.scenes = project.scenes.filter(x => x.name !== body.name)
2073
- } else if (op === 'image') {
2074
- const name = body?.name?.trim()
2075
- const dataUrl = body?.dataUrl?.trim()
2076
- if (name === undefined || name === '' || dataUrl === undefined || dataUrl === '') {
2077
- writeJson(res, 400, { error: 'name(场景名)与 dataUrl 必填' })
2078
- return
2079
- }
2080
- const scene = project.scenes.find(x => x.name === name)
2081
- if (scene === undefined) {
2082
- writeJson(res, 404, { error: `场景 ${name} 不存在` })
2083
- return
3177
+ writeJson(res, 400, { error: (error as Error).message })
2084
3178
  }
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
3179
  return
2099
3180
  }
2100
- project.updatedAt = new Date().toISOString()
2101
- saveProject(config.outputDir, project)
2102
- writeJson(res, 200, { scenes: project.scenes } satisfies ScenesResponse)
3181
+ if (runner.isWorking()) {
3182
+ writeJson(res, 409, { error: '生产单正在运行中,请先停止后再迁移目录' })
3183
+ return
3184
+ }
3185
+ const to = body?.to?.trim() ?? ''
3186
+ if (to === '') {
3187
+ writeJson(res, 400, { error: '新目录路径不能为空' })
3188
+ return
3189
+ }
3190
+ if (normalizeDir(to) === normalizeDir(from)) {
3191
+ writeJson(res, 400, { error: '新目录与当前目录相同' })
3192
+ return
3193
+ }
3194
+ try {
3195
+ // 1. copy 全部内容(源目录保留;失败即中止,源数据无损)。
3196
+ copyDirContents(from, to)
3197
+ } catch (error) {
3198
+ writeJson(res, 400, { error: `迁移中止:${(error as Error).message}` })
3199
+ return
3200
+ }
3201
+ // 2. 书架重指向(outputDir === from 的条目全部改到 to)。
3202
+ let movedBooks = 0
3203
+ let repointError = ''
3204
+ try { movedBooks = repointBookDirs(from, to) } catch (error) { repointError = (error as Error).message }
3205
+ // 3. settings 默认目录同步(仅当默认目录就是被搬的这个)。
3206
+ let defaultUpdated = false
3207
+ let defaultError = ''
3208
+ const raw = deps.rawConfig?.()
3209
+ if (raw !== undefined && raw.outputDir !== undefined && raw.outputDir !== '' && normalizeDir(raw.outputDir) === normalizeDir(from)) {
3210
+ try {
3211
+ await patchConfig({ outputDir: to })
3212
+ defaultUpdated = true
3213
+ } catch (error) { defaultError = (error as Error).message }
3214
+ }
3215
+ // 4. 收尾:重指向全部成功才删源目录;否则保留源目录兜底并如实上报。
3216
+ if (repointError === '' && defaultError === '') {
3217
+ removeDir(from)
3218
+ const contents = listDirContents(to)
3219
+ writeJson(res, 200, { from, to, files: contents.files.slice(0, 300), bytes: contents.bytes, movedBooks, defaultUpdated } satisfies MoveOutputDirResponse)
3220
+ return
3221
+ }
3222
+ writeJson(res, 500, {
3223
+ error: `文件已复制到 ${to},但收尾未完成${repointError !== '' ? `(书架重指向失败:${repointError})` : ''}${defaultError !== '' ? `(默认目录更新失败:${defaultError})` : ''}。源目录已保留,请检查后重试或手动处理。`,
3224
+ from,
3225
+ to,
3226
+ movedBooks,
3227
+ defaultUpdated,
3228
+ })
2103
3229
  },
2104
3230
  }
2105
3231
 
2106
- // ----------------------------------------------------------- visual rules
2107
- /** 视觉世界观规则:从道藏提炼(生图/生视频纠偏),或手动保存。 */
2108
- const visualRulesRoute: WebRoute = {
3232
+ // ----------------------------------------------------------- book-settings
3233
+ /**
3234
+ * 本书参数:POST {} 读取当前书的 settings + 生效配置(全局+本书回退链);
3235
+ * POST {patch} 白名单合并保存到 novel-project.json.bookSettings。
3236
+ */
3237
+ const bookSettingsRoute: WebRoute = {
2109
3238
  kind: 'exact',
2110
- path: NOVEL_API.visualRules,
3239
+ path: NOVEL_API.bookSettings,
2111
3240
  handler: async (req, res) => {
2112
3241
  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}` })
3242
+ const outputDir = getConfig().outputDir
3243
+ const body = await readJsonBody<BookSettingsRequest>(req)
3244
+ if (body?.patch !== undefined) {
3245
+ const project = loadProject(outputDir)
3246
+ if (project === undefined) {
3247
+ writeJson(res, 400, { error: '当前没有打开的书(未找到 novel-project.json)' })
2127
3248
  return
2128
3249
  }
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)
3250
+ const cur: BookSettings = project.bookSettings ?? {}
3251
+ const p = body.patch
3252
+ const next: Record<string, unknown> = { ...cur }
3253
+ for (const key of ['provider', 'model', 'reasoningEffort', 'analysisReasoning', 'chapterChars', 'maxTokens', 'reviewPassScore', 'autoReview', 'autoAuthorReview', 'autoReviewAfterRevise'] as const) {
3254
+ const v = (p as Record<string, unknown>)[key]
3255
+ if (v === null) delete next[key]
3256
+ else if (v !== undefined) next[key] = v
3257
+ }
3258
+ project.bookSettings = next as BookSettings
2131
3259
  project.updatedAt = new Date().toISOString()
2132
- saveProject(config.outputDir, project)
2133
- writeJson(res, 200, { rules: project.visualRules } satisfies VisualRulesResponse)
2134
- return
3260
+ saveProject(outputDir, project)
2135
3261
  }
2136
- writeJson(res, 400, { error: 'op 须为 extract 或 save' })
3262
+ const project = loadProject(outputDir)
3263
+ writeJson(res, 200, { settings: project?.bookSettings ?? {}, effective: getConfig(), hasBook: project !== undefined } satisfies BookSettingsResponse)
2137
3264
  },
2138
3265
  }
2139
3266
 
2140
- // ----------------------------------------------------------- production run
2141
- /** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
2142
- const runner = new ProductionRunner({ ctx, getConfig })
2143
-
2144
3267
  const runStartRoute: WebRoute = {
2145
3268
  kind: 'exact',
2146
3269
  path: NOVEL_API.runStart,
2147
3270
  handler: async (req, res) => {
2148
3271
  if (!guard(req, res, 'POST')) return
2149
- const body = await readJsonBody<RunStartRequest>(req)
3272
+ const body = await readJsonBody<RunStartRequest & { bookId?: string }>(req)
2150
3273
  const config = getConfig()
2151
- const project = loadProject(config.outputDir)
3274
+ const outputDir = resolveOutputDir(config, body?.bookId)
3275
+ const project = loadProject(outputDir)
2152
3276
  if (project === undefined) {
2153
3277
  writeJson(res, 400, { error: '输出目录中没有项目' })
2154
3278
  return
@@ -2165,7 +3289,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2165
3289
  return
2166
3290
  }
2167
3291
  try {
2168
- const state = await runner.start(startNo, endNo)
3292
+ const state = await runner.start(startNo, endNo, outputDir)
2169
3293
  writeJson(res, 200, state satisfies RunState)
2170
3294
  } catch (error) {
2171
3295
  writeJson(res, 409, { error: (error as Error).message })
@@ -2204,6 +3328,337 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2204
3328
  },
2205
3329
  }
2206
3330
 
3331
+ // -------------------------------------------------- author assets (总数据)
3332
+ const authorAssetsRoute: WebRoute = {
3333
+ kind: 'exact',
3334
+ path: NOVEL_API.authorAssets,
3335
+ handler: (req, res) => {
3336
+ if (!guard(req, res, 'GET')) return
3337
+ const response: AuthorAssetsResponse = { assets: loadAuthorAssets() }
3338
+ writeJson(res, 200, response)
3339
+ },
3340
+ }
3341
+
3342
+ const authorAssetsUpsertRoute: WebRoute = {
3343
+ kind: 'exact',
3344
+ path: NOVEL_API.authorAssetsUpsert,
3345
+ handler: async (req, res) => {
3346
+ if (!guard(req, res, 'POST')) return
3347
+ const body = await readJsonBody<AuthorAssetUpsertRequest>(req)
3348
+ const asset = body?.asset
3349
+ if (asset === undefined || asset.name.trim() === '' || asset.content.trim() === '') {
3350
+ writeJson(res, 400, { error: 'asset 须含 name / content' })
3351
+ return
3352
+ }
3353
+ const response: AuthorAssetsResponse = { assets: upsertAuthorAsset(asset) }
3354
+ writeJson(res, 200, response)
3355
+ },
3356
+ }
3357
+
3358
+ const authorAssetsRemoveRoute: WebRoute = {
3359
+ kind: 'exact',
3360
+ path: NOVEL_API.authorAssetsRemove,
3361
+ handler: async (req, res) => {
3362
+ if (!guard(req, res, 'POST')) return
3363
+ const body = await readJsonBody<AuthorAssetRemoveRequest>(req)
3364
+ const id = body?.id ?? ''
3365
+ if (id === '') { writeJson(res, 400, { error: 'id 必填' }); return }
3366
+ const response: AuthorAssetsResponse = { assets: removeAuthorAsset(id) }
3367
+ writeJson(res, 200, response)
3368
+ },
3369
+ }
3370
+
3371
+ const authorAssetsImportDefaultRoute: WebRoute = {
3372
+ kind: 'exact',
3373
+ path: NOVEL_API.authorAssetsImportDefault,
3374
+ handler: (req, res) => {
3375
+ if (!guard(req, res, 'POST')) return
3376
+ const response: AuthorAssetsResponse = { assets: importDefaultAuthorAssets() }
3377
+ writeJson(res, 200, response)
3378
+ },
3379
+ }
3380
+
3381
+ // --------------------------------------------------- adaptation (改编 P0 分析)
3382
+ const adaptAnalyzeRoute: WebRoute = {
3383
+ kind: 'exact',
3384
+ path: NOVEL_API.adaptAnalyze,
3385
+ handler: async (req, res) => {
3386
+ if (!guard(req, res, 'POST')) return
3387
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
3388
+ const body = await readJsonBody<AdaptAnalyzeRequest>(req)
3389
+ const config = getConfig()
3390
+ let text = body?.text?.trim() ?? ''
3391
+ if (text === '' && body?.filePath !== undefined && body.filePath !== '') {
3392
+ try {
3393
+ text = readFileSync(body.filePath, 'utf8')
3394
+ } catch (err) {
3395
+ writeJson(res, 400, { error: '读取文件失败:' + (err as Error).message })
3396
+ return
3397
+ }
3398
+ }
3399
+ if (text.length < 200) {
3400
+ writeJson(res, 400, { error: '全文内容过短(<200 字符),请上传完整小说文本' })
3401
+ return
3402
+ }
3403
+ try {
3404
+ const response: AdaptAnalyzeResponse = await analyzeAdaptation(ctx, config, text)
3405
+ writeJson(res, 200, response)
3406
+ } catch (err) {
3407
+ writeJson(res, 400, { error: (err as Error).message })
3408
+ }
3409
+ },
3410
+ }
3411
+
3412
+ const adaptProposeRoute: WebRoute = {
3413
+ kind: 'exact',
3414
+ path: NOVEL_API.adaptPropose,
3415
+ handler: async (req, res) => {
3416
+ if (!guard(req, res, 'POST')) return
3417
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
3418
+ const body = await readJsonBody<AdaptProposeRequest>(req)
3419
+ const config = getConfig()
3420
+ const text = (body?.text ?? '').trim()
3421
+ const selections = body?.selections ?? []
3422
+ if (text.length < 200 || selections.length === 0) {
3423
+ writeJson(res, 400, { error: '请提供全文与至少一条要改的维度' })
3424
+ return
3425
+ }
3426
+ try {
3427
+ const response: AdaptProposeResponse = await proposeAdaptation(ctx, config, text, selections, body?.dimensions)
3428
+ writeJson(res, 200, response)
3429
+ } catch (err) {
3430
+ writeJson(res, 400, { error: (err as Error).message })
3431
+ }
3432
+ },
3433
+ }
3434
+
3435
+ const adaptExecuteRoute: WebRoute = {
3436
+ kind: 'exact',
3437
+ path: NOVEL_API.adaptExecute,
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<AdaptExecuteRequest>(req)
3442
+ const text = body?.text ?? ''
3443
+ const mappings = body?.mappings ?? []
3444
+ if (text.length < 200 || mappings.length === 0) {
3445
+ writeJson(res, 400, { error: '请提供全文与映射表' })
3446
+ return
3447
+ }
3448
+ if (body?.mode === 'rewrite') {
3449
+ try {
3450
+ const result = await rewriteAdaptationBook(ctx, getConfig(), text, mappings, body?.rules, { maxChapters: body?.maxChapters, startNo: body?.startNo, endNo: body?.endNo })
3451
+ const response: AdaptExecuteResponse = { adaptedText: result.adaptedText, mappings: mappings.length, hits: result.hits, mode: 'rewrite', rewritten: result.rewritten, skipped: result.skipped }
3452
+ writeJson(res, 200, response)
3453
+ } catch (err) {
3454
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
3455
+ }
3456
+ return
3457
+ }
3458
+ const { adaptedText, hits } = applyAdaptationReplacements(text, mappings)
3459
+ const response: AdaptExecuteResponse = { adaptedText, mappings: mappings.length, hits, mode: 'replace' }
3460
+ writeJson(res, 200, response)
3461
+ },
3462
+ }
3463
+
3464
+ /** rewrite 逐章重写:NDJSON 流式进度(支持分段 startNo/endNo)。 */
3465
+ const adaptRewriteStreamRoute: WebRoute = {
3466
+ kind: 'exact',
3467
+ path: NOVEL_API.adaptRewriteStream,
3468
+ handler: async (req, res) => {
3469
+ if (!guard(req, res, 'POST')) return
3470
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
3471
+ const body = await readJsonBody<AdaptExecuteRequest>(req)
3472
+ const text = body?.text ?? ''
3473
+ const mappings = body?.mappings ?? []
3474
+ if (text.length < 200 || mappings.length === 0) { writeJson(res, 400, { error: '请提供全文与映射表' }); return }
3475
+ res.writeHead(200, {
3476
+ 'content-type': 'application/x-ndjson; charset=utf-8',
3477
+ 'cache-control': 'no-cache',
3478
+ 'x-accel-buffering': 'no',
3479
+ 'referrer-policy': 'no-referrer',
3480
+ })
3481
+ const send = (frame: AdaptRewriteFrame): void => { res.write(JSON.stringify(frame) + '\n') }
3482
+ try {
3483
+ const result = await rewriteAdaptationBook(ctx, getConfig(), text, mappings, body?.rules, {
3484
+ maxChapters: body?.maxChapters,
3485
+ startNo: body?.startNo,
3486
+ endNo: body?.endNo,
3487
+ onProgress: (info) => send({ type: 'progress', completed: info.completed, total: info.total, no: info.no, title: info.title }),
3488
+ })
3489
+ const response: AdaptExecuteResponse = { adaptedText: result.adaptedText, mappings: mappings.length, hits: result.hits, mode: 'rewrite', rewritten: result.rewritten, skipped: result.skipped }
3490
+ send({ type: 'done', result: response })
3491
+ } catch (err) {
3492
+ send({ type: 'error', message: err instanceof Error ? err.message : String(err) })
3493
+ } finally {
3494
+ res.end()
3495
+ }
3496
+ },
3497
+ }
3498
+
3499
+ /** 保存改编全文为新书(原书保留,登记书架)。 */
3500
+ const adaptSaveRoute: WebRoute = {
3501
+ kind: 'exact',
3502
+ path: NOVEL_API.adaptSave,
3503
+ handler: async (req, res) => {
3504
+ if (!guard(req, res, 'POST')) return
3505
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
3506
+ const body = await readJsonBody<AdaptSaveRequest>(req)
3507
+ const text = (body?.text ?? '').trim()
3508
+ if (text.length < 200) {
3509
+ writeJson(res, 400, { error: '改编全文内容过短(<200 字符)' })
3510
+ return
3511
+ }
3512
+ const bookName = (body?.bookName ?? '').trim().slice(0, 40) || '改编新书'
3513
+ const outDir = (body?.outputDir ?? '').trim() !== '' && body?.outputDir !== undefined && body.outputDir.trim() !== ''
3514
+ ? body.outputDir.trim()
3515
+ : defaultOutputDirFor(bookName)
3516
+ // 原书保留:若目录已被另一本书占用则拒绝,避免误覆盖原书/别的书。
3517
+ const occupied = loadBookshelf().books.find(b => b.outputDir === outDir)
3518
+ if (occupied !== undefined && occupied.bookName !== bookName) {
3519
+ writeJson(res, 409, { error: `目录已被《${occupied.bookName}》占用,请更换书名或输出目录` })
3520
+ return
3521
+ }
3522
+ try {
3523
+ const result = importBookTextFromText(text, outDir, bookName)
3524
+ // 有反推大纲时写进新项目,便于后续续写/编辑(importBookTextFromText 默认 outline=书名)。
3525
+ const outline = (body?.outline ?? '').trim()
3526
+ if (outline !== '') {
3527
+ const project = loadProject(outDir)
3528
+ if (project !== undefined) {
3529
+ project.outline = outline
3530
+ saveProject(outDir, project)
3531
+ }
3532
+ }
3533
+ const { book } = importDir(outDir)
3534
+ const response: AdaptSaveResponse = { book, bookName: result.bookName, chapters: result.chapters, skipped: result.skipped, outputDir: outDir }
3535
+ writeJson(res, 200, response)
3536
+ } catch (err) {
3537
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
3538
+ }
3539
+ },
3540
+ }
3541
+
3542
+ /** 从源全文 + 编辑后方案提炼新书资料并保存为「待写新书」。 */
3543
+ const adaptMaterializeRoute: WebRoute = {
3544
+ kind: 'exact',
3545
+ path: NOVEL_API.adaptMaterialize,
3546
+ handler: async (req, res) => {
3547
+ if (!guard(req, res, 'POST')) return
3548
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
3549
+ const body = await readJsonBody<AdaptMaterializeRequest>(req)
3550
+ const text = (body?.text ?? '').trim()
3551
+ if (text.length < 200) { writeJson(res, 400, { error: '源书全文内容过短(<200 字符)' }); return }
3552
+ const proposal = body?.proposal
3553
+ if (proposal === undefined || proposal.mappings.length === 0) { writeJson(res, 400, { error: '请先提供至少一条映射的改编方案' }); return }
3554
+ const bookName = (body?.bookName ?? '').trim().slice(0, 40) || '改编新书'
3555
+ const outDir = (body?.outputDir ?? '').trim() !== '' && body?.outputDir !== undefined && body.outputDir.trim() !== ''
3556
+ ? body.outputDir.trim()
3557
+ : defaultOutputDirFor(bookName)
3558
+ // 原书保留:若目录已被另一本书占用则拒绝。
3559
+ const occupied = loadBookshelf().books.find(b => b.outputDir === outDir)
3560
+ if (occupied !== undefined && occupied.bookName !== bookName) {
3561
+ writeJson(res, 409, { error: `目录已被《${occupied.bookName}》占用,请更换书名或输出目录` })
3562
+ return
3563
+ }
3564
+ try {
3565
+ const result = await materializeAdaptedBook(ctx, getConfig(), {
3566
+ text,
3567
+ bookName,
3568
+ outputDir: outDir,
3569
+ outline: body?.outline,
3570
+ proposal,
3571
+ chapterCount: body?.chapterCount,
3572
+ })
3573
+ // 预览:只返回提炼资料,不落盘;保存走 /adapt/materialize-save。
3574
+ const response: AdaptMaterializeResponse = result
3575
+ writeJson(res, 200, response)
3576
+ } catch (err) {
3577
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
3578
+ }
3579
+ },
3580
+ }
3581
+
3582
+ /** 保存预览/微调后的新书资料为新书(原书保留,登记书架)。 */
3583
+ const adaptMaterializeSaveRoute: WebRoute = {
3584
+ kind: 'exact',
3585
+ path: NOVEL_API.adaptMaterializeSave,
3586
+ handler: async (req, res) => {
3587
+ if (!guard(req, res, 'POST')) return
3588
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
3589
+ const body = await readJsonBody<AdaptMaterializeSaveRequest>(req)
3590
+ if (body === undefined || body === null || (body.outline ?? '').trim() === '') {
3591
+ writeJson(res, 400, { error: '请提供改编后总纲' })
3592
+ return
3593
+ }
3594
+ const bookName = (body.bookName ?? '').trim().slice(0, 40) || '改编新书'
3595
+ const outDir = (body.outputDir ?? '').trim() !== '' && body.outputDir !== undefined && body.outputDir.trim() !== ''
3596
+ ? body.outputDir.trim()
3597
+ : defaultOutputDirFor(bookName)
3598
+ const occupied = loadBookshelf().books.find(b => b.outputDir === outDir)
3599
+ if (occupied !== undefined && occupied.bookName !== bookName) {
3600
+ writeJson(res, 409, { error: `目录已被《${occupied.bookName}》占用,请更换书名或输出目录` })
3601
+ return
3602
+ }
3603
+ try {
3604
+ const result = saveMaterializedBook(outDir, bookName, {
3605
+ outline: body.outline,
3606
+ bible: body.bible,
3607
+ roles: body.roles,
3608
+ world: body.world,
3609
+ volumes: body.volumes,
3610
+ chapters: body.chapters,
3611
+ })
3612
+ const { book } = importDir(outDir)
3613
+ const response: AdaptMaterializeSaveResponse = { ...result, book }
3614
+ writeJson(res, 200, response)
3615
+ } catch (err) {
3616
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
3617
+ }
3618
+ },
3619
+ }
3620
+
3621
+ // ------------------------------------------ theme custom background (upload + serve)
3622
+ const THEME_BG_DIR = join(homedir(), '.dsh', 'dsh-novel-forge-assets')
3623
+ const MIME_BY_EXT: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }
3624
+
3625
+ const themeBackgroundUploadRoute: WebRoute = {
3626
+ kind: 'exact',
3627
+ path: NOVEL_API.themeBackgroundUpload,
3628
+ handler: async (req, res) => {
3629
+ if (!guard(req, res, 'POST')) return
3630
+ const body = await readJsonBody<{ dataUrl?: string }>(req)
3631
+ const dataUrl = body?.dataUrl ?? ''
3632
+ const m = /^data:image\/(png|jpeg|jpg|webp|gif);base64,(.+)$/i.exec(dataUrl)
3633
+ if (m === null) { writeJson(res, 400, { error: '仅支持 PNG/JPG/WebP/GIF 图片' }); return }
3634
+ const ext = m[1]!.toLowerCase() === 'jpg' ? 'jpeg' : m[1]!.toLowerCase()
3635
+ const buf = Buffer.from(m[2]!, 'base64')
3636
+ if (buf.length === 0) { writeJson(res, 400, { error: '图片数据为空' }); return }
3637
+ mkdirSync(THEME_BG_DIR, { recursive: true })
3638
+ const name = 'theme-bg-' + Date.now().toString(36) + '-' + randomBytes(3).toString('hex') + '.' + ext
3639
+ writeFileSync(join(THEME_BG_DIR, name), buf)
3640
+ writeJson(res, 200, { url: NOVEL_API.themeBackgroundGet + '/' + name })
3641
+ },
3642
+ }
3643
+
3644
+ const themeBackgroundGetRoute: WebRoute = {
3645
+ kind: 'prefix',
3646
+ path: NOVEL_API.themeBackgroundGet,
3647
+ handler: (req, res) => {
3648
+ if (!guard(req, res, 'GET')) return
3649
+ let pathName = ''
3650
+ try { pathName = new URL(req.url ?? '', 'http://localhost').pathname } catch { /* ignore */ }
3651
+ const name = basename(pathName)
3652
+ if (!name.startsWith('theme-bg-')) { writeJson(res, 404, { error: 'not found' }); return }
3653
+ const file = join(THEME_BG_DIR, name)
3654
+ if (!existsSync(file)) { writeJson(res, 404, { error: 'not found' }); return }
3655
+ const ext = extname(file).slice(1)
3656
+ const type = MIME_BY_EXT[ext] ?? 'application/octet-stream'
3657
+ res.writeHead(200, { 'content-type': type, 'cache-control': 'public, max-age=31536000' })
3658
+ res.end(readFileSync(file))
3659
+ },
3660
+ }
3661
+
2207
3662
  return [
2208
3663
  statusRoute,
2209
3664
  loadOutlineRoute,
@@ -2225,12 +3680,36 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2225
3680
  chapterSaveRoute,
2226
3681
  assetsRoute,
2227
3682
  styleEngineRoute,
3683
+ styleFormulaRoute,
3684
+ styleDetectRoute,
3685
+ marketRadarRoute,
3686
+ marketRadarScanRoute,
3687
+ marketRadarApplyRoute,
3688
+ marketRadarSyncRoute,
3689
+ marketRadarBriefRoute,
3690
+ knowledgeRoute,
3691
+ bookAnalysisRoute,
3692
+ ideaInspirationRoute,
3693
+ marketIdeaInspirationRoute,
3694
+ directorRoute,
3695
+ directorTodosRoute,
3696
+ llmLiveRoute,
2228
3697
  assistantRoute,
2229
3698
  assistantHistoryRoute,
3699
+ llmPromptRoute,
3700
+ usageResetRoute,
3701
+ timelineRoute,
3702
+ revisionPlanRoute,
3703
+ snapshotRoute,
3704
+ tensionRoute,
3705
+ promptSlotsRoute,
2230
3706
  assistantClearRoute,
2231
3707
  bookshelfRoute,
2232
3708
  bookshelfActivateRoute,
2233
3709
  bookshelfRemoveRoute,
3710
+ bookshelfImportDirRoute,
3711
+ bookshelfImportTextRoute,
3712
+ bookshelfImportTextPreviewRoute,
2234
3713
  resetRoute,
2235
3714
  auditRoute,
2236
3715
  charactersRefreshRoute,
@@ -2242,19 +3721,40 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2242
3721
  renameRoute,
2243
3722
  plotlinesRoute,
2244
3723
  rolesRoute,
2245
- scenesRoute,
2246
- visualRulesRoute,
2247
3724
  sensitiveRoute,
2248
3725
  reviewBackfillRoute,
2249
3726
  chapterResetRoute,
2250
3727
  chapterApproveRoute,
2251
3728
  configRoute,
3729
+ moveOutputDirRoute,
3730
+ bookSettingsRoute,
2252
3731
  openFolderRoute,
3732
+ pluginUpdateRoute,
2253
3733
  outlineSuggestRoute,
3734
+ outlineReverseRoute,
3735
+ llmTestRoute,
3736
+ addModelRoute,
3737
+ llmVendorsRoute,
3738
+ llmModelsRoute,
3739
+ llmProvidersRoute,
3740
+ llmRemoveRoute,
2254
3741
  breakdownRoute,
2255
3742
  runStartRoute,
2256
3743
  runControlRoute,
2257
3744
  runStatusRoute,
3745
+ authorAssetsRoute,
3746
+ authorAssetsUpsertRoute,
3747
+ authorAssetsRemoveRoute,
3748
+ authorAssetsImportDefaultRoute,
3749
+ adaptAnalyzeRoute,
3750
+ adaptProposeRoute,
3751
+ adaptExecuteRoute,
3752
+ adaptSaveRoute,
3753
+ adaptMaterializeRoute,
3754
+ adaptMaterializeSaveRoute,
3755
+ adaptRewriteStreamRoute,
3756
+ themeBackgroundUploadRoute,
3757
+ themeBackgroundGetRoute,
2258
3758
  ]
2259
3759
  }
2260
3760