@waterwx/dsh-novel-forge 1.3.3 → 1.4.1

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/src/routes.ts CHANGED
@@ -1,2272 +1,2409 @@
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 { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
14
- import { join } from 'node:path'
15
- import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
16
- import type { Context } from '@deepseek-ai/cordis'
17
- import { ProductionRunner } from './run.ts'
18
- import {
19
- NOVEL_API,
20
- type AssetsPatch,
21
- type AssetsResponse,
22
- type AssistantFrame,
23
- type AssistantHistoryResponse,
24
- type AssistantRequest,
25
- type AuditResponse,
26
- type AuditStatus,
27
- type AuthorReview,
28
- type BiblePatchRequest,
29
- type BibleRequest,
30
- type BlurbRequest,
31
- type CoverRequest,
32
- type CoverResponse,
33
- type RenameRequest,
34
- type WorldRequest,
35
- type BibleResponse,
36
- type BookActivateRequest,
37
- type BookCreateRequest,
38
- type BookRemoveRequest,
39
- type BookshelfSnapshot,
40
- type ChapterResponse,
41
- type ChapterPlan,
42
- type ChapterSaveResponse,
43
- type ChapterTextRequest,
44
- type ConfigPatch,
45
- type DraftDecisionRequest,
46
- type ExportRequest,
47
- type ExportResponse,
48
- type ForeshadowRequest,
49
- type ForeshadowResponse,
50
- type JobFrame,
51
- type LoadOutlineRequest,
52
- type LoadOutlineResponse,
53
- type NovelConfig,
54
- type PlotlinesRequest,
55
- type PlotlinesResponse,
56
- type PlanRequest,
57
- type PlanResponse,
58
- type PolishRequest,
59
- type ResetRequest,
60
- type ReviewReport,
61
- type ReviewRequest,
62
- type RunControlRequest,
63
- type RunStartRequest,
64
- type RunState,
65
- type RewriteRequest,
66
- type RolesRequest,
67
- type RolesResponse,
68
- type SceneCard,
69
- type ScenesRequest,
70
- type ScenesResponse,
71
- type VisualRulesRequest,
72
- type VisualRulesResponse,
73
- type OutlineSuggestRequest,
74
- type OutlineSuggestResponse,
75
- type BreakdownRequest,
76
- type BreakdownResponse,
77
- type SensitiveCheckRequest,
78
- type SensitiveCheckResponse,
79
- type SensitiveHit,
80
- type StatusResponse,
81
- type StoryBible,
82
- type StyleEngineRequest,
83
- type SummaryRequest,
84
- type VolumesRequest,
85
- type VolumesResponse,
86
- } from './protocol.ts'
87
- import { readOutlineFromDocx } from './docx.ts'
88
- import { clearAssistantHistory, loadAssistantHistory, runAssistantTurn } from './assistant.ts'
89
- import { activateBook, bookshelfSnapshot, createBook, defaultOutputDirFor, loadBookshelf, removeBook, renameBook, seedBookshelfFromOutputDir } from './bookshelf.ts'
90
- import { BUILTIN_ANTI_AI_RULES, BUILTIN_GENRE_LIBRARY, BUILTIN_PROGRESSION_MODES, BUILTIN_STYLE_TEMPLATES, emptyProjectAssets } from './assets.ts'
91
- import {
92
- chapterFileName,
93
- auditBook,
94
- authorReviewChapter,
95
- autoLinkPlotlines,
96
- backfillFacts,
97
- createProject,
98
- exportBook,
99
- extractBible,
100
- extractStyleAsset,
101
- extractWorld,
102
- generateBlurb,
103
- generateChapterStream,
104
- listChapterFiles,
105
- loadProject,
106
- markForeshadowPlanted,
107
- mergeVolatileFromDisk,
108
- planChapters,
109
- planVolumes,
110
- polishChapterStream,
111
- readChapterFile,
112
- refreshCharacters,
113
- refreshPlotlineProgress,
114
- reviewChapter,
115
- reviewChapterText,
116
- rewriteChapterStream,
117
- saveProject,
118
- analyzePlotlineHealth,
119
- designPlotlinePlan,
120
- extractRoles,
121
- extractScenes,
122
- generateRolePromptKit,
123
- extractVisualRules,
124
- extractRoleVisual,
125
- suggestOutlines,
126
- breakdownBook,
127
- generateRoleReferenceImage,
128
- checkSensitiveText,
129
- suggestForeshadows,
130
- suggestPlotlines,
131
- summarizeAndExtractFacts,
132
- summarizeChapter,
133
- syncProjectWithDisk,
134
- } from './engine.ts'
135
-
136
- /** Cap on JSON request bodies (generous: cover images travel as base64). */
137
- const MAX_JSON_BODY_BYTES = 16 * 1024 * 1024
138
-
139
- /** Loopback-only fence (mirrors the family plugins' pairing routes). */
140
- function isLoopbackRequest(request: IncomingMessage): boolean {
141
- const address = request.socket.remoteAddress
142
- if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
143
- const host = request.headers.host
144
- if (typeof host !== 'string') return false
145
- let hostUrl: URL
146
- try {
147
- hostUrl = new URL(`http://${host}`)
148
- } catch {
149
- return false
150
- }
151
- if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
152
- if (request.headers['sec-fetch-site'] === 'cross-site') return false
153
- const origin = request.headers.origin
154
- if (origin === undefined) return true
155
- try {
156
- return new URL(origin).host === hostUrl.host
157
- } catch {
158
- return false
159
- }
160
- }
161
-
162
- /** One JSON response. */
163
- function writeJson(res: ServerResponse, status: number, body: unknown): void {
164
- const payload = JSON.stringify(body)
165
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
166
- res.end(payload)
167
- }
168
-
169
- /** Read a JSON request body. */
170
- async function readJsonBody<T>(req: IncomingMessage): Promise<T | undefined> {
171
- const chunks: Buffer[] = []
172
- let size = 0
173
- for await (const chunk of req) {
174
- const buffer = chunk as Buffer
175
- size += buffer.length
176
- if (size > MAX_JSON_BODY_BYTES) return undefined
177
- chunks.push(buffer)
178
- }
179
- try {
180
- return JSON.parse(Buffer.concat(chunks).toString('utf8')) as T
181
- } catch {
182
- return undefined
183
- }
184
- }
185
-
186
- /** Route deps. */
187
- export interface NovelRoutesDeps {
188
- ctx: Context
189
- /** Resolve the live plugin config (settings-aware). */
190
- getConfig: () => NovelConfig
191
- /** Persist a config patch through the settings seam. */
192
- patchConfig: (patch: ConfigPatch) => Promise<NovelConfig>
193
- }
194
-
195
- /** Default chapter count for planning when the request omits it. */
196
- const DEFAULT_PLAN_COUNT = 30
197
-
198
- /**
199
- * Build every /api/dsh-novel-forge route.
200
- * @param deps - context, config resolver, config patcher.
201
- * @returns the route list.
202
- */
203
- export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
204
- const { ctx, getConfig, patchConfig } = deps
205
-
206
- /** 全书质检实时状态(内存态,重启后回到 idle;用于 /status 暴露进度)。 */
207
- let auditState: AuditStatus = { status: 'idle', totalBatches: 0, completedBatches: 0 }
208
-
209
- /** Guard helper: fence + method check. */
210
- const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
211
- if (!isLoopbackRequest(req)) {
212
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
213
- return false
214
- }
215
- if (req.method !== method) {
216
- writeJson(res, 405, { error: `method not allowed (expected ${method})` })
217
- return false
218
- }
219
- return true
220
- }
221
-
222
- /** Load (and sync) the project, or respond 400. */
223
- const requireProject = (res: ServerResponse): ReturnType<typeof loadProject> => {
224
- const config = getConfig()
225
- const project = loadProject(config.outputDir)
226
- if (project === undefined) {
227
- writeJson(res, 400, { error: '输出目录中没有项目,请先加载大纲' })
228
- return undefined
229
- }
230
- syncProjectWithDisk(project, config.outputDir)
231
- saveProject(config.outputDir, project)
232
- return project
233
- }
234
-
235
- // -------------------------------------------------------------- status
236
- const statusRoute: WebRoute = {
237
- kind: 'exact',
238
- path: NOVEL_API.status,
239
- handler: (req, res) => {
240
- if (!guard(req, res, 'GET')) return
241
- const config = getConfig()
242
- // 书架为空时播种 settings 默认输出目录里的已有项目。
243
- seedBookshelfFromOutputDir(config.outputDir)
244
- const project = loadProject(config.outputDir)
245
- if (project !== undefined) {
246
- // 自动兜底:generating 超过 10 分钟(正常一章 2-5 分钟)视为卡死/中断,
247
- // 复位为 pending,避免"生成中"状态卡死无法重新生成。
248
- const staleMs = 10 * 60 * 1000
249
- for (const c of project.chapters) {
250
- if (c.status === 'generating' && c.generatingAt !== undefined) {
251
- const started = new Date(c.generatingAt).getTime()
252
- if (Number.isFinite(started) && Date.now() - started > staleMs) {
253
- c.status = 'pending'
254
- c.error = undefined
255
- c.generatingAt = undefined
256
- }
257
- }
258
- }
259
- syncProjectWithDisk(project, config.outputDir)
260
- saveProject(config.outputDir, project)
261
- }
262
- const response: StatusResponse = {
263
- config,
264
- // 瘦身:facts 只回最近 80 条(客户端最多展示 60),避免长篇后
265
- // status 响应体随编年录无限膨胀。
266
- project: project !== undefined
267
- ? { ...project, facts: (project.facts ?? []).slice(-80) }
268
- : undefined,
269
- generatedFiles: listChapterFiles(config.outputDir),
270
- audit: auditState,
271
- }
272
- writeJson(res, 200, response)
273
- },
274
- }
275
-
276
- // -------------------------------------------------------- load-outline
277
- const loadOutlineRoute: WebRoute = {
278
- kind: 'exact',
279
- path: NOVEL_API.loadOutline,
280
- handler: async (req, res) => {
281
- if (!guard(req, res, 'POST')) return
282
- const body = await readJsonBody<LoadOutlineRequest>(req)
283
- const config = getConfig()
284
- try {
285
- let outline: string
286
- let path: string | undefined
287
- if (body?.text !== undefined && body.text.trim() !== '') {
288
- outline = body.text.trim()
289
- } else {
290
- const target = body?.path?.trim() !== '' && body?.path !== undefined ? body.path : config.outlinePath
291
- outline = readOutlineFromDocx(target)
292
- path = target
293
- }
294
- if (outline.length < 50) {
295
- writeJson(res, 400, { error: '大纲内容过短(<50 字符),请检查文件或直接粘贴大纲文本' })
296
- return
297
- }
298
- const response: LoadOutlineResponse = {
299
- outline,
300
- bookName: createProject(outline).bookName,
301
- chars: outline.length,
302
- path,
303
- }
304
- writeJson(res, 200, response)
305
- } catch (error) {
306
- writeJson(res, 400, { error: (error as Error).message })
307
- }
308
- },
309
- }
310
-
311
- // -------------------------------------------------------- save-outline
312
- const saveOutlineRoute: WebRoute = {
313
- kind: 'exact',
314
- path: NOVEL_API.saveOutline,
315
- handler: async (req, res) => {
316
- if (!guard(req, res, 'POST')) return
317
- const body = await readJsonBody<LoadOutlineRequest>(req)
318
- const config = getConfig()
319
- const outline = body?.text ?? ''
320
- if (outline.trim().length < 50) {
321
- writeJson(res, 400, { error: '大纲内容过短(<50 字符)' })
322
- return
323
- }
324
- let project = loadProject(config.outputDir)
325
- const now = new Date().toISOString()
326
- if (project === undefined) {
327
- project = createProject(outline)
328
- } else {
329
- project.outline = outline
330
- project.bookName = createProject(outline).bookName
331
- project.updatedAt = now
332
- }
333
- saveProject(config.outputDir, project)
334
- writeJson(res, 200, { ok: true, bookName: project.bookName })
335
- },
336
- }
337
-
338
- // --------------------------------------------------------------- bible
339
- const bibleRoute: WebRoute = {
340
- kind: 'exact',
341
- path: NOVEL_API.bible,
342
- handler: async (req, res) => {
343
- if (!guard(req, res, 'POST')) return
344
- const body = await readJsonBody<BibleRequest>(req)
345
- const config = getConfig()
346
- const project = loadProject(config.outputDir)
347
- const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
348
- ? body.outline
349
- : project?.outline
350
- if (outline === undefined || outline.length < 50) {
351
- writeJson(res, 400, { error: '请先加载大纲' })
352
- return
353
- }
354
- try {
355
- const bible = await extractBible(ctx, config, outline, project)
356
- const now = new Date().toISOString()
357
- const next = project ?? createProject(outline)
358
- next.bible = bible
359
- next.updatedAt = now
360
- saveProject(config.outputDir, next)
361
- const response: BibleResponse = { bible }
362
- writeJson(res, 200, response)
363
- } catch (error) {
364
- writeJson(res, 500, { error: (error as Error).message })
365
- }
366
- },
367
- }
368
-
369
- // ------------------------------------------------------------- volumes
370
- const volumesRoute: WebRoute = {
371
- kind: 'exact',
372
- path: NOVEL_API.volumes,
373
- handler: async (req, res) => {
374
- if (!guard(req, res, 'POST')) return
375
- const body = await readJsonBody<VolumesRequest>(req)
376
- const config = getConfig()
377
- const project = loadProject(config.outputDir)
378
- const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
379
- ? body.outline
380
- : project?.outline
381
- if (outline === undefined || outline.length < 50) {
382
- writeJson(res, 400, { error: '请先加载大纲' })
383
- return
384
- }
385
- try {
386
- const volumes = await planVolumes(ctx, config, outline)
387
- const now = new Date().toISOString()
388
- const next = project ?? createProject(outline)
389
- next.volumes = volumes
390
- next.updatedAt = now
391
- saveProject(config.outputDir, next)
392
- const response: VolumesResponse = { volumes }
393
- writeJson(res, 200, response)
394
- } catch (error) {
395
- writeJson(res, 500, { error: (error as Error).message })
396
- }
397
- },
398
- }
399
-
400
- // ----------------------------------------------------------------- plan
401
- const planRoute: WebRoute = {
402
- kind: 'exact',
403
- path: NOVEL_API.plan,
404
- handler: async (req, res) => {
405
- if (!guard(req, res, 'POST')) return
406
- const body = await readJsonBody<PlanRequest>(req)
407
- const config = getConfig()
408
- const project = loadProject(config.outputDir)
409
- const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
410
- ? body.outline
411
- : project?.outline
412
- if (outline === undefined || outline.length < 50) {
413
- writeJson(res, 400, { error: '请先加载大纲(或粘贴大纲文本)' })
414
- return
415
- }
416
- const count = body?.chapterCount ?? DEFAULT_PLAN_COUNT
417
- if (!Number.isInteger(count) || count < 1 || count > 200) {
418
- writeJson(res, 400, { error: 'chapterCount 须为 1-200 的整数' })
419
- return
420
- }
421
- try {
422
- const next = project ?? createProject(outline)
423
- const chapters: ChapterPlan[] = await planChapters(ctx, config, next, count, body?.volume, config.outputDir)
424
- // 并发保护:长 LLM 调用期间磁盘可能已被其他请求更新(角色库/剧情线等),
425
- // 保存前合并磁盘最新易变字段,避免旧快照覆盖新修改。
426
- mergeVolatileFromDisk(config.outputDir, next)
427
- next.chapters.push(...chapters)
428
- next.updatedAt = new Date().toISOString()
429
- saveProject(config.outputDir, next)
430
- const response: PlanResponse = { chapters, volumes: next.volumes }
431
- writeJson(res, 200, response)
432
- } catch (error) {
433
- writeJson(res, 500, { error: (error as Error).message })
434
- }
435
- },
436
- }
437
-
438
- // ------------------------------------------------------------- generate
439
- const generateRoute: WebRoute = {
440
- kind: 'exact',
441
- path: NOVEL_API.generate,
442
- handler: async (req, res) => {
443
- if (!guard(req, res, 'POST')) return
444
- const config = getConfig()
445
- const project = requireProject(res)
446
- if (project === undefined) return
447
- const body = await readJsonBody<{ chapterNo?: number; skipReview?: boolean }>(req)
448
- const rawNo = body?.chapterNo
449
- if (!Number.isInteger(rawNo) || rawNo === undefined || rawNo < 1) {
450
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
451
- return
452
- }
453
- const no: number = rawNo
454
- const chapter = project.chapters.find(c => c.no === no)
455
- if (chapter === undefined) {
456
- writeJson(res, 404, { error: `章节 ${no} 不在计划中` })
457
- return
458
- }
459
- if (chapter.status === 'generating') {
460
- writeJson(res, 409, { error: `章节 ${no} 正在生成中` })
461
- return
462
- }
463
-
464
- // NDJSON stream.
465
- res.writeHead(200, {
466
- 'content-type': 'application/x-ndjson; charset=utf-8',
467
- 'cache-control': 'no-cache',
468
- 'x-accel-buffering': 'no',
469
- 'referrer-policy': 'no-referrer',
470
- })
471
- chapter.status = 'generating'
472
- chapter.error = undefined
473
- chapter.generatingAt = new Date().toISOString()
474
- // 并发保护:合并磁盘最新易变字段后保存,避免本请求覆盖并发修改(角色库/剧情线等)。
475
- mergeVolatileFromDisk(config.outputDir, project)
476
- saveProject(config.outputDir, project)
477
-
478
- const send = (frame: JobFrame): void => {
479
- res.write(JSON.stringify(frame) + '\n')
480
- }
481
-
482
- try {
483
- send({ type: 'start', no, title: chapter.title })
484
- for await (const step of generateChapterStream(ctx, config, project, config.outputDir, no)) {
485
- if (step.frame === 'delta') {
486
- send({ type: 'delta', text: step.text })
487
- } else if (step.frame === 'done') {
488
- send({ type: 'done', no, file: step.file, chars: step.chars, title: chapter.title })
489
- }
490
- }
491
- // Auto pipeline: summary + facts(一次调用)-> review(unless skipped)。
492
- try {
493
- await summarizeAndExtractFacts(ctx, config, project, config.outputDir, no)
494
- } catch (error) {
495
- console.warn('[dsh-novel-forge] summary/facts failed:', (error as Error).message)
496
- }
497
- // 伏笔落地标记:正文中命中 planned 伏笔关键词 → 自动标为 planted(暗线管理页与正文同步)。
498
- try {
499
- const marked = markForeshadowPlanted(project, config.outputDir, no)
500
- if (marked > 0) {
501
- console.log(`[dsh-novel-forge] 第${no}章已埋伏笔 ${marked} 条`)
502
- }
503
- } catch (error) {
504
- console.warn('[dsh-novel-forge] markForeshadowPlanted failed:', (error as Error).message)
505
- }
506
- if (!(body?.skipReview === true) && (config.autoReview ?? true)) {
507
- const report = await reviewChapter(ctx, config, project, config.outputDir, no)
508
- send({ type: 'review', no, report })
509
- } else {
510
- chapter.status = 'approved'
511
- mergeVolatileFromDisk(config.outputDir, project)
512
- saveProject(config.outputDir, project)
513
- }
514
- // 作者复盘:叙事结构检查(钩子兑现/结尾钩子/推进/连续性/趋势;不改变章节状态)。
515
- if (config.autoAuthorReview ?? true) {
516
- try {
517
- const currentBody = readChapterFile(config.outputDir, chapter)
518
- let prevTail = ''
519
- if (no > 1) {
520
- const prev = project.chapters.find(c => c.no === no - 1)
521
- if (prev !== undefined) {
522
- prevTail = (readChapterFile(config.outputDir, prev) ?? '').replace(/^#.*$/m, '').trim().slice(-600)
523
- }
524
- }
525
- if (currentBody !== undefined) {
526
- const review = await authorReviewChapter(ctx, config, project, no, currentBody, prevTail)
527
- chapter.authorReview = review
528
- // 复盘标记推进的剧情线 → 自动关联本章。
529
- if (review.advancedLines !== undefined) {
530
- autoLinkPlotlines(project, no, review.advancedLines)
531
- }
532
- mergeVolatileFromDisk(config.outputDir, project)
533
- saveProject(config.outputDir, project)
534
- send({ type: 'author-review', no, review })
535
- }
536
- } catch (error) {
537
- console.warn('[dsh-novel-forge] author review failed:', (error as Error).message)
538
- }
539
- }
540
- res.end()
541
- } catch (error) {
542
- chapter.status = 'error'
543
- chapter.error = (error as Error).message
544
- mergeVolatileFromDisk(config.outputDir, project)
545
- saveProject(config.outputDir, project)
546
- if (!res.writableEnded) {
547
- send({ type: 'error', no, message: (error as Error).message })
548
- res.end()
549
- }
550
- }
551
- },
552
- }
553
-
554
- // --------------------------------------------------------------- review
555
- const reviewRoute: WebRoute = {
556
- kind: 'exact',
557
- path: NOVEL_API.review,
558
- handler: async (req, res) => {
559
- if (!guard(req, res, 'POST')) return
560
- const config = getConfig()
561
- const project = requireProject(res)
562
- if (project === undefined) return
563
- const body = await readJsonBody<ReviewRequest>(req)
564
- if (!Number.isInteger(body?.chapterNo)) {
565
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
566
- return
567
- }
568
- const no = body!.chapterNo!
569
- try {
570
- const report = await reviewChapter(ctx, config, project, config.outputDir, no)
571
- writeJson(res, 200, { report })
572
- } catch (error) {
573
- writeJson(res, 500, { error: (error as Error).message })
574
- }
575
- },
576
- }
577
-
578
- // -------------------------------------------------------------- rewrite
579
- const rewriteRoute: WebRoute = {
580
- kind: 'exact',
581
- path: NOVEL_API.rewrite,
582
- handler: async (req, res) => {
583
- if (!guard(req, res, 'POST')) return
584
- const config = getConfig()
585
- const project = requireProject(res)
586
- if (project === undefined) return
587
- const body = await readJsonBody<RewriteRequest>(req)
588
- if (!Number.isInteger(body?.chapterNo)) {
589
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
590
- return
591
- }
592
- const no = body!.chapterNo!
593
- res.writeHead(200, {
594
- 'content-type': 'application/x-ndjson; charset=utf-8',
595
- 'cache-control': 'no-cache',
596
- 'x-accel-buffering': 'no',
597
- 'referrer-policy': 'no-referrer',
598
- })
599
- const send = (frame: JobFrame): void => { res.write(JSON.stringify(frame) + '\n') }
600
- try {
601
- for await (const step of rewriteChapterStream(ctx, config, project, config.outputDir, no, body?.instructions ?? '', body?.target)) {
602
- if (step.frame === 'delta') send({ type: 'delta', text: step.text })
603
- else if (step.frame === 'drafted') send({ type: 'drafted', no, chars: step.chars, draft: step.draft })
604
- }
605
- // Draft mode: no auto re-review — the user reviews the diff and
606
- // decides; re-run review after applying if wanted.
607
- res.end()
608
- } catch (error) {
609
- if (!res.writableEnded) {
610
- send({ type: 'error', no, message: (error as Error).message })
611
- res.end()
612
- }
613
- }
614
- },
615
- }
616
-
617
- // --------------------------------------------------------------- polish
618
- const polishRoute: WebRoute = {
619
- kind: 'exact',
620
- path: NOVEL_API.polish,
621
- handler: async (req, res) => {
622
- if (!guard(req, res, 'POST')) return
623
- const config = getConfig()
624
- const project = requireProject(res)
625
- if (project === undefined) return
626
- const body = await readJsonBody<PolishRequest>(req)
627
- if (!Number.isInteger(body?.chapterNo)) {
628
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
629
- return
630
- }
631
- const no = body!.chapterNo!
632
- res.writeHead(200, {
633
- 'content-type': 'application/x-ndjson; charset=utf-8',
634
- 'cache-control': 'no-cache',
635
- 'x-accel-buffering': 'no',
636
- 'referrer-policy': 'no-referrer',
637
- })
638
- const send = (frame: JobFrame): void => { res.write(JSON.stringify(frame) + '\n') }
639
- try {
640
- for await (const step of polishChapterStream(ctx, config, project, config.outputDir, no)) {
641
- if (step.frame === 'delta') send({ type: 'delta', text: step.text })
642
- else if (step.frame === 'drafted') send({ type: 'drafted', no, chars: step.chars, draft: step.draft })
643
- }
644
- res.end()
645
- } catch (error) {
646
- if (!res.writableEnded) {
647
- send({ type: 'error', no, message: (error as Error).message })
648
- res.end()
649
- }
650
- }
651
- },
652
- }
653
-
654
- // ---------------------------------------------------- draft apply/discard
655
- /** 采纳待确认草稿:覆盖正文文件 + 状态回 written + 清空草稿。 */
656
- const draftApplyRoute: WebRoute = {
657
- kind: 'exact',
658
- path: NOVEL_API.draftApply,
659
- handler: async (req, res) => {
660
- if (!guard(req, res, 'POST')) return
661
- const config = getConfig()
662
- const project = requireProject(res)
663
- if (project === undefined) return
664
- const body = await readJsonBody<DraftDecisionRequest>(req)
665
- if (!Number.isInteger(body?.chapterNo)) {
666
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
667
- return
668
- }
669
- const chapter = project.chapters.find(c => c.no === body!.chapterNo!)
670
- if (chapter === undefined) {
671
- writeJson(res, 404, { error: `章节 ${body!.chapterNo} 不在计划中` })
672
- return
673
- }
674
- if (chapter.pendingDraft === undefined || chapter.pendingDraft === '') {
675
- writeJson(res, 400, { error: `章节 ${chapter.no} 没有待确认的草稿` })
676
- return
677
- }
678
- const draft = chapter.pendingDraft
679
- const fileName = chapterFileName(chapter)
680
- mkdirSync(config.outputDir, { recursive: true })
681
- const targetPath = join(config.outputDir, fileName)
682
- // 采纳前自动备份当前原稿为 .bak.md(每次应用都刷新为最新原稿),可随时回退。
683
- if (existsSync(targetPath)) {
684
- copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
685
- }
686
- writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
687
- chapter.pendingDraft = undefined
688
- chapter.chars = draft.length
689
- chapter.file = fileName
690
- // 携带审查报告则沿用结论定状态(修订后审查通过 → approved);否则置 written 待审。
691
- const carried = body?.report
692
- if (carried !== undefined && typeof carried.score === 'number') {
693
- chapter.review = carried
694
- chapter.status = carried.passed ? 'approved' : 'rejected'
695
- } else {
696
- chapter.status = 'written'
697
- chapter.review = undefined
698
- }
699
- chapter.error = undefined
700
- project.updatedAt = new Date().toISOString()
701
- saveProject(config.outputDir, project)
702
- writeJson(res, 200, { ok: true, chars: draft.length, file: fileName, markdown: draft })
703
- },
704
- }
705
-
706
- /** 放弃待确认草稿:保留原稿,仅清空草稿字段。 */
707
- const draftDiscardRoute: WebRoute = {
708
- kind: 'exact',
709
- path: NOVEL_API.draftDiscard,
710
- handler: async (req, res) => {
711
- if (!guard(req, res, 'POST')) return
712
- const config = getConfig()
713
- const project = requireProject(res)
714
- if (project === undefined) return
715
- const body = await readJsonBody<DraftDecisionRequest>(req)
716
- if (!Number.isInteger(body?.chapterNo)) {
717
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
718
- return
719
- }
720
- const chapter = project.chapters.find(c => c.no === body!.chapterNo!)
721
- if (chapter === undefined) {
722
- writeJson(res, 404, { error: `章节 ${body!.chapterNo} 不在计划中` })
723
- return
724
- }
725
- if (chapter.pendingDraft !== undefined) {
726
- chapter.pendingDraft = undefined
727
- project.updatedAt = new Date().toISOString()
728
- saveProject(config.outputDir, project)
729
- }
730
- writeJson(res, 200, { ok: true })
731
- },
732
- }
733
-
734
- // -------------------------------------------------------------- summary
735
- const summaryRoute: WebRoute = {
736
- kind: 'exact',
737
- path: NOVEL_API.summary,
738
- handler: async (req, res) => {
739
- if (!guard(req, res, 'POST')) return
740
- const config = getConfig()
741
- const project = requireProject(res)
742
- if (project === undefined) return
743
- const body = await readJsonBody<SummaryRequest>(req)
744
- if (!Number.isInteger(body?.chapterNo)) {
745
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
746
- return
747
- }
748
- try {
749
- const summary = await summarizeChapter(ctx, config, project, config.outputDir, body!.chapterNo!)
750
- writeJson(res, 200, { summary })
751
- } catch (error) {
752
- writeJson(res, 500, { error: (error as Error).message })
753
- }
754
- },
755
- }
756
-
757
- // ---------------------------------------------------------- foreshadow
758
- const foreshadowRoute: WebRoute = {
759
- kind: 'exact',
760
- path: NOVEL_API.foreshadow,
761
- handler: async (req, res) => {
762
- if (!guard(req, res, 'POST')) return
763
- const config = getConfig()
764
- const project = requireProject(res)
765
- if (project === undefined) return
766
- const body = await readJsonBody<ForeshadowRequest>(req)
767
- try {
768
- if (body?.suggest === true) {
769
- // AI suggestion pass: create several foreshadows from the outline.
770
- const created = await suggestForeshadows(ctx, config, project)
771
- project.updatedAt = new Date().toISOString()
772
- saveProject(config.outputDir, project)
773
- const response: ForeshadowResponse = { foreshadows: created }
774
- writeJson(res, 200, response)
775
- return
776
- }
777
- if (body?.id !== undefined) {
778
- // Update an existing foreshadow.
779
- const target = project.foreshadows.find(f => f.id === body.id)
780
- if (target === undefined) {
781
- writeJson(res, 404, { error: `伏笔 ${body.id} 不存在` })
782
- return
783
- }
784
- if (body.description !== undefined) target.description = body.description
785
- if (body.plantedChapter !== undefined) target.plantedChapter = body.plantedChapter
786
- if (body.targetChapter !== undefined) target.targetChapter = body.targetChapter
787
- if (body.status !== undefined) target.status = body.status
788
- if (body.resolvedNote !== undefined) target.resolvedNote = body.resolvedNote
789
- } else {
790
- // Create one manually.
791
- const description = body?.description?.trim()
792
- if (description === undefined || description === '') {
793
- writeJson(res, 400, { error: 'description 必填' })
794
- return
795
- }
796
- project.foreshadows.push({
797
- id: `fs-${Date.now().toString(36)}`,
798
- description,
799
- plantedChapter: body?.plantedChapter,
800
- targetChapter: body?.targetChapter,
801
- status: body?.status ?? 'planned',
802
- })
803
- }
804
- project.updatedAt = new Date().toISOString()
805
- saveProject(config.outputDir, project)
806
- const response: ForeshadowResponse = { foreshadows: project.foreshadows }
807
- writeJson(res, 200, response)
808
- } catch (error) {
809
- writeJson(res, 500, { error: (error as Error).message })
810
- }
811
- },
812
- }
813
-
814
- // -------------------------------------------------------------- export
815
- const exportRoute: WebRoute = {
816
- kind: 'exact',
817
- path: NOVEL_API.exportBook,
818
- handler: async (req, res) => {
819
- if (!guard(req, res, 'POST')) return
820
- const config = getConfig()
821
- const project = requireProject(res)
822
- if (project === undefined) return
823
- const body = await readJsonBody<ExportRequest>(req)
824
- const format = body?.format === 'md' ? 'md' : 'txt'
825
- try {
826
- const result = exportBook(config.outputDir, project, format)
827
- const response: ExportResponse = { ...result }
828
- writeJson(res, 200, response)
829
- } catch (error) {
830
- writeJson(res, 500, { error: (error as Error).message })
831
- }
832
- },
833
- }
834
-
835
- // -------------------------------------------------------------- chapter
836
- const chapterRoute: WebRoute = {
837
- kind: 'exact',
838
- path: NOVEL_API.chapter,
839
- handler: async (req, res) => {
840
- if (!guard(req, res, 'GET')) return
841
- const config = getConfig()
842
- const project = requireProject(res)
843
- if (project === undefined) return
844
- const url = new URL(req.url ?? '/', 'http://localhost')
845
- const rawNo = Number(url.searchParams.get('no') ?? '0')
846
- if (!Number.isInteger(rawNo) || rawNo < 1) {
847
- writeJson(res, 400, { error: 'no 须为正整数' })
848
- return
849
- }
850
- const chapter = project.chapters.find(c => c.no === rawNo)
851
- if (chapter === undefined) {
852
- writeJson(res, 404, { error: `章节 ${rawNo} 不在计划中` })
853
- return
854
- }
855
- const markdown = readChapterFile(config.outputDir, chapter)
856
- if (markdown === undefined) {
857
- writeJson(res, 404, { error: `章节 ${rawNo} 尚未生成` })
858
- return
859
- }
860
- const response: ChapterResponse = { no: chapter.no, title: chapter.title, markdown }
861
- writeJson(res, 200, response)
862
- },
863
- }
864
-
865
- // ------------------------------------------------------ chapter check/save
866
- /** 审查手动编辑的正文(不落盘,返回审稿报告)。 */
867
- const chapterCheckRoute: WebRoute = {
868
- kind: 'exact',
869
- path: NOVEL_API.chapterCheck,
870
- handler: async (req, res) => {
871
- if (!guard(req, res, 'POST')) return
872
- const config = getConfig()
873
- const project = requireProject(res)
874
- if (project === undefined) return
875
- const body = await readJsonBody<ChapterTextRequest>(req)
876
- const text = body?.text?.trim() ?? ''
877
- if (text.length < 50) {
878
- writeJson(res, 400, { error: '正文过短(<50 字),请先编辑内容' })
879
- return
880
- }
881
- try {
882
- // 携带上一轮报告 → 验证模式(逐条核对原意见解决情况 + 只挑新增 high)。
883
- const report = await reviewChapterText(ctx, config, project, text, body?.previousReport)
884
- writeJson(res, 200, { report })
885
- } catch (error) {
886
- writeJson(res, 500, { error: (error as Error).message })
887
- }
888
- },
889
- }
890
-
891
- /** 保存手动编辑的正文(自动备份 .bak,状态回 written)。 */
892
- const chapterSaveRoute: WebRoute = {
893
- kind: 'exact',
894
- path: NOVEL_API.chapterSave,
895
- handler: async (req, res) => {
896
- if (!guard(req, res, 'POST')) return
897
- const config = getConfig()
898
- const project = requireProject(res)
899
- if (project === undefined) return
900
- const body = await readJsonBody<ChapterTextRequest>(req)
901
- if (!Number.isInteger(body?.chapterNo)) {
902
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
903
- return
904
- }
905
- const chapter = project.chapters.find(c => c.no === body!.chapterNo!)
906
- if (chapter === undefined) {
907
- writeJson(res, 404, { error: `章节 ${body!.chapterNo} 不在计划中` })
908
- return
909
- }
910
- const text = body?.text?.trim() ?? ''
911
- if (text.length < 50) {
912
- writeJson(res, 400, { error: '正文过短(<50 字),未保存' })
913
- return
914
- }
915
- const fileName = chapterFileName(chapter)
916
- mkdirSync(config.outputDir, { recursive: true })
917
- const targetPath = join(config.outputDir, fileName)
918
- if (existsSync(targetPath)) {
919
- copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
920
- }
921
- writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${text}\n`, 'utf8')
922
- chapter.status = 'written'
923
- chapter.chars = text.length
924
- chapter.file = fileName
925
- chapter.pendingDraft = undefined
926
- // 保存即审稿:携带工作区审查报告则沿用(不重复审);否则自动正式审稿一次。
927
- let report: ReviewReport | undefined
928
- const carried = body?.report
929
- if (carried !== undefined && typeof carried.score === 'number') {
930
- report = carried
931
- chapter.review = report
932
- chapter.status = report.passed ? 'approved' : 'rejected'
933
- } else {
934
- report = await reviewChapter(ctx, config, project, config.outputDir, chapter.no)
935
- }
936
- project.updatedAt = new Date().toISOString()
937
- saveProject(config.outputDir, project)
938
- const response: ChapterSaveResponse = { ok: true, chars: text.length, file: fileName, report }
939
- writeJson(res, 200, response)
940
- },
941
- }
942
-
943
- // ----------------------------------------------------------- assistant
944
- const assistantRoute: WebRoute = {
945
- kind: 'exact',
946
- path: NOVEL_API.assistant,
947
- handler: async (req, res) => {
948
- if (!guard(req, res, 'POST')) return
949
- const config = getConfig()
950
- const project = requireProject(res)
951
- if (project === undefined) return
952
- const body = await readJsonBody<AssistantRequest>(req)
953
- const message = body?.message?.trim()
954
- if (message === undefined || message === '') {
955
- writeJson(res, 400, { error: '消息不能为空' })
956
- return
957
- }
958
- res.writeHead(200, {
959
- 'content-type': 'application/x-ndjson; charset=utf-8',
960
- 'cache-control': 'no-cache',
961
- 'x-accel-buffering': 'no',
962
- 'referrer-policy': 'no-referrer',
963
- })
964
- const send = (frame: AssistantFrame): void => { res.write(JSON.stringify(frame) + '\n') }
965
- try {
966
- for await (const step of runAssistantTurn(ctx, config, project, config.outputDir, message)) {
967
- if (step.frame === 'delta') send({ type: 'delta', text: step.text })
968
- else if (step.frame === 'tool') send({ type: 'tool', name: step.name, status: step.status, detail: step.detail })
969
- else if (step.frame === 'toolDelta') send({ type: 'toolDelta', name: step.name, text: step.text })
970
- }
971
- send({ type: 'done' })
972
- res.end()
973
- } catch (error) {
974
- if (!res.writableEnded) {
975
- send({ type: 'error', message: (error as Error).message })
976
- res.end()
977
- }
978
- }
979
- },
980
- }
981
-
982
- // -------------------------------------------------- assistant-history
983
- const assistantHistoryRoute: WebRoute = {
984
- kind: 'exact',
985
- path: NOVEL_API.assistantHistory,
986
- handler: (req, res) => {
987
- if (!guard(req, res, 'GET')) return
988
- const config = getConfig()
989
- const response: AssistantHistoryResponse = { messages: loadAssistantHistory(config.outputDir) }
990
- writeJson(res, 200, response)
991
- },
992
- }
993
-
994
- // --------------------------------------------------- assistant-clear
995
- /** 清空助手对话记录。 */
996
- const assistantClearRoute: WebRoute = {
997
- kind: 'exact',
998
- path: NOVEL_API.assistantClear,
999
- handler: (req, res) => {
1000
- if (!guard(req, res, 'POST')) return
1001
- const config = getConfig()
1002
- clearAssistantHistory(config.outputDir)
1003
- writeJson(res, 200, { ok: true })
1004
- },
1005
- }
1006
-
1007
- // --------------------------------------------------------------- assets
1008
- const assetsRoute: WebRoute = {
1009
- kind: 'exact',
1010
- path: NOVEL_API.assets,
1011
- handler: async (req, res) => {
1012
- // GET (read) and POST (patch) are both allowed — check methods first,
1013
- // then the loopback fence (guard() would 405 on POST, which is wrong).
1014
- if (req.method !== 'GET' && req.method !== 'POST') {
1015
- writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
1016
- return
1017
- }
1018
- if (!isLoopbackRequest(req)) {
1019
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
1020
- return
1021
- }
1022
- const config = getConfig()
1023
- const project = loadProject(config.outputDir)
1024
- const projectAssets = project?.assets ?? emptyProjectAssets()
1025
- if (req.method === 'POST') {
1026
- const body = await readJsonBody<AssetsPatch>(req)
1027
- if (body === undefined) {
1028
- writeJson(res, 400, { error: '无效的 JSON' })
1029
- return
1030
- }
1031
- if (project === undefined) {
1032
- writeJson(res, 400, { error: '请先加载大纲创建项目' })
1033
- return
1034
- }
1035
- if (body.genre !== undefined) projectAssets.genre = body.genre
1036
- if (body.primaryProgression !== undefined) projectAssets.primaryProgression = body.primaryProgression
1037
- if (body.auxiliaryProgressions !== undefined) projectAssets.auxiliaryProgressions = body.auxiliaryProgressions
1038
- if (body.antiAiRules !== undefined) projectAssets.antiAiRules = body.antiAiRules
1039
- if (body.styleAssets !== undefined) projectAssets.styleAssets = body.styleAssets
1040
- projectAssets.updatedAt = new Date().toISOString()
1041
- project.assets = projectAssets
1042
- project.updatedAt = new Date().toISOString()
1043
- saveProject(config.outputDir, project)
1044
- }
1045
- const response: AssetsResponse = {
1046
- projectAssets,
1047
- genreLibrary: BUILTIN_GENRE_LIBRARY,
1048
- antiAiLibrary: BUILTIN_ANTI_AI_RULES,
1049
- styleTemplates: BUILTIN_STYLE_TEMPLATES,
1050
- progressionLibrary: BUILTIN_PROGRESSION_MODES,
1051
- }
1052
- writeJson(res, 200, response)
1053
- },
1054
- }
1055
-
1056
- // ----------------------------------------------------------- style-engine
1057
- const styleEngineRoute: WebRoute = {
1058
- kind: 'exact',
1059
- path: NOVEL_API.styleEngine,
1060
- handler: async (req, res) => {
1061
- if (!guard(req, res, 'POST')) return
1062
- const config = getConfig()
1063
- const project = loadProject(config.outputDir)
1064
- const body = await readJsonBody<StyleEngineRequest>(req)
1065
- const sample = body?.sampleText?.trim()
1066
- if (sample === undefined || sample.length < 50) {
1067
- writeJson(res, 400, { error: '样本文本过短(<50 字符),请粘贴一段能代表目标风格的文字' })
1068
- return
1069
- }
1070
- try {
1071
- const rules = await extractStyleAsset(ctx, config, sample)
1072
- const name = body?.name?.trim() !== '' && body?.name !== undefined ? body.name : `风格资产 ${Date.now().toString(36)}`
1073
- const styleAsset = {
1074
- name: name.slice(0, 40),
1075
- ...rules,
1076
- sourceText: sample.slice(0, 3000),
1077
- createdAt: new Date().toISOString(),
1078
- }
1079
- if (project !== undefined) {
1080
- project.assets ??= emptyProjectAssets()
1081
- project.assets.styleAssets ??= []
1082
- project.assets.styleAssets.push(styleAsset)
1083
- project.assets.updatedAt = new Date().toISOString()
1084
- project.updatedAt = new Date().toISOString()
1085
- saveProject(config.outputDir, project)
1086
- }
1087
- writeJson(res, 200, { styleAsset })
1088
- } catch (error) {
1089
- writeJson(res, 500, { error: (error as Error).message })
1090
- }
1091
- },
1092
- }
1093
-
1094
- // ------------------------------------------------------------- bookshelf
1095
- const bookshelfRoute: WebRoute = {
1096
- kind: 'exact',
1097
- path: NOVEL_API.bookshelf,
1098
- handler: async (req, res) => {
1099
- // GET = snapshot; POST = create book.
1100
- if (req.method === 'GET') {
1101
- if (!isLoopbackRequest(req)) {
1102
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
1103
- return
1104
- }
1105
- // 书架为空时,把 settings 默认输出目录里已有的项目播种为第一本书。
1106
- seedBookshelfFromOutputDir(getConfig().outputDir)
1107
- const snapshot: BookshelfSnapshot = bookshelfSnapshot(loadBookshelf())
1108
- writeJson(res, 200, snapshot)
1109
- return
1110
- }
1111
- if (req.method === 'POST') {
1112
- if (!isLoopbackRequest(req)) {
1113
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
1114
- return
1115
- }
1116
- const body = await readJsonBody<BookCreateRequest>(req)
1117
- const bookName = body?.bookName?.trim()
1118
- if (bookName === undefined || bookName === '') {
1119
- writeJson(res, 400, { error: 'bookName 不能为空' })
1120
- return
1121
- }
1122
- const outputDir = body?.outputDir?.trim() !== '' && body?.outputDir !== undefined
1123
- ? body.outputDir
1124
- : defaultOutputDirFor(bookName)
1125
- const book = createBook(bookName, outputDir)
1126
- // 开书向导:创建时带大纲 → 立即建立项目(书名以大纲首行为准)。
1127
- const outline = body?.outline?.trim()
1128
- if (outline !== undefined && outline.length >= 50) {
1129
- const project = createProject(outline)
1130
- saveProject(outputDir, project)
1131
- renameBook(book.id, project.bookName)
1132
- }
1133
- writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
1134
- return
1135
- }
1136
- writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
1137
- },
1138
- }
1139
-
1140
- // --------------------------------------------------------------- reset
1141
- /** 重置项目:清空设定/卷/章节计划/正文/伏笔/资产/事实库(可携带新大纲)。 */
1142
- const resetRoute: WebRoute = {
1143
- kind: 'exact',
1144
- path: NOVEL_API.reset,
1145
- handler: async (req, res) => {
1146
- if (!guard(req, res, 'POST')) return
1147
- const config = getConfig()
1148
- const project = loadProject(config.outputDir)
1149
- if (project === undefined) {
1150
- writeJson(res, 400, { error: '输出目录中没有项目,无需重置' })
1151
- return
1152
- }
1153
- const body = await readJsonBody<ResetRequest>(req)
1154
- const outline = body?.outline?.trim()
1155
- if (outline !== undefined && outline.length >= 50) {
1156
- project.outline = outline
1157
- project.bookName = createProject(outline).bookName
1158
- }
1159
- project.bible = undefined
1160
- project.volumes = undefined
1161
- project.chapters = []
1162
- project.foreshadows = []
1163
- project.assets = emptyProjectAssets()
1164
- project.facts = []
1165
- project.updatedAt = new Date().toISOString()
1166
- saveProject(config.outputDir, project)
1167
- writeJson(res, 200, { ok: true, bookName: project.bookName })
1168
- },
1169
- }
1170
-
1171
- // --------------------------------------------------- bookshelf activate
1172
- const bookshelfActivateRoute: WebRoute = {
1173
- kind: 'exact',
1174
- path: '/api/dsh-novel-forge/bookshelf/activate',
1175
- handler: async (req, res) => {
1176
- if (!guard(req, res, 'POST')) return
1177
- const body = await readJsonBody<BookActivateRequest>(req)
1178
- if (body?.id === undefined || body.id === '') {
1179
- writeJson(res, 400, { error: 'id 不能为空' })
1180
- return
1181
- }
1182
- const book = activateBook(body.id)
1183
- if (book === undefined) {
1184
- writeJson(res, 404, { error: `书 ${body.id} 不存在` })
1185
- return
1186
- }
1187
- writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
1188
- },
1189
- }
1190
-
1191
- // ---------------------------------------------------- bookshelf remove
1192
- const bookshelfRemoveRoute: WebRoute = {
1193
- kind: 'exact',
1194
- path: '/api/dsh-novel-forge/bookshelf/remove',
1195
- handler: async (req, res) => {
1196
- if (!guard(req, res, 'POST')) return
1197
- const body = await readJsonBody<BookRemoveRequest>(req)
1198
- if (body?.id === undefined || body.id === '') {
1199
- writeJson(res, 400, { error: 'id 不能为空' })
1200
- return
1201
- }
1202
- const removed = removeBook(body.id)
1203
- if (!removed) {
1204
- writeJson(res, 404, { error: `书 ${body.id} 不存在` })
1205
- return
1206
- }
1207
- writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
1208
- },
1209
- }
1210
-
1211
- // ---------------------------------------------------------------- audit
1212
- /** 全书一致性质检。 */
1213
- const auditRoute: WebRoute = {
1214
- kind: 'exact',
1215
- path: NOVEL_API.audit,
1216
- handler: async (req, res) => {
1217
- if (!guard(req, res, 'POST')) return
1218
- const config = getConfig()
1219
- const project = requireProject(res)
1220
- if (project === undefined) return
1221
- const startedAt = new Date().toISOString()
1222
- auditState = { status: 'running', startedAt, totalBatches: 0, completedBatches: 0 }
1223
- try {
1224
- const issues = await auditBook(ctx, config, project, config.outputDir, (completed, total) => {
1225
- auditState.totalBatches = total
1226
- auditState.completedBatches = completed
1227
- })
1228
- const auditedChapters = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating').length
1229
- auditState = {
1230
- status: 'done',
1231
- startedAt,
1232
- finishedAt: new Date().toISOString(),
1233
- totalBatches: auditState.totalBatches,
1234
- completedBatches: auditState.totalBatches,
1235
- auditedChapters,
1236
- issuesCount: issues.length,
1237
- issues,
1238
- }
1239
- const response: AuditResponse = {
1240
- issues,
1241
- auditedChapters,
1242
- auditedAt: auditState.finishedAt!,
1243
- }
1244
- writeJson(res, 200, response)
1245
- } catch (error) {
1246
- auditState = {
1247
- ...auditState,
1248
- status: 'error',
1249
- finishedAt: new Date().toISOString(),
1250
- error: (error as Error).message,
1251
- }
1252
- writeJson(res, 500, { error: (error as Error).message })
1253
- }
1254
- },
1255
- }
1256
-
1257
- // ----------------------------------------------------- characters refresh
1258
- /** 角色卡刷新(出场统计精确化 + LLM 聚合状态)。 */
1259
- const charactersRefreshRoute: WebRoute = {
1260
- kind: 'exact',
1261
- path: NOVEL_API.charactersRefresh,
1262
- handler: async (req, res) => {
1263
- if (!guard(req, res, 'POST')) return
1264
- const config = getConfig()
1265
- const project = requireProject(res)
1266
- if (project === undefined) return
1267
- try {
1268
- const cards = await refreshCharacters(ctx, config, project, config.outputDir)
1269
- // 存档:人物志聚合结果落盘,下次打开直接显示,无需重新计算。
1270
- project.roleStatus = cards
1271
- project.updatedAt = new Date().toISOString()
1272
- saveProject(config.outputDir, project)
1273
- writeJson(res, 200, { cards })
1274
- } catch (error) {
1275
- writeJson(res, 500, { error: (error as Error).message })
1276
- }
1277
- },
1278
- }
1279
-
1280
- // ------------------------------------------------------- facts backfill
1281
- /** 事实库回填:对历史已生成章节批量抽取事实。 */
1282
- const factsBackfillRoute: WebRoute = {
1283
- kind: 'exact',
1284
- path: NOVEL_API.factsBackfill,
1285
- handler: async (req, res) => {
1286
- if (!guard(req, res, 'POST')) return
1287
- const config = getConfig()
1288
- const project = requireProject(res)
1289
- if (project === undefined) return
1290
- try {
1291
- const filled = await backfillFacts(ctx, config, project, config.outputDir)
1292
- writeJson(res, 200, { ok: true, filled })
1293
- } catch (error) {
1294
- writeJson(res, 500, { error: (error as Error).message })
1295
- }
1296
- },
1297
- }
1298
-
1299
- // ---------------------------------------------------------- bible patch
1300
- /** 设定圣经局部修补(世界观规则/红线/风格)。 */
1301
- const biblePatchRoute: WebRoute = {
1302
- kind: 'exact',
1303
- path: NOVEL_API.biblePatch,
1304
- handler: async (req, res) => {
1305
- if (!guard(req, res, 'POST')) return
1306
- const config = getConfig()
1307
- const project = requireProject(res)
1308
- if (project === undefined) return
1309
- if (project.bible === undefined) {
1310
- writeJson(res, 400, { error: '尚未生成道藏,请先生成' })
1311
- return
1312
- }
1313
- const body = await readJsonBody<BiblePatchRequest>(req)
1314
- if (Array.isArray(body?.worldRules)) project.bible.worldRules = body.worldRules.filter(r => r.trim() !== '')
1315
- if (Array.isArray(body?.redLines)) project.bible.redLines = body.redLines.filter(r => r.trim() !== '')
1316
- if (Array.isArray(body?.style)) project.bible.style = body.style.filter(r => r.trim() !== '')
1317
- if (Array.isArray(body?.characters)) {
1318
- project.bible.characters = body.characters
1319
- .filter(c => c !== undefined && c !== null && typeof c.name === 'string' && c.name.trim() !== '')
1320
- .map(c => ({
1321
- name: c.name.trim(),
1322
- role: (['protagonist', 'supporting', 'antagonist', 'other'] as const).includes(c.role as never)
1323
- ? c.role as StoryBible['characters'][number]['role']
1324
- : 'other',
1325
- traits: Array.isArray(c.traits) ? c.traits.filter((t): t is string => typeof t === 'string' && t.trim() !== '') : [],
1326
- goals: typeof c.goals === 'string' ? c.goals : '',
1327
- relations: typeof c.relations === 'string' ? c.relations : '',
1328
- knowledge: Array.isArray(c.knowledge)
1329
- ? c.knowledge.filter((k): k is string => typeof k === 'string' && k.trim() !== '')
1330
- : undefined,
1331
- }))
1332
- }
1333
- project.updatedAt = new Date().toISOString()
1334
- saveProject(config.outputDir, project)
1335
- writeJson(res, 200, { bible: project.bible })
1336
- },
1337
- }
1338
-
1339
- // ---------------------------------------------------------------- blurb
1340
- /** 小说简介:AI 生成/补全,或手动保存。 */
1341
- const blurbRoute: WebRoute = {
1342
- kind: 'exact',
1343
- path: NOVEL_API.blurb,
1344
- handler: async (req, res) => {
1345
- if (!guard(req, res, 'POST')) return
1346
- const config = getConfig()
1347
- const project = requireProject(res)
1348
- if (project === undefined) return
1349
- const body = await readJsonBody<BlurbRequest>(req)
1350
- try {
1351
- if (body?.action === 'save') {
1352
- const text = body.text?.trim() ?? ''
1353
- project.blurb = text
1354
- project.updatedAt = new Date().toISOString()
1355
- saveProject(config.outputDir, project)
1356
- writeJson(res, 200, { blurb: text })
1357
- return
1358
- }
1359
- const partial = body?.partial?.trim() ?? ''
1360
- const blurb = await generateBlurb(ctx, config, project, partial)
1361
- project.blurb = blurb
1362
- project.updatedAt = new Date().toISOString()
1363
- saveProject(config.outputDir, project)
1364
- writeJson(res, 200, { blurb })
1365
- } catch (error) {
1366
- writeJson(res, 500, { error: (error as Error).message })
1367
- }
1368
- },
1369
- }
1370
-
1371
- // ---------------------------------------------------------------- cover
1372
- /** 封面:GET 读取(dataUrl);POST 上传(base64)或移除。 */
1373
- const coverRoute: WebRoute = {
1374
- kind: 'exact',
1375
- path: NOVEL_API.cover,
1376
- handler: async (req, res) => {
1377
- const config = getConfig()
1378
- if (req.method === 'GET') {
1379
- if (!isLoopbackRequest(req)) {
1380
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
1381
- return
1382
- }
1383
- // 书架页按书读取:?dir=<输出目录>;省略时用当前激活书目录。
1384
- const url = new URL(req.url ?? '/', 'http://localhost')
1385
- const dirParam = url.searchParams.get('dir')
1386
- const targetDir = dirParam !== null && dirParam !== '' ? dirParam : config.outputDir
1387
- const project = loadProject(targetDir)
1388
- const coverPath = project?.coverPath
1389
- if (coverPath === undefined || coverPath === '') {
1390
- writeJson(res, 200, { dataUrl: null } satisfies CoverResponse)
1391
- return
1392
- }
1393
- const file = join(targetDir, coverPath)
1394
- if (!existsSync(file)) {
1395
- writeJson(res, 200, { dataUrl: null } satisfies CoverResponse)
1396
- return
1397
- }
1398
- const mime = coverPath.toLowerCase().endsWith('.png') ? 'image/png'
1399
- : coverPath.toLowerCase().endsWith('.jpg') || coverPath.toLowerCase().endsWith('.jpeg') ? 'image/jpeg'
1400
- : coverPath.toLowerCase().endsWith('.webp') ? 'image/webp'
1401
- : 'image/png'
1402
- const dataUrl = `data:${mime};base64,${readFileSync(file).toString('base64')}`
1403
- writeJson(res, 200, { dataUrl } satisfies CoverResponse)
1404
- return
1405
- }
1406
- if (req.method === 'POST') {
1407
- if (!guard(req, res, 'POST')) return
1408
- const project = requireProject(res)
1409
- if (project === undefined) return
1410
- const body = await readJsonBody<CoverRequest>(req)
1411
- try {
1412
- if (body?.action === 'remove') {
1413
- if (project.coverPath !== undefined && project.coverPath !== '') {
1414
- const oldFile = join(config.outputDir, project.coverPath)
1415
- if (existsSync(oldFile)) rmSync(oldFile, { force: true })
1416
- }
1417
- project.coverPath = undefined
1418
- project.updatedAt = new Date().toISOString()
1419
- saveProject(config.outputDir, project)
1420
- writeJson(res, 200, { ok: true, coverPath: null })
1421
- return
1422
- }
1423
- const dataUrl = body?.dataUrl ?? ''
1424
- const match = /^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/s.exec(dataUrl)
1425
- if (match === null) {
1426
- writeJson(res, 400, { error: '封面须为 PNG/JPEG/WebP 的 base64 data URL' })
1427
- return
1428
- }
1429
- const mime = match[1]!
1430
- const ext = mime === 'image/png' ? 'png' : mime === 'image/webp' ? 'webp' : 'jpg'
1431
- const fileName = `cover.${ext}`
1432
- const targetPath = join(config.outputDir, fileName)
1433
- const oldPath = project.coverPath
1434
- mkdirSync(config.outputDir, { recursive: true })
1435
- writeFileSync(targetPath, Buffer.from(match[2]!, 'base64'))
1436
- if (oldPath !== undefined && oldPath !== '' && oldPath !== fileName) {
1437
- const oldFile = join(config.outputDir, oldPath)
1438
- if (existsSync(oldFile)) rmSync(oldFile, { force: true })
1439
- }
1440
- project.coverPath = fileName
1441
- project.updatedAt = new Date().toISOString()
1442
- saveProject(config.outputDir, project)
1443
- writeJson(res, 200, { ok: true, coverPath: fileName })
1444
- } catch (error) {
1445
- writeJson(res, 500, { error: (error as Error).message })
1446
- }
1447
- return
1448
- }
1449
- writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
1450
- },
1451
- }
1452
-
1453
- // ---------------------------------------------------------------- world
1454
- /** 大世界:AI 提炼或手动保存(境界体系/区域/势力)。 */
1455
- const worldRoute: WebRoute = {
1456
- kind: 'exact',
1457
- path: NOVEL_API.world,
1458
- handler: async (req, res) => {
1459
- if (!guard(req, res, 'POST')) return
1460
- const config = getConfig()
1461
- const project = requireProject(res)
1462
- if (project === undefined) return
1463
- const body = await readJsonBody<WorldRequest>(req)
1464
- try {
1465
- if (body?.action === 'save' && body.world !== undefined) {
1466
- project.world = {
1467
- realms: Array.isArray(body.world.realms) ? body.world.realms : [],
1468
- regions: Array.isArray(body.world.regions) ? body.world.regions : [],
1469
- factions: Array.isArray(body.world.factions) ? body.world.factions : [],
1470
- }
1471
- project.updatedAt = new Date().toISOString()
1472
- saveProject(config.outputDir, project)
1473
- writeJson(res, 200, { world: project.world })
1474
- return
1475
- }
1476
- const world = await extractWorld(ctx, config, project)
1477
- project.world = world
1478
- project.updatedAt = new Date().toISOString()
1479
- saveProject(config.outputDir, project)
1480
- writeJson(res, 200, { world })
1481
- } catch (error) {
1482
- writeJson(res, 500, { error: (error as Error).message })
1483
- }
1484
- },
1485
- }
1486
-
1487
- // --------------------------------------------------------------- rename
1488
- /** 重命名当前书:同步项目 bookName 与书架条目。 */
1489
- const renameRoute: WebRoute = {
1490
- kind: 'exact',
1491
- path: NOVEL_API.rename,
1492
- handler: async (req, res) => {
1493
- if (!guard(req, res, 'POST')) return
1494
- const config = getConfig()
1495
- const project = requireProject(res)
1496
- if (project === undefined) return
1497
- const body = await readJsonBody<RenameRequest>(req)
1498
- const bookName = body?.bookName?.trim()
1499
- if (bookName === undefined || bookName === '') {
1500
- writeJson(res, 400, { error: '书名不能为空' })
1501
- return
1502
- }
1503
- project.bookName = bookName.slice(0, 60)
1504
- project.updatedAt = new Date().toISOString()
1505
- saveProject(config.outputDir, project)
1506
- const store = loadBookshelf()
1507
- if (store.activeBookId !== null) renameBook(store.activeBookId, project.bookName)
1508
- writeJson(res, 200, { bookName: project.bookName })
1509
- },
1510
- }
1511
-
1512
- // ------------------------------------------------------------- plotlines
1513
- /** 剧情线管理:增删改 + 关联章节(主线/支线/人物线/悬念线)。 */
1514
- const plotlinesRoute: WebRoute = {
1515
- kind: 'exact',
1516
- path: NOVEL_API.plotlines,
1517
- handler: async (req, res) => {
1518
- if (!guard(req, res, 'POST')) return
1519
- const config = getConfig()
1520
- const project = requireProject(res)
1521
- if (project === undefined) return
1522
- const body = await readJsonBody<PlotlinesRequest>(req)
1523
- if (project.plotlines === undefined) project.plotlines = []
1524
- const op = body?.op
1525
- if (op === 'add' && body?.line !== undefined) {
1526
- const line = body.line
1527
- project.plotlines.push({
1528
- id: line.id !== '' ? line.id : `pl-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
1529
- name: line.name.slice(0, 40),
1530
- kind: line.kind,
1531
- goal: line.goal.slice(0, 300),
1532
- progress: line.progress.slice(0, 300),
1533
- status: line.status,
1534
- chapters: Array.isArray(line.chapters) ? line.chapters.filter((n): n is number => typeof n === 'number') : [],
1535
- createdAt: line.createdAt !== '' ? line.createdAt : new Date().toISOString(),
1536
- })
1537
- } else if (op === 'update' && body?.line !== undefined && body.line.id !== '') {
1538
- const idx = project.plotlines.findIndex(l => l.id === body.line!.id)
1539
- if (idx !== -1) {
1540
- const line = body.line
1541
- project.plotlines[idx] = {
1542
- ...project.plotlines[idx]!,
1543
- name: line.name.slice(0, 40),
1544
- kind: line.kind,
1545
- goal: line.goal.slice(0, 300),
1546
- progress: line.progress.slice(0, 300),
1547
- status: line.status,
1548
- }
1549
- }
1550
- } else if (op === 'remove' && body?.id !== undefined) {
1551
- project.plotlines = project.plotlines.filter(l => l.id !== body.id)
1552
- } else if (op === 'link' && body?.id !== undefined && typeof body.chapterNo === 'number' && body.chapterNo > 0) {
1553
- const line = project.plotlines.find(l => l.id === body.id)
1554
- if (line !== undefined && !line.chapters.includes(body.chapterNo)) {
1555
- line.chapters.push(body.chapterNo)
1556
- if (line.status === 'active' && line.progress === '') {
1557
- line.progress = `推进至第 ${body.chapterNo} 章`
1558
- }
1559
- }
1560
- } else if (op === 'suggest') {
1561
- try {
1562
- const suggestions = await suggestPlotlines(ctx, config, project)
1563
- writeJson(res, 200, { plotlines: project.plotlines, suggestions } satisfies PlotlinesResponse)
1564
- return
1565
- } catch (error) {
1566
- writeJson(res, 500, { error: `AI 建议失败:${(error as Error).message}` })
1567
- return
1568
- }
1569
- } else if (op === 'refresh' && body?.id !== undefined) {
1570
- const line = project.plotlines.find(l => l.id === body.id)
1571
- if (line === undefined) {
1572
- writeJson(res, 404, { error: '剧情线不存在' })
1573
- return
1574
- }
1575
- try {
1576
- const progress = await refreshPlotlineProgress(ctx, config, project, line)
1577
- line.progress = progress
1578
- project.updatedAt = new Date().toISOString()
1579
- saveProject(config.outputDir, project)
1580
- writeJson(res, 200, { plotlines: project.plotlines } satisfies PlotlinesResponse)
1581
- return
1582
- } catch (error) {
1583
- writeJson(res, 500, { error: `刷新进度失败:${(error as Error).message}` })
1584
- return
1585
- }
1586
- } else if (op === 'health') {
1587
- try {
1588
- const health = await analyzePlotlineHealth(ctx, config, project)
1589
- writeJson(res, 200, { plotlines: project.plotlines, health } satisfies PlotlinesResponse)
1590
- return
1591
- } catch (error) {
1592
- writeJson(res, 500, { error: `健康检查失败:${(error as Error).message}` })
1593
- return
1594
- }
1595
- } else if (op === 'plan') {
1596
- try {
1597
- const health = await analyzePlotlineHealth(ctx, config, project)
1598
- const plan = await designPlotlinePlan(ctx, config, project, health)
1599
- writeJson(res, 200, { plotlines: project.plotlines, health, plan } satisfies PlotlinesResponse)
1600
- return
1601
- } catch (error) {
1602
- writeJson(res, 500, { error: `剧情方案生成失败:${(error as Error).message}` })
1603
- return
1604
- }
1605
- }
1606
- project.updatedAt = new Date().toISOString()
1607
- saveProject(config.outputDir, project)
1608
- const response: PlotlinesResponse = { plotlines: project.plotlines }
1609
- writeJson(res, 200, response)
1610
- },
1611
- }
1612
-
1613
- // --------------------------------------------------------- sensitive
1614
- /** 敏感词检查:指定章节 / 任意文本 / 全书已写章节。 */
1615
- const sensitiveRoute: WebRoute = {
1616
- kind: 'exact',
1617
- path: NOVEL_API.sensitiveCheck,
1618
- handler: async (req, res) => {
1619
- if (!guard(req, res, 'POST')) return
1620
- const config = getConfig()
1621
- const project = requireProject(res)
1622
- if (project === undefined) return
1623
- const body = await readJsonBody<SensitiveCheckRequest>(req)
1624
- const hits: SensitiveHit[] = []
1625
- let scanned = 0
1626
- if (body?.text !== undefined) {
1627
- for (const hit of checkSensitiveText(body.text)) {
1628
- hits.push({ chapterNo: 0, word: hit.word, category: hit.category, count: hit.count })
1629
- }
1630
- } else if (typeof body?.chapterNo === 'number') {
1631
- const chapter = project.chapters.find(c => c.no === body.chapterNo)
1632
- if (chapter !== undefined) {
1633
- const text = readChapterFile(config.outputDir, chapter)
1634
- if (text !== undefined) {
1635
- scanned = 1
1636
- for (const hit of checkSensitiveText(text)) {
1637
- hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
1638
- }
1639
- }
1640
- }
1641
- } else if (body?.all === true) {
1642
- for (const chapter of project.chapters) {
1643
- if (chapter.status === 'pending' || chapter.status === 'generating') continue
1644
- const text = readChapterFile(config.outputDir, chapter)
1645
- if (text === undefined) continue
1646
- scanned++
1647
- for (const hit of checkSensitiveText(text)) {
1648
- hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
1649
- }
1650
- }
1651
- } else {
1652
- writeJson(res, 400, { error: '请提供 chapterNo / text / all 之一' })
1653
- return
1654
- }
1655
- const response: SensitiveCheckResponse = { hits, scannedChapters: scanned }
1656
- writeJson(res, 200, response)
1657
- },
1658
- }
1659
-
1660
- // ---------------------------------------------------------------- roles
1661
- /** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
1662
- const rolesRoute: WebRoute = {
1663
- kind: 'exact',
1664
- path: NOVEL_API.roles,
1665
- handler: async (req, res) => {
1666
- if (!guard(req, res, 'POST')) return
1667
- const config = getConfig()
1668
- const project = requireProject(res)
1669
- if (project === undefined) return
1670
- const body = await readJsonBody<RolesRequest>(req)
1671
- if (project.roles === undefined) project.roles = []
1672
- const op = body?.op
1673
- if (op === 'extract') {
1674
- try {
1675
- const candidates = await extractRoles(ctx, config, project)
1676
- writeJson(res, 200, { roles: project.roles, candidates } satisfies RolesResponse)
1677
- return
1678
- } catch (error) {
1679
- writeJson(res, 500, { error: `角色提炼失败:${(error as Error).message}` })
1680
- return
1681
- }
1682
- } else if ((op === 'adopt' || op === 'update') && body?.role !== undefined) {
1683
- const r = body.role
1684
- const idx = project.roles.findIndex(x => x.name === r.name)
1685
- if (idx === -1) project.roles.push(r)
1686
- else project.roles[idx] = r
1687
- } else if (op === 'remove' && body?.name !== undefined) {
1688
- project.roles = project.roles.filter(x => x.name !== body.name)
1689
- } else if (op === 'image') {
1690
- const name = body?.name?.trim()
1691
- const dataUrl = body?.dataUrl?.trim()
1692
- if (name === undefined || name === '') {
1693
- writeJson(res, 400, { error: 'name(角色名)必填' })
1694
- return
1695
- }
1696
- if (dataUrl === undefined || dataUrl === '') {
1697
- writeJson(res, 400, { error: 'dataUrl(参考图)必填' })
1698
- return
1699
- }
1700
- const role = project.roles.find(r => r.name === name)
1701
- if (role === undefined) {
1702
- writeJson(res, 404, { error: `当前激活《${project.bookName}》,角色 ${name} 不存在——若期望的书不对,请先在书架切换该书并刷新` })
1703
- return
1704
- }
1705
- const label = body?.label?.trim() ?? ''
1706
- if (label !== '') {
1707
- // 图集上传:带用途标签。标签「立绘」时同步为角色参考图(imageUrl)。
1708
- role.gallery ??= []
1709
- role.gallery = role.gallery.filter(g => g.label !== label)
1710
- role.gallery.push({ label, dataUrl })
1711
- if (label === '立绘' || label.includes('立绘')) role.imageUrl = dataUrl
1712
- } else {
1713
- // 兼容旧行为:不带标签 = 角色参考图。
1714
- role.imageUrl = dataUrl
1715
- }
1716
- project.updatedAt = new Date().toISOString()
1717
- saveProject(config.outputDir, project)
1718
- writeJson(res, 200, { roles: project.roles, imageUrl: role.imageUrl } satisfies RolesResponse)
1719
- return
1720
- } else if (op === 'removeImage') {
1721
- const name = body?.name?.trim()
1722
- const label = body?.label?.trim() ?? ''
1723
- const role = project.roles.find(r => r.name === name)
1724
- if (role !== undefined) {
1725
- if (label !== '') {
1726
- role.gallery = (role.gallery ?? []).filter(g => g.label !== label)
1727
- if (label === '立绘' || label.includes('立绘')) role.imageUrl = undefined
1728
- } else {
1729
- role.imageUrl = undefined
1730
- }
1731
- }
1732
- project.updatedAt = new Date().toISOString()
1733
- saveProject(config.outputDir, project)
1734
- writeJson(res, 200, { roles: project.roles } satisfies RolesResponse)
1735
- return
1736
- } else if (op === 'imageGenerate') {
1737
- const name = body?.name?.trim()
1738
- if (name === undefined || name === '') {
1739
- writeJson(res, 400, { error: 'name(角色名)必填' })
1740
- return
1741
- }
1742
- try {
1743
- const imageUrl = await generateRoleReferenceImage(ctx, config, project, config.outputDir, name, body?.style ?? '')
1744
- project.updatedAt = new Date().toISOString()
1745
- saveProject(config.outputDir, project)
1746
- writeJson(res, 200, { roles: project.roles, imageUrl } satisfies RolesResponse)
1747
- return
1748
- } catch (error) {
1749
- writeJson(res, 500, { error: `角色参考图生成失败:${(error as Error).message}` })
1750
- return
1751
- }
1752
- } else if (op === 'visual') {
1753
- // 动漫形象描述词提炼:扫描正文外貌描写 → LLM 提炼 → 写入角色卡。
1754
- const name = body?.name?.trim()
1755
- if (name === undefined || name === '') {
1756
- writeJson(res, 400, { error: 'name(角色名)必填' })
1757
- return
1758
- }
1759
- try {
1760
- const visual = await extractRoleVisual(ctx, config, project, config.outputDir, name)
1761
- const role = project.roles.find(r => r.name === name)
1762
- if (role !== undefined) {
1763
- role.imagePrompt = visual
1764
- if ((visual.expressions ?? []).length > 0) role.expressions = visual.expressions
1765
- if (visual.promptKit !== undefined) role.promptKit = visual.promptKit
1766
- }
1767
- project.updatedAt = new Date().toISOString()
1768
- saveProject(config.outputDir, project)
1769
- writeJson(res, 200, { roles: project.roles, visual } satisfies RolesResponse)
1770
- return
1771
- } catch (error) {
1772
- writeJson(res, 500, { error: `形象提炼失败:${(error as Error).message}` })
1773
- return
1774
- }
1775
- } else if (op === 'promptKit') {
1776
- // 角色四类提示词精修包(立绘/四视图/表情/细节)
1777
- const name = body?.name?.trim()
1778
- if (name === undefined || name === '') {
1779
- writeJson(res, 400, { error: 'name(角色名)必填' })
1780
- return
1781
- }
1782
- try {
1783
- const kit = await generateRolePromptKit(ctx, config, project, name)
1784
- const role = project.roles.find(r => r.name === name)
1785
- if (role !== undefined) role.promptKit = kit
1786
- project.updatedAt = new Date().toISOString()
1787
- saveProject(config.outputDir, project)
1788
- writeJson(res, 200, { roles: project.roles, promptKit: kit } satisfies RolesResponse)
1789
- return
1790
- } catch (error) {
1791
- writeJson(res, 500, { error: `提示词精修失败:${(error as Error).message}` })
1792
- return
1793
- }
1794
- } else {
1795
- writeJson(res, 400, { error: '未知的 roles op' })
1796
- return
1797
- }
1798
- project.updatedAt = new Date().toISOString()
1799
- saveProject(config.outputDir, project)
1800
- const response: RolesResponse = { roles: project.roles }
1801
- writeJson(res, 200, response)
1802
- },
1803
- }
1804
-
1805
- // ------------------------------------------------------- chapter reset
1806
- /** 章节复位:generating 卡死 → pending(可重新生成)。 */
1807
- const chapterResetRoute: WebRoute = {
1808
- kind: 'exact',
1809
- path: NOVEL_API.chapterReset,
1810
- handler: async (req, res) => {
1811
- if (!guard(req, res, 'POST')) return
1812
- const config = getConfig()
1813
- const project = requireProject(res)
1814
- if (project === undefined) return
1815
- const body = await readJsonBody<{ chapterNo?: number }>(req)
1816
- const no = body?.chapterNo
1817
- if (!Number.isInteger(no) || no === undefined || no < 1) {
1818
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
1819
- return
1820
- }
1821
- const chapter = project.chapters.find(c => c.no === no)
1822
- if (chapter === undefined) {
1823
- writeJson(res, 404, { error: `章节 ${no} 不在计划中` })
1824
- return
1825
- }
1826
- chapter.status = 'pending'
1827
- chapter.error = undefined
1828
- chapter.generatingAt = undefined
1829
- project.updatedAt = new Date().toISOString()
1830
- saveProject(config.outputDir, project)
1831
- writeJson(res, 200, { ok: true, no })
1832
- },
1833
- }
1834
-
1835
- // ------------------------------------------------------ chapter approve
1836
- /** 章节直接通过:作者行使最终决定权(不重审,保留审稿记录)。 */
1837
- const chapterApproveRoute: WebRoute = {
1838
- kind: 'exact',
1839
- path: NOVEL_API.chapterApprove,
1840
- handler: async (req, res) => {
1841
- if (!guard(req, res, 'POST')) return
1842
- const config = getConfig()
1843
- const project = requireProject(res)
1844
- if (project === undefined) return
1845
- const body = await readJsonBody<{ chapterNo?: number }>(req)
1846
- const no = body?.chapterNo
1847
- if (!Number.isInteger(no) || no === undefined || no < 1) {
1848
- writeJson(res, 400, { error: 'chapterNo 须为正整数' })
1849
- return
1850
- }
1851
- const chapter = project.chapters.find(c => c.no === no)
1852
- if (chapter === undefined) {
1853
- writeJson(res, 404, { error: `章节 ${no} 不在计划中` })
1854
- return
1855
- }
1856
- chapter.status = 'approved'
1857
- project.updatedAt = new Date().toISOString()
1858
- saveProject(config.outputDir, project)
1859
- writeJson(res, 200, { ok: true, no })
1860
- },
1861
- }
1862
-
1863
- // ------------------------------------------------------ author review backfill
1864
- /** 作者复盘补跑:对已写章节补齐 authorReview(body.chapterNo=单章 JSON,缺省=全书 NDJSON 流)。 */
1865
- const reviewBackfillRoute: WebRoute = {
1866
- kind: 'exact',
1867
- path: NOVEL_API.reviewBackfill,
1868
- handler: async (req, res) => {
1869
- if (!guard(req, res, 'POST')) return
1870
- const config = getConfig()
1871
- const project = requireProject(res)
1872
- if (project === undefined) return
1873
- const body = await readJsonBody<{ chapterNo?: number }>(req)
1874
-
1875
- /** 对一章执行作者复盘(读取已落盘正文,不改变章节状态/正文)。 */
1876
- const runOne = async (chapter: ChapterPlan): Promise<AuthorReview> => {
1877
- const currentBody = readChapterFile(config.outputDir, chapter)
1878
- if (currentBody === undefined) throw new Error(`章节 ${chapter.no} 的正文文件不存在`)
1879
- let prevTail = ''
1880
- if (chapter.no > 1) {
1881
- const prev = project.chapters.find(c => c.no === chapter.no - 1)
1882
- if (prev !== undefined) {
1883
- prevTail = (readChapterFile(config.outputDir, prev) ?? '').replace(/^#.*$/m, '').trim().slice(-600)
1884
- }
1885
- }
1886
- return authorReviewChapter(ctx, config, project, chapter.no, currentBody, prevTail)
1887
- }
1888
-
1889
- // 单章:JSON 响应。
1890
- if (typeof body?.chapterNo === 'number' && body.chapterNo > 0) {
1891
- const chapter = project.chapters.find(c => c.no === body.chapterNo)
1892
- if (chapter === undefined) {
1893
- writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` })
1894
- return
1895
- }
1896
- if (chapter.status === 'pending') {
1897
- writeJson(res, 400, { error: '该章尚未生成正文,无法复盘' })
1898
- return
1899
- }
1900
- try {
1901
- const review = await runOne(chapter)
1902
- chapter.authorReview = review
1903
- if (review.advancedLines !== undefined) {
1904
- autoLinkPlotlines(project, chapter.no, review.advancedLines)
1905
- }
1906
- project.updatedAt = new Date().toISOString()
1907
- saveProject(config.outputDir, project)
1908
- writeJson(res, 200, { no: chapter.no, review } satisfies { no: number; review: AuthorReview })
1909
- return
1910
- } catch (error) {
1911
- writeJson(res, 500, { error: (error as Error).message })
1912
- return
1913
- }
1914
- }
1915
-
1916
- // 全书:NDJSON 流式补跑缺失复盘的已写章节。
1917
- const missing = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error' && c.authorReview === undefined)
1918
- if (missing.length === 0) {
1919
- writeJson(res, 200, { count: 0 })
1920
- return
1921
- }
1922
- res.writeHead(200, {
1923
- 'content-type': 'application/x-ndjson; charset=utf-8',
1924
- 'cache-control': 'no-cache',
1925
- 'referrer-policy': 'no-referrer',
1926
- })
1927
- const send = (frame: JobFrame): void => {
1928
- res.write(JSON.stringify(frame) + '\n')
1929
- }
1930
- let done = 0
1931
- for (const chapter of missing) {
1932
- try {
1933
- const review = await runOne(chapter)
1934
- chapter.authorReview = review
1935
- if (review.advancedLines !== undefined) {
1936
- autoLinkPlotlines(project, chapter.no, review.advancedLines)
1937
- }
1938
- done++
1939
- saveProject(config.outputDir, project)
1940
- send({ type: 'author-review', no: chapter.no, review })
1941
- } catch (error) {
1942
- console.warn(`[dsh-novel-forge] author backfill ch.${chapter.no} failed:`, (error as Error).message)
1943
- }
1944
- }
1945
- project.updatedAt = new Date().toISOString()
1946
- saveProject(config.outputDir, project)
1947
- send({ type: 'author-backfill-done', count: done })
1948
- res.end()
1949
- },
1950
- }
1951
-
1952
- // --------------------------------------------------------------- config
1953
- const configRoute: WebRoute = {
1954
- kind: 'exact',
1955
- path: NOVEL_API.config,
1956
- handler: async (req, res) => {
1957
- if (!guard(req, res, 'POST')) return
1958
- const body = await readJsonBody<ConfigPatch>(req)
1959
- if (body === undefined) {
1960
- writeJson(res, 400, { error: '无效的配置 JSON' })
1961
- return
1962
- }
1963
- try {
1964
- const next = await patchConfig(body)
1965
- writeJson(res, 200, { config: next })
1966
- } catch (error) {
1967
- writeJson(res, 400, { error: (error as Error).message })
1968
- }
1969
- },
1970
- }
1971
-
1972
- // ---------------------------------------------------------- open-folder
1973
- const openFolderRoute: WebRoute = {
1974
- kind: 'exact',
1975
- path: NOVEL_API.openFolder,
1976
- handler: async (req, res) => {
1977
- if (!guard(req, res, 'POST')) return
1978
- const config = getConfig()
1979
- const dir = config.outputDir
1980
- exec(`explorer "${dir.replace(/"/g, '')}"`, (error) => {
1981
- if (error) {
1982
- writeJson(res, 500, { ok: false, error: error.message })
1983
- } else {
1984
- writeJson(res, 200, { ok: true })
1985
- }
1986
- })
1987
- },
1988
- }
1989
-
1990
- // ------------------------------------------------------- outline suggest
1991
- /** 开书想法 → AI 大纲:2-3 个可选方案(支持暂留换批:count 只补空槽 + exclude 避开已留方向)。 */
1992
- const outlineSuggestRoute: WebRoute = {
1993
- kind: 'exact',
1994
- path: NOVEL_API.outlineSuggest,
1995
- handler: async (req, res) => {
1996
- if (!guard(req, res, 'POST')) return
1997
- const config = getConfig()
1998
- const body = await readJsonBody<OutlineSuggestRequest>(req)
1999
- const idea = body?.idea?.trim() ?? ''
2000
- if (idea.length < 50) {
2001
- writeJson(res, 400, { error: '想法太短(<50 字),请多写一两句:主角是谁、什么世界、想要什么爽点' })
2002
- return
2003
- }
2004
- const count = body?.count !== undefined ? Math.max(1, Math.min(3, Math.floor(body.count))) : 3
2005
- const exclude = Array.isArray(body?.exclude) ? body.exclude.filter((e): e is string => typeof e === 'string' && e.trim() !== '').map(e => e.trim().slice(0, 200)) : []
2006
- try {
2007
- const candidates = await suggestOutlines(ctx, config, idea, count, exclude)
2008
- const response: OutlineSuggestResponse = { candidates }
2009
- writeJson(res, 200, response)
2010
- } catch (error) {
2011
- writeJson(res, 500, { error: `大纲方案生成失败:${(error as Error).message}` })
2012
- }
2013
- },
2014
- }
2015
-
2016
- // ----------------------------------------------------------- breakdown
2017
- /** 拆书分析:对已写章节做结构/人物/文风/卖点体检(两阶段:源笔记→分节分析)。 */
2018
- const breakdownRoute: WebRoute = {
2019
- kind: 'exact',
2020
- path: NOVEL_API.breakdown,
2021
- handler: async (req, res) => {
2022
- if (!guard(req, res, 'POST')) return
2023
- const config = getConfig()
2024
- const project = requireProject(res)
2025
- if (project === undefined) return
2026
- const body = await readJsonBody<BreakdownRequest>(req)
2027
- try {
2028
- const result = await breakdownBook(
2029
- ctx,
2030
- config,
2031
- project,
2032
- config.outputDir,
2033
- body?.scope ?? 'recent',
2034
- body?.preset ?? 'quick',
2035
- body?.budgetTokens ?? 50000,
2036
- )
2037
- writeJson(res, 200, result satisfies BreakdownResponse)
2038
- } catch (error) {
2039
- writeJson(res, 500, { error: `拆书分析失败:${(error as Error).message}` })
2040
- }
2041
- },
2042
- }
2043
-
2044
- // --------------------------------------------------------------- scenes
2045
- /** 场景库:AI 提炼 / 采纳 / 更新 / 删除 / 图集。 */
2046
- const scenesRoute: WebRoute = {
2047
- kind: 'exact',
2048
- path: NOVEL_API.scenes,
2049
- handler: async (req, res) => {
2050
- if (!guard(req, res, 'POST')) return
2051
- const config = getConfig()
2052
- const project = requireProject(res)
2053
- if (project === undefined) return
2054
- const body = await readJsonBody<ScenesRequest>(req)
2055
- if (project.scenes === undefined) project.scenes = []
2056
- const op = body?.op
2057
- if (op === 'extract') {
2058
- try {
2059
- const candidates = await extractScenes(ctx, config, project)
2060
- writeJson(res, 200, { scenes: project.scenes, candidates } satisfies ScenesResponse)
2061
- return
2062
- } catch (error) {
2063
- writeJson(res, 500, { error: `场景提炼失败:${(error as Error).message}` })
2064
- return
2065
- }
2066
- } else if ((op === 'adopt' || op === 'update') && body?.scene !== undefined) {
2067
- const s = body.scene
2068
- const idx = project.scenes.findIndex(x => x.name === s.name)
2069
- if (idx === -1) project.scenes.push(s)
2070
- else project.scenes[idx] = s
2071
- } else if (op === 'remove' && body?.name !== undefined) {
2072
- project.scenes = project.scenes.filter(x => x.name !== body.name)
2073
- } else if (op === 'image') {
2074
- const name = body?.name?.trim()
2075
- const dataUrl = body?.dataUrl?.trim()
2076
- if (name === undefined || name === '' || dataUrl === undefined || dataUrl === '') {
2077
- writeJson(res, 400, { error: 'name(场景名)与 dataUrl 必填' })
2078
- return
2079
- }
2080
- const scene = project.scenes.find(x => x.name === name)
2081
- if (scene === undefined) {
2082
- writeJson(res, 404, { error: `当前激活《${project.bookName}》,场景 ${name} 不存在——若期望的书不对,请先在书架切换该书并刷新` })
2083
- return
2084
- }
2085
- const label = body?.label?.trim() ?? '全景'
2086
- scene.gallery ??= []
2087
- scene.gallery = scene.gallery.filter(g => g.label !== label)
2088
- scene.gallery.push({ label, dataUrl })
2089
- } else if (op === 'removeImage') {
2090
- const name = body?.name?.trim()
2091
- const label = body?.label?.trim() ?? ''
2092
- const scene = project.scenes.find(x => x.name === name)
2093
- if (scene !== undefined && label !== '') {
2094
- scene.gallery = (scene.gallery ?? []).filter(g => g.label !== label)
2095
- }
2096
- } else {
2097
- writeJson(res, 400, { error: `未知操作 ${op}` })
2098
- return
2099
- }
2100
- project.updatedAt = new Date().toISOString()
2101
- saveProject(config.outputDir, project)
2102
- writeJson(res, 200, { scenes: project.scenes } satisfies ScenesResponse)
2103
- },
2104
- }
2105
-
2106
- // ----------------------------------------------------------- visual rules
2107
- /** 视觉世界观规则:从道藏提炼(生图/生视频纠偏),或手动保存。 */
2108
- const visualRulesRoute: WebRoute = {
2109
- kind: 'exact',
2110
- path: NOVEL_API.visualRules,
2111
- handler: async (req, res) => {
2112
- if (!guard(req, res, 'POST')) return
2113
- const config = getConfig()
2114
- const project = requireProject(res)
2115
- if (project === undefined) return
2116
- const body = await readJsonBody<VisualRulesRequest>(req)
2117
- if (body?.op === 'extract') {
2118
- try {
2119
- const rules = await extractVisualRules(ctx, config, project)
2120
- project.visualRules = rules
2121
- project.updatedAt = new Date().toISOString()
2122
- saveProject(config.outputDir, project)
2123
- writeJson(res, 200, { rules } satisfies VisualRulesResponse)
2124
- return
2125
- } catch (error) {
2126
- writeJson(res, 500, { error: `视觉规则提炼失败:${(error as Error).message}` })
2127
- return
2128
- }
2129
- } else if (body?.op === 'save' && Array.isArray(body.rules)) {
2130
- project.visualRules = body.rules.filter(r => typeof r === 'string' && r.trim() !== '').map(r => r.trim().slice(0, 80)).slice(0, 12)
2131
- project.updatedAt = new Date().toISOString()
2132
- saveProject(config.outputDir, project)
2133
- writeJson(res, 200, { rules: project.visualRules } satisfies VisualRulesResponse)
2134
- return
2135
- }
2136
- writeJson(res, 400, { error: 'op 须为 extract 或 save' })
2137
- },
2138
- }
2139
-
2140
- // ----------------------------------------------------------- production run
2141
- /** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
2142
- const runner = new ProductionRunner({ ctx, getConfig })
2143
-
2144
- const runStartRoute: WebRoute = {
2145
- kind: 'exact',
2146
- path: NOVEL_API.runStart,
2147
- handler: async (req, res) => {
2148
- if (!guard(req, res, 'POST')) return
2149
- const body = await readJsonBody<RunStartRequest>(req)
2150
- const config = getConfig()
2151
- const project = loadProject(config.outputDir)
2152
- if (project === undefined) {
2153
- writeJson(res, 400, { error: '输出目录中没有项目' })
2154
- return
2155
- }
2156
- const startNo = Number.isInteger(body?.startNo) && (body!.startNo! >= 1) ? body!.startNo! : 1
2157
- let endNo: number
2158
- if (Number.isInteger(body?.endNo) && (body!.endNo! >= startNo)) {
2159
- endNo = body!.endNo!
2160
- } else if (Number.isInteger(body?.count) && (body!.count! >= 1) && (body!.count! <= 200)) {
2161
- const last = Math.max(0, ...project.chapters.map(c => c.no))
2162
- endNo = last + body!.count!
2163
- } else {
2164
- writeJson(res, 400, { error: '请提供 endNo 或 count(1-200)' })
2165
- return
2166
- }
2167
- try {
2168
- const state = await runner.start(startNo, endNo)
2169
- writeJson(res, 200, state satisfies RunState)
2170
- } catch (error) {
2171
- writeJson(res, 409, { error: (error as Error).message })
2172
- }
2173
- },
2174
- }
2175
-
2176
- const runControlRoute: WebRoute = {
2177
- kind: 'exact',
2178
- path: NOVEL_API.runControl,
2179
- handler: async (req, res) => {
2180
- if (!guard(req, res, 'POST')) return
2181
- const body = await readJsonBody<RunControlRequest>(req)
2182
- const state = runner.status()
2183
- if (state === null) {
2184
- writeJson(res, 400, { error: '没有生产单' })
2185
- return
2186
- }
2187
- if (body?.action === 'pause') runner.pause()
2188
- else if (body?.action === 'resume') runner.resume()
2189
- else if (body?.action === 'stop') runner.stop()
2190
- else {
2191
- writeJson(res, 400, { error: 'action 须为 pause / resume / stop' })
2192
- return
2193
- }
2194
- writeJson(res, 200, runner.status())
2195
- },
2196
- }
2197
-
2198
- const runStatusRoute: WebRoute = {
2199
- kind: 'exact',
2200
- path: NOVEL_API.runStatus,
2201
- handler: (req, res) => {
2202
- if (!guard(req, res, 'GET')) return
2203
- writeJson(res, 200, runner.status())
2204
- },
2205
- }
2206
-
2207
- return [
2208
- statusRoute,
2209
- loadOutlineRoute,
2210
- saveOutlineRoute,
2211
- bibleRoute,
2212
- volumesRoute,
2213
- planRoute,
2214
- generateRoute,
2215
- reviewRoute,
2216
- rewriteRoute,
2217
- polishRoute,
2218
- draftApplyRoute,
2219
- draftDiscardRoute,
2220
- summaryRoute,
2221
- foreshadowRoute,
2222
- exportRoute,
2223
- chapterRoute,
2224
- chapterCheckRoute,
2225
- chapterSaveRoute,
2226
- assetsRoute,
2227
- styleEngineRoute,
2228
- assistantRoute,
2229
- assistantHistoryRoute,
2230
- assistantClearRoute,
2231
- bookshelfRoute,
2232
- bookshelfActivateRoute,
2233
- bookshelfRemoveRoute,
2234
- resetRoute,
2235
- auditRoute,
2236
- charactersRefreshRoute,
2237
- factsBackfillRoute,
2238
- biblePatchRoute,
2239
- blurbRoute,
2240
- coverRoute,
2241
- worldRoute,
2242
- renameRoute,
2243
- plotlinesRoute,
2244
- rolesRoute,
2245
- scenesRoute,
2246
- visualRulesRoute,
2247
- sensitiveRoute,
2248
- reviewBackfillRoute,
2249
- chapterResetRoute,
2250
- chapterApproveRoute,
2251
- configRoute,
2252
- openFolderRoute,
2253
- outlineSuggestRoute,
2254
- breakdownRoute,
2255
- runStartRoute,
2256
- runControlRoute,
2257
- runStatusRoute,
2258
- ]
2259
- }
2260
-
2261
- // Re-export for tests / type consumers.
2262
- export type {
2263
- ConfigPatch,
2264
- NovelConfig,
2265
- StatusResponse,
2266
- PlanResponse,
2267
- LoadOutlineResponse,
2268
- BibleResponse,
2269
- VolumesResponse,
2270
- ExportResponse,
2271
- }
2272
- export { chapterFileName }
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 { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
14
+ import { join, basename, extname } from 'node:path'
15
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
16
+ import type { Context } from '@deepseek-ai/cordis'
17
+ import { ProductionRunner } from './run.ts'
18
+ import {
19
+ NOVEL_API,
20
+ type AssetsPatch,
21
+ type AssetsResponse,
22
+ type AssistantFrame,
23
+ type AssistantHistoryResponse,
24
+ type AssistantRequest,
25
+ type AuditResponse,
26
+ type AuditStatus,
27
+ type AuthorReview,
28
+ type BiblePatchRequest,
29
+ type BibleRequest,
30
+ type BlurbRequest,
31
+ type CoverRequest,
32
+ type CoverResponse,
33
+ type RenameRequest,
34
+ type WorldRequest,
35
+ type BibleResponse,
36
+ type BookActivateRequest,
37
+ type BookCreateRequest,
38
+ type BookImportDirRequest,
39
+ type BookImportDirResponse,
40
+ type BookImportTextRequest,
41
+ type BookImportTextResponse,
42
+ type BookImportTextPreviewRequest,
43
+ type BookImportTextPreviewResponse,
44
+ type BookRemoveRequest,
45
+ type BookshelfSnapshot,
46
+ type ChapterResponse,
47
+ type ChapterPlan,
48
+ type ChapterSaveResponse,
49
+ type ChapterTextRequest,
50
+ type ConfigPatch,
51
+ type DraftDecisionRequest,
52
+ type ExportRequest,
53
+ type ExportResponse,
54
+ type ForeshadowRequest,
55
+ type ForeshadowResponse,
56
+ type JobFrame,
57
+ type LoadOutlineRequest,
58
+ type LoadOutlineResponse,
59
+ type NovelConfig,
60
+ type PlotlinesRequest,
61
+ type PlotlinesResponse,
62
+ type PlanRequest,
63
+ type PlanResponse,
64
+ type PolishRequest,
65
+ type ResetRequest,
66
+ type ReviewReport,
67
+ type ReviewRequest,
68
+ type RunControlRequest,
69
+ type RunStartRequest,
70
+ type RunState,
71
+ type RewriteRequest,
72
+ type RolesRequest,
73
+ type RolesResponse,
74
+ type SceneCard,
75
+ type ScenesRequest,
76
+ type ScenesResponse,
77
+ type VisualRulesRequest,
78
+ type VisualRulesResponse,
79
+ type OutlineSuggestRequest,
80
+ type OutlineSuggestResponse,
81
+ type BreakdownRequest,
82
+ type BreakdownResponse,
83
+ type SensitiveCheckRequest,
84
+ type SensitiveCheckResponse,
85
+ type SensitiveHit,
86
+ type StatusResponse,
87
+ type StoryBible,
88
+ type StyleEngineRequest,
89
+ type SummaryRequest,
90
+ type VolumesRequest,
91
+ type VolumesResponse,
92
+ } from './protocol.ts'
93
+ import { readOutlineFromDocx } from './docx.ts'
94
+ import { clearAssistantHistory, loadAssistantHistory, runAssistantTurn } from './assistant.ts'
95
+ import { activateBook, bookshelfSnapshot, createBook, defaultOutputDirFor, importDir, loadBookshelf, removeBook, renameBook, seedBookshelfFromOutputDir } from './bookshelf.ts'
96
+ import { BUILTIN_ANTI_AI_RULES, BUILTIN_GENRE_LIBRARY, BUILTIN_PROGRESSION_MODES, BUILTIN_STYLE_TEMPLATES, emptyProjectAssets } from './assets.ts'
97
+ import {
98
+ chapterFileName,
99
+ auditBook,
100
+ authorReviewChapter,
101
+ autoLinkPlotlines,
102
+ backfillFacts,
103
+ createProject,
104
+ exportBook,
105
+ importBookText,
106
+ importBookTextFromText,
107
+ previewBookText,
108
+ extractBible,
109
+ extractStyleAsset,
110
+ extractWorld,
111
+ generateBlurb,
112
+ generateChapterStream,
113
+ listChapterFiles,
114
+ loadProject,
115
+ markForeshadowPlanted,
116
+ mergeVolatileFromDisk,
117
+ planChapters,
118
+ planVolumes,
119
+ polishChapterStream,
120
+ readChapterFile,
121
+ refreshCharacters,
122
+ refreshPlotlineProgress,
123
+ reviewChapter,
124
+ reviewChapterText,
125
+ rewriteChapterStream,
126
+ saveProject,
127
+ analyzePlotlineHealth,
128
+ designPlotlinePlan,
129
+ extractRoles,
130
+ extractScenes,
131
+ generateRolePromptKit,
132
+ extractVisualRules,
133
+ extractRoleVisual,
134
+ suggestOutlines,
135
+ reverseOutlineFromChapters,
136
+ breakdownBook,
137
+ generateRoleReferenceImage,
138
+ checkSensitiveText,
139
+ suggestForeshadows,
140
+ suggestPlotlines,
141
+ summarizeAndExtractFacts,
142
+ summarizeChapter,
143
+ syncProjectWithDisk,
144
+ } from './engine.ts'
145
+
146
+ /** Cap on JSON request bodies (generous: cover images travel as base64). */
147
+ const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024
148
+
149
+ /** Loopback-only fence (mirrors the family plugins' pairing routes). */
150
+ function isLoopbackRequest(request: IncomingMessage): boolean {
151
+ const address = request.socket.remoteAddress
152
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
153
+ const host = request.headers.host
154
+ if (typeof host !== 'string') return false
155
+ let hostUrl: URL
156
+ try {
157
+ hostUrl = new URL(`http://${host}`)
158
+ } catch {
159
+ return false
160
+ }
161
+ if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
162
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
163
+ const origin = request.headers.origin
164
+ if (origin === undefined) return true
165
+ try {
166
+ return new URL(origin).host === hostUrl.host
167
+ } catch {
168
+ return false
169
+ }
170
+ }
171
+
172
+ /** One JSON response. */
173
+ function writeJson(res: ServerResponse, status: number, body: unknown): void {
174
+ const payload = JSON.stringify(body)
175
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
176
+ res.end(payload)
177
+ }
178
+
179
+ /** Read a JSON request body. */
180
+ async function readJsonBody<T>(req: IncomingMessage): Promise<T | undefined> {
181
+ const chunks: Buffer[] = []
182
+ let size = 0
183
+ for await (const chunk of req) {
184
+ const buffer = chunk as Buffer
185
+ size += buffer.length
186
+ if (size > MAX_JSON_BODY_BYTES) return undefined
187
+ chunks.push(buffer)
188
+ }
189
+ try {
190
+ return JSON.parse(Buffer.concat(chunks).toString('utf8')) as T
191
+ } catch {
192
+ return undefined
193
+ }
194
+ }
195
+
196
+ /** Route deps. */
197
+ export interface NovelRoutesDeps {
198
+ ctx: Context
199
+ /** Resolve the live plugin config (settings-aware). */
200
+ getConfig: () => NovelConfig
201
+ /** Persist a config patch through the settings seam. */
202
+ patchConfig: (patch: ConfigPatch) => Promise<NovelConfig>
203
+ }
204
+
205
+ /** Default chapter count for planning when the request omits it. */
206
+ const DEFAULT_PLAN_COUNT = 30
207
+
208
+ /**
209
+ * Build every /api/dsh-novel-forge route.
210
+ * @param deps - context, config resolver, config patcher.
211
+ * @returns the route list.
212
+ */
213
+ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
214
+ const { ctx, getConfig, patchConfig } = deps
215
+
216
+ /** 全书质检实时状态(内存态,重启后回到 idle;用于 /status 暴露进度)。 */
217
+ let auditState: AuditStatus = { status: 'idle', totalBatches: 0, completedBatches: 0 }
218
+
219
+ /** Guard helper: fence + method check. */
220
+ const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
221
+ if (!isLoopbackRequest(req)) {
222
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
223
+ return false
224
+ }
225
+ if (req.method !== method) {
226
+ writeJson(res, 405, { error: `method not allowed (expected ${method})` })
227
+ return false
228
+ }
229
+ return true
230
+ }
231
+
232
+ /** Load (and sync) the project, or respond 400. */
233
+ const requireProject = (res: ServerResponse): ReturnType<typeof loadProject> => {
234
+ const config = getConfig()
235
+ const project = loadProject(config.outputDir)
236
+ if (project === undefined) {
237
+ writeJson(res, 400, { error: '输出目录中没有项目,请先加载大纲' })
238
+ return undefined
239
+ }
240
+ syncProjectWithDisk(project, config.outputDir)
241
+ saveProject(config.outputDir, project)
242
+ return project
243
+ }
244
+
245
+ // -------------------------------------------------------------- status
246
+ const statusRoute: WebRoute = {
247
+ kind: 'exact',
248
+ path: NOVEL_API.status,
249
+ handler: (req, res) => {
250
+ if (!guard(req, res, 'GET')) return
251
+ const config = getConfig()
252
+ // 书架为空时播种 settings 默认输出目录里的已有项目。
253
+ seedBookshelfFromOutputDir(config.outputDir)
254
+ const project = loadProject(config.outputDir)
255
+ if (project !== undefined) {
256
+ // 自动兜底:generating 超过 10 分钟(正常一章 2-5 分钟)视为卡死/中断,
257
+ // 复位为 pending,避免"生成中"状态卡死无法重新生成。
258
+ const staleMs = 10 * 60 * 1000
259
+ for (const c of project.chapters) {
260
+ if (c.status === 'generating' && c.generatingAt !== undefined) {
261
+ const started = new Date(c.generatingAt).getTime()
262
+ if (Number.isFinite(started) && Date.now() - started > staleMs) {
263
+ c.status = 'pending'
264
+ c.error = undefined
265
+ c.generatingAt = undefined
266
+ }
267
+ }
268
+ }
269
+ syncProjectWithDisk(project, config.outputDir)
270
+ saveProject(config.outputDir, project)
271
+ }
272
+ const response: StatusResponse = {
273
+ config,
274
+ // 瘦身:facts 只回最近 80 条(客户端最多展示 60),避免长篇后
275
+ // status 响应体随编年录无限膨胀。
276
+ project: project !== undefined
277
+ ? { ...project, facts: (project.facts ?? []).slice(-80) }
278
+ : undefined,
279
+ generatedFiles: listChapterFiles(config.outputDir),
280
+ audit: auditState,
281
+ }
282
+ writeJson(res, 200, response)
283
+ },
284
+ }
285
+
286
+ // -------------------------------------------------------- load-outline
287
+ const loadOutlineRoute: WebRoute = {
288
+ kind: 'exact',
289
+ path: NOVEL_API.loadOutline,
290
+ handler: async (req, res) => {
291
+ if (!guard(req, res, 'POST')) return
292
+ const body = await readJsonBody<LoadOutlineRequest>(req)
293
+ const config = getConfig()
294
+ try {
295
+ let outline: string
296
+ let path: string | undefined
297
+ if (body?.text !== undefined && body.text.trim() !== '') {
298
+ outline = body.text.trim()
299
+ } else {
300
+ const target = body?.path?.trim() !== '' && body?.path !== undefined ? body.path : config.outlinePath
301
+ outline = readOutlineFromDocx(target)
302
+ path = target
303
+ }
304
+ if (outline.length < 50) {
305
+ writeJson(res, 400, { error: '大纲内容过短(<50 字符),请检查文件或直接粘贴大纲文本' })
306
+ return
307
+ }
308
+ const response: LoadOutlineResponse = {
309
+ outline,
310
+ bookName: createProject(outline).bookName,
311
+ chars: outline.length,
312
+ path,
313
+ }
314
+ writeJson(res, 200, response)
315
+ } catch (error) {
316
+ writeJson(res, 400, { error: (error as Error).message })
317
+ }
318
+ },
319
+ }
320
+
321
+ // -------------------------------------------------------- save-outline
322
+ const saveOutlineRoute: WebRoute = {
323
+ kind: 'exact',
324
+ path: NOVEL_API.saveOutline,
325
+ handler: async (req, res) => {
326
+ if (!guard(req, res, 'POST')) return
327
+ const body = await readJsonBody<LoadOutlineRequest>(req)
328
+ const config = getConfig()
329
+ const outline = body?.text ?? ''
330
+ if (outline.trim().length < 50) {
331
+ writeJson(res, 400, { error: '大纲内容过短(<50 字符)' })
332
+ return
333
+ }
334
+ let project = loadProject(config.outputDir)
335
+ const now = new Date().toISOString()
336
+ if (project === undefined) {
337
+ project = createProject(outline)
338
+ } else {
339
+ project.outline = outline
340
+ project.bookName = createProject(outline).bookName
341
+ project.updatedAt = now
342
+ }
343
+ saveProject(config.outputDir, project)
344
+ writeJson(res, 200, { ok: true, bookName: project.bookName })
345
+ },
346
+ }
347
+
348
+ // --------------------------------------------------------------- bible
349
+ const bibleRoute: WebRoute = {
350
+ kind: 'exact',
351
+ path: NOVEL_API.bible,
352
+ handler: async (req, res) => {
353
+ if (!guard(req, res, 'POST')) return
354
+ const body = await readJsonBody<BibleRequest>(req)
355
+ const config = getConfig()
356
+ const project = loadProject(config.outputDir)
357
+ const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
358
+ ? body.outline
359
+ : project?.outline
360
+ if (outline === undefined || outline.length < 50) {
361
+ writeJson(res, 400, { error: '请先加载大纲' })
362
+ return
363
+ }
364
+ try {
365
+ const bible = await extractBible(ctx, config, outline, project)
366
+ const now = new Date().toISOString()
367
+ const next = project ?? createProject(outline)
368
+ next.bible = bible
369
+ next.updatedAt = now
370
+ saveProject(config.outputDir, next)
371
+ const response: BibleResponse = { bible }
372
+ writeJson(res, 200, response)
373
+ } catch (error) {
374
+ writeJson(res, 500, { error: (error as Error).message })
375
+ }
376
+ },
377
+ }
378
+
379
+ // ------------------------------------------------------------- volumes
380
+ const volumesRoute: WebRoute = {
381
+ kind: 'exact',
382
+ path: NOVEL_API.volumes,
383
+ handler: async (req, res) => {
384
+ if (!guard(req, res, 'POST')) return
385
+ const body = await readJsonBody<VolumesRequest>(req)
386
+ const config = getConfig()
387
+ const project = loadProject(config.outputDir)
388
+ const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
389
+ ? body.outline
390
+ : project?.outline
391
+ if (outline === undefined || outline.length < 50) {
392
+ writeJson(res, 400, { error: '请先加载大纲' })
393
+ return
394
+ }
395
+ try {
396
+ const volumes = await planVolumes(ctx, config, outline)
397
+ const now = new Date().toISOString()
398
+ const next = project ?? createProject(outline)
399
+ next.volumes = volumes
400
+ next.updatedAt = now
401
+ saveProject(config.outputDir, next)
402
+ const response: VolumesResponse = { volumes }
403
+ writeJson(res, 200, response)
404
+ } catch (error) {
405
+ writeJson(res, 500, { error: (error as Error).message })
406
+ }
407
+ },
408
+ }
409
+
410
+ // ----------------------------------------------------------------- plan
411
+ const planRoute: WebRoute = {
412
+ kind: 'exact',
413
+ path: NOVEL_API.plan,
414
+ handler: async (req, res) => {
415
+ if (!guard(req, res, 'POST')) return
416
+ const body = await readJsonBody<PlanRequest>(req)
417
+ const config = getConfig()
418
+ const project = loadProject(config.outputDir)
419
+ const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
420
+ ? body.outline
421
+ : project?.outline
422
+ if (outline === undefined || outline.length < 50) {
423
+ writeJson(res, 400, { error: '请先加载大纲(或粘贴大纲文本)' })
424
+ return
425
+ }
426
+ const count = body?.chapterCount ?? DEFAULT_PLAN_COUNT
427
+ if (!Number.isInteger(count) || count < 1 || count > 200) {
428
+ writeJson(res, 400, { error: 'chapterCount 须为 1-200 的整数' })
429
+ return
430
+ }
431
+ try {
432
+ const next = project ?? createProject(outline)
433
+ const chapters: ChapterPlan[] = await planChapters(ctx, config, next, count, body?.volume, config.outputDir)
434
+ // 并发保护:长 LLM 调用期间磁盘可能已被其他请求更新(角色库/剧情线等),
435
+ // 保存前合并磁盘最新易变字段,避免旧快照覆盖新修改。
436
+ mergeVolatileFromDisk(config.outputDir, next)
437
+ next.chapters.push(...chapters)
438
+ next.updatedAt = new Date().toISOString()
439
+ saveProject(config.outputDir, next)
440
+ const response: PlanResponse = { chapters, volumes: next.volumes }
441
+ writeJson(res, 200, response)
442
+ } catch (error) {
443
+ writeJson(res, 500, { error: (error as Error).message })
444
+ }
445
+ },
446
+ }
447
+
448
+ // ------------------------------------------------------------- generate
449
+ const generateRoute: WebRoute = {
450
+ kind: 'exact',
451
+ path: NOVEL_API.generate,
452
+ handler: async (req, res) => {
453
+ if (!guard(req, res, 'POST')) return
454
+ const config = getConfig()
455
+ const project = requireProject(res)
456
+ if (project === undefined) return
457
+ const body = await readJsonBody<{ chapterNo?: number; skipReview?: boolean }>(req)
458
+ const rawNo = body?.chapterNo
459
+ if (!Number.isInteger(rawNo) || rawNo === undefined || rawNo < 1) {
460
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
461
+ return
462
+ }
463
+ const no: number = rawNo
464
+ const chapter = project.chapters.find(c => c.no === no)
465
+ if (chapter === undefined) {
466
+ writeJson(res, 404, { error: `章节 ${no} 不在计划中` })
467
+ return
468
+ }
469
+ if (chapter.status === 'generating') {
470
+ writeJson(res, 409, { error: `章节 ${no} 正在生成中` })
471
+ return
472
+ }
473
+
474
+ // NDJSON stream.
475
+ res.writeHead(200, {
476
+ 'content-type': 'application/x-ndjson; charset=utf-8',
477
+ 'cache-control': 'no-cache',
478
+ 'x-accel-buffering': 'no',
479
+ 'referrer-policy': 'no-referrer',
480
+ })
481
+ chapter.status = 'generating'
482
+ chapter.error = undefined
483
+ chapter.generatingAt = new Date().toISOString()
484
+ // 并发保护:合并磁盘最新易变字段后保存,避免本请求覆盖并发修改(角色库/剧情线等)。
485
+ mergeVolatileFromDisk(config.outputDir, project)
486
+ saveProject(config.outputDir, project)
487
+
488
+ const send = (frame: JobFrame): void => {
489
+ res.write(JSON.stringify(frame) + '\n')
490
+ }
491
+
492
+ try {
493
+ send({ type: 'start', no, title: chapter.title })
494
+ for await (const step of generateChapterStream(ctx, config, project, config.outputDir, no)) {
495
+ if (step.frame === 'delta') {
496
+ send({ type: 'delta', text: step.text })
497
+ } else if (step.frame === 'done') {
498
+ send({ type: 'done', no, file: step.file, chars: step.chars, title: chapter.title })
499
+ }
500
+ }
501
+ // Auto pipeline: summary + facts(一次调用)-> review(unless skipped)。
502
+ try {
503
+ await summarizeAndExtractFacts(ctx, config, project, config.outputDir, no)
504
+ } catch (error) {
505
+ console.warn('[dsh-novel-forge] summary/facts failed:', (error as Error).message)
506
+ }
507
+ // 伏笔落地标记:正文中命中 planned 伏笔关键词 → 自动标为 planted(暗线管理页与正文同步)。
508
+ try {
509
+ const marked = markForeshadowPlanted(project, config.outputDir, no)
510
+ if (marked > 0) {
511
+ console.log(`[dsh-novel-forge] 第${no}章已埋伏笔 ${marked} 条`)
512
+ }
513
+ } catch (error) {
514
+ console.warn('[dsh-novel-forge] markForeshadowPlanted failed:', (error as Error).message)
515
+ }
516
+ if (!(body?.skipReview === true) && (config.autoReview ?? true)) {
517
+ const report = await reviewChapter(ctx, config, project, config.outputDir, no)
518
+ send({ type: 'review', no, report })
519
+ } else {
520
+ chapter.status = 'approved'
521
+ mergeVolatileFromDisk(config.outputDir, project)
522
+ saveProject(config.outputDir, project)
523
+ }
524
+ // 作者复盘:叙事结构检查(钩子兑现/结尾钩子/推进/连续性/趋势;不改变章节状态)。
525
+ if (config.autoAuthorReview ?? true) {
526
+ try {
527
+ const currentBody = readChapterFile(config.outputDir, chapter)
528
+ let prevTail = ''
529
+ if (no > 1) {
530
+ const prev = project.chapters.find(c => c.no === no - 1)
531
+ if (prev !== undefined) {
532
+ prevTail = (readChapterFile(config.outputDir, prev) ?? '').replace(/^#.*$/m, '').trim().slice(-600)
533
+ }
534
+ }
535
+ if (currentBody !== undefined) {
536
+ const review = await authorReviewChapter(ctx, config, project, no, currentBody, prevTail)
537
+ chapter.authorReview = review
538
+ // 复盘标记推进的剧情线 → 自动关联本章。
539
+ if (review.advancedLines !== undefined) {
540
+ autoLinkPlotlines(project, no, review.advancedLines)
541
+ }
542
+ mergeVolatileFromDisk(config.outputDir, project)
543
+ saveProject(config.outputDir, project)
544
+ send({ type: 'author-review', no, review })
545
+ }
546
+ } catch (error) {
547
+ console.warn('[dsh-novel-forge] author review failed:', (error as Error).message)
548
+ }
549
+ }
550
+ res.end()
551
+ } catch (error) {
552
+ chapter.status = 'error'
553
+ chapter.error = (error as Error).message
554
+ mergeVolatileFromDisk(config.outputDir, project)
555
+ saveProject(config.outputDir, project)
556
+ if (!res.writableEnded) {
557
+ send({ type: 'error', no, message: (error as Error).message })
558
+ res.end()
559
+ }
560
+ }
561
+ },
562
+ }
563
+
564
+ // --------------------------------------------------------------- review
565
+ const reviewRoute: WebRoute = {
566
+ kind: 'exact',
567
+ path: NOVEL_API.review,
568
+ handler: async (req, res) => {
569
+ if (!guard(req, res, 'POST')) return
570
+ const config = getConfig()
571
+ const project = requireProject(res)
572
+ if (project === undefined) return
573
+ const body = await readJsonBody<ReviewRequest>(req)
574
+ if (!Number.isInteger(body?.chapterNo)) {
575
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
576
+ return
577
+ }
578
+ const no = body!.chapterNo!
579
+ try {
580
+ const report = await reviewChapter(ctx, config, project, config.outputDir, no)
581
+ writeJson(res, 200, { report })
582
+ } catch (error) {
583
+ writeJson(res, 500, { error: (error as Error).message })
584
+ }
585
+ },
586
+ }
587
+
588
+ // -------------------------------------------------------------- rewrite
589
+ const rewriteRoute: WebRoute = {
590
+ kind: 'exact',
591
+ path: NOVEL_API.rewrite,
592
+ handler: async (req, res) => {
593
+ if (!guard(req, res, 'POST')) return
594
+ const config = getConfig()
595
+ const project = requireProject(res)
596
+ if (project === undefined) return
597
+ const body = await readJsonBody<RewriteRequest>(req)
598
+ if (!Number.isInteger(body?.chapterNo)) {
599
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
600
+ return
601
+ }
602
+ const no = body!.chapterNo!
603
+ res.writeHead(200, {
604
+ 'content-type': 'application/x-ndjson; charset=utf-8',
605
+ 'cache-control': 'no-cache',
606
+ 'x-accel-buffering': 'no',
607
+ 'referrer-policy': 'no-referrer',
608
+ })
609
+ const send = (frame: JobFrame): void => { res.write(JSON.stringify(frame) + '\n') }
610
+ try {
611
+ for await (const step of rewriteChapterStream(ctx, config, project, config.outputDir, no, body?.instructions ?? '', body?.target)) {
612
+ if (step.frame === 'delta') send({ type: 'delta', text: step.text })
613
+ else if (step.frame === 'drafted') send({ type: 'drafted', no, chars: step.chars, draft: step.draft })
614
+ }
615
+ // Draft mode: no auto re-review — the user reviews the diff and
616
+ // decides; re-run review after applying if wanted.
617
+ res.end()
618
+ } catch (error) {
619
+ if (!res.writableEnded) {
620
+ send({ type: 'error', no, message: (error as Error).message })
621
+ res.end()
622
+ }
623
+ }
624
+ },
625
+ }
626
+
627
+ // --------------------------------------------------------------- polish
628
+ const polishRoute: WebRoute = {
629
+ kind: 'exact',
630
+ path: NOVEL_API.polish,
631
+ handler: async (req, res) => {
632
+ if (!guard(req, res, 'POST')) return
633
+ const config = getConfig()
634
+ const project = requireProject(res)
635
+ if (project === undefined) return
636
+ const body = await readJsonBody<PolishRequest>(req)
637
+ if (!Number.isInteger(body?.chapterNo)) {
638
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
639
+ return
640
+ }
641
+ const no = body!.chapterNo!
642
+ res.writeHead(200, {
643
+ 'content-type': 'application/x-ndjson; charset=utf-8',
644
+ 'cache-control': 'no-cache',
645
+ 'x-accel-buffering': 'no',
646
+ 'referrer-policy': 'no-referrer',
647
+ })
648
+ const send = (frame: JobFrame): void => { res.write(JSON.stringify(frame) + '\n') }
649
+ try {
650
+ for await (const step of polishChapterStream(ctx, config, project, config.outputDir, no)) {
651
+ if (step.frame === 'delta') send({ type: 'delta', text: step.text })
652
+ else if (step.frame === 'drafted') send({ type: 'drafted', no, chars: step.chars, draft: step.draft })
653
+ }
654
+ res.end()
655
+ } catch (error) {
656
+ if (!res.writableEnded) {
657
+ send({ type: 'error', no, message: (error as Error).message })
658
+ res.end()
659
+ }
660
+ }
661
+ },
662
+ }
663
+
664
+ // ---------------------------------------------------- draft apply/discard
665
+ /** 采纳待确认草稿:覆盖正文文件 + 状态回 written + 清空草稿。 */
666
+ const draftApplyRoute: WebRoute = {
667
+ kind: 'exact',
668
+ path: NOVEL_API.draftApply,
669
+ handler: async (req, res) => {
670
+ if (!guard(req, res, 'POST')) return
671
+ const config = getConfig()
672
+ const project = requireProject(res)
673
+ if (project === undefined) return
674
+ const body = await readJsonBody<DraftDecisionRequest>(req)
675
+ if (!Number.isInteger(body?.chapterNo)) {
676
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
677
+ return
678
+ }
679
+ const chapter = project.chapters.find(c => c.no === body!.chapterNo!)
680
+ if (chapter === undefined) {
681
+ writeJson(res, 404, { error: `章节 ${body!.chapterNo} 不在计划中` })
682
+ return
683
+ }
684
+ if (chapter.pendingDraft === undefined || chapter.pendingDraft === '') {
685
+ writeJson(res, 400, { error: `章节 ${chapter.no} 没有待确认的草稿` })
686
+ return
687
+ }
688
+ const draft = chapter.pendingDraft
689
+ const fileName = chapterFileName(chapter)
690
+ mkdirSync(config.outputDir, { recursive: true })
691
+ const targetPath = join(config.outputDir, fileName)
692
+ // 采纳前自动备份当前原稿为 .bak.md(每次应用都刷新为最新原稿),可随时回退。
693
+ if (existsSync(targetPath)) {
694
+ copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
695
+ }
696
+ writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
697
+ chapter.pendingDraft = undefined
698
+ chapter.chars = draft.length
699
+ chapter.file = fileName
700
+ // 携带审查报告则沿用结论定状态(修订后审查通过 → approved);否则置 written 待审。
701
+ const carried = body?.report
702
+ if (carried !== undefined && typeof carried.score === 'number') {
703
+ chapter.review = carried
704
+ chapter.status = carried.passed ? 'approved' : 'rejected'
705
+ } else {
706
+ chapter.status = 'written'
707
+ chapter.review = undefined
708
+ }
709
+ chapter.error = undefined
710
+ project.updatedAt = new Date().toISOString()
711
+ saveProject(config.outputDir, project)
712
+ writeJson(res, 200, { ok: true, chars: draft.length, file: fileName, markdown: draft })
713
+ },
714
+ }
715
+
716
+ /** 放弃待确认草稿:保留原稿,仅清空草稿字段。 */
717
+ const draftDiscardRoute: WebRoute = {
718
+ kind: 'exact',
719
+ path: NOVEL_API.draftDiscard,
720
+ handler: async (req, res) => {
721
+ if (!guard(req, res, 'POST')) return
722
+ const config = getConfig()
723
+ const project = requireProject(res)
724
+ if (project === undefined) return
725
+ const body = await readJsonBody<DraftDecisionRequest>(req)
726
+ if (!Number.isInteger(body?.chapterNo)) {
727
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
728
+ return
729
+ }
730
+ const chapter = project.chapters.find(c => c.no === body!.chapterNo!)
731
+ if (chapter === undefined) {
732
+ writeJson(res, 404, { error: `章节 ${body!.chapterNo} 不在计划中` })
733
+ return
734
+ }
735
+ if (chapter.pendingDraft !== undefined) {
736
+ chapter.pendingDraft = undefined
737
+ project.updatedAt = new Date().toISOString()
738
+ saveProject(config.outputDir, project)
739
+ }
740
+ writeJson(res, 200, { ok: true })
741
+ },
742
+ }
743
+
744
+ // -------------------------------------------------------------- summary
745
+ const summaryRoute: WebRoute = {
746
+ kind: 'exact',
747
+ path: NOVEL_API.summary,
748
+ handler: async (req, res) => {
749
+ if (!guard(req, res, 'POST')) return
750
+ const config = getConfig()
751
+ const project = requireProject(res)
752
+ if (project === undefined) return
753
+ const body = await readJsonBody<SummaryRequest>(req)
754
+ if (!Number.isInteger(body?.chapterNo)) {
755
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
756
+ return
757
+ }
758
+ try {
759
+ const summary = await summarizeChapter(ctx, config, project, config.outputDir, body!.chapterNo!)
760
+ writeJson(res, 200, { summary })
761
+ } catch (error) {
762
+ writeJson(res, 500, { error: (error as Error).message })
763
+ }
764
+ },
765
+ }
766
+
767
+ // ---------------------------------------------------------- foreshadow
768
+ const foreshadowRoute: WebRoute = {
769
+ kind: 'exact',
770
+ path: NOVEL_API.foreshadow,
771
+ handler: async (req, res) => {
772
+ if (!guard(req, res, 'POST')) return
773
+ const config = getConfig()
774
+ const project = requireProject(res)
775
+ if (project === undefined) return
776
+ const body = await readJsonBody<ForeshadowRequest>(req)
777
+ try {
778
+ if (body?.suggest === true) {
779
+ // AI suggestion pass: create several foreshadows from the outline.
780
+ const created = await suggestForeshadows(ctx, config, project)
781
+ project.updatedAt = new Date().toISOString()
782
+ saveProject(config.outputDir, project)
783
+ const response: ForeshadowResponse = { foreshadows: created }
784
+ writeJson(res, 200, response)
785
+ return
786
+ }
787
+ if (body?.id !== undefined) {
788
+ // Update an existing foreshadow.
789
+ const target = project.foreshadows.find(f => f.id === body.id)
790
+ if (target === undefined) {
791
+ writeJson(res, 404, { error: `伏笔 ${body.id} 不存在` })
792
+ return
793
+ }
794
+ if (body.description !== undefined) target.description = body.description
795
+ if (body.plantedChapter !== undefined) target.plantedChapter = body.plantedChapter
796
+ if (body.targetChapter !== undefined) target.targetChapter = body.targetChapter
797
+ if (body.status !== undefined) target.status = body.status
798
+ if (body.resolvedNote !== undefined) target.resolvedNote = body.resolvedNote
799
+ } else {
800
+ // Create one manually.
801
+ const description = body?.description?.trim()
802
+ if (description === undefined || description === '') {
803
+ writeJson(res, 400, { error: 'description 必填' })
804
+ return
805
+ }
806
+ project.foreshadows.push({
807
+ id: `fs-${Date.now().toString(36)}`,
808
+ description,
809
+ plantedChapter: body?.plantedChapter,
810
+ targetChapter: body?.targetChapter,
811
+ status: body?.status ?? 'planned',
812
+ })
813
+ }
814
+ project.updatedAt = new Date().toISOString()
815
+ saveProject(config.outputDir, project)
816
+ const response: ForeshadowResponse = { foreshadows: project.foreshadows }
817
+ writeJson(res, 200, response)
818
+ } catch (error) {
819
+ writeJson(res, 500, { error: (error as Error).message })
820
+ }
821
+ },
822
+ }
823
+
824
+ // -------------------------------------------------------------- export
825
+ const exportRoute: WebRoute = {
826
+ kind: 'exact',
827
+ path: NOVEL_API.exportBook,
828
+ handler: async (req, res) => {
829
+ if (!guard(req, res, 'POST')) return
830
+ const config = getConfig()
831
+ const project = requireProject(res)
832
+ if (project === undefined) return
833
+ const body = await readJsonBody<ExportRequest>(req)
834
+ const format = body?.format === 'md' ? 'md' : 'txt'
835
+ try {
836
+ const result = exportBook(config.outputDir, project, format)
837
+ const response: ExportResponse = { ...result }
838
+ writeJson(res, 200, response)
839
+ } catch (error) {
840
+ writeJson(res, 500, { error: (error as Error).message })
841
+ }
842
+ },
843
+ }
844
+
845
+ // -------------------------------------------------------------- chapter
846
+ const chapterRoute: WebRoute = {
847
+ kind: 'exact',
848
+ path: NOVEL_API.chapter,
849
+ handler: async (req, res) => {
850
+ if (!guard(req, res, 'GET')) return
851
+ const config = getConfig()
852
+ const project = requireProject(res)
853
+ if (project === undefined) return
854
+ const url = new URL(req.url ?? '/', 'http://localhost')
855
+ const rawNo = Number(url.searchParams.get('no') ?? '0')
856
+ if (!Number.isInteger(rawNo) || rawNo < 1) {
857
+ writeJson(res, 400, { error: 'no 须为正整数' })
858
+ return
859
+ }
860
+ const chapter = project.chapters.find(c => c.no === rawNo)
861
+ if (chapter === undefined) {
862
+ writeJson(res, 404, { error: `章节 ${rawNo} 不在计划中` })
863
+ return
864
+ }
865
+ const markdown = readChapterFile(config.outputDir, chapter)
866
+ if (markdown === undefined) {
867
+ writeJson(res, 404, { error: `章节 ${rawNo} 尚未生成` })
868
+ return
869
+ }
870
+ const response: ChapterResponse = { no: chapter.no, title: chapter.title, markdown }
871
+ writeJson(res, 200, response)
872
+ },
873
+ }
874
+
875
+ // ------------------------------------------------------ chapter check/save
876
+ /** 审查手动编辑的正文(不落盘,返回审稿报告)。 */
877
+ const chapterCheckRoute: WebRoute = {
878
+ kind: 'exact',
879
+ path: NOVEL_API.chapterCheck,
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<ChapterTextRequest>(req)
886
+ const text = body?.text?.trim() ?? ''
887
+ if (text.length < 50) {
888
+ writeJson(res, 400, { error: '正文过短(<50 字),请先编辑内容' })
889
+ return
890
+ }
891
+ try {
892
+ // 携带上一轮报告 → 验证模式(逐条核对原意见解决情况 + 只挑新增 high)。
893
+ const report = await reviewChapterText(ctx, config, project, text, body?.previousReport)
894
+ writeJson(res, 200, { report })
895
+ } catch (error) {
896
+ writeJson(res, 500, { error: (error as Error).message })
897
+ }
898
+ },
899
+ }
900
+
901
+ /** 保存手动编辑的正文(自动备份 .bak,状态回 written)。 */
902
+ const chapterSaveRoute: WebRoute = {
903
+ kind: 'exact',
904
+ path: NOVEL_API.chapterSave,
905
+ handler: async (req, res) => {
906
+ if (!guard(req, res, 'POST')) return
907
+ const config = getConfig()
908
+ const project = requireProject(res)
909
+ if (project === undefined) return
910
+ const body = await readJsonBody<ChapterTextRequest>(req)
911
+ if (!Number.isInteger(body?.chapterNo)) {
912
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
913
+ return
914
+ }
915
+ const chapter = project.chapters.find(c => c.no === body!.chapterNo!)
916
+ if (chapter === undefined) {
917
+ writeJson(res, 404, { error: `章节 ${body!.chapterNo} 不在计划中` })
918
+ return
919
+ }
920
+ const text = body?.text?.trim() ?? ''
921
+ if (text.length < 50) {
922
+ writeJson(res, 400, { error: '正文过短(<50 字),未保存' })
923
+ return
924
+ }
925
+ const fileName = chapterFileName(chapter)
926
+ mkdirSync(config.outputDir, { recursive: true })
927
+ const targetPath = join(config.outputDir, fileName)
928
+ if (existsSync(targetPath)) {
929
+ copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
930
+ }
931
+ writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${text}\n`, 'utf8')
932
+ chapter.status = 'written'
933
+ chapter.chars = text.length
934
+ chapter.file = fileName
935
+ chapter.pendingDraft = undefined
936
+ // 保存即审稿:携带工作区审查报告则沿用(不重复审);否则自动正式审稿一次。
937
+ let report: ReviewReport | undefined
938
+ const carried = body?.report
939
+ if (carried !== undefined && typeof carried.score === 'number') {
940
+ report = carried
941
+ chapter.review = report
942
+ chapter.status = report.passed ? 'approved' : 'rejected'
943
+ } else {
944
+ report = await reviewChapter(ctx, config, project, config.outputDir, chapter.no)
945
+ }
946
+ project.updatedAt = new Date().toISOString()
947
+ saveProject(config.outputDir, project)
948
+ const response: ChapterSaveResponse = { ok: true, chars: text.length, file: fileName, report }
949
+ writeJson(res, 200, response)
950
+ },
951
+ }
952
+
953
+ // ----------------------------------------------------------- assistant
954
+ const assistantRoute: WebRoute = {
955
+ kind: 'exact',
956
+ path: NOVEL_API.assistant,
957
+ handler: async (req, res) => {
958
+ if (!guard(req, res, 'POST')) return
959
+ const config = getConfig()
960
+ const project = requireProject(res)
961
+ if (project === undefined) return
962
+ const body = await readJsonBody<AssistantRequest>(req)
963
+ const message = body?.message?.trim()
964
+ if (message === undefined || message === '') {
965
+ writeJson(res, 400, { error: '消息不能为空' })
966
+ return
967
+ }
968
+ res.writeHead(200, {
969
+ 'content-type': 'application/x-ndjson; charset=utf-8',
970
+ 'cache-control': 'no-cache',
971
+ 'x-accel-buffering': 'no',
972
+ 'referrer-policy': 'no-referrer',
973
+ })
974
+ const send = (frame: AssistantFrame): void => { res.write(JSON.stringify(frame) + '\n') }
975
+ try {
976
+ for await (const step of runAssistantTurn(ctx, config, project, config.outputDir, message)) {
977
+ if (step.frame === 'delta') send({ type: 'delta', text: step.text })
978
+ else if (step.frame === 'tool') send({ type: 'tool', name: step.name, status: step.status, detail: step.detail })
979
+ else if (step.frame === 'toolDelta') send({ type: 'toolDelta', name: step.name, text: step.text })
980
+ }
981
+ send({ type: 'done' })
982
+ res.end()
983
+ } catch (error) {
984
+ if (!res.writableEnded) {
985
+ send({ type: 'error', message: (error as Error).message })
986
+ res.end()
987
+ }
988
+ }
989
+ },
990
+ }
991
+
992
+ // -------------------------------------------------- assistant-history
993
+ const assistantHistoryRoute: WebRoute = {
994
+ kind: 'exact',
995
+ path: NOVEL_API.assistantHistory,
996
+ handler: (req, res) => {
997
+ if (!guard(req, res, 'GET')) return
998
+ const config = getConfig()
999
+ const response: AssistantHistoryResponse = { messages: loadAssistantHistory(config.outputDir) }
1000
+ writeJson(res, 200, response)
1001
+ },
1002
+ }
1003
+
1004
+ // --------------------------------------------------- assistant-clear
1005
+ /** 清空助手对话记录。 */
1006
+ const assistantClearRoute: WebRoute = {
1007
+ kind: 'exact',
1008
+ path: NOVEL_API.assistantClear,
1009
+ handler: (req, res) => {
1010
+ if (!guard(req, res, 'POST')) return
1011
+ const config = getConfig()
1012
+ clearAssistantHistory(config.outputDir)
1013
+ writeJson(res, 200, { ok: true })
1014
+ },
1015
+ }
1016
+
1017
+ // --------------------------------------------------------------- assets
1018
+ const assetsRoute: WebRoute = {
1019
+ kind: 'exact',
1020
+ path: NOVEL_API.assets,
1021
+ handler: async (req, res) => {
1022
+ // GET (read) and POST (patch) are both allowed — check methods first,
1023
+ // then the loopback fence (guard() would 405 on POST, which is wrong).
1024
+ if (req.method !== 'GET' && req.method !== 'POST') {
1025
+ writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
1026
+ return
1027
+ }
1028
+ if (!isLoopbackRequest(req)) {
1029
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
1030
+ return
1031
+ }
1032
+ const config = getConfig()
1033
+ const project = loadProject(config.outputDir)
1034
+ const projectAssets = project?.assets ?? emptyProjectAssets()
1035
+ if (req.method === 'POST') {
1036
+ const body = await readJsonBody<AssetsPatch>(req)
1037
+ if (body === undefined) {
1038
+ writeJson(res, 400, { error: '无效的 JSON' })
1039
+ return
1040
+ }
1041
+ if (project === undefined) {
1042
+ writeJson(res, 400, { error: '请先加载大纲创建项目' })
1043
+ return
1044
+ }
1045
+ if (body.genre !== undefined) projectAssets.genre = body.genre
1046
+ if (body.primaryProgression !== undefined) projectAssets.primaryProgression = body.primaryProgression
1047
+ if (body.auxiliaryProgressions !== undefined) projectAssets.auxiliaryProgressions = body.auxiliaryProgressions
1048
+ if (body.antiAiRules !== undefined) projectAssets.antiAiRules = body.antiAiRules
1049
+ if (body.styleAssets !== undefined) projectAssets.styleAssets = body.styleAssets
1050
+ projectAssets.updatedAt = new Date().toISOString()
1051
+ project.assets = projectAssets
1052
+ project.updatedAt = new Date().toISOString()
1053
+ saveProject(config.outputDir, project)
1054
+ }
1055
+ const response: AssetsResponse = {
1056
+ projectAssets,
1057
+ genreLibrary: BUILTIN_GENRE_LIBRARY,
1058
+ antiAiLibrary: BUILTIN_ANTI_AI_RULES,
1059
+ styleTemplates: BUILTIN_STYLE_TEMPLATES,
1060
+ progressionLibrary: BUILTIN_PROGRESSION_MODES,
1061
+ }
1062
+ writeJson(res, 200, response)
1063
+ },
1064
+ }
1065
+
1066
+ // ----------------------------------------------------------- style-engine
1067
+ const styleEngineRoute: WebRoute = {
1068
+ kind: 'exact',
1069
+ path: NOVEL_API.styleEngine,
1070
+ handler: async (req, res) => {
1071
+ if (!guard(req, res, 'POST')) return
1072
+ const config = getConfig()
1073
+ const project = loadProject(config.outputDir)
1074
+ const body = await readJsonBody<StyleEngineRequest>(req)
1075
+ const sample = body?.sampleText?.trim()
1076
+ if (sample === undefined || sample.length < 50) {
1077
+ writeJson(res, 400, { error: '样本文本过短(<50 字符),请粘贴一段能代表目标风格的文字' })
1078
+ return
1079
+ }
1080
+ try {
1081
+ const rules = await extractStyleAsset(ctx, config, sample)
1082
+ const name = body?.name?.trim() !== '' && body?.name !== undefined ? body.name : `风格资产 ${Date.now().toString(36)}`
1083
+ const styleAsset = {
1084
+ name: name.slice(0, 40),
1085
+ ...rules,
1086
+ sourceText: sample.slice(0, 3000),
1087
+ createdAt: new Date().toISOString(),
1088
+ }
1089
+ if (project !== undefined) {
1090
+ project.assets ??= emptyProjectAssets()
1091
+ project.assets.styleAssets ??= []
1092
+ project.assets.styleAssets.push(styleAsset)
1093
+ project.assets.updatedAt = new Date().toISOString()
1094
+ project.updatedAt = new Date().toISOString()
1095
+ saveProject(config.outputDir, project)
1096
+ }
1097
+ writeJson(res, 200, { styleAsset })
1098
+ } catch (error) {
1099
+ writeJson(res, 500, { error: (error as Error).message })
1100
+ }
1101
+ },
1102
+ }
1103
+
1104
+ // ------------------------------------------------------------- bookshelf
1105
+ const bookshelfRoute: WebRoute = {
1106
+ kind: 'exact',
1107
+ path: NOVEL_API.bookshelf,
1108
+ handler: async (req, res) => {
1109
+ // GET = snapshot; POST = create book.
1110
+ if (req.method === 'GET') {
1111
+ if (!isLoopbackRequest(req)) {
1112
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
1113
+ return
1114
+ }
1115
+ // 书架为空时,把 settings 默认输出目录里已有的项目播种为第一本书。
1116
+ seedBookshelfFromOutputDir(getConfig().outputDir)
1117
+ const snapshot: BookshelfSnapshot = bookshelfSnapshot(loadBookshelf())
1118
+ writeJson(res, 200, snapshot)
1119
+ return
1120
+ }
1121
+ if (req.method === 'POST') {
1122
+ if (!isLoopbackRequest(req)) {
1123
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
1124
+ return
1125
+ }
1126
+ const body = await readJsonBody<BookCreateRequest>(req)
1127
+ const bookName = body?.bookName?.trim()
1128
+ if (bookName === undefined || bookName === '') {
1129
+ writeJson(res, 400, { error: 'bookName 不能为空' })
1130
+ return
1131
+ }
1132
+ const outputDir = body?.outputDir?.trim() !== '' && body?.outputDir !== undefined
1133
+ ? body.outputDir
1134
+ : defaultOutputDirFor(bookName)
1135
+ const book = createBook(bookName, outputDir)
1136
+ // 开书向导:创建时带大纲 → 立即建立项目(书名以大纲首行为准)。
1137
+ const outline = body?.outline?.trim()
1138
+ if (outline !== undefined && outline.length >= 50) {
1139
+ const project = createProject(outline)
1140
+ saveProject(outputDir, project)
1141
+ renameBook(book.id, project.bookName)
1142
+ }
1143
+ writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
1144
+ return
1145
+ }
1146
+ writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
1147
+ },
1148
+ }
1149
+
1150
+ // --------------------------------------------------------------- reset
1151
+ /** 重置项目:清空设定/卷/章节计划/正文/伏笔/资产/事实库(可携带新大纲)。 */
1152
+ const resetRoute: WebRoute = {
1153
+ kind: 'exact',
1154
+ path: NOVEL_API.reset,
1155
+ handler: async (req, res) => {
1156
+ if (!guard(req, res, 'POST')) return
1157
+ const config = getConfig()
1158
+ const project = loadProject(config.outputDir)
1159
+ if (project === undefined) {
1160
+ writeJson(res, 400, { error: '输出目录中没有项目,无需重置' })
1161
+ return
1162
+ }
1163
+ const body = await readJsonBody<ResetRequest>(req)
1164
+ const outline = body?.outline?.trim()
1165
+ if (outline !== undefined && outline.length >= 50) {
1166
+ project.outline = outline
1167
+ project.bookName = createProject(outline).bookName
1168
+ }
1169
+ project.bible = undefined
1170
+ project.volumes = undefined
1171
+ project.chapters = []
1172
+ project.foreshadows = []
1173
+ project.assets = emptyProjectAssets()
1174
+ project.facts = []
1175
+ project.updatedAt = new Date().toISOString()
1176
+ saveProject(config.outputDir, project)
1177
+ writeJson(res, 200, { ok: true, bookName: project.bookName })
1178
+ },
1179
+ }
1180
+
1181
+ // --------------------------------------------------- bookshelf activate
1182
+ const bookshelfActivateRoute: WebRoute = {
1183
+ kind: 'exact',
1184
+ path: '/api/dsh-novel-forge/bookshelf/activate',
1185
+ handler: async (req, res) => {
1186
+ if (!guard(req, res, 'POST')) return
1187
+ const body = await readJsonBody<BookActivateRequest>(req)
1188
+ if (body?.id === undefined || body.id === '') {
1189
+ writeJson(res, 400, { error: 'id 不能为空' })
1190
+ return
1191
+ }
1192
+ const book = activateBook(body.id)
1193
+ if (book === undefined) {
1194
+ writeJson(res, 404, { error: `书 ${body.id} 不存在` })
1195
+ return
1196
+ }
1197
+ writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
1198
+ },
1199
+ }
1200
+
1201
+ // ---------------------------------------------------- bookshelf remove
1202
+ const bookshelfRemoveRoute: WebRoute = {
1203
+ kind: 'exact',
1204
+ path: '/api/dsh-novel-forge/bookshelf/remove',
1205
+ handler: async (req, res) => {
1206
+ if (!guard(req, res, 'POST')) return
1207
+ const body = await readJsonBody<BookRemoveRequest>(req)
1208
+ if (body?.id === undefined || body.id === '') {
1209
+ writeJson(res, 400, { error: 'id 不能为空' })
1210
+ return
1211
+ }
1212
+ const removed = removeBook(body.id)
1213
+ if (!removed) {
1214
+ writeJson(res, 404, { error: `书 ${body.id} 不存在` })
1215
+ return
1216
+ }
1217
+ writeJson(res, 200, bookshelfSnapshot(loadBookshelf()))
1218
+ },
1219
+ }
1220
+
1221
+ // ------------------------------------------- bookshelf import-dir
1222
+ /** 导入已有项目目录(Mode A):校验 novel-project.json,登记/激活书架。 */
1223
+ const bookshelfImportDirRoute: WebRoute = {
1224
+ kind: 'exact',
1225
+ path: NOVEL_API.bookshelfImportDir,
1226
+ handler: async (req, res) => {
1227
+ if (!guard(req, res, 'POST')) return
1228
+ const body = await readJsonBody<BookImportDirRequest>(req)
1229
+ const outputDir = body?.outputDir?.trim()
1230
+ if (outputDir === undefined || outputDir === '') {
1231
+ writeJson(res, 400, { error: 'outputDir 不能为空' })
1232
+ return
1233
+ }
1234
+ try {
1235
+ const { book, existed } = importDir(outputDir)
1236
+ writeJson(res, 200, { book, existed })
1237
+ } catch (err) {
1238
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
1239
+ }
1240
+ },
1241
+ }
1242
+
1243
+ // ------------------------------------------- bookshelf import-text
1244
+ /** 导入 txt/md 全本(Mode B):拆章落盘建项目,登记书架。支持浏览器上传(text+fileName)与服务器本地文件(filePath)两种模式。 */
1245
+ const bookshelfImportTextRoute: WebRoute = {
1246
+ kind: 'exact',
1247
+ path: NOVEL_API.bookshelfImportText,
1248
+ handler: async (req, res) => {
1249
+ if (!guard(req, res, 'POST')) return
1250
+ const body = await readJsonBody<BookImportTextRequest>(req)
1251
+ try {
1252
+ // 模式二:浏览器上传全文内容
1253
+ if (body?.text !== undefined && body.text.length > 0) {
1254
+ const bookName = (body.fileName ?? '').replace(/\.[^.]+$/, '').trim().slice(0, 40) || '导入小说'
1255
+ const outDir = body?.outputDir?.trim() !== undefined && body.outputDir.trim() !== ''
1256
+ ? body.outputDir.trim()
1257
+ : defaultOutputDirFor(bookName)
1258
+ const result = importBookTextFromText(body.text, outDir, bookName)
1259
+ const { book } = importDir(outDir)
1260
+ writeJson(res, 200, { ...result, book })
1261
+ return
1262
+ }
1263
+ // 模式一:服务器本地文件
1264
+ const filePath = body?.filePath?.trim()
1265
+ if (filePath === undefined || filePath === '') {
1266
+ writeJson(res, 400, { error: 'filePath 不能为空(或请上传文件内容)' })
1267
+ return
1268
+ }
1269
+ if (!existsSync(filePath)) {
1270
+ writeJson(res, 400, { error: `文件不存在:${filePath}` })
1271
+ return
1272
+ }
1273
+ const bookName = basename(filePath, extname(filePath)).slice(0, 40) || '导入小说'
1274
+ const outDir = body?.outputDir?.trim() !== undefined && body.outputDir.trim() !== ''
1275
+ ? body.outputDir.trim()
1276
+ : defaultOutputDirFor(bookName)
1277
+ const result = importBookText(filePath, outDir)
1278
+ const { book } = importDir(outDir)
1279
+ writeJson(res, 200, { ...result, book })
1280
+ } catch (err) {
1281
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
1282
+ }
1283
+ },
1284
+ }
1285
+
1286
+ // --------------------------------------- bookshelf import-text preview
1287
+ /** 上传全文做拆章预览(不落盘、不登记书架)。 */
1288
+ const bookshelfImportTextPreviewRoute: WebRoute = {
1289
+ kind: 'exact',
1290
+ path: NOVEL_API.bookshelfImportTextPreview,
1291
+ handler: async (req, res) => {
1292
+ if (!guard(req, res, 'POST')) return
1293
+ const body = await readJsonBody<BookImportTextPreviewRequest>(req)
1294
+ if (body?.text === undefined || body.text.length === 0) {
1295
+ writeJson(res, 400, { error: 'text 不能为空' })
1296
+ return
1297
+ }
1298
+ try {
1299
+ const bookName = (body.fileName ?? '').replace(/\.[^.]+$/, '').trim().slice(0, 40) || '导入小说'
1300
+ const preview = previewBookText(body.text)
1301
+ writeJson(res, 200, { bookName, ...preview })
1302
+ } catch (err) {
1303
+ writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
1304
+ }
1305
+ },
1306
+ }
1307
+
1308
+ // ---------------------------------------------------------------- audit
1309
+ /** 全书一致性质检。 */
1310
+ const auditRoute: WebRoute = {
1311
+ kind: 'exact',
1312
+ path: NOVEL_API.audit,
1313
+ handler: async (req, res) => {
1314
+ if (!guard(req, res, 'POST')) return
1315
+ const config = getConfig()
1316
+ const project = requireProject(res)
1317
+ if (project === undefined) return
1318
+ const startedAt = new Date().toISOString()
1319
+ auditState = { status: 'running', startedAt, totalBatches: 0, completedBatches: 0 }
1320
+ try {
1321
+ const issues = await auditBook(ctx, config, project, config.outputDir, (completed, total) => {
1322
+ auditState.totalBatches = total
1323
+ auditState.completedBatches = completed
1324
+ })
1325
+ const auditedChapters = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating').length
1326
+ auditState = {
1327
+ status: 'done',
1328
+ startedAt,
1329
+ finishedAt: new Date().toISOString(),
1330
+ totalBatches: auditState.totalBatches,
1331
+ completedBatches: auditState.totalBatches,
1332
+ auditedChapters,
1333
+ issuesCount: issues.length,
1334
+ issues,
1335
+ }
1336
+ const response: AuditResponse = {
1337
+ issues,
1338
+ auditedChapters,
1339
+ auditedAt: auditState.finishedAt!,
1340
+ }
1341
+ writeJson(res, 200, response)
1342
+ } catch (error) {
1343
+ auditState = {
1344
+ ...auditState,
1345
+ status: 'error',
1346
+ finishedAt: new Date().toISOString(),
1347
+ error: (error as Error).message,
1348
+ }
1349
+ writeJson(res, 500, { error: (error as Error).message })
1350
+ }
1351
+ },
1352
+ }
1353
+
1354
+ // ----------------------------------------------------- characters refresh
1355
+ /** 角色卡刷新(出场统计精确化 + LLM 聚合状态)。 */
1356
+ const charactersRefreshRoute: WebRoute = {
1357
+ kind: 'exact',
1358
+ path: NOVEL_API.charactersRefresh,
1359
+ handler: async (req, res) => {
1360
+ if (!guard(req, res, 'POST')) return
1361
+ const config = getConfig()
1362
+ const project = requireProject(res)
1363
+ if (project === undefined) return
1364
+ try {
1365
+ const cards = await refreshCharacters(ctx, config, project, config.outputDir)
1366
+ // 存档:人物志聚合结果落盘,下次打开直接显示,无需重新计算。
1367
+ project.roleStatus = cards
1368
+ project.updatedAt = new Date().toISOString()
1369
+ saveProject(config.outputDir, project)
1370
+ writeJson(res, 200, { cards })
1371
+ } catch (error) {
1372
+ writeJson(res, 500, { error: (error as Error).message })
1373
+ }
1374
+ },
1375
+ }
1376
+
1377
+ // ------------------------------------------------------- facts backfill
1378
+ /** 事实库回填:对历史已生成章节批量抽取事实。 */
1379
+ const factsBackfillRoute: WebRoute = {
1380
+ kind: 'exact',
1381
+ path: NOVEL_API.factsBackfill,
1382
+ handler: async (req, res) => {
1383
+ if (!guard(req, res, 'POST')) return
1384
+ const config = getConfig()
1385
+ const project = requireProject(res)
1386
+ if (project === undefined) return
1387
+ try {
1388
+ const filled = await backfillFacts(ctx, config, project, config.outputDir)
1389
+ writeJson(res, 200, { ok: true, filled })
1390
+ } catch (error) {
1391
+ writeJson(res, 500, { error: (error as Error).message })
1392
+ }
1393
+ },
1394
+ }
1395
+
1396
+ // ---------------------------------------------------------- bible patch
1397
+ /** 设定圣经局部修补(世界观规则/红线/风格)。 */
1398
+ const biblePatchRoute: WebRoute = {
1399
+ kind: 'exact',
1400
+ path: NOVEL_API.biblePatch,
1401
+ handler: async (req, res) => {
1402
+ if (!guard(req, res, 'POST')) return
1403
+ const config = getConfig()
1404
+ const project = requireProject(res)
1405
+ if (project === undefined) return
1406
+ if (project.bible === undefined) {
1407
+ writeJson(res, 400, { error: '尚未生成道藏,请先生成' })
1408
+ return
1409
+ }
1410
+ const body = await readJsonBody<BiblePatchRequest>(req)
1411
+ if (Array.isArray(body?.worldRules)) project.bible.worldRules = body.worldRules.filter(r => r.trim() !== '')
1412
+ if (Array.isArray(body?.redLines)) project.bible.redLines = body.redLines.filter(r => r.trim() !== '')
1413
+ if (Array.isArray(body?.style)) project.bible.style = body.style.filter(r => r.trim() !== '')
1414
+ if (Array.isArray(body?.characters)) {
1415
+ project.bible.characters = body.characters
1416
+ .filter(c => c !== undefined && c !== null && typeof c.name === 'string' && c.name.trim() !== '')
1417
+ .map(c => ({
1418
+ name: c.name.trim(),
1419
+ role: (['protagonist', 'supporting', 'antagonist', 'other'] as const).includes(c.role as never)
1420
+ ? c.role as StoryBible['characters'][number]['role']
1421
+ : 'other',
1422
+ traits: Array.isArray(c.traits) ? c.traits.filter((t): t is string => typeof t === 'string' && t.trim() !== '') : [],
1423
+ goals: typeof c.goals === 'string' ? c.goals : '',
1424
+ relations: typeof c.relations === 'string' ? c.relations : '',
1425
+ knowledge: Array.isArray(c.knowledge)
1426
+ ? c.knowledge.filter((k): k is string => typeof k === 'string' && k.trim() !== '')
1427
+ : undefined,
1428
+ }))
1429
+ }
1430
+ project.updatedAt = new Date().toISOString()
1431
+ saveProject(config.outputDir, project)
1432
+ writeJson(res, 200, { bible: project.bible })
1433
+ },
1434
+ }
1435
+
1436
+ // ---------------------------------------------------------------- blurb
1437
+ /** 小说简介:AI 生成/补全,或手动保存。 */
1438
+ const blurbRoute: WebRoute = {
1439
+ kind: 'exact',
1440
+ path: NOVEL_API.blurb,
1441
+ handler: async (req, res) => {
1442
+ if (!guard(req, res, 'POST')) return
1443
+ const config = getConfig()
1444
+ const project = requireProject(res)
1445
+ if (project === undefined) return
1446
+ const body = await readJsonBody<BlurbRequest>(req)
1447
+ try {
1448
+ if (body?.action === 'save') {
1449
+ const text = body.text?.trim() ?? ''
1450
+ project.blurb = text
1451
+ project.updatedAt = new Date().toISOString()
1452
+ saveProject(config.outputDir, project)
1453
+ writeJson(res, 200, { blurb: text })
1454
+ return
1455
+ }
1456
+ const partial = body?.partial?.trim() ?? ''
1457
+ const blurb = await generateBlurb(ctx, config, project, partial)
1458
+ project.blurb = blurb
1459
+ project.updatedAt = new Date().toISOString()
1460
+ saveProject(config.outputDir, project)
1461
+ writeJson(res, 200, { blurb })
1462
+ } catch (error) {
1463
+ writeJson(res, 500, { error: (error as Error).message })
1464
+ }
1465
+ },
1466
+ }
1467
+
1468
+ // ---------------------------------------------------------------- cover
1469
+ /** 封面:GET 读取(dataUrl);POST 上传(base64)或移除。 */
1470
+ const coverRoute: WebRoute = {
1471
+ kind: 'exact',
1472
+ path: NOVEL_API.cover,
1473
+ handler: async (req, res) => {
1474
+ const config = getConfig()
1475
+ if (req.method === 'GET') {
1476
+ if (!isLoopbackRequest(req)) {
1477
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
1478
+ return
1479
+ }
1480
+ // 书架页按书读取:?dir=<输出目录>;省略时用当前激活书目录。
1481
+ const url = new URL(req.url ?? '/', 'http://localhost')
1482
+ const dirParam = url.searchParams.get('dir')
1483
+ const targetDir = dirParam !== null && dirParam !== '' ? dirParam : config.outputDir
1484
+ const project = loadProject(targetDir)
1485
+ const coverPath = project?.coverPath
1486
+ if (coverPath === undefined || coverPath === '') {
1487
+ writeJson(res, 200, { dataUrl: null } satisfies CoverResponse)
1488
+ return
1489
+ }
1490
+ const file = join(targetDir, coverPath)
1491
+ if (!existsSync(file)) {
1492
+ writeJson(res, 200, { dataUrl: null } satisfies CoverResponse)
1493
+ return
1494
+ }
1495
+ const mime = coverPath.toLowerCase().endsWith('.png') ? 'image/png'
1496
+ : coverPath.toLowerCase().endsWith('.jpg') || coverPath.toLowerCase().endsWith('.jpeg') ? 'image/jpeg'
1497
+ : coverPath.toLowerCase().endsWith('.webp') ? 'image/webp'
1498
+ : 'image/png'
1499
+ const dataUrl = `data:${mime};base64,${readFileSync(file).toString('base64')}`
1500
+ writeJson(res, 200, { dataUrl } satisfies CoverResponse)
1501
+ return
1502
+ }
1503
+ if (req.method === 'POST') {
1504
+ if (!guard(req, res, 'POST')) return
1505
+ const project = requireProject(res)
1506
+ if (project === undefined) return
1507
+ const body = await readJsonBody<CoverRequest>(req)
1508
+ try {
1509
+ if (body?.action === 'remove') {
1510
+ if (project.coverPath !== undefined && project.coverPath !== '') {
1511
+ const oldFile = join(config.outputDir, project.coverPath)
1512
+ if (existsSync(oldFile)) rmSync(oldFile, { force: true })
1513
+ }
1514
+ project.coverPath = undefined
1515
+ project.updatedAt = new Date().toISOString()
1516
+ saveProject(config.outputDir, project)
1517
+ writeJson(res, 200, { ok: true, coverPath: null })
1518
+ return
1519
+ }
1520
+ const dataUrl = body?.dataUrl ?? ''
1521
+ const match = /^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/s.exec(dataUrl)
1522
+ if (match === null) {
1523
+ writeJson(res, 400, { error: '封面须为 PNG/JPEG/WebP 的 base64 data URL' })
1524
+ return
1525
+ }
1526
+ const mime = match[1]!
1527
+ const ext = mime === 'image/png' ? 'png' : mime === 'image/webp' ? 'webp' : 'jpg'
1528
+ const fileName = `cover.${ext}`
1529
+ const targetPath = join(config.outputDir, fileName)
1530
+ const oldPath = project.coverPath
1531
+ mkdirSync(config.outputDir, { recursive: true })
1532
+ writeFileSync(targetPath, Buffer.from(match[2]!, 'base64'))
1533
+ if (oldPath !== undefined && oldPath !== '' && oldPath !== fileName) {
1534
+ const oldFile = join(config.outputDir, oldPath)
1535
+ if (existsSync(oldFile)) rmSync(oldFile, { force: true })
1536
+ }
1537
+ project.coverPath = fileName
1538
+ project.updatedAt = new Date().toISOString()
1539
+ saveProject(config.outputDir, project)
1540
+ writeJson(res, 200, { ok: true, coverPath: fileName })
1541
+ } catch (error) {
1542
+ writeJson(res, 500, { error: (error as Error).message })
1543
+ }
1544
+ return
1545
+ }
1546
+ writeJson(res, 405, { error: 'method not allowed (expected GET or POST)' })
1547
+ },
1548
+ }
1549
+
1550
+ // ---------------------------------------------------------------- world
1551
+ /** 大世界:AI 提炼或手动保存(境界体系/区域/势力)。 */
1552
+ const worldRoute: WebRoute = {
1553
+ kind: 'exact',
1554
+ path: NOVEL_API.world,
1555
+ handler: async (req, res) => {
1556
+ if (!guard(req, res, 'POST')) return
1557
+ const config = getConfig()
1558
+ const project = requireProject(res)
1559
+ if (project === undefined) return
1560
+ const body = await readJsonBody<WorldRequest>(req)
1561
+ try {
1562
+ if (body?.action === 'save' && body.world !== undefined) {
1563
+ project.world = {
1564
+ realms: Array.isArray(body.world.realms) ? body.world.realms : [],
1565
+ regions: Array.isArray(body.world.regions) ? body.world.regions : [],
1566
+ factions: Array.isArray(body.world.factions) ? body.world.factions : [],
1567
+ }
1568
+ project.updatedAt = new Date().toISOString()
1569
+ saveProject(config.outputDir, project)
1570
+ writeJson(res, 200, { world: project.world })
1571
+ return
1572
+ }
1573
+ const world = await extractWorld(ctx, config, project)
1574
+ project.world = world
1575
+ project.updatedAt = new Date().toISOString()
1576
+ saveProject(config.outputDir, project)
1577
+ writeJson(res, 200, { world })
1578
+ } catch (error) {
1579
+ writeJson(res, 500, { error: (error as Error).message })
1580
+ }
1581
+ },
1582
+ }
1583
+
1584
+ // --------------------------------------------------------------- rename
1585
+ /** 重命名当前书:同步项目 bookName 与书架条目。 */
1586
+ const renameRoute: WebRoute = {
1587
+ kind: 'exact',
1588
+ path: NOVEL_API.rename,
1589
+ handler: async (req, res) => {
1590
+ if (!guard(req, res, 'POST')) return
1591
+ const config = getConfig()
1592
+ const project = requireProject(res)
1593
+ if (project === undefined) return
1594
+ const body = await readJsonBody<RenameRequest>(req)
1595
+ const bookName = body?.bookName?.trim()
1596
+ if (bookName === undefined || bookName === '') {
1597
+ writeJson(res, 400, { error: '书名不能为空' })
1598
+ return
1599
+ }
1600
+ project.bookName = bookName.slice(0, 60)
1601
+ project.updatedAt = new Date().toISOString()
1602
+ saveProject(config.outputDir, project)
1603
+ const store = loadBookshelf()
1604
+ if (store.activeBookId !== null) renameBook(store.activeBookId, project.bookName)
1605
+ writeJson(res, 200, { bookName: project.bookName })
1606
+ },
1607
+ }
1608
+
1609
+ // ------------------------------------------------------------- plotlines
1610
+ /** 剧情线管理:增删改 + 关联章节(主线/支线/人物线/悬念线)。 */
1611
+ const plotlinesRoute: WebRoute = {
1612
+ kind: 'exact',
1613
+ path: NOVEL_API.plotlines,
1614
+ handler: async (req, res) => {
1615
+ if (!guard(req, res, 'POST')) return
1616
+ const config = getConfig()
1617
+ const project = requireProject(res)
1618
+ if (project === undefined) return
1619
+ const body = await readJsonBody<PlotlinesRequest>(req)
1620
+ if (project.plotlines === undefined) project.plotlines = []
1621
+ const op = body?.op
1622
+ if (op === 'add' && body?.line !== undefined) {
1623
+ const line = body.line
1624
+ project.plotlines.push({
1625
+ id: line.id !== '' ? line.id : `pl-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
1626
+ name: line.name.slice(0, 40),
1627
+ kind: line.kind,
1628
+ goal: line.goal.slice(0, 300),
1629
+ progress: line.progress.slice(0, 300),
1630
+ status: line.status,
1631
+ chapters: Array.isArray(line.chapters) ? line.chapters.filter((n): n is number => typeof n === 'number') : [],
1632
+ createdAt: line.createdAt !== '' ? line.createdAt : new Date().toISOString(),
1633
+ })
1634
+ } else if (op === 'update' && body?.line !== undefined && body.line.id !== '') {
1635
+ const idx = project.plotlines.findIndex(l => l.id === body.line!.id)
1636
+ if (idx !== -1) {
1637
+ const line = body.line
1638
+ project.plotlines[idx] = {
1639
+ ...project.plotlines[idx]!,
1640
+ name: line.name.slice(0, 40),
1641
+ kind: line.kind,
1642
+ goal: line.goal.slice(0, 300),
1643
+ progress: line.progress.slice(0, 300),
1644
+ status: line.status,
1645
+ }
1646
+ }
1647
+ } else if (op === 'remove' && body?.id !== undefined) {
1648
+ project.plotlines = project.plotlines.filter(l => l.id !== body.id)
1649
+ } else if (op === 'link' && body?.id !== undefined && typeof body.chapterNo === 'number' && body.chapterNo > 0) {
1650
+ const line = project.plotlines.find(l => l.id === body.id)
1651
+ if (line !== undefined && !line.chapters.includes(body.chapterNo)) {
1652
+ line.chapters.push(body.chapterNo)
1653
+ if (line.status === 'active' && line.progress === '') {
1654
+ line.progress = `推进至第 ${body.chapterNo} 章`
1655
+ }
1656
+ }
1657
+ } else if (op === 'suggest') {
1658
+ try {
1659
+ const suggestions = await suggestPlotlines(ctx, config, project)
1660
+ writeJson(res, 200, { plotlines: project.plotlines, suggestions } satisfies PlotlinesResponse)
1661
+ return
1662
+ } catch (error) {
1663
+ writeJson(res, 500, { error: `AI 建议失败:${(error as Error).message}` })
1664
+ return
1665
+ }
1666
+ } else if (op === 'refresh' && body?.id !== undefined) {
1667
+ const line = project.plotlines.find(l => l.id === body.id)
1668
+ if (line === undefined) {
1669
+ writeJson(res, 404, { error: '剧情线不存在' })
1670
+ return
1671
+ }
1672
+ try {
1673
+ const progress = await refreshPlotlineProgress(ctx, config, project, line)
1674
+ line.progress = progress
1675
+ project.updatedAt = new Date().toISOString()
1676
+ saveProject(config.outputDir, project)
1677
+ writeJson(res, 200, { plotlines: project.plotlines } satisfies PlotlinesResponse)
1678
+ return
1679
+ } catch (error) {
1680
+ writeJson(res, 500, { error: `刷新进度失败:${(error as Error).message}` })
1681
+ return
1682
+ }
1683
+ } else if (op === 'health') {
1684
+ try {
1685
+ const health = await analyzePlotlineHealth(ctx, config, project)
1686
+ writeJson(res, 200, { plotlines: project.plotlines, health } satisfies PlotlinesResponse)
1687
+ return
1688
+ } catch (error) {
1689
+ writeJson(res, 500, { error: `健康检查失败:${(error as Error).message}` })
1690
+ return
1691
+ }
1692
+ } else if (op === 'plan') {
1693
+ try {
1694
+ const health = await analyzePlotlineHealth(ctx, config, project)
1695
+ const plan = await designPlotlinePlan(ctx, config, project, health)
1696
+ writeJson(res, 200, { plotlines: project.plotlines, health, plan } satisfies PlotlinesResponse)
1697
+ return
1698
+ } catch (error) {
1699
+ writeJson(res, 500, { error: `剧情方案生成失败:${(error as Error).message}` })
1700
+ return
1701
+ }
1702
+ }
1703
+ project.updatedAt = new Date().toISOString()
1704
+ saveProject(config.outputDir, project)
1705
+ const response: PlotlinesResponse = { plotlines: project.plotlines }
1706
+ writeJson(res, 200, response)
1707
+ },
1708
+ }
1709
+
1710
+ // --------------------------------------------------------- sensitive
1711
+ /** 敏感词检查:指定章节 / 任意文本 / 全书已写章节。 */
1712
+ const sensitiveRoute: WebRoute = {
1713
+ kind: 'exact',
1714
+ path: NOVEL_API.sensitiveCheck,
1715
+ handler: async (req, res) => {
1716
+ if (!guard(req, res, 'POST')) return
1717
+ const config = getConfig()
1718
+ const project = requireProject(res)
1719
+ if (project === undefined) return
1720
+ const body = await readJsonBody<SensitiveCheckRequest>(req)
1721
+ const hits: SensitiveHit[] = []
1722
+ let scanned = 0
1723
+ if (body?.text !== undefined) {
1724
+ for (const hit of checkSensitiveText(body.text)) {
1725
+ hits.push({ chapterNo: 0, word: hit.word, category: hit.category, count: hit.count })
1726
+ }
1727
+ } else if (typeof body?.chapterNo === 'number') {
1728
+ const chapter = project.chapters.find(c => c.no === body.chapterNo)
1729
+ if (chapter !== undefined) {
1730
+ const text = readChapterFile(config.outputDir, chapter)
1731
+ if (text !== undefined) {
1732
+ scanned = 1
1733
+ for (const hit of checkSensitiveText(text)) {
1734
+ hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
1735
+ }
1736
+ }
1737
+ }
1738
+ } else if (body?.all === true) {
1739
+ for (const chapter of project.chapters) {
1740
+ if (chapter.status === 'pending' || chapter.status === 'generating') continue
1741
+ const text = readChapterFile(config.outputDir, chapter)
1742
+ if (text === undefined) continue
1743
+ scanned++
1744
+ for (const hit of checkSensitiveText(text)) {
1745
+ hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
1746
+ }
1747
+ }
1748
+ } else {
1749
+ writeJson(res, 400, { error: '请提供 chapterNo / text / all 之一' })
1750
+ return
1751
+ }
1752
+ const response: SensitiveCheckResponse = { hits, scannedChapters: scanned }
1753
+ writeJson(res, 200, response)
1754
+ },
1755
+ }
1756
+
1757
+ // ---------------------------------------------------------------- roles
1758
+ /** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
1759
+ const rolesRoute: WebRoute = {
1760
+ kind: 'exact',
1761
+ path: NOVEL_API.roles,
1762
+ handler: async (req, res) => {
1763
+ if (!guard(req, res, 'POST')) return
1764
+ const config = getConfig()
1765
+ const project = requireProject(res)
1766
+ if (project === undefined) return
1767
+ const body = await readJsonBody<RolesRequest>(req)
1768
+ if (project.roles === undefined) project.roles = []
1769
+ const op = body?.op
1770
+ if (op === 'extract') {
1771
+ try {
1772
+ const candidates = await extractRoles(ctx, config, project)
1773
+ writeJson(res, 200, { roles: project.roles, candidates } satisfies RolesResponse)
1774
+ return
1775
+ } catch (error) {
1776
+ writeJson(res, 500, { error: `角色提炼失败:${(error as Error).message}` })
1777
+ return
1778
+ }
1779
+ } else if ((op === 'adopt' || op === 'update') && body?.role !== undefined) {
1780
+ const r = body.role
1781
+ const idx = project.roles.findIndex(x => x.name === r.name)
1782
+ if (idx === -1) project.roles.push(r)
1783
+ else project.roles[idx] = r
1784
+ } else if (op === 'remove' && body?.name !== undefined) {
1785
+ project.roles = project.roles.filter(x => x.name !== body.name)
1786
+ } else if (op === 'image') {
1787
+ const name = body?.name?.trim()
1788
+ const dataUrl = body?.dataUrl?.trim()
1789
+ if (name === undefined || name === '') {
1790
+ writeJson(res, 400, { error: 'name(角色名)必填' })
1791
+ return
1792
+ }
1793
+ if (dataUrl === undefined || dataUrl === '') {
1794
+ writeJson(res, 400, { error: 'dataUrl(参考图)必填' })
1795
+ return
1796
+ }
1797
+ const role = project.roles.find(r => r.name === name)
1798
+ if (role === undefined) {
1799
+ writeJson(res, 404, { error: `当前激活《${project.bookName}》,角色 ${name} 不存在——若期望的书不对,请先在书架切换该书并刷新` })
1800
+ return
1801
+ }
1802
+ const label = body?.label?.trim() ?? ''
1803
+ if (label !== '') {
1804
+ // 图集上传:带用途标签。标签「立绘」时同步为角色参考图(imageUrl)。
1805
+ role.gallery ??= []
1806
+ role.gallery = role.gallery.filter(g => g.label !== label)
1807
+ role.gallery.push({ label, dataUrl })
1808
+ if (label === '立绘' || label.includes('立绘')) role.imageUrl = dataUrl
1809
+ } else {
1810
+ // 兼容旧行为:不带标签 = 角色参考图。
1811
+ role.imageUrl = dataUrl
1812
+ }
1813
+ project.updatedAt = new Date().toISOString()
1814
+ saveProject(config.outputDir, project)
1815
+ writeJson(res, 200, { roles: project.roles, imageUrl: role.imageUrl } satisfies RolesResponse)
1816
+ return
1817
+ } else if (op === 'removeImage') {
1818
+ const name = body?.name?.trim()
1819
+ const label = body?.label?.trim() ?? ''
1820
+ const role = project.roles.find(r => r.name === name)
1821
+ if (role !== undefined) {
1822
+ if (label !== '') {
1823
+ role.gallery = (role.gallery ?? []).filter(g => g.label !== label)
1824
+ if (label === '立绘' || label.includes('立绘')) role.imageUrl = undefined
1825
+ } else {
1826
+ role.imageUrl = undefined
1827
+ }
1828
+ }
1829
+ project.updatedAt = new Date().toISOString()
1830
+ saveProject(config.outputDir, project)
1831
+ writeJson(res, 200, { roles: project.roles } satisfies RolesResponse)
1832
+ return
1833
+ } else if (op === 'imageGenerate') {
1834
+ const name = body?.name?.trim()
1835
+ if (name === undefined || name === '') {
1836
+ writeJson(res, 400, { error: 'name(角色名)必填' })
1837
+ return
1838
+ }
1839
+ try {
1840
+ const imageUrl = await generateRoleReferenceImage(ctx, config, project, config.outputDir, name, body?.style ?? '')
1841
+ project.updatedAt = new Date().toISOString()
1842
+ saveProject(config.outputDir, project)
1843
+ writeJson(res, 200, { roles: project.roles, imageUrl } satisfies RolesResponse)
1844
+ return
1845
+ } catch (error) {
1846
+ writeJson(res, 500, { error: `角色参考图生成失败:${(error as Error).message}` })
1847
+ return
1848
+ }
1849
+ } else if (op === 'visual') {
1850
+ // 动漫形象描述词提炼:扫描正文外貌描写 → LLM 提炼 → 写入角色卡。
1851
+ const name = body?.name?.trim()
1852
+ if (name === undefined || name === '') {
1853
+ writeJson(res, 400, { error: 'name(角色名)必填' })
1854
+ return
1855
+ }
1856
+ try {
1857
+ const visual = await extractRoleVisual(ctx, config, project, config.outputDir, name)
1858
+ const role = project.roles.find(r => r.name === name)
1859
+ if (role !== undefined) {
1860
+ role.imagePrompt = visual
1861
+ if ((visual.expressions ?? []).length > 0) role.expressions = visual.expressions
1862
+ if (visual.promptKit !== undefined) role.promptKit = visual.promptKit
1863
+ }
1864
+ project.updatedAt = new Date().toISOString()
1865
+ saveProject(config.outputDir, project)
1866
+ writeJson(res, 200, { roles: project.roles, visual } satisfies RolesResponse)
1867
+ return
1868
+ } catch (error) {
1869
+ writeJson(res, 500, { error: `形象提炼失败:${(error as Error).message}` })
1870
+ return
1871
+ }
1872
+ } else if (op === 'promptKit') {
1873
+ // 角色四类提示词精修包(立绘/四视图/表情/细节)
1874
+ const name = body?.name?.trim()
1875
+ if (name === undefined || name === '') {
1876
+ writeJson(res, 400, { error: 'name(角色名)必填' })
1877
+ return
1878
+ }
1879
+ try {
1880
+ const kit = await generateRolePromptKit(ctx, config, project, name)
1881
+ const role = project.roles.find(r => r.name === name)
1882
+ if (role !== undefined) role.promptKit = kit
1883
+ project.updatedAt = new Date().toISOString()
1884
+ saveProject(config.outputDir, project)
1885
+ writeJson(res, 200, { roles: project.roles, promptKit: kit } satisfies RolesResponse)
1886
+ return
1887
+ } catch (error) {
1888
+ writeJson(res, 500, { error: `提示词精修失败:${(error as Error).message}` })
1889
+ return
1890
+ }
1891
+ } else {
1892
+ writeJson(res, 400, { error: '未知的 roles op' })
1893
+ return
1894
+ }
1895
+ project.updatedAt = new Date().toISOString()
1896
+ saveProject(config.outputDir, project)
1897
+ const response: RolesResponse = { roles: project.roles }
1898
+ writeJson(res, 200, response)
1899
+ },
1900
+ }
1901
+
1902
+ // ------------------------------------------------------- chapter reset
1903
+ /** 章节复位:generating 卡死 → pending(可重新生成)。 */
1904
+ const chapterResetRoute: WebRoute = {
1905
+ kind: 'exact',
1906
+ path: NOVEL_API.chapterReset,
1907
+ handler: async (req, res) => {
1908
+ if (!guard(req, res, 'POST')) return
1909
+ const config = getConfig()
1910
+ const project = requireProject(res)
1911
+ if (project === undefined) return
1912
+ const body = await readJsonBody<{ chapterNo?: number }>(req)
1913
+ const no = body?.chapterNo
1914
+ if (!Number.isInteger(no) || no === undefined || no < 1) {
1915
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
1916
+ return
1917
+ }
1918
+ const chapter = project.chapters.find(c => c.no === no)
1919
+ if (chapter === undefined) {
1920
+ writeJson(res, 404, { error: `章节 ${no} 不在计划中` })
1921
+ return
1922
+ }
1923
+ chapter.status = 'pending'
1924
+ chapter.error = undefined
1925
+ chapter.generatingAt = undefined
1926
+ project.updatedAt = new Date().toISOString()
1927
+ saveProject(config.outputDir, project)
1928
+ writeJson(res, 200, { ok: true, no })
1929
+ },
1930
+ }
1931
+
1932
+ // ------------------------------------------------------ chapter approve
1933
+ /** 章节直接通过:作者行使最终决定权(不重审,保留审稿记录)。 */
1934
+ const chapterApproveRoute: WebRoute = {
1935
+ kind: 'exact',
1936
+ path: NOVEL_API.chapterApprove,
1937
+ handler: async (req, res) => {
1938
+ if (!guard(req, res, 'POST')) return
1939
+ const config = getConfig()
1940
+ const project = requireProject(res)
1941
+ if (project === undefined) return
1942
+ const body = await readJsonBody<{ chapterNo?: number }>(req)
1943
+ const no = body?.chapterNo
1944
+ if (!Number.isInteger(no) || no === undefined || no < 1) {
1945
+ writeJson(res, 400, { error: 'chapterNo 须为正整数' })
1946
+ return
1947
+ }
1948
+ const chapter = project.chapters.find(c => c.no === no)
1949
+ if (chapter === undefined) {
1950
+ writeJson(res, 404, { error: `章节 ${no} 不在计划中` })
1951
+ return
1952
+ }
1953
+ chapter.status = 'approved'
1954
+ project.updatedAt = new Date().toISOString()
1955
+ saveProject(config.outputDir, project)
1956
+ writeJson(res, 200, { ok: true, no })
1957
+ },
1958
+ }
1959
+
1960
+ // ------------------------------------------------------ author review backfill
1961
+ /** 作者复盘补跑:对已写章节补齐 authorReview(body.chapterNo=单章 JSON,缺省=全书 NDJSON 流)。 */
1962
+ const reviewBackfillRoute: WebRoute = {
1963
+ kind: 'exact',
1964
+ path: NOVEL_API.reviewBackfill,
1965
+ handler: async (req, res) => {
1966
+ if (!guard(req, res, 'POST')) return
1967
+ const config = getConfig()
1968
+ const project = requireProject(res)
1969
+ if (project === undefined) return
1970
+ const body = await readJsonBody<{ chapterNo?: number }>(req)
1971
+
1972
+ /** 对一章执行作者复盘(读取已落盘正文,不改变章节状态/正文)。 */
1973
+ const runOne = async (chapter: ChapterPlan): Promise<AuthorReview> => {
1974
+ const currentBody = readChapterFile(config.outputDir, chapter)
1975
+ if (currentBody === undefined) throw new Error(`章节 ${chapter.no} 的正文文件不存在`)
1976
+ let prevTail = ''
1977
+ if (chapter.no > 1) {
1978
+ const prev = project.chapters.find(c => c.no === chapter.no - 1)
1979
+ if (prev !== undefined) {
1980
+ prevTail = (readChapterFile(config.outputDir, prev) ?? '').replace(/^#.*$/m, '').trim().slice(-600)
1981
+ }
1982
+ }
1983
+ return authorReviewChapter(ctx, config, project, chapter.no, currentBody, prevTail)
1984
+ }
1985
+
1986
+ // 单章:JSON 响应。
1987
+ if (typeof body?.chapterNo === 'number' && body.chapterNo > 0) {
1988
+ const chapter = project.chapters.find(c => c.no === body.chapterNo)
1989
+ if (chapter === undefined) {
1990
+ writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` })
1991
+ return
1992
+ }
1993
+ if (chapter.status === 'pending') {
1994
+ writeJson(res, 400, { error: '该章尚未生成正文,无法复盘' })
1995
+ return
1996
+ }
1997
+ try {
1998
+ const review = await runOne(chapter)
1999
+ chapter.authorReview = review
2000
+ if (review.advancedLines !== undefined) {
2001
+ autoLinkPlotlines(project, chapter.no, review.advancedLines)
2002
+ }
2003
+ project.updatedAt = new Date().toISOString()
2004
+ saveProject(config.outputDir, project)
2005
+ writeJson(res, 200, { no: chapter.no, review } satisfies { no: number; review: AuthorReview })
2006
+ return
2007
+ } catch (error) {
2008
+ writeJson(res, 500, { error: (error as Error).message })
2009
+ return
2010
+ }
2011
+ }
2012
+
2013
+ // 全书:NDJSON 流式补跑缺失复盘的已写章节。
2014
+ const missing = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error' && c.authorReview === undefined)
2015
+ if (missing.length === 0) {
2016
+ writeJson(res, 200, { count: 0 })
2017
+ return
2018
+ }
2019
+ res.writeHead(200, {
2020
+ 'content-type': 'application/x-ndjson; charset=utf-8',
2021
+ 'cache-control': 'no-cache',
2022
+ 'referrer-policy': 'no-referrer',
2023
+ })
2024
+ const send = (frame: JobFrame): void => {
2025
+ res.write(JSON.stringify(frame) + '\n')
2026
+ }
2027
+ let done = 0
2028
+ for (const chapter of missing) {
2029
+ try {
2030
+ const review = await runOne(chapter)
2031
+ chapter.authorReview = review
2032
+ if (review.advancedLines !== undefined) {
2033
+ autoLinkPlotlines(project, chapter.no, review.advancedLines)
2034
+ }
2035
+ done++
2036
+ saveProject(config.outputDir, project)
2037
+ send({ type: 'author-review', no: chapter.no, review })
2038
+ } catch (error) {
2039
+ console.warn(`[dsh-novel-forge] author backfill ch.${chapter.no} failed:`, (error as Error).message)
2040
+ }
2041
+ }
2042
+ project.updatedAt = new Date().toISOString()
2043
+ saveProject(config.outputDir, project)
2044
+ send({ type: 'author-backfill-done', count: done })
2045
+ res.end()
2046
+ },
2047
+ }
2048
+
2049
+ // --------------------------------------------------------------- config
2050
+ const configRoute: WebRoute = {
2051
+ kind: 'exact',
2052
+ path: NOVEL_API.config,
2053
+ handler: async (req, res) => {
2054
+ if (!guard(req, res, 'POST')) return
2055
+ const body = await readJsonBody<ConfigPatch>(req)
2056
+ if (body === undefined) {
2057
+ writeJson(res, 400, { error: '无效的配置 JSON' })
2058
+ return
2059
+ }
2060
+ try {
2061
+ const next = await patchConfig(body)
2062
+ writeJson(res, 200, { config: next })
2063
+ } catch (error) {
2064
+ writeJson(res, 400, { error: (error as Error).message })
2065
+ }
2066
+ },
2067
+ }
2068
+
2069
+ // ---------------------------------------------------------- open-folder
2070
+ const openFolderRoute: WebRoute = {
2071
+ kind: 'exact',
2072
+ path: NOVEL_API.openFolder,
2073
+ handler: async (req, res) => {
2074
+ if (!guard(req, res, 'POST')) return
2075
+ const config = getConfig()
2076
+ const dir = config.outputDir
2077
+ exec(`explorer "${dir.replace(/"/g, '')}"`, (error) => {
2078
+ if (error) {
2079
+ writeJson(res, 500, { ok: false, error: error.message })
2080
+ } else {
2081
+ writeJson(res, 200, { ok: true })
2082
+ }
2083
+ })
2084
+ },
2085
+ }
2086
+
2087
+ // ------------------------------------------------------- outline suggest
2088
+ /** 开书想法 → AI 大纲:2-3 个可选方案(支持暂留换批:count 只补空槽 + exclude 避开已留方向)。 */
2089
+ const outlineSuggestRoute: WebRoute = {
2090
+ kind: 'exact',
2091
+ path: NOVEL_API.outlineSuggest,
2092
+ handler: async (req, res) => {
2093
+ if (!guard(req, res, 'POST')) return
2094
+ const config = getConfig()
2095
+ const body = await readJsonBody<OutlineSuggestRequest>(req)
2096
+ const idea = body?.idea?.trim() ?? ''
2097
+ if (idea.length < 50) {
2098
+ writeJson(res, 400, { error: '想法太短(<50 字),请多写一两句:主角是谁、什么世界、想要什么爽点' })
2099
+ return
2100
+ }
2101
+ const count = body?.count !== undefined ? Math.max(1, Math.min(3, Math.floor(body.count))) : 3
2102
+ 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)) : []
2103
+ try {
2104
+ const candidates = await suggestOutlines(ctx, config, idea, count, exclude)
2105
+ const response: OutlineSuggestResponse = { candidates }
2106
+ writeJson(res, 200, response)
2107
+ } catch (error) {
2108
+ writeJson(res, 500, { error: `大纲方案生成失败:${(error as Error).message}` })
2109
+ }
2110
+ },
2111
+ }
2112
+
2113
+ // ------------------------------------------------- outline reverse
2114
+ /** 反推大纲:从已写章节正文反向生成全书总纲(NDJSON 流式进度)。 */
2115
+ const outlineReverseRoute: WebRoute = {
2116
+ kind: 'exact',
2117
+ path: NOVEL_API.outlineReverse,
2118
+ handler: async (req, res) => {
2119
+ if (!guard(req, res, 'POST')) return
2120
+ const config = getConfig()
2121
+ const project = requireProject(res)
2122
+ if (project === undefined) return
2123
+ res.writeHead(200, {
2124
+ 'content-type': 'application/x-ndjson; charset=utf-8',
2125
+ 'cache-control': 'no-cache',
2126
+ 'x-accel-buffering': 'no',
2127
+ 'referrer-policy': 'no-referrer',
2128
+ })
2129
+ const send = (frame: JobFrame): void => {
2130
+ res.write(JSON.stringify(frame) + '\n')
2131
+ }
2132
+ try {
2133
+ const outline = await reverseOutlineFromChapters(ctx, config, project, config.outputDir, (done, total, phase) => {
2134
+ send({ type: 'outline-progress', done, total, phase })
2135
+ })
2136
+ // 保存总纲(仅更新文本,不动书名/进度)。
2137
+ project.outline = outline
2138
+ project.updatedAt = new Date().toISOString()
2139
+ saveProject(config.outputDir, project)
2140
+ send({ type: 'outline-done', outline, chars: outline.length })
2141
+ } catch (error) {
2142
+ send({ type: 'error', no: 0, message: (error as Error).message })
2143
+ } finally {
2144
+ res.end()
2145
+ }
2146
+ },
2147
+ }
2148
+
2149
+ // ----------------------------------------------------------- breakdown
2150
+ /** 拆书分析:对已写章节做结构/人物/文风/卖点体检(两阶段:源笔记→分节分析)。 */
2151
+ const breakdownRoute: WebRoute = {
2152
+ kind: 'exact',
2153
+ path: NOVEL_API.breakdown,
2154
+ handler: async (req, res) => {
2155
+ if (!guard(req, res, 'POST')) return
2156
+ const config = getConfig()
2157
+ const project = requireProject(res)
2158
+ if (project === undefined) return
2159
+ const body = await readJsonBody<BreakdownRequest>(req)
2160
+ try {
2161
+ const result = await breakdownBook(
2162
+ ctx,
2163
+ config,
2164
+ project,
2165
+ config.outputDir,
2166
+ body?.scope ?? 'recent',
2167
+ body?.preset ?? 'quick',
2168
+ body?.budgetTokens ?? 50000,
2169
+ )
2170
+ writeJson(res, 200, result satisfies BreakdownResponse)
2171
+ } catch (error) {
2172
+ writeJson(res, 500, { error: `拆书分析失败:${(error as Error).message}` })
2173
+ }
2174
+ },
2175
+ }
2176
+
2177
+ // --------------------------------------------------------------- scenes
2178
+ /** 场景库:AI 提炼 / 采纳 / 更新 / 删除 / 图集。 */
2179
+ const scenesRoute: WebRoute = {
2180
+ kind: 'exact',
2181
+ path: NOVEL_API.scenes,
2182
+ handler: async (req, res) => {
2183
+ if (!guard(req, res, 'POST')) return
2184
+ const config = getConfig()
2185
+ const project = requireProject(res)
2186
+ if (project === undefined) return
2187
+ const body = await readJsonBody<ScenesRequest>(req)
2188
+ if (project.scenes === undefined) project.scenes = []
2189
+ const op = body?.op
2190
+ if (op === 'extract') {
2191
+ try {
2192
+ const candidates = await extractScenes(ctx, config, project)
2193
+ writeJson(res, 200, { scenes: project.scenes, candidates } satisfies ScenesResponse)
2194
+ return
2195
+ } catch (error) {
2196
+ writeJson(res, 500, { error: `场景提炼失败:${(error as Error).message}` })
2197
+ return
2198
+ }
2199
+ } else if ((op === 'adopt' || op === 'update') && body?.scene !== undefined) {
2200
+ const s = body.scene
2201
+ const idx = project.scenes.findIndex(x => x.name === s.name)
2202
+ if (idx === -1) project.scenes.push(s)
2203
+ else project.scenes[idx] = s
2204
+ } else if (op === 'remove' && body?.name !== undefined) {
2205
+ project.scenes = project.scenes.filter(x => x.name !== body.name)
2206
+ } else if (op === 'image') {
2207
+ const name = body?.name?.trim()
2208
+ const dataUrl = body?.dataUrl?.trim()
2209
+ if (name === undefined || name === '' || dataUrl === undefined || dataUrl === '') {
2210
+ writeJson(res, 400, { error: 'name(场景名)与 dataUrl 必填' })
2211
+ return
2212
+ }
2213
+ const scene = project.scenes.find(x => x.name === name)
2214
+ if (scene === undefined) {
2215
+ writeJson(res, 404, { error: `当前激活《${project.bookName}》,场景 ${name} 不存在——若期望的书不对,请先在书架切换该书并刷新` })
2216
+ return
2217
+ }
2218
+ const label = body?.label?.trim() ?? '全景'
2219
+ scene.gallery ??= []
2220
+ scene.gallery = scene.gallery.filter(g => g.label !== label)
2221
+ scene.gallery.push({ label, dataUrl })
2222
+ } else if (op === 'removeImage') {
2223
+ const name = body?.name?.trim()
2224
+ const label = body?.label?.trim() ?? ''
2225
+ const scene = project.scenes.find(x => x.name === name)
2226
+ if (scene !== undefined && label !== '') {
2227
+ scene.gallery = (scene.gallery ?? []).filter(g => g.label !== label)
2228
+ }
2229
+ } else {
2230
+ writeJson(res, 400, { error: `未知操作 ${op}` })
2231
+ return
2232
+ }
2233
+ project.updatedAt = new Date().toISOString()
2234
+ saveProject(config.outputDir, project)
2235
+ writeJson(res, 200, { scenes: project.scenes } satisfies ScenesResponse)
2236
+ },
2237
+ }
2238
+
2239
+ // ----------------------------------------------------------- visual rules
2240
+ /** 视觉世界观规则:从道藏提炼(生图/生视频纠偏),或手动保存。 */
2241
+ const visualRulesRoute: WebRoute = {
2242
+ kind: 'exact',
2243
+ path: NOVEL_API.visualRules,
2244
+ handler: async (req, res) => {
2245
+ if (!guard(req, res, 'POST')) return
2246
+ const config = getConfig()
2247
+ const project = requireProject(res)
2248
+ if (project === undefined) return
2249
+ const body = await readJsonBody<VisualRulesRequest>(req)
2250
+ if (body?.op === 'extract') {
2251
+ try {
2252
+ const rules = await extractVisualRules(ctx, config, project)
2253
+ project.visualRules = rules
2254
+ project.updatedAt = new Date().toISOString()
2255
+ saveProject(config.outputDir, project)
2256
+ writeJson(res, 200, { rules } satisfies VisualRulesResponse)
2257
+ return
2258
+ } catch (error) {
2259
+ writeJson(res, 500, { error: `视觉规则提炼失败:${(error as Error).message}` })
2260
+ return
2261
+ }
2262
+ } else if (body?.op === 'save' && Array.isArray(body.rules)) {
2263
+ project.visualRules = body.rules.filter(r => typeof r === 'string' && r.trim() !== '').map(r => r.trim().slice(0, 80)).slice(0, 12)
2264
+ project.updatedAt = new Date().toISOString()
2265
+ saveProject(config.outputDir, project)
2266
+ writeJson(res, 200, { rules: project.visualRules } satisfies VisualRulesResponse)
2267
+ return
2268
+ }
2269
+ writeJson(res, 400, { error: 'op 须为 extract 或 save' })
2270
+ },
2271
+ }
2272
+
2273
+ // ----------------------------------------------------------- production run
2274
+ /** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
2275
+ const runner = new ProductionRunner({ ctx, getConfig })
2276
+
2277
+ const runStartRoute: WebRoute = {
2278
+ kind: 'exact',
2279
+ path: NOVEL_API.runStart,
2280
+ handler: async (req, res) => {
2281
+ if (!guard(req, res, 'POST')) return
2282
+ const body = await readJsonBody<RunStartRequest>(req)
2283
+ const config = getConfig()
2284
+ const project = loadProject(config.outputDir)
2285
+ if (project === undefined) {
2286
+ writeJson(res, 400, { error: '输出目录中没有项目' })
2287
+ return
2288
+ }
2289
+ const startNo = Number.isInteger(body?.startNo) && (body!.startNo! >= 1) ? body!.startNo! : 1
2290
+ let endNo: number
2291
+ if (Number.isInteger(body?.endNo) && (body!.endNo! >= startNo)) {
2292
+ endNo = body!.endNo!
2293
+ } else if (Number.isInteger(body?.count) && (body!.count! >= 1) && (body!.count! <= 200)) {
2294
+ const last = Math.max(0, ...project.chapters.map(c => c.no))
2295
+ endNo = last + body!.count!
2296
+ } else {
2297
+ writeJson(res, 400, { error: '请提供 endNo 或 count(1-200)' })
2298
+ return
2299
+ }
2300
+ try {
2301
+ const state = await runner.start(startNo, endNo)
2302
+ writeJson(res, 200, state satisfies RunState)
2303
+ } catch (error) {
2304
+ writeJson(res, 409, { error: (error as Error).message })
2305
+ }
2306
+ },
2307
+ }
2308
+
2309
+ const runControlRoute: WebRoute = {
2310
+ kind: 'exact',
2311
+ path: NOVEL_API.runControl,
2312
+ handler: async (req, res) => {
2313
+ if (!guard(req, res, 'POST')) return
2314
+ const body = await readJsonBody<RunControlRequest>(req)
2315
+ const state = runner.status()
2316
+ if (state === null) {
2317
+ writeJson(res, 400, { error: '没有生产单' })
2318
+ return
2319
+ }
2320
+ if (body?.action === 'pause') runner.pause()
2321
+ else if (body?.action === 'resume') runner.resume()
2322
+ else if (body?.action === 'stop') runner.stop()
2323
+ else {
2324
+ writeJson(res, 400, { error: 'action 须为 pause / resume / stop' })
2325
+ return
2326
+ }
2327
+ writeJson(res, 200, runner.status())
2328
+ },
2329
+ }
2330
+
2331
+ const runStatusRoute: WebRoute = {
2332
+ kind: 'exact',
2333
+ path: NOVEL_API.runStatus,
2334
+ handler: (req, res) => {
2335
+ if (!guard(req, res, 'GET')) return
2336
+ writeJson(res, 200, runner.status())
2337
+ },
2338
+ }
2339
+
2340
+ return [
2341
+ statusRoute,
2342
+ loadOutlineRoute,
2343
+ saveOutlineRoute,
2344
+ bibleRoute,
2345
+ volumesRoute,
2346
+ planRoute,
2347
+ generateRoute,
2348
+ reviewRoute,
2349
+ rewriteRoute,
2350
+ polishRoute,
2351
+ draftApplyRoute,
2352
+ draftDiscardRoute,
2353
+ summaryRoute,
2354
+ foreshadowRoute,
2355
+ exportRoute,
2356
+ chapterRoute,
2357
+ chapterCheckRoute,
2358
+ chapterSaveRoute,
2359
+ assetsRoute,
2360
+ styleEngineRoute,
2361
+ assistantRoute,
2362
+ assistantHistoryRoute,
2363
+ assistantClearRoute,
2364
+ bookshelfRoute,
2365
+ bookshelfActivateRoute,
2366
+ bookshelfRemoveRoute,
2367
+ bookshelfImportDirRoute,
2368
+ bookshelfImportTextRoute,
2369
+ bookshelfImportTextPreviewRoute,
2370
+ resetRoute,
2371
+ auditRoute,
2372
+ charactersRefreshRoute,
2373
+ factsBackfillRoute,
2374
+ biblePatchRoute,
2375
+ blurbRoute,
2376
+ coverRoute,
2377
+ worldRoute,
2378
+ renameRoute,
2379
+ plotlinesRoute,
2380
+ rolesRoute,
2381
+ scenesRoute,
2382
+ visualRulesRoute,
2383
+ sensitiveRoute,
2384
+ reviewBackfillRoute,
2385
+ chapterResetRoute,
2386
+ chapterApproveRoute,
2387
+ configRoute,
2388
+ openFolderRoute,
2389
+ outlineSuggestRoute,
2390
+ outlineReverseRoute,
2391
+ breakdownRoute,
2392
+ runStartRoute,
2393
+ runControlRoute,
2394
+ runStatusRoute,
2395
+ ]
2396
+ }
2397
+
2398
+ // Re-export for tests / type consumers.
2399
+ export type {
2400
+ ConfigPatch,
2401
+ NovelConfig,
2402
+ StatusResponse,
2403
+ PlanResponse,
2404
+ LoadOutlineResponse,
2405
+ BibleResponse,
2406
+ VolumesResponse,
2407
+ ExportResponse,
2408
+ }
2409
+ export { chapterFileName }