@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/assistant.ts CHANGED
@@ -29,6 +29,11 @@ import {
29
29
  reviewChapter,
30
30
  saveProject,
31
31
  summarizeAndExtractFacts,
32
+ runBookAnalysis,
33
+ runDirectorAdvice,
34
+ breakdownBook,
35
+ auditBook,
36
+ generateBlurb,
32
37
  } from './engine.ts'
33
38
  import { rewriteChapterStream } from './engine.ts'
34
39
 
@@ -36,7 +41,7 @@ import { rewriteChapterStream } from './engine.ts'
36
41
  export const ASSISTANT_HISTORY_FILE = 'novel-assistant.jsonl'
37
42
 
38
43
  /** Max tool-call rounds per user turn (safety bound). */
39
- const MAX_TOOL_ROUNDS = 6
44
+ const MAX_TOOL_ROUNDS = 4
40
45
 
41
46
  /** Max history messages kept in context (older ones summarized away). */
42
47
  const MAX_HISTORY_MESSAGES = 18
@@ -174,11 +179,12 @@ function assistantSystemPrompt(project: ProjectState): string {
174
179
  '工作规则(严格遵守):',
175
180
  '1. 全量知情:回答和修改必须基于项目真实数据,禁止编造书中不存在的设定。需要完整信息时,先调用 book_overview 获取全书上下文(总纲全文/道藏/大世界/编年录/全部章节要点/暗线/卷首语);需要某章正文用 chapter_text。',
176
181
  '2. 修改流程:改前用一句话说明意图 → 执行工具 → 改后简要汇报。',
177
- '3. 连锁维护:改动可能波及其它位置(其它章节、设定、编年录、卷首语)时,执行后主动调用 impact_analysis 分析影响面,并把「必须同步」的项一并处理或明确提示作者逐项确认。',
182
+ '3. 连锁维护(只用于大改动,不要滥用):改动**整段大纲/道藏规则/章节正文**这类可能冲突的大改动时,才主动调用 impact_analysis;**新增知识库文档、记待办、加剧情线、改暗线状态**这类轻量增改,做完直接一句话汇报即可,禁止再连锁调用其它工具。',
178
183
  '4. 删除红线:删除章节、清空设定等破坏性操作必须等作者明确同意。',
179
- '5. 品质门槛:建议必须具体——指出问题在哪一章、哪一段、哪一句,并给出可落地的改法;禁止"建议增强冲突"这类空话。',
180
- '6. 设定忠诚:忠于总纲、道藏、大世界、编年录;发现书中已有内容与设定冲突时,主动指出并给修正方案。',
181
- '7. 中文回复,简洁有干货。',
184
+ '5. 收敛执行(最重要):每轮只做用户明确要求的那一件事。执行完立即用一句话汇报并结束本轮;**禁止**重复调用已做过的工具、**禁止**为了"保险/确认/顺便"再调用无关工具。若用户说"记住这个设定/记一条待办/加一条剧情线",就调对应工具一次 → 汇报 → 停。',
185
+ '6. 品质门槛:建议必须具体——指出问题在哪一章、哪一段、哪一句,并给出可落地的改法;禁止"建议增强冲突"这类空话。',
186
+ '7. 设定忠诚:忠于总纲、道藏、大世界、编年录;发现书中已有内容与设定冲突时,主动指出并给修正方案。',
187
+ '8. 中文回复,简洁有干货。',
182
188
  '',
183
189
  '可用工具:',
184
190
  '- book_overview:{"scope": "recent|full|volume:2"(可选,默认 recent)}。返回全书上下文包(总纲/道藏/大世界/章节要点/编年录/暗线/卷首语)。recent=最近30章;full=全部章节(书很长时慎用);volume:N=只看第N卷。',
@@ -199,6 +205,18 @@ function assistantSystemPrompt(project: ProjectState): string {
199
205
  '- assets_set_genre:{"name": "题材名", "description": "题材说明(可选)"}。设置本书题材基底。',
200
206
  '- assets_set_progression:{"name": "模式名", "driver": "驱动力", "primary": true/false}。设置主/辅助推进模式。',
201
207
  '- assets_add_rule:{"name": "规则名(可选)", "avoid": "要避免的表达问题", "fix": "修正方向(可选)}。新增反 AI 规则。',
208
+ '- book_analysis:{"scope": "recent|full|volume:N"(可选)}。拆书:提炼本书卖点/结构/可借鉴/风险(分析当前书,不照搬外部作品)。',
209
+ '- director_advice:{"focus": "聚焦方向(可选)"}。自动导演:基于全书给出下一阶段剧情节点/节奏板/风险/修复建议。',
210
+ '- knowledge_add:{"title": "标题", "content": "内容"}。往本书知识库加一条自由参考文档(生成时会被检索注入)。',
211
+ '- knowledge_search:{"query": "关键词"}。在本书知识库检索相关内容。',
212
+ '- plotline_list:无参数。查看本书当前剧情线。',
213
+ '- plotline_add:{"name": "线名", "goal": "目标", "kind": "main|branch|character|mystery(可选)"}。新增一条剧情线。',
214
+ '- director_todo_add:{"text": "待办内容", "source": "risk|fix(可选)"}。把一条风险/修复记成导演待办。',
215
+ '- director_todo_list:无参数。查看本书导演待办。',
216
+ '- knowledge_list:无参数。**列出本书全部知识库文档**(标题+内容)。作者说"收集/汇总/列出所有知识库"时调用它。',
217
+ '- breakdown:{"scope": "recent|all|volume:N"(可选,默认 recent), "preset": "quick|standard"(可选)}。书内拆书分析:对本书已写章节做结构/人物/文风/卖点体检。',
218
+ '- audit:无参数。全书一致性质检(分批扫描章节+设定+事实库,聚合矛盾)。',
219
+ '- blurb:{"partial": "已写开头(可选)"}。AI 生成/补全小说简介并保存到本书。',
202
220
  '',
203
221
  '回答质量要求(非常重要):',
204
222
  '- 具体:回答必须引用项目里的真实内容(人名、境界、章节、暗线、设定),禁止空泛套话。快照里没有的信息,先调用工具获取(chapter_text / outline_text)再回答。',
@@ -218,7 +236,7 @@ function assistantSystemPrompt(project: ProjectState): string {
218
236
  '- 铁律:只要你想执行任何操作,你的【整个回复】必须只包含一个动作标签,禁止先说话、禁止解释"我要做什么"、禁止铺垫——直接输出标签。',
219
237
  '- 如果你收到「格式提示」(宿主说你没有输出动作标签):你的下一条回复必须只输出动作标签,禁止再解释、禁止再道歉、禁止再描述计划。',
220
238
  '- 工具调用是自动的:你输出标签后,宿主会执行并把结果反馈给你,你再基于结果继续。',
221
- '- 每次回复最多调用 1 个动作;执行结果会反馈给你,你可以继续讨论或再调用。',
239
+ '- 每次回复最多调用 1 个动作;**完成用户本轮要求后立即用一句话汇报并停止,不要继续追加工具调用**(除非用户在下一轮明确要求)。',
222
240
  '- 需要先看总纲/章节再决定怎么改?那就先输出一个 outline_text / chapter_text 的标签,等结果回来。',
223
241
  '- chapter_rewrite 的 target 参数:从章节正文中复制一小段(一句话或几句话即可),不要带换行、不要带引号,取连续文本片段。',
224
242
  '- 如果工具执行失败(例如片段未找到),根据错误信息修正参数后自动重试一次,不要直接放弃或让作者手动操作。',
@@ -481,6 +499,123 @@ export async function* executeAction(
481
499
  saveProject(outputDir, project)
482
500
  return `已新增反 AI 规则「${name !== '' ? name : avoid.slice(0, 20)}」`
483
501
  }
502
+ case 'book_analysis': {
503
+ // 拆书:分析当前书(默认最近30章要点)的卖点/结构/可借鉴/风险。
504
+ const scopeArg = str(args.scope)
505
+ const scope = scopeArg === 'full' ? 'full' as const : scopeArg === 'all' ? 'full' as const : (/^volume:(\d+)$/.test(scopeArg) ? Number(scopeArg.slice(7)) : 'recent' as const)
506
+ const text = bookOverview(project, scope)
507
+ const result = await runBookAnalysis(ctx, config, { text })
508
+ return [
509
+ '拆书结果:',
510
+ '卖点:',
511
+ ...(result.sellingPoints ?? []).map(s => `- ${s}`),
512
+ '结构:',
513
+ ...(result.structure ?? []).map(s => `- ${s}`),
514
+ '可借鉴:',
515
+ ...(result.lessons ?? []).map(s => `- ${s}`),
516
+ '风险:',
517
+ ...(result.risks ?? []).map(s => `- ${s}`),
518
+ ].join('\n')
519
+ }
520
+ case 'director_advice': {
521
+ // 自动导演:基于全书上下文的下一阶段编排 + 修复再平衡。
522
+ const focus = str(args.focus)
523
+ const result = await runDirectorAdvice(ctx, config, project, { focus })
524
+ return [
525
+ '自动导演建议:',
526
+ `总体判断:${result.summary}`,
527
+ '下一阶段节点:',
528
+ ...(result.nextArc ?? []).map(s => `- ${s}`),
529
+ `节奏板:${result.pacing}`,
530
+ '风险提示:',
531
+ ...(result.risks ?? []).map(s => `- ${s}`),
532
+ '需要修复/再平衡:',
533
+ ...(result.fixes ?? []).map(s => `- ${s}`),
534
+ ].join('\n')
535
+ }
536
+ case 'knowledge_add': {
537
+ const title = str(args.title).trim()
538
+ const content = str(args.content).trim()
539
+ if (title === '' || content === '') throw new Error('knowledge_add 需要 title 和 content')
540
+ project.knowledgeDocs ??= []
541
+ project.knowledgeDocs.push({ id: `kd-${Date.now().toString(36)}`, title, content, updatedAt: new Date().toISOString() })
542
+ project.updatedAt = new Date().toISOString()
543
+ saveProject(outputDir, project)
544
+ return `已加入知识库「${title}」(当前 ${project.knowledgeDocs.length} 篇)`
545
+ }
546
+ case 'knowledge_search': {
547
+ const q = str(args.query).trim()
548
+ if (q === '') throw new Error('knowledge_search 需要 query')
549
+ const ql = q.toLowerCase()
550
+ const hits = (project.knowledgeDocs ?? []).filter(d => d.title.toLowerCase().includes(ql) || d.content.toLowerCase().includes(ql)).slice(-5)
551
+ if (hits.length === 0) return '知识库中未找到相关内容。'
552
+ return `知识库中与「${q}」相关(${hits.length} 篇):\n` + hits.map(d => `- 【${d.title}】\n${d.content.slice(0, 400)}`).join('\n')
553
+ }
554
+ case 'plotline_list': {
555
+ const lines = project.plotlines ?? []
556
+ if (lines.length === 0) return '本书还没有剧情线。'
557
+ return `本书剧情线(${lines.length} 条):\n` + lines.map(l => `- [${l.status}] ${l.name}(${l.kind}):${l.goal}${l.progress !== '' ? `|${l.progress}` : ''}`).join('\n')
558
+ }
559
+ case 'plotline_add': {
560
+ const name = str(args.name).trim()
561
+ const goal = str(args.goal).trim()
562
+ if (name === '' || goal === '') throw new Error('plotline_add 需要 name 和 goal')
563
+ const kindArg = str(args.kind)
564
+ const kind = (['main', 'branch', 'character', 'mystery'].includes(kindArg) ? kindArg : 'branch') as 'main' | 'branch' | 'character' | 'mystery'
565
+ const statusArg = str(args.status)
566
+ const status = (['active', 'paused', 'resolved', 'abandoned'].includes(statusArg) ? statusArg : 'active') as 'active' | 'paused' | 'resolved' | 'abandoned'
567
+ project.plotlines ??= []
568
+ project.plotlines.push({ id: `pl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, name, kind, goal, progress: str(args.progress), status, chapters: Array.isArray(args.chapters) ? (args.chapters as number[]) : [], createdAt: new Date().toISOString() })
569
+ project.updatedAt = new Date().toISOString()
570
+ saveProject(outputDir, project)
571
+ return `已加入剧情线「${name}」(当前 ${project.plotlines.length} 条)`
572
+ }
573
+ case 'director_todo_add': {
574
+ const text = str(args.text).trim()
575
+ if (text === '') throw new Error('director_todo_add 需要 text')
576
+ const source = args.source === 'fix' ? 'fix' as const : 'risk' as const
577
+ project.todos ??= []
578
+ project.todos.unshift({ id: `td-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, text, source, done: false, createdAt: new Date().toISOString() })
579
+ project.updatedAt = new Date().toISOString()
580
+ saveProject(outputDir, project)
581
+ return `已加入导演待办「${text.slice(0, 30)}」(当前 ${project.todos.length} 条)`
582
+ }
583
+ case 'director_todo_list': {
584
+ const todos = project.todos ?? []
585
+ if (todos.length === 0) return '还没有导演待办。'
586
+ return `导演待办(${todos.length} 条):\n` + todos.map(t => `- ${t.done ? '[已处理]' : '[待处理]'} ${t.text}`).join('\n')
587
+ }
588
+ case 'knowledge_list': {
589
+ const docs = project.knowledgeDocs ?? []
590
+ if (docs.length === 0) return '本书知识库目前为空。'
591
+ return `本书知识库(${docs.length} 篇):\n` + docs.map((d, i) => `[${i + 1}]《${d.title}》\n${d.content}`).join('\n\n')
592
+ }
593
+ case 'breakdown': {
594
+ // 书内拆书分析:对本书已写章节做结构/人物/文风/卖点体检。
595
+ const scope = str(args.scope) === 'all' ? 'all' : /^volume:\d+$/.test(str(args.scope)) ? str(args.scope) : 'recent'
596
+ const preset = str(args.preset) === 'standard' ? 'standard' as const : 'quick' as const
597
+ const result = await breakdownBook(ctx, config, project, outputDir, scope, preset)
598
+ const lines = [`拆书分析结果(扫描 ${result.chaptersScanned} 章):`]
599
+ for (const s of result.sections ?? []) {
600
+ lines.push(`\n【${s.title}】\n${s.markdown}`)
601
+ }
602
+ return lines.join('\n')
603
+ }
604
+ case 'audit': {
605
+ // 全书一致性质检:LLM 分批扫描已生成章节 + 设定 + 事实库。
606
+ const issues = await auditBook(ctx, config, project, outputDir)
607
+ if (issues.length === 0) return '全书质检:未发现明显矛盾。'
608
+ return `全书质检发现 ${issues.length} 处问题:\n` + issues.map(it => `- [${it.severity}] 第${it.chapterNo}章:${it.item}${it.suggestion !== '' ? ` → ${it.suggestion}` : ''}`).join('\n')
609
+ }
610
+ case 'blurb': {
611
+ // 小说简介:AI 生成/补全并保存到项目。
612
+ const partial = str(args.partial)
613
+ const text = await generateBlurb(ctx, config, project, partial)
614
+ project.blurb = text
615
+ project.updatedAt = new Date().toISOString()
616
+ saveProject(outputDir, project)
617
+ return `简介已生成/更新:\n${text}`
618
+ }
484
619
  default:
485
620
  throw new Error(`未知工具 ${name}`)
486
621
  }
@@ -599,7 +734,8 @@ export async function* runAssistantTurn(
599
734
  ): AsyncGenerator<
600
735
  | { frame: 'delta'; text: string }
601
736
  | { frame: 'tool'; name: string; status: 'start' | 'done' | 'error'; detail?: string }
602
- | { frame: 'toolDelta'; name: string; text: string },
737
+ | { frame: 'toolDelta'; name: string; text: string }
738
+ | { frame: 'toolResult'; name: string; text: string },
603
739
  void,
604
740
  unknown
605
741
  > {
@@ -612,6 +748,8 @@ export async function* runAssistantTurn(
612
748
  appendHistory(outputDir, userEntry)
613
749
 
614
750
  let round = 0
751
+ /** 最大迭代次数(防空转的总保险,与「已执行动作数 round」分开,避免计数混淆)。 */
752
+ let iterations = 0
615
753
  /** 已提示模型输出动作标签的次数(0 = 尚未提示;超过上限则按纯文字回复结束,防死循环)。 */
616
754
  let nudged = 0
617
755
  const MAX_NUDGES = 6
@@ -635,6 +773,10 @@ export async function* runAssistantTurn(
635
773
  assets_set_genre: /(题材)/,
636
774
  assets_set_progression: /(推进)/,
637
775
  assets_add_rule: /(规则|文戒|反AI)/,
776
+ knowledge_add: /(知识库|记住|补充|收进|参考|资料)/,
777
+ plotline_add: /(剧情线|长线|加入剧情线|线名)/,
778
+ director_todo_add: /(待办|风险|修复|记一下|记一条)/,
779
+ blurb: /(简介|封面|小说简介)/,
638
780
  }
639
781
  /** 本轮已放行过写操作:后续写操作(生成→审稿→修订闭环)不再逐个拦截。 */
640
782
  let writeUnlocked = false
@@ -655,7 +797,7 @@ export async function* runAssistantTurn(
655
797
  return false
656
798
  }
657
799
  for (;;) {
658
- if (round++ > 20) break
800
+ if (iterations++ > 20) break
659
801
  const reply = await chatOnce(ctx, config, system, history)
660
802
 
661
803
  // 异常输出防护:全 hex/二进制乱码(模型偶发把回复编码成十六进制)——丢弃重试一次。
@@ -683,7 +825,9 @@ export async function* runAssistantTurn(
683
825
  // 避免"先查一遍编年录"这类措辞漏网被静默当作纯聊天。
684
826
  const mentionsTarget = /(编年录|道藏|暗线|总纲|卷首语|章节|正文|规则|红线|伏笔|简介|大纲|事实|设定|世界|角色|人物|第\s*\d+\s*章)/.test(reply)
685
827
  const strayTag = /<[a-z_-]*action[^>]*>/.test(reply)
686
- if ((intendsAction || mentionsTarget || strayTag) && nudged < MAX_NUDGES) {
828
+ // 只在「本轮还没执行过任何工具」时才逼它出标签;一旦已经执行过(round>0),
829
+ // 后续的纯文字回复一律当作收尾,绝不 nudge,避免「已加入…设定」被误判成还想操作而反复执行。
830
+ if ((intendsAction || mentionsTarget || strayTag) && round === 0 && nudged < MAX_NUDGES) {
687
831
  // 用户消息本身没有写意图时,引导模型直接文字回答(不要被逼出写操作标签)。
688
832
  const userWriteIntent = Object.values(WRITE_TOOL_KEYS).some(re => re.test(userMessage))
689
833
  const nudge = nudged === 0
@@ -743,6 +887,7 @@ export async function* runAssistantTurn(
743
887
  }
744
888
  }
745
889
  yield { frame: 'tool', name, status: 'done', detail: result.slice(0, 200) }
890
+ yield { frame: 'toolResult', name, text: result.slice(0, 4000) }
746
891
  } catch (error) {
747
892
  result = `执行失败:${(error as Error).message}`
748
893
  yield { frame: 'tool', name, status: 'error', detail: (error as Error).message }
package/src/client/api.ts CHANGED
@@ -64,12 +64,29 @@ async function readJson<T>(response: Response): Promise<T> {
64
64
  return body as T
65
65
  }
66
66
 
67
- /** POST JSON, return parsed JSON. */
67
+ /** 面板所绑定的当前书(打开时锁定;每次书级请求带上,避免被全局 active 串书)。 */
68
+ let currentBookId: string | null = null
69
+ export function setCurrentBook(id: string | null): void {
70
+ currentBookId = id
71
+ }
72
+
73
+ /** 给 GET 路径追加当前书 bookId(读接口也按书路由)。 */
74
+ function withBookId(path: string): string {
75
+ if (currentBookId === null) return path
76
+ const sep = path.includes('?') ? '&' : '?'
77
+ return `${path}${sep}bookId=${encodeURIComponent(currentBookId)}`
78
+ }
79
+
80
+ /** POST JSON, return parsed JSON. 若已绑定当前书,自动把 bookId 注入请求体。 */
68
81
  async function postJson<T>(path: string, payload: unknown): Promise<T> {
82
+ let body = payload
83
+ if (currentBookId !== null && payload !== null && typeof payload === 'object' && !Array.isArray(payload)) {
84
+ body = { ...(payload as Record<string, unknown>), bookId: currentBookId }
85
+ }
69
86
  const response = await fetch(path, {
70
87
  method: 'POST',
71
88
  headers: { 'content-type': 'application/json' },
72
- body: JSON.stringify(payload),
89
+ body: JSON.stringify(body),
73
90
  })
74
91
  return readJson<T>(response)
75
92
  }
@@ -77,7 +94,7 @@ async function postJson<T>(path: string, payload: unknown): Promise<T> {
77
94
  /** The browser half's only data entry point. */
78
95
  export class NovelApi {
79
96
  async status(): Promise<StatusResponse> {
80
- const response = await fetch(NOVEL_API.status)
97
+ const response = await fetch(withBookId(NOVEL_API.status))
81
98
  return readJson<StatusResponse>(response)
82
99
  }
83
100
 
@@ -153,7 +170,7 @@ export class NovelApi {
153
170
  }
154
171
 
155
172
  async chapter(no: number): Promise<ChapterResponse> {
156
- const response = await fetch(`${NOVEL_API.chapter}?no=${no}`)
173
+ const response = await fetch(withBookId(`${NOVEL_API.chapter}?no=${no}`))
157
174
  return readJson<ChapterResponse>(response)
158
175
  }
159
176
 
@@ -201,6 +218,88 @@ export class NovelApi {
201
218
  return postJson<import('../protocol.ts').AuditResponse>(NOVEL_API.audit, {})
202
219
  }
203
220
 
221
+ /** 热门题材雷达:信号 + 生产底座。 */
222
+ async marketRadar(req: import('../protocol.ts').MarketRadarRequest): Promise<{ result: import('../protocol.ts').MarketRadarResult }> {
223
+ return postJson<{ result: import('../protocol.ts').MarketRadarResult }>(NOVEL_API.marketRadar, req)
224
+ }
225
+
226
+ /** 真实榜单扫榜:抓取公开榜单,返回分组候选。 */
227
+ async marketRadarScan(req: { platforms?: string[] }): Promise<{ result: { scannedAt: string; groups: any[] } }> {
228
+ return postJson<{ result: { scannedAt: string; groups: any[] } }>(NOVEL_API.marketRadarScan, req)
229
+ }
230
+
231
+ /** 把雷达生产底座里的新资产同步进全局资源库(跨书复用;已有名字跳过)。 */
232
+ async marketRadarSync(req: { foundation: import('../protocol.ts').ProductionFoundation }): Promise<{ ok: boolean; synced: { genre: boolean; primaryMode: boolean; secondaryMode: boolean } }> {
233
+ return postJson<{ ok: boolean; synced: { genre: boolean; primaryMode: boolean; secondaryMode: boolean } }>(NOVEL_API.marketRadarSync, req)
234
+ }
235
+
236
+ /** 书内知识库:读取。 */
237
+ async knowledgeList(): Promise<{ docs: import('../protocol.ts').KnowledgeDoc[] }> {
238
+ const response = await fetch(withBookId(NOVEL_API.knowledge))
239
+ return readJson<{ docs: import('../protocol.ts').KnowledgeDoc[] }>(response)
240
+ }
241
+
242
+ /** 书内知识库:新增。 */
243
+ async knowledgeAdd(doc: { title: string; content: string }): Promise<{ docs: import('../protocol.ts').KnowledgeDoc[] }> {
244
+ return postJson<{ docs: import('../protocol.ts').KnowledgeDoc[] }>(NOVEL_API.knowledge, { action: 'add', doc })
245
+ }
246
+
247
+ /** 书内知识库:删除。 */
248
+ async knowledgeRemove(id: string): Promise<{ docs: import('../protocol.ts').KnowledgeDoc[] }> {
249
+ return postJson<{ docs: import('../protocol.ts').KnowledgeDoc[] }>(NOVEL_API.knowledge, { action: 'remove', id })
250
+ }
251
+
252
+ /** 书分析/拆书。 */
253
+ async bookAnalysis(text: string): Promise<{ result: import('../protocol.ts').BookAnalysisResult }> {
254
+ return postJson<{ result: import('../protocol.ts').BookAnalysisResult }>(NOVEL_API.bookAnalysis, { text })
255
+ }
256
+
257
+ /** 创意灵感。 */
258
+ async ideaInspiration(idea: string, count = 5): Promise<{ result: import('../protocol.ts').IdeaInspirationResult }> {
259
+ return postJson<{ result: import('../protocol.ts').IdeaInspirationResult }>(NOVEL_API.ideaInspiration, { idea, count })
260
+ }
261
+
262
+ /** 雷达→灵感:基于市场信号/生产底座/创意简报生成开书灵感。 */
263
+ async marketIdeaInspiration(req: { signals?: import('../protocol.ts').MarketRadarSignal[]; foundation?: import('../protocol.ts').ProductionFoundation; brief?: import('../protocol.ts').MarketCreativeBrief; count?: number }): Promise<{ result: import('../protocol.ts').IdeaInspirationResult }> {
264
+ return postJson<{ result: import('../protocol.ts').IdeaInspirationResult }>(NOVEL_API.ideaInspirationMarket, req)
265
+ }
266
+
267
+ /** 自动导演编排建议:基于全书上下文。 */
268
+ async director(focus?: string): Promise<{ result: import('../protocol.ts').DirectorAdvice }> {
269
+ return postJson<{ result: import('../protocol.ts').DirectorAdvice }>(NOVEL_API.director, { focus })
270
+ }
271
+
272
+ /** 自动导演「采纳」出的书内待办:读取。 */
273
+ async directorTodosList(): Promise<{ todos: import('../protocol.ts').DirectorTodo[] }> {
274
+ const response = await fetch(withBookId(NOVEL_API.directorTodos))
275
+ return readJson<{ todos: import('../protocol.ts').DirectorTodo[] }>(response)
276
+ }
277
+
278
+ /** 待办:新增(来源 risk/fix)。 */
279
+ async directorTodosAdd(text: string, source: 'risk' | 'fix'): Promise<{ todos: import('../protocol.ts').DirectorTodo[] }> {
280
+ return postJson<{ todos: import('../protocol.ts').DirectorTodo[] }>(NOVEL_API.directorTodos, { op: 'add', text, source })
281
+ }
282
+
283
+ /** 待办:勾选/取消。 */
284
+ async directorTodosToggle(id: string): Promise<{ todos: import('../protocol.ts').DirectorTodo[] }> {
285
+ return postJson<{ todos: import('../protocol.ts').DirectorTodo[] }>(NOVEL_API.directorTodos, { op: 'toggle', id })
286
+ }
287
+
288
+ /** 待办:删除。 */
289
+ async directorTodosRemove(id: string): Promise<{ todos: import('../protocol.ts').DirectorTodo[] }> {
290
+ return postJson<{ todos: import('../protocol.ts').DirectorTodo[] }>(NOVEL_API.directorTodos, { op: 'remove', id })
291
+ }
292
+
293
+ /** 用选中的市场信号 + 影响模式生成开书创意简报。 */
294
+ async marketRadarBrief(req: import('../protocol.ts').MarketRadarBriefRequest): Promise<{ creativeBrief: import('../protocol.ts').MarketCreativeBrief }> {
295
+ return postJson<{ creativeBrief: import('../protocol.ts').MarketCreativeBrief }>(NOVEL_API.marketRadarBrief, req)
296
+ }
297
+
298
+ /** 把雷达生产底座一键应用到某本书(写入项目资产/开书定盘)。 */
299
+ async marketRadarApply(req: { bookId?: string; foundation: import('../protocol.ts').ProductionFoundation }): Promise<{ ok: boolean; bookName: string }> {
300
+ return postJson<{ ok: boolean; bookName: string }>(NOVEL_API.marketRadarApply, req)
301
+ }
302
+
204
303
  /** 角色卡刷新(基于事实库聚合)。 */
205
304
  async charactersRefresh(): Promise<{ cards: import('../protocol.ts').RoleStatusCard[] }> {
206
305
  return postJson<{ cards: import('../protocol.ts').RoleStatusCard[] }>(NOVEL_API.charactersRefresh, {})
@@ -395,7 +494,7 @@ export class NovelApi {
395
494
 
396
495
  /** Get project writing assets + built-in libraries. */
397
496
  async assets(): Promise<AssetsResponse> {
398
- const response = await fetch(NOVEL_API.assets)
497
+ const response = await fetch(withBookId(NOVEL_API.assets))
399
498
  return readJson<AssetsResponse>(response)
400
499
  }
401
500
 
@@ -514,7 +613,7 @@ export class NovelApi {
514
613
 
515
614
  /** Load the persisted assistant conversation. */
516
615
  async assistantHistory(): Promise<import('../protocol.ts').AssistantMessage[]> {
517
- const response = await fetch(NOVEL_API.assistantHistory)
616
+ const response = await fetch(withBookId(NOVEL_API.assistantHistory))
518
617
  const body = await readJson<{ messages: import('../protocol.ts').AssistantMessage[] }>(response)
519
618
  return body.messages
520
619
  }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * 浏览器端「AI 创作实况」钩子:连接 /llm-live/stream (SSE),按 LLM 调用分组会话。
3
+ * 轻量实现,不持久化缓存(对齐上游 useLlmLiveFeed 的核心行为)。
4
+ */
5
+
6
+ import { useEffect, useMemo, useRef, useState } from 'react'
7
+ import { NOVEL_API } from '../protocol.ts'
8
+
9
+ export interface LlmLiveFrame {
10
+ type: 'session_started' | 'output_delta' | 'reasoning_delta' | 'phase_changed' | 'session_completed'
11
+ sessionId: string
12
+ label?: string
13
+ model?: string
14
+ at: string
15
+ content?: string
16
+ totalChars?: number
17
+ totalReasoningChars?: number
18
+ phase?: 'requesting' | 'streaming' | 'completed' | 'failed'
19
+ phaseMessage?: string
20
+ preview?: string
21
+ error?: string
22
+ }
23
+
24
+ export interface LlmLiveSession {
25
+ sessionId: string
26
+ label: string
27
+ model?: string
28
+ phase: 'requesting' | 'streaming' | 'completed' | 'failed'
29
+ phaseMessage: string
30
+ preview: string
31
+ totalChars: number
32
+ startedAt: string
33
+ updatedAt: string
34
+ }
35
+
36
+ const MAX_PREVIEW_CHARS = 20_000
37
+
38
+ function applyFrame(current: Record<string, LlmLiveSession>, frame: LlmLiveFrame): Record<string, LlmLiveSession> {
39
+ const next = { ...current }
40
+ const id = frame.sessionId
41
+ let session = next[id]
42
+ if (frame.type === 'session_started') {
43
+ session = {
44
+ sessionId: id,
45
+ label: frame.label ?? 'LLM 调用',
46
+ model: frame.model,
47
+ phase: 'requesting',
48
+ phaseMessage: '正在连接模型',
49
+ preview: '',
50
+ totalChars: 0,
51
+ startedAt: frame.at,
52
+ updatedAt: frame.at,
53
+ }
54
+ next[id] = session
55
+ return next
56
+ }
57
+ if (session === undefined) return next
58
+ if (frame.type === 'output_delta') {
59
+ const preview = (session.preview + (frame.content ?? '')).slice(-MAX_PREVIEW_CHARS)
60
+ session = { ...session, phase: session.phase === 'requesting' ? 'streaming' : session.phase, phaseMessage: session.phase === 'requesting' ? '模型正在返回内容' : session.phaseMessage, preview, totalChars: frame.totalChars ?? session.totalChars + (frame.content?.length ?? 0), updatedAt: frame.at }
61
+ next[id] = session
62
+ } else if (frame.type === 'reasoning_delta') {
63
+ session = { ...session, phase: session.phase === 'requesting' ? 'streaming' : session.phase, updatedAt: frame.at }
64
+ next[id] = session
65
+ } else if (frame.type === 'phase_changed') {
66
+ session = { ...session, phase: frame.phase ?? session.phase, phaseMessage: frame.phaseMessage ?? session.phaseMessage, updatedAt: frame.at }
67
+ next[id] = session
68
+ } else if (frame.type === 'session_completed') {
69
+ session = { ...session, phase: frame.phase ?? 'completed', phaseMessage: frame.phase === 'failed' ? (frame.error ?? '调用失败') : '模型结果已准备完成', totalChars: frame.totalChars ?? session.totalChars, preview: session.preview || (frame.preview ?? ''), updatedAt: frame.at }
70
+ next[id] = session
71
+ }
72
+ return next
73
+ }
74
+
75
+ export function useLlmLiveFeed(enabled = true): { connected: boolean; sessions: LlmLiveSession[] } {
76
+ const [sessions, setSessions] = useState<Record<string, LlmLiveSession>>({})
77
+ const [connected, setConnected] = useState(false)
78
+ useEffect(() => {
79
+ if (!enabled) {
80
+ setSessions({})
81
+ setConnected(false)
82
+ return
83
+ }
84
+ const es = new EventSource(NOVEL_API.llmLive)
85
+ es.onopen = () => setConnected(true)
86
+ es.onmessage = (ev) => {
87
+ try {
88
+ const frame = JSON.parse(ev.data as string) as LlmLiveFrame
89
+ setSessions(prev => applyFrame(prev, frame))
90
+ } catch { /* ignore malformed frame */ }
91
+ }
92
+ // EventSource 断线会自动重连;这里只在断线瞬间标记未连接,重连成功后会再次 onopen。
93
+ es.onerror = () => setConnected(false)
94
+ return () => {
95
+ es.close()
96
+ setConnected(false)
97
+ }
98
+ }, [enabled])
99
+ const list = useMemo(() => Object.values(sessions).sort((a, b) => a.startedAt.localeCompare(b.startedAt)), [sessions])
100
+ return { connected, sessions: list }
101
+ }
@@ -19,7 +19,7 @@ export const zh = {
19
19
  'tab.plan': '章节',
20
20
  'tab.bible': '道藏',
21
21
  'tab.foreshadow': '暗线',
22
- 'tab.assistant': 'AI 助手',
22
+ 'tab.assistant': 'AI 编辑 Agent',
23
23
  'tab.settings': '设置',
24
24
  'workflow.title': '创作工作台',
25
25
  'workflow.step1': '① 加载总纲',
@@ -196,13 +196,13 @@ export const zh = {
196
196
  'progress.rewriting': '正在修订第 {no} 章…',
197
197
  'progress.polishing': '正在润色第 {no} 章…',
198
198
  'progress.empty': '生成/审稿进度将显示在这里',
199
- 'assistant.hint': '和 AI 编辑讨论剧情、人设、暗线;达成一致后可让它直接修改总纲、道藏、章节内容。',
199
+ 'assistant.hint': '和 AI 编辑 Agent 讨论剧情、人设、暗线;达成一致后可让它直接修改总纲、道藏、章节内容。',
200
200
  'assistant.placeholder': '例如:我想让第 2 章结尾加一个悬念——墟境里传来爷爷的声音…',
201
201
  'assistant.send': '发送',
202
202
  'assistant.toolStart': '⚙ 执行操作:{name}…',
203
203
  'assistant.toolDone': '✓ {name} 完成:{detail}',
204
204
  'assistant.toolError': '✗ {name} 失败:{detail}',
205
- 'assistant.empty': '还没有对话。和 AI 编辑聊聊剧情吧。',
205
+ 'assistant.empty': '还没有对话。和 AI 编辑 Agent 聊聊剧情吧。',
206
206
  'status.projectNone': '输出目录中还没有项目。请先加载大纲。',
207
207
  'status.files': '已生成文件',
208
208
  'api.error': '请求失败',
@@ -402,7 +402,7 @@ export const en: Record<string, string> = {
402
402
  'progress.rewriting': 'Revising chapter {no}…',
403
403
  'progress.polishing': 'Polishing chapter {no}…',
404
404
  'progress.empty': 'Generation/review progress appears here',
405
- 'assistant.hint': 'Discuss plot, characters, foreshadowing with the AI editor; once agreed, let it edit the outline, bible, or chapters directly.',
405
+ 'assistant.hint': 'Discuss plot, characters, foreshadowing with the AI Editor Agent; once agreed, let it edit the outline, bible, or chapters directly.',
406
406
  'assistant.placeholder': 'e.g. Add a hook at the end of chapter 2…',
407
407
  'assistant.send': 'Send',
408
408
  'assistant.toolStart': '⚙ Running {name}…',
@@ -73,7 +73,7 @@ export function AdaptModeView({ api, onOpenBook }: { api: NovelApi; onOpenBook?:
73
73
  setResult(res);
74
74
  const next: Record<string, EditState> = {};
75
75
  for (const d of res.dimensions) {
76
- next[d.key] = { enabled: d.mutability !== 'locked', target: d.candidates?.[0] ?? '' };
76
+ next[d.key] = { enabled: d.mutability !== 'locked', target: d.candidates?.[0]?.name ?? '' };
77
77
  }
78
78
  setEdits(next);
79
79
  setStep(2);
@@ -289,7 +289,7 @@ export function AdaptModeView({ api, onOpenBook }: { api: NovelApi; onOpenBook?:
289
289
  </div>
290
290
  <div className={css.dimCurrent}><b>当前值</b>:{d.current}</div>
291
291
  {(d.evidence !== undefined && d.evidence !== '') && <div className={css.meta}>证据:{d.evidence}</div>}
292
- {(d.candidates ?? []).length > 0 && <div className={css.meta}>候选:{(d.candidates ?? []).join(' / ')}</div>}
292
+ {(d.candidates ?? []).length > 0 && <div className={css.meta}>候选:{(d.candidates ?? []).map(c => `${c.name}${c.desc !== undefined && c.desc !== '' ? `(${c.desc})` : ''}`).join(' / ')}</div>}
293
293
  <div className={css.meta}>影响:{d.impact}</div>
294
294
  </div>
295
295
  ))}
@@ -325,7 +325,22 @@ export function AdaptModeView({ api, onOpenBook }: { api: NovelApi; onOpenBook?:
325
325
  <input className={css.input} value={target} disabled={!enabled} placeholder="改为什么?" onChange={e2 => setEdit(d.key, { target: e2.target.value })} />
326
326
  </div>
327
327
  )}
328
- {(d.candidates ?? []).length > 0 && <div className={css.meta}>候选:{(d.candidates ?? []).join(' / ')}</div>}
328
+ {(d.candidates ?? []).length > 0 && (
329
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--nf-space-6)', marginTop: 6 }}>
330
+ {(d.candidates ?? []).map((c, ci) => (
331
+ <button
332
+ key={ci}
333
+ type="button"
334
+ className={css.badge}
335
+ title={c.desc ?? ''}
336
+ style={{ cursor: 'pointer', borderColor: target === c.name ? 'var(--nf-accent)' : 'var(--nf-border)', color: target === c.name ? 'var(--nf-accent)' : 'var(--nf-text-2)', background: 'transparent' }}
337
+ onClick={() => setEdit(d.key, { enabled: true, target: c.name })}
338
+ >
339
+ {c.name}{c.desc !== undefined && c.desc !== '' ? ` · ${c.desc}` : ''}
340
+ </button>
341
+ ))}
342
+ </div>
343
+ )}
329
344
  <div className={css.meta}>影响:{d.impact}</div>
330
345
  </div>
331
346
  );