@waterwx/dsh-novel-forge 0.1.0 → 1.0.0-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 (180) hide show
  1. package/README.md +162 -111
  2. package/cordis.patch.yml +1 -1
  3. package/lib/client.js +54165 -2666
  4. package/lib/index.js +11895 -2211
  5. package/lib/types/ai-scan.d.ts +36 -0
  6. package/lib/types/assets.d.ts +19 -4
  7. package/lib/types/assistant.d.ts +6 -0
  8. package/lib/types/author-assets.d.ts +25 -0
  9. package/lib/types/bookshelf.d.ts +8 -1
  10. package/lib/types/client/api.d.ts +235 -3
  11. package/lib/types/client/index.d.ts +1 -1
  12. package/lib/types/client/llmLive.d.ts +33 -0
  13. package/lib/types/client/locales.d.ts +101 -28
  14. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  15. package/lib/types/client/panel/AssetsTab.d.ts +6 -1
  16. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  17. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  18. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  19. package/lib/types/client/panel/CreateBookView.d.ts +13 -0
  20. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  21. package/lib/types/client/panel/FlowGuide.d.ts +22 -0
  22. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  23. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  24. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  25. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  26. package/lib/types/client/panel/LiveFeedLog.d.ts +5 -0
  27. package/lib/types/client/panel/MangaRoleLibrary.d.ts +20 -0
  28. package/lib/types/client/panel/MangaWorkspace.d.ts +12 -0
  29. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  30. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  31. package/lib/types/client/panel/ModelPicker.d.ts +11 -0
  32. package/lib/types/client/panel/NovelPanel.d.ts +1 -1
  33. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  34. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  35. package/lib/types/client/panel/PropLibrary.d.ts +8 -0
  36. package/lib/types/client/panel/ReaderView.d.ts +8 -0
  37. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  38. package/lib/types/client/panel/RunPanel.d.ts +5 -0
  39. package/lib/types/client/panel/SceneLibrary.d.ts +16 -0
  40. package/lib/types/client/panel/SettingsView.d.ts +16 -0
  41. package/lib/types/client/panel/ShelfView.d.ts +15 -0
  42. package/lib/types/client/panel/StoryboardTab.d.ts +19 -0
  43. package/lib/types/client/panel/StyleCard.d.ts +8 -0
  44. package/lib/types/client/panel/StyleGallery.d.ts +1 -0
  45. package/lib/types/client/panel/WorldTab.d.ts +10 -0
  46. package/lib/types/client/panel/helpers.d.ts +10 -0
  47. package/lib/types/client/panel/views.d.ts +77 -0
  48. package/lib/types/client/run-status.d.ts +19 -0
  49. package/lib/types/client/text.d.ts +9 -0
  50. package/lib/types/comic-presets.d.ts +12 -0
  51. package/lib/types/engine.d.ts +250 -8
  52. package/lib/types/global-assets.d.ts +6 -0
  53. package/lib/types/index.d.ts +24 -2
  54. package/lib/types/llm-live.d.ts +44 -0
  55. package/lib/types/manga-genre-rules.d.ts +22 -0
  56. package/lib/types/market-radar-scan.d.ts +19 -0
  57. package/lib/types/market-radar-sources.d.ts +31 -0
  58. package/lib/types/novel-context.d.ts +80 -0
  59. package/lib/types/prompt-utils.d.ts +35 -0
  60. package/lib/types/prompting.d.ts +35 -0
  61. package/lib/types/protocol.d.ts +1453 -5
  62. package/lib/types/review-policy.d.ts +97 -0
  63. package/lib/types/run.d.ts +32 -0
  64. package/lib/types/shot-language.d.ts +46 -0
  65. package/lib/types/src/ai-scan.d.ts +36 -0
  66. package/lib/types/src/assets.d.ts +37 -0
  67. package/lib/types/src/assistant.d.ts +45 -0
  68. package/lib/types/src/author-assets.d.ts +25 -0
  69. package/lib/types/src/bookshelf.d.ts +42 -0
  70. package/lib/types/src/client/api.d.ts +234 -0
  71. package/lib/types/src/client/docx.d.ts +15 -0
  72. package/lib/types/src/client/index.d.ts +14 -0
  73. package/lib/types/src/client/locales.d.ts +212 -0
  74. package/lib/types/src/client/mount.d.ts +9 -0
  75. package/lib/types/src/client/panel/AdaptModeView.d.ts +5 -0
  76. package/lib/types/src/client/panel/AssetsTab.d.ts +12 -0
  77. package/lib/types/src/client/panel/AssistantTab.d.ts +7 -0
  78. package/lib/types/src/client/panel/AuthorAssetsView.d.ts +4 -0
  79. package/lib/types/src/client/panel/AuthorHome.d.ts +32 -0
  80. package/lib/types/src/client/panel/BookshelfBar.d.ts +11 -0
  81. package/lib/types/src/client/panel/CreateBookView.d.ts +9 -0
  82. package/lib/types/src/client/panel/FlowGuide.d.ts +22 -0
  83. package/lib/types/src/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  84. package/lib/types/src/client/panel/ImportModal.d.ts +7 -0
  85. package/lib/types/src/client/panel/MangaRoleLibrary.d.ts +20 -0
  86. package/lib/types/src/client/panel/MangaWorkspace.d.ts +12 -0
  87. package/lib/types/src/client/panel/ModelManager.d.ts +14 -0
  88. package/lib/types/src/client/panel/NovelPanel.d.ts +13 -0
  89. package/lib/types/src/client/panel/ProgressConsole.d.ts +17 -0
  90. package/lib/types/src/client/panel/ProgressHomeView.d.ts +5 -0
  91. package/lib/types/src/client/panel/PropLibrary.d.ts +8 -0
  92. package/lib/types/src/client/panel/ReaderView.d.ts +8 -0
  93. package/lib/types/src/client/panel/ReasoningSection.d.ts +10 -0
  94. package/lib/types/src/client/panel/RunPanel.d.ts +5 -0
  95. package/lib/types/src/client/panel/SceneLibrary.d.ts +16 -0
  96. package/lib/types/src/client/panel/SettingsView.d.ts +16 -0
  97. package/lib/types/src/client/panel/ShelfView.d.ts +15 -0
  98. package/lib/types/src/client/panel/StoryboardTab.d.ts +19 -0
  99. package/lib/types/src/client/panel/StyleCard.d.ts +8 -0
  100. package/lib/types/src/client/panel/StyleGallery.d.ts +1 -0
  101. package/lib/types/src/client/panel/WorldTab.d.ts +10 -0
  102. package/lib/types/src/client/panel/controller.d.ts +19 -0
  103. package/lib/types/src/client/panel/helpers.d.ts +18 -0
  104. package/lib/types/src/client/panel/views.d.ts +77 -0
  105. package/lib/types/src/client/sidebar-entry.d.ts +13 -0
  106. package/lib/types/src/client/text.d.ts +9 -0
  107. package/lib/types/src/comic-presets.d.ts +12 -0
  108. package/lib/types/src/docx.d.ts +19 -0
  109. package/lib/types/src/engine.d.ts +402 -0
  110. package/lib/types/src/index.d.ts +79 -0
  111. package/lib/types/src/manga-genre-rules.d.ts +22 -0
  112. package/lib/types/src/novel-context.d.ts +80 -0
  113. package/lib/types/src/prompt-utils.d.ts +35 -0
  114. package/lib/types/src/protocol.d.ts +2176 -0
  115. package/lib/types/src/review-policy.d.ts +97 -0
  116. package/lib/types/src/routes.d.ts +29 -0
  117. package/lib/types/src/run.d.ts +32 -0
  118. package/lib/types/src/shot-language.d.ts +46 -0
  119. package/lib/types/src/story-beat-language.d.ts +35 -0
  120. package/lib/types/src/style-library.d.ts +33 -0
  121. package/lib/types/story-beat-language.d.ts +35 -0
  122. package/lib/types/style-library.d.ts +33 -0
  123. package/package.json +57 -42
  124. package/scripts/patch-schemastery.mjs +47 -0
  125. package/src/ai-scan.ts +179 -0
  126. package/src/assets.ts +1774 -518
  127. package/src/assistant.ts +915 -547
  128. package/src/author-assets.ts +147 -0
  129. package/src/bookshelf.ts +32 -2
  130. package/src/client/api.ts +666 -254
  131. package/src/client/index.ts +3 -1
  132. package/src/client/llmLive.ts +101 -0
  133. package/src/client/locales.ts +178 -31
  134. package/src/client/mount.tsx +1 -1
  135. package/src/client/panel/AdaptModeView.tsx +535 -0
  136. package/src/client/panel/AssetsTab.tsx +168 -51
  137. package/src/client/panel/AssistantTab.tsx +339 -188
  138. package/src/client/panel/AuthorAssetsView.tsx +208 -0
  139. package/src/client/panel/AuthorHome.tsx +234 -0
  140. package/src/client/panel/BookAnalysisView.tsx +60 -0
  141. package/src/client/panel/BookshelfBar.tsx +63 -9
  142. package/src/client/panel/CreateBookView.tsx +371 -0
  143. package/src/client/panel/DirectorView.tsx +200 -0
  144. package/src/client/panel/GlobalAssetLibraryView.tsx +172 -0
  145. package/src/client/panel/IdeaInspirationView.tsx +71 -0
  146. package/src/client/panel/ImportModal.tsx +303 -0
  147. package/src/client/panel/KnowledgeBaseView.tsx +73 -0
  148. package/src/client/panel/LiveFeedLog.tsx +67 -0
  149. package/src/client/panel/MarketRadarView.tsx +374 -0
  150. package/src/client/panel/ModelManager.tsx +205 -0
  151. package/src/client/panel/NovelPanel.tsx +4713 -990
  152. package/src/client/panel/ProgressConsole.tsx +63 -0
  153. package/src/client/panel/ProgressHomeView.tsx +87 -0
  154. package/src/client/panel/ReaderView.tsx +306 -0
  155. package/src/client/panel/ReasoningSection.tsx +31 -0
  156. package/src/client/panel/RunPanel.tsx +204 -0
  157. package/src/client/panel/SettingsView.tsx +265 -0
  158. package/src/client/panel/ShelfView.tsx +242 -0
  159. package/src/client/panel/WorldTab.tsx +202 -0
  160. package/src/client/panel/helpers.ts +50 -0
  161. package/src/client/panel/panel.module.css +4325 -526
  162. package/src/client/panel/views.tsx +346 -0
  163. package/src/client/run-status.ts +100 -0
  164. package/src/client/sidebar-entry.ts +1 -1
  165. package/src/client/text.ts +28 -0
  166. package/src/engine.ts +4230 -1019
  167. package/src/global-assets.ts +59 -0
  168. package/src/index.ts +123 -9
  169. package/src/llm-live.ts +68 -0
  170. package/src/market-radar-scan.ts +41 -0
  171. package/src/market-radar-sources.ts +173 -0
  172. package/src/novel-context.ts +287 -0
  173. package/src/prompt-utils.ts +73 -0
  174. package/src/prompting.ts +159 -0
  175. package/src/protocol.ts +1998 -539
  176. package/src/review-policy.ts +161 -0
  177. package/src/routes.ts +3277 -955
  178. package/src/run.ts +338 -0
  179. package/lib/client.js.map +0 -1
  180. package/lib/index.js.map +0 -1
package/src/routes.ts CHANGED
@@ -1,955 +1,3277 @@
1
- /**
2
- * The /api/dsh-novel-forge route family: status, docx outline loading, LLM
3
- * story-bible extraction, volume planning, chapter planning, streaming
4
- * generation / rewrite / polish (NDJSON frames), review, summaries,
5
- * foreshadows, export, chapter reading, config patching, and opening the
6
- * output folder. Every route carries the same loopback-only trust fence as
7
- * the family plugins — these endpoints invoke the LLM and write files on the
8
- * host machine.
9
- */
10
-
11
- import type { IncomingMessage, ServerResponse } from 'node:http'
12
- import { exec } from 'node:child_process'
13
- import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
14
- import type { Context } from '@deepseek-ai/cordis'
15
- import {
16
- NOVEL_API,
17
- type AssetsPatch,
18
- type AssetsResponse,
19
- type AssistantFrame,
20
- type AssistantHistoryResponse,
21
- type AssistantRequest,
22
- type BibleRequest,
23
- type BibleResponse,
24
- type BookActivateRequest,
25
- type BookCreateRequest,
26
- type BookRemoveRequest,
27
- type BookshelfSnapshot,
28
- type ChapterResponse,
29
- type ChapterPlan,
30
- type ConfigPatch,
31
- type ExportRequest,
32
- type ExportResponse,
33
- type ForeshadowRequest,
34
- type ForeshadowResponse,
35
- type JobFrame,
36
- type LoadOutlineRequest,
37
- type LoadOutlineResponse,
38
- type NovelConfig,
39
- type PlanRequest,
40
- type PlanResponse,
41
- type PolishRequest,
42
- type ReviewRequest,
43
- type RewriteRequest,
44
- type StatusResponse,
45
- type StyleEngineRequest,
46
- type SummaryRequest,
47
- type VolumesRequest,
48
- type VolumesResponse,
49
- } from './protocol.ts'
50
- import { readOutlineFromDocx } from './docx.ts'
51
- import { loadAssistantHistory, runAssistantTurn } from './assistant.ts'
52
- import { activateBook, bookshelfSnapshot, createBook, defaultOutputDirFor, loadBookshelf, removeBook, seedBookshelfFromOutputDir } from './bookshelf.ts'
53
- import { BUILTIN_ANTI_AI_RULES, BUILTIN_GENRE_LIBRARY, BUILTIN_PROGRESSION_MODES, BUILTIN_STYLE_TEMPLATES, emptyProjectAssets } from './assets.ts'
54
- import {
55
- chapterFileName,
56
- createProject,
57
- exportBook,
58
- extractBible,
59
- extractStyleAsset,
60
- generateChapterStream,
61
- listChapterFiles,
62
- loadProject,
63
- planChapters,
64
- planVolumes,
65
- polishChapterStream,
66
- readChapterFile,
67
- reviewChapter,
68
- rewriteChapterStream,
69
- saveProject,
70
- suggestForeshadows,
71
- summarizeChapter,
72
- syncProjectWithDisk,
73
- } from './engine.ts'
74
-
75
- /** Cap on JSON request bodies. */
76
- const MAX_JSON_BODY_BYTES = 4 * 1024 * 1024
77
-
78
- /** Loopback-only fence (mirrors the family plugins' pairing routes). */
79
- function isLoopbackRequest(request: IncomingMessage): boolean {
80
- const address = request.socket.remoteAddress
81
- if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
82
- const host = request.headers.host
83
- if (typeof host !== 'string') return false
84
- let hostUrl: URL
85
- try {
86
- hostUrl = new URL(`http://${host}`)
87
- } catch {
88
- return false
89
- }
90
- if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
91
- if (request.headers['sec-fetch-site'] === 'cross-site') return false
92
- const origin = request.headers.origin
93
- if (origin === undefined) return true
94
- try {
95
- return new URL(origin).host === hostUrl.host
96
- } catch {
97
- return false
98
- }
99
- }
100
-
101
- /** One JSON response. */
102
- function writeJson(res: ServerResponse, status: number, body: unknown): void {
103
- const payload = JSON.stringify(body)
104
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
105
- res.end(payload)
106
- }
107
-
108
- /** Read a JSON request body. */
109
- async function readJsonBody<T>(req: IncomingMessage): Promise<T | undefined> {
110
- const chunks: Buffer[] = []
111
- let size = 0
112
- for await (const chunk of req) {
113
- const buffer = chunk as Buffer
114
- size += buffer.length
115
- if (size > MAX_JSON_BODY_BYTES) return undefined
116
- chunks.push(buffer)
117
- }
118
- try {
119
- return JSON.parse(Buffer.concat(chunks).toString('utf8')) as T
120
- } catch {
121
- return undefined
122
- }
123
- }
124
-
125
- /** Route deps. */
126
- export interface NovelRoutesDeps {
127
- ctx: Context
128
- /** Resolve the live plugin config (settings-aware). */
129
- getConfig: () => NovelConfig
130
- /** Persist a config patch through the settings seam. */
131
- patchConfig: (patch: ConfigPatch) => Promise<NovelConfig>
132
- }
133
-
134
- /** Default chapter count for planning when the request omits it. */
135
- const DEFAULT_PLAN_COUNT = 30
136
-
137
- /**
138
- * Build every /api/dsh-novel-forge route.
139
- * @param deps - context, config resolver, config patcher.
140
- * @returns the route list.
141
- */
142
- export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
143
- const { ctx, getConfig, patchConfig } = deps
144
-
145
- /** Guard helper: fence + method check. */
146
- const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
147
- if (!isLoopbackRequest(req)) {
148
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
149
- return false
150
- }
151
- if (req.method !== method) {
152
- writeJson(res, 405, { error: `method not allowed (expected ${method})` })
153
- return false
154
- }
155
- return true
156
- }
157
-
158
- /** Load (and sync) the project, or respond 400. */
159
- const requireProject = (res: ServerResponse): ReturnType<typeof loadProject> => {
160
- const config = getConfig()
161
- const project = loadProject(config.outputDir)
162
- if (project === undefined) {
163
- writeJson(res, 400, { error: '输出目录中没有项目,请先加载大纲' })
164
- return undefined
165
- }
166
- syncProjectWithDisk(project, config.outputDir)
167
- saveProject(config.outputDir, project)
168
- return project
169
- }
170
-
171
- // -------------------------------------------------------------- status
172
- const statusRoute: WebRoute = {
173
- kind: 'exact',
174
- path: NOVEL_API.status,
175
- handler: (req, res) => {
176
- if (!guard(req, res, 'GET')) return
177
- const config = getConfig()
178
- // 书架为空时播种 settings 默认输出目录里的已有项目。
179
- seedBookshelfFromOutputDir(config.outputDir)
180
- const project = loadProject(config.outputDir)
181
- if (project !== undefined) {
182
- syncProjectWithDisk(project, config.outputDir)
183
- saveProject(config.outputDir, project)
184
- }
185
- const response: StatusResponse = {
186
- config,
187
- project: project ?? undefined,
188
- generatedFiles: listChapterFiles(config.outputDir),
189
- }
190
- writeJson(res, 200, response)
191
- },
192
- }
193
-
194
- // -------------------------------------------------------- load-outline
195
- const loadOutlineRoute: WebRoute = {
196
- kind: 'exact',
197
- path: NOVEL_API.loadOutline,
198
- handler: async (req, res) => {
199
- if (!guard(req, res, 'POST')) return
200
- const body = await readJsonBody<LoadOutlineRequest>(req)
201
- const config = getConfig()
202
- try {
203
- let outline: string
204
- let path: string | undefined
205
- if (body?.text !== undefined && body.text.trim() !== '') {
206
- outline = body.text.trim()
207
- } else {
208
- const target = body?.path?.trim() !== '' && body?.path !== undefined ? body.path : config.outlinePath
209
- outline = readOutlineFromDocx(target)
210
- path = target
211
- }
212
- if (outline.length < 50) {
213
- writeJson(res, 400, { error: '大纲内容过短(<50 字符),请检查文件或直接粘贴大纲文本' })
214
- return
215
- }
216
- const response: LoadOutlineResponse = {
217
- outline,
218
- bookName: createProject(outline).bookName,
219
- chars: outline.length,
220
- path,
221
- }
222
- writeJson(res, 200, response)
223
- } catch (error) {
224
- writeJson(res, 400, { error: (error as Error).message })
225
- }
226
- },
227
- }
228
-
229
- // -------------------------------------------------------- save-outline
230
- const saveOutlineRoute: WebRoute = {
231
- kind: 'exact',
232
- path: NOVEL_API.saveOutline,
233
- handler: async (req, res) => {
234
- if (!guard(req, res, 'POST')) return
235
- const body = await readJsonBody<LoadOutlineRequest>(req)
236
- const config = getConfig()
237
- const outline = body?.text ?? ''
238
- if (outline.trim().length < 50) {
239
- writeJson(res, 400, { error: '大纲内容过短(<50 字符)' })
240
- return
241
- }
242
- let project = loadProject(config.outputDir)
243
- const now = new Date().toISOString()
244
- if (project === undefined) {
245
- project = createProject(outline)
246
- } else {
247
- project.outline = outline
248
- project.bookName = createProject(outline).bookName
249
- project.updatedAt = now
250
- }
251
- saveProject(config.outputDir, project)
252
- writeJson(res, 200, { ok: true, bookName: project.bookName })
253
- },
254
- }
255
-
256
- // --------------------------------------------------------------- bible
257
- const bibleRoute: WebRoute = {
258
- kind: 'exact',
259
- path: NOVEL_API.bible,
260
- handler: async (req, res) => {
261
- if (!guard(req, res, 'POST')) return
262
- const body = await readJsonBody<BibleRequest>(req)
263
- const config = getConfig()
264
- const project = loadProject(config.outputDir)
265
- const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
266
- ? body.outline
267
- : project?.outline
268
- if (outline === undefined || outline.length < 50) {
269
- writeJson(res, 400, { error: '请先加载大纲' })
270
- return
271
- }
272
- try {
273
- const bible = await extractBible(ctx, config, outline)
274
- const now = new Date().toISOString()
275
- const next = project ?? createProject(outline)
276
- next.bible = bible
277
- next.updatedAt = now
278
- saveProject(config.outputDir, next)
279
- const response: BibleResponse = { bible }
280
- writeJson(res, 200, response)
281
- } catch (error) {
282
- writeJson(res, 500, { error: (error as Error).message })
283
- }
284
- },
285
- }
286
-
287
- // ------------------------------------------------------------- volumes
288
- const volumesRoute: WebRoute = {
289
- kind: 'exact',
290
- path: NOVEL_API.volumes,
291
- handler: async (req, res) => {
292
- if (!guard(req, res, 'POST')) return
293
- const body = await readJsonBody<VolumesRequest>(req)
294
- const config = getConfig()
295
- const project = loadProject(config.outputDir)
296
- const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
297
- ? body.outline
298
- : project?.outline
299
- if (outline === undefined || outline.length < 50) {
300
- writeJson(res, 400, { error: '请先加载大纲' })
301
- return
302
- }
303
- try {
304
- const volumes = await planVolumes(ctx, config, outline)
305
- const now = new Date().toISOString()
306
- const next = project ?? createProject(outline)
307
- next.volumes = volumes
308
- next.updatedAt = now
309
- saveProject(config.outputDir, next)
310
- const response: VolumesResponse = { volumes }
311
- writeJson(res, 200, response)
312
- } catch (error) {
313
- writeJson(res, 500, { error: (error as Error).message })
314
- }
315
- },
316
- }
317
-
318
- // ----------------------------------------------------------------- plan
319
- const planRoute: WebRoute = {
320
- kind: 'exact',
321
- path: NOVEL_API.plan,
322
- handler: async (req, res) => {
323
- if (!guard(req, res, 'POST')) return
324
- const body = await readJsonBody<PlanRequest>(req)
325
- const config = getConfig()
326
- const project = loadProject(config.outputDir)
327
- const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
328
- ? body.outline
329
- : project?.outline
330
- if (outline === undefined || outline.length < 50) {
331
- writeJson(res, 400, { error: '请先加载大纲(或粘贴大纲文本)' })
332
- return
333
- }
334
- const count = body?.chapterCount ?? DEFAULT_PLAN_COUNT
335
- if (!Number.isInteger(count) || count < 1 || count > 200) {
336
- writeJson(res, 400, { error: 'chapterCount 须为 1-200 的整数' })
337
- return
338
- }
339
- try {
340
- const next = project ?? createProject(outline)
341
- const chapters: ChapterPlan[] = await planChapters(ctx, config, next, count, body?.volume)
342
- next.chapters.push(...chapters)
343
- next.updatedAt = new Date().toISOString()
344
- saveProject(config.outputDir, next)
345
- const response: PlanResponse = { chapters, volumes: next.volumes }
346
- writeJson(res, 200, response)
347
- } catch (error) {
348
- writeJson(res, 500, { error: (error as Error).message })
349
- }
350
- },
351
- }
352
-
353
- // ------------------------------------------------------------- generate
354
- const generateRoute: WebRoute = {
355
- kind: 'exact',
356
- path: NOVEL_API.generate,
357
- handler: async (req, res) => {
358
- if (!guard(req, res, 'POST')) return
359
- const config = getConfig()
360
- const project = requireProject(res)
361
- if (project === undefined) return
362
- const body = await readJsonBody<{ chapterNo?: number; skipReview?: boolean }>(req)
363
- const rawNo = body?.chapterNo
364
- if (!Number.isInteger(rawNo) || rawNo === undefined || rawNo < 1) {
365
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
366
- return
367
- }
368
- const no: number = rawNo
369
- const chapter = project.chapters.find(c => c.no === no)
370
- if (chapter === undefined) {
371
- writeJson(res, 404, { error: `章节 ${no} 不在计划中` })
372
- return
373
- }
374
- if (chapter.status === 'generating') {
375
- writeJson(res, 409, { error: `章节 ${no} 正在生成中` })
376
- return
377
- }
378
-
379
- // NDJSON stream.
380
- res.writeHead(200, {
381
- 'content-type': 'application/x-ndjson; charset=utf-8',
382
- 'cache-control': 'no-cache',
383
- 'x-accel-buffering': 'no',
384
- 'referrer-policy': 'no-referrer',
385
- })
386
- chapter.status = 'generating'
387
- chapter.error = undefined
388
- saveProject(config.outputDir, project)
389
-
390
- const send = (frame: JobFrame): void => {
391
- res.write(JSON.stringify(frame) + '\n')
392
- }
393
-
394
- try {
395
- send({ type: 'start', no, title: chapter.title })
396
- for await (const step of generateChapterStream(ctx, config, project, config.outputDir, no)) {
397
- if (step.frame === 'delta') {
398
- send({ type: 'delta', text: step.text })
399
- } else if (step.frame === 'done') {
400
- send({ type: 'done', no, file: step.file, chars: step.chars, title: chapter.title })
401
- }
402
- }
403
- // Auto pipeline: summary -> review (unless skipped).
404
- try {
405
- await summarizeChapter(ctx, config, project, config.outputDir, no)
406
- } catch (error) {
407
- console.warn('[dsh-novel-forge] summary failed:', (error as Error).message)
408
- }
409
- if (!(body?.skipReview === true) && (config.autoReview ?? true)) {
410
- const report = await reviewChapter(ctx, config, project, config.outputDir, no)
411
- send({ type: 'review', no, report })
412
- } else {
413
- chapter.status = 'approved'
414
- saveProject(config.outputDir, project)
415
- }
416
- res.end()
417
- } catch (error) {
418
- chapter.status = 'error'
419
- chapter.error = (error as Error).message
420
- saveProject(config.outputDir, project)
421
- if (!res.writableEnded) {
422
- send({ type: 'error', no, message: (error as Error).message })
423
- res.end()
424
- }
425
- }
426
- },
427
- }
428
-
429
- // --------------------------------------------------------------- review
430
- const reviewRoute: WebRoute = {
431
- kind: 'exact',
432
- path: NOVEL_API.review,
433
- handler: async (req, res) => {
434
- if (!guard(req, res, 'POST')) return
435
- const config = getConfig()
436
- const project = requireProject(res)
437
- if (project === undefined) return
438
- const body = await readJsonBody<ReviewRequest>(req)
439
- if (!Number.isInteger(body?.chapterNo)) {
440
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
441
- return
442
- }
443
- const no = body!.chapterNo!
444
- try {
445
- const report = await reviewChapter(ctx, config, project, config.outputDir, no)
446
- writeJson(res, 200, { report })
447
- } catch (error) {
448
- writeJson(res, 500, { error: (error as Error).message })
449
- }
450
- },
451
- }
452
-
453
- // -------------------------------------------------------------- rewrite
454
- const rewriteRoute: WebRoute = {
455
- kind: 'exact',
456
- path: NOVEL_API.rewrite,
457
- handler: async (req, res) => {
458
- if (!guard(req, res, 'POST')) return
459
- const config = getConfig()
460
- const project = requireProject(res)
461
- if (project === undefined) return
462
- const body = await readJsonBody<RewriteRequest>(req)
463
- if (!Number.isInteger(body?.chapterNo)) {
464
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
465
- return
466
- }
467
- const no = body!.chapterNo!
468
- res.writeHead(200, {
469
- 'content-type': 'application/x-ndjson; charset=utf-8',
470
- 'cache-control': 'no-cache',
471
- 'x-accel-buffering': 'no',
472
- 'referrer-policy': 'no-referrer',
473
- })
474
- const send = (frame: JobFrame): void => { res.write(JSON.stringify(frame) + '\n') }
475
- try {
476
- for await (const step of rewriteChapterStream(ctx, config, project, config.outputDir, no, body?.instructions ?? '', body?.target)) {
477
- if (step.frame === 'delta') send({ type: 'delta', text: step.text })
478
- else if (step.frame === 'done') send({ type: 'rewritten', no, file: step.file, chars: step.chars })
479
- }
480
- // Re-review after rewrite.
481
- const report = await reviewChapter(ctx, config, project, config.outputDir, no)
482
- send({ type: 'review', no, report })
483
- res.end()
484
- } catch (error) {
485
- if (!res.writableEnded) {
486
- send({ type: 'error', no, message: (error as Error).message })
487
- res.end()
488
- }
489
- }
490
- },
491
- }
492
-
493
- // --------------------------------------------------------------- polish
494
- const polishRoute: WebRoute = {
495
- kind: 'exact',
496
- path: NOVEL_API.polish,
497
- handler: async (req, res) => {
498
- if (!guard(req, res, 'POST')) return
499
- const config = getConfig()
500
- const project = requireProject(res)
501
- if (project === undefined) return
502
- const body = await readJsonBody<PolishRequest>(req)
503
- if (!Number.isInteger(body?.chapterNo)) {
504
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
505
- return
506
- }
507
- const no = body!.chapterNo!
508
- res.writeHead(200, {
509
- 'content-type': 'application/x-ndjson; charset=utf-8',
510
- 'cache-control': 'no-cache',
511
- 'x-accel-buffering': 'no',
512
- 'referrer-policy': 'no-referrer',
513
- })
514
- const send = (frame: JobFrame): void => { res.write(JSON.stringify(frame) + '\n') }
515
- try {
516
- for await (const step of polishChapterStream(ctx, config, project, config.outputDir, no)) {
517
- if (step.frame === 'delta') send({ type: 'delta', text: step.text })
518
- else if (step.frame === 'done') send({ type: 'rewritten', no, file: step.file, chars: step.chars })
519
- }
520
- res.end()
521
- } catch (error) {
522
- if (!res.writableEnded) {
523
- send({ type: 'error', no, message: (error as Error).message })
524
- res.end()
525
- }
526
- }
527
- },
528
- }
529
-
530
- // -------------------------------------------------------------- summary
531
- const summaryRoute: WebRoute = {
532
- kind: 'exact',
533
- path: NOVEL_API.summary,
534
- handler: async (req, res) => {
535
- if (!guard(req, res, 'POST')) return
536
- const config = getConfig()
537
- const project = requireProject(res)
538
- if (project === undefined) return
539
- const body = await readJsonBody<SummaryRequest>(req)
540
- if (!Number.isInteger(body?.chapterNo)) {
541
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
542
- return
543
- }
544
- try {
545
- const summary = await summarizeChapter(ctx, config, project, config.outputDir, body!.chapterNo!)
546
- writeJson(res, 200, { summary })
547
- } catch (error) {
548
- writeJson(res, 500, { error: (error as Error).message })
549
- }
550
- },
551
- }
552
-
553
- // ---------------------------------------------------------- foreshadow
554
- const foreshadowRoute: WebRoute = {
555
- kind: 'exact',
556
- path: NOVEL_API.foreshadow,
557
- handler: async (req, res) => {
558
- if (!guard(req, res, 'POST')) return
559
- const config = getConfig()
560
- const project = requireProject(res)
561
- if (project === undefined) return
562
- const body = await readJsonBody<ForeshadowRequest>(req)
563
- try {
564
- if (body?.suggest === true) {
565
- // AI suggestion pass: create several foreshadows from the outline.
566
- const created = await suggestForeshadows(ctx, config, project)
567
- project.updatedAt = new Date().toISOString()
568
- saveProject(config.outputDir, project)
569
- const response: ForeshadowResponse = { foreshadows: created }
570
- writeJson(res, 200, response)
571
- return
572
- }
573
- if (body?.id !== undefined) {
574
- // Update an existing foreshadow.
575
- const target = project.foreshadows.find(f => f.id === body.id)
576
- if (target === undefined) {
577
- writeJson(res, 404, { error: `伏笔 ${body.id} 不存在` })
578
- return
579
- }
580
- if (body.description !== undefined) target.description = body.description
581
- if (body.plantedChapter !== undefined) target.plantedChapter = body.plantedChapter
582
- if (body.targetChapter !== undefined) target.targetChapter = body.targetChapter
583
- if (body.status !== undefined) target.status = body.status
584
- if (body.resolvedNote !== undefined) target.resolvedNote = body.resolvedNote
585
- } else {
586
- // Create one manually.
587
- const description = body?.description?.trim()
588
- if (description === undefined || description === '') {
589
- writeJson(res, 400, { error: 'description 必填' })
590
- return
591
- }
592
- project.foreshadows.push({
593
- id: `fs-${Date.now().toString(36)}`,
594
- description,
595
- plantedChapter: body?.plantedChapter,
596
- targetChapter: body?.targetChapter,
597
- status: body?.status ?? 'planned',
598
- })
599
- }
600
- project.updatedAt = new Date().toISOString()
601
- saveProject(config.outputDir, project)
602
- const response: ForeshadowResponse = { foreshadows: project.foreshadows }
603
- writeJson(res, 200, response)
604
- } catch (error) {
605
- writeJson(res, 500, { error: (error as Error).message })
606
- }
607
- },
608
- }
609
-
610
- // -------------------------------------------------------------- export
611
- const exportRoute: WebRoute = {
612
- kind: 'exact',
613
- path: NOVEL_API.exportBook,
614
- handler: async (req, res) => {
615
- if (!guard(req, res, 'POST')) return
616
- const config = getConfig()
617
- const project = requireProject(res)
618
- if (project === undefined) return
619
- const body = await readJsonBody<ExportRequest>(req)
620
- const format = body?.format === 'md' ? 'md' : 'txt'
621
- try {
622
- const result = exportBook(config.outputDir, project, format)
623
- const response: ExportResponse = { ...result }
624
- writeJson(res, 200, response)
625
- } catch (error) {
626
- writeJson(res, 500, { error: (error as Error).message })
627
- }
628
- },
629
- }
630
-
631
- // -------------------------------------------------------------- chapter
632
- const chapterRoute: WebRoute = {
633
- kind: 'exact',
634
- path: NOVEL_API.chapter,
635
- handler: async (req, res) => {
636
- if (!guard(req, res, 'GET')) return
637
- const config = getConfig()
638
- const project = requireProject(res)
639
- if (project === undefined) return
640
- const url = new URL(req.url ?? '/', 'http://localhost')
641
- const rawNo = Number(url.searchParams.get('no') ?? '0')
642
- if (!Number.isInteger(rawNo) || rawNo < 1) {
643
- writeJson(res, 400, { error: 'no 须为正整数' })
644
- return
645
- }
646
- const chapter = project.chapters.find(c => c.no === rawNo)
647
- if (chapter === undefined) {
648
- writeJson(res, 404, { error: `章节 ${rawNo} 不在计划中` })
649
- return
650
- }
651
- const markdown = readChapterFile(config.outputDir, chapter)
652
- if (markdown === undefined) {
653
- writeJson(res, 404, { error: `章节 ${rawNo} 尚未生成` })
654
- return
655
- }
656
- const response: ChapterResponse = { no: chapter.no, title: chapter.title, markdown }
657
- writeJson(res, 200, response)
658
- },
659
- }
660
-
661
- // ----------------------------------------------------------- assistant
662
- const assistantRoute: WebRoute = {
663
- kind: 'exact',
664
- path: NOVEL_API.assistant,
665
- handler: async (req, res) => {
666
- if (!guard(req, res, 'POST')) return
667
- const config = getConfig()
668
- const project = requireProject(res)
669
- if (project === undefined) return
670
- const body = await readJsonBody<AssistantRequest>(req)
671
- const message = body?.message?.trim()
672
- if (message === undefined || message === '') {
673
- writeJson(res, 400, { error: '消息不能为空' })
674
- return
675
- }
676
- res.writeHead(200, {
677
- 'content-type': 'application/x-ndjson; charset=utf-8',
678
- 'cache-control': 'no-cache',
679
- 'x-accel-buffering': 'no',
680
- 'referrer-policy': 'no-referrer',
681
- })
682
- const send = (frame: AssistantFrame): void => { res.write(JSON.stringify(frame) + '\n') }
683
- try {
684
- for await (const step of runAssistantTurn(ctx, config, project, config.outputDir, message)) {
685
- if (step.frame === 'delta') send({ type: 'delta', text: step.text })
686
- else if (step.frame === 'tool') send({ type: 'tool', name: step.name, status: step.status, detail: step.detail })
687
- else if (step.frame === 'toolDelta') send({ type: 'toolDelta', name: step.name, text: step.text })
688
- }
689
- send({ type: 'done' })
690
- res.end()
691
- } catch (error) {
692
- if (!res.writableEnded) {
693
- send({ type: 'error', message: (error as Error).message })
694
- res.end()
695
- }
696
- }
697
- },
698
- }
699
-
700
- // -------------------------------------------------- assistant-history
701
- const assistantHistoryRoute: WebRoute = {
702
- kind: 'exact',
703
- path: NOVEL_API.assistantHistory,
704
- handler: (req, res) => {
705
- if (!guard(req, res, 'GET')) return
706
- const config = getConfig()
707
- const response: AssistantHistoryResponse = { messages: loadAssistantHistory(config.outputDir) }
708
- writeJson(res, 200, response)
709
- },
710
- }
711
-
712
- // --------------------------------------------------------------- assets
713
- const assetsRoute: WebRoute = {
714
- kind: 'exact',
715
- path: NOVEL_API.assets,
716
- handler: async (req, res) => {
717
- // GET (read) and POST (patch) are both allowed — check methods first,
718
- // then the loopback fence (guard() would 405 on POST, which is wrong).
719
- if (req.method !== 'GET' && req.method !== 'POST') {
720
- writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
721
- return
722
- }
723
- if (!isLoopbackRequest(req)) {
724
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
725
- return
726
- }
727
- const config = getConfig()
728
- const project = loadProject(config.outputDir)
729
- const projectAssets = project?.assets ?? emptyProjectAssets()
730
- if (req.method === 'POST') {
731
- const body = await readJsonBody<AssetsPatch>(req)
732
- if (body === undefined) {
733
- writeJson(res, 400, { error: '无效的 JSON' })
734
- return
735
- }
736
- if (project === undefined) {
737
- writeJson(res, 400, { error: '请先加载大纲创建项目' })
738
- return
739
- }
740
- if (body.genre !== undefined) projectAssets.genre = body.genre
741
- if (body.primaryProgression !== undefined) projectAssets.primaryProgression = body.primaryProgression
742
- if (body.auxiliaryProgressions !== undefined) projectAssets.auxiliaryProgressions = body.auxiliaryProgressions
743
- if (body.antiAiRules !== undefined) projectAssets.antiAiRules = body.antiAiRules
744
- if (body.styleAssets !== undefined) projectAssets.styleAssets = body.styleAssets
745
- projectAssets.updatedAt = new Date().toISOString()
746
- project.assets = projectAssets
747
- project.updatedAt = new Date().toISOString()
748
- saveProject(config.outputDir, project)
749
- }
750
- const response: AssetsResponse = {
751
- projectAssets,
752
- genreLibrary: BUILTIN_GENRE_LIBRARY,
753
- antiAiLibrary: BUILTIN_ANTI_AI_RULES,
754
- styleTemplates: BUILTIN_STYLE_TEMPLATES,
755
- progressionLibrary: BUILTIN_PROGRESSION_MODES,
756
- }
757
- writeJson(res, 200, response)
758
- },
759
- }
760
-
761
- // ----------------------------------------------------------- style-engine
762
- const styleEngineRoute: WebRoute = {
763
- kind: 'exact',
764
- path: NOVEL_API.styleEngine,
765
- handler: async (req, res) => {
766
- if (!guard(req, res, 'POST')) return
767
- const config = getConfig()
768
- const project = loadProject(config.outputDir)
769
- const body = await readJsonBody<StyleEngineRequest>(req)
770
- const sample = body?.sampleText?.trim()
771
- if (sample === undefined || sample.length < 50) {
772
- writeJson(res, 400, { error: '样本文本过短(<50 字符),请粘贴一段能代表目标风格的文字' })
773
- return
774
- }
775
- try {
776
- const rules = await extractStyleAsset(ctx, config, sample)
777
- const name = body?.name?.trim() !== '' && body?.name !== undefined ? body.name : `风格资产 ${Date.now().toString(36)}`
778
- const styleAsset = {
779
- name: name.slice(0, 40),
780
- ...rules,
781
- sourceText: sample.slice(0, 3000),
782
- createdAt: new Date().toISOString(),
783
- }
784
- if (project !== undefined) {
785
- project.assets ??= emptyProjectAssets()
786
- project.assets.styleAssets ??= []
787
- project.assets.styleAssets.push(styleAsset)
788
- project.assets.updatedAt = new Date().toISOString()
789
- project.updatedAt = new Date().toISOString()
790
- saveProject(config.outputDir, project)
791
- }
792
- writeJson(res, 200, { styleAsset })
793
- } catch (error) {
794
- writeJson(res, 500, { error: (error as Error).message })
795
- }
796
- },
797
- }
798
-
799
- // ------------------------------------------------------------- bookshelf
800
- const bookshelfRoute: WebRoute = {
801
- kind: 'exact',
802
- path: NOVEL_API.bookshelf,
803
- handler: async (req, res) => {
804
- // GET = snapshot; POST = create book.
805
- if (req.method === 'GET') {
806
- if (!isLoopbackRequest(req)) {
807
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
808
- return
809
- }
810
- // 书架为空时,把 settings 默认输出目录里已有的项目播种为第一本书。
811
- seedBookshelfFromOutputDir(getConfig().outputDir)
812
- const snapshot: BookshelfSnapshot = bookshelfSnapshot(loadBookshelf())
813
- writeJson(res, 200, snapshot)
814
- return
815
- }
816
- if (req.method === 'POST') {
817
- if (!isLoopbackRequest(req)) {
818
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
819
- return
820
- }
821
- const body = await readJsonBody<BookCreateRequest>(req)
822
- const bookName = body?.bookName?.trim()
823
- if (bookName === undefined || bookName === '') {
824
- writeJson(res, 400, { error: 'bookName 不能为空' })
825
- return
826
- }
827
- const outputDir = body?.outputDir?.trim() !== '' && body?.outputDir !== undefined
828
- ? body.outputDir
829
- : defaultOutputDirFor(bookName)
830
- const book = createBook(bookName, outputDir)
831
- writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
832
- void book
833
- return
834
- }
835
- writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
836
- },
837
- }
838
-
839
- // --------------------------------------------------- bookshelf activate
840
- const bookshelfActivateRoute: WebRoute = {
841
- kind: 'exact',
842
- path: '/api/dsh-novel-forge/bookshelf/activate',
843
- handler: async (req, res) => {
844
- if (!guard(req, res, 'POST')) return
845
- const body = await readJsonBody<BookActivateRequest>(req)
846
- if (body?.id === undefined || body.id === '') {
847
- writeJson(res, 400, { error: 'id 不能为空' })
848
- return
849
- }
850
- const book = activateBook(body.id)
851
- if (book === undefined) {
852
- writeJson(res, 404, { error: `书 ${body.id} 不存在` })
853
- return
854
- }
855
- writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
856
- },
857
- }
858
-
859
- // ---------------------------------------------------- bookshelf remove
860
- const bookshelfRemoveRoute: WebRoute = {
861
- kind: 'exact',
862
- path: '/api/dsh-novel-forge/bookshelf/remove',
863
- handler: async (req, res) => {
864
- if (!guard(req, res, 'POST')) return
865
- const body = await readJsonBody<BookRemoveRequest>(req)
866
- if (body?.id === undefined || body.id === '') {
867
- writeJson(res, 400, { error: 'id 不能为空' })
868
- return
869
- }
870
- const removed = removeBook(body.id)
871
- if (!removed) {
872
- writeJson(res, 404, { error: `书 ${body.id} 不存在` })
873
- return
874
- }
875
- writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
876
- },
877
- }
878
-
879
- // --------------------------------------------------------------- config
880
- const configRoute: WebRoute = {
881
- kind: 'exact',
882
- path: NOVEL_API.config,
883
- handler: async (req, res) => {
884
- if (!guard(req, res, 'POST')) return
885
- const body = await readJsonBody<ConfigPatch>(req)
886
- if (body === undefined) {
887
- writeJson(res, 400, { error: '无效的配置 JSON' })
888
- return
889
- }
890
- try {
891
- const next = await patchConfig(body)
892
- writeJson(res, 200, { config: next })
893
- } catch (error) {
894
- writeJson(res, 400, { error: (error as Error).message })
895
- }
896
- },
897
- }
898
-
899
- // ---------------------------------------------------------- open-folder
900
- const openFolderRoute: WebRoute = {
901
- kind: 'exact',
902
- path: NOVEL_API.openFolder,
903
- handler: async (req, res) => {
904
- if (!guard(req, res, 'POST')) return
905
- const config = getConfig()
906
- const dir = config.outputDir
907
- exec(`explorer "${dir.replace(/"/g, '')}"`, (error) => {
908
- if (error) {
909
- writeJson(res, 500, { ok: false, error: error.message })
910
- } else {
911
- writeJson(res, 200, { ok: true })
912
- }
913
- })
914
- },
915
- }
916
-
917
- return [
918
- statusRoute,
919
- loadOutlineRoute,
920
- saveOutlineRoute,
921
- bibleRoute,
922
- volumesRoute,
923
- planRoute,
924
- generateRoute,
925
- reviewRoute,
926
- rewriteRoute,
927
- polishRoute,
928
- summaryRoute,
929
- foreshadowRoute,
930
- exportRoute,
931
- chapterRoute,
932
- assetsRoute,
933
- styleEngineRoute,
934
- assistantRoute,
935
- assistantHistoryRoute,
936
- bookshelfRoute,
937
- bookshelfActivateRoute,
938
- bookshelfRemoveRoute,
939
- configRoute,
940
- openFolderRoute,
941
- ]
942
- }
943
-
944
- // Re-export for tests / type consumers.
945
- export type {
946
- ConfigPatch,
947
- NovelConfig,
948
- StatusResponse,
949
- PlanResponse,
950
- LoadOutlineResponse,
951
- BibleResponse,
952
- VolumesResponse,
953
- ExportResponse,
954
- }
955
- export { chapterFileName }
1
+ /**
2
+ * The /api/dsh-novel-forge route family: status, docx outline loading, LLM
3
+ * story-bible extraction, volume planning, chapter planning, streaming
4
+ * generation / rewrite / polish (NDJSON frames), review, summaries,
5
+ * foreshadows, export, chapter reading, config patching, and opening the
6
+ * output folder. Every route carries the same loopback-only trust fence as
7
+ * the family plugins — these endpoints invoke the LLM and write files on the
8
+ * host machine.
9
+ */
10
+
11
+ import type { IncomingMessage, ServerResponse } from 'node:http'
12
+ import { spawn } from 'node:child_process'
13
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
14
+ import { join, basename, extname } from 'node:path'
15
+ import { homedir } from 'node:os'
16
+ import { randomBytes } from 'node:crypto'
17
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
18
+ import type { Context } from '@deepseek-ai/cordis'
19
+ import { ProductionRunner } from './run.ts'
20
+ import {
21
+ NOVEL_API,
22
+ type AssetsPatch,
23
+ type AssetsResponse,
24
+ type AddModelRequest,
25
+ type AddModelResponse,
26
+ type LlmModelsResponse,
27
+ type LlmProvidersResponse,
28
+ type LlmVendorsResponse,
29
+ type RemoveProviderRequest,
30
+ type RemoveProviderResponse,
31
+ type AssistantFrame,
32
+ type AssistantHistoryResponse,
33
+ type AssistantRequest,
34
+ type AuditResponse,
35
+ type AuditStatus,
36
+ type AuthorReview,
37
+ type BiblePatchRequest,
38
+ type BibleRequest,
39
+ type BlurbRequest,
40
+ type CoverRequest,
41
+ type CoverResponse,
42
+ type RenameRequest,
43
+ type WorldRequest,
44
+ type BibleResponse,
45
+ type BookActivateRequest,
46
+ type BookCreateRequest,
47
+ type BookImportDirRequest,
48
+ type BookImportDirResponse,
49
+ type BookImportTextRequest,
50
+ type BookImportTextResponse,
51
+ type BookImportTextPreviewRequest,
52
+ type BookImportTextPreviewResponse,
53
+ type BookRemoveRequest,
54
+ type BookshelfSnapshot,
55
+ type ChapterResponse,
56
+ type ChapterPlan,
57
+ type ChapterSaveResponse,
58
+ type ChapterTextRequest,
59
+ type ConfigPatch,
60
+ type DraftDecisionRequest,
61
+ type ExportRequest,
62
+ type ExportResponse,
63
+ type ForeshadowRequest,
64
+ type ForeshadowResponse,
65
+ type JobFrame,
66
+ type LoadOutlineRequest,
67
+ type LoadOutlineResponse,
68
+ type NovelConfig,
69
+ type PlotlinesRequest,
70
+ type PlotlinesResponse,
71
+ type PlanRequest,
72
+ type PlanResponse,
73
+ type PolishRequest,
74
+ type ResetRequest,
75
+ type ReviewReport,
76
+ type ReviewRequest,
77
+ type RunControlRequest,
78
+ type RunStartRequest,
79
+ type RunState,
80
+ type RewriteRequest,
81
+ type RolesRequest,
82
+ type RolesResponse,
83
+ type OutlineSuggestRequest,
84
+ type OutlineSuggestResponse,
85
+ type BreakdownRequest,
86
+ type BreakdownResponse,
87
+ type SensitiveCheckRequest,
88
+ type SensitiveCheckResponse,
89
+ type SensitiveHit,
90
+ type ProjectState,
91
+ type StatusResponse,
92
+ type StoryBible,
93
+ type LlmTestRequest,
94
+ type LlmTestResponse,
95
+ type StyleEngineRequest,
96
+ type SummaryRequest,
97
+ type VolumesRequest,
98
+ type VolumesResponse,
99
+ type AuthorAssetsResponse,
100
+ type AuthorAssetUpsertRequest,
101
+ type AuthorAssetRemoveRequest,
102
+ type AdaptAnalyzeRequest,
103
+ type AdaptAnalyzeResponse,
104
+ type AdaptProposeRequest,
105
+ type AdaptProposeResponse,
106
+ type AdaptExecuteRequest,
107
+ type AdaptExecuteResponse,
108
+ type AdaptSaveRequest,
109
+ type AdaptSaveResponse,
110
+ type AdaptMaterializeRequest,
111
+ type AdaptMaterializeResponse,
112
+ type AdaptMaterializeSaveRequest,
113
+ type AdaptMaterializeSaveResponse,
114
+ type AdaptRewriteFrame,
115
+ type PluginUpdateResponse,
116
+ type MarketRadarRequest,
117
+ type MarketRadarBriefRequest,
118
+ type ProductionFoundation,
119
+ type ProgressionMode,
120
+ type GenreNode,
121
+ type KnowledgeDoc,
122
+ type KnowledgeRequest,
123
+ type BookAnalysisRequest,
124
+ type IdeaInspirationRequest,
125
+ type DirectorRequest,
126
+ type DirectorTodo,
127
+ } from './protocol.ts'
128
+ import { readOutlineFromDocx } from './docx.ts'
129
+ import { clearAssistantHistory, loadAssistantHistory, runAssistantTurn } from './assistant.ts'
130
+ import { activateBook, bookshelfSnapshot, createBook, defaultOutputDirFor, importDir, loadBookshelf, removeBook, renameBook, seedBookshelfFromOutputDir } from './bookshelf.ts'
131
+ import { loadAuthorAssets, upsertAuthorAsset, removeAuthorAsset, importDefaultAuthorAssets } from './author-assets.ts'
132
+ 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'
133
+ import { scanAiFlavor } from './ai-scan.ts'
134
+ import { emitLive, nextSessionId, subscribeLiveFeed } from './llm-live.ts'
135
+ import { scanMarketRanking } from './market-radar-scan.ts'
136
+ import { addGlobalGenre, addGlobalMode, globalGenreLibrary, globalProgressionLibrary } from './global-assets.ts'
137
+ import {
138
+ chapterFileName,
139
+ auditBook,
140
+ authorReviewChapter,
141
+ autoLinkPlotlines,
142
+ backfillFacts,
143
+ createProject,
144
+ exportBook,
145
+ importBookText,
146
+ importBookTextFromText,
147
+ previewBookText,
148
+ extractBible,
149
+ extractStyleAsset,
150
+ extractStyleFormula,
151
+ runMarketRadar,
152
+ runMarketCreativeBrief,
153
+ runBookAnalysis,
154
+ runIdeaInspiration,
155
+ runMarketIdeaInspiration,
156
+ runDirectorAdvice,
157
+ extractWorld,
158
+ generateBlurb,
159
+ generateChapterStream,
160
+ listChapterFiles,
161
+ loadProject,
162
+ markForeshadowPlanted,
163
+ mergeVolatileFromDisk,
164
+ planChapters,
165
+ planVolumes,
166
+ polishChapterStream,
167
+ readChapterFile,
168
+ refreshCharacters,
169
+ refreshPlotlineProgress,
170
+ reviewChapter,
171
+ reviewChapterText,
172
+ rewriteChapterStream,
173
+ saveProject,
174
+ analyzePlotlineHealth,
175
+ designPlotlinePlan,
176
+ extractRoles,
177
+ suggestOutlines,
178
+ reverseOutlineFromChapters,
179
+ breakdownBook,
180
+ analyzeAdaptation,
181
+ proposeAdaptation,
182
+ applyAdaptationReplacements,
183
+ rewriteAdaptationBook,
184
+ materializeAdaptedBook,
185
+ saveMaterializedBook,
186
+ testLlmModel,
187
+ registerLlmModel,
188
+ listLlmVendors,
189
+ listLlmModels,
190
+ listLlmProviders,
191
+ removeLlmProvider,
192
+ checkSensitiveText,
193
+ suggestForeshadows,
194
+ suggestPlotlines,
195
+ summarizeAndExtractFacts,
196
+ summarizeChapter,
197
+ syncProjectWithDisk,
198
+ } from './engine.ts'
199
+
200
+ /** Cap on JSON request bodies (generous: cover images travel as base64). */
201
+ const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024
202
+
203
+ /** 包内置风格效果图目录(assets/styles,随 npm 包分发)。 */
204
+
205
+ /** 解析请求级目标书目录:优先按请求携带的 bookId 查书架,否则回退全局 active 书目录。 */
206
+ function resolveOutputDir(config: NovelConfig, bookId?: string): string {
207
+ if (bookId !== undefined && bookId !== '') {
208
+ const book = loadBookshelf().books.find(b => b.id === bookId)
209
+ if (book !== undefined && book.outputDir !== '') return book.outputDir
210
+ }
211
+ return config.outputDir
212
+ }
213
+
214
+ /** Loopback-only fence (mirrors the family plugins' pairing routes). */
215
+ function isLoopbackRequest(request: IncomingMessage): boolean {
216
+ const address = request.socket.remoteAddress
217
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
218
+ const host = request.headers.host
219
+ if (typeof host !== 'string') return false
220
+ let hostUrl: URL
221
+ try {
222
+ hostUrl = new URL(`http://${host}`)
223
+ } catch {
224
+ return false
225
+ }
226
+ if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
227
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
228
+ const origin = request.headers.origin
229
+ if (origin === undefined) return true
230
+ try {
231
+ return new URL(origin).host === hostUrl.host
232
+ } catch {
233
+ return false
234
+ }
235
+ }
236
+
237
+ /** One JSON response. */
238
+ function writeJson(res: ServerResponse, status: number, body: unknown): void {
239
+ const payload = JSON.stringify(body)
240
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
241
+ res.end(payload)
242
+ }
243
+
244
+ /** Read a JSON request body. */
245
+ async function readJsonBody<T>(req: IncomingMessage): Promise<T | undefined> {
246
+ const chunks: Buffer[] = []
247
+ let size = 0
248
+ for await (const chunk of req) {
249
+ const buffer = chunk as Buffer
250
+ size += buffer.length
251
+ if (size > MAX_JSON_BODY_BYTES) return undefined
252
+ chunks.push(buffer)
253
+ }
254
+ try {
255
+ return JSON.parse(Buffer.concat(chunks).toString('utf8')) as T
256
+ } catch {
257
+ return undefined
258
+ }
259
+ }
260
+
261
+ /** Route deps. */
262
+ export interface NovelRoutesDeps {
263
+ ctx: Context
264
+ /** Resolve the live plugin config (settings-aware). */
265
+ getConfig: () => NovelConfig
266
+ /** Persist a config patch through the settings seam. */
267
+ patchConfig: (patch: ConfigPatch) => Promise<NovelConfig>
268
+ }
269
+
270
+ /** Default chapter count for planning when the request omits it. */
271
+ const DEFAULT_PLAN_COUNT = 30
272
+
273
+ /**
274
+ * Build every /api/dsh-novel-forge route.
275
+ * @param deps - context, config resolver, config patcher.
276
+ * @returns the route list.
277
+ */
278
+ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
279
+ const { ctx, getConfig, patchConfig } = deps
280
+
281
+ /** 全书质检实时状态(内存态,重启后回到 idle;用于 /status 暴露进度)。 */
282
+ let auditState: AuditStatus = { status: 'idle', totalBatches: 0, completedBatches: 0 }
283
+
284
+ /** Guard helper: fence + method check. */
285
+ const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
286
+ if (!isLoopbackRequest(req)) {
287
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
288
+ return false
289
+ }
290
+ if (req.method !== method) {
291
+ writeJson(res, 405, { error: `method not allowed (expected ${method})` })
292
+ return false
293
+ }
294
+ return true
295
+ }
296
+
297
+ /** Load (and sync) the project, or respond 400. */
298
+ const requireProject = (res: ServerResponse): ReturnType<typeof loadProject> => {
299
+ const config = getConfig()
300
+ const project = loadProject(config.outputDir)
301
+ if (project === undefined) {
302
+ writeJson(res, 400, { error: '输出目录中没有项目,请先加载大纲' })
303
+ return undefined
304
+ }
305
+ syncProjectWithDisk(project, config.outputDir)
306
+ saveProject(config.outputDir, project)
307
+ return project
308
+ }
309
+
310
+ // -------------------------------------------------------------- status
311
+ const statusRoute: WebRoute = {
312
+ kind: 'exact',
313
+ path: NOVEL_API.status,
314
+ handler: (req, res) => {
315
+ if (!guard(req, res, 'GET')) return
316
+ const config = getConfig()
317
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
318
+ const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
319
+ // 书架为空时播种 settings 默认输出目录里的已有项目。
320
+ seedBookshelfFromOutputDir(outputDir)
321
+ const project = loadProject(outputDir)
322
+ if (project !== undefined) {
323
+ // 自动兜底:generating 超过 10 分钟(正常一章 2-5 分钟)视为卡死/中断,
324
+ // 复位为 pending,避免"生成中"状态卡死无法重新生成。
325
+ const staleMs = 10 * 60 * 1000
326
+ for (const c of project.chapters) {
327
+ if (c.status === 'generating' && c.generatingAt !== undefined) {
328
+ const started = new Date(c.generatingAt).getTime()
329
+ if (Number.isFinite(started) && Date.now() - started > staleMs) {
330
+ c.status = 'pending'
331
+ c.error = undefined
332
+ c.generatingAt = undefined
333
+ }
334
+ }
335
+ }
336
+ syncProjectWithDisk(project, outputDir)
337
+ saveProject(outputDir, project)
338
+ }
339
+ const slim = new URL(req.url ?? '/', 'http://localhost').searchParams.get('slim') === '1'
340
+ let projectPayload: StatusResponse['project']
341
+ if (project === undefined) {
342
+ projectPayload = undefined
343
+ } else if (slim) {
344
+ // slim 瘦身:轮询/角标只需章节状态与分数 + 轻量元数据,
345
+ // 去掉正文、beats、图集(base64)、道藏/大世界/资产等重字段,避免长书轮询反复传输大体积。
346
+ projectPayload = {
347
+ bookName: project.bookName,
348
+ outline: project.outline.slice(0, 200),
349
+ chapters: project.chapters.map(c => ({
350
+ no: c.no,
351
+ volume: c.volume,
352
+ title: c.title,
353
+ status: c.status,
354
+ chars: c.chars,
355
+ error: c.error,
356
+ review: c.review !== undefined ? { score: c.review.score, passed: c.review.passed } : undefined,
357
+ })) as unknown as ProjectState['chapters'],
358
+ createdAt: project.createdAt,
359
+ updatedAt: project.updatedAt,
360
+ } as unknown as ProjectState
361
+ } else {
362
+ // 瘦身:facts 只回最近 80 条(客户端最多展示 60),避免长篇后
363
+ // status 响应体随编年录无限膨胀。
364
+ projectPayload = { ...project, facts: (project.facts ?? []).slice(-80) }
365
+ }
366
+ const response: StatusResponse = {
367
+ config,
368
+ project: projectPayload,
369
+ generatedFiles: listChapterFiles(config.outputDir),
370
+ audit: auditState,
371
+ }
372
+ writeJson(res, 200, response)
373
+ },
374
+ }
375
+
376
+ // -------------------------------------------------------- load-outline
377
+ const loadOutlineRoute: WebRoute = {
378
+ kind: 'exact',
379
+ path: NOVEL_API.loadOutline,
380
+ handler: async (req, res) => {
381
+ if (!guard(req, res, 'POST')) return
382
+ const body = await readJsonBody<LoadOutlineRequest>(req)
383
+ const config = getConfig()
384
+ try {
385
+ let outline: string
386
+ let path: string | undefined
387
+ if (body?.text !== undefined && body.text.trim() !== '') {
388
+ outline = body.text.trim()
389
+ } else {
390
+ const target = body?.path?.trim() !== '' && body?.path !== undefined ? body.path : config.outlinePath
391
+ outline = readOutlineFromDocx(target)
392
+ path = target
393
+ }
394
+ if (outline.length < 50) {
395
+ writeJson(res, 400, { error: '大纲内容过短(<50 字符),请检查文件或直接粘贴大纲文本' })
396
+ return
397
+ }
398
+ const response: LoadOutlineResponse = {
399
+ outline,
400
+ bookName: createProject(outline).bookName,
401
+ chars: outline.length,
402
+ path,
403
+ }
404
+ writeJson(res, 200, response)
405
+ } catch (error) {
406
+ writeJson(res, 400, { error: (error as Error).message })
407
+ }
408
+ },
409
+ }
410
+
411
+ // -------------------------------------------------------- save-outline
412
+ const saveOutlineRoute: WebRoute = {
413
+ kind: 'exact',
414
+ path: NOVEL_API.saveOutline,
415
+ handler: async (req, res) => {
416
+ if (!guard(req, res, 'POST')) return
417
+ const body = await readJsonBody<LoadOutlineRequest>(req)
418
+ const config = getConfig()
419
+ const outline = body?.text ?? ''
420
+ if (outline.trim().length < 50) {
421
+ writeJson(res, 400, { error: '大纲内容过短(<50 字符)' })
422
+ return
423
+ }
424
+ let project = loadProject(config.outputDir)
425
+ const now = new Date().toISOString()
426
+ if (project === undefined) {
427
+ project = createProject(outline)
428
+ } else {
429
+ project.outline = outline
430
+ project.bookName = createProject(outline).bookName
431
+ project.updatedAt = now
432
+ }
433
+ saveProject(config.outputDir, project)
434
+ writeJson(res, 200, { ok: true, bookName: project.bookName })
435
+ },
436
+ }
437
+
438
+ // --------------------------------------------------------------- bible
439
+ const bibleRoute: WebRoute = {
440
+ kind: 'exact',
441
+ path: NOVEL_API.bible,
442
+ handler: async (req, res) => {
443
+ if (!guard(req, res, 'POST')) return
444
+ const body = await readJsonBody<BibleRequest>(req)
445
+ const config = getConfig()
446
+ const project = loadProject(config.outputDir)
447
+ const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
448
+ ? body.outline
449
+ : project?.outline
450
+ if (outline === undefined || outline.length < 50) {
451
+ writeJson(res, 400, { error: '请先加载大纲' })
452
+ return
453
+ }
454
+ try {
455
+ const bible = await extractBible(ctx, config, outline, project)
456
+ const now = new Date().toISOString()
457
+ const next = project ?? createProject(outline)
458
+ next.bible = bible
459
+ next.updatedAt = now
460
+ saveProject(config.outputDir, next)
461
+ const response: BibleResponse = { bible }
462
+ writeJson(res, 200, response)
463
+ } catch (error) {
464
+ writeJson(res, 500, { error: (error as Error).message })
465
+ }
466
+ },
467
+ }
468
+
469
+ // ------------------------------------------------------------- volumes
470
+ const volumesRoute: WebRoute = {
471
+ kind: 'exact',
472
+ path: NOVEL_API.volumes,
473
+ handler: async (req, res) => {
474
+ if (!guard(req, res, 'POST')) return
475
+ const body = await readJsonBody<VolumesRequest & { bookId?: string }>(req)
476
+ const config0 = getConfig()
477
+ const outputDir = resolveOutputDir(config0, body?.bookId)
478
+ const config = { ...config0, outputDir }
479
+ const project = loadProject(outputDir)
480
+ const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
481
+ ? body.outline
482
+ : project?.outline
483
+ if (outline === undefined || outline.length < 50) {
484
+ writeJson(res, 400, { error: '请先加载大纲' })
485
+ return
486
+ }
487
+ try {
488
+ const volumes = await planVolumes(ctx, config, outline)
489
+ const now = new Date().toISOString()
490
+ const next = project ?? createProject(outline)
491
+ next.volumes = volumes
492
+ next.updatedAt = now
493
+ saveProject(outputDir, next)
494
+ const response: VolumesResponse = { volumes }
495
+ writeJson(res, 200, response)
496
+ } catch (error) {
497
+ writeJson(res, 500, { error: (error as Error).message })
498
+ }
499
+ },
500
+ }
501
+
502
+ // ----------------------------------------------------------------- plan
503
+ const planRoute: WebRoute = {
504
+ kind: 'exact',
505
+ path: NOVEL_API.plan,
506
+ handler: async (req, res) => {
507
+ if (!guard(req, res, 'POST')) return
508
+ const body = await readJsonBody<PlanRequest & { bookId?: string }>(req)
509
+ const config0 = getConfig()
510
+ const outputDir = resolveOutputDir(config0, body?.bookId)
511
+ const config = { ...config0, outputDir }
512
+ const project = loadProject(outputDir)
513
+ const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
514
+ ? body.outline
515
+ : project?.outline
516
+ if (outline === undefined || outline.length < 50) {
517
+ writeJson(res, 400, { error: '请先加载大纲(或粘贴大纲文本)' })
518
+ return
519
+ }
520
+ const count = body?.chapterCount ?? DEFAULT_PLAN_COUNT
521
+ if (!Number.isInteger(count) || count < 1 || count > 200) {
522
+ writeJson(res, 400, { error: 'chapterCount 须为 1-200 的整数' })
523
+ return
524
+ }
525
+ try {
526
+ const next = project ?? createProject(outline)
527
+ const chapters: ChapterPlan[] = await planChapters(ctx, config, next, count, body?.volume, config.outputDir)
528
+ // 并发保护:长 LLM 调用期间磁盘可能已被其他请求更新(角色库/剧情线等),
529
+ // 保存前合并磁盘最新易变字段,避免旧快照覆盖新修改。
530
+ mergeVolatileFromDisk(config.outputDir, next)
531
+ next.chapters.push(...chapters)
532
+ next.updatedAt = new Date().toISOString()
533
+ saveProject(config.outputDir, next)
534
+ const response: PlanResponse = { chapters, volumes: next.volumes }
535
+ writeJson(res, 200, response)
536
+ } catch (error) {
537
+ writeJson(res, 500, { error: (error as Error).message })
538
+ }
539
+ },
540
+ }
541
+
542
+ // ------------------------------------------------------------- generate
543
+ const generateRoute: WebRoute = {
544
+ kind: 'exact',
545
+ path: NOVEL_API.generate,
546
+ handler: async (req, res) => {
547
+ if (!guard(req, res, 'POST')) return
548
+ const config0 = getConfig()
549
+ const body = await readJsonBody<{ chapterNo?: number; skipReview?: boolean; bookId?: string }>(req)
550
+ const outputDir = resolveOutputDir(config0, body?.bookId)
551
+ const config = { ...config0, outputDir }
552
+ const project = loadProject(outputDir)
553
+ if (project === undefined) {
554
+ writeJson(res, 400, { error: '输出目录中没有项目' })
555
+ return
556
+ }
557
+ const rawNo = body?.chapterNo
558
+ if (!Number.isInteger(rawNo) || rawNo === undefined || rawNo < 1) {
559
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
560
+ return
561
+ }
562
+ const no: number = rawNo
563
+ const chapter = project.chapters.find(c => c.no === no)
564
+ if (chapter === undefined) {
565
+ writeJson(res, 404, { error: `章节 ${no} 不在计划中` })
566
+ return
567
+ }
568
+ if (chapter.status === 'generating') {
569
+ writeJson(res, 409, { error: `章节 ${no} 正在生成中` })
570
+ return
571
+ }
572
+
573
+ // NDJSON stream.
574
+ res.writeHead(200, {
575
+ 'content-type': 'application/x-ndjson; charset=utf-8',
576
+ 'cache-control': 'no-cache',
577
+ 'x-accel-buffering': 'no',
578
+ 'referrer-policy': 'no-referrer',
579
+ })
580
+ chapter.status = 'generating'
581
+ chapter.error = undefined
582
+ chapter.generatingAt = new Date().toISOString()
583
+ // 并发保护:合并磁盘最新易变字段后保存,避免本请求覆盖并发修改(角色库/剧情线等)。
584
+ mergeVolatileFromDisk(config.outputDir, project)
585
+ saveProject(config.outputDir, project)
586
+
587
+ const send = (frame: JobFrame): void => {
588
+ res.write(JSON.stringify(frame) + '\n')
589
+ }
590
+
591
+ const liveSession = nextSessionId()
592
+ emitLive({ type: 'session_started', sessionId: liveSession, label: '正文生成', model: config.generateModel || config.model, at: new Date().toISOString(), context: { interactionId: liveSession, taskId: `ch-${no}` } })
593
+ emitLive({ type: 'phase_changed', sessionId: liveSession, phase: 'streaming', phaseMessage: '模型正在返回正文', at: new Date().toISOString() })
594
+
595
+ try {
596
+ send({ type: 'start', no, title: chapter.title })
597
+ let genChars = 0
598
+ for await (const step of generateChapterStream(ctx, config, project, config.outputDir, no)) {
599
+ if (step.frame === 'delta') {
600
+ send({ type: 'delta', text: step.text })
601
+ genChars += step.text.length
602
+ emitLive({ type: 'output_delta', sessionId: liveSession, content: step.text, totalChars: genChars, at: new Date().toISOString() })
603
+ } else if (step.frame === 'done') {
604
+ send({ type: 'done', no, file: step.file, chars: step.chars, title: chapter.title, warn: step.warn })
605
+ }
606
+ }
607
+ emitLive({ type: 'session_completed', sessionId: liveSession, totalChars: genChars, preview: '', at: new Date().toISOString(), phase: 'completed' })
608
+ // Auto pipeline: summary + facts(一次调用)-> review(unless skipped)。
609
+ try {
610
+ await summarizeAndExtractFacts(ctx, config, project, config.outputDir, no)
611
+ } catch (error) {
612
+ console.warn('[dsh-novel-forge] summary/facts failed:', (error as Error).message)
613
+ }
614
+ // 伏笔落地标记:正文中命中 planned 伏笔关键词 → 自动标为 planted(暗线管理页与正文同步)。
615
+ try {
616
+ const marked = markForeshadowPlanted(project, config.outputDir, no)
617
+ if (marked > 0) {
618
+ console.log(`[dsh-novel-forge] 第${no}章已埋伏笔 ${marked} 条`)
619
+ }
620
+ } catch (error) {
621
+ console.warn('[dsh-novel-forge] markForeshadowPlanted failed:', (error as Error).message)
622
+ }
623
+ if (!(body?.skipReview === true) && (config.autoReview ?? true)) {
624
+ const report = await reviewChapter(ctx, config, project, config.outputDir, no)
625
+ send({ type: 'review', no, report })
626
+ } else {
627
+ chapter.status = 'approved'
628
+ mergeVolatileFromDisk(config.outputDir, project)
629
+ saveProject(config.outputDir, project)
630
+ }
631
+ // 作者复盘:叙事结构检查(钩子兑现/结尾钩子/推进/连续性/趋势;不改变章节状态)。
632
+ if (config.autoAuthorReview ?? true) {
633
+ try {
634
+ const currentBody = readChapterFile(config.outputDir, chapter)
635
+ let prevTail = ''
636
+ if (no > 1) {
637
+ const prev = project.chapters.find(c => c.no === no - 1)
638
+ if (prev !== undefined) {
639
+ prevTail = (readChapterFile(config.outputDir, prev) ?? '').replace(/^#.*$/m, '').trim().slice(-600)
640
+ }
641
+ }
642
+ if (currentBody !== undefined) {
643
+ const review = await authorReviewChapter(ctx, config, project, no, currentBody, prevTail)
644
+ chapter.authorReview = review
645
+ // 复盘标记推进的剧情线 → 自动关联本章。
646
+ if (review.advancedLines !== undefined) {
647
+ autoLinkPlotlines(project, no, review.advancedLines)
648
+ }
649
+ mergeVolatileFromDisk(config.outputDir, project)
650
+ saveProject(config.outputDir, project)
651
+ send({ type: 'author-review', no, review })
652
+ }
653
+ } catch (error) {
654
+ console.warn('[dsh-novel-forge] author review failed:', (error as Error).message)
655
+ }
656
+ }
657
+ res.end()
658
+ } catch (error) {
659
+ chapter.status = 'error'
660
+ chapter.error = (error as Error).message
661
+ mergeVolatileFromDisk(config.outputDir, project)
662
+ saveProject(config.outputDir, project)
663
+ if (!res.writableEnded) {
664
+ send({ type: 'error', no, message: (error as Error).message })
665
+ res.end()
666
+ }
667
+ }
668
+ },
669
+ }
670
+
671
+ // --------------------------------------------------------------- review
672
+ const reviewRoute: WebRoute = {
673
+ kind: 'exact',
674
+ path: NOVEL_API.review,
675
+ handler: async (req, res) => {
676
+ if (!guard(req, res, 'POST')) return
677
+ const config0 = getConfig()
678
+ const body = await readJsonBody<ReviewRequest & { bookId?: string }>(req)
679
+ const outputDir = resolveOutputDir(config0, body?.bookId)
680
+ const config = { ...config0, outputDir }
681
+ const project = loadProject(outputDir)
682
+ if (project === undefined) {
683
+ writeJson(res, 400, { error: '输出目录中没有项目' })
684
+ return
685
+ }
686
+ if (!Number.isInteger(body?.chapterNo)) {
687
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
688
+ return
689
+ }
690
+ const no = body!.chapterNo!
691
+ try {
692
+ const report = await reviewChapter(ctx, config, project, outputDir, no)
693
+ writeJson(res, 200, { report })
694
+ } catch (error) {
695
+ writeJson(res, 500, { error: (error as Error).message })
696
+ }
697
+ },
698
+ }
699
+
700
+ // -------------------------------------------------------------- rewrite
701
+ const rewriteRoute: WebRoute = {
702
+ kind: 'exact',
703
+ path: NOVEL_API.rewrite,
704
+ handler: async (req, res) => {
705
+ if (!guard(req, res, 'POST')) return
706
+ const config0 = getConfig()
707
+ const body = await readJsonBody<RewriteRequest & { bookId?: string }>(req)
708
+ const outputDir = resolveOutputDir(config0, body?.bookId)
709
+ const config = { ...config0, outputDir }
710
+ const project = loadProject(outputDir)
711
+ if (project === undefined) {
712
+ writeJson(res, 400, { error: '输出目录中没有项目' })
713
+ return
714
+ }
715
+ if (!Number.isInteger(body?.chapterNo)) {
716
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
717
+ return
718
+ }
719
+ const no = body!.chapterNo!
720
+ res.writeHead(200, {
721
+ 'content-type': 'application/x-ndjson; charset=utf-8',
722
+ 'cache-control': 'no-cache',
723
+ 'x-accel-buffering': 'no',
724
+ 'referrer-policy': 'no-referrer',
725
+ })
726
+ const send = (frame: JobFrame): void => { res.write(JSON.stringify(frame) + '\n') }
727
+ try {
728
+ for await (const step of rewriteChapterStream(ctx, config, project, config.outputDir, no, body?.instructions ?? '', body?.target)) {
729
+ if (step.frame === 'delta') send({ type: 'delta', text: step.text })
730
+ else if (step.frame === 'drafted') send({ type: 'drafted', no, chars: step.chars, draft: step.draft })
731
+ }
732
+ // Draft mode: no auto re-review — the user reviews the diff and
733
+ // decides; re-run review after applying if wanted.
734
+ res.end()
735
+ } catch (error) {
736
+ if (!res.writableEnded) {
737
+ send({ type: 'error', no, message: (error as Error).message })
738
+ res.end()
739
+ }
740
+ }
741
+ },
742
+ }
743
+
744
+ // --------------------------------------------------------------- polish
745
+ const polishRoute: WebRoute = {
746
+ kind: 'exact',
747
+ path: NOVEL_API.polish,
748
+ handler: async (req, res) => {
749
+ if (!guard(req, res, 'POST')) return
750
+ const config0 = getConfig()
751
+ const body = await readJsonBody<PolishRequest & { bookId?: string }>(req)
752
+ const outputDir = resolveOutputDir(config0, body?.bookId)
753
+ const config = { ...config0, outputDir }
754
+ const project = loadProject(outputDir)
755
+ if (project === undefined) {
756
+ writeJson(res, 400, { error: '输出目录中没有项目' })
757
+ return
758
+ }
759
+ if (!Number.isInteger(body?.chapterNo)) {
760
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
761
+ return
762
+ }
763
+ const no = body!.chapterNo!
764
+ res.writeHead(200, {
765
+ 'content-type': 'application/x-ndjson; charset=utf-8',
766
+ 'cache-control': 'no-cache',
767
+ 'x-accel-buffering': 'no',
768
+ 'referrer-policy': 'no-referrer',
769
+ })
770
+ const send = (frame: JobFrame): void => { res.write(JSON.stringify(frame) + '\n') }
771
+ try {
772
+ for await (const step of polishChapterStream(ctx, config, project, config.outputDir, no)) {
773
+ if (step.frame === 'delta') send({ type: 'delta', text: step.text })
774
+ else if (step.frame === 'drafted') send({ type: 'drafted', no, chars: step.chars, draft: step.draft })
775
+ }
776
+ res.end()
777
+ } catch (error) {
778
+ if (!res.writableEnded) {
779
+ send({ type: 'error', no, message: (error as Error).message })
780
+ res.end()
781
+ }
782
+ }
783
+ },
784
+ }
785
+
786
+ // ---------------------------------------------------- draft apply/discard
787
+ /** 采纳待确认草稿:覆盖正文文件 + 状态回 written + 清空草稿。 */
788
+ const draftApplyRoute: WebRoute = {
789
+ kind: 'exact',
790
+ path: NOVEL_API.draftApply,
791
+ handler: async (req, res) => {
792
+ if (!guard(req, res, 'POST')) return
793
+ const config = getConfig()
794
+ const project = requireProject(res)
795
+ if (project === undefined) return
796
+ const body = await readJsonBody<DraftDecisionRequest>(req)
797
+ if (!Number.isInteger(body?.chapterNo)) {
798
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
799
+ return
800
+ }
801
+ const chapter = project.chapters.find(c => c.no === body!.chapterNo!)
802
+ if (chapter === undefined) {
803
+ writeJson(res, 404, { error: `章节 ${body!.chapterNo} 不在计划中` })
804
+ return
805
+ }
806
+ if (chapter.pendingDraft === undefined || chapter.pendingDraft === '') {
807
+ writeJson(res, 400, { error: `章节 ${chapter.no} 没有待确认的草稿` })
808
+ return
809
+ }
810
+ const draft = chapter.pendingDraft
811
+ const fileName = chapterFileName(chapter)
812
+ mkdirSync(config.outputDir, { recursive: true })
813
+ const targetPath = join(config.outputDir, fileName)
814
+ // 采纳前自动备份当前原稿为 .bak.md(每次应用都刷新为最新原稿),可随时回退。
815
+ if (existsSync(targetPath)) {
816
+ copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
817
+ }
818
+ writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
819
+ chapter.pendingDraft = undefined
820
+ chapter.chars = draft.length
821
+ chapter.file = fileName
822
+ // 携带审查报告则沿用结论定状态(修订后审查通过 → approved);否则置 written 待审。
823
+ const carried = body?.report
824
+ if (carried !== undefined && typeof carried.score === 'number') {
825
+ chapter.review = carried
826
+ chapter.status = carried.passed ? 'approved' : 'rejected'
827
+ } else {
828
+ chapter.status = 'written'
829
+ chapter.review = undefined
830
+ }
831
+ chapter.error = undefined
832
+ project.updatedAt = new Date().toISOString()
833
+ saveProject(config.outputDir, project)
834
+ writeJson(res, 200, { ok: true, chars: draft.length, file: fileName, markdown: draft })
835
+ },
836
+ }
837
+
838
+ /** 放弃待确认草稿:保留原稿,仅清空草稿字段。 */
839
+ const draftDiscardRoute: WebRoute = {
840
+ kind: 'exact',
841
+ path: NOVEL_API.draftDiscard,
842
+ handler: async (req, res) => {
843
+ if (!guard(req, res, 'POST')) return
844
+ const config = getConfig()
845
+ const project = requireProject(res)
846
+ if (project === undefined) return
847
+ const body = await readJsonBody<DraftDecisionRequest>(req)
848
+ if (!Number.isInteger(body?.chapterNo)) {
849
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
850
+ return
851
+ }
852
+ const chapter = project.chapters.find(c => c.no === body!.chapterNo!)
853
+ if (chapter === undefined) {
854
+ writeJson(res, 404, { error: `章节 ${body!.chapterNo} 不在计划中` })
855
+ return
856
+ }
857
+ if (chapter.pendingDraft !== undefined) {
858
+ chapter.pendingDraft = undefined
859
+ project.updatedAt = new Date().toISOString()
860
+ saveProject(config.outputDir, project)
861
+ }
862
+ writeJson(res, 200, { ok: true })
863
+ },
864
+ }
865
+
866
+ // -------------------------------------------------------------- summary
867
+ const summaryRoute: WebRoute = {
868
+ kind: 'exact',
869
+ path: NOVEL_API.summary,
870
+ handler: async (req, res) => {
871
+ if (!guard(req, res, 'POST')) return
872
+ const config = getConfig()
873
+ const project = requireProject(res)
874
+ if (project === undefined) return
875
+ const body = await readJsonBody<SummaryRequest>(req)
876
+ if (!Number.isInteger(body?.chapterNo)) {
877
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
878
+ return
879
+ }
880
+ try {
881
+ const summary = await summarizeChapter(ctx, config, project, config.outputDir, body!.chapterNo!)
882
+ writeJson(res, 200, { summary })
883
+ } catch (error) {
884
+ writeJson(res, 500, { error: (error as Error).message })
885
+ }
886
+ },
887
+ }
888
+
889
+ // ---------------------------------------------------------- foreshadow
890
+ const foreshadowRoute: WebRoute = {
891
+ kind: 'exact',
892
+ path: NOVEL_API.foreshadow,
893
+ handler: async (req, res) => {
894
+ if (!guard(req, res, 'POST')) return
895
+ const config0 = getConfig()
896
+ const body = await readJsonBody<ForeshadowRequest & { bookId?: string }>(req)
897
+ const outputDir = resolveOutputDir(config0, body?.bookId)
898
+ const config = { ...config0, outputDir }
899
+ const project = loadProject(outputDir)
900
+ if (project === undefined) {
901
+ writeJson(res, 400, { error: '输出目录中没有项目' })
902
+ return
903
+ }
904
+ try {
905
+ if (body?.suggest === true) {
906
+ // AI suggestion pass: create several foreshadows from the outline.
907
+ const created = await suggestForeshadows(ctx, config, project)
908
+ project.updatedAt = new Date().toISOString()
909
+ saveProject(config.outputDir, project)
910
+ const response: ForeshadowResponse = { foreshadows: created }
911
+ writeJson(res, 200, response)
912
+ return
913
+ }
914
+ if (body?.id !== undefined) {
915
+ // Update an existing foreshadow.
916
+ const target = project.foreshadows.find(f => f.id === body.id)
917
+ if (target === undefined) {
918
+ writeJson(res, 404, { error: `伏笔 ${body.id} 不存在` })
919
+ return
920
+ }
921
+ if (body.description !== undefined) target.description = body.description
922
+ if (body.plantedChapter !== undefined) target.plantedChapter = body.plantedChapter
923
+ if (body.targetChapter !== undefined) target.targetChapter = body.targetChapter
924
+ if (body.status !== undefined) target.status = body.status
925
+ if (body.resolvedNote !== undefined) target.resolvedNote = body.resolvedNote
926
+ } else {
927
+ // Create one manually.
928
+ const description = body?.description?.trim()
929
+ if (description === undefined || description === '') {
930
+ writeJson(res, 400, { error: 'description 必填' })
931
+ return
932
+ }
933
+ project.foreshadows.push({
934
+ id: `fs-${Date.now().toString(36)}`,
935
+ description,
936
+ plantedChapter: body?.plantedChapter,
937
+ targetChapter: body?.targetChapter,
938
+ status: body?.status ?? 'planned',
939
+ })
940
+ }
941
+ project.updatedAt = new Date().toISOString()
942
+ saveProject(config.outputDir, project)
943
+ const response: ForeshadowResponse = { foreshadows: project.foreshadows }
944
+ writeJson(res, 200, response)
945
+ } catch (error) {
946
+ writeJson(res, 500, { error: (error as Error).message })
947
+ }
948
+ },
949
+ }
950
+
951
+ // -------------------------------------------------------------- export
952
+ const exportRoute: WebRoute = {
953
+ kind: 'exact',
954
+ path: NOVEL_API.exportBook,
955
+ handler: async (req, res) => {
956
+ if (!guard(req, res, 'POST')) return
957
+ const config0 = getConfig()
958
+ const body = await readJsonBody<ExportRequest & { bookId?: string }>(req)
959
+ const outputDir = resolveOutputDir(config0, body?.bookId)
960
+ const config = { ...config0, outputDir }
961
+ const project = loadProject(outputDir)
962
+ if (project === undefined) {
963
+ writeJson(res, 400, { error: '输出目录中没有项目' })
964
+ return
965
+ }
966
+ const format = body?.format === 'md' ? 'md' : 'txt'
967
+ try {
968
+ const result = exportBook(config.outputDir, project, format)
969
+ const response: ExportResponse = { ...result }
970
+ writeJson(res, 200, response)
971
+ } catch (error) {
972
+ writeJson(res, 500, { error: (error as Error).message })
973
+ }
974
+ },
975
+ }
976
+
977
+ // -------------------------------------------------------------- chapter
978
+ const chapterRoute: WebRoute = {
979
+ kind: 'exact',
980
+ path: NOVEL_API.chapter,
981
+ handler: async (req, res) => {
982
+ if (!guard(req, res, 'GET')) return
983
+ const config = getConfig()
984
+ const url = new URL(req.url ?? '/', 'http://localhost')
985
+ const outputDir = resolveOutputDir(config, url.searchParams.get('bookId') ?? undefined)
986
+ const project = loadProject(outputDir)
987
+ if (project === undefined) {
988
+ writeJson(res, 400, { error: '输出目录中没有项目' })
989
+ return
990
+ }
991
+ const rawNo = Number(url.searchParams.get('no') ?? '0')
992
+ if (!Number.isInteger(rawNo) || rawNo < 1) {
993
+ writeJson(res, 400, { error: 'no 须为正整数' })
994
+ return
995
+ }
996
+ const chapter = project.chapters.find(c => c.no === rawNo)
997
+ if (chapter === undefined) {
998
+ writeJson(res, 404, { error: `章节 ${rawNo} 不在计划中` })
999
+ return
1000
+ }
1001
+ const markdown = readChapterFile(outputDir, chapter)
1002
+ if (markdown === undefined) {
1003
+ writeJson(res, 404, { error: `章节 ${rawNo} 尚未生成` })
1004
+ return
1005
+ }
1006
+ const response: ChapterResponse = { no: chapter.no, title: chapter.title, markdown }
1007
+ writeJson(res, 200, response)
1008
+ },
1009
+ }
1010
+
1011
+ // ------------------------------------------------------ chapter check/save
1012
+ /** 审查手动编辑的正文(不落盘,返回审稿报告)。 */
1013
+ const chapterCheckRoute: WebRoute = {
1014
+ kind: 'exact',
1015
+ path: NOVEL_API.chapterCheck,
1016
+ handler: async (req, res) => {
1017
+ if (!guard(req, res, 'POST')) return
1018
+ const config = getConfig()
1019
+ const project = requireProject(res)
1020
+ if (project === undefined) return
1021
+ const body = await readJsonBody<ChapterTextRequest>(req)
1022
+ const text = body?.text?.trim() ?? ''
1023
+ if (text.length < 50) {
1024
+ writeJson(res, 400, { error: '正文过短(<50 字),请先编辑内容' })
1025
+ return
1026
+ }
1027
+ try {
1028
+ // 携带上一轮报告 → 验证模式(逐条核对原意见解决情况 + 只挑新增 high)。
1029
+ const report = await reviewChapterText(ctx, config, project, text, body?.previousReport)
1030
+ writeJson(res, 200, { report })
1031
+ } catch (error) {
1032
+ writeJson(res, 500, { error: (error as Error).message })
1033
+ }
1034
+ },
1035
+ }
1036
+
1037
+ /** 保存手动编辑的正文(自动备份 .bak,状态回 written)。 */
1038
+ const chapterSaveRoute: WebRoute = {
1039
+ kind: 'exact',
1040
+ path: NOVEL_API.chapterSave,
1041
+ handler: async (req, res) => {
1042
+ if (!guard(req, res, 'POST')) return
1043
+ const config = getConfig()
1044
+ const project = requireProject(res)
1045
+ if (project === undefined) return
1046
+ const body = await readJsonBody<ChapterTextRequest>(req)
1047
+ if (!Number.isInteger(body?.chapterNo)) {
1048
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
1049
+ return
1050
+ }
1051
+ const chapter = project.chapters.find(c => c.no === body!.chapterNo!)
1052
+ if (chapter === undefined) {
1053
+ writeJson(res, 404, { error: `章节 ${body!.chapterNo} 不在计划中` })
1054
+ return
1055
+ }
1056
+ const text = body?.text?.trim() ?? ''
1057
+ if (text.length < 50) {
1058
+ writeJson(res, 400, { error: '正文过短(<50 字),未保存' })
1059
+ return
1060
+ }
1061
+ const fileName = chapterFileName(chapter)
1062
+ mkdirSync(config.outputDir, { recursive: true })
1063
+ const targetPath = join(config.outputDir, fileName)
1064
+ if (existsSync(targetPath)) {
1065
+ copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
1066
+ }
1067
+ writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${text}\n`, 'utf8')
1068
+ chapter.status = 'written'
1069
+ chapter.chars = text.length
1070
+ chapter.file = fileName
1071
+ chapter.pendingDraft = undefined
1072
+ // 保存即审稿:携带工作区审查报告则沿用(不重复审);否则自动正式审稿一次。
1073
+ let report: ReviewReport | undefined
1074
+ const carried = body?.report
1075
+ if (carried !== undefined && typeof carried.score === 'number') {
1076
+ report = carried
1077
+ chapter.review = report
1078
+ chapter.status = report.passed ? 'approved' : 'rejected'
1079
+ } else {
1080
+ report = await reviewChapter(ctx, config, project, config.outputDir, chapter.no)
1081
+ }
1082
+ project.updatedAt = new Date().toISOString()
1083
+ saveProject(config.outputDir, project)
1084
+ const response: ChapterSaveResponse = { ok: true, chars: text.length, file: fileName, report }
1085
+ writeJson(res, 200, response)
1086
+ },
1087
+ }
1088
+
1089
+ // ----------------------------------------------------------- assistant
1090
+ const assistantRoute: WebRoute = {
1091
+ kind: 'exact',
1092
+ path: NOVEL_API.assistant,
1093
+ handler: async (req, res) => {
1094
+ if (!guard(req, res, 'POST')) return
1095
+ const config = getConfig()
1096
+ const body = await readJsonBody<AssistantRequest & { bookId?: string }>(req)
1097
+ const outputDir = resolveOutputDir(config, body?.bookId)
1098
+ const project = loadProject(outputDir)
1099
+ if (project === undefined) {
1100
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1101
+ return
1102
+ }
1103
+ const message = body?.message?.trim()
1104
+ if (message === undefined || message === '') {
1105
+ writeJson(res, 400, { error: '消息不能为空' })
1106
+ return
1107
+ }
1108
+ res.writeHead(200, {
1109
+ 'content-type': 'application/x-ndjson; charset=utf-8',
1110
+ 'cache-control': 'no-cache',
1111
+ 'x-accel-buffering': 'no',
1112
+ 'referrer-policy': 'no-referrer',
1113
+ })
1114
+ const send = (frame: AssistantFrame): void => { res.write(JSON.stringify(frame) + '\n') }
1115
+ try {
1116
+ for await (const step of runAssistantTurn(ctx, config, project, outputDir, message)) {
1117
+ if (step.frame === 'delta') send({ type: 'delta', text: step.text })
1118
+ else if (step.frame === 'tool') send({ type: 'tool', name: step.name, status: step.status, detail: step.detail })
1119
+ else if (step.frame === 'toolDelta') send({ type: 'toolDelta', name: step.name, text: step.text })
1120
+ else if (step.frame === 'toolResult') send({ type: 'toolResult', name: step.name, text: step.text })
1121
+ }
1122
+ send({ type: 'done' })
1123
+ res.end()
1124
+ } catch (error) {
1125
+ if (!res.writableEnded) {
1126
+ send({ type: 'error', message: (error as Error).message })
1127
+ res.end()
1128
+ }
1129
+ }
1130
+ },
1131
+ }
1132
+
1133
+ // -------------------------------------------------- assistant-history
1134
+ const assistantHistoryRoute: WebRoute = {
1135
+ kind: 'exact',
1136
+ path: NOVEL_API.assistantHistory,
1137
+ handler: (req, res) => {
1138
+ if (!guard(req, res, 'GET')) return
1139
+ const config = getConfig()
1140
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1141
+ const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
1142
+ const response: AssistantHistoryResponse = { messages: loadAssistantHistory(outputDir) }
1143
+ writeJson(res, 200, response)
1144
+ },
1145
+ }
1146
+
1147
+ // --------------------------------------------------- assistant-clear
1148
+ /** 清空助手对话记录。 */
1149
+ const assistantClearRoute: WebRoute = {
1150
+ kind: 'exact',
1151
+ path: NOVEL_API.assistantClear,
1152
+ handler: async (req, res) => {
1153
+ if (!guard(req, res, 'POST')) return
1154
+ const config = getConfig()
1155
+ const body = await readJsonBody<{ bookId?: string }>(req)
1156
+ const outputDir = resolveOutputDir(config, body?.bookId)
1157
+ clearAssistantHistory(outputDir)
1158
+ writeJson(res, 200, { ok: true })
1159
+ },
1160
+ }
1161
+
1162
+ // --------------------------------------------------------------- assets
1163
+ const assetsRoute: WebRoute = {
1164
+ kind: 'exact',
1165
+ path: NOVEL_API.assets,
1166
+ handler: async (req, res) => {
1167
+ // GET (read) and POST (patch) are both allowed — check methods first,
1168
+ // then the loopback fence (guard() would 405 on POST, which is wrong).
1169
+ if (req.method !== 'GET' && req.method !== 'POST') {
1170
+ writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
1171
+ return
1172
+ }
1173
+ if (!isLoopbackRequest(req)) {
1174
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
1175
+ return
1176
+ }
1177
+ const config = getConfig()
1178
+ let postBody: (AssetsPatch & { bookId?: string }) | undefined
1179
+ if (req.method === 'POST') {
1180
+ postBody = await readJsonBody<AssetsPatch & { bookId?: string }>(req)
1181
+ if (postBody === undefined) {
1182
+ writeJson(res, 400, { error: '无效的 JSON' })
1183
+ return
1184
+ }
1185
+ }
1186
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1187
+ const queryBookId = qurl.searchParams.get('bookId') ?? undefined
1188
+ const outputDir = resolveOutputDir(config, postBody?.bookId ?? queryBookId)
1189
+ const project = loadProject(outputDir)
1190
+ const projectAssets = ensureBuiltinAssets(project?.assets ?? emptyProjectAssets())
1191
+ if (req.method === 'POST') {
1192
+ const body = postBody
1193
+ if (body === undefined) {
1194
+ writeJson(res, 400, { error: '无效的 JSON' })
1195
+ return
1196
+ }
1197
+ if (project === undefined) {
1198
+ writeJson(res, 400, { error: '请先加载大纲创建项目' })
1199
+ return
1200
+ }
1201
+ if (body.genre !== undefined) projectAssets.genre = body.genre
1202
+ if (body.primaryProgression !== undefined) projectAssets.primaryProgression = body.primaryProgression
1203
+ if (body.auxiliaryProgressions !== undefined) projectAssets.auxiliaryProgressions = body.auxiliaryProgressions
1204
+ if (body.antiAiRules !== undefined) projectAssets.antiAiRules = body.antiAiRules
1205
+ if (body.styleAssets !== undefined) projectAssets.styleAssets = body.styleAssets
1206
+ projectAssets.updatedAt = new Date().toISOString()
1207
+ project.assets = projectAssets
1208
+ project.updatedAt = new Date().toISOString()
1209
+ saveProject(outputDir, project)
1210
+ }
1211
+ const response: AssetsResponse = {
1212
+ projectAssets,
1213
+ genreLibrary: [...BUILTIN_GENRE_LIBRARY, ...globalGenreLibrary()],
1214
+ antiAiLibrary: BUILTIN_ANTI_AI_RULES,
1215
+ styleTemplates: BUILTIN_STYLE_TEMPLATES,
1216
+ progressionLibrary: [...BUILTIN_PROGRESSION_MODES, ...globalProgressionLibrary()],
1217
+ starterStyleProfiles: BUILTIN_STARTER_STYLE_PROFILES,
1218
+ plotBeatLibrary: BUILTIN_PLOT_BEATS,
1219
+ }
1220
+ writeJson(res, 200, response)
1221
+ },
1222
+ }
1223
+
1224
+ // ----------------------------------------------------------- style-engine
1225
+ const styleEngineRoute: WebRoute = {
1226
+ kind: 'exact',
1227
+ path: NOVEL_API.styleEngine,
1228
+ handler: async (req, res) => {
1229
+ if (!guard(req, res, 'POST')) return
1230
+ const config = getConfig()
1231
+ const project = loadProject(config.outputDir)
1232
+ const body = await readJsonBody<StyleEngineRequest>(req)
1233
+ const sample = body?.sampleText?.trim()
1234
+ if (sample === undefined || sample.length < 50) {
1235
+ writeJson(res, 400, { error: '样本文本过短(<50 字符),请粘贴一段能代表目标风格的文字' })
1236
+ return
1237
+ }
1238
+ try {
1239
+ const rules = await extractStyleAsset(ctx, config, sample)
1240
+ const name = body?.name?.trim() !== '' && body?.name !== undefined ? body.name : `风格资产 ${Date.now().toString(36)}`
1241
+ const styleAsset = {
1242
+ name: name.slice(0, 40),
1243
+ ...rules,
1244
+ sourceText: sample.slice(0, 3000),
1245
+ createdAt: new Date().toISOString(),
1246
+ }
1247
+ if (project !== undefined) {
1248
+ project.assets ??= emptyProjectAssets()
1249
+ project.assets.styleAssets ??= []
1250
+ project.assets.styleAssets.push(styleAsset)
1251
+ project.assets.updatedAt = new Date().toISOString()
1252
+ project.updatedAt = new Date().toISOString()
1253
+ saveProject(config.outputDir, project)
1254
+ }
1255
+ writeJson(res, 200, { styleAsset })
1256
+ } catch (error) {
1257
+ writeJson(res, 500, { error: (error as Error).message })
1258
+ }
1259
+ },
1260
+ }
1261
+
1262
+ // ----------------------------------------------------------- style-formula
1263
+ const styleFormulaRoute: WebRoute = {
1264
+ kind: 'exact',
1265
+ path: NOVEL_API.styleFormula,
1266
+ handler: async (req, res) => {
1267
+ if (!guard(req, res, 'POST')) return
1268
+ const config = getConfig()
1269
+ const project = loadProject(config.outputDir)
1270
+ const body = await readJsonBody<StyleEngineRequest & { depth?: string }>(req)
1271
+ const sample = body?.sampleText?.trim()
1272
+ if (sample === undefined || sample.length < 50) {
1273
+ writeJson(res, 400, { error: '样本文本过短(<50 字符),请粘贴一段能代表目标风格的文字' })
1274
+ return
1275
+ }
1276
+ const depth = (['basic', 'standard', 'deep'] as const).includes(body?.depth as never)
1277
+ ? body!.depth! as 'basic' | 'standard' | 'deep'
1278
+ : 'standard'
1279
+ try {
1280
+ const formula = await extractStyleFormula(ctx, config, sample, depth)
1281
+ const key = `sf-${Date.now().toString(36)}`
1282
+ const styleFormula = { key, ...formula, depth, createdAt: new Date().toISOString() }
1283
+ if (project !== undefined) {
1284
+ project.assets ??= emptyProjectAssets()
1285
+ project.assets.styleFormulas ??= []
1286
+ project.assets.styleFormulas.push(styleFormula)
1287
+ project.assets.updatedAt = new Date().toISOString()
1288
+ project.updatedAt = new Date().toISOString()
1289
+ saveProject(config.outputDir, project)
1290
+ }
1291
+ writeJson(res, 200, { styleFormula })
1292
+ } catch (error) {
1293
+ writeJson(res, 500, { error: (error as Error).message })
1294
+ }
1295
+ },
1296
+ }
1297
+
1298
+ // ----------------------------------------------------------- style-detect
1299
+ /** 正文实时 AI 味检测(本地确定性扫描,免 LLM)。 */
1300
+ const styleDetectRoute: WebRoute = {
1301
+ kind: 'exact',
1302
+ path: NOVEL_API.styleDetect,
1303
+ handler: async (req, res) => {
1304
+ if (!guard(req, res, 'POST')) return
1305
+ const body = await readJsonBody<{ text?: string }>(req)
1306
+ const text = body?.text?.trim() ?? ''
1307
+ if (text.length < 20) {
1308
+ writeJson(res, 400, { error: '文本过短(<20 字符)' })
1309
+ return
1310
+ }
1311
+ const scan = scanAiFlavor(text.slice(0, 20000))
1312
+ writeJson(res, 200, { scan })
1313
+ },
1314
+ }
1315
+
1316
+ // --------------------------------------------------------- market-radar
1317
+ /** 热门题材雷达:输入平台/题材/榜单文本 → 信号 + 生产底座 + 开书创意。 */
1318
+ const marketRadarRoute: WebRoute = {
1319
+ kind: 'exact',
1320
+ path: NOVEL_API.marketRadar,
1321
+ handler: async (req, res) => {
1322
+ if (!guard(req, res, 'POST')) return
1323
+ const config = getConfig()
1324
+ const body = await readJsonBody<MarketRadarRequest>(req)
1325
+ try {
1326
+ const result = await runMarketRadar(ctx, config, body ?? {})
1327
+ writeJson(res, 200, { result })
1328
+ } catch (error) {
1329
+ writeJson(res, 500, { error: (error as Error).message })
1330
+ }
1331
+ },
1332
+ }
1333
+
1334
+ // ------------------------------------------------- market-radar scan
1335
+ /** 真实榜单扫榜:抓取公开榜单(容错),返回分组候选。 */
1336
+ const marketRadarScanRoute: WebRoute = {
1337
+ kind: 'exact',
1338
+ path: NOVEL_API.marketRadarScan,
1339
+ handler: async (req, res) => {
1340
+ if (!guard(req, res, 'POST')) return
1341
+ const body = await readJsonBody<{ platforms?: string[] }>(req)
1342
+ try {
1343
+ const result = await scanMarketRanking(body?.platforms)
1344
+ writeJson(res, 200, { result })
1345
+ } catch (error) {
1346
+ writeJson(res, 500, { error: (error as Error).message })
1347
+ }
1348
+ },
1349
+ }
1350
+
1351
+ // ------------------------------------------------- market-radar apply
1352
+ /** 把雷达生产底座一键应用到某本书(写入项目资产/开书定盘,供后续规划与生成遵守)。 */
1353
+ const marketRadarApplyRoute: WebRoute = {
1354
+ kind: 'exact',
1355
+ path: NOVEL_API.marketRadarApply,
1356
+ handler: async (req, res) => {
1357
+ if (!guard(req, res, 'POST')) return
1358
+ const body = await readJsonBody<{ bookId?: string; foundation?: ProductionFoundation }>(req)
1359
+ const pf = body?.foundation
1360
+ if (pf === undefined) {
1361
+ writeJson(res, 400, { error: '缺少 foundation' })
1362
+ return
1363
+ }
1364
+ const config = getConfig()
1365
+ const outputDir = resolveOutputDir(config, body?.bookId)
1366
+ const project = loadProject(outputDir)
1367
+ if (project === undefined) {
1368
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1369
+ return
1370
+ }
1371
+ const genreBuiltin = BUILTIN_GENRE_LIBRARY.find(g => g.name === (pf.genre.existingId || pf.genre.name))
1372
+ const genreNode: GenreNode = genreBuiltin !== undefined
1373
+ ? { ...genreBuiltin }
1374
+ : { name: pf.genre.name.slice(0, 40), description: pf.genre.description, template: pf.genre.template, children: [] }
1375
+ const findMode = (m: { existingId?: string; name: string }): ProgressionMode | undefined => BUILTIN_PROGRESSION_MODES.find(x => x.name === (m.existingId || m.name))
1376
+ const primary = findMode(pf.primaryStoryMode) ?? {
1377
+ name: pf.primaryStoryMode.name.slice(0, 40),
1378
+ driver: pf.primaryStoryMode.driver,
1379
+ readerExpectation: pf.primaryStoryMode.readerExpectation,
1380
+ payoffs: [],
1381
+ risks: [],
1382
+ primary: true,
1383
+ }
1384
+ const aux: ProgressionMode[] = []
1385
+ if (pf.secondaryStoryMode !== undefined) {
1386
+ const sec = findMode(pf.secondaryStoryMode) ?? {
1387
+ name: pf.secondaryStoryMode.name.slice(0, 40),
1388
+ driver: pf.secondaryStoryMode.driver,
1389
+ readerExpectation: pf.secondaryStoryMode.readerExpectation,
1390
+ payoffs: [],
1391
+ risks: [],
1392
+ primary: false,
1393
+ }
1394
+ aux.push(sec)
1395
+ }
1396
+ project.assets ??= emptyProjectAssets()
1397
+ project.assets.genre = genreNode
1398
+ project.assets.primaryProgression = primary
1399
+ project.assets.auxiliaryProgressions = aux
1400
+ project.bookContract = {
1401
+ promise: pf.genre.description.slice(0, 120),
1402
+ primaryModeName: pf.primaryStoryMode.name,
1403
+ secondaryModeNames: pf.secondaryStoryMode !== undefined ? [pf.secondaryStoryMode.name] : [],
1404
+ tone: pf.genre.template,
1405
+ targetPlatform: undefined,
1406
+ }
1407
+ project.updatedAt = new Date().toISOString()
1408
+ saveProject(outputDir, project)
1409
+ writeJson(res, 200, { ok: true, bookName: project.bookName })
1410
+ },
1411
+ }
1412
+
1413
+ // ------------------------------------------------- market-radar sync
1414
+ /** 把雷达生产底座里的「新资产」同步进全局资源库(跨书复用;已有名字跳过)。 */
1415
+ const marketRadarSyncRoute: WebRoute = {
1416
+ kind: 'exact',
1417
+ path: NOVEL_API.marketRadarSync,
1418
+ handler: async (req, res) => {
1419
+ if (!guard(req, res, 'POST')) return
1420
+ const body = await readJsonBody<{ foundation?: ProductionFoundation }>(req)
1421
+ const pf = body?.foundation
1422
+ if (pf === undefined) {
1423
+ writeJson(res, 400, { error: '缺少 foundation' })
1424
+ return
1425
+ }
1426
+ const synced = { genre: false, primaryMode: false, secondaryMode: false }
1427
+ if (pf.genre.existingId === undefined && pf.genre.name !== '') {
1428
+ synced.genre = addGlobalGenre({ name: pf.genre.name.slice(0, 40), description: pf.genre.description, template: pf.genre.template, children: [] })
1429
+ }
1430
+ if (pf.primaryStoryMode.existingId === undefined && pf.primaryStoryMode.name !== '') {
1431
+ synced.primaryMode = addGlobalMode({ name: pf.primaryStoryMode.name.slice(0, 40), driver: pf.primaryStoryMode.driver, readerExpectation: pf.primaryStoryMode.readerExpectation, payoffs: [], risks: [], primary: true })
1432
+ }
1433
+ if (pf.secondaryStoryMode !== undefined && pf.secondaryStoryMode !== null && pf.secondaryStoryMode.existingId === undefined && pf.secondaryStoryMode.name !== '') {
1434
+ synced.secondaryMode = addGlobalMode({ name: pf.secondaryStoryMode.name.slice(0, 40), driver: pf.secondaryStoryMode.driver, readerExpectation: pf.secondaryStoryMode.readerExpectation, payoffs: [], risks: [], primary: false })
1435
+ }
1436
+ writeJson(res, 200, { ok: true, synced })
1437
+ },
1438
+ }
1439
+
1440
+ // ------------------------------------------------- market-radar brief
1441
+ /** 开书创意简报:用选中的市场信号 + 影响模式生成。 */
1442
+ const marketRadarBriefRoute: WebRoute = {
1443
+ kind: 'exact',
1444
+ path: NOVEL_API.marketRadarBrief,
1445
+ handler: async (req, res) => {
1446
+ if (!guard(req, res, 'POST')) return
1447
+ const config = getConfig()
1448
+ const body = await readJsonBody<MarketRadarBriefRequest>(req)
1449
+ if (body === undefined || body.influenceMode === undefined) {
1450
+ writeJson(res, 400, { error: '缺少 influenceMode' })
1451
+ return
1452
+ }
1453
+ try {
1454
+ const creativeBrief = await runMarketCreativeBrief(ctx, config, body)
1455
+ writeJson(res, 200, { creativeBrief })
1456
+ } catch (error) {
1457
+ writeJson(res, 500, { error: (error as Error).message })
1458
+ }
1459
+ },
1460
+ }
1461
+
1462
+ // ------------------------------------------------------------- knowledge
1463
+ /** 书内知识库:GET 读取 / POST 增删改(按书路由)。 */
1464
+ const knowledgeRoute: WebRoute = {
1465
+ kind: 'exact',
1466
+ path: NOVEL_API.knowledge,
1467
+ handler: async (req, res) => {
1468
+ if (req.method === 'POST') {
1469
+ if (!guard(req, res, 'POST')) return
1470
+ } else {
1471
+ if (!guard(req, res, 'GET')) return
1472
+ }
1473
+ const config = getConfig()
1474
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1475
+ const body = req.method === 'POST' ? await readJsonBody<KnowledgeRequest & { bookId?: string }>(req) : undefined
1476
+ const outputDir = resolveOutputDir(config, body?.bookId ?? qurl.searchParams.get('bookId') ?? undefined)
1477
+ const project = loadProject(outputDir)
1478
+ if (project === undefined) {
1479
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1480
+ return
1481
+ }
1482
+ project.knowledgeDocs ??= []
1483
+ if (req.method === 'POST' && body !== undefined) {
1484
+ if (body.action === 'add' && body.doc !== undefined) {
1485
+ project.knowledgeDocs.push({ id: body.doc.id ?? `kd-${Date.now().toString(36)}`, title: body.doc.title, content: body.doc.content, updatedAt: new Date().toISOString() })
1486
+ } else if (body.action === 'remove' && body.id !== undefined) {
1487
+ project.knowledgeDocs = project.knowledgeDocs.filter(d => d.id !== body.id)
1488
+ } else if (body.action === 'replace' && body.doc !== undefined) {
1489
+ 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)
1490
+ }
1491
+ project.updatedAt = new Date().toISOString()
1492
+ saveProject(outputDir, project)
1493
+ }
1494
+ writeJson(res, 200, { docs: project.knowledgeDocs })
1495
+ },
1496
+ }
1497
+
1498
+ // ---------------------------------------------------------- book-analysis
1499
+ /** 书分析/拆书:输入文本 → 卖点/结构/可借鉴点/风险。 */
1500
+ const bookAnalysisRoute: WebRoute = {
1501
+ kind: 'exact',
1502
+ path: NOVEL_API.bookAnalysis,
1503
+ handler: async (req, res) => {
1504
+ if (!guard(req, res, 'POST')) return
1505
+ const config = getConfig()
1506
+ const body = await readJsonBody<BookAnalysisRequest>(req)
1507
+ if (body === undefined || (body.text ?? '').trim().length < 50) {
1508
+ writeJson(res, 400, { error: '文本过短(<50 字符)' })
1509
+ return
1510
+ }
1511
+ try {
1512
+ const result = await runBookAnalysis(ctx, config, body)
1513
+ writeJson(res, 200, { result })
1514
+ } catch (error) {
1515
+ writeJson(res, 500, { error: (error as Error).message })
1516
+ }
1517
+ },
1518
+ }
1519
+
1520
+ // ---------------------------------------------------------- idea-inspiration
1521
+ /** 创意灵感:一句话/题材 → 多方向开书灵感。 */
1522
+ const ideaInspirationRoute: WebRoute = {
1523
+ kind: 'exact',
1524
+ path: NOVEL_API.ideaInspiration,
1525
+ handler: async (req, res) => {
1526
+ if (!guard(req, res, 'POST')) return
1527
+ const config = getConfig()
1528
+ const body = await readJsonBody<IdeaInspirationRequest>(req)
1529
+ if (body === undefined || (body.idea ?? '').trim().length < 2) {
1530
+ writeJson(res, 400, { error: '请填写你的方向/一句话想法' })
1531
+ return
1532
+ }
1533
+ try {
1534
+ const result = await runIdeaInspiration(ctx, config, body)
1535
+ writeJson(res, 200, { result })
1536
+ } catch (error) {
1537
+ writeJson(res, 500, { error: (error as Error).message })
1538
+ }
1539
+ },
1540
+ }
1541
+
1542
+ // ------------------------------------------------------- idea-inspiration/market
1543
+ /** 雷达→灵感:基于市场信号/生产底座/创意简报生成开书灵感。 */
1544
+ const marketIdeaInspirationRoute: WebRoute = {
1545
+ kind: 'exact',
1546
+ path: NOVEL_API.ideaInspirationMarket,
1547
+ handler: async (req, res) => {
1548
+ if (!guard(req, res, 'POST')) return
1549
+ const config = getConfig()
1550
+ const body = await readJsonBody<{ signals?: import('./protocol.ts').MarketRadarSignal[]; foundation?: import('./protocol.ts').ProductionFoundation; brief?: import('./protocol.ts').MarketCreativeBrief; count?: number }>(req)
1551
+ if (body === undefined) {
1552
+ writeJson(res, 400, { error: '缺少市场分析参数' })
1553
+ return
1554
+ }
1555
+ try {
1556
+ const result = await runMarketIdeaInspiration(ctx, config, body)
1557
+ writeJson(res, 200, { result })
1558
+ } catch (error) {
1559
+ writeJson(res, 500, { error: (error as Error).message })
1560
+ }
1561
+ },
1562
+ }
1563
+
1564
+ // ------------------------------------------------------------- director
1565
+ /** 自动导演编排建议:基于全书上下文。 */
1566
+ const directorRoute: WebRoute = {
1567
+ kind: 'exact',
1568
+ path: NOVEL_API.director,
1569
+ handler: async (req, res) => {
1570
+ if (!guard(req, res, 'POST')) return
1571
+ const config = getConfig()
1572
+ const body = await readJsonBody<DirectorRequest & { bookId?: string }>(req)
1573
+ const outputDir = resolveOutputDir(config, body?.bookId)
1574
+ const project = loadProject(outputDir)
1575
+ if (project === undefined) {
1576
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1577
+ return
1578
+ }
1579
+ try {
1580
+ const result = await runDirectorAdvice(ctx, config, project, body ?? {})
1581
+ writeJson(res, 200, { result })
1582
+ } catch (error) {
1583
+ writeJson(res, 500, { error: (error as Error).message })
1584
+ }
1585
+ },
1586
+ }
1587
+
1588
+ // ------------------------------------------------------------- director/todos
1589
+ /** 自动导演「采纳」出的书内待办:GET 读取 / POST 增删勾选(按书路由)。 */
1590
+ const directorTodosRoute: WebRoute = {
1591
+ kind: 'exact',
1592
+ path: NOVEL_API.directorTodos,
1593
+ handler: async (req, res) => {
1594
+ type DirectorTodosReq = { op?: 'add' | 'toggle' | 'remove'; text?: string; source?: DirectorTodo['source']; id?: string; bookId?: string }
1595
+ let body: DirectorTodosReq | undefined
1596
+ if (req.method === 'POST') {
1597
+ if (!guard(req, res, 'POST')) return
1598
+ body = await readJsonBody<DirectorTodosReq>(req)
1599
+ } else {
1600
+ if (!guard(req, res, 'GET')) return
1601
+ }
1602
+ const config = getConfig()
1603
+ const outputDir = resolveOutputDir(config, body?.bookId)
1604
+ const project = loadProject(outputDir)
1605
+ if (project === undefined) {
1606
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1607
+ return
1608
+ }
1609
+ project.todos ??= []
1610
+ if (req.method === 'POST' && body !== undefined) {
1611
+ if (body.op === 'add' && (body.text ?? '').trim() !== '') {
1612
+ 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() })
1613
+ } else if (body.op === 'toggle' && body.id !== undefined) {
1614
+ project.todos = project.todos.map(t => t.id === body.id ? { ...t, done: !t.done } : t)
1615
+ } else if (body.op === 'remove' && body.id !== undefined) {
1616
+ project.todos = project.todos.filter(t => t.id !== body.id)
1617
+ }
1618
+ project.updatedAt = new Date().toISOString()
1619
+ saveProject(outputDir, project)
1620
+ }
1621
+ writeJson(res, 200, { todos: project.todos })
1622
+ },
1623
+ }
1624
+
1625
+ // ------------------------------------------------------------- llm-live
1626
+ /** AI 创作实况:订阅 LLM 实时事件流(NDJSON;订阅时先重放最近缓冲)。 */
1627
+ const llmLiveRoute: WebRoute = {
1628
+ kind: 'exact',
1629
+ path: NOVEL_API.llmLive,
1630
+ handler: async (req, res) => {
1631
+ if (req.method !== 'GET') {
1632
+ writeJson(res, 405, { error: 'GET only' })
1633
+ return
1634
+ }
1635
+ if (!isLoopbackRequest(req)) {
1636
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
1637
+ return
1638
+ }
1639
+ res.writeHead(200, {
1640
+ 'content-type': 'text/event-stream; charset=utf-8',
1641
+ 'cache-control': 'no-cache',
1642
+ 'x-accel-buffering': 'no',
1643
+ 'referrer-policy': 'no-referrer',
1644
+ })
1645
+ const unsubscribe = subscribeLiveFeed((frame) => {
1646
+ try { res.write('data: ' + JSON.stringify(frame) + '\n\n') } catch { /* client closed */ }
1647
+ })
1648
+ const hb = setInterval(() => { try { res.write(': ping\n\n') } catch { /* closed */ } }, 25000)
1649
+ // 关键:让 handler 一直挂起,直到客户端关闭,避免 WebRoute 框架在 handler 返回后关闭响应。
1650
+ await new Promise<void>((resolve) => {
1651
+ res.on('close', () => {
1652
+ clearInterval(hb)
1653
+ unsubscribe()
1654
+ resolve()
1655
+ })
1656
+ })
1657
+ },
1658
+ }
1659
+
1660
+ // ------------------------------------------------------------- bookshelf
1661
+ const bookshelfRoute: WebRoute = {
1662
+ kind: 'exact',
1663
+ path: NOVEL_API.bookshelf,
1664
+ handler: async (req, res) => {
1665
+ // GET = snapshot; POST = create book.
1666
+ if (req.method === 'GET') {
1667
+ if (!isLoopbackRequest(req)) {
1668
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
1669
+ return
1670
+ }
1671
+ // 书架为空时,把 settings 默认输出目录里已有的项目播种为第一本书。
1672
+ seedBookshelfFromOutputDir(getConfig().outputDir)
1673
+ const snapshot: BookshelfSnapshot = bookshelfSnapshot(loadBookshelf())
1674
+ writeJson(res, 200, snapshot)
1675
+ return
1676
+ }
1677
+ if (req.method === 'POST') {
1678
+ if (!isLoopbackRequest(req)) {
1679
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
1680
+ return
1681
+ }
1682
+ const body = await readJsonBody<BookCreateRequest>(req)
1683
+ const bookName = body?.bookName?.trim()
1684
+ if (bookName === undefined || bookName === '') {
1685
+ writeJson(res, 400, { error: 'bookName 不能为空' })
1686
+ return
1687
+ }
1688
+ const outputDir = body?.outputDir?.trim() !== '' && body?.outputDir !== undefined
1689
+ ? body.outputDir
1690
+ : defaultOutputDirFor(bookName)
1691
+ const book = createBook(bookName, outputDir)
1692
+ // 开书向导:创建时带大纲 → 立即建立项目(书名以大纲首行为准)。
1693
+ const outline = body?.outline?.trim()
1694
+ if (outline !== undefined && outline.length >= 50) {
1695
+ const project = createProject(outline)
1696
+ saveProject(outputDir, project)
1697
+ renameBook(book.id, project.bookName)
1698
+ }
1699
+ writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
1700
+ return
1701
+ }
1702
+ writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
1703
+ },
1704
+ }
1705
+
1706
+ // --------------------------------------------------------------- reset
1707
+ /** 重置项目:清空设定/卷/章节计划/正文/伏笔/资产/事实库(可携带新大纲)。 */
1708
+ const resetRoute: WebRoute = {
1709
+ kind: 'exact',
1710
+ path: NOVEL_API.reset,
1711
+ handler: async (req, res) => {
1712
+ if (!guard(req, res, 'POST')) return
1713
+ const config = getConfig()
1714
+ const project = loadProject(config.outputDir)
1715
+ if (project === undefined) {
1716
+ writeJson(res, 400, { error: '输出目录中没有项目,无需重置' })
1717
+ return
1718
+ }
1719
+ const body = await readJsonBody<ResetRequest>(req)
1720
+ const outline = body?.outline?.trim()
1721
+ if (outline !== undefined && outline.length >= 50) {
1722
+ project.outline = outline
1723
+ project.bookName = createProject(outline).bookName
1724
+ }
1725
+ project.bible = undefined
1726
+ project.volumes = undefined
1727
+ project.chapters = []
1728
+ project.foreshadows = []
1729
+ project.assets = emptyProjectAssets()
1730
+ project.facts = []
1731
+ project.updatedAt = new Date().toISOString()
1732
+ saveProject(config.outputDir, project)
1733
+ writeJson(res, 200, { ok: true, bookName: project.bookName })
1734
+ },
1735
+ }
1736
+
1737
+ // --------------------------------------------------- bookshelf activate
1738
+ const bookshelfActivateRoute: WebRoute = {
1739
+ kind: 'exact',
1740
+ path: '/api/dsh-novel-forge/bookshelf/activate',
1741
+ handler: async (req, res) => {
1742
+ if (!guard(req, res, 'POST')) return
1743
+ const body = await readJsonBody<BookActivateRequest>(req)
1744
+ if (body?.id === undefined || body.id === '') {
1745
+ writeJson(res, 400, { error: 'id 不能为空' })
1746
+ return
1747
+ }
1748
+ const book = activateBook(body.id)
1749
+ if (book === undefined) {
1750
+ writeJson(res, 404, { error: `书 ${body.id} 不存在` })
1751
+ return
1752
+ }
1753
+ writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
1754
+ },
1755
+ }
1756
+
1757
+ // ---------------------------------------------------- bookshelf remove
1758
+ const bookshelfRemoveRoute: WebRoute = {
1759
+ kind: 'exact',
1760
+ path: '/api/dsh-novel-forge/bookshelf/remove',
1761
+ handler: async (req, res) => {
1762
+ if (!guard(req, res, 'POST')) return
1763
+ const body = await readJsonBody<BookRemoveRequest>(req)
1764
+ if (body?.id === undefined || body.id === '') {
1765
+ writeJson(res, 400, { error: 'id 不能为空' })
1766
+ return
1767
+ }
1768
+ const removed = removeBook(body.id)
1769
+ if (!removed) {
1770
+ writeJson(res, 404, { error: `书 ${body.id} 不存在` })
1771
+ return
1772
+ }
1773
+ writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
1774
+ },
1775
+ }
1776
+
1777
+ // ------------------------------------------- bookshelf import-dir
1778
+ /** 导入已有项目目录(Mode A):校验 novel-project.json,登记/激活书架。 */
1779
+ const bookshelfImportDirRoute: WebRoute = {
1780
+ kind: 'exact',
1781
+ path: NOVEL_API.bookshelfImportDir,
1782
+ handler: async (req, res) => {
1783
+ if (!guard(req, res, 'POST')) return
1784
+ const body = await readJsonBody<BookImportDirRequest>(req)
1785
+ const outputDir = body?.outputDir?.trim()
1786
+ if (outputDir === undefined || outputDir === '') {
1787
+ writeJson(res, 400, { error: 'outputDir 不能为空' })
1788
+ return
1789
+ }
1790
+ try {
1791
+ const { book, existed } = importDir(outputDir)
1792
+ writeJson(res, 200, { book, existed })
1793
+ } catch (err) {
1794
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
1795
+ }
1796
+ },
1797
+ }
1798
+
1799
+ // ------------------------------------------- bookshelf import-text
1800
+ /** 导入 txt/md 全本(Mode B):拆章落盘建项目,登记书架。支持浏览器上传(text+fileName)与服务器本地文件(filePath)两种模式。 */
1801
+ const bookshelfImportTextRoute: WebRoute = {
1802
+ kind: 'exact',
1803
+ path: NOVEL_API.bookshelfImportText,
1804
+ handler: async (req, res) => {
1805
+ if (!guard(req, res, 'POST')) return
1806
+ const body = await readJsonBody<BookImportTextRequest>(req)
1807
+ try {
1808
+ // 模式二:浏览器上传全文内容
1809
+ if (body?.text !== undefined && body.text.length > 0) {
1810
+ const bookName = (body.fileName ?? '').replace(/\.[^.]+$/, '').trim().slice(0, 40) || '导入小说'
1811
+ const outDir = body?.outputDir?.trim() !== undefined && body.outputDir.trim() !== ''
1812
+ ? body.outputDir.trim()
1813
+ : defaultOutputDirFor(bookName)
1814
+ const result = importBookTextFromText(body.text, outDir, bookName)
1815
+ const { book } = importDir(outDir)
1816
+ writeJson(res, 200, { ...result, book })
1817
+ return
1818
+ }
1819
+ // 模式一:服务器本地文件
1820
+ const filePath = body?.filePath?.trim()
1821
+ if (filePath === undefined || filePath === '') {
1822
+ writeJson(res, 400, { error: 'filePath 不能为空(或请上传文件内容)' })
1823
+ return
1824
+ }
1825
+ if (!existsSync(filePath)) {
1826
+ writeJson(res, 400, { error: `文件不存在:${filePath}` })
1827
+ return
1828
+ }
1829
+ const bookName = basename(filePath, extname(filePath)).slice(0, 40) || '导入小说'
1830
+ const outDir = body?.outputDir?.trim() !== undefined && body.outputDir.trim() !== ''
1831
+ ? body.outputDir.trim()
1832
+ : defaultOutputDirFor(bookName)
1833
+ const result = importBookText(filePath, outDir)
1834
+ const { book } = importDir(outDir)
1835
+ writeJson(res, 200, { ...result, book })
1836
+ } catch (err) {
1837
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
1838
+ }
1839
+ },
1840
+ }
1841
+
1842
+ // --------------------------------------- bookshelf import-text preview
1843
+ /** 上传全文做拆章预览(不落盘、不登记书架)。 */
1844
+ const bookshelfImportTextPreviewRoute: WebRoute = {
1845
+ kind: 'exact',
1846
+ path: NOVEL_API.bookshelfImportTextPreview,
1847
+ handler: async (req, res) => {
1848
+ if (!guard(req, res, 'POST')) return
1849
+ const body = await readJsonBody<BookImportTextPreviewRequest>(req)
1850
+ if (body?.text === undefined || body.text.length === 0) {
1851
+ writeJson(res, 400, { error: 'text 不能为空' })
1852
+ return
1853
+ }
1854
+ try {
1855
+ const bookName = (body.fileName ?? '').replace(/\.[^.]+$/, '').trim().slice(0, 40) || '导入小说'
1856
+ const preview = previewBookText(body.text)
1857
+ writeJson(res, 200, { bookName, ...preview })
1858
+ } catch (err) {
1859
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
1860
+ }
1861
+ },
1862
+ }
1863
+
1864
+ // ---------------------------------------------------------------- audit
1865
+ /** 全书一致性质检。 */
1866
+ const auditRoute: WebRoute = {
1867
+ kind: 'exact',
1868
+ path: NOVEL_API.audit,
1869
+ handler: async (req, res) => {
1870
+ if (!guard(req, res, 'POST')) return
1871
+ const body = await readJsonBody<{ bookId?: string }>(req)
1872
+ const config0 = getConfig()
1873
+ const outputDir = resolveOutputDir(config0, body?.bookId)
1874
+ const config = { ...config0, outputDir }
1875
+ const project = loadProject(outputDir)
1876
+ if (project === undefined) {
1877
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1878
+ return
1879
+ }
1880
+ const startedAt = new Date().toISOString()
1881
+ auditState = { status: 'running', startedAt, totalBatches: 0, completedBatches: 0 }
1882
+ try {
1883
+ const issues = await auditBook(ctx, config, project, config.outputDir, (completed, total) => {
1884
+ auditState.totalBatches = total
1885
+ auditState.completedBatches = completed
1886
+ })
1887
+ const auditedChapters = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating').length
1888
+ auditState = {
1889
+ status: 'done',
1890
+ startedAt,
1891
+ finishedAt: new Date().toISOString(),
1892
+ totalBatches: auditState.totalBatches,
1893
+ completedBatches: auditState.totalBatches,
1894
+ auditedChapters,
1895
+ issuesCount: issues.length,
1896
+ issues,
1897
+ }
1898
+ const response: AuditResponse = {
1899
+ issues,
1900
+ auditedChapters,
1901
+ auditedAt: auditState.finishedAt!,
1902
+ model: config.auditModel || config.model,
1903
+ }
1904
+ writeJson(res, 200, response)
1905
+ } catch (error) {
1906
+ auditState = {
1907
+ ...auditState,
1908
+ status: 'error',
1909
+ finishedAt: new Date().toISOString(),
1910
+ error: (error as Error).message,
1911
+ }
1912
+ writeJson(res, 500, { error: (error as Error).message })
1913
+ }
1914
+ },
1915
+ }
1916
+
1917
+ // ----------------------------------------------------- characters refresh
1918
+ /** 角色卡刷新(出场统计精确化 + LLM 聚合状态)。 */
1919
+ const charactersRefreshRoute: WebRoute = {
1920
+ kind: 'exact',
1921
+ path: NOVEL_API.charactersRefresh,
1922
+ handler: async (req, res) => {
1923
+ if (!guard(req, res, 'POST')) return
1924
+ const config = getConfig()
1925
+ const project = requireProject(res)
1926
+ if (project === undefined) return
1927
+ try {
1928
+ const cards = await refreshCharacters(ctx, config, project, config.outputDir)
1929
+ // 存档:人物志聚合结果落盘,下次打开直接显示,无需重新计算。
1930
+ project.roleStatus = cards
1931
+ project.updatedAt = new Date().toISOString()
1932
+ saveProject(config.outputDir, project)
1933
+ writeJson(res, 200, { cards })
1934
+ } catch (error) {
1935
+ writeJson(res, 500, { error: (error as Error).message })
1936
+ }
1937
+ },
1938
+ }
1939
+
1940
+ // ------------------------------------------------------- facts backfill
1941
+ /** 事实库回填:对历史已生成章节批量抽取事实。 */
1942
+ const factsBackfillRoute: WebRoute = {
1943
+ kind: 'exact',
1944
+ path: NOVEL_API.factsBackfill,
1945
+ handler: async (req, res) => {
1946
+ if (!guard(req, res, 'POST')) return
1947
+ const config = getConfig()
1948
+ const project = requireProject(res)
1949
+ if (project === undefined) return
1950
+ try {
1951
+ const filled = await backfillFacts(ctx, config, project, config.outputDir)
1952
+ writeJson(res, 200, { ok: true, filled })
1953
+ } catch (error) {
1954
+ writeJson(res, 500, { error: (error as Error).message })
1955
+ }
1956
+ },
1957
+ }
1958
+
1959
+ // ---------------------------------------------------------- bible patch
1960
+ /** 道藏局部修补(世界观规则/红线/风格)。 */
1961
+ const biblePatchRoute: WebRoute = {
1962
+ kind: 'exact',
1963
+ path: NOVEL_API.biblePatch,
1964
+ handler: async (req, res) => {
1965
+ if (!guard(req, res, 'POST')) return
1966
+ const config = getConfig()
1967
+ const project = requireProject(res)
1968
+ if (project === undefined) return
1969
+ if (project.bible === undefined) {
1970
+ writeJson(res, 400, { error: '尚未生成道藏,请先生成' })
1971
+ return
1972
+ }
1973
+ const body = await readJsonBody<BiblePatchRequest>(req)
1974
+ if (Array.isArray(body?.worldRules)) project.bible.worldRules = body.worldRules.filter(r => r.trim() !== '')
1975
+ if (Array.isArray(body?.redLines)) project.bible.redLines = body.redLines.filter(r => r.trim() !== '')
1976
+ if (Array.isArray(body?.style)) project.bible.style = body.style.filter(r => r.trim() !== '')
1977
+ if (Array.isArray(body?.characters)) {
1978
+ project.bible.characters = body.characters
1979
+ .filter(c => c !== undefined && c !== null && typeof c.name === 'string' && c.name.trim() !== '')
1980
+ .map(c => ({
1981
+ name: c.name.trim(),
1982
+ role: (['protagonist', 'supporting', 'antagonist', 'other'] as const).includes(c.role as never)
1983
+ ? c.role as StoryBible['characters'][number]['role']
1984
+ : 'other',
1985
+ traits: Array.isArray(c.traits) ? c.traits.filter((t): t is string => typeof t === 'string' && t.trim() !== '') : [],
1986
+ goals: typeof c.goals === 'string' ? c.goals : '',
1987
+ relations: typeof c.relations === 'string' ? c.relations : '',
1988
+ knowledge: Array.isArray(c.knowledge)
1989
+ ? c.knowledge.filter((k): k is string => typeof k === 'string' && k.trim() !== '')
1990
+ : undefined,
1991
+ }))
1992
+ }
1993
+ project.updatedAt = new Date().toISOString()
1994
+ saveProject(config.outputDir, project)
1995
+ writeJson(res, 200, { bible: project.bible })
1996
+ },
1997
+ }
1998
+
1999
+ // ---------------------------------------------------------------- blurb
2000
+ /** 小说简介:AI 生成/补全,或手动保存。 */
2001
+ const blurbRoute: WebRoute = {
2002
+ kind: 'exact',
2003
+ path: NOVEL_API.blurb,
2004
+ handler: async (req, res) => {
2005
+ if (!guard(req, res, 'POST')) return
2006
+ const config = getConfig()
2007
+ const project = requireProject(res)
2008
+ if (project === undefined) return
2009
+ const body = await readJsonBody<BlurbRequest>(req)
2010
+ try {
2011
+ if (body?.action === 'save') {
2012
+ const text = body.text?.trim() ?? ''
2013
+ project.blurb = text
2014
+ project.updatedAt = new Date().toISOString()
2015
+ saveProject(config.outputDir, project)
2016
+ writeJson(res, 200, { blurb: text })
2017
+ return
2018
+ }
2019
+ const partial = body?.partial?.trim() ?? ''
2020
+ const blurb = await generateBlurb(ctx, config, project, partial)
2021
+ project.blurb = blurb
2022
+ project.updatedAt = new Date().toISOString()
2023
+ saveProject(config.outputDir, project)
2024
+ writeJson(res, 200, { blurb })
2025
+ } catch (error) {
2026
+ writeJson(res, 500, { error: (error as Error).message })
2027
+ }
2028
+ },
2029
+ }
2030
+
2031
+ // ---------------------------------------------------------------- cover
2032
+ /** 封面:GET 读取(dataUrl);POST 上传(base64)或移除。 */
2033
+ const coverRoute: WebRoute = {
2034
+ kind: 'exact',
2035
+ path: NOVEL_API.cover,
2036
+ handler: async (req, res) => {
2037
+ const config = getConfig()
2038
+ if (req.method === 'GET') {
2039
+ if (!isLoopbackRequest(req)) {
2040
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
2041
+ return
2042
+ }
2043
+ // 书架页按书读取:?dir=<输出目录>;省略时用当前激活书目录。
2044
+ const url = new URL(req.url ?? '/', 'http://localhost')
2045
+ const dirParam = url.searchParams.get('dir')
2046
+ const targetDir = dirParam !== null && dirParam !== '' ? dirParam : config.outputDir
2047
+ const project = loadProject(targetDir)
2048
+ const coverPath = project?.coverPath
2049
+ if (coverPath === undefined || coverPath === '') {
2050
+ writeJson(res, 200, { dataUrl: null } satisfies CoverResponse)
2051
+ return
2052
+ }
2053
+ const file = join(targetDir, coverPath)
2054
+ if (!existsSync(file)) {
2055
+ writeJson(res, 200, { dataUrl: null } satisfies CoverResponse)
2056
+ return
2057
+ }
2058
+ const mime = coverPath.toLowerCase().endsWith('.png') ? 'image/png'
2059
+ : coverPath.toLowerCase().endsWith('.jpg') || coverPath.toLowerCase().endsWith('.jpeg') ? 'image/jpeg'
2060
+ : coverPath.toLowerCase().endsWith('.webp') ? 'image/webp'
2061
+ : 'image/png'
2062
+ const dataUrl = `data:${mime};base64,${readFileSync(file).toString('base64')}`
2063
+ writeJson(res, 200, { dataUrl } satisfies CoverResponse)
2064
+ return
2065
+ }
2066
+ if (req.method === 'POST') {
2067
+ if (!guard(req, res, 'POST')) return
2068
+ const project = requireProject(res)
2069
+ if (project === undefined) return
2070
+ const body = await readJsonBody<CoverRequest>(req)
2071
+ try {
2072
+ if (body?.action === 'remove') {
2073
+ if (project.coverPath !== undefined && project.coverPath !== '') {
2074
+ const oldFile = join(config.outputDir, project.coverPath)
2075
+ if (existsSync(oldFile)) rmSync(oldFile, { force: true })
2076
+ }
2077
+ project.coverPath = undefined
2078
+ project.updatedAt = new Date().toISOString()
2079
+ saveProject(config.outputDir, project)
2080
+ writeJson(res, 200, { ok: true, coverPath: null })
2081
+ return
2082
+ }
2083
+ const dataUrl = body?.dataUrl ?? ''
2084
+ const match = /^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/s.exec(dataUrl)
2085
+ if (match === null) {
2086
+ writeJson(res, 400, { error: '封面须为 PNG/JPEG/WebP 的 base64 data URL' })
2087
+ return
2088
+ }
2089
+ const mime = match[1]!
2090
+ const ext = mime === 'image/png' ? 'png' : mime === 'image/webp' ? 'webp' : 'jpg'
2091
+ const fileName = `cover.${ext}`
2092
+ const targetPath = join(config.outputDir, fileName)
2093
+ const oldPath = project.coverPath
2094
+ mkdirSync(config.outputDir, { recursive: true })
2095
+ writeFileSync(targetPath, Buffer.from(match[2]!, 'base64'))
2096
+ if (oldPath !== undefined && oldPath !== '' && oldPath !== fileName) {
2097
+ const oldFile = join(config.outputDir, oldPath)
2098
+ if (existsSync(oldFile)) rmSync(oldFile, { force: true })
2099
+ }
2100
+ project.coverPath = fileName
2101
+ project.updatedAt = new Date().toISOString()
2102
+ saveProject(config.outputDir, project)
2103
+ writeJson(res, 200, { ok: true, coverPath: fileName })
2104
+ } catch (error) {
2105
+ writeJson(res, 500, { error: (error as Error).message })
2106
+ }
2107
+ return
2108
+ }
2109
+ writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
2110
+ },
2111
+ }
2112
+
2113
+ // ---------------------------------------------------------------- world
2114
+ /** 大世界:AI 提炼或手动保存(境界体系/区域/势力)。 */
2115
+ const worldRoute: WebRoute = {
2116
+ kind: 'exact',
2117
+ path: NOVEL_API.world,
2118
+ handler: async (req, res) => {
2119
+ if (!guard(req, res, 'POST')) return
2120
+ const config = getConfig()
2121
+ const project = requireProject(res)
2122
+ if (project === undefined) return
2123
+ const body = await readJsonBody<WorldRequest>(req)
2124
+ try {
2125
+ if (body?.action === 'save' && body.world !== undefined) {
2126
+ project.world = {
2127
+ realms: Array.isArray(body.world.realms) ? body.world.realms : [],
2128
+ regions: Array.isArray(body.world.regions) ? body.world.regions : [],
2129
+ factions: Array.isArray(body.world.factions) ? body.world.factions : [],
2130
+ }
2131
+ project.updatedAt = new Date().toISOString()
2132
+ saveProject(config.outputDir, project)
2133
+ writeJson(res, 200, { world: project.world })
2134
+ return
2135
+ }
2136
+ const world = await extractWorld(ctx, config, project)
2137
+ project.world = world
2138
+ project.updatedAt = new Date().toISOString()
2139
+ saveProject(config.outputDir, project)
2140
+ writeJson(res, 200, { world })
2141
+ } catch (error) {
2142
+ writeJson(res, 500, { error: (error as Error).message })
2143
+ }
2144
+ },
2145
+ }
2146
+
2147
+ // --------------------------------------------------------------- rename
2148
+ /** 重命名当前书:同步项目 bookName 与书架条目。 */
2149
+ const renameRoute: WebRoute = {
2150
+ kind: 'exact',
2151
+ path: NOVEL_API.rename,
2152
+ handler: async (req, res) => {
2153
+ if (!guard(req, res, 'POST')) return
2154
+ const config = getConfig()
2155
+ const project = requireProject(res)
2156
+ if (project === undefined) return
2157
+ const body = await readJsonBody<RenameRequest>(req)
2158
+ const bookName = body?.bookName?.trim()
2159
+ if (bookName === undefined || bookName === '') {
2160
+ writeJson(res, 400, { error: '书名不能为空' })
2161
+ return
2162
+ }
2163
+ project.bookName = bookName.slice(0, 60)
2164
+ project.updatedAt = new Date().toISOString()
2165
+ saveProject(config.outputDir, project)
2166
+ const store = loadBookshelf()
2167
+ if (store.activeBookId !== null) renameBook(store.activeBookId, project.bookName)
2168
+ writeJson(res, 200, { bookName: project.bookName })
2169
+ },
2170
+ }
2171
+
2172
+ // ------------------------------------------------------------- plotlines
2173
+ /** 剧情线管理:增删改 + 关联章节(主线/支线/人物线/悬念线)。 */
2174
+ const plotlinesRoute: WebRoute = {
2175
+ kind: 'exact',
2176
+ path: NOVEL_API.plotlines,
2177
+ handler: async (req, res) => {
2178
+ if (!guard(req, res, 'POST')) return
2179
+ const config = getConfig()
2180
+ const project = requireProject(res)
2181
+ if (project === undefined) return
2182
+ const body = await readJsonBody<PlotlinesRequest>(req)
2183
+ if (project.plotlines === undefined) project.plotlines = []
2184
+ const op = body?.op
2185
+ if (op === 'add' && body?.line !== undefined) {
2186
+ const line = body.line
2187
+ project.plotlines.push({
2188
+ id: line.id !== '' ? line.id : `pl-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
2189
+ name: line.name.slice(0, 40),
2190
+ kind: line.kind,
2191
+ goal: line.goal.slice(0, 300),
2192
+ progress: line.progress.slice(0, 300),
2193
+ status: line.status,
2194
+ chapters: Array.isArray(line.chapters) ? line.chapters.filter((n): n is number => typeof n === 'number') : [],
2195
+ createdAt: line.createdAt !== '' ? line.createdAt : new Date().toISOString(),
2196
+ })
2197
+ } else if (op === 'update' && body?.line !== undefined && body.line.id !== '') {
2198
+ const idx = project.plotlines.findIndex(l => l.id === body.line!.id)
2199
+ if (idx !== -1) {
2200
+ const line = body.line
2201
+ project.plotlines[idx] = {
2202
+ ...project.plotlines[idx]!,
2203
+ name: line.name.slice(0, 40),
2204
+ kind: line.kind,
2205
+ goal: line.goal.slice(0, 300),
2206
+ progress: line.progress.slice(0, 300),
2207
+ status: line.status,
2208
+ }
2209
+ }
2210
+ } else if (op === 'remove' && body?.id !== undefined) {
2211
+ project.plotlines = project.plotlines.filter(l => l.id !== body.id)
2212
+ } else if (op === 'link' && body?.id !== undefined && typeof body.chapterNo === 'number' && body.chapterNo > 0) {
2213
+ const line = project.plotlines.find(l => l.id === body.id)
2214
+ if (line !== undefined && !line.chapters.includes(body.chapterNo)) {
2215
+ line.chapters.push(body.chapterNo)
2216
+ if (line.status === 'active' && line.progress === '') {
2217
+ line.progress = `推进至第 ${body.chapterNo} 章`
2218
+ }
2219
+ }
2220
+ } else if (op === 'suggest') {
2221
+ try {
2222
+ const suggestions = await suggestPlotlines(ctx, config, project)
2223
+ writeJson(res, 200, { plotlines: project.plotlines, suggestions } satisfies PlotlinesResponse)
2224
+ return
2225
+ } catch (error) {
2226
+ writeJson(res, 500, { error: `AI 建议失败:${(error as Error).message}` })
2227
+ return
2228
+ }
2229
+ } else if (op === 'refresh' && body?.id !== undefined) {
2230
+ const line = project.plotlines.find(l => l.id === body.id)
2231
+ if (line === undefined) {
2232
+ writeJson(res, 404, { error: '剧情线不存在' })
2233
+ return
2234
+ }
2235
+ try {
2236
+ const progress = await refreshPlotlineProgress(ctx, config, project, line)
2237
+ line.progress = progress
2238
+ project.updatedAt = new Date().toISOString()
2239
+ saveProject(config.outputDir, project)
2240
+ writeJson(res, 200, { plotlines: project.plotlines } satisfies PlotlinesResponse)
2241
+ return
2242
+ } catch (error) {
2243
+ writeJson(res, 500, { error: `刷新进度失败:${(error as Error).message}` })
2244
+ return
2245
+ }
2246
+ } else if (op === 'health') {
2247
+ try {
2248
+ const health = await analyzePlotlineHealth(ctx, config, project)
2249
+ writeJson(res, 200, { plotlines: project.plotlines, health } satisfies PlotlinesResponse)
2250
+ return
2251
+ } catch (error) {
2252
+ writeJson(res, 500, { error: `健康检查失败:${(error as Error).message}` })
2253
+ return
2254
+ }
2255
+ } else if (op === 'plan') {
2256
+ try {
2257
+ const health = await analyzePlotlineHealth(ctx, config, project)
2258
+ const plan = await designPlotlinePlan(ctx, config, project, health)
2259
+ writeJson(res, 200, { plotlines: project.plotlines, health, plan } satisfies PlotlinesResponse)
2260
+ return
2261
+ } catch (error) {
2262
+ writeJson(res, 500, { error: `剧情方案生成失败:${(error as Error).message}` })
2263
+ return
2264
+ }
2265
+ }
2266
+ project.updatedAt = new Date().toISOString()
2267
+ saveProject(config.outputDir, project)
2268
+ const response: PlotlinesResponse = { plotlines: project.plotlines }
2269
+ writeJson(res, 200, response)
2270
+ },
2271
+ }
2272
+
2273
+ // --------------------------------------------------------- sensitive
2274
+ /** 敏感词检查:指定章节 / 任意文本 / 全书已写章节。 */
2275
+ const sensitiveRoute: WebRoute = {
2276
+ kind: 'exact',
2277
+ path: NOVEL_API.sensitiveCheck,
2278
+ handler: async (req, res) => {
2279
+ if (!guard(req, res, 'POST')) return
2280
+ const config = getConfig()
2281
+ const project = requireProject(res)
2282
+ if (project === undefined) return
2283
+ const body = await readJsonBody<SensitiveCheckRequest>(req)
2284
+ const hits: SensitiveHit[] = []
2285
+ let scanned = 0
2286
+ if (body?.text !== undefined) {
2287
+ for (const hit of checkSensitiveText(body.text)) {
2288
+ hits.push({ chapterNo: 0, word: hit.word, category: hit.category, count: hit.count })
2289
+ }
2290
+ } else if (typeof body?.chapterNo === 'number') {
2291
+ const chapter = project.chapters.find(c => c.no === body.chapterNo)
2292
+ if (chapter !== undefined) {
2293
+ const text = readChapterFile(config.outputDir, chapter)
2294
+ if (text !== undefined) {
2295
+ scanned = 1
2296
+ for (const hit of checkSensitiveText(text)) {
2297
+ hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
2298
+ }
2299
+ }
2300
+ }
2301
+ } else if (body?.all === true) {
2302
+ for (const chapter of project.chapters) {
2303
+ if (chapter.status === 'pending' || chapter.status === 'generating') continue
2304
+ const text = readChapterFile(config.outputDir, chapter)
2305
+ if (text === undefined) continue
2306
+ scanned++
2307
+ for (const hit of checkSensitiveText(text)) {
2308
+ hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
2309
+ }
2310
+ }
2311
+ } else {
2312
+ writeJson(res, 400, { error: '请提供 chapterNo / text / all 之一' })
2313
+ return
2314
+ }
2315
+ const response: SensitiveCheckResponse = { hits, scannedChapters: scanned }
2316
+ writeJson(res, 200, response)
2317
+ },
2318
+ }
2319
+
2320
+ // ---------------------------------------------------------------- roles
2321
+ /** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
2322
+ const rolesRoute: WebRoute = {
2323
+ kind: 'exact',
2324
+ path: NOVEL_API.roles,
2325
+ handler: async (req, res) => {
2326
+ if (!guard(req, res, 'POST')) return
2327
+ const config = getConfig()
2328
+ const project = requireProject(res)
2329
+ if (project === undefined) return
2330
+ const body = await readJsonBody<RolesRequest>(req)
2331
+ if (project.roles === undefined) project.roles = []
2332
+ const op = body?.op
2333
+ if (op === 'extract') {
2334
+ try {
2335
+ const candidates = await extractRoles(ctx, config, project)
2336
+ writeJson(res, 200, { roles: project.roles, candidates } satisfies RolesResponse)
2337
+ return
2338
+ } catch (error) {
2339
+ writeJson(res, 500, { error: `角色提炼失败:${(error as Error).message}` })
2340
+ return
2341
+ }
2342
+ } else if ((op === 'adopt' || op === 'update') && body?.role !== undefined) {
2343
+ const r = body.role
2344
+ const idx = project.roles.findIndex(x => x.name === r.name)
2345
+ if (idx === -1) project.roles.push(r)
2346
+ else project.roles[idx] = r
2347
+ } else if (op === 'remove' && body?.name !== undefined) {
2348
+ project.roles = project.roles.filter(x => x.name !== body.name)
2349
+ } else {
2350
+ writeJson(res, 400, { error: '未知的 roles op' })
2351
+ return
2352
+ }
2353
+ project.updatedAt = new Date().toISOString()
2354
+ saveProject(config.outputDir, project)
2355
+ const response: RolesResponse = { roles: project.roles }
2356
+ writeJson(res, 200, response)
2357
+ },
2358
+ }
2359
+
2360
+ // ------------------------------------------------------- chapter reset
2361
+ /** 章节复位:generating 卡死 → pending(可重新生成)。 */
2362
+ const chapterResetRoute: WebRoute = {
2363
+ kind: 'exact',
2364
+ path: NOVEL_API.chapterReset,
2365
+ handler: async (req, res) => {
2366
+ if (!guard(req, res, 'POST')) return
2367
+ const config = getConfig()
2368
+ const project = requireProject(res)
2369
+ if (project === undefined) return
2370
+ const body = await readJsonBody<{ chapterNo?: number }>(req)
2371
+ const no = body?.chapterNo
2372
+ if (!Number.isInteger(no) || no === undefined || no < 1) {
2373
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
2374
+ return
2375
+ }
2376
+ const chapter = project.chapters.find(c => c.no === no)
2377
+ if (chapter === undefined) {
2378
+ writeJson(res, 404, { error: `章节 ${no} 不在计划中` })
2379
+ return
2380
+ }
2381
+ chapter.status = 'pending'
2382
+ chapter.error = undefined
2383
+ chapter.generatingAt = undefined
2384
+ project.updatedAt = new Date().toISOString()
2385
+ saveProject(config.outputDir, project)
2386
+ writeJson(res, 200, { ok: true, no })
2387
+ },
2388
+ }
2389
+
2390
+ // ------------------------------------------------------ chapter approve
2391
+ /** 章节直接通过:作者行使最终决定权(不重审,保留审稿记录)。 */
2392
+ const chapterApproveRoute: WebRoute = {
2393
+ kind: 'exact',
2394
+ path: NOVEL_API.chapterApprove,
2395
+ handler: async (req, res) => {
2396
+ if (!guard(req, res, 'POST')) return
2397
+ const config = getConfig()
2398
+ const project = requireProject(res)
2399
+ if (project === undefined) return
2400
+ const body = await readJsonBody<{ chapterNo?: number }>(req)
2401
+ const no = body?.chapterNo
2402
+ if (!Number.isInteger(no) || no === undefined || no < 1) {
2403
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
2404
+ return
2405
+ }
2406
+ const chapter = project.chapters.find(c => c.no === no)
2407
+ if (chapter === undefined) {
2408
+ writeJson(res, 404, { error: `章节 ${no} 不在计划中` })
2409
+ return
2410
+ }
2411
+ chapter.status = 'approved'
2412
+ project.updatedAt = new Date().toISOString()
2413
+ saveProject(config.outputDir, project)
2414
+ writeJson(res, 200, { ok: true, no })
2415
+ },
2416
+ }
2417
+
2418
+ // ------------------------------------------------------ author review backfill
2419
+ /** 作者复盘补跑:对已写章节补齐 authorReview(body.chapterNo=单章 JSON,缺省=全书 NDJSON 流)。 */
2420
+ const reviewBackfillRoute: WebRoute = {
2421
+ kind: 'exact',
2422
+ path: NOVEL_API.reviewBackfill,
2423
+ handler: async (req, res) => {
2424
+ if (!guard(req, res, 'POST')) return
2425
+ const config = getConfig()
2426
+ const project = requireProject(res)
2427
+ if (project === undefined) return
2428
+ const body = await readJsonBody<{ chapterNo?: number }>(req)
2429
+
2430
+ /** 对一章执行作者复盘(读取已落盘正文,不改变章节状态/正文)。 */
2431
+ const runOne = async (chapter: ChapterPlan): Promise<AuthorReview> => {
2432
+ const currentBody = readChapterFile(config.outputDir, chapter)
2433
+ if (currentBody === undefined) throw new Error(`章节 ${chapter.no} 的正文文件不存在`)
2434
+ let prevTail = ''
2435
+ if (chapter.no > 1) {
2436
+ const prev = project.chapters.find(c => c.no === chapter.no - 1)
2437
+ if (prev !== undefined) {
2438
+ prevTail = (readChapterFile(config.outputDir, prev) ?? '').replace(/^#.*$/m, '').trim().slice(-600)
2439
+ }
2440
+ }
2441
+ return authorReviewChapter(ctx, config, project, chapter.no, currentBody, prevTail)
2442
+ }
2443
+
2444
+ // 单章:JSON 响应。
2445
+ if (typeof body?.chapterNo === 'number' && body.chapterNo > 0) {
2446
+ const chapter = project.chapters.find(c => c.no === body.chapterNo)
2447
+ if (chapter === undefined) {
2448
+ writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` })
2449
+ return
2450
+ }
2451
+ if (chapter.status === 'pending') {
2452
+ writeJson(res, 400, { error: '该章尚未生成正文,无法复盘' })
2453
+ return
2454
+ }
2455
+ try {
2456
+ const review = await runOne(chapter)
2457
+ chapter.authorReview = review
2458
+ if (review.advancedLines !== undefined) {
2459
+ autoLinkPlotlines(project, chapter.no, review.advancedLines)
2460
+ }
2461
+ project.updatedAt = new Date().toISOString()
2462
+ saveProject(config.outputDir, project)
2463
+ writeJson(res, 200, { no: chapter.no, review } satisfies { no: number; review: AuthorReview })
2464
+ return
2465
+ } catch (error) {
2466
+ writeJson(res, 500, { error: (error as Error).message })
2467
+ return
2468
+ }
2469
+ }
2470
+
2471
+ // 全书:NDJSON 流式补跑缺失复盘的已写章节。
2472
+ const missing = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error' && c.authorReview === undefined)
2473
+ if (missing.length === 0) {
2474
+ writeJson(res, 200, { count: 0 })
2475
+ return
2476
+ }
2477
+ res.writeHead(200, {
2478
+ 'content-type': 'application/x-ndjson; charset=utf-8',
2479
+ 'cache-control': 'no-cache',
2480
+ 'referrer-policy': 'no-referrer',
2481
+ })
2482
+ const send = (frame: JobFrame): void => {
2483
+ res.write(JSON.stringify(frame) + '\n')
2484
+ }
2485
+ let done = 0
2486
+ for (const chapter of missing) {
2487
+ try {
2488
+ const review = await runOne(chapter)
2489
+ chapter.authorReview = review
2490
+ if (review.advancedLines !== undefined) {
2491
+ autoLinkPlotlines(project, chapter.no, review.advancedLines)
2492
+ }
2493
+ done++
2494
+ saveProject(config.outputDir, project)
2495
+ send({ type: 'author-review', no: chapter.no, review })
2496
+ } catch (error) {
2497
+ console.warn(`[dsh-novel-forge] author backfill ch.${chapter.no} failed:`, (error as Error).message)
2498
+ }
2499
+ }
2500
+ project.updatedAt = new Date().toISOString()
2501
+ saveProject(config.outputDir, project)
2502
+ send({ type: 'author-backfill-done', count: done })
2503
+ res.end()
2504
+ },
2505
+ }
2506
+
2507
+ // --------------------------------------------------------------- config
2508
+ const configRoute: WebRoute = {
2509
+ kind: 'exact',
2510
+ path: NOVEL_API.config,
2511
+ handler: async (req, res) => {
2512
+ if (!guard(req, res, 'POST')) return
2513
+ const body = await readJsonBody<ConfigPatch>(req)
2514
+ if (body === undefined) {
2515
+ writeJson(res, 400, { error: '无效的配置 JSON' })
2516
+ return
2517
+ }
2518
+ try {
2519
+ const next = await patchConfig(body)
2520
+ writeJson(res, 200, { config: next })
2521
+ } catch (error) {
2522
+ writeJson(res, 400, { error: (error as Error).message })
2523
+ }
2524
+ },
2525
+ }
2526
+
2527
+ // ---------------------------------------------------------- open-folder
2528
+ const openFolderRoute: WebRoute = {
2529
+ kind: 'exact',
2530
+ path: NOVEL_API.openFolder,
2531
+ handler: async (req, res) => {
2532
+ if (!guard(req, res, 'POST')) return
2533
+ const config = getConfig()
2534
+ const dir = config.outputDir
2535
+ const child = spawn('explorer', [dir], { shell: false })
2536
+ let responded = false
2537
+ child.on('error', (error) => {
2538
+ if (responded) return
2539
+ responded = true
2540
+ writeJson(res, 500, { ok: false, error: error.message })
2541
+ })
2542
+ child.on('exit', (code) => {
2543
+ if (responded) return
2544
+ responded = true
2545
+ if (code === 0) writeJson(res, 200, { ok: true })
2546
+ else writeJson(res, 500, { ok: false, error: `explorer 退出码 ${code}` })
2547
+ })
2548
+ },
2549
+ }
2550
+
2551
+ // ---------------------------------------------------------- plugin update
2552
+ /** 插件自更新:在 DSH profile 目录拉取最新 npm 版(仅下载,需重启 DSH 生效)。 */
2553
+ const pluginUpdateRoute: WebRoute = {
2554
+ kind: 'exact',
2555
+ path: NOVEL_API.pluginUpdate,
2556
+ handler: async (req, res) => {
2557
+ if (!guard(req, res, 'POST')) return
2558
+ const profileDir = join(homedir(), '.dsh', 'profiles', 'web')
2559
+ const response: PluginUpdateResponse = await new Promise((resolve) => {
2560
+ const child = spawn('pnpm', ['add', '@waterwx/dsh-novel-forge@latest'], { cwd: profileDir, shell: true })
2561
+ let acc = ''
2562
+ child.stdout.on('data', (d: Buffer) => { acc += String(d) })
2563
+ child.stderr.on('data', (d: Buffer) => { acc += String(d) })
2564
+ child.on('error', (error) => resolve({ ok: false, message: '无法执行更新:' + error.message }))
2565
+ child.on('close', (code) => {
2566
+ if (code === 0) resolve({ ok: true, message: '已更新到最新版本,请重启 DSH 生效。' })
2567
+ else resolve({ ok: false, message: '更新失败(退出码 ' + code + '):' + acc.slice(-400) })
2568
+ })
2569
+ })
2570
+ writeJson(res, 200, response)
2571
+ },
2572
+ }
2573
+
2574
+ // ------------------------------------------------------- outline suggest
2575
+ /** 开书想法 → AI 大纲:2-3 个可选方案(支持暂留换批:count 只补空槽 + exclude 避开已留方向)。 */
2576
+ const outlineSuggestRoute: WebRoute = {
2577
+ kind: 'exact',
2578
+ path: NOVEL_API.outlineSuggest,
2579
+ handler: async (req, res) => {
2580
+ if (!guard(req, res, 'POST')) return
2581
+ const config = getConfig()
2582
+ const body = await readJsonBody<OutlineSuggestRequest>(req)
2583
+ const idea = body?.idea?.trim() ?? ''
2584
+ if (idea.length < 50) {
2585
+ writeJson(res, 400, { error: '想法太短(<50 字),请多写一两句:主角是谁、什么世界、想要什么爽点' })
2586
+ return
2587
+ }
2588
+ const count = body?.count !== undefined ? Math.max(1, Math.min(3, Math.floor(body.count))) : 3
2589
+ 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)) : []
2590
+ try {
2591
+ const candidates = await suggestOutlines(ctx, config, idea, count, exclude)
2592
+ const response: OutlineSuggestResponse = { candidates }
2593
+ writeJson(res, 200, response)
2594
+ } catch (error) {
2595
+ writeJson(res, 500, { error: `大纲方案生成失败:${(error as Error).message}` })
2596
+ }
2597
+ },
2598
+ }
2599
+
2600
+ // ------------------------------------------------- outline reverse
2601
+ /** 反推大纲:从已写章节正文反向生成全书总纲(NDJSON 流式进度)。 */
2602
+ const outlineReverseRoute: WebRoute = {
2603
+ kind: 'exact',
2604
+ path: NOVEL_API.outlineReverse,
2605
+ handler: async (req, res) => {
2606
+ if (!guard(req, res, 'POST')) return
2607
+ const config = getConfig()
2608
+ const project = requireProject(res)
2609
+ if (project === undefined) return
2610
+ res.writeHead(200, {
2611
+ 'content-type': 'application/x-ndjson; charset=utf-8',
2612
+ 'cache-control': 'no-cache',
2613
+ 'x-accel-buffering': 'no',
2614
+ 'referrer-policy': 'no-referrer',
2615
+ })
2616
+ const send = (frame: JobFrame): void => {
2617
+ res.write(JSON.stringify(frame) + '\n')
2618
+ }
2619
+ try {
2620
+ const outline = await reverseOutlineFromChapters(ctx, config, project, config.outputDir, (done, total, phase) => {
2621
+ send({ type: 'outline-progress', done, total, phase })
2622
+ })
2623
+ // 保存总纲(仅更新文本,不动书名/进度)。
2624
+ project.outline = outline
2625
+ project.updatedAt = new Date().toISOString()
2626
+ saveProject(config.outputDir, project)
2627
+ send({ type: 'outline-done', outline, chars: outline.length })
2628
+ } catch (error) {
2629
+ send({ type: 'error', no: 0, message: (error as Error).message })
2630
+ } finally {
2631
+ res.end()
2632
+ }
2633
+ },
2634
+ }
2635
+
2636
+ // ------------------------------------------------------------- llm-test
2637
+ /** LLM 连通性测试:对选中的提供商/模型发一次最小真实调用。 */
2638
+ const llmTestRoute: WebRoute = {
2639
+ kind: 'exact',
2640
+ path: NOVEL_API.llmTest,
2641
+ handler: async (req, res) => {
2642
+ if (!guard(req, res, 'POST')) return
2643
+ const body = await readJsonBody<LlmTestRequest>(req)
2644
+ const provider = (body?.provider ?? '').trim()
2645
+ const model = (body?.model ?? '').trim()
2646
+ if (provider === '' || model === '') {
2647
+ writeJson(res, 400, { error: 'provider 与 model 必填' })
2648
+ return
2649
+ }
2650
+ const result = await testLlmModel(ctx, provider, model)
2651
+ writeJson(res, 200, result satisfies LlmTestResponse)
2652
+ },
2653
+ }
2654
+
2655
+ // ------------------------------------------------------------- llm-add
2656
+ /** 添加模型:厂商直填 API Key,或自定义 OpenAI 兼容路由(写 DSH 凭据 + router)。 */
2657
+ const addModelRoute: WebRoute = {
2658
+ kind: 'exact',
2659
+ path: NOVEL_API.addModel,
2660
+ handler: async (req, res) => {
2661
+ if (!guard(req, res, 'POST')) return
2662
+ const body = await readJsonBody<AddModelRequest>(req)
2663
+ if (body === undefined) {
2664
+ writeJson(res, 400, { error: '缺少请求体' })
2665
+ return
2666
+ }
2667
+ try {
2668
+ const result = await registerLlmModel(ctx, body)
2669
+ writeJson(res, 200, result satisfies AddModelResponse)
2670
+ } catch (error) {
2671
+ writeJson(res, 500, { error: `添加模型失败:${(error as Error).message}` })
2672
+ }
2673
+ },
2674
+ }
2675
+
2676
+ // ---------------------------------------------------------- llm-vendors
2677
+ /** 运行时厂商目录(DSH pi-ai 可配置提供方 + 内置适配器)。 */
2678
+ const llmVendorsRoute: WebRoute = {
2679
+ kind: 'exact',
2680
+ path: NOVEL_API.llmVendors,
2681
+ handler: async (req, res) => {
2682
+ if (!guard(req, res, 'GET')) return
2683
+ try {
2684
+ const result = await listLlmVendors(ctx)
2685
+ writeJson(res, 200, result satisfies LlmVendorsResponse)
2686
+ } catch (error) {
2687
+ writeJson(res, 500, { error: `读取厂商目录失败:${(error as Error).message}` })
2688
+ }
2689
+ },
2690
+ }
2691
+
2692
+ // ---------------------------------------------------------- llm-models
2693
+ /** 查询某个 provider 当前可用的模型(添加成功后可即时刷新下拉)。 */
2694
+ const llmModelsRoute: WebRoute = {
2695
+ kind: 'exact',
2696
+ path: NOVEL_API.llmModels,
2697
+ handler: async (req, res) => {
2698
+ if (!guard(req, res, 'GET')) return
2699
+ const url = new URL(req.url ?? '', 'http://localhost')
2700
+ const provider = decodeURIComponent((url.searchParams.get('provider') ?? '').trim())
2701
+ try {
2702
+ const result = await listLlmModels(ctx, provider)
2703
+ writeJson(res, 200, result satisfies LlmModelsResponse)
2704
+ } catch (error) {
2705
+ writeJson(res, 500, { error: `读取模型列表失败:${(error as Error).message}` })
2706
+ }
2707
+ },
2708
+ }
2709
+
2710
+ // ---------------------------------------------------------- llm-providers
2711
+ /** 已注册的提供方路由列表(提供方管理卡片)。 */
2712
+ const llmProvidersRoute: WebRoute = {
2713
+ kind: 'exact',
2714
+ path: NOVEL_API.llmProviders,
2715
+ handler: async (req, res) => {
2716
+ if (!guard(req, res, 'GET')) return
2717
+ try {
2718
+ const result = await listLlmProviders(ctx)
2719
+ writeJson(res, 200, result satisfies LlmProvidersResponse)
2720
+ } catch (error) {
2721
+ writeJson(res, 500, { error: `读取提供方列表失败:${(error as Error).message}` })
2722
+ }
2723
+ },
2724
+ }
2725
+
2726
+ // ------------------------------------------------------------- llm-remove
2727
+ /** 移除一个提供方(unset key + 移除 llm-pi-ai 路由)。 */
2728
+ const llmRemoveRoute: WebRoute = {
2729
+ kind: 'exact',
2730
+ path: NOVEL_API.llmRemove,
2731
+ handler: async (req, res) => {
2732
+ if (!guard(req, res, 'POST')) return
2733
+ const body = await readJsonBody<RemoveProviderRequest>(req)
2734
+ if (body === undefined) {
2735
+ writeJson(res, 400, { error: '缺少请求体' })
2736
+ return
2737
+ }
2738
+ try {
2739
+ const result = await removeLlmProvider(ctx, body)
2740
+ writeJson(res, 200, result satisfies RemoveProviderResponse)
2741
+ } catch (error) {
2742
+ writeJson(res, 500, { error: `移除提供方失败:${(error as Error).message}` })
2743
+ }
2744
+ },
2745
+ }
2746
+
2747
+
2748
+ // ----------------------------------------------------------- breakdown
2749
+ /** 拆书分析:对已写章节做结构/人物/文风/卖点体检(两阶段:源笔记→分节分析)。 */
2750
+ const breakdownRoute: WebRoute = {
2751
+ kind: 'exact',
2752
+ path: NOVEL_API.breakdown,
2753
+ handler: async (req, res) => {
2754
+ if (!guard(req, res, 'POST')) return
2755
+ const config = getConfig()
2756
+ const project = requireProject(res)
2757
+ if (project === undefined) return
2758
+ const body = await readJsonBody<BreakdownRequest>(req)
2759
+ try {
2760
+ const result = await breakdownBook(
2761
+ ctx,
2762
+ config,
2763
+ project,
2764
+ config.outputDir,
2765
+ body?.scope ?? 'recent',
2766
+ body?.preset ?? 'quick',
2767
+ body?.budgetTokens ?? 50000,
2768
+ )
2769
+ writeJson(res, 200, result satisfies BreakdownResponse)
2770
+ } catch (error) {
2771
+ writeJson(res, 500, { error: `拆书分析失败:${(error as Error).message}` })
2772
+ }
2773
+ },
2774
+ }
2775
+
2776
+
2777
+ // ----------------------------------------------------------- production run
2778
+ /** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
2779
+ const runner = new ProductionRunner({ ctx, getConfig })
2780
+
2781
+ const runStartRoute: WebRoute = {
2782
+ kind: 'exact',
2783
+ path: NOVEL_API.runStart,
2784
+ handler: async (req, res) => {
2785
+ if (!guard(req, res, 'POST')) return
2786
+ const body = await readJsonBody<RunStartRequest & { bookId?: string }>(req)
2787
+ const config = getConfig()
2788
+ const outputDir = resolveOutputDir(config, body?.bookId)
2789
+ const project = loadProject(outputDir)
2790
+ if (project === undefined) {
2791
+ writeJson(res, 400, { error: '输出目录中没有项目' })
2792
+ return
2793
+ }
2794
+ const startNo = Number.isInteger(body?.startNo) && (body!.startNo! >= 1) ? body!.startNo! : 1
2795
+ let endNo: number
2796
+ if (Number.isInteger(body?.endNo) && (body!.endNo! >= startNo)) {
2797
+ endNo = body!.endNo!
2798
+ } else if (Number.isInteger(body?.count) && (body!.count! >= 1) && (body!.count! <= 200)) {
2799
+ const last = Math.max(0, ...project.chapters.map(c => c.no))
2800
+ endNo = last + body!.count!
2801
+ } else {
2802
+ writeJson(res, 400, { error: '请提供 endNo 或 count(1-200)' })
2803
+ return
2804
+ }
2805
+ try {
2806
+ const state = await runner.start(startNo, endNo, outputDir)
2807
+ writeJson(res, 200, state satisfies RunState)
2808
+ } catch (error) {
2809
+ writeJson(res, 409, { error: (error as Error).message })
2810
+ }
2811
+ },
2812
+ }
2813
+
2814
+ const runControlRoute: WebRoute = {
2815
+ kind: 'exact',
2816
+ path: NOVEL_API.runControl,
2817
+ handler: async (req, res) => {
2818
+ if (!guard(req, res, 'POST')) return
2819
+ const body = await readJsonBody<RunControlRequest>(req)
2820
+ const state = runner.status()
2821
+ if (state === null) {
2822
+ writeJson(res, 400, { error: '没有生产单' })
2823
+ return
2824
+ }
2825
+ if (body?.action === 'pause') runner.pause()
2826
+ else if (body?.action === 'resume') runner.resume()
2827
+ else if (body?.action === 'stop') runner.stop()
2828
+ else {
2829
+ writeJson(res, 400, { error: 'action 须为 pause / resume / stop' })
2830
+ return
2831
+ }
2832
+ writeJson(res, 200, runner.status())
2833
+ },
2834
+ }
2835
+
2836
+ const runStatusRoute: WebRoute = {
2837
+ kind: 'exact',
2838
+ path: NOVEL_API.runStatus,
2839
+ handler: (req, res) => {
2840
+ if (!guard(req, res, 'GET')) return
2841
+ writeJson(res, 200, runner.status())
2842
+ },
2843
+ }
2844
+
2845
+ // -------------------------------------------------- author assets (总数据)
2846
+ const authorAssetsRoute: WebRoute = {
2847
+ kind: 'exact',
2848
+ path: NOVEL_API.authorAssets,
2849
+ handler: (req, res) => {
2850
+ if (!guard(req, res, 'GET')) return
2851
+ const response: AuthorAssetsResponse = { assets: loadAuthorAssets() }
2852
+ writeJson(res, 200, response)
2853
+ },
2854
+ }
2855
+
2856
+ const authorAssetsUpsertRoute: WebRoute = {
2857
+ kind: 'exact',
2858
+ path: NOVEL_API.authorAssetsUpsert,
2859
+ handler: async (req, res) => {
2860
+ if (!guard(req, res, 'POST')) return
2861
+ const body = await readJsonBody<AuthorAssetUpsertRequest>(req)
2862
+ const asset = body?.asset
2863
+ if (asset === undefined || asset.name.trim() === '' || asset.content.trim() === '') {
2864
+ writeJson(res, 400, { error: 'asset 须含 name / content' })
2865
+ return
2866
+ }
2867
+ const response: AuthorAssetsResponse = { assets: upsertAuthorAsset(asset) }
2868
+ writeJson(res, 200, response)
2869
+ },
2870
+ }
2871
+
2872
+ const authorAssetsRemoveRoute: WebRoute = {
2873
+ kind: 'exact',
2874
+ path: NOVEL_API.authorAssetsRemove,
2875
+ handler: async (req, res) => {
2876
+ if (!guard(req, res, 'POST')) return
2877
+ const body = await readJsonBody<AuthorAssetRemoveRequest>(req)
2878
+ const id = body?.id ?? ''
2879
+ if (id === '') { writeJson(res, 400, { error: 'id 必填' }); return }
2880
+ const response: AuthorAssetsResponse = { assets: removeAuthorAsset(id) }
2881
+ writeJson(res, 200, response)
2882
+ },
2883
+ }
2884
+
2885
+ const authorAssetsImportDefaultRoute: WebRoute = {
2886
+ kind: 'exact',
2887
+ path: NOVEL_API.authorAssetsImportDefault,
2888
+ handler: (req, res) => {
2889
+ if (!guard(req, res, 'POST')) return
2890
+ const response: AuthorAssetsResponse = { assets: importDefaultAuthorAssets() }
2891
+ writeJson(res, 200, response)
2892
+ },
2893
+ }
2894
+
2895
+ // --------------------------------------------------- adaptation (改编 P0 分析)
2896
+ const adaptAnalyzeRoute: WebRoute = {
2897
+ kind: 'exact',
2898
+ path: NOVEL_API.adaptAnalyze,
2899
+ handler: async (req, res) => {
2900
+ if (!guard(req, res, 'POST')) return
2901
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
2902
+ const body = await readJsonBody<AdaptAnalyzeRequest>(req)
2903
+ const config = getConfig()
2904
+ let text = body?.text?.trim() ?? ''
2905
+ if (text === '' && body?.filePath !== undefined && body.filePath !== '') {
2906
+ try {
2907
+ text = readFileSync(body.filePath, 'utf8')
2908
+ } catch (err) {
2909
+ writeJson(res, 400, { error: '读取文件失败:' + (err as Error).message })
2910
+ return
2911
+ }
2912
+ }
2913
+ if (text.length < 200) {
2914
+ writeJson(res, 400, { error: '全文内容过短(<200 字符),请上传完整小说文本' })
2915
+ return
2916
+ }
2917
+ try {
2918
+ const response: AdaptAnalyzeResponse = await analyzeAdaptation(ctx, config, text)
2919
+ writeJson(res, 200, response)
2920
+ } catch (err) {
2921
+ writeJson(res, 400, { error: (err as Error).message })
2922
+ }
2923
+ },
2924
+ }
2925
+
2926
+ const adaptProposeRoute: WebRoute = {
2927
+ kind: 'exact',
2928
+ path: NOVEL_API.adaptPropose,
2929
+ handler: async (req, res) => {
2930
+ if (!guard(req, res, 'POST')) return
2931
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
2932
+ const body = await readJsonBody<AdaptProposeRequest>(req)
2933
+ const config = getConfig()
2934
+ const text = (body?.text ?? '').trim()
2935
+ const selections = body?.selections ?? []
2936
+ if (text.length < 200 || selections.length === 0) {
2937
+ writeJson(res, 400, { error: '请提供全文与至少一条要改的维度' })
2938
+ return
2939
+ }
2940
+ try {
2941
+ const response: AdaptProposeResponse = await proposeAdaptation(ctx, config, text, selections, body?.dimensions)
2942
+ writeJson(res, 200, response)
2943
+ } catch (err) {
2944
+ writeJson(res, 400, { error: (err as Error).message })
2945
+ }
2946
+ },
2947
+ }
2948
+
2949
+ const adaptExecuteRoute: WebRoute = {
2950
+ kind: 'exact',
2951
+ path: NOVEL_API.adaptExecute,
2952
+ handler: async (req, res) => {
2953
+ if (!guard(req, res, 'POST')) return
2954
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
2955
+ const body = await readJsonBody<AdaptExecuteRequest>(req)
2956
+ const text = body?.text ?? ''
2957
+ const mappings = body?.mappings ?? []
2958
+ if (text.length < 200 || mappings.length === 0) {
2959
+ writeJson(res, 400, { error: '请提供全文与映射表' })
2960
+ return
2961
+ }
2962
+ if (body?.mode === 'rewrite') {
2963
+ try {
2964
+ const result = await rewriteAdaptationBook(ctx, getConfig(), text, mappings, body?.rules, { maxChapters: body?.maxChapters, startNo: body?.startNo, endNo: body?.endNo })
2965
+ const response: AdaptExecuteResponse = { adaptedText: result.adaptedText, mappings: mappings.length, hits: result.hits, mode: 'rewrite', rewritten: result.rewritten, skipped: result.skipped }
2966
+ writeJson(res, 200, response)
2967
+ } catch (err) {
2968
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
2969
+ }
2970
+ return
2971
+ }
2972
+ const { adaptedText, hits } = applyAdaptationReplacements(text, mappings)
2973
+ const response: AdaptExecuteResponse = { adaptedText, mappings: mappings.length, hits, mode: 'replace' }
2974
+ writeJson(res, 200, response)
2975
+ },
2976
+ }
2977
+
2978
+ /** rewrite 逐章重写:NDJSON 流式进度(支持分段 startNo/endNo)。 */
2979
+ const adaptRewriteStreamRoute: WebRoute = {
2980
+ kind: 'exact',
2981
+ path: NOVEL_API.adaptRewriteStream,
2982
+ handler: async (req, res) => {
2983
+ if (!guard(req, res, 'POST')) return
2984
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
2985
+ const body = await readJsonBody<AdaptExecuteRequest>(req)
2986
+ const text = body?.text ?? ''
2987
+ const mappings = body?.mappings ?? []
2988
+ if (text.length < 200 || mappings.length === 0) { writeJson(res, 400, { error: '请提供全文与映射表' }); return }
2989
+ res.writeHead(200, {
2990
+ 'content-type': 'application/x-ndjson; charset=utf-8',
2991
+ 'cache-control': 'no-cache',
2992
+ 'x-accel-buffering': 'no',
2993
+ 'referrer-policy': 'no-referrer',
2994
+ })
2995
+ const send = (frame: AdaptRewriteFrame): void => { res.write(JSON.stringify(frame) + '\n') }
2996
+ try {
2997
+ const result = await rewriteAdaptationBook(ctx, getConfig(), text, mappings, body?.rules, {
2998
+ maxChapters: body?.maxChapters,
2999
+ startNo: body?.startNo,
3000
+ endNo: body?.endNo,
3001
+ onProgress: (info) => send({ type: 'progress', completed: info.completed, total: info.total, no: info.no, title: info.title }),
3002
+ })
3003
+ const response: AdaptExecuteResponse = { adaptedText: result.adaptedText, mappings: mappings.length, hits: result.hits, mode: 'rewrite', rewritten: result.rewritten, skipped: result.skipped }
3004
+ send({ type: 'done', result: response })
3005
+ } catch (err) {
3006
+ send({ type: 'error', message: err instanceof Error ? err.message : String(err) })
3007
+ } finally {
3008
+ res.end()
3009
+ }
3010
+ },
3011
+ }
3012
+
3013
+ /** 保存改编全文为新书(原书保留,登记书架)。 */
3014
+ const adaptSaveRoute: WebRoute = {
3015
+ kind: 'exact',
3016
+ path: NOVEL_API.adaptSave,
3017
+ handler: async (req, res) => {
3018
+ if (!guard(req, res, 'POST')) return
3019
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
3020
+ const body = await readJsonBody<AdaptSaveRequest>(req)
3021
+ const text = (body?.text ?? '').trim()
3022
+ if (text.length < 200) {
3023
+ writeJson(res, 400, { error: '改编全文内容过短(<200 字符)' })
3024
+ return
3025
+ }
3026
+ const bookName = (body?.bookName ?? '').trim().slice(0, 40) || '改编新书'
3027
+ const outDir = (body?.outputDir ?? '').trim() !== '' && body?.outputDir !== undefined && body.outputDir.trim() !== ''
3028
+ ? body.outputDir.trim()
3029
+ : defaultOutputDirFor(bookName)
3030
+ // 原书保留:若目录已被另一本书占用则拒绝,避免误覆盖原书/别的书。
3031
+ const occupied = loadBookshelf().books.find(b => b.outputDir === outDir)
3032
+ if (occupied !== undefined && occupied.bookName !== bookName) {
3033
+ writeJson(res, 409, { error: `目录已被《${occupied.bookName}》占用,请更换书名或输出目录` })
3034
+ return
3035
+ }
3036
+ try {
3037
+ const result = importBookTextFromText(text, outDir, bookName)
3038
+ // 有反推大纲时写进新项目,便于后续续写/编辑(importBookTextFromText 默认 outline=书名)。
3039
+ const outline = (body?.outline ?? '').trim()
3040
+ if (outline !== '') {
3041
+ const project = loadProject(outDir)
3042
+ if (project !== undefined) {
3043
+ project.outline = outline
3044
+ saveProject(outDir, project)
3045
+ }
3046
+ }
3047
+ const { book } = importDir(outDir)
3048
+ const response: AdaptSaveResponse = { book, bookName: result.bookName, chapters: result.chapters, skipped: result.skipped, outputDir: outDir }
3049
+ writeJson(res, 200, response)
3050
+ } catch (err) {
3051
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
3052
+ }
3053
+ },
3054
+ }
3055
+
3056
+ /** 从源全文 + 编辑后方案提炼新书资料并保存为「待写新书」。 */
3057
+ const adaptMaterializeRoute: WebRoute = {
3058
+ kind: 'exact',
3059
+ path: NOVEL_API.adaptMaterialize,
3060
+ handler: async (req, res) => {
3061
+ if (!guard(req, res, 'POST')) return
3062
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
3063
+ const body = await readJsonBody<AdaptMaterializeRequest>(req)
3064
+ const text = (body?.text ?? '').trim()
3065
+ if (text.length < 200) { writeJson(res, 400, { error: '源书全文内容过短(<200 字符)' }); return }
3066
+ const proposal = body?.proposal
3067
+ if (proposal === undefined || proposal.mappings.length === 0) { writeJson(res, 400, { error: '请先提供至少一条映射的改编方案' }); return }
3068
+ const bookName = (body?.bookName ?? '').trim().slice(0, 40) || '改编新书'
3069
+ const outDir = (body?.outputDir ?? '').trim() !== '' && body?.outputDir !== undefined && body.outputDir.trim() !== ''
3070
+ ? body.outputDir.trim()
3071
+ : defaultOutputDirFor(bookName)
3072
+ // 原书保留:若目录已被另一本书占用则拒绝。
3073
+ const occupied = loadBookshelf().books.find(b => b.outputDir === outDir)
3074
+ if (occupied !== undefined && occupied.bookName !== bookName) {
3075
+ writeJson(res, 409, { error: `目录已被《${occupied.bookName}》占用,请更换书名或输出目录` })
3076
+ return
3077
+ }
3078
+ try {
3079
+ const result = await materializeAdaptedBook(ctx, getConfig(), {
3080
+ text,
3081
+ bookName,
3082
+ outputDir: outDir,
3083
+ outline: body?.outline,
3084
+ proposal,
3085
+ chapterCount: body?.chapterCount,
3086
+ })
3087
+ // 预览:只返回提炼资料,不落盘;保存走 /adapt/materialize-save。
3088
+ const response: AdaptMaterializeResponse = result
3089
+ writeJson(res, 200, response)
3090
+ } catch (err) {
3091
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
3092
+ }
3093
+ },
3094
+ }
3095
+
3096
+ /** 保存预览/微调后的新书资料为新书(原书保留,登记书架)。 */
3097
+ const adaptMaterializeSaveRoute: WebRoute = {
3098
+ kind: 'exact',
3099
+ path: NOVEL_API.adaptMaterializeSave,
3100
+ handler: async (req, res) => {
3101
+ if (!guard(req, res, 'POST')) return
3102
+ if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
3103
+ const body = await readJsonBody<AdaptMaterializeSaveRequest>(req)
3104
+ if (body === undefined || body === null || (body.outline ?? '').trim() === '') {
3105
+ writeJson(res, 400, { error: '请提供改编后总纲' })
3106
+ return
3107
+ }
3108
+ const bookName = (body.bookName ?? '').trim().slice(0, 40) || '改编新书'
3109
+ const outDir = (body.outputDir ?? '').trim() !== '' && body.outputDir !== undefined && body.outputDir.trim() !== ''
3110
+ ? body.outputDir.trim()
3111
+ : defaultOutputDirFor(bookName)
3112
+ const occupied = loadBookshelf().books.find(b => b.outputDir === outDir)
3113
+ if (occupied !== undefined && occupied.bookName !== bookName) {
3114
+ writeJson(res, 409, { error: `目录已被《${occupied.bookName}》占用,请更换书名或输出目录` })
3115
+ return
3116
+ }
3117
+ try {
3118
+ const result = saveMaterializedBook(outDir, bookName, {
3119
+ outline: body.outline,
3120
+ bible: body.bible,
3121
+ roles: body.roles,
3122
+ world: body.world,
3123
+ volumes: body.volumes,
3124
+ chapters: body.chapters,
3125
+ })
3126
+ const { book } = importDir(outDir)
3127
+ const response: AdaptMaterializeSaveResponse = { ...result, book }
3128
+ writeJson(res, 200, response)
3129
+ } catch (err) {
3130
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
3131
+ }
3132
+ },
3133
+ }
3134
+
3135
+ // ------------------------------------------ theme custom background (upload + serve)
3136
+ const THEME_BG_DIR = join(homedir(), '.dsh', 'dsh-novel-forge-assets')
3137
+ const MIME_BY_EXT: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }
3138
+
3139
+ const themeBackgroundUploadRoute: WebRoute = {
3140
+ kind: 'exact',
3141
+ path: NOVEL_API.themeBackgroundUpload,
3142
+ handler: async (req, res) => {
3143
+ if (!guard(req, res, 'POST')) return
3144
+ const body = await readJsonBody<{ dataUrl?: string }>(req)
3145
+ const dataUrl = body?.dataUrl ?? ''
3146
+ const m = /^data:image\/(png|jpeg|jpg|webp|gif);base64,(.+)$/i.exec(dataUrl)
3147
+ if (m === null) { writeJson(res, 400, { error: '仅支持 PNG/JPG/WebP/GIF 图片' }); return }
3148
+ const ext = m[1]!.toLowerCase() === 'jpg' ? 'jpeg' : m[1]!.toLowerCase()
3149
+ const buf = Buffer.from(m[2]!, 'base64')
3150
+ if (buf.length === 0) { writeJson(res, 400, { error: '图片数据为空' }); return }
3151
+ mkdirSync(THEME_BG_DIR, { recursive: true })
3152
+ const name = 'theme-bg-' + Date.now().toString(36) + '-' + randomBytes(3).toString('hex') + '.' + ext
3153
+ writeFileSync(join(THEME_BG_DIR, name), buf)
3154
+ writeJson(res, 200, { url: NOVEL_API.themeBackgroundGet + '/' + name })
3155
+ },
3156
+ }
3157
+
3158
+ const themeBackgroundGetRoute: WebRoute = {
3159
+ kind: 'prefix',
3160
+ path: NOVEL_API.themeBackgroundGet,
3161
+ handler: (req, res) => {
3162
+ if (!guard(req, res, 'GET')) return
3163
+ let pathName = ''
3164
+ try { pathName = new URL(req.url ?? '', 'http://localhost').pathname } catch { /* ignore */ }
3165
+ const name = basename(pathName)
3166
+ if (!name.startsWith('theme-bg-')) { writeJson(res, 404, { error: 'not found' }); return }
3167
+ const file = join(THEME_BG_DIR, name)
3168
+ if (!existsSync(file)) { writeJson(res, 404, { error: 'not found' }); return }
3169
+ const ext = extname(file).slice(1)
3170
+ const type = MIME_BY_EXT[ext] ?? 'application/octet-stream'
3171
+ res.writeHead(200, { 'content-type': type, 'cache-control': 'public, max-age=31536000' })
3172
+ res.end(readFileSync(file))
3173
+ },
3174
+ }
3175
+
3176
+ return [
3177
+ statusRoute,
3178
+ loadOutlineRoute,
3179
+ saveOutlineRoute,
3180
+ bibleRoute,
3181
+ volumesRoute,
3182
+ planRoute,
3183
+ generateRoute,
3184
+ reviewRoute,
3185
+ rewriteRoute,
3186
+ polishRoute,
3187
+ draftApplyRoute,
3188
+ draftDiscardRoute,
3189
+ summaryRoute,
3190
+ foreshadowRoute,
3191
+ exportRoute,
3192
+ chapterRoute,
3193
+ chapterCheckRoute,
3194
+ chapterSaveRoute,
3195
+ assetsRoute,
3196
+ styleEngineRoute,
3197
+ styleFormulaRoute,
3198
+ styleDetectRoute,
3199
+ marketRadarRoute,
3200
+ marketRadarScanRoute,
3201
+ marketRadarApplyRoute,
3202
+ marketRadarSyncRoute,
3203
+ marketRadarBriefRoute,
3204
+ knowledgeRoute,
3205
+ bookAnalysisRoute,
3206
+ ideaInspirationRoute,
3207
+ marketIdeaInspirationRoute,
3208
+ directorRoute,
3209
+ directorTodosRoute,
3210
+ llmLiveRoute,
3211
+ assistantRoute,
3212
+ assistantHistoryRoute,
3213
+ assistantClearRoute,
3214
+ bookshelfRoute,
3215
+ bookshelfActivateRoute,
3216
+ bookshelfRemoveRoute,
3217
+ bookshelfImportDirRoute,
3218
+ bookshelfImportTextRoute,
3219
+ bookshelfImportTextPreviewRoute,
3220
+ resetRoute,
3221
+ auditRoute,
3222
+ charactersRefreshRoute,
3223
+ factsBackfillRoute,
3224
+ biblePatchRoute,
3225
+ blurbRoute,
3226
+ coverRoute,
3227
+ worldRoute,
3228
+ renameRoute,
3229
+ plotlinesRoute,
3230
+ rolesRoute,
3231
+ sensitiveRoute,
3232
+ reviewBackfillRoute,
3233
+ chapterResetRoute,
3234
+ chapterApproveRoute,
3235
+ configRoute,
3236
+ openFolderRoute,
3237
+ pluginUpdateRoute,
3238
+ outlineSuggestRoute,
3239
+ outlineReverseRoute,
3240
+ llmTestRoute,
3241
+ addModelRoute,
3242
+ llmVendorsRoute,
3243
+ llmModelsRoute,
3244
+ llmProvidersRoute,
3245
+ llmRemoveRoute,
3246
+ breakdownRoute,
3247
+ runStartRoute,
3248
+ runControlRoute,
3249
+ runStatusRoute,
3250
+ authorAssetsRoute,
3251
+ authorAssetsUpsertRoute,
3252
+ authorAssetsRemoveRoute,
3253
+ authorAssetsImportDefaultRoute,
3254
+ adaptAnalyzeRoute,
3255
+ adaptProposeRoute,
3256
+ adaptExecuteRoute,
3257
+ adaptSaveRoute,
3258
+ adaptMaterializeRoute,
3259
+ adaptMaterializeSaveRoute,
3260
+ adaptRewriteStreamRoute,
3261
+ themeBackgroundUploadRoute,
3262
+ themeBackgroundGetRoute,
3263
+ ]
3264
+ }
3265
+
3266
+ // Re-export for tests / type consumers.
3267
+ export type {
3268
+ ConfigPatch,
3269
+ NovelConfig,
3270
+ StatusResponse,
3271
+ PlanResponse,
3272
+ LoadOutlineResponse,
3273
+ BibleResponse,
3274
+ VolumesResponse,
3275
+ ExportResponse,
3276
+ }
3277
+ export { chapterFileName }