@waterwx/dsh-novel-forge 2.0.0-alpha.1 → 2.0.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.
- package/README.md +286 -285
- package/lib/client.js +19561 -19439
- package/lib/index.js +8159 -4456
- package/lib/types/action-guard.d.ts +61 -0
- package/lib/types/ai-scan.d.ts +0 -6
- package/lib/types/assets.d.ts +20 -9
- package/lib/types/assistant.d.ts +29 -0
- package/lib/types/author-assets.d.ts +1 -3
- package/lib/types/bookshelf.d.ts +13 -3
- package/lib/types/client/api.d.ts +136 -27
- package/lib/types/client/chip-prefs.d.ts +36 -0
- package/lib/types/client/docx.d.ts +2 -2
- package/lib/types/client/llmLive.d.ts +31 -0
- package/lib/types/client/locales.d.ts +15 -70
- package/lib/types/client/mount.d.ts +0 -2
- package/lib/types/client/panel/AiTag.d.ts +1 -0
- package/lib/types/client/panel/AssistantTab.d.ts +4 -1
- package/lib/types/client/panel/AuthorHome.d.ts +13 -11
- package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
- package/lib/types/client/panel/CmdPalette.d.ts +12 -0
- package/lib/types/client/panel/CreateBookView.d.ts +5 -1
- package/lib/types/client/panel/DirectorView.d.ts +7 -0
- package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
- package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
- package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
- package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
- package/lib/types/client/panel/ModelManager.d.ts +2 -2
- package/lib/types/client/panel/NovelPanel.d.ts +6 -1
- package/lib/types/client/panel/PageHeader.d.ts +18 -0
- package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
- package/lib/types/client/panel/SettingsView.d.ts +12 -9
- package/lib/types/client/panel/ShelfView.d.ts +10 -1
- package/lib/types/client/panel/SubPage.d.ts +27 -0
- package/lib/types/client/panel/TensionView.d.ts +18 -0
- package/lib/types/client/panel/TimelineView.d.ts +18 -0
- package/lib/types/client/panel/helpers.d.ts +5 -3
- package/lib/types/client/panel/market-radar-state.d.ts +56 -0
- package/lib/types/client/panel/toast.d.ts +2 -0
- package/lib/types/client/panel/views.d.ts +21 -35
- package/lib/types/client/run-status.d.ts +21 -0
- package/lib/types/client/sidebar-entry.d.ts +0 -2
- package/lib/types/docx-core.d.ts +16 -0
- package/lib/types/docx.d.ts +2 -3
- package/lib/types/engine.d.ts +78 -103
- package/lib/types/global-assets.d.ts +6 -0
- package/lib/types/home.d.ts +11 -0
- package/lib/types/index.d.ts +26 -12
- package/lib/types/llm-live.d.ts +70 -0
- package/lib/types/llm-retry.d.ts +38 -0
- package/lib/types/market-radar-scan.d.ts +18 -0
- package/lib/types/market-radar-sources.d.ts +31 -0
- package/lib/types/migrate-output.d.ts +25 -0
- package/lib/types/model-capability.d.ts +93 -0
- package/lib/types/output-budget.d.ts +86 -0
- package/lib/types/prompt-slots.d.ts +57 -0
- package/lib/types/prompting.d.ts +10 -0
- package/lib/types/protocol.d.ts +816 -601
- package/lib/types/revision.d.ts +76 -0
- package/lib/types/routes.d.ts +9 -3
- package/lib/types/run.d.ts +23 -1
- package/lib/types/settings-persist.d.ts +59 -0
- package/lib/types/snapshots.d.ts +46 -0
- package/lib/types/stage-contract.d.ts +58 -0
- package/lib/types/tension.d.ts +53 -0
- package/lib/types/timeline.d.ts +37 -0
- package/package.json +50 -25
- package/src/action-guard.ts +127 -0
- package/src/ai-scan.ts +0 -24
- package/src/assets.ts +1801 -879
- package/src/assistant.ts +1098 -770
- package/src/author-assets.ts +131 -147
- package/src/bookshelf.ts +193 -167
- package/src/client/api.ts +767 -617
- package/src/client/chip-prefs.ts +121 -0
- package/src/client/docx.ts +32 -69
- package/src/client/index.ts +2 -0
- package/src/client/llmLive.ts +111 -0
- package/src/client/locales.ts +15 -274
- package/src/client/mount.tsx +3 -2
- package/src/client/panel/AdaptModeView.tsx +535 -520
- package/src/client/panel/AiTag.tsx +7 -0
- package/src/client/panel/AssetsTab.tsx +448 -458
- package/src/client/panel/AssistantTab.tsx +355 -301
- package/src/client/panel/AuthorAssetsView.tsx +205 -207
- package/src/client/panel/AuthorHome.tsx +309 -204
- package/src/client/panel/BookAnalysisView.tsx +63 -0
- package/src/client/panel/CmdPalette.tsx +102 -0
- package/src/client/panel/CreateBookView.tsx +366 -352
- package/src/client/panel/DirectorView.tsx +200 -0
- package/src/client/panel/GlobalAssetLibraryView.tsx +168 -172
- package/src/client/panel/IdeaInspirationView.tsx +76 -0
- package/src/client/panel/ImportModal.tsx +302 -302
- package/src/client/panel/KnowledgeBaseView.tsx +125 -0
- package/src/client/panel/LiveFeedLog.tsx +204 -0
- package/src/client/panel/MarketRadarView.tsx +415 -0
- package/src/client/panel/ModelManager.tsx +205 -205
- package/src/client/panel/NovelPanel.tsx +3144 -2205
- package/src/client/panel/PageHeader.tsx +37 -0
- package/src/client/panel/ProgressConsole.tsx +63 -64
- package/src/client/panel/PromptSlotsView.tsx +97 -0
- package/src/client/panel/ReaderView.tsx +306 -306
- package/src/client/panel/RunPanel.tsx +56 -24
- package/src/client/panel/SettingsView.tsx +175 -106
- package/src/client/panel/ShelfView.tsx +63 -39
- package/src/client/panel/SubPage.tsx +42 -0
- package/src/client/panel/TensionView.tsx +212 -0
- package/src/client/panel/TimelineView.tsx +208 -0
- package/src/client/panel/WorldTab.tsx +202 -202
- package/src/client/panel/helpers.ts +7 -5
- package/src/client/panel/market-radar-state.ts +79 -0
- package/src/client/panel/panel.module.css +5767 -4360
- package/src/client/panel/toast.ts +51 -0
- package/src/client/panel/views.tsx +316 -314
- package/src/client/run-status.ts +171 -0
- package/src/client/sidebar-entry.ts +3 -3
- package/src/docx-core.ts +53 -0
- package/src/docx.ts +44 -83
- package/src/engine.ts +1197 -1638
- package/src/global-assets.ts +58 -0
- package/src/home.ts +33 -0
- package/src/index.ts +181 -101
- package/src/llm-live.ts +245 -0
- package/src/llm-retry.ts +77 -0
- package/src/market-radar-scan.ts +35 -0
- package/src/market-radar-sources.ts +173 -0
- package/src/migrate-output.ts +76 -0
- package/src/model-capability.ts +215 -0
- package/src/novel-context.ts +286 -287
- package/src/output-budget.ts +147 -0
- package/src/prompt-slots.ts +125 -0
- package/src/prompting.ts +26 -0
- package/src/protocol.ts +835 -614
- package/src/revision.ts +283 -0
- package/src/routes.ts +1286 -910
- package/src/run.ts +113 -17
- package/src/settings-persist.ts +114 -0
- package/src/snapshots.ts +174 -0
- package/src/stage-contract.ts +186 -0
- package/src/tension.ts +251 -0
- package/src/timeline.ts +159 -0
- package/assets/styles/arcane-thick-3d.webp +0 -0
- package/assets/styles/chinese-ink.webp +0 -0
- package/assets/styles/clay-stopmotion-3d.webp +0 -0
- package/assets/styles/dark-ghibli.webp +0 -0
- package/assets/styles/dark-gothic-3d.webp +0 -0
- package/assets/styles/film-photo.webp +0 -0
- package/assets/styles/guofeng-heavy.webp +0 -0
- package/assets/styles/guoman-2d-gufeng.webp +0 -0
- package/assets/styles/guoman-3d-tianyuan.webp +0 -0
- package/assets/styles/guoman-3d-xuanhuan.webp +0 -0
- package/assets/styles/horror-game-3d.webp +0 -0
- package/assets/styles/hyperreal-3d.webp +0 -0
- package/assets/styles/japan-cel-dark.webp +0 -0
- package/assets/styles/korean-webtoon.webp +0 -0
- package/assets/styles/low-cyberpunk-3d.webp +0 -0
- package/assets/styles/nextgen-cg.webp +0 -0
- package/assets/styles/noir-film.webp +0 -0
- package/assets/styles/openworld-survival.webp +0 -0
- package/assets/styles/paper-cut-3d.webp +0 -0
- package/assets/styles/pixar-adult-3d.webp +0 -0
- package/assets/styles/pop-marvel-2d.webp +0 -0
- package/assets/styles/sandiao-qban.webp +0 -0
- package/assets/styles/shinkai-anime.webp +0 -0
- package/assets/styles/tactical-stealth.webp +0 -0
- package/assets/styles/urban-lightreal-3d.webp +0 -0
- package/assets/styles/vaporwave-3d.webp +0 -0
- package/assets/styles/vhs90.webp +0 -0
- package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
- package/lib/types/client/panel/FlowGuide.d.ts +0 -22
- package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -20
- package/lib/types/client/panel/MangaWorkspace.d.ts +0 -12
- package/lib/types/client/panel/ModelPicker.d.ts +0 -11
- package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
- package/lib/types/client/panel/PropLibrary.d.ts +0 -8
- package/lib/types/client/panel/SceneLibrary.d.ts +0 -16
- package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
- package/lib/types/client/panel/StyleCard.d.ts +0 -8
- package/lib/types/client/panel/StyleGallery.d.ts +0 -1
- package/lib/types/comic-presets.d.ts +0 -12
- package/lib/types/manga-genre-rules.d.ts +0 -22
- package/lib/types/prompt-utils.d.ts +0 -35
- package/lib/types/review-policy.d.ts +0 -97
- package/lib/types/shot-language.d.ts +0 -46
- package/lib/types/story-beat-language.d.ts +0 -35
- package/lib/types/style-library.d.ts +0 -33
- package/skills/chapter-batch/SKILL.md +0 -81
- package/src/client/mount.tsx._new +0 -97
- package/src/client/mount.tsx._nf +0 -97
- package/src/client/mount.tsx._tmp +0 -97
- package/src/client/panel/BookshelfBar.tsx +0 -170
- package/src/client/panel/FlowGuide.tsx +0 -84
- package/src/client/panel/MangaRoleLibrary.tsx +0 -1002
- package/src/client/panel/MangaWorkspace.tsx +0 -715
- package/src/client/panel/ProgressHomeView.tsx +0 -87
- package/src/client/panel/PropLibrary.tsx +0 -175
- package/src/client/panel/SceneLibrary.tsx +0 -453
- package/src/client/panel/StoryboardTab.tsx +0 -472
- package/src/client/panel/StyleCard.tsx +0 -90
- package/src/client/panel/StyleGallery.tsx +0 -58
- package/src/comic-presets.ts +0 -67
- package/src/manga-genre-rules.ts +0 -87
- package/src/prompt-utils.ts +0 -73
- package/src/review-policy.ts +0 -161
- package/src/shot-language.ts +0 -181
- package/src/story-beat-language.ts +0 -130
- package/src/style-library.ts +0 -84
package/src/routes.ts
CHANGED
|
@@ -12,13 +12,12 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
|
12
12
|
import { spawn } from 'node:child_process'
|
|
13
13
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
14
14
|
import { join, basename, extname } from 'node:path'
|
|
15
|
-
import { homedir } from 'node:os'
|
|
16
15
|
import { randomBytes } from 'node:crypto'
|
|
17
|
-
import { fileURLToPath } from 'node:url'
|
|
18
16
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
19
17
|
import type { Context } from '@deepseek-ai/cordis'
|
|
20
18
|
import { ProductionRunner } from './run.ts'
|
|
21
19
|
import {
|
|
20
|
+
EXPORT_SCOPE_VALUES,
|
|
22
21
|
NOVEL_API,
|
|
23
22
|
type AssetsPatch,
|
|
24
23
|
type AssetsResponse,
|
|
@@ -46,11 +45,8 @@ import {
|
|
|
46
45
|
type BookActivateRequest,
|
|
47
46
|
type BookCreateRequest,
|
|
48
47
|
type BookImportDirRequest,
|
|
49
|
-
type BookImportDirResponse,
|
|
50
48
|
type BookImportTextRequest,
|
|
51
|
-
type BookImportTextResponse,
|
|
52
49
|
type BookImportTextPreviewRequest,
|
|
53
|
-
type BookImportTextPreviewResponse,
|
|
54
50
|
type BookRemoveRequest,
|
|
55
51
|
type BookshelfSnapshot,
|
|
56
52
|
type ChapterResponse,
|
|
@@ -58,6 +54,13 @@ import {
|
|
|
58
54
|
type ChapterSaveResponse,
|
|
59
55
|
type ChapterTextRequest,
|
|
60
56
|
type ConfigPatch,
|
|
57
|
+
type ConfigResponse,
|
|
58
|
+
type GenerateRequest,
|
|
59
|
+
type MoveOutputDirRequest,
|
|
60
|
+
type MoveOutputDirResponse,
|
|
61
|
+
type BookSettings,
|
|
62
|
+
type BookSettingsRequest,
|
|
63
|
+
type BookSettingsResponse,
|
|
61
64
|
type DraftDecisionRequest,
|
|
62
65
|
type ExportRequest,
|
|
63
66
|
type ExportResponse,
|
|
@@ -65,13 +68,18 @@ import {
|
|
|
65
68
|
type ForeshadowResponse,
|
|
66
69
|
type JobFrame,
|
|
67
70
|
type LoadOutlineRequest,
|
|
68
|
-
type MangaPlan,
|
|
69
|
-
type MangaPlansRequest,
|
|
70
|
-
type MangaPlansResponse,
|
|
71
|
-
type MangaRoleCard,
|
|
72
|
-
type MangaRolesRequest,
|
|
73
|
-
type MangaRolesResponse,
|
|
74
71
|
type LoadOutlineResponse,
|
|
72
|
+
type ExportScope,
|
|
73
|
+
type PromptSlotsRequest,
|
|
74
|
+
type PromptSlotsResponse,
|
|
75
|
+
type SnapshotRequest,
|
|
76
|
+
type SnapshotResponse,
|
|
77
|
+
type RevisionPlanRequest,
|
|
78
|
+
type RevisionPlanResponse,
|
|
79
|
+
type TensionRequest,
|
|
80
|
+
type TensionResponse,
|
|
81
|
+
type TimelineRequest,
|
|
82
|
+
type TimelineResponse,
|
|
75
83
|
type NovelConfig,
|
|
76
84
|
type PlotlinesRequest,
|
|
77
85
|
type PlotlinesResponse,
|
|
@@ -87,13 +95,6 @@ import {
|
|
|
87
95
|
type RewriteRequest,
|
|
88
96
|
type RolesRequest,
|
|
89
97
|
type RolesResponse,
|
|
90
|
-
type SceneCard,
|
|
91
|
-
type ScenesRequest,
|
|
92
|
-
type ScenesResponse,
|
|
93
|
-
type MangaPropsRequest,
|
|
94
|
-
type MangaPropsResponse,
|
|
95
|
-
type VisualRulesRequest,
|
|
96
|
-
type VisualRulesResponse,
|
|
97
98
|
type OutlineSuggestRequest,
|
|
98
99
|
type OutlineSuggestResponse,
|
|
99
100
|
type BreakdownRequest,
|
|
@@ -104,16 +105,8 @@ import {
|
|
|
104
105
|
type ProjectState,
|
|
105
106
|
type StatusResponse,
|
|
106
107
|
type StoryBible,
|
|
107
|
-
type StoryboardSkeletonRequest,
|
|
108
|
-
type StoryboardSkeletonResponse,
|
|
109
|
-
type StoryboardTableRequest,
|
|
110
|
-
type StoryboardTableResponse,
|
|
111
|
-
type StoryboardPromptsRequest,
|
|
112
|
-
type ImageTestRequest,
|
|
113
|
-
type ImageTestResponse,
|
|
114
108
|
type LlmTestRequest,
|
|
115
109
|
type LlmTestResponse,
|
|
116
|
-
type StoryboardPromptsResponse,
|
|
117
110
|
type StyleEngineRequest,
|
|
118
111
|
type SummaryRequest,
|
|
119
112
|
type VolumesRequest,
|
|
@@ -135,12 +128,27 @@ import {
|
|
|
135
128
|
type AdaptMaterializeSaveResponse,
|
|
136
129
|
type AdaptRewriteFrame,
|
|
137
130
|
type PluginUpdateResponse,
|
|
131
|
+
type MarketRadarRequest,
|
|
132
|
+
type MarketRadarBriefRequest,
|
|
133
|
+
type ProductionFoundation,
|
|
134
|
+
type ProgressionMode,
|
|
135
|
+
type GenreNode,
|
|
136
|
+
type KnowledgeRequest,
|
|
137
|
+
type BookAnalysisRequest,
|
|
138
|
+
type IdeaInspirationRequest,
|
|
139
|
+
type DirectorRequest,
|
|
140
|
+
type DirectorTodo,
|
|
138
141
|
} from './protocol.ts'
|
|
139
142
|
import { readOutlineFromDocx } from './docx.ts'
|
|
140
143
|
import { clearAssistantHistory, loadAssistantHistory, runAssistantTurn } from './assistant.ts'
|
|
141
|
-
import { activateBook, bookshelfSnapshot, createBook, defaultOutputDirFor, importDir, loadBookshelf, removeBook, renameBook, seedBookshelfFromOutputDir } from './bookshelf.ts'
|
|
144
|
+
import { activateBook, bookshelfSnapshot, createBook, defaultOutputDirFor, importDir, loadBookshelf, removeBook, renameBook, repointBookDirs, seedBookshelfFromOutputDir } from './bookshelf.ts'
|
|
145
|
+
import { copyDirContents, listDirContents, normalizeDir, removeDir } from './migrate-output.ts'
|
|
142
146
|
import { loadAuthorAssets, upsertAuthorAsset, removeAuthorAsset, importDefaultAuthorAssets } from './author-assets.ts'
|
|
143
|
-
import { BUILTIN_ANTI_AI_RULES, BUILTIN_GENRE_LIBRARY, BUILTIN_PLOT_BEATS, BUILTIN_PROGRESSION_MODES, BUILTIN_STYLE_TEMPLATES, emptyProjectAssets } from './assets.ts'
|
|
147
|
+
import { BUILTIN_ANTI_AI_RULES, BUILTIN_GENRE_LIBRARY, BUILTIN_PLOT_BEATS, BUILTIN_PROGRESSION_MODES, BUILTIN_STARTER_STYLE_PROFILES, BUILTIN_STYLE_TEMPLATES, emptyProjectAssets, ensureBuiltinAssets } from './assets.ts'
|
|
148
|
+
import { scanAiFlavor } from './ai-scan.ts'
|
|
149
|
+
import { emitLive, livePrompt, liveUsage, nextSessionId, resetLiveUsage, subscribeLiveFeed } from './llm-live.ts'
|
|
150
|
+
import { scanMarketRanking } from './market-radar-scan.ts'
|
|
151
|
+
import { addGlobalGenre, addGlobalMode, globalGenreLibrary, globalProgressionLibrary } from './global-assets.ts'
|
|
144
152
|
import {
|
|
145
153
|
chapterFileName,
|
|
146
154
|
auditBook,
|
|
@@ -148,12 +156,19 @@ import {
|
|
|
148
156
|
autoLinkPlotlines,
|
|
149
157
|
backfillFacts,
|
|
150
158
|
createProject,
|
|
151
|
-
|
|
159
|
+
exportScope,
|
|
152
160
|
importBookText,
|
|
153
161
|
importBookTextFromText,
|
|
154
162
|
previewBookText,
|
|
155
163
|
extractBible,
|
|
156
164
|
extractStyleAsset,
|
|
165
|
+
extractStyleFormula,
|
|
166
|
+
runMarketRadar,
|
|
167
|
+
runMarketCreativeBrief,
|
|
168
|
+
runBookAnalysis,
|
|
169
|
+
runIdeaInspiration,
|
|
170
|
+
runMarketIdeaInspiration,
|
|
171
|
+
runDirectorAdvice,
|
|
157
172
|
extractWorld,
|
|
158
173
|
generateBlurb,
|
|
159
174
|
generateChapterStream,
|
|
@@ -174,14 +189,6 @@ import {
|
|
|
174
189
|
analyzePlotlineHealth,
|
|
175
190
|
designPlotlinePlan,
|
|
176
191
|
extractRoles,
|
|
177
|
-
extractScenes,
|
|
178
|
-
generateRolePromptKit,
|
|
179
|
-
generateMangaRolePromptKit,
|
|
180
|
-
extractVisualRules,
|
|
181
|
-
extractProps,
|
|
182
|
-
extractRoleVisual,
|
|
183
|
-
extractMangaRoleVisual,
|
|
184
|
-
nominateMangaRoles,
|
|
185
192
|
suggestOutlines,
|
|
186
193
|
reverseOutlineFromChapters,
|
|
187
194
|
breakdownBook,
|
|
@@ -191,32 +198,43 @@ import {
|
|
|
191
198
|
rewriteAdaptationBook,
|
|
192
199
|
materializeAdaptedBook,
|
|
193
200
|
saveMaterializedBook,
|
|
194
|
-
getActiveMangaStyle,
|
|
195
201
|
testLlmModel,
|
|
196
202
|
registerLlmModel,
|
|
197
203
|
listLlmVendors,
|
|
198
204
|
listLlmModels,
|
|
199
205
|
listLlmProviders,
|
|
200
206
|
removeLlmProvider,
|
|
201
|
-
generateStoryboardSkeleton,
|
|
202
|
-
generateStoryboardTable,
|
|
203
|
-
generateStoryboardPrompts,
|
|
204
|
-
autoGenerateMangaChapter,
|
|
205
|
-
mangaAssetsDir,
|
|
206
|
-
saveMangaAssetPackage,
|
|
207
207
|
checkSensitiveText,
|
|
208
208
|
suggestForeshadows,
|
|
209
209
|
suggestPlotlines,
|
|
210
210
|
summarizeAndExtractFacts,
|
|
211
|
+
extractTimelineForChapter,
|
|
212
|
+
reviewTimeline,
|
|
211
213
|
summarizeChapter,
|
|
212
214
|
syncProjectWithDisk,
|
|
213
215
|
} from './engine.ts'
|
|
216
|
+
import { countHanzi } from './engine.ts'
|
|
217
|
+
import { computeBookStage } from './stage-contract.ts'
|
|
218
|
+
import { createSnapshot, loadSnapshots, pruneSnapshots, removeSnapshot, restoreSnapshot, snapshotsBytes } from './snapshots.ts'
|
|
219
|
+
import { detectTimelineIssues } from './timeline.ts'
|
|
220
|
+
import { buildCurveData, detectTensionIssues, TENSION_MAX, TENSION_MIN, TENSION_PRESETS, clampTension } from './tension.ts'
|
|
221
|
+
import { PROMPT_SLOTS, normalizeSlotValue, readPromptSlots } from './prompt-slots.ts'
|
|
222
|
+
import { buildRevisionPlan, describeRevisionPlan, REVISION_ITEM_CAP } from './revision.ts'
|
|
223
|
+
import { dshHomePath } from './home.ts'
|
|
214
224
|
|
|
215
225
|
/** Cap on JSON request bodies (generous: cover images travel as base64). */
|
|
216
226
|
const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024
|
|
217
227
|
|
|
218
228
|
/** 包内置风格效果图目录(assets/styles,随 npm 包分发)。 */
|
|
219
|
-
|
|
229
|
+
|
|
230
|
+
/** 解析请求级目标书目录:优先按请求携带的 bookId 查书架,否则回退全局 active 书目录。 */
|
|
231
|
+
function resolveOutputDir(config: NovelConfig, bookId?: string): string {
|
|
232
|
+
if (bookId !== undefined && bookId !== '') {
|
|
233
|
+
const book = loadBookshelf().books.find(b => b.id === bookId)
|
|
234
|
+
if (book !== undefined && book.outputDir !== '') return book.outputDir
|
|
235
|
+
}
|
|
236
|
+
return config.outputDir
|
|
237
|
+
}
|
|
220
238
|
|
|
221
239
|
/** Loopback-only fence (mirrors the family plugins' pairing routes). */
|
|
222
240
|
function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
@@ -270,13 +288,29 @@ export interface NovelRoutesDeps {
|
|
|
270
288
|
ctx: Context
|
|
271
289
|
/** Resolve the live plugin config (settings-aware). */
|
|
272
290
|
getConfig: () => NovelConfig
|
|
273
|
-
/** Persist a config patch through the settings seam. */
|
|
274
|
-
patchConfig: (patch: ConfigPatch) => Promise<
|
|
291
|
+
/** Persist a config patch through the settings seam (memory fallback included). */
|
|
292
|
+
patchConfig: (patch: ConfigPatch) => Promise<ConfigResponse>
|
|
293
|
+
/** Raw settings config(未与激活书合并)——目录迁移判断默认目录是否需同步用。 */
|
|
294
|
+
rawConfig?: () => { outputDir?: string }
|
|
295
|
+
/** Loader entry id the settings section is addressed by (undefined when unknown). */
|
|
296
|
+
settingsNs?: string
|
|
275
297
|
}
|
|
276
298
|
|
|
277
299
|
/** Default chapter count for planning when the request omits it. */
|
|
278
300
|
const DEFAULT_PLAN_COUNT = 30
|
|
279
301
|
|
|
302
|
+
/**
|
|
303
|
+
* The profile this host booted (`--profile <name>` on the dsh CLI invocation).
|
|
304
|
+
* Profile-scoped paths must not assume a name: the shipped template is `web`,
|
|
305
|
+
* but a custom profile (`web-alpha`) is the one actually running.
|
|
306
|
+
* @returns the profile name, or undefined when the flag is absent.
|
|
307
|
+
*/
|
|
308
|
+
function argvProfile(): string | undefined {
|
|
309
|
+
const flag = process.argv.indexOf('--profile')
|
|
310
|
+
const value = flag === -1 ? undefined : process.argv[flag + 1]
|
|
311
|
+
return value !== undefined && !value.startsWith('-') ? value : undefined
|
|
312
|
+
}
|
|
313
|
+
|
|
280
314
|
/**
|
|
281
315
|
* Build every /api/dsh-novel-forge route.
|
|
282
316
|
* @param deps - context, config resolver, config patcher.
|
|
@@ -285,6 +319,13 @@ const DEFAULT_PLAN_COUNT = 30
|
|
|
285
319
|
export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
286
320
|
const { ctx, getConfig, patchConfig } = deps
|
|
287
321
|
|
|
322
|
+
/**
|
|
323
|
+
* 新书默认目录的基准:settings 里配置的输出目录(未经激活书合并,激活书的
|
|
324
|
+
* outputDir 指向它自己,不能当作新书的父目录)。未配置时 `defaultOutputDirFor`
|
|
325
|
+
* 回退到 `<DSH_HOME>/novels`。
|
|
326
|
+
*/
|
|
327
|
+
const defaultBookDir = (bookName: string): string => defaultOutputDirFor(bookName, deps.rawConfig?.().outputDir)
|
|
328
|
+
|
|
288
329
|
/** 全书质检实时状态(内存态,重启后回到 idle;用于 /status 暴露进度)。 */
|
|
289
330
|
let auditState: AuditStatus = { status: 'idle', totalBatches: 0, completedBatches: 0 }
|
|
290
331
|
|
|
@@ -321,9 +362,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
321
362
|
handler: (req, res) => {
|
|
322
363
|
if (!guard(req, res, 'GET')) return
|
|
323
364
|
const config = getConfig()
|
|
365
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
366
|
+
const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
|
|
324
367
|
// 书架为空时播种 settings 默认输出目录里的已有项目。
|
|
325
|
-
seedBookshelfFromOutputDir(
|
|
326
|
-
const project = loadProject(
|
|
368
|
+
seedBookshelfFromOutputDir(outputDir)
|
|
369
|
+
const project = loadProject(outputDir)
|
|
327
370
|
if (project !== undefined) {
|
|
328
371
|
// 自动兜底:generating 超过 10 分钟(正常一章 2-5 分钟)视为卡死/中断,
|
|
329
372
|
// 复位为 pending,避免"生成中"状态卡死无法重新生成。
|
|
@@ -338,14 +381,18 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
338
381
|
}
|
|
339
382
|
}
|
|
340
383
|
}
|
|
341
|
-
syncProjectWithDisk(project,
|
|
342
|
-
saveProject(
|
|
384
|
+
syncProjectWithDisk(project, outputDir)
|
|
385
|
+
saveProject(outputDir, project)
|
|
343
386
|
}
|
|
344
387
|
const slim = new URL(req.url ?? '/', 'http://localhost').searchParams.get('slim') === '1'
|
|
388
|
+
// 截断显式化:任何被裁掉的字段都要在响应里声明,客户端/模型不能
|
|
389
|
+
// 把"看到的"当成"全部"。静默截断会让一致性判断建立在不完整事实上。
|
|
390
|
+
const truncations: string[] = []
|
|
345
391
|
let projectPayload: StatusResponse['project']
|
|
346
392
|
if (project === undefined) {
|
|
347
393
|
projectPayload = undefined
|
|
348
394
|
} else if (slim) {
|
|
395
|
+
truncations.push('slim=1:正文/beats/图集/道藏/大世界/资产等重字段未返回,大纲仅返回前 200 字')
|
|
349
396
|
// slim 瘦身:轮询/角标只需章节状态与分数 + 轻量元数据,
|
|
350
397
|
// 去掉正文、beats、图集(base64)、道藏/大世界/资产等重字段,避免长书轮询反复传输大体积。
|
|
351
398
|
projectPayload = {
|
|
@@ -366,13 +413,23 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
366
413
|
} else {
|
|
367
414
|
// 瘦身:facts 只回最近 80 条(客户端最多展示 60),避免长篇后
|
|
368
415
|
// status 响应体随编年录无限膨胀。
|
|
369
|
-
|
|
416
|
+
const allFacts = project.facts ?? []
|
|
417
|
+
const shownFacts = allFacts.slice(-80)
|
|
418
|
+
if (allFacts.length > shownFacts.length) {
|
|
419
|
+
truncations.push(`编年录:共 ${allFacts.length} 条,仅返回最近 ${shownFacts.length} 条(较早 ${allFacts.length - shownFacts.length} 条未返回)`)
|
|
420
|
+
}
|
|
421
|
+
projectPayload = { ...project, facts: shownFacts }
|
|
370
422
|
}
|
|
371
423
|
const response: StatusResponse = {
|
|
372
424
|
config,
|
|
373
425
|
project: projectPayload,
|
|
374
426
|
generatedFiles: listChapterFiles(config.outputDir),
|
|
375
427
|
audit: auditState,
|
|
428
|
+
// 阶段契约:宿主算一次,面板角标、助手提示词、外部自动化共用同一结论。
|
|
429
|
+
stage: computeBookStage(project),
|
|
430
|
+
// 本次运行(进程内)的 LLM 用量:调用次数 / 输入·输出·思考 token / 耗时。
|
|
431
|
+
usage: liveUsage(),
|
|
432
|
+
...(truncations.length > 0 ? { truncations } : {}),
|
|
376
433
|
}
|
|
377
434
|
writeJson(res, 200, response)
|
|
378
435
|
},
|
|
@@ -477,9 +534,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
477
534
|
path: NOVEL_API.volumes,
|
|
478
535
|
handler: async (req, res) => {
|
|
479
536
|
if (!guard(req, res, 'POST')) return
|
|
480
|
-
const body = await readJsonBody<VolumesRequest>(req)
|
|
481
|
-
const
|
|
482
|
-
const
|
|
537
|
+
const body = await readJsonBody<VolumesRequest & { bookId?: string }>(req)
|
|
538
|
+
const config0 = getConfig()
|
|
539
|
+
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
540
|
+
const config = { ...config0, outputDir }
|
|
541
|
+
const project = loadProject(outputDir)
|
|
483
542
|
const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
|
|
484
543
|
? body.outline
|
|
485
544
|
: project?.outline
|
|
@@ -493,7 +552,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
493
552
|
const next = project ?? createProject(outline)
|
|
494
553
|
next.volumes = volumes
|
|
495
554
|
next.updatedAt = now
|
|
496
|
-
saveProject(
|
|
555
|
+
saveProject(outputDir, next)
|
|
497
556
|
const response: VolumesResponse = { volumes }
|
|
498
557
|
writeJson(res, 200, response)
|
|
499
558
|
} catch (error) {
|
|
@@ -508,9 +567,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
508
567
|
path: NOVEL_API.plan,
|
|
509
568
|
handler: async (req, res) => {
|
|
510
569
|
if (!guard(req, res, 'POST')) return
|
|
511
|
-
const body = await readJsonBody<PlanRequest>(req)
|
|
512
|
-
const
|
|
513
|
-
const
|
|
570
|
+
const body = await readJsonBody<PlanRequest & { bookId?: string }>(req)
|
|
571
|
+
const config0 = getConfig()
|
|
572
|
+
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
573
|
+
const config = { ...config0, outputDir }
|
|
574
|
+
const project = loadProject(outputDir)
|
|
514
575
|
const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
|
|
515
576
|
? body.outline
|
|
516
577
|
: project?.outline
|
|
@@ -546,10 +607,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
546
607
|
path: NOVEL_API.generate,
|
|
547
608
|
handler: async (req, res) => {
|
|
548
609
|
if (!guard(req, res, 'POST')) return
|
|
549
|
-
const
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
const
|
|
610
|
+
const config0 = getConfig()
|
|
611
|
+
const body = await readJsonBody<GenerateRequest & { bookId?: string }>(req)
|
|
612
|
+
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
613
|
+
const config = { ...config0, outputDir }
|
|
614
|
+
const project = loadProject(outputDir)
|
|
615
|
+
if (project === undefined) {
|
|
616
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
617
|
+
return
|
|
618
|
+
}
|
|
553
619
|
const rawNo = body?.chapterNo
|
|
554
620
|
if (!Number.isInteger(rawNo) || rawNo === undefined || rawNo < 1) {
|
|
555
621
|
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
@@ -584,21 +650,38 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
584
650
|
res.write(JSON.stringify(frame) + '\n')
|
|
585
651
|
}
|
|
586
652
|
|
|
653
|
+
const liveSession = nextSessionId()
|
|
654
|
+
emitLive({ type: 'session_started', sessionId: liveSession, label: '正文生成', model: config.generateModel || config.model, at: new Date().toISOString(), context: { interactionId: liveSession, taskId: `ch-${no}` } })
|
|
655
|
+
emitLive({ type: 'phase_changed', sessionId: liveSession, phase: 'streaming', phaseMessage: '模型正在返回正文', at: new Date().toISOString() })
|
|
656
|
+
|
|
587
657
|
try {
|
|
588
658
|
send({ type: 'start', no, title: chapter.title })
|
|
659
|
+
let genChars = 0
|
|
589
660
|
for await (const step of generateChapterStream(ctx, config, project, config.outputDir, no)) {
|
|
590
661
|
if (step.frame === 'delta') {
|
|
591
662
|
send({ type: 'delta', text: step.text })
|
|
663
|
+
genChars += step.text.length
|
|
664
|
+
emitLive({ type: 'output_delta', sessionId: liveSession, content: step.text, totalChars: genChars, at: new Date().toISOString() })
|
|
592
665
|
} else if (step.frame === 'done') {
|
|
593
666
|
send({ type: 'done', no, file: step.file, chars: step.chars, title: chapter.title, warn: step.warn })
|
|
594
667
|
}
|
|
595
668
|
}
|
|
596
|
-
|
|
669
|
+
emitLive({ type: 'session_completed', sessionId: liveSession, totalChars: genChars, preview: '', at: new Date().toISOString(), phase: 'completed' })
|
|
670
|
+
// Auto pipeline: summary + facts(一次调用)-> timeline -> review(unless skipped)。
|
|
597
671
|
try {
|
|
598
672
|
await summarizeAndExtractFacts(ctx, config, project, config.outputDir, no)
|
|
599
673
|
} catch (error) {
|
|
600
674
|
console.warn('[dsh-novel-forge] summary/facts failed:', (error as Error).message)
|
|
601
675
|
}
|
|
676
|
+
// 故事时间线:抽取本章事件(失败不阻断出章,作者可在时间线页手工补)。
|
|
677
|
+
if (config.autoTimeline ?? true) {
|
|
678
|
+
try {
|
|
679
|
+
await extractTimelineForChapter(ctx, config, project, config.outputDir, no)
|
|
680
|
+
saveProject(config.outputDir, project)
|
|
681
|
+
} catch (error) {
|
|
682
|
+
console.warn('[dsh-novel-forge] timeline extract failed:', (error as Error).message)
|
|
683
|
+
}
|
|
684
|
+
}
|
|
602
685
|
// 伏笔落地标记:正文中命中 planned 伏笔关键词 → 自动标为 planted(暗线管理页与正文同步)。
|
|
603
686
|
try {
|
|
604
687
|
const marked = markForeshadowPlanted(project, config.outputDir, no)
|
|
@@ -662,17 +745,22 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
662
745
|
path: NOVEL_API.review,
|
|
663
746
|
handler: async (req, res) => {
|
|
664
747
|
if (!guard(req, res, 'POST')) return
|
|
665
|
-
const
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
const
|
|
748
|
+
const config0 = getConfig()
|
|
749
|
+
const body = await readJsonBody<ReviewRequest & { bookId?: string }>(req)
|
|
750
|
+
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
751
|
+
const config = { ...config0, outputDir }
|
|
752
|
+
const project = loadProject(outputDir)
|
|
753
|
+
if (project === undefined) {
|
|
754
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
755
|
+
return
|
|
756
|
+
}
|
|
669
757
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
670
758
|
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
671
759
|
return
|
|
672
760
|
}
|
|
673
761
|
const no = body!.chapterNo!
|
|
674
762
|
try {
|
|
675
|
-
const report = await reviewChapter(ctx, config, project,
|
|
763
|
+
const report = await reviewChapter(ctx, config, project, outputDir, no)
|
|
676
764
|
writeJson(res, 200, { report })
|
|
677
765
|
} catch (error) {
|
|
678
766
|
writeJson(res, 500, { error: (error as Error).message })
|
|
@@ -686,10 +774,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
686
774
|
path: NOVEL_API.rewrite,
|
|
687
775
|
handler: async (req, res) => {
|
|
688
776
|
if (!guard(req, res, 'POST')) return
|
|
689
|
-
const
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
const
|
|
777
|
+
const config0 = getConfig()
|
|
778
|
+
const body = await readJsonBody<RewriteRequest & { bookId?: string }>(req)
|
|
779
|
+
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
780
|
+
const config = { ...config0, outputDir }
|
|
781
|
+
const project = loadProject(outputDir)
|
|
782
|
+
if (project === undefined) {
|
|
783
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
784
|
+
return
|
|
785
|
+
}
|
|
693
786
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
694
787
|
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
695
788
|
return
|
|
@@ -725,10 +818,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
725
818
|
path: NOVEL_API.polish,
|
|
726
819
|
handler: async (req, res) => {
|
|
727
820
|
if (!guard(req, res, 'POST')) return
|
|
728
|
-
const
|
|
729
|
-
const
|
|
730
|
-
|
|
731
|
-
const
|
|
821
|
+
const config0 = getConfig()
|
|
822
|
+
const body = await readJsonBody<PolishRequest & { bookId?: string }>(req)
|
|
823
|
+
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
824
|
+
const config = { ...config0, outputDir }
|
|
825
|
+
const project = loadProject(outputDir)
|
|
826
|
+
if (project === undefined) {
|
|
827
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
828
|
+
return
|
|
829
|
+
}
|
|
732
830
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
733
831
|
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
734
832
|
return
|
|
@@ -790,7 +888,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
790
888
|
}
|
|
791
889
|
writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
|
|
792
890
|
chapter.pendingDraft = undefined
|
|
793
|
-
chapter.chars = draft
|
|
891
|
+
chapter.chars = countHanzi(draft)
|
|
794
892
|
chapter.file = fileName
|
|
795
893
|
// 携带审查报告则沿用结论定状态(修订后审查通过 → approved);否则置 written 待审。
|
|
796
894
|
const carried = body?.report
|
|
@@ -865,10 +963,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
865
963
|
path: NOVEL_API.foreshadow,
|
|
866
964
|
handler: async (req, res) => {
|
|
867
965
|
if (!guard(req, res, 'POST')) return
|
|
868
|
-
const
|
|
869
|
-
const
|
|
870
|
-
|
|
871
|
-
const
|
|
966
|
+
const config0 = getConfig()
|
|
967
|
+
const body = await readJsonBody<ForeshadowRequest & { bookId?: string }>(req)
|
|
968
|
+
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
969
|
+
const config = { ...config0, outputDir }
|
|
970
|
+
const project = loadProject(outputDir)
|
|
971
|
+
if (project === undefined) {
|
|
972
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
973
|
+
return
|
|
974
|
+
}
|
|
872
975
|
try {
|
|
873
976
|
if (body?.suggest === true) {
|
|
874
977
|
// AI suggestion pass: create several foreshadows from the outline.
|
|
@@ -922,14 +1025,21 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
922
1025
|
path: NOVEL_API.exportBook,
|
|
923
1026
|
handler: async (req, res) => {
|
|
924
1027
|
if (!guard(req, res, 'POST')) return
|
|
925
|
-
const
|
|
926
|
-
const
|
|
927
|
-
|
|
928
|
-
const
|
|
929
|
-
const
|
|
1028
|
+
const config0 = getConfig()
|
|
1029
|
+
const body = await readJsonBody<ExportRequest & { bookId?: string }>(req)
|
|
1030
|
+
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
1031
|
+
const config = { ...config0, outputDir }
|
|
1032
|
+
const project = loadProject(outputDir)
|
|
1033
|
+
if (project === undefined) {
|
|
1034
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
1035
|
+
return
|
|
1036
|
+
}
|
|
1037
|
+
const format: 'txt' | 'md' | 'json' = body?.format === 'json' ? 'json' : body?.format === 'md' ? 'md' : 'txt'
|
|
1038
|
+
// 导出范围:整本正文 / 设定 / 规划 / 角色 / 质检记录 / 项目 JSON。
|
|
1039
|
+
const scope: ExportScope = body?.scope !== undefined && EXPORT_SCOPE_VALUES.includes(body.scope) ? body.scope : 'book'
|
|
930
1040
|
try {
|
|
931
|
-
const result =
|
|
932
|
-
const response: ExportResponse = { ...result }
|
|
1041
|
+
const result = exportScope(config.outputDir, project, scope, format)
|
|
1042
|
+
const response: ExportResponse = { ...result, scope, format }
|
|
933
1043
|
writeJson(res, 200, response)
|
|
934
1044
|
} catch (error) {
|
|
935
1045
|
writeJson(res, 500, { error: (error as Error).message })
|
|
@@ -944,9 +1054,13 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
944
1054
|
handler: async (req, res) => {
|
|
945
1055
|
if (!guard(req, res, 'GET')) return
|
|
946
1056
|
const config = getConfig()
|
|
947
|
-
const project = requireProject(res)
|
|
948
|
-
if (project === undefined) return
|
|
949
1057
|
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
1058
|
+
const outputDir = resolveOutputDir(config, url.searchParams.get('bookId') ?? undefined)
|
|
1059
|
+
const project = loadProject(outputDir)
|
|
1060
|
+
if (project === undefined) {
|
|
1061
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
1062
|
+
return
|
|
1063
|
+
}
|
|
950
1064
|
const rawNo = Number(url.searchParams.get('no') ?? '0')
|
|
951
1065
|
if (!Number.isInteger(rawNo) || rawNo < 1) {
|
|
952
1066
|
writeJson(res, 400, { error: 'no 须为正整数' })
|
|
@@ -957,7 +1071,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
957
1071
|
writeJson(res, 404, { error: `章节 ${rawNo} 不在计划中` })
|
|
958
1072
|
return
|
|
959
1073
|
}
|
|
960
|
-
const markdown = readChapterFile(
|
|
1074
|
+
const markdown = readChapterFile(outputDir, chapter)
|
|
961
1075
|
if (markdown === undefined) {
|
|
962
1076
|
writeJson(res, 404, { error: `章节 ${rawNo} 尚未生成` })
|
|
963
1077
|
return
|
|
@@ -1023,9 +1137,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1023
1137
|
if (existsSync(targetPath)) {
|
|
1024
1138
|
copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
|
|
1025
1139
|
}
|
|
1140
|
+
// 覆盖前留一份历史版本:采纳草稿改坏了可以回滚。
|
|
1141
|
+
createSnapshot(config.outputDir, chapter, '采纳草稿前')
|
|
1026
1142
|
writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${text}\n`, 'utf8')
|
|
1027
1143
|
chapter.status = 'written'
|
|
1028
|
-
chapter.chars = text
|
|
1144
|
+
chapter.chars = countHanzi(text)
|
|
1029
1145
|
chapter.file = fileName
|
|
1030
1146
|
chapter.pendingDraft = undefined
|
|
1031
1147
|
// 保存即审稿:携带工作区审查报告则沿用(不重复审);否则自动正式审稿一次。
|
|
@@ -1052,9 +1168,13 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1052
1168
|
handler: async (req, res) => {
|
|
1053
1169
|
if (!guard(req, res, 'POST')) return
|
|
1054
1170
|
const config = getConfig()
|
|
1055
|
-
const
|
|
1056
|
-
|
|
1057
|
-
const
|
|
1171
|
+
const body = await readJsonBody<AssistantRequest & { bookId?: string }>(req)
|
|
1172
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1173
|
+
const project = loadProject(outputDir)
|
|
1174
|
+
if (project === undefined) {
|
|
1175
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
1176
|
+
return
|
|
1177
|
+
}
|
|
1058
1178
|
const message = body?.message?.trim()
|
|
1059
1179
|
if (message === undefined || message === '') {
|
|
1060
1180
|
writeJson(res, 400, { error: '消息不能为空' })
|
|
@@ -1068,10 +1188,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1068
1188
|
})
|
|
1069
1189
|
const send = (frame: AssistantFrame): void => { res.write(JSON.stringify(frame) + '\n') }
|
|
1070
1190
|
try {
|
|
1071
|
-
for await (const step of runAssistantTurn(ctx, config, project,
|
|
1191
|
+
for await (const step of runAssistantTurn(ctx, config, project, outputDir, message)) {
|
|
1072
1192
|
if (step.frame === 'delta') send({ type: 'delta', text: step.text })
|
|
1073
1193
|
else if (step.frame === 'tool') send({ type: 'tool', name: step.name, status: step.status, detail: step.detail })
|
|
1074
1194
|
else if (step.frame === 'toolDelta') send({ type: 'toolDelta', name: step.name, text: step.text })
|
|
1195
|
+
else if (step.frame === 'toolResult') send({ type: 'toolResult', name: step.name, text: step.text })
|
|
1075
1196
|
}
|
|
1076
1197
|
send({ type: 'done' })
|
|
1077
1198
|
res.end()
|
|
@@ -1091,7 +1212,9 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1091
1212
|
handler: (req, res) => {
|
|
1092
1213
|
if (!guard(req, res, 'GET')) return
|
|
1093
1214
|
const config = getConfig()
|
|
1094
|
-
const
|
|
1215
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
1216
|
+
const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
|
|
1217
|
+
const response: AssistantHistoryResponse = { messages: loadAssistantHistory(outputDir) }
|
|
1095
1218
|
writeJson(res, 200, response)
|
|
1096
1219
|
},
|
|
1097
1220
|
}
|
|
@@ -1101,10 +1224,12 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1101
1224
|
const assistantClearRoute: WebRoute = {
|
|
1102
1225
|
kind: 'exact',
|
|
1103
1226
|
path: NOVEL_API.assistantClear,
|
|
1104
|
-
handler: (req, res) => {
|
|
1227
|
+
handler: async (req, res) => {
|
|
1105
1228
|
if (!guard(req, res, 'POST')) return
|
|
1106
1229
|
const config = getConfig()
|
|
1107
|
-
|
|
1230
|
+
const body = await readJsonBody<{ bookId?: string }>(req)
|
|
1231
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1232
|
+
clearAssistantHistory(outputDir)
|
|
1108
1233
|
writeJson(res, 200, { ok: true })
|
|
1109
1234
|
},
|
|
1110
1235
|
}
|
|
@@ -1125,10 +1250,21 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1125
1250
|
return
|
|
1126
1251
|
}
|
|
1127
1252
|
const config = getConfig()
|
|
1128
|
-
|
|
1129
|
-
|
|
1253
|
+
let postBody: (AssetsPatch & { bookId?: string }) | undefined
|
|
1254
|
+
if (req.method === 'POST') {
|
|
1255
|
+
postBody = await readJsonBody<AssetsPatch & { bookId?: string }>(req)
|
|
1256
|
+
if (postBody === undefined) {
|
|
1257
|
+
writeJson(res, 400, { error: '无效的 JSON' })
|
|
1258
|
+
return
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
1262
|
+
const queryBookId = qurl.searchParams.get('bookId') ?? undefined
|
|
1263
|
+
const outputDir = resolveOutputDir(config, postBody?.bookId ?? queryBookId)
|
|
1264
|
+
const project = loadProject(outputDir)
|
|
1265
|
+
const projectAssets = ensureBuiltinAssets(project?.assets ?? emptyProjectAssets())
|
|
1130
1266
|
if (req.method === 'POST') {
|
|
1131
|
-
const body =
|
|
1267
|
+
const body = postBody
|
|
1132
1268
|
if (body === undefined) {
|
|
1133
1269
|
writeJson(res, 400, { error: '无效的 JSON' })
|
|
1134
1270
|
return
|
|
@@ -1145,14 +1281,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1145
1281
|
projectAssets.updatedAt = new Date().toISOString()
|
|
1146
1282
|
project.assets = projectAssets
|
|
1147
1283
|
project.updatedAt = new Date().toISOString()
|
|
1148
|
-
saveProject(
|
|
1284
|
+
saveProject(outputDir, project)
|
|
1149
1285
|
}
|
|
1150
1286
|
const response: AssetsResponse = {
|
|
1151
1287
|
projectAssets,
|
|
1152
|
-
genreLibrary: BUILTIN_GENRE_LIBRARY,
|
|
1288
|
+
genreLibrary: [...BUILTIN_GENRE_LIBRARY, ...globalGenreLibrary()],
|
|
1153
1289
|
antiAiLibrary: BUILTIN_ANTI_AI_RULES,
|
|
1154
1290
|
styleTemplates: BUILTIN_STYLE_TEMPLATES,
|
|
1155
|
-
progressionLibrary: BUILTIN_PROGRESSION_MODES,
|
|
1291
|
+
progressionLibrary: [...BUILTIN_PROGRESSION_MODES, ...globalProgressionLibrary()],
|
|
1292
|
+
starterStyleProfiles: BUILTIN_STARTER_STYLE_PROFILES,
|
|
1156
1293
|
plotBeatLibrary: BUILTIN_PLOT_BEATS,
|
|
1157
1294
|
}
|
|
1158
1295
|
writeJson(res, 200, response)
|
|
@@ -1197,161 +1334,885 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1197
1334
|
},
|
|
1198
1335
|
}
|
|
1199
1336
|
|
|
1200
|
-
//
|
|
1201
|
-
const
|
|
1337
|
+
// ----------------------------------------------------------- style-formula
|
|
1338
|
+
const styleFormulaRoute: WebRoute = {
|
|
1202
1339
|
kind: 'exact',
|
|
1203
|
-
path: NOVEL_API.
|
|
1340
|
+
path: NOVEL_API.styleFormula,
|
|
1204
1341
|
handler: async (req, res) => {
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
seedBookshelfFromOutputDir(getConfig().outputDir)
|
|
1213
|
-
const snapshot: BookshelfSnapshot = bookshelfSnapshot(loadBookshelf())
|
|
1214
|
-
writeJson(res, 200, snapshot)
|
|
1342
|
+
if (!guard(req, res, 'POST')) return
|
|
1343
|
+
const config = getConfig()
|
|
1344
|
+
const project = loadProject(config.outputDir)
|
|
1345
|
+
const body = await readJsonBody<StyleEngineRequest & { depth?: string }>(req)
|
|
1346
|
+
const sample = body?.sampleText?.trim()
|
|
1347
|
+
if (sample === undefined || sample.length < 50) {
|
|
1348
|
+
writeJson(res, 400, { error: '样本文本过短(<50 字符),请粘贴一段能代表目标风格的文字' })
|
|
1215
1349
|
return
|
|
1216
1350
|
}
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
const
|
|
1223
|
-
const
|
|
1224
|
-
if (
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
const book = createBook(bookName, outputDir)
|
|
1232
|
-
// 开书向导:创建时带大纲 → 立即建立项目(书名以大纲首行为准)。
|
|
1233
|
-
const outline = body?.outline?.trim()
|
|
1234
|
-
if (outline !== undefined && outline.length >= 50) {
|
|
1235
|
-
const project = createProject(outline)
|
|
1236
|
-
saveProject(outputDir, project)
|
|
1237
|
-
renameBook(book.id, project.bookName)
|
|
1351
|
+
const depth = (['basic', 'standard', 'deep'] as const).includes(body?.depth as never)
|
|
1352
|
+
? body!.depth! as 'basic' | 'standard' | 'deep'
|
|
1353
|
+
: 'standard'
|
|
1354
|
+
try {
|
|
1355
|
+
const formula = await extractStyleFormula(ctx, config, sample, depth)
|
|
1356
|
+
const key = `sf-${Date.now().toString(36)}`
|
|
1357
|
+
const styleFormula = { key, ...formula, depth, createdAt: new Date().toISOString() }
|
|
1358
|
+
if (project !== undefined) {
|
|
1359
|
+
project.assets ??= emptyProjectAssets()
|
|
1360
|
+
project.assets.styleFormulas ??= []
|
|
1361
|
+
project.assets.styleFormulas.push(styleFormula)
|
|
1362
|
+
project.assets.updatedAt = new Date().toISOString()
|
|
1363
|
+
project.updatedAt = new Date().toISOString()
|
|
1364
|
+
saveProject(config.outputDir, project)
|
|
1238
1365
|
}
|
|
1239
|
-
writeJson(res, 200,
|
|
1366
|
+
writeJson(res, 200, { styleFormula })
|
|
1367
|
+
} catch (error) {
|
|
1368
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1369
|
+
}
|
|
1370
|
+
},
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// ----------------------------------------------------------- style-detect
|
|
1374
|
+
/** 正文实时 AI 味检测(本地确定性扫描,免 LLM)。 */
|
|
1375
|
+
const styleDetectRoute: WebRoute = {
|
|
1376
|
+
kind: 'exact',
|
|
1377
|
+
path: NOVEL_API.styleDetect,
|
|
1378
|
+
handler: async (req, res) => {
|
|
1379
|
+
if (!guard(req, res, 'POST')) return
|
|
1380
|
+
const body = await readJsonBody<{ text?: string }>(req)
|
|
1381
|
+
const text = body?.text?.trim() ?? ''
|
|
1382
|
+
if (text.length < 20) {
|
|
1383
|
+
writeJson(res, 400, { error: '文本过短(<20 字符)' })
|
|
1240
1384
|
return
|
|
1241
1385
|
}
|
|
1242
|
-
|
|
1386
|
+
const scan = scanAiFlavor(text.slice(0, 20000))
|
|
1387
|
+
writeJson(res, 200, { scan })
|
|
1243
1388
|
},
|
|
1244
1389
|
}
|
|
1245
1390
|
|
|
1246
|
-
//
|
|
1247
|
-
/**
|
|
1248
|
-
const
|
|
1391
|
+
// --------------------------------------------------------- market-radar
|
|
1392
|
+
/** 热门题材雷达:输入平台/题材/榜单文本 → 信号 + 生产底座 + 开书创意。 */
|
|
1393
|
+
const marketRadarRoute: WebRoute = {
|
|
1249
1394
|
kind: 'exact',
|
|
1250
|
-
path: NOVEL_API.
|
|
1395
|
+
path: NOVEL_API.marketRadar,
|
|
1251
1396
|
handler: async (req, res) => {
|
|
1252
1397
|
if (!guard(req, res, 'POST')) return
|
|
1253
1398
|
const config = getConfig()
|
|
1254
|
-
const
|
|
1399
|
+
const body = await readJsonBody<MarketRadarRequest>(req)
|
|
1400
|
+
try {
|
|
1401
|
+
const result = await runMarketRadar(ctx, config, body ?? {})
|
|
1402
|
+
writeJson(res, 200, { result })
|
|
1403
|
+
} catch (error) {
|
|
1404
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1405
|
+
}
|
|
1406
|
+
},
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// ------------------------------------------------- market-radar scan
|
|
1410
|
+
/** 真实榜单扫榜:抓取公开榜单(容错),返回分组候选。 */
|
|
1411
|
+
const marketRadarScanRoute: WebRoute = {
|
|
1412
|
+
kind: 'exact',
|
|
1413
|
+
path: NOVEL_API.marketRadarScan,
|
|
1414
|
+
handler: async (req, res) => {
|
|
1415
|
+
if (!guard(req, res, 'POST')) return
|
|
1416
|
+
const body = await readJsonBody<{ platforms?: string[] }>(req)
|
|
1417
|
+
try {
|
|
1418
|
+
const result = await scanMarketRanking(body?.platforms)
|
|
1419
|
+
writeJson(res, 200, { result })
|
|
1420
|
+
} catch (error) {
|
|
1421
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1422
|
+
}
|
|
1423
|
+
},
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// ------------------------------------------------- market-radar apply
|
|
1427
|
+
/** 把雷达生产底座一键应用到某本书(写入项目资产/开书定盘,供后续规划与生成遵守)。 */
|
|
1428
|
+
const marketRadarApplyRoute: WebRoute = {
|
|
1429
|
+
kind: 'exact',
|
|
1430
|
+
path: NOVEL_API.marketRadarApply,
|
|
1431
|
+
handler: async (req, res) => {
|
|
1432
|
+
if (!guard(req, res, 'POST')) return
|
|
1433
|
+
const body = await readJsonBody<{ bookId?: string; foundation?: ProductionFoundation }>(req)
|
|
1434
|
+
const pf = body?.foundation
|
|
1435
|
+
if (pf === undefined) {
|
|
1436
|
+
writeJson(res, 400, { error: '缺少 foundation' })
|
|
1437
|
+
return
|
|
1438
|
+
}
|
|
1439
|
+
const config = getConfig()
|
|
1440
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1441
|
+
const project = loadProject(outputDir)
|
|
1255
1442
|
if (project === undefined) {
|
|
1256
|
-
writeJson(res, 400, { error: '
|
|
1443
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
1257
1444
|
return
|
|
1258
1445
|
}
|
|
1259
|
-
const
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1446
|
+
const genreBuiltin = BUILTIN_GENRE_LIBRARY.find(g => g.name === (pf.genre.existingId || pf.genre.name))
|
|
1447
|
+
const genreNode: GenreNode = genreBuiltin !== undefined
|
|
1448
|
+
? { ...genreBuiltin }
|
|
1449
|
+
: { name: pf.genre.name.slice(0, 40), description: pf.genre.description, template: pf.genre.template, children: [] }
|
|
1450
|
+
const findMode = (m: { existingId?: string; name: string }): ProgressionMode | undefined => BUILTIN_PROGRESSION_MODES.find(x => x.name === (m.existingId || m.name))
|
|
1451
|
+
const primary = findMode(pf.primaryStoryMode) ?? {
|
|
1452
|
+
name: pf.primaryStoryMode.name.slice(0, 40),
|
|
1453
|
+
driver: pf.primaryStoryMode.driver,
|
|
1454
|
+
readerExpectation: pf.primaryStoryMode.readerExpectation,
|
|
1455
|
+
payoffs: [],
|
|
1456
|
+
risks: [],
|
|
1457
|
+
primary: true,
|
|
1458
|
+
}
|
|
1459
|
+
const aux: ProgressionMode[] = []
|
|
1460
|
+
if (pf.secondaryStoryMode !== undefined) {
|
|
1461
|
+
const sec = findMode(pf.secondaryStoryMode) ?? {
|
|
1462
|
+
name: pf.secondaryStoryMode.name.slice(0, 40),
|
|
1463
|
+
driver: pf.secondaryStoryMode.driver,
|
|
1464
|
+
readerExpectation: pf.secondaryStoryMode.readerExpectation,
|
|
1465
|
+
payoffs: [],
|
|
1466
|
+
risks: [],
|
|
1467
|
+
primary: false,
|
|
1468
|
+
}
|
|
1469
|
+
aux.push(sec)
|
|
1470
|
+
}
|
|
1471
|
+
project.assets ??= emptyProjectAssets()
|
|
1472
|
+
project.assets.genre = genreNode
|
|
1473
|
+
project.assets.primaryProgression = primary
|
|
1474
|
+
project.assets.auxiliaryProgressions = aux
|
|
1475
|
+
project.bookContract = {
|
|
1476
|
+
promise: pf.genre.description.slice(0, 120),
|
|
1477
|
+
primaryModeName: pf.primaryStoryMode.name,
|
|
1478
|
+
secondaryModeNames: pf.secondaryStoryMode !== undefined ? [pf.secondaryStoryMode.name] : [],
|
|
1479
|
+
tone: pf.genre.template,
|
|
1480
|
+
targetPlatform: undefined,
|
|
1264
1481
|
}
|
|
1265
|
-
project.bible = undefined
|
|
1266
|
-
project.volumes = undefined
|
|
1267
|
-
project.chapters = []
|
|
1268
|
-
project.foreshadows = []
|
|
1269
|
-
project.assets = emptyProjectAssets()
|
|
1270
|
-
project.facts = []
|
|
1271
1482
|
project.updatedAt = new Date().toISOString()
|
|
1272
|
-
saveProject(
|
|
1483
|
+
saveProject(outputDir, project)
|
|
1273
1484
|
writeJson(res, 200, { ok: true, bookName: project.bookName })
|
|
1274
1485
|
},
|
|
1275
1486
|
}
|
|
1276
1487
|
|
|
1277
|
-
//
|
|
1278
|
-
|
|
1488
|
+
// ------------------------------------------------- market-radar sync
|
|
1489
|
+
/** 把雷达生产底座里的「新资产」同步进全局资源库(跨书复用;已有名字跳过)。 */
|
|
1490
|
+
const marketRadarSyncRoute: WebRoute = {
|
|
1279
1491
|
kind: 'exact',
|
|
1280
|
-
path:
|
|
1492
|
+
path: NOVEL_API.marketRadarSync,
|
|
1281
1493
|
handler: async (req, res) => {
|
|
1282
1494
|
if (!guard(req, res, 'POST')) return
|
|
1283
|
-
const body = await readJsonBody<
|
|
1284
|
-
|
|
1285
|
-
|
|
1495
|
+
const body = await readJsonBody<{ foundation?: ProductionFoundation }>(req)
|
|
1496
|
+
const pf = body?.foundation
|
|
1497
|
+
if (pf === undefined) {
|
|
1498
|
+
writeJson(res, 400, { error: '缺少 foundation' })
|
|
1286
1499
|
return
|
|
1287
1500
|
}
|
|
1288
|
-
const
|
|
1289
|
-
if (
|
|
1290
|
-
|
|
1291
|
-
return
|
|
1501
|
+
const synced = { genre: false, primaryMode: false, secondaryMode: false }
|
|
1502
|
+
if (pf.genre.existingId === undefined && pf.genre.name !== '') {
|
|
1503
|
+
synced.genre = addGlobalGenre({ name: pf.genre.name.slice(0, 40), description: pf.genre.description, template: pf.genre.template, children: [] })
|
|
1292
1504
|
}
|
|
1293
|
-
|
|
1505
|
+
if (pf.primaryStoryMode.existingId === undefined && pf.primaryStoryMode.name !== '') {
|
|
1506
|
+
synced.primaryMode = addGlobalMode({ name: pf.primaryStoryMode.name.slice(0, 40), driver: pf.primaryStoryMode.driver, readerExpectation: pf.primaryStoryMode.readerExpectation, payoffs: [], risks: [], primary: true })
|
|
1507
|
+
}
|
|
1508
|
+
if (pf.secondaryStoryMode !== undefined && pf.secondaryStoryMode !== null && pf.secondaryStoryMode.existingId === undefined && pf.secondaryStoryMode.name !== '') {
|
|
1509
|
+
synced.secondaryMode = addGlobalMode({ name: pf.secondaryStoryMode.name.slice(0, 40), driver: pf.secondaryStoryMode.driver, readerExpectation: pf.secondaryStoryMode.readerExpectation, payoffs: [], risks: [], primary: false })
|
|
1510
|
+
}
|
|
1511
|
+
writeJson(res, 200, { ok: true, synced })
|
|
1294
1512
|
},
|
|
1295
1513
|
}
|
|
1296
1514
|
|
|
1297
|
-
//
|
|
1298
|
-
|
|
1515
|
+
// ------------------------------------------------- market-radar brief
|
|
1516
|
+
/** 开书创意简报:用选中的市场信号 + 影响模式生成。 */
|
|
1517
|
+
const marketRadarBriefRoute: WebRoute = {
|
|
1299
1518
|
kind: 'exact',
|
|
1300
|
-
path:
|
|
1519
|
+
path: NOVEL_API.marketRadarBrief,
|
|
1301
1520
|
handler: async (req, res) => {
|
|
1302
1521
|
if (!guard(req, res, 'POST')) return
|
|
1303
|
-
const
|
|
1304
|
-
|
|
1305
|
-
|
|
1522
|
+
const config = getConfig()
|
|
1523
|
+
const body = await readJsonBody<MarketRadarBriefRequest>(req)
|
|
1524
|
+
if (body === undefined || body.influenceMode === undefined) {
|
|
1525
|
+
writeJson(res, 400, { error: '缺少 influenceMode' })
|
|
1306
1526
|
return
|
|
1307
1527
|
}
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
writeJson(res,
|
|
1528
|
+
try {
|
|
1529
|
+
const creativeBrief = await runMarketCreativeBrief(ctx, config, body)
|
|
1530
|
+
writeJson(res, 200, { creativeBrief })
|
|
1531
|
+
} catch (error) {
|
|
1532
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1533
|
+
}
|
|
1534
|
+
},
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// ------------------------------------------------------------- knowledge
|
|
1538
|
+
/** 书内知识库:GET 读取 / POST 增删改(按书路由)。 */
|
|
1539
|
+
const knowledgeRoute: WebRoute = {
|
|
1540
|
+
kind: 'exact',
|
|
1541
|
+
path: NOVEL_API.knowledge,
|
|
1542
|
+
handler: async (req, res) => {
|
|
1543
|
+
if (req.method === 'POST') {
|
|
1544
|
+
if (!guard(req, res, 'POST')) return
|
|
1545
|
+
} else {
|
|
1546
|
+
if (!guard(req, res, 'GET')) return
|
|
1547
|
+
}
|
|
1548
|
+
const config = getConfig()
|
|
1549
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
1550
|
+
const body = req.method === 'POST' ? await readJsonBody<KnowledgeRequest & { bookId?: string }>(req) : undefined
|
|
1551
|
+
const outputDir = resolveOutputDir(config, body?.bookId ?? qurl.searchParams.get('bookId') ?? undefined)
|
|
1552
|
+
const project = loadProject(outputDir)
|
|
1553
|
+
if (project === undefined) {
|
|
1554
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
1311
1555
|
return
|
|
1312
1556
|
}
|
|
1313
|
-
|
|
1557
|
+
project.knowledgeDocs ??= []
|
|
1558
|
+
if (req.method === 'POST' && body !== undefined) {
|
|
1559
|
+
if (body.action === 'add' && body.doc !== undefined) {
|
|
1560
|
+
project.knowledgeDocs.push({ id: body.doc.id ?? `kd-${Date.now().toString(36)}`, title: body.doc.title, content: body.doc.content, updatedAt: new Date().toISOString() })
|
|
1561
|
+
} else if (body.action === 'remove' && body.id !== undefined) {
|
|
1562
|
+
project.knowledgeDocs = project.knowledgeDocs.filter(d => d.id !== body.id)
|
|
1563
|
+
} else if (body.action === 'replace' && body.doc !== undefined) {
|
|
1564
|
+
project.knowledgeDocs = project.knowledgeDocs.map(d => d.id === body.doc!.id ? { ...d, title: body.doc!.title, content: body.doc!.content, updatedAt: new Date().toISOString() } : d)
|
|
1565
|
+
}
|
|
1566
|
+
project.updatedAt = new Date().toISOString()
|
|
1567
|
+
saveProject(outputDir, project)
|
|
1568
|
+
}
|
|
1569
|
+
writeJson(res, 200, { docs: project.knowledgeDocs })
|
|
1314
1570
|
},
|
|
1315
1571
|
}
|
|
1316
1572
|
|
|
1317
|
-
//
|
|
1318
|
-
/**
|
|
1319
|
-
const
|
|
1573
|
+
// ---------------------------------------------------------- book-analysis
|
|
1574
|
+
/** 书分析/拆书:输入文本 → 卖点/结构/可借鉴点/风险。 */
|
|
1575
|
+
const bookAnalysisRoute: WebRoute = {
|
|
1320
1576
|
kind: 'exact',
|
|
1321
|
-
path: NOVEL_API.
|
|
1577
|
+
path: NOVEL_API.bookAnalysis,
|
|
1322
1578
|
handler: async (req, res) => {
|
|
1323
1579
|
if (!guard(req, res, 'POST')) return
|
|
1324
|
-
const
|
|
1325
|
-
const
|
|
1326
|
-
if (
|
|
1327
|
-
writeJson(res, 400, { error: '
|
|
1580
|
+
const config = getConfig()
|
|
1581
|
+
const body = await readJsonBody<BookAnalysisRequest>(req)
|
|
1582
|
+
if (body === undefined || (body.text ?? '').trim().length < 50) {
|
|
1583
|
+
writeJson(res, 400, { error: '文本过短(<50 字符)' })
|
|
1328
1584
|
return
|
|
1329
1585
|
}
|
|
1330
1586
|
try {
|
|
1331
|
-
const
|
|
1332
|
-
writeJson(res, 200, {
|
|
1333
|
-
} catch (
|
|
1334
|
-
writeJson(res,
|
|
1587
|
+
const result = await runBookAnalysis(ctx, config, body)
|
|
1588
|
+
writeJson(res, 200, { result })
|
|
1589
|
+
} catch (error) {
|
|
1590
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1335
1591
|
}
|
|
1336
1592
|
},
|
|
1337
1593
|
}
|
|
1338
1594
|
|
|
1339
|
-
//
|
|
1340
|
-
/**
|
|
1341
|
-
const
|
|
1595
|
+
// ---------------------------------------------------------- idea-inspiration
|
|
1596
|
+
/** 创意灵感:一句话/题材 → 多方向开书灵感。 */
|
|
1597
|
+
const ideaInspirationRoute: WebRoute = {
|
|
1342
1598
|
kind: 'exact',
|
|
1343
|
-
path: NOVEL_API.
|
|
1599
|
+
path: NOVEL_API.ideaInspiration,
|
|
1344
1600
|
handler: async (req, res) => {
|
|
1345
1601
|
if (!guard(req, res, 'POST')) return
|
|
1346
|
-
const
|
|
1602
|
+
const config = getConfig()
|
|
1603
|
+
const body = await readJsonBody<IdeaInspirationRequest>(req)
|
|
1604
|
+
if (body === undefined || (body.idea ?? '').trim().length < 2) {
|
|
1605
|
+
writeJson(res, 400, { error: '请填写你的方向/一句话想法' })
|
|
1606
|
+
return
|
|
1607
|
+
}
|
|
1347
1608
|
try {
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1609
|
+
const result = await runIdeaInspiration(ctx, config, body)
|
|
1610
|
+
writeJson(res, 200, { result })
|
|
1611
|
+
} catch (error) {
|
|
1612
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1613
|
+
}
|
|
1614
|
+
},
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// ------------------------------------------------------- idea-inspiration/market
|
|
1618
|
+
/** 雷达→灵感:基于市场信号/生产底座/创意简报生成开书灵感。 */
|
|
1619
|
+
const marketIdeaInspirationRoute: WebRoute = {
|
|
1620
|
+
kind: 'exact',
|
|
1621
|
+
path: NOVEL_API.ideaInspirationMarket,
|
|
1622
|
+
handler: async (req, res) => {
|
|
1623
|
+
if (!guard(req, res, 'POST')) return
|
|
1624
|
+
const config = getConfig()
|
|
1625
|
+
const body = await readJsonBody<{ signals?: import('./protocol.ts').MarketRadarSignal[]; foundation?: import('./protocol.ts').ProductionFoundation; brief?: import('./protocol.ts').MarketCreativeBrief; count?: number }>(req)
|
|
1626
|
+
if (body === undefined) {
|
|
1627
|
+
writeJson(res, 400, { error: '缺少市场分析参数' })
|
|
1628
|
+
return
|
|
1629
|
+
}
|
|
1630
|
+
try {
|
|
1631
|
+
const result = await runMarketIdeaInspiration(ctx, config, body)
|
|
1632
|
+
writeJson(res, 200, { result })
|
|
1633
|
+
} catch (error) {
|
|
1634
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1635
|
+
}
|
|
1636
|
+
},
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// ------------------------------------------------------------- director
|
|
1640
|
+
/** 自动导演编排建议:基于全书上下文。 */
|
|
1641
|
+
const directorRoute: WebRoute = {
|
|
1642
|
+
kind: 'exact',
|
|
1643
|
+
path: NOVEL_API.director,
|
|
1644
|
+
handler: async (req, res) => {
|
|
1645
|
+
if (!guard(req, res, 'POST')) return
|
|
1646
|
+
const config = getConfig()
|
|
1647
|
+
const body = await readJsonBody<DirectorRequest & { bookId?: string }>(req)
|
|
1648
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1649
|
+
const project = loadProject(outputDir)
|
|
1650
|
+
if (project === undefined) {
|
|
1651
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
1652
|
+
return
|
|
1653
|
+
}
|
|
1654
|
+
try {
|
|
1655
|
+
const result = await runDirectorAdvice(ctx, config, project, body ?? {})
|
|
1656
|
+
writeJson(res, 200, { result })
|
|
1657
|
+
} catch (error) {
|
|
1658
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1659
|
+
}
|
|
1660
|
+
},
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// ------------------------------------------------------------- director/todos
|
|
1664
|
+
/** 自动导演「采纳」出的书内待办:GET 读取 / POST 增删勾选(按书路由)。 */
|
|
1665
|
+
const directorTodosRoute: WebRoute = {
|
|
1666
|
+
kind: 'exact',
|
|
1667
|
+
path: NOVEL_API.directorTodos,
|
|
1668
|
+
handler: async (req, res) => {
|
|
1669
|
+
type DirectorTodosReq = { op?: 'add' | 'toggle' | 'remove'; text?: string; source?: DirectorTodo['source']; id?: string; bookId?: string }
|
|
1670
|
+
let body: DirectorTodosReq | undefined
|
|
1671
|
+
if (req.method === 'POST') {
|
|
1672
|
+
if (!guard(req, res, 'POST')) return
|
|
1673
|
+
body = await readJsonBody<DirectorTodosReq>(req)
|
|
1674
|
+
} else {
|
|
1675
|
+
if (!guard(req, res, 'GET')) return
|
|
1676
|
+
}
|
|
1677
|
+
const config = getConfig()
|
|
1678
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1679
|
+
const project = loadProject(outputDir)
|
|
1680
|
+
if (project === undefined) {
|
|
1681
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
1682
|
+
return
|
|
1683
|
+
}
|
|
1684
|
+
project.todos ??= []
|
|
1685
|
+
if (req.method === 'POST' && body !== undefined) {
|
|
1686
|
+
if (body.op === 'add' && (body.text ?? '').trim() !== '') {
|
|
1687
|
+
project.todos.unshift({ id: `td-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, text: body.text!.trim(), source: body.source ?? 'fix', done: false, createdAt: new Date().toISOString() })
|
|
1688
|
+
} else if (body.op === 'toggle' && body.id !== undefined) {
|
|
1689
|
+
project.todos = project.todos.map(t => t.id === body.id ? { ...t, done: !t.done } : t)
|
|
1690
|
+
} else if (body.op === 'remove' && body.id !== undefined) {
|
|
1691
|
+
project.todos = project.todos.filter(t => t.id !== body.id)
|
|
1692
|
+
}
|
|
1693
|
+
project.updatedAt = new Date().toISOString()
|
|
1694
|
+
saveProject(outputDir, project)
|
|
1695
|
+
}
|
|
1696
|
+
writeJson(res, 200, { todos: project.todos })
|
|
1697
|
+
},
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// ------------------------------------------------------------- llm-live
|
|
1701
|
+
/** AI 创作实况:订阅 LLM 实时事件流(NDJSON;订阅时先重放最近缓冲)。 */
|
|
1702
|
+
const llmLiveRoute: WebRoute = {
|
|
1703
|
+
kind: 'exact',
|
|
1704
|
+
path: NOVEL_API.llmLive,
|
|
1705
|
+
handler: async (req, res) => {
|
|
1706
|
+
if (req.method !== 'GET') {
|
|
1707
|
+
writeJson(res, 405, { error: 'GET only' })
|
|
1708
|
+
return
|
|
1709
|
+
}
|
|
1710
|
+
if (!isLoopbackRequest(req)) {
|
|
1711
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
1712
|
+
return
|
|
1713
|
+
}
|
|
1714
|
+
res.writeHead(200, {
|
|
1715
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
1716
|
+
'cache-control': 'no-cache',
|
|
1717
|
+
'x-accel-buffering': 'no',
|
|
1718
|
+
'referrer-policy': 'no-referrer',
|
|
1719
|
+
})
|
|
1720
|
+
const unsubscribe = subscribeLiveFeed((frame) => {
|
|
1721
|
+
try { res.write('data: ' + JSON.stringify(frame) + '\n\n') } catch { /* client closed */ }
|
|
1722
|
+
})
|
|
1723
|
+
const hb = setInterval(() => { try { res.write(': ping\n\n') } catch { /* closed */ } }, 25000)
|
|
1724
|
+
// 关键:让 handler 一直挂起,直到客户端关闭,避免 WebRoute 框架在 handler 返回后关闭响应。
|
|
1725
|
+
await new Promise<void>((resolve) => {
|
|
1726
|
+
res.on('close', () => {
|
|
1727
|
+
clearInterval(hb)
|
|
1728
|
+
unsubscribe()
|
|
1729
|
+
resolve()
|
|
1730
|
+
})
|
|
1731
|
+
})
|
|
1732
|
+
},
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// --------------------------------------------------- llm-live prompt/usage
|
|
1736
|
+
/** 查看某次调用实际发送的 Prompt(面板「查看 Prompt」按钮用)。 */
|
|
1737
|
+
const llmPromptRoute: WebRoute = {
|
|
1738
|
+
kind: 'exact',
|
|
1739
|
+
path: NOVEL_API.llmPrompt,
|
|
1740
|
+
handler: (req, res) => {
|
|
1741
|
+
if (!guard(req, res, 'GET')) return
|
|
1742
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
1743
|
+
const sessionId = qurl.searchParams.get('sessionId') ?? ''
|
|
1744
|
+
const record = livePrompt(sessionId)
|
|
1745
|
+
if (record === undefined) {
|
|
1746
|
+
writeJson(res, 404, { error: '该调用的 Prompt 记录已过期(只保留最近 40 次调用)' })
|
|
1747
|
+
return
|
|
1748
|
+
}
|
|
1749
|
+
writeJson(res, 200, record)
|
|
1750
|
+
},
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
/** 清零本次运行的用量账本。 */
|
|
1754
|
+
const usageResetRoute: WebRoute = {
|
|
1755
|
+
kind: 'exact',
|
|
1756
|
+
path: NOVEL_API.usageReset,
|
|
1757
|
+
handler: (req, res) => {
|
|
1758
|
+
if (!guard(req, res, 'POST')) return
|
|
1759
|
+
resetLiveUsage()
|
|
1760
|
+
writeJson(res, 200, { ok: true, usage: liveUsage() })
|
|
1761
|
+
},
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
// ------------------------------------------------------------- timeline
|
|
1765
|
+
/**
|
|
1766
|
+
* 故事时间线:GET 列表(含规则初筛问题);POST op=extract|check|update|remove|clear。
|
|
1767
|
+
*/
|
|
1768
|
+
const timelineRoute: WebRoute = {
|
|
1769
|
+
kind: 'exact',
|
|
1770
|
+
path: NOVEL_API.timeline,
|
|
1771
|
+
handler: async (req, res) => {
|
|
1772
|
+
const config = getConfig()
|
|
1773
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
1774
|
+
if (req.method === 'GET') {
|
|
1775
|
+
if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
|
|
1776
|
+
const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
|
|
1777
|
+
const project = loadProject(outputDir)
|
|
1778
|
+
if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
|
|
1779
|
+
const response: TimelineResponse = {
|
|
1780
|
+
events: project.timeline ?? [],
|
|
1781
|
+
issues: detectTimelineIssues(project),
|
|
1782
|
+
}
|
|
1783
|
+
writeJson(res, 200, response)
|
|
1784
|
+
return
|
|
1785
|
+
}
|
|
1786
|
+
if (!guard(req, res, 'POST')) return
|
|
1787
|
+
const body = await readJsonBody<TimelineRequest & { bookId?: string }>(req)
|
|
1788
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1789
|
+
const project = loadProject(outputDir)
|
|
1790
|
+
if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
|
|
1791
|
+
try {
|
|
1792
|
+
if (body?.op === 'extract') {
|
|
1793
|
+
const no = body.chapterNo
|
|
1794
|
+
if (typeof no !== 'number' || !Number.isFinite(no)) { writeJson(res, 400, { error: 'extract 需要 chapterNo' }); return }
|
|
1795
|
+
const events = await extractTimelineForChapter(ctx, config, project, outputDir, no)
|
|
1796
|
+
saveProject(outputDir, project)
|
|
1797
|
+
writeJson(res, 200, { events, all: project.timeline ?? [] })
|
|
1798
|
+
return
|
|
1799
|
+
}
|
|
1800
|
+
if (body?.op === 'check') {
|
|
1801
|
+
const ruleIssues = detectTimelineIssues(project)
|
|
1802
|
+
let aiIssues: typeof ruleIssues = []
|
|
1803
|
+
try {
|
|
1804
|
+
aiIssues = await reviewTimeline(ctx, config, project)
|
|
1805
|
+
} catch (error) {
|
|
1806
|
+
// AI 复核失败不阻断:规则初筛结果照常返回,并说明原因。
|
|
1807
|
+
writeJson(res, 200, { issues: ruleIssues, aiError: (error as Error).message })
|
|
1808
|
+
return
|
|
1809
|
+
}
|
|
1810
|
+
writeJson(res, 200, { issues: [...ruleIssues, ...aiIssues] })
|
|
1811
|
+
return
|
|
1812
|
+
}
|
|
1813
|
+
if (body?.op === 'update') {
|
|
1814
|
+
const event = body.event
|
|
1815
|
+
if (event === undefined || typeof event.id !== 'string') { writeJson(res, 400, { error: 'update 需要 event' }); return }
|
|
1816
|
+
const list = project.timeline ?? []
|
|
1817
|
+
const idx = list.findIndex(e => e.id === event.id)
|
|
1818
|
+
if (idx === -1) { writeJson(res, 404, { error: '时间线事件不存在' }); return }
|
|
1819
|
+
list[idx] = { ...event, source: 'manual' }
|
|
1820
|
+
project.timeline = list
|
|
1821
|
+
project.updatedAt = new Date().toISOString()
|
|
1822
|
+
saveProject(outputDir, project)
|
|
1823
|
+
writeJson(res, 200, { events: project.timeline })
|
|
1824
|
+
return
|
|
1825
|
+
}
|
|
1826
|
+
if (body?.op === 'remove') {
|
|
1827
|
+
project.timeline = (project.timeline ?? []).filter(e => e.id !== body.id)
|
|
1828
|
+
project.updatedAt = new Date().toISOString()
|
|
1829
|
+
saveProject(outputDir, project)
|
|
1830
|
+
writeJson(res, 200, { events: project.timeline })
|
|
1831
|
+
return
|
|
1832
|
+
}
|
|
1833
|
+
if (body?.op === 'clear') {
|
|
1834
|
+
project.timeline = []
|
|
1835
|
+
project.updatedAt = new Date().toISOString()
|
|
1836
|
+
saveProject(outputDir, project)
|
|
1837
|
+
writeJson(res, 200, { events: [] })
|
|
1838
|
+
return
|
|
1839
|
+
}
|
|
1840
|
+
writeJson(res, 400, { error: `未知 op:${String(body?.op)}` })
|
|
1841
|
+
} catch (error) {
|
|
1842
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1843
|
+
}
|
|
1844
|
+
},
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
// ------------------------------------------------------------ revision plan
|
|
1848
|
+
/** 修订指令合并:POST { op: 'plan', chapterNo, reviewIssues? } → 一份合并指令 + 验证基准。
|
|
1849
|
+
* 面板与生产单共用,保证「同一章只改一轮」;张力/时间线问题默认只是建议(advisory)。 */
|
|
1850
|
+
const revisionPlanRoute: WebRoute = {
|
|
1851
|
+
kind: 'exact',
|
|
1852
|
+
path: NOVEL_API.revisionPlan,
|
|
1853
|
+
handler: async (req, res) => {
|
|
1854
|
+
if (!guard(req, res, 'POST')) return
|
|
1855
|
+
const config = getConfig()
|
|
1856
|
+
const body = await readJsonBody<RevisionPlanRequest>(req)
|
|
1857
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1858
|
+
const project = loadProject(outputDir)
|
|
1859
|
+
if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
|
|
1860
|
+
const chapterNo = body?.chapterNo
|
|
1861
|
+
if (chapterNo !== undefined && !project.chapters.some(c => c.no === chapterNo)) {
|
|
1862
|
+
writeJson(res, 404, { error: `章节 ${String(chapterNo)} 不在计划中` })
|
|
1863
|
+
return
|
|
1864
|
+
}
|
|
1865
|
+
const chapter = chapterNo === undefined ? undefined : project.chapters.find(c => c.no === chapterNo)
|
|
1866
|
+
try {
|
|
1867
|
+
const plan = buildRevisionPlan(project, {
|
|
1868
|
+
chapterNo,
|
|
1869
|
+
reviewIssues: body?.reviewIssues,
|
|
1870
|
+
includeTimeline: body?.includeTimeline,
|
|
1871
|
+
includeTension: body?.includeTension,
|
|
1872
|
+
baseReport: chapter?.review,
|
|
1873
|
+
cap: REVISION_ITEM_CAP,
|
|
1874
|
+
})
|
|
1875
|
+
const response: RevisionPlanResponse = {
|
|
1876
|
+
chapterNo: chapterNo ?? 0,
|
|
1877
|
+
items: plan.items,
|
|
1878
|
+
instruction: plan.instruction,
|
|
1879
|
+
counts: plan.counts,
|
|
1880
|
+
omitted: plan.omitted,
|
|
1881
|
+
baseline: plan.baseline,
|
|
1882
|
+
summary: describeRevisionPlan(plan),
|
|
1883
|
+
}
|
|
1884
|
+
writeJson(res, 200, response)
|
|
1885
|
+
} catch (error) {
|
|
1886
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1887
|
+
}
|
|
1888
|
+
},
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// ------------------------------------------------------------ snapshots
|
|
1892
|
+
/** 章节历史版本:GET 列表;POST op=create|restore|remove|prune。 */
|
|
1893
|
+
const snapshotRoute: WebRoute = {
|
|
1894
|
+
kind: 'exact',
|
|
1895
|
+
path: NOVEL_API.snapshot,
|
|
1896
|
+
handler: async (req, res) => {
|
|
1897
|
+
const config = getConfig()
|
|
1898
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
1899
|
+
if (req.method === 'GET') {
|
|
1900
|
+
if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
|
|
1901
|
+
const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
|
|
1902
|
+
const response: SnapshotResponse = { snapshots: loadSnapshots(outputDir), bytes: snapshotsBytes(outputDir) }
|
|
1903
|
+
writeJson(res, 200, response)
|
|
1904
|
+
return
|
|
1905
|
+
}
|
|
1906
|
+
if (!guard(req, res, 'POST')) return
|
|
1907
|
+
const body = await readJsonBody<SnapshotRequest & { bookId?: string }>(req)
|
|
1908
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1909
|
+
const project = loadProject(outputDir)
|
|
1910
|
+
try {
|
|
1911
|
+
if (body?.op === 'create') {
|
|
1912
|
+
if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
|
|
1913
|
+
const chapter = project.chapters.find(c => c.no === body.chapterNo)
|
|
1914
|
+
if (chapter === undefined) { writeJson(res, 404, { error: `章节 ${String(body.chapterNo)} 不在计划中` }); return }
|
|
1915
|
+
const snapshot = createSnapshot(outputDir, chapter, '手工存档')
|
|
1916
|
+
if (snapshot === undefined) { writeJson(res, 400, { error: '该章暂无正文文件,无法存档' }); return }
|
|
1917
|
+
writeJson(res, 200, { snapshot, snapshots: loadSnapshots(outputDir) })
|
|
1918
|
+
return
|
|
1919
|
+
}
|
|
1920
|
+
if (body?.op === 'restore') {
|
|
1921
|
+
if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
|
|
1922
|
+
const id = body.id ?? ''
|
|
1923
|
+
const snap = loadSnapshots(outputDir).find(x => x.id === id)
|
|
1924
|
+
if (snap === undefined) { writeJson(res, 404, { error: '快照不存在' }); return }
|
|
1925
|
+
const chapter = project.chapters.find(c => c.no === snap.chapterNo)
|
|
1926
|
+
if (chapter === undefined) { writeJson(res, 404, { error: `章节 ${snap.chapterNo} 不在计划中` }); return }
|
|
1927
|
+
const chars = restoreSnapshot(outputDir, chapter, id)
|
|
1928
|
+
chapter.chars = countHanzi(readChapterFile(outputDir, chapter) ?? '')
|
|
1929
|
+
chapter.status = chapter.status === 'approved' ? 'written' : chapter.status
|
|
1930
|
+
chapter.review = undefined
|
|
1931
|
+
project.updatedAt = new Date().toISOString()
|
|
1932
|
+
saveProject(outputDir, project)
|
|
1933
|
+
writeJson(res, 200, { ok: true, chapterNo: chapter.no, chars, snapshots: loadSnapshots(outputDir) })
|
|
1934
|
+
return
|
|
1935
|
+
}
|
|
1936
|
+
if (body?.op === 'remove') {
|
|
1937
|
+
const ok = removeSnapshot(outputDir, body.id ?? '')
|
|
1938
|
+
writeJson(res, ok ? 200 : 404, { ok, snapshots: loadSnapshots(outputDir) })
|
|
1939
|
+
return
|
|
1940
|
+
}
|
|
1941
|
+
if (body?.op === 'prune') {
|
|
1942
|
+
const removed = pruneSnapshots(outputDir, body.keep)
|
|
1943
|
+
writeJson(res, 200, { removed, snapshots: loadSnapshots(outputDir), bytes: snapshotsBytes(outputDir) })
|
|
1944
|
+
return
|
|
1945
|
+
}
|
|
1946
|
+
writeJson(res, 400, { error: `未知 op:${String(body?.op)}` })
|
|
1947
|
+
} catch (error) {
|
|
1948
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1949
|
+
}
|
|
1950
|
+
},
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// --------------------------------------------------------------- tension
|
|
1954
|
+
/** 张力曲线:GET 数据(含规则核对);POST op=preset|set|clear。 */
|
|
1955
|
+
const tensionRoute: WebRoute = {
|
|
1956
|
+
kind: 'exact',
|
|
1957
|
+
path: NOVEL_API.tension,
|
|
1958
|
+
handler: async (req, res) => {
|
|
1959
|
+
const config = getConfig()
|
|
1960
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
1961
|
+
if (req.method === 'GET') {
|
|
1962
|
+
if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
|
|
1963
|
+
const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
|
|
1964
|
+
const project = loadProject(outputDir)
|
|
1965
|
+
if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
|
|
1966
|
+
const curve = buildCurveData(project)
|
|
1967
|
+
const response: TensionResponse = { preset: curve.preset, points: curve.points, issues: detectTensionIssues(project) }
|
|
1968
|
+
writeJson(res, 200, response)
|
|
1969
|
+
return
|
|
1970
|
+
}
|
|
1971
|
+
if (!guard(req, res, 'POST')) return
|
|
1972
|
+
const body = await readJsonBody<TensionRequest & { bookId?: string }>(req)
|
|
1973
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1974
|
+
const project = loadProject(outputDir)
|
|
1975
|
+
if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
|
|
1976
|
+
if (body?.op === 'preset') {
|
|
1977
|
+
const preset = body.preset
|
|
1978
|
+
if (preset === undefined || !TENSION_PRESETS.includes(preset)) { writeJson(res, 400, { error: '未知的张力曲线预设' }); return }
|
|
1979
|
+
project.tensionCurve = { ...(project.tensionCurve ?? {}), preset }
|
|
1980
|
+
project.updatedAt = new Date().toISOString()
|
|
1981
|
+
saveProject(outputDir, project)
|
|
1982
|
+
writeJson(res, 200, { ok: true, preset })
|
|
1983
|
+
return
|
|
1984
|
+
}
|
|
1985
|
+
if (body?.op === 'set') {
|
|
1986
|
+
const no = body.chapterNo
|
|
1987
|
+
if (typeof no !== 'number' || !Number.isFinite(no)) { writeJson(res, 400, { error: 'set 需要 chapterNo' }); return }
|
|
1988
|
+
const chapter = project.chapters.find(c => c.no === no)
|
|
1989
|
+
if (chapter === undefined) { writeJson(res, 404, { error: `章节 ${no} 不在计划中` }); return }
|
|
1990
|
+
if (body.tension === undefined) {
|
|
1991
|
+
chapter.tension = undefined
|
|
1992
|
+
} else {
|
|
1993
|
+
if (body.tension < TENSION_MIN || body.tension > TENSION_MAX) { writeJson(res, 400, { error: `张力需在 ${TENSION_MIN}-${TENSION_MAX} 之间` }); return }
|
|
1994
|
+
chapter.tension = clampTension(body.tension)
|
|
1995
|
+
}
|
|
1996
|
+
project.updatedAt = new Date().toISOString()
|
|
1997
|
+
saveProject(outputDir, project)
|
|
1998
|
+
writeJson(res, 200, { ok: true, chapterNo: no, tension: chapter.tension })
|
|
1999
|
+
return
|
|
2000
|
+
}
|
|
2001
|
+
if (body?.op === 'clear') {
|
|
2002
|
+
for (const c of project.chapters) c.tension = undefined
|
|
2003
|
+
project.updatedAt = new Date().toISOString()
|
|
2004
|
+
saveProject(outputDir, project)
|
|
2005
|
+
writeJson(res, 200, { ok: true })
|
|
2006
|
+
return
|
|
2007
|
+
}
|
|
2008
|
+
writeJson(res, 400, { error: `未知 op:${String(body?.op)}` })
|
|
2009
|
+
},
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// ---------------------------------------------------------- prompt slots
|
|
2013
|
+
/** 提示词槽位:GET 列表(含说明与上限);POST 写入或清空单个槽位。 */
|
|
2014
|
+
const promptSlotsRoute: WebRoute = {
|
|
2015
|
+
kind: 'exact',
|
|
2016
|
+
path: NOVEL_API.promptSlots,
|
|
2017
|
+
handler: async (req, res) => {
|
|
2018
|
+
const config = getConfig()
|
|
2019
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
2020
|
+
if (req.method === 'GET') {
|
|
2021
|
+
if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
|
|
2022
|
+
const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
|
|
2023
|
+
const project = loadProject(outputDir)
|
|
2024
|
+
if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
|
|
2025
|
+
const values = new Map(readPromptSlots(project).map(s => [s.id, s.text] as const))
|
|
2026
|
+
const response: PromptSlotsResponse = {
|
|
2027
|
+
slots: PROMPT_SLOTS.map(spec => ({
|
|
2028
|
+
id: spec.id,
|
|
2029
|
+
label: spec.label,
|
|
2030
|
+
hint: spec.hint,
|
|
2031
|
+
placeholder: spec.placeholder,
|
|
2032
|
+
maxChars: spec.maxChars,
|
|
2033
|
+
value: values.get(spec.id) ?? '',
|
|
2034
|
+
})),
|
|
2035
|
+
}
|
|
2036
|
+
writeJson(res, 200, response)
|
|
2037
|
+
return
|
|
2038
|
+
}
|
|
2039
|
+
if (!guard(req, res, 'POST')) return
|
|
2040
|
+
const body = await readJsonBody<PromptSlotsRequest & { bookId?: string }>(req)
|
|
2041
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
2042
|
+
const project = loadProject(outputDir)
|
|
2043
|
+
if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
|
|
2044
|
+
let value: string
|
|
2045
|
+
try {
|
|
2046
|
+
value = normalizeSlotValue(String(body?.id ?? ''), String(body?.value ?? ''))
|
|
2047
|
+
} catch (error) {
|
|
2048
|
+
writeJson(res, 400, { error: (error as Error).message })
|
|
2049
|
+
return
|
|
2050
|
+
}
|
|
2051
|
+
const slots = { ...(project.promptSlots ?? {}) }
|
|
2052
|
+
if (value === '') delete slots[body!.id]
|
|
2053
|
+
else slots[body!.id] = value
|
|
2054
|
+
project.promptSlots = slots
|
|
2055
|
+
project.updatedAt = new Date().toISOString()
|
|
2056
|
+
saveProject(outputDir, project)
|
|
2057
|
+
writeJson(res, 200, { ok: true, id: body!.id, chars: value.length })
|
|
2058
|
+
},
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// ------------------------------------------------------------- bookshelf
|
|
2062
|
+
const bookshelfRoute: WebRoute = {
|
|
2063
|
+
kind: 'exact',
|
|
2064
|
+
path: NOVEL_API.bookshelf,
|
|
2065
|
+
handler: async (req, res) => {
|
|
2066
|
+
// GET = snapshot; POST = create book.
|
|
2067
|
+
if (req.method === 'GET') {
|
|
2068
|
+
if (!isLoopbackRequest(req)) {
|
|
2069
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
2070
|
+
return
|
|
2071
|
+
}
|
|
2072
|
+
// 书架为空时,把 settings 默认输出目录里已有的项目播种为第一本书。
|
|
2073
|
+
seedBookshelfFromOutputDir(getConfig().outputDir)
|
|
2074
|
+
const snapshot: BookshelfSnapshot = bookshelfSnapshot(loadBookshelf())
|
|
2075
|
+
writeJson(res, 200, snapshot)
|
|
2076
|
+
return
|
|
2077
|
+
}
|
|
2078
|
+
if (req.method === 'POST') {
|
|
2079
|
+
if (!isLoopbackRequest(req)) {
|
|
2080
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
2081
|
+
return
|
|
2082
|
+
}
|
|
2083
|
+
const body = await readJsonBody<BookCreateRequest>(req)
|
|
2084
|
+
const bookName = body?.bookName?.trim()
|
|
2085
|
+
if (bookName === undefined || bookName === '') {
|
|
2086
|
+
writeJson(res, 400, { error: 'bookName 不能为空' })
|
|
2087
|
+
return
|
|
2088
|
+
}
|
|
2089
|
+
const outputDir = body?.outputDir?.trim() !== '' && body?.outputDir !== undefined
|
|
2090
|
+
? body.outputDir
|
|
2091
|
+
: defaultBookDir(bookName)
|
|
2092
|
+
const book = createBook(bookName, outputDir)
|
|
2093
|
+
// 开书向导:创建时带大纲 → 立即建立项目(书名以大纲首行为准)。
|
|
2094
|
+
const outline = body?.outline?.trim()
|
|
2095
|
+
if (outline !== undefined && outline.length >= 50) {
|
|
2096
|
+
const project = createProject(outline)
|
|
2097
|
+
saveProject(outputDir, project)
|
|
2098
|
+
renameBook(book.id, project.bookName)
|
|
2099
|
+
}
|
|
2100
|
+
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
|
|
2101
|
+
return
|
|
2102
|
+
}
|
|
2103
|
+
writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
|
|
2104
|
+
},
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
// --------------------------------------------------------------- reset
|
|
2108
|
+
/** 重置项目:清空设定/卷/章节计划/正文/伏笔/资产/事实库(可携带新大纲)。 */
|
|
2109
|
+
const resetRoute: WebRoute = {
|
|
2110
|
+
kind: 'exact',
|
|
2111
|
+
path: NOVEL_API.reset,
|
|
2112
|
+
handler: async (req, res) => {
|
|
2113
|
+
if (!guard(req, res, 'POST')) return
|
|
2114
|
+
const config = getConfig()
|
|
2115
|
+
const project = loadProject(config.outputDir)
|
|
2116
|
+
if (project === undefined) {
|
|
2117
|
+
writeJson(res, 400, { error: '输出目录中没有项目,无需重置' })
|
|
2118
|
+
return
|
|
2119
|
+
}
|
|
2120
|
+
const body = await readJsonBody<ResetRequest>(req)
|
|
2121
|
+
const outline = body?.outline?.trim()
|
|
2122
|
+
if (outline !== undefined && outline.length >= 50) {
|
|
2123
|
+
project.outline = outline
|
|
2124
|
+
project.bookName = createProject(outline).bookName
|
|
2125
|
+
}
|
|
2126
|
+
project.bible = undefined
|
|
2127
|
+
project.volumes = undefined
|
|
2128
|
+
project.chapters = []
|
|
2129
|
+
project.foreshadows = []
|
|
2130
|
+
project.assets = emptyProjectAssets()
|
|
2131
|
+
project.facts = []
|
|
2132
|
+
project.updatedAt = new Date().toISOString()
|
|
2133
|
+
saveProject(config.outputDir, project)
|
|
2134
|
+
writeJson(res, 200, { ok: true, bookName: project.bookName })
|
|
2135
|
+
},
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// --------------------------------------------------- bookshelf activate
|
|
2139
|
+
const bookshelfActivateRoute: WebRoute = {
|
|
2140
|
+
kind: 'exact',
|
|
2141
|
+
path: '/api/dsh-novel-forge/bookshelf/activate',
|
|
2142
|
+
handler: async (req, res) => {
|
|
2143
|
+
if (!guard(req, res, 'POST')) return
|
|
2144
|
+
const body = await readJsonBody<BookActivateRequest>(req)
|
|
2145
|
+
if (body?.id === undefined || body.id === '') {
|
|
2146
|
+
writeJson(res, 400, { error: 'id 不能为空' })
|
|
2147
|
+
return
|
|
2148
|
+
}
|
|
2149
|
+
const book = activateBook(body.id)
|
|
2150
|
+
if (book === undefined) {
|
|
2151
|
+
writeJson(res, 404, { error: `书 ${body.id} 不存在` })
|
|
2152
|
+
return
|
|
2153
|
+
}
|
|
2154
|
+
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
|
|
2155
|
+
},
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
// ---------------------------------------------------- bookshelf remove
|
|
2159
|
+
const bookshelfRemoveRoute: WebRoute = {
|
|
2160
|
+
kind: 'exact',
|
|
2161
|
+
path: '/api/dsh-novel-forge/bookshelf/remove',
|
|
2162
|
+
handler: async (req, res) => {
|
|
2163
|
+
if (!guard(req, res, 'POST')) return
|
|
2164
|
+
const body = await readJsonBody<BookRemoveRequest>(req)
|
|
2165
|
+
if (body?.id === undefined || body.id === '') {
|
|
2166
|
+
writeJson(res, 400, { error: 'id 不能为空' })
|
|
2167
|
+
return
|
|
2168
|
+
}
|
|
2169
|
+
const removed = removeBook(body.id)
|
|
2170
|
+
if (!removed) {
|
|
2171
|
+
writeJson(res, 404, { error: `书 ${body.id} 不存在` })
|
|
2172
|
+
return
|
|
2173
|
+
}
|
|
2174
|
+
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
|
|
2175
|
+
},
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// ------------------------------------------- bookshelf import-dir
|
|
2179
|
+
/** 导入已有项目目录(Mode A):校验 novel-project.json,登记/激活书架。 */
|
|
2180
|
+
const bookshelfImportDirRoute: WebRoute = {
|
|
2181
|
+
kind: 'exact',
|
|
2182
|
+
path: NOVEL_API.bookshelfImportDir,
|
|
2183
|
+
handler: async (req, res) => {
|
|
2184
|
+
if (!guard(req, res, 'POST')) return
|
|
2185
|
+
const body = await readJsonBody<BookImportDirRequest>(req)
|
|
2186
|
+
const outputDir = body?.outputDir?.trim()
|
|
2187
|
+
if (outputDir === undefined || outputDir === '') {
|
|
2188
|
+
writeJson(res, 400, { error: 'outputDir 不能为空' })
|
|
2189
|
+
return
|
|
2190
|
+
}
|
|
2191
|
+
try {
|
|
2192
|
+
const { book, existed } = importDir(outputDir)
|
|
2193
|
+
writeJson(res, 200, { book, existed })
|
|
2194
|
+
} catch (err) {
|
|
2195
|
+
writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
|
|
2196
|
+
}
|
|
2197
|
+
},
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// ------------------------------------------- bookshelf import-text
|
|
2201
|
+
/** 导入 txt/md 全本(Mode B):拆章落盘建项目,登记书架。支持浏览器上传(text+fileName)与服务器本地文件(filePath)两种模式。 */
|
|
2202
|
+
const bookshelfImportTextRoute: WebRoute = {
|
|
2203
|
+
kind: 'exact',
|
|
2204
|
+
path: NOVEL_API.bookshelfImportText,
|
|
2205
|
+
handler: async (req, res) => {
|
|
2206
|
+
if (!guard(req, res, 'POST')) return
|
|
2207
|
+
const body = await readJsonBody<BookImportTextRequest>(req)
|
|
2208
|
+
try {
|
|
2209
|
+
// 模式二:浏览器上传全文内容
|
|
2210
|
+
if (body?.text !== undefined && body.text.length > 0) {
|
|
2211
|
+
const bookName = (body.fileName ?? '').replace(/\.[^.]+$/, '').trim().slice(0, 40) || '导入小说'
|
|
2212
|
+
const outDir = body?.outputDir?.trim() !== undefined && body.outputDir.trim() !== ''
|
|
2213
|
+
? body.outputDir.trim()
|
|
2214
|
+
: defaultBookDir(bookName)
|
|
2215
|
+
const result = importBookTextFromText(body.text, outDir, bookName)
|
|
1355
2216
|
const { book } = importDir(outDir)
|
|
1356
2217
|
writeJson(res, 200, { ...result, book })
|
|
1357
2218
|
return
|
|
@@ -1369,7 +2230,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1369
2230
|
const bookName = basename(filePath, extname(filePath)).slice(0, 40) || '导入小说'
|
|
1370
2231
|
const outDir = body?.outputDir?.trim() !== undefined && body.outputDir.trim() !== ''
|
|
1371
2232
|
? body.outputDir.trim()
|
|
1372
|
-
:
|
|
2233
|
+
: defaultBookDir(bookName)
|
|
1373
2234
|
const result = importBookText(filePath, outDir)
|
|
1374
2235
|
const { book } = importDir(outDir)
|
|
1375
2236
|
writeJson(res, 200, { ...result, book })
|
|
@@ -1408,9 +2269,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1408
2269
|
path: NOVEL_API.audit,
|
|
1409
2270
|
handler: async (req, res) => {
|
|
1410
2271
|
if (!guard(req, res, 'POST')) return
|
|
1411
|
-
const
|
|
1412
|
-
const
|
|
1413
|
-
|
|
2272
|
+
const body = await readJsonBody<{ bookId?: string }>(req)
|
|
2273
|
+
const config0 = getConfig()
|
|
2274
|
+
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
2275
|
+
const config = { ...config0, outputDir }
|
|
2276
|
+
const project = loadProject(outputDir)
|
|
2277
|
+
if (project === undefined) {
|
|
2278
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
2279
|
+
return
|
|
2280
|
+
}
|
|
1414
2281
|
const startedAt = new Date().toISOString()
|
|
1415
2282
|
auditState = { status: 'running', startedAt, totalBatches: 0, completedBatches: 0 }
|
|
1416
2283
|
try {
|
|
@@ -1433,6 +2300,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1433
2300
|
issues,
|
|
1434
2301
|
auditedChapters,
|
|
1435
2302
|
auditedAt: auditState.finishedAt!,
|
|
2303
|
+
model: config.auditModel || config.model,
|
|
1436
2304
|
}
|
|
1437
2305
|
writeJson(res, 200, response)
|
|
1438
2306
|
} catch (error) {
|
|
@@ -1530,7 +2398,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1530
2398
|
}
|
|
1531
2399
|
|
|
1532
2400
|
// ---------------------------------------------------------------- blurb
|
|
1533
|
-
/**
|
|
2401
|
+
/** 小说简介:生成/补全,或手动保存。 */
|
|
1534
2402
|
const blurbRoute: WebRoute = {
|
|
1535
2403
|
kind: 'exact',
|
|
1536
2404
|
path: NOVEL_API.blurb,
|
|
@@ -1644,7 +2512,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1644
2512
|
}
|
|
1645
2513
|
|
|
1646
2514
|
// ---------------------------------------------------------------- world
|
|
1647
|
-
/**
|
|
2515
|
+
/** 大世界:提炼或手动保存(境界体系/区域/势力)。 */
|
|
1648
2516
|
const worldRoute: WebRoute = {
|
|
1649
2517
|
kind: 'exact',
|
|
1650
2518
|
path: NOVEL_API.world,
|
|
@@ -1756,7 +2624,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1756
2624
|
writeJson(res, 200, { plotlines: project.plotlines, suggestions } satisfies PlotlinesResponse)
|
|
1757
2625
|
return
|
|
1758
2626
|
} catch (error) {
|
|
1759
|
-
writeJson(res, 500, { error:
|
|
2627
|
+
writeJson(res, 500, { error: `建议失败:${(error as Error).message}` })
|
|
1760
2628
|
return
|
|
1761
2629
|
}
|
|
1762
2630
|
} else if (op === 'refresh' && body?.id !== undefined) {
|
|
@@ -1779,424 +2647,114 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1779
2647
|
} else if (op === 'health') {
|
|
1780
2648
|
try {
|
|
1781
2649
|
const health = await analyzePlotlineHealth(ctx, config, project)
|
|
1782
|
-
writeJson(res, 200, { plotlines: project.plotlines, health } satisfies PlotlinesResponse)
|
|
1783
|
-
return
|
|
1784
|
-
} catch (error) {
|
|
1785
|
-
writeJson(res, 500, { error: `健康检查失败:${(error as Error).message}` })
|
|
1786
|
-
return
|
|
1787
|
-
}
|
|
1788
|
-
} else if (op === 'plan') {
|
|
1789
|
-
try {
|
|
1790
|
-
const health = await analyzePlotlineHealth(ctx, config, project)
|
|
1791
|
-
const plan = await designPlotlinePlan(ctx, config, project, health)
|
|
1792
|
-
writeJson(res, 200, { plotlines: project.plotlines, health, plan } satisfies PlotlinesResponse)
|
|
1793
|
-
return
|
|
1794
|
-
} catch (error) {
|
|
1795
|
-
writeJson(res, 500, { error: `剧情方案生成失败:${(error as Error).message}` })
|
|
1796
|
-
return
|
|
1797
|
-
}
|
|
1798
|
-
}
|
|
1799
|
-
project.updatedAt = new Date().toISOString()
|
|
1800
|
-
saveProject(config.outputDir, project)
|
|
1801
|
-
const response: PlotlinesResponse = { plotlines: project.plotlines }
|
|
1802
|
-
writeJson(res, 200, response)
|
|
1803
|
-
},
|
|
1804
|
-
}
|
|
1805
|
-
|
|
1806
|
-
// --------------------------------------------------------- sensitive
|
|
1807
|
-
/** 敏感词检查:指定章节 / 任意文本 / 全书已写章节。 */
|
|
1808
|
-
const sensitiveRoute: WebRoute = {
|
|
1809
|
-
kind: 'exact',
|
|
1810
|
-
path: NOVEL_API.sensitiveCheck,
|
|
1811
|
-
handler: async (req, res) => {
|
|
1812
|
-
if (!guard(req, res, 'POST')) return
|
|
1813
|
-
const config = getConfig()
|
|
1814
|
-
const project = requireProject(res)
|
|
1815
|
-
if (project === undefined) return
|
|
1816
|
-
const body = await readJsonBody<SensitiveCheckRequest>(req)
|
|
1817
|
-
const hits: SensitiveHit[] = []
|
|
1818
|
-
let scanned = 0
|
|
1819
|
-
if (body?.text !== undefined) {
|
|
1820
|
-
for (const hit of checkSensitiveText(body.text)) {
|
|
1821
|
-
hits.push({ chapterNo: 0, word: hit.word, category: hit.category, count: hit.count })
|
|
1822
|
-
}
|
|
1823
|
-
} else if (typeof body?.chapterNo === 'number') {
|
|
1824
|
-
const chapter = project.chapters.find(c => c.no === body.chapterNo)
|
|
1825
|
-
if (chapter !== undefined) {
|
|
1826
|
-
const text = readChapterFile(config.outputDir, chapter)
|
|
1827
|
-
if (text !== undefined) {
|
|
1828
|
-
scanned = 1
|
|
1829
|
-
for (const hit of checkSensitiveText(text)) {
|
|
1830
|
-
hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
|
|
1831
|
-
}
|
|
1832
|
-
}
|
|
1833
|
-
}
|
|
1834
|
-
} else if (body?.all === true) {
|
|
1835
|
-
for (const chapter of project.chapters) {
|
|
1836
|
-
if (chapter.status === 'pending' || chapter.status === 'generating') continue
|
|
1837
|
-
const text = readChapterFile(config.outputDir, chapter)
|
|
1838
|
-
if (text === undefined) continue
|
|
1839
|
-
scanned++
|
|
1840
|
-
for (const hit of checkSensitiveText(text)) {
|
|
1841
|
-
hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
|
|
1842
|
-
}
|
|
1843
|
-
}
|
|
1844
|
-
} else {
|
|
1845
|
-
writeJson(res, 400, { error: '请提供 chapterNo / text / all 之一' })
|
|
1846
|
-
return
|
|
1847
|
-
}
|
|
1848
|
-
const response: SensitiveCheckResponse = { hits, scannedChapters: scanned }
|
|
1849
|
-
writeJson(res, 200, response)
|
|
1850
|
-
},
|
|
1851
|
-
}
|
|
1852
|
-
|
|
1853
|
-
// ---------------------------------------------------------------- roles
|
|
1854
|
-
/** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
|
|
1855
|
-
const rolesRoute: WebRoute = {
|
|
1856
|
-
kind: 'exact',
|
|
1857
|
-
path: NOVEL_API.roles,
|
|
1858
|
-
handler: async (req, res) => {
|
|
1859
|
-
if (!guard(req, res, 'POST')) return
|
|
1860
|
-
const config = getConfig()
|
|
1861
|
-
const project = requireProject(res)
|
|
1862
|
-
if (project === undefined) return
|
|
1863
|
-
const body = await readJsonBody<RolesRequest>(req)
|
|
1864
|
-
if (project.roles === undefined) project.roles = []
|
|
1865
|
-
const op = body?.op
|
|
1866
|
-
if (op === 'extract') {
|
|
1867
|
-
try {
|
|
1868
|
-
const candidates = await extractRoles(ctx, config, project)
|
|
1869
|
-
writeJson(res, 200, { roles: project.roles, candidates } satisfies RolesResponse)
|
|
1870
|
-
return
|
|
1871
|
-
} catch (error) {
|
|
1872
|
-
writeJson(res, 500, { error: `角色提炼失败:${(error as Error).message}` })
|
|
1873
|
-
return
|
|
1874
|
-
}
|
|
1875
|
-
} else if ((op === 'adopt' || op === 'update') && body?.role !== undefined) {
|
|
1876
|
-
const r = body.role
|
|
1877
|
-
const idx = project.roles.findIndex(x => x.name === r.name)
|
|
1878
|
-
if (idx === -1) project.roles.push(r)
|
|
1879
|
-
else project.roles[idx] = r
|
|
1880
|
-
} else if (op === 'remove' && body?.name !== undefined) {
|
|
1881
|
-
project.roles = project.roles.filter(x => x.name !== body.name)
|
|
1882
|
-
} else if (op === 'image') {
|
|
1883
|
-
const name = body?.name?.trim()
|
|
1884
|
-
const dataUrl = body?.dataUrl?.trim()
|
|
1885
|
-
if (name === undefined || name === '') {
|
|
1886
|
-
writeJson(res, 400, { error: 'name(角色名)必填' })
|
|
1887
|
-
return
|
|
1888
|
-
}
|
|
1889
|
-
if (dataUrl === undefined || dataUrl === '') {
|
|
1890
|
-
writeJson(res, 400, { error: 'dataUrl(参考图)必填' })
|
|
1891
|
-
return
|
|
1892
|
-
}
|
|
1893
|
-
const role = project.roles.find(r => r.name === name)
|
|
1894
|
-
if (role === undefined) {
|
|
1895
|
-
writeJson(res, 404, { error: `当前激活《${project.bookName}》,角色 ${name} 不存在——若期望的书不对,请先在书架切换该书并刷新` })
|
|
1896
|
-
return
|
|
1897
|
-
}
|
|
1898
|
-
const label = body?.label?.trim() ?? ''
|
|
1899
|
-
if (label !== '') {
|
|
1900
|
-
// 图集上传:带用途标签。标签「立绘」时同步为角色参考图(imageUrl)。
|
|
1901
|
-
role.gallery ??= []
|
|
1902
|
-
role.gallery = role.gallery.filter(g => g.label !== label)
|
|
1903
|
-
role.gallery.push({ label, dataUrl })
|
|
1904
|
-
if (label === '立绘' || label.includes('立绘')) role.imageUrl = dataUrl
|
|
1905
|
-
} else {
|
|
1906
|
-
// 兼容旧行为:不带标签 = 角色参考图。
|
|
1907
|
-
role.imageUrl = dataUrl
|
|
1908
|
-
}
|
|
1909
|
-
project.updatedAt = new Date().toISOString()
|
|
1910
|
-
saveProject(config.outputDir, project)
|
|
1911
|
-
writeJson(res, 200, { roles: project.roles, imageUrl: role.imageUrl } satisfies RolesResponse)
|
|
1912
|
-
return
|
|
1913
|
-
} else if (op === 'removeImage') {
|
|
1914
|
-
const name = body?.name?.trim()
|
|
1915
|
-
const label = body?.label?.trim() ?? ''
|
|
1916
|
-
const role = project.roles.find(r => r.name === name)
|
|
1917
|
-
if (role !== undefined) {
|
|
1918
|
-
if (label !== '') {
|
|
1919
|
-
role.gallery = (role.gallery ?? []).filter(g => g.label !== label)
|
|
1920
|
-
if (label === '立绘' || label.includes('立绘')) role.imageUrl = undefined
|
|
1921
|
-
} else {
|
|
1922
|
-
role.imageUrl = undefined
|
|
1923
|
-
}
|
|
1924
|
-
}
|
|
1925
|
-
project.updatedAt = new Date().toISOString()
|
|
1926
|
-
saveProject(config.outputDir, project)
|
|
1927
|
-
writeJson(res, 200, { roles: project.roles } satisfies RolesResponse)
|
|
1928
|
-
return
|
|
1929
|
-
} else if (op === 'visual') {
|
|
1930
|
-
// 动漫形象描述词提炼:扫描正文外貌描写 → LLM 提炼 → 写入角色卡。
|
|
1931
|
-
const name = body?.name?.trim()
|
|
1932
|
-
if (name === undefined || name === '') {
|
|
1933
|
-
writeJson(res, 400, { error: 'name(角色名)必填' })
|
|
1934
|
-
return
|
|
1935
|
-
}
|
|
1936
|
-
try {
|
|
1937
|
-
const visual = await extractRoleVisual(ctx, config, project, config.outputDir, name, body?.styleId, body?.filterId)
|
|
1938
|
-
const role = project.roles.find(r => r.name === name)
|
|
1939
|
-
if (role !== undefined) {
|
|
1940
|
-
role.imagePrompt = visual
|
|
1941
|
-
if ((visual.expressions ?? []).length > 0) role.expressions = visual.expressions
|
|
1942
|
-
if (visual.promptKit !== undefined) role.promptKit = visual.promptKit
|
|
1943
|
-
}
|
|
1944
|
-
project.updatedAt = new Date().toISOString()
|
|
1945
|
-
saveProject(config.outputDir, project)
|
|
1946
|
-
writeJson(res, 200, { roles: project.roles, visual } satisfies RolesResponse)
|
|
1947
|
-
return
|
|
1948
|
-
} catch (error) {
|
|
1949
|
-
writeJson(res, 500, { error: `形象提炼失败:${(error as Error).message}` })
|
|
1950
|
-
return
|
|
1951
|
-
}
|
|
1952
|
-
} else if (op === 'promptKit') {
|
|
1953
|
-
// 角色四类提示词精修包(立绘/四视图/表情/细节)
|
|
1954
|
-
const name = body?.name?.trim()
|
|
1955
|
-
if (name === undefined || name === '') {
|
|
1956
|
-
writeJson(res, 400, { error: 'name(角色名)必填' })
|
|
1957
|
-
return
|
|
1958
|
-
}
|
|
1959
|
-
try {
|
|
1960
|
-
const kit = await generateRolePromptKit(ctx, config, project, name, body?.styleId, body?.filterId)
|
|
1961
|
-
const role = project.roles.find(r => r.name === name)
|
|
1962
|
-
if (role !== undefined) role.promptKit = kit
|
|
1963
|
-
project.updatedAt = new Date().toISOString()
|
|
1964
|
-
saveProject(config.outputDir, project)
|
|
1965
|
-
writeJson(res, 200, { roles: project.roles, promptKit: kit } satisfies RolesResponse)
|
|
1966
|
-
return
|
|
1967
|
-
} catch (error) {
|
|
1968
|
-
writeJson(res, 500, { error: `提示词精修失败:${(error as Error).message}` })
|
|
1969
|
-
return
|
|
1970
|
-
}
|
|
1971
|
-
} else {
|
|
1972
|
-
writeJson(res, 400, { error: '未知的 roles op' })
|
|
1973
|
-
return
|
|
1974
|
-
}
|
|
1975
|
-
project.updatedAt = new Date().toISOString()
|
|
1976
|
-
saveProject(config.outputDir, project)
|
|
1977
|
-
const response: RolesResponse = { roles: project.roles }
|
|
1978
|
-
writeJson(res, 200, response)
|
|
1979
|
-
},
|
|
1980
|
-
}
|
|
1981
|
-
|
|
1982
|
-
// ------------------------------------------------------- manga roles
|
|
1983
|
-
/** 漫剧角色库:从分镜提名(规则+LLM 两段式)/ 建卡 / 更新 / 删除 / 形象锚点 / 精修提示词。 */
|
|
1984
|
-
const mangaRolesRoute: WebRoute = {
|
|
1985
|
-
kind: 'exact',
|
|
1986
|
-
path: NOVEL_API.mangaRoles,
|
|
1987
|
-
handler: async (req, res) => {
|
|
1988
|
-
if (!guard(req, res, 'POST')) return
|
|
1989
|
-
const config = getConfig()
|
|
1990
|
-
const project = requireProject(res)
|
|
1991
|
-
if (project === undefined) return
|
|
1992
|
-
const body = await readJsonBody<MangaRolesRequest>(req)
|
|
1993
|
-
if (project.mangaRoles === undefined) project.mangaRoles = []
|
|
1994
|
-
const op = body?.op
|
|
1995
|
-
if (op === 'nominate') {
|
|
1996
|
-
const chapterNo = body?.chapterNo
|
|
1997
|
-
if (typeof chapterNo !== 'number' || chapterNo <= 0) {
|
|
1998
|
-
writeJson(res, 400, { error: 'chapterNo(章号)必填' })
|
|
1999
|
-
return
|
|
2000
|
-
}
|
|
2001
|
-
try {
|
|
2002
|
-
const candidates = await nominateMangaRoles(ctx, config, project, config.outputDir, chapterNo)
|
|
2003
|
-
writeJson(res, 200, { cards: project.mangaRoles, candidates } satisfies MangaRolesResponse)
|
|
2004
|
-
return
|
|
2005
|
-
} catch (error) {
|
|
2006
|
-
writeJson(res, 500, { error: `提名失败:${(error as Error).message}` })
|
|
2007
|
-
return
|
|
2008
|
-
}
|
|
2009
|
-
} else if (op === 'adopt' || op === 'update') {
|
|
2010
|
-
const card = body?.card
|
|
2011
|
-
if (card === undefined || typeof card !== 'object') {
|
|
2012
|
-
writeJson(res, 400, { error: 'card(漫剧角色卡)必填' })
|
|
2013
|
-
return
|
|
2014
|
-
}
|
|
2015
|
-
const name = (card.name ?? '').trim()
|
|
2016
|
-
if (name === '') {
|
|
2017
|
-
writeJson(res, 400, { error: '漫剧角色名不能为空' })
|
|
2018
|
-
return
|
|
2019
|
-
}
|
|
2020
|
-
const now = new Date().toISOString()
|
|
2021
|
-
const existing = card.id !== '' ? project.mangaRoles.find(c => c.id === card.id) : undefined
|
|
2022
|
-
const next: MangaRoleCard = {
|
|
2023
|
-
id: card.id !== '' ? card.id : 'mr-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6),
|
|
2024
|
-
sourceRoleName: card.sourceRoleName !== undefined && card.sourceRoleName !== '' ? card.sourceRoleName : undefined,
|
|
2025
|
-
name,
|
|
2026
|
-
identity: card.identity ?? '',
|
|
2027
|
-
coreFunction: card.coreFunction ?? 'functional',
|
|
2028
|
-
protagonistRelation: card.protagonistRelation ?? 'neutral',
|
|
2029
|
-
speechStyle: card.speechStyle ?? '',
|
|
2030
|
-
traits: Array.isArray(card.traits) ? card.traits.map(t => String(t).slice(0, 20)).filter(t => t !== '').slice(0, 3) : [],
|
|
2031
|
-
appearance: card.appearance ?? '',
|
|
2032
|
-
keyScenes: Array.isArray(card.keyScenes) ? card.keyScenes.map(k => String(k).slice(0, 120)).filter(k => k !== '').slice(0, 6) : [],
|
|
2033
|
-
appearsInEpisodes: Array.isArray(card.appearsInEpisodes)
|
|
2034
|
-
? [...new Set(card.appearsInEpisodes.map(n => Number(n)).filter(n => Number.isFinite(n) && n > 0))].sort((a, b) => a - b).slice(0, 200)
|
|
2035
|
-
: [],
|
|
2036
|
-
status: card.status ?? 'imported',
|
|
2037
|
-
imagePrompt: card.imagePrompt,
|
|
2038
|
-
expressions: card.expressions,
|
|
2039
|
-
promptKit: card.promptKit,
|
|
2040
|
-
imageUrl: card.imageUrl,
|
|
2041
|
-
gallery: card.gallery,
|
|
2042
|
-
promptStyleId: card.promptStyleId,
|
|
2043
|
-
createdAt: existing?.createdAt ?? now,
|
|
2044
|
-
updatedAt: now,
|
|
2045
|
-
}
|
|
2046
|
-
if (existing !== undefined) {
|
|
2047
|
-
const idx = project.mangaRoles.findIndex(c => c.id === card.id)
|
|
2048
|
-
project.mangaRoles[idx] = next
|
|
2049
|
-
} else {
|
|
2050
|
-
project.mangaRoles.push(next)
|
|
2051
|
-
}
|
|
2052
|
-
project.updatedAt = now
|
|
2053
|
-
saveProject(config.outputDir, project)
|
|
2054
|
-
writeJson(res, 200, { cards: project.mangaRoles } satisfies MangaRolesResponse)
|
|
2055
|
-
return
|
|
2056
|
-
} else if (op === 'remove') {
|
|
2057
|
-
const id = body?.id
|
|
2058
|
-
if (typeof id !== 'string' || id === '') {
|
|
2059
|
-
writeJson(res, 400, { error: 'id(漫剧卡 id)必填' })
|
|
2060
|
-
return
|
|
2061
|
-
}
|
|
2062
|
-
project.mangaRoles = project.mangaRoles.filter(c => c.id !== id)
|
|
2063
|
-
project.updatedAt = new Date().toISOString()
|
|
2064
|
-
saveProject(config.outputDir, project)
|
|
2065
|
-
writeJson(res, 200, { cards: project.mangaRoles } satisfies MangaRolesResponse)
|
|
2066
|
-
return
|
|
2067
|
-
} else if (op === 'visual') {
|
|
2068
|
-
const id = body?.id?.trim()
|
|
2069
|
-
if (id === undefined || id === '') {
|
|
2070
|
-
writeJson(res, 400, { error: 'id(漫剧卡 id)必填' })
|
|
2071
|
-
return
|
|
2072
|
-
}
|
|
2073
|
-
try {
|
|
2074
|
-
const activeStyle = getActiveMangaStyle(project); const visual = await extractMangaRoleVisual(ctx, config, project, config.outputDir, id, body?.styleId ?? activeStyle.styleId, body?.filterId ?? activeStyle.filterId)
|
|
2075
|
-
project.updatedAt = new Date().toISOString()
|
|
2076
|
-
saveProject(config.outputDir, project)
|
|
2077
|
-
writeJson(res, 200, { cards: project.mangaRoles, visual } satisfies MangaRolesResponse)
|
|
2078
|
-
return
|
|
2079
|
-
} catch (error) {
|
|
2080
|
-
writeJson(res, 500, { error: `形象锚点生成失败:${(error as Error).message}` })
|
|
2081
|
-
return
|
|
2082
|
-
}
|
|
2083
|
-
} else if (op === 'promptKit') {
|
|
2084
|
-
const id = body?.id?.trim()
|
|
2085
|
-
if (id === undefined || id === '') {
|
|
2086
|
-
writeJson(res, 400, { error: 'id(漫剧卡 id)必填' })
|
|
2087
|
-
return
|
|
2088
|
-
}
|
|
2089
|
-
try {
|
|
2090
|
-
const activeStyle2 = getActiveMangaStyle(project); const kit = await generateMangaRolePromptKit(ctx, config, project, id, body?.styleId ?? activeStyle2.styleId, body?.filterId ?? activeStyle2.filterId)
|
|
2091
|
-
project.updatedAt = new Date().toISOString()
|
|
2092
|
-
saveProject(config.outputDir, project)
|
|
2093
|
-
writeJson(res, 200, { cards: project.mangaRoles, promptKit: kit } satisfies MangaRolesResponse)
|
|
2650
|
+
writeJson(res, 200, { plotlines: project.plotlines, health } satisfies PlotlinesResponse)
|
|
2094
2651
|
return
|
|
2095
2652
|
} catch (error) {
|
|
2096
|
-
writeJson(res, 500, { error:
|
|
2653
|
+
writeJson(res, 500, { error: `健康检查失败:${(error as Error).message}` })
|
|
2097
2654
|
return
|
|
2098
2655
|
}
|
|
2099
|
-
} else if (op === '
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
writeJson(res, 400, { error: 'id(漫剧卡 id)必填' })
|
|
2656
|
+
} else if (op === 'plan') {
|
|
2657
|
+
try {
|
|
2658
|
+
const health = await analyzePlotlineHealth(ctx, config, project)
|
|
2659
|
+
const plan = await designPlotlinePlan(ctx, config, project, health)
|
|
2660
|
+
writeJson(res, 200, { plotlines: project.plotlines, health, plan } satisfies PlotlinesResponse)
|
|
2105
2661
|
return
|
|
2106
|
-
}
|
|
2107
|
-
|
|
2108
|
-
writeJson(res, 400, { error: 'dataUrl(定妆图)必填' })
|
|
2662
|
+
} catch (error) {
|
|
2663
|
+
writeJson(res, 500, { error: `剧情方案生成失败:${(error as Error).message}` })
|
|
2109
2664
|
return
|
|
2110
2665
|
}
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2666
|
+
}
|
|
2667
|
+
project.updatedAt = new Date().toISOString()
|
|
2668
|
+
saveProject(config.outputDir, project)
|
|
2669
|
+
const response: PlotlinesResponse = { plotlines: project.plotlines }
|
|
2670
|
+
writeJson(res, 200, response)
|
|
2671
|
+
},
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
// --------------------------------------------------------- sensitive
|
|
2675
|
+
/** 敏感词检查:指定章节 / 任意文本 / 全书已写章节。 */
|
|
2676
|
+
const sensitiveRoute: WebRoute = {
|
|
2677
|
+
kind: 'exact',
|
|
2678
|
+
path: NOVEL_API.sensitiveCheck,
|
|
2679
|
+
handler: async (req, res) => {
|
|
2680
|
+
if (!guard(req, res, 'POST')) return
|
|
2681
|
+
const config = getConfig()
|
|
2682
|
+
const project = requireProject(res)
|
|
2683
|
+
if (project === undefined) return
|
|
2684
|
+
const body = await readJsonBody<SensitiveCheckRequest>(req)
|
|
2685
|
+
const hits: SensitiveHit[] = []
|
|
2686
|
+
let scanned = 0
|
|
2687
|
+
if (body?.text !== undefined) {
|
|
2688
|
+
for (const hit of checkSensitiveText(body.text)) {
|
|
2689
|
+
hits.push({ chapterNo: 0, word: hit.word, category: hit.category, count: hit.count })
|
|
2115
2690
|
}
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2691
|
+
} else if (typeof body?.chapterNo === 'number') {
|
|
2692
|
+
const chapter = project.chapters.find(c => c.no === body.chapterNo)
|
|
2693
|
+
if (chapter !== undefined) {
|
|
2694
|
+
const text = readChapterFile(config.outputDir, chapter)
|
|
2695
|
+
if (text !== undefined) {
|
|
2696
|
+
scanned = 1
|
|
2697
|
+
for (const hit of checkSensitiveText(text)) {
|
|
2698
|
+
hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2124
2701
|
}
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
const card = project.mangaRoles.find(c => c.id === id)
|
|
2134
|
-
if (card !== undefined) {
|
|
2135
|
-
if (label !== '') {
|
|
2136
|
-
card.gallery = (card.gallery ?? []).filter(g => g.label !== label)
|
|
2137
|
-
if (label.includes('立绘')) card.imageUrl = undefined
|
|
2138
|
-
} else {
|
|
2139
|
-
card.imageUrl = undefined
|
|
2702
|
+
} else if (body?.all === true) {
|
|
2703
|
+
for (const chapter of project.chapters) {
|
|
2704
|
+
if (chapter.status === 'pending' || chapter.status === 'generating') continue
|
|
2705
|
+
const text = readChapterFile(config.outputDir, chapter)
|
|
2706
|
+
if (text === undefined) continue
|
|
2707
|
+
scanned++
|
|
2708
|
+
for (const hit of checkSensitiveText(text)) {
|
|
2709
|
+
hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
|
|
2140
2710
|
}
|
|
2141
|
-
card.updatedAt = new Date().toISOString()
|
|
2142
2711
|
}
|
|
2143
|
-
project.updatedAt = new Date().toISOString()
|
|
2144
|
-
saveProject(config.outputDir, project)
|
|
2145
|
-
writeJson(res, 200, { cards: project.mangaRoles } satisfies MangaRolesResponse)
|
|
2146
|
-
return
|
|
2147
|
-
} else if (op === 'mode') {
|
|
2148
|
-
// 短剧精简模式开关(漫剧角色库 5-8 人 / 功能标签 / 关系闭环 / 人设极致化)。
|
|
2149
|
-
project.shortDramaMode = body?.shortDramaMode === true
|
|
2150
|
-
project.updatedAt = new Date().toISOString()
|
|
2151
|
-
saveProject(config.outputDir, project)
|
|
2152
|
-
writeJson(res, 200, { cards: project.mangaRoles, shortDramaMode: project.shortDramaMode } satisfies MangaRolesResponse)
|
|
2153
|
-
return
|
|
2154
|
-
} else if (op === 'autoGenerate') {
|
|
2155
|
-
const chapterNo = body?.chapterNo
|
|
2156
|
-
if (typeof chapterNo !== 'number' || chapterNo <= 0) { writeJson(res, 400, { error: 'chapterNo(章号)必填' }); return }
|
|
2157
|
-
try {
|
|
2158
|
-
const activeStyle3 = getActiveMangaStyle(project); const result = await autoGenerateMangaChapter(ctx, config, project, config.outputDir, chapterNo, body?.styleId ?? activeStyle3.styleId, body?.filterId ?? activeStyle3.filterId)
|
|
2159
|
-
writeJson(res, 200, { cards: project.mangaRoles, autoGenerate: result } satisfies MangaRolesResponse)
|
|
2160
|
-
return
|
|
2161
|
-
} catch (error) { writeJson(res, 500, { error: '一键生成失败:' + (error as Error).message }); return }
|
|
2162
|
-
} else if (op === 'openAssets') {
|
|
2163
|
-
const dir = mangaAssetsDir(config.outputDir)
|
|
2164
|
-
try {
|
|
2165
|
-
mkdirSync(dir, { recursive: true })
|
|
2166
|
-
if (process.platform === 'win32') { spawn('explorer', [dir], { detached: true }) }
|
|
2167
|
-
else if (process.platform === 'darwin') { spawn('open', [dir], { detached: true }) }
|
|
2168
|
-
else { spawn('xdg-open', [dir], { detached: true }) }
|
|
2169
|
-
writeJson(res, 200, { ok: true, dir })
|
|
2170
|
-
return
|
|
2171
|
-
} catch (error) { writeJson(res, 500, { error: '打开资产库失败:' + (error as Error).message, dir }); return }
|
|
2172
2712
|
} else {
|
|
2173
|
-
writeJson(res, 400, { error: '
|
|
2713
|
+
writeJson(res, 400, { error: '请提供 chapterNo / text / all 之一' })
|
|
2174
2714
|
return
|
|
2175
2715
|
}
|
|
2716
|
+
const response: SensitiveCheckResponse = { hits, scannedChapters: scanned }
|
|
2717
|
+
writeJson(res, 200, response)
|
|
2176
2718
|
},
|
|
2177
2719
|
}
|
|
2178
2720
|
|
|
2179
|
-
//
|
|
2180
|
-
/**
|
|
2181
|
-
const
|
|
2721
|
+
// ---------------------------------------------------------------- roles
|
|
2722
|
+
/** 角色库:提炼 / 采纳 / 更新 / 删除。 */
|
|
2723
|
+
const rolesRoute: WebRoute = {
|
|
2182
2724
|
kind: 'exact',
|
|
2183
|
-
path: NOVEL_API.
|
|
2725
|
+
path: NOVEL_API.roles,
|
|
2184
2726
|
handler: async (req, res) => {
|
|
2185
2727
|
if (!guard(req, res, 'POST')) return
|
|
2186
2728
|
const config = getConfig()
|
|
2187
|
-
const
|
|
2188
|
-
|
|
2189
|
-
const
|
|
2190
|
-
if (
|
|
2191
|
-
|
|
2729
|
+
const project = requireProject(res)
|
|
2730
|
+
if (project === undefined) return
|
|
2731
|
+
const body = await readJsonBody<RolesRequest>(req)
|
|
2732
|
+
if (project.roles === undefined) project.roles = []
|
|
2733
|
+
const op = body?.op
|
|
2734
|
+
if (op === 'extract') {
|
|
2735
|
+
try {
|
|
2736
|
+
const candidates = await extractRoles(ctx, config, project)
|
|
2737
|
+
writeJson(res, 200, { roles: project.roles, candidates } satisfies RolesResponse)
|
|
2738
|
+
return
|
|
2739
|
+
} catch (error) {
|
|
2740
|
+
writeJson(res, 500, { error: `角色提炼失败:${(error as Error).message}` })
|
|
2741
|
+
return
|
|
2742
|
+
}
|
|
2743
|
+
} else if ((op === 'adopt' || op === 'update') && body?.role !== undefined) {
|
|
2744
|
+
const r = body.role
|
|
2745
|
+
const idx = project.roles.findIndex(x => x.name === r.name)
|
|
2746
|
+
if (idx === -1) project.roles.push(r)
|
|
2747
|
+
else project.roles[idx] = r
|
|
2748
|
+
} else if (op === 'remove' && body?.name !== undefined) {
|
|
2749
|
+
project.roles = project.roles.filter(x => x.name !== body.name)
|
|
2750
|
+
} else {
|
|
2751
|
+
writeJson(res, 400, { error: '未知的 roles op' })
|
|
2192
2752
|
return
|
|
2193
2753
|
}
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
writeJson(res, 500, { error: (error as Error).message })
|
|
2199
|
-
}
|
|
2754
|
+
project.updatedAt = new Date().toISOString()
|
|
2755
|
+
saveProject(config.outputDir, project)
|
|
2756
|
+
const response: RolesResponse = { roles: project.roles }
|
|
2757
|
+
writeJson(res, 200, response)
|
|
2200
2758
|
},
|
|
2201
2759
|
}
|
|
2202
2760
|
|
|
@@ -2359,8 +2917,8 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2359
2917
|
return
|
|
2360
2918
|
}
|
|
2361
2919
|
try {
|
|
2362
|
-
const
|
|
2363
|
-
writeJson(res, 200,
|
|
2920
|
+
const result = await patchConfig(body)
|
|
2921
|
+
writeJson(res, 200, result satisfies ConfigResponse)
|
|
2364
2922
|
} catch (error) {
|
|
2365
2923
|
writeJson(res, 400, { error: (error as Error).message })
|
|
2366
2924
|
}
|
|
@@ -2398,7 +2956,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2398
2956
|
path: NOVEL_API.pluginUpdate,
|
|
2399
2957
|
handler: async (req, res) => {
|
|
2400
2958
|
if (!guard(req, res, 'POST')) return
|
|
2401
|
-
const profileDir =
|
|
2959
|
+
const profileDir = dshHomePath('profiles', argvProfile() ?? 'web')
|
|
2402
2960
|
const response: PluginUpdateResponse = await new Promise((resolve) => {
|
|
2403
2961
|
const child = spawn('pnpm', ['add', '@waterwx/dsh-novel-forge@latest'], { cwd: profileDir, shell: true })
|
|
2404
2962
|
let acc = ''
|
|
@@ -2476,35 +3034,6 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2476
3034
|
},
|
|
2477
3035
|
}
|
|
2478
3036
|
|
|
2479
|
-
// ------------------------------------------------- storyboard prompts
|
|
2480
|
-
/** 分镜·提示词级:分镜表 → 即梦可粘贴视频提示词。 */
|
|
2481
|
-
const storyboardPromptsRoute: WebRoute = {
|
|
2482
|
-
kind: 'exact',
|
|
2483
|
-
path: NOVEL_API.storyboardPrompts,
|
|
2484
|
-
handler: async (req, res) => {
|
|
2485
|
-
if (!guard(req, res, 'POST')) return
|
|
2486
|
-
const config = getConfig()
|
|
2487
|
-
const project = requireProject(res)
|
|
2488
|
-
if (project === undefined) return
|
|
2489
|
-
const body = await readJsonBody<StoryboardPromptsRequest>(req)
|
|
2490
|
-
const no = body?.chapterNo
|
|
2491
|
-
if (!Number.isInteger(no) || no === undefined || no < 1) {
|
|
2492
|
-
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
2493
|
-
return
|
|
2494
|
-
}
|
|
2495
|
-
if (body?.table === undefined || (body.table.shots ?? []).length === 0) {
|
|
2496
|
-
writeJson(res, 400, { error: 'table 不能为空,请先生成分镜表' })
|
|
2497
|
-
return
|
|
2498
|
-
}
|
|
2499
|
-
try {
|
|
2500
|
-
const activeStyle5 = getActiveMangaStyle(project); const prompts = await generateStoryboardPrompts(ctx, config, project, config.outputDir, no, body.table, body?.styleId ?? activeStyle5.styleId, body?.filterId ?? activeStyle5.filterId)
|
|
2501
|
-
writeJson(res, 200, { prompts } satisfies StoryboardPromptsResponse)
|
|
2502
|
-
} catch (error) {
|
|
2503
|
-
writeJson(res, 500, { error: `视频提示词生成失败:${(error as Error).message}` })
|
|
2504
|
-
}
|
|
2505
|
-
},
|
|
2506
|
-
}
|
|
2507
|
-
|
|
2508
3037
|
// ------------------------------------------------------------- llm-test
|
|
2509
3038
|
/** LLM 连通性测试:对选中的提供商/模型发一次最小真实调用。 */
|
|
2510
3039
|
const llmTestRoute: WebRoute = {
|
|
@@ -2616,149 +3145,6 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2616
3145
|
},
|
|
2617
3146
|
}
|
|
2618
3147
|
|
|
2619
|
-
// ---------------------------------------------------- storyboard table
|
|
2620
|
-
/** 分镜·导演级:骨架 → 分镜表(镜头级)。 */
|
|
2621
|
-
const storyboardTableRoute: WebRoute = {
|
|
2622
|
-
kind: 'exact',
|
|
2623
|
-
path: NOVEL_API.storyboardTable,
|
|
2624
|
-
handler: async (req, res) => {
|
|
2625
|
-
if (!guard(req, res, 'POST')) return
|
|
2626
|
-
const config = getConfig()
|
|
2627
|
-
const project = requireProject(res)
|
|
2628
|
-
if (project === undefined) return
|
|
2629
|
-
const body = await readJsonBody<StoryboardTableRequest>(req)
|
|
2630
|
-
const no = body?.chapterNo
|
|
2631
|
-
if (!Number.isInteger(no) || no === undefined || no < 1) {
|
|
2632
|
-
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
2633
|
-
return
|
|
2634
|
-
}
|
|
2635
|
-
if (body?.skeleton === undefined || (body.skeleton.beats ?? []).length === 0) {
|
|
2636
|
-
writeJson(res, 400, { error: 'skeleton 不能为空,请先生成剧情骨架' })
|
|
2637
|
-
return
|
|
2638
|
-
}
|
|
2639
|
-
try {
|
|
2640
|
-
const activeStyle6 = getActiveMangaStyle(project); const table = await generateStoryboardTable(ctx, config, project, config.outputDir, no, body.skeleton, body?.styleId ?? activeStyle6.styleId, body?.filterId ?? activeStyle6.filterId)
|
|
2641
|
-
writeJson(res, 200, { table } satisfies StoryboardTableResponse)
|
|
2642
|
-
} catch (error) {
|
|
2643
|
-
writeJson(res, 500, { error: `分镜表生成失败:${(error as Error).message}` })
|
|
2644
|
-
}
|
|
2645
|
-
},
|
|
2646
|
-
}
|
|
2647
|
-
|
|
2648
|
-
// ------------------------------------------------------ manhua plans
|
|
2649
|
-
/** 漫剧方案管理:create / remove / activate。 */
|
|
2650
|
-
const manhuaPlansRoute: WebRoute = {
|
|
2651
|
-
kind: 'exact',
|
|
2652
|
-
path: NOVEL_API.manhuaPlans,
|
|
2653
|
-
handler: async (req, res) => {
|
|
2654
|
-
if (!guard(req, res, 'POST')) return
|
|
2655
|
-
const config = getConfig()
|
|
2656
|
-
const project = requireProject(res)
|
|
2657
|
-
if (project === undefined) return
|
|
2658
|
-
const body = await readJsonBody<MangaPlansRequest>(req)
|
|
2659
|
-
if (project.mangaPlans === undefined) project.mangaPlans = []
|
|
2660
|
-
if (body?.op === 'create') {
|
|
2661
|
-
const name = body.name?.trim()
|
|
2662
|
-
const styleId = body.styleId?.trim()
|
|
2663
|
-
if (name === undefined || name === '' || styleId === undefined || styleId === '') {
|
|
2664
|
-
writeJson(res, 400, { error: 'name 与 styleId 不能为空' })
|
|
2665
|
-
return
|
|
2666
|
-
}
|
|
2667
|
-
if (project.mangaPlans.some(p => p.name === name)) {
|
|
2668
|
-
writeJson(res, 400, { error: `方案名「${name}」已存在` })
|
|
2669
|
-
return
|
|
2670
|
-
}
|
|
2671
|
-
const id = `manga-${Date.now().toString(36)}`
|
|
2672
|
-
project.mangaPlans.push({
|
|
2673
|
-
id,
|
|
2674
|
-
name: name.slice(0, 40),
|
|
2675
|
-
styleId,
|
|
2676
|
-
filterId: body.filterId?.trim() !== '' ? body.filterId?.trim() : undefined,
|
|
2677
|
-
genre: body.genre?.trim() !== '' ? body.genre?.trim() : undefined,
|
|
2678
|
-
active: project.mangaPlans.length === 0,
|
|
2679
|
-
createdAt: new Date().toISOString(),
|
|
2680
|
-
updatedAt: new Date().toISOString(),
|
|
2681
|
-
})
|
|
2682
|
-
} else if (body?.op === 'remove' && body.id !== undefined) {
|
|
2683
|
-
const removed = project.mangaPlans.find(p => p.id === body.id)
|
|
2684
|
-
project.mangaPlans = project.mangaPlans.filter(p => p.id !== body.id)
|
|
2685
|
-
if (removed?.active === true && project.mangaPlans.length > 0) {
|
|
2686
|
-
project.mangaPlans[0].active = true
|
|
2687
|
-
}
|
|
2688
|
-
// 删除方案时同时清除该方案的漫剧产出:分镜(骨架/表/提示词)、角色卡
|
|
2689
|
-
// 场景库与视觉规则是全书资产,保留供后续方案复用
|
|
2690
|
-
project.storyboards = []
|
|
2691
|
-
project.mangaRoles = []
|
|
2692
|
-
} else if (body?.op === 'activate' && body.id !== undefined) {
|
|
2693
|
-
project.mangaPlans.forEach(p => { p.active = p.id === body.id })
|
|
2694
|
-
} else {
|
|
2695
|
-
writeJson(res, 400, { error: 'op 须为 create/remove/activate' })
|
|
2696
|
-
return
|
|
2697
|
-
}
|
|
2698
|
-
project.updatedAt = new Date().toISOString()
|
|
2699
|
-
saveProject(config.outputDir, project)
|
|
2700
|
-
writeJson(res, 200, { plans: project.mangaPlans } satisfies MangaPlansResponse)
|
|
2701
|
-
},
|
|
2702
|
-
}
|
|
2703
|
-
|
|
2704
|
-
// ------------------------------------------------------ style image
|
|
2705
|
-
/** 风格库效果图:GET /styles/image?id=<styleId>,从 ~/.dsh/dsh-novel-forge-styles/<id>.png 读取。 */
|
|
2706
|
-
const styleImageRoute: WebRoute = {
|
|
2707
|
-
kind: 'exact',
|
|
2708
|
-
path: '/api/dsh-novel-forge/styles/image',
|
|
2709
|
-
handler: async (req, res) => {
|
|
2710
|
-
if (!isLoopbackRequest(req)) {
|
|
2711
|
-
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
2712
|
-
return
|
|
2713
|
-
}
|
|
2714
|
-
const url = new URL(req.url ?? '', 'http://localhost')
|
|
2715
|
-
const id = (url.searchParams.get('id') ?? '').replace(/[^a-z0-9-]/gi, '')
|
|
2716
|
-
if (id === '') {
|
|
2717
|
-
writeJson(res, 404, { error: 'style image not found' })
|
|
2718
|
-
return
|
|
2719
|
-
}
|
|
2720
|
-
// 图片来源:用户数据目录缩略图(自定义覆盖)→ 原图 → 包内置缩略图(随插件分发)。
|
|
2721
|
-
const base = join(homedir(), '.dsh', 'dsh-novel-forge-styles')
|
|
2722
|
-
const candidates: Array<{ file: string; type: string }> = [
|
|
2723
|
-
{ file: join(base, 'thumbs', id + '.webp'), type: 'image/webp' },
|
|
2724
|
-
{ file: join(base, id + '.png'), type: 'image/png' },
|
|
2725
|
-
{ file: join(builtinStyleDir, id + '.webp'), type: 'image/webp' },
|
|
2726
|
-
]
|
|
2727
|
-
for (const c of candidates) {
|
|
2728
|
-
if (existsSync(c.file)) {
|
|
2729
|
-
res.writeHead(200, { 'content-type': c.type, 'cache-control': 'public, max-age=86400' })
|
|
2730
|
-
res.end(readFileSync(c.file))
|
|
2731
|
-
return
|
|
2732
|
-
}
|
|
2733
|
-
}
|
|
2734
|
-
writeJson(res, 404, { error: 'style image not found' })
|
|
2735
|
-
},
|
|
2736
|
-
}
|
|
2737
|
-
|
|
2738
|
-
// ------------------------------------------------------- storyboard
|
|
2739
|
-
/** 分镜·编剧级:单章 → 剧情骨架(节拍链)。 */
|
|
2740
|
-
const storyboardSkeletonRoute: WebRoute = {
|
|
2741
|
-
kind: 'exact',
|
|
2742
|
-
path: NOVEL_API.storyboardSkeleton,
|
|
2743
|
-
handler: async (req, res) => {
|
|
2744
|
-
if (!guard(req, res, 'POST')) return
|
|
2745
|
-
const config = getConfig()
|
|
2746
|
-
const project = requireProject(res)
|
|
2747
|
-
if (project === undefined) return
|
|
2748
|
-
const body = await readJsonBody<StoryboardSkeletonRequest>(req)
|
|
2749
|
-
const no = body?.chapterNo
|
|
2750
|
-
if (!Number.isInteger(no) || no === undefined || no < 1) {
|
|
2751
|
-
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
2752
|
-
return
|
|
2753
|
-
}
|
|
2754
|
-
try {
|
|
2755
|
-
const skeleton = await generateStoryboardSkeleton(ctx, config, project, config.outputDir, no)
|
|
2756
|
-
writeJson(res, 200, { skeleton } satisfies StoryboardSkeletonResponse)
|
|
2757
|
-
} catch (error) {
|
|
2758
|
-
writeJson(res, 500, { error: `剧情骨架生成失败:${(error as Error).message}` })
|
|
2759
|
-
}
|
|
2760
|
-
},
|
|
2761
|
-
}
|
|
2762
3148
|
|
|
2763
3149
|
// ----------------------------------------------------------- breakdown
|
|
2764
3150
|
/** 拆书分析:对已写章节做结构/人物/文风/卖点体检(两阶段:源笔记→分节分析)。 */
|
|
@@ -2788,151 +3174,128 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2788
3174
|
},
|
|
2789
3175
|
}
|
|
2790
3176
|
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
3177
|
+
|
|
3178
|
+
// ----------------------------------------------------------- production run
|
|
3179
|
+
/** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
|
|
3180
|
+
const runner = new ProductionRunner({ ctx, getConfig })
|
|
3181
|
+
|
|
3182
|
+
// --------------------------------------------------------- move-output-dir
|
|
3183
|
+
/**
|
|
3184
|
+
* 输出目录迁移:dryRun 预览;确认后 copy 全部内容 → 书架重指向 → settings
|
|
3185
|
+
* 默认目录同步(仅当默认目录就是被搬目录)→ 最后删源目录(数据零丢失排序)。
|
|
3186
|
+
*/
|
|
3187
|
+
const moveOutputDirRoute: WebRoute = {
|
|
2794
3188
|
kind: 'exact',
|
|
2795
|
-
path: NOVEL_API.
|
|
3189
|
+
path: NOVEL_API.moveOutputDir,
|
|
2796
3190
|
handler: async (req, res) => {
|
|
2797
3191
|
if (!guard(req, res, 'POST')) return
|
|
2798
|
-
const
|
|
2799
|
-
const
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
if (project.scenes === undefined) project.scenes = []
|
|
2803
|
-
const op = body?.op
|
|
2804
|
-
if (op === 'extract') {
|
|
3192
|
+
const from = getConfig().outputDir
|
|
3193
|
+
const body = await readJsonBody<MoveOutputDirRequest>(req)
|
|
3194
|
+
// 预览:只列内容,不搬移。
|
|
3195
|
+
if (body?.dryRun === true) {
|
|
2805
3196
|
try {
|
|
2806
|
-
const
|
|
2807
|
-
writeJson(res, 200, {
|
|
2808
|
-
return
|
|
3197
|
+
const contents = listDirContents(from)
|
|
3198
|
+
writeJson(res, 200, { from, files: contents.files.slice(0, 300), bytes: contents.bytes, movedBooks: 0, defaultUpdated: false } satisfies MoveOutputDirResponse)
|
|
2809
3199
|
} catch (error) {
|
|
2810
|
-
writeJson(res,
|
|
2811
|
-
return
|
|
2812
|
-
}
|
|
2813
|
-
} else if ((op === 'adopt' || op === 'update') && body?.scene !== undefined) {
|
|
2814
|
-
const s = body.scene
|
|
2815
|
-
const idx = project.scenes.findIndex(x => x.name === s.name)
|
|
2816
|
-
if (idx === -1) project.scenes.push(s)
|
|
2817
|
-
else project.scenes[idx] = s
|
|
2818
|
-
} else if (op === 'remove' && body?.name !== undefined) {
|
|
2819
|
-
project.scenes = project.scenes.filter(x => x.name !== body.name)
|
|
2820
|
-
} else if (op === 'image') {
|
|
2821
|
-
const name = body?.name?.trim()
|
|
2822
|
-
const dataUrl = body?.dataUrl?.trim()
|
|
2823
|
-
if (name === undefined || name === '' || dataUrl === undefined || dataUrl === '') {
|
|
2824
|
-
writeJson(res, 400, { error: 'name(场景名)与 dataUrl 必填' })
|
|
2825
|
-
return
|
|
2826
|
-
}
|
|
2827
|
-
const scene = project.scenes.find(x => x.name === name)
|
|
2828
|
-
if (scene === undefined) {
|
|
2829
|
-
writeJson(res, 404, { error: `当前激活《${project.bookName}》,场景 ${name} 不存在——若期望的书不对,请先在书架切换该书并刷新` })
|
|
2830
|
-
return
|
|
3200
|
+
writeJson(res, 400, { error: (error as Error).message })
|
|
2831
3201
|
}
|
|
2832
|
-
const label = body?.label?.trim() ?? '全景'
|
|
2833
|
-
scene.gallery ??= []
|
|
2834
|
-
scene.gallery = scene.gallery.filter(g => g.label !== label)
|
|
2835
|
-
scene.gallery.push({ label, dataUrl })
|
|
2836
|
-
} else if (op === 'removeImage') {
|
|
2837
|
-
const name = body?.name?.trim()
|
|
2838
|
-
const label = body?.label?.trim() ?? ''
|
|
2839
|
-
const scene = project.scenes.find(x => x.name === name)
|
|
2840
|
-
if (scene !== undefined && label !== '') {
|
|
2841
|
-
scene.gallery = (scene.gallery ?? []).filter(g => g.label !== label)
|
|
2842
|
-
}
|
|
2843
|
-
} else {
|
|
2844
|
-
writeJson(res, 400, { error: `未知操作 ${op}` })
|
|
2845
3202
|
return
|
|
2846
3203
|
}
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
/** 视觉世界观规则:从道藏提炼(生图/生视频纠偏),或手动保存。 */
|
|
2855
|
-
const visualRulesRoute: WebRoute = {
|
|
2856
|
-
kind: 'exact',
|
|
2857
|
-
path: NOVEL_API.visualRules,
|
|
2858
|
-
handler: async (req, res) => {
|
|
2859
|
-
if (!guard(req, res, 'POST')) return
|
|
2860
|
-
const config = getConfig()
|
|
2861
|
-
const project = requireProject(res)
|
|
2862
|
-
if (project === undefined) return
|
|
2863
|
-
const body = await readJsonBody<VisualRulesRequest>(req)
|
|
2864
|
-
if (body?.op === 'extract') {
|
|
2865
|
-
try {
|
|
2866
|
-
const rules = await extractVisualRules(ctx, config, project)
|
|
2867
|
-
project.visualRules = rules
|
|
2868
|
-
project.updatedAt = new Date().toISOString()
|
|
2869
|
-
saveProject(config.outputDir, project)
|
|
2870
|
-
writeJson(res, 200, { rules } satisfies VisualRulesResponse)
|
|
2871
|
-
return
|
|
2872
|
-
} catch (error) {
|
|
2873
|
-
writeJson(res, 500, { error: `视觉规则提炼失败:${(error as Error).message}` })
|
|
2874
|
-
return
|
|
2875
|
-
}
|
|
2876
|
-
} else if (body?.op === 'save' && Array.isArray(body.rules)) {
|
|
2877
|
-
project.visualRules = body.rules.filter(r => typeof r === 'string' && r.trim() !== '').map(r => r.trim().slice(0, 80)).slice(0, 12)
|
|
2878
|
-
project.updatedAt = new Date().toISOString()
|
|
2879
|
-
saveProject(config.outputDir, project)
|
|
2880
|
-
writeJson(res, 200, { rules: project.visualRules } satisfies VisualRulesResponse)
|
|
3204
|
+
if (runner.isWorking()) {
|
|
3205
|
+
writeJson(res, 409, { error: '生产单正在运行中,请先停止后再迁移目录' })
|
|
3206
|
+
return
|
|
3207
|
+
}
|
|
3208
|
+
const to = body?.to?.trim() ?? ''
|
|
3209
|
+
if (to === '') {
|
|
3210
|
+
writeJson(res, 400, { error: '新目录路径不能为空' })
|
|
2881
3211
|
return
|
|
2882
3212
|
}
|
|
2883
|
-
|
|
3213
|
+
if (normalizeDir(to) === normalizeDir(from)) {
|
|
3214
|
+
writeJson(res, 400, { error: '新目录与当前目录相同' })
|
|
3215
|
+
return
|
|
3216
|
+
}
|
|
3217
|
+
try {
|
|
3218
|
+
// 1. copy 全部内容(源目录保留;失败即中止,源数据无损)。
|
|
3219
|
+
copyDirContents(from, to)
|
|
3220
|
+
} catch (error) {
|
|
3221
|
+
writeJson(res, 400, { error: `迁移中止:${(error as Error).message}` })
|
|
3222
|
+
return
|
|
3223
|
+
}
|
|
3224
|
+
// 2. 书架重指向(outputDir === from 的条目全部改到 to)。
|
|
3225
|
+
let movedBooks = 0
|
|
3226
|
+
let repointError = ''
|
|
3227
|
+
try { movedBooks = repointBookDirs(from, to) } catch (error) { repointError = (error as Error).message }
|
|
3228
|
+
// 3. settings 默认目录同步(仅当默认目录就是被搬的这个)。
|
|
3229
|
+
let defaultUpdated = false
|
|
3230
|
+
let defaultError = ''
|
|
3231
|
+
const raw = deps.rawConfig?.()
|
|
3232
|
+
if (raw !== undefined && raw.outputDir !== undefined && raw.outputDir !== '' && normalizeDir(raw.outputDir) === normalizeDir(from)) {
|
|
3233
|
+
try {
|
|
3234
|
+
await patchConfig({ outputDir: to })
|
|
3235
|
+
defaultUpdated = true
|
|
3236
|
+
} catch (error) { defaultError = (error as Error).message }
|
|
3237
|
+
}
|
|
3238
|
+
// 4. 收尾:重指向全部成功才删源目录;否则保留源目录兜底并如实上报。
|
|
3239
|
+
if (repointError === '' && defaultError === '') {
|
|
3240
|
+
removeDir(from)
|
|
3241
|
+
const contents = listDirContents(to)
|
|
3242
|
+
writeJson(res, 200, { from, to, files: contents.files.slice(0, 300), bytes: contents.bytes, movedBooks, defaultUpdated } satisfies MoveOutputDirResponse)
|
|
3243
|
+
return
|
|
3244
|
+
}
|
|
3245
|
+
writeJson(res, 500, {
|
|
3246
|
+
error: `文件已复制到 ${to},但收尾未完成${repointError !== '' ? `(书架重指向失败:${repointError})` : ''}${defaultError !== '' ? `(默认目录更新失败:${defaultError})` : ''}。源目录已保留,请检查后重试或手动处理。`,
|
|
3247
|
+
from,
|
|
3248
|
+
to,
|
|
3249
|
+
movedBooks,
|
|
3250
|
+
defaultUpdated,
|
|
3251
|
+
})
|
|
2884
3252
|
},
|
|
2885
3253
|
}
|
|
2886
3254
|
|
|
2887
|
-
|
|
3255
|
+
// ----------------------------------------------------------- book-settings
|
|
3256
|
+
/**
|
|
3257
|
+
* 本书参数:POST {} 读取当前书的 settings + 生效配置(全局+本书回退链);
|
|
3258
|
+
* POST {patch} 白名单合并保存到 novel-project.json.bookSettings。
|
|
3259
|
+
*/
|
|
3260
|
+
const bookSettingsRoute: WebRoute = {
|
|
2888
3261
|
kind: 'exact',
|
|
2889
|
-
path: NOVEL_API.
|
|
3262
|
+
path: NOVEL_API.bookSettings,
|
|
2890
3263
|
handler: async (req, res) => {
|
|
2891
3264
|
if (!guard(req, res, 'POST')) return
|
|
2892
|
-
const
|
|
2893
|
-
const
|
|
2894
|
-
if (
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
const props = await extractProps(ctx, config, project)
|
|
2899
|
-
if (props.length > 0) {
|
|
2900
|
-
project.props = props
|
|
2901
|
-
project.updatedAt = new Date().toISOString()
|
|
2902
|
-
saveProject(config.outputDir, project)
|
|
2903
|
-
}
|
|
2904
|
-
writeJson(res, 200, { props: project.props ?? [] } satisfies MangaPropsResponse)
|
|
2905
|
-
return
|
|
2906
|
-
} catch (error) {
|
|
2907
|
-
writeJson(res, 500, { error: `道具提炼失败:${(error as Error).message}` })
|
|
3265
|
+
const outputDir = getConfig().outputDir
|
|
3266
|
+
const body = await readJsonBody<BookSettingsRequest>(req)
|
|
3267
|
+
if (body?.patch !== undefined) {
|
|
3268
|
+
const project = loadProject(outputDir)
|
|
3269
|
+
if (project === undefined) {
|
|
3270
|
+
writeJson(res, 400, { error: '当前没有打开的书(未找到 novel-project.json)' })
|
|
2908
3271
|
return
|
|
2909
3272
|
}
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
3273
|
+
const cur: BookSettings = project.bookSettings ?? {}
|
|
3274
|
+
const p = body.patch
|
|
3275
|
+
const next: Record<string, unknown> = { ...cur }
|
|
3276
|
+
for (const key of ['provider', 'model', 'reasoningEffort', 'analysisReasoning', 'chapterChars', 'maxTokens', 'reviewPassScore', 'autoReview', 'autoAuthorReview', 'autoReviewAfterRevise'] as const) {
|
|
3277
|
+
const v = (p as Record<string, unknown>)[key]
|
|
3278
|
+
if (v === null) delete next[key]
|
|
3279
|
+
else if (v !== undefined) next[key] = v
|
|
3280
|
+
}
|
|
3281
|
+
project.bookSettings = next as BookSettings
|
|
2915
3282
|
project.updatedAt = new Date().toISOString()
|
|
2916
|
-
saveProject(
|
|
2917
|
-
writeJson(res, 200, { props: project.props } satisfies MangaPropsResponse)
|
|
2918
|
-
return
|
|
3283
|
+
saveProject(outputDir, project)
|
|
2919
3284
|
}
|
|
2920
|
-
|
|
3285
|
+
const project = loadProject(outputDir)
|
|
3286
|
+
writeJson(res, 200, { settings: project?.bookSettings ?? {}, effective: getConfig(), hasBook: project !== undefined } satisfies BookSettingsResponse)
|
|
2921
3287
|
},
|
|
2922
3288
|
}
|
|
2923
3289
|
|
|
2924
|
-
// ----------------------------------------------------------- production run
|
|
2925
|
-
/** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
|
|
2926
|
-
const runner = new ProductionRunner({ ctx, getConfig })
|
|
2927
|
-
|
|
2928
3290
|
const runStartRoute: WebRoute = {
|
|
2929
3291
|
kind: 'exact',
|
|
2930
3292
|
path: NOVEL_API.runStart,
|
|
2931
3293
|
handler: async (req, res) => {
|
|
2932
3294
|
if (!guard(req, res, 'POST')) return
|
|
2933
|
-
const body = await readJsonBody<RunStartRequest>(req)
|
|
3295
|
+
const body = await readJsonBody<RunStartRequest & { bookId?: string }>(req)
|
|
2934
3296
|
const config = getConfig()
|
|
2935
|
-
const
|
|
3297
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
3298
|
+
const project = loadProject(outputDir)
|
|
2936
3299
|
if (project === undefined) {
|
|
2937
3300
|
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
2938
3301
|
return
|
|
@@ -2949,7 +3312,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2949
3312
|
return
|
|
2950
3313
|
}
|
|
2951
3314
|
try {
|
|
2952
|
-
const state = await runner.start(startNo, endNo)
|
|
3315
|
+
const state = await runner.start(startNo, endNo, outputDir)
|
|
2953
3316
|
writeJson(res, 200, state satisfies RunState)
|
|
2954
3317
|
} catch (error) {
|
|
2955
3318
|
writeJson(res, 409, { error: (error as Error).message })
|
|
@@ -3279,7 +3642,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
3279
3642
|
}
|
|
3280
3643
|
|
|
3281
3644
|
// ------------------------------------------ theme custom background (upload + serve)
|
|
3282
|
-
const THEME_BG_DIR =
|
|
3645
|
+
const THEME_BG_DIR = dshHomePath('dsh-novel-forge-assets')
|
|
3283
3646
|
const MIME_BY_EXT: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }
|
|
3284
3647
|
|
|
3285
3648
|
const themeBackgroundUploadRoute: WebRoute = {
|
|
@@ -3340,8 +3703,29 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
3340
3703
|
chapterSaveRoute,
|
|
3341
3704
|
assetsRoute,
|
|
3342
3705
|
styleEngineRoute,
|
|
3706
|
+
styleFormulaRoute,
|
|
3707
|
+
styleDetectRoute,
|
|
3708
|
+
marketRadarRoute,
|
|
3709
|
+
marketRadarScanRoute,
|
|
3710
|
+
marketRadarApplyRoute,
|
|
3711
|
+
marketRadarSyncRoute,
|
|
3712
|
+
marketRadarBriefRoute,
|
|
3713
|
+
knowledgeRoute,
|
|
3714
|
+
bookAnalysisRoute,
|
|
3715
|
+
ideaInspirationRoute,
|
|
3716
|
+
marketIdeaInspirationRoute,
|
|
3717
|
+
directorRoute,
|
|
3718
|
+
directorTodosRoute,
|
|
3719
|
+
llmLiveRoute,
|
|
3343
3720
|
assistantRoute,
|
|
3344
3721
|
assistantHistoryRoute,
|
|
3722
|
+
llmPromptRoute,
|
|
3723
|
+
usageResetRoute,
|
|
3724
|
+
timelineRoute,
|
|
3725
|
+
revisionPlanRoute,
|
|
3726
|
+
snapshotRoute,
|
|
3727
|
+
tensionRoute,
|
|
3728
|
+
promptSlotsRoute,
|
|
3345
3729
|
assistantClearRoute,
|
|
3346
3730
|
bookshelfRoute,
|
|
3347
3731
|
bookshelfActivateRoute,
|
|
@@ -3360,31 +3744,23 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
3360
3744
|
renameRoute,
|
|
3361
3745
|
plotlinesRoute,
|
|
3362
3746
|
rolesRoute,
|
|
3363
|
-
mangaRolesRoute,
|
|
3364
|
-
exportPackageRoute,
|
|
3365
|
-
scenesRoute,
|
|
3366
|
-
mangaPropsRoute,
|
|
3367
|
-
visualRulesRoute,
|
|
3368
3747
|
sensitiveRoute,
|
|
3369
3748
|
reviewBackfillRoute,
|
|
3370
3749
|
chapterResetRoute,
|
|
3371
3750
|
chapterApproveRoute,
|
|
3372
3751
|
configRoute,
|
|
3752
|
+
moveOutputDirRoute,
|
|
3753
|
+
bookSettingsRoute,
|
|
3373
3754
|
openFolderRoute,
|
|
3374
3755
|
pluginUpdateRoute,
|
|
3375
3756
|
outlineSuggestRoute,
|
|
3376
3757
|
outlineReverseRoute,
|
|
3377
|
-
manhuaPlansRoute,
|
|
3378
|
-
styleImageRoute,
|
|
3379
|
-
storyboardSkeletonRoute,
|
|
3380
3758
|
llmTestRoute,
|
|
3381
3759
|
addModelRoute,
|
|
3382
3760
|
llmVendorsRoute,
|
|
3383
3761
|
llmModelsRoute,
|
|
3384
3762
|
llmProvidersRoute,
|
|
3385
3763
|
llmRemoveRoute,
|
|
3386
|
-
storyboardTableRoute,
|
|
3387
|
-
storyboardPromptsRoute,
|
|
3388
3764
|
breakdownRoute,
|
|
3389
3765
|
runStartRoute,
|
|
3390
3766
|
runControlRoute,
|