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