@waterwx/dsh-novel-forge 1.1.2 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/protocol.ts CHANGED
@@ -54,6 +54,12 @@ export const NOVEL_API = {
54
54
  world: '/api/dsh-novel-forge/world',
55
55
  /** 封面:GET 读取(dataUrl)/ POST 上传或移除。 */
56
56
  cover: '/api/dsh-novel-forge/blurb/cover',
57
+ /** 剧情线管理:增删改 + 关联章节。 */
58
+ plotlines: '/api/dsh-novel-forge/plotlines',
59
+ /** 作者复盘补跑:对已写章节补齐 authorReview(全书流式 / 单章 JSON)。 */
60
+ reviewBackfill: '/api/dsh-novel-forge/review/backfill',
61
+ /** 敏感词检查:全书已写章节或指定文本。 */
62
+ sensitiveCheck: '/api/dsh-novel-forge/sensitive-check',
57
63
  config: '/api/dsh-novel-forge/config',
58
64
  openFolder: '/api/dsh-novel-forge/open-folder',
59
65
  } as const
@@ -137,6 +143,8 @@ export interface ChapterPlan {
137
143
  summary?: string
138
144
  /** Latest review report (present once reviewed). */
139
145
  review?: ReviewReport
146
+ /** 作者复盘:钩子兑现/结尾钩子/剧情线推进/连续性/节奏趋势(生成后自动)。 */
147
+ authorReview?: AuthorReview
140
148
  /**
141
149
  * 待确认草稿:润色(去AI味)或整章重写的产物正文。生成时先存这里,
142
150
  * 用户看过对比后点「采纳」才覆盖正文文件;点「放弃」则丢弃。刷新页面不丢失。
@@ -144,6 +152,26 @@ export interface ChapterPlan {
144
152
  pendingDraft?: string
145
153
  }
146
154
 
155
+ /** 作者复盘:叙事结构层面的逐章检查(钩子/推进/连续性/趋势)。 */
156
+ export interface AuthorReview {
157
+ /** 上一章结尾钩子是否在本章兑现。 */
158
+ hookHonored: boolean
159
+ /** 钩子兑现说明(未兑现时给出建议)。 */
160
+ hookNote: string
161
+ /** 本章结尾钩子强度 0-10。 */
162
+ endingHook: number
163
+ /** 剧情线推进情况(推进了哪条线/或未推进)。 */
164
+ plotlineProgress: string
165
+ /** 结构化:本章推进的剧情线名称列表(与项目剧情线 name 精确匹配,复盘后自动关联章节)。 */
166
+ advancedLines?: string[]
167
+ /** 连续性检查(人物位置/时间/伤势/资源是否与上章衔接)。 */
168
+ continuity: string
169
+ /** 近期节奏趋势提示(拖沓/爽点密度等)。 */
170
+ trend: string
171
+ /** 复盘时间。 */
172
+ reviewedAt: string
173
+ }
174
+
147
175
  /** One review finding. */
148
176
  export interface ReviewIssue {
149
177
  /** Severity: high = must fix, medium = should fix, low = suggestion. */
@@ -192,6 +220,8 @@ export interface CharacterCard {
192
220
  goals: string
193
221
  /** Key relations to other characters. */
194
222
  relations: string
223
+ /** 知情度:该角色已经知道的事实/秘密(未列出的信息该角色不知道)。 */
224
+ knowledge?: string[]
195
225
  }
196
226
 
197
227
  /** The structured story bible (worldbuilding extracted from the outline). */
@@ -254,6 +284,102 @@ export interface AuditResponse {
254
284
  auditedAt: string
255
285
  }
256
286
 
287
+ /** 一条剧情线(主线/支线/人物线/悬念线)。 */
288
+ export interface Plotline {
289
+ /** 稳定 id。 */
290
+ id: string
291
+ /** 线名。 */
292
+ name: string
293
+ /** 类型:主线 / 支线 / 人物线 / 悬念线。 */
294
+ kind: 'main' | 'branch' | 'character' | 'mystery'
295
+ /** 目标/终点(这条线最终要完成什么)。 */
296
+ goal: string
297
+ /** 当前进度说明(最近推进到哪)。 */
298
+ progress: string
299
+ /** 生命周期状态。 */
300
+ status: 'active' | 'paused' | 'resolved' | 'abandoned'
301
+ /** 关联章节号(推进/落地的章节)。 */
302
+ chapters: number[]
303
+ /** 创建时间。 */
304
+ createdAt: string
305
+ }
306
+
307
+ /** POST /plotlines 请求:剧情线增删改 + 关联章节 + AI 辅助。 */
308
+ export interface PlotlinesRequest {
309
+ op: 'add' | 'update' | 'remove' | 'link' | 'suggest' | 'refresh' | 'health' | 'plan'
310
+ /** add / update 时传入的完整剧情线。 */
311
+ line?: Plotline
312
+ /** remove / link / refresh 时的目标线 id。 */
313
+ id?: string
314
+ /** link 时关联的章节号。 */
315
+ chapterNo?: number
316
+ }
317
+
318
+ /** 剧情线健康检查报告。 */
319
+ export interface PlotlineHealthReport {
320
+ /** 是否需要新线(需要 / 暂不需要 / 再写 X 章后需要)。 */
321
+ verdict: string
322
+ /** 建议添加新线的时机说明。 */
323
+ timing: string
324
+ /** 依据(基于数据的理由,每条一句)。 */
325
+ reasons: string[]
326
+ /** 各线健康度。 */
327
+ lines: Array<{
328
+ name: string
329
+ /** ok=健康 / warning=预警 / stale=搁置过久。 */
330
+ health: 'ok' | 'warning' | 'stale'
331
+ note: string
332
+ }>
333
+ }
334
+
335
+ /** AI 剧情方案:下一阶段目标 + 建议新线。 */
336
+ export interface PlotlinePlan {
337
+ /** 下一阶段(未来 5-10 章)剧情方向。 */
338
+ direction: string
339
+ /** 建议的新线(可逐条采纳)。 */
340
+ suggestions: Plotline[]
341
+ }
342
+
343
+ /** POST /plotlines 响应。 */
344
+ export interface PlotlinesResponse {
345
+ plotlines: Plotline[]
346
+ /** op=suggest 时的 AI 建议候选线。 */
347
+ suggestions?: Plotline[]
348
+ /** op=health 时的健康检查报告。 */
349
+ health?: PlotlineHealthReport
350
+ /** op=plan 时的剧情方案。 */
351
+ plan?: PlotlinePlan
352
+ }
353
+
354
+ /** 一条敏感词命中。 */
355
+ export interface SensitiveHit {
356
+ /** 命中章节号(文本检测时为 0)。 */
357
+ chapterNo: number
358
+ /** 命中的违禁词。 */
359
+ word: string
360
+ /** 类别:政治 / 擦边 / 暴力 / 辱骂 / 广告 / 其他。 */
361
+ category: string
362
+ /** 出现次数。 */
363
+ count: number
364
+ }
365
+
366
+ /** POST /sensitive-check 请求:检测指定章节/任意文本/全书。 */
367
+ export interface SensitiveCheckRequest {
368
+ /** 检测该章正文。 */
369
+ chapterNo?: number
370
+ /** 检测任意文本(优先于 chapterNo)。 */
371
+ text?: string
372
+ /** 扫描全部已写章节。 */
373
+ all?: boolean
374
+ }
375
+
376
+ /** POST /sensitive-check 响应。 */
377
+ export interface SensitiveCheckResponse {
378
+ hits: SensitiveHit[]
379
+ /** 参与扫描的章节数。 */
380
+ scannedChapters: number
381
+ }
382
+
257
383
  /** 角色卡:角色当前状态(从事实库聚合)。 */
258
384
  export interface RoleStatusCard {
259
385
  name: string
@@ -272,6 +398,8 @@ export interface BiblePatchRequest {
272
398
  worldRules?: string[]
273
399
  redLines?: string[]
274
400
  style?: string[]
401
+ /** 角色卡整体替换(人物志编辑知情度等)。 */
402
+ characters?: CharacterCard[]
275
403
  }
276
404
 
277
405
  /** POST /blurb 请求:AI 生成/补全或手动保存小说简介。 */
@@ -384,6 +512,8 @@ export interface ProjectState {
384
512
  coverPath?: string
385
513
  /** 大世界结构化数据(境界体系/区域/势力)。 */
386
514
  world?: WorldState
515
+ /** 剧情线(主线/支线/人物线/悬念线)。 */
516
+ plotlines?: Plotline[]
387
517
  /** ISO timestamps. */
388
518
  createdAt: string
389
519
  updatedAt: string
@@ -407,6 +537,8 @@ export interface NovelConfig {
407
537
  reviewPassScore: number
408
538
  /** Whether generation auto-runs review after writing. */
409
539
  autoReview: boolean
540
+ /** Whether generation auto-runs the author review (hook/continuity/trend) after writing. */
541
+ autoAuthorReview: boolean
410
542
  }
411
543
 
412
544
  /** GET /status response. */
@@ -482,6 +614,8 @@ export type JobFrame =
482
614
  | { type: 'progress'; chars: number }
483
615
  | { type: 'done'; no: number; file: string; chars: number; title: string }
484
616
  | { type: 'review'; no: number; report: ReviewReport }
617
+ | { type: 'author-review'; no: number; review: AuthorReview }
618
+ | { type: 'author-backfill-done'; count: number }
485
619
  | { type: 'rewritten'; no: number; file: string; chars: number }
486
620
  /** 润色/重写完成,产物作为待确认草稿(尚未覆盖正文)。 */
487
621
  | { type: 'drafted'; no: number; chars: number; draft: string }
@@ -562,6 +696,7 @@ export interface ConfigPatch {
562
696
  maxTokens?: number
563
697
  reviewPassScore?: number
564
698
  autoReview?: boolean
699
+ autoAuthorReview?: boolean
565
700
  }
566
701
 
567
702
  // ------------------------------------------------------------ assistant
package/src/routes.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  type AssistantHistoryResponse,
23
23
  type AssistantRequest,
24
24
  type AuditResponse,
25
+ type AuthorReview,
25
26
  type BiblePatchRequest,
26
27
  type BibleRequest,
27
28
  type BlurbRequest,
@@ -48,6 +49,8 @@ import {
48
49
  type LoadOutlineRequest,
49
50
  type LoadOutlineResponse,
50
51
  type NovelConfig,
52
+ type PlotlinesRequest,
53
+ type PlotlinesResponse,
51
54
  type PlanRequest,
52
55
  type PlanResponse,
53
56
  type PolishRequest,
@@ -55,7 +58,11 @@ import {
55
58
  type ReviewReport,
56
59
  type ReviewRequest,
57
60
  type RewriteRequest,
61
+ type SensitiveCheckRequest,
62
+ type SensitiveCheckResponse,
63
+ type SensitiveHit,
58
64
  type StatusResponse,
65
+ type StoryBible,
59
66
  type StyleEngineRequest,
60
67
  type SummaryRequest,
61
68
  type VolumesRequest,
@@ -68,6 +75,8 @@ import { BUILTIN_ANTI_AI_RULES, BUILTIN_GENRE_LIBRARY, BUILTIN_PROGRESSION_MODES
68
75
  import {
69
76
  chapterFileName,
70
77
  auditBook,
78
+ authorReviewChapter,
79
+ autoLinkPlotlines,
71
80
  backfillFacts,
72
81
  createProject,
73
82
  exportBook,
@@ -83,11 +92,16 @@ import {
83
92
  polishChapterStream,
84
93
  readChapterFile,
85
94
  refreshCharacters,
95
+ refreshPlotlineProgress,
86
96
  reviewChapter,
87
97
  reviewChapterText,
88
98
  rewriteChapterStream,
89
99
  saveProject,
100
+ analyzePlotlineHealth,
101
+ designPlotlinePlan,
102
+ checkSensitiveText,
90
103
  suggestForeshadows,
104
+ suggestPlotlines,
91
105
  summarizeAndExtractFacts,
92
106
  summarizeChapter,
93
107
  syncProjectWithDisk,
@@ -438,6 +452,31 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
438
452
  chapter.status = 'approved'
439
453
  saveProject(config.outputDir, project)
440
454
  }
455
+ // 作者复盘:叙事结构检查(钩子兑现/结尾钩子/推进/连续性/趋势;不改变章节状态)。
456
+ if (config.autoAuthorReview ?? true) {
457
+ try {
458
+ const currentBody = readChapterFile(config.outputDir, chapter)
459
+ let prevTail = ''
460
+ if (no > 1) {
461
+ const prev = project.chapters.find(c => c.no === no - 1)
462
+ if (prev !== undefined) {
463
+ prevTail = (readChapterFile(config.outputDir, prev) ?? '').replace(/^#.*$/m, '').trim().slice(-600)
464
+ }
465
+ }
466
+ if (currentBody !== undefined) {
467
+ const review = await authorReviewChapter(ctx, config, project, no, currentBody, prevTail)
468
+ chapter.authorReview = review
469
+ // 复盘标记推进的剧情线 → 自动关联本章。
470
+ if (review.advancedLines !== undefined) {
471
+ autoLinkPlotlines(project, no, review.advancedLines)
472
+ }
473
+ saveProject(config.outputDir, project)
474
+ send({ type: 'author-review', no, review })
475
+ }
476
+ } catch (error) {
477
+ console.warn('[dsh-novel-forge] author review failed:', (error as Error).message)
478
+ }
479
+ }
441
480
  res.end()
442
481
  } catch (error) {
443
482
  chapter.status = 'error'
@@ -1180,6 +1219,22 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1180
1219
  if (Array.isArray(body?.worldRules)) project.bible.worldRules = body.worldRules.filter(r => r.trim() !== '')
1181
1220
  if (Array.isArray(body?.redLines)) project.bible.redLines = body.redLines.filter(r => r.trim() !== '')
1182
1221
  if (Array.isArray(body?.style)) project.bible.style = body.style.filter(r => r.trim() !== '')
1222
+ if (Array.isArray(body?.characters)) {
1223
+ project.bible.characters = body.characters
1224
+ .filter(c => c !== undefined && c !== null && typeof c.name === 'string' && c.name.trim() !== '')
1225
+ .map(c => ({
1226
+ name: c.name.trim(),
1227
+ role: (['protagonist', 'supporting', 'antagonist', 'other'] as const).includes(c.role as never)
1228
+ ? c.role as StoryBible['characters'][number]['role']
1229
+ : 'other',
1230
+ traits: Array.isArray(c.traits) ? c.traits.filter((t): t is string => typeof t === 'string' && t.trim() !== '') : [],
1231
+ goals: typeof c.goals === 'string' ? c.goals : '',
1232
+ relations: typeof c.relations === 'string' ? c.relations : '',
1233
+ knowledge: Array.isArray(c.knowledge)
1234
+ ? c.knowledge.filter((k): k is string => typeof k === 'string' && k.trim() !== '')
1235
+ : undefined,
1236
+ }))
1237
+ }
1183
1238
  project.updatedAt = new Date().toISOString()
1184
1239
  saveProject(config.outputDir, project)
1185
1240
  writeJson(res, 200, { bible: project.bible })
@@ -1359,6 +1414,243 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1359
1414
  },
1360
1415
  }
1361
1416
 
1417
+ // ------------------------------------------------------------- plotlines
1418
+ /** 剧情线管理:增删改 + 关联章节(主线/支线/人物线/悬念线)。 */
1419
+ const plotlinesRoute: WebRoute = {
1420
+ kind: 'exact',
1421
+ path: NOVEL_API.plotlines,
1422
+ handler: async (req, res) => {
1423
+ if (!guard(req, res, 'POST')) return
1424
+ const config = getConfig()
1425
+ const project = requireProject(res)
1426
+ if (project === undefined) return
1427
+ const body = await readJsonBody<PlotlinesRequest>(req)
1428
+ if (project.plotlines === undefined) project.plotlines = []
1429
+ const op = body?.op
1430
+ if (op === 'add' && body?.line !== undefined) {
1431
+ const line = body.line
1432
+ project.plotlines.push({
1433
+ id: line.id !== '' ? line.id : `pl-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
1434
+ name: line.name.slice(0, 40),
1435
+ kind: line.kind,
1436
+ goal: line.goal.slice(0, 300),
1437
+ progress: line.progress.slice(0, 300),
1438
+ status: line.status,
1439
+ chapters: Array.isArray(line.chapters) ? line.chapters.filter((n): n is number => typeof n === 'number') : [],
1440
+ createdAt: line.createdAt !== '' ? line.createdAt : new Date().toISOString(),
1441
+ })
1442
+ } else if (op === 'update' && body?.line !== undefined && body.line.id !== '') {
1443
+ const idx = project.plotlines.findIndex(l => l.id === body.line!.id)
1444
+ if (idx !== -1) {
1445
+ const line = body.line
1446
+ project.plotlines[idx] = {
1447
+ ...project.plotlines[idx]!,
1448
+ name: line.name.slice(0, 40),
1449
+ kind: line.kind,
1450
+ goal: line.goal.slice(0, 300),
1451
+ progress: line.progress.slice(0, 300),
1452
+ status: line.status,
1453
+ }
1454
+ }
1455
+ } else if (op === 'remove' && body?.id !== undefined) {
1456
+ project.plotlines = project.plotlines.filter(l => l.id !== body.id)
1457
+ } else if (op === 'link' && body?.id !== undefined && typeof body.chapterNo === 'number' && body.chapterNo > 0) {
1458
+ const line = project.plotlines.find(l => l.id === body.id)
1459
+ if (line !== undefined && !line.chapters.includes(body.chapterNo)) {
1460
+ line.chapters.push(body.chapterNo)
1461
+ if (line.status === 'active' && line.progress === '') {
1462
+ line.progress = `推进至第 ${body.chapterNo} 章`
1463
+ }
1464
+ }
1465
+ } else if (op === 'suggest') {
1466
+ try {
1467
+ const suggestions = await suggestPlotlines(ctx, config, project)
1468
+ writeJson(res, 200, { plotlines: project.plotlines, suggestions } satisfies PlotlinesResponse)
1469
+ return
1470
+ } catch (error) {
1471
+ writeJson(res, 500, { error: `AI 建议失败:${(error as Error).message}` })
1472
+ return
1473
+ }
1474
+ } else if (op === 'refresh' && body?.id !== undefined) {
1475
+ const line = project.plotlines.find(l => l.id === body.id)
1476
+ if (line === undefined) {
1477
+ writeJson(res, 404, { error: '剧情线不存在' })
1478
+ return
1479
+ }
1480
+ try {
1481
+ const progress = await refreshPlotlineProgress(ctx, config, project, line)
1482
+ line.progress = progress
1483
+ project.updatedAt = new Date().toISOString()
1484
+ saveProject(config.outputDir, project)
1485
+ writeJson(res, 200, { plotlines: project.plotlines } satisfies PlotlinesResponse)
1486
+ return
1487
+ } catch (error) {
1488
+ writeJson(res, 500, { error: `刷新进度失败:${(error as Error).message}` })
1489
+ return
1490
+ }
1491
+ } else if (op === 'health') {
1492
+ try {
1493
+ const health = await analyzePlotlineHealth(ctx, config, project)
1494
+ writeJson(res, 200, { plotlines: project.plotlines, health } satisfies PlotlinesResponse)
1495
+ return
1496
+ } catch (error) {
1497
+ writeJson(res, 500, { error: `健康检查失败:${(error as Error).message}` })
1498
+ return
1499
+ }
1500
+ } else if (op === 'plan') {
1501
+ try {
1502
+ const health = await analyzePlotlineHealth(ctx, config, project)
1503
+ const plan = await designPlotlinePlan(ctx, config, project, health)
1504
+ writeJson(res, 200, { plotlines: project.plotlines, health, plan } satisfies PlotlinesResponse)
1505
+ return
1506
+ } catch (error) {
1507
+ writeJson(res, 500, { error: `剧情方案生成失败:${(error as Error).message}` })
1508
+ return
1509
+ }
1510
+ }
1511
+ project.updatedAt = new Date().toISOString()
1512
+ saveProject(config.outputDir, project)
1513
+ const response: PlotlinesResponse = { plotlines: project.plotlines }
1514
+ writeJson(res, 200, response)
1515
+ },
1516
+ }
1517
+
1518
+ // --------------------------------------------------------- sensitive
1519
+ /** 敏感词检查:指定章节 / 任意文本 / 全书已写章节。 */
1520
+ const sensitiveRoute: WebRoute = {
1521
+ kind: 'exact',
1522
+ path: NOVEL_API.sensitiveCheck,
1523
+ handler: async (req, res) => {
1524
+ if (!guard(req, res, 'POST')) return
1525
+ const config = getConfig()
1526
+ const project = requireProject(res)
1527
+ if (project === undefined) return
1528
+ const body = await readJsonBody<SensitiveCheckRequest>(req)
1529
+ const hits: SensitiveHit[] = []
1530
+ let scanned = 0
1531
+ if (body?.text !== undefined) {
1532
+ for (const hit of checkSensitiveText(body.text)) {
1533
+ hits.push({ chapterNo: 0, word: hit.word, category: hit.category, count: hit.count })
1534
+ }
1535
+ } else if (typeof body?.chapterNo === 'number') {
1536
+ const chapter = project.chapters.find(c => c.no === body.chapterNo)
1537
+ if (chapter !== undefined) {
1538
+ const text = readChapterFile(config.outputDir, chapter)
1539
+ if (text !== undefined) {
1540
+ scanned = 1
1541
+ for (const hit of checkSensitiveText(text)) {
1542
+ hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
1543
+ }
1544
+ }
1545
+ }
1546
+ } else if (body?.all === true) {
1547
+ for (const chapter of project.chapters) {
1548
+ if (chapter.status === 'pending' || chapter.status === 'generating') continue
1549
+ const text = readChapterFile(config.outputDir, chapter)
1550
+ if (text === undefined) continue
1551
+ scanned++
1552
+ for (const hit of checkSensitiveText(text)) {
1553
+ hits.push({ chapterNo: chapter.no, word: hit.word, category: hit.category, count: hit.count })
1554
+ }
1555
+ }
1556
+ } else {
1557
+ writeJson(res, 400, { error: '请提供 chapterNo / text / all 之一' })
1558
+ return
1559
+ }
1560
+ const response: SensitiveCheckResponse = { hits, scannedChapters: scanned }
1561
+ writeJson(res, 200, response)
1562
+ },
1563
+ }
1564
+
1565
+ // ------------------------------------------------------ author review backfill
1566
+ /** 作者复盘补跑:对已写章节补齐 authorReview(body.chapterNo=单章 JSON,缺省=全书 NDJSON 流)。 */
1567
+ const reviewBackfillRoute: WebRoute = {
1568
+ kind: 'exact',
1569
+ path: NOVEL_API.reviewBackfill,
1570
+ handler: async (req, res) => {
1571
+ if (!guard(req, res, 'POST')) return
1572
+ const config = getConfig()
1573
+ const project = requireProject(res)
1574
+ if (project === undefined) return
1575
+ const body = await readJsonBody<{ chapterNo?: number }>(req)
1576
+
1577
+ /** 对一章执行作者复盘(读取已落盘正文,不改变章节状态/正文)。 */
1578
+ const runOne = async (chapter: ChapterPlan): Promise<AuthorReview> => {
1579
+ const currentBody = readChapterFile(config.outputDir, chapter)
1580
+ if (currentBody === undefined) throw new Error(`章节 ${chapter.no} 的正文文件不存在`)
1581
+ let prevTail = ''
1582
+ if (chapter.no > 1) {
1583
+ const prev = project.chapters.find(c => c.no === chapter.no - 1)
1584
+ if (prev !== undefined) {
1585
+ prevTail = (readChapterFile(config.outputDir, prev) ?? '').replace(/^#.*$/m, '').trim().slice(-600)
1586
+ }
1587
+ }
1588
+ return authorReviewChapter(ctx, config, project, chapter.no, currentBody, prevTail)
1589
+ }
1590
+
1591
+ // 单章:JSON 响应。
1592
+ if (typeof body?.chapterNo === 'number' && body.chapterNo > 0) {
1593
+ const chapter = project.chapters.find(c => c.no === body.chapterNo)
1594
+ if (chapter === undefined) {
1595
+ writeJson(res, 404, { error: `章节 ${body.chapterNo} 不在计划中` })
1596
+ return
1597
+ }
1598
+ if (chapter.status === 'pending') {
1599
+ writeJson(res, 400, { error: '该章尚未生成正文,无法复盘' })
1600
+ return
1601
+ }
1602
+ try {
1603
+ const review = await runOne(chapter)
1604
+ chapter.authorReview = review
1605
+ if (review.advancedLines !== undefined) {
1606
+ autoLinkPlotlines(project, chapter.no, review.advancedLines)
1607
+ }
1608
+ project.updatedAt = new Date().toISOString()
1609
+ saveProject(config.outputDir, project)
1610
+ writeJson(res, 200, { no: chapter.no, review } satisfies { no: number; review: AuthorReview })
1611
+ return
1612
+ } catch (error) {
1613
+ writeJson(res, 500, { error: (error as Error).message })
1614
+ return
1615
+ }
1616
+ }
1617
+
1618
+ // 全书:NDJSON 流式补跑缺失复盘的已写章节。
1619
+ const missing = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.authorReview === undefined)
1620
+ if (missing.length === 0) {
1621
+ writeJson(res, 200, { count: 0 })
1622
+ return
1623
+ }
1624
+ res.writeHead(200, {
1625
+ 'content-type': 'application/x-ndjson; charset=utf-8',
1626
+ 'cache-control': 'no-cache',
1627
+ 'referrer-policy': 'no-referrer',
1628
+ })
1629
+ const send = (frame: JobFrame): void => {
1630
+ res.write(JSON.stringify(frame) + '\n')
1631
+ }
1632
+ let done = 0
1633
+ for (const chapter of missing) {
1634
+ try {
1635
+ const review = await runOne(chapter)
1636
+ chapter.authorReview = review
1637
+ if (review.advancedLines !== undefined) {
1638
+ autoLinkPlotlines(project, chapter.no, review.advancedLines)
1639
+ }
1640
+ done++
1641
+ saveProject(config.outputDir, project)
1642
+ send({ type: 'author-review', no: chapter.no, review })
1643
+ } catch (error) {
1644
+ console.warn(`[dsh-novel-forge] author backfill ch.${chapter.no} failed:`, (error as Error).message)
1645
+ }
1646
+ }
1647
+ project.updatedAt = new Date().toISOString()
1648
+ saveProject(config.outputDir, project)
1649
+ send({ type: 'author-backfill-done', count: done })
1650
+ res.end()
1651
+ },
1652
+ }
1653
+
1362
1654
  // --------------------------------------------------------------- config
1363
1655
  const configRoute: WebRoute = {
1364
1656
  kind: 'exact',
@@ -1433,6 +1725,9 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1433
1725
  coverRoute,
1434
1726
  worldRoute,
1435
1727
  renameRoute,
1728
+ plotlinesRoute,
1729
+ sensitiveRoute,
1730
+ reviewBackfillRoute,
1436
1731
  configRoute,
1437
1732
  openFolderRoute,
1438
1733
  ]