@waterwx/dsh-novel-forge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +180 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.js +3924 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3120 -0
- package/lib/index.js.map +1 -0
- package/lib/types/assets.d.ts +35 -0
- package/lib/types/assistant.d.ts +43 -0
- package/lib/types/bookshelf.d.ts +35 -0
- package/lib/types/client/api.d.ts +68 -0
- package/lib/types/client/docx.d.ts +15 -0
- package/lib/types/client/index.d.ts +14 -0
- package/lib/types/client/locales.d.ts +139 -0
- package/lib/types/client/mount.d.ts +9 -0
- package/lib/types/client/panel/AssetsTab.d.ts +7 -0
- package/lib/types/client/panel/AssistantTab.d.ts +7 -0
- package/lib/types/client/panel/BookshelfBar.d.ts +11 -0
- package/lib/types/client/panel/NovelPanel.d.ts +13 -0
- package/lib/types/client/panel/controller.d.ts +19 -0
- package/lib/types/client/panel/helpers.d.ts +8 -0
- package/lib/types/client/sidebar-entry.d.ts +13 -0
- package/lib/types/docx.d.ts +19 -0
- package/lib/types/engine.d.ts +95 -0
- package/lib/types/index.d.ts +55 -0
- package/lib/types/protocol.d.ts +521 -0
- package/lib/types/routes.d.ts +29 -0
- package/package.json +105 -0
- package/src/assets.ts +518 -0
- package/src/assistant.ts +547 -0
- package/src/bookshelf.ts +137 -0
- package/src/client/api.ts +254 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/docx.ts +69 -0
- package/src/client/index.ts +34 -0
- package/src/client/locales.ts +271 -0
- package/src/client/mount.tsx +97 -0
- package/src/client/panel/AssetsTab.tsx +341 -0
- package/src/client/panel/AssistantTab.tsx +188 -0
- package/src/client/panel/BookshelfBar.tsx +116 -0
- package/src/client/panel/NovelPanel.tsx +990 -0
- package/src/client/panel/controller.ts +45 -0
- package/src/client/panel/helpers.ts +17 -0
- package/src/client/panel/panel.module.css +894 -0
- package/src/client/sidebar-entry.ts +122 -0
- package/src/docx.ts +83 -0
- package/src/engine.ts +1019 -0
- package/src/index.ts +184 -0
- package/src/protocol.ts +539 -0
- package/src/routes.ts +955 -0
package/src/routes.ts
ADDED
|
@@ -0,0 +1,955 @@
|
|
|
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 { exec } from 'node:child_process'
|
|
13
|
+
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
14
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
15
|
+
import {
|
|
16
|
+
NOVEL_API,
|
|
17
|
+
type AssetsPatch,
|
|
18
|
+
type AssetsResponse,
|
|
19
|
+
type AssistantFrame,
|
|
20
|
+
type AssistantHistoryResponse,
|
|
21
|
+
type AssistantRequest,
|
|
22
|
+
type BibleRequest,
|
|
23
|
+
type BibleResponse,
|
|
24
|
+
type BookActivateRequest,
|
|
25
|
+
type BookCreateRequest,
|
|
26
|
+
type BookRemoveRequest,
|
|
27
|
+
type BookshelfSnapshot,
|
|
28
|
+
type ChapterResponse,
|
|
29
|
+
type ChapterPlan,
|
|
30
|
+
type ConfigPatch,
|
|
31
|
+
type ExportRequest,
|
|
32
|
+
type ExportResponse,
|
|
33
|
+
type ForeshadowRequest,
|
|
34
|
+
type ForeshadowResponse,
|
|
35
|
+
type JobFrame,
|
|
36
|
+
type LoadOutlineRequest,
|
|
37
|
+
type LoadOutlineResponse,
|
|
38
|
+
type NovelConfig,
|
|
39
|
+
type PlanRequest,
|
|
40
|
+
type PlanResponse,
|
|
41
|
+
type PolishRequest,
|
|
42
|
+
type ReviewRequest,
|
|
43
|
+
type RewriteRequest,
|
|
44
|
+
type StatusResponse,
|
|
45
|
+
type StyleEngineRequest,
|
|
46
|
+
type SummaryRequest,
|
|
47
|
+
type VolumesRequest,
|
|
48
|
+
type VolumesResponse,
|
|
49
|
+
} from './protocol.ts'
|
|
50
|
+
import { readOutlineFromDocx } from './docx.ts'
|
|
51
|
+
import { loadAssistantHistory, runAssistantTurn } from './assistant.ts'
|
|
52
|
+
import { activateBook, bookshelfSnapshot, createBook, defaultOutputDirFor, loadBookshelf, removeBook, seedBookshelfFromOutputDir } from './bookshelf.ts'
|
|
53
|
+
import { BUILTIN_ANTI_AI_RULES, BUILTIN_GENRE_LIBRARY, BUILTIN_PROGRESSION_MODES, BUILTIN_STYLE_TEMPLATES, emptyProjectAssets } from './assets.ts'
|
|
54
|
+
import {
|
|
55
|
+
chapterFileName,
|
|
56
|
+
createProject,
|
|
57
|
+
exportBook,
|
|
58
|
+
extractBible,
|
|
59
|
+
extractStyleAsset,
|
|
60
|
+
generateChapterStream,
|
|
61
|
+
listChapterFiles,
|
|
62
|
+
loadProject,
|
|
63
|
+
planChapters,
|
|
64
|
+
planVolumes,
|
|
65
|
+
polishChapterStream,
|
|
66
|
+
readChapterFile,
|
|
67
|
+
reviewChapter,
|
|
68
|
+
rewriteChapterStream,
|
|
69
|
+
saveProject,
|
|
70
|
+
suggestForeshadows,
|
|
71
|
+
summarizeChapter,
|
|
72
|
+
syncProjectWithDisk,
|
|
73
|
+
} from './engine.ts'
|
|
74
|
+
|
|
75
|
+
/** Cap on JSON request bodies. */
|
|
76
|
+
const MAX_JSON_BODY_BYTES = 4 * 1024 * 1024
|
|
77
|
+
|
|
78
|
+
/** Loopback-only fence (mirrors the family plugins' pairing routes). */
|
|
79
|
+
function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
80
|
+
const address = request.socket.remoteAddress
|
|
81
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
|
|
82
|
+
const host = request.headers.host
|
|
83
|
+
if (typeof host !== 'string') return false
|
|
84
|
+
let hostUrl: URL
|
|
85
|
+
try {
|
|
86
|
+
hostUrl = new URL(`http://${host}`)
|
|
87
|
+
} catch {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
|
|
91
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
92
|
+
const origin = request.headers.origin
|
|
93
|
+
if (origin === undefined) return true
|
|
94
|
+
try {
|
|
95
|
+
return new URL(origin).host === hostUrl.host
|
|
96
|
+
} catch {
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One JSON response. */
|
|
102
|
+
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
103
|
+
const payload = JSON.stringify(body)
|
|
104
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
|
|
105
|
+
res.end(payload)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Read a JSON request body. */
|
|
109
|
+
async function readJsonBody<T>(req: IncomingMessage): Promise<T | undefined> {
|
|
110
|
+
const chunks: Buffer[] = []
|
|
111
|
+
let size = 0
|
|
112
|
+
for await (const chunk of req) {
|
|
113
|
+
const buffer = chunk as Buffer
|
|
114
|
+
size += buffer.length
|
|
115
|
+
if (size > MAX_JSON_BODY_BYTES) return undefined
|
|
116
|
+
chunks.push(buffer)
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as T
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Route deps. */
|
|
126
|
+
export interface NovelRoutesDeps {
|
|
127
|
+
ctx: Context
|
|
128
|
+
/** Resolve the live plugin config (settings-aware). */
|
|
129
|
+
getConfig: () => NovelConfig
|
|
130
|
+
/** Persist a config patch through the settings seam. */
|
|
131
|
+
patchConfig: (patch: ConfigPatch) => Promise<NovelConfig>
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Default chapter count for planning when the request omits it. */
|
|
135
|
+
const DEFAULT_PLAN_COUNT = 30
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build every /api/dsh-novel-forge route.
|
|
139
|
+
* @param deps - context, config resolver, config patcher.
|
|
140
|
+
* @returns the route list.
|
|
141
|
+
*/
|
|
142
|
+
export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
|
|
143
|
+
const { ctx, getConfig, patchConfig } = deps
|
|
144
|
+
|
|
145
|
+
/** Guard helper: fence + method check. */
|
|
146
|
+
const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
|
|
147
|
+
if (!isLoopbackRequest(req)) {
|
|
148
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
if (req.method !== method) {
|
|
152
|
+
writeJson(res, 405, { error: `method not allowed (expected ${method})` })
|
|
153
|
+
return false
|
|
154
|
+
}
|
|
155
|
+
return true
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Load (and sync) the project, or respond 400. */
|
|
159
|
+
const requireProject = (res: ServerResponse): ReturnType<typeof loadProject> => {
|
|
160
|
+
const config = getConfig()
|
|
161
|
+
const project = loadProject(config.outputDir)
|
|
162
|
+
if (project === undefined) {
|
|
163
|
+
writeJson(res, 400, { error: '输出目录中没有项目,请先加载大纲' })
|
|
164
|
+
return undefined
|
|
165
|
+
}
|
|
166
|
+
syncProjectWithDisk(project, config.outputDir)
|
|
167
|
+
saveProject(config.outputDir, project)
|
|
168
|
+
return project
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// -------------------------------------------------------------- status
|
|
172
|
+
const statusRoute: WebRoute = {
|
|
173
|
+
kind: 'exact',
|
|
174
|
+
path: NOVEL_API.status,
|
|
175
|
+
handler: (req, res) => {
|
|
176
|
+
if (!guard(req, res, 'GET')) return
|
|
177
|
+
const config = getConfig()
|
|
178
|
+
// 书架为空时播种 settings 默认输出目录里的已有项目。
|
|
179
|
+
seedBookshelfFromOutputDir(config.outputDir)
|
|
180
|
+
const project = loadProject(config.outputDir)
|
|
181
|
+
if (project !== undefined) {
|
|
182
|
+
syncProjectWithDisk(project, config.outputDir)
|
|
183
|
+
saveProject(config.outputDir, project)
|
|
184
|
+
}
|
|
185
|
+
const response: StatusResponse = {
|
|
186
|
+
config,
|
|
187
|
+
project: project ?? undefined,
|
|
188
|
+
generatedFiles: listChapterFiles(config.outputDir),
|
|
189
|
+
}
|
|
190
|
+
writeJson(res, 200, response)
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// -------------------------------------------------------- load-outline
|
|
195
|
+
const loadOutlineRoute: WebRoute = {
|
|
196
|
+
kind: 'exact',
|
|
197
|
+
path: NOVEL_API.loadOutline,
|
|
198
|
+
handler: async (req, res) => {
|
|
199
|
+
if (!guard(req, res, 'POST')) return
|
|
200
|
+
const body = await readJsonBody<LoadOutlineRequest>(req)
|
|
201
|
+
const config = getConfig()
|
|
202
|
+
try {
|
|
203
|
+
let outline: string
|
|
204
|
+
let path: string | undefined
|
|
205
|
+
if (body?.text !== undefined && body.text.trim() !== '') {
|
|
206
|
+
outline = body.text.trim()
|
|
207
|
+
} else {
|
|
208
|
+
const target = body?.path?.trim() !== '' && body?.path !== undefined ? body.path : config.outlinePath
|
|
209
|
+
outline = readOutlineFromDocx(target)
|
|
210
|
+
path = target
|
|
211
|
+
}
|
|
212
|
+
if (outline.length < 50) {
|
|
213
|
+
writeJson(res, 400, { error: '大纲内容过短(<50 字符),请检查文件或直接粘贴大纲文本' })
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
const response: LoadOutlineResponse = {
|
|
217
|
+
outline,
|
|
218
|
+
bookName: createProject(outline).bookName,
|
|
219
|
+
chars: outline.length,
|
|
220
|
+
path,
|
|
221
|
+
}
|
|
222
|
+
writeJson(res, 200, response)
|
|
223
|
+
} catch (error) {
|
|
224
|
+
writeJson(res, 400, { error: (error as Error).message })
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// -------------------------------------------------------- save-outline
|
|
230
|
+
const saveOutlineRoute: WebRoute = {
|
|
231
|
+
kind: 'exact',
|
|
232
|
+
path: NOVEL_API.saveOutline,
|
|
233
|
+
handler: async (req, res) => {
|
|
234
|
+
if (!guard(req, res, 'POST')) return
|
|
235
|
+
const body = await readJsonBody<LoadOutlineRequest>(req)
|
|
236
|
+
const config = getConfig()
|
|
237
|
+
const outline = body?.text ?? ''
|
|
238
|
+
if (outline.trim().length < 50) {
|
|
239
|
+
writeJson(res, 400, { error: '大纲内容过短(<50 字符)' })
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
let project = loadProject(config.outputDir)
|
|
243
|
+
const now = new Date().toISOString()
|
|
244
|
+
if (project === undefined) {
|
|
245
|
+
project = createProject(outline)
|
|
246
|
+
} else {
|
|
247
|
+
project.outline = outline
|
|
248
|
+
project.bookName = createProject(outline).bookName
|
|
249
|
+
project.updatedAt = now
|
|
250
|
+
}
|
|
251
|
+
saveProject(config.outputDir, project)
|
|
252
|
+
writeJson(res, 200, { ok: true, bookName: project.bookName })
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --------------------------------------------------------------- bible
|
|
257
|
+
const bibleRoute: WebRoute = {
|
|
258
|
+
kind: 'exact',
|
|
259
|
+
path: NOVEL_API.bible,
|
|
260
|
+
handler: async (req, res) => {
|
|
261
|
+
if (!guard(req, res, 'POST')) return
|
|
262
|
+
const body = await readJsonBody<BibleRequest>(req)
|
|
263
|
+
const config = getConfig()
|
|
264
|
+
const project = loadProject(config.outputDir)
|
|
265
|
+
const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
|
|
266
|
+
? body.outline
|
|
267
|
+
: project?.outline
|
|
268
|
+
if (outline === undefined || outline.length < 50) {
|
|
269
|
+
writeJson(res, 400, { error: '请先加载大纲' })
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
const bible = await extractBible(ctx, config, outline)
|
|
274
|
+
const now = new Date().toISOString()
|
|
275
|
+
const next = project ?? createProject(outline)
|
|
276
|
+
next.bible = bible
|
|
277
|
+
next.updatedAt = now
|
|
278
|
+
saveProject(config.outputDir, next)
|
|
279
|
+
const response: BibleResponse = { bible }
|
|
280
|
+
writeJson(res, 200, response)
|
|
281
|
+
} catch (error) {
|
|
282
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ------------------------------------------------------------- volumes
|
|
288
|
+
const volumesRoute: WebRoute = {
|
|
289
|
+
kind: 'exact',
|
|
290
|
+
path: NOVEL_API.volumes,
|
|
291
|
+
handler: async (req, res) => {
|
|
292
|
+
if (!guard(req, res, 'POST')) return
|
|
293
|
+
const body = await readJsonBody<VolumesRequest>(req)
|
|
294
|
+
const config = getConfig()
|
|
295
|
+
const project = loadProject(config.outputDir)
|
|
296
|
+
const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
|
|
297
|
+
? body.outline
|
|
298
|
+
: project?.outline
|
|
299
|
+
if (outline === undefined || outline.length < 50) {
|
|
300
|
+
writeJson(res, 400, { error: '请先加载大纲' })
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
const volumes = await planVolumes(ctx, config, outline)
|
|
305
|
+
const now = new Date().toISOString()
|
|
306
|
+
const next = project ?? createProject(outline)
|
|
307
|
+
next.volumes = volumes
|
|
308
|
+
next.updatedAt = now
|
|
309
|
+
saveProject(config.outputDir, next)
|
|
310
|
+
const response: VolumesResponse = { volumes }
|
|
311
|
+
writeJson(res, 200, response)
|
|
312
|
+
} catch (error) {
|
|
313
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ----------------------------------------------------------------- plan
|
|
319
|
+
const planRoute: WebRoute = {
|
|
320
|
+
kind: 'exact',
|
|
321
|
+
path: NOVEL_API.plan,
|
|
322
|
+
handler: async (req, res) => {
|
|
323
|
+
if (!guard(req, res, 'POST')) return
|
|
324
|
+
const body = await readJsonBody<PlanRequest>(req)
|
|
325
|
+
const config = getConfig()
|
|
326
|
+
const project = loadProject(config.outputDir)
|
|
327
|
+
const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
|
|
328
|
+
? body.outline
|
|
329
|
+
: project?.outline
|
|
330
|
+
if (outline === undefined || outline.length < 50) {
|
|
331
|
+
writeJson(res, 400, { error: '请先加载大纲(或粘贴大纲文本)' })
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
const count = body?.chapterCount ?? DEFAULT_PLAN_COUNT
|
|
335
|
+
if (!Number.isInteger(count) || count < 1 || count > 200) {
|
|
336
|
+
writeJson(res, 400, { error: 'chapterCount 须为 1-200 的整数' })
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
const next = project ?? createProject(outline)
|
|
341
|
+
const chapters: ChapterPlan[] = await planChapters(ctx, config, next, count, body?.volume)
|
|
342
|
+
next.chapters.push(...chapters)
|
|
343
|
+
next.updatedAt = new Date().toISOString()
|
|
344
|
+
saveProject(config.outputDir, next)
|
|
345
|
+
const response: PlanResponse = { chapters, volumes: next.volumes }
|
|
346
|
+
writeJson(res, 200, response)
|
|
347
|
+
} catch (error) {
|
|
348
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ------------------------------------------------------------- generate
|
|
354
|
+
const generateRoute: WebRoute = {
|
|
355
|
+
kind: 'exact',
|
|
356
|
+
path: NOVEL_API.generate,
|
|
357
|
+
handler: async (req, res) => {
|
|
358
|
+
if (!guard(req, res, 'POST')) return
|
|
359
|
+
const config = getConfig()
|
|
360
|
+
const project = requireProject(res)
|
|
361
|
+
if (project === undefined) return
|
|
362
|
+
const body = await readJsonBody<{ chapterNo?: number; skipReview?: boolean }>(req)
|
|
363
|
+
const rawNo = body?.chapterNo
|
|
364
|
+
if (!Number.isInteger(rawNo) || rawNo === undefined || rawNo < 1) {
|
|
365
|
+
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
const no: number = rawNo
|
|
369
|
+
const chapter = project.chapters.find(c => c.no === no)
|
|
370
|
+
if (chapter === undefined) {
|
|
371
|
+
writeJson(res, 404, { error: `章节 ${no} 不在计划中` })
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
if (chapter.status === 'generating') {
|
|
375
|
+
writeJson(res, 409, { error: `章节 ${no} 正在生成中` })
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// NDJSON stream.
|
|
380
|
+
res.writeHead(200, {
|
|
381
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
382
|
+
'cache-control': 'no-cache',
|
|
383
|
+
'x-accel-buffering': 'no',
|
|
384
|
+
'referrer-policy': 'no-referrer',
|
|
385
|
+
})
|
|
386
|
+
chapter.status = 'generating'
|
|
387
|
+
chapter.error = undefined
|
|
388
|
+
saveProject(config.outputDir, project)
|
|
389
|
+
|
|
390
|
+
const send = (frame: JobFrame): void => {
|
|
391
|
+
res.write(JSON.stringify(frame) + '\n')
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
send({ type: 'start', no, title: chapter.title })
|
|
396
|
+
for await (const step of generateChapterStream(ctx, config, project, config.outputDir, no)) {
|
|
397
|
+
if (step.frame === 'delta') {
|
|
398
|
+
send({ type: 'delta', text: step.text })
|
|
399
|
+
} else if (step.frame === 'done') {
|
|
400
|
+
send({ type: 'done', no, file: step.file, chars: step.chars, title: chapter.title })
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// Auto pipeline: summary -> review (unless skipped).
|
|
404
|
+
try {
|
|
405
|
+
await summarizeChapter(ctx, config, project, config.outputDir, no)
|
|
406
|
+
} catch (error) {
|
|
407
|
+
console.warn('[dsh-novel-forge] summary failed:', (error as Error).message)
|
|
408
|
+
}
|
|
409
|
+
if (!(body?.skipReview === true) && (config.autoReview ?? true)) {
|
|
410
|
+
const report = await reviewChapter(ctx, config, project, config.outputDir, no)
|
|
411
|
+
send({ type: 'review', no, report })
|
|
412
|
+
} else {
|
|
413
|
+
chapter.status = 'approved'
|
|
414
|
+
saveProject(config.outputDir, project)
|
|
415
|
+
}
|
|
416
|
+
res.end()
|
|
417
|
+
} catch (error) {
|
|
418
|
+
chapter.status = 'error'
|
|
419
|
+
chapter.error = (error as Error).message
|
|
420
|
+
saveProject(config.outputDir, project)
|
|
421
|
+
if (!res.writableEnded) {
|
|
422
|
+
send({ type: 'error', no, message: (error as Error).message })
|
|
423
|
+
res.end()
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// --------------------------------------------------------------- review
|
|
430
|
+
const reviewRoute: WebRoute = {
|
|
431
|
+
kind: 'exact',
|
|
432
|
+
path: NOVEL_API.review,
|
|
433
|
+
handler: async (req, res) => {
|
|
434
|
+
if (!guard(req, res, 'POST')) return
|
|
435
|
+
const config = getConfig()
|
|
436
|
+
const project = requireProject(res)
|
|
437
|
+
if (project === undefined) return
|
|
438
|
+
const body = await readJsonBody<ReviewRequest>(req)
|
|
439
|
+
if (!Number.isInteger(body?.chapterNo)) {
|
|
440
|
+
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
const no = body!.chapterNo!
|
|
444
|
+
try {
|
|
445
|
+
const report = await reviewChapter(ctx, config, project, config.outputDir, no)
|
|
446
|
+
writeJson(res, 200, { report })
|
|
447
|
+
} catch (error) {
|
|
448
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// -------------------------------------------------------------- rewrite
|
|
454
|
+
const rewriteRoute: WebRoute = {
|
|
455
|
+
kind: 'exact',
|
|
456
|
+
path: NOVEL_API.rewrite,
|
|
457
|
+
handler: async (req, res) => {
|
|
458
|
+
if (!guard(req, res, 'POST')) return
|
|
459
|
+
const config = getConfig()
|
|
460
|
+
const project = requireProject(res)
|
|
461
|
+
if (project === undefined) return
|
|
462
|
+
const body = await readJsonBody<RewriteRequest>(req)
|
|
463
|
+
if (!Number.isInteger(body?.chapterNo)) {
|
|
464
|
+
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
const no = body!.chapterNo!
|
|
468
|
+
res.writeHead(200, {
|
|
469
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
470
|
+
'cache-control': 'no-cache',
|
|
471
|
+
'x-accel-buffering': 'no',
|
|
472
|
+
'referrer-policy': 'no-referrer',
|
|
473
|
+
})
|
|
474
|
+
const send = (frame: JobFrame): void => { res.write(JSON.stringify(frame) + '\n') }
|
|
475
|
+
try {
|
|
476
|
+
for await (const step of rewriteChapterStream(ctx, config, project, config.outputDir, no, body?.instructions ?? '', body?.target)) {
|
|
477
|
+
if (step.frame === 'delta') send({ type: 'delta', text: step.text })
|
|
478
|
+
else if (step.frame === 'done') send({ type: 'rewritten', no, file: step.file, chars: step.chars })
|
|
479
|
+
}
|
|
480
|
+
// Re-review after rewrite.
|
|
481
|
+
const report = await reviewChapter(ctx, config, project, config.outputDir, no)
|
|
482
|
+
send({ type: 'review', no, report })
|
|
483
|
+
res.end()
|
|
484
|
+
} catch (error) {
|
|
485
|
+
if (!res.writableEnded) {
|
|
486
|
+
send({ type: 'error', no, message: (error as Error).message })
|
|
487
|
+
res.end()
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
},
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// --------------------------------------------------------------- polish
|
|
494
|
+
const polishRoute: WebRoute = {
|
|
495
|
+
kind: 'exact',
|
|
496
|
+
path: NOVEL_API.polish,
|
|
497
|
+
handler: async (req, res) => {
|
|
498
|
+
if (!guard(req, res, 'POST')) return
|
|
499
|
+
const config = getConfig()
|
|
500
|
+
const project = requireProject(res)
|
|
501
|
+
if (project === undefined) return
|
|
502
|
+
const body = await readJsonBody<PolishRequest>(req)
|
|
503
|
+
if (!Number.isInteger(body?.chapterNo)) {
|
|
504
|
+
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
const no = body!.chapterNo!
|
|
508
|
+
res.writeHead(200, {
|
|
509
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
510
|
+
'cache-control': 'no-cache',
|
|
511
|
+
'x-accel-buffering': 'no',
|
|
512
|
+
'referrer-policy': 'no-referrer',
|
|
513
|
+
})
|
|
514
|
+
const send = (frame: JobFrame): void => { res.write(JSON.stringify(frame) + '\n') }
|
|
515
|
+
try {
|
|
516
|
+
for await (const step of polishChapterStream(ctx, config, project, config.outputDir, no)) {
|
|
517
|
+
if (step.frame === 'delta') send({ type: 'delta', text: step.text })
|
|
518
|
+
else if (step.frame === 'done') send({ type: 'rewritten', no, file: step.file, chars: step.chars })
|
|
519
|
+
}
|
|
520
|
+
res.end()
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if (!res.writableEnded) {
|
|
523
|
+
send({ type: 'error', no, message: (error as Error).message })
|
|
524
|
+
res.end()
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// -------------------------------------------------------------- summary
|
|
531
|
+
const summaryRoute: WebRoute = {
|
|
532
|
+
kind: 'exact',
|
|
533
|
+
path: NOVEL_API.summary,
|
|
534
|
+
handler: async (req, res) => {
|
|
535
|
+
if (!guard(req, res, 'POST')) return
|
|
536
|
+
const config = getConfig()
|
|
537
|
+
const project = requireProject(res)
|
|
538
|
+
if (project === undefined) return
|
|
539
|
+
const body = await readJsonBody<SummaryRequest>(req)
|
|
540
|
+
if (!Number.isInteger(body?.chapterNo)) {
|
|
541
|
+
writeJson(res, 400, { error: 'chapterNo 须为正整数' })
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
const summary = await summarizeChapter(ctx, config, project, config.outputDir, body!.chapterNo!)
|
|
546
|
+
writeJson(res, 200, { summary })
|
|
547
|
+
} catch (error) {
|
|
548
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ---------------------------------------------------------- foreshadow
|
|
554
|
+
const foreshadowRoute: WebRoute = {
|
|
555
|
+
kind: 'exact',
|
|
556
|
+
path: NOVEL_API.foreshadow,
|
|
557
|
+
handler: async (req, res) => {
|
|
558
|
+
if (!guard(req, res, 'POST')) return
|
|
559
|
+
const config = getConfig()
|
|
560
|
+
const project = requireProject(res)
|
|
561
|
+
if (project === undefined) return
|
|
562
|
+
const body = await readJsonBody<ForeshadowRequest>(req)
|
|
563
|
+
try {
|
|
564
|
+
if (body?.suggest === true) {
|
|
565
|
+
// AI suggestion pass: create several foreshadows from the outline.
|
|
566
|
+
const created = await suggestForeshadows(ctx, config, project)
|
|
567
|
+
project.updatedAt = new Date().toISOString()
|
|
568
|
+
saveProject(config.outputDir, project)
|
|
569
|
+
const response: ForeshadowResponse = { foreshadows: created }
|
|
570
|
+
writeJson(res, 200, response)
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
if (body?.id !== undefined) {
|
|
574
|
+
// Update an existing foreshadow.
|
|
575
|
+
const target = project.foreshadows.find(f => f.id === body.id)
|
|
576
|
+
if (target === undefined) {
|
|
577
|
+
writeJson(res, 404, { error: `伏笔 ${body.id} 不存在` })
|
|
578
|
+
return
|
|
579
|
+
}
|
|
580
|
+
if (body.description !== undefined) target.description = body.description
|
|
581
|
+
if (body.plantedChapter !== undefined) target.plantedChapter = body.plantedChapter
|
|
582
|
+
if (body.targetChapter !== undefined) target.targetChapter = body.targetChapter
|
|
583
|
+
if (body.status !== undefined) target.status = body.status
|
|
584
|
+
if (body.resolvedNote !== undefined) target.resolvedNote = body.resolvedNote
|
|
585
|
+
} else {
|
|
586
|
+
// Create one manually.
|
|
587
|
+
const description = body?.description?.trim()
|
|
588
|
+
if (description === undefined || description === '') {
|
|
589
|
+
writeJson(res, 400, { error: 'description 必填' })
|
|
590
|
+
return
|
|
591
|
+
}
|
|
592
|
+
project.foreshadows.push({
|
|
593
|
+
id: `fs-${Date.now().toString(36)}`,
|
|
594
|
+
description,
|
|
595
|
+
plantedChapter: body?.plantedChapter,
|
|
596
|
+
targetChapter: body?.targetChapter,
|
|
597
|
+
status: body?.status ?? 'planned',
|
|
598
|
+
})
|
|
599
|
+
}
|
|
600
|
+
project.updatedAt = new Date().toISOString()
|
|
601
|
+
saveProject(config.outputDir, project)
|
|
602
|
+
const response: ForeshadowResponse = { foreshadows: project.foreshadows }
|
|
603
|
+
writeJson(res, 200, response)
|
|
604
|
+
} catch (error) {
|
|
605
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
606
|
+
}
|
|
607
|
+
},
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// -------------------------------------------------------------- export
|
|
611
|
+
const exportRoute: WebRoute = {
|
|
612
|
+
kind: 'exact',
|
|
613
|
+
path: NOVEL_API.exportBook,
|
|
614
|
+
handler: async (req, res) => {
|
|
615
|
+
if (!guard(req, res, 'POST')) return
|
|
616
|
+
const config = getConfig()
|
|
617
|
+
const project = requireProject(res)
|
|
618
|
+
if (project === undefined) return
|
|
619
|
+
const body = await readJsonBody<ExportRequest>(req)
|
|
620
|
+
const format = body?.format === 'md' ? 'md' : 'txt'
|
|
621
|
+
try {
|
|
622
|
+
const result = exportBook(config.outputDir, project, format)
|
|
623
|
+
const response: ExportResponse = { ...result }
|
|
624
|
+
writeJson(res, 200, response)
|
|
625
|
+
} catch (error) {
|
|
626
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
627
|
+
}
|
|
628
|
+
},
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// -------------------------------------------------------------- chapter
|
|
632
|
+
const chapterRoute: WebRoute = {
|
|
633
|
+
kind: 'exact',
|
|
634
|
+
path: NOVEL_API.chapter,
|
|
635
|
+
handler: async (req, res) => {
|
|
636
|
+
if (!guard(req, res, 'GET')) return
|
|
637
|
+
const config = getConfig()
|
|
638
|
+
const project = requireProject(res)
|
|
639
|
+
if (project === undefined) return
|
|
640
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
641
|
+
const rawNo = Number(url.searchParams.get('no') ?? '0')
|
|
642
|
+
if (!Number.isInteger(rawNo) || rawNo < 1) {
|
|
643
|
+
writeJson(res, 400, { error: 'no 须为正整数' })
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
const chapter = project.chapters.find(c => c.no === rawNo)
|
|
647
|
+
if (chapter === undefined) {
|
|
648
|
+
writeJson(res, 404, { error: `章节 ${rawNo} 不在计划中` })
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
const markdown = readChapterFile(config.outputDir, chapter)
|
|
652
|
+
if (markdown === undefined) {
|
|
653
|
+
writeJson(res, 404, { error: `章节 ${rawNo} 尚未生成` })
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
const response: ChapterResponse = { no: chapter.no, title: chapter.title, markdown }
|
|
657
|
+
writeJson(res, 200, response)
|
|
658
|
+
},
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ----------------------------------------------------------- assistant
|
|
662
|
+
const assistantRoute: WebRoute = {
|
|
663
|
+
kind: 'exact',
|
|
664
|
+
path: NOVEL_API.assistant,
|
|
665
|
+
handler: async (req, res) => {
|
|
666
|
+
if (!guard(req, res, 'POST')) return
|
|
667
|
+
const config = getConfig()
|
|
668
|
+
const project = requireProject(res)
|
|
669
|
+
if (project === undefined) return
|
|
670
|
+
const body = await readJsonBody<AssistantRequest>(req)
|
|
671
|
+
const message = body?.message?.trim()
|
|
672
|
+
if (message === undefined || message === '') {
|
|
673
|
+
writeJson(res, 400, { error: '消息不能为空' })
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
res.writeHead(200, {
|
|
677
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
678
|
+
'cache-control': 'no-cache',
|
|
679
|
+
'x-accel-buffering': 'no',
|
|
680
|
+
'referrer-policy': 'no-referrer',
|
|
681
|
+
})
|
|
682
|
+
const send = (frame: AssistantFrame): void => { res.write(JSON.stringify(frame) + '\n') }
|
|
683
|
+
try {
|
|
684
|
+
for await (const step of runAssistantTurn(ctx, config, project, config.outputDir, message)) {
|
|
685
|
+
if (step.frame === 'delta') send({ type: 'delta', text: step.text })
|
|
686
|
+
else if (step.frame === 'tool') send({ type: 'tool', name: step.name, status: step.status, detail: step.detail })
|
|
687
|
+
else if (step.frame === 'toolDelta') send({ type: 'toolDelta', name: step.name, text: step.text })
|
|
688
|
+
}
|
|
689
|
+
send({ type: 'done' })
|
|
690
|
+
res.end()
|
|
691
|
+
} catch (error) {
|
|
692
|
+
if (!res.writableEnded) {
|
|
693
|
+
send({ type: 'error', message: (error as Error).message })
|
|
694
|
+
res.end()
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
},
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// -------------------------------------------------- assistant-history
|
|
701
|
+
const assistantHistoryRoute: WebRoute = {
|
|
702
|
+
kind: 'exact',
|
|
703
|
+
path: NOVEL_API.assistantHistory,
|
|
704
|
+
handler: (req, res) => {
|
|
705
|
+
if (!guard(req, res, 'GET')) return
|
|
706
|
+
const config = getConfig()
|
|
707
|
+
const response: AssistantHistoryResponse = { messages: loadAssistantHistory(config.outputDir) }
|
|
708
|
+
writeJson(res, 200, response)
|
|
709
|
+
},
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// --------------------------------------------------------------- assets
|
|
713
|
+
const assetsRoute: WebRoute = {
|
|
714
|
+
kind: 'exact',
|
|
715
|
+
path: NOVEL_API.assets,
|
|
716
|
+
handler: async (req, res) => {
|
|
717
|
+
// GET (read) and POST (patch) are both allowed — check methods first,
|
|
718
|
+
// then the loopback fence (guard() would 405 on POST, which is wrong).
|
|
719
|
+
if (req.method !== 'GET' && req.method !== 'POST') {
|
|
720
|
+
writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
|
|
721
|
+
return
|
|
722
|
+
}
|
|
723
|
+
if (!isLoopbackRequest(req)) {
|
|
724
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
const config = getConfig()
|
|
728
|
+
const project = loadProject(config.outputDir)
|
|
729
|
+
const projectAssets = project?.assets ?? emptyProjectAssets()
|
|
730
|
+
if (req.method === 'POST') {
|
|
731
|
+
const body = await readJsonBody<AssetsPatch>(req)
|
|
732
|
+
if (body === undefined) {
|
|
733
|
+
writeJson(res, 400, { error: '无效的 JSON' })
|
|
734
|
+
return
|
|
735
|
+
}
|
|
736
|
+
if (project === undefined) {
|
|
737
|
+
writeJson(res, 400, { error: '请先加载大纲创建项目' })
|
|
738
|
+
return
|
|
739
|
+
}
|
|
740
|
+
if (body.genre !== undefined) projectAssets.genre = body.genre
|
|
741
|
+
if (body.primaryProgression !== undefined) projectAssets.primaryProgression = body.primaryProgression
|
|
742
|
+
if (body.auxiliaryProgressions !== undefined) projectAssets.auxiliaryProgressions = body.auxiliaryProgressions
|
|
743
|
+
if (body.antiAiRules !== undefined) projectAssets.antiAiRules = body.antiAiRules
|
|
744
|
+
if (body.styleAssets !== undefined) projectAssets.styleAssets = body.styleAssets
|
|
745
|
+
projectAssets.updatedAt = new Date().toISOString()
|
|
746
|
+
project.assets = projectAssets
|
|
747
|
+
project.updatedAt = new Date().toISOString()
|
|
748
|
+
saveProject(config.outputDir, project)
|
|
749
|
+
}
|
|
750
|
+
const response: AssetsResponse = {
|
|
751
|
+
projectAssets,
|
|
752
|
+
genreLibrary: BUILTIN_GENRE_LIBRARY,
|
|
753
|
+
antiAiLibrary: BUILTIN_ANTI_AI_RULES,
|
|
754
|
+
styleTemplates: BUILTIN_STYLE_TEMPLATES,
|
|
755
|
+
progressionLibrary: BUILTIN_PROGRESSION_MODES,
|
|
756
|
+
}
|
|
757
|
+
writeJson(res, 200, response)
|
|
758
|
+
},
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// ----------------------------------------------------------- style-engine
|
|
762
|
+
const styleEngineRoute: WebRoute = {
|
|
763
|
+
kind: 'exact',
|
|
764
|
+
path: NOVEL_API.styleEngine,
|
|
765
|
+
handler: async (req, res) => {
|
|
766
|
+
if (!guard(req, res, 'POST')) return
|
|
767
|
+
const config = getConfig()
|
|
768
|
+
const project = loadProject(config.outputDir)
|
|
769
|
+
const body = await readJsonBody<StyleEngineRequest>(req)
|
|
770
|
+
const sample = body?.sampleText?.trim()
|
|
771
|
+
if (sample === undefined || sample.length < 50) {
|
|
772
|
+
writeJson(res, 400, { error: '样本文本过短(<50 字符),请粘贴一段能代表目标风格的文字' })
|
|
773
|
+
return
|
|
774
|
+
}
|
|
775
|
+
try {
|
|
776
|
+
const rules = await extractStyleAsset(ctx, config, sample)
|
|
777
|
+
const name = body?.name?.trim() !== '' && body?.name !== undefined ? body.name : `风格资产 ${Date.now().toString(36)}`
|
|
778
|
+
const styleAsset = {
|
|
779
|
+
name: name.slice(0, 40),
|
|
780
|
+
...rules,
|
|
781
|
+
sourceText: sample.slice(0, 3000),
|
|
782
|
+
createdAt: new Date().toISOString(),
|
|
783
|
+
}
|
|
784
|
+
if (project !== undefined) {
|
|
785
|
+
project.assets ??= emptyProjectAssets()
|
|
786
|
+
project.assets.styleAssets ??= []
|
|
787
|
+
project.assets.styleAssets.push(styleAsset)
|
|
788
|
+
project.assets.updatedAt = new Date().toISOString()
|
|
789
|
+
project.updatedAt = new Date().toISOString()
|
|
790
|
+
saveProject(config.outputDir, project)
|
|
791
|
+
}
|
|
792
|
+
writeJson(res, 200, { styleAsset })
|
|
793
|
+
} catch (error) {
|
|
794
|
+
writeJson(res, 500, { error: (error as Error).message })
|
|
795
|
+
}
|
|
796
|
+
},
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// ------------------------------------------------------------- bookshelf
|
|
800
|
+
const bookshelfRoute: WebRoute = {
|
|
801
|
+
kind: 'exact',
|
|
802
|
+
path: NOVEL_API.bookshelf,
|
|
803
|
+
handler: async (req, res) => {
|
|
804
|
+
// GET = snapshot; POST = create book.
|
|
805
|
+
if (req.method === 'GET') {
|
|
806
|
+
if (!isLoopbackRequest(req)) {
|
|
807
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
808
|
+
return
|
|
809
|
+
}
|
|
810
|
+
// 书架为空时,把 settings 默认输出目录里已有的项目播种为第一本书。
|
|
811
|
+
seedBookshelfFromOutputDir(getConfig().outputDir)
|
|
812
|
+
const snapshot: BookshelfSnapshot = bookshelfSnapshot(loadBookshelf())
|
|
813
|
+
writeJson(res, 200, snapshot)
|
|
814
|
+
return
|
|
815
|
+
}
|
|
816
|
+
if (req.method === 'POST') {
|
|
817
|
+
if (!isLoopbackRequest(req)) {
|
|
818
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
819
|
+
return
|
|
820
|
+
}
|
|
821
|
+
const body = await readJsonBody<BookCreateRequest>(req)
|
|
822
|
+
const bookName = body?.bookName?.trim()
|
|
823
|
+
if (bookName === undefined || bookName === '') {
|
|
824
|
+
writeJson(res, 400, { error: 'bookName 不能为空' })
|
|
825
|
+
return
|
|
826
|
+
}
|
|
827
|
+
const outputDir = body?.outputDir?.trim() !== '' && body?.outputDir !== undefined
|
|
828
|
+
? body.outputDir
|
|
829
|
+
: defaultOutputDirFor(bookName)
|
|
830
|
+
const book = createBook(bookName, outputDir)
|
|
831
|
+
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
|
|
832
|
+
void book
|
|
833
|
+
return
|
|
834
|
+
}
|
|
835
|
+
writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
|
|
836
|
+
},
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// --------------------------------------------------- bookshelf activate
|
|
840
|
+
const bookshelfActivateRoute: WebRoute = {
|
|
841
|
+
kind: 'exact',
|
|
842
|
+
path: '/api/dsh-novel-forge/bookshelf/activate',
|
|
843
|
+
handler: async (req, res) => {
|
|
844
|
+
if (!guard(req, res, 'POST')) return
|
|
845
|
+
const body = await readJsonBody<BookActivateRequest>(req)
|
|
846
|
+
if (body?.id === undefined || body.id === '') {
|
|
847
|
+
writeJson(res, 400, { error: 'id 不能为空' })
|
|
848
|
+
return
|
|
849
|
+
}
|
|
850
|
+
const book = activateBook(body.id)
|
|
851
|
+
if (book === undefined) {
|
|
852
|
+
writeJson(res, 404, { error: `书 ${body.id} 不存在` })
|
|
853
|
+
return
|
|
854
|
+
}
|
|
855
|
+
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
|
|
856
|
+
},
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// ---------------------------------------------------- bookshelf remove
|
|
860
|
+
const bookshelfRemoveRoute: WebRoute = {
|
|
861
|
+
kind: 'exact',
|
|
862
|
+
path: '/api/dsh-novel-forge/bookshelf/remove',
|
|
863
|
+
handler: async (req, res) => {
|
|
864
|
+
if (!guard(req, res, 'POST')) return
|
|
865
|
+
const body = await readJsonBody<BookRemoveRequest>(req)
|
|
866
|
+
if (body?.id === undefined || body.id === '') {
|
|
867
|
+
writeJson(res, 400, { error: 'id 不能为空' })
|
|
868
|
+
return
|
|
869
|
+
}
|
|
870
|
+
const removed = removeBook(body.id)
|
|
871
|
+
if (!removed) {
|
|
872
|
+
writeJson(res, 404, { error: `书 ${body.id} 不存在` })
|
|
873
|
+
return
|
|
874
|
+
}
|
|
875
|
+
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
|
|
876
|
+
},
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// --------------------------------------------------------------- config
|
|
880
|
+
const configRoute: WebRoute = {
|
|
881
|
+
kind: 'exact',
|
|
882
|
+
path: NOVEL_API.config,
|
|
883
|
+
handler: async (req, res) => {
|
|
884
|
+
if (!guard(req, res, 'POST')) return
|
|
885
|
+
const body = await readJsonBody<ConfigPatch>(req)
|
|
886
|
+
if (body === undefined) {
|
|
887
|
+
writeJson(res, 400, { error: '无效的配置 JSON' })
|
|
888
|
+
return
|
|
889
|
+
}
|
|
890
|
+
try {
|
|
891
|
+
const next = await patchConfig(body)
|
|
892
|
+
writeJson(res, 200, { config: next })
|
|
893
|
+
} catch (error) {
|
|
894
|
+
writeJson(res, 400, { error: (error as Error).message })
|
|
895
|
+
}
|
|
896
|
+
},
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// ---------------------------------------------------------- open-folder
|
|
900
|
+
const openFolderRoute: WebRoute = {
|
|
901
|
+
kind: 'exact',
|
|
902
|
+
path: NOVEL_API.openFolder,
|
|
903
|
+
handler: async (req, res) => {
|
|
904
|
+
if (!guard(req, res, 'POST')) return
|
|
905
|
+
const config = getConfig()
|
|
906
|
+
const dir = config.outputDir
|
|
907
|
+
exec(`explorer "${dir.replace(/"/g, '')}"`, (error) => {
|
|
908
|
+
if (error) {
|
|
909
|
+
writeJson(res, 500, { ok: false, error: error.message })
|
|
910
|
+
} else {
|
|
911
|
+
writeJson(res, 200, { ok: true })
|
|
912
|
+
}
|
|
913
|
+
})
|
|
914
|
+
},
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
return [
|
|
918
|
+
statusRoute,
|
|
919
|
+
loadOutlineRoute,
|
|
920
|
+
saveOutlineRoute,
|
|
921
|
+
bibleRoute,
|
|
922
|
+
volumesRoute,
|
|
923
|
+
planRoute,
|
|
924
|
+
generateRoute,
|
|
925
|
+
reviewRoute,
|
|
926
|
+
rewriteRoute,
|
|
927
|
+
polishRoute,
|
|
928
|
+
summaryRoute,
|
|
929
|
+
foreshadowRoute,
|
|
930
|
+
exportRoute,
|
|
931
|
+
chapterRoute,
|
|
932
|
+
assetsRoute,
|
|
933
|
+
styleEngineRoute,
|
|
934
|
+
assistantRoute,
|
|
935
|
+
assistantHistoryRoute,
|
|
936
|
+
bookshelfRoute,
|
|
937
|
+
bookshelfActivateRoute,
|
|
938
|
+
bookshelfRemoveRoute,
|
|
939
|
+
configRoute,
|
|
940
|
+
openFolderRoute,
|
|
941
|
+
]
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Re-export for tests / type consumers.
|
|
945
|
+
export type {
|
|
946
|
+
ConfigPatch,
|
|
947
|
+
NovelConfig,
|
|
948
|
+
StatusResponse,
|
|
949
|
+
PlanResponse,
|
|
950
|
+
LoadOutlineResponse,
|
|
951
|
+
BibleResponse,
|
|
952
|
+
VolumesResponse,
|
|
953
|
+
ExportResponse,
|
|
954
|
+
}
|
|
955
|
+
export { chapterFileName }
|