@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/README.md +2 -2
- package/lib/client.js +548 -267
- package/lib/client.js.map +1 -1
- package/lib/index.js +183 -9
- package/lib/index.js.map +1 -1
- package/lib/types/bookshelf.d.ts +6 -1
- package/lib/types/client/api.d.ts +4 -0
- package/lib/types/client/panel/ImportModal.d.ts +7 -0
- package/lib/types/client/panel/ShelfView.d.ts +3 -1
- package/lib/types/engine.d.ts +6 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/protocol.d.ts +32 -0
- package/package.json +97 -97
- package/src/bookshelf.ts +18 -2
- package/src/client/api.ts +420 -410
- package/src/client/panel/ImportModal.tsx +191 -0
- package/src/client/panel/NovelPanel.tsx +5058 -5046
- package/src/client/panel/ShelfView.tsx +249 -236
- package/src/client/panel/panel.module.css +3037 -2941
- package/src/engine.ts +3001 -2943
- package/src/index.ts +7 -5
- package/src/protocol.ts +1244 -1207
- package/src/routes.ts +2332 -2272
- package/scripts/assistant-frames.txt +0 -8
- package/scripts/diagnose-adapter.mts +0 -81
- package/scripts/diagnose-assistant.mjs +0 -58
- package/scripts/diagnose-bible.mjs +0 -72
- package/scripts/diagnose-harness.mts +0 -75
- package/scripts/novel-forge-restart.log +0 -10
- package/scripts/novel-forge-web.stderr.log +0 -2
- package/scripts/novel-forge-web.stdout.log +0 -1
- package/scripts/outline-sample.txt +0 -449
- package/scripts/probe.mjs +0 -6
- package/scripts/restart-web.ps1 +0 -88
- package/scripts/smoke.mts +0 -72
- package/scripts/upload-github.ps1 +0 -51
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
|
|
39
|
-
type
|
|
40
|
-
type
|
|
41
|
-
type
|
|
42
|
-
type
|
|
43
|
-
type
|
|
44
|
-
type
|
|
45
|
-
type
|
|
46
|
-
type
|
|
47
|
-
type
|
|
48
|
-
type
|
|
49
|
-
type
|
|
50
|
-
type
|
|
51
|
-
type
|
|
52
|
-
type
|
|
53
|
-
type
|
|
54
|
-
type
|
|
55
|
-
type
|
|
56
|
-
type
|
|
57
|
-
type
|
|
58
|
-
type
|
|
59
|
-
type
|
|
60
|
-
type
|
|
61
|
-
type
|
|
62
|
-
type
|
|
63
|
-
type
|
|
64
|
-
type
|
|
65
|
-
type
|
|
66
|
-
type
|
|
67
|
-
type
|
|
68
|
-
type
|
|
69
|
-
type
|
|
70
|
-
type
|
|
71
|
-
type
|
|
72
|
-
type
|
|
73
|
-
type
|
|
74
|
-
type
|
|
75
|
-
type
|
|
76
|
-
type
|
|
77
|
-
type
|
|
78
|
-
type
|
|
79
|
-
type
|
|
80
|
-
type
|
|
81
|
-
type
|
|
82
|
-
type
|
|
83
|
-
type
|
|
84
|
-
type
|
|
85
|
-
type
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
import {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
if (
|
|
418
|
-
writeJson(res, 400, { error: '
|
|
419
|
-
return
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
next
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
if (!
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
if (!
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
if (!
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
send({ type: '
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
if (!
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
const
|
|
670
|
-
if (
|
|
671
|
-
writeJson(res,
|
|
672
|
-
return
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
if (!
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
const
|
|
721
|
-
if (
|
|
722
|
-
writeJson(res,
|
|
723
|
-
return
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
if (!
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
writeJson(res,
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
if (
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
if (
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
description
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
const
|
|
851
|
-
if (
|
|
852
|
-
writeJson(res,
|
|
853
|
-
return
|
|
854
|
-
}
|
|
855
|
-
const
|
|
856
|
-
if (
|
|
857
|
-
writeJson(res, 404, { error: `章节 ${rawNo}
|
|
858
|
-
return
|
|
859
|
-
}
|
|
860
|
-
const
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
if (!
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
const
|
|
906
|
-
if (
|
|
907
|
-
writeJson(res,
|
|
908
|
-
return
|
|
909
|
-
}
|
|
910
|
-
const
|
|
911
|
-
if (
|
|
912
|
-
writeJson(res,
|
|
913
|
-
return
|
|
914
|
-
}
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
if (
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
projectAssets.
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
projectAssets
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
if (
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
const
|
|
1123
|
-
|
|
1124
|
-
:
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
const
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
project.
|
|
1165
|
-
project.
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
const
|
|
1183
|
-
if (
|
|
1184
|
-
writeJson(res,
|
|
1185
|
-
return
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
const
|
|
1203
|
-
if (
|
|
1204
|
-
writeJson(res,
|
|
1205
|
-
return
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
writeJson(res,
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
writeJson(res,
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
const
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
if (
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
if (
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
if (
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
const
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
}
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
}
|
|
1595
|
-
} else if (op === '
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
const
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
const
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
} else {
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
writeJson(res,
|
|
1854
|
-
return
|
|
1855
|
-
}
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
writeJson(res, 200,
|
|
1860
|
-
},
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
//
|
|
1864
|
-
/**
|
|
1865
|
-
const
|
|
1866
|
-
kind: 'exact',
|
|
1867
|
-
path: NOVEL_API.
|
|
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
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
}
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
}
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
const
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
}
|
|
2097
|
-
writeJson(res,
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
const
|
|
2115
|
-
if (
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
} else if (
|
|
2130
|
-
project.
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
if (body?.
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
writeJson(res,
|
|
2192
|
-
return
|
|
2193
|
-
}
|
|
2194
|
-
writeJson(res,
|
|
2195
|
-
},
|
|
2196
|
-
}
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
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 }
|