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