@waterwx/dsh-novel-forge 2.0.1-alpha → 2.1.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +97 -2
  2. package/lib/client.js +3291 -752
  3. package/lib/index.js +3557 -237
  4. package/lib/types/assets.d.ts +16 -3
  5. package/lib/types/assistant.d.ts +4 -0
  6. package/lib/types/client/api.d.ts +89 -0
  7. package/lib/types/client/llmLive.d.ts +33 -0
  8. package/lib/types/client/locales.d.ts +3 -3
  9. package/lib/types/client/panel/AdaptModeView.d.ts +1 -1
  10. package/lib/types/client/panel/AssetsTab.d.ts +1 -1
  11. package/lib/types/client/panel/AssistantTab.d.ts +1 -1
  12. package/lib/types/client/panel/AuthorAssetsView.d.ts +1 -1
  13. package/lib/types/client/panel/AuthorHome.d.ts +4 -2
  14. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  15. package/lib/types/client/panel/BookshelfBar.d.ts +1 -1
  16. package/lib/types/client/panel/CreateBookView.d.ts +6 -2
  17. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  18. package/lib/types/client/panel/FlowGuide.d.ts +1 -1
  19. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +1 -1
  20. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  21. package/lib/types/client/panel/ImportModal.d.ts +1 -1
  22. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  23. package/lib/types/client/panel/LiveFeedLog.d.ts +5 -0
  24. package/lib/types/client/panel/MangaRoleLibrary.d.ts +1 -1
  25. package/lib/types/client/panel/MangaWorkspace.d.ts +1 -1
  26. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  27. package/lib/types/client/panel/NovelPanel.d.ts +2 -2
  28. package/lib/types/client/panel/ProgressConsole.d.ts +1 -1
  29. package/lib/types/client/panel/ProgressHomeView.d.ts +1 -1
  30. package/lib/types/client/panel/PropLibrary.d.ts +1 -1
  31. package/lib/types/client/panel/ReaderView.d.ts +1 -1
  32. package/lib/types/client/panel/RunPanel.d.ts +1 -1
  33. package/lib/types/client/panel/SceneLibrary.d.ts +1 -1
  34. package/lib/types/client/panel/SettingsView.d.ts +1 -1
  35. package/lib/types/client/panel/ShelfView.d.ts +1 -1
  36. package/lib/types/client/panel/StoryboardTab.d.ts +1 -1
  37. package/lib/types/client/panel/StyleCard.d.ts +1 -1
  38. package/lib/types/client/panel/StyleGallery.d.ts +1 -1
  39. package/lib/types/client/panel/WorldTab.d.ts +1 -1
  40. package/lib/types/engine.d.ts +29 -1
  41. package/lib/types/global-assets.d.ts +6 -0
  42. package/lib/types/index.d.ts +6 -0
  43. package/lib/types/llm-live.d.ts +44 -0
  44. package/lib/types/market-radar-scan.d.ts +19 -0
  45. package/lib/types/market-radar-sources.d.ts +31 -0
  46. package/lib/types/prompting.d.ts +35 -0
  47. package/lib/types/protocol.d.ts +364 -8
  48. package/lib/types/run.d.ts +1 -1
  49. package/lib/types/src/ai-scan.d.ts +36 -0
  50. package/lib/types/src/assets.d.ts +37 -0
  51. package/lib/types/src/assistant.d.ts +45 -0
  52. package/lib/types/src/author-assets.d.ts +25 -0
  53. package/lib/types/src/bookshelf.d.ts +42 -0
  54. package/lib/types/src/client/api.d.ts +234 -0
  55. package/lib/types/src/client/docx.d.ts +15 -0
  56. package/lib/types/src/client/index.d.ts +14 -0
  57. package/lib/types/src/client/locales.d.ts +212 -0
  58. package/lib/types/src/client/mount.d.ts +9 -0
  59. package/lib/types/src/client/panel/AdaptModeView.d.ts +5 -0
  60. package/lib/types/src/client/panel/AssetsTab.d.ts +12 -0
  61. package/lib/types/src/client/panel/AssistantTab.d.ts +7 -0
  62. package/lib/types/src/client/panel/AuthorAssetsView.d.ts +4 -0
  63. package/lib/types/src/client/panel/AuthorHome.d.ts +32 -0
  64. package/lib/types/src/client/panel/BookshelfBar.d.ts +11 -0
  65. package/lib/types/src/client/panel/CreateBookView.d.ts +9 -0
  66. package/lib/types/src/client/panel/FlowGuide.d.ts +22 -0
  67. package/lib/types/src/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  68. package/lib/types/src/client/panel/ImportModal.d.ts +7 -0
  69. package/lib/types/src/client/panel/MangaRoleLibrary.d.ts +20 -0
  70. package/lib/types/src/client/panel/MangaWorkspace.d.ts +12 -0
  71. package/lib/types/src/client/panel/ModelManager.d.ts +14 -0
  72. package/lib/types/src/client/panel/NovelPanel.d.ts +13 -0
  73. package/lib/types/src/client/panel/ProgressConsole.d.ts +17 -0
  74. package/lib/types/src/client/panel/ProgressHomeView.d.ts +5 -0
  75. package/lib/types/src/client/panel/PropLibrary.d.ts +8 -0
  76. package/lib/types/src/client/panel/ReaderView.d.ts +8 -0
  77. package/lib/types/src/client/panel/ReasoningSection.d.ts +10 -0
  78. package/lib/types/src/client/panel/RunPanel.d.ts +5 -0
  79. package/lib/types/src/client/panel/SceneLibrary.d.ts +16 -0
  80. package/lib/types/src/client/panel/SettingsView.d.ts +16 -0
  81. package/lib/types/src/client/panel/ShelfView.d.ts +15 -0
  82. package/lib/types/src/client/panel/StoryboardTab.d.ts +19 -0
  83. package/lib/types/src/client/panel/StyleCard.d.ts +8 -0
  84. package/lib/types/src/client/panel/StyleGallery.d.ts +1 -0
  85. package/lib/types/src/client/panel/WorldTab.d.ts +10 -0
  86. package/lib/types/src/client/panel/controller.d.ts +19 -0
  87. package/lib/types/src/client/panel/helpers.d.ts +18 -0
  88. package/lib/types/src/client/panel/views.d.ts +77 -0
  89. package/lib/types/src/client/sidebar-entry.d.ts +13 -0
  90. package/lib/types/src/client/text.d.ts +9 -0
  91. package/lib/types/src/comic-presets.d.ts +12 -0
  92. package/lib/types/src/docx.d.ts +19 -0
  93. package/lib/types/src/engine.d.ts +402 -0
  94. package/lib/types/src/index.d.ts +79 -0
  95. package/lib/types/src/manga-genre-rules.d.ts +22 -0
  96. package/lib/types/src/novel-context.d.ts +80 -0
  97. package/lib/types/src/prompt-utils.d.ts +35 -0
  98. package/lib/types/src/protocol.d.ts +2176 -0
  99. package/lib/types/src/review-policy.d.ts +97 -0
  100. package/lib/types/src/routes.d.ts +29 -0
  101. package/lib/types/src/run.d.ts +32 -0
  102. package/lib/types/src/shot-language.d.ts +46 -0
  103. package/lib/types/src/story-beat-language.d.ts +35 -0
  104. package/lib/types/src/style-library.d.ts +33 -0
  105. package/package.json +1 -1
  106. package/src/assets.ts +1774 -879
  107. package/src/assistant.ts +154 -9
  108. package/src/client/api.ts +105 -6
  109. package/src/client/llmLive.ts +101 -0
  110. package/src/client/locales.ts +4 -4
  111. package/src/client/panel/AdaptModeView.tsx +18 -3
  112. package/src/client/panel/AssistantTab.tsx +41 -3
  113. package/src/client/panel/AuthorHome.tsx +51 -20
  114. package/src/client/panel/BookAnalysisView.tsx +60 -0
  115. package/src/client/panel/CreateBookView.tsx +22 -3
  116. package/src/client/panel/DirectorView.tsx +200 -0
  117. package/src/client/panel/IdeaInspirationView.tsx +71 -0
  118. package/src/client/panel/KnowledgeBaseView.tsx +73 -0
  119. package/src/client/panel/LiveFeedLog.tsx +76 -0
  120. package/src/client/panel/MarketRadarView.tsx +374 -0
  121. package/src/client/panel/NovelPanel.tsx +117 -16
  122. package/src/client/panel/ProgressConsole.tsx +5 -6
  123. package/src/client/panel/SettingsView.tsx +3 -0
  124. package/src/client/panel/panel.module.css +2 -3
  125. package/src/engine.ts +459 -23
  126. package/src/global-assets.ts +59 -0
  127. package/src/index.ts +17 -1
  128. package/src/llm-live.ts +68 -0
  129. package/src/market-radar-scan.ts +41 -0
  130. package/src/market-radar-sources.ts +173 -0
  131. package/src/prompting.ts +159 -0
  132. package/src/protocol.ts +340 -8
  133. package/src/routes.ts +579 -59
  134. package/src/run.ts +15 -8
  135. package/skills/chapter-batch/SKILL.md +0 -81
package/src/routes.ts CHANGED
@@ -135,12 +135,27 @@ import {
135
135
  type AdaptMaterializeSaveResponse,
136
136
  type AdaptRewriteFrame,
137
137
  type PluginUpdateResponse,
138
+ type MarketRadarRequest,
139
+ type MarketRadarBriefRequest,
140
+ type ProductionFoundation,
141
+ type ProgressionMode,
142
+ type GenreNode,
143
+ type KnowledgeDoc,
144
+ type KnowledgeRequest,
145
+ type BookAnalysisRequest,
146
+ type IdeaInspirationRequest,
147
+ type DirectorRequest,
148
+ type DirectorTodo,
138
149
  } from './protocol.ts'
139
150
  import { readOutlineFromDocx } from './docx.ts'
140
151
  import { clearAssistantHistory, loadAssistantHistory, runAssistantTurn } from './assistant.ts'
141
152
  import { activateBook, bookshelfSnapshot, createBook, defaultOutputDirFor, importDir, loadBookshelf, removeBook, renameBook, seedBookshelfFromOutputDir } from './bookshelf.ts'
142
153
  import { loadAuthorAssets, upsertAuthorAsset, removeAuthorAsset, importDefaultAuthorAssets } from './author-assets.ts'
143
- import { BUILTIN_ANTI_AI_RULES, BUILTIN_GENRE_LIBRARY, BUILTIN_PLOT_BEATS, BUILTIN_PROGRESSION_MODES, BUILTIN_STYLE_TEMPLATES, emptyProjectAssets } from './assets.ts'
154
+ import { BUILTIN_ANTI_AI_RULES, BUILTIN_GENRE_LIBRARY, BUILTIN_PLOT_BEATS, BUILTIN_PROGRESSION_MODES, BUILTIN_STARTER_STYLE_PROFILES, BUILTIN_STYLE_TEMPLATES, emptyProjectAssets, ensureBuiltinAssets } from './assets.ts'
155
+ import { scanAiFlavor } from './ai-scan.ts'
156
+ import { emitLive, nextSessionId, subscribeLiveFeed } from './llm-live.ts'
157
+ import { scanMarketRanking } from './market-radar-scan.ts'
158
+ import { addGlobalGenre, addGlobalMode, globalGenreLibrary, globalProgressionLibrary } from './global-assets.ts'
144
159
  import {
145
160
  chapterFileName,
146
161
  auditBook,
@@ -154,6 +169,13 @@ import {
154
169
  previewBookText,
155
170
  extractBible,
156
171
  extractStyleAsset,
172
+ extractStyleFormula,
173
+ runMarketRadar,
174
+ runMarketCreativeBrief,
175
+ runBookAnalysis,
176
+ runIdeaInspiration,
177
+ runMarketIdeaInspiration,
178
+ runDirectorAdvice,
157
179
  extractWorld,
158
180
  generateBlurb,
159
181
  generateChapterStream,
@@ -218,6 +240,15 @@ const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024
218
240
  /** 包内置风格效果图目录(assets/styles,随 npm 包分发)。 */
219
241
  const builtinStyleDir = fileURLToPath(new URL('../assets/styles/', import.meta.url))
220
242
 
243
+ /** 解析请求级目标书目录:优先按请求携带的 bookId 查书架,否则回退全局 active 书目录。 */
244
+ function resolveOutputDir(config: NovelConfig, bookId?: string): string {
245
+ if (bookId !== undefined && bookId !== '') {
246
+ const book = loadBookshelf().books.find(b => b.id === bookId)
247
+ if (book !== undefined && book.outputDir !== '') return book.outputDir
248
+ }
249
+ return config.outputDir
250
+ }
251
+
221
252
  /** Loopback-only fence (mirrors the family plugins' pairing routes). */
222
253
  function isLoopbackRequest(request: IncomingMessage): boolean {
223
254
  const address = request.socket.remoteAddress
@@ -321,9 +352,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
321
352
  handler: (req, res) => {
322
353
  if (!guard(req, res, 'GET')) return
323
354
  const config = getConfig()
355
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
356
+ const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
324
357
  // 书架为空时播种 settings 默认输出目录里的已有项目。
325
- seedBookshelfFromOutputDir(config.outputDir)
326
- const project = loadProject(config.outputDir)
358
+ seedBookshelfFromOutputDir(outputDir)
359
+ const project = loadProject(outputDir)
327
360
  if (project !== undefined) {
328
361
  // 自动兜底:generating 超过 10 分钟(正常一章 2-5 分钟)视为卡死/中断,
329
362
  // 复位为 pending,避免"生成中"状态卡死无法重新生成。
@@ -338,8 +371,8 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
338
371
  }
339
372
  }
340
373
  }
341
- syncProjectWithDisk(project, config.outputDir)
342
- saveProject(config.outputDir, project)
374
+ syncProjectWithDisk(project, outputDir)
375
+ saveProject(outputDir, project)
343
376
  }
344
377
  const slim = new URL(req.url ?? '/', 'http://localhost').searchParams.get('slim') === '1'
345
378
  let projectPayload: StatusResponse['project']
@@ -477,9 +510,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
477
510
  path: NOVEL_API.volumes,
478
511
  handler: async (req, res) => {
479
512
  if (!guard(req, res, 'POST')) return
480
- const body = await readJsonBody<VolumesRequest>(req)
481
- const config = getConfig()
482
- const project = loadProject(config.outputDir)
513
+ const body = await readJsonBody<VolumesRequest & { bookId?: string }>(req)
514
+ const config0 = getConfig()
515
+ const outputDir = resolveOutputDir(config0, body?.bookId)
516
+ const config = { ...config0, outputDir }
517
+ const project = loadProject(outputDir)
483
518
  const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
484
519
  ? body.outline
485
520
  : project?.outline
@@ -493,7 +528,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
493
528
  const next = project ?? createProject(outline)
494
529
  next.volumes = volumes
495
530
  next.updatedAt = now
496
- saveProject(config.outputDir, next)
531
+ saveProject(outputDir, next)
497
532
  const response: VolumesResponse = { volumes }
498
533
  writeJson(res, 200, response)
499
534
  } catch (error) {
@@ -508,9 +543,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
508
543
  path: NOVEL_API.plan,
509
544
  handler: async (req, res) => {
510
545
  if (!guard(req, res, 'POST')) return
511
- const body = await readJsonBody<PlanRequest>(req)
512
- const config = getConfig()
513
- const project = loadProject(config.outputDir)
546
+ const body = await readJsonBody<PlanRequest & { bookId?: string }>(req)
547
+ const config0 = getConfig()
548
+ const outputDir = resolveOutputDir(config0, body?.bookId)
549
+ const config = { ...config0, outputDir }
550
+ const project = loadProject(outputDir)
514
551
  const outline = body?.outline?.trim() !== '' && body?.outline !== undefined
515
552
  ? body.outline
516
553
  : project?.outline
@@ -546,10 +583,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
546
583
  path: NOVEL_API.generate,
547
584
  handler: async (req, res) => {
548
585
  if (!guard(req, res, 'POST')) return
549
- const config = getConfig()
550
- const project = requireProject(res)
551
- if (project === undefined) return
552
- const body = await readJsonBody<{ chapterNo?: number; skipReview?: boolean }>(req)
586
+ const config0 = getConfig()
587
+ const body = await readJsonBody<{ chapterNo?: number; skipReview?: boolean; bookId?: string }>(req)
588
+ const outputDir = resolveOutputDir(config0, body?.bookId)
589
+ const config = { ...config0, outputDir }
590
+ const project = loadProject(outputDir)
591
+ if (project === undefined) {
592
+ writeJson(res, 400, { error: '输出目录中没有项目' })
593
+ return
594
+ }
553
595
  const rawNo = body?.chapterNo
554
596
  if (!Number.isInteger(rawNo) || rawNo === undefined || rawNo < 1) {
555
597
  writeJson(res, 400, { error: 'chapterNo 须为正整数' })
@@ -584,15 +626,23 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
584
626
  res.write(JSON.stringify(frame) + '\n')
585
627
  }
586
628
 
629
+ const liveSession = nextSessionId()
630
+ emitLive({ type: 'session_started', sessionId: liveSession, label: '正文生成', model: config.generateModel || config.model, at: new Date().toISOString(), context: { interactionId: liveSession, taskId: `ch-${no}` } })
631
+ emitLive({ type: 'phase_changed', sessionId: liveSession, phase: 'streaming', phaseMessage: '模型正在返回正文', at: new Date().toISOString() })
632
+
587
633
  try {
588
634
  send({ type: 'start', no, title: chapter.title })
635
+ let genChars = 0
589
636
  for await (const step of generateChapterStream(ctx, config, project, config.outputDir, no)) {
590
637
  if (step.frame === 'delta') {
591
638
  send({ type: 'delta', text: step.text })
639
+ genChars += step.text.length
640
+ emitLive({ type: 'output_delta', sessionId: liveSession, content: step.text, totalChars: genChars, at: new Date().toISOString() })
592
641
  } else if (step.frame === 'done') {
593
642
  send({ type: 'done', no, file: step.file, chars: step.chars, title: chapter.title, warn: step.warn })
594
643
  }
595
644
  }
645
+ emitLive({ type: 'session_completed', sessionId: liveSession, totalChars: genChars, preview: '', at: new Date().toISOString(), phase: 'completed' })
596
646
  // Auto pipeline: summary + facts(一次调用)-> review(unless skipped)。
597
647
  try {
598
648
  await summarizeAndExtractFacts(ctx, config, project, config.outputDir, no)
@@ -662,17 +712,22 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
662
712
  path: NOVEL_API.review,
663
713
  handler: async (req, res) => {
664
714
  if (!guard(req, res, 'POST')) return
665
- const config = getConfig()
666
- const project = requireProject(res)
667
- if (project === undefined) return
668
- const body = await readJsonBody<ReviewRequest>(req)
715
+ const config0 = getConfig()
716
+ const body = await readJsonBody<ReviewRequest & { bookId?: string }>(req)
717
+ const outputDir = resolveOutputDir(config0, body?.bookId)
718
+ const config = { ...config0, outputDir }
719
+ const project = loadProject(outputDir)
720
+ if (project === undefined) {
721
+ writeJson(res, 400, { error: '输出目录中没有项目' })
722
+ return
723
+ }
669
724
  if (!Number.isInteger(body?.chapterNo)) {
670
725
  writeJson(res, 400, { error: 'chapterNo 须为正整数' })
671
726
  return
672
727
  }
673
728
  const no = body!.chapterNo!
674
729
  try {
675
- const report = await reviewChapter(ctx, config, project, config.outputDir, no)
730
+ const report = await reviewChapter(ctx, config, project, outputDir, no)
676
731
  writeJson(res, 200, { report })
677
732
  } catch (error) {
678
733
  writeJson(res, 500, { error: (error as Error).message })
@@ -686,10 +741,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
686
741
  path: NOVEL_API.rewrite,
687
742
  handler: async (req, res) => {
688
743
  if (!guard(req, res, 'POST')) return
689
- const config = getConfig()
690
- const project = requireProject(res)
691
- if (project === undefined) return
692
- const body = await readJsonBody<RewriteRequest>(req)
744
+ const config0 = getConfig()
745
+ const body = await readJsonBody<RewriteRequest & { bookId?: string }>(req)
746
+ const outputDir = resolveOutputDir(config0, body?.bookId)
747
+ const config = { ...config0, outputDir }
748
+ const project = loadProject(outputDir)
749
+ if (project === undefined) {
750
+ writeJson(res, 400, { error: '输出目录中没有项目' })
751
+ return
752
+ }
693
753
  if (!Number.isInteger(body?.chapterNo)) {
694
754
  writeJson(res, 400, { error: 'chapterNo 须为正整数' })
695
755
  return
@@ -725,10 +785,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
725
785
  path: NOVEL_API.polish,
726
786
  handler: async (req, res) => {
727
787
  if (!guard(req, res, 'POST')) return
728
- const config = getConfig()
729
- const project = requireProject(res)
730
- if (project === undefined) return
731
- const body = await readJsonBody<PolishRequest>(req)
788
+ const config0 = getConfig()
789
+ const body = await readJsonBody<PolishRequest & { bookId?: string }>(req)
790
+ const outputDir = resolveOutputDir(config0, body?.bookId)
791
+ const config = { ...config0, outputDir }
792
+ const project = loadProject(outputDir)
793
+ if (project === undefined) {
794
+ writeJson(res, 400, { error: '输出目录中没有项目' })
795
+ return
796
+ }
732
797
  if (!Number.isInteger(body?.chapterNo)) {
733
798
  writeJson(res, 400, { error: 'chapterNo 须为正整数' })
734
799
  return
@@ -865,10 +930,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
865
930
  path: NOVEL_API.foreshadow,
866
931
  handler: async (req, res) => {
867
932
  if (!guard(req, res, 'POST')) return
868
- const config = getConfig()
869
- const project = requireProject(res)
870
- if (project === undefined) return
871
- const body = await readJsonBody<ForeshadowRequest>(req)
933
+ const config0 = getConfig()
934
+ const body = await readJsonBody<ForeshadowRequest & { bookId?: string }>(req)
935
+ const outputDir = resolveOutputDir(config0, body?.bookId)
936
+ const config = { ...config0, outputDir }
937
+ const project = loadProject(outputDir)
938
+ if (project === undefined) {
939
+ writeJson(res, 400, { error: '输出目录中没有项目' })
940
+ return
941
+ }
872
942
  try {
873
943
  if (body?.suggest === true) {
874
944
  // AI suggestion pass: create several foreshadows from the outline.
@@ -922,10 +992,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
922
992
  path: NOVEL_API.exportBook,
923
993
  handler: async (req, res) => {
924
994
  if (!guard(req, res, 'POST')) return
925
- const config = getConfig()
926
- const project = requireProject(res)
927
- if (project === undefined) return
928
- const body = await readJsonBody<ExportRequest>(req)
995
+ const config0 = getConfig()
996
+ const body = await readJsonBody<ExportRequest & { bookId?: string }>(req)
997
+ const outputDir = resolveOutputDir(config0, body?.bookId)
998
+ const config = { ...config0, outputDir }
999
+ const project = loadProject(outputDir)
1000
+ if (project === undefined) {
1001
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1002
+ return
1003
+ }
929
1004
  const format = body?.format === 'md' ? 'md' : 'txt'
930
1005
  try {
931
1006
  const result = exportBook(config.outputDir, project, format)
@@ -944,9 +1019,13 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
944
1019
  handler: async (req, res) => {
945
1020
  if (!guard(req, res, 'GET')) return
946
1021
  const config = getConfig()
947
- const project = requireProject(res)
948
- if (project === undefined) return
949
1022
  const url = new URL(req.url ?? '/', 'http://localhost')
1023
+ const outputDir = resolveOutputDir(config, url.searchParams.get('bookId') ?? undefined)
1024
+ const project = loadProject(outputDir)
1025
+ if (project === undefined) {
1026
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1027
+ return
1028
+ }
950
1029
  const rawNo = Number(url.searchParams.get('no') ?? '0')
951
1030
  if (!Number.isInteger(rawNo) || rawNo < 1) {
952
1031
  writeJson(res, 400, { error: 'no 须为正整数' })
@@ -957,7 +1036,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
957
1036
  writeJson(res, 404, { error: `章节 ${rawNo} 不在计划中` })
958
1037
  return
959
1038
  }
960
- const markdown = readChapterFile(config.outputDir, chapter)
1039
+ const markdown = readChapterFile(outputDir, chapter)
961
1040
  if (markdown === undefined) {
962
1041
  writeJson(res, 404, { error: `章节 ${rawNo} 尚未生成` })
963
1042
  return
@@ -1052,9 +1131,13 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1052
1131
  handler: async (req, res) => {
1053
1132
  if (!guard(req, res, 'POST')) return
1054
1133
  const config = getConfig()
1055
- const project = requireProject(res)
1056
- if (project === undefined) return
1057
- const body = await readJsonBody<AssistantRequest>(req)
1134
+ const body = await readJsonBody<AssistantRequest & { bookId?: string }>(req)
1135
+ const outputDir = resolveOutputDir(config, body?.bookId)
1136
+ const project = loadProject(outputDir)
1137
+ if (project === undefined) {
1138
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1139
+ return
1140
+ }
1058
1141
  const message = body?.message?.trim()
1059
1142
  if (message === undefined || message === '') {
1060
1143
  writeJson(res, 400, { error: '消息不能为空' })
@@ -1068,10 +1151,11 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1068
1151
  })
1069
1152
  const send = (frame: AssistantFrame): void => { res.write(JSON.stringify(frame) + '\n') }
1070
1153
  try {
1071
- for await (const step of runAssistantTurn(ctx, config, project, config.outputDir, message)) {
1154
+ for await (const step of runAssistantTurn(ctx, config, project, outputDir, message)) {
1072
1155
  if (step.frame === 'delta') send({ type: 'delta', text: step.text })
1073
1156
  else if (step.frame === 'tool') send({ type: 'tool', name: step.name, status: step.status, detail: step.detail })
1074
1157
  else if (step.frame === 'toolDelta') send({ type: 'toolDelta', name: step.name, text: step.text })
1158
+ else if (step.frame === 'toolResult') send({ type: 'toolResult', name: step.name, text: step.text })
1075
1159
  }
1076
1160
  send({ type: 'done' })
1077
1161
  res.end()
@@ -1091,7 +1175,9 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1091
1175
  handler: (req, res) => {
1092
1176
  if (!guard(req, res, 'GET')) return
1093
1177
  const config = getConfig()
1094
- const response: AssistantHistoryResponse = { messages: loadAssistantHistory(config.outputDir) }
1178
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1179
+ const outputDir = resolveOutputDir(config, qurl.searchParams.get('bookId') ?? undefined)
1180
+ const response: AssistantHistoryResponse = { messages: loadAssistantHistory(outputDir) }
1095
1181
  writeJson(res, 200, response)
1096
1182
  },
1097
1183
  }
@@ -1101,10 +1187,12 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1101
1187
  const assistantClearRoute: WebRoute = {
1102
1188
  kind: 'exact',
1103
1189
  path: NOVEL_API.assistantClear,
1104
- handler: (req, res) => {
1190
+ handler: async (req, res) => {
1105
1191
  if (!guard(req, res, 'POST')) return
1106
1192
  const config = getConfig()
1107
- clearAssistantHistory(config.outputDir)
1193
+ const body = await readJsonBody<{ bookId?: string }>(req)
1194
+ const outputDir = resolveOutputDir(config, body?.bookId)
1195
+ clearAssistantHistory(outputDir)
1108
1196
  writeJson(res, 200, { ok: true })
1109
1197
  },
1110
1198
  }
@@ -1125,10 +1213,21 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1125
1213
  return
1126
1214
  }
1127
1215
  const config = getConfig()
1128
- const project = loadProject(config.outputDir)
1129
- const projectAssets = project?.assets ?? emptyProjectAssets()
1216
+ let postBody: (AssetsPatch & { bookId?: string }) | undefined
1217
+ if (req.method === 'POST') {
1218
+ postBody = await readJsonBody<AssetsPatch & { bookId?: string }>(req)
1219
+ if (postBody === undefined) {
1220
+ writeJson(res, 400, { error: '无效的 JSON' })
1221
+ return
1222
+ }
1223
+ }
1224
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1225
+ const queryBookId = qurl.searchParams.get('bookId') ?? undefined
1226
+ const outputDir = resolveOutputDir(config, postBody?.bookId ?? queryBookId)
1227
+ const project = loadProject(outputDir)
1228
+ const projectAssets = ensureBuiltinAssets(project?.assets ?? emptyProjectAssets())
1130
1229
  if (req.method === 'POST') {
1131
- const body = await readJsonBody<AssetsPatch>(req)
1230
+ const body = postBody
1132
1231
  if (body === undefined) {
1133
1232
  writeJson(res, 400, { error: '无效的 JSON' })
1134
1233
  return
@@ -1145,14 +1244,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1145
1244
  projectAssets.updatedAt = new Date().toISOString()
1146
1245
  project.assets = projectAssets
1147
1246
  project.updatedAt = new Date().toISOString()
1148
- saveProject(config.outputDir, project)
1247
+ saveProject(outputDir, project)
1149
1248
  }
1150
1249
  const response: AssetsResponse = {
1151
1250
  projectAssets,
1152
- genreLibrary: BUILTIN_GENRE_LIBRARY,
1251
+ genreLibrary: [...BUILTIN_GENRE_LIBRARY, ...globalGenreLibrary()],
1153
1252
  antiAiLibrary: BUILTIN_ANTI_AI_RULES,
1154
1253
  styleTemplates: BUILTIN_STYLE_TEMPLATES,
1155
- progressionLibrary: BUILTIN_PROGRESSION_MODES,
1254
+ progressionLibrary: [...BUILTIN_PROGRESSION_MODES, ...globalProgressionLibrary()],
1255
+ starterStyleProfiles: BUILTIN_STARTER_STYLE_PROFILES,
1156
1256
  plotBeatLibrary: BUILTIN_PLOT_BEATS,
1157
1257
  }
1158
1258
  writeJson(res, 200, response)
@@ -1197,6 +1297,404 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1197
1297
  },
1198
1298
  }
1199
1299
 
1300
+ // ----------------------------------------------------------- style-formula
1301
+ const styleFormulaRoute: WebRoute = {
1302
+ kind: 'exact',
1303
+ path: NOVEL_API.styleFormula,
1304
+ handler: async (req, res) => {
1305
+ if (!guard(req, res, 'POST')) return
1306
+ const config = getConfig()
1307
+ const project = loadProject(config.outputDir)
1308
+ const body = await readJsonBody<StyleEngineRequest & { depth?: string }>(req)
1309
+ const sample = body?.sampleText?.trim()
1310
+ if (sample === undefined || sample.length < 50) {
1311
+ writeJson(res, 400, { error: '样本文本过短(<50 字符),请粘贴一段能代表目标风格的文字' })
1312
+ return
1313
+ }
1314
+ const depth = (['basic', 'standard', 'deep'] as const).includes(body?.depth as never)
1315
+ ? body!.depth! as 'basic' | 'standard' | 'deep'
1316
+ : 'standard'
1317
+ try {
1318
+ const formula = await extractStyleFormula(ctx, config, sample, depth)
1319
+ const key = `sf-${Date.now().toString(36)}`
1320
+ const styleFormula = { key, ...formula, depth, createdAt: new Date().toISOString() }
1321
+ if (project !== undefined) {
1322
+ project.assets ??= emptyProjectAssets()
1323
+ project.assets.styleFormulas ??= []
1324
+ project.assets.styleFormulas.push(styleFormula)
1325
+ project.assets.updatedAt = new Date().toISOString()
1326
+ project.updatedAt = new Date().toISOString()
1327
+ saveProject(config.outputDir, project)
1328
+ }
1329
+ writeJson(res, 200, { styleFormula })
1330
+ } catch (error) {
1331
+ writeJson(res, 500, { error: (error as Error).message })
1332
+ }
1333
+ },
1334
+ }
1335
+
1336
+ // ----------------------------------------------------------- style-detect
1337
+ /** 正文实时 AI 味检测(本地确定性扫描,免 LLM)。 */
1338
+ const styleDetectRoute: WebRoute = {
1339
+ kind: 'exact',
1340
+ path: NOVEL_API.styleDetect,
1341
+ handler: async (req, res) => {
1342
+ if (!guard(req, res, 'POST')) return
1343
+ const body = await readJsonBody<{ text?: string }>(req)
1344
+ const text = body?.text?.trim() ?? ''
1345
+ if (text.length < 20) {
1346
+ writeJson(res, 400, { error: '文本过短(<20 字符)' })
1347
+ return
1348
+ }
1349
+ const scan = scanAiFlavor(text.slice(0, 20000))
1350
+ writeJson(res, 200, { scan })
1351
+ },
1352
+ }
1353
+
1354
+ // --------------------------------------------------------- market-radar
1355
+ /** 热门题材雷达:输入平台/题材/榜单文本 → 信号 + 生产底座 + 开书创意。 */
1356
+ const marketRadarRoute: WebRoute = {
1357
+ kind: 'exact',
1358
+ path: NOVEL_API.marketRadar,
1359
+ handler: async (req, res) => {
1360
+ if (!guard(req, res, 'POST')) return
1361
+ const config = getConfig()
1362
+ const body = await readJsonBody<MarketRadarRequest>(req)
1363
+ try {
1364
+ const result = await runMarketRadar(ctx, config, body ?? {})
1365
+ writeJson(res, 200, { result })
1366
+ } catch (error) {
1367
+ writeJson(res, 500, { error: (error as Error).message })
1368
+ }
1369
+ },
1370
+ }
1371
+
1372
+ // ------------------------------------------------- market-radar scan
1373
+ /** 真实榜单扫榜:抓取公开榜单(容错),返回分组候选。 */
1374
+ const marketRadarScanRoute: WebRoute = {
1375
+ kind: 'exact',
1376
+ path: NOVEL_API.marketRadarScan,
1377
+ handler: async (req, res) => {
1378
+ if (!guard(req, res, 'POST')) return
1379
+ const body = await readJsonBody<{ platforms?: string[] }>(req)
1380
+ try {
1381
+ const result = await scanMarketRanking(body?.platforms)
1382
+ writeJson(res, 200, { result })
1383
+ } catch (error) {
1384
+ writeJson(res, 500, { error: (error as Error).message })
1385
+ }
1386
+ },
1387
+ }
1388
+
1389
+ // ------------------------------------------------- market-radar apply
1390
+ /** 把雷达生产底座一键应用到某本书(写入项目资产/开书定盘,供后续规划与生成遵守)。 */
1391
+ const marketRadarApplyRoute: WebRoute = {
1392
+ kind: 'exact',
1393
+ path: NOVEL_API.marketRadarApply,
1394
+ handler: async (req, res) => {
1395
+ if (!guard(req, res, 'POST')) return
1396
+ const body = await readJsonBody<{ bookId?: string; foundation?: ProductionFoundation }>(req)
1397
+ const pf = body?.foundation
1398
+ if (pf === undefined) {
1399
+ writeJson(res, 400, { error: '缺少 foundation' })
1400
+ return
1401
+ }
1402
+ const config = getConfig()
1403
+ const outputDir = resolveOutputDir(config, body?.bookId)
1404
+ const project = loadProject(outputDir)
1405
+ if (project === undefined) {
1406
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1407
+ return
1408
+ }
1409
+ const genreBuiltin = BUILTIN_GENRE_LIBRARY.find(g => g.name === (pf.genre.existingId || pf.genre.name))
1410
+ const genreNode: GenreNode = genreBuiltin !== undefined
1411
+ ? { ...genreBuiltin }
1412
+ : { name: pf.genre.name.slice(0, 40), description: pf.genre.description, template: pf.genre.template, children: [] }
1413
+ const findMode = (m: { existingId?: string; name: string }): ProgressionMode | undefined => BUILTIN_PROGRESSION_MODES.find(x => x.name === (m.existingId || m.name))
1414
+ const primary = findMode(pf.primaryStoryMode) ?? {
1415
+ name: pf.primaryStoryMode.name.slice(0, 40),
1416
+ driver: pf.primaryStoryMode.driver,
1417
+ readerExpectation: pf.primaryStoryMode.readerExpectation,
1418
+ payoffs: [],
1419
+ risks: [],
1420
+ primary: true,
1421
+ }
1422
+ const aux: ProgressionMode[] = []
1423
+ if (pf.secondaryStoryMode !== undefined) {
1424
+ const sec = findMode(pf.secondaryStoryMode) ?? {
1425
+ name: pf.secondaryStoryMode.name.slice(0, 40),
1426
+ driver: pf.secondaryStoryMode.driver,
1427
+ readerExpectation: pf.secondaryStoryMode.readerExpectation,
1428
+ payoffs: [],
1429
+ risks: [],
1430
+ primary: false,
1431
+ }
1432
+ aux.push(sec)
1433
+ }
1434
+ project.assets ??= emptyProjectAssets()
1435
+ project.assets.genre = genreNode
1436
+ project.assets.primaryProgression = primary
1437
+ project.assets.auxiliaryProgressions = aux
1438
+ project.bookContract = {
1439
+ promise: pf.genre.description.slice(0, 120),
1440
+ primaryModeName: pf.primaryStoryMode.name,
1441
+ secondaryModeNames: pf.secondaryStoryMode !== undefined ? [pf.secondaryStoryMode.name] : [],
1442
+ tone: pf.genre.template,
1443
+ targetPlatform: undefined,
1444
+ }
1445
+ project.updatedAt = new Date().toISOString()
1446
+ saveProject(outputDir, project)
1447
+ writeJson(res, 200, { ok: true, bookName: project.bookName })
1448
+ },
1449
+ }
1450
+
1451
+ // ------------------------------------------------- market-radar sync
1452
+ /** 把雷达生产底座里的「新资产」同步进全局资源库(跨书复用;已有名字跳过)。 */
1453
+ const marketRadarSyncRoute: WebRoute = {
1454
+ kind: 'exact',
1455
+ path: NOVEL_API.marketRadarSync,
1456
+ handler: async (req, res) => {
1457
+ if (!guard(req, res, 'POST')) return
1458
+ const body = await readJsonBody<{ foundation?: ProductionFoundation }>(req)
1459
+ const pf = body?.foundation
1460
+ if (pf === undefined) {
1461
+ writeJson(res, 400, { error: '缺少 foundation' })
1462
+ return
1463
+ }
1464
+ const synced = { genre: false, primaryMode: false, secondaryMode: false }
1465
+ if (pf.genre.existingId === undefined && pf.genre.name !== '') {
1466
+ synced.genre = addGlobalGenre({ name: pf.genre.name.slice(0, 40), description: pf.genre.description, template: pf.genre.template, children: [] })
1467
+ }
1468
+ if (pf.primaryStoryMode.existingId === undefined && pf.primaryStoryMode.name !== '') {
1469
+ synced.primaryMode = addGlobalMode({ name: pf.primaryStoryMode.name.slice(0, 40), driver: pf.primaryStoryMode.driver, readerExpectation: pf.primaryStoryMode.readerExpectation, payoffs: [], risks: [], primary: true })
1470
+ }
1471
+ if (pf.secondaryStoryMode !== undefined && pf.secondaryStoryMode !== null && pf.secondaryStoryMode.existingId === undefined && pf.secondaryStoryMode.name !== '') {
1472
+ synced.secondaryMode = addGlobalMode({ name: pf.secondaryStoryMode.name.slice(0, 40), driver: pf.secondaryStoryMode.driver, readerExpectation: pf.secondaryStoryMode.readerExpectation, payoffs: [], risks: [], primary: false })
1473
+ }
1474
+ writeJson(res, 200, { ok: true, synced })
1475
+ },
1476
+ }
1477
+
1478
+ // ------------------------------------------------- market-radar brief
1479
+ /** 开书创意简报:用选中的市场信号 + 影响模式生成。 */
1480
+ const marketRadarBriefRoute: WebRoute = {
1481
+ kind: 'exact',
1482
+ path: NOVEL_API.marketRadarBrief,
1483
+ handler: async (req, res) => {
1484
+ if (!guard(req, res, 'POST')) return
1485
+ const config = getConfig()
1486
+ const body = await readJsonBody<MarketRadarBriefRequest>(req)
1487
+ if (body === undefined || body.influenceMode === undefined) {
1488
+ writeJson(res, 400, { error: '缺少 influenceMode' })
1489
+ return
1490
+ }
1491
+ try {
1492
+ const creativeBrief = await runMarketCreativeBrief(ctx, config, body)
1493
+ writeJson(res, 200, { creativeBrief })
1494
+ } catch (error) {
1495
+ writeJson(res, 500, { error: (error as Error).message })
1496
+ }
1497
+ },
1498
+ }
1499
+
1500
+ // ------------------------------------------------------------- knowledge
1501
+ /** 书内知识库:GET 读取 / POST 增删改(按书路由)。 */
1502
+ const knowledgeRoute: WebRoute = {
1503
+ kind: 'exact',
1504
+ path: NOVEL_API.knowledge,
1505
+ handler: async (req, res) => {
1506
+ if (req.method === 'POST') {
1507
+ if (!guard(req, res, 'POST')) return
1508
+ } else {
1509
+ if (!guard(req, res, 'GET')) return
1510
+ }
1511
+ const config = getConfig()
1512
+ const qurl = new URL(req.url ?? '/', 'http://localhost')
1513
+ const body = req.method === 'POST' ? await readJsonBody<KnowledgeRequest & { bookId?: string }>(req) : undefined
1514
+ const outputDir = resolveOutputDir(config, body?.bookId ?? qurl.searchParams.get('bookId') ?? undefined)
1515
+ const project = loadProject(outputDir)
1516
+ if (project === undefined) {
1517
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1518
+ return
1519
+ }
1520
+ project.knowledgeDocs ??= []
1521
+ if (req.method === 'POST' && body !== undefined) {
1522
+ if (body.action === 'add' && body.doc !== undefined) {
1523
+ project.knowledgeDocs.push({ id: body.doc.id ?? `kd-${Date.now().toString(36)}`, title: body.doc.title, content: body.doc.content, updatedAt: new Date().toISOString() })
1524
+ } else if (body.action === 'remove' && body.id !== undefined) {
1525
+ project.knowledgeDocs = project.knowledgeDocs.filter(d => d.id !== body.id)
1526
+ } else if (body.action === 'replace' && body.doc !== undefined) {
1527
+ project.knowledgeDocs = project.knowledgeDocs.map(d => d.id === body.doc!.id ? { ...d, title: body.doc!.title, content: body.doc!.content, updatedAt: new Date().toISOString() } : d)
1528
+ }
1529
+ project.updatedAt = new Date().toISOString()
1530
+ saveProject(outputDir, project)
1531
+ }
1532
+ writeJson(res, 200, { docs: project.knowledgeDocs })
1533
+ },
1534
+ }
1535
+
1536
+ // ---------------------------------------------------------- book-analysis
1537
+ /** 书分析/拆书:输入文本 → 卖点/结构/可借鉴点/风险。 */
1538
+ const bookAnalysisRoute: WebRoute = {
1539
+ kind: 'exact',
1540
+ path: NOVEL_API.bookAnalysis,
1541
+ handler: async (req, res) => {
1542
+ if (!guard(req, res, 'POST')) return
1543
+ const config = getConfig()
1544
+ const body = await readJsonBody<BookAnalysisRequest>(req)
1545
+ if (body === undefined || (body.text ?? '').trim().length < 50) {
1546
+ writeJson(res, 400, { error: '文本过短(<50 字符)' })
1547
+ return
1548
+ }
1549
+ try {
1550
+ const result = await runBookAnalysis(ctx, config, body)
1551
+ writeJson(res, 200, { result })
1552
+ } catch (error) {
1553
+ writeJson(res, 500, { error: (error as Error).message })
1554
+ }
1555
+ },
1556
+ }
1557
+
1558
+ // ---------------------------------------------------------- idea-inspiration
1559
+ /** 创意灵感:一句话/题材 → 多方向开书灵感。 */
1560
+ const ideaInspirationRoute: WebRoute = {
1561
+ kind: 'exact',
1562
+ path: NOVEL_API.ideaInspiration,
1563
+ handler: async (req, res) => {
1564
+ if (!guard(req, res, 'POST')) return
1565
+ const config = getConfig()
1566
+ const body = await readJsonBody<IdeaInspirationRequest>(req)
1567
+ if (body === undefined || (body.idea ?? '').trim().length < 2) {
1568
+ writeJson(res, 400, { error: '请填写你的方向/一句话想法' })
1569
+ return
1570
+ }
1571
+ try {
1572
+ const result = await runIdeaInspiration(ctx, config, body)
1573
+ writeJson(res, 200, { result })
1574
+ } catch (error) {
1575
+ writeJson(res, 500, { error: (error as Error).message })
1576
+ }
1577
+ },
1578
+ }
1579
+
1580
+ // ------------------------------------------------------- idea-inspiration/market
1581
+ /** 雷达→灵感:基于市场信号/生产底座/创意简报生成开书灵感。 */
1582
+ const marketIdeaInspirationRoute: WebRoute = {
1583
+ kind: 'exact',
1584
+ path: NOVEL_API.ideaInspirationMarket,
1585
+ handler: async (req, res) => {
1586
+ if (!guard(req, res, 'POST')) return
1587
+ const config = getConfig()
1588
+ const body = await readJsonBody<{ signals?: import('./protocol.ts').MarketRadarSignal[]; foundation?: import('./protocol.ts').ProductionFoundation; brief?: import('./protocol.ts').MarketCreativeBrief; count?: number }>(req)
1589
+ if (body === undefined) {
1590
+ writeJson(res, 400, { error: '缺少市场分析参数' })
1591
+ return
1592
+ }
1593
+ try {
1594
+ const result = await runMarketIdeaInspiration(ctx, config, body)
1595
+ writeJson(res, 200, { result })
1596
+ } catch (error) {
1597
+ writeJson(res, 500, { error: (error as Error).message })
1598
+ }
1599
+ },
1600
+ }
1601
+
1602
+ // ------------------------------------------------------------- director
1603
+ /** 自动导演编排建议:基于全书上下文。 */
1604
+ const directorRoute: WebRoute = {
1605
+ kind: 'exact',
1606
+ path: NOVEL_API.director,
1607
+ handler: async (req, res) => {
1608
+ if (!guard(req, res, 'POST')) return
1609
+ const config = getConfig()
1610
+ const body = await readJsonBody<DirectorRequest & { bookId?: string }>(req)
1611
+ const outputDir = resolveOutputDir(config, body?.bookId)
1612
+ const project = loadProject(outputDir)
1613
+ if (project === undefined) {
1614
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1615
+ return
1616
+ }
1617
+ try {
1618
+ const result = await runDirectorAdvice(ctx, config, project, body ?? {})
1619
+ writeJson(res, 200, { result })
1620
+ } catch (error) {
1621
+ writeJson(res, 500, { error: (error as Error).message })
1622
+ }
1623
+ },
1624
+ }
1625
+
1626
+ // ------------------------------------------------------------- director/todos
1627
+ /** 自动导演「采纳」出的书内待办:GET 读取 / POST 增删勾选(按书路由)。 */
1628
+ const directorTodosRoute: WebRoute = {
1629
+ kind: 'exact',
1630
+ path: NOVEL_API.directorTodos,
1631
+ handler: async (req, res) => {
1632
+ type DirectorTodosReq = { op?: 'add' | 'toggle' | 'remove'; text?: string; source?: DirectorTodo['source']; id?: string; bookId?: string }
1633
+ let body: DirectorTodosReq | undefined
1634
+ if (req.method === 'POST') {
1635
+ if (!guard(req, res, 'POST')) return
1636
+ body = await readJsonBody<DirectorTodosReq>(req)
1637
+ } else {
1638
+ if (!guard(req, res, 'GET')) return
1639
+ }
1640
+ const config = getConfig()
1641
+ const outputDir = resolveOutputDir(config, body?.bookId)
1642
+ const project = loadProject(outputDir)
1643
+ if (project === undefined) {
1644
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1645
+ return
1646
+ }
1647
+ project.todos ??= []
1648
+ if (req.method === 'POST' && body !== undefined) {
1649
+ if (body.op === 'add' && (body.text ?? '').trim() !== '') {
1650
+ project.todos.unshift({ id: `td-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, text: body.text!.trim(), source: body.source ?? 'fix', done: false, createdAt: new Date().toISOString() })
1651
+ } else if (body.op === 'toggle' && body.id !== undefined) {
1652
+ project.todos = project.todos.map(t => t.id === body.id ? { ...t, done: !t.done } : t)
1653
+ } else if (body.op === 'remove' && body.id !== undefined) {
1654
+ project.todos = project.todos.filter(t => t.id !== body.id)
1655
+ }
1656
+ project.updatedAt = new Date().toISOString()
1657
+ saveProject(outputDir, project)
1658
+ }
1659
+ writeJson(res, 200, { todos: project.todos })
1660
+ },
1661
+ }
1662
+
1663
+ // ------------------------------------------------------------- llm-live
1664
+ /** AI 创作实况:订阅 LLM 实时事件流(NDJSON;订阅时先重放最近缓冲)。 */
1665
+ const llmLiveRoute: WebRoute = {
1666
+ kind: 'exact',
1667
+ path: NOVEL_API.llmLive,
1668
+ handler: async (req, res) => {
1669
+ if (req.method !== 'GET') {
1670
+ writeJson(res, 405, { error: 'GET only' })
1671
+ return
1672
+ }
1673
+ if (!isLoopbackRequest(req)) {
1674
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
1675
+ return
1676
+ }
1677
+ res.writeHead(200, {
1678
+ 'content-type': 'text/event-stream; charset=utf-8',
1679
+ 'cache-control': 'no-cache',
1680
+ 'x-accel-buffering': 'no',
1681
+ 'referrer-policy': 'no-referrer',
1682
+ })
1683
+ const unsubscribe = subscribeLiveFeed((frame) => {
1684
+ try { res.write('data: ' + JSON.stringify(frame) + '\n\n') } catch { /* client closed */ }
1685
+ })
1686
+ const hb = setInterval(() => { try { res.write(': ping\n\n') } catch { /* closed */ } }, 25000)
1687
+ // 关键:让 handler 一直挂起,直到客户端关闭,避免 WebRoute 框架在 handler 返回后关闭响应。
1688
+ await new Promise<void>((resolve) => {
1689
+ res.on('close', () => {
1690
+ clearInterval(hb)
1691
+ unsubscribe()
1692
+ resolve()
1693
+ })
1694
+ })
1695
+ },
1696
+ }
1697
+
1200
1698
  // ------------------------------------------------------------- bookshelf
1201
1699
  const bookshelfRoute: WebRoute = {
1202
1700
  kind: 'exact',
@@ -1408,9 +1906,15 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1408
1906
  path: NOVEL_API.audit,
1409
1907
  handler: async (req, res) => {
1410
1908
  if (!guard(req, res, 'POST')) return
1411
- const config = getConfig()
1412
- const project = requireProject(res)
1413
- if (project === undefined) return
1909
+ const body = await readJsonBody<{ bookId?: string }>(req)
1910
+ const config0 = getConfig()
1911
+ const outputDir = resolveOutputDir(config0, body?.bookId)
1912
+ const config = { ...config0, outputDir }
1913
+ const project = loadProject(outputDir)
1914
+ if (project === undefined) {
1915
+ writeJson(res, 400, { error: '输出目录中没有项目' })
1916
+ return
1917
+ }
1414
1918
  const startedAt = new Date().toISOString()
1415
1919
  auditState = { status: 'running', startedAt, totalBatches: 0, completedBatches: 0 }
1416
1920
  try {
@@ -1433,6 +1937,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1433
1937
  issues,
1434
1938
  auditedChapters,
1435
1939
  auditedAt: auditState.finishedAt!,
1940
+ model: config.auditModel || config.model,
1436
1941
  }
1437
1942
  writeJson(res, 200, response)
1438
1943
  } catch (error) {
@@ -2930,9 +3435,10 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2930
3435
  path: NOVEL_API.runStart,
2931
3436
  handler: async (req, res) => {
2932
3437
  if (!guard(req, res, 'POST')) return
2933
- const body = await readJsonBody<RunStartRequest>(req)
3438
+ const body = await readJsonBody<RunStartRequest & { bookId?: string }>(req)
2934
3439
  const config = getConfig()
2935
- const project = loadProject(config.outputDir)
3440
+ const outputDir = resolveOutputDir(config, body?.bookId)
3441
+ const project = loadProject(outputDir)
2936
3442
  if (project === undefined) {
2937
3443
  writeJson(res, 400, { error: '输出目录中没有项目' })
2938
3444
  return
@@ -2949,7 +3455,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2949
3455
  return
2950
3456
  }
2951
3457
  try {
2952
- const state = await runner.start(startNo, endNo)
3458
+ const state = await runner.start(startNo, endNo, outputDir)
2953
3459
  writeJson(res, 200, state satisfies RunState)
2954
3460
  } catch (error) {
2955
3461
  writeJson(res, 409, { error: (error as Error).message })
@@ -3340,6 +3846,20 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
3340
3846
  chapterSaveRoute,
3341
3847
  assetsRoute,
3342
3848
  styleEngineRoute,
3849
+ styleFormulaRoute,
3850
+ styleDetectRoute,
3851
+ marketRadarRoute,
3852
+ marketRadarScanRoute,
3853
+ marketRadarApplyRoute,
3854
+ marketRadarSyncRoute,
3855
+ marketRadarBriefRoute,
3856
+ knowledgeRoute,
3857
+ bookAnalysisRoute,
3858
+ ideaInspirationRoute,
3859
+ marketIdeaInspirationRoute,
3860
+ directorRoute,
3861
+ directorTodosRoute,
3862
+ llmLiveRoute,
3343
3863
  assistantRoute,
3344
3864
  assistantHistoryRoute,
3345
3865
  assistantClearRoute,