@waterwx/dsh-novel-forge 0.1.0

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