@waterwx/dsh-novel-forge 1.3.2 → 1.4.0

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