@waterwx/dsh-novel-forge 2.0.1-alpha → 2.1.0

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