@waterwx/dsh-novel-forge 1.3.1 → 1.3.2-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 +56309 -12221
- package/lib/index.js +14239 -5595
- package/lib/types/action-guard.d.ts +61 -0
- package/lib/types/ai-scan.d.ts +36 -0
- package/lib/types/assets.d.ts +24 -9
- package/lib/types/assistant.d.ts +29 -0
- package/lib/types/author-assets.d.ts +25 -0
- package/lib/types/bookshelf.d.ts +11 -1
- package/lib/types/client/api.d.ts +186 -10
- package/lib/types/client/chip-prefs.d.ts +36 -0
- package/lib/types/client/docx.d.ts +2 -0
- 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 +28 -67
- 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/toast.d.ts +2 -0
- package/lib/types/client/panel/views.d.ts +25 -7
- package/lib/types/client/run-status.d.ts +21 -0
- 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/index.d.ts +22 -8
- package/lib/types/llm-live.d.ts +75 -0
- package/lib/types/llm-retry.d.ts +38 -0
- package/lib/types/market-radar-scan.d.ts +19 -0
- package/lib/types/market-radar-sources.d.ts +31 -0
- package/lib/types/migrate-output.d.ts +25 -0
- package/lib/types/novel-context.d.ts +80 -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 +1362 -166
- package/lib/types/review-policy.d.ts +97 -0
- package/lib/types/revision.d.ts +76 -0
- package/lib/types/routes.d.ts +4 -0
- package/lib/types/run.d.ts +23 -1
- package/lib/types/snapshots.d.ts +46 -0
- package/lib/types/stage-contract.d.ts +60 -0
- package/lib/types/tension.d.ts +53 -0
- package/lib/types/timeline.d.ts +37 -0
- package/package.json +52 -28
- package/src/action-guard.ts +127 -0
- package/src/ai-scan.ts +179 -0
- package/src/assets.ts +1806 -520
- package/src/assistant.ts +1098 -770
- package/src/author-assets.ts +149 -0
- package/src/bookshelf.ts +187 -151
- package/src/client/api.ts +378 -21
- package/src/client/chip-prefs.ts +121 -0
- package/src/client/docx.ts +37 -69
- package/src/client/index.ts +3 -1
- package/src/client/llmLive.ts +111 -0
- package/src/client/locales.ts +28 -253
- package/src/client/mount.tsx +1 -1
- 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 +377 -0
- package/src/client/panel/ModelManager.tsx +205 -0
- package/src/client/panel/NovelPanel.tsx +3135 -2651
- 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 +354 -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/panel.module.css +4481 -1655
- package/src/client/panel/toast.ts +51 -0
- package/src/client/panel/views.tsx +401 -314
- package/src/client/run-status.ts +171 -0
- package/src/client/sidebar-entry.ts +1 -1
- package/src/client/text.ts +28 -0
- package/src/docx-core.ts +53 -0
- package/src/docx.ts +44 -83
- package/src/engine.ts +4672 -2943
- package/src/global-assets.ts +59 -0
- package/src/index.ts +89 -33
- package/src/llm-live.ts +260 -0
- package/src/llm-retry.ts +77 -0
- package/src/market-radar-scan.ts +41 -0
- package/src/market-radar-sources.ts +173 -0
- package/src/migrate-output.ts +76 -0
- package/src/novel-context.ts +286 -0
- package/src/prompt-slots.ts +125 -0
- package/src/prompting.ts +26 -0
- package/src/protocol.ts +1388 -158
- package/src/review-policy.ts +161 -0
- package/src/revision.ts +283 -0
- package/src/routes.ts +1934 -434
- package/src/run.ts +118 -21
- package/src/snapshots.ts +174 -0
- package/src/stage-contract.ts +189 -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,26 @@
|
|
|
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 { homedir } from 'node:os'
|
|
16
|
+
import { randomBytes } from 'node:crypto'
|
|
15
17
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
16
18
|
import type { Context } from '@deepseek-ai/cordis'
|
|
17
19
|
import { ProductionRunner } from './run.ts'
|
|
18
20
|
import {
|
|
21
|
+
EXPORT_SCOPE_VALUES,
|
|
19
22
|
NOVEL_API,
|
|
20
23
|
type AssetsPatch,
|
|
21
24
|
type AssetsResponse,
|
|
25
|
+
type AddModelRequest,
|
|
26
|
+
type AddModelResponse,
|
|
27
|
+
type LlmModelsResponse,
|
|
28
|
+
type LlmProvidersResponse,
|
|
29
|
+
type LlmVendorsResponse,
|
|
30
|
+
type RemoveProviderRequest,
|
|
31
|
+
type RemoveProviderResponse,
|
|
22
32
|
type AssistantFrame,
|
|
23
33
|
type AssistantHistoryResponse,
|
|
24
34
|
type AssistantRequest,
|
|
@@ -35,6 +45,9 @@ import {
|
|
|
35
45
|
type BibleResponse,
|
|
36
46
|
type BookActivateRequest,
|
|
37
47
|
type BookCreateRequest,
|
|
48
|
+
type BookImportDirRequest,
|
|
49
|
+
type BookImportTextRequest,
|
|
50
|
+
type BookImportTextPreviewRequest,
|
|
38
51
|
type BookRemoveRequest,
|
|
39
52
|
type BookshelfSnapshot,
|
|
40
53
|
type ChapterResponse,
|
|
@@ -42,6 +55,11 @@ import {
|
|
|
42
55
|
type ChapterSaveResponse,
|
|
43
56
|
type ChapterTextRequest,
|
|
44
57
|
type ConfigPatch,
|
|
58
|
+
type MoveOutputDirRequest,
|
|
59
|
+
type MoveOutputDirResponse,
|
|
60
|
+
type BookSettings,
|
|
61
|
+
type BookSettingsRequest,
|
|
62
|
+
type BookSettingsResponse,
|
|
45
63
|
type DraftDecisionRequest,
|
|
46
64
|
type ExportRequest,
|
|
47
65
|
type ExportResponse,
|
|
@@ -50,6 +68,17 @@ import {
|
|
|
50
68
|
type JobFrame,
|
|
51
69
|
type LoadOutlineRequest,
|
|
52
70
|
type LoadOutlineResponse,
|
|
71
|
+
type ExportScope,
|
|
72
|
+
type PromptSlotsRequest,
|
|
73
|
+
type PromptSlotsResponse,
|
|
74
|
+
type SnapshotRequest,
|
|
75
|
+
type SnapshotResponse,
|
|
76
|
+
type RevisionPlanRequest,
|
|
77
|
+
type RevisionPlanResponse,
|
|
78
|
+
type TensionRequest,
|
|
79
|
+
type TensionResponse,
|
|
80
|
+
type TimelineRequest,
|
|
81
|
+
type TimelineResponse,
|
|
53
82
|
type NovelConfig,
|
|
54
83
|
type PlotlinesRequest,
|
|
55
84
|
type PlotlinesResponse,
|
|
@@ -65,11 +94,6 @@ import {
|
|
|
65
94
|
type RewriteRequest,
|
|
66
95
|
type RolesRequest,
|
|
67
96
|
type RolesResponse,
|
|
68
|
-
type SceneCard,
|
|
69
|
-
type ScenesRequest,
|
|
70
|
-
type ScenesResponse,
|
|
71
|
-
type VisualRulesRequest,
|
|
72
|
-
type VisualRulesResponse,
|
|
73
97
|
type OutlineSuggestRequest,
|
|
74
98
|
type OutlineSuggestResponse,
|
|
75
99
|
type BreakdownRequest,
|
|
@@ -77,17 +101,53 @@ import {
|
|
|
77
101
|
type SensitiveCheckRequest,
|
|
78
102
|
type SensitiveCheckResponse,
|
|
79
103
|
type SensitiveHit,
|
|
104
|
+
type ProjectState,
|
|
80
105
|
type StatusResponse,
|
|
81
106
|
type StoryBible,
|
|
107
|
+
type LlmTestRequest,
|
|
108
|
+
type LlmTestResponse,
|
|
82
109
|
type StyleEngineRequest,
|
|
83
110
|
type SummaryRequest,
|
|
84
111
|
type VolumesRequest,
|
|
85
112
|
type VolumesResponse,
|
|
113
|
+
type AuthorAssetsResponse,
|
|
114
|
+
type AuthorAssetUpsertRequest,
|
|
115
|
+
type AuthorAssetRemoveRequest,
|
|
116
|
+
type AdaptAnalyzeRequest,
|
|
117
|
+
type AdaptAnalyzeResponse,
|
|
118
|
+
type AdaptProposeRequest,
|
|
119
|
+
type AdaptProposeResponse,
|
|
120
|
+
type AdaptExecuteRequest,
|
|
121
|
+
type AdaptExecuteResponse,
|
|
122
|
+
type AdaptSaveRequest,
|
|
123
|
+
type AdaptSaveResponse,
|
|
124
|
+
type AdaptMaterializeRequest,
|
|
125
|
+
type AdaptMaterializeResponse,
|
|
126
|
+
type AdaptMaterializeSaveRequest,
|
|
127
|
+
type AdaptMaterializeSaveResponse,
|
|
128
|
+
type AdaptRewriteFrame,
|
|
129
|
+
type PluginUpdateResponse,
|
|
130
|
+
type MarketRadarRequest,
|
|
131
|
+
type MarketRadarBriefRequest,
|
|
132
|
+
type ProductionFoundation,
|
|
133
|
+
type ProgressionMode,
|
|
134
|
+
type GenreNode,
|
|
135
|
+
type KnowledgeRequest,
|
|
136
|
+
type BookAnalysisRequest,
|
|
137
|
+
type IdeaInspirationRequest,
|
|
138
|
+
type DirectorRequest,
|
|
139
|
+
type DirectorTodo,
|
|
86
140
|
} from './protocol.ts'
|
|
87
141
|
import { readOutlineFromDocx } from './docx.ts'
|
|
88
142
|
import { clearAssistantHistory, loadAssistantHistory, runAssistantTurn } from './assistant.ts'
|
|
89
|
-
import { activateBook, bookshelfSnapshot, createBook, defaultOutputDirFor, loadBookshelf, removeBook, renameBook, seedBookshelfFromOutputDir } from './bookshelf.ts'
|
|
90
|
-
import {
|
|
143
|
+
import { activateBook, bookshelfSnapshot, createBook, defaultOutputDirFor, importDir, loadBookshelf, removeBook, renameBook, repointBookDirs, seedBookshelfFromOutputDir } from './bookshelf.ts'
|
|
144
|
+
import { copyDirContents, listDirContents, normalizeDir, removeDir } from './migrate-output.ts'
|
|
145
|
+
import { loadAuthorAssets, upsertAuthorAsset, removeAuthorAsset, importDefaultAuthorAssets } from './author-assets.ts'
|
|
146
|
+
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'
|
|
147
|
+
import { scanAiFlavor } from './ai-scan.ts'
|
|
148
|
+
import { emitLive, livePrompt, liveUsage, nextSessionId, resetLiveUsage, subscribeLiveFeed } from './llm-live.ts'
|
|
149
|
+
import { scanMarketRanking } from './market-radar-scan.ts'
|
|
150
|
+
import { addGlobalGenre, addGlobalMode, globalGenreLibrary, globalProgressionLibrary } from './global-assets.ts'
|
|
91
151
|
import {
|
|
92
152
|
chapterFileName,
|
|
93
153
|
auditBook,
|
|
@@ -95,9 +155,19 @@ import {
|
|
|
95
155
|
autoLinkPlotlines,
|
|
96
156
|
backfillFacts,
|
|
97
157
|
createProject,
|
|
98
|
-
|
|
158
|
+
exportScope,
|
|
159
|
+
importBookText,
|
|
160
|
+
importBookTextFromText,
|
|
161
|
+
previewBookText,
|
|
99
162
|
extractBible,
|
|
100
163
|
extractStyleAsset,
|
|
164
|
+
extractStyleFormula,
|
|
165
|
+
runMarketRadar,
|
|
166
|
+
runMarketCreativeBrief,
|
|
167
|
+
runBookAnalysis,
|
|
168
|
+
runIdeaInspiration,
|
|
169
|
+
runMarketIdeaInspiration,
|
|
170
|
+
runDirectorAdvice,
|
|
101
171
|
extractWorld,
|
|
102
172
|
generateBlurb,
|
|
103
173
|
generateChapterStream,
|
|
@@ -118,23 +188,51 @@ import {
|
|
|
118
188
|
analyzePlotlineHealth,
|
|
119
189
|
designPlotlinePlan,
|
|
120
190
|
extractRoles,
|
|
121
|
-
extractScenes,
|
|
122
|
-
generateRolePromptKit,
|
|
123
|
-
extractVisualRules,
|
|
124
|
-
extractRoleVisual,
|
|
125
191
|
suggestOutlines,
|
|
192
|
+
reverseOutlineFromChapters,
|
|
126
193
|
breakdownBook,
|
|
127
|
-
|
|
194
|
+
analyzeAdaptation,
|
|
195
|
+
proposeAdaptation,
|
|
196
|
+
applyAdaptationReplacements,
|
|
197
|
+
rewriteAdaptationBook,
|
|
198
|
+
materializeAdaptedBook,
|
|
199
|
+
saveMaterializedBook,
|
|
200
|
+
testLlmModel,
|
|
201
|
+
registerLlmModel,
|
|
202
|
+
listLlmVendors,
|
|
203
|
+
listLlmModels,
|
|
204
|
+
listLlmProviders,
|
|
205
|
+
removeLlmProvider,
|
|
128
206
|
checkSensitiveText,
|
|
129
207
|
suggestForeshadows,
|
|
130
208
|
suggestPlotlines,
|
|
131
209
|
summarizeAndExtractFacts,
|
|
210
|
+
extractTimelineForChapter,
|
|
211
|
+
reviewTimeline,
|
|
132
212
|
summarizeChapter,
|
|
133
213
|
syncProjectWithDisk,
|
|
134
214
|
} from './engine.ts'
|
|
215
|
+
import { countHanzi } from './engine.ts'
|
|
216
|
+
import { computeBookStage } from './stage-contract.ts'
|
|
217
|
+
import { createSnapshot, loadSnapshots, pruneSnapshots, removeSnapshot, restoreSnapshot, snapshotsBytes } from './snapshots.ts'
|
|
218
|
+
import { detectTimelineIssues } from './timeline.ts'
|
|
219
|
+
import { buildCurveData, detectTensionIssues, TENSION_MAX, TENSION_MIN, TENSION_PRESETS, clampTension } from './tension.ts'
|
|
220
|
+
import { PROMPT_SLOTS, normalizeSlotValue, readPromptSlots } from './prompt-slots.ts'
|
|
221
|
+
import { buildRevisionPlan, describeRevisionPlan, REVISION_ITEM_CAP } from './revision.ts'
|
|
135
222
|
|
|
136
223
|
/** Cap on JSON request bodies (generous: cover images travel as base64). */
|
|
137
|
-
const MAX_JSON_BODY_BYTES =
|
|
224
|
+
const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024
|
|
225
|
+
|
|
226
|
+
/** 包内置风格效果图目录(assets/styles,随 npm 包分发)。 */
|
|
227
|
+
|
|
228
|
+
/** 解析请求级目标书目录:优先按请求携带的 bookId 查书架,否则回退全局 active 书目录。 */
|
|
229
|
+
function resolveOutputDir(config: NovelConfig, bookId?: string): string {
|
|
230
|
+
if (bookId !== undefined && bookId !== '') {
|
|
231
|
+
const book = loadBookshelf().books.find(b => b.id === bookId)
|
|
232
|
+
if (book !== undefined && book.outputDir !== '') return book.outputDir
|
|
233
|
+
}
|
|
234
|
+
return config.outputDir
|
|
235
|
+
}
|
|
138
236
|
|
|
139
237
|
/** Loopback-only fence (mirrors the family plugins' pairing routes). */
|
|
140
238
|
function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
@@ -190,6 +288,8 @@ export interface NovelRoutesDeps {
|
|
|
190
288
|
getConfig: () => NovelConfig
|
|
191
289
|
/** Persist a config patch through the settings seam. */
|
|
192
290
|
patchConfig: (patch: ConfigPatch) => Promise<NovelConfig>
|
|
291
|
+
/** Raw settings config(未与激活书合并)——目录迁移判断默认目录是否需同步用。 */
|
|
292
|
+
rawConfig?: () => { outputDir?: string }
|
|
193
293
|
}
|
|
194
294
|
|
|
195
295
|
/** Default chapter count for planning when the request omits it. */
|
|
@@ -239,9 +339,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
239
339
|
handler: (req, res) => {
|
|
240
340
|
if (!guard(req, res, 'GET')) return
|
|
241
341
|
const config = getConfig()
|
|
342
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
343
|
+
const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
|
|
242
344
|
// 书架为空时播种 settings 默认输出目录里的已有项目。
|
|
243
|
-
seedBookshelfFromOutputDir(
|
|
244
|
-
const project = loadProject(
|
|
345
|
+
seedBookshelfFromOutputDir(outputDir)
|
|
346
|
+
const project = loadProject(outputDir)
|
|
245
347
|
if (project !== undefined) {
|
|
246
348
|
// 自动兜底:generating 超过 10 分钟(正常一章 2-5 分钟)视为卡死/中断,
|
|
247
349
|
// 复位为 pending,避免"生成中"状态卡死无法重新生成。
|
|
@@ -256,18 +358,55 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
256
358
|
}
|
|
257
359
|
}
|
|
258
360
|
}
|
|
259
|
-
syncProjectWithDisk(project,
|
|
260
|
-
saveProject(
|
|
361
|
+
syncProjectWithDisk(project, outputDir)
|
|
362
|
+
saveProject(outputDir, project)
|
|
261
363
|
}
|
|
262
|
-
const
|
|
263
|
-
|
|
364
|
+
const slim = new URL(req.url ?? '/', 'http://localhost').searchParams.get('slim') === '1'
|
|
365
|
+
// 截断显式化:任何被裁掉的字段都要在响应里声明,客户端/模型不能
|
|
366
|
+
// 把"看到的"当成"全部"。静默截断会让一致性判断建立在不完整事实上。
|
|
367
|
+
const truncations: string[] = []
|
|
368
|
+
let projectPayload: StatusResponse['project']
|
|
369
|
+
if (project === undefined) {
|
|
370
|
+
projectPayload = undefined
|
|
371
|
+
} else if (slim) {
|
|
372
|
+
truncations.push('slim=1:正文/beats/图集/道藏/大世界/资产等重字段未返回,大纲仅返回前 200 字')
|
|
373
|
+
// slim 瘦身:轮询/角标只需章节状态与分数 + 轻量元数据,
|
|
374
|
+
// 去掉正文、beats、图集(base64)、道藏/大世界/资产等重字段,避免长书轮询反复传输大体积。
|
|
375
|
+
projectPayload = {
|
|
376
|
+
bookName: project.bookName,
|
|
377
|
+
outline: project.outline.slice(0, 200),
|
|
378
|
+
chapters: project.chapters.map(c => ({
|
|
379
|
+
no: c.no,
|
|
380
|
+
volume: c.volume,
|
|
381
|
+
title: c.title,
|
|
382
|
+
status: c.status,
|
|
383
|
+
chars: c.chars,
|
|
384
|
+
error: c.error,
|
|
385
|
+
review: c.review !== undefined ? { score: c.review.score, passed: c.review.passed } : undefined,
|
|
386
|
+
})) as unknown as ProjectState['chapters'],
|
|
387
|
+
createdAt: project.createdAt,
|
|
388
|
+
updatedAt: project.updatedAt,
|
|
389
|
+
} as unknown as ProjectState
|
|
390
|
+
} else {
|
|
264
391
|
// 瘦身:facts 只回最近 80 条(客户端最多展示 60),避免长篇后
|
|
265
392
|
// status 响应体随编年录无限膨胀。
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
393
|
+
const allFacts = project.facts ?? []
|
|
394
|
+
const shownFacts = allFacts.slice(-80)
|
|
395
|
+
if (allFacts.length > shownFacts.length) {
|
|
396
|
+
truncations.push(`编年录:共 ${allFacts.length} 条,仅返回最近 ${shownFacts.length} 条(较早 ${allFacts.length - shownFacts.length} 条未返回)`)
|
|
397
|
+
}
|
|
398
|
+
projectPayload = { ...project, facts: shownFacts }
|
|
399
|
+
}
|
|
400
|
+
const response: StatusResponse = {
|
|
401
|
+
config,
|
|
402
|
+
project: projectPayload,
|
|
269
403
|
generatedFiles: listChapterFiles(config.outputDir),
|
|
270
404
|
audit: auditState,
|
|
405
|
+
// 阶段契约:宿主算一次,面板角标、助手提示词、外部自动化共用同一结论。
|
|
406
|
+
stage: computeBookStage(project),
|
|
407
|
+
// 本次运行(进程内)的 LLM 用量:调用次数 / 输入·输出·思考 token / 耗时。
|
|
408
|
+
usage: liveUsage(),
|
|
409
|
+
...(truncations.length > 0 ? { truncations } : {}),
|
|
271
410
|
}
|
|
272
411
|
writeJson(res, 200, response)
|
|
273
412
|
},
|
|
@@ -372,9 +511,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
372
511
|
path: NOVEL_API.volumes,
|
|
373
512
|
handler: async (req, res) => {
|
|
374
513
|
if (!guard(req, res, 'POST')) return
|
|
375
|
-
const body = await readJsonBody<VolumesRequest>(req)
|
|
376
|
-
const
|
|
377
|
-
const
|
|
514
|
+
const body = await readJsonBody<VolumesRequest & { bookId?: string }>(req)
|
|
515
|
+
const config0 = getConfig()
|
|
516
|
+
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
517
|
+
const config = { ...config0, outputDir }
|
|
518
|
+
const project = loadProject(outputDir)
|
|
378
519
|
const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
|
|
379
520
|
? body.outline
|
|
380
521
|
: project?.outline
|
|
@@ -388,7 +529,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
388
529
|
const next = project ?? createProject(outline)
|
|
389
530
|
next.volumes = volumes
|
|
390
531
|
next.updatedAt = now
|
|
391
|
-
saveProject(
|
|
532
|
+
saveProject(outputDir, next)
|
|
392
533
|
const response: VolumesResponse = { volumes }
|
|
393
534
|
writeJson(res, 200, response)
|
|
394
535
|
} catch (error) {
|
|
@@ -403,9 +544,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
403
544
|
path: NOVEL_API.plan,
|
|
404
545
|
handler: async (req, res) => {
|
|
405
546
|
if (!guard(req, res, 'POST')) return
|
|
406
|
-
const body = await readJsonBody<PlanRequest>(req)
|
|
407
|
-
const
|
|
408
|
-
const
|
|
547
|
+
const body = await readJsonBody<PlanRequest & { bookId?: string }>(req)
|
|
548
|
+
const config0 = getConfig()
|
|
549
|
+
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
550
|
+
const config = { ...config0, outputDir }
|
|
551
|
+
const project = loadProject(outputDir)
|
|
409
552
|
const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
|
|
410
553
|
? body.outline
|
|
411
554
|
: project?.outline
|
|
@@ -441,10 +584,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
441
584
|
path: NOVEL_API.generate,
|
|
442
585
|
handler: async (req, res) => {
|
|
443
586
|
if (!guard(req, res, 'POST')) return
|
|
444
|
-
const
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
const
|
|
587
|
+
const config0 = getConfig()
|
|
588
|
+
const body = await readJsonBody<{ chapterNo?: number; skipReview?: boolean; bookId?: string }>(req)
|
|
589
|
+
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
590
|
+
const config = { ...config0, outputDir }
|
|
591
|
+
const project = loadProject(outputDir)
|
|
592
|
+
if (project === undefined) {
|
|
593
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
594
|
+
return
|
|
595
|
+
}
|
|
448
596
|
const rawNo = body?.chapterNo
|
|
449
597
|
if (!Number.isInteger(rawNo) || rawNo === undefined || rawNo < 1) {
|
|
450
598
|
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
@@ -479,21 +627,38 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
479
627
|
res.write(JSON.stringify(frame) + '\n')
|
|
480
628
|
}
|
|
481
629
|
|
|
630
|
+
const liveSession = nextSessionId()
|
|
631
|
+
emitLive({ type: 'session_started', sessionId: liveSession, label: '正文生成', model: config.generateModel || config.model, at: new Date().toISOString(), context: { interactionId: liveSession, taskId: `ch-${no}` } })
|
|
632
|
+
emitLive({ type: 'phase_changed', sessionId: liveSession, phase: 'streaming', phaseMessage: '模型正在返回正文', at: new Date().toISOString() })
|
|
633
|
+
|
|
482
634
|
try {
|
|
483
635
|
send({ type: 'start', no, title: chapter.title })
|
|
636
|
+
let genChars = 0
|
|
484
637
|
for await (const step of generateChapterStream(ctx, config, project, config.outputDir, no)) {
|
|
485
638
|
if (step.frame === 'delta') {
|
|
486
639
|
send({ type: 'delta', text: step.text })
|
|
640
|
+
genChars += step.text.length
|
|
641
|
+
emitLive({ type: 'output_delta', sessionId: liveSession, content: step.text, totalChars: genChars, at: new Date().toISOString() })
|
|
487
642
|
} else if (step.frame === 'done') {
|
|
488
|
-
send({ type: 'done', no, file: step.file, chars: step.chars, title: chapter.title })
|
|
643
|
+
send({ type: 'done', no, file: step.file, chars: step.chars, title: chapter.title, warn: step.warn })
|
|
489
644
|
}
|
|
490
645
|
}
|
|
491
|
-
|
|
646
|
+
emitLive({ type: 'session_completed', sessionId: liveSession, totalChars: genChars, preview: '', at: new Date().toISOString(), phase: 'completed' })
|
|
647
|
+
// Auto pipeline: summary + facts(一次调用)-> timeline -> review(unless skipped)。
|
|
492
648
|
try {
|
|
493
649
|
await summarizeAndExtractFacts(ctx, config, project, config.outputDir, no)
|
|
494
650
|
} catch (error) {
|
|
495
651
|
console.warn('[dsh-novel-forge] summary/facts failed:', (error as Error).message)
|
|
496
652
|
}
|
|
653
|
+
// 故事时间线:抽取本章事件(失败不阻断出章,作者可在时间线页手工补)。
|
|
654
|
+
if (config.autoTimeline ?? true) {
|
|
655
|
+
try {
|
|
656
|
+
await extractTimelineForChapter(ctx, config, project, config.outputDir, no)
|
|
657
|
+
saveProject(config.outputDir, project)
|
|
658
|
+
} catch (error) {
|
|
659
|
+
console.warn('[dsh-novel-forge] timeline extract failed:', (error as Error).message)
|
|
660
|
+
}
|
|
661
|
+
}
|
|
497
662
|
// 伏笔落地标记:正文中命中 planned 伏笔关键词 → 自动标为 planted(暗线管理页与正文同步)。
|
|
498
663
|
try {
|
|
499
664
|
const marked = markForeshadowPlanted(project, config.outputDir, no)
|
|
@@ -557,17 +722,22 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
557
722
|
path: NOVEL_API.review,
|
|
558
723
|
handler: async (req, res) => {
|
|
559
724
|
if (!guard(req, res, 'POST')) return
|
|
560
|
-
const
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
const
|
|
725
|
+
const config0 = getConfig()
|
|
726
|
+
const body = await readJsonBody<ReviewRequest & { bookId?: string }>(req)
|
|
727
|
+
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
728
|
+
const config = { ...config0, outputDir }
|
|
729
|
+
const project = loadProject(outputDir)
|
|
730
|
+
if (project === undefined) {
|
|
731
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
732
|
+
return
|
|
733
|
+
}
|
|
564
734
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
565
735
|
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
566
736
|
return
|
|
567
737
|
}
|
|
568
738
|
const no = body!.chapterNo!
|
|
569
739
|
try {
|
|
570
|
-
const report = await reviewChapter(ctx, config, project,
|
|
740
|
+
const report = await reviewChapter(ctx, config, project, outputDir, no)
|
|
571
741
|
writeJson(res, 200, { report })
|
|
572
742
|
} catch (error) {
|
|
573
743
|
writeJson(res, 500, { error: (error as Error).message })
|
|
@@ -581,10 +751,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
581
751
|
path: NOVEL_API.rewrite,
|
|
582
752
|
handler: async (req, res) => {
|
|
583
753
|
if (!guard(req, res, 'POST')) return
|
|
584
|
-
const
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
const
|
|
754
|
+
const config0 = getConfig()
|
|
755
|
+
const body = await readJsonBody<RewriteRequest & { bookId?: string }>(req)
|
|
756
|
+
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
757
|
+
const config = { ...config0, outputDir }
|
|
758
|
+
const project = loadProject(outputDir)
|
|
759
|
+
if (project === undefined) {
|
|
760
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
761
|
+
return
|
|
762
|
+
}
|
|
588
763
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
589
764
|
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
590
765
|
return
|
|
@@ -620,10 +795,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
620
795
|
path: NOVEL_API.polish,
|
|
621
796
|
handler: async (req, res) => {
|
|
622
797
|
if (!guard(req, res, 'POST')) return
|
|
623
|
-
const
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
const
|
|
798
|
+
const config0 = getConfig()
|
|
799
|
+
const body = await readJsonBody<PolishRequest & { bookId?: string }>(req)
|
|
800
|
+
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
801
|
+
const config = { ...config0, outputDir }
|
|
802
|
+
const project = loadProject(outputDir)
|
|
803
|
+
if (project === undefined) {
|
|
804
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
805
|
+
return
|
|
806
|
+
}
|
|
627
807
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
628
808
|
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
629
809
|
return
|
|
@@ -685,7 +865,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
685
865
|
}
|
|
686
866
|
writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
|
|
687
867
|
chapter.pendingDraft = undefined
|
|
688
|
-
chapter.chars = draft
|
|
868
|
+
chapter.chars = countHanzi(draft)
|
|
689
869
|
chapter.file = fileName
|
|
690
870
|
// 携带审查报告则沿用结论定状态(修订后审查通过 → approved);否则置 written 待审。
|
|
691
871
|
const carried = body?.report
|
|
@@ -760,10 +940,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
760
940
|
path: NOVEL_API.foreshadow,
|
|
761
941
|
handler: async (req, res) => {
|
|
762
942
|
if (!guard(req, res, 'POST')) return
|
|
763
|
-
const
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
const
|
|
943
|
+
const config0 = getConfig()
|
|
944
|
+
const body = await readJsonBody<ForeshadowRequest & { bookId?: string }>(req)
|
|
945
|
+
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
946
|
+
const config = { ...config0, outputDir }
|
|
947
|
+
const project = loadProject(outputDir)
|
|
948
|
+
if (project === undefined) {
|
|
949
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
950
|
+
return
|
|
951
|
+
}
|
|
767
952
|
try {
|
|
768
953
|
if (body?.suggest === true) {
|
|
769
954
|
// AI suggestion pass: create several foreshadows from the outline.
|
|
@@ -817,14 +1002,21 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
817
1002
|
path: NOVEL_API.exportBook,
|
|
818
1003
|
handler: async (req, res) => {
|
|
819
1004
|
if (!guard(req, res, 'POST')) return
|
|
820
|
-
const
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
const
|
|
824
|
-
const
|
|
1005
|
+
const config0 = getConfig()
|
|
1006
|
+
const body = await readJsonBody<ExportRequest & { bookId?: string }>(req)
|
|
1007
|
+
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
1008
|
+
const config = { ...config0, outputDir }
|
|
1009
|
+
const project = loadProject(outputDir)
|
|
1010
|
+
if (project === undefined) {
|
|
1011
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
1012
|
+
return
|
|
1013
|
+
}
|
|
1014
|
+
const format: 'txt' | 'md' | 'json' = body?.format === 'json' ? 'json' : body?.format === 'md' ? 'md' : 'txt'
|
|
1015
|
+
// 导出范围:整本正文 / 设定 / 规划 / 角色 / 质检记录 / 项目 JSON。
|
|
1016
|
+
const scope: ExportScope = body?.scope !== undefined && EXPORT_SCOPE_VALUES.includes(body.scope) ? body.scope : 'book'
|
|
825
1017
|
try {
|
|
826
|
-
const result =
|
|
827
|
-
const response: ExportResponse = { ...result }
|
|
1018
|
+
const result = exportScope(config.outputDir, project, scope, format)
|
|
1019
|
+
const response: ExportResponse = { ...result, scope, format }
|
|
828
1020
|
writeJson(res, 200, response)
|
|
829
1021
|
} catch (error) {
|
|
830
1022
|
writeJson(res, 500, { error: (error as Error).message })
|
|
@@ -839,9 +1031,13 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
839
1031
|
handler: async (req, res) => {
|
|
840
1032
|
if (!guard(req, res, 'GET')) return
|
|
841
1033
|
const config = getConfig()
|
|
842
|
-
const project = requireProject(res)
|
|
843
|
-
if (project === undefined) return
|
|
844
1034
|
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
1035
|
+
const outputDir = resolveOutputDir(config, url.searchParams.get('bookId') ?? undefined)
|
|
1036
|
+
const project = loadProject(outputDir)
|
|
1037
|
+
if (project === undefined) {
|
|
1038
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
1039
|
+
return
|
|
1040
|
+
}
|
|
845
1041
|
const rawNo = Number(url.searchParams.get('no') ?? '0')
|
|
846
1042
|
if (!Number.isInteger(rawNo) || rawNo < 1) {
|
|
847
1043
|
writeJson(res, 400, { error: 'no 须为正整数' })
|
|
@@ -852,7 +1048,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
852
1048
|
writeJson(res, 404, { error: `章节 ${rawNo} 不在计划中` })
|
|
853
1049
|
return
|
|
854
1050
|
}
|
|
855
|
-
const markdown = readChapterFile(
|
|
1051
|
+
const markdown = readChapterFile(outputDir, chapter)
|
|
856
1052
|
if (markdown === undefined) {
|
|
857
1053
|
writeJson(res, 404, { error: `章节 ${rawNo} 尚未生成` })
|
|
858
1054
|
return
|
|
@@ -918,9 +1114,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
918
1114
|
if (existsSync(targetPath)) {
|
|
919
1115
|
copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
|
|
920
1116
|
}
|
|
1117
|
+
// 覆盖前留一份历史版本:采纳草稿改坏了可以回滚。
|
|
1118
|
+
createSnapshot(config.outputDir, chapter, '采纳草稿前')
|
|
921
1119
|
writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${text}\n`, 'utf8')
|
|
922
1120
|
chapter.status = 'written'
|
|
923
|
-
chapter.chars = text
|
|
1121
|
+
chapter.chars = countHanzi(text)
|
|
924
1122
|
chapter.file = fileName
|
|
925
1123
|
chapter.pendingDraft = undefined
|
|
926
1124
|
// 保存即审稿:携带工作区审查报告则沿用(不重复审);否则自动正式审稿一次。
|
|
@@ -947,9 +1145,13 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
947
1145
|
handler: async (req, res) => {
|
|
948
1146
|
if (!guard(req, res, 'POST')) return
|
|
949
1147
|
const config = getConfig()
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
const
|
|
1148
|
+
const body = await readJsonBody<AssistantRequest & { bookId?: string }>(req)
|
|
1149
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1150
|
+
const project = loadProject(outputDir)
|
|
1151
|
+
if (project === undefined) {
|
|
1152
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
1153
|
+
return
|
|
1154
|
+
}
|
|
953
1155
|
const message = body?.message?.trim()
|
|
954
1156
|
if (message === undefined || message === '') {
|
|
955
1157
|
writeJson(res, 400, { error: '消息不能为空' })
|
|
@@ -963,10 +1165,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
963
1165
|
})
|
|
964
1166
|
const send = (frame: AssistantFrame): void => { res.write(JSON.stringify(frame) + '\n') }
|
|
965
1167
|
try {
|
|
966
|
-
for await (const step of runAssistantTurn(ctx, config, project,
|
|
1168
|
+
for await (const step of runAssistantTurn(ctx, config, project, outputDir, message)) {
|
|
967
1169
|
if (step.frame === 'delta') send({ type: 'delta', text: step.text })
|
|
968
1170
|
else if (step.frame === 'tool') send({ type: 'tool', name: step.name, status: step.status, detail: step.detail })
|
|
969
1171
|
else if (step.frame === 'toolDelta') send({ type: 'toolDelta', name: step.name, text: step.text })
|
|
1172
|
+
else if (step.frame === 'toolResult') send({ type: 'toolResult', name: step.name, text: step.text })
|
|
970
1173
|
}
|
|
971
1174
|
send({ type: 'done' })
|
|
972
1175
|
res.end()
|
|
@@ -986,7 +1189,9 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
986
1189
|
handler: (req, res) => {
|
|
987
1190
|
if (!guard(req, res, 'GET')) return
|
|
988
1191
|
const config = getConfig()
|
|
989
|
-
const
|
|
1192
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
1193
|
+
const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
|
|
1194
|
+
const response: AssistantHistoryResponse = { messages: loadAssistantHistory(outputDir) }
|
|
990
1195
|
writeJson(res, 200, response)
|
|
991
1196
|
},
|
|
992
1197
|
}
|
|
@@ -996,10 +1201,12 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
996
1201
|
const assistantClearRoute: WebRoute = {
|
|
997
1202
|
kind: 'exact',
|
|
998
1203
|
path: NOVEL_API.assistantClear,
|
|
999
|
-
handler: (req, res) => {
|
|
1204
|
+
handler: async (req, res) => {
|
|
1000
1205
|
if (!guard(req, res, 'POST')) return
|
|
1001
1206
|
const config = getConfig()
|
|
1002
|
-
|
|
1207
|
+
const body = await readJsonBody<{ bookId?: string }>(req)
|
|
1208
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1209
|
+
clearAssistantHistory(outputDir)
|
|
1003
1210
|
writeJson(res, 200, { ok: true })
|
|
1004
1211
|
},
|
|
1005
1212
|
}
|
|
@@ -1020,10 +1227,21 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1020
1227
|
return
|
|
1021
1228
|
}
|
|
1022
1229
|
const config = getConfig()
|
|
1023
|
-
|
|
1024
|
-
|
|
1230
|
+
let postBody: (AssetsPatch & { bookId?: string }) | undefined
|
|
1231
|
+
if (req.method === 'POST') {
|
|
1232
|
+
postBody = await readJsonBody<AssetsPatch & { bookId?: string }>(req)
|
|
1233
|
+
if (postBody === undefined) {
|
|
1234
|
+
writeJson(res, 400, { error: '无效的 JSON' })
|
|
1235
|
+
return
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
1239
|
+
const queryBookId = qurl.searchParams.get('bookId') ?? undefined
|
|
1240
|
+
const outputDir = resolveOutputDir(config, postBody?.bookId ?? queryBookId)
|
|
1241
|
+
const project = loadProject(outputDir)
|
|
1242
|
+
const projectAssets = ensureBuiltinAssets(project?.assets ?? emptyProjectAssets())
|
|
1025
1243
|
if (req.method === 'POST') {
|
|
1026
|
-
const body =
|
|
1244
|
+
const body = postBody
|
|
1027
1245
|
if (body === undefined) {
|
|
1028
1246
|
writeJson(res, 400, { error: '无效的 JSON' })
|
|
1029
1247
|
return
|
|
@@ -1040,14 +1258,16 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1040
1258
|
projectAssets.updatedAt = new Date().toISOString()
|
|
1041
1259
|
project.assets = projectAssets
|
|
1042
1260
|
project.updatedAt = new Date().toISOString()
|
|
1043
|
-
saveProject(
|
|
1261
|
+
saveProject(outputDir, project)
|
|
1044
1262
|
}
|
|
1045
1263
|
const response: AssetsResponse = {
|
|
1046
1264
|
projectAssets,
|
|
1047
|
-
genreLibrary: BUILTIN_GENRE_LIBRARY,
|
|
1265
|
+
genreLibrary: [...BUILTIN_GENRE_LIBRARY, ...globalGenreLibrary()],
|
|
1048
1266
|
antiAiLibrary: BUILTIN_ANTI_AI_RULES,
|
|
1049
1267
|
styleTemplates: BUILTIN_STYLE_TEMPLATES,
|
|
1050
|
-
progressionLibrary: BUILTIN_PROGRESSION_MODES,
|
|
1268
|
+
progressionLibrary: [...BUILTIN_PROGRESSION_MODES, ...globalProgressionLibrary()],
|
|
1269
|
+
starterStyleProfiles: BUILTIN_STARTER_STYLE_PROFILES,
|
|
1270
|
+
plotBeatLibrary: BUILTIN_PLOT_BEATS,
|
|
1051
1271
|
}
|
|
1052
1272
|
writeJson(res, 200, response)
|
|
1053
1273
|
},
|
|
@@ -1091,178 +1311,996 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1091
1311
|
},
|
|
1092
1312
|
}
|
|
1093
1313
|
|
|
1094
|
-
//
|
|
1095
|
-
const
|
|
1314
|
+
// ----------------------------------------------------------- style-formula
|
|
1315
|
+
const styleFormulaRoute: WebRoute = {
|
|
1096
1316
|
kind: 'exact',
|
|
1097
|
-
path: NOVEL_API.
|
|
1317
|
+
path: NOVEL_API.styleFormula,
|
|
1098
1318
|
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)
|
|
1319
|
+
if (!guard(req, res, 'POST')) return
|
|
1320
|
+
const config = getConfig()
|
|
1321
|
+
const project = loadProject(config.outputDir)
|
|
1322
|
+
const body = await readJsonBody<StyleEngineRequest & { depth?: string }>(req)
|
|
1323
|
+
const sample = body?.sampleText?.trim()
|
|
1324
|
+
if (sample === undefined || sample.length < 50) {
|
|
1325
|
+
writeJson(res, 400, { error: '样本文本过短(<50 字符),请粘贴一段能代表目标风格的文字' })
|
|
1109
1326
|
return
|
|
1110
1327
|
}
|
|
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)
|
|
1328
|
+
const depth = (['basic', 'standard', 'deep'] as const).includes(body?.depth as never)
|
|
1329
|
+
? body!.depth! as 'basic' | 'standard' | 'deep'
|
|
1330
|
+
: 'standard'
|
|
1331
|
+
try {
|
|
1332
|
+
const formula = await extractStyleFormula(ctx, config, sample, depth)
|
|
1333
|
+
const key = `sf-${Date.now().toString(36)}`
|
|
1334
|
+
const styleFormula = { key, ...formula, depth, createdAt: new Date().toISOString() }
|
|
1335
|
+
if (project !== undefined) {
|
|
1336
|
+
project.assets ??= emptyProjectAssets()
|
|
1337
|
+
project.assets.styleFormulas ??= []
|
|
1338
|
+
project.assets.styleFormulas.push(styleFormula)
|
|
1339
|
+
project.assets.updatedAt = new Date().toISOString()
|
|
1340
|
+
project.updatedAt = new Date().toISOString()
|
|
1341
|
+
saveProject(config.outputDir, project)
|
|
1132
1342
|
}
|
|
1133
|
-
writeJson(res, 200,
|
|
1343
|
+
writeJson(res, 200, { styleFormula })
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1346
|
+
}
|
|
1347
|
+
},
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// ----------------------------------------------------------- style-detect
|
|
1351
|
+
/** 正文实时 AI 味检测(本地确定性扫描,免 LLM)。 */
|
|
1352
|
+
const styleDetectRoute: WebRoute = {
|
|
1353
|
+
kind: 'exact',
|
|
1354
|
+
path: NOVEL_API.styleDetect,
|
|
1355
|
+
handler: async (req, res) => {
|
|
1356
|
+
if (!guard(req, res, 'POST')) return
|
|
1357
|
+
const body = await readJsonBody<{ text?: string }>(req)
|
|
1358
|
+
const text = body?.text?.trim() ?? ''
|
|
1359
|
+
if (text.length < 20) {
|
|
1360
|
+
writeJson(res, 400, { error: '文本过短(<20 字符)' })
|
|
1134
1361
|
return
|
|
1135
1362
|
}
|
|
1136
|
-
|
|
1363
|
+
const scan = scanAiFlavor(text.slice(0, 20000))
|
|
1364
|
+
writeJson(res, 200, { scan })
|
|
1137
1365
|
},
|
|
1138
1366
|
}
|
|
1139
1367
|
|
|
1140
|
-
//
|
|
1141
|
-
/**
|
|
1142
|
-
const
|
|
1368
|
+
// --------------------------------------------------------- market-radar
|
|
1369
|
+
/** 热门题材雷达:输入平台/题材/榜单文本 → 信号 + 生产底座 + 开书创意。 */
|
|
1370
|
+
const marketRadarRoute: WebRoute = {
|
|
1143
1371
|
kind: 'exact',
|
|
1144
|
-
path: NOVEL_API.
|
|
1372
|
+
path: NOVEL_API.marketRadar,
|
|
1145
1373
|
handler: async (req, res) => {
|
|
1146
1374
|
if (!guard(req, res, 'POST')) return
|
|
1147
1375
|
const config = getConfig()
|
|
1148
|
-
const
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1376
|
+
const body = await readJsonBody<MarketRadarRequest>(req)
|
|
1377
|
+
try {
|
|
1378
|
+
const result = await runMarketRadar(ctx, config, body ?? {})
|
|
1379
|
+
writeJson(res, 200, { result })
|
|
1380
|
+
} catch (error) {
|
|
1381
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1152
1382
|
}
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1383
|
+
},
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// ------------------------------------------------- market-radar scan
|
|
1387
|
+
/** 真实榜单扫榜:抓取公开榜单(容错),返回分组候选。 */
|
|
1388
|
+
const marketRadarScanRoute: WebRoute = {
|
|
1389
|
+
kind: 'exact',
|
|
1390
|
+
path: NOVEL_API.marketRadarScan,
|
|
1391
|
+
handler: async (req, res) => {
|
|
1392
|
+
if (!guard(req, res, 'POST')) return
|
|
1393
|
+
const body = await readJsonBody<{ platforms?: string[] }>(req)
|
|
1394
|
+
try {
|
|
1395
|
+
const result = await scanMarketRanking(body?.platforms)
|
|
1396
|
+
writeJson(res, 200, { result })
|
|
1397
|
+
} catch (error) {
|
|
1398
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1158
1399
|
}
|
|
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
1400
|
},
|
|
1169
1401
|
}
|
|
1170
1402
|
|
|
1171
|
-
//
|
|
1172
|
-
|
|
1403
|
+
// ------------------------------------------------- market-radar apply
|
|
1404
|
+
/** 把雷达生产底座一键应用到某本书(写入项目资产/开书定盘,供后续规划与生成遵守)。 */
|
|
1405
|
+
const marketRadarApplyRoute: WebRoute = {
|
|
1173
1406
|
kind: 'exact',
|
|
1174
|
-
path:
|
|
1407
|
+
path: NOVEL_API.marketRadarApply,
|
|
1175
1408
|
handler: async (req, res) => {
|
|
1176
1409
|
if (!guard(req, res, 'POST')) return
|
|
1177
|
-
const body = await readJsonBody<
|
|
1178
|
-
|
|
1179
|
-
|
|
1410
|
+
const body = await readJsonBody<{ bookId?: string; foundation?: ProductionFoundation }>(req)
|
|
1411
|
+
const pf = body?.foundation
|
|
1412
|
+
if (pf === undefined) {
|
|
1413
|
+
writeJson(res, 400, { error: '缺少 foundation' })
|
|
1180
1414
|
return
|
|
1181
1415
|
}
|
|
1182
|
-
const
|
|
1183
|
-
|
|
1184
|
-
|
|
1416
|
+
const config = getConfig()
|
|
1417
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1418
|
+
const project = loadProject(outputDir)
|
|
1419
|
+
if (project === undefined) {
|
|
1420
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
1185
1421
|
return
|
|
1186
1422
|
}
|
|
1187
|
-
|
|
1423
|
+
const genreBuiltin = BUILTIN_GENRE_LIBRARY.find(g => g.name === (pf.genre.existingId || pf.genre.name))
|
|
1424
|
+
const genreNode: GenreNode = genreBuiltin !== undefined
|
|
1425
|
+
? { ...genreBuiltin }
|
|
1426
|
+
: { name: pf.genre.name.slice(0, 40), description: pf.genre.description, template: pf.genre.template, children: [] }
|
|
1427
|
+
const findMode = (m: { existingId?: string; name: string }): ProgressionMode | undefined => BUILTIN_PROGRESSION_MODES.find(x => x.name === (m.existingId || m.name))
|
|
1428
|
+
const primary = findMode(pf.primaryStoryMode) ?? {
|
|
1429
|
+
name: pf.primaryStoryMode.name.slice(0, 40),
|
|
1430
|
+
driver: pf.primaryStoryMode.driver,
|
|
1431
|
+
readerExpectation: pf.primaryStoryMode.readerExpectation,
|
|
1432
|
+
payoffs: [],
|
|
1433
|
+
risks: [],
|
|
1434
|
+
primary: true,
|
|
1435
|
+
}
|
|
1436
|
+
const aux: ProgressionMode[] = []
|
|
1437
|
+
if (pf.secondaryStoryMode !== undefined) {
|
|
1438
|
+
const sec = findMode(pf.secondaryStoryMode) ?? {
|
|
1439
|
+
name: pf.secondaryStoryMode.name.slice(0, 40),
|
|
1440
|
+
driver: pf.secondaryStoryMode.driver,
|
|
1441
|
+
readerExpectation: pf.secondaryStoryMode.readerExpectation,
|
|
1442
|
+
payoffs: [],
|
|
1443
|
+
risks: [],
|
|
1444
|
+
primary: false,
|
|
1445
|
+
}
|
|
1446
|
+
aux.push(sec)
|
|
1447
|
+
}
|
|
1448
|
+
project.assets ??= emptyProjectAssets()
|
|
1449
|
+
project.assets.genre = genreNode
|
|
1450
|
+
project.assets.primaryProgression = primary
|
|
1451
|
+
project.assets.auxiliaryProgressions = aux
|
|
1452
|
+
project.bookContract = {
|
|
1453
|
+
promise: pf.genre.description.slice(0, 120),
|
|
1454
|
+
primaryModeName: pf.primaryStoryMode.name,
|
|
1455
|
+
secondaryModeNames: pf.secondaryStoryMode !== undefined ? [pf.secondaryStoryMode.name] : [],
|
|
1456
|
+
tone: pf.genre.template,
|
|
1457
|
+
targetPlatform: undefined,
|
|
1458
|
+
}
|
|
1459
|
+
project.updatedAt = new Date().toISOString()
|
|
1460
|
+
saveProject(outputDir, project)
|
|
1461
|
+
writeJson(res, 200, { ok: true, bookName: project.bookName })
|
|
1188
1462
|
},
|
|
1189
1463
|
}
|
|
1190
1464
|
|
|
1191
|
-
//
|
|
1192
|
-
|
|
1465
|
+
// ------------------------------------------------- market-radar sync
|
|
1466
|
+
/** 把雷达生产底座里的「新资产」同步进全局资源库(跨书复用;已有名字跳过)。 */
|
|
1467
|
+
const marketRadarSyncRoute: WebRoute = {
|
|
1193
1468
|
kind: 'exact',
|
|
1194
|
-
path:
|
|
1469
|
+
path: NOVEL_API.marketRadarSync,
|
|
1195
1470
|
handler: async (req, res) => {
|
|
1196
1471
|
if (!guard(req, res, 'POST')) return
|
|
1197
|
-
const body = await readJsonBody<
|
|
1198
|
-
|
|
1199
|
-
|
|
1472
|
+
const body = await readJsonBody<{ foundation?: ProductionFoundation }>(req)
|
|
1473
|
+
const pf = body?.foundation
|
|
1474
|
+
if (pf === undefined) {
|
|
1475
|
+
writeJson(res, 400, { error: '缺少 foundation' })
|
|
1200
1476
|
return
|
|
1201
1477
|
}
|
|
1202
|
-
const
|
|
1203
|
-
if (
|
|
1204
|
-
|
|
1205
|
-
return
|
|
1478
|
+
const synced = { genre: false, primaryMode: false, secondaryMode: false }
|
|
1479
|
+
if (pf.genre.existingId === undefined && pf.genre.name !== '') {
|
|
1480
|
+
synced.genre = addGlobalGenre({ name: pf.genre.name.slice(0, 40), description: pf.genre.description, template: pf.genre.template, children: [] })
|
|
1206
1481
|
}
|
|
1207
|
-
|
|
1482
|
+
if (pf.primaryStoryMode.existingId === undefined && pf.primaryStoryMode.name !== '') {
|
|
1483
|
+
synced.primaryMode = addGlobalMode({ name: pf.primaryStoryMode.name.slice(0, 40), driver: pf.primaryStoryMode.driver, readerExpectation: pf.primaryStoryMode.readerExpectation, payoffs: [], risks: [], primary: true })
|
|
1484
|
+
}
|
|
1485
|
+
if (pf.secondaryStoryMode !== undefined && pf.secondaryStoryMode !== null && pf.secondaryStoryMode.existingId === undefined && pf.secondaryStoryMode.name !== '') {
|
|
1486
|
+
synced.secondaryMode = addGlobalMode({ name: pf.secondaryStoryMode.name.slice(0, 40), driver: pf.secondaryStoryMode.driver, readerExpectation: pf.secondaryStoryMode.readerExpectation, payoffs: [], risks: [], primary: false })
|
|
1487
|
+
}
|
|
1488
|
+
writeJson(res, 200, { ok: true, synced })
|
|
1208
1489
|
},
|
|
1209
1490
|
}
|
|
1210
1491
|
|
|
1211
|
-
//
|
|
1212
|
-
/**
|
|
1213
|
-
const
|
|
1492
|
+
// ------------------------------------------------- market-radar brief
|
|
1493
|
+
/** 开书创意简报:用选中的市场信号 + 影响模式生成。 */
|
|
1494
|
+
const marketRadarBriefRoute: WebRoute = {
|
|
1214
1495
|
kind: 'exact',
|
|
1215
|
-
path: NOVEL_API.
|
|
1496
|
+
path: NOVEL_API.marketRadarBrief,
|
|
1216
1497
|
handler: async (req, res) => {
|
|
1217
1498
|
if (!guard(req, res, 'POST')) return
|
|
1218
1499
|
const config = getConfig()
|
|
1219
|
-
const
|
|
1220
|
-
if (
|
|
1221
|
-
|
|
1222
|
-
|
|
1500
|
+
const body = await readJsonBody<MarketRadarBriefRequest>(req)
|
|
1501
|
+
if (body === undefined || body.influenceMode === undefined) {
|
|
1502
|
+
writeJson(res, 400, { error: '缺少 influenceMode' })
|
|
1503
|
+
return
|
|
1504
|
+
}
|
|
1223
1505
|
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)
|
|
1506
|
+
const creativeBrief = await runMarketCreativeBrief(ctx, config, body)
|
|
1507
|
+
writeJson(res, 200, { creativeBrief })
|
|
1245
1508
|
} catch (error) {
|
|
1246
|
-
auditState = {
|
|
1247
|
-
...auditState,
|
|
1248
|
-
status: 'error',
|
|
1249
|
-
finishedAt: new Date().toISOString(),
|
|
1250
|
-
error: (error as Error).message,
|
|
1251
|
-
}
|
|
1252
1509
|
writeJson(res, 500, { error: (error as Error).message })
|
|
1253
1510
|
}
|
|
1254
1511
|
},
|
|
1255
1512
|
}
|
|
1256
1513
|
|
|
1257
|
-
//
|
|
1258
|
-
/**
|
|
1259
|
-
const
|
|
1514
|
+
// ------------------------------------------------------------- knowledge
|
|
1515
|
+
/** 书内知识库:GET 读取 / POST 增删改(按书路由)。 */
|
|
1516
|
+
const knowledgeRoute: WebRoute = {
|
|
1260
1517
|
kind: 'exact',
|
|
1261
|
-
path: NOVEL_API.
|
|
1518
|
+
path: NOVEL_API.knowledge,
|
|
1262
1519
|
handler: async (req, res) => {
|
|
1263
|
-
if (
|
|
1520
|
+
if (req.method === 'POST') {
|
|
1521
|
+
if (!guard(req, res, 'POST')) return
|
|
1522
|
+
} else {
|
|
1523
|
+
if (!guard(req, res, 'GET')) return
|
|
1524
|
+
}
|
|
1264
1525
|
const config = getConfig()
|
|
1265
|
-
const
|
|
1526
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
1527
|
+
const body = req.method === 'POST' ? await readJsonBody<KnowledgeRequest & { bookId?: string }>(req) : undefined
|
|
1528
|
+
const outputDir = resolveOutputDir(config, body?.bookId ?? qurl.searchParams.get('bookId') ?? undefined)
|
|
1529
|
+
const project = loadProject(outputDir)
|
|
1530
|
+
if (project === undefined) {
|
|
1531
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
1532
|
+
return
|
|
1533
|
+
}
|
|
1534
|
+
project.knowledgeDocs ??= []
|
|
1535
|
+
if (req.method === 'POST' && body !== undefined) {
|
|
1536
|
+
if (body.action === 'add' && body.doc !== undefined) {
|
|
1537
|
+
project.knowledgeDocs.push({ id: body.doc.id ?? `kd-${Date.now().toString(36)}`, title: body.doc.title, content: body.doc.content, updatedAt: new Date().toISOString() })
|
|
1538
|
+
} else if (body.action === 'remove' && body.id !== undefined) {
|
|
1539
|
+
project.knowledgeDocs = project.knowledgeDocs.filter(d => d.id !== body.id)
|
|
1540
|
+
} else if (body.action === 'replace' && body.doc !== undefined) {
|
|
1541
|
+
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)
|
|
1542
|
+
}
|
|
1543
|
+
project.updatedAt = new Date().toISOString()
|
|
1544
|
+
saveProject(outputDir, project)
|
|
1545
|
+
}
|
|
1546
|
+
writeJson(res, 200, { docs: project.knowledgeDocs })
|
|
1547
|
+
},
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// ---------------------------------------------------------- book-analysis
|
|
1551
|
+
/** 书分析/拆书:输入文本 → 卖点/结构/可借鉴点/风险。 */
|
|
1552
|
+
const bookAnalysisRoute: WebRoute = {
|
|
1553
|
+
kind: 'exact',
|
|
1554
|
+
path: NOVEL_API.bookAnalysis,
|
|
1555
|
+
handler: async (req, res) => {
|
|
1556
|
+
if (!guard(req, res, 'POST')) return
|
|
1557
|
+
const config = getConfig()
|
|
1558
|
+
const body = await readJsonBody<BookAnalysisRequest>(req)
|
|
1559
|
+
if (body === undefined || (body.text ?? '').trim().length < 50) {
|
|
1560
|
+
writeJson(res, 400, { error: '文本过短(<50 字符)' })
|
|
1561
|
+
return
|
|
1562
|
+
}
|
|
1563
|
+
try {
|
|
1564
|
+
const result = await runBookAnalysis(ctx, config, body)
|
|
1565
|
+
writeJson(res, 200, { result })
|
|
1566
|
+
} catch (error) {
|
|
1567
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1568
|
+
}
|
|
1569
|
+
},
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// ---------------------------------------------------------- idea-inspiration
|
|
1573
|
+
/** 创意灵感:一句话/题材 → 多方向开书灵感。 */
|
|
1574
|
+
const ideaInspirationRoute: WebRoute = {
|
|
1575
|
+
kind: 'exact',
|
|
1576
|
+
path: NOVEL_API.ideaInspiration,
|
|
1577
|
+
handler: async (req, res) => {
|
|
1578
|
+
if (!guard(req, res, 'POST')) return
|
|
1579
|
+
const config = getConfig()
|
|
1580
|
+
const body = await readJsonBody<IdeaInspirationRequest>(req)
|
|
1581
|
+
if (body === undefined || (body.idea ?? '').trim().length < 2) {
|
|
1582
|
+
writeJson(res, 400, { error: '请填写你的方向/一句话想法' })
|
|
1583
|
+
return
|
|
1584
|
+
}
|
|
1585
|
+
try {
|
|
1586
|
+
const result = await runIdeaInspiration(ctx, config, body)
|
|
1587
|
+
writeJson(res, 200, { result })
|
|
1588
|
+
} catch (error) {
|
|
1589
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1590
|
+
}
|
|
1591
|
+
},
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// ------------------------------------------------------- idea-inspiration/market
|
|
1595
|
+
/** 雷达→灵感:基于市场信号/生产底座/创意简报生成开书灵感。 */
|
|
1596
|
+
const marketIdeaInspirationRoute: WebRoute = {
|
|
1597
|
+
kind: 'exact',
|
|
1598
|
+
path: NOVEL_API.ideaInspirationMarket,
|
|
1599
|
+
handler: async (req, res) => {
|
|
1600
|
+
if (!guard(req, res, 'POST')) return
|
|
1601
|
+
const config = getConfig()
|
|
1602
|
+
const body = await readJsonBody<{ signals?: import('./protocol.ts').MarketRadarSignal[]; foundation?: import('./protocol.ts').ProductionFoundation; brief?: import('./protocol.ts').MarketCreativeBrief; count?: number }>(req)
|
|
1603
|
+
if (body === undefined) {
|
|
1604
|
+
writeJson(res, 400, { error: '缺少市场分析参数' })
|
|
1605
|
+
return
|
|
1606
|
+
}
|
|
1607
|
+
try {
|
|
1608
|
+
const result = await runMarketIdeaInspiration(ctx, config, body)
|
|
1609
|
+
writeJson(res, 200, { result })
|
|
1610
|
+
} catch (error) {
|
|
1611
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1612
|
+
}
|
|
1613
|
+
},
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// ------------------------------------------------------------- director
|
|
1617
|
+
/** 自动导演编排建议:基于全书上下文。 */
|
|
1618
|
+
const directorRoute: WebRoute = {
|
|
1619
|
+
kind: 'exact',
|
|
1620
|
+
path: NOVEL_API.director,
|
|
1621
|
+
handler: async (req, res) => {
|
|
1622
|
+
if (!guard(req, res, 'POST')) return
|
|
1623
|
+
const config = getConfig()
|
|
1624
|
+
const body = await readJsonBody<DirectorRequest & { bookId?: string }>(req)
|
|
1625
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1626
|
+
const project = loadProject(outputDir)
|
|
1627
|
+
if (project === undefined) {
|
|
1628
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
1629
|
+
return
|
|
1630
|
+
}
|
|
1631
|
+
try {
|
|
1632
|
+
const result = await runDirectorAdvice(ctx, config, project, body ?? {})
|
|
1633
|
+
writeJson(res, 200, { result })
|
|
1634
|
+
} catch (error) {
|
|
1635
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1636
|
+
}
|
|
1637
|
+
},
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// ------------------------------------------------------------- director/todos
|
|
1641
|
+
/** 自动导演「采纳」出的书内待办:GET 读取 / POST 增删勾选(按书路由)。 */
|
|
1642
|
+
const directorTodosRoute: WebRoute = {
|
|
1643
|
+
kind: 'exact',
|
|
1644
|
+
path: NOVEL_API.directorTodos,
|
|
1645
|
+
handler: async (req, res) => {
|
|
1646
|
+
type DirectorTodosReq = { op?: 'add' | 'toggle' | 'remove'; text?: string; source?: DirectorTodo['source']; id?: string; bookId?: string }
|
|
1647
|
+
let body: DirectorTodosReq | undefined
|
|
1648
|
+
if (req.method === 'POST') {
|
|
1649
|
+
if (!guard(req, res, 'POST')) return
|
|
1650
|
+
body = await readJsonBody<DirectorTodosReq>(req)
|
|
1651
|
+
} else {
|
|
1652
|
+
if (!guard(req, res, 'GET')) return
|
|
1653
|
+
}
|
|
1654
|
+
const config = getConfig()
|
|
1655
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1656
|
+
const project = loadProject(outputDir)
|
|
1657
|
+
if (project === undefined) {
|
|
1658
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
1659
|
+
return
|
|
1660
|
+
}
|
|
1661
|
+
project.todos ??= []
|
|
1662
|
+
if (req.method === 'POST' && body !== undefined) {
|
|
1663
|
+
if (body.op === 'add' && (body.text ?? '').trim() !== '') {
|
|
1664
|
+
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() })
|
|
1665
|
+
} else if (body.op === 'toggle' && body.id !== undefined) {
|
|
1666
|
+
project.todos = project.todos.map(t => t.id === body.id ? { ...t, done: !t.done } : t)
|
|
1667
|
+
} else if (body.op === 'remove' && body.id !== undefined) {
|
|
1668
|
+
project.todos = project.todos.filter(t => t.id !== body.id)
|
|
1669
|
+
}
|
|
1670
|
+
project.updatedAt = new Date().toISOString()
|
|
1671
|
+
saveProject(outputDir, project)
|
|
1672
|
+
}
|
|
1673
|
+
writeJson(res, 200, { todos: project.todos })
|
|
1674
|
+
},
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// ------------------------------------------------------------- llm-live
|
|
1678
|
+
/** AI 创作实况:订阅 LLM 实时事件流(NDJSON;订阅时先重放最近缓冲)。 */
|
|
1679
|
+
const llmLiveRoute: WebRoute = {
|
|
1680
|
+
kind: 'exact',
|
|
1681
|
+
path: NOVEL_API.llmLive,
|
|
1682
|
+
handler: async (req, res) => {
|
|
1683
|
+
if (req.method !== 'GET') {
|
|
1684
|
+
writeJson(res, 405, { error: 'GET only' })
|
|
1685
|
+
return
|
|
1686
|
+
}
|
|
1687
|
+
if (!isLoopbackRequest(req)) {
|
|
1688
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
1689
|
+
return
|
|
1690
|
+
}
|
|
1691
|
+
res.writeHead(200, {
|
|
1692
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
1693
|
+
'cache-control': 'no-cache',
|
|
1694
|
+
'x-accel-buffering': 'no',
|
|
1695
|
+
'referrer-policy': 'no-referrer',
|
|
1696
|
+
})
|
|
1697
|
+
const unsubscribe = subscribeLiveFeed((frame) => {
|
|
1698
|
+
try { res.write('data: ' + JSON.stringify(frame) + '\n\n') } catch { /* client closed */ }
|
|
1699
|
+
})
|
|
1700
|
+
const hb = setInterval(() => { try { res.write(': ping\n\n') } catch { /* closed */ } }, 25000)
|
|
1701
|
+
// 关键:让 handler 一直挂起,直到客户端关闭,避免 WebRoute 框架在 handler 返回后关闭响应。
|
|
1702
|
+
await new Promise<void>((resolve) => {
|
|
1703
|
+
res.on('close', () => {
|
|
1704
|
+
clearInterval(hb)
|
|
1705
|
+
unsubscribe()
|
|
1706
|
+
resolve()
|
|
1707
|
+
})
|
|
1708
|
+
})
|
|
1709
|
+
},
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// --------------------------------------------------- llm-live prompt/usage
|
|
1713
|
+
/** 查看某次调用实际发送的 Prompt(面板「查看 Prompt」按钮用)。 */
|
|
1714
|
+
const llmPromptRoute: WebRoute = {
|
|
1715
|
+
kind: 'exact',
|
|
1716
|
+
path: NOVEL_API.llmPrompt,
|
|
1717
|
+
handler: (req, res) => {
|
|
1718
|
+
if (!guard(req, res, 'GET')) return
|
|
1719
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
1720
|
+
const sessionId = qurl.searchParams.get('sessionId') ?? ''
|
|
1721
|
+
const record = livePrompt(sessionId)
|
|
1722
|
+
if (record === undefined) {
|
|
1723
|
+
writeJson(res, 404, { error: '该调用的 Prompt 记录已过期(只保留最近 40 次调用)' })
|
|
1724
|
+
return
|
|
1725
|
+
}
|
|
1726
|
+
writeJson(res, 200, record)
|
|
1727
|
+
},
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/** 清零本次运行的用量账本。 */
|
|
1731
|
+
const usageResetRoute: WebRoute = {
|
|
1732
|
+
kind: 'exact',
|
|
1733
|
+
path: NOVEL_API.usageReset,
|
|
1734
|
+
handler: (req, res) => {
|
|
1735
|
+
if (!guard(req, res, 'POST')) return
|
|
1736
|
+
resetLiveUsage()
|
|
1737
|
+
writeJson(res, 200, { ok: true, usage: liveUsage() })
|
|
1738
|
+
},
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// ------------------------------------------------------------- timeline
|
|
1742
|
+
/**
|
|
1743
|
+
* 故事时间线:GET 列表(含规则初筛问题);POST op=extract|check|update|remove|clear。
|
|
1744
|
+
*/
|
|
1745
|
+
const timelineRoute: WebRoute = {
|
|
1746
|
+
kind: 'exact',
|
|
1747
|
+
path: NOVEL_API.timeline,
|
|
1748
|
+
handler: async (req, res) => {
|
|
1749
|
+
const config = getConfig()
|
|
1750
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
1751
|
+
if (req.method === 'GET') {
|
|
1752
|
+
if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
|
|
1753
|
+
const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
|
|
1754
|
+
const project = loadProject(outputDir)
|
|
1755
|
+
if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
|
|
1756
|
+
const response: TimelineResponse = {
|
|
1757
|
+
events: project.timeline ?? [],
|
|
1758
|
+
issues: detectTimelineIssues(project),
|
|
1759
|
+
}
|
|
1760
|
+
writeJson(res, 200, response)
|
|
1761
|
+
return
|
|
1762
|
+
}
|
|
1763
|
+
if (!guard(req, res, 'POST')) return
|
|
1764
|
+
const body = await readJsonBody<TimelineRequest & { bookId?: string }>(req)
|
|
1765
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1766
|
+
const project = loadProject(outputDir)
|
|
1767
|
+
if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
|
|
1768
|
+
try {
|
|
1769
|
+
if (body?.op === 'extract') {
|
|
1770
|
+
const no = body.chapterNo
|
|
1771
|
+
if (typeof no !== 'number' || !Number.isFinite(no)) { writeJson(res, 400, { error: 'extract 需要 chapterNo' }); return }
|
|
1772
|
+
const events = await extractTimelineForChapter(ctx, config, project, outputDir, no)
|
|
1773
|
+
saveProject(outputDir, project)
|
|
1774
|
+
writeJson(res, 200, { events, all: project.timeline ?? [] })
|
|
1775
|
+
return
|
|
1776
|
+
}
|
|
1777
|
+
if (body?.op === 'check') {
|
|
1778
|
+
const ruleIssues = detectTimelineIssues(project)
|
|
1779
|
+
let aiIssues: typeof ruleIssues = []
|
|
1780
|
+
try {
|
|
1781
|
+
aiIssues = await reviewTimeline(ctx, config, project)
|
|
1782
|
+
} catch (error) {
|
|
1783
|
+
// AI 复核失败不阻断:规则初筛结果照常返回,并说明原因。
|
|
1784
|
+
writeJson(res, 200, { issues: ruleIssues, aiError: (error as Error).message })
|
|
1785
|
+
return
|
|
1786
|
+
}
|
|
1787
|
+
writeJson(res, 200, { issues: [...ruleIssues, ...aiIssues] })
|
|
1788
|
+
return
|
|
1789
|
+
}
|
|
1790
|
+
if (body?.op === 'update') {
|
|
1791
|
+
const event = body.event
|
|
1792
|
+
if (event === undefined || typeof event.id !== 'string') { writeJson(res, 400, { error: 'update 需要 event' }); return }
|
|
1793
|
+
const list = project.timeline ?? []
|
|
1794
|
+
const idx = list.findIndex(e => e.id === event.id)
|
|
1795
|
+
if (idx === -1) { writeJson(res, 404, { error: '时间线事件不存在' }); return }
|
|
1796
|
+
list[idx] = { ...event, source: 'manual' }
|
|
1797
|
+
project.timeline = list
|
|
1798
|
+
project.updatedAt = new Date().toISOString()
|
|
1799
|
+
saveProject(outputDir, project)
|
|
1800
|
+
writeJson(res, 200, { events: project.timeline })
|
|
1801
|
+
return
|
|
1802
|
+
}
|
|
1803
|
+
if (body?.op === 'remove') {
|
|
1804
|
+
project.timeline = (project.timeline ?? []).filter(e => e.id !== body.id)
|
|
1805
|
+
project.updatedAt = new Date().toISOString()
|
|
1806
|
+
saveProject(outputDir, project)
|
|
1807
|
+
writeJson(res, 200, { events: project.timeline })
|
|
1808
|
+
return
|
|
1809
|
+
}
|
|
1810
|
+
if (body?.op === 'clear') {
|
|
1811
|
+
project.timeline = []
|
|
1812
|
+
project.updatedAt = new Date().toISOString()
|
|
1813
|
+
saveProject(outputDir, project)
|
|
1814
|
+
writeJson(res, 200, { events: [] })
|
|
1815
|
+
return
|
|
1816
|
+
}
|
|
1817
|
+
writeJson(res, 400, { error: `未知 op:${String(body?.op)}` })
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1820
|
+
}
|
|
1821
|
+
},
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
// ------------------------------------------------------------ revision plan
|
|
1825
|
+
/** 修订指令合并:POST { op: 'plan', chapterNo, reviewIssues? } → 一份合并指令 + 验证基准。
|
|
1826
|
+
* 面板与生产单共用,保证「同一章只改一轮」;张力/时间线问题默认只是建议(advisory)。 */
|
|
1827
|
+
const revisionPlanRoute: WebRoute = {
|
|
1828
|
+
kind: 'exact',
|
|
1829
|
+
path: NOVEL_API.revisionPlan,
|
|
1830
|
+
handler: async (req, res) => {
|
|
1831
|
+
if (!guard(req, res, 'POST')) return
|
|
1832
|
+
const config = getConfig()
|
|
1833
|
+
const body = await readJsonBody<RevisionPlanRequest>(req)
|
|
1834
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1835
|
+
const project = loadProject(outputDir)
|
|
1836
|
+
if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
|
|
1837
|
+
const chapterNo = body?.chapterNo
|
|
1838
|
+
if (chapterNo !== undefined && !project.chapters.some(c => c.no === chapterNo)) {
|
|
1839
|
+
writeJson(res, 404, { error: `章节 ${String(chapterNo)} 不在计划中` })
|
|
1840
|
+
return
|
|
1841
|
+
}
|
|
1842
|
+
const chapter = chapterNo === undefined ? undefined : project.chapters.find(c => c.no === chapterNo)
|
|
1843
|
+
try {
|
|
1844
|
+
const plan = buildRevisionPlan(project, {
|
|
1845
|
+
chapterNo,
|
|
1846
|
+
reviewIssues: body?.reviewIssues,
|
|
1847
|
+
includeTimeline: body?.includeTimeline,
|
|
1848
|
+
includeTension: body?.includeTension,
|
|
1849
|
+
baseReport: chapter?.review,
|
|
1850
|
+
cap: REVISION_ITEM_CAP,
|
|
1851
|
+
})
|
|
1852
|
+
const response: RevisionPlanResponse = {
|
|
1853
|
+
chapterNo: chapterNo ?? 0,
|
|
1854
|
+
items: plan.items,
|
|
1855
|
+
instruction: plan.instruction,
|
|
1856
|
+
counts: plan.counts,
|
|
1857
|
+
omitted: plan.omitted,
|
|
1858
|
+
baseline: plan.baseline,
|
|
1859
|
+
summary: describeRevisionPlan(plan),
|
|
1860
|
+
}
|
|
1861
|
+
writeJson(res, 200, response)
|
|
1862
|
+
} catch (error) {
|
|
1863
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1864
|
+
}
|
|
1865
|
+
},
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// ------------------------------------------------------------ snapshots
|
|
1869
|
+
/** 章节历史版本:GET 列表;POST op=create|restore|remove|prune。 */
|
|
1870
|
+
const snapshotRoute: WebRoute = {
|
|
1871
|
+
kind: 'exact',
|
|
1872
|
+
path: NOVEL_API.snapshot,
|
|
1873
|
+
handler: async (req, res) => {
|
|
1874
|
+
const config = getConfig()
|
|
1875
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
1876
|
+
if (req.method === 'GET') {
|
|
1877
|
+
if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
|
|
1878
|
+
const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
|
|
1879
|
+
const response: SnapshotResponse = { snapshots: loadSnapshots(outputDir), bytes: snapshotsBytes(outputDir) }
|
|
1880
|
+
writeJson(res, 200, response)
|
|
1881
|
+
return
|
|
1882
|
+
}
|
|
1883
|
+
if (!guard(req, res, 'POST')) return
|
|
1884
|
+
const body = await readJsonBody<SnapshotRequest & { bookId?: string }>(req)
|
|
1885
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1886
|
+
const project = loadProject(outputDir)
|
|
1887
|
+
try {
|
|
1888
|
+
if (body?.op === 'create') {
|
|
1889
|
+
if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
|
|
1890
|
+
const chapter = project.chapters.find(c => c.no === body.chapterNo)
|
|
1891
|
+
if (chapter === undefined) { writeJson(res, 404, { error: `章节 ${String(body.chapterNo)} 不在计划中` }); return }
|
|
1892
|
+
const snapshot = createSnapshot(outputDir, chapter, '手工存档')
|
|
1893
|
+
if (snapshot === undefined) { writeJson(res, 400, { error: '该章暂无正文文件,无法存档' }); return }
|
|
1894
|
+
writeJson(res, 200, { snapshot, snapshots: loadSnapshots(outputDir) })
|
|
1895
|
+
return
|
|
1896
|
+
}
|
|
1897
|
+
if (body?.op === 'restore') {
|
|
1898
|
+
if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
|
|
1899
|
+
const id = body.id ?? ''
|
|
1900
|
+
const snap = loadSnapshots(outputDir).find(x => x.id === id)
|
|
1901
|
+
if (snap === undefined) { writeJson(res, 404, { error: '快照不存在' }); return }
|
|
1902
|
+
const chapter = project.chapters.find(c => c.no === snap.chapterNo)
|
|
1903
|
+
if (chapter === undefined) { writeJson(res, 404, { error: `章节 ${snap.chapterNo} 不在计划中` }); return }
|
|
1904
|
+
const chars = restoreSnapshot(outputDir, chapter, id)
|
|
1905
|
+
chapter.chars = countHanzi(readChapterFile(outputDir, chapter) ?? '')
|
|
1906
|
+
chapter.status = chapter.status === 'approved' ? 'written' : chapter.status
|
|
1907
|
+
chapter.review = undefined
|
|
1908
|
+
project.updatedAt = new Date().toISOString()
|
|
1909
|
+
saveProject(outputDir, project)
|
|
1910
|
+
writeJson(res, 200, { ok: true, chapterNo: chapter.no, chars, snapshots: loadSnapshots(outputDir) })
|
|
1911
|
+
return
|
|
1912
|
+
}
|
|
1913
|
+
if (body?.op === 'remove') {
|
|
1914
|
+
const ok = removeSnapshot(outputDir, body.id ?? '')
|
|
1915
|
+
writeJson(res, ok ? 200 : 404, { ok, snapshots: loadSnapshots(outputDir) })
|
|
1916
|
+
return
|
|
1917
|
+
}
|
|
1918
|
+
if (body?.op === 'prune') {
|
|
1919
|
+
const removed = pruneSnapshots(outputDir, body.keep)
|
|
1920
|
+
writeJson(res, 200, { removed, snapshots: loadSnapshots(outputDir), bytes: snapshotsBytes(outputDir) })
|
|
1921
|
+
return
|
|
1922
|
+
}
|
|
1923
|
+
writeJson(res, 400, { error: `未知 op:${String(body?.op)}` })
|
|
1924
|
+
} catch (error) {
|
|
1925
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
1926
|
+
}
|
|
1927
|
+
},
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// --------------------------------------------------------------- tension
|
|
1931
|
+
/** 张力曲线:GET 数据(含规则核对);POST op=preset|set|clear。 */
|
|
1932
|
+
const tensionRoute: WebRoute = {
|
|
1933
|
+
kind: 'exact',
|
|
1934
|
+
path: NOVEL_API.tension,
|
|
1935
|
+
handler: async (req, res) => {
|
|
1936
|
+
const config = getConfig()
|
|
1937
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
1938
|
+
if (req.method === 'GET') {
|
|
1939
|
+
if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
|
|
1940
|
+
const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
|
|
1941
|
+
const project = loadProject(outputDir)
|
|
1942
|
+
if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
|
|
1943
|
+
const curve = buildCurveData(project)
|
|
1944
|
+
const response: TensionResponse = { preset: curve.preset, points: curve.points, issues: detectTensionIssues(project) }
|
|
1945
|
+
writeJson(res, 200, response)
|
|
1946
|
+
return
|
|
1947
|
+
}
|
|
1948
|
+
if (!guard(req, res, 'POST')) return
|
|
1949
|
+
const body = await readJsonBody<TensionRequest & { bookId?: string }>(req)
|
|
1950
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
1951
|
+
const project = loadProject(outputDir)
|
|
1952
|
+
if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
|
|
1953
|
+
if (body?.op === 'preset') {
|
|
1954
|
+
const preset = body.preset
|
|
1955
|
+
if (preset === undefined || !TENSION_PRESETS.includes(preset)) { writeJson(res, 400, { error: '未知的张力曲线预设' }); return }
|
|
1956
|
+
project.tensionCurve = { ...(project.tensionCurve ?? {}), preset }
|
|
1957
|
+
project.updatedAt = new Date().toISOString()
|
|
1958
|
+
saveProject(outputDir, project)
|
|
1959
|
+
writeJson(res, 200, { ok: true, preset })
|
|
1960
|
+
return
|
|
1961
|
+
}
|
|
1962
|
+
if (body?.op === 'set') {
|
|
1963
|
+
const no = body.chapterNo
|
|
1964
|
+
if (typeof no !== 'number' || !Number.isFinite(no)) { writeJson(res, 400, { error: 'set 需要 chapterNo' }); return }
|
|
1965
|
+
const chapter = project.chapters.find(c => c.no === no)
|
|
1966
|
+
if (chapter === undefined) { writeJson(res, 404, { error: `章节 ${no} 不在计划中` }); return }
|
|
1967
|
+
if (body.tension === undefined) {
|
|
1968
|
+
chapter.tension = undefined
|
|
1969
|
+
} else {
|
|
1970
|
+
if (body.tension < TENSION_MIN || body.tension > TENSION_MAX) { writeJson(res, 400, { error: `张力需在 ${TENSION_MIN}-${TENSION_MAX} 之间` }); return }
|
|
1971
|
+
chapter.tension = clampTension(body.tension)
|
|
1972
|
+
}
|
|
1973
|
+
project.updatedAt = new Date().toISOString()
|
|
1974
|
+
saveProject(outputDir, project)
|
|
1975
|
+
writeJson(res, 200, { ok: true, chapterNo: no, tension: chapter.tension })
|
|
1976
|
+
return
|
|
1977
|
+
}
|
|
1978
|
+
if (body?.op === 'clear') {
|
|
1979
|
+
for (const c of project.chapters) c.tension = undefined
|
|
1980
|
+
project.updatedAt = new Date().toISOString()
|
|
1981
|
+
saveProject(outputDir, project)
|
|
1982
|
+
writeJson(res, 200, { ok: true })
|
|
1983
|
+
return
|
|
1984
|
+
}
|
|
1985
|
+
writeJson(res, 400, { error: `未知 op:${String(body?.op)}` })
|
|
1986
|
+
},
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// ---------------------------------------------------------- prompt slots
|
|
1990
|
+
/** 提示词槽位:GET 列表(含说明与上限);POST 写入或清空单个槽位。 */
|
|
1991
|
+
const promptSlotsRoute: WebRoute = {
|
|
1992
|
+
kind: 'exact',
|
|
1993
|
+
path: NOVEL_API.promptSlots,
|
|
1994
|
+
handler: async (req, res) => {
|
|
1995
|
+
const config = getConfig()
|
|
1996
|
+
const qurl = new URL(req.url ?? '/', 'http://localhost')
|
|
1997
|
+
if (req.method === 'GET') {
|
|
1998
|
+
if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
|
|
1999
|
+
const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
|
|
2000
|
+
const project = loadProject(outputDir)
|
|
2001
|
+
if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
|
|
2002
|
+
const values = new Map(readPromptSlots(project).map(s => [s.id, s.text] as const))
|
|
2003
|
+
const response: PromptSlotsResponse = {
|
|
2004
|
+
slots: PROMPT_SLOTS.map(spec => ({
|
|
2005
|
+
id: spec.id,
|
|
2006
|
+
label: spec.label,
|
|
2007
|
+
hint: spec.hint,
|
|
2008
|
+
placeholder: spec.placeholder,
|
|
2009
|
+
maxChars: spec.maxChars,
|
|
2010
|
+
value: values.get(spec.id) ?? '',
|
|
2011
|
+
})),
|
|
2012
|
+
}
|
|
2013
|
+
writeJson(res, 200, response)
|
|
2014
|
+
return
|
|
2015
|
+
}
|
|
2016
|
+
if (!guard(req, res, 'POST')) return
|
|
2017
|
+
const body = await readJsonBody<PromptSlotsRequest & { bookId?: string }>(req)
|
|
2018
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
2019
|
+
const project = loadProject(outputDir)
|
|
2020
|
+
if (project === undefined) { writeJson(res, 400, { error: '输出目录中没有项目' }); return }
|
|
2021
|
+
let value: string
|
|
2022
|
+
try {
|
|
2023
|
+
value = normalizeSlotValue(String(body?.id ?? ''), String(body?.value ?? ''))
|
|
2024
|
+
} catch (error) {
|
|
2025
|
+
writeJson(res, 400, { error: (error as Error).message })
|
|
2026
|
+
return
|
|
2027
|
+
}
|
|
2028
|
+
const slots = { ...(project.promptSlots ?? {}) }
|
|
2029
|
+
if (value === '') delete slots[body!.id]
|
|
2030
|
+
else slots[body!.id] = value
|
|
2031
|
+
project.promptSlots = slots
|
|
2032
|
+
project.updatedAt = new Date().toISOString()
|
|
2033
|
+
saveProject(outputDir, project)
|
|
2034
|
+
writeJson(res, 200, { ok: true, id: body!.id, chars: value.length })
|
|
2035
|
+
},
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
// ------------------------------------------------------------- bookshelf
|
|
2039
|
+
const bookshelfRoute: WebRoute = {
|
|
2040
|
+
kind: 'exact',
|
|
2041
|
+
path: NOVEL_API.bookshelf,
|
|
2042
|
+
handler: async (req, res) => {
|
|
2043
|
+
// GET = snapshot; POST = create book.
|
|
2044
|
+
if (req.method === 'GET') {
|
|
2045
|
+
if (!isLoopbackRequest(req)) {
|
|
2046
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
2047
|
+
return
|
|
2048
|
+
}
|
|
2049
|
+
// 书架为空时,把 settings 默认输出目录里已有的项目播种为第一本书。
|
|
2050
|
+
seedBookshelfFromOutputDir(getConfig().outputDir)
|
|
2051
|
+
const snapshot: BookshelfSnapshot = bookshelfSnapshot(loadBookshelf())
|
|
2052
|
+
writeJson(res, 200, snapshot)
|
|
2053
|
+
return
|
|
2054
|
+
}
|
|
2055
|
+
if (req.method === 'POST') {
|
|
2056
|
+
if (!isLoopbackRequest(req)) {
|
|
2057
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
2058
|
+
return
|
|
2059
|
+
}
|
|
2060
|
+
const body = await readJsonBody<BookCreateRequest>(req)
|
|
2061
|
+
const bookName = body?.bookName?.trim()
|
|
2062
|
+
if (bookName === undefined || bookName === '') {
|
|
2063
|
+
writeJson(res, 400, { error: 'bookName 不能为空' })
|
|
2064
|
+
return
|
|
2065
|
+
}
|
|
2066
|
+
const outputDir = body?.outputDir?.trim() !== '' && body?.outputDir !== undefined
|
|
2067
|
+
? body.outputDir
|
|
2068
|
+
: defaultOutputDirFor(bookName)
|
|
2069
|
+
const book = createBook(bookName, outputDir)
|
|
2070
|
+
// 开书向导:创建时带大纲 → 立即建立项目(书名以大纲首行为准)。
|
|
2071
|
+
const outline = body?.outline?.trim()
|
|
2072
|
+
if (outline !== undefined && outline.length >= 50) {
|
|
2073
|
+
const project = createProject(outline)
|
|
2074
|
+
saveProject(outputDir, project)
|
|
2075
|
+
renameBook(book.id, project.bookName)
|
|
2076
|
+
}
|
|
2077
|
+
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
|
|
2078
|
+
return
|
|
2079
|
+
}
|
|
2080
|
+
writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
|
|
2081
|
+
},
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
// --------------------------------------------------------------- reset
|
|
2085
|
+
/** 重置项目:清空设定/卷/章节计划/正文/伏笔/资产/事实库(可携带新大纲)。 */
|
|
2086
|
+
const resetRoute: WebRoute = {
|
|
2087
|
+
kind: 'exact',
|
|
2088
|
+
path: NOVEL_API.reset,
|
|
2089
|
+
handler: async (req, res) => {
|
|
2090
|
+
if (!guard(req, res, 'POST')) return
|
|
2091
|
+
const config = getConfig()
|
|
2092
|
+
const project = loadProject(config.outputDir)
|
|
2093
|
+
if (project === undefined) {
|
|
2094
|
+
writeJson(res, 400, { error: '输出目录中没有项目,无需重置' })
|
|
2095
|
+
return
|
|
2096
|
+
}
|
|
2097
|
+
const body = await readJsonBody<ResetRequest>(req)
|
|
2098
|
+
const outline = body?.outline?.trim()
|
|
2099
|
+
if (outline !== undefined && outline.length >= 50) {
|
|
2100
|
+
project.outline = outline
|
|
2101
|
+
project.bookName = createProject(outline).bookName
|
|
2102
|
+
}
|
|
2103
|
+
project.bible = undefined
|
|
2104
|
+
project.volumes = undefined
|
|
2105
|
+
project.chapters = []
|
|
2106
|
+
project.foreshadows = []
|
|
2107
|
+
project.assets = emptyProjectAssets()
|
|
2108
|
+
project.facts = []
|
|
2109
|
+
project.updatedAt = new Date().toISOString()
|
|
2110
|
+
saveProject(config.outputDir, project)
|
|
2111
|
+
writeJson(res, 200, { ok: true, bookName: project.bookName })
|
|
2112
|
+
},
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
// --------------------------------------------------- bookshelf activate
|
|
2116
|
+
const bookshelfActivateRoute: WebRoute = {
|
|
2117
|
+
kind: 'exact',
|
|
2118
|
+
path: '/api/dsh-novel-forge/bookshelf/activate',
|
|
2119
|
+
handler: async (req, res) => {
|
|
2120
|
+
if (!guard(req, res, 'POST')) return
|
|
2121
|
+
const body = await readJsonBody<BookActivateRequest>(req)
|
|
2122
|
+
if (body?.id === undefined || body.id === '') {
|
|
2123
|
+
writeJson(res, 400, { error: 'id 不能为空' })
|
|
2124
|
+
return
|
|
2125
|
+
}
|
|
2126
|
+
const book = activateBook(body.id)
|
|
2127
|
+
if (book === undefined) {
|
|
2128
|
+
writeJson(res, 404, { error: `书 ${body.id} 不存在` })
|
|
2129
|
+
return
|
|
2130
|
+
}
|
|
2131
|
+
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
|
|
2132
|
+
},
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// ---------------------------------------------------- bookshelf remove
|
|
2136
|
+
const bookshelfRemoveRoute: WebRoute = {
|
|
2137
|
+
kind: 'exact',
|
|
2138
|
+
path: '/api/dsh-novel-forge/bookshelf/remove',
|
|
2139
|
+
handler: async (req, res) => {
|
|
2140
|
+
if (!guard(req, res, 'POST')) return
|
|
2141
|
+
const body = await readJsonBody<BookRemoveRequest>(req)
|
|
2142
|
+
if (body?.id === undefined || body.id === '') {
|
|
2143
|
+
writeJson(res, 400, { error: 'id 不能为空' })
|
|
2144
|
+
return
|
|
2145
|
+
}
|
|
2146
|
+
const removed = removeBook(body.id)
|
|
2147
|
+
if (!removed) {
|
|
2148
|
+
writeJson(res, 404, { error: `书 ${body.id} 不存在` })
|
|
2149
|
+
return
|
|
2150
|
+
}
|
|
2151
|
+
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
|
|
2152
|
+
},
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
// ------------------------------------------- bookshelf import-dir
|
|
2156
|
+
/** 导入已有项目目录(Mode A):校验 novel-project.json,登记/激活书架。 */
|
|
2157
|
+
const bookshelfImportDirRoute: WebRoute = {
|
|
2158
|
+
kind: 'exact',
|
|
2159
|
+
path: NOVEL_API.bookshelfImportDir,
|
|
2160
|
+
handler: async (req, res) => {
|
|
2161
|
+
if (!guard(req, res, 'POST')) return
|
|
2162
|
+
const body = await readJsonBody<BookImportDirRequest>(req)
|
|
2163
|
+
const outputDir = body?.outputDir?.trim()
|
|
2164
|
+
if (outputDir === undefined || outputDir === '') {
|
|
2165
|
+
writeJson(res, 400, { error: 'outputDir 不能为空' })
|
|
2166
|
+
return
|
|
2167
|
+
}
|
|
2168
|
+
try {
|
|
2169
|
+
const { book, existed } = importDir(outputDir)
|
|
2170
|
+
writeJson(res, 200, { book, existed })
|
|
2171
|
+
} catch (err) {
|
|
2172
|
+
writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
|
|
2173
|
+
}
|
|
2174
|
+
},
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
// ------------------------------------------- bookshelf import-text
|
|
2178
|
+
/** 导入 txt/md 全本(Mode B):拆章落盘建项目,登记书架。支持浏览器上传(text+fileName)与服务器本地文件(filePath)两种模式。 */
|
|
2179
|
+
const bookshelfImportTextRoute: WebRoute = {
|
|
2180
|
+
kind: 'exact',
|
|
2181
|
+
path: NOVEL_API.bookshelfImportText,
|
|
2182
|
+
handler: async (req, res) => {
|
|
2183
|
+
if (!guard(req, res, 'POST')) return
|
|
2184
|
+
const body = await readJsonBody<BookImportTextRequest>(req)
|
|
2185
|
+
try {
|
|
2186
|
+
// 模式二:浏览器上传全文内容
|
|
2187
|
+
if (body?.text !== undefined && body.text.length > 0) {
|
|
2188
|
+
const bookName = (body.fileName ?? '').replace(/\.[^.]+$/, '').trim().slice(0, 40) || '导入小说'
|
|
2189
|
+
const outDir = body?.outputDir?.trim() !== undefined && body.outputDir.trim() !== ''
|
|
2190
|
+
? body.outputDir.trim()
|
|
2191
|
+
: defaultOutputDirFor(bookName)
|
|
2192
|
+
const result = importBookTextFromText(body.text, outDir, bookName)
|
|
2193
|
+
const { book } = importDir(outDir)
|
|
2194
|
+
writeJson(res, 200, { ...result, book })
|
|
2195
|
+
return
|
|
2196
|
+
}
|
|
2197
|
+
// 模式一:服务器本地文件
|
|
2198
|
+
const filePath = body?.filePath?.trim()
|
|
2199
|
+
if (filePath === undefined || filePath === '') {
|
|
2200
|
+
writeJson(res, 400, { error: 'filePath 不能为空(或请上传文件内容)' })
|
|
2201
|
+
return
|
|
2202
|
+
}
|
|
2203
|
+
if (!existsSync(filePath)) {
|
|
2204
|
+
writeJson(res, 400, { error: `文件不存在:${filePath}` })
|
|
2205
|
+
return
|
|
2206
|
+
}
|
|
2207
|
+
const bookName = basename(filePath, extname(filePath)).slice(0, 40) || '导入小说'
|
|
2208
|
+
const outDir = body?.outputDir?.trim() !== undefined && body.outputDir.trim() !== ''
|
|
2209
|
+
? body.outputDir.trim()
|
|
2210
|
+
: defaultOutputDirFor(bookName)
|
|
2211
|
+
const result = importBookText(filePath, outDir)
|
|
2212
|
+
const { book } = importDir(outDir)
|
|
2213
|
+
writeJson(res, 200, { ...result, book })
|
|
2214
|
+
} catch (err) {
|
|
2215
|
+
writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
|
|
2216
|
+
}
|
|
2217
|
+
},
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
// --------------------------------------- bookshelf import-text preview
|
|
2221
|
+
/** 上传全文做拆章预览(不落盘、不登记书架)。 */
|
|
2222
|
+
const bookshelfImportTextPreviewRoute: WebRoute = {
|
|
2223
|
+
kind: 'exact',
|
|
2224
|
+
path: NOVEL_API.bookshelfImportTextPreview,
|
|
2225
|
+
handler: async (req, res) => {
|
|
2226
|
+
if (!guard(req, res, 'POST')) return
|
|
2227
|
+
const body = await readJsonBody<BookImportTextPreviewRequest>(req)
|
|
2228
|
+
if (body?.text === undefined || body.text.length === 0) {
|
|
2229
|
+
writeJson(res, 400, { error: 'text 不能为空' })
|
|
2230
|
+
return
|
|
2231
|
+
}
|
|
2232
|
+
try {
|
|
2233
|
+
const bookName = (body.fileName ?? '').replace(/\.[^.]+$/, '').trim().slice(0, 40) || '导入小说'
|
|
2234
|
+
const preview = previewBookText(body.text)
|
|
2235
|
+
writeJson(res, 200, { bookName, ...preview })
|
|
2236
|
+
} catch (err) {
|
|
2237
|
+
writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
|
|
2238
|
+
}
|
|
2239
|
+
},
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
// ---------------------------------------------------------------- audit
|
|
2243
|
+
/** 全书一致性质检。 */
|
|
2244
|
+
const auditRoute: WebRoute = {
|
|
2245
|
+
kind: 'exact',
|
|
2246
|
+
path: NOVEL_API.audit,
|
|
2247
|
+
handler: async (req, res) => {
|
|
2248
|
+
if (!guard(req, res, 'POST')) return
|
|
2249
|
+
const body = await readJsonBody<{ bookId?: string }>(req)
|
|
2250
|
+
const config0 = getConfig()
|
|
2251
|
+
const outputDir = resolveOutputDir(config0, body?.bookId)
|
|
2252
|
+
const config = { ...config0, outputDir }
|
|
2253
|
+
const project = loadProject(outputDir)
|
|
2254
|
+
if (project === undefined) {
|
|
2255
|
+
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
2256
|
+
return
|
|
2257
|
+
}
|
|
2258
|
+
const startedAt = new Date().toISOString()
|
|
2259
|
+
auditState = { status: 'running', startedAt, totalBatches: 0, completedBatches: 0 }
|
|
2260
|
+
try {
|
|
2261
|
+
const issues = await auditBook(ctx, config, project, config.outputDir, (completed, total) => {
|
|
2262
|
+
auditState.totalBatches = total
|
|
2263
|
+
auditState.completedBatches = completed
|
|
2264
|
+
})
|
|
2265
|
+
const auditedChapters = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating').length
|
|
2266
|
+
auditState = {
|
|
2267
|
+
status: 'done',
|
|
2268
|
+
startedAt,
|
|
2269
|
+
finishedAt: new Date().toISOString(),
|
|
2270
|
+
totalBatches: auditState.totalBatches,
|
|
2271
|
+
completedBatches: auditState.totalBatches,
|
|
2272
|
+
auditedChapters,
|
|
2273
|
+
issuesCount: issues.length,
|
|
2274
|
+
issues,
|
|
2275
|
+
}
|
|
2276
|
+
const response: AuditResponse = {
|
|
2277
|
+
issues,
|
|
2278
|
+
auditedChapters,
|
|
2279
|
+
auditedAt: auditState.finishedAt!,
|
|
2280
|
+
model: config.auditModel || config.model,
|
|
2281
|
+
}
|
|
2282
|
+
writeJson(res, 200, response)
|
|
2283
|
+
} catch (error) {
|
|
2284
|
+
auditState = {
|
|
2285
|
+
...auditState,
|
|
2286
|
+
status: 'error',
|
|
2287
|
+
finishedAt: new Date().toISOString(),
|
|
2288
|
+
error: (error as Error).message,
|
|
2289
|
+
}
|
|
2290
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
2291
|
+
}
|
|
2292
|
+
},
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
// ----------------------------------------------------- characters refresh
|
|
2296
|
+
/** 角色卡刷新(出场统计精确化 + LLM 聚合状态)。 */
|
|
2297
|
+
const charactersRefreshRoute: WebRoute = {
|
|
2298
|
+
kind: 'exact',
|
|
2299
|
+
path: NOVEL_API.charactersRefresh,
|
|
2300
|
+
handler: async (req, res) => {
|
|
2301
|
+
if (!guard(req, res, 'POST')) return
|
|
2302
|
+
const config = getConfig()
|
|
2303
|
+
const project = requireProject(res)
|
|
1266
2304
|
if (project === undefined) return
|
|
1267
2305
|
try {
|
|
1268
2306
|
const cards = await refreshCharacters(ctx, config, project, config.outputDir)
|
|
@@ -1297,7 +2335,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1297
2335
|
}
|
|
1298
2336
|
|
|
1299
2337
|
// ---------------------------------------------------------- bible patch
|
|
1300
|
-
/**
|
|
2338
|
+
/** 道藏局部修补(世界观规则/红线/风格)。 */
|
|
1301
2339
|
const biblePatchRoute: WebRoute = {
|
|
1302
2340
|
kind: 'exact',
|
|
1303
2341
|
path: NOVEL_API.biblePatch,
|
|
@@ -1337,7 +2375,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1337
2375
|
}
|
|
1338
2376
|
|
|
1339
2377
|
// ---------------------------------------------------------------- blurb
|
|
1340
|
-
/**
|
|
2378
|
+
/** 小说简介:生成/补全,或手动保存。 */
|
|
1341
2379
|
const blurbRoute: WebRoute = {
|
|
1342
2380
|
kind: 'exact',
|
|
1343
2381
|
path: NOVEL_API.blurb,
|
|
@@ -1451,7 +2489,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1451
2489
|
}
|
|
1452
2490
|
|
|
1453
2491
|
// ---------------------------------------------------------------- world
|
|
1454
|
-
/**
|
|
2492
|
+
/** 大世界:提炼或手动保存(境界体系/区域/势力)。 */
|
|
1455
2493
|
const worldRoute: WebRoute = {
|
|
1456
2494
|
kind: 'exact',
|
|
1457
2495
|
path: NOVEL_API.world,
|
|
@@ -1563,7 +2601,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1563
2601
|
writeJson(res, 200, { plotlines: project.plotlines, suggestions } satisfies PlotlinesResponse)
|
|
1564
2602
|
return
|
|
1565
2603
|
} catch (error) {
|
|
1566
|
-
writeJson(res, 500, { error:
|
|
2604
|
+
writeJson(res, 500, { error: `建议失败:${(error as Error).message}` })
|
|
1567
2605
|
return
|
|
1568
2606
|
}
|
|
1569
2607
|
} else if (op === 'refresh' && body?.id !== undefined) {
|
|
@@ -1658,7 +2696,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1658
2696
|
}
|
|
1659
2697
|
|
|
1660
2698
|
// ---------------------------------------------------------------- roles
|
|
1661
|
-
/**
|
|
2699
|
+
/** 角色库:提炼 / 采纳 / 更新 / 删除。 */
|
|
1662
2700
|
const rolesRoute: WebRoute = {
|
|
1663
2701
|
kind: 'exact',
|
|
1664
2702
|
path: NOVEL_API.roles,
|
|
@@ -1686,111 +2724,6 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1686
2724
|
else project.roles[idx] = r
|
|
1687
2725
|
} else if (op === 'remove' && body?.name !== undefined) {
|
|
1688
2726
|
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: `角色 ${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
2727
|
} else {
|
|
1795
2728
|
writeJson(res, 400, { error: '未知的 roles op' })
|
|
1796
2729
|
return
|
|
@@ -1942,77 +2875,254 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
1942
2875
|
console.warn(`[dsh-novel-forge] author backfill ch.${chapter.no} failed:`, (error as Error).message)
|
|
1943
2876
|
}
|
|
1944
2877
|
}
|
|
1945
|
-
project.updatedAt = new Date().toISOString()
|
|
1946
|
-
saveProject(config.outputDir, project)
|
|
1947
|
-
send({ type: 'author-backfill-done', count: done })
|
|
1948
|
-
res.end()
|
|
2878
|
+
project.updatedAt = new Date().toISOString()
|
|
2879
|
+
saveProject(config.outputDir, project)
|
|
2880
|
+
send({ type: 'author-backfill-done', count: done })
|
|
2881
|
+
res.end()
|
|
2882
|
+
},
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
// --------------------------------------------------------------- config
|
|
2886
|
+
const configRoute: WebRoute = {
|
|
2887
|
+
kind: 'exact',
|
|
2888
|
+
path: NOVEL_API.config,
|
|
2889
|
+
handler: async (req, res) => {
|
|
2890
|
+
if (!guard(req, res, 'POST')) return
|
|
2891
|
+
const body = await readJsonBody<ConfigPatch>(req)
|
|
2892
|
+
if (body === undefined) {
|
|
2893
|
+
writeJson(res, 400, { error: '无效的配置 JSON' })
|
|
2894
|
+
return
|
|
2895
|
+
}
|
|
2896
|
+
try {
|
|
2897
|
+
const next = await patchConfig(body)
|
|
2898
|
+
writeJson(res, 200, { config: next })
|
|
2899
|
+
} catch (error) {
|
|
2900
|
+
writeJson(res, 400, { error: (error as Error).message })
|
|
2901
|
+
}
|
|
2902
|
+
},
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
// ---------------------------------------------------------- open-folder
|
|
2906
|
+
const openFolderRoute: WebRoute = {
|
|
2907
|
+
kind: 'exact',
|
|
2908
|
+
path: NOVEL_API.openFolder,
|
|
2909
|
+
handler: async (req, res) => {
|
|
2910
|
+
if (!guard(req, res, 'POST')) return
|
|
2911
|
+
const config = getConfig()
|
|
2912
|
+
const dir = config.outputDir
|
|
2913
|
+
const child = spawn('explorer', [dir], { shell: false })
|
|
2914
|
+
let responded = false
|
|
2915
|
+
child.on('error', (error) => {
|
|
2916
|
+
if (responded) return
|
|
2917
|
+
responded = true
|
|
2918
|
+
writeJson(res, 500, { ok: false, error: error.message })
|
|
2919
|
+
})
|
|
2920
|
+
child.on('exit', (code) => {
|
|
2921
|
+
if (responded) return
|
|
2922
|
+
responded = true
|
|
2923
|
+
if (code === 0) writeJson(res, 200, { ok: true })
|
|
2924
|
+
else writeJson(res, 500, { ok: false, error: `explorer 退出码 ${code}` })
|
|
2925
|
+
})
|
|
2926
|
+
},
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
// ---------------------------------------------------------- plugin update
|
|
2930
|
+
/** 插件自更新:在 DSH profile 目录拉取最新 npm 版(仅下载,需重启 DSH 生效)。 */
|
|
2931
|
+
const pluginUpdateRoute: WebRoute = {
|
|
2932
|
+
kind: 'exact',
|
|
2933
|
+
path: NOVEL_API.pluginUpdate,
|
|
2934
|
+
handler: async (req, res) => {
|
|
2935
|
+
if (!guard(req, res, 'POST')) return
|
|
2936
|
+
const profileDir = join(homedir(), '.dsh', 'profiles', 'web')
|
|
2937
|
+
const response: PluginUpdateResponse = await new Promise((resolve) => {
|
|
2938
|
+
const child = spawn('pnpm', ['add', '@waterwx/dsh-novel-forge@latest'], { cwd: profileDir, shell: true })
|
|
2939
|
+
let acc = ''
|
|
2940
|
+
child.stdout.on('data', (d: Buffer) => { acc += String(d) })
|
|
2941
|
+
child.stderr.on('data', (d: Buffer) => { acc += String(d) })
|
|
2942
|
+
child.on('error', (error) => resolve({ ok: false, message: '无法执行更新:' + error.message }))
|
|
2943
|
+
child.on('close', (code) => {
|
|
2944
|
+
if (code === 0) resolve({ ok: true, message: '已更新到最新版本,请重启 DSH 生效。' })
|
|
2945
|
+
else resolve({ ok: false, message: '更新失败(退出码 ' + code + '):' + acc.slice(-400) })
|
|
2946
|
+
})
|
|
2947
|
+
})
|
|
2948
|
+
writeJson(res, 200, response)
|
|
2949
|
+
},
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
// ------------------------------------------------------- outline suggest
|
|
2953
|
+
/** 开书想法 → AI 大纲:2-3 个可选方案(支持暂留换批:count 只补空槽 + exclude 避开已留方向)。 */
|
|
2954
|
+
const outlineSuggestRoute: WebRoute = {
|
|
2955
|
+
kind: 'exact',
|
|
2956
|
+
path: NOVEL_API.outlineSuggest,
|
|
2957
|
+
handler: async (req, res) => {
|
|
2958
|
+
if (!guard(req, res, 'POST')) return
|
|
2959
|
+
const config = getConfig()
|
|
2960
|
+
const body = await readJsonBody<OutlineSuggestRequest>(req)
|
|
2961
|
+
const idea = body?.idea?.trim() ?? ''
|
|
2962
|
+
if (idea.length < 50) {
|
|
2963
|
+
writeJson(res, 400, { error: '想法太短(<50 字),请多写一两句:主角是谁、什么世界、想要什么爽点' })
|
|
2964
|
+
return
|
|
2965
|
+
}
|
|
2966
|
+
const count = body?.count !== undefined ? Math.max(1, Math.min(3, Math.floor(body.count))) : 3
|
|
2967
|
+
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)) : []
|
|
2968
|
+
try {
|
|
2969
|
+
const candidates = await suggestOutlines(ctx, config, idea, count, exclude)
|
|
2970
|
+
const response: OutlineSuggestResponse = { candidates }
|
|
2971
|
+
writeJson(res, 200, response)
|
|
2972
|
+
} catch (error) {
|
|
2973
|
+
writeJson(res, 500, { error: `大纲方案生成失败:${(error as Error).message}` })
|
|
2974
|
+
}
|
|
2975
|
+
},
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
// ------------------------------------------------- outline reverse
|
|
2979
|
+
/** 反推大纲:从已写章节正文反向生成全书总纲(NDJSON 流式进度)。 */
|
|
2980
|
+
const outlineReverseRoute: WebRoute = {
|
|
2981
|
+
kind: 'exact',
|
|
2982
|
+
path: NOVEL_API.outlineReverse,
|
|
2983
|
+
handler: async (req, res) => {
|
|
2984
|
+
if (!guard(req, res, 'POST')) return
|
|
2985
|
+
const config = getConfig()
|
|
2986
|
+
const project = requireProject(res)
|
|
2987
|
+
if (project === undefined) return
|
|
2988
|
+
res.writeHead(200, {
|
|
2989
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
2990
|
+
'cache-control': 'no-cache',
|
|
2991
|
+
'x-accel-buffering': 'no',
|
|
2992
|
+
'referrer-policy': 'no-referrer',
|
|
2993
|
+
})
|
|
2994
|
+
const send = (frame: JobFrame): void => {
|
|
2995
|
+
res.write(JSON.stringify(frame) + '\n')
|
|
2996
|
+
}
|
|
2997
|
+
try {
|
|
2998
|
+
const outline = await reverseOutlineFromChapters(ctx, config, project, config.outputDir, (done, total, phase) => {
|
|
2999
|
+
send({ type: 'outline-progress', done, total, phase })
|
|
3000
|
+
})
|
|
3001
|
+
// 保存总纲(仅更新文本,不动书名/进度)。
|
|
3002
|
+
project.outline = outline
|
|
3003
|
+
project.updatedAt = new Date().toISOString()
|
|
3004
|
+
saveProject(config.outputDir, project)
|
|
3005
|
+
send({ type: 'outline-done', outline, chars: outline.length })
|
|
3006
|
+
} catch (error) {
|
|
3007
|
+
send({ type: 'error', no: 0, message: (error as Error).message })
|
|
3008
|
+
} finally {
|
|
3009
|
+
res.end()
|
|
3010
|
+
}
|
|
3011
|
+
},
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
// ------------------------------------------------------------- llm-test
|
|
3015
|
+
/** LLM 连通性测试:对选中的提供商/模型发一次最小真实调用。 */
|
|
3016
|
+
const llmTestRoute: WebRoute = {
|
|
3017
|
+
kind: 'exact',
|
|
3018
|
+
path: NOVEL_API.llmTest,
|
|
3019
|
+
handler: async (req, res) => {
|
|
3020
|
+
if (!guard(req, res, 'POST')) return
|
|
3021
|
+
const body = await readJsonBody<LlmTestRequest>(req)
|
|
3022
|
+
const provider = (body?.provider ?? '').trim()
|
|
3023
|
+
const model = (body?.model ?? '').trim()
|
|
3024
|
+
if (provider === '' || model === '') {
|
|
3025
|
+
writeJson(res, 400, { error: 'provider 与 model 必填' })
|
|
3026
|
+
return
|
|
3027
|
+
}
|
|
3028
|
+
const result = await testLlmModel(ctx, provider, model)
|
|
3029
|
+
writeJson(res, 200, result satisfies LlmTestResponse)
|
|
1949
3030
|
},
|
|
1950
3031
|
}
|
|
1951
3032
|
|
|
1952
|
-
//
|
|
1953
|
-
|
|
3033
|
+
// ------------------------------------------------------------- llm-add
|
|
3034
|
+
/** 添加模型:厂商直填 API Key,或自定义 OpenAI 兼容路由(写 DSH 凭据 + router)。 */
|
|
3035
|
+
const addModelRoute: WebRoute = {
|
|
1954
3036
|
kind: 'exact',
|
|
1955
|
-
path: NOVEL_API.
|
|
3037
|
+
path: NOVEL_API.addModel,
|
|
1956
3038
|
handler: async (req, res) => {
|
|
1957
3039
|
if (!guard(req, res, 'POST')) return
|
|
1958
|
-
const body = await readJsonBody<
|
|
3040
|
+
const body = await readJsonBody<AddModelRequest>(req)
|
|
1959
3041
|
if (body === undefined) {
|
|
1960
|
-
writeJson(res, 400, { error: '
|
|
3042
|
+
writeJson(res, 400, { error: '缺少请求体' })
|
|
1961
3043
|
return
|
|
1962
3044
|
}
|
|
1963
3045
|
try {
|
|
1964
|
-
const
|
|
1965
|
-
writeJson(res, 200,
|
|
3046
|
+
const result = await registerLlmModel(ctx, body)
|
|
3047
|
+
writeJson(res, 200, result satisfies AddModelResponse)
|
|
1966
3048
|
} catch (error) {
|
|
1967
|
-
writeJson(res,
|
|
3049
|
+
writeJson(res, 500, { error: `添加模型失败:${(error as Error).message}` })
|
|
1968
3050
|
}
|
|
1969
3051
|
},
|
|
1970
3052
|
}
|
|
1971
3053
|
|
|
1972
|
-
// ----------------------------------------------------------
|
|
1973
|
-
|
|
3054
|
+
// ---------------------------------------------------------- llm-vendors
|
|
3055
|
+
/** 运行时厂商目录(DSH pi-ai 可配置提供方 + 内置适配器)。 */
|
|
3056
|
+
const llmVendorsRoute: WebRoute = {
|
|
1974
3057
|
kind: 'exact',
|
|
1975
|
-
path: NOVEL_API.
|
|
3058
|
+
path: NOVEL_API.llmVendors,
|
|
1976
3059
|
handler: async (req, res) => {
|
|
1977
|
-
if (!guard(req, res, '
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
writeJson(res, 200, { ok: true })
|
|
1985
|
-
}
|
|
1986
|
-
})
|
|
3060
|
+
if (!guard(req, res, 'GET')) return
|
|
3061
|
+
try {
|
|
3062
|
+
const result = await listLlmVendors(ctx)
|
|
3063
|
+
writeJson(res, 200, result satisfies LlmVendorsResponse)
|
|
3064
|
+
} catch (error) {
|
|
3065
|
+
writeJson(res, 500, { error: `读取厂商目录失败:${(error as Error).message}` })
|
|
3066
|
+
}
|
|
1987
3067
|
},
|
|
1988
3068
|
}
|
|
1989
3069
|
|
|
1990
|
-
//
|
|
1991
|
-
/**
|
|
1992
|
-
const
|
|
3070
|
+
// ---------------------------------------------------------- llm-models
|
|
3071
|
+
/** 查询某个 provider 当前可用的模型(添加成功后可即时刷新下拉)。 */
|
|
3072
|
+
const llmModelsRoute: WebRoute = {
|
|
1993
3073
|
kind: 'exact',
|
|
1994
|
-
path: NOVEL_API.
|
|
3074
|
+
path: NOVEL_API.llmModels,
|
|
3075
|
+
handler: async (req, res) => {
|
|
3076
|
+
if (!guard(req, res, 'GET')) return
|
|
3077
|
+
const url = new URL(req.url ?? '', 'http://localhost')
|
|
3078
|
+
const provider = decodeURIComponent((url.searchParams.get('provider') ?? '').trim())
|
|
3079
|
+
try {
|
|
3080
|
+
const result = await listLlmModels(ctx, provider)
|
|
3081
|
+
writeJson(res, 200, result satisfies LlmModelsResponse)
|
|
3082
|
+
} catch (error) {
|
|
3083
|
+
writeJson(res, 500, { error: `读取模型列表失败:${(error as Error).message}` })
|
|
3084
|
+
}
|
|
3085
|
+
},
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
// ---------------------------------------------------------- llm-providers
|
|
3089
|
+
/** 已注册的提供方路由列表(提供方管理卡片)。 */
|
|
3090
|
+
const llmProvidersRoute: WebRoute = {
|
|
3091
|
+
kind: 'exact',
|
|
3092
|
+
path: NOVEL_API.llmProviders,
|
|
3093
|
+
handler: async (req, res) => {
|
|
3094
|
+
if (!guard(req, res, 'GET')) return
|
|
3095
|
+
try {
|
|
3096
|
+
const result = await listLlmProviders(ctx)
|
|
3097
|
+
writeJson(res, 200, result satisfies LlmProvidersResponse)
|
|
3098
|
+
} catch (error) {
|
|
3099
|
+
writeJson(res, 500, { error: `读取提供方列表失败:${(error as Error).message}` })
|
|
3100
|
+
}
|
|
3101
|
+
},
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
// ------------------------------------------------------------- llm-remove
|
|
3105
|
+
/** 移除一个提供方(unset key + 移除 llm-pi-ai 路由)。 */
|
|
3106
|
+
const llmRemoveRoute: WebRoute = {
|
|
3107
|
+
kind: 'exact',
|
|
3108
|
+
path: NOVEL_API.llmRemove,
|
|
1995
3109
|
handler: async (req, res) => {
|
|
1996
3110
|
if (!guard(req, res, 'POST')) return
|
|
1997
|
-
const
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
if (idea.length < 50) {
|
|
2001
|
-
writeJson(res, 400, { error: '想法太短(<50 字),请多写一两句:主角是谁、什么世界、想要什么爽点' })
|
|
3111
|
+
const body = await readJsonBody<RemoveProviderRequest>(req)
|
|
3112
|
+
if (body === undefined) {
|
|
3113
|
+
writeJson(res, 400, { error: '缺少请求体' })
|
|
2002
3114
|
return
|
|
2003
3115
|
}
|
|
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
3116
|
try {
|
|
2007
|
-
const
|
|
2008
|
-
|
|
2009
|
-
writeJson(res, 200, response)
|
|
3117
|
+
const result = await removeLlmProvider(ctx, body)
|
|
3118
|
+
writeJson(res, 200, result satisfies RemoveProviderResponse)
|
|
2010
3119
|
} catch (error) {
|
|
2011
|
-
writeJson(res, 500, { error:
|
|
3120
|
+
writeJson(res, 500, { error: `移除提供方失败:${(error as Error).message}` })
|
|
2012
3121
|
}
|
|
2013
3122
|
},
|
|
2014
3123
|
}
|
|
2015
3124
|
|
|
3125
|
+
|
|
2016
3126
|
// ----------------------------------------------------------- breakdown
|
|
2017
3127
|
/** 拆书分析:对已写章节做结构/人物/文风/卖点体检(两阶段:源笔记→分节分析)。 */
|
|
2018
3128
|
const breakdownRoute: WebRoute = {
|
|
@@ -2041,114 +3151,128 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2041
3151
|
},
|
|
2042
3152
|
}
|
|
2043
3153
|
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
3154
|
+
|
|
3155
|
+
// ----------------------------------------------------------- production run
|
|
3156
|
+
/** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
|
|
3157
|
+
const runner = new ProductionRunner({ ctx, getConfig })
|
|
3158
|
+
|
|
3159
|
+
// --------------------------------------------------------- move-output-dir
|
|
3160
|
+
/**
|
|
3161
|
+
* 输出目录迁移:dryRun 预览;确认后 copy 全部内容 → 书架重指向 → settings
|
|
3162
|
+
* 默认目录同步(仅当默认目录就是被搬目录)→ 最后删源目录(数据零丢失排序)。
|
|
3163
|
+
*/
|
|
3164
|
+
const moveOutputDirRoute: WebRoute = {
|
|
2047
3165
|
kind: 'exact',
|
|
2048
|
-
path: NOVEL_API.
|
|
3166
|
+
path: NOVEL_API.moveOutputDir,
|
|
2049
3167
|
handler: async (req, res) => {
|
|
2050
3168
|
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') {
|
|
3169
|
+
const from = getConfig().outputDir
|
|
3170
|
+
const body = await readJsonBody<MoveOutputDirRequest>(req)
|
|
3171
|
+
// 预览:只列内容,不搬移。
|
|
3172
|
+
if (body?.dryRun === true) {
|
|
2058
3173
|
try {
|
|
2059
|
-
const
|
|
2060
|
-
writeJson(res, 200, {
|
|
2061
|
-
return
|
|
3174
|
+
const contents = listDirContents(from)
|
|
3175
|
+
writeJson(res, 200, { from, files: contents.files.slice(0, 300), bytes: contents.bytes, movedBooks: 0, defaultUpdated: false } satisfies MoveOutputDirResponse)
|
|
2062
3176
|
} 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: `场景 ${name} 不存在` })
|
|
2083
|
-
return
|
|
3177
|
+
writeJson(res, 400, { error: (error as Error).message })
|
|
2084
3178
|
}
|
|
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
3179
|
return
|
|
2099
3180
|
}
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
3181
|
+
if (runner.isWorking()) {
|
|
3182
|
+
writeJson(res, 409, { error: '生产单正在运行中,请先停止后再迁移目录' })
|
|
3183
|
+
return
|
|
3184
|
+
}
|
|
3185
|
+
const to = body?.to?.trim() ?? ''
|
|
3186
|
+
if (to === '') {
|
|
3187
|
+
writeJson(res, 400, { error: '新目录路径不能为空' })
|
|
3188
|
+
return
|
|
3189
|
+
}
|
|
3190
|
+
if (normalizeDir(to) === normalizeDir(from)) {
|
|
3191
|
+
writeJson(res, 400, { error: '新目录与当前目录相同' })
|
|
3192
|
+
return
|
|
3193
|
+
}
|
|
3194
|
+
try {
|
|
3195
|
+
// 1. copy 全部内容(源目录保留;失败即中止,源数据无损)。
|
|
3196
|
+
copyDirContents(from, to)
|
|
3197
|
+
} catch (error) {
|
|
3198
|
+
writeJson(res, 400, { error: `迁移中止:${(error as Error).message}` })
|
|
3199
|
+
return
|
|
3200
|
+
}
|
|
3201
|
+
// 2. 书架重指向(outputDir === from 的条目全部改到 to)。
|
|
3202
|
+
let movedBooks = 0
|
|
3203
|
+
let repointError = ''
|
|
3204
|
+
try { movedBooks = repointBookDirs(from, to) } catch (error) { repointError = (error as Error).message }
|
|
3205
|
+
// 3. settings 默认目录同步(仅当默认目录就是被搬的这个)。
|
|
3206
|
+
let defaultUpdated = false
|
|
3207
|
+
let defaultError = ''
|
|
3208
|
+
const raw = deps.rawConfig?.()
|
|
3209
|
+
if (raw !== undefined && raw.outputDir !== undefined && raw.outputDir !== '' && normalizeDir(raw.outputDir) === normalizeDir(from)) {
|
|
3210
|
+
try {
|
|
3211
|
+
await patchConfig({ outputDir: to })
|
|
3212
|
+
defaultUpdated = true
|
|
3213
|
+
} catch (error) { defaultError = (error as Error).message }
|
|
3214
|
+
}
|
|
3215
|
+
// 4. 收尾:重指向全部成功才删源目录;否则保留源目录兜底并如实上报。
|
|
3216
|
+
if (repointError === '' && defaultError === '') {
|
|
3217
|
+
removeDir(from)
|
|
3218
|
+
const contents = listDirContents(to)
|
|
3219
|
+
writeJson(res, 200, { from, to, files: contents.files.slice(0, 300), bytes: contents.bytes, movedBooks, defaultUpdated } satisfies MoveOutputDirResponse)
|
|
3220
|
+
return
|
|
3221
|
+
}
|
|
3222
|
+
writeJson(res, 500, {
|
|
3223
|
+
error: `文件已复制到 ${to},但收尾未完成${repointError !== '' ? `(书架重指向失败:${repointError})` : ''}${defaultError !== '' ? `(默认目录更新失败:${defaultError})` : ''}。源目录已保留,请检查后重试或手动处理。`,
|
|
3224
|
+
from,
|
|
3225
|
+
to,
|
|
3226
|
+
movedBooks,
|
|
3227
|
+
defaultUpdated,
|
|
3228
|
+
})
|
|
2103
3229
|
},
|
|
2104
3230
|
}
|
|
2105
3231
|
|
|
2106
|
-
// -----------------------------------------------------------
|
|
2107
|
-
/**
|
|
2108
|
-
|
|
3232
|
+
// ----------------------------------------------------------- book-settings
|
|
3233
|
+
/**
|
|
3234
|
+
* 本书参数:POST {} 读取当前书的 settings + 生效配置(全局+本书回退链);
|
|
3235
|
+
* POST {patch} 白名单合并保存到 novel-project.json.bookSettings。
|
|
3236
|
+
*/
|
|
3237
|
+
const bookSettingsRoute: WebRoute = {
|
|
2109
3238
|
kind: 'exact',
|
|
2110
|
-
path: NOVEL_API.
|
|
3239
|
+
path: NOVEL_API.bookSettings,
|
|
2111
3240
|
handler: async (req, res) => {
|
|
2112
3241
|
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}` })
|
|
3242
|
+
const outputDir = getConfig().outputDir
|
|
3243
|
+
const body = await readJsonBody<BookSettingsRequest>(req)
|
|
3244
|
+
if (body?.patch !== undefined) {
|
|
3245
|
+
const project = loadProject(outputDir)
|
|
3246
|
+
if (project === undefined) {
|
|
3247
|
+
writeJson(res, 400, { error: '当前没有打开的书(未找到 novel-project.json)' })
|
|
2127
3248
|
return
|
|
2128
3249
|
}
|
|
2129
|
-
|
|
2130
|
-
|
|
3250
|
+
const cur: BookSettings = project.bookSettings ?? {}
|
|
3251
|
+
const p = body.patch
|
|
3252
|
+
const next: Record<string, unknown> = { ...cur }
|
|
3253
|
+
for (const key of ['provider', 'model', 'reasoningEffort', 'analysisReasoning', 'chapterChars', 'maxTokens', 'reviewPassScore', 'autoReview', 'autoAuthorReview', 'autoReviewAfterRevise'] as const) {
|
|
3254
|
+
const v = (p as Record<string, unknown>)[key]
|
|
3255
|
+
if (v === null) delete next[key]
|
|
3256
|
+
else if (v !== undefined) next[key] = v
|
|
3257
|
+
}
|
|
3258
|
+
project.bookSettings = next as BookSettings
|
|
2131
3259
|
project.updatedAt = new Date().toISOString()
|
|
2132
|
-
saveProject(
|
|
2133
|
-
writeJson(res, 200, { rules: project.visualRules } satisfies VisualRulesResponse)
|
|
2134
|
-
return
|
|
3260
|
+
saveProject(outputDir, project)
|
|
2135
3261
|
}
|
|
2136
|
-
|
|
3262
|
+
const project = loadProject(outputDir)
|
|
3263
|
+
writeJson(res, 200, { settings: project?.bookSettings ?? {}, effective: getConfig(), hasBook: project !== undefined } satisfies BookSettingsResponse)
|
|
2137
3264
|
},
|
|
2138
3265
|
}
|
|
2139
3266
|
|
|
2140
|
-
// ----------------------------------------------------------- production run
|
|
2141
|
-
/** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
|
|
2142
|
-
const runner = new ProductionRunner({ ctx, getConfig })
|
|
2143
|
-
|
|
2144
3267
|
const runStartRoute: WebRoute = {
|
|
2145
3268
|
kind: 'exact',
|
|
2146
3269
|
path: NOVEL_API.runStart,
|
|
2147
3270
|
handler: async (req, res) => {
|
|
2148
3271
|
if (!guard(req, res, 'POST')) return
|
|
2149
|
-
const body = await readJsonBody<RunStartRequest>(req)
|
|
3272
|
+
const body = await readJsonBody<RunStartRequest & { bookId?: string }>(req)
|
|
2150
3273
|
const config = getConfig()
|
|
2151
|
-
const
|
|
3274
|
+
const outputDir = resolveOutputDir(config, body?.bookId)
|
|
3275
|
+
const project = loadProject(outputDir)
|
|
2152
3276
|
if (project === undefined) {
|
|
2153
3277
|
writeJson(res, 400, { error: '输出目录中没有项目' })
|
|
2154
3278
|
return
|
|
@@ -2165,7 +3289,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2165
3289
|
return
|
|
2166
3290
|
}
|
|
2167
3291
|
try {
|
|
2168
|
-
const state = await runner.start(startNo, endNo)
|
|
3292
|
+
const state = await runner.start(startNo, endNo, outputDir)
|
|
2169
3293
|
writeJson(res, 200, state satisfies RunState)
|
|
2170
3294
|
} catch (error) {
|
|
2171
3295
|
writeJson(res, 409, { error: (error as Error).message })
|
|
@@ -2204,6 +3328,337 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2204
3328
|
},
|
|
2205
3329
|
}
|
|
2206
3330
|
|
|
3331
|
+
// -------------------------------------------------- author assets (总数据)
|
|
3332
|
+
const authorAssetsRoute: WebRoute = {
|
|
3333
|
+
kind: 'exact',
|
|
3334
|
+
path: NOVEL_API.authorAssets,
|
|
3335
|
+
handler: (req, res) => {
|
|
3336
|
+
if (!guard(req, res, 'GET')) return
|
|
3337
|
+
const response: AuthorAssetsResponse = { assets: loadAuthorAssets() }
|
|
3338
|
+
writeJson(res, 200, response)
|
|
3339
|
+
},
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3342
|
+
const authorAssetsUpsertRoute: WebRoute = {
|
|
3343
|
+
kind: 'exact',
|
|
3344
|
+
path: NOVEL_API.authorAssetsUpsert,
|
|
3345
|
+
handler: async (req, res) => {
|
|
3346
|
+
if (!guard(req, res, 'POST')) return
|
|
3347
|
+
const body = await readJsonBody<AuthorAssetUpsertRequest>(req)
|
|
3348
|
+
const asset = body?.asset
|
|
3349
|
+
if (asset === undefined || asset.name.trim() === '' || asset.content.trim() === '') {
|
|
3350
|
+
writeJson(res, 400, { error: 'asset 须含 name / content' })
|
|
3351
|
+
return
|
|
3352
|
+
}
|
|
3353
|
+
const response: AuthorAssetsResponse = { assets: upsertAuthorAsset(asset) }
|
|
3354
|
+
writeJson(res, 200, response)
|
|
3355
|
+
},
|
|
3356
|
+
}
|
|
3357
|
+
|
|
3358
|
+
const authorAssetsRemoveRoute: WebRoute = {
|
|
3359
|
+
kind: 'exact',
|
|
3360
|
+
path: NOVEL_API.authorAssetsRemove,
|
|
3361
|
+
handler: async (req, res) => {
|
|
3362
|
+
if (!guard(req, res, 'POST')) return
|
|
3363
|
+
const body = await readJsonBody<AuthorAssetRemoveRequest>(req)
|
|
3364
|
+
const id = body?.id ?? ''
|
|
3365
|
+
if (id === '') { writeJson(res, 400, { error: 'id 必填' }); return }
|
|
3366
|
+
const response: AuthorAssetsResponse = { assets: removeAuthorAsset(id) }
|
|
3367
|
+
writeJson(res, 200, response)
|
|
3368
|
+
},
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
const authorAssetsImportDefaultRoute: WebRoute = {
|
|
3372
|
+
kind: 'exact',
|
|
3373
|
+
path: NOVEL_API.authorAssetsImportDefault,
|
|
3374
|
+
handler: (req, res) => {
|
|
3375
|
+
if (!guard(req, res, 'POST')) return
|
|
3376
|
+
const response: AuthorAssetsResponse = { assets: importDefaultAuthorAssets() }
|
|
3377
|
+
writeJson(res, 200, response)
|
|
3378
|
+
},
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
// --------------------------------------------------- adaptation (改编 P0 分析)
|
|
3382
|
+
const adaptAnalyzeRoute: WebRoute = {
|
|
3383
|
+
kind: 'exact',
|
|
3384
|
+
path: NOVEL_API.adaptAnalyze,
|
|
3385
|
+
handler: async (req, res) => {
|
|
3386
|
+
if (!guard(req, res, 'POST')) return
|
|
3387
|
+
if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
|
|
3388
|
+
const body = await readJsonBody<AdaptAnalyzeRequest>(req)
|
|
3389
|
+
const config = getConfig()
|
|
3390
|
+
let text = body?.text?.trim() ?? ''
|
|
3391
|
+
if (text === '' && body?.filePath !== undefined && body.filePath !== '') {
|
|
3392
|
+
try {
|
|
3393
|
+
text = readFileSync(body.filePath, 'utf8')
|
|
3394
|
+
} catch (err) {
|
|
3395
|
+
writeJson(res, 400, { error: '读取文件失败:' + (err as Error).message })
|
|
3396
|
+
return
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
if (text.length < 200) {
|
|
3400
|
+
writeJson(res, 400, { error: '全文内容过短(<200 字符),请上传完整小说文本' })
|
|
3401
|
+
return
|
|
3402
|
+
}
|
|
3403
|
+
try {
|
|
3404
|
+
const response: AdaptAnalyzeResponse = await analyzeAdaptation(ctx, config, text)
|
|
3405
|
+
writeJson(res, 200, response)
|
|
3406
|
+
} catch (err) {
|
|
3407
|
+
writeJson(res, 400, { error: (err as Error).message })
|
|
3408
|
+
}
|
|
3409
|
+
},
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
const adaptProposeRoute: WebRoute = {
|
|
3413
|
+
kind: 'exact',
|
|
3414
|
+
path: NOVEL_API.adaptPropose,
|
|
3415
|
+
handler: async (req, res) => {
|
|
3416
|
+
if (!guard(req, res, 'POST')) return
|
|
3417
|
+
if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
|
|
3418
|
+
const body = await readJsonBody<AdaptProposeRequest>(req)
|
|
3419
|
+
const config = getConfig()
|
|
3420
|
+
const text = (body?.text ?? '').trim()
|
|
3421
|
+
const selections = body?.selections ?? []
|
|
3422
|
+
if (text.length < 200 || selections.length === 0) {
|
|
3423
|
+
writeJson(res, 400, { error: '请提供全文与至少一条要改的维度' })
|
|
3424
|
+
return
|
|
3425
|
+
}
|
|
3426
|
+
try {
|
|
3427
|
+
const response: AdaptProposeResponse = await proposeAdaptation(ctx, config, text, selections, body?.dimensions)
|
|
3428
|
+
writeJson(res, 200, response)
|
|
3429
|
+
} catch (err) {
|
|
3430
|
+
writeJson(res, 400, { error: (err as Error).message })
|
|
3431
|
+
}
|
|
3432
|
+
},
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
const adaptExecuteRoute: WebRoute = {
|
|
3436
|
+
kind: 'exact',
|
|
3437
|
+
path: NOVEL_API.adaptExecute,
|
|
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<AdaptExecuteRequest>(req)
|
|
3442
|
+
const text = body?.text ?? ''
|
|
3443
|
+
const mappings = body?.mappings ?? []
|
|
3444
|
+
if (text.length < 200 || mappings.length === 0) {
|
|
3445
|
+
writeJson(res, 400, { error: '请提供全文与映射表' })
|
|
3446
|
+
return
|
|
3447
|
+
}
|
|
3448
|
+
if (body?.mode === 'rewrite') {
|
|
3449
|
+
try {
|
|
3450
|
+
const result = await rewriteAdaptationBook(ctx, getConfig(), text, mappings, body?.rules, { maxChapters: body?.maxChapters, startNo: body?.startNo, endNo: body?.endNo })
|
|
3451
|
+
const response: AdaptExecuteResponse = { adaptedText: result.adaptedText, mappings: mappings.length, hits: result.hits, mode: 'rewrite', rewritten: result.rewritten, skipped: result.skipped }
|
|
3452
|
+
writeJson(res, 200, response)
|
|
3453
|
+
} catch (err) {
|
|
3454
|
+
writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
|
|
3455
|
+
}
|
|
3456
|
+
return
|
|
3457
|
+
}
|
|
3458
|
+
const { adaptedText, hits } = applyAdaptationReplacements(text, mappings)
|
|
3459
|
+
const response: AdaptExecuteResponse = { adaptedText, mappings: mappings.length, hits, mode: 'replace' }
|
|
3460
|
+
writeJson(res, 200, response)
|
|
3461
|
+
},
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
/** rewrite 逐章重写:NDJSON 流式进度(支持分段 startNo/endNo)。 */
|
|
3465
|
+
const adaptRewriteStreamRoute: WebRoute = {
|
|
3466
|
+
kind: 'exact',
|
|
3467
|
+
path: NOVEL_API.adaptRewriteStream,
|
|
3468
|
+
handler: async (req, res) => {
|
|
3469
|
+
if (!guard(req, res, 'POST')) return
|
|
3470
|
+
if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
|
|
3471
|
+
const body = await readJsonBody<AdaptExecuteRequest>(req)
|
|
3472
|
+
const text = body?.text ?? ''
|
|
3473
|
+
const mappings = body?.mappings ?? []
|
|
3474
|
+
if (text.length < 200 || mappings.length === 0) { writeJson(res, 400, { error: '请提供全文与映射表' }); return }
|
|
3475
|
+
res.writeHead(200, {
|
|
3476
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
3477
|
+
'cache-control': 'no-cache',
|
|
3478
|
+
'x-accel-buffering': 'no',
|
|
3479
|
+
'referrer-policy': 'no-referrer',
|
|
3480
|
+
})
|
|
3481
|
+
const send = (frame: AdaptRewriteFrame): void => { res.write(JSON.stringify(frame) + '\n') }
|
|
3482
|
+
try {
|
|
3483
|
+
const result = await rewriteAdaptationBook(ctx, getConfig(), text, mappings, body?.rules, {
|
|
3484
|
+
maxChapters: body?.maxChapters,
|
|
3485
|
+
startNo: body?.startNo,
|
|
3486
|
+
endNo: body?.endNo,
|
|
3487
|
+
onProgress: (info) => send({ type: 'progress', completed: info.completed, total: info.total, no: info.no, title: info.title }),
|
|
3488
|
+
})
|
|
3489
|
+
const response: AdaptExecuteResponse = { adaptedText: result.adaptedText, mappings: mappings.length, hits: result.hits, mode: 'rewrite', rewritten: result.rewritten, skipped: result.skipped }
|
|
3490
|
+
send({ type: 'done', result: response })
|
|
3491
|
+
} catch (err) {
|
|
3492
|
+
send({ type: 'error', message: err instanceof Error ? err.message : String(err) })
|
|
3493
|
+
} finally {
|
|
3494
|
+
res.end()
|
|
3495
|
+
}
|
|
3496
|
+
},
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
/** 保存改编全文为新书(原书保留,登记书架)。 */
|
|
3500
|
+
const adaptSaveRoute: WebRoute = {
|
|
3501
|
+
kind: 'exact',
|
|
3502
|
+
path: NOVEL_API.adaptSave,
|
|
3503
|
+
handler: async (req, res) => {
|
|
3504
|
+
if (!guard(req, res, 'POST')) return
|
|
3505
|
+
if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
|
|
3506
|
+
const body = await readJsonBody<AdaptSaveRequest>(req)
|
|
3507
|
+
const text = (body?.text ?? '').trim()
|
|
3508
|
+
if (text.length < 200) {
|
|
3509
|
+
writeJson(res, 400, { error: '改编全文内容过短(<200 字符)' })
|
|
3510
|
+
return
|
|
3511
|
+
}
|
|
3512
|
+
const bookName = (body?.bookName ?? '').trim().slice(0, 40) || '改编新书'
|
|
3513
|
+
const outDir = (body?.outputDir ?? '').trim() !== '' && body?.outputDir !== undefined && body.outputDir.trim() !== ''
|
|
3514
|
+
? body.outputDir.trim()
|
|
3515
|
+
: defaultOutputDirFor(bookName)
|
|
3516
|
+
// 原书保留:若目录已被另一本书占用则拒绝,避免误覆盖原书/别的书。
|
|
3517
|
+
const occupied = loadBookshelf().books.find(b => b.outputDir === outDir)
|
|
3518
|
+
if (occupied !== undefined && occupied.bookName !== bookName) {
|
|
3519
|
+
writeJson(res, 409, { error: `目录已被《${occupied.bookName}》占用,请更换书名或输出目录` })
|
|
3520
|
+
return
|
|
3521
|
+
}
|
|
3522
|
+
try {
|
|
3523
|
+
const result = importBookTextFromText(text, outDir, bookName)
|
|
3524
|
+
// 有反推大纲时写进新项目,便于后续续写/编辑(importBookTextFromText 默认 outline=书名)。
|
|
3525
|
+
const outline = (body?.outline ?? '').trim()
|
|
3526
|
+
if (outline !== '') {
|
|
3527
|
+
const project = loadProject(outDir)
|
|
3528
|
+
if (project !== undefined) {
|
|
3529
|
+
project.outline = outline
|
|
3530
|
+
saveProject(outDir, project)
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
const { book } = importDir(outDir)
|
|
3534
|
+
const response: AdaptSaveResponse = { book, bookName: result.bookName, chapters: result.chapters, skipped: result.skipped, outputDir: outDir }
|
|
3535
|
+
writeJson(res, 200, response)
|
|
3536
|
+
} catch (err) {
|
|
3537
|
+
writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
|
|
3538
|
+
}
|
|
3539
|
+
},
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
/** 从源全文 + 编辑后方案提炼新书资料并保存为「待写新书」。 */
|
|
3543
|
+
const adaptMaterializeRoute: WebRoute = {
|
|
3544
|
+
kind: 'exact',
|
|
3545
|
+
path: NOVEL_API.adaptMaterialize,
|
|
3546
|
+
handler: async (req, res) => {
|
|
3547
|
+
if (!guard(req, res, 'POST')) return
|
|
3548
|
+
if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
|
|
3549
|
+
const body = await readJsonBody<AdaptMaterializeRequest>(req)
|
|
3550
|
+
const text = (body?.text ?? '').trim()
|
|
3551
|
+
if (text.length < 200) { writeJson(res, 400, { error: '源书全文内容过短(<200 字符)' }); return }
|
|
3552
|
+
const proposal = body?.proposal
|
|
3553
|
+
if (proposal === undefined || proposal.mappings.length === 0) { writeJson(res, 400, { error: '请先提供至少一条映射的改编方案' }); return }
|
|
3554
|
+
const bookName = (body?.bookName ?? '').trim().slice(0, 40) || '改编新书'
|
|
3555
|
+
const outDir = (body?.outputDir ?? '').trim() !== '' && body?.outputDir !== undefined && body.outputDir.trim() !== ''
|
|
3556
|
+
? body.outputDir.trim()
|
|
3557
|
+
: defaultOutputDirFor(bookName)
|
|
3558
|
+
// 原书保留:若目录已被另一本书占用则拒绝。
|
|
3559
|
+
const occupied = loadBookshelf().books.find(b => b.outputDir === outDir)
|
|
3560
|
+
if (occupied !== undefined && occupied.bookName !== bookName) {
|
|
3561
|
+
writeJson(res, 409, { error: `目录已被《${occupied.bookName}》占用,请更换书名或输出目录` })
|
|
3562
|
+
return
|
|
3563
|
+
}
|
|
3564
|
+
try {
|
|
3565
|
+
const result = await materializeAdaptedBook(ctx, getConfig(), {
|
|
3566
|
+
text,
|
|
3567
|
+
bookName,
|
|
3568
|
+
outputDir: outDir,
|
|
3569
|
+
outline: body?.outline,
|
|
3570
|
+
proposal,
|
|
3571
|
+
chapterCount: body?.chapterCount,
|
|
3572
|
+
})
|
|
3573
|
+
// 预览:只返回提炼资料,不落盘;保存走 /adapt/materialize-save。
|
|
3574
|
+
const response: AdaptMaterializeResponse = result
|
|
3575
|
+
writeJson(res, 200, response)
|
|
3576
|
+
} catch (err) {
|
|
3577
|
+
writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
|
|
3578
|
+
}
|
|
3579
|
+
},
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
/** 保存预览/微调后的新书资料为新书(原书保留,登记书架)。 */
|
|
3583
|
+
const adaptMaterializeSaveRoute: WebRoute = {
|
|
3584
|
+
kind: 'exact',
|
|
3585
|
+
path: NOVEL_API.adaptMaterializeSave,
|
|
3586
|
+
handler: async (req, res) => {
|
|
3587
|
+
if (!guard(req, res, 'POST')) return
|
|
3588
|
+
if (!getConfig().enableAdaptMode) { writeJson(res, 404, { error: '改编模式未开启' }); return }
|
|
3589
|
+
const body = await readJsonBody<AdaptMaterializeSaveRequest>(req)
|
|
3590
|
+
if (body === undefined || body === null || (body.outline ?? '').trim() === '') {
|
|
3591
|
+
writeJson(res, 400, { error: '请提供改编后总纲' })
|
|
3592
|
+
return
|
|
3593
|
+
}
|
|
3594
|
+
const bookName = (body.bookName ?? '').trim().slice(0, 40) || '改编新书'
|
|
3595
|
+
const outDir = (body.outputDir ?? '').trim() !== '' && body.outputDir !== undefined && body.outputDir.trim() !== ''
|
|
3596
|
+
? body.outputDir.trim()
|
|
3597
|
+
: defaultOutputDirFor(bookName)
|
|
3598
|
+
const occupied = loadBookshelf().books.find(b => b.outputDir === outDir)
|
|
3599
|
+
if (occupied !== undefined && occupied.bookName !== bookName) {
|
|
3600
|
+
writeJson(res, 409, { error: `目录已被《${occupied.bookName}》占用,请更换书名或输出目录` })
|
|
3601
|
+
return
|
|
3602
|
+
}
|
|
3603
|
+
try {
|
|
3604
|
+
const result = saveMaterializedBook(outDir, bookName, {
|
|
3605
|
+
outline: body.outline,
|
|
3606
|
+
bible: body.bible,
|
|
3607
|
+
roles: body.roles,
|
|
3608
|
+
world: body.world,
|
|
3609
|
+
volumes: body.volumes,
|
|
3610
|
+
chapters: body.chapters,
|
|
3611
|
+
})
|
|
3612
|
+
const { book } = importDir(outDir)
|
|
3613
|
+
const response: AdaptMaterializeSaveResponse = { ...result, book }
|
|
3614
|
+
writeJson(res, 200, response)
|
|
3615
|
+
} catch (err) {
|
|
3616
|
+
writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
|
|
3617
|
+
}
|
|
3618
|
+
},
|
|
3619
|
+
}
|
|
3620
|
+
|
|
3621
|
+
// ------------------------------------------ theme custom background (upload + serve)
|
|
3622
|
+
const THEME_BG_DIR = join(homedir(), '.dsh', 'dsh-novel-forge-assets')
|
|
3623
|
+
const MIME_BY_EXT: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }
|
|
3624
|
+
|
|
3625
|
+
const themeBackgroundUploadRoute: WebRoute = {
|
|
3626
|
+
kind: 'exact',
|
|
3627
|
+
path: NOVEL_API.themeBackgroundUpload,
|
|
3628
|
+
handler: async (req, res) => {
|
|
3629
|
+
if (!guard(req, res, 'POST')) return
|
|
3630
|
+
const body = await readJsonBody<{ dataUrl?: string }>(req)
|
|
3631
|
+
const dataUrl = body?.dataUrl ?? ''
|
|
3632
|
+
const m = /^data:image\/(png|jpeg|jpg|webp|gif);base64,(.+)$/i.exec(dataUrl)
|
|
3633
|
+
if (m === null) { writeJson(res, 400, { error: '仅支持 PNG/JPG/WebP/GIF 图片' }); return }
|
|
3634
|
+
const ext = m[1]!.toLowerCase() === 'jpg' ? 'jpeg' : m[1]!.toLowerCase()
|
|
3635
|
+
const buf = Buffer.from(m[2]!, 'base64')
|
|
3636
|
+
if (buf.length === 0) { writeJson(res, 400, { error: '图片数据为空' }); return }
|
|
3637
|
+
mkdirSync(THEME_BG_DIR, { recursive: true })
|
|
3638
|
+
const name = 'theme-bg-' + Date.now().toString(36) + '-' + randomBytes(3).toString('hex') + '.' + ext
|
|
3639
|
+
writeFileSync(join(THEME_BG_DIR, name), buf)
|
|
3640
|
+
writeJson(res, 200, { url: NOVEL_API.themeBackgroundGet + '/' + name })
|
|
3641
|
+
},
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
const themeBackgroundGetRoute: WebRoute = {
|
|
3645
|
+
kind: 'prefix',
|
|
3646
|
+
path: NOVEL_API.themeBackgroundGet,
|
|
3647
|
+
handler: (req, res) => {
|
|
3648
|
+
if (!guard(req, res, 'GET')) return
|
|
3649
|
+
let pathName = ''
|
|
3650
|
+
try { pathName = new URL(req.url ?? '', 'http://localhost').pathname } catch { /* ignore */ }
|
|
3651
|
+
const name = basename(pathName)
|
|
3652
|
+
if (!name.startsWith('theme-bg-')) { writeJson(res, 404, { error: 'not found' }); return }
|
|
3653
|
+
const file = join(THEME_BG_DIR, name)
|
|
3654
|
+
if (!existsSync(file)) { writeJson(res, 404, { error: 'not found' }); return }
|
|
3655
|
+
const ext = extname(file).slice(1)
|
|
3656
|
+
const type = MIME_BY_EXT[ext] ?? 'application/octet-stream'
|
|
3657
|
+
res.writeHead(200, { 'content-type': type, 'cache-control': 'public, max-age=31536000' })
|
|
3658
|
+
res.end(readFileSync(file))
|
|
3659
|
+
},
|
|
3660
|
+
}
|
|
3661
|
+
|
|
2207
3662
|
return [
|
|
2208
3663
|
statusRoute,
|
|
2209
3664
|
loadOutlineRoute,
|
|
@@ -2225,12 +3680,36 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2225
3680
|
chapterSaveRoute,
|
|
2226
3681
|
assetsRoute,
|
|
2227
3682
|
styleEngineRoute,
|
|
3683
|
+
styleFormulaRoute,
|
|
3684
|
+
styleDetectRoute,
|
|
3685
|
+
marketRadarRoute,
|
|
3686
|
+
marketRadarScanRoute,
|
|
3687
|
+
marketRadarApplyRoute,
|
|
3688
|
+
marketRadarSyncRoute,
|
|
3689
|
+
marketRadarBriefRoute,
|
|
3690
|
+
knowledgeRoute,
|
|
3691
|
+
bookAnalysisRoute,
|
|
3692
|
+
ideaInspirationRoute,
|
|
3693
|
+
marketIdeaInspirationRoute,
|
|
3694
|
+
directorRoute,
|
|
3695
|
+
directorTodosRoute,
|
|
3696
|
+
llmLiveRoute,
|
|
2228
3697
|
assistantRoute,
|
|
2229
3698
|
assistantHistoryRoute,
|
|
3699
|
+
llmPromptRoute,
|
|
3700
|
+
usageResetRoute,
|
|
3701
|
+
timelineRoute,
|
|
3702
|
+
revisionPlanRoute,
|
|
3703
|
+
snapshotRoute,
|
|
3704
|
+
tensionRoute,
|
|
3705
|
+
promptSlotsRoute,
|
|
2230
3706
|
assistantClearRoute,
|
|
2231
3707
|
bookshelfRoute,
|
|
2232
3708
|
bookshelfActivateRoute,
|
|
2233
3709
|
bookshelfRemoveRoute,
|
|
3710
|
+
bookshelfImportDirRoute,
|
|
3711
|
+
bookshelfImportTextRoute,
|
|
3712
|
+
bookshelfImportTextPreviewRoute,
|
|
2234
3713
|
resetRoute,
|
|
2235
3714
|
auditRoute,
|
|
2236
3715
|
charactersRefreshRoute,
|
|
@@ -2242,19 +3721,40 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
|
2242
3721
|
renameRoute,
|
|
2243
3722
|
plotlinesRoute,
|
|
2244
3723
|
rolesRoute,
|
|
2245
|
-
scenesRoute,
|
|
2246
|
-
visualRulesRoute,
|
|
2247
3724
|
sensitiveRoute,
|
|
2248
3725
|
reviewBackfillRoute,
|
|
2249
3726
|
chapterResetRoute,
|
|
2250
3727
|
chapterApproveRoute,
|
|
2251
3728
|
configRoute,
|
|
3729
|
+
moveOutputDirRoute,
|
|
3730
|
+
bookSettingsRoute,
|
|
2252
3731
|
openFolderRoute,
|
|
3732
|
+
pluginUpdateRoute,
|
|
2253
3733
|
outlineSuggestRoute,
|
|
3734
|
+
outlineReverseRoute,
|
|
3735
|
+
llmTestRoute,
|
|
3736
|
+
addModelRoute,
|
|
3737
|
+
llmVendorsRoute,
|
|
3738
|
+
llmModelsRoute,
|
|
3739
|
+
llmProvidersRoute,
|
|
3740
|
+
llmRemoveRoute,
|
|
2254
3741
|
breakdownRoute,
|
|
2255
3742
|
runStartRoute,
|
|
2256
3743
|
runControlRoute,
|
|
2257
3744
|
runStatusRoute,
|
|
3745
|
+
authorAssetsRoute,
|
|
3746
|
+
authorAssetsUpsertRoute,
|
|
3747
|
+
authorAssetsRemoveRoute,
|
|
3748
|
+
authorAssetsImportDefaultRoute,
|
|
3749
|
+
adaptAnalyzeRoute,
|
|
3750
|
+
adaptProposeRoute,
|
|
3751
|
+
adaptExecuteRoute,
|
|
3752
|
+
adaptSaveRoute,
|
|
3753
|
+
adaptMaterializeRoute,
|
|
3754
|
+
adaptMaterializeSaveRoute,
|
|
3755
|
+
adaptRewriteStreamRoute,
|
|
3756
|
+
themeBackgroundUploadRoute,
|
|
3757
|
+
themeBackgroundGetRoute,
|
|
2258
3758
|
]
|
|
2259
3759
|
}
|
|
2260
3760
|
|