@waterwx/dsh-novel-forge 1.3.2 → 1.3.3-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +288 -279
- package/lib/client.js +56288 -12061
- package/lib/index.js +14655 -5573
- package/lib/types/action-guard.d.ts +61 -0
- package/lib/types/ai-scan.d.ts +30 -0
- package/lib/types/assets.d.ts +22 -9
- package/lib/types/assistant.d.ts +29 -0
- package/lib/types/author-assets.d.ts +23 -0
- package/lib/types/bookshelf.d.ts +18 -3
- package/lib/types/client/api.d.ts +187 -10
- package/lib/types/client/chip-prefs.d.ts +36 -0
- package/lib/types/client/docx.d.ts +2 -2
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/llmLive.d.ts +31 -0
- package/lib/types/client/locales.d.ts +29 -67
- package/lib/types/client/mount.d.ts +0 -2
- package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
- 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/AuthorAssetsView.d.ts +4 -0
- package/lib/types/client/panel/AuthorHome.d.ts +34 -0
- 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/GlobalAssetLibraryView.d.ts +4 -0
- package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
- package/lib/types/client/panel/ImportModal.d.ts +7 -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 +14 -0
- 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/ProgressConsole.d.ts +17 -0
- package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
- package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
- package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
- package/lib/types/client/panel/SettingsView.d.ts +19 -0
- package/lib/types/client/panel/ShelfView.d.ts +12 -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/client/text.d.ts +9 -0
- package/lib/types/docx-core.d.ts +16 -0
- package/lib/types/docx.d.ts +2 -3
- package/lib/types/engine.d.ts +186 -45
- package/lib/types/global-assets.d.ts +6 -0
- package/lib/types/home.d.ts +11 -0
- package/lib/types/index.d.ts +36 -10
- 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/novel-context.d.ts +80 -0
- package/lib/types/output-budget.d.ts +86 -0
- package/lib/types/prompt-slots.d.ts +57 -0
- package/lib/types/prompt-utils.d.ts +35 -0
- package/lib/types/prompting.d.ts +10 -0
- package/lib/types/protocol.d.ts +1378 -166
- package/lib/types/review-policy.d.ts +97 -0
- 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 +54 -28
- package/src/action-guard.ts +127 -0
- package/src/ai-scan.ts +155 -0
- package/src/assets.ts +1801 -520
- package/src/assistant.ts +1098 -770
- package/src/author-assets.ts +131 -0
- package/src/bookshelf.ts +193 -151
- package/src/client/api.ts +380 -23
- package/src/client/chip-prefs.ts +121 -0
- package/src/client/docx.ts +32 -69
- package/src/client/index.ts +3 -1
- package/src/client/llmLive.ts +111 -0
- package/src/client/locales.ts +29 -253
- package/src/client/mount.tsx +4 -3
- package/src/client/panel/AdaptModeView.tsx +535 -0
- 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 +206 -0
- package/src/client/panel/AuthorHome.tsx +309 -0
- package/src/client/panel/BookAnalysisView.tsx +63 -0
- package/src/client/panel/CmdPalette.tsx +102 -0
- package/src/client/panel/CreateBookView.tsx +366 -348
- package/src/client/panel/DirectorView.tsx +200 -0
- package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
- package/src/client/panel/IdeaInspirationView.tsx +76 -0
- package/src/client/panel/ImportModal.tsx +303 -0
- 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 -0
- package/src/client/panel/NovelPanel.tsx +3013 -2500
- package/src/client/panel/PageHeader.tsx +37 -0
- package/src/client/panel/ProgressConsole.tsx +63 -0
- package/src/client/panel/PromptSlotsView.tsx +97 -0
- package/src/client/panel/ReaderView.tsx +306 -306
- package/src/client/panel/ReasoningSection.tsx +31 -0
- package/src/client/panel/RunPanel.tsx +64 -32
- package/src/client/panel/SettingsView.tsx +355 -0
- package/src/client/panel/ShelfView.tsx +266 -236
- 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 +4481 -1655
- 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 +4 -4
- package/src/client/text.ts +28 -0
- package/src/docx-core.ts +53 -0
- package/src/docx.ts +44 -83
- package/src/engine.ts +4759 -2943
- package/src/global-assets.ts +58 -0
- package/src/home.ts +33 -0
- package/src/index.ts +206 -65
- 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 -0
- package/src/output-budget.ts +147 -0
- package/src/prompt-slots.ts +125 -0
- package/src/prompting.ts +26 -0
- package/src/protocol.ts +1405 -158
- package/src/revision.ts +283 -0
- package/src/routes.ts +1959 -436
- package/src/run.ts +118 -21
- 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/lib/client.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types/comic-presets.d.ts +0 -12
- package/scripts/assistant-frames.txt +0 -8
- package/scripts/diagnose-adapter.mts +0 -81
- package/scripts/diagnose-assistant.mjs +0 -58
- package/scripts/diagnose-bible.mjs +0 -72
- package/scripts/diagnose-harness.mts +0 -75
- package/scripts/novel-forge-restart.log +0 -10
- package/scripts/novel-forge-web.stderr.log +0 -2
- package/scripts/novel-forge-web.stdout.log +0 -1
- package/scripts/outline-sample.txt +0 -449
- package/scripts/probe.mjs +0 -6
- package/scripts/restart-web.ps1 +0 -88
- package/scripts/smoke.mts +0 -72
- package/scripts/upload-github.ps1 +0 -51
- package/src/client/panel/BookshelfBar.tsx +0 -170
- package/src/comic-presets.ts +0 -67
package/src/routes.ts
CHANGED
|
@@ -9,16 +9,25 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
12
|
-
import {
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
13
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
14
|
-
import { join } from 'node:path'
|
|
14
|
+
import { join, basename, extname } from 'node:path'
|
|
15
|
+
import { randomBytes } from 'node:crypto'
|
|
15
16
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
16
17
|
import type { Context } from '@deepseek-ai/cordis'
|
|
17
18
|
import { ProductionRunner } from './run.ts'
|
|
18
19
|
import {
|
|
20
|
+
EXPORT_SCOPE_VALUES,
|
|
19
21
|
NOVEL_API,
|
|
20
22
|
type AssetsPatch,
|
|
21
23
|
type AssetsResponse,
|
|
24
|
+
type AddModelRequest,
|
|
25
|
+
type AddModelResponse,
|
|
26
|
+
type LlmModelsResponse,
|
|
27
|
+
type LlmProvidersResponse,
|
|
28
|
+
type LlmVendorsResponse,
|
|
29
|
+
type RemoveProviderRequest,
|
|
30
|
+
type RemoveProviderResponse,
|
|
22
31
|
type AssistantFrame,
|
|
23
32
|
type AssistantHistoryResponse,
|
|
24
33
|
type AssistantRequest,
|
|
@@ -35,6 +44,9 @@ import {
|
|
|
35
44
|
type BibleResponse,
|
|
36
45
|
type BookActivateRequest,
|
|
37
46
|
type BookCreateRequest,
|
|
47
|
+
type BookImportDirRequest,
|
|
48
|
+
type BookImportTextRequest,
|
|
49
|
+
type BookImportTextPreviewRequest,
|
|
38
50
|
type BookRemoveRequest,
|
|
39
51
|
type BookshelfSnapshot,
|
|
40
52
|
type ChapterResponse,
|
|
@@ -42,6 +54,13 @@ import {
|
|
|
42
54
|
type ChapterSaveResponse,
|
|
43
55
|
type ChapterTextRequest,
|
|
44
56
|
type ConfigPatch,
|
|
57
|
+
type ConfigResponse,
|
|
58
|
+
type GenerateRequest,
|
|
59
|
+
type MoveOutputDirRequest,
|
|
60
|
+
type MoveOutputDirResponse,
|
|
61
|
+
type BookSettings,
|
|
62
|
+
type BookSettingsRequest,
|
|
63
|
+
type BookSettingsResponse,
|
|
45
64
|
type DraftDecisionRequest,
|
|
46
65
|
type ExportRequest,
|
|
47
66
|
type ExportResponse,
|
|
@@ -50,6 +69,17 @@ import {
|
|
|
50
69
|
type JobFrame,
|
|
51
70
|
type LoadOutlineRequest,
|
|
52
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,
|
|
53
83
|
type NovelConfig,
|
|
54
84
|
type PlotlinesRequest,
|
|
55
85
|
type PlotlinesResponse,
|
|
@@ -65,11 +95,6 @@ import {
|
|
|
65
95
|
type RewriteRequest,
|
|
66
96
|
type RolesRequest,
|
|
67
97
|
type RolesResponse,
|
|
68
|
-
type SceneCard,
|
|
69
|
-
type ScenesRequest,
|
|
70
|
-
type ScenesResponse,
|
|
71
|
-
type VisualRulesRequest,
|
|
72
|
-
type VisualRulesResponse,
|
|
73
98
|
type OutlineSuggestRequest,
|
|
74
99
|
type OutlineSuggestResponse,
|
|
75
100
|
type BreakdownRequest,
|
|
@@ -77,17 +102,53 @@ import {
|
|
|
77
102
|
type SensitiveCheckRequest,
|
|
78
103
|
type SensitiveCheckResponse,
|
|
79
104
|
type SensitiveHit,
|
|
105
|
+
type ProjectState,
|
|
80
106
|
type StatusResponse,
|
|
81
107
|
type StoryBible,
|
|
108
|
+
type LlmTestRequest,
|
|
109
|
+
type LlmTestResponse,
|
|
82
110
|
type StyleEngineRequest,
|
|
83
111
|
type SummaryRequest,
|
|
84
112
|
type VolumesRequest,
|
|
85
113
|
type VolumesResponse,
|
|
114
|
+
type AuthorAssetsResponse,
|
|
115
|
+
type AuthorAssetUpsertRequest,
|
|
116
|
+
type AuthorAssetRemoveRequest,
|
|
117
|
+
type AdaptAnalyzeRequest,
|
|
118
|
+
type AdaptAnalyzeResponse,
|
|
119
|
+
type AdaptProposeRequest,
|
|
120
|
+
type AdaptProposeResponse,
|
|
121
|
+
type AdaptExecuteRequest,
|
|
122
|
+
type AdaptExecuteResponse,
|
|
123
|
+
type AdaptSaveRequest,
|
|
124
|
+
type AdaptSaveResponse,
|
|
125
|
+
type AdaptMaterializeRequest,
|
|
126
|
+
type AdaptMaterializeResponse,
|
|
127
|
+
type AdaptMaterializeSaveRequest,
|
|
128
|
+
type AdaptMaterializeSaveResponse,
|
|
129
|
+
type AdaptRewriteFrame,
|
|
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,
|
|
86
141
|
} from './protocol.ts'
|
|
87
142
|
import { readOutlineFromDocx } from './docx.ts'
|
|
88
143
|
import { clearAssistantHistory, loadAssistantHistory, runAssistantTurn } from './assistant.ts'
|
|
89
|
-
import { activateBook, bookshelfSnapshot, createBook, defaultOutputDirFor, loadBookshelf, removeBook, renameBook, seedBookshelfFromOutputDir } from './bookshelf.ts'
|
|
90
|
-
import {
|
|
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'
|
|
146
|
+
import { loadAuthorAssets, upsertAuthorAsset, removeAuthorAsset, importDefaultAuthorAssets } from './author-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'
|
|
91
152
|
import {
|
|
92
153
|
chapterFileName,
|
|
93
154
|
auditBook,
|
|
@@ -95,9 +156,19 @@ import {
|
|
|
95
156
|
autoLinkPlotlines,
|
|
96
157
|
backfillFacts,
|
|
97
158
|
createProject,
|
|
98
|
-
|
|
159
|
+
exportScope,
|
|
160
|
+
importBookText,
|
|
161
|
+
importBookTextFromText,
|
|
162
|
+
previewBookText,
|
|
99
163
|
extractBible,
|
|
100
164
|
extractStyleAsset,
|
|
165
|
+
extractStyleFormula,
|
|
166
|
+
runMarketRadar,
|
|
167
|
+
runMarketCreativeBrief,
|
|
168
|
+
runBookAnalysis,
|
|
169
|
+
runIdeaInspiration,
|
|
170
|
+
runMarketIdeaInspiration,
|
|
171
|
+
runDirectorAdvice,
|
|
101
172
|
extractWorld,
|
|
102
173
|
generateBlurb,
|
|
103
174
|
generateChapterStream,
|
|
@@ -118,23 +189,52 @@ import {
|
|
|
118
189
|
analyzePlotlineHealth,
|
|
119
190
|
designPlotlinePlan,
|
|
120
191
|
extractRoles,
|
|
121
|
-
extractScenes,
|
|
122
|
-
generateRolePromptKit,
|
|
123
|
-
extractVisualRules,
|
|
124
|
-
extractRoleVisual,
|
|
125
192
|
suggestOutlines,
|
|
193
|
+
reverseOutlineFromChapters,
|
|
126
194
|
breakdownBook,
|
|
127
|
-
|
|
195
|
+
analyzeAdaptation,
|
|
196
|
+
proposeAdaptation,
|
|
197
|
+
applyAdaptationReplacements,
|
|
198
|
+
rewriteAdaptationBook,
|
|
199
|
+
materializeAdaptedBook,
|
|
200
|
+
saveMaterializedBook,
|
|
201
|
+
testLlmModel,
|
|
202
|
+
registerLlmModel,
|
|
203
|
+
listLlmVendors,
|
|
204
|
+
listLlmModels,
|
|
205
|
+
listLlmProviders,
|
|
206
|
+
removeLlmProvider,
|
|
128
207
|
checkSensitiveText,
|
|
129
208
|
suggestForeshadows,
|
|
130
209
|
suggestPlotlines,
|
|
131
210
|
summarizeAndExtractFacts,
|
|
211
|
+
extractTimelineForChapter,
|
|
212
|
+
reviewTimeline,
|
|
132
213
|
summarizeChapter,
|
|
133
214
|
syncProjectWithDisk,
|
|
134
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'
|
|
135
224
|
|
|
136
225
|
/** Cap on JSON request bodies (generous: cover images travel as base64). */
|
|
137
|
-
const MAX_JSON_BODY_BYTES =
|
|
226
|
+
const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024
|
|
227
|
+
|
|
228
|
+
/** 包内置风格效果图目录(assets/styles,随 npm 包分发)。 */
|
|
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
|
+
}
|
|
138
238
|
|
|
139
239
|
/** Loopback-only fence (mirrors the family plugins' pairing routes). */
|
|
140
240
|
function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
@@ -188,13 +288,29 @@ export interface NovelRoutesDeps {
|
|
|
188
288
|
ctx: Context
|
|
189
289
|
/** Resolve the live plugin config (settings-aware). */
|
|
190
290
|
getConfig: () => NovelConfig
|
|
191
|
-
/** Persist a config patch through the settings seam. */
|
|
192
|
-
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
|
|
193
297
|
}
|
|
194
298
|
|
|
195
299
|
/** Default chapter count for planning when the request omits it. */
|
|
196
300
|
const DEFAULT_PLAN_COUNT = 30
|
|
197
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
|
+
|
|
198
314
|
/**
|
|
199
315
|
* Build every /api/dsh-novel-forge route.
|
|
200
316
|
* @param deps - context, config resolver, config patcher.
|
|
@@ -203,6 +319,13 @@ const DEFAULT_PLAN_COUNT = 30
|
|
|
203
319
|
export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
204
320
|
const { ctx, getConfig, patchConfig } = deps
|
|
205
321
|
|
|
322
|
+
/**
|
|
323
|
+
* 新书默认目录的基准:settings 里配置的输出目录(未经激活书合并,激活书的
|
|
324
|
+
* outputDir 指向它自己,不能当作新书的父目录)。未配置时 `defaultOutputDirFor`
|
|
325
|
+
* 回退到 `<DSH_HOME>/novels`。
|
|
326
|
+
*/
|
|
327
|
+
const defaultBookDir = (bookName: string): string => defaultOutputDirFor(bookName, deps.rawConfig?.().outputDir)
|
|
328
|
+
|
|
206
329
|
/** 全书质检实时状态(内存态,重启后回到 idle;用于 /status 暴露进度)。 */
|
|
207
330
|
let auditState: AuditStatus = { status: 'idle', totalBatches: 0, completedBatches: 0 }
|
|
208
331
|
|
|
@@ -239,9 +362,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
239
362
|
handler: (req, res) => {
|
|
240
363
|
if (!guard(req, res, 'GET')) return
|
|
241
364
|
const config = getConfig()
|
|
365
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
366
|
+
const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
|
|
242
367
|
// 书架为空时播种 settings 默认输出目录里的已有项目。
|
|
243
|
-
seedBookshelfFromOutputDir(
|
|
244
|
-
const project = loadProject(
|
|
368
|
+
seedBookshelfFromOutputDir(outputDir)
|
|
369
|
+
const project = loadProject(outputDir)
|
|
245
370
|
if (project !== undefined) {
|
|
246
371
|
// 自动兜底:generating 超过 10 分钟(正常一章 2-5 分钟)视为卡死/中断,
|
|
247
372
|
// 复位为 pending,避免"生成中"状态卡死无法重新生成。
|
|
@@ -256,18 +381,55 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
256
381
|
}
|
|
257
382
|
}
|
|
258
383
|
}
|
|
259
|
-
syncProjectWithDisk(project,
|
|
260
|
-
saveProject(
|
|
384
|
+
syncProjectWithDisk(project, outputDir)
|
|
385
|
+
saveProject(outputDir, project)
|
|
261
386
|
}
|
|
262
|
-
const
|
|
263
|
-
|
|
387
|
+
const slim = new URL(req.url ?? '/', 'http://localhost').searchParams.get('slim') === '1'
|
|
388
|
+
// 截断显式化:任何被裁掉的字段都要在响应里声明,客户端/模型不能
|
|
389
|
+
// 把"看到的"当成"全部"。静默截断会让一致性判断建立在不完整事实上。
|
|
390
|
+
const truncations: string[] = []
|
|
391
|
+
let projectPayload: StatusResponse['project']
|
|
392
|
+
if (project === undefined) {
|
|
393
|
+
projectPayload = undefined
|
|
394
|
+
} else if (slim) {
|
|
395
|
+
truncations.push('slim=1:正文/beats/图集/道藏/大世界/资产等重字段未返回,大纲仅返回前 200 字')
|
|
396
|
+
// slim 瘦身:轮询/角标只需章节状态与分数 + 轻量元数据,
|
|
397
|
+
// 去掉正文、beats、图集(base64)、道藏/大世界/资产等重字段,避免长书轮询反复传输大体积。
|
|
398
|
+
projectPayload = {
|
|
399
|
+
bookName: project.bookName,
|
|
400
|
+
outline: project.outline.slice(0, 200),
|
|
401
|
+
chapters: project.chapters.map(c => ({
|
|
402
|
+
no: c.no,
|
|
403
|
+
volume: c.volume,
|
|
404
|
+
title: c.title,
|
|
405
|
+
status: c.status,
|
|
406
|
+
chars: c.chars,
|
|
407
|
+
error: c.error,
|
|
408
|
+
review: c.review !== undefined ? { score: c.review.score, passed: c.review.passed } : undefined,
|
|
409
|
+
})) as unknown as ProjectState['chapters'],
|
|
410
|
+
createdAt: project.createdAt,
|
|
411
|
+
updatedAt: project.updatedAt,
|
|
412
|
+
} as unknown as ProjectState
|
|
413
|
+
} else {
|
|
264
414
|
// 瘦身:facts 只回最近 80 条(客户端最多展示 60),避免长篇后
|
|
265
415
|
// status 响应体随编年录无限膨胀。
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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 }
|
|
422
|
+
}
|
|
423
|
+
const response: StatusResponse = {
|
|
424
|
+
config,
|
|
425
|
+
project: projectPayload,
|
|
269
426
|
generatedFiles: listChapterFiles(config.outputDir),
|
|
270
427
|
audit: auditState,
|
|
428
|
+
// 阶段契约:宿主算一次,面板角标、助手提示词、外部自动化共用同一结论。
|
|
429
|
+
stage: computeBookStage(project),
|
|
430
|
+
// 本次运行(进程内)的 LLM 用量:调用次数 / 输入·输出·思考 token / 耗时。
|
|
431
|
+
usage: liveUsage(),
|
|
432
|
+
...(truncations.length > 0 ? { truncations } : {}),
|
|
271
433
|
}
|
|
272
434
|
writeJson(res, 200, response)
|
|
273
435
|
},
|
|
@@ -372,9 +534,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
372
534
|
path: NOVEL_API.volumes,
|
|
373
535
|
handler: async (req, res) => {
|
|
374
536
|
if (!guard(req, res, 'POST')) return
|
|
375
|
-
const body = await readJsonBody<VolumesRequest>(req)
|
|
376
|
-
const
|
|
377
|
-
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)
|
|
378
542
|
const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
|
|
379
543
|
? body.outline
|
|
380
544
|
: project?.outline
|
|
@@ -388,7 +552,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
388
552
|
const next = project ?? createProject(outline)
|
|
389
553
|
next.volumes = volumes
|
|
390
554
|
next.updatedAt = now
|
|
391
|
-
saveProject(
|
|
555
|
+
saveProject(outputDir, next)
|
|
392
556
|
const response: VolumesResponse = { volumes }
|
|
393
557
|
writeJson(res, 200, response)
|
|
394
558
|
} catch (error) {
|
|
@@ -403,9 +567,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
403
567
|
path: NOVEL_API.plan,
|
|
404
568
|
handler: async (req, res) => {
|
|
405
569
|
if (!guard(req, res, 'POST')) return
|
|
406
|
-
const body = await readJsonBody<PlanRequest>(req)
|
|
407
|
-
const
|
|
408
|
-
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)
|
|
409
575
|
const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
|
|
410
576
|
? body.outline
|
|
411
577
|
: project?.outline
|
|
@@ -441,10 +607,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
441
607
|
path: NOVEL_API.generate,
|
|
442
608
|
handler: async (req, res) => {
|
|
443
609
|
if (!guard(req, res, 'POST')) return
|
|
444
|
-
const
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
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
|
+
}
|
|
448
619
|
const rawNo = body?.chapterNo
|
|
449
620
|
if (!Number.isInteger(rawNo) || rawNo === undefined || rawNo < 1) {
|
|
450
621
|
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
@@ -479,21 +650,38 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
479
650
|
res.write(JSON.stringify(frame) + '\n')
|
|
480
651
|
}
|
|
481
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
|
+
|
|
482
657
|
try {
|
|
483
658
|
send({ type: 'start', no, title: chapter.title })
|
|
659
|
+
let genChars = 0
|
|
484
660
|
for await (const step of generateChapterStream(ctx, config, project, config.outputDir, no)) {
|
|
485
661
|
if (step.frame === 'delta') {
|
|
486
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() })
|
|
487
665
|
} else if (step.frame === 'done') {
|
|
488
|
-
send({ type: 'done', no, file: step.file, chars: step.chars, title: chapter.title })
|
|
666
|
+
send({ type: 'done', no, file: step.file, chars: step.chars, title: chapter.title, warn: step.warn })
|
|
489
667
|
}
|
|
490
668
|
}
|
|
491
|
-
|
|
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)。
|
|
492
671
|
try {
|
|
493
672
|
await summarizeAndExtractFacts(ctx, config, project, config.outputDir, no)
|
|
494
673
|
} catch (error) {
|
|
495
674
|
console.warn('[dsh-novel-forge] summary/facts failed:', (error as Error).message)
|
|
496
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
|
+
}
|
|
497
685
|
// 伏笔落地标记:正文中命中 planned 伏笔关键词 → 自动标为 planted(暗线管理页与正文同步)。
|
|
498
686
|
try {
|
|
499
687
|
const marked = markForeshadowPlanted(project, config.outputDir, no)
|
|
@@ -557,17 +745,22 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
557
745
|
path: NOVEL_API.review,
|
|
558
746
|
handler: async (req, res) => {
|
|
559
747
|
if (!guard(req, res, 'POST')) return
|
|
560
|
-
const
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
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
|
+
}
|
|
564
757
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
565
758
|
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
566
759
|
return
|
|
567
760
|
}
|
|
568
761
|
const no = body!.chapterNo!
|
|
569
762
|
try {
|
|
570
|
-
const report = await reviewChapter(ctx, config, project,
|
|
763
|
+
const report = await reviewChapter(ctx, config, project, outputDir, no)
|
|
571
764
|
writeJson(res, 200, { report })
|
|
572
765
|
} catch (error) {
|
|
573
766
|
writeJson(res, 500, { error: (error as Error).message })
|
|
@@ -581,10 +774,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
581
774
|
path: NOVEL_API.rewrite,
|
|
582
775
|
handler: async (req, res) => {
|
|
583
776
|
if (!guard(req, res, 'POST')) return
|
|
584
|
-
const
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
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
|
+
}
|
|
588
786
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
589
787
|
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
590
788
|
return
|
|
@@ -620,10 +818,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
620
818
|
path: NOVEL_API.polish,
|
|
621
819
|
handler: async (req, res) => {
|
|
622
820
|
if (!guard(req, res, 'POST')) return
|
|
623
|
-
const
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
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
|
+
}
|
|
627
830
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
628
831
|
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
629
832
|
return
|
|
@@ -685,7 +888,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
685
888
|
}
|
|
686
889
|
writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
|
|
687
890
|
chapter.pendingDraft = undefined
|
|
688
|
-
chapter.chars = draft
|
|
891
|
+
chapter.chars = countHanzi(draft)
|
|
689
892
|
chapter.file = fileName
|
|
690
893
|
// 携带审查报告则沿用结论定状态(修订后审查通过 → approved);否则置 written 待审。
|
|
691
894
|
const carried = body?.report
|
|
@@ -760,10 +963,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
760
963
|
path: NOVEL_API.foreshadow,
|
|
761
964
|
handler: async (req, res) => {
|
|
762
965
|
if (!guard(req, res, 'POST')) return
|
|
763
|
-
const
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
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
|
+
}
|
|
767
975
|
try {
|
|
768
976
|
if (body?.suggest === true) {
|
|
769
977
|
// AI suggestion pass: create several foreshadows from the outline.
|
|
@@ -817,14 +1025,21 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
817
1025
|
path: NOVEL_API.exportBook,
|
|
818
1026
|
handler: async (req, res) => {
|
|
819
1027
|
if (!guard(req, res, 'POST')) return
|
|
820
|
-
const
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
const
|
|
824
|
-
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'
|
|
825
1040
|
try {
|
|
826
|
-
const result =
|
|
827
|
-
const response: ExportResponse = { ...result }
|
|
1041
|
+
const result = exportScope(config.outputDir, project, scope, format)
|
|
1042
|
+
const response: ExportResponse = { ...result, scope, format }
|
|
828
1043
|
writeJson(res, 200, response)
|
|
829
1044
|
} catch (error) {
|
|
830
1045
|
writeJson(res, 500, { error: (error as Error).message })
|
|
@@ -839,9 +1054,13 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
839
1054
|
handler: async (req, res) => {
|
|
840
1055
|
if (!guard(req, res, 'GET')) return
|
|
841
1056
|
const config = getConfig()
|
|
842
|
-
const project = requireProject(res)
|
|
843
|
-
if (project === undefined) return
|
|
844
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
|
+
}
|
|
845
1064
|
const rawNo = Number(url.searchParams.get('no') ?? '0')
|
|
846
1065
|
if (!Number.isInteger(rawNo) || rawNo < 1) {
|
|
847
1066
|
writeJson(res, 400, { error: 'no 须为正整数' })
|
|
@@ -852,7 +1071,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
852
1071
|
writeJson(res, 404, { error: `章节 ${rawNo} 不在计划中` })
|
|
853
1072
|
return
|
|
854
1073
|
}
|
|
855
|
-
const markdown = readChapterFile(
|
|
1074
|
+
const markdown = readChapterFile(outputDir, chapter)
|
|
856
1075
|
if (markdown === undefined) {
|
|
857
1076
|
writeJson(res, 404, { error: `章节 ${rawNo} 尚未生成` })
|
|
858
1077
|
return
|
|
@@ -918,9 +1137,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
918
1137
|
if (existsSync(targetPath)) {
|
|
919
1138
|
copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
|
|
920
1139
|
}
|
|
1140
|
+
// 覆盖前留一份历史版本:采纳草稿改坏了可以回滚。
|
|
1141
|
+
createSnapshot(config.outputDir, chapter, '采纳草稿前')
|
|
921
1142
|
writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${text}\n`, 'utf8')
|
|
922
1143
|
chapter.status = 'written'
|
|
923
|
-
chapter.chars = text
|
|
1144
|
+
chapter.chars = countHanzi(text)
|
|
924
1145
|
chapter.file = fileName
|
|
925
1146
|
chapter.pendingDraft = undefined
|
|
926
1147
|
// 保存即审稿:携带工作区审查报告则沿用(不重复审);否则自动正式审稿一次。
|
|
@@ -947,9 +1168,13 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
947
1168
|
handler: async (req, res) => {
|
|
948
1169
|
if (!guard(req, res, 'POST')) return
|
|
949
1170
|
const config = getConfig()
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
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
|
+
}
|
|
953
1178
|
const message = body?.message?.trim()
|
|
954
1179
|
if (message === undefined || message === '') {
|
|
955
1180
|
writeJson(res, 400, { error: '消息不能为空' })
|
|
@@ -963,10 +1188,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
963
1188
|
})
|
|
964
1189
|
const send = (frame: AssistantFrame): void => { res.write(JSON.stringify(frame) + '\n') }
|
|
965
1190
|
try {
|
|
966
|
-
for await (const step of runAssistantTurn(ctx, config, project,
|
|
1191
|
+
for await (const step of runAssistantTurn(ctx, config, project, outputDir, message)) {
|
|
967
1192
|
if (step.frame === 'delta') send({ type: 'delta', text: step.text })
|
|
968
1193
|
else if (step.frame === 'tool') send({ type: 'tool', name: step.name, status: step.status, detail: step.detail })
|
|
969
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 })
|
|
970
1196
|
}
|
|
971
1197
|
send({ type: 'done' })
|
|
972
1198
|
res.end()
|
|
@@ -986,7 +1212,9 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
986
1212
|
handler: (req, res) => {
|
|
987
1213
|
if (!guard(req, res, 'GET')) return
|
|
988
1214
|
const config = getConfig()
|
|
989
|
-
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) }
|
|
990
1218
|
writeJson(res, 200, response)
|
|
991
1219
|
},
|
|
992
1220
|
}
|
|
@@ -996,10 +1224,12 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
996
1224
|
const assistantClearRoute: WebRoute = {
|
|
997
1225
|
kind: 'exact',
|
|
998
1226
|
path: NOVEL_API.assistantClear,
|
|
999
|
-
handler: (req, res) => {
|
|
1227
|
+
handler: async (req, res) => {
|
|
1000
1228
|
if (!guard(req, res, 'POST')) return
|
|
1001
1229
|
const config = getConfig()
|
|
1002
|
-
|
|
1230
|
+
const body = await readJsonBody<{ bookId?: string }>(req)
|
|
1231
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1232
|
+
clearAssistantHistory(outputDir)
|
|
1003
1233
|
writeJson(res, 200, { ok: true })
|
|
1004
1234
|
},
|
|
1005
1235
|
}
|
|
@@ -1020,10 +1250,21 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1020
1250
|
return
|
|
1021
1251
|
}
|
|
1022
1252
|
const config = getConfig()
|
|
1023
|
-
|
|
1024
|
-
|
|
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())
|
|
1025
1266
|
if (req.method === 'POST') {
|
|
1026
|
-
const body =
|
|
1267
|
+
const body = postBody
|
|
1027
1268
|
if (body === undefined) {
|
|
1028
1269
|
writeJson(res, 400, { error: '无效的 JSON' })
|
|
1029
1270
|
return
|
|
@@ -1040,14 +1281,16 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1040
1281
|
projectAssets.updatedAt = new Date().toISOString()
|
|
1041
1282
|
project.assets = projectAssets
|
|
1042
1283
|
project.updatedAt = new Date().toISOString()
|
|
1043
|
-
saveProject(
|
|
1284
|
+
saveProject(outputDir, project)
|
|
1044
1285
|
}
|
|
1045
1286
|
const response: AssetsResponse = {
|
|
1046
1287
|
projectAssets,
|
|
1047
|
-
genreLibrary: BUILTIN_GENRE_LIBRARY,
|
|
1288
|
+
genreLibrary: [...BUILTIN_GENRE_LIBRARY, ...globalGenreLibrary()],
|
|
1048
1289
|
antiAiLibrary: BUILTIN_ANTI_AI_RULES,
|
|
1049
1290
|
styleTemplates: BUILTIN_STYLE_TEMPLATES,
|
|
1050
|
-
progressionLibrary: BUILTIN_PROGRESSION_MODES,
|
|
1291
|
+
progressionLibrary: [...BUILTIN_PROGRESSION_MODES, ...globalProgressionLibrary()],
|
|
1292
|
+
starterStyleProfiles: BUILTIN_STARTER_STYLE_PROFILES,
|
|
1293
|
+
plotBeatLibrary: BUILTIN_PLOT_BEATS,
|
|
1051
1294
|
}
|
|
1052
1295
|
writeJson(res, 200, response)
|
|
1053
1296
|
},
|
|
@@ -1091,177 +1334,995 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1091
1334
|
},
|
|
1092
1335
|
}
|
|
1093
1336
|
|
|
1094
|
-
//
|
|
1095
|
-
const
|
|
1337
|
+
// ----------------------------------------------------------- style-formula
|
|
1338
|
+
const styleFormulaRoute: WebRoute = {
|
|
1096
1339
|
kind: 'exact',
|
|
1097
|
-
path: NOVEL_API.
|
|
1340
|
+
path: NOVEL_API.styleFormula,
|
|
1098
1341
|
handler: async (req, res) => {
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
seedBookshelfFromOutputDir(getConfig().outputDir)
|
|
1107
|
-
const snapshot: BookshelfSnapshot = bookshelfSnapshot(loadBookshelf())
|
|
1108
|
-
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 字符),请粘贴一段能代表目标风格的文字' })
|
|
1109
1349
|
return
|
|
1110
1350
|
}
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
const
|
|
1117
|
-
const
|
|
1118
|
-
if (
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
const book = createBook(bookName, outputDir)
|
|
1126
|
-
// 开书向导:创建时带大纲 → 立即建立项目(书名以大纲首行为准)。
|
|
1127
|
-
const outline = body?.outline?.trim()
|
|
1128
|
-
if (outline !== undefined && outline.length >= 50) {
|
|
1129
|
-
const project = createProject(outline)
|
|
1130
|
-
saveProject(outputDir, project)
|
|
1131
|
-
renameBook(book.id, project.bookName)
|
|
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)
|
|
1132
1365
|
}
|
|
1133
|
-
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 字符)' })
|
|
1134
1384
|
return
|
|
1135
1385
|
}
|
|
1136
|
-
|
|
1386
|
+
const scan = scanAiFlavor(text.slice(0, 20000))
|
|
1387
|
+
writeJson(res, 200, { scan })
|
|
1137
1388
|
},
|
|
1138
1389
|
}
|
|
1139
1390
|
|
|
1140
|
-
//
|
|
1141
|
-
/**
|
|
1142
|
-
const
|
|
1391
|
+
// --------------------------------------------------------- market-radar
|
|
1392
|
+
/** 热门题材雷达:输入平台/题材/榜单文本 → 信号 + 生产底座 + 开书创意。 */
|
|
1393
|
+
const marketRadarRoute: WebRoute = {
|
|
1143
1394
|
kind: 'exact',
|
|
1144
|
-
path: NOVEL_API.
|
|
1395
|
+
path: NOVEL_API.marketRadar,
|
|
1145
1396
|
handler: async (req, res) => {
|
|
1146
1397
|
if (!guard(req, res, 'POST')) return
|
|
1147
1398
|
const config = getConfig()
|
|
1148
|
-
const
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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 })
|
|
1152
1405
|
}
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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 })
|
|
1158
1422
|
}
|
|
1159
|
-
project.bible = undefined
|
|
1160
|
-
project.volumes = undefined
|
|
1161
|
-
project.chapters = []
|
|
1162
|
-
project.foreshadows = []
|
|
1163
|
-
project.assets = emptyProjectAssets()
|
|
1164
|
-
project.facts = []
|
|
1165
|
-
project.updatedAt = new Date().toISOString()
|
|
1166
|
-
saveProject(config.outputDir, project)
|
|
1167
|
-
writeJson(res, 200, { ok: true, bookName: project.bookName })
|
|
1168
1423
|
},
|
|
1169
1424
|
}
|
|
1170
1425
|
|
|
1171
|
-
//
|
|
1172
|
-
|
|
1426
|
+
// ------------------------------------------------- market-radar apply
|
|
1427
|
+
/** 把雷达生产底座一键应用到某本书(写入项目资产/开书定盘,供后续规划与生成遵守)。 */
|
|
1428
|
+
const marketRadarApplyRoute: WebRoute = {
|
|
1173
1429
|
kind: 'exact',
|
|
1174
|
-
path:
|
|
1430
|
+
path: NOVEL_API.marketRadarApply,
|
|
1175
1431
|
handler: async (req, res) => {
|
|
1176
1432
|
if (!guard(req, res, 'POST')) return
|
|
1177
|
-
const body = await readJsonBody<
|
|
1178
|
-
|
|
1179
|
-
|
|
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' })
|
|
1180
1437
|
return
|
|
1181
1438
|
}
|
|
1182
|
-
const
|
|
1183
|
-
|
|
1184
|
-
|
|
1439
|
+
const config = getConfig()
|
|
1440
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1441
|
+
const project = loadProject(outputDir)
|
|
1442
|
+
if (project === undefined) {
|
|
1443
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
1185
1444
|
return
|
|
1186
1445
|
}
|
|
1187
|
-
|
|
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,
|
|
1481
|
+
}
|
|
1482
|
+
project.updatedAt = new Date().toISOString()
|
|
1483
|
+
saveProject(outputDir, project)
|
|
1484
|
+
writeJson(res, 200, { ok: true, bookName: project.bookName })
|
|
1188
1485
|
},
|
|
1189
1486
|
}
|
|
1190
1487
|
|
|
1191
|
-
//
|
|
1192
|
-
|
|
1488
|
+
// ------------------------------------------------- market-radar sync
|
|
1489
|
+
/** 把雷达生产底座里的「新资产」同步进全局资源库(跨书复用;已有名字跳过)。 */
|
|
1490
|
+
const marketRadarSyncRoute: WebRoute = {
|
|
1193
1491
|
kind: 'exact',
|
|
1194
|
-
path:
|
|
1492
|
+
path: NOVEL_API.marketRadarSync,
|
|
1195
1493
|
handler: async (req, res) => {
|
|
1196
1494
|
if (!guard(req, res, 'POST')) return
|
|
1197
|
-
const body = await readJsonBody<
|
|
1198
|
-
|
|
1199
|
-
|
|
1495
|
+
const body = await readJsonBody<{ foundation?: ProductionFoundation }>(req)
|
|
1496
|
+
const pf = body?.foundation
|
|
1497
|
+
if (pf === undefined) {
|
|
1498
|
+
writeJson(res, 400, { error: '缺少 foundation' })
|
|
1200
1499
|
return
|
|
1201
1500
|
}
|
|
1202
|
-
const
|
|
1203
|
-
if (
|
|
1204
|
-
|
|
1205
|
-
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: [] })
|
|
1206
1504
|
}
|
|
1207
|
-
|
|
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 })
|
|
1208
1512
|
},
|
|
1209
1513
|
}
|
|
1210
1514
|
|
|
1211
|
-
//
|
|
1212
|
-
/**
|
|
1213
|
-
const
|
|
1515
|
+
// ------------------------------------------------- market-radar brief
|
|
1516
|
+
/** 开书创意简报:用选中的市场信号 + 影响模式生成。 */
|
|
1517
|
+
const marketRadarBriefRoute: WebRoute = {
|
|
1214
1518
|
kind: 'exact',
|
|
1215
|
-
path: NOVEL_API.
|
|
1519
|
+
path: NOVEL_API.marketRadarBrief,
|
|
1216
1520
|
handler: async (req, res) => {
|
|
1217
1521
|
if (!guard(req, res, 'POST')) return
|
|
1218
1522
|
const config = getConfig()
|
|
1219
|
-
const
|
|
1220
|
-
if (
|
|
1221
|
-
|
|
1222
|
-
|
|
1523
|
+
const body = await readJsonBody<MarketRadarBriefRequest>(req)
|
|
1524
|
+
if (body === undefined || body.influenceMode === undefined) {
|
|
1525
|
+
writeJson(res, 400, { error: '缺少 influenceMode' })
|
|
1526
|
+
return
|
|
1527
|
+
}
|
|
1223
1528
|
try {
|
|
1224
|
-
const
|
|
1225
|
-
|
|
1226
|
-
auditState.completedBatches = completed
|
|
1227
|
-
})
|
|
1228
|
-
const auditedChapters = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating').length
|
|
1229
|
-
auditState = {
|
|
1230
|
-
status: 'done',
|
|
1231
|
-
startedAt,
|
|
1232
|
-
finishedAt: new Date().toISOString(),
|
|
1233
|
-
totalBatches: auditState.totalBatches,
|
|
1234
|
-
completedBatches: auditState.totalBatches,
|
|
1235
|
-
auditedChapters,
|
|
1236
|
-
issuesCount: issues.length,
|
|
1237
|
-
issues,
|
|
1238
|
-
}
|
|
1239
|
-
const response: AuditResponse = {
|
|
1240
|
-
issues,
|
|
1241
|
-
auditedChapters,
|
|
1242
|
-
auditedAt: auditState.finishedAt!,
|
|
1243
|
-
}
|
|
1244
|
-
writeJson(res, 200, response)
|
|
1529
|
+
const creativeBrief = await runMarketCreativeBrief(ctx, config, body)
|
|
1530
|
+
writeJson(res, 200, { creativeBrief })
|
|
1245
1531
|
} catch (error) {
|
|
1246
|
-
auditState = {
|
|
1247
|
-
...auditState,
|
|
1248
|
-
status: 'error',
|
|
1249
|
-
finishedAt: new Date().toISOString(),
|
|
1250
|
-
error: (error as Error).message,
|
|
1251
|
-
}
|
|
1252
1532
|
writeJson(res, 500, { error: (error as Error).message })
|
|
1253
1533
|
}
|
|
1254
1534
|
},
|
|
1255
1535
|
}
|
|
1256
1536
|
|
|
1257
|
-
//
|
|
1258
|
-
/**
|
|
1259
|
-
const
|
|
1537
|
+
// ------------------------------------------------------------- knowledge
|
|
1538
|
+
/** 书内知识库:GET 读取 / POST 增删改(按书路由)。 */
|
|
1539
|
+
const knowledgeRoute: WebRoute = {
|
|
1260
1540
|
kind: 'exact',
|
|
1261
|
-
path: NOVEL_API.
|
|
1541
|
+
path: NOVEL_API.knowledge,
|
|
1262
1542
|
handler: async (req, res) => {
|
|
1263
|
-
if (
|
|
1264
|
-
|
|
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: '输出目录中没有项目' })
|
|
1555
|
+
return
|
|
1556
|
+
}
|
|
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 })
|
|
1570
|
+
},
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// ---------------------------------------------------------- book-analysis
|
|
1574
|
+
/** 书分析/拆书:输入文本 → 卖点/结构/可借鉴点/风险。 */
|
|
1575
|
+
const bookAnalysisRoute: WebRoute = {
|
|
1576
|
+
kind: 'exact',
|
|
1577
|
+
path: NOVEL_API.bookAnalysis,
|
|
1578
|
+
handler: async (req, res) => {
|
|
1579
|
+
if (!guard(req, res, 'POST')) return
|
|
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 字符)' })
|
|
1584
|
+
return
|
|
1585
|
+
}
|
|
1586
|
+
try {
|
|
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 })
|
|
1591
|
+
}
|
|
1592
|
+
},
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// ---------------------------------------------------------- idea-inspiration
|
|
1596
|
+
/** 创意灵感:一句话/题材 → 多方向开书灵感。 */
|
|
1597
|
+
const ideaInspirationRoute: WebRoute = {
|
|
1598
|
+
kind: 'exact',
|
|
1599
|
+
path: NOVEL_API.ideaInspiration,
|
|
1600
|
+
handler: async (req, res) => {
|
|
1601
|
+
if (!guard(req, res, 'POST')) return
|
|
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
|
+
}
|
|
1608
|
+
try {
|
|
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)
|
|
2216
|
+
const { book } = importDir(outDir)
|
|
2217
|
+
writeJson(res, 200, { ...result, book })
|
|
2218
|
+
return
|
|
2219
|
+
}
|
|
2220
|
+
// 模式一:服务器本地文件
|
|
2221
|
+
const filePath = body?.filePath?.trim()
|
|
2222
|
+
if (filePath === undefined || filePath === '') {
|
|
2223
|
+
writeJson(res, 400, { error: 'filePath 不能为空(或请上传文件内容)' })
|
|
2224
|
+
return
|
|
2225
|
+
}
|
|
2226
|
+
if (!existsSync(filePath)) {
|
|
2227
|
+
writeJson(res, 400, { error: `文件不存在:${filePath}` })
|
|
2228
|
+
return
|
|
2229
|
+
}
|
|
2230
|
+
const bookName = basename(filePath, extname(filePath)).slice(0, 40) || '导入小说'
|
|
2231
|
+
const outDir = body?.outputDir?.trim() !== undefined && body.outputDir.trim() !== ''
|
|
2232
|
+
? body.outputDir.trim()
|
|
2233
|
+
: defaultBookDir(bookName)
|
|
2234
|
+
const result = importBookText(filePath, outDir)
|
|
2235
|
+
const { book } = importDir(outDir)
|
|
2236
|
+
writeJson(res, 200, { ...result, book })
|
|
2237
|
+
} catch (err) {
|
|
2238
|
+
writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
|
|
2239
|
+
}
|
|
2240
|
+
},
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
// --------------------------------------- bookshelf import-text preview
|
|
2244
|
+
/** 上传全文做拆章预览(不落盘、不登记书架)。 */
|
|
2245
|
+
const bookshelfImportTextPreviewRoute: WebRoute = {
|
|
2246
|
+
kind: 'exact',
|
|
2247
|
+
path: NOVEL_API.bookshelfImportTextPreview,
|
|
2248
|
+
handler: async (req, res) => {
|
|
2249
|
+
if (!guard(req, res, 'POST')) return
|
|
2250
|
+
const body = await readJsonBody<BookImportTextPreviewRequest>(req)
|
|
2251
|
+
if (body?.text === undefined || body.text.length === 0) {
|
|
2252
|
+
writeJson(res, 400, { error: 'text 不能为空' })
|
|
2253
|
+
return
|
|
2254
|
+
}
|
|
2255
|
+
try {
|
|
2256
|
+
const bookName = (body.fileName ?? '').replace(/\.[^.]+$/, '').trim().slice(0, 40) || '导入小说'
|
|
2257
|
+
const preview = previewBookText(body.text)
|
|
2258
|
+
writeJson(res, 200, { bookName, ...preview })
|
|
2259
|
+
} catch (err) {
|
|
2260
|
+
writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
|
|
2261
|
+
}
|
|
2262
|
+
},
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
// ---------------------------------------------------------------- audit
|
|
2266
|
+
/** 全书一致性质检。 */
|
|
2267
|
+
const auditRoute: WebRoute = {
|
|
2268
|
+
kind: 'exact',
|
|
2269
|
+
path: NOVEL_API.audit,
|
|
2270
|
+
handler: async (req, res) => {
|
|
2271
|
+
if (!guard(req, res, 'POST')) return
|
|
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
|
+
}
|
|
2281
|
+
const startedAt = new Date().toISOString()
|
|
2282
|
+
auditState = { status: 'running', startedAt, totalBatches: 0, completedBatches: 0 }
|
|
2283
|
+
try {
|
|
2284
|
+
const issues = await auditBook(ctx, config, project, config.outputDir, (completed, total) => {
|
|
2285
|
+
auditState.totalBatches = total
|
|
2286
|
+
auditState.completedBatches = completed
|
|
2287
|
+
})
|
|
2288
|
+
const auditedChapters = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating').length
|
|
2289
|
+
auditState = {
|
|
2290
|
+
status: 'done',
|
|
2291
|
+
startedAt,
|
|
2292
|
+
finishedAt: new Date().toISOString(),
|
|
2293
|
+
totalBatches: auditState.totalBatches,
|
|
2294
|
+
completedBatches: auditState.totalBatches,
|
|
2295
|
+
auditedChapters,
|
|
2296
|
+
issuesCount: issues.length,
|
|
2297
|
+
issues,
|
|
2298
|
+
}
|
|
2299
|
+
const response: AuditResponse = {
|
|
2300
|
+
issues,
|
|
2301
|
+
auditedChapters,
|
|
2302
|
+
auditedAt: auditState.finishedAt!,
|
|
2303
|
+
model: config.auditModel || config.model,
|
|
2304
|
+
}
|
|
2305
|
+
writeJson(res, 200, response)
|
|
2306
|
+
} catch (error) {
|
|
2307
|
+
auditState = {
|
|
2308
|
+
...auditState,
|
|
2309
|
+
status: 'error',
|
|
2310
|
+
finishedAt: new Date().toISOString(),
|
|
2311
|
+
error: (error as Error).message,
|
|
2312
|
+
}
|
|
2313
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
2314
|
+
}
|
|
2315
|
+
},
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
// ----------------------------------------------------- characters refresh
|
|
2319
|
+
/** 角色卡刷新(出场统计精确化 + LLM 聚合状态)。 */
|
|
2320
|
+
const charactersRefreshRoute: WebRoute = {
|
|
2321
|
+
kind: 'exact',
|
|
2322
|
+
path: NOVEL_API.charactersRefresh,
|
|
2323
|
+
handler: async (req, res) => {
|
|
2324
|
+
if (!guard(req, res, 'POST')) return
|
|
2325
|
+
const config = getConfig()
|
|
1265
2326
|
const project = requireProject(res)
|
|
1266
2327
|
if (project === undefined) return
|
|
1267
2328
|
try {
|
|
@@ -1297,7 +2358,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1297
2358
|
}
|
|
1298
2359
|
|
|
1299
2360
|
// ---------------------------------------------------------- bible patch
|
|
1300
|
-
/**
|
|
2361
|
+
/** 道藏局部修补(世界观规则/红线/风格)。 */
|
|
1301
2362
|
const biblePatchRoute: WebRoute = {
|
|
1302
2363
|
kind: 'exact',
|
|
1303
2364
|
path: NOVEL_API.biblePatch,
|
|
@@ -1337,7 +2398,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1337
2398
|
}
|
|
1338
2399
|
|
|
1339
2400
|
// ---------------------------------------------------------------- blurb
|
|
1340
|
-
/**
|
|
2401
|
+
/** 小说简介:生成/补全,或手动保存。 */
|
|
1341
2402
|
const blurbRoute: WebRoute = {
|
|
1342
2403
|
kind: 'exact',
|
|
1343
2404
|
path: NOVEL_API.blurb,
|
|
@@ -1451,7 +2512,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1451
2512
|
}
|
|
1452
2513
|
|
|
1453
2514
|
// ---------------------------------------------------------------- world
|
|
1454
|
-
/**
|
|
2515
|
+
/** 大世界:提炼或手动保存(境界体系/区域/势力)。 */
|
|
1455
2516
|
const worldRoute: WebRoute = {
|
|
1456
2517
|
kind: 'exact',
|
|
1457
2518
|
path: NOVEL_API.world,
|
|
@@ -1563,7 +2624,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1563
2624
|
writeJson(res, 200, { plotlines: project.plotlines, suggestions } satisfies PlotlinesResponse)
|
|
1564
2625
|
return
|
|
1565
2626
|
} catch (error) {
|
|
1566
|
-
writeJson(res, 500, { error:
|
|
2627
|
+
writeJson(res, 500, { error: `建议失败:${(error as Error).message}` })
|
|
1567
2628
|
return
|
|
1568
2629
|
}
|
|
1569
2630
|
} else if (op === 'refresh' && body?.id !== undefined) {
|
|
@@ -1658,7 +2719,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1658
2719
|
}
|
|
1659
2720
|
|
|
1660
2721
|
// ---------------------------------------------------------------- roles
|
|
1661
|
-
/**
|
|
2722
|
+
/** 角色库:提炼 / 采纳 / 更新 / 删除。 */
|
|
1662
2723
|
const rolesRoute: WebRoute = {
|
|
1663
2724
|
kind: 'exact',
|
|
1664
2725
|
path: NOVEL_API.roles,
|
|
@@ -1686,111 +2747,6 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1686
2747
|
else project.roles[idx] = r
|
|
1687
2748
|
} else if (op === 'remove' && body?.name !== undefined) {
|
|
1688
2749
|
project.roles = project.roles.filter(x => x.name !== body.name)
|
|
1689
|
-
} else if (op === 'image') {
|
|
1690
|
-
const name = body?.name?.trim()
|
|
1691
|
-
const dataUrl = body?.dataUrl?.trim()
|
|
1692
|
-
if (name === undefined || name === '') {
|
|
1693
|
-
writeJson(res, 400, { error: 'name(角色名)必填' })
|
|
1694
|
-
return
|
|
1695
|
-
}
|
|
1696
|
-
if (dataUrl === undefined || dataUrl === '') {
|
|
1697
|
-
writeJson(res, 400, { error: 'dataUrl(参考图)必填' })
|
|
1698
|
-
return
|
|
1699
|
-
}
|
|
1700
|
-
const role = project.roles.find(r => r.name === name)
|
|
1701
|
-
if (role === undefined) {
|
|
1702
|
-
writeJson(res, 404, { error: `当前激活《${project.bookName}》,角色 ${name} 不存在——若期望的书不对,请先在书架切换该书并刷新` })
|
|
1703
|
-
return
|
|
1704
|
-
}
|
|
1705
|
-
const label = body?.label?.trim() ?? ''
|
|
1706
|
-
if (label !== '') {
|
|
1707
|
-
// 图集上传:带用途标签。标签「立绘」时同步为角色参考图(imageUrl)。
|
|
1708
|
-
role.gallery ??= []
|
|
1709
|
-
role.gallery = role.gallery.filter(g => g.label !== label)
|
|
1710
|
-
role.gallery.push({ label, dataUrl })
|
|
1711
|
-
if (label === '立绘' || label.includes('立绘')) role.imageUrl = dataUrl
|
|
1712
|
-
} else {
|
|
1713
|
-
// 兼容旧行为:不带标签 = 角色参考图。
|
|
1714
|
-
role.imageUrl = dataUrl
|
|
1715
|
-
}
|
|
1716
|
-
project.updatedAt = new Date().toISOString()
|
|
1717
|
-
saveProject(config.outputDir, project)
|
|
1718
|
-
writeJson(res, 200, { roles: project.roles, imageUrl: role.imageUrl } satisfies RolesResponse)
|
|
1719
|
-
return
|
|
1720
|
-
} else if (op === 'removeImage') {
|
|
1721
|
-
const name = body?.name?.trim()
|
|
1722
|
-
const label = body?.label?.trim() ?? ''
|
|
1723
|
-
const role = project.roles.find(r => r.name === name)
|
|
1724
|
-
if (role !== undefined) {
|
|
1725
|
-
if (label !== '') {
|
|
1726
|
-
role.gallery = (role.gallery ?? []).filter(g => g.label !== label)
|
|
1727
|
-
if (label === '立绘' || label.includes('立绘')) role.imageUrl = undefined
|
|
1728
|
-
} else {
|
|
1729
|
-
role.imageUrl = undefined
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
project.updatedAt = new Date().toISOString()
|
|
1733
|
-
saveProject(config.outputDir, project)
|
|
1734
|
-
writeJson(res, 200, { roles: project.roles } satisfies RolesResponse)
|
|
1735
|
-
return
|
|
1736
|
-
} else if (op === 'imageGenerate') {
|
|
1737
|
-
const name = body?.name?.trim()
|
|
1738
|
-
if (name === undefined || name === '') {
|
|
1739
|
-
writeJson(res, 400, { error: 'name(角色名)必填' })
|
|
1740
|
-
return
|
|
1741
|
-
}
|
|
1742
|
-
try {
|
|
1743
|
-
const imageUrl = await generateRoleReferenceImage(ctx, config, project, config.outputDir, name, body?.style ?? '')
|
|
1744
|
-
project.updatedAt = new Date().toISOString()
|
|
1745
|
-
saveProject(config.outputDir, project)
|
|
1746
|
-
writeJson(res, 200, { roles: project.roles, imageUrl } satisfies RolesResponse)
|
|
1747
|
-
return
|
|
1748
|
-
} catch (error) {
|
|
1749
|
-
writeJson(res, 500, { error: `角色参考图生成失败:${(error as Error).message}` })
|
|
1750
|
-
return
|
|
1751
|
-
}
|
|
1752
|
-
} else if (op === 'visual') {
|
|
1753
|
-
// 动漫形象描述词提炼:扫描正文外貌描写 → LLM 提炼 → 写入角色卡。
|
|
1754
|
-
const name = body?.name?.trim()
|
|
1755
|
-
if (name === undefined || name === '') {
|
|
1756
|
-
writeJson(res, 400, { error: 'name(角色名)必填' })
|
|
1757
|
-
return
|
|
1758
|
-
}
|
|
1759
|
-
try {
|
|
1760
|
-
const visual = await extractRoleVisual(ctx, config, project, config.outputDir, name)
|
|
1761
|
-
const role = project.roles.find(r => r.name === name)
|
|
1762
|
-
if (role !== undefined) {
|
|
1763
|
-
role.imagePrompt = visual
|
|
1764
|
-
if ((visual.expressions ?? []).length > 0) role.expressions = visual.expressions
|
|
1765
|
-
if (visual.promptKit !== undefined) role.promptKit = visual.promptKit
|
|
1766
|
-
}
|
|
1767
|
-
project.updatedAt = new Date().toISOString()
|
|
1768
|
-
saveProject(config.outputDir, project)
|
|
1769
|
-
writeJson(res, 200, { roles: project.roles, visual } satisfies RolesResponse)
|
|
1770
|
-
return
|
|
1771
|
-
} catch (error) {
|
|
1772
|
-
writeJson(res, 500, { error: `形象提炼失败:${(error as Error).message}` })
|
|
1773
|
-
return
|
|
1774
|
-
}
|
|
1775
|
-
} else if (op === 'promptKit') {
|
|
1776
|
-
// 角色四类提示词精修包(立绘/四视图/表情/细节)
|
|
1777
|
-
const name = body?.name?.trim()
|
|
1778
|
-
if (name === undefined || name === '') {
|
|
1779
|
-
writeJson(res, 400, { error: 'name(角色名)必填' })
|
|
1780
|
-
return
|
|
1781
|
-
}
|
|
1782
|
-
try {
|
|
1783
|
-
const kit = await generateRolePromptKit(ctx, config, project, name)
|
|
1784
|
-
const role = project.roles.find(r => r.name === name)
|
|
1785
|
-
if (role !== undefined) role.promptKit = kit
|
|
1786
|
-
project.updatedAt = new Date().toISOString()
|
|
1787
|
-
saveProject(config.outputDir, project)
|
|
1788
|
-
writeJson(res, 200, { roles: project.roles, promptKit: kit } satisfies RolesResponse)
|
|
1789
|
-
return
|
|
1790
|
-
} catch (error) {
|
|
1791
|
-
writeJson(res, 500, { error: `提示词精修失败:${(error as Error).message}` })
|
|
1792
|
-
return
|
|
1793
|
-
}
|
|
1794
2750
|
} else {
|
|
1795
2751
|
writeJson(res, 400, { error: '未知的 roles op' })
|
|
1796
2752
|
return
|
|
@@ -1942,77 +2898,254 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1942
2898
|
console.warn(`[dsh-novel-forge] author backfill ch.${chapter.no} failed:`, (error as Error).message)
|
|
1943
2899
|
}
|
|
1944
2900
|
}
|
|
1945
|
-
project.updatedAt = new Date().toISOString()
|
|
1946
|
-
saveProject(config.outputDir, project)
|
|
1947
|
-
send({ type: 'author-backfill-done', count: done })
|
|
1948
|
-
res.end()
|
|
2901
|
+
project.updatedAt = new Date().toISOString()
|
|
2902
|
+
saveProject(config.outputDir, project)
|
|
2903
|
+
send({ type: 'author-backfill-done', count: done })
|
|
2904
|
+
res.end()
|
|
2905
|
+
},
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
// --------------------------------------------------------------- config
|
|
2909
|
+
const configRoute: WebRoute = {
|
|
2910
|
+
kind: 'exact',
|
|
2911
|
+
path: NOVEL_API.config,
|
|
2912
|
+
handler: async (req, res) => {
|
|
2913
|
+
if (!guard(req, res, 'POST')) return
|
|
2914
|
+
const body = await readJsonBody<ConfigPatch>(req)
|
|
2915
|
+
if (body === undefined) {
|
|
2916
|
+
writeJson(res, 400, { error: '无效的配置 JSON' })
|
|
2917
|
+
return
|
|
2918
|
+
}
|
|
2919
|
+
try {
|
|
2920
|
+
const result = await patchConfig(body)
|
|
2921
|
+
writeJson(res, 200, result satisfies ConfigResponse)
|
|
2922
|
+
} catch (error) {
|
|
2923
|
+
writeJson(res, 400, { error: (error as Error).message })
|
|
2924
|
+
}
|
|
2925
|
+
},
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
// ---------------------------------------------------------- open-folder
|
|
2929
|
+
const openFolderRoute: WebRoute = {
|
|
2930
|
+
kind: 'exact',
|
|
2931
|
+
path: NOVEL_API.openFolder,
|
|
2932
|
+
handler: async (req, res) => {
|
|
2933
|
+
if (!guard(req, res, 'POST')) return
|
|
2934
|
+
const config = getConfig()
|
|
2935
|
+
const dir = config.outputDir
|
|
2936
|
+
const child = spawn('explorer', [dir], { shell: false })
|
|
2937
|
+
let responded = false
|
|
2938
|
+
child.on('error', (error) => {
|
|
2939
|
+
if (responded) return
|
|
2940
|
+
responded = true
|
|
2941
|
+
writeJson(res, 500, { ok: false, error: error.message })
|
|
2942
|
+
})
|
|
2943
|
+
child.on('exit', (code) => {
|
|
2944
|
+
if (responded) return
|
|
2945
|
+
responded = true
|
|
2946
|
+
if (code === 0) writeJson(res, 200, { ok: true })
|
|
2947
|
+
else writeJson(res, 500, { ok: false, error: `explorer 退出码 ${code}` })
|
|
2948
|
+
})
|
|
2949
|
+
},
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
// ---------------------------------------------------------- plugin update
|
|
2953
|
+
/** 插件自更新:在 DSH profile 目录拉取最新 npm 版(仅下载,需重启 DSH 生效)。 */
|
|
2954
|
+
const pluginUpdateRoute: WebRoute = {
|
|
2955
|
+
kind: 'exact',
|
|
2956
|
+
path: NOVEL_API.pluginUpdate,
|
|
2957
|
+
handler: async (req, res) => {
|
|
2958
|
+
if (!guard(req, res, 'POST')) return
|
|
2959
|
+
const profileDir = dshHomePath('profiles', argvProfile() ?? 'web')
|
|
2960
|
+
const response: PluginUpdateResponse = await new Promise((resolve) => {
|
|
2961
|
+
const child = spawn('pnpm', ['add', '@waterwx/dsh-novel-forge@latest'], { cwd: profileDir, shell: true })
|
|
2962
|
+
let acc = ''
|
|
2963
|
+
child.stdout.on('data', (d: Buffer) => { acc += String(d) })
|
|
2964
|
+
child.stderr.on('data', (d: Buffer) => { acc += String(d) })
|
|
2965
|
+
child.on('error', (error) => resolve({ ok: false, message: '无法执行更新:' + error.message }))
|
|
2966
|
+
child.on('close', (code) => {
|
|
2967
|
+
if (code === 0) resolve({ ok: true, message: '已更新到最新版本,请重启 DSH 生效。' })
|
|
2968
|
+
else resolve({ ok: false, message: '更新失败(退出码 ' + code + '):' + acc.slice(-400) })
|
|
2969
|
+
})
|
|
2970
|
+
})
|
|
2971
|
+
writeJson(res, 200, response)
|
|
2972
|
+
},
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
// ------------------------------------------------------- outline suggest
|
|
2976
|
+
/** 开书想法 → AI 大纲:2-3 个可选方案(支持暂留换批:count 只补空槽 + exclude 避开已留方向)。 */
|
|
2977
|
+
const outlineSuggestRoute: WebRoute = {
|
|
2978
|
+
kind: 'exact',
|
|
2979
|
+
path: NOVEL_API.outlineSuggest,
|
|
2980
|
+
handler: async (req, res) => {
|
|
2981
|
+
if (!guard(req, res, 'POST')) return
|
|
2982
|
+
const config = getConfig()
|
|
2983
|
+
const body = await readJsonBody<OutlineSuggestRequest>(req)
|
|
2984
|
+
const idea = body?.idea?.trim() ?? ''
|
|
2985
|
+
if (idea.length < 50) {
|
|
2986
|
+
writeJson(res, 400, { error: '想法太短(<50 字),请多写一两句:主角是谁、什么世界、想要什么爽点' })
|
|
2987
|
+
return
|
|
2988
|
+
}
|
|
2989
|
+
const count = body?.count !== undefined ? Math.max(1, Math.min(3, Math.floor(body.count))) : 3
|
|
2990
|
+
const exclude = Array.isArray(body?.exclude) ? body.exclude.filter((e): e is string => typeof e === 'string' && e.trim() !== '').map(e => e.trim().slice(0, 200)) : []
|
|
2991
|
+
try {
|
|
2992
|
+
const candidates = await suggestOutlines(ctx, config, idea, count, exclude)
|
|
2993
|
+
const response: OutlineSuggestResponse = { candidates }
|
|
2994
|
+
writeJson(res, 200, response)
|
|
2995
|
+
} catch (error) {
|
|
2996
|
+
writeJson(res, 500, { error: `大纲方案生成失败:${(error as Error).message}` })
|
|
2997
|
+
}
|
|
2998
|
+
},
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
// ------------------------------------------------- outline reverse
|
|
3002
|
+
/** 反推大纲:从已写章节正文反向生成全书总纲(NDJSON 流式进度)。 */
|
|
3003
|
+
const outlineReverseRoute: WebRoute = {
|
|
3004
|
+
kind: 'exact',
|
|
3005
|
+
path: NOVEL_API.outlineReverse,
|
|
3006
|
+
handler: async (req, res) => {
|
|
3007
|
+
if (!guard(req, res, 'POST')) return
|
|
3008
|
+
const config = getConfig()
|
|
3009
|
+
const project = requireProject(res)
|
|
3010
|
+
if (project === undefined) return
|
|
3011
|
+
res.writeHead(200, {
|
|
3012
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
3013
|
+
'cache-control': 'no-cache',
|
|
3014
|
+
'x-accel-buffering': 'no',
|
|
3015
|
+
'referrer-policy': 'no-referrer',
|
|
3016
|
+
})
|
|
3017
|
+
const send = (frame: JobFrame): void => {
|
|
3018
|
+
res.write(JSON.stringify(frame) + '\n')
|
|
3019
|
+
}
|
|
3020
|
+
try {
|
|
3021
|
+
const outline = await reverseOutlineFromChapters(ctx, config, project, config.outputDir, (done, total, phase) => {
|
|
3022
|
+
send({ type: 'outline-progress', done, total, phase })
|
|
3023
|
+
})
|
|
3024
|
+
// 保存总纲(仅更新文本,不动书名/进度)。
|
|
3025
|
+
project.outline = outline
|
|
3026
|
+
project.updatedAt = new Date().toISOString()
|
|
3027
|
+
saveProject(config.outputDir, project)
|
|
3028
|
+
send({ type: 'outline-done', outline, chars: outline.length })
|
|
3029
|
+
} catch (error) {
|
|
3030
|
+
send({ type: 'error', no: 0, message: (error as Error).message })
|
|
3031
|
+
} finally {
|
|
3032
|
+
res.end()
|
|
3033
|
+
}
|
|
3034
|
+
},
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
// ------------------------------------------------------------- llm-test
|
|
3038
|
+
/** LLM 连通性测试:对选中的提供商/模型发一次最小真实调用。 */
|
|
3039
|
+
const llmTestRoute: WebRoute = {
|
|
3040
|
+
kind: 'exact',
|
|
3041
|
+
path: NOVEL_API.llmTest,
|
|
3042
|
+
handler: async (req, res) => {
|
|
3043
|
+
if (!guard(req, res, 'POST')) return
|
|
3044
|
+
const body = await readJsonBody<LlmTestRequest>(req)
|
|
3045
|
+
const provider = (body?.provider ?? '').trim()
|
|
3046
|
+
const model = (body?.model ?? '').trim()
|
|
3047
|
+
if (provider === '' || model === '') {
|
|
3048
|
+
writeJson(res, 400, { error: 'provider 与 model 必填' })
|
|
3049
|
+
return
|
|
3050
|
+
}
|
|
3051
|
+
const result = await testLlmModel(ctx, provider, model)
|
|
3052
|
+
writeJson(res, 200, result satisfies LlmTestResponse)
|
|
1949
3053
|
},
|
|
1950
3054
|
}
|
|
1951
3055
|
|
|
1952
|
-
//
|
|
1953
|
-
|
|
3056
|
+
// ------------------------------------------------------------- llm-add
|
|
3057
|
+
/** 添加模型:厂商直填 API Key,或自定义 OpenAI 兼容路由(写 DSH 凭据 + router)。 */
|
|
3058
|
+
const addModelRoute: WebRoute = {
|
|
1954
3059
|
kind: 'exact',
|
|
1955
|
-
path: NOVEL_API.
|
|
3060
|
+
path: NOVEL_API.addModel,
|
|
1956
3061
|
handler: async (req, res) => {
|
|
1957
3062
|
if (!guard(req, res, 'POST')) return
|
|
1958
|
-
const body = await readJsonBody<
|
|
3063
|
+
const body = await readJsonBody<AddModelRequest>(req)
|
|
1959
3064
|
if (body === undefined) {
|
|
1960
|
-
writeJson(res, 400, { error: '
|
|
3065
|
+
writeJson(res, 400, { error: '缺少请求体' })
|
|
1961
3066
|
return
|
|
1962
3067
|
}
|
|
1963
3068
|
try {
|
|
1964
|
-
const
|
|
1965
|
-
writeJson(res, 200,
|
|
3069
|
+
const result = await registerLlmModel(ctx, body)
|
|
3070
|
+
writeJson(res, 200, result satisfies AddModelResponse)
|
|
1966
3071
|
} catch (error) {
|
|
1967
|
-
writeJson(res,
|
|
3072
|
+
writeJson(res, 500, { error: `添加模型失败:${(error as Error).message}` })
|
|
1968
3073
|
}
|
|
1969
3074
|
},
|
|
1970
3075
|
}
|
|
1971
3076
|
|
|
1972
|
-
// ----------------------------------------------------------
|
|
1973
|
-
|
|
3077
|
+
// ---------------------------------------------------------- llm-vendors
|
|
3078
|
+
/** 运行时厂商目录(DSH pi-ai 可配置提供方 + 内置适配器)。 */
|
|
3079
|
+
const llmVendorsRoute: WebRoute = {
|
|
1974
3080
|
kind: 'exact',
|
|
1975
|
-
path: NOVEL_API.
|
|
3081
|
+
path: NOVEL_API.llmVendors,
|
|
1976
3082
|
handler: async (req, res) => {
|
|
1977
|
-
if (!guard(req, res, '
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
writeJson(res, 200, { ok: true })
|
|
1985
|
-
}
|
|
1986
|
-
})
|
|
3083
|
+
if (!guard(req, res, 'GET')) return
|
|
3084
|
+
try {
|
|
3085
|
+
const result = await listLlmVendors(ctx)
|
|
3086
|
+
writeJson(res, 200, result satisfies LlmVendorsResponse)
|
|
3087
|
+
} catch (error) {
|
|
3088
|
+
writeJson(res, 500, { error: `读取厂商目录失败:${(error as Error).message}` })
|
|
3089
|
+
}
|
|
1987
3090
|
},
|
|
1988
3091
|
}
|
|
1989
3092
|
|
|
1990
|
-
//
|
|
1991
|
-
/**
|
|
1992
|
-
const
|
|
3093
|
+
// ---------------------------------------------------------- llm-models
|
|
3094
|
+
/** 查询某个 provider 当前可用的模型(添加成功后可即时刷新下拉)。 */
|
|
3095
|
+
const llmModelsRoute: WebRoute = {
|
|
1993
3096
|
kind: 'exact',
|
|
1994
|
-
path: NOVEL_API.
|
|
3097
|
+
path: NOVEL_API.llmModels,
|
|
3098
|
+
handler: async (req, res) => {
|
|
3099
|
+
if (!guard(req, res, 'GET')) return
|
|
3100
|
+
const url = new URL(req.url ?? '', 'http://localhost')
|
|
3101
|
+
const provider = decodeURIComponent((url.searchParams.get('provider') ?? '').trim())
|
|
3102
|
+
try {
|
|
3103
|
+
const result = await listLlmModels(ctx, provider)
|
|
3104
|
+
writeJson(res, 200, result satisfies LlmModelsResponse)
|
|
3105
|
+
} catch (error) {
|
|
3106
|
+
writeJson(res, 500, { error: `读取模型列表失败:${(error as Error).message}` })
|
|
3107
|
+
}
|
|
3108
|
+
},
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
// ---------------------------------------------------------- llm-providers
|
|
3112
|
+
/** 已注册的提供方路由列表(提供方管理卡片)。 */
|
|
3113
|
+
const llmProvidersRoute: WebRoute = {
|
|
3114
|
+
kind: 'exact',
|
|
3115
|
+
path: NOVEL_API.llmProviders,
|
|
3116
|
+
handler: async (req, res) => {
|
|
3117
|
+
if (!guard(req, res, 'GET')) return
|
|
3118
|
+
try {
|
|
3119
|
+
const result = await listLlmProviders(ctx)
|
|
3120
|
+
writeJson(res, 200, result satisfies LlmProvidersResponse)
|
|
3121
|
+
} catch (error) {
|
|
3122
|
+
writeJson(res, 500, { error: `读取提供方列表失败:${(error as Error).message}` })
|
|
3123
|
+
}
|
|
3124
|
+
},
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
// ------------------------------------------------------------- llm-remove
|
|
3128
|
+
/** 移除一个提供方(unset key + 移除 llm-pi-ai 路由)。 */
|
|
3129
|
+
const llmRemoveRoute: WebRoute = {
|
|
3130
|
+
kind: 'exact',
|
|
3131
|
+
path: NOVEL_API.llmRemove,
|
|
1995
3132
|
handler: async (req, res) => {
|
|
1996
3133
|
if (!guard(req, res, 'POST')) return
|
|
1997
|
-
const
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
if (idea.length < 50) {
|
|
2001
|
-
writeJson(res, 400, { error: '想法太短(<50 字),请多写一两句:主角是谁、什么世界、想要什么爽点' })
|
|
3134
|
+
const body = await readJsonBody<RemoveProviderRequest>(req)
|
|
3135
|
+
if (body === undefined) {
|
|
3136
|
+
writeJson(res, 400, { error: '缺少请求体' })
|
|
2002
3137
|
return
|
|
2003
3138
|
}
|
|
2004
|
-
const count = body?.count !== undefined ? Math.max(1, Math.min(3, Math.floor(body.count))) : 3
|
|
2005
|
-
const exclude = Array.isArray(body?.exclude) ? body.exclude.filter((e): e is string => typeof e === 'string' && e.trim() !== '').map(e => e.trim().slice(0, 200)) : []
|
|
2006
3139
|
try {
|
|
2007
|
-
const
|
|
2008
|
-
|
|
2009
|
-
writeJson(res, 200, response)
|
|
3140
|
+
const result = await removeLlmProvider(ctx, body)
|
|
3141
|
+
writeJson(res, 200, result satisfies RemoveProviderResponse)
|
|
2010
3142
|
} catch (error) {
|
|
2011
|
-
writeJson(res, 500, { error:
|
|
3143
|
+
writeJson(res, 500, { error: `移除提供方失败:${(error as Error).message}` })
|
|
2012
3144
|
}
|
|
2013
3145
|
},
|
|
2014
3146
|
}
|
|
2015
3147
|
|
|
3148
|
+
|
|
2016
3149
|
// ----------------------------------------------------------- breakdown
|
|
2017
3150
|
/** 拆书分析:对已写章节做结构/人物/文风/卖点体检(两阶段:源笔记→分节分析)。 */
|
|
2018
3151
|
const breakdownRoute: WebRoute = {
|
|
@@ -2041,114 +3174,128 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2041
3174
|
},
|
|
2042
3175
|
}
|
|
2043
3176
|
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
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 = {
|
|
2047
3188
|
kind: 'exact',
|
|
2048
|
-
path: NOVEL_API.
|
|
3189
|
+
path: NOVEL_API.moveOutputDir,
|
|
2049
3190
|
handler: async (req, res) => {
|
|
2050
3191
|
if (!guard(req, res, 'POST')) return
|
|
2051
|
-
const
|
|
2052
|
-
const
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
if (project.scenes === undefined) project.scenes = []
|
|
2056
|
-
const op = body?.op
|
|
2057
|
-
if (op === 'extract') {
|
|
3192
|
+
const from = getConfig().outputDir
|
|
3193
|
+
const body = await readJsonBody<MoveOutputDirRequest>(req)
|
|
3194
|
+
// 预览:只列内容,不搬移。
|
|
3195
|
+
if (body?.dryRun === true) {
|
|
2058
3196
|
try {
|
|
2059
|
-
const
|
|
2060
|
-
writeJson(res, 200, {
|
|
2061
|
-
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)
|
|
2062
3199
|
} catch (error) {
|
|
2063
|
-
writeJson(res,
|
|
2064
|
-
return
|
|
2065
|
-
}
|
|
2066
|
-
} else if ((op === 'adopt' || op === 'update') && body?.scene !== undefined) {
|
|
2067
|
-
const s = body.scene
|
|
2068
|
-
const idx = project.scenes.findIndex(x => x.name === s.name)
|
|
2069
|
-
if (idx === -1) project.scenes.push(s)
|
|
2070
|
-
else project.scenes[idx] = s
|
|
2071
|
-
} else if (op === 'remove' && body?.name !== undefined) {
|
|
2072
|
-
project.scenes = project.scenes.filter(x => x.name !== body.name)
|
|
2073
|
-
} else if (op === 'image') {
|
|
2074
|
-
const name = body?.name?.trim()
|
|
2075
|
-
const dataUrl = body?.dataUrl?.trim()
|
|
2076
|
-
if (name === undefined || name === '' || dataUrl === undefined || dataUrl === '') {
|
|
2077
|
-
writeJson(res, 400, { error: 'name(场景名)与 dataUrl 必填' })
|
|
2078
|
-
return
|
|
2079
|
-
}
|
|
2080
|
-
const scene = project.scenes.find(x => x.name === name)
|
|
2081
|
-
if (scene === undefined) {
|
|
2082
|
-
writeJson(res, 404, { error: `当前激活《${project.bookName}》,场景 ${name} 不存在——若期望的书不对,请先在书架切换该书并刷新` })
|
|
2083
|
-
return
|
|
3200
|
+
writeJson(res, 400, { error: (error as Error).message })
|
|
2084
3201
|
}
|
|
2085
|
-
const label = body?.label?.trim() ?? '全景'
|
|
2086
|
-
scene.gallery ??= []
|
|
2087
|
-
scene.gallery = scene.gallery.filter(g => g.label !== label)
|
|
2088
|
-
scene.gallery.push({ label, dataUrl })
|
|
2089
|
-
} else if (op === 'removeImage') {
|
|
2090
|
-
const name = body?.name?.trim()
|
|
2091
|
-
const label = body?.label?.trim() ?? ''
|
|
2092
|
-
const scene = project.scenes.find(x => x.name === name)
|
|
2093
|
-
if (scene !== undefined && label !== '') {
|
|
2094
|
-
scene.gallery = (scene.gallery ?? []).filter(g => g.label !== label)
|
|
2095
|
-
}
|
|
2096
|
-
} else {
|
|
2097
|
-
writeJson(res, 400, { error: `未知操作 ${op}` })
|
|
2098
3202
|
return
|
|
2099
3203
|
}
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
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: '新目录路径不能为空' })
|
|
3211
|
+
return
|
|
3212
|
+
}
|
|
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
|
+
})
|
|
2103
3252
|
},
|
|
2104
3253
|
}
|
|
2105
3254
|
|
|
2106
|
-
// -----------------------------------------------------------
|
|
2107
|
-
/**
|
|
2108
|
-
|
|
3255
|
+
// ----------------------------------------------------------- book-settings
|
|
3256
|
+
/**
|
|
3257
|
+
* 本书参数:POST {} 读取当前书的 settings + 生效配置(全局+本书回退链);
|
|
3258
|
+
* POST {patch} 白名单合并保存到 novel-project.json.bookSettings。
|
|
3259
|
+
*/
|
|
3260
|
+
const bookSettingsRoute: WebRoute = {
|
|
2109
3261
|
kind: 'exact',
|
|
2110
|
-
path: NOVEL_API.
|
|
3262
|
+
path: NOVEL_API.bookSettings,
|
|
2111
3263
|
handler: async (req, res) => {
|
|
2112
3264
|
if (!guard(req, res, 'POST')) return
|
|
2113
|
-
const
|
|
2114
|
-
const
|
|
2115
|
-
if (
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
const rules = await extractVisualRules(ctx, config, project)
|
|
2120
|
-
project.visualRules = rules
|
|
2121
|
-
project.updatedAt = new Date().toISOString()
|
|
2122
|
-
saveProject(config.outputDir, project)
|
|
2123
|
-
writeJson(res, 200, { rules } satisfies VisualRulesResponse)
|
|
2124
|
-
return
|
|
2125
|
-
} catch (error) {
|
|
2126
|
-
writeJson(res, 500, { error: `视觉规则提炼失败:${(error as Error).message}` })
|
|
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)' })
|
|
2127
3271
|
return
|
|
2128
3272
|
}
|
|
2129
|
-
|
|
2130
|
-
|
|
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
|
|
2131
3282
|
project.updatedAt = new Date().toISOString()
|
|
2132
|
-
saveProject(
|
|
2133
|
-
writeJson(res, 200, { rules: project.visualRules } satisfies VisualRulesResponse)
|
|
2134
|
-
return
|
|
3283
|
+
saveProject(outputDir, project)
|
|
2135
3284
|
}
|
|
2136
|
-
|
|
3285
|
+
const project = loadProject(outputDir)
|
|
3286
|
+
writeJson(res, 200, { settings: project?.bookSettings ?? {}, effective: getConfig(), hasBook: project !== undefined } satisfies BookSettingsResponse)
|
|
2137
3287
|
},
|
|
2138
3288
|
}
|
|
2139
3289
|
|
|
2140
|
-
// ----------------------------------------------------------- production run
|
|
2141
|
-
/** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
|
|
2142
|
-
const runner = new ProductionRunner({ ctx, getConfig })
|
|
2143
|
-
|
|
2144
3290
|
const runStartRoute: WebRoute = {
|
|
2145
3291
|
kind: 'exact',
|
|
2146
3292
|
path: NOVEL_API.runStart,
|
|
2147
3293
|
handler: async (req, res) => {
|
|
2148
3294
|
if (!guard(req, res, 'POST')) return
|
|
2149
|
-
const body = await readJsonBody<RunStartRequest>(req)
|
|
3295
|
+
const body = await readJsonBody<RunStartRequest & { bookId?: string }>(req)
|
|
2150
3296
|
const config = getConfig()
|
|
2151
|
-
const
|
|
3297
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
3298
|
+
const project = loadProject(outputDir)
|
|
2152
3299
|
if (project === undefined) {
|
|
2153
3300
|
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
2154
3301
|
return
|
|
@@ -2165,7 +3312,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2165
3312
|
return
|
|
2166
3313
|
}
|
|
2167
3314
|
try {
|
|
2168
|
-
const state = await runner.start(startNo, endNo)
|
|
3315
|
+
const state = await runner.start(startNo, endNo, outputDir)
|
|
2169
3316
|
writeJson(res, 200, state satisfies RunState)
|
|
2170
3317
|
} catch (error) {
|
|
2171
3318
|
writeJson(res, 409, { error: (error as Error).message })
|
|
@@ -2204,6 +3351,337 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2204
3351
|
},
|
|
2205
3352
|
}
|
|
2206
3353
|
|
|
3354
|
+
// -------------------------------------------------- author assets (总数据)
|
|
3355
|
+
const authorAssetsRoute: WebRoute = {
|
|
3356
|
+
kind: 'exact',
|
|
3357
|
+
path: NOVEL_API.authorAssets,
|
|
3358
|
+
handler: (req, res) => {
|
|
3359
|
+
if (!guard(req, res, 'GET')) return
|
|
3360
|
+
const response: AuthorAssetsResponse = { assets: loadAuthorAssets() }
|
|
3361
|
+
writeJson(res, 200, response)
|
|
3362
|
+
},
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
const authorAssetsUpsertRoute: WebRoute = {
|
|
3366
|
+
kind: 'exact',
|
|
3367
|
+
path: NOVEL_API.authorAssetsUpsert,
|
|
3368
|
+
handler: async (req, res) => {
|
|
3369
|
+
if (!guard(req, res, 'POST')) return
|
|
3370
|
+
const body = await readJsonBody<AuthorAssetUpsertRequest>(req)
|
|
3371
|
+
const asset = body?.asset
|
|
3372
|
+
if (asset === undefined || asset.name.trim() === '' || asset.content.trim() === '') {
|
|
3373
|
+
writeJson(res, 400, { error: 'asset 须含 name / content' })
|
|
3374
|
+
return
|
|
3375
|
+
}
|
|
3376
|
+
const response: AuthorAssetsResponse = { assets: upsertAuthorAsset(asset) }
|
|
3377
|
+
writeJson(res, 200, response)
|
|
3378
|
+
},
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
const authorAssetsRemoveRoute: WebRoute = {
|
|
3382
|
+
kind: 'exact',
|
|
3383
|
+
path: NOVEL_API.authorAssetsRemove,
|
|
3384
|
+
handler: async (req, res) => {
|
|
3385
|
+
if (!guard(req, res, 'POST')) return
|
|
3386
|
+
const body = await readJsonBody<AuthorAssetRemoveRequest>(req)
|
|
3387
|
+
const id = body?.id ?? ''
|
|
3388
|
+
if (id === '') { writeJson(res, 400, { error: 'id 必填' }); return }
|
|
3389
|
+
const response: AuthorAssetsResponse = { assets: removeAuthorAsset(id) }
|
|
3390
|
+
writeJson(res, 200, response)
|
|
3391
|
+
},
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
const authorAssetsImportDefaultRoute: WebRoute = {
|
|
3395
|
+
kind: 'exact',
|
|
3396
|
+
path: NOVEL_API.authorAssetsImportDefault,
|
|
3397
|
+
handler: (req, res) => {
|
|
3398
|
+
if (!guard(req, res, 'POST')) return
|
|
3399
|
+
const response: AuthorAssetsResponse = { assets: importDefaultAuthorAssets() }
|
|
3400
|
+
writeJson(res, 200, response)
|
|
3401
|
+
},
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
// --------------------------------------------------- adaptation (改编 P0 分析)
|
|
3405
|
+
const adaptAnalyzeRoute: WebRoute = {
|
|
3406
|
+
kind: 'exact',
|
|
3407
|
+
path: NOVEL_API.adaptAnalyze,
|
|
3408
|
+
handler: async (req, res) => {
|
|
3409
|
+
if (!guard(req, res, 'POST')) return
|
|
3410
|
+
if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
|
|
3411
|
+
const body = await readJsonBody<AdaptAnalyzeRequest>(req)
|
|
3412
|
+
const config = getConfig()
|
|
3413
|
+
let text = body?.text?.trim() ?? ''
|
|
3414
|
+
if (text === '' && body?.filePath !== undefined && body.filePath !== '') {
|
|
3415
|
+
try {
|
|
3416
|
+
text = readFileSync(body.filePath, 'utf8')
|
|
3417
|
+
} catch (err) {
|
|
3418
|
+
writeJson(res, 400, { error: '读取文件失败:' + (err as Error).message })
|
|
3419
|
+
return
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
if (text.length < 200) {
|
|
3423
|
+
writeJson(res, 400, { error: '全文内容过短(<200 字符),请上传完整小说文本' })
|
|
3424
|
+
return
|
|
3425
|
+
}
|
|
3426
|
+
try {
|
|
3427
|
+
const response: AdaptAnalyzeResponse = await analyzeAdaptation(ctx, config, text)
|
|
3428
|
+
writeJson(res, 200, response)
|
|
3429
|
+
} catch (err) {
|
|
3430
|
+
writeJson(res, 400, { error: (err as Error).message })
|
|
3431
|
+
}
|
|
3432
|
+
},
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
const adaptProposeRoute: WebRoute = {
|
|
3436
|
+
kind: 'exact',
|
|
3437
|
+
path: NOVEL_API.adaptPropose,
|
|
3438
|
+
handler: async (req, res) => {
|
|
3439
|
+
if (!guard(req, res, 'POST')) return
|
|
3440
|
+
if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
|
|
3441
|
+
const body = await readJsonBody<AdaptProposeRequest>(req)
|
|
3442
|
+
const config = getConfig()
|
|
3443
|
+
const text = (body?.text ?? '').trim()
|
|
3444
|
+
const selections = body?.selections ?? []
|
|
3445
|
+
if (text.length < 200 || selections.length === 0) {
|
|
3446
|
+
writeJson(res, 400, { error: '请提供全文与至少一条要改的维度' })
|
|
3447
|
+
return
|
|
3448
|
+
}
|
|
3449
|
+
try {
|
|
3450
|
+
const response: AdaptProposeResponse = await proposeAdaptation(ctx, config, text, selections, body?.dimensions)
|
|
3451
|
+
writeJson(res, 200, response)
|
|
3452
|
+
} catch (err) {
|
|
3453
|
+
writeJson(res, 400, { error: (err as Error).message })
|
|
3454
|
+
}
|
|
3455
|
+
},
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
const adaptExecuteRoute: WebRoute = {
|
|
3459
|
+
kind: 'exact',
|
|
3460
|
+
path: NOVEL_API.adaptExecute,
|
|
3461
|
+
handler: async (req, res) => {
|
|
3462
|
+
if (!guard(req, res, 'POST')) return
|
|
3463
|
+
if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
|
|
3464
|
+
const body = await readJsonBody<AdaptExecuteRequest>(req)
|
|
3465
|
+
const text = body?.text ?? ''
|
|
3466
|
+
const mappings = body?.mappings ?? []
|
|
3467
|
+
if (text.length < 200 || mappings.length === 0) {
|
|
3468
|
+
writeJson(res, 400, { error: '请提供全文与映射表' })
|
|
3469
|
+
return
|
|
3470
|
+
}
|
|
3471
|
+
if (body?.mode === 'rewrite') {
|
|
3472
|
+
try {
|
|
3473
|
+
const result = await rewriteAdaptationBook(ctx, getConfig(), text, mappings, body?.rules, { maxChapters: body?.maxChapters, startNo: body?.startNo, endNo: body?.endNo })
|
|
3474
|
+
const response: AdaptExecuteResponse = { adaptedText: result.adaptedText, mappings: mappings.length, hits: result.hits, mode: 'rewrite', rewritten: result.rewritten, skipped: result.skipped }
|
|
3475
|
+
writeJson(res, 200, response)
|
|
3476
|
+
} catch (err) {
|
|
3477
|
+
writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
|
|
3478
|
+
}
|
|
3479
|
+
return
|
|
3480
|
+
}
|
|
3481
|
+
const { adaptedText, hits } = applyAdaptationReplacements(text, mappings)
|
|
3482
|
+
const response: AdaptExecuteResponse = { adaptedText, mappings: mappings.length, hits, mode: 'replace' }
|
|
3483
|
+
writeJson(res, 200, response)
|
|
3484
|
+
},
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
/** rewrite 逐章重写:NDJSON 流式进度(支持分段 startNo/endNo)。 */
|
|
3488
|
+
const adaptRewriteStreamRoute: WebRoute = {
|
|
3489
|
+
kind: 'exact',
|
|
3490
|
+
path: NOVEL_API.adaptRewriteStream,
|
|
3491
|
+
handler: async (req, res) => {
|
|
3492
|
+
if (!guard(req, res, 'POST')) return
|
|
3493
|
+
if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
|
|
3494
|
+
const body = await readJsonBody<AdaptExecuteRequest>(req)
|
|
3495
|
+
const text = body?.text ?? ''
|
|
3496
|
+
const mappings = body?.mappings ?? []
|
|
3497
|
+
if (text.length < 200 || mappings.length === 0) { writeJson(res, 400, { error: '请提供全文与映射表' }); return }
|
|
3498
|
+
res.writeHead(200, {
|
|
3499
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
3500
|
+
'cache-control': 'no-cache',
|
|
3501
|
+
'x-accel-buffering': 'no',
|
|
3502
|
+
'referrer-policy': 'no-referrer',
|
|
3503
|
+
})
|
|
3504
|
+
const send = (frame: AdaptRewriteFrame): void => { res.write(JSON.stringify(frame) + '\n') }
|
|
3505
|
+
try {
|
|
3506
|
+
const result = await rewriteAdaptationBook(ctx, getConfig(), text, mappings, body?.rules, {
|
|
3507
|
+
maxChapters: body?.maxChapters,
|
|
3508
|
+
startNo: body?.startNo,
|
|
3509
|
+
endNo: body?.endNo,
|
|
3510
|
+
onProgress: (info) => send({ type: 'progress', completed: info.completed, total: info.total, no: info.no, title: info.title }),
|
|
3511
|
+
})
|
|
3512
|
+
const response: AdaptExecuteResponse = { adaptedText: result.adaptedText, mappings: mappings.length, hits: result.hits, mode: 'rewrite', rewritten: result.rewritten, skipped: result.skipped }
|
|
3513
|
+
send({ type: 'done', result: response })
|
|
3514
|
+
} catch (err) {
|
|
3515
|
+
send({ type: 'error', message: err instanceof Error ? err.message : String(err) })
|
|
3516
|
+
} finally {
|
|
3517
|
+
res.end()
|
|
3518
|
+
}
|
|
3519
|
+
},
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
/** 保存改编全文为新书(原书保留,登记书架)。 */
|
|
3523
|
+
const adaptSaveRoute: WebRoute = {
|
|
3524
|
+
kind: 'exact',
|
|
3525
|
+
path: NOVEL_API.adaptSave,
|
|
3526
|
+
handler: async (req, res) => {
|
|
3527
|
+
if (!guard(req, res, 'POST')) return
|
|
3528
|
+
if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
|
|
3529
|
+
const body = await readJsonBody<AdaptSaveRequest>(req)
|
|
3530
|
+
const text = (body?.text ?? '').trim()
|
|
3531
|
+
if (text.length < 200) {
|
|
3532
|
+
writeJson(res, 400, { error: '改编全文内容过短(<200 字符)' })
|
|
3533
|
+
return
|
|
3534
|
+
}
|
|
3535
|
+
const bookName = (body?.bookName ?? '').trim().slice(0, 40) || '改编新书'
|
|
3536
|
+
const outDir = (body?.outputDir ?? '').trim() !== '' && body?.outputDir !== undefined && body.outputDir.trim() !== ''
|
|
3537
|
+
? body.outputDir.trim()
|
|
3538
|
+
: defaultOutputDirFor(bookName)
|
|
3539
|
+
// 原书保留:若目录已被另一本书占用则拒绝,避免误覆盖原书/别的书。
|
|
3540
|
+
const occupied = loadBookshelf().books.find(b => b.outputDir === outDir)
|
|
3541
|
+
if (occupied !== undefined && occupied.bookName !== bookName) {
|
|
3542
|
+
writeJson(res, 409, { error: `目录已被《${occupied.bookName}》占用,请更换书名或输出目录` })
|
|
3543
|
+
return
|
|
3544
|
+
}
|
|
3545
|
+
try {
|
|
3546
|
+
const result = importBookTextFromText(text, outDir, bookName)
|
|
3547
|
+
// 有反推大纲时写进新项目,便于后续续写/编辑(importBookTextFromText 默认 outline=书名)。
|
|
3548
|
+
const outline = (body?.outline ?? '').trim()
|
|
3549
|
+
if (outline !== '') {
|
|
3550
|
+
const project = loadProject(outDir)
|
|
3551
|
+
if (project !== undefined) {
|
|
3552
|
+
project.outline = outline
|
|
3553
|
+
saveProject(outDir, project)
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
const { book } = importDir(outDir)
|
|
3557
|
+
const response: AdaptSaveResponse = { book, bookName: result.bookName, chapters: result.chapters, skipped: result.skipped, outputDir: outDir }
|
|
3558
|
+
writeJson(res, 200, response)
|
|
3559
|
+
} catch (err) {
|
|
3560
|
+
writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
|
|
3561
|
+
}
|
|
3562
|
+
},
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
/** 从源全文 + 编辑后方案提炼新书资料并保存为「待写新书」。 */
|
|
3566
|
+
const adaptMaterializeRoute: WebRoute = {
|
|
3567
|
+
kind: 'exact',
|
|
3568
|
+
path: NOVEL_API.adaptMaterialize,
|
|
3569
|
+
handler: async (req, res) => {
|
|
3570
|
+
if (!guard(req, res, 'POST')) return
|
|
3571
|
+
if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
|
|
3572
|
+
const body = await readJsonBody<AdaptMaterializeRequest>(req)
|
|
3573
|
+
const text = (body?.text ?? '').trim()
|
|
3574
|
+
if (text.length < 200) { writeJson(res, 400, { error: '源书全文内容过短(<200 字符)' }); return }
|
|
3575
|
+
const proposal = body?.proposal
|
|
3576
|
+
if (proposal === undefined || proposal.mappings.length === 0) { writeJson(res, 400, { error: '请先提供至少一条映射的改编方案' }); return }
|
|
3577
|
+
const bookName = (body?.bookName ?? '').trim().slice(0, 40) || '改编新书'
|
|
3578
|
+
const outDir = (body?.outputDir ?? '').trim() !== '' && body?.outputDir !== undefined && body.outputDir.trim() !== ''
|
|
3579
|
+
? body.outputDir.trim()
|
|
3580
|
+
: defaultOutputDirFor(bookName)
|
|
3581
|
+
// 原书保留:若目录已被另一本书占用则拒绝。
|
|
3582
|
+
const occupied = loadBookshelf().books.find(b => b.outputDir === outDir)
|
|
3583
|
+
if (occupied !== undefined && occupied.bookName !== bookName) {
|
|
3584
|
+
writeJson(res, 409, { error: `目录已被《${occupied.bookName}》占用,请更换书名或输出目录` })
|
|
3585
|
+
return
|
|
3586
|
+
}
|
|
3587
|
+
try {
|
|
3588
|
+
const result = await materializeAdaptedBook(ctx, getConfig(), {
|
|
3589
|
+
text,
|
|
3590
|
+
bookName,
|
|
3591
|
+
outputDir: outDir,
|
|
3592
|
+
outline: body?.outline,
|
|
3593
|
+
proposal,
|
|
3594
|
+
chapterCount: body?.chapterCount,
|
|
3595
|
+
})
|
|
3596
|
+
// 预览:只返回提炼资料,不落盘;保存走 /adapt/materialize-save。
|
|
3597
|
+
const response: AdaptMaterializeResponse = result
|
|
3598
|
+
writeJson(res, 200, response)
|
|
3599
|
+
} catch (err) {
|
|
3600
|
+
writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
|
|
3601
|
+
}
|
|
3602
|
+
},
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
/** 保存预览/微调后的新书资料为新书(原书保留,登记书架)。 */
|
|
3606
|
+
const adaptMaterializeSaveRoute: WebRoute = {
|
|
3607
|
+
kind: 'exact',
|
|
3608
|
+
path: NOVEL_API.adaptMaterializeSave,
|
|
3609
|
+
handler: async (req, res) => {
|
|
3610
|
+
if (!guard(req, res, 'POST')) return
|
|
3611
|
+
if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
|
|
3612
|
+
const body = await readJsonBody<AdaptMaterializeSaveRequest>(req)
|
|
3613
|
+
if (body === undefined || body === null || (body.outline ?? '').trim() === '') {
|
|
3614
|
+
writeJson(res, 400, { error: '请提供改编后总纲' })
|
|
3615
|
+
return
|
|
3616
|
+
}
|
|
3617
|
+
const bookName = (body.bookName ?? '').trim().slice(0, 40) || '改编新书'
|
|
3618
|
+
const outDir = (body.outputDir ?? '').trim() !== '' && body.outputDir !== undefined && body.outputDir.trim() !== ''
|
|
3619
|
+
? body.outputDir.trim()
|
|
3620
|
+
: defaultOutputDirFor(bookName)
|
|
3621
|
+
const occupied = loadBookshelf().books.find(b => b.outputDir === outDir)
|
|
3622
|
+
if (occupied !== undefined && occupied.bookName !== bookName) {
|
|
3623
|
+
writeJson(res, 409, { error: `目录已被《${occupied.bookName}》占用,请更换书名或输出目录` })
|
|
3624
|
+
return
|
|
3625
|
+
}
|
|
3626
|
+
try {
|
|
3627
|
+
const result = saveMaterializedBook(outDir, bookName, {
|
|
3628
|
+
outline: body.outline,
|
|
3629
|
+
bible: body.bible,
|
|
3630
|
+
roles: body.roles,
|
|
3631
|
+
world: body.world,
|
|
3632
|
+
volumes: body.volumes,
|
|
3633
|
+
chapters: body.chapters,
|
|
3634
|
+
})
|
|
3635
|
+
const { book } = importDir(outDir)
|
|
3636
|
+
const response: AdaptMaterializeSaveResponse = { ...result, book }
|
|
3637
|
+
writeJson(res, 200, response)
|
|
3638
|
+
} catch (err) {
|
|
3639
|
+
writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
|
|
3640
|
+
}
|
|
3641
|
+
},
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
// ------------------------------------------ theme custom background (upload + serve)
|
|
3645
|
+
const THEME_BG_DIR = dshHomePath('dsh-novel-forge-assets')
|
|
3646
|
+
const MIME_BY_EXT: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }
|
|
3647
|
+
|
|
3648
|
+
const themeBackgroundUploadRoute: WebRoute = {
|
|
3649
|
+
kind: 'exact',
|
|
3650
|
+
path: NOVEL_API.themeBackgroundUpload,
|
|
3651
|
+
handler: async (req, res) => {
|
|
3652
|
+
if (!guard(req, res, 'POST')) return
|
|
3653
|
+
const body = await readJsonBody<{ dataUrl?: string }>(req)
|
|
3654
|
+
const dataUrl = body?.dataUrl ?? ''
|
|
3655
|
+
const m = /^data:image\/(png|jpeg|jpg|webp|gif);base64,(.+)$/i.exec(dataUrl)
|
|
3656
|
+
if (m === null) { writeJson(res, 400, { error: '仅支持 PNG/JPG/WebP/GIF 图片' }); return }
|
|
3657
|
+
const ext = m[1]!.toLowerCase() === 'jpg' ? 'jpeg' : m[1]!.toLowerCase()
|
|
3658
|
+
const buf = Buffer.from(m[2]!, 'base64')
|
|
3659
|
+
if (buf.length === 0) { writeJson(res, 400, { error: '图片数据为空' }); return }
|
|
3660
|
+
mkdirSync(THEME_BG_DIR, { recursive: true })
|
|
3661
|
+
const name = 'theme-bg-' + Date.now().toString(36) + '-' + randomBytes(3).toString('hex') + '.' + ext
|
|
3662
|
+
writeFileSync(join(THEME_BG_DIR, name), buf)
|
|
3663
|
+
writeJson(res, 200, { url: NOVEL_API.themeBackgroundGet + '/' + name })
|
|
3664
|
+
},
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
const themeBackgroundGetRoute: WebRoute = {
|
|
3668
|
+
kind: 'prefix',
|
|
3669
|
+
path: NOVEL_API.themeBackgroundGet,
|
|
3670
|
+
handler: (req, res) => {
|
|
3671
|
+
if (!guard(req, res, 'GET')) return
|
|
3672
|
+
let pathName = ''
|
|
3673
|
+
try { pathName = new URL(req.url ?? '', 'http://localhost').pathname } catch { /* ignore */ }
|
|
3674
|
+
const name = basename(pathName)
|
|
3675
|
+
if (!name.startsWith('theme-bg-')) { writeJson(res, 404, { error: 'not found' }); return }
|
|
3676
|
+
const file = join(THEME_BG_DIR, name)
|
|
3677
|
+
if (!existsSync(file)) { writeJson(res, 404, { error: 'not found' }); return }
|
|
3678
|
+
const ext = extname(file).slice(1)
|
|
3679
|
+
const type = MIME_BY_EXT[ext] ?? 'application/octet-stream'
|
|
3680
|
+
res.writeHead(200, { 'content-type': type, 'cache-control': 'public, max-age=31536000' })
|
|
3681
|
+
res.end(readFileSync(file))
|
|
3682
|
+
},
|
|
3683
|
+
}
|
|
3684
|
+
|
|
2207
3685
|
return [
|
|
2208
3686
|
statusRoute,
|
|
2209
3687
|
loadOutlineRoute,
|
|
@@ -2225,12 +3703,36 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2225
3703
|
chapterSaveRoute,
|
|
2226
3704
|
assetsRoute,
|
|
2227
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,
|
|
2228
3720
|
assistantRoute,
|
|
2229
3721
|
assistantHistoryRoute,
|
|
3722
|
+
llmPromptRoute,
|
|
3723
|
+
usageResetRoute,
|
|
3724
|
+
timelineRoute,
|
|
3725
|
+
revisionPlanRoute,
|
|
3726
|
+
snapshotRoute,
|
|
3727
|
+
tensionRoute,
|
|
3728
|
+
promptSlotsRoute,
|
|
2230
3729
|
assistantClearRoute,
|
|
2231
3730
|
bookshelfRoute,
|
|
2232
3731
|
bookshelfActivateRoute,
|
|
2233
3732
|
bookshelfRemoveRoute,
|
|
3733
|
+
bookshelfImportDirRoute,
|
|
3734
|
+
bookshelfImportTextRoute,
|
|
3735
|
+
bookshelfImportTextPreviewRoute,
|
|
2234
3736
|
resetRoute,
|
|
2235
3737
|
auditRoute,
|
|
2236
3738
|
charactersRefreshRoute,
|
|
@@ -2242,19 +3744,40 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2242
3744
|
renameRoute,
|
|
2243
3745
|
plotlinesRoute,
|
|
2244
3746
|
rolesRoute,
|
|
2245
|
-
scenesRoute,
|
|
2246
|
-
visualRulesRoute,
|
|
2247
3747
|
sensitiveRoute,
|
|
2248
3748
|
reviewBackfillRoute,
|
|
2249
3749
|
chapterResetRoute,
|
|
2250
3750
|
chapterApproveRoute,
|
|
2251
3751
|
configRoute,
|
|
3752
|
+
moveOutputDirRoute,
|
|
3753
|
+
bookSettingsRoute,
|
|
2252
3754
|
openFolderRoute,
|
|
3755
|
+
pluginUpdateRoute,
|
|
2253
3756
|
outlineSuggestRoute,
|
|
3757
|
+
outlineReverseRoute,
|
|
3758
|
+
llmTestRoute,
|
|
3759
|
+
addModelRoute,
|
|
3760
|
+
llmVendorsRoute,
|
|
3761
|
+
llmModelsRoute,
|
|
3762
|
+
llmProvidersRoute,
|
|
3763
|
+
llmRemoveRoute,
|
|
2254
3764
|
breakdownRoute,
|
|
2255
3765
|
runStartRoute,
|
|
2256
3766
|
runControlRoute,
|
|
2257
3767
|
runStatusRoute,
|
|
3768
|
+
authorAssetsRoute,
|
|
3769
|
+
authorAssetsUpsertRoute,
|
|
3770
|
+
authorAssetsRemoveRoute,
|
|
3771
|
+
authorAssetsImportDefaultRoute,
|
|
3772
|
+
adaptAnalyzeRoute,
|
|
3773
|
+
adaptProposeRoute,
|
|
3774
|
+
adaptExecuteRoute,
|
|
3775
|
+
adaptSaveRoute,
|
|
3776
|
+
adaptMaterializeRoute,
|
|
3777
|
+
adaptMaterializeSaveRoute,
|
|
3778
|
+
adaptRewriteStreamRoute,
|
|
3779
|
+
themeBackgroundUploadRoute,
|
|
3780
|
+
themeBackgroundGetRoute,
|
|
2258
3781
|
]
|
|
2259
3782
|
}
|
|
2260
3783
|
|