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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. package/README.md +368 -457
  2. package/lib/client.js +20495 -22316
  3. package/lib/index.js +4640 -3932
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +0 -6
  6. package/lib/types/assets.d.ts +7 -9
  7. package/lib/types/assistant.d.ts +25 -0
  8. package/lib/types/author-assets.d.ts +1 -3
  9. package/lib/types/bookshelf.d.ts +13 -3
  10. package/lib/types/client/api.d.ts +49 -29
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -2
  13. package/lib/types/client/llmLive.d.ts +12 -14
  14. package/lib/types/client/locales.d.ts +12 -67
  15. package/lib/types/client/mount.d.ts +0 -2
  16. package/lib/types/client/panel/AdaptModeView.d.ts +1 -1
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssetsTab.d.ts +1 -1
  19. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  20. package/lib/types/client/panel/AuthorAssetsView.d.ts +1 -1
  21. package/lib/types/client/panel/AuthorHome.d.ts +12 -12
  22. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  23. package/lib/types/client/panel/CreateBookView.d.ts +1 -1
  24. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +1 -1
  25. package/lib/types/client/panel/ImportModal.d.ts +1 -1
  26. package/lib/types/client/panel/LiveFeedLog.d.ts +8 -0
  27. package/lib/types/client/panel/ModelManager.d.ts +2 -2
  28. package/lib/types/client/panel/NovelPanel.d.ts +7 -2
  29. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  30. package/lib/types/client/panel/ProgressConsole.d.ts +1 -1
  31. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  32. package/lib/types/client/panel/ReaderView.d.ts +1 -1
  33. package/lib/types/client/panel/RunPanel.d.ts +1 -1
  34. package/lib/types/client/panel/SettingsView.d.ts +13 -10
  35. package/lib/types/client/panel/ShelfView.d.ts +11 -2
  36. package/lib/types/client/panel/SubPage.d.ts +27 -0
  37. package/lib/types/client/panel/TensionView.d.ts +18 -0
  38. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  39. package/lib/types/client/panel/WorldTab.d.ts +1 -1
  40. package/lib/types/client/panel/helpers.d.ts +5 -3
  41. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  42. package/lib/types/client/panel/outline-markdown.d.ts +112 -0
  43. package/lib/types/client/panel/status-badge.d.ts +13 -0
  44. package/lib/types/client/panel/storage-parse.d.ts +46 -0
  45. package/lib/types/client/panel/toast.d.ts +2 -0
  46. package/lib/types/client/panel/usePersistentState.d.ts +17 -0
  47. package/lib/types/client/panel/views.d.ts +21 -35
  48. package/lib/types/client/run-status.d.ts +21 -0
  49. package/lib/types/client/sidebar-entry.d.ts +0 -2
  50. package/lib/types/digest-parse.d.ts +29 -0
  51. package/lib/types/docx-core.d.ts +16 -0
  52. package/lib/types/docx.d.ts +2 -3
  53. package/lib/types/engine.d.ts +122 -103
  54. package/lib/types/home.d.ts +11 -0
  55. package/lib/types/index.d.ts +21 -13
  56. package/lib/types/llm-live.d.ts +62 -36
  57. package/lib/types/llm-retry.d.ts +38 -0
  58. package/lib/types/market-radar-scan.d.ts +0 -1
  59. package/lib/types/market-radar-sources.d.ts +1 -1
  60. package/lib/types/migrate-output.d.ts +25 -0
  61. package/lib/types/model-capability.d.ts +119 -0
  62. package/lib/types/model-request.d.ts +36 -0
  63. package/lib/types/output-budget.d.ts +86 -0
  64. package/lib/types/plotline-align.d.ts +35 -0
  65. package/lib/types/prompt-slots.d.ts +57 -0
  66. package/lib/types/prompting.d.ts +2 -27
  67. package/lib/types/protocol.d.ts +479 -595
  68. package/lib/types/revision.d.ts +76 -0
  69. package/lib/types/routes.d.ts +9 -3
  70. package/lib/types/run-selection.d.ts +51 -0
  71. package/lib/types/run.d.ts +31 -0
  72. package/lib/types/settings-persist.d.ts +59 -0
  73. package/lib/types/snapshots.d.ts +46 -0
  74. package/lib/types/stage-contract.d.ts +58 -0
  75. package/lib/types/stream-fallback.d.ts +60 -0
  76. package/lib/types/strip-headings.d.ts +33 -0
  77. package/lib/types/tension.d.ts +53 -0
  78. package/lib/types/timeline.d.ts +37 -0
  79. package/package.json +50 -27
  80. package/assets/styles/arcane-thick-3d.webp +0 -0
  81. package/assets/styles/chinese-ink.webp +0 -0
  82. package/assets/styles/clay-stopmotion-3d.webp +0 -0
  83. package/assets/styles/dark-ghibli.webp +0 -0
  84. package/assets/styles/dark-gothic-3d.webp +0 -0
  85. package/assets/styles/film-photo.webp +0 -0
  86. package/assets/styles/guofeng-heavy.webp +0 -0
  87. package/assets/styles/guoman-2d-gufeng.webp +0 -0
  88. package/assets/styles/guoman-3d-tianyuan.webp +0 -0
  89. package/assets/styles/guoman-3d-xuanhuan.webp +0 -0
  90. package/assets/styles/horror-game-3d.webp +0 -0
  91. package/assets/styles/hyperreal-3d.webp +0 -0
  92. package/assets/styles/japan-cel-dark.webp +0 -0
  93. package/assets/styles/korean-webtoon.webp +0 -0
  94. package/assets/styles/low-cyberpunk-3d.webp +0 -0
  95. package/assets/styles/nextgen-cg.webp +0 -0
  96. package/assets/styles/noir-film.webp +0 -0
  97. package/assets/styles/openworld-survival.webp +0 -0
  98. package/assets/styles/paper-cut-3d.webp +0 -0
  99. package/assets/styles/pixar-adult-3d.webp +0 -0
  100. package/assets/styles/pop-marvel-2d.webp +0 -0
  101. package/assets/styles/sandiao-qban.webp +0 -0
  102. package/assets/styles/shinkai-anime.webp +0 -0
  103. package/assets/styles/tactical-stealth.webp +0 -0
  104. package/assets/styles/urban-lightreal-3d.webp +0 -0
  105. package/assets/styles/vaporwave-3d.webp +0 -0
  106. package/assets/styles/vhs90.webp +0 -0
  107. package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
  108. package/lib/types/client/panel/FlowGuide.d.ts +0 -22
  109. package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -20
  110. package/lib/types/client/panel/MangaWorkspace.d.ts +0 -12
  111. package/lib/types/client/panel/ModelPicker.d.ts +0 -11
  112. package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
  113. package/lib/types/client/panel/PropLibrary.d.ts +0 -8
  114. package/lib/types/client/panel/SceneLibrary.d.ts +0 -16
  115. package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
  116. package/lib/types/client/panel/StyleCard.d.ts +0 -8
  117. package/lib/types/client/panel/StyleGallery.d.ts +0 -1
  118. package/lib/types/comic-presets.d.ts +0 -12
  119. package/lib/types/manga-genre-rules.d.ts +0 -22
  120. package/lib/types/prompt-utils.d.ts +0 -35
  121. package/lib/types/review-policy.d.ts +0 -97
  122. package/lib/types/shot-language.d.ts +0 -46
  123. package/lib/types/src/ai-scan.d.ts +0 -36
  124. package/lib/types/src/assets.d.ts +0 -37
  125. package/lib/types/src/assistant.d.ts +0 -45
  126. package/lib/types/src/author-assets.d.ts +0 -25
  127. package/lib/types/src/bookshelf.d.ts +0 -42
  128. package/lib/types/src/client/api.d.ts +0 -234
  129. package/lib/types/src/client/docx.d.ts +0 -15
  130. package/lib/types/src/client/index.d.ts +0 -14
  131. package/lib/types/src/client/locales.d.ts +0 -212
  132. package/lib/types/src/client/mount.d.ts +0 -9
  133. package/lib/types/src/client/panel/AdaptModeView.d.ts +0 -5
  134. package/lib/types/src/client/panel/AssetsTab.d.ts +0 -12
  135. package/lib/types/src/client/panel/AssistantTab.d.ts +0 -7
  136. package/lib/types/src/client/panel/AuthorAssetsView.d.ts +0 -4
  137. package/lib/types/src/client/panel/AuthorHome.d.ts +0 -32
  138. package/lib/types/src/client/panel/BookshelfBar.d.ts +0 -11
  139. package/lib/types/src/client/panel/CreateBookView.d.ts +0 -9
  140. package/lib/types/src/client/panel/FlowGuide.d.ts +0 -22
  141. package/lib/types/src/client/panel/GlobalAssetLibraryView.d.ts +0 -4
  142. package/lib/types/src/client/panel/ImportModal.d.ts +0 -7
  143. package/lib/types/src/client/panel/MangaRoleLibrary.d.ts +0 -20
  144. package/lib/types/src/client/panel/MangaWorkspace.d.ts +0 -12
  145. package/lib/types/src/client/panel/ModelManager.d.ts +0 -14
  146. package/lib/types/src/client/panel/NovelPanel.d.ts +0 -13
  147. package/lib/types/src/client/panel/ProgressConsole.d.ts +0 -17
  148. package/lib/types/src/client/panel/ProgressHomeView.d.ts +0 -5
  149. package/lib/types/src/client/panel/PropLibrary.d.ts +0 -8
  150. package/lib/types/src/client/panel/ReaderView.d.ts +0 -8
  151. package/lib/types/src/client/panel/ReasoningSection.d.ts +0 -10
  152. package/lib/types/src/client/panel/RunPanel.d.ts +0 -5
  153. package/lib/types/src/client/panel/SceneLibrary.d.ts +0 -16
  154. package/lib/types/src/client/panel/SettingsView.d.ts +0 -16
  155. package/lib/types/src/client/panel/ShelfView.d.ts +0 -15
  156. package/lib/types/src/client/panel/StoryboardTab.d.ts +0 -19
  157. package/lib/types/src/client/panel/StyleCard.d.ts +0 -8
  158. package/lib/types/src/client/panel/StyleGallery.d.ts +0 -1
  159. package/lib/types/src/client/panel/WorldTab.d.ts +0 -10
  160. package/lib/types/src/client/panel/controller.d.ts +0 -19
  161. package/lib/types/src/client/panel/helpers.d.ts +0 -18
  162. package/lib/types/src/client/panel/views.d.ts +0 -77
  163. package/lib/types/src/client/sidebar-entry.d.ts +0 -13
  164. package/lib/types/src/client/text.d.ts +0 -9
  165. package/lib/types/src/comic-presets.d.ts +0 -12
  166. package/lib/types/src/docx.d.ts +0 -19
  167. package/lib/types/src/engine.d.ts +0 -402
  168. package/lib/types/src/index.d.ts +0 -79
  169. package/lib/types/src/manga-genre-rules.d.ts +0 -22
  170. package/lib/types/src/novel-context.d.ts +0 -80
  171. package/lib/types/src/prompt-utils.d.ts +0 -35
  172. package/lib/types/src/protocol.d.ts +0 -2176
  173. package/lib/types/src/review-policy.d.ts +0 -97
  174. package/lib/types/src/routes.d.ts +0 -29
  175. package/lib/types/src/run.d.ts +0 -32
  176. package/lib/types/src/shot-language.d.ts +0 -46
  177. package/lib/types/src/story-beat-language.d.ts +0 -35
  178. package/lib/types/src/style-library.d.ts +0 -33
  179. package/lib/types/story-beat-language.d.ts +0 -35
  180. package/lib/types/style-library.d.ts +0 -33
  181. package/src/ai-scan.ts +0 -179
  182. package/src/assets.ts +0 -1774
  183. package/src/assistant.ts +0 -915
  184. package/src/author-assets.ts +0 -147
  185. package/src/bookshelf.ts +0 -167
  186. package/src/client/api.ts +0 -716
  187. package/src/client/css-modules.d.ts +0 -8
  188. package/src/client/docx.ts +0 -69
  189. package/src/client/index.ts +0 -34
  190. package/src/client/llmLive.ts +0 -101
  191. package/src/client/locales.ts +0 -418
  192. package/src/client/mount.tsx +0 -97
  193. package/src/client/mount.tsx._new +0 -97
  194. package/src/client/mount.tsx._nf +0 -97
  195. package/src/client/mount.tsx._tmp +0 -97
  196. package/src/client/panel/AdaptModeView.tsx +0 -535
  197. package/src/client/panel/AssetsTab.tsx +0 -458
  198. package/src/client/panel/AssistantTab.tsx +0 -339
  199. package/src/client/panel/AuthorAssetsView.tsx +0 -208
  200. package/src/client/panel/AuthorHome.tsx +0 -235
  201. package/src/client/panel/BookAnalysisView.tsx +0 -60
  202. package/src/client/panel/BookshelfBar.tsx +0 -170
  203. package/src/client/panel/CreateBookView.tsx +0 -371
  204. package/src/client/panel/DirectorView.tsx +0 -200
  205. package/src/client/panel/FlowGuide.tsx +0 -84
  206. package/src/client/panel/GlobalAssetLibraryView.tsx +0 -172
  207. package/src/client/panel/IdeaInspirationView.tsx +0 -71
  208. package/src/client/panel/ImportModal.tsx +0 -303
  209. package/src/client/panel/KnowledgeBaseView.tsx +0 -73
  210. package/src/client/panel/LiveFeedLog.tsx +0 -76
  211. package/src/client/panel/MangaRoleLibrary.tsx +0 -1002
  212. package/src/client/panel/MangaWorkspace.tsx +0 -715
  213. package/src/client/panel/MarketRadarView.tsx +0 -374
  214. package/src/client/panel/ModelManager.tsx +0 -205
  215. package/src/client/panel/NovelPanel.tsx +0 -4721
  216. package/src/client/panel/ProgressConsole.tsx +0 -63
  217. package/src/client/panel/ProgressHomeView.tsx +0 -87
  218. package/src/client/panel/PropLibrary.tsx +0 -175
  219. package/src/client/panel/ReaderView.tsx +0 -306
  220. package/src/client/panel/ReasoningSection.tsx +0 -31
  221. package/src/client/panel/RunPanel.tsx +0 -204
  222. package/src/client/panel/SceneLibrary.tsx +0 -453
  223. package/src/client/panel/SettingsView.tsx +0 -289
  224. package/src/client/panel/ShelfView.tsx +0 -242
  225. package/src/client/panel/StoryboardTab.tsx +0 -472
  226. package/src/client/panel/StyleCard.tsx +0 -90
  227. package/src/client/panel/StyleGallery.tsx +0 -58
  228. package/src/client/panel/WorldTab.tsx +0 -202
  229. package/src/client/panel/controller.ts +0 -45
  230. package/src/client/panel/helpers.ts +0 -67
  231. package/src/client/panel/panel.module.css +0 -4359
  232. package/src/client/panel/views.tsx +0 -314
  233. package/src/client/sidebar-entry.ts +0 -122
  234. package/src/client/text.ts +0 -28
  235. package/src/comic-presets.ts +0 -67
  236. package/src/docx.ts +0 -83
  237. package/src/engine.ts +0 -5636
  238. package/src/global-assets.ts +0 -59
  239. package/src/index.ts +0 -345
  240. package/src/llm-live.ts +0 -68
  241. package/src/manga-genre-rules.ts +0 -87
  242. package/src/market-radar-scan.ts +0 -41
  243. package/src/market-radar-sources.ts +0 -173
  244. package/src/novel-context.ts +0 -287
  245. package/src/prompt-utils.ts +0 -73
  246. package/src/prompting.ts +0 -159
  247. package/src/protocol.ts +0 -2565
  248. package/src/review-policy.ts +0 -161
  249. package/src/routes.ts +0 -3939
  250. package/src/run.ts +0 -338
  251. package/src/shot-language.ts +0 -181
  252. package/src/story-beat-language.ts +0 -130
  253. package/src/style-library.ts +0 -84
@@ -1,4721 +0,0 @@
1
- /**
2
- * The novel-forge workbench panel: tabs — 工作流 (guided pipeline), 大纲
3
- * (outline), 章节 (chapter plan + per-chapter write/review/rewrite/polish),
4
- * 设定库 (story bible), 伏笔 (foreshadows), 设置 (config). Generation and
5
- * review streams land in the progress console.
6
- */
7
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
8
- import type { ReactElement } from 'react'
9
- import type { NovelApi } from '../api.ts'
10
- import { setCurrentBook } from '../api.ts'
11
- import type { PanelController } from './controller.ts'
12
- import { tt } from './helpers.ts'
13
- import { ModelManager } from './ModelManager.tsx'
14
- import { ReasoningSection } from './ReasoningSection.tsx'
15
- import LiveFeedLog from './LiveFeedLog.tsx'
16
- import { BarChart3, Book, BookMarked, BookOpen, Brain, Clapperboard, Factory, FileText, Folder, GitBranch, Library, MessageSquare, Palette, PenLine, PlugZap, RotateCcw, ScrollText, Search, Settings, Sparkles, Wrench } from 'lucide-react'
17
- import { AssistantTab } from './AssistantTab.tsx'
18
- import { AssetsTab } from './AssetsTab.tsx'
19
- import { ShelfView } from './ShelfView.tsx'
20
- import { AuthorHome } from './AuthorHome.tsx'
21
- import { ReaderView } from './ReaderView.tsx'
22
- import { RunPanel } from './RunPanel.tsx'
23
- import { CreateBookView } from './CreateBookView.tsx'
24
- import { ImportModal } from './ImportModal.tsx'
25
- import { WorldTab } from './WorldTab.tsx'
26
- import { MangaWorkspace } from './MangaWorkspace.tsx'
27
- import DirectorView from './DirectorView.tsx'
28
- import KnowledgeBaseView from './KnowledgeBaseView.tsx'
29
- import { AuditIssueRow, PlotlineCard, PlotlineHealthPanel, PlotlinePlanPanel, PlotlineSuggestionPanel, RoleCandidateRow, RoleCard, StatCell, TodoRow } from './views.tsx'
30
- import { extractDocxTextFromBuffer } from '../docx.ts'
31
- import type {
32
- AuditStatus,
33
- BookshelfSnapshot,
34
- ChapterPlan,
35
- Foreshadow,
36
- ImageModelConfig,
37
- JobFrame,
38
- NovelConfig,
39
- Plotline,
40
- PlotlineHealthReport,
41
- PlotlinePlan,
42
- ProjectState,
43
- ReviewReport,
44
- RoleRecord,
45
- SensitiveHit,
46
- StoryBible,
47
- Volume,
48
- } from '../../protocol.ts'
49
- import css from './panel.module.css'
50
-
51
- /** The panel's tab identifiers. */
52
- export type NovelTab =
53
- | 'workflow' | 'overview' | 'blurb' | 'plan' | 'bible' | 'world' | 'foreshadow' | 'assistant' | 'settings'
54
- | 'characters' | 'roles' | 'facts' | 'plotlines' | 'reviews' | 'progress' | 'breakdown' | 'director' | 'knowledge'
55
- | 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle' | 'run' | 'manhua'
56
- | 'book' | 'assets'
57
-
58
- /** Panel shell props. */
59
- export interface NovelPanelProps {
60
- /** The panel state owner (open/close/toggle). */
61
- controller: PanelController
62
- /** The API client every tab operates through. */
63
- api: NovelApi
64
- }
65
-
66
- /** One progress console line. */
67
- interface ProgressLine {
68
- id: number
69
- text: string
70
- kind: 'info' | 'done' | 'error'
71
- /** Live line: a single in-place-updating row (generation counter + bar). */
72
- live?: boolean
73
- /** 0..1 completion ratio for the live line's progress bar. */
74
- ratio?: number
75
- }
76
-
77
- /** The navigation groups (AI-Novel-Writing-Assistant style grouping). */
78
- const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; collapsible?: boolean; items: ReadonlyArray<{ id: NovelTab; label: string; icon: ReactElement }> }> = [
79
- {
80
- id: 'create',
81
- label: '创作',
82
- items: [
83
- { id: 'workflow', label: tt('tab.workflow'), icon: <Wrench size={18} /> },
84
- { id: 'overview', label: tt('tab.overview'), icon: <FileText size={18} /> },
85
- { id: 'blurb', label: '简介 / 封面', icon: <BookOpen size={18} /> },
86
- { id: 'plan', label: tt('tab.plan'), icon: <BookMarked size={18} /> },
87
- { id: 'plotlines', label: '长线管理', icon: <ScrollText size={18} /> },
88
- { id: 'book', label: '本书设定', icon: <Library size={18} /> },
89
- ],
90
- },
91
- {
92
- id: 'core',
93
- label: '核心',
94
- items: [
95
- { id: 'assistant', label: tt('tab.assistant'), icon: <MessageSquare size={18} /> },
96
- { id: 'progress', label: 'AI 进度', icon: <BarChart3 size={18} /> },
97
- ],
98
- },
99
- {
100
- id: 'advanced',
101
- label: '进阶工具',
102
- collapsible: true,
103
- items: [
104
- { id: 'breakdown', label: '拆书分析', icon: <Search size={18} /> },
105
- { id: 'director', label: '自动导演', icon: <Brain size={18} /> },
106
- { id: 'knowledge', label: '知识库', icon: <BookOpen size={18} /> },
107
- { id: 'manhua', label: '漫剧工作台', icon: <Clapperboard size={18} /> },
108
- { id: 'run', label: '生产单', icon: <Factory size={18} /> },
109
- ],
110
- },
111
- {
112
- id: 'assets',
113
- label: '资产',
114
- items: [
115
- { id: 'assets', label: '创作资产', icon: <Wrench size={18} /> },
116
- ],
117
- },
118
- ]
119
-
120
- /** Settings tab — pinned to the bottom of the nav rail. */
121
- const SETTINGS_TAB: { id: NovelTab; label: string; icon: ReactElement } = { id: 'settings', label: tt('tab.settings'), icon: <Settings size={18} /> }
122
-
123
- /** 审稿维度中文名(审稿问题按维度标注展示)。 */
124
- const REVIEW_DIM_ZH: Record<string, string> = {
125
- character: '人设', setting: '设定', redline: '红线', writing: '文笔', pacing: '节奏', logic: '逻辑', 'anti-ai': '反AI', presentation: '呈现', compliance: '合规',
126
- }
127
-
128
-
129
- /** 设置页内子导航分组。 */
130
- const SETTINGS_SECTIONS: ReadonlyArray<{ id: 'model' | 'writing' | 'image' | 'files' | 'appearance'; label: string; icon: ReactElement }> = [
131
- { id: 'model', label: '模型与推理', icon: <Brain size={16} /> },
132
- { id: 'writing', label: '写作与审稿', icon: <PenLine size={16} /> },
133
- { id: 'files', label: '路径与文件', icon: <Folder size={16} /> },
134
- { id: 'appearance', label: '外观与主题', icon: <Sparkles size={16} /> },
135
- ]
136
-
137
- /** 构建时注入的插件版本(tsdown define 替换为字符串字面量)。 */
138
- declare const __NOVEL_FORGE_VERSION__: string | undefined
139
- const PLUGIN_VERSION: string = typeof __NOVEL_FORGE_VERSION__ !== 'undefined' ? __NOVEL_FORGE_VERSION__ : '0.0.0'
140
- /** GitHub 仓库地址(关于区块点击跳转)。 */
141
- const REPO_URL = 'https://github.com/watersxya/dsh-novel-forge'
142
-
143
- /** Whether any chapter is being generated right now. */
144
- function anyGenerating(chapters: ChapterPlan[] | undefined): boolean {
145
- return (chapters ?? []).some(c => c.status === 'generating' || c.status === 'reviewing')
146
- }
147
-
148
- /** Status badge class + label. */
149
- function statusBadge(chapter: ChapterPlan): { cls: string; label: string } {
150
- switch (chapter.status) {
151
- case 'pending': return { cls: css.badgePending, label: tt('plan.pending') }
152
- case 'generating': return { cls: css.badgeGenerating, label: tt('plan.generating') }
153
- case 'written': return { cls: css.badgeWritten, label: tt('plan.written') }
154
- case 'reviewing': return { cls: css.badgeGenerating, label: tt('plan.reviewing') }
155
- case 'approved': return { cls: css.badgeDone, label: tt('plan.approved') }
156
- case 'rejected': return { cls: css.badgeRejected, label: tt('plan.rejected') }
157
- case 'error': return { cls: css.badgeError, label: tt('plan.error') }
158
- default: return { cls: css.badgeError, label: tt('plan.error') }
159
- }
160
- }
161
-
162
- /** One review issue line (severity-colored, theme-aware). */
163
- function severityColor(severity: string): string {
164
- return severity === 'high' ? 'var(--nf-error)' : severity === 'medium' ? 'var(--nf-warn)' : 'var(--nf-info)'
165
- }
166
-
167
- /** One row of a chapter-level diff (paragraph granularity). */
168
- type DiffRow =
169
- | { kind: 'same'; text: string }
170
- | { kind: 'change'; old: string; neu: string }
171
- | { kind: 'del'; text: string }
172
- | { kind: 'add'; text: string }
173
-
174
- /**
175
- * Paragraph-level LCS diff between an original chapter body and its
176
- * rewrite/polish draft. Adjacent delete+add runs merge into "change" pairs
177
- * (the common case: a reworded paragraph shown as old → new).
178
- */
179
- function paragraphDiff(oldText: string, newText: string): DiffRow[] {
180
- const split = (t: string): string[] =>
181
- t.replace(/^#\s+.*$/m, '').trim().split(/\n{2,}/).map(p => p.trim()).filter(p => p !== '')
182
- const a = split(oldText)
183
- const b = split(newText)
184
- const n = a.length
185
- const m = b.length
186
- const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
187
- for (let i = n - 1; i >= 0; i--) {
188
- for (let j = m - 1; j >= 0; j--) {
189
- dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!)
190
- }
191
- }
192
- const rows: DiffRow[] = []
193
- let i = 0
194
- let j = 0
195
- while (i < n || j < m) {
196
- if (i < n && j < m && a[i] === b[j]) {
197
- rows.push({ kind: 'same', text: a[i]! })
198
- i++
199
- j++
200
- } else if (i < n && (j >= m || dp[i + 1]![j]! >= dp[i]![j + 1]!)) {
201
- rows.push({ kind: 'del', text: a[i]! })
202
- i++
203
- } else if (j < m) {
204
- rows.push({ kind: 'add', text: b[j]! })
205
- j++
206
- } else if (i < n) {
207
- rows.push({ kind: 'del', text: a[i]! })
208
- i++
209
- } else {
210
- rows.push({ kind: 'add', text: b[j]! })
211
- j++
212
- }
213
- }
214
- // Merge adjacent del/add runs into change pairs.
215
- const merged: DiffRow[] = []
216
- let k = 0
217
- while (k < rows.length) {
218
- const row = rows[k]!
219
- if (row.kind !== 'del' && row.kind !== 'add') {
220
- merged.push(row)
221
- k++
222
- continue
223
- }
224
- const dels: string[] = []
225
- const adds: string[] = []
226
- while (k < rows.length && (rows[k]!.kind === 'del' || rows[k]!.kind === 'add')) {
227
- if (rows[k]!.kind === 'del') dels.push((rows[k] as { text: string }).text)
228
- else adds.push((rows[k] as { text: string }).text)
229
- k++
230
- }
231
- if (dels.length > 0 && adds.length > 0) {
232
- merged.push({ kind: 'change', old: dels.join('\n\n'), neu: adds.join('\n\n') })
233
- } else if (dels.length > 0) {
234
- for (const d of dels) merged.push({ kind: 'del', text: d })
235
- } else {
236
- for (const ad of adds) merged.push({ kind: 'add', text: ad })
237
- }
238
- }
239
- return merged
240
- }
241
-
242
- /** 把章节 beats 按结构标签渲染(本章目标/剧情要点/爽点/结尾钩子 等)。 */
243
- function renderBeats(beats: string): ReactElement {
244
- const lines = beats.split('\n')
245
- return (
246
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-2)' }}>
247
- {lines.map((line, i) => {
248
- const trimmed = line.trim()
249
- const match = /^([^::]{2,14})[::]/.exec(trimmed)
250
- if (match !== null) {
251
- return (
252
- <div key={i}>
253
- <b style={{ color: 'var(--nf-accent)' }}>{match[1]}</b>
254
- {trimmed.slice(match[0].length)}
255
- </div>
256
- )
257
- }
258
- return <div key={i}>{line}</div>
259
- })}
260
- </div>
261
- )
262
- }
263
-
264
- /** The diff list of a draft-vs-original comparison (scrollable). */
265
- function DiffList({ original, draft, fontSize }: { original: string; draft: string; fontSize?: number }): ReactElement { const rows = useMemo(() => paragraphDiff(original, draft), [original, draft])
266
- const changed = rows.filter(r => r.kind === 'change').length
267
- const added = rows.filter(r => r.kind === 'add').length
268
- const removed = rows.filter(r => r.kind === 'del').length
269
- const [onlyChanges, setOnlyChanges] = useState(false)
270
- const shown = onlyChanges ? rows.filter(r => r.kind !== 'same') : rows
271
- return (
272
- <>
273
- <div className={css.diffLegend}>
274
- <span className={css.legendOld}>■ 原稿</span>
275
- <span className={css.legendNew}>■ 新稿</span>
276
- <span className={css.meta}>
277
- 原 {original.length} 字 → 新 {draft.length} 字 · 修改 {changed} · 新增 {added} · 删除 {removed}
278
- </span>
279
- <label className={css.onlyChanges}>
280
- <input
281
- type="checkbox"
282
- checked={onlyChanges}
283
- onChange={e => { setOnlyChanges(e.target.checked) }}
284
- />
285
- 只看改动
286
- </label>
287
- </div>
288
- <div className={css.diffList} style={fontSize !== undefined ? { fontSize } : undefined}>
289
- {shown.map((row, idx) => {
290
- if (row.kind === 'same') {
291
- return (
292
- <details key={idx} className={css.diffSame}>
293
- <summary>第 {idx + 1} 段 · 未改动(点击展开)</summary>
294
- <div className={css.diffSameBody}>{row.text}</div>
295
- </details>
296
- )
297
- }
298
- if (row.kind === 'change') {
299
- return (
300
- <div key={idx} className={css.diffChange}>
301
- <div className={css.diffColumn}>
302
- <span className={css.diffTagOld}>原稿</span>
303
- <div className={css.diffOld}>{row.old}</div>
304
- </div>
305
- <div className={css.diffColumn}>
306
- <span className={css.diffTagNew}>新稿</span>
307
- <div className={css.diffNew}>{row.neu}</div>
308
- </div>
309
- </div>
310
- )
311
- }
312
- if (row.kind === 'del') {
313
- return (
314
- <div key={idx} className={css.diffDel}>
315
- <span className={css.diffTagOld}>原稿</span>
316
- <span className={css.diffText}>{row.text}</span>
317
- </div>
318
- )
319
- }
320
- return (
321
- <div key={idx} className={css.diffAdd}>
322
- <span className={css.diffTagNew}>新稿</span>
323
- <span className={css.diffText}>{row.text}</span>
324
- </div>
325
- )
326
- })}
327
- </div>
328
- </>
329
- )
330
- }
331
-
332
- /** The novel-forge panel. */
333
- export function NovelPanel({ controller, api }: NovelPanelProps) {
334
- const [activeTab, setActiveTab] = useState<NovelTab>('workflow')
335
- const [config, setConfig] = useState<NovelConfig | null>(null)
336
- /** 自定义背景(URL / dataURL),覆盖主题默认背景。 */
337
- const [themeBg, setThemeBg] = useState<string | undefined>(undefined)
338
- /** 自定义背景遮罩/模糊强度 0-80。 */
339
- const [themeBgBlur, setThemeBgBlur] = useState(0)
340
- /** 玻璃透明度 0-100(100=主题原样)。 */
341
- const [themeOpacity, setThemeOpacity] = useState(100)
342
- const [project, setProject] = useState<ProjectState | null>(null)
343
- const [generatedFiles, setGeneratedFiles] = useState<string[]>([])
344
- const [outlineText, setOutlineText] = useState('')
345
- const [customDocxPath, setCustomDocxPath] = useState('')
346
- const [shelf, setShelf] = useState<BookshelfSnapshot | null>(null)
347
- const [dragActive, setDragActive] = useState(false)
348
- const fileInputRef = useRef<HTMLInputElement | null>(null)
349
- const [planCount, setPlanCount] = useState(30)
350
- const [busy, setBusy] = useState(false)
351
- const [busyLabel, setBusyLabel] = useState('')
352
- const [error, setError] = useState('')
353
- const [notice, setNotice] = useState('')
354
- const [progress, setProgress] = useState<ProgressLine[]>([])
355
- const [configDraft, setConfigDraft] = useState<NovelConfig | null>(null)
356
- const [expandedChapter, setExpandedChapter] = useState<number | null>(null)
357
- /** 复盘记录页:当前展开的章节号。 */
358
- const [expandedReviewChapter, setExpandedReviewChapter] = useState<number | null>(null)
359
- /** 复盘记录页:按卷折叠,卷号 → 是否展开(默认全部收起)。 */
360
- const [expandedVolumes, setExpandedVolumes] = useState<Record<number, boolean>>({})
361
- const [chapterText, setChapterText] = useState('')
362
- const progressId = useRef(0)
363
- /** 面板已绑定的书(打开即锁,不随全局 active 漂移,避免串书)。 */
364
- const bookBoundRef = useRef(false)
365
- /** id of the single live progress row (generation counter), if any. */
366
- const liveProgressId = useRef<number | null>(null)
367
- /** last chars value rendered into the live row (throttle for streaming). */
368
- const lastDeltaChars = useRef(0)
369
- /** cumulative chars received this job (delta frames carry increments). */
370
- const liveChars = useRef(0)
371
- /** chapter no of the job currently streaming (delta frames carry no `no`). */
372
- const currentJobNo = useRef(0)
373
- /** prominent top-of-panel progress bar while a chapter is being written. */
374
- const [liveBar, setLiveBar] = useState<{ text: string; ratio?: number } | null>(null)
375
- /** 润色/修订工作区:左栏原文(可选中局部目标)+ 右栏指令/预览/应用。 */
376
- const [workspace, setWorkspace] = useState<{
377
- no: number
378
- title: string
379
- original: string
380
- instruction: string
381
- draft: string | null
382
- /** 已采纳草稿(原地结论态):显示已采纳横幅 + 自动审稿结果,返回时才关闭工作区。 */
383
- applied?: boolean
384
- } | null>(null)
385
- /** 工作区左栏当前选中的文字(局部修订目标)。 */
386
- const [wsSelected, setWsSelected] = useState('')
387
- /** 工作区预览区:diff 对比视图开关。 */
388
- const [wsShowDiff, setWsShowDiff] = useState(false)
389
- /** 工作区原文 textarea 引用(用于捕获选中文字)。 */
390
- const wsEditorRef = useRef<HTMLTextAreaElement | null>(null)
391
- /** 工作区:手动编辑后的 AI 审查结果(不落盘)。 */
392
- const [wsCheckReport, setWsCheckReport] = useState<ReviewReport | null>(null)
393
- /** 手动审查结果中作者勾选要修复的问题(issue 下标)。 */
394
- const [wsChecked, setWsChecked] = useState<number[]>([])
395
- /** 工作区「一键修订结果」模式:顶部显示「✅ 修订完成」横幅,不展示旧意见选择。 */
396
- const [wsResultMode, setWsResultMode] = useState(false)
397
- /** 编辑页字号(localStorage 记忆,仅影响显示)。 */
398
- const [editorFontSize, setEditorFontSize] = useState<number>(() => {
399
- try {
400
- const v = Number(window.localStorage.getItem('dsh-novel-forge.editor.fontSize'))
401
- return v >= 12 && v <= 24 ? v : 14
402
- } catch { return 14 }
403
- })
404
- const changeEditorFontSize = (next: number): void => {
405
- const v = Math.min(24, Math.max(12, next))
406
- setEditorFontSize(v)
407
- try { window.localStorage.setItem('dsh-novel-forge.editor.fontSize', String(v)) } catch { /* ignore */ }
408
- }
409
- /** 面板主题(localStorage 记忆):'liquid'=iOS 液态玻璃(绿) / 'neumorph'=新拟物(浅色) / 'macos'=macOS 玻璃(蓝,随外观自动浅深) / 'clay'=粘土拟态 / 'endfield'=终末地纸墨工业风。 */
410
- const [panelTheme, setPanelTheme] = useState<'liquid' | 'neumorph' | 'macos' | 'clay' | 'endfield'>(() => {
411
- try {
412
- const v = window.localStorage.getItem('dsh-novel-forge.theme')
413
- return v === 'neumorph' || v === 'macos' || v === 'clay' || v === 'endfield' ? v : 'liquid'
414
- } catch { return 'liquid' }
415
- })
416
- const changePanelTheme = (next: 'liquid' | 'neumorph' | 'macos' | 'clay' | 'endfield'): void => {
417
- setPanelTheme(next)
418
- try { window.localStorage.setItem('dsh-novel-forge.theme', next) } catch { /* ignore */ }
419
- }
420
- /** 终末地强调色(localStorage 记忆):'valley'=谷地黄(默认) / 'wuling'=武陵青。 */
421
- const [endfieldAccent, setEndfieldAccent] = useState<'valley' | 'wuling'>(() => {
422
- try {
423
- const v = window.localStorage.getItem('dsh-novel-forge.theme.endfield.accent')
424
- return v === 'wuling' ? v : 'valley'
425
- } catch { return 'valley' }
426
- })
427
- const changeEndfieldAccent = (next: 'valley' | 'wuling'): void => {
428
- setEndfieldAccent(next)
429
- try { window.localStorage.setItem('dsh-novel-forge.theme.endfield.accent', next) } catch { /* ignore */ }
430
- }
431
- /** 显示模式(跟随系统 / 强制浅色 / 强制深色),localStorage 记忆,只作用于小说工坊面板。 */
432
- const [themeMode, setThemeMode] = useState<'system' | 'light' | 'dark'>(() => {
433
- try {
434
- const v = window.localStorage.getItem('dsh-novel-forge.theme.mode')
435
- return v === 'light' || v === 'dark' ? v : 'system'
436
- } catch { return 'system' }
437
- })
438
- const changeThemeMode = (next: 'system' | 'light' | 'dark'): void => {
439
- setThemeMode(next)
440
- try { window.localStorage.setItem('dsh-novel-forge.theme.mode', next) } catch { /* ignore */ }
441
- }
442
- /** 界面密度(舒适 / 紧凑 / 宽松),localStorage 记忆。 */
443
- const [themeDensity, setThemeDensity] = useState<'comfort' | 'compact' | 'spacious'>(() => {
444
- try {
445
- const v = window.localStorage.getItem('dsh-novel-forge.theme.density')
446
- return v === 'compact' || v === 'spacious' ? v : 'comfort'
447
- } catch { return 'comfort' }
448
- })
449
- const changeThemeDensity = (next: 'comfort' | 'compact' | 'spacious'): void => {
450
- setThemeDensity(next)
451
- try { window.localStorage.setItem('dsh-novel-forge.theme.density', next) } catch { /* ignore */ }
452
- }
453
- /** 恢复默认主题:清掉主题/模式/密度/终末地强调色记忆。 */
454
- const resetTheme = (): void => {
455
- try {
456
- window.localStorage.removeItem('dsh-novel-forge.theme')
457
- window.localStorage.removeItem('dsh-novel-forge.theme.mode')
458
- window.localStorage.removeItem('dsh-novel-forge.theme.density')
459
- window.localStorage.removeItem('dsh-novel-forge.theme.endfield.accent')
460
- } catch { /* ignore */ }
461
- setPanelTheme('liquid')
462
- setThemeMode('system')
463
- setThemeDensity('comfort')
464
- setEndfieldAccent('valley')
465
- }
466
- /** 设置页内子导航:当前分组(localStorage 记忆)。 */
467
- const [settingsTab, setSettingsTab] = useState<'model' | 'writing' | 'image' | 'files' | 'appearance'>(() => {
468
- try {
469
- const v = window.localStorage.getItem('dsh-novel-forge.settings.tab')
470
- return v === 'writing' || v === 'image' || v === 'files' || v === 'appearance' ? v : 'model'
471
- } catch { return 'model' }
472
- })
473
- const changeSettingsTab = (next: 'model' | 'writing' | 'image' | 'files' | 'appearance'): void => {
474
- setSettingsTab(next)
475
- try { window.localStorage.setItem('dsh-novel-forge.settings.tab', next) } catch { /* ignore */ }
476
- }
477
- /** 有未采纳草稿的章节号(refresh 后检测到遗留草稿时提示)。 */
478
- const [draftNo, setDraftNo] = useState<number | null>(null)
479
- /** 大纲页「更新大纲」编辑区是否展开。 */
480
- const [updatingOutline, setUpdatingOutline] = useState(false)
481
- /** 全书质检结果(null = 未运行)。 */
482
- const [auditIssues, setAuditIssues] = useState<import('../../protocol.ts').AuditIssue[] | null>(null)
483
- /** 全书质检实时状态(来自 /status,用于显示进度)。 */
484
- const [auditStatus, setAuditStatus] = useState<AuditStatus | null>(null)
485
- /** 角色卡(从事实库聚合)。 */
486
- const [charCards, setCharCards] = useState<import('../../protocol.ts').RoleStatusCard[] | null>(null)
487
- /** 世界观规则编辑草稿(bible tab,每行一条)。 */
488
- const [worldRulesDraft, setWorldRulesDraft] = useState('')
489
- /** 小说简介编辑草稿。 */
490
- const [blurbDraft, setBlurbDraft] = useState('')
491
- /** 书名编辑草稿(简介页改名用)。 */
492
- const [bookNameDraft, setBookNameDraft] = useState('')
493
- /** 封面 dataUrl(无封面为 null)。 */
494
- const [coverDataUrl, setCoverDataUrl] = useState<string | null>(null)
495
- /** 封面文件选择。 */
496
- const coverFileRef = useRef<HTMLInputElement | null>(null)
497
- /** 章节列表按卷折叠(存已折叠的卷号)。 */
498
- const [collapsedVolumes, setCollapsedVolumes] = useState<number[]>([])
499
- /** 章节页当前选中的卷('all' = 全部卷显示在一起)。 */
500
- const [selectedVolume, setSelectedVolume] = useState<number | 'all'>('all')
501
- /** 待办/主行动卡「定位章节」的目标章号(用于展开+滚动+高亮,消费后清空)。 */
502
- const [focusNo, setFocusNo] = useState<number | null>(null)
503
- /** 剧情线编辑草稿(null = 未在编辑)。 */
504
- const [plotlineDraft, setPlotlineDraft] = useState<{
505
- id: string
506
- name: string
507
- kind: Plotline['kind']
508
- goal: string
509
- progress: string
510
- status: Plotline['status']
511
- } | null>(null)
512
- /** 长线管理页:子页签(剧情线 / 伏笔),localStorage 记忆。 */
513
- const [longlineTab, setLonglineTab] = useState<'plotlines' | 'foreshadow'>(() => {
514
- try {
515
- const v = window.localStorage.getItem('dsh-novel-forge.longline.tab')
516
- return v === 'foreshadow' ? 'foreshadow' : 'plotlines'
517
- } catch { return 'plotlines' }
518
- })
519
- const changeLonglineTab = (next: 'plotlines' | 'foreshadow'): void => {
520
- setLonglineTab(next)
521
- try { window.localStorage.setItem('dsh-novel-forge.longline.tab', next) } catch { /* ignore */ }
522
- }
523
- /** 编年 / 复盘页:子页签(编年录 / 复盘记录),localStorage 记忆。 */
524
- const [archiveTab, setArchiveTab] = useState<'facts' | 'reviews'>(() => {
525
- try {
526
- const v = window.localStorage.getItem('dsh-novel-forge.archive.tab')
527
- return v === 'reviews' ? 'reviews' : 'facts'
528
- } catch { return 'facts' }
529
- })
530
- const changeArchiveTab = (next: 'facts' | 'reviews'): void => {
531
- setArchiveTab(next)
532
- try { window.localStorage.setItem('dsh-novel-forge.archive.tab', next) } catch { /* ignore */ }
533
- }
534
- /** 本书设定页:子页签(设定库 / 大世界 / 角色库 / 编年·复盘),localStorage 记忆。 */
535
- const [bookTab, setBookTab] = useState<'bible' | 'world' | 'roles' | 'facts'>(() => {
536
- try {
537
- const v = window.localStorage.getItem('dsh-novel-forge.book.tab')
538
- return v === 'world' || v === 'roles' || v === 'facts' ? v : 'bible'
539
- } catch { return 'bible' }
540
- })
541
- const changeBookTab = (next: 'bible' | 'world' | 'roles' | 'facts'): void => {
542
- setBookTab(next)
543
- try { window.localStorage.setItem('dsh-novel-forge.book.tab', next) } catch { /* ignore */ }
544
- }
545
- /** 角色知情度编辑草稿(角色名 → 文本,每行一条)。 */
546
- const [knowledgeDraft, setKnowledgeDraft] = useState<Record<string, string>>({})
547
- /** 角色库:AI 提炼候选(null = 未运行;localStorage 持久化,刷新不丢)。 */
548
- const [roleCandidates, setRoleCandidates] = useState<RoleRecord[] | null>(() => {
549
- try {
550
- const raw = window.localStorage.getItem('dsh-novel-forge.role.candidates')
551
- if (raw !== null) {
552
- const parsed = JSON.parse(raw) as RoleRecord[]
553
- return Array.isArray(parsed) && parsed.length > 0 ? parsed : null
554
- }
555
- } catch { /* ignore */ }
556
- return null
557
- })
558
- /** 角色库:编辑草稿(null = 不在编辑)。 */
559
- const [roleDraft, setRoleDraft] = useState<RoleRecord | null>(null)
560
- /** 角色库候选持久化:提炼/采纳后写回 localStorage。 */
561
- useEffect(() => {
562
- try {
563
- if (roleCandidates !== null && roleCandidates.length > 0) {
564
- window.localStorage.setItem('dsh-novel-forge.role.candidates', JSON.stringify(roleCandidates))
565
- } else {
566
- window.localStorage.removeItem('dsh-novel-forge.role.candidates')
567
- }
568
- } catch { /* ignore */ }
569
- }, [roleCandidates])
570
- /** 全书敏感词检查结果(null = 未运行)。 */
571
- const [sensHits, setSensHits] = useState<SensitiveHit[] | null>(null)
572
- const [sensScanned, setSensScanned] = useState(0)
573
- /** 拆书分析结果(null = 未运行)。 */
574
- const [breakdownResult, setBreakdownResult] = useState<import('../../protocol.ts').BreakdownResponse | null>(null)
575
- const [breakdownScope, setBreakdownScope] = useState<'recent' | 'volume:2' | 'volume:3' | 'all'>('recent')
576
- const [breakdownPreset, setBreakdownPreset] = useState<'quick' | 'standard'>('quick')
577
- /** AI 建议的剧情线候选(null = 未运行)。 */
578
- const [plotlineSuggestions, setPlotlineSuggestions] = useState<Plotline[] | null>(null)
579
- /** 剧情健康检查报告(null = 未运行)。 */
580
- const [plotlineHealth, setPlotlineHealth] = useState<PlotlineHealthReport | null>(null)
581
- /** AI 剧情方案(null = 未运行)。 */
582
- const [plotlinePlan, setPlotlinePlan] = useState<PlotlinePlan | null>(null)
583
- /** npm 最新版本(更新检测;null = 未检测/检测失败)。 */
584
- const [npmLatest, setNpmLatest] = useState<string | null>(null)
585
- /** 活动输出容器:自动滚动锚点(有新活动时跟随到底部)。 */
586
- const progressEndRef = useRef<HTMLDivElement | null>(null)
587
- useEffect(() => {
588
- progressEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
589
- }, [progress])
590
-
591
- /** 后台检测 npm 最新版本(失败静默,不打扰)。 */
592
- useEffect(() => {
593
- let cancelled = false
594
- void fetch('https://registry.npmjs.org/@waterwx%2Fdsh-novel-forge')
595
- .then(response => response.json() as Promise<{ 'dist-tags'?: { latest?: string } }>)
596
- .then(data => {
597
- if (!cancelled && data['dist-tags']?.latest !== undefined) setNpmLatest(data['dist-tags'].latest)
598
- })
599
- .catch(() => { /* best-effort */ })
600
- return () => { cancelled = true }
601
- }, [])
602
- /** AI 助手悬浮窗:是否打开。 */
603
- const [assistantOpen, setAssistantOpen] = useState(false)
604
- const [advancedOpen, setAdvancedOpen] = useState(false)
605
- /** AI进度悬浮窗:是否打开。 */
606
- const [progressOpen, setProgressOpen] = useState(false)
607
- /** AI进度悬浮窗位置(localStorage 记忆)。 */
608
- const [progressPos, setProgressPos] = useState(() => {
609
- try {
610
- const raw = window.localStorage.getItem('dsh-novel-forge.progress.float')
611
- if (raw !== null) {
612
- const parsed = JSON.parse(raw) as { x?: unknown; y?: unknown }
613
- return { x: typeof parsed.x === 'number' ? parsed.x : 60, y: typeof parsed.y === 'number' ? parsed.y : 120 }
614
- }
615
- } catch { /* ignore */ }
616
- return { x: 60, y: 120 }
617
- })
618
- /** AI进度悬浮窗尺寸(localStorage 记忆)。 */
619
- const [progressSize, setProgressSize] = useState(() => {
620
- try {
621
- const raw = window.localStorage.getItem('dsh-novel-forge.progress.size')
622
- if (raw !== null) {
623
- const parsed = JSON.parse(raw) as { w?: unknown; h?: unknown }
624
- return { w: typeof parsed.w === 'number' ? parsed.w : 460, h: typeof parsed.h === 'number' ? parsed.h : 420 }
625
- }
626
- } catch { /* ignore */ }
627
- return { w: 460, h: 420 }
628
- })
629
- /** 悬浮窗位置(相对面板,localStorage 记忆)。 */
630
- const [assistantPos, setAssistantPos] = useState(() => {
631
- try {
632
- const raw = window.localStorage.getItem('dsh-novel-forge.assistant.float')
633
- if (raw !== null) {
634
- const parsed = JSON.parse(raw) as { x?: unknown; y?: unknown }
635
- return { x: typeof parsed.x === 'number' ? parsed.x : 260, y: typeof parsed.y === 'number' ? parsed.y : 60 }
636
- }
637
- } catch { /* ignore */ }
638
- return { x: 260, y: 60 }
639
- })
640
- /** 悬浮窗尺寸(localStorage 记忆)。 */
641
- const [assistantSize, setAssistantSize] = useState(() => {
642
- try {
643
- const raw = window.localStorage.getItem('dsh-novel-forge.assistant.size')
644
- if (raw !== null) {
645
- const parsed = JSON.parse(raw) as { w?: unknown; h?: unknown }
646
- return { w: typeof parsed.w === 'number' ? parsed.w : 420, h: typeof parsed.h === 'number' ? parsed.h : 460 }
647
- }
648
- } catch { /* ignore */ }
649
- return { w: 420, h: 460 }
650
- })
651
- /** 拖拽/缩放状态(target 区分 AI 助手 / AI进度两个悬浮窗)。 */
652
- const dragState = useRef<{ type: 'move' | 'resize'; target: 'assistant' | 'progress'; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null>(null)
653
-
654
- /** 悬浮窗位置/尺寸持久化。 */
655
- useEffect(() => {
656
- try {
657
- window.localStorage.setItem('dsh-novel-forge.assistant.float', JSON.stringify(assistantPos))
658
- window.localStorage.setItem('dsh-novel-forge.assistant.size', JSON.stringify(assistantSize))
659
- window.localStorage.setItem('dsh-novel-forge.progress.float', JSON.stringify(progressPos))
660
- window.localStorage.setItem('dsh-novel-forge.progress.size', JSON.stringify(progressSize))
661
- } catch { /* ignore */ }
662
- }, [assistantPos, assistantSize, progressPos, progressSize])
663
-
664
- /** 全局拖拽/缩放监听(挂一次,靠 dragState 判断)。 */
665
- useEffect(() => {
666
- const onMove = (e: MouseEvent): void => {
667
- const s = dragState.current
668
- if (s === null) return
669
- if (s.type === 'move') {
670
- const next = {
671
- x: Math.max(-340, Math.min(s.origX + e.clientX - s.startX, 3000)),
672
- y: Math.max(0, Math.min(s.origY + e.clientY - s.startY, 3000)),
673
- }
674
- if (s.target === 'assistant') setAssistantPos(next)
675
- else setProgressPos(next)
676
- } else {
677
- const next = {
678
- w: Math.max(320, Math.min(s.origW + e.clientX - s.startX, 1400)),
679
- h: Math.max(220, Math.min(s.origH + e.clientY - s.startY, 1200)),
680
- }
681
- if (s.target === 'assistant') setAssistantSize(next)
682
- else setProgressSize(next)
683
- }
684
- }
685
- const onUp = (): void => { dragState.current = null }
686
- window.addEventListener('mousemove', onMove)
687
- window.addEventListener('mouseup', onUp)
688
- return () => {
689
- window.removeEventListener('mousemove', onMove)
690
- window.removeEventListener('mouseup', onUp)
691
- }
692
- }, [])
693
- /** 左侧导航折叠状态(localStorage 记忆,参照 AI-Novel-Writing-Assistant 侧边栏)。 */
694
- const [navCollapsed, setNavCollapsed] = useState(() => {
695
- try { return window.localStorage.getItem('dsh-novel-forge.nav.collapsed') === 'true' } catch { return false }
696
- })
697
- /** 视图:shelf = 书架首页;create = 开书向导;workspace = 当前书工作台。 */
698
- const [viewMode, setViewMode] = useState<'shelf' | 'create' | 'workspace' | 'reader'>('shelf')
699
- /** 从「创意灵感」采纳后带入开书向导的预填(书名 + 一句话想法)。 */
700
- const [createPrefill, setCreatePrefill] = useState<{ name: string; idea: string } | null>(null)
701
- const [showImport, setShowImport] = useState(false)
702
-
703
- /** Refresh bookshelf. */
704
- const refreshShelf = useCallback(async () => {
705
- try {
706
- const snapshot = await api.bookshelf()
707
- setShelf(snapshot)
708
- if (!bookBoundRef.current) {
709
- bookBoundRef.current = true
710
- const initialId = snapshot.activeBookId ?? snapshot.books[0]?.id ?? null
711
- setCurrentBook(initialId)
712
- }
713
- } catch { /* shelf is best-effort */ }
714
- }, [api])
715
-
716
- /** Append a progress console line. */
717
- const pushProgress = useCallback((text: string, kind: ProgressLine['kind'] = 'info') => {
718
- setProgress(prev => [...prev.slice(-300), { id: progressId.current++, text, kind }])
719
- }, [])
720
-
721
- /** Update the single live progress row in place (create it on first call). */
722
- const setLiveProgress = useCallback((text: string, ratio?: number) => {
723
- setProgress(prev => {
724
- if (liveProgressId.current !== null) {
725
- return prev.map(l => l.id === liveProgressId.current ? { ...l, text, ratio } : l)
726
- }
727
- const id = progressId.current++
728
- liveProgressId.current = id
729
- return [...prev.slice(-300), { id, text, kind: 'info', live: true, ratio }]
730
- })
731
- }, [])
732
-
733
- /** Remove the live progress row (a job finished / failed). */
734
- const clearLiveProgress = useCallback(() => {
735
- if (liveProgressId.current === null) return
736
- const id = liveProgressId.current
737
- liveProgressId.current = null
738
- setProgress(prev => prev.filter(l => l.id !== id))
739
- }, [])
740
-
741
- /** Refresh status (config + project + files). */
742
- const refresh = useCallback(async (showError = true, forceOutline = false) => {
743
- try {
744
- const status = await api.status()
745
- setConfig(status.config)
746
- setConfigDraft(status.config)
747
- setAuditStatus(status.audit ?? null)
748
- setProject(status.project ?? null)
749
- // 人物志存档同步:有 roleStatus 存档直接显示,无需重新刷新计算。
750
- if (status.project?.roleStatus !== undefined) {
751
- setCharCards(status.project.roleStatus)
752
- } else {
753
- setCharCards(null)
754
- }
755
- setGeneratedFiles(status.generatedFiles)
756
- const withDraft = status.project?.chapters.find(c => c.pendingDraft !== undefined && c.pendingDraft !== '')
757
- setDraftNo(withDraft?.no ?? null)
758
- const nextOutline = status.project?.outline
759
- // forceOutline:切换书/开书后强制同步大纲(refresh 闭包里的
760
- // outlineText 可能是旧值,导致 `=== ''` 条件失效、大纲不同步、
761
- // 「生成章节计划」按钮被禁用)。
762
- if (forceOutline || (nextOutline !== undefined && outlineText === '')) {
763
- setOutlineText(nextOutline ?? '')
764
- }
765
- } catch (err) {
766
- if (showError) setError((err as Error).message)
767
- }
768
- }, [api, outlineText])
769
-
770
- /** 激活一本书(书架入口共用):重置本地编辑状态 → 拉取目标书 → 进入工作台或阅读页。 */
771
- const activateBook = useCallback(async (id: string, mode: 'workspace' | 'reader') => {
772
- setBusy(true)
773
- setError('')
774
- try {
775
- await api.bookActivate(id)
776
- // 显式切书:重绑当前书(之后所有书级请求明确带 bookId,不再被全局 active 串书)。
777
- setCurrentBook(id)
778
- // 切换书后重置本地编辑状态,重新拉取目标书。
779
- setOutlineText('')
780
- setProject(null)
781
- setGeneratedFiles([])
782
- setChapterText('')
783
- setExpandedChapter(null)
784
- setProgress([])
785
- setAuditIssues(null)
786
- setCharCards(null)
787
- await refresh(false, true)
788
- await refreshShelf()
789
- setViewMode(mode)
790
- } catch (err) {
791
- setError((err as Error).message)
792
- } finally {
793
- setBusy(false)
794
- }
795
- }, [api, refresh, refreshShelf])
796
-
797
- /** 首页「设置」与书内设置同入口:激活当前/首选书并跳到设置页。 */
798
- const openSettingsFromHome = async (): Promise<void> => {
799
- const targetId = shelf?.activeBookId ?? shelf?.books[0]?.id ?? null
800
- if (targetId === null || targetId === undefined) {
801
- setError('请先在书架开一本书;设置页以书籍工作台为入口')
802
- return
803
- }
804
- await activateBook(targetId, 'workspace')
805
- setActiveTab('settings')
806
- }
807
-
808
- /** Handle a docx file (pick or drag): parse locally, save outline. */
809
- const handleDocxFile = useCallback(async (file: File) => {
810
- setBusy(true)
811
- setBusyLabel(tt('overview.loadingOutline'))
812
- setError('')
813
- try {
814
- const buffer = await file.arrayBuffer()
815
- const outline = extractDocxTextFromBuffer(buffer)
816
- if (outline.length < 50) {
817
- throw new Error('大纲内容过短(<50 字符),请检查文件')
818
- }
819
- setOutlineText(outline)
820
- await api.saveOutline(outline)
821
- await refresh(false)
822
- pushProgress(`已从「${file.name}」读取大纲(${outline.length} 字)`, 'done')
823
- } catch (err) {
824
- setError((err as Error).message)
825
- pushProgress(`读取大纲失败:${(err as Error).message}`, 'error')
826
- } finally {
827
- setBusy(false)
828
- setBusyLabel('')
829
- }
830
- }, [api, pushProgress, refresh])
831
-
832
- useEffect(() => {
833
- void refresh()
834
- void refreshShelf()
835
- // eslint-disable-next-line react-hooks/exhaustive-deps
836
- }, [])
837
-
838
- /** 进入简介页时预填已保存的简介。 */
839
- useEffect(() => {
840
- if (activeTab === 'blurb') {
841
- if (blurbDraft === '' && project?.blurb !== undefined && project.blurb !== '') {
842
- setBlurbDraft(project.blurb)
843
- }
844
- if (bookNameDraft === '' && project?.bookName !== undefined && project.bookName !== '') {
845
- setBookNameDraft(project.bookName)
846
- }
847
- void loadCover()
848
- }
849
- // eslint-disable-next-line react-hooks/exhaustive-deps
850
- }, [activeTab, project?.blurb, project?.bookName])
851
-
852
- /** Load the outline from docx (default path or custom). */
853
- const handleLoadDocx = async (useCustom: boolean): Promise<void> => {
854
- setBusy(true)
855
- setBusyLabel(tt('overview.loadingOutline'))
856
- setError('')
857
- try {
858
- const result = await api.loadOutline(useCustom ? customDocxPath || undefined : undefined)
859
- setOutlineText(result.outline)
860
- // Persist into the project (load-or-create).
861
- await api.saveOutline(result.outline)
862
- await refresh(false)
863
- pushProgress(`大纲已读取(${result.chars} 字):${result.bookName}${result.path !== undefined ? ` ← ${result.path}` : ''}`, 'done')
864
- } catch (err) {
865
- setError((err as Error).message)
866
- pushProgress(`读取大纲失败:${(err as Error).message}`, 'error')
867
- } finally {
868
- setBusy(false)
869
- setBusyLabel('')
870
- }
871
- }
872
-
873
- /** Save the edited outline. */
874
- const handleSaveOutline = async (): Promise<void> => {
875
- setBusy(true)
876
- setError('')
877
- try {
878
- await api.saveOutline(outlineText)
879
- setNotice(tt('overview.saved'))
880
- pushProgress(tt('overview.saved'), 'done')
881
- await refresh(false)
882
- } catch (err) {
883
- setError((err as Error).message)
884
- } finally {
885
- setBusy(false)
886
- }
887
- }
888
-
889
- /** 反推大纲:从已写章节正文反向生成全书总纲(流式进度)。 */
890
- const handleReverseOutline = async (): Promise<void> => {
891
- if (project === null) return
892
- if (!window.confirm('将从已写章节正文反推出全书总纲(覆盖当前大纲文本,不影响章节/设定/进度)。确定继续?')) return
893
- setBusy(true)
894
- setError('')
895
- try {
896
- await api.outlineReverse(frame => {
897
- if (frame.type === 'outline-progress') {
898
- pushProgress(`反推大纲:${frame.phase}(${frame.done}/${frame.total})`, 'info')
899
- } else if (frame.type === 'outline-done') {
900
- setOutlineText(frame.outline)
901
- pushProgress(`反推大纲完成(${frame.chars} 字),已保存为当前总纲`, 'done')
902
- }
903
- })
904
- await refresh(false)
905
- } catch (err) {
906
- setError((err as Error).message)
907
- pushProgress(`反推大纲失败:${(err as Error).message}`, 'error')
908
- } finally {
909
- setBusy(false)
910
- }
911
- }
912
-
913
- /** 大纲页「更新大纲」展开/收起(展开时预填当前大纲文本)。 */
914
- const handleToggleUpdateOutline = (): void => {
915
- if (!updatingOutline && project !== null) setOutlineText(project.outline)
916
- setUpdatingOutline(v => !v)
917
- }
918
-
919
- /** 重置项目:清空全部进度,从新大纲重新开始(二次确认)。 */
920
- const handleResetProject = async (): Promise<void> => {
921
- if (project === null) return
922
- if (!window.confirm('将清空本书全部进度(道藏/卷计划/章节计划/已生成章节/暗线/写作资产/编年录),且不可恢复。确定用新总纲重置?')) return
923
- setBusy(true)
924
- setError('')
925
- try {
926
- const result = await api.reset(outlineText)
927
- setUpdatingOutline(false)
928
- pushProgress(`已重置项目:${result.bookName}(从新大纲重新开始)`, 'done')
929
- } catch (err) {
930
- setError((err as Error).message)
931
- pushProgress(`重置失败:${(err as Error).message}`, 'error')
932
- } finally {
933
- setBusy(false)
934
- setBusyLabel('')
935
- await refresh(false)
936
- }
937
- }
938
-
939
- /** 全书一致性质检(LLM 扫描已生成章节)。 */
940
- const handleAudit = async (): Promise<void> => {
941
- setBusy(true)
942
- setBusyLabel('全书一致性质检中…')
943
- setError('')
944
- setAuditStatus({ status: 'running', totalBatches: 0, completedBatches: 0 })
945
- let stopped = false
946
- const poll = window.setInterval(async () => {
947
- if (stopped) return
948
- try {
949
- const s = await api.status()
950
- if (s.audit !== undefined) setAuditStatus(s.audit)
951
- } catch { /* 轮询失败忽略,主请求仍会给出最终结果 */ }
952
- }, 1000)
953
- try {
954
- const result = await api.audit()
955
- if (stopped) return
956
- setAuditIssues(result.issues)
957
- pushProgress(result.issues.length === 0
958
- ? `全书质检完成:${result.auditedChapters} 章未发现矛盾 🎉`
959
- : `全书质检完成:发现 ${result.issues.length} 处疑似矛盾`, result.issues.length === 0 ? 'done' : 'error')
960
- } catch (err) {
961
- if (stopped) return
962
- setError((err as Error).message)
963
- pushProgress(`全书质检失败:${(err as Error).message}`, 'error')
964
- } finally {
965
- stopped = true
966
- window.clearInterval(poll)
967
- setBusy(false)
968
- setBusyLabel('')
969
- await refresh(false)
970
- }
971
- }
972
-
973
- /** 角色卡刷新。 */
974
- const handleCharactersRefresh = async (): Promise<void> => {
975
- setBusy(true)
976
- setBusyLabel('聚合角色状态中…')
977
- setError('')
978
- try {
979
- const result = await api.charactersRefresh()
980
- setCharCards(result.cards)
981
- // 同步到项目存档(角色库卡片显示当前状态)。
982
- setProject(prev => prev === null ? prev : { ...prev, roleStatus: result.cards, updatedAt: new Date().toISOString() })
983
- pushProgress(`角色状态已刷新:${result.cards.length} 个角色`, 'done')
984
- } catch (err) {
985
- setError((err as Error).message)
986
- pushProgress(`角色状态刷新失败:${(err as Error).message}`, 'error')
987
- } finally {
988
- setBusy(false)
989
- setBusyLabel('')
990
- }
991
- }
992
-
993
- /** 事实库回填(历史章节批量抽取)。 */
994
- const handleFactsBackfill = async (): Promise<void> => { if (doneCount === 0) return
995
- setBusy(true)
996
- setBusyLabel('回填历史章节事实中…')
997
- setError('')
998
- try {
999
- const result = await api.factsBackfill()
1000
- pushProgress(result.filled > 0
1001
- ? `事实库回填完成:${result.filled} 章已抽取事实`
1002
- : '事实库无需回填(所有章节都已有事实记录)', 'done')
1003
- await refresh(false)
1004
- } catch (err) {
1005
- setError((err as Error).message)
1006
- pushProgress(`事实库回填失败:${(err as Error).message}`, 'error')
1007
- } finally {
1008
- setBusy(false)
1009
- setBusyLabel('')
1010
- }
1011
- }
1012
-
1013
- /** 章节复位:generating 卡死 → pending(可重新生成)。 */
1014
- const handleChapterReset = async (no: number): Promise<void> => {
1015
- setBusy(true)
1016
- setError('')
1017
- try {
1018
- await api.chapterReset(no)
1019
- setProject(prev => prev === null ? prev : {
1020
- ...prev,
1021
- chapters: prev.chapters.map(c => c.no === no ? { ...c, status: 'pending', error: undefined } : c),
1022
- })
1023
- pushProgress(`第 ${no} 章已复位为待生成,可重新生成`, 'info')
1024
- } catch (err) {
1025
- setError((err as Error).message)
1026
- pushProgress(`复位失败:${(err as Error).message}`, 'error')
1027
- } finally {
1028
- setBusy(false)
1029
- }
1030
- }
1031
-
1032
- /** 章节直接通过(作者行使最终决定权)。 */
1033
- const handleChapterApprove = async (no: number): Promise<void> => {
1034
- if (!window.confirm(`确定直接通过第 ${no} 章?(跳过审稿判定,保留审稿记录)`)) return
1035
- setBusy(true)
1036
- setError('')
1037
- try {
1038
- await api.chapterApprove(no)
1039
- setProject(prev => prev === null ? prev : {
1040
- ...prev,
1041
- chapters: prev.chapters.map(c => c.no === no ? { ...c, status: 'approved' } : c),
1042
- })
1043
- pushProgress(`第 ${no} 章已直接通过`, 'done')
1044
- } catch (err) {
1045
- setError((err as Error).message)
1046
- pushProgress(`操作失败:${(err as Error).message}`, 'error')
1047
- } finally {
1048
- setBusy(false)
1049
- }
1050
- }
1051
-
1052
- /** 剧情线:保存草稿(新增或更新)。 */
1053
- const handlePlotlineSave = async (): Promise<void> => {
1054
- if (plotlineDraft === null) return
1055
- const line: Plotline = {
1056
- id: plotlineDraft.id,
1057
- name: plotlineDraft.name.trim(),
1058
- kind: plotlineDraft.kind,
1059
- goal: plotlineDraft.goal.trim(),
1060
- progress: plotlineDraft.progress.trim(),
1061
- status: plotlineDraft.status,
1062
- chapters: plotlineDraft.id !== ''
1063
- ? (project?.plotlines?.find(l => l.id === plotlineDraft.id)?.chapters ?? [])
1064
- : [],
1065
- createdAt: plotlineDraft.id !== ''
1066
- ? (project?.plotlines?.find(l => l.id === plotlineDraft.id)?.createdAt ?? new Date().toISOString())
1067
- : new Date().toISOString(),
1068
- }
1069
- if (line.name === '') {
1070
- setError('剧情线名称不能为空')
1071
- return
1072
- }
1073
- setBusy(true)
1074
- setError('')
1075
- try {
1076
- const result = await api.plotlines({ op: plotlineDraft.id !== '' ? 'update' : 'add', line })
1077
- setProject(prev => prev === null ? prev : { ...prev, plotlines: result.plotlines, updatedAt: new Date().toISOString() })
1078
- setPlotlineDraft(null)
1079
- pushProgress(plotlineDraft.id !== '' ? `剧情线已更新:${line.name}` : `剧情线已创建:${line.name}`, 'done')
1080
- } catch (err) {
1081
- setError((err as Error).message)
1082
- pushProgress(`保存剧情线失败:${(err as Error).message}`, 'error')
1083
- } finally {
1084
- setBusy(false)
1085
- }
1086
- }
1087
-
1088
- /** 剧情线:删除。 */
1089
- const handlePlotlineRemove = async (id: string): Promise<void> => {
1090
- if (!window.confirm('确定删除这条剧情线?关联章节记录会一并移除。')) return
1091
- setBusy(true)
1092
- setError('')
1093
- try {
1094
- const result = await api.plotlines({ op: 'remove', id })
1095
- setProject(prev => prev === null ? prev : { ...prev, plotlines: result.plotlines, updatedAt: new Date().toISOString() })
1096
- pushProgress('剧情线已删除', 'done')
1097
- } catch (err) {
1098
- setError((err as Error).message)
1099
- } finally {
1100
- setBusy(false)
1101
- }
1102
- }
1103
-
1104
- /** 剧情线:把本章关联到某条线(推进节点)。 */
1105
- const handlePlotlineLink = async (id: string, chapterNo: number): Promise<void> => {
1106
- setBusy(true)
1107
- setError('')
1108
- try {
1109
- const result = await api.plotlines({ op: 'link', id, chapterNo })
1110
- setProject(prev => prev === null ? prev : { ...prev, plotlines: result.plotlines, updatedAt: new Date().toISOString() })
1111
- pushProgress(`已把第 ${chapterNo} 章关联到剧情线`, 'done')
1112
- } catch (err) {
1113
- setError((err as Error).message)
1114
- } finally {
1115
- setBusy(false)
1116
- }
1117
- }
1118
-
1119
- /** 剧情线:AI 建议候选线。 */
1120
- const handlePlotlineSuggest = async (): Promise<void> => {
1121
- setBusy(true)
1122
- setBusyLabel('AI 分析剧情线中…')
1123
- setError('')
1124
- try {
1125
- const result = await api.plotlines({ op: 'suggest' })
1126
- setPlotlineSuggestions(result.suggestions ?? [])
1127
- pushProgress(result.suggestions !== undefined && result.suggestions.length > 0
1128
- ? `AI 建议了 ${result.suggestions.length} 条剧情线,可逐条采纳`
1129
- : 'AI 没有给出剧情线建议,请检查大纲是否已加载', 'done')
1130
- } catch (err) {
1131
- setError((err as Error).message)
1132
- pushProgress(`AI 建议剧情线失败:${(err as Error).message}`, 'error')
1133
- } finally {
1134
- setBusy(false)
1135
- setBusyLabel('')
1136
- }
1137
- }
1138
-
1139
- /** 剧情线:采纳一条 AI 建议。 */
1140
- const handlePlotlineAdopt = async (suggestion: Plotline): Promise<void> => {
1141
- setBusy(true)
1142
- setError('')
1143
- try {
1144
- const result = await api.plotlines({ op: 'add', line: { ...suggestion, id: '' } })
1145
- setProject(prev => prev === null ? prev : { ...prev, plotlines: result.plotlines, updatedAt: new Date().toISOString() })
1146
- setPlotlineSuggestions(prev => prev === null ? prev : prev.filter(s => s !== suggestion))
1147
- pushProgress(`已采纳剧情线:${suggestion.name}`, 'done')
1148
- } catch (err) {
1149
- setError((err as Error).message)
1150
- } finally {
1151
- setBusy(false)
1152
- }
1153
- }
1154
-
1155
- /** 剧情线:AI 刷新单条线进度。 */
1156
- const handlePlotlineRefresh = async (id: string): Promise<void> => {
1157
- setBusy(true)
1158
- setBusyLabel('AI 刷新剧情线进度中…')
1159
- setError('')
1160
- try {
1161
- const result = await api.plotlines({ op: 'refresh', id })
1162
- setProject(prev => prev === null ? prev : { ...prev, plotlines: result.plotlines, updatedAt: new Date().toISOString() })
1163
- const line = result.plotlines.find(l => l.id === id)
1164
- pushProgress(`剧情线进度已刷新:${line?.progress ?? ''}`, 'done')
1165
- } catch (err) {
1166
- setError((err as Error).message)
1167
- pushProgress(`刷新剧情线进度失败:${(err as Error).message}`, 'error')
1168
- } finally {
1169
- setBusy(false)
1170
- setBusyLabel('')
1171
- }
1172
- }
1173
-
1174
- /** 🩺 剧情健康检查:判断是否需要新线、多少章后添加。 */
1175
- const handlePlotlineHealth = async (): Promise<void> => {
1176
- setBusy(true)
1177
- setBusyLabel('🩺 剧情健康检查中…')
1178
- setError('')
1179
- try {
1180
- const result = await api.plotlines({ op: 'health' })
1181
- setPlotlineHealth(result.health ?? null)
1182
- pushProgress(`剧情健康检查完成:${result.health?.verdict ?? '无结论'}`, 'done')
1183
- } catch (err) {
1184
- setError((err as Error).message)
1185
- pushProgress(`剧情健康检查失败:${(err as Error).message}`, 'error')
1186
- } finally {
1187
- setBusy(false)
1188
- setBusyLabel('')
1189
- }
1190
- }
1191
-
1192
- /** ✨ AI 设计剧情方案:下一阶段方向 + 建议新线(含健康检查)。 */
1193
- const handlePlotlinePlan = async (): Promise<void> => {
1194
- setBusy(true)
1195
- setBusyLabel('✨ 设计剧情方案中…')
1196
- setError('')
1197
- try {
1198
- const result = await api.plotlines({ op: 'plan' })
1199
- setPlotlineHealth(result.health ?? null)
1200
- setPlotlinePlan(result.plan ?? null)
1201
- pushProgress(`剧情方案已生成:${result.plan?.suggestions.length ?? 0} 条建议新线`, 'done')
1202
- } catch (err) {
1203
- setError((err as Error).message)
1204
- pushProgress(`剧情方案生成失败:${(err as Error).message}`, 'error')
1205
- } finally {
1206
- setBusy(false)
1207
- setBusyLabel('')
1208
- }
1209
- }
1210
-
1211
- /** 采纳方案里的一条建议线。 */
1212
- const handlePlanAdopt = async (suggestion: Plotline): Promise<void> => {
1213
- setBusy(true)
1214
- setError('')
1215
- try {
1216
- const result = await api.plotlines({ op: 'add', line: { ...suggestion, id: '' } })
1217
- setProject(prev => prev === null ? prev : { ...prev, plotlines: result.plotlines, updatedAt: new Date().toISOString() })
1218
- setPlotlinePlan(prev => prev === null ? prev : { ...prev, suggestions: prev.suggestions.filter(s => s !== suggestion) })
1219
- pushProgress(`已采纳剧情线:${suggestion.name}`, 'done')
1220
- } catch (err) {
1221
- setError((err as Error).message)
1222
- } finally {
1223
- setBusy(false)
1224
- }
1225
- }
1226
-
1227
- /** 全书敏感词检查(硬匹配内置词库)。 */
1228
- const handleSensitiveScan = async (): Promise<void> => {
1229
- setBusy(true)
1230
- setBusyLabel('敏感词检查中…')
1231
- setError('')
1232
- try {
1233
- const result = await api.sensitiveCheck({ all: true })
1234
- setSensHits(result.hits)
1235
- setSensScanned(result.scannedChapters)
1236
- pushProgress(result.hits.length > 0
1237
- ? `敏感词检查:${result.hits.length} 处命中(${new Set(result.hits.map(h => h.chapterNo)).size} 章受影响)`
1238
- : `敏感词检查完成:扫描 ${result.scannedChapters} 章,未命中违禁词`, result.hits.length > 0 ? 'error' : 'done')
1239
- } catch (err) {
1240
- setError((err as Error).message)
1241
- pushProgress(`敏感词检查失败:${(err as Error).message}`, 'error')
1242
- } finally {
1243
- setBusy(false)
1244
- setBusyLabel('')
1245
- }
1246
- }
1247
-
1248
- /** 拆书分析:对已写章节做结构/人物/文风/卖点体检(两阶段 LLM 管道,约 1-3 分钟)。 */
1249
- const handleBreakdown = async (): Promise<void> => {
1250
- setBusy(true)
1251
- setBusyLabel('拆书分析中…')
1252
- setError('')
1253
- try {
1254
- const result = await api.breakdown(breakdownScope, breakdownPreset)
1255
- setBreakdownResult(result)
1256
- pushProgress(`拆书分析完成:${result.chaptersScanned} 章 · ${result.sections.length} 个小节 · 约 ${result.usedTokens} token`, 'done')
1257
- } catch (err) {
1258
- setError((err as Error).message)
1259
- pushProgress(`拆书分析失败:${(err as Error).message}`, 'error')
1260
- } finally {
1261
- setBusy(false)
1262
- setBusyLabel('')
1263
- }
1264
- }
1265
-
1266
- /** 生成单个角色的形象锚点(复用角色库 visual 能力)。 */
1267
- /** 作者复盘补跑:全书缺失章节(流式)。 */
1268
- const handleAuthorBackfillAll = async (): Promise<void> => {
1269
- const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error' && c.authorReview === undefined).length
1270
- setBusy(true)
1271
- setBusyLabel(`补齐历史章节作者复盘(${missing} 章)…`)
1272
- setError('')
1273
- try {
1274
- await api.reviewBackfillAll(frame => { applyJobFrame(frame, () => '') })
1275
- } catch (err) {
1276
- setError((err as Error).message)
1277
- pushProgress(`补齐作者复盘失败:${(err as Error).message}`, 'error')
1278
- } finally {
1279
- setBusy(false)
1280
- setBusyLabel('')
1281
- }
1282
- }
1283
-
1284
- /** 作者复盘补跑:单章。 */
1285
- const handleAuthorBackfillChapter = async (no: number): Promise<void> => {
1286
- setBusy(true)
1287
- setBusyLabel(`生成第 ${no} 章作者复盘…`)
1288
- setError('')
1289
- try {
1290
- const result = await api.reviewBackfillChapter(no)
1291
- setProject(prev => prev === null ? prev : {
1292
- ...prev,
1293
- chapters: prev.chapters.map(c => c.no === result.no ? { ...c, authorReview: result.review } : c),
1294
- })
1295
- const r = result.review
1296
- pushProgress(
1297
- `📋 第${no}章作者复盘:钩子${r.hookHonored ? '已兑现 ✓' : '未兑现 ✗'} · 结尾钩子 ${r.endingHook}/10`,
1298
- r.hookHonored && r.endingHook >= 6 ? 'done' : 'error',
1299
- )
1300
- } catch (err) {
1301
- setError((err as Error).message)
1302
- pushProgress(`生成第 ${no} 章作者复盘失败:${(err as Error).message}`, 'error')
1303
- } finally {
1304
- setBusy(false)
1305
- setBusyLabel('')
1306
- }
1307
- }
1308
-
1309
- /** 保存角色知情度(bible.characters 整体更新)。 */
1310
- const handleKnowledgeSave = async (): Promise<void> => {
1311
- if (bible === undefined) return
1312
- const characters = bible.characters.map(card => ({
1313
- ...card,
1314
- knowledge: (knowledgeDraft[card.name] ?? (card.knowledge ?? []).join('\n'))
1315
- .split('\n').map(l => l.trim()).filter(l => l !== ''),
1316
- }))
1317
- setBusy(true)
1318
- setError('')
1319
- try {
1320
- const result = await api.biblePatch({ characters })
1321
- setProject(prev => prev === null || prev.bible === undefined ? prev : { ...prev, bible: result.bible, updatedAt: new Date().toISOString() })
1322
- pushProgress('角色知情度已保存(生成/审稿都会严格遵守信息差)', 'done')
1323
- } catch (err) {
1324
- setError((err as Error).message)
1325
- pushProgress(`保存知情度失败:${(err as Error).message}`, 'error')
1326
- } finally {
1327
- setBusy(false)
1328
- }
1329
- }
1330
-
1331
- /** 角色库:AI 从全书提炼角色候选。 */
1332
- const handleRolesExtract = async (): Promise<void> => {
1333
- setBusy(true)
1334
- setBusyLabel('✨ 提炼角色库中…')
1335
- setError('')
1336
- try {
1337
- const result = await api.roles({ op: 'extract' })
1338
- // 双保险:过滤掉已入库的同名角色(服务端已排除,这里兜底)。
1339
- const inLibrary = new Set((project?.roles ?? []).map(r => r.name))
1340
- const fresh = (result.candidates ?? []).filter(r => !inLibrary.has(r.name))
1341
- setRoleCandidates(prev => {
1342
- const merged = [...(prev ?? []).filter(p => !fresh.some(f => f.name === p.name)), ...fresh]
1343
- return merged
1344
- })
1345
- pushProgress(`AI 提炼出 ${fresh.length} 个新角色(已排除 ${(result.candidates?.length ?? 0) - fresh.length} 个已收录)`, 'done')
1346
- } catch (err) {
1347
- setError((err as Error).message)
1348
- pushProgress(`角色提炼失败:${(err as Error).message}`, 'error')
1349
- } finally {
1350
- setBusy(false)
1351
- setBusyLabel('')
1352
- }
1353
- }
1354
-
1355
- /** 角色库:采纳候选(或修改后采纳)。 */
1356
- const handleRoleAdopt = async (role: RoleRecord): Promise<void> => {
1357
- setBusy(true)
1358
- setError('')
1359
- try {
1360
- const result = await api.roles({ op: 'adopt', role })
1361
- setProject(prev => prev === null ? prev : { ...prev, roles: result.roles, updatedAt: new Date().toISOString() })
1362
- setRoleCandidates(prev => prev === null ? prev : prev.filter(r => r !== role))
1363
- pushProgress(`已加入角色库:${role.name}`, 'done')
1364
- } catch (err) {
1365
- setError((err as Error).message)
1366
- } finally {
1367
- setBusy(false)
1368
- }
1369
- }
1370
-
1371
- /** 角色库:保存编辑草稿。 */
1372
- const handleRoleSave = async (): Promise<void> => {
1373
- if (roleDraft === null) return
1374
- if (roleDraft.name.trim() === '') {
1375
- setError('角色名不能为空')
1376
- return
1377
- }
1378
- setBusy(true)
1379
- setError('')
1380
- try {
1381
- const result = await api.roles({ op: 'update', role: roleDraft })
1382
- setProject(prev => prev === null ? prev : { ...prev, roles: result.roles, updatedAt: new Date().toISOString() })
1383
- setRoleDraft(null)
1384
- pushProgress(`角色已保存:${roleDraft.name}`, 'done')
1385
- } catch (err) {
1386
- setError((err as Error).message)
1387
- } finally {
1388
- setBusy(false)
1389
- }
1390
- }
1391
-
1392
- /** 角色库:删除角色。 */
1393
- const handleRoleRemove = async (name: string): Promise<void> => {
1394
- if (!window.confirm(`确定从角色库删除「${name}」?`)) return
1395
- setBusy(true)
1396
- setError('')
1397
- try {
1398
- const result = await api.roles({ op: 'remove', name })
1399
- setProject(prev => prev === null ? prev : { ...prev, roles: result.roles, updatedAt: new Date().toISOString() })
1400
- pushProgress(`已从角色库删除:${name}`, 'info')
1401
- } catch (err) {
1402
- setError((err as Error).message)
1403
- } finally {
1404
- setBusy(false)
1405
- }
1406
- }
1407
-
1408
- /** 保存世界观规则编辑(bible tab,每行一条)。 */
1409
- const handleSaveWorldRules = async (): Promise<void> => {
1410
- if (bible === undefined) return
1411
- const rules = worldRulesDraft.split('\n').map(line => line.trim()).filter(line => line !== '')
1412
- setBusy(true)
1413
- setError('')
1414
- try {
1415
- const result = await api.biblePatch({ worldRules: rules })
1416
- setProject(prev => prev === null ? prev : { ...prev, bible: result.bible, updatedAt: new Date().toISOString() })
1417
- pushProgress(`世界观规则已保存(${rules.length} 条)`, 'done')
1418
- } catch (err) {
1419
- setError((err as Error).message)
1420
- } finally {
1421
- setBusy(false)
1422
- }
1423
- }
1424
-
1425
- /** 简介:AI 全量生成。 */
1426
- const handleBlurbGenerate = async (): Promise<void> => {
1427
- if (project === null) return
1428
- setBusy(true)
1429
- setBusyLabel('AI 生成简介中…')
1430
- setError('')
1431
- try {
1432
- const result = await api.blurb('generate')
1433
- setBlurbDraft(result.blurb)
1434
- pushProgress(`简介已生成(${result.blurb.length} 字)`, 'done')
1435
- await refresh(false)
1436
- } catch (err) {
1437
- setError((err as Error).message)
1438
- pushProgress(`简介生成失败:${(err as Error).message}`, 'error')
1439
- } finally {
1440
- setBusy(false)
1441
- setBusyLabel('')
1442
- }
1443
- }
1444
-
1445
- /** 简介:按已写开头 AI 补全。 */
1446
- const handleBlurbComplete = async (): Promise<void> => {
1447
- if (project === null) return
1448
- if (blurbDraft.trim() === '') return
1449
- setBusy(true)
1450
- setBusyLabel('AI 补全简介中…')
1451
- setError('')
1452
- try {
1453
- const result = await api.blurb('generate', undefined, blurbDraft)
1454
- setBlurbDraft(result.blurb)
1455
- pushProgress(`简介已补全(${result.blurb.length} 字)`, 'done')
1456
- await refresh(false)
1457
- } catch (err) {
1458
- setError((err as Error).message)
1459
- pushProgress(`简介补全失败:${(err as Error).message}`, 'error')
1460
- } finally {
1461
- setBusy(false)
1462
- setBusyLabel('')
1463
- }
1464
- }
1465
-
1466
- /** 简介:手动保存。 */
1467
- const handleBlurbSave = async (): Promise<void> => {
1468
- if (project === null) return
1469
- setBusy(true)
1470
- setError('')
1471
- try {
1472
- const result = await api.blurb('save', blurbDraft)
1473
- pushProgress(`简介已保存(${result.blurb.length} 字)`, 'done')
1474
- await refresh(false)
1475
- } catch (err) {
1476
- setError((err as Error).message)
1477
- } finally {
1478
- setBusy(false)
1479
- }
1480
- }
1481
-
1482
- /** 加载封面(进入简介页时)。 */
1483
- const loadCover = useCallback(async (): Promise<void> => {
1484
- try {
1485
- const result = await api.coverGet()
1486
- setCoverDataUrl(result.dataUrl)
1487
- } catch { /* best-effort */ }
1488
- }, [api])
1489
-
1490
- /** 封面上传(本地预览 + 落盘)。 */
1491
- const handleCoverUpload = (file: File | undefined): void => {
1492
- if (file === undefined) return
1493
- const reader = new FileReader()
1494
- reader.onload = () => {
1495
- const dataUrl = typeof reader.result === 'string' ? reader.result : null
1496
- if (dataUrl === null) return
1497
- setCoverDataUrl(dataUrl)
1498
- void (async () => {
1499
- setBusy(true)
1500
- setError('')
1501
- try {
1502
- await api.coverPost('upload', dataUrl)
1503
- pushProgress(`封面已上传:${file.name}`, 'done')
1504
- await refresh(false)
1505
- } catch (err) {
1506
- setError((err as Error).message)
1507
- pushProgress(`封面上传失败:${(err as Error).message}`, 'error')
1508
- await loadCover()
1509
- } finally {
1510
- setBusy(false)
1511
- }
1512
- })()
1513
- }
1514
- reader.readAsDataURL(file)
1515
- }
1516
-
1517
- /** 工作区:AI 审查当前编辑的正文(不落盘)。 */
1518
- const handleWsCheck = async (): Promise<void> => {
1519
- if (workspace === null) return
1520
- if (workspace.original.trim().length < 50) {
1521
- setError('正文过短(<50 字),请先编辑内容')
1522
- return
1523
- }
1524
- setBusy(true)
1525
- setBusyLabel(`AI 审查 第${workspace.no}章`)
1526
- setError('')
1527
- try {
1528
- const result = await api.chapterCheck(workspace.no, workspace.original)
1529
- setWsCheckReport(result.report)
1530
- // 默认勾选 high 问题(无 high 则勾选全部 medium),作者可自行增删。
1531
- const highIdx = result.report.issues.map((it, i) => ({ it, i })).filter(x => x.it.severity === 'high').map(x => x.i)
1532
- const mediumIdx = result.report.issues.map((it, i) => ({ it, i })).filter(x => x.it.severity === 'medium').map(x => x.i)
1533
- setWsChecked(highIdx.length > 0 ? highIdx : mediumIdx)
1534
- pushProgress(`审查完成:${result.report.score} 分 — ${result.report.verdict}`, result.report.passed ? 'done' : 'error')
1535
- } catch (err) {
1536
- setError((err as Error).message)
1537
- pushProgress(`AI 审查失败:${(err as Error).message}`, 'error')
1538
- } finally {
1539
- setBusy(false)
1540
- setBusyLabel('')
1541
- }
1542
- }
1543
-
1544
- /** 工作区「应用并保存」:有草稿(修订/润色产物)→ 应用草稿落盘;无草稿(手动编辑)→ 保存原文。
1545
- * 沿用已有审查报告(wsCheckReport)或后端自动审稿;成功后原地进入「已采纳」结论态,返回定位到该章。 */
1546
- const handleWsSave = async (): Promise<void> => {
1547
- if (workspace === null) return
1548
- if (workspace.original.trim().length < 50) {
1549
- setError('正文过短(<50 字),未保存')
1550
- return
1551
- }
1552
- setBusy(true)
1553
- setError('')
1554
- const no = workspace.no
1555
- try {
1556
- let report: ReviewReport | undefined = undefined
1557
- let chars = workspace.original.length
1558
- if (workspace.draft !== null) {
1559
- // 有草稿:应用草稿(draftApply 已返回新正文 markdown;携带审查报告沿用结论定状态)。
1560
- setBusyLabel(`应用草稿 第${no}章`)
1561
- const result = await api.draftApply(no, wsCheckReport ?? undefined)
1562
- report = wsCheckReport ?? undefined
1563
- chars = result.chars
1564
- setWorkspace(prev => prev === null ? prev : {
1565
- ...prev,
1566
- original: result.markdown ?? prev.original,
1567
- draft: null,
1568
- applied: true,
1569
- })
1570
- } else {
1571
- // 无草稿:保存当前原文(沿用报告或后端自动审稿,1-2 分钟)。
1572
- setBusyLabel(`AI 审查 第${no}章`)
1573
- const result = await api.chapterSave(no, workspace.original, wsCheckReport ?? undefined)
1574
- report = result.report
1575
- setWorkspace(prev => prev === null ? prev : { ...prev, applied: true })
1576
- }
1577
- setDraftNo(null)
1578
- if (report !== undefined) {
1579
- pushProgress(`已保存并审稿:${report.score} 分 — ${report.verdict}(${report.passed ? '通过' : '未通过'})`, report.passed ? 'done' : 'error')
1580
- } else {
1581
- pushProgress(`已保存第 ${no} 章(${chars} 字,原稿已备份 .bak)`, 'done')
1582
- }
1583
- } catch (err) {
1584
- setError((err as Error).message)
1585
- pushProgress(`保存失败:${(err as Error).message}`, 'error')
1586
- } finally {
1587
- setBusy(false)
1588
- setBusyLabel('')
1589
- await refresh(false)
1590
- }
1591
- }
1592
-
1593
- /** 移除封面。 */
1594
- const handleCoverRemove = async (): Promise<void> => {
1595
- setBusy(true)
1596
- setError('')
1597
- try {
1598
- await api.coverPost('remove')
1599
- setCoverDataUrl(null)
1600
- pushProgress('封面已移除', 'info')
1601
- await refresh(false)
1602
- } catch (err) {
1603
- setError((err as Error).message)
1604
- } finally {
1605
- setBusy(false)
1606
- }
1607
- }
1608
-
1609
- /** 重命名当前书(同步项目与书架)。 */
1610
- const handleRename = async (): Promise<void> => {
1611
- const name = bookNameDraft.trim()
1612
- if (name === '' || project === null || name === project.bookName) return
1613
- setBusy(true)
1614
- setError('')
1615
- try {
1616
- const result = await api.rename(name)
1617
- pushProgress(`书名已改为《${result.bookName}》`, 'done')
1618
- await refresh(false)
1619
- await refreshShelf()
1620
- } catch (err) {
1621
- setError((err as Error).message)
1622
- pushProgress(`改名失败:${(err as Error).message}`, 'error')
1623
- } finally {
1624
- setBusy(false)
1625
- }
1626
- }
1627
-
1628
- /** Extract the story bible. */
1629
- const handleBible = async (): Promise<void> => {
1630
- setBusy(true)
1631
- setBusyLabel(tt('bible.gen'))
1632
- setError('')
1633
- try {
1634
- const result = await api.bible(outlineText || undefined)
1635
- setProject(prev => prev === null ? prev : { ...prev, bible: result.bible, updatedAt: new Date().toISOString() })
1636
- const bible: StoryBible = result.bible
1637
- pushProgress(tt('workflow.bibleDone', {
1638
- n: bible.worldRules.length,
1639
- c: bible.characters.length,
1640
- r: bible.redLines.length,
1641
- }), 'done')
1642
- } catch (err) {
1643
- setError((err as Error).message)
1644
- pushProgress(`提炼道藏失败:${(err as Error).message}`, 'error')
1645
- } finally {
1646
- setBusy(false)
1647
- setBusyLabel('')
1648
- }
1649
- }
1650
-
1651
- /** Plan volumes. */
1652
- const handleVolumes = async (): Promise<void> => {
1653
- setBusy(true)
1654
- setBusyLabel(tt('workflow.genVolumes'))
1655
- setError('')
1656
- try {
1657
- const result = await api.volumes(outlineText || undefined)
1658
- setProject(prev => prev === null ? prev : { ...prev, volumes: result.volumes, updatedAt: new Date().toISOString() })
1659
- pushProgress(tt('workflow.volumesDone', { n: result.volumes.length }), 'done')
1660
- } catch (err) {
1661
- setError((err as Error).message)
1662
- pushProgress(`生成卷计划失败:${(err as Error).message}`, 'error')
1663
- } finally {
1664
- setBusy(false)
1665
- setBusyLabel('')
1666
- }
1667
- }
1668
-
1669
- /** Generate the chapter plan via LLM. */
1670
- const handlePlan = async (): Promise<void> => {
1671
- setBusy(true)
1672
- setBusyLabel(tt('plan.generate'))
1673
- setError('')
1674
- try {
1675
- const result = await api.plan(outlineText || undefined, planCount)
1676
- let freshCount = 0
1677
- setProject(prev => {
1678
- const base = prev ?? {
1679
- bookName: '', outline: outlineText, chapters: [] as ChapterPlan[],
1680
- foreshadows: [] as Foreshadow[], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
1681
- }
1682
- // 追加时按标题去重,避免重复生成计划导致剧情「重头再来」。
1683
- const existingTitles = new Set(base.chapters.map(c => c.title))
1684
- const fresh = result.chapters.filter(c => !existingTitles.has(c.title))
1685
- freshCount = fresh.length
1686
- if (fresh.length === 0) return base
1687
- return { ...base, chapters: [...base.chapters, ...fresh], updatedAt: new Date().toISOString() }
1688
- })
1689
- pushProgress(tt('workflow.planDone', { n: freshCount }), 'done')
1690
- if (freshCount < result.chapters.length) {
1691
- pushProgress(`已跳过 ${result.chapters.length - freshCount} 个与已有章节同名的重复章节`, 'error')
1692
- }
1693
- } catch (err) {
1694
- setError((err as Error).message)
1695
- pushProgress(`生成章节计划失败:${(err as Error).message}`, 'error')
1696
- } finally {
1697
- setBusy(false)
1698
- setBusyLabel('')
1699
- }
1700
- }
1701
-
1702
- /** 打开某章的工作区(读取服务器原文;有遗留草稿时预载草稿;可预填修订指令)。 */
1703
- const openWorkspace = useCallback(async (no: number, instruction?: string, mode?: 'pick' | 'result'): Promise<void> => {
1704
- try {
1705
- const [chapterRes, statusRes] = await Promise.all([api.chapter(no), api.status()])
1706
- const chapter = statusRes.project?.chapters.find(c => c.no === no)
1707
- if (chapter === undefined) return
1708
- // 指令框只承载「显式传入的指令」(如按质检/敏感词意见修订);
1709
- // 审稿意见统一由下方可勾选列表承载,不再自动预填文字,避免重复。
1710
- const autoInstruction = instruction ?? ''
1711
- setWorkspace({
1712
- no,
1713
- title: chapter.title,
1714
- original: chapterRes.markdown,
1715
- instruction: autoInstruction,
1716
- draft: chapter.pendingDraft !== undefined && chapter.pendingDraft !== '' ? chapter.pendingDraft : null,
1717
- })
1718
- setWsSelected('')
1719
- setWsShowDiff(true)
1720
- if (mode === 'result') {
1721
- // 一键修订结果模式:不再加载旧审稿意见(修订已按意见执行完),
1722
- // 直接展示草稿;wsResultMode 标记用于顶部「✅ 修订完成」横幅。
1723
- setWsCheckReport(null)
1724
- setWsChecked([])
1725
- setWsResultMode(true)
1726
- } else {
1727
- // 意见统一为「当前意见」:进工作区即加载已有审稿意见为可勾选列表,
1728
- // 默认勾选 high(无 high 则勾选全部 medium),作者可自行增删。
1729
- setWsResultMode(false)
1730
- const carried = chapter.review
1731
- setWsCheckReport(carried ?? null)
1732
- if (carried !== undefined && carried.issues.length > 0) {
1733
- const highIdx = carried.issues.map((it, i) => ({ it, i })).filter(x => x.it.severity === 'high').map(x => x.i)
1734
- const mediumIdx = carried.issues.map((it, i) => ({ it, i })).filter(x => x.it.severity === 'medium').map(x => x.i)
1735
- setWsChecked(highIdx.length > 0 ? highIdx : mediumIdx)
1736
- } else {
1737
- setWsChecked([])
1738
- }
1739
- }
1740
- } catch { /* best-effort */ }
1741
- }, [api])
1742
-
1743
- /** 捕获工作区原文 textarea 中选中的文字(局部修订目标)。 */
1744
- const captureWsSelection = (): void => {
1745
- const el = wsEditorRef.current
1746
- if (el === null) return
1747
- const start = el.selectionStart ?? 0
1748
- const end = el.selectionEnd ?? 0
1749
- if (end > start) setWsSelected(el.value.slice(start, end).trim())
1750
- else setWsSelected('')
1751
- }
1752
-
1753
- /** 工作区:去 AI 味润色(流式 → 预览草稿)。 */
1754
- const handleWsPolish = async (): Promise<void> => {
1755
- if (workspace === null) return
1756
- setBusy(true)
1757
- setBusyLabel(`${tt('plan.polish')} 第${workspace.no}章`)
1758
- setError('')
1759
- try {
1760
- await api.polish(workspace.no, frame => { applyJobFrame(frame, n => tt('progress.polishing', { no: n })) })
1761
- } catch (err) {
1762
- setError((err as Error).message)
1763
- pushProgress(`第 ${workspace.no} 章润色失败:${(err as Error).message}`, 'error')
1764
- } finally {
1765
- setBusy(false)
1766
- setBusyLabel('')
1767
- await refresh(false)
1768
- }
1769
- }
1770
-
1771
- /** 工作区:按指令修订(whole=true 整章,false 仅修订选中片段)。 */
1772
- /** 工作区:对草稿自动跑一次 AI 审查(不落盘),刷新「当前意见」为草稿版本。 */
1773
- /**
1774
- * 主观项豁免判定(方案 B):修订后审查中,剩余问题若没有 high(逻辑/设定/事实矛盾),
1775
- * 即使分数未达阈值也视为可接受——主观项(文笔/节奏/套话)不再无限循环卡修订。
1776
- */
1777
- const reviseAcceptable = (report: ReviewReport): boolean =>
1778
- report.passed || report.issues.every(i => i.severity !== 'high')
1779
-
1780
- const autoCheckDraft = async (no: number, previousReport?: ReviewReport | null): Promise<void> => {
1781
- const draft = project?.chapters.find(c => c.no === no)?.pendingDraft
1782
- if (draft === undefined || draft === '') return
1783
- setBusyLabel(`AI 审查草稿 第${no}章`)
1784
- try {
1785
- // 携带上一轮审稿报告 → 后端走「验证模式」:核对原意见是否解决 + 只挑新增 high,防止越修越多。
1786
- const result = await api.chapterCheck(no, draft, previousReport ?? undefined)
1787
- // 主观项豁免:无 high 即视为通过(passed 置 true,供保存沿用与横幅展示)。
1788
- const acceptable = reviseAcceptable(result.report)
1789
- const report: ReviewReport = acceptable ? { ...result.report, passed: true } : result.report
1790
- setWsCheckReport(report)
1791
- const highIdx = report.issues.map((it, i) => ({ it, i })).filter(x => x.it.severity === 'high').map(x => x.i)
1792
- const mediumIdx = report.issues.map((it, i) => ({ it, i })).filter(x => x.it.severity === 'medium').map(x => x.i)
1793
- setWsChecked(highIdx.length > 0 ? highIdx : mediumIdx)
1794
- if (acceptable && !result.report.passed) {
1795
- pushProgress(`草稿审查:${report.score} 分 — 可接受(剩余均为主观项,无逻辑/设定矛盾)`, 'done')
1796
- } else {
1797
- pushProgress(`草稿审查:${report.score} 分 — ${report.verdict}`, report.passed ? 'done' : 'error')
1798
- }
1799
- } catch (err) {
1800
- pushProgress(`草稿审查失败:${(err as Error).message}`, 'error')
1801
- } finally {
1802
- setBusyLabel('')
1803
- }
1804
- }
1805
-
1806
- const handleWsRewrite = async (whole: boolean, overrideInstruction?: string): Promise<void> => {
1807
- if (workspace === null) return
1808
- const target = whole ? '' : wsSelected
1809
- const instruction = overrideInstruction ?? workspace.instruction
1810
- // 新一轮修订开始:清掉「已采纳」结论态(横幅只在采纳后短暂停留)。
1811
- if (workspace.applied === true) {
1812
- setWorkspace({ ...workspace, applied: false })
1813
- }
1814
- setBusy(true)
1815
- setBusyLabel(`${tt('plan.rewrite')} 第${workspace.no}章`)
1816
- setError('')
1817
- const no = workspace.no
1818
- try {
1819
- await api.rewrite(no, instruction, target, frame => { applyJobFrame(frame, n => tt('progress.rewriting', { no: n })) })
1820
- } catch (err) {
1821
- setError((err as Error).message)
1822
- pushProgress(`第 ${no} 章修订失败:${(err as Error).message}`, 'error')
1823
- } finally {
1824
- setBusy(false)
1825
- setBusyLabel('')
1826
- await refresh(false)
1827
- // 修订后自动审查(设置开关,默认开):草稿 → 验证模式审查(携带本次修订所依据的报告),
1828
- // 核对原意见解决情况 + 只挑新增 high,防止"越修 high 越多"。
1829
- if (config?.autoReviewAfterRevise !== false) {
1830
- setBusy(true)
1831
- await autoCheckDraft(no, wsCheckReport)
1832
- setBusy(false)
1833
- }
1834
- }
1835
- }
1836
-
1837
- /** 工作区:按审查报告中勾选的问题一键修订(按勾选意见整章修订到草稿,不污染指令框)。 */
1838
- const handleWsReviseByReport = async (): Promise<void> => {
1839
- if (workspace === null || wsCheckReport === null) return
1840
- const picked = wsChecked
1841
- .map(i => wsCheckReport.issues[i])
1842
- .filter((it): it is ReviewReport['issues'][number] => it !== undefined)
1843
- .slice(0, 5)
1844
- if (picked.length === 0) return
1845
- const instruction = '按审稿意见修订(优先处理):\n' + picked.map(i => `[${i.severity}] ${i.item} → ${i.suggestion}`).join('\n')
1846
- await handleWsRewrite(true, instruction)
1847
- }
1848
-
1849
- /** 列表页「按意见修订」一键直达:不进工作区,直接按该章审稿意见全部修订(high 优先,
1850
- * 无 high 用 medium,不足取前 3 条),修订完自动打开工作区展示草稿 + 自动审查结果。 */
1851
- const handleReviseNow = async (no: number): Promise<void> => {
1852
- const chapter = chapters.find(c => c.no === no)
1853
- if (chapter?.review === undefined || chapter.review.issues.length === 0) {
1854
- openWorkspace(no)
1855
- return
1856
- }
1857
- const issues = chapter.review.issues
1858
- const high = issues.filter(i => i.severity === 'high')
1859
- const medium = issues.filter(i => i.severity === 'medium')
1860
- const picked = high.length > 0 ? high : medium.length > 0 ? medium : issues
1861
- const top = picked.slice(0, 5)
1862
- const instruction = '按审稿意见修订(优先处理):\n' + top.map(i => `[${i.severity}] ${i.item} → ${i.suggestion}`).join('\n')
1863
- setBusy(true)
1864
- setBusyLabel(`${tt('plan.rewrite')} 第${no}章`)
1865
- setError('')
1866
- try {
1867
- await api.rewrite(no, instruction, '', frame => { applyJobFrame(frame, n => tt('progress.rewriting', { no: n })) })
1868
- await refresh(false)
1869
- // 结果模式打开工作区:不展示旧意见,直接看草稿;开启自动审查时以「验证模式」跑一次
1870
- // (携带本章原审稿报告:核对原意见解决情况 + 只挑新增 high,防止越修越多)。
1871
- await openWorkspace(no, undefined, 'result')
1872
- if (config?.autoReviewAfterRevise !== false) {
1873
- setBusy(true)
1874
- await autoCheckDraft(no, chapter.review)
1875
- }
1876
- } catch (err) {
1877
- setError((err as Error).message)
1878
- pushProgress(`第 ${no} 章修订失败:${(err as Error).message}`, 'error')
1879
- } finally {
1880
- setBusy(false)
1881
- setBusyLabel('')
1882
- }
1883
- }
1884
-
1885
- /** 放弃草稿:保留原稿,仅清空草稿。 */
1886
- const handleDraftDiscard = async (no: number): Promise<void> => {
1887
- setBusy(true)
1888
- setError('')
1889
- try {
1890
- await api.draftDiscard(no)
1891
- setWorkspace(null)
1892
- setWsResultMode(false)
1893
- setDraftNo(null)
1894
- pushProgress(`已放弃第 ${no} 章草稿,保留原稿`, 'info')
1895
- } catch (err) {
1896
- setError((err as Error).message)
1897
- pushProgress(`放弃第 ${no} 章草稿失败:${(err as Error).message}`, 'error')
1898
- } finally {
1899
- setBusy(false)
1900
- setBusyLabel('')
1901
- await refresh(false)
1902
- }
1903
- }
1904
-
1905
- /** Shared frame handler for generate/rewrite/polish streams. */
1906
- const applyJobFrame = useCallback((frame: JobFrame, label: (no: number) => string) => {
1907
- if (frame.type === 'start') {
1908
- clearLiveProgress()
1909
- setLiveBar(null)
1910
- lastDeltaChars.current = 0
1911
- liveChars.current = 0
1912
- currentJobNo.current = frame.no
1913
- // 任务开始:不自动弹出进度悬浮窗(导航「AI进度」显示呼吸绿点提示,
1914
- // 想看进度时手动点导航打开;liveBar 数据照常累积)。
1915
- setProject(prev => prev === null ? prev : {
1916
- ...prev,
1917
- chapters: prev.chapters.map(c => c.no === frame.no ? { ...c, status: 'generating', error: undefined } : c),
1918
- })
1919
- pushProgress(label(frame.no))
1920
- } else if (frame.type === 'delta') {
1921
- // One live row updated in place — no per-token console spam. The
1922
- // server streams incremental text, so accumulate locally.
1923
- const chars = (liveChars.current += frame.text.length)
1924
- const target = project?.chapters.find(c => c.no === currentJobNo.current)?.targetChars ?? 0
1925
- if (chars < 50 || chars - lastDeltaChars.current >= 200) {
1926
- lastDeltaChars.current = chars
1927
- const text = target > 0 ? `已生成 ${chars} / ${target} 字` : `已生成 ${chars} 字`
1928
- const ratio = target > 0 ? Math.min(chars / target, 1) : undefined
1929
- setLiveProgress(text, ratio)
1930
- setLiveBar({ text, ratio })
1931
- }
1932
- } else if (frame.type === 'done' || frame.type === 'rewritten') {
1933
- clearLiveProgress()
1934
- setLiveBar(null)
1935
- setProject(prev => prev === null ? prev : {
1936
- ...prev,
1937
- chapters: prev.chapters.map(c => c.no === frame.no ? { ...c, status: 'written', chars: frame.chars, file: frame.file, review: undefined } : c),
1938
- })
1939
- pushProgress(tt('progress.done', { no: frame.no, chars: frame.chars, file: frame.file }), 'done')
1940
- if (frame.type === 'done' && frame.warn !== undefined && frame.warn !== '') {
1941
- pushProgress(`⚠️ ${frame.warn}`, 'info')
1942
- }
1943
- setGeneratedFiles(prev => prev.includes(frame.file) ? prev : [...prev, frame.file])
1944
- } else if (frame.type === 'review') {
1945
- clearLiveProgress()
1946
- setLiveBar(null)
1947
- setProject(prev => prev === null ? prev : {
1948
- ...prev,
1949
- chapters: prev.chapters.map(c => c.no === frame.no ? { ...c, status: frame.report.passed ? 'approved' : 'rejected', review: frame.report } : c),
1950
- })
1951
- pushProgress(tt('progress.reviewed', {
1952
- no: frame.no,
1953
- score: frame.report.score,
1954
- verdict: frame.report.verdict,
1955
- }), frame.report.passed ? 'done' : 'error')
1956
- } else if (frame.type === 'author-review') {
1957
- setProject(prev => prev === null ? prev : {
1958
- ...prev,
1959
- chapters: prev.chapters.map(c => c.no === frame.no ? { ...c, authorReview: frame.review } : c),
1960
- })
1961
- const r = frame.review
1962
- pushProgress(
1963
- `📋 第${frame.no}章作者复盘:钩子${r.hookHonored ? '已兑现 ✓' : '未兑现 ✗'} · 结尾钩子 ${r.endingHook}/10 · ${r.plotlineProgress !== '' ? r.plotlineProgress : '无实质推进'}`,
1964
- r.hookHonored && r.endingHook >= 6 ? 'done' : r.endingHook < 6 || !r.hookHonored ? 'error' : 'info',
1965
- )
1966
- } else if (frame.type === 'author-backfill-done') {
1967
- clearLiveProgress()
1968
- setLiveBar(null)
1969
- pushProgress(`✅ 历史章节作者复盘补齐完成(共 ${frame.count} 章)`, 'done')
1970
- void refresh(false)
1971
- } else if (frame.type === 'drafted') {
1972
- // 润色/重写完成:产物作为待确认草稿,展示在工作区预览,由用户决定。
1973
- clearLiveProgress()
1974
- setLiveBar(null)
1975
- setProject(prev => prev === null ? prev : {
1976
- ...prev,
1977
- chapters: prev.chapters.map(c => c.no === frame.no ? { ...c, pendingDraft: frame.draft } : c),
1978
- })
1979
- pushProgress(`第 ${frame.no} 章润色完成(${frame.chars} 字),请查看预览后应用或放弃`)
1980
- setDraftNo(frame.no)
1981
- setWsShowDiff(false)
1982
- setWorkspace(prev => prev !== null && prev.no === frame.no ? { ...prev, draft: frame.draft } : prev)
1983
- } else if (frame.type === 'error') {
1984
- clearLiveProgress()
1985
- setLiveBar(null)
1986
- setProject(prev => prev === null ? prev : {
1987
- ...prev,
1988
- chapters: prev.chapters.map(c => c.no === frame.no ? { ...c, status: 'error', error: frame.message } : c),
1989
- })
1990
- pushProgress(tt('progress.error', { no: frame.no, message: frame.message }), 'error')
1991
- }
1992
- }, [pushProgress, setLiveProgress, clearLiveProgress, project])
1993
-
1994
- /** Generate one chapter, streaming frames into the console. */
1995
- const handleWriteChapter = async (no: number, skipReview: boolean): Promise<void> => {
1996
- setBusy(true)
1997
- setBusyLabel(`${tt('plan.write')} 第${no}章`)
1998
- setError('')
1999
- try {
2000
- await api.generate(no, skipReview, frame => { applyJobFrame(frame, n => tt('progress.generating', { no: n, title: (project?.chapters.find(c => c.no === n)?.title ?? '') })) })
2001
- } catch (err) {
2002
- setError((err as Error).message)
2003
- pushProgress(`第 ${no} 章失败:${(err as Error).message}`, 'error')
2004
- } finally {
2005
- setBusy(false)
2006
- setBusyLabel('')
2007
- await refresh(false)
2008
- }
2009
- }
2010
-
2011
- /** Batch-write all remaining chapters in sequence (auto-retry once per chapter). */
2012
- const handleWriteAll = async (): Promise<void> => {
2013
- const remaining = chapters.filter(c => c.status === 'pending' || c.status === 'error')
2014
- if (remaining.length === 0) return
2015
- setBusy(true)
2016
- setBusyLabel(`${tt('plan.writeAllPending')}(共 ${remaining.length} 章)`)
2017
- setError('')
2018
- let failed = 0
2019
- const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
2020
- for (const chapter of remaining) {
2021
- pushProgress(`▶ 开始生成第 ${chapter.no} 章《${chapter.title}》`)
2022
- let lastError: unknown = null
2023
- // 失败自动重试:最多尝试 2 次,间隔 3 秒(网络抖动/LLM 偶发失败自愈)。
2024
- for (let attempt = 1; attempt <= 2; attempt++) {
2025
- try {
2026
- // 批量连写也走完整质量门:生成 → 自动审稿 → 作者复盘(false = 不跳过审稿)。
2027
- await api.generate(chapter.no, false, frame => { applyJobFrame(frame, n => tt('progress.generating', { no: n, title: (project?.chapters.find(c => c.no === n)?.title ?? '') })) })
2028
- lastError = null
2029
- break
2030
- } catch (err) {
2031
- lastError = err
2032
- if (attempt < 2) {
2033
- pushProgress(`第 ${chapter.no} 章第 ${attempt} 次尝试失败(${(err as Error).message}),3 秒后自动重试…`, 'error')
2034
- await sleep(3000)
2035
- }
2036
- }
2037
- }
2038
- if (lastError !== null) {
2039
- failed++
2040
- pushProgress(`第 ${chapter.no} 章失败:${(lastError as Error).message}`, 'error')
2041
- }
2042
- }
2043
- setBusy(false)
2044
- setBusyLabel('')
2045
- await refresh(false)
2046
- pushProgress(failed === 0
2047
- ? `批量生成完成:${remaining.length} 章全部完成`
2048
- : `批量生成结束:${remaining.length - failed} 章完成,${failed} 章失败`, failed === 0 ? 'done' : 'error')
2049
- }
2050
-
2051
- /** Review one chapter. */
2052
- const handleReview = async (no: number): Promise<void> => {
2053
- setBusy(true)
2054
- setBusyLabel(`${tt('plan.review')} 第${no}章`)
2055
- setError('')
2056
- try {
2057
- const result = await api.review(no)
2058
- const report: ReviewReport = result.report
2059
- setProject(prev => prev === null ? prev : {
2060
- ...prev,
2061
- chapters: prev.chapters.map(c => c.no === no ? { ...c, status: report.passed ? 'approved' : 'rejected', review: report } : c),
2062
- })
2063
- pushProgress(tt('progress.reviewed', { no, score: report.score, verdict: report.verdict }), report.passed ? 'done' : 'error')
2064
- } catch (err) {
2065
- setError((err as Error).message)
2066
- } finally {
2067
- setBusy(false)
2068
- setBusyLabel('')
2069
- }
2070
- }
2071
-
2072
- /** Toggle chapter preview. */
2073
- const handleToggleChapter = async (no: number): Promise<void> => {
2074
- if (expandedChapter === no) {
2075
- setExpandedChapter(null)
2076
- setChapterText('')
2077
- return
2078
- }
2079
- setExpandedChapter(no)
2080
- setChapterText('')
2081
- try {
2082
- const result = await api.chapter(no)
2083
- setChapterText(result.markdown)
2084
- } catch (err) {
2085
- setChapterText(`(${(err as Error).message})`)
2086
- }
2087
- }
2088
-
2089
- /** Suggest foreshadows via LLM. */
2090
- const handleSuggestForeshadows = async (): Promise<void> => {
2091
- setBusy(true)
2092
- setBusyLabel(tt('foreshadow.suggest'))
2093
- setError('')
2094
- try {
2095
- const result = await api.foreshadow({ suggest: true })
2096
- setProject(prev => prev === null ? prev : { ...prev, foreshadows: [...(prev?.foreshadows ?? []), ...result.foreshadows], updatedAt: new Date().toISOString() })
2097
- pushProgress(`AI 已建议 ${result.foreshadows.length} 条伏笔`, 'done')
2098
- } catch (err) {
2099
- setError((err as Error).message)
2100
- pushProgress(`伏笔建议失败:${(err as Error).message}`, 'error')
2101
- } finally {
2102
- setBusy(false)
2103
- setBusyLabel('')
2104
- }
2105
- }
2106
-
2107
- /** Save the settings draft. */
2108
- const handleSaveConfig = async (): Promise<void> => {
2109
- if (configDraft === null) return
2110
- setBusy(true)
2111
- setError('')
2112
- try {
2113
- const result = await api.patchConfig({
2114
- outlinePath: configDraft.outlinePath,
2115
- outputDir: configDraft.outputDir,
2116
- provider: configDraft.provider,
2117
- model: configDraft.model,
2118
- generateModel: configDraft.generateModel,
2119
- reviewModel: configDraft.reviewModel,
2120
- auditModel: configDraft.auditModel,
2121
- reasoningEffort: configDraft.reasoningEffort ?? 'off',
2122
- chapterChars: configDraft.chapterChars,
2123
- maxTokens: configDraft.maxTokens,
2124
- reviewPassScore: configDraft.reviewPassScore,
2125
- autoReview: configDraft.autoReview,
2126
- autoAuthorReview: configDraft.autoAuthorReview,
2127
- autoReviewAfterRevise: configDraft.autoReviewAfterRevise,
2128
- imageApiKey: configDraft.imageApiKey,
2129
- imageApiModel: configDraft.imageApiModel,
2130
- imageApiEnabled: configDraft.imageApiEnabled ?? false,
2131
- imageModels: configDraft.imageModels ?? [],
2132
- savedModels: configDraft.savedModels ?? [],
2133
- })
2134
- setConfig(result.config)
2135
- setConfigDraft(result.config)
2136
- setNotice(tt('settings.saved'))
2137
- pushProgress(tt('settings.saved'), 'done')
2138
- await refresh(false)
2139
- } catch (err) {
2140
- setError((err as Error).message)
2141
- } finally {
2142
- setBusy(false)
2143
- }
2144
- }
2145
-
2146
- /** 生图接口测试状态(模型 id → 结果)。 */
2147
- const [imageTestState, setImageTestState] = useState<Record<string, { testing: boolean; ok?: boolean; ms?: number; message?: string; modelFound?: boolean }>>({})
2148
- /** 测试一条生图模型:连通 + 延迟。 */
2149
- const testImageModel = async (m: ImageModelConfig): Promise<void> => {
2150
- if (m.baseURL.trim() === '' || m.apiKey.trim() === '') {
2151
- setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: false, message: '请先填写接口地址和 API Key' } }))
2152
- return
2153
- }
2154
- setImageTestState(prev => ({ ...prev, [m.id]: { testing: true } }))
2155
- try {
2156
- const r = await api.imageTest({ baseURL: m.baseURL, apiKey: m.apiKey, model: m.model })
2157
- setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: r.ok, ms: r.ms, message: r.message } }))
2158
- } catch (err) {
2159
- setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: false, message: (err as Error).message } }))
2160
- }
2161
- }
2162
- /** 更新一条生图模型配置。 */
2163
- const updateImageModel = (id: string, patch: Partial<ImageModelConfig>): void => {
2164
- setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).map(m => m.id === id ? { ...m, ...patch } : m) })
2165
- }
2166
- /** 新增一条生图模型(第一条默认启用)。 */
2167
- const addImageModel = (): void => {
2168
- setConfigDraft(prev => prev === null ? prev : {
2169
- ...prev,
2170
- imageModels: [...(prev.imageModels ?? []), { id: 'img-' + Date.now().toString(36), name: '', baseURL: '', apiKey: '', model: '', enabled: (prev.imageModels ?? []).length === 0 }],
2171
- })
2172
- }
2173
- /** 删除一条生图模型。 */
2174
- const removeImageModel = (id: string): void => {
2175
- setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).filter(m => m.id !== id) })
2176
- }
2177
- /** 启用一条(其余自动停用)。 */
2178
- const enableImageModel = (id: string): void => {
2179
- setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).map(m => ({ ...m, enabled: m.id === id })) })
2180
- }
2181
-
2182
- /** Export the book. */
2183
- const handleExport = async (format: 'txt' | 'md'): Promise<void> => {
2184
- setBusy(true)
2185
- setError('')
2186
- try {
2187
- const result = await api.exportBook(format)
2188
- setNotice(tt('settings.exported', { file: result.file, chars: result.chars, chapters: result.chapters }))
2189
- pushProgress(tt('settings.exported', { file: result.file, chars: result.chars, chapters: result.chapters }), 'done')
2190
- } catch (err) {
2191
- setError((err as Error).message)
2192
- } finally {
2193
- setBusy(false)
2194
- }
2195
- }
2196
-
2197
- const busyAny = anyGenerating(project?.chapters)
2198
- const chapters = project?.chapters ?? []
2199
- const doneCount = chapters.filter(c => c.status === 'approved' || c.status === 'written' || c.status === 'rejected').length
2200
- const pendingCount = chapters.filter(c => c.status === 'pending' || c.status === 'error').length
2201
- const bible: StoryBible | undefined = project?.bible
2202
- const volumes: Volume[] | undefined = project?.volumes
2203
- const foreshadows: Foreshadow[] = project?.foreshadows ?? []
2204
-
2205
- /**
2206
- * 定位章节:从待办/主行动卡跳到章节页的目标章——切 tab、解除卷筛选、
2207
- * 展开所在卷,并滚动高亮(focusNo 驱动 useEffect 执行)。
2208
- * 不强行展开章节详情:保持章节原有收起/展开状态,避免页面被撑开。
2209
- */
2210
- const gotoChapter = useCallback((no: number): void => {
2211
- setActiveTab('plan')
2212
- setSelectedVolume('all')
2213
- // 展开目标章所在卷(从折叠列表移除该卷号)。
2214
- const chapter = chapters.find(c => c.no === no)
2215
- if (chapter !== undefined && chapter.volume > 0) {
2216
- setCollapsedVolumes(prev => prev.filter(v => v !== chapter.volume))
2217
- }
2218
- setFocusNo(no)
2219
- }, [chapters])
2220
-
2221
- /** focusNo 驱动:等 DOM 渲染后滚动到目标章并短暂高亮(3 秒后清除)。 */
2222
- useEffect(() => {
2223
- if (focusNo === null) return
2224
- const timer = window.setTimeout(() => {
2225
- const el = document.querySelector(`[data-chapter-no="${focusNo}"]`)
2226
- if (el !== null) {
2227
- el.scrollIntoView({ behavior: 'smooth', block: 'center' })
2228
- el.classList.add(css.chapterFocus)
2229
- window.setTimeout(() => { el.classList.remove(css.chapterFocus) }, 3000)
2230
- }
2231
- setFocusNo(null)
2232
- }, 350)
2233
- return () => { window.clearTimeout(timer) }
2234
- }, [focusNo])
2235
-
2236
- /** 章节按卷分组(未分卷章节单独一组;章节多时可按卷折叠浏览)。 */
2237
- const chapterGroups = useMemo(() => {
2238
- const groups: Array<{ no: number; title: string; chapters: ChapterPlan[] }> = []
2239
- if (volumes !== undefined) {
2240
- for (const v of volumes) {
2241
- const list = chapters.filter(c => c.volume === v.no)
2242
- if (list.length > 0) groups.push({ no: v.no, title: `第${v.no}卷 · ${v.title}`, chapters: list })
2243
- }
2244
- }
2245
- const unassigned = chapters.filter(c => c.volume === 0)
2246
- if (unassigned.length > 0) groups.push({ no: 0, title: '未分卷', chapters: unassigned })
2247
- if (groups.length === 0) groups.push({ no: 0, title: '全部章节', chapters })
2248
- return groups
2249
- }, [chapters, volumes])
2250
-
2251
- // --------------------------------------------------- dashboard (workflow)
2252
- const approvedCount = chapters.filter(c => c.status === 'approved').length
2253
- const reviewPendingCount = chapters.filter(c => c.status === 'written' || c.status === 'rejected').length
2254
- const writingNow = chapters.find(c => c.status === 'generating' || c.status === 'reviewing')
2255
- const totalChars = chapters.reduce((sum, c) => sum + (c.chars ?? 0), 0)
2256
- const firstChapter = chapters[0]
2257
- const currentVolumeName = (() => {
2258
- if (firstChapter === undefined || volumes === undefined || volumes.length === 0) return '—'
2259
- const vol = volumes.find(v => v.no === firstChapter.volume)
2260
- return vol !== undefined ? vol.title : `第 ${firstChapter.volume} 卷`
2261
- })()
2262
- const lastUpdated = project?.updatedAt !== undefined
2263
- ? new Date(project.updatedAt).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
2264
- : '—'
2265
-
2266
- /** 创作旅程 6 阶段(完成/当前/未到)。 */
2267
- const journeyStages: Array<{ id: string; label: string; done: boolean }> = [
2268
- { id: 'outline', label: '大纲', done: project !== null },
2269
- { id: 'bible', label: '设定', done: bible !== undefined },
2270
- { id: 'volumes', label: '卷计划', done: volumes !== undefined },
2271
- { id: 'plan', label: '章节计划', done: chapters.length > 0 },
2272
- { id: 'write', label: '正文', done: chapters.some(c => (c.chars ?? 0) > 0) },
2273
- { id: 'review', label: '审稿', done: approvedCount > 0 },
2274
- ]
2275
- const journeyDoneCount = journeyStages.filter(s => s.done).length
2276
- const journeyPercent = Math.round((journeyDoneCount / journeyStages.length) * 100)
2277
- const currentStageId = journeyStages.find(s => !s.done)?.id
2278
-
2279
- /** 创作时间线阶段点击 → 跳转对应 tab。 */
2280
- const jumpToStage = (id: string): void => {
2281
- switch (id) {
2282
- case 'outline': setActiveTab('overview'); break
2283
- case 'bible': setActiveTab('book'); changeBookTab('bible'); break
2284
- case 'volumes':
2285
- case 'plan':
2286
- case 'write': setActiveTab('plan'); break
2287
- case 'review': setActiveTab('book'); changeBookTab('facts'); changeArchiveTab('reviews'); break
2288
- }
2289
- }
2290
-
2291
- /** 侧栏当前书卡(demo 风格):书架激活书 → 封面首字 / 书名 / 进度。 */
2292
- const activeBook = shelf?.books.find(b => b.id === shelf?.activeBookId) ?? null
2293
- const activeBookName = activeBook?.bookName ?? project?.bookName ?? '未选书'
2294
- const activeBookMeta = activeBook !== null ? `${activeBook.done}/${activeBook.total} 章 · 点按打开书架` : '点按打开书架'
2295
- const activeBookLetter = activeBookName.trim().charAt(0) || '书'
2296
-
2297
- /** 主行动卡片:推荐下一步(AI-Novel-Writing-Assistant 首页主卡模式)。 */
2298
- const nextAction = useMemo((): { eyebrow: string; title: string; reason: string; actionLabel: string; onClick: () => void } | null => {
2299
- if (project === null) {
2300
- return {
2301
- eyebrow: '开始你的第一本书',
2302
- title: '导入小说大纲',
2303
- reason: '从 docx 文件或粘贴文本开始,AI 会把一份大纲「编译」成完整的小说。',
2304
- actionLabel: '导入大纲',
2305
- onClick: () => { setActiveTab('overview') },
2306
- }
2307
- }
2308
- if (bible === undefined) {
2309
- return {
2310
- eyebrow: '推荐下一步',
2311
- title: '提炼道藏',
2312
- reason: '人设、世界观、金手指规则、写作红线是后续所有生成的地基,越完整质量越高。',
2313
- actionLabel: '生成道藏',
2314
- onClick: () => { void handleBible() },
2315
- }
2316
- }
2317
- if (volumes === undefined) {
2318
- return {
2319
- eyebrow: '推荐下一步',
2320
- title: '规划全书卷结构',
2321
- reason: '按剧情弧线划分卷,章节计划才有骨架可依。',
2322
- actionLabel: '生成卷计划',
2323
- onClick: () => { void handleVolumes() },
2324
- }
2325
- }
2326
- if (chapters.length === 0) {
2327
- return {
2328
- eyebrow: '推荐下一步',
2329
- title: '生成章节计划',
2330
- reason: 'LLM 根据大纲拆解每章标题与剧情要点,然后就可以逐章生成正文。',
2331
- actionLabel: '生成章节计划',
2332
- onClick: () => { void handlePlan() },
2333
- }
2334
- }
2335
- const drafting = chapters.find(c => c.pendingDraft !== undefined && c.pendingDraft !== '')
2336
- if (drafting !== undefined) {
2337
- return {
2338
- eyebrow: '需要你确认',
2339
- title: `第 ${drafting.no} 章有未采纳的润色草稿`,
2340
- reason: '打开工作区查看对比,决定采纳新稿或保留原稿(原稿未被改动)。',
2341
- actionLabel: '打开工作区',
2342
- onClick: () => { void openWorkspace(drafting.no) },
2343
- }
2344
- }
2345
- if (pendingCount > 0) {
2346
- return {
2347
- eyebrow: '继续创作',
2348
- title: `还有 ${pendingCount} 章待生成`,
2349
- reason: '批量生成剩余章节,顶部进度条会实时显示每章字数与进度。',
2350
- actionLabel: `批量生成(${pendingCount})`,
2351
- onClick: () => { void handleWriteAll() },
2352
- }
2353
- }
2354
- if (reviewPendingCount > 0) {
2355
- const firstPending = chapters.find(c => c.status === 'written' || c.status === 'rejected')
2356
- return {
2357
- eyebrow: '推荐下一步',
2358
- title: `${reviewPendingCount} 章待审稿`,
2359
- reason: '审稿通过后章节才算完成;不通过的可按意见在工作区修订。',
2360
- actionLabel: '去审稿',
2361
- onClick: () => { if (firstPending !== undefined) gotoChapter(firstPending.no) },
2362
- }
2363
- }
2364
- if ((project?.todos ?? []).some(t => !t.done)) {
2365
- const n = (project?.todos ?? []).filter(t => !t.done).length
2366
- return {
2367
- eyebrow: '需要你确认',
2368
- title: `还有 ${n} 条导演待办未处理`,
2369
- reason: '自动导演给出的风险/修复建议已加入待办,点进去逐条处理或勾掉。',
2370
- actionLabel: '去处理',
2371
- onClick: () => { setActiveTab('director') },
2372
- }
2373
- }
2374
- return {
2375
- eyebrow: '全部完成 🎉',
2376
- title: '《' + project.bookName + '》已全部生成',
2377
- reason: '可以去 AI 味润色(对比后采纳)、按卷复查或导出全本(TXT/MD)。',
2378
- actionLabel: '导出全本',
2379
- onClick: () => { void handleExport('txt') },
2380
- }
2381
- }, [project, bible, volumes, chapters, pendingCount, reviewPendingCount, openWorkspace, gotoChapter])
2382
-
2383
- /** 待办队列(失败/草稿/待审稿,点击直达)。 */
2384
- const todos = useMemo(() => {
2385
- const items: Array<{ tone: 'danger' | 'warning' | 'info' | 'success'; title: string; description: string; actionLabel: string; onClick: () => void }> = []
2386
- for (const chapter of chapters) {
2387
- if (chapter.status === 'error') {
2388
- items.push({
2389
- tone: 'danger',
2390
- title: `第 ${chapter.no} 章《${chapter.title}》生成失败`,
2391
- description: chapter.error ?? '',
2392
- actionLabel: '去处理',
2393
- onClick: () => { gotoChapter(chapter.no) },
2394
- })
2395
- }
2396
- if (items.length >= 3) return items
2397
- }
2398
- const drafting = chapters.find(c => c.pendingDraft !== undefined && c.pendingDraft !== '')
2399
- if (drafting !== undefined && items.length < 3) {
2400
- items.push({
2401
- tone: 'warning',
2402
- title: `第 ${drafting.no} 章《${drafting.title}》有未采纳草稿`,
2403
- description: '原稿未被改动,采纳或放弃由你决定',
2404
- actionLabel: '打开工作区',
2405
- onClick: () => { void openWorkspace(drafting.no) },
2406
- })
2407
- }
2408
- for (const chapter of chapters) {
2409
- if ((chapter.status === 'written' || chapter.status === 'rejected') && items.length < 3) {
2410
- items.push({
2411
- tone: 'info',
2412
- title: `第 ${chapter.no} 章《${chapter.title}》待审稿`,
2413
- description: chapter.status === 'rejected' ? '审稿未通过,可按意见修订' : '等待 AI 审稿确认',
2414
- actionLabel: '去审稿',
2415
- onClick: () => { gotoChapter(chapter.no) },
2416
- })
2417
- }
2418
- }
2419
- // 导演采纳的待办:风险/修复清单(来自「工具 → 自动导演」)
2420
- if (project !== null) {
2421
- for (const td of (project.todos ?? []).filter(t => !t.done)) {
2422
- if (items.length >= 6) break
2423
- items.push({
2424
- tone: 'warning',
2425
- title: `导演待办:${td.text.length > 26 ? td.text.slice(0, 26) + '…' : td.text}`,
2426
- description: '自动导演给出的风险/修复,点进去逐条处理或勾掉',
2427
- actionLabel: '去处理',
2428
- onClick: () => { setActiveTab('director') },
2429
- })
2430
- }
2431
- }
2432
- return items
2433
- }, [chapters, openWorkspace, gotoChapter, project])
2434
-
2435
- /** 资产健康(设定/卷/写作资产/伏笔)。 */
2436
- const assetSummary = (() => {
2437
- const assets = project?.assets
2438
- const parts: string[] = []
2439
- if (assets?.genre !== undefined) parts.push(`题材:${assets.genre.name}`)
2440
- if (assets?.primaryProgression !== undefined) parts.push(`推进:${assets.primaryProgression.name}`)
2441
- if ((assets?.styleAssets?.length ?? 0) > 0) parts.push(`写法:${assets!.styleAssets!.length} 套`)
2442
- return parts.length > 0 ? parts.join(' · ') : '题材 / 推进 / 写法未绑定'
2443
- })()
2444
- const assetCount = (() => {
2445
- const assets = project?.assets
2446
- let n = 0
2447
- if (assets?.genre !== undefined) n++
2448
- if (assets?.primaryProgression !== undefined) n++
2449
- n += assets?.auxiliaryProgressions?.length ?? 0
2450
- n += assets?.styleAssets?.length ?? 0
2451
- n += assets?.antiAiRules?.length ?? 0
2452
- return n
2453
- })()
2454
- /** 自定义背景:优先用户显式选择(themeBg),否则用 config 里的持久化值。 */
2455
- const effectiveBg = themeBg !== undefined && themeBg !== '' ? themeBg : (config?.themeBackground ?? '')
2456
- const effectiveBgBlur = themeBgBlur > 0 ? themeBgBlur : (config?.themeBackgroundBlur ?? 0)
2457
- const effectiveOpacity = themeOpacity > 0 ? themeOpacity : (config?.themeOpacity ?? 100)
2458
- const panelBgStyle: Record<string, string> = {}
2459
- if (effectiveBg !== '') panelBgStyle['--nf-glass-bg-image' as string] = 'url(' + effectiveBg + ')'
2460
- if (effectiveBgBlur > 0) panelBgStyle['--nf-glass-bg-dim' as string] = String(effectiveBgBlur)
2461
- panelBgStyle['--nf-glass-opacity' as string] = String(effectiveOpacity) + '%'
2462
-
2463
- return (
2464
- <div className={css.panel} data-nf-theme={panelTheme} data-nf-mode={themeMode === 'system' ? undefined : themeMode} data-nf-density={themeDensity} data-nf-endfield-accent={endfieldAccent} style={panelBgStyle}>
2465
- {viewMode === 'shelf' ? (
2466
- /* 书架首页:作者级左侧导航 + 书架/改编/资产库/设置 */
2467
- <AuthorHome
2468
- api={api}
2469
- shelf={shelf ?? { books: [], activeBookId: null }}
2470
- onOpenBook={async (id) => {
2471
- await activateBook(id, 'workspace')
2472
- }}
2473
- onReadBook={async (id) => {
2474
- await activateBook(id, 'reader')
2475
- }}
2476
- onAddBook={() => { setCreatePrefill(null); setViewMode('create') }}
2477
- onImportBook={() => { setShowImport(true) }}
2478
- onUseIdea={(idea) => {
2479
- setCreatePrefill({
2480
- name: idea.title,
2481
- idea: `《${idea.title}》\n题材:${idea.genre}\n视角:${idea.pov}\n钩子:${idea.hook}\n长期兑现:${idea.payoff}`,
2482
- })
2483
- setViewMode('create')
2484
- }}
2485
- onOpenSettings={() => { void openSettingsFromHome() }}
2486
- onTheme={(t, m, d) => { changePanelTheme(t); changeThemeMode(m); changeThemeDensity(d) }}
2487
- onBackground={(bg, blur) => { setThemeBg(bg); setThemeBgBlur(blur) }}
2488
- onOpacity={(n) => { setThemeOpacity(n) }}
2489
- onEndfieldAccent={(accent) => { changeEndfieldAccent(accent) }}
2490
- adaptEnabled={config?.enableAdaptMode === true}
2491
- progress={progress}
2492
- busy={busy}
2493
- busyLabel={busyLabel}
2494
- liveBar={liveBar}
2495
- onClearProgress={() => { setProgress([]) }}
2496
- />
2497
- ) : viewMode === 'create' ? (
2498
- /* 开书向导:独立页面 */
2499
- <CreateBookView
2500
- api={api}
2501
- initialName={createPrefill?.name}
2502
- initialIdea={createPrefill?.idea}
2503
- onBack={() => { setViewMode('shelf') }}
2504
- onCreated={async (id) => {
2505
- setBusy(true)
2506
- try {
2507
- setOutlineText('')
2508
- setProject(null)
2509
- setGeneratedFiles([])
2510
- setChapterText('')
2511
- setExpandedChapter(null)
2512
- setProgress([])
2513
- setAuditIssues(null)
2514
- setCharCards(null)
2515
- await refresh(false, true)
2516
- await refreshShelf()
2517
- setViewMode('workspace')
2518
- } catch (err) {
2519
- setError((err as Error).message)
2520
- } finally {
2521
- setBusy(false)
2522
- }
2523
- }}
2524
- />
2525
- ) : viewMode === 'reader' ? (
2526
- /* 沉浸式阅读页:只读已写章节 */
2527
- <ReaderView
2528
- api={api}
2529
- project={project ?? { bookName: '', outline: '', chapters: [], foreshadows: [], createdAt: '', updatedAt: '' }}
2530
- onBack={() => { setViewMode('shelf') }}
2531
- onOpenWorkspace={() => { setViewMode('workspace') }}
2532
- />
2533
- ) : (
2534
- <>
2535
- <div className={css.panelBody}>
2536
- <nav className={`${css.panelNav} ${navCollapsed ? css.panelNavCollapsed : ''}`} role="tablist" aria-label="工作台导航">
2537
- {/* 标题区:小说工坊 + 当前书名(顶部标题栏取消后移入侧栏) */}
2538
- <div className={css.navTitle}>
2539
- <div className={css.navTitleLogo}>书</div>
2540
- {!navCollapsed && (
2541
- <div style={{ minWidth: 0 }}>
2542
- <div className={css.navTitleName}>小说工坊</div>
2543
- <div className={css.navTitleBook} title={project?.bookName ?? ''}>
2544
- {project?.bookName !== undefined && project.bookName !== '' ? `📖 ${project.bookName}` : '未选书'}
2545
- </div>
2546
- </div>
2547
- )}
2548
- </div>
2549
- {/* 分组导航(创作 / 工具 / 数据库) */}
2550
- {NAV_GROUPS.map(group => (
2551
- <div key={group.id} className={css.navGroup}>
2552
- {!navCollapsed && <div className={css.navGroupLabel}>{group.label}</div>}
2553
- {group.collapsible === true && (
2554
- <button type="button" className={css.navTab} onClick={() => setAdvancedOpen(v => !v)} title={group.label}>
2555
- <span className={css.navTabIcon}>{advancedOpen ? '▾' : '▸'}</span>
2556
- {!navCollapsed && <span className={css.navTabLabel}>{advancedOpen ? '收起进阶' : '展开进阶'}</span>}
2557
- </button>
2558
- )}
2559
- {(group.collapsible !== true || advancedOpen) && group.items.map(tab => (
2560
- <button
2561
- key={tab.id}
2562
- type="button"
2563
- role="tab"
2564
- aria-selected={activeTab === tab.id || (tab.id === 'assistant' && assistantOpen) || (tab.id === 'progress' && progressOpen)}
2565
- data-active={activeTab === tab.id || (tab.id === 'assistant' && assistantOpen) || (tab.id === 'progress' && progressOpen) ? '' : undefined}
2566
- className={css.navTab}
2567
- title={tab.label}
2568
- onClick={() => {
2569
- if (tab.id === 'assistant') {
2570
- setAssistantOpen(true)
2571
- } else if (tab.id === 'progress') {
2572
- setProgressOpen(v => !v)
2573
- } else {
2574
- setActiveTab(tab.id)
2575
- }
2576
- }}
2577
- >
2578
- <span className={css.navTabIcon}>{tab.icon}</span>
2579
- {!navCollapsed && <span className={css.navTabLabel}>{tab.label}</span>}
2580
- {/* 状态角标:章节待办 / 伏笔待埋 / 工作流进度 */}
2581
- {tab.id === 'plan' && (pendingCount > 0 || reviewPendingCount > 0) && (
2582
- <span className={`${css.navTabBadge} ${chapters.some(c => c.status === 'error') ? css.navTabBadgeDanger : css.navTabBadgeWarn}`}>
2583
- {chapters.some(c => c.status === 'error') ? `!${pendingCount + reviewPendingCount}` : pendingCount + reviewPendingCount}
2584
- </span>
2585
- )}
2586
- {tab.id === 'plotlines' && foreshadows.some(f => f.status === 'planned') && (
2587
- <span className={css.navTabBadge}>{foreshadows.filter(f => f.status === 'planned').length}</span>
2588
- )}
2589
- {tab.id === 'workflow' && chapters.length > 0 && (
2590
- <span className={`${css.navTabBadge} ${css.navTabBadgeDone}`}>{journeyPercent}%</span>
2591
- )}
2592
- {/* 任务进行中:AI进度导航显示呼吸绿点(悬停看任务名),不自动弹窗 */}
2593
- {tab.id === 'progress' && busy && (
2594
- <span
2595
- className={`${css.navTabBadge} ${css.navTabBadgeLive}`}
2596
- title={busyLabel !== '' ? `任务中:${busyLabel}` : '任务进行中'}
2597
- >
2598
- ●
2599
- </span>
2600
- )}
2601
- </button>
2602
- ))}
2603
- {!navCollapsed && <div className={css.navGroupSep} />}
2604
- </div>
2605
- ))}
2606
- {/* 设置沉底 */}
2607
- <div className={css.navSpacer} />
2608
- <button
2609
- type="button"
2610
- role="tab"
2611
- aria-selected={activeTab === SETTINGS_TAB.id}
2612
- data-active={activeTab === SETTINGS_TAB.id ? '' : undefined}
2613
- className={css.navTab}
2614
- title={SETTINGS_TAB.label}
2615
- onClick={() => { setActiveTab(SETTINGS_TAB.id) }}
2616
- >
2617
- <span className={css.navTabIcon}>{SETTINGS_TAB.icon}</span>
2618
- {!navCollapsed && <span className={css.navTabLabel}>{SETTINGS_TAB.label}</span>}
2619
- </button>
2620
- {/* 当前书切换卡(demo 风格底部内容卡:封面首字 + 书名 + 进度,点击回书架) */}
2621
- {!navCollapsed && shelf !== null && (
2622
- <button
2623
- type="button"
2624
- className={css.bookSwitch}
2625
- title={activeBook !== null ? `当前书:${activeBook.bookName}(${activeBook.outputDir})· 点击打开书架` : '打开书架'}
2626
- onClick={() => { setViewMode('shelf') }}
2627
- >
2628
- <span className={css.bookSwitchCover}>{activeBookLetter}</span>
2629
- <span style={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
2630
- <span className={css.bookSwitchName}>{activeBookName}</span>
2631
- <span className={css.bookSwitchMeta}>{activeBookMeta}</span>
2632
- </span>
2633
- <span className={css.bookSwitchArrow}>⌄</span>
2634
- </button>
2635
- )}
2636
- {/* 关于:版本 + GitHub + 更新检测 */}
2637
- <div className={css.navAbout}>
2638
- <button
2639
- type="button"
2640
- className={css.navAboutRow}
2641
- title="打开 GitHub 仓库"
2642
- onClick={() => { window.open(REPO_URL, '_blank', 'noopener') }}
2643
- >
2644
- <span>ℹ️ v{PLUGIN_VERSION}</span>
2645
- <span className={css.meta}>GitHub ↗</span>
2646
- </button>
2647
- {npmLatest !== null && npmLatest !== PLUGIN_VERSION && (
2648
- <button
2649
- type="button"
2650
- className={css.navAboutUpdate}
2651
- title="查看更新方法"
2652
- onClick={() => {
2653
- window.alert(
2654
- `检测到新版本 v${npmLatest}(当前 v${PLUGIN_VERSION})\n\n更新方式:\n\n【npm 安装】\ncd ~/.dsh/profiles/web && pnpm add @waterwx/dsh-novel-forge@latest\n然后重启 dsh web\n\n【GitHub 安装】\ndsh plugin --profile web add github:watersxya/dsh-novel-forge\n\n【本地开发】\n拉取最新代码 → pnpm install && pnpm build → 重启 dsh web`,
2655
- )
2656
- }}
2657
- >
2658
- 📦 有新版本 v{npmLatest}
2659
- </button>
2660
- )}
2661
- </div>
2662
- {/* 操作行:收起导航 / 关闭面板(原顶部按钮下放,收起态保留图标) */}
2663
- <div className={css.navActions}>
2664
- <button
2665
- type="button"
2666
- className={css.navActionBtn}
2667
- title={navCollapsed ? '展开导航栏' : '收起导航栏'}
2668
- aria-label={navCollapsed ? '展开导航栏' : '收起导航栏'}
2669
- onClick={() => {
2670
- setNavCollapsed(prev => {
2671
- const next = !prev
2672
- try { window.localStorage.setItem('dsh-novel-forge.nav.collapsed', String(next)) } catch { /* ignore */ }
2673
- return next
2674
- })
2675
- }}
2676
- >
2677
- {navCollapsed ? '▸' : '◂'}
2678
- </button>
2679
- <button
2680
- type="button"
2681
- className={css.navActionBtn}
2682
- title={tt('common.close')}
2683
- aria-label={tt('common.close')}
2684
- onClick={() => { controller.close() }}
2685
- >
2686
- ×
2687
- </button>
2688
- </div>
2689
- </nav>
2690
- <div className={css.panelContent}>
2691
- {error !== '' && <div className={css.card} style={{ borderColor: 'var(--nf-error)' }}><span style={{ color: 'var(--nf-error)' }}>{tt('common.error')}: {error}</span></div>}
2692
- {notice !== '' && <div className={css.card}><span style={{ color: 'var(--nf-success)' }}>{notice}</span></div>}
2693
-
2694
- {/* 遗留草稿提示:刷新页面后仍有未采纳的润色/修订草稿 */}
2695
- {workspace === null && draftNo !== null && (
2696
- <div className={css.card} style={{ borderColor: 'var(--nf-info)' }}>
2697
- <div className={css.busyRow}>
2698
- <span style={{ color: 'var(--nf-info)' }}>第 {draftNo} 章有未采纳的润色/修订草稿(原稿未被改动)</span>
2699
- <span style={{ display: 'flex', gap: 'var(--nf-space-8)' }}>
2700
- <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void openWorkspace(draftNo) }}>
2701
- 打开工作区
2702
- </button>
2703
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleDraftDiscard(draftNo) }}>
2704
- 放弃
2705
- </button>
2706
- </span>
2707
- </div>
2708
- </div>
2709
- )}
2710
-
2711
- {/* 章节编辑页(独占整页:点「编辑」进入,返回后回到原页面;无卡片盒子,撑满内容区) */}
2712
- {workspace !== null && (
2713
- <div className={css.wsPage}>
2714
- <div className={css.wsPageHeader}>
2715
- {workspace.applied === true ? (
2716
- <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} onClick={() => { setWorkspace(null); setWsResultMode(false); gotoChapter(workspace.no) }} title="关闭工作区,返回章节页并定位到本章(滚动 + 高亮)">
2717
- ← 返回章节页
2718
- </button>
2719
- ) : (
2720
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setWorkspace(null); setWsResultMode(false) }} title="返回章节列表(草稿不丢失)">
2721
- ← 返回
2722
- </button>
2723
- )}
2724
- <span className={css.cardTitle}>第 {workspace.no} 章《{workspace.title}》</span>
2725
- <span className={css.meta}>{workspace.original.length} 字</span>
2726
- <span style={{ display: 'flex', gap: 'var(--nf-space-4)', alignItems: 'center', marginLeft: 'auto' }}>
2727
- <button type="button" className={css.iconButton} title="减小字号" aria-label="减小字号" onClick={() => { changeEditorFontSize(editorFontSize - 1) }}>A−</button>
2728
- <span className={css.meta}>{editorFontSize}px</span>
2729
- <button type="button" className={css.iconButton} title="增大字号" aria-label="增大字号" onClick={() => { changeEditorFontSize(editorFontSize + 1) }}>A+</button>
2730
- <button type="button" className={css.iconButton} title="关闭工作区" aria-label="关闭工作区" onClick={() => { setWorkspace(null); setWsResultMode(false) }}>×</button>
2731
- </span>
2732
- </div>
2733
- {workspace.applied === true && (
2734
- <div className={css.wsAppliedBanner}>
2735
- ✅ 已采纳第 {workspace.no} 章新稿({workspace.original.length} 字)· 原稿已自动备份 .bak
2736
- {wsCheckReport !== null ? (
2737
- <span style={{ color: wsCheckReport.passed ? 'var(--nf-success)' : 'var(--nf-error)' }}>
2738
- {' · '}修订时审查:{wsCheckReport.score} 分 — {wsCheckReport.passed ? '通过 ✓' : '未通过,可继续勾选意见修订'}
2739
- </span>
2740
- ) : (
2741
- <span className={css.meta}>{' · '}需要结论?点「🔍 AI 审查」查看</span>
2742
- )}
2743
- </div>
2744
- )}
2745
- {workspace.applied !== true && wsResultMode && (
2746
- <div className={css.wsAppliedBanner} style={{ borderColor: 'var(--nf-accent)', background: 'color-mix(in srgb, var(--nf-accent) 8%, transparent)' }}>
2747
- ✅ 修订完成,请查看草稿对比
2748
- {wsCheckReport !== null && (
2749
- <span style={{ color: wsCheckReport.passed ? 'var(--nf-success)' : 'var(--nf-error)' }}>
2750
- {' · '}草稿审查:{wsCheckReport.score} 分 — {wsCheckReport.passed ? '通过 ✓' : '未通过,可勾选意见继续修订'}
2751
- </span>
2752
- )}
2753
- <span className={css.meta}>{' · '}满意后点「✅ 应用并保存」落盘</span>
2754
- </div>
2755
- )}
2756
- <div className={css.wsColumns}>
2757
- <div className={css.wsColumn}>
2758
- <div className={css.meta}>
2759
- 原文({workspace.original.length} 字)— 在正文中选中文字可作为「修订选中」的局部目标
2760
- </div>
2761
- <textarea
2762
- ref={wsEditorRef}
2763
- className={`${css.textarea} ${css.wsEditor}`}
2764
- style={{ fontSize: editorFontSize }}
2765
- value={workspace.original}
2766
- onChange={e => { setWorkspace({ ...workspace, original: e.target.value }) }}
2767
- onMouseUp={captureWsSelection}
2768
- onKeyUp={captureWsSelection}
2769
- onSelect={captureWsSelection}
2770
- spellCheck={false}
2771
- />
2772
- </div>
2773
- <div className={css.wsColumn}>
2774
- <div className={css.meta} style={{ fontWeight: 600 }}>AI 修正指令</div>
2775
- <textarea
2776
- className={css.textarea}
2777
- style={{ minHeight: 60 }}
2778
- placeholder="输入修正要求,例如:压缩冗余、加强冲突、这段对话更口语化…(可留空)"
2779
- value={workspace.instruction}
2780
- onChange={e => { setWorkspace({ ...workspace, instruction: e.target.value }) }}
2781
- />
2782
- {wsSelected !== '' ? (
2783
- <div className={css.wsSelected}>
2784
- <div className={css.meta}>当前选中内容(将用于精准修订)</div>
2785
- <div className={css.wsSelectedText}>{wsSelected}</div>
2786
- </div>
2787
- ) : (
2788
- <div className={css.meta}>未选中内容时仅支持整章润色/修订。</div>
2789
- )}
2790
- <div className={css.row} style={{ flexWrap: 'wrap' }}>
2791
- {(wsCheckReport !== null && wsCheckReport.issues.length > 0) ? (
2792
- <button
2793
- type="button"
2794
- className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
2795
- disabled={busy || wsChecked.length === 0}
2796
- onClick={() => { void handleWsReviseByReport() }}
2797
- title="按下方勾选的意见自动修订整章;产出草稿后自动附带一次 AI 审查"
2798
- >
2799
- 🔧 按意见修订({wsChecked.length})
2800
- </button>
2801
- ) : (
2802
- <button
2803
- type="button"
2804
- className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
2805
- disabled={busy || workspace.instruction.trim() === ''}
2806
- onClick={() => { void handleWsRewrite(true) }}
2807
- title="按指令框内容整章修订"
2808
- >
2809
- 🔧 整章修订
2810
- </button>
2811
- )}
2812
- <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleWsPolish() }}>
2813
- ✨ 去AI味润色
2814
- </button>
2815
- <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || workspace.original.trim().length < 50} onClick={() => { void handleWsSave() }} title="有草稿则应用草稿,无草稿则保存当前编辑;沿用审查结论或自动审稿,落盘后原地显示结果">
2816
- ✅ 应用并保存
2817
- </button>
2818
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || wsSelected === ''} onClick={() => { void handleWsRewrite(false) }} title="只修订在左栏选中的文字片段">
2819
- 📝 修订选中
2820
- </button>
2821
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || workspace.original.trim().length < 50} onClick={() => { void handleWsCheck() }} title="对当前正文跑一次 AI 审查(不落盘)">
2822
- 🔍 AI 审查
2823
- </button>
2824
- {workspace.draft !== null && (
2825
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleDraftDiscard(workspace.no) }} title="放弃草稿,保留原稿">
2826
- ↩️ 放弃草稿
2827
- </button>
2828
- )}
2829
- </div>
2830
- {/* 手动编辑后的 AI 审查结果 */}
2831
- {wsCheckReport !== null && (
2832
- <div className={css.wsPreview} style={{ borderColor: wsCheckReport.passed ? 'var(--nf-success)' : 'var(--nf-warn)' }}>
2833
- <div className={css.busyRow}>
2834
- <span className={css.meta} style={{ fontWeight: 600 }}>AI 审查结果</span>
2835
- <span style={{ color: wsCheckReport.passed ? 'var(--nf-success)' : 'var(--nf-error)' }}>
2836
- {wsCheckReport.score} 分 — {wsCheckReport.passed ? '通过' : '未通过'}
2837
- </span>
2838
- </div>
2839
- <div className={css.meta}><b>总评:</b>{wsCheckReport.verdict}</div>
2840
- {wsCheckReport.issues.length > 0 && (
2841
- <ul style={{ margin: 0, paddingLeft: 'var(--nf-space-16)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', fontSize: editorFontSize - 1, maxHeight: '45vh', overflowY: 'auto' }}>
2842
- {wsCheckReport.issues.map((issue, i) => (
2843
- <li key={i} style={{ color: severityColor(issue.severity), display: 'flex', gap: 'var(--nf-space-6)', alignItems: 'flex-start' }}>
2844
- <input
2845
- type="checkbox"
2846
- style={{ marginTop: 'var(--nf-space-2)' }}
2847
- checked={wsChecked.includes(i)}
2848
- onChange={e => {
2849
- setWsChecked(prev => e.target.checked ? [...prev, i] : prev.filter(x => x !== i))
2850
- }}
2851
- title="勾选后由「按意见修订」一起修订"
2852
- />
2853
- <span>
2854
- [{issue.severity}{issue.dimension !== undefined ? ` · ${REVIEW_DIM_ZH[issue.dimension] ?? issue.dimension}` : ''}] {issue.item}
2855
- {issue.suggestion !== '' && <span style={{ color: 'var(--nf-text-2)' }}> → {issue.suggestion}</span>}
2856
- </span>
2857
- </li>
2858
- ))}
2859
- </ul>
2860
- )}
2861
- <span className={css.meta}>
2862
- {wsCheckReport.passed
2863
- ? '当前意见:已通过。勾选想微调的问题点「按意见修订」,或直接「✅ 应用并保存」。'
2864
- : '意见只读不落盘;勾选要修的问题点「🔧 按意见修订」一键修订(默认已勾 high),满意后点「✅ 应用并保存」写入文件(原稿自动备份 .bak)。'}
2865
- </span>
2866
- </div>
2867
- )}
2868
- {workspace.draft !== null && (
2869
- <div className={css.wsPreview} style={{ flex: 1, minHeight: 0 }}>
2870
- <div className={css.busyRow}>
2871
- <span className={css.meta}>优化预览({workspace.draft.length} 字)</span>
2872
- <span style={{ display: 'flex', gap: 'var(--nf-space-8)' }}>
2873
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setWsShowDiff(v => !v) }}>
2874
- {wsShowDiff ? '显示文本' : '查看对比'}
2875
- </button>
2876
- </span>
2877
- </div>
2878
- {wsShowDiff
2879
- ? <DiffList original={workspace.original} draft={workspace.draft} fontSize={editorFontSize} />
2880
- : <pre className={css.wsPreviewText} style={{ fontSize: editorFontSize }}>{workspace.draft}</pre>}
2881
- <span className={css.meta}>满意后点上方「✅ 应用并保存」落盘(原稿自动备份 .bak);不满意可继续修订。</span>
2882
- </div>
2883
- )}
2884
- </div>
2885
- </div>
2886
- </div>
2887
- )}
2888
-
2889
- {/* 编辑页打开时独占整页:隐藏其余所有页面内容 */}
2890
- {workspace === null && (<>
2891
- {activeTab === 'workflow' && (
2892
- <>
2893
- {/* ⭐ 主行动大卡片 */}
2894
- <div className={css.dashHero}>
2895
- <div className={css.dashHeroEyebrow}>
2896
- <span className={css.dashHeroSparkle}>✨</span>
2897
- {nextAction?.eyebrow ?? '开始'}
2898
- </div>
2899
- {project !== null && (
2900
- <div className={css.dashHeroTitle}>
2901
- <span className={css.meta}>正在创作</span>
2902
- <h3 className={css.dashHeroBook}>《{project.bookName}》</h3>
2903
- </div>
2904
- )}
2905
- {nextAction !== null && (
2906
- <div className={css.dashHeroAction}>
2907
- <span className={css.dashHeroArrow}>→</span>
2908
- <div className={css.dashHeroActionBody}>
2909
- <div className={css.dashHeroActionTitle}>{nextAction.title}</div>
2910
- <div className={css.meta}>{nextAction.reason}</div>
2911
- </div>
2912
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { nextAction.onClick() }}>
2913
- {nextAction.actionLabel}
2914
- </button>
2915
- </div>
2916
- )}
2917
- {/* 问编辑老师:把拆书/导演/知识库/待办交给 AI 编辑 Agent */}
2918
- <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-8)', marginTop: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
2919
- <button type="button" className={css.button} onClick={() => { setAssistantOpen(true) }}>💬 问 AI 编辑 Agent</button>
2920
- <span className={css.meta}>拆书 / 自动导演 / 知识库 / 待办,一句话交给它帮你跑。</span>
2921
- </div>
2922
- {/* 创作旅程进度 */}
2923
- <div className={css.dashJourney}>
2924
- <div className={css.busyRow}>
2925
- <span className={css.meta} style={{ fontWeight: 600 }}>创作旅程</span>
2926
- <span className={css.meta}>{journeyPercent}% · 已完成 {journeyDoneCount}/{journeyStages.length} 步</span>
2927
- </div>
2928
- <div className={css.tlBar}>
2929
- {journeyStages.map(stage => (
2930
- <button
2931
- key={stage.id}
2932
- type="button"
2933
- className={`${css.tlSeg} ${stage.done ? css.tlSegDone : stage.id === currentStageId ? css.tlSegCurrent : css.tlSegTodo}`}
2934
- title={`${stage.label}${stage.done ? ' · 已完成' : stage.id === currentStageId ? ' · 进行中' : ' · 未开始'}(点击跳转)`}
2935
- onClick={() => { jumpToStage(stage.id) }}
2936
- >
2937
- <span className={css.tlSegTrack}><span className={css.tlSegFill} /></span>
2938
- <span className={css.tlSegLabel}>{stage.done ? '✓ ' : ''}{stage.label}</span>
2939
- </button>
2940
- ))}
2941
- </div>
2942
- </div>
2943
- </div>
2944
-
2945
- {/* 统计卡(demo 风格:今日待写 / 写作中 / 已通过 / 待审稿) */}
2946
- <div className={css.statRowD}>
2947
- <div className={css.statCardD}>
2948
- <div style={{ minWidth: 0 }}>
2949
- <div className={css.statCardDLabel}>今日待写</div>
2950
- <div className={css.statCardDValue}>{pendingCount} <span className={css.statCardDUnit}>章</span></div>
2951
- <div className={css.statCardDDetail}>
2952
- {chapters.some(c => c.status === 'error') ? `含 error ${chapters.filter(c => c.status === 'error').length} 章` : '待生成队列'}
2953
- </div>
2954
- </div>
2955
- <div className={css.statCardDIcon}>📝</div>
2956
- </div>
2957
- <div className={css.statCardD}>
2958
- <div style={{ minWidth: 0 }}>
2959
- <div className={css.statCardDLabel}>写作中</div>
2960
- <div className={css.statCardDValue}>{chapters.filter(c => c.status === 'generating' || c.status === 'reviewing').length} <span className={css.statCardDUnit}>章</span></div>
2961
- <div className={css.statCardDDetail}>{writingNow !== undefined ? `第 ${writingNow.no} 章 · ${writingNow.title}` : '无进行中任务'}</div>
2962
- </div>
2963
- <div className={css.statCardDIcon}>✍️</div>
2964
- </div>
2965
- <div className={css.statCardD}>
2966
- <div style={{ minWidth: 0 }}>
2967
- <div className={css.statCardDLabel}>已通过</div>
2968
- <div className={css.statCardDValue}>{approvedCount} <span className={css.statCardDUnit}>章</span></div>
2969
- <div className={css.statCardDDetail}>{chapters.length > 0 ? `占全书 ${Math.round((approvedCount / chapters.length) * 100)}%` : '尚无已通过章节'}</div>
2970
- </div>
2971
- <div className={css.statCardDIcon}>✅</div>
2972
- </div>
2973
- <div className={css.statCardD}>
2974
- <div style={{ minWidth: 0 }}>
2975
- <div className={css.statCardDLabel}>待审稿</div>
2976
- <div className={css.statCardDValue}>{reviewPendingCount} <span className={css.statCardDUnit}>章</span></div>
2977
- <div className={`${css.statCardDDetail} ${reviewPendingCount > 0 ? css.statCardDDown : ''}`}>
2978
- {`written ${chapters.filter(c => c.status === 'written').length} · rejected ${chapters.filter(c => c.status === 'rejected').length}`}
2979
- </div>
2980
- </div>
2981
- <div className={`${css.statCardDIcon} ${css.statCardDIconRed}`}>⚠️</div>
2982
- </div>
2983
- </div>
2984
- {/* 补充统计:总字数 / 当前卷 / 最近创作(原有数据保留) */}
2985
- <div className={css.assetGrid}>
2986
- <StatCell label="总字数" value={String(totalChars)} detail="已生成正文累计" />
2987
- <StatCell label="当前卷" value={currentVolumeName} valueFontSize={13} detail="正在推进的卷" />
2988
- <StatCell label="最近创作" value={lastUpdated} valueFontSize={13} detail="最近生成/编辑时间" />
2989
- </div>
2990
-
2991
- {/* 资产健康:独占一行(道藏/卷计划/写作资产/伏笔 + 全书质检) */}
2992
- <div className={css.card}>
2993
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
2994
- <span className={css.cardTitle}>资产健康</span>
2995
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || doneCount === 0} onClick={() => { void handleAudit() }} title="LLM 扫描全本已生成章节,检查人名/境界/资源/时间线矛盾">
2996
- 🔍 全书质检
2997
- </button>
2998
- </div>
2999
- {auditStatus?.status === 'running' && (
3000
- <div className={css.meta} style={{ marginTop: 'var(--nf-space-8)' }}>
3001
- 🔍 全书质检中:{auditStatus.completedBatches}/{auditStatus.totalBatches > 0 ? auditStatus.totalBatches : '…'} 批
3002
- {auditStatus.totalBatches > 0 && (
3003
- <div className={css.dashJourneyBar} style={{ marginTop: 'var(--nf-space-4)' }}>
3004
- <div className={css.dashJourneyFill} style={{ width: `${Math.round((auditStatus.completedBatches / auditStatus.totalBatches) * 100)}%` }} />
3005
- </div>
3006
- )}
3007
- </div>
3008
- )}
3009
- {auditStatus?.status === 'error' && auditStatus.error !== undefined && (
3010
- <div className={css.meta} style={{ color: 'var(--nf-error)', marginTop: 'var(--nf-space-8)' }}>全书质检失败:{auditStatus.error}</div>
3011
- )}
3012
- <div className={css.assetGrid}>
3013
- <StatCell
3014
- label="道藏"
3015
- value={bible !== undefined ? `✓ ${bible.worldRules.length} 条规则` : '未生成'}
3016
- valueColor={bible !== undefined ? 'var(--nf-success)' : 'var(--nf-text-3)'}
3017
- detail={bible !== undefined ? `${bible.characters.length} 人物 · ${bible.redLines.length} 红线` : '提炼人设 / 世界观 / 金手指'}
3018
- />
3019
- <StatCell
3020
- label="卷计划"
3021
- value={volumes !== undefined ? `${volumes.length} 卷` : '未生成'}
3022
- valueColor={volumes !== undefined ? 'var(--nf-success)' : 'var(--nf-text-3)'}
3023
- detail={volumes !== undefined ? volumes.map(v => v.title).join(' / ') : '按剧情弧线划分全书'}
3024
- detailTitle={volumes?.map(v => v.title).join(' / ')}
3025
- />
3026
- <StatCell
3027
- label="写作资产"
3028
- value={`${assetCount} 项`}
3029
- valueColor={assetCount > 0 ? 'var(--nf-success)' : 'var(--nf-text-3)'}
3030
- detail={assetSummary}
3031
- detailTitle={assetSummary}
3032
- />
3033
- <StatCell
3034
- label="伏笔"
3035
- value={`${foreshadows.length} 条`}
3036
- detail={`${foreshadows.filter(f => f.status === 'planned').length} 待埋 · ${foreshadows.filter(f => f.status === 'resolved').length} 已回收`}
3037
- />
3038
- </div>
3039
- </div>
3040
-
3041
- {/* 剧情线进度 | 作者复盘趋势:精简摘要卡,并排(点击跳转详情页) */}
3042
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 'var(--nf-space-14)', alignItems: 'stretch' }}>
3043
- <div className={css.card}>
3044
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3045
- <span className={css.cardTitle}>🧵 {tt('plotlines.workflowTitle')}({(project?.plotlines ?? []).length} 条)</span>
3046
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setActiveTab('plotlines') }} title="查看完整剧情线管理页">
3047
- 查看全部 →
3048
- </button>
3049
- </div>
3050
- {(() => {
3051
- const all = project?.plotlines ?? []
3052
- const active = all.filter(l => l.status === 'active').length
3053
- const paused = all.filter(l => l.status === 'paused').length
3054
- const resolved = all.filter(l => l.status === 'resolved').length
3055
- const main = all.find(l => l.kind === 'main' && l.status === 'active')
3056
- const withLinks = all.filter(l => l.chapters.length > 0)
3057
- const latest = withLinks.sort((a, b) => Math.max(...b.chapters) - Math.max(...a.chapters))[0]
3058
- if (all.length === 0) return <span className={css.meta}>{tt('plotlines.workflowEmpty')}</span>
3059
- return (
3060
- <span className={css.meta}>
3061
- {active} 推进中 · {paused} 暂停 · {resolved} 已完结
3062
- {main !== undefined && <> · 主线:{main.name}</>}
3063
- {latest !== undefined && <> · 最近推进:{latest.name}(第 {Math.max(...latest.chapters)} 章)</>}
3064
- </span>
3065
- )
3066
- })()}
3067
- </div>
3068
-
3069
- <div className={css.card}>
3070
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3071
- <span className={css.cardTitle}>📋 作者复盘(最近 {Math.min(6, chapters.filter(c => c.authorReview !== undefined).length)} 章)</span>
3072
- <div className={css.row}>
3073
- {(() => {
3074
- const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error' && c.authorReview === undefined).length
3075
- return missing > 0 && (
3076
- <button
3077
- type="button"
3078
- className={`${css.button} ${css.buttonSmall}`}
3079
- disabled={busy}
3080
- onClick={() => { void handleAuthorBackfillAll() }}
3081
- title="对历史已写章节逐章补跑作者复盘(不重新生成正文,每章约 2000 token)"
3082
- >
3083
- ↻ 补齐({missing})
3084
- </button>
3085
- )
3086
- })()}
3087
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setActiveTab('book'); changeBookTab('facts'); changeArchiveTab('reviews') }} title="查看按卷分组的全部复盘记录">
3088
- 查看全部 →
3089
- </button>
3090
- </div>
3091
- </div>
3092
- {(() => {
3093
- const reviewed = chapters.filter(c => c.authorReview !== undefined)
3094
- if (reviewed.length === 0) {
3095
- return <span className={css.meta}>尚无作者复盘——生成/审稿后自动生成,或点「补齐」为已写章节补跑。</span>
3096
- }
3097
- const honored = reviewed.filter(c => c.authorReview!.hookHonored).length
3098
- const avg = Math.round(reviewed.reduce((s, c) => s + c.authorReview!.endingHook, 0) / reviewed.length * 10) / 10
3099
- const last = reviewed[reviewed.length - 1]!
3100
- return (
3101
- <span className={css.meta}>
3102
- 钩子兑现 {honored}/{reviewed.length} · 结尾钩子均分 {avg} · 最近:第 {last.no} 章(钩子{last.authorReview!.hookHonored ? '✓' : '✗'} {last.authorReview!.endingHook}/10)
3103
- </span>
3104
- )
3105
- })()}
3106
- </div>
3107
- </div>
3108
-
3109
- {/* 全书质检结果 */}
3110
- {auditIssues !== null && (
3111
- <div className={css.card} style={{ borderColor: auditIssues.length > 0 ? 'var(--nf-error)' : 'var(--nf-success)' }}>
3112
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
3113
- <span className={css.cardTitle}>
3114
- 🔍 全书质检{auditIssues.length === 0 ? ':未发现矛盾 🎉' : `:${auditIssues.length} 处疑似矛盾`}
3115
- </span>
3116
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setAuditIssues(null) }}>
3117
- 收起
3118
- </button>
3119
- </div>
3120
- {auditIssues.length > 0 && (
3121
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
3122
- {auditIssues.map((issue, i) => (
3123
- <AuditIssueRow
3124
- key={i}
3125
- issue={issue}
3126
- disabled={busy}
3127
- onFix={() => { void openWorkspace(issue.chapterNo, `按质检意见修订:${issue.item}(建议:${issue.suggestion})`) }}
3128
- />
3129
- ))}
3130
- </div>
3131
- )}
3132
- </div>
3133
- )}
3134
-
3135
- {/* 待办队列(活动输出已移入「工具 → AI进度」悬浮窗) */}
3136
- <div className={css.card}>
3137
- <span className={css.cardTitle}>待办队列</span>
3138
- {todos.length === 0 ? (
3139
- <span className={css.meta}>🎉 暂无待办,一切顺畅</span>
3140
- ) : (
3141
- todos.map((todo, i) => (
3142
- <TodoRow
3143
- key={i}
3144
- tone={todo.tone}
3145
- title={todo.title}
3146
- description={todo.description}
3147
- actionLabel={todo.actionLabel}
3148
- disabled={busy}
3149
- onAction={todo.onClick}
3150
- />
3151
- ))
3152
- )}
3153
- </div>
3154
- </>
3155
- )}
3156
-
3157
- {activeTab === 'overview' && (
3158
- <>
3159
- <div className={css.card}>
3160
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
3161
- <span className={css.cardTitle}>{tt('tab.overview')}</span>
3162
- {project !== null && (
3163
- <div className={css.row}>
3164
- <span className={css.meta}>
3165
- {tt('overview.bookName')}: {project.bookName} · {project.outline.length} 字
3166
- {project.outlinePath !== undefined && <span> · {project.outlinePath}</span>}
3167
- </span>
3168
- <button
3169
- type="button"
3170
- className={`${css.button} ${css.buttonPrimary}`}
3171
- disabled={busy || !(project.chapters ?? []).some(c => c.status === 'written' || c.status === 'approved' || c.status === 'rejected')}
3172
- onClick={() => { void handleReverseOutline() }}
3173
- title="从已写章节正文反向生成全书总纲(覆盖当前大纲文本)"
3174
- >
3175
- 🔄 反推大纲
3176
- </button>
3177
- <button type="button" className={css.button} disabled={busy} onClick={() => { handleToggleUpdateOutline() }}>
3178
- {updatingOutline ? '收起' : '更新大纲'}
3179
- </button>
3180
- </div>
3181
- )}
3182
- </div>
3183
- {project === null ? (
3184
- <>
3185
- {/* 未开书:导入大纲(开书动作) */}
3186
- <div
3187
- className={`${css.dropzone} ${dragActive ? css.dropzoneActive : ''}`}
3188
- onClick={() => { fileInputRef.current?.click() }}
3189
- onDragOver={e => { e.preventDefault(); setDragActive(true) }}
3190
- onDragLeave={() => { setDragActive(false) }}
3191
- onDrop={e => {
3192
- e.preventDefault()
3193
- setDragActive(false)
3194
- const file = e.dataTransfer.files?.[0]
3195
- if (file !== undefined) void handleDocxFile(file)
3196
- }}
3197
- >
3198
- <span className={css.dropzoneIcon}>📄</span>
3199
- <span>点击选择本机 docx 大纲,或将文件拖到这里</span>
3200
- <span className={css.meta}>也支持粘贴文本到下方编辑区</span>
3201
- <input
3202
- ref={fileInputRef}
3203
- type="file"
3204
- accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
3205
- style={{ display: 'none' }}
3206
- onChange={e => {
3207
- const file = e.target.files?.[0]
3208
- if (file !== undefined) void handleDocxFile(file)
3209
- e.target.value = ''
3210
- }}
3211
- />
3212
- </div>
3213
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
3214
- <span className={css.meta}>{tt('overview.outlineChars')}: {outlineText.length}</span>
3215
- <button type="button" className={css.button} disabled={busy || outlineText.length < 50} onClick={() => { void handleSaveOutline() }}>
3216
- {tt('overview.saveOutline')}
3217
- </button>
3218
- </div>
3219
- <textarea
3220
- className={css.textarea}
3221
- value={outlineText}
3222
- placeholder={tt('overview.outlineHint')}
3223
- onChange={e => { setOutlineText(e.target.value) }}
3224
- spellCheck={false}
3225
- />
3226
- </>
3227
- ) : updatingOutline ? (
3228
- <>
3229
- <div className={css.meta}>
3230
- 大纲是本书的「出生证明」。更新时请二选一:<b>仅更新文本</b>(保留设定/章节/正文全部进度),或
3231
- <b>重置项目</b>(从新总纲重新开始,清空道藏/卷/章节/正文/暗线/资产/编年录,不可恢复)。
3232
- </div>
3233
- <textarea
3234
- className={css.textarea}
3235
- value={outlineText}
3236
- placeholder="粘贴新版大纲文本…"
3237
- onChange={e => { setOutlineText(e.target.value) }}
3238
- spellCheck={false}
3239
- />
3240
- <div className={css.row}>
3241
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || outlineText.length < 50} onClick={() => { void handleSaveOutline() }}>
3242
- 仅更新文本(保留进度)
3243
- </button>
3244
- <button type="button" className={`${css.button} ${css.buttonDanger}`} disabled={busy || outlineText.length < 50} onClick={() => { void handleResetProject() }}>
3245
- 重置项目并更新(清空进度)
3246
- </button>
3247
- <span className={css.meta}>{outlineText.length} 字</span>
3248
- </div>
3249
- </>
3250
- ) : (
3251
- /* 只读展示:大纲是开书时的出生证明 */
3252
- <>
3253
- <div className={css.meta} style={{ marginBottom: 'var(--nf-space-6)' }}>
3254
- 大纲是本书的「出生证明」;已写章节可点右上角「🔄 反推大纲」从正文反推,或「更新大纲」手动修订。
3255
- </div>
3256
- <pre className={css.outlineReadonly}>{project.outline}</pre>
3257
- </>
3258
- )}
3259
- </div>
3260
- <div className={css.card}>
3261
- <span className={css.cardTitle}>{tt('status.files')}({generatedFiles.length})</span>
3262
- <div className={css.fileList}>
3263
- {generatedFiles.length === 0 && <span>{tt('status.projectNone')}</span>}
3264
- {generatedFiles.map(file => <span key={file}>{file}</span>)}
3265
- </div>
3266
- </div>
3267
- </>
3268
- )}
3269
-
3270
- {/* 卷首语:面向读者的作品门面 */}
3271
- {activeTab === 'blurb' && (
3272
- <div className={css.card}>
3273
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
3274
- <span className={css.cardTitle}>📖 简介 / 封面</span>
3275
- <div className={css.row}>
3276
- <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || project === null} onClick={() => { void handleBlurbGenerate() }}>
3277
- ✨ AI 生成
3278
- </button>
3279
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || project === null || blurbDraft.trim() === ''} onClick={() => { void handleBlurbComplete() }}>
3280
- ✍️ AI 补全
3281
- </button>
3282
- {project?.blurb !== undefined && (
3283
- <button
3284
- type="button"
3285
- className={`${css.button} ${css.buttonSmall}`}
3286
- disabled={busy || project === null}
3287
- onClick={() => {
3288
- if (window.confirm('重新生成会覆盖当前简介(可先复制保存),确定?')) void handleBlurbGenerate()
3289
- }}
3290
- >
3291
- 🔄 重新生成
3292
- </button>
3293
- )}
3294
- </div>
3295
- </div>
3296
- {/* 书名(可改名,同步书架) */}
3297
- <div className={css.row}>
3298
- <input
3299
- className={css.input}
3300
- style={{ flex: 1, maxWidth: 320 }}
3301
- placeholder="书名"
3302
- value={bookNameDraft}
3303
- onChange={e => { setBookNameDraft(e.target.value) }}
3304
- onKeyDown={e => { if (e.key === 'Enter') void handleRename() }}
3305
- />
3306
- <button
3307
- type="button"
3308
- className={`${css.button} ${css.buttonSmall}`}
3309
- disabled={busy || bookNameDraft.trim() === '' || bookNameDraft.trim() === project?.bookName}
3310
- onClick={() => { void handleRename() }}
3311
- >
3312
- 💾 改书名
3313
- </button>
3314
- </div>
3315
- {/* 封面 */}
3316
- <div className={css.row} style={{ alignItems: 'flex-start', gap: 'var(--nf-space-14)' }}>
3317
- <div className={css.coverPreview}>
3318
- {coverDataUrl !== null ? (
3319
- <img src={coverDataUrl} alt="封面" />
3320
- ) : (
3321
- <span className={css.coverPlaceholder}>暂无封面</span>
3322
- )}
3323
- </div>
3324
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)' }}>
3325
- <div className={css.row}>
3326
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || project === null} onClick={() => { coverFileRef.current?.click() }}>
3327
- 📤 上传封面
3328
- </button>
3329
- <input
3330
- ref={coverFileRef}
3331
- type="file"
3332
- accept="image/png,image/jpeg,image/webp"
3333
- style={{ display: 'none' }}
3334
- onChange={e => {
3335
- handleCoverUpload(e.target.files?.[0])
3336
- e.target.value = ''
3337
- }}
3338
- />
3339
- {coverDataUrl !== null && (
3340
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleCoverRemove() }}>
3341
- 🗑️ 移除
3342
- </button>
3343
- )}
3344
- </div>
3345
- <span className={css.meta}>支持 PNG / JPG / WebP,建议 3:4 竖版;保存于输出目录 cover.*。</span>
3346
- </div>
3347
- </div>
3348
- <span className={css.meta}>
3349
- 面向读者的作品门面(120-250 字):突出核心卖点与开局钩子,不剧透。点击 ✨AI 生成全量生成;或先写几句再点 ✍️AI 补全续写完整;不满意可 🔄 重新生成。
3350
- </span>
3351
- {project === null ? (
3352
- <span className={css.meta}>请先在大纲页导入大纲建立项目。</span>
3353
- ) : (
3354
- <>
3355
- <textarea
3356
- className={css.textarea}
3357
- style={{ minHeight: 140 }}
3358
- placeholder="点击 ✨AI 生成,或先写下开头几句,再点 ✍️AI 补全…"
3359
- value={blurbDraft}
3360
- onChange={e => { setBlurbDraft(e.target.value) }}
3361
- spellCheck={false}
3362
- />
3363
- <div className={css.row}>
3364
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || blurbDraft.trim() === ''} onClick={() => { void handleBlurbSave() }}>
3365
- 💾 保存简介
3366
- </button>
3367
- <span className={css.meta}>
3368
- {blurbDraft.length} 字 · 已保存:{project.blurb !== undefined ? `${project.blurb.length} 字` : '无'}
3369
- </span>
3370
- </div>
3371
- </>
3372
- )}
3373
- </div>
3374
- )}
3375
-
3376
- {activeTab === 'plan' && (
3377
- <>
3378
- <div className={css.card}>
3379
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3380
- <span className={css.cardTitle}>{tt('tab.plan')}</span>
3381
- <div className={css.row} style={{ flexWrap: 'wrap' }}>
3382
- <span className={css.meta}>{tt('plan.generateHint')}</span>
3383
- <input
3384
- className={css.input}
3385
- style={{ width: 72 }}
3386
- type="number"
3387
- min={1}
3388
- max={200}
3389
- value={planCount}
3390
- onChange={e => { const v = Number(e.target.value); if (Number.isInteger(v)) setPlanCount(v) }}
3391
- />
3392
- <span className={css.meta}>{tt('plan.count')}</span>
3393
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || outlineText.length < 50} onClick={() => { void handlePlan() }}>
3394
- {tt('plan.generate')}
3395
- </button>
3396
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || doneCount === 0} onClick={() => { void handleSensitiveScan() }} title={tt('sensitive.hint')}>
3397
- 🔞 {tt('sensitive.scanAll')}
3398
- </button>
3399
- </div>
3400
- </div>
3401
- </div>
3402
-
3403
- {sensHits !== null && (
3404
- <div className={css.card} style={{ borderColor: sensHits.length > 0 ? 'var(--nf-warn)' : 'var(--nf-success)' }}>
3405
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3406
- <span className={css.cardTitle}>
3407
- {sensHits.length === 0
3408
- ? tt('sensitive.clean', { n: sensScanned })
3409
- : tt('sensitive.hits', { n: sensHits.length, chapters: new Set(sensHits.map(h => h.chapterNo)).size })}
3410
- </span>
3411
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSensHits(null) }}>收起</button>
3412
- </div>
3413
- <span className={css.meta}>{tt('sensitive.hint')}</span>
3414
- {sensHits.length > 0 && (
3415
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', maxHeight: 300, overflowY: 'auto', fontSize: 'var(--nf-fs-12)' }}>
3416
- {sensHits.map((hit, i) => (
3417
- <div key={i} style={{ display: 'flex', gap: 'var(--nf-space-8)', alignItems: 'flex-start', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-6)', padding: 'var(--nf-space-4) var(--nf-space-8)' }}>
3418
- <span className={css.badge} style={{ borderColor: 'var(--nf-warn)', color: 'var(--nf-warn)', flex: 'none' }}>
3419
- {hit.chapterNo > 0 ? `第${hit.chapterNo}章` : '文本'}
3420
- </span>
3421
- <span className={css.meta} style={{ flex: 1 }}>
3422
- <b style={{ color: 'var(--nf-error)' }}>{hit.word}</b> ×{hit.count} · [{hit.category}]
3423
- </span>
3424
- {hit.chapterNo > 0 && (
3425
- <button
3426
- type="button"
3427
- className={`${css.button} ${css.buttonSmall}`}
3428
- disabled={busy}
3429
- onClick={() => { void openWorkspace(hit.chapterNo, tt('sensitive.fixPrefill', { word: hit.word, category: hit.category })) }}
3430
- >
3431
- {tt('sensitive.goFix')}
3432
- </button>
3433
- )}
3434
- </div>
3435
- ))}
3436
- </div>
3437
- )}
3438
- </div>
3439
- )}
3440
-
3441
- {chapters.length > 0 && (
3442
- <div className={css.card}>
3443
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3444
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
3445
- {(volumes !== undefined && volumes.length > 0) && (
3446
- <>
3447
- <button
3448
- type="button"
3449
- className={`${css.button} ${css.buttonSmall} ${selectedVolume === 'all' ? css.buttonPrimary : ''}`}
3450
- onClick={() => { setSelectedVolume('all') }}
3451
- >
3452
- 全部卷
3453
- </button>
3454
- {volumes.map(v => (
3455
- <button
3456
- key={v.no}
3457
- type="button"
3458
- className={`${css.button} ${css.buttonSmall} ${selectedVolume === v.no ? css.buttonPrimary : ''}`}
3459
- onClick={() => { setSelectedVolume(v.no) }}
3460
- title={`第${v.no}卷 · ${v.chapterStart}-${v.chapterEnd} 章`}
3461
- >
3462
- {v.no}. {v.title}({v.chapterStart}-{v.chapterEnd})
3463
- </button>
3464
- ))}
3465
- </>
3466
- )}
3467
- </div>
3468
- {pendingCount > 0 && (
3469
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleWriteAll() }}>
3470
- {tt('plan.writeAllPending')}({pendingCount})
3471
- </button>
3472
- )}
3473
- </div>
3474
- <div className={css.chapterList}>
3475
- {chapterGroups.filter(g => selectedVolume === 'all' || g.no === selectedVolume).map(group => {
3476
- const collapsed = group.no !== 0 && collapsedVolumes.includes(group.no)
3477
- const groupDone = group.chapters.filter(c => c.status === 'approved' || c.status === 'written' || c.status === 'rejected').length
3478
- return (
3479
- <div key={group.no} className={css.volumeGroup}>
3480
- <div
3481
- className={css.volumeGroupHeader}
3482
- onClick={() => {
3483
- if (group.no !== 0) {
3484
- setCollapsedVolumes(prev => prev.includes(group.no) ? prev.filter(x => x !== group.no) : [...prev, group.no])
3485
- }
3486
- }}
3487
- >
3488
- <span className={css.volumeGroupToggle}>{group.no !== 0 ? (collapsed ? '▸' : '▾') : '📖'}</span>
3489
- <b>{group.title}</b>
3490
- <span className={css.meta}>({group.chapters.length} 章 · 已完成 {groupDone})</span>
3491
- </div>
3492
- {!collapsed && group.chapters.map(chapter => {
3493
- const badge = statusBadge(chapter)
3494
- const expanded = expandedChapter === chapter.no
3495
- const review: ReviewReport | undefined = chapter.review
3496
- return (
3497
- <div key={chapter.no} className={css.chapter} data-chapter-no={chapter.no}>
3498
- <span className={css.chapterNum}>{chapter.no}</span>
3499
- <div className={css.chapterMain}>
3500
- <div className={css.chapterTitle}>
3501
- <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ padding: 'var(--nf-space-2) var(--nf-space-6)' }} onClick={() => { void handleToggleChapter(chapter.no) }}>
3502
- {expanded ? '−' : '+'}
3503
- </button>
3504
- <span>{chapter.title}</span>
3505
- {chapter.status === 'approved' && chapter.chars !== undefined && (
3506
- <span className={css.meta}>{chapter.chars}{tt('common.chars')}</span>
3507
- )}
3508
- {chapter.volume > 0 && <span className={css.meta}>{tt('plan.volumes')}{chapter.volume}</span>}
3509
- </div>
3510
- {!expanded && <div className={css.chapterBeats} title={chapter.beats}>{chapter.beats}</div>}
3511
- {expanded && (
3512
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
3513
- <div className={css.meta}><b>{tt('plan.beats')}:</b></div>
3514
- <div className={css.meta}>{renderBeats(chapter.beats)}</div>
3515
- {chapter.summary !== undefined && chapter.summary !== '' && (
3516
- <div className={css.meta}><b>{tt('plan.summary')}:</b> {chapter.summary}</div>
3517
- )}
3518
- <pre className={css.chapterPreview}>{chapterText || `(${tt('common.loading')})`}</pre>
3519
- {review !== undefined && (
3520
- <div className={css.reviewBox}>
3521
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
3522
- <b>{tt('plan.reviewReport')}</b>
3523
- <span style={{ color: review.passed ? 'var(--nf-success)' : 'var(--nf-error)' }}>
3524
- {tt('plan.reviewScore')}: {review.score} — {review.passed ? tt('plan.reviewPass') : tt('plan.reviewFail')}
3525
- </span>
3526
- </div>
3527
- <div className={css.meta}><b>{tt('plan.reviewVerdict')}:</b> {review.verdict}</div>
3528
- {review.riskScore !== undefined && (
3529
- <div className={css.meta}><b>风险分:</b> {review.riskScore}/100</div>
3530
- )}
3531
- {review.aiFlavor !== undefined && (
3532
- <div className={css.meta}><b>AI 味指数:</b> {review.aiFlavor}/100{(review.aiPhrases?.length ?? 0) > 0 && ` · 套话:` + review.aiPhrases.map(p => `${p.word}×${p.count}`).join('、')}</div>
3533
- )}
3534
- {review.issues.length > 0 && (
3535
- <ul style={{ margin: 0, paddingLeft: 'var(--nf-space-18)', fontSize: 'var(--nf-fs-12)' }}>
3536
- {review.issues.map((issue, i) => (
3537
- <li key={i} style={{ color: severityColor(issue.severity) }}>
3538
- [{issue.severity}{issue.dimension !== undefined ? ` · ${REVIEW_DIM_ZH[issue.dimension] ?? issue.dimension}` : ''}] {issue.item} → {issue.suggestion}
3539
- </li>
3540
- ))}
3541
- </ul>
3542
- )}
3543
- </div>
3544
- )}
3545
- {chapter.authorReview === undefined && chapter.status !== 'pending' && chapter.status !== 'generating' && chapter.status !== 'error' && (
3546
- <div className={css.row}>
3547
- <button
3548
- type="button"
3549
- className={`${css.button} ${css.buttonSmall}`}
3550
- disabled={busy}
3551
- onClick={() => { void handleAuthorBackfillChapter(chapter.no) }}
3552
- title="对该章补跑一次作者复盘(读取已落盘正文,不重新生成)"
3553
- >
3554
- 📋 生成作者复盘
3555
- </button>
3556
- </div>
3557
- )}
3558
- {chapter.authorReview !== undefined && (
3559
- <div className={css.reviewBox} style={{ borderColor: 'color-mix(in srgb, var(--nf-info) 45%, transparent)' }}>
3560
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
3561
- <b>📋 作者复盘</b>
3562
- <span style={{ color: chapter.authorReview.hookHonored ? 'var(--nf-success)' : 'var(--nf-warn)' }}>
3563
- 钩子{chapter.authorReview.hookHonored ? '已兑现 ✓' : '未兑现 ✗'} · 结尾钩子 {chapter.authorReview.endingHook}/10
3564
- </span>
3565
- </div>
3566
- {chapter.authorReview.hookNote !== '' && (
3567
- <div className={css.meta}><b>钩子:</b>{chapter.authorReview.hookNote}</div>
3568
- )}
3569
- {chapter.authorReview.plotlineProgress !== '' && (
3570
- <div className={css.meta}><b>推进:</b>{chapter.authorReview.plotlineProgress}</div>
3571
- )}
3572
- {chapter.authorReview.continuity !== '' && (
3573
- <div className={css.meta}><b>衔接:</b>{chapter.authorReview.continuity}</div>
3574
- )}
3575
- {chapter.authorReview.trend !== '' && (
3576
- <div className={css.meta}><b>趋势:</b>{chapter.authorReview.trend}</div>
3577
- )}
3578
- </div>
3579
- )}
3580
- {(chapter.status === 'rejected' || chapter.status === 'written' || chapter.status === 'approved') && (
3581
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
3582
- <div className={css.meta} style={{ fontWeight: 600 }}>
3583
- 润色 / 修订 — 在右上角打开工作区:左栏原文可直接选中文字做局部修订,右栏输入指令后预览,确认后再应用(未应用不改动原稿)
3584
- </div>
3585
- <div className={css.row}>
3586
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || busyAny} onClick={() => { void openWorkspace(chapter.no) }}>
3587
- {tt('plan.rewrite')} / {tt('plan.polish')}
3588
- </button>
3589
- </div>
3590
- </div>
3591
- )}
3592
- </div>
3593
- )}
3594
- </div>
3595
- <span className={`${css.badge} ${badge.cls}`}>{badge.label}</span>
3596
- <div className={css.chapterActions}>
3597
- {(chapter.status === 'pending' || chapter.status === 'error') && (
3598
- <button
3599
- type="button"
3600
- className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
3601
- disabled={busy || busyAny}
3602
- onClick={() => { void handleWriteChapter(chapter.no, true) }}
3603
- >
3604
- {tt('plan.write')}
3605
- </button>
3606
- )}
3607
- {chapter.status === 'generating' && (
3608
- <button
3609
- type="button"
3610
- className={`${css.button} ${css.buttonSmall}`}
3611
- disabled={busy}
3612
- onClick={() => { void handleChapterReset(chapter.no) }}
3613
- title="生成卡死/中断时可复位为待生成,重新生成"
3614
- >
3615
- 🔄 复位
3616
- </button>
3617
- )}
3618
- {(chapter.status === 'written' || chapter.status === 'rejected') && (
3619
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || busyAny} onClick={() => { void handleReview(chapter.no) }}>
3620
- {tt('plan.review')}
3621
- </button>
3622
- )}
3623
- {(chapter.status === 'written' || chapter.status === 'rejected') && (
3624
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || busyAny} onClick={() => { void handleChapterApprove(chapter.no) }} title="作者行使最终决定权:直接通过(不重审,保留审稿记录,落盘保存)">
3625
- ✔ 直接通过
3626
- </button>
3627
- )}
3628
- {(chapter.status === 'written' || chapter.status === 'rejected' || chapter.status === 'approved') && (
3629
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || busyAny} onClick={() => { void openWorkspace(chapter.no) }} title="手动编辑正文 → AI 审查 → 保存">
3630
- ✏️ 编辑
3631
- </button>
3632
- )}
3633
- {(chapter.status === 'written' || chapter.status === 'rejected' || chapter.status === 'approved') && (
3634
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || busyAny} onClick={() => { void openWorkspace(chapter.no) }}>
3635
- {tt('plan.polish')}
3636
- </button>
3637
- )}
3638
- {chapter.status === 'rejected' && (
3639
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || busyAny} onClick={() => { void handleReviseNow(chapter.no) }} title="一键按该章审稿意见全部修订(high 优先,无需进工作区选择);修订完自动打开工作区看草稿与审查">
3640
- 按意见修订
3641
- </button>
3642
- )}
3643
- {chapter.status === 'rejected' && (
3644
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || busyAny} onClick={() => { void handleWriteChapter(chapter.no, true) }} title="整章重新生成">
3645
- 重新生成
3646
- </button>
3647
- )}
3648
- </div>
3649
- </div>
3650
- )
3651
- })}
3652
- </div>
3653
- )
3654
- })}
3655
- </div>
3656
- </div>
3657
- )}
3658
- </>
3659
- )}
3660
-
3661
- {activeTab === 'book' && (
3662
- <div className={css.card} style={{ gap: 'var(--nf-space-12)' }}>
3663
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3664
- <span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-16)', fontWeight: 700 }}>📚 本书设定</span>
3665
- <span className={css.meta}>结构化设定/道藏(世界观·角色·境界·红线)。零散补充资料请用「工具 → 知识库」。</span>
3666
- </div>
3667
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
3668
- <button
3669
- type="button"
3670
- className={`${css.button} ${bookTab === 'bible' ? css.buttonPrimary : ''}`}
3671
- style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
3672
- onClick={() => { changeBookTab('bible') }}
3673
- title="设定库:题材 / 世界观规则 / 人物摘要 / 红线 / 文风"
3674
- >
3675
- 📖 设定库
3676
- </button>
3677
- <button
3678
- type="button"
3679
- className={`${css.button} ${bookTab === 'world' ? css.buttonPrimary : ''}`}
3680
- style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
3681
- onClick={() => { changeBookTab('world') }}
3682
- title="大世界:境界体系 / 地理区域 / 势力分布(注入生成与审稿提示词)"
3683
- >
3684
- 🌍 大世界
3685
- </button>
3686
- <button
3687
- type="button"
3688
- className={`${css.button} ${bookTab === 'roles' ? css.buttonPrimary : ''}`}
3689
- style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
3690
- onClick={() => { changeBookTab('roles') }}
3691
- title="角色库:全书角色主表(定位 / 关系网 / 成长线 / 知情度)"
3692
- >
3693
- 👥 角色库({project?.roles?.length ?? 0})
3694
- </button>
3695
- <button
3696
- type="button"
3697
- className={`${css.button} ${bookTab === 'facts' ? css.buttonPrimary : ''}`}
3698
- style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
3699
- onClick={() => { changeBookTab('facts') }}
3700
- title="编年录与复盘记录"
3701
- >
3702
- 📚 编年 / 复盘
3703
- </button>
3704
- </div>
3705
- </div>
3706
- )}
3707
- {activeTab === 'book' && bookTab === 'bible' && (
3708
- <>
3709
- <div className={css.card}>
3710
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
3711
- <span className={css.cardTitle}>{tt('bible.title')}</span>
3712
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleBible() }}>
3713
- {tt('bible.gen')}
3714
- </button>
3715
- </div>
3716
- {bible === undefined ? (
3717
- <span className={css.meta}>{tt('bible.none')}</span>
3718
- ) : (
3719
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)' }}>
3720
- {bible.genre !== '' && (
3721
- <div><b>{tt('bible.genre')}:</b> <span className={css.meta}>{bible.genre}</span></div>
3722
- )}
3723
- {/* 世界观规则独立卡片(可编辑,每行一条) */}
3724
- {bible.worldRules.length > 0 && (
3725
- <div style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-10)' }}>
3726
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
3727
- <b>{tt('bible.worldRules')}({bible.worldRules.length})</b>
3728
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setWorldRulesDraft(bible.worldRules.join('\n')) }}>
3729
- 编辑
3730
- </button>
3731
- </div>
3732
- {worldRulesDraft !== '' && (
3733
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', marginTop: 'var(--nf-space-6)' }}>
3734
- <textarea
3735
- className={css.textarea}
3736
- style={{ minHeight: 120 }}
3737
- value={worldRulesDraft}
3738
- onChange={e => { setWorldRulesDraft(e.target.value) }}
3739
- placeholder="每条规则一行…"
3740
- />
3741
- <div className={css.row}>
3742
- <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSaveWorldRules() }}>
3743
- 保存规则
3744
- </button>
3745
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setWorldRulesDraft('') }}>
3746
- 取消
3747
- </button>
3748
- </div>
3749
- </div>
3750
- )}
3751
- {worldRulesDraft === '' && (
3752
- <ul style={{ margin: '4px 0 0', paddingLeft: 'var(--nf-space-18)', fontSize: 'var(--nf-fs-12)' }}>{bible.worldRules.map((r, i) => <li key={i}>{r}</li>)}</ul>
3753
- )}
3754
- </div>
3755
- )}
3756
- {bible.characters.length > 0 && (
3757
- <div>
3758
- <b>{tt('bible.characters')}({bible.characters.length})</b>
3759
- {bible.characters.map(card => (
3760
- <div key={card.name} style={{ marginTop: 'var(--nf-space-4)', fontSize: 'var(--nf-fs-12)' }}>
3761
- <b>{card.name}</b> <span className={css.meta}>[{card.role}] {card.traits.join('、')}</span>
3762
- {card.goals !== '' && <div className={css.meta}>目标:{card.goals}</div>}
3763
- {card.relations !== '' && <div className={css.meta}>关系:{card.relations}</div>}
3764
- </div>
3765
- ))}
3766
- </div>
3767
- )}
3768
- {bible.redLines.length > 0 && (
3769
- <div>
3770
- <b>{tt('bible.redLines')}({bible.redLines.length})</b>
3771
- <ul style={{ margin: 0, paddingLeft: 'var(--nf-space-18)', fontSize: 'var(--nf-fs-12)', color: 'var(--nf-error)' }}>{bible.redLines.map((r, i) => <li key={i}>{r}</li>)}</ul>
3772
- </div>
3773
- )}
3774
- {bible.style.length > 0 && (
3775
- <div>
3776
- <b>{tt('bible.style')}({bible.style.length})</b>
3777
- <ul style={{ margin: 0, paddingLeft: 'var(--nf-space-18)', fontSize: 'var(--nf-fs-12)' }}>{bible.style.map((r, i) => <li key={i}>{r}</li>)}</ul>
3778
- </div>
3779
- )}
3780
- </div>
3781
- )}
3782
- </div>
3783
- </>
3784
- )}
3785
-
3786
- {/* 大世界:境界体系 / 区域 / 势力 */}
3787
- {activeTab === 'book' && bookTab === 'world' && (
3788
- <WorldTab
3789
- api={api}
3790
- world={project?.world}
3791
- onChanged={w => {
3792
- setProject(prev => prev === null ? prev : { ...prev, world: w, updatedAt: new Date().toISOString() })
3793
- pushProgress(`大世界已保存:${w.realms.length} 境界 · ${w.regions.length} 区域 · ${w.factions.length} 势力`, 'done')
3794
- }}
3795
- />
3796
- )}
3797
-
3798
- {/* 角色库:全书角色主表(独立导航页) */}
3799
- {activeTab === 'book' && bookTab === 'roles' && (
3800
- <div className={css.card}>
3801
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3802
- <span className={css.cardTitle}>👥 角色库({(project?.roles ?? []).length} 个)</span>
3803
- <div className={css.row}>
3804
- <button
3805
- type="button"
3806
- className={`${css.button} ${css.buttonSmall}`}
3807
- disabled={busy || (project?.facts ?? []).length === 0}
3808
- onClick={() => { void handleCharactersRefresh() }}
3809
- title="从编年录聚合各角色当前状态(境界/伤势/心境/出场统计),显示在每张卡上"
3810
- >
3811
- ↻ 从编年录刷新状态
3812
- </button>
3813
- <button
3814
- type="button"
3815
- className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
3816
- disabled={busy || doneCount === 0}
3817
- onClick={() => { void handleRolesExtract() }}
3818
- title="AI 扫描大纲/编年录/已写章节,提炼完整角色库(含女主/女配/反派定位)"
3819
- >
3820
- ✨ 从全书提炼角色
3821
- </button>
3822
- </div>
3823
- </div>
3824
- <span className={css.meta}>角色库是全书角色主表:定位(女主/女配/配角/反派)、身份、关系网、成长线、知情度——生成与审稿都会按定位规格刻画互动。点「从编年录刷新状态」可在每张卡上显示角色当前状态。</span>
3825
-
3826
- {roleCandidates !== null && (
3827
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', border: '1px solid var(--nf-info)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-10)' }}>
3828
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3829
- <b>✨ AI 提炼候选({roleCandidates.length})</b>
3830
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setRoleCandidates(null) }}>收起</button>
3831
- </div>
3832
- {roleCandidates.length === 0 && <span className={css.meta}>未提炼到角色。</span>}
3833
- {roleCandidates.map((r, i) => (
3834
- <RoleCandidateRow
3835
- key={i}
3836
- candidate={r}
3837
- disabled={busy}
3838
- onAdopt={() => { void handleRoleAdopt(r) }}
3839
- onEdit={() => { setRoleDraft(r) }}
3840
- />
3841
- ))}
3842
- </div>
3843
- )}
3844
-
3845
- {roleDraft !== null && (
3846
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', border: '1px solid var(--nf-accent)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-10)' }}>
3847
- <div className={css.row} style={{ flexWrap: 'wrap' }}>
3848
- <div className={css.field} style={{ flex: 1, minWidth: 140 }}>
3849
- <label className={css.fieldLabel}>角色名</label>
3850
- <input className={css.input} value={roleDraft.name} onChange={e => { setRoleDraft({ ...roleDraft, name: e.target.value }) }} />
3851
- </div>
3852
- <div className={css.field} style={{ flex: 1 }}>
3853
- <label className={css.fieldLabel}>定位</label>
3854
- <select className={css.input} value={roleDraft.roleLabel} onChange={e => { setRoleDraft({ ...roleDraft, roleLabel: e.target.value as RoleRecord['roleLabel'] }) }}>
3855
- <option value="protagonist">主角</option>
3856
- <option value="female_lead">女主</option>
3857
- <option value="female_support">女配</option>
3858
- <option value="support">配角</option>
3859
- <option value="antagonist">反派</option>
3860
- <option value="extra">路人</option>
3861
- </select>
3862
- </div>
3863
- <div className={css.field} style={{ flex: 2 }}>
3864
- <label className={css.fieldLabel}>身份</label>
3865
- <input className={css.input} value={roleDraft.identity} onChange={e => { setRoleDraft({ ...roleDraft, identity: e.target.value }) }} />
3866
- </div>
3867
- </div>
3868
- <div className={css.field}>
3869
- <label className={css.fieldLabel}>目标</label>
3870
- <textarea className={css.textarea} style={{ minHeight: 44 }} value={roleDraft.goals} onChange={e => { setRoleDraft({ ...roleDraft, goals: e.target.value }) }} />
3871
- </div>
3872
- <div className={css.field}>
3873
- <label className={css.fieldLabel}>关系网(每行一条:角色名(关系))</label>
3874
- <textarea className={css.textarea} style={{ minHeight: 44 }} value={roleDraft.relations.join('\n')} onChange={e => { setRoleDraft({ ...roleDraft, relations: e.target.value.split('\n').map(l => l.trim()).filter(l => l !== '') }) }} />
3875
- </div>
3876
- <div className={css.field}>
3877
- <label className={css.fieldLabel}>成长线(每行一条:阶段:说明)</label>
3878
- <textarea className={css.textarea} style={{ minHeight: 44 }} value={roleDraft.arc.join('\n')} onChange={e => { setRoleDraft({ ...roleDraft, arc: e.target.value.split('\n').map(l => l.trim()).filter(l => l !== '') }) }} />
3879
- </div>
3880
- <div className={css.field}>
3881
- <label className={css.fieldLabel}>知情度(每行一条该角色知道的信息)</label>
3882
- <textarea className={css.textarea} style={{ minHeight: 44 }} value={roleDraft.knowledge.join('\n')} onChange={e => { setRoleDraft({ ...roleDraft, knowledge: e.target.value.split('\n').map(l => l.trim()).filter(l => l !== '') }) }} />
3883
- </div>
3884
- <div className={css.row}>
3885
- <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleRoleSave() }}>
3886
- 保存
3887
- </button>
3888
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setRoleDraft(null) }}>取消</button>
3889
- </div>
3890
- </div>
3891
- )}
3892
-
3893
- {(project?.roles ?? []).length === 0 ? (
3894
- <span className={css.meta}>角色库为空——点「✨ 从全书提炼角色」自动建立,或手动新增。</span>
3895
- ) : (
3896
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
3897
- {(project?.roles ?? []).map(r => (
3898
- <RoleCard
3899
- key={r.name}
3900
- role={r}
3901
- status={(project?.roleStatus ?? []).find(s => s.name === r.name)}
3902
- disabled={busy}
3903
- onEdit={() => { setRoleDraft(r) }}
3904
- onRemove={() => { void handleRoleRemove(r.name) }}
3905
- />
3906
- ))}
3907
- </div>
3908
- )}
3909
- </div>
3910
- )}
3911
-
3912
- {/* 写作资产 5 个子分类(左侧导航直达) */}
3913
- {activeTab === 'assets' && <AssetsTab api={api} />}
3914
-
3915
- {activeTab === 'settings' && configDraft !== null && (
3916
- <>
3917
- {/* 设置页内子导航(大容器) */}
3918
- <div className={`${css.card} ${css.settingsCard}`}>
3919
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', alignItems: 'center', gap: 'var(--nf-space-8)' }}>
3920
- <span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-20)', fontWeight: 700 }}>⚙️ 设置</span>
3921
- <span className={css.meta}>当前:{config?.provider} / {config?.model} · {config?.outputDir}</span>
3922
- </div>
3923
- <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
3924
- {SETTINGS_SECTIONS.map(s => (
3925
- <button
3926
- key={s.id}
3927
- type="button"
3928
- className={`${css.button} ${settingsTab === s.id ? css.buttonPrimary : ''}`}
3929
- style={{ fontSize: 'var(--nf-fs-14)', padding: 'var(--nf-space-8) var(--nf-space-12)', flex: 1, minWidth: 104, justifyContent: 'center' }}
3930
- onClick={() => { changeSettingsTab(s.id) }}
3931
- >
3932
- {s.icon} {s.label}
3933
- </button>
3934
- ))}
3935
- </div>
3936
- </div>
3937
-
3938
- {settingsTab === 'model' && (
3939
- <>
3940
- <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
3941
- <span className={css.cardTitle}><Brain size={18} style={{ verticalAlign: -3 }} /> 模型与推理</span>
3942
- <ModelManager
3943
- api={api}
3944
- provider={configDraft.provider}
3945
- model={configDraft.model}
3946
- savedModels={configDraft.savedModels ?? []}
3947
- onProvider={v => { setConfigDraft({ ...configDraft, provider: v }) }}
3948
- onModel={v => { setConfigDraft({ ...configDraft, model: v }) }}
3949
- onSavedModels={models => setConfigDraft({ ...configDraft, savedModels: models })}
3950
- />
3951
- </div>
3952
- <ReasoningSection
3953
- reasoningEffort={configDraft.reasoningEffort ?? 'off'}
3954
- analysisReasoning={configDraft.analysisReasoning ?? 'low'}
3955
- onChange={patch => setConfigDraft({ ...configDraft, ...patch })}
3956
- />
3957
- <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
3958
- <span className={css.cardTitle}><Settings size={18} style={{ verticalAlign: -3 }} /> 任务级模型路由</span>
3959
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-24)' }}>
3960
- <div className={css.field}>
3961
- <label className={css.fieldLabel}>正文生成模型</label>
3962
- <input className={css.input} value={configDraft.generateModel ?? ''} placeholder={configDraft.model} onChange={e => setConfigDraft({ ...configDraft, generateModel: e.target.value })} />
3963
- <span className={css.meta}>留空则跟随全局模型(当前 {configDraft.model})</span>
3964
- </div>
3965
- <div className={css.field}>
3966
- <label className={css.fieldLabel}>审稿模型</label>
3967
- <input className={css.input} value={configDraft.reviewModel ?? ''} placeholder={configDraft.model} onChange={e => setConfigDraft({ ...configDraft, reviewModel: e.target.value })} />
3968
- <span className={css.meta}>留空则跟随全局模型</span>
3969
- </div>
3970
- <div className={css.field}>
3971
- <label className={css.fieldLabel}>AI 复核/质检模型</label>
3972
- <input className={css.input} value={configDraft.auditModel ?? ''} placeholder={configDraft.model} onChange={e => setConfigDraft({ ...configDraft, auditModel: e.target.value })} />
3973
- <span className={css.meta}>留空则跟随全局模型</span>
3974
- </div>
3975
- </div>
3976
- </div>
3977
- </>
3978
- )}
3979
-
3980
- {settingsTab === 'writing' && (
3981
- <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
3982
- <span className={css.cardTitle}><PenLine size={18} style={{ verticalAlign: -3 }} /> 写作与审稿</span>
3983
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-24)' }}>
3984
- <div className={css.field}>
3985
- <label className={css.fieldLabel}>{tt('settings.chapterChars')}</label>
3986
- <input className={css.input} type="number" min={1000} max={20000} value={configDraft.chapterChars} onChange={e => { setConfigDraft({ ...configDraft, chapterChars: Number(e.target.value) }) }} />
3987
- </div>
3988
- <div className={css.field}>
3989
- <label className={css.fieldLabel}>{tt('settings.maxTokens')}</label>
3990
- <input className={css.input} type="number" min={2000} max={64000} value={configDraft.maxTokens} onChange={e => { setConfigDraft({ ...configDraft, maxTokens: Number(e.target.value) }) }} />
3991
- </div>
3992
- <div className={css.field}>
3993
- <label className={css.fieldLabel}>{tt('settings.reviewPassScore')}</label>
3994
- <input className={css.input} type="number" min={0} max={100} value={configDraft.reviewPassScore} onChange={e => { setConfigDraft({ ...configDraft, reviewPassScore: Number(e.target.value) }) }} />
3995
- <span className={css.meta}>通过判定:综合评分 ≥ 此分数(默认 70),或无 high 级问题且评分 ≥ 60;建议设 60-80。</span>
3996
- </div>
3997
- <div className={css.field}>
3998
- <label className={css.fieldLabel}>{tt('settings.autoReview')}</label>
3999
- <select
4000
- className={css.input}
4001
- value={configDraft.autoReview ? '1' : '0'}
4002
- onChange={e => { setConfigDraft({ ...configDraft, autoReview: e.target.value === '1' }) }}
4003
- >
4004
- <option value="1">✓ 是</option>
4005
- <option value="0">✗ 否</option>
4006
- </select>
4007
- </div>
4008
- <div className={css.field}>
4009
- <label className={css.fieldLabel}>{tt('settings.autoAuthorReview')}</label>
4010
- <select
4011
- className={css.input}
4012
- value={configDraft.autoAuthorReview ? '1' : '0'}
4013
- onChange={e => { setConfigDraft({ ...configDraft, autoAuthorReview: e.target.value === '1' }) }}
4014
- >
4015
- <option value="1">✓ 是</option>
4016
- <option value="0">✗ 否</option>
4017
- </select>
4018
- <span className={css.meta}>{tt('settings.autoAuthorReviewHint')}</span>
4019
- </div>
4020
- <div className={css.field}>
4021
- <label className={css.fieldLabel}>{tt('settings.autoReviewAfterRevise')}</label>
4022
- <select
4023
- className={css.input}
4024
- value={configDraft.autoReviewAfterRevise ? '1' : '0'}
4025
- onChange={e => { setConfigDraft({ ...configDraft, autoReviewAfterRevise: e.target.value === '1' }) }}
4026
- >
4027
- <option value="1">✓ 是</option>
4028
- <option value="0">✗ 否</option>
4029
- </select>
4030
- <span className={css.meta}>{tt('settings.autoReviewAfterReviseHint')}</span>
4031
- </div>
4032
- <div className={css.field}>
4033
- <label className={css.fieldLabel}>编辑器字号(正文编辑 / 工作区)</label>
4034
- <select
4035
- className={css.input}
4036
- value={editorFontSize}
4037
- onChange={e => { changeEditorFontSize(Number(e.target.value)) }}
4038
- >
4039
- {[12, 13, 14, 15, 16, 18, 20, 22, 24].map(v => (
4040
- <option key={v} value={v}>{v}px</option>
4041
- ))}
4042
- </select>
4043
- <span className={css.meta}>更改的是章节正文编辑区与润色/修订工作区(原稿 / 草稿 / diff)的显示字号,不影响面板主题。</span>
4044
- </div>
4045
- </div>
4046
- </div>
4047
- )}
4048
-
4049
-
4050
- {settingsTab === 'files' && (
4051
- <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
4052
- <span className={css.cardTitle}><Folder size={18} style={{ verticalAlign: -3 }} /> 路径与文件</span>
4053
- <div className={css.field}>
4054
- <label className={css.fieldLabel}>{tt('settings.outlinePath')}</label>
4055
- <input className={css.input} value={configDraft.outlinePath} onChange={e => { setConfigDraft({ ...configDraft, outlinePath: e.target.value }) }} />
4056
- </div>
4057
- <div className={css.field}>
4058
- <label className={css.fieldLabel}>{tt('settings.outputDir')}</label>
4059
- <input className={css.input} value={configDraft.outputDir} onChange={e => { setConfigDraft({ ...configDraft, outputDir: e.target.value }) }} />
4060
- </div>
4061
- <div className={css.row}>
4062
- <button type="button" className={css.button} onClick={() => { void api.openFolder() }}>{tt('settings.openFolder')}</button>
4063
- </div>
4064
-
4065
- <div style={{ borderTop: '1px solid var(--nf-border)', margin: '14px 0 12px' }} />
4066
-
4067
- <span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-14)' }}>💾 保存与导出</span>
4068
- <div className={css.row}>
4069
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSaveConfig() }}>
4070
- {tt('settings.save')}
4071
- </button>
4072
- </div>
4073
- <div className={css.row}>
4074
- <button type="button" className={css.button} disabled={busy || chapters.length === 0} onClick={() => { void handleExport('txt') }}>
4075
- {tt('settings.exportTxt')}
4076
- </button>
4077
- <button type="button" className={css.button} disabled={busy || chapters.length === 0} onClick={() => { void handleExport('md') }}>
4078
- {tt('settings.exportMd')}
4079
- </button>
4080
- </div>
4081
- </div>
4082
- )}
4083
-
4084
- {settingsTab === 'appearance' && (
4085
- <div className={`${css.card} ${css.settingsCard}`}>
4086
- <span className={css.cardTitle}><Sparkles size={18} style={{ verticalAlign: -3 }} /> 外观与主题</span>
4087
- <span className={css.meta}>选择适合长时间创作的界面颜色和显示密度。主题只保存在当前设备,不影响小说内容和任务状态。</span>
4088
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-24)' }}>
4089
- <div className={css.field}>
4090
- <label className={css.fieldLabel} style={{ fontSize: 'var(--nf-fs-14)' }}>显示模式</label>
4091
- <select className={css.input} style={{ padding: 'var(--nf-space-10) var(--nf-space-12)', fontSize: 'var(--nf-fs-14)' }} value={themeMode} onChange={e => { changeThemeMode(e.target.value as 'system' | 'light' | 'dark') }}>
4092
- <option value="system">跟随系统</option>
4093
- <option value="light">浅色</option>
4094
- <option value="dark">深色</option>
4095
- </select>
4096
- <span className={css.meta}>强制浅色/深色只作用于小说工坊面板</span>
4097
- </div>
4098
- <div className={css.field}>
4099
- <label className={css.fieldLabel} style={{ fontSize: 'var(--nf-fs-14)' }}>主题风格</label>
4100
- <select className={css.input} style={{ padding: 'var(--nf-space-10) var(--nf-space-12)', fontSize: 'var(--nf-fs-14)' }} value={panelTheme} onChange={e => { changePanelTheme(e.target.value as 'liquid' | 'neumorph' | 'macos' | 'clay' | 'endfield') }}>
4101
- <option value="liquid">液态玻璃 · 清新绿(默认)</option>
4102
- <option value="neumorph">新拟物 · 柔和浅色</option>
4103
- <option value="macos">macOS · 玻璃(蓝,随外观浅/深)</option>
4104
- <option value="clay">粘土拟态 · 柔和黏土</option>
4105
- <option value="endfield">终末地 · 纸墨工业风</option>
4106
- </select>
4107
- <span className={css.meta}>{tt('settings.themeHint')}</span>
4108
- </div>
4109
- {panelTheme === 'endfield' && (
4110
- <div className={css.field}>
4111
- <label className={css.fieldLabel} style={{ fontSize: 'var(--nf-fs-14)' }}>终末地强调色</label>
4112
- <select className={css.input} style={{ padding: 'var(--nf-space-10) var(--nf-space-12)', fontSize: 'var(--nf-fs-14)' }} value={endfieldAccent} onChange={e => { changeEndfieldAccent(e.target.value as 'valley' | 'wuling') }}>
4113
- <option value="valley">谷地黄(默认)</option>
4114
- <option value="wuling">武陵青</option>
4115
- </select>
4116
- <span className={css.meta}>参考明日方舟:终末地官网,亮色自动使用深色档保证可读性。</span>
4117
- </div>
4118
- )}
4119
- <div className={css.field}>
4120
- <label className={css.fieldLabel} style={{ fontSize: 'var(--nf-fs-14)' }}>界面密度</label>
4121
- <select className={css.input} style={{ padding: 'var(--nf-space-10) var(--nf-space-12)', fontSize: 'var(--nf-fs-14)' }} value={themeDensity} onChange={e => { changeThemeDensity(e.target.value as 'comfort' | 'compact' | 'spacious') }}>
4122
- <option value="comfort">舒适(默认)</option>
4123
- <option value="compact">紧凑</option>
4124
- <option value="spacious">宽松</option>
4125
- </select>
4126
- <span className={css.meta}>卡片 / 导航 / 内容的间距密度</span>
4127
- </div>
4128
- </div>
4129
- <div className={css.row} style={{ justifyContent: 'flex-end' }}>
4130
- <button type="button" className={css.button} style={{ padding: 'var(--nf-space-10) var(--nf-space-18)', fontSize: 'var(--nf-fs-14)' }} onClick={resetTheme}>
4131
- <RotateCcw size={14} style={{ verticalAlign: -2 }} /> 恢复默认主题
4132
- </button>
4133
- </div>
4134
- </div>
4135
- )}
4136
- </>
4137
- )}
4138
- {activeTab === 'book' && bookTab === 'facts' && (
4139
- <div className={css.card}>
4140
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4141
- <span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-16)', fontWeight: 700 }}>📚 编年 / 复盘</span>
4142
- </div>
4143
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
4144
- <button
4145
- type="button"
4146
- className={`${css.button} ${archiveTab === 'facts' ? css.buttonPrimary : ''}`}
4147
- style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
4148
- onClick={() => { changeArchiveTab('facts') }}
4149
- title="编年录:客观事实流水(第 N 章 · 事件),生成/审稿查证用"
4150
- >
4151
- 📜 编年录({(project?.facts ?? []).length})
4152
- </button>
4153
- <button
4154
- type="button"
4155
- className={`${css.button} ${archiveTab === 'reviews' ? css.buttonPrimary : ''}`}
4156
- style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
4157
- onClick={() => { changeArchiveTab('reviews') }}
4158
- title="复盘记录:每章作者复盘(钩子 / 推进 / 衔接 / 趋势)"
4159
- >
4160
- 📋 复盘记录({chapters.filter(c => c.authorReview !== undefined).length} 章)
4161
- </button>
4162
- </div>
4163
-
4164
- {archiveTab === 'reviews' ? (
4165
- <>
4166
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4167
- <span className={css.cardTitle}>📋 复盘记录({chapters.filter(c => c.authorReview !== undefined).length} 章已复盘)</span>
4168
- <div className={css.row}>
4169
- {(() => {
4170
- const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error' && c.authorReview === undefined).length
4171
- return missing > 0 && (
4172
- <button
4173
- type="button"
4174
- className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
4175
- disabled={busy}
4176
- onClick={() => { void handleAuthorBackfillAll() }}
4177
- title="对历史已写章节逐章补跑作者复盘"
4178
- >
4179
- ↻ 补齐缺失复盘({missing})
4180
- </button>
4181
- )
4182
- })()}
4183
- </div>
4184
- </div>
4185
- {(() => {
4186
- const reviewed = chapters.filter(c => c.authorReview !== undefined)
4187
- if (reviewed.length === 0) {
4188
- return <span className={css.meta}>尚无作者复盘——生成/审稿后自动生成,或点「补齐缺失复盘」为已写章节补跑。</span>
4189
- }
4190
- const honored = reviewed.filter(c => c.authorReview!.hookHonored).length
4191
- const avg = Math.round(reviewed.reduce((s, c) => s + c.authorReview!.endingHook, 0) / reviewed.length * 10) / 10
4192
- return (
4193
- <span className={css.meta} style={{ fontWeight: 600 }}>
4194
- 钩子兑现 {honored}/{reviewed.length} · 结尾钩子均分 {avg}/10
4195
- </span>
4196
- )
4197
- })()}
4198
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)', overflowY: 'auto', flex: 1, minHeight: 0 }}>
4199
- {(() => {
4200
- const groupsWithReviews = chapterGroups.filter(group => group.chapters.some(c => c.authorReview !== undefined))
4201
- if (groupsWithReviews.length === 0) return null
4202
- return (
4203
- <div className={css.row} style={{ justifyContent: 'flex-end', gap: 'var(--nf-space-6)' }}>
4204
- <button
4205
- type="button"
4206
- className={`${css.button} ${css.buttonSmall}`}
4207
- onClick={() => { setExpandedVolumes(Object.fromEntries(groupsWithReviews.map(g => [g.no, true]))) }}
4208
- >
4209
- 全部展开
4210
- </button>
4211
- <button
4212
- type="button"
4213
- className={`${css.button} ${css.buttonSmall}`}
4214
- onClick={() => { setExpandedVolumes({}) }}
4215
- >
4216
- 全部折叠
4217
- </button>
4218
- </div>
4219
- )
4220
- })()}
4221
- {chapterGroups.map(group => {
4222
- const groupReviewed = group.chapters.filter(c => c.authorReview !== undefined)
4223
- if (groupReviewed.length === 0) return null
4224
- const volumeExpanded = expandedVolumes[group.no] === true
4225
- return (
4226
- <div key={group.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', overflow: 'hidden' }}>
4227
- <button
4228
- type="button"
4229
- style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: 'var(--nf-space-8) var(--nf-space-12)', fontSize: 'var(--nf-fs-14)', display: 'flex', alignItems: 'center', gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}
4230
- onClick={() => { setExpandedVolumes(prev => ({ ...prev, [group.no]: !volumeExpanded })) }}
4231
- >
4232
- <span style={{ color: 'var(--nf-text-3)' }}>{volumeExpanded ? '▾' : '▸'}</span>
4233
- <b>{group.title}</b>
4234
- <span className={css.meta}>已复盘 {groupReviewed.length}/{group.chapters.length} 章</span>
4235
- </button>
4236
- {volumeExpanded && (
4237
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', padding: 'var(--nf-space-4) var(--nf-space-8) var(--nf-space-8)', borderTop: '1px solid var(--nf-border)' }}>
4238
- {[...groupReviewed].reverse().map(c => {
4239
- const ar = c.authorReview!
4240
- const expanded = expandedReviewChapter === c.no
4241
- return (
4242
- <div key={c.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-8)', overflow: 'hidden' }}>
4243
- <button
4244
- type="button"
4245
- style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: 'var(--nf-space-6) var(--nf-space-10)', fontSize: 'var(--nf-fs-12)', display: 'flex', alignItems: 'center', gap: 'var(--nf-space-6)', flexWrap: 'wrap' }}
4246
- onClick={() => { setExpandedReviewChapter(expanded ? null : c.no) }}
4247
- >
4248
- <span style={{ color: 'var(--nf-text-3)' }}>{expanded ? '▾' : '▸'}</span>
4249
- <b>第{c.no}章《{c.title}》</b>
4250
- <span style={{ color: ar.hookHonored ? 'var(--nf-success)' : 'var(--nf-warn)' }}>钩子{ar.hookHonored ? '✓' : '✗'}</span>
4251
- <span style={{ color: ar.endingHook >= 6 ? 'var(--nf-success)' : 'var(--nf-error)' }}>结尾钩子 {ar.endingHook}/10</span>
4252
- {ar.plotlineProgress !== '' && <span className={css.meta} style={{ marginLeft: 'var(--nf-space-4)' }}>{ar.plotlineProgress.slice(0, 40)}{ar.plotlineProgress.length > 40 ? '…' : ''}</span>}
4253
- </button>
4254
- {expanded && (
4255
- <div style={{ padding: 'var(--nf-space-4) var(--nf-space-10) var(--nf-space-8)', fontSize: 'var(--nf-fs-12)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', borderTop: '1px solid var(--nf-border)' }}>
4256
- {ar.hookNote !== '' && <span className={css.meta}><b>钩子:</b>{ar.hookNote}</span>}
4257
- {ar.plotlineProgress !== '' && <span className={css.meta}><b>推进:</b>{ar.plotlineProgress}</span>}
4258
- {ar.continuity !== '' && <span className={css.meta}><b>衔接:</b>{ar.continuity}</span>}
4259
- {ar.trend !== '' && <span className={css.meta}><b>趋势:</b>{ar.trend}</span>}
4260
- </div>
4261
- )}
4262
- </div>
4263
- )
4264
- })}
4265
- </div>
4266
- )}
4267
- </div>
4268
- )
4269
- })}
4270
- </div>
4271
- </>
4272
- ) : (
4273
- <>
4274
- <div className={css.busyRow} style={{ flexWrap: 'wrap' }}>
4275
- <span className={css.cardTitle}>{tt('facts.title', { n: (project?.facts ?? []).length })}</span>
4276
- <button
4277
- type="button"
4278
- className={`${css.button} ${css.buttonSmall}`}
4279
- disabled={busy || chapters.length === 0}
4280
- onClick={() => { void handleFactsBackfill() }}
4281
- title="用 LLM 从历史章节正文重新抽取事实,补齐缺失的编年录条目"
4282
- >
4283
- 📥 {tt('facts.backfill')}
4284
- </button>
4285
- </div>
4286
- <span className={css.meta}>{tt('facts.hint')}</span>
4287
- {(project?.facts ?? []).length > 0 ? (
4288
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', maxHeight: '60vh', overflowY: 'auto', fontSize: 'var(--nf-fs-12)' }}>
4289
- {[...(project?.facts ?? [])].reverse().map((fact, i) => (
4290
- <div key={i} style={{ display: 'flex', gap: 'var(--nf-space-8)', alignItems: 'flex-start' }}>
4291
- <span className={css.badge} style={{ borderColor: 'var(--nf-text-3)', color: 'var(--nf-text-3)', flex: 'none', marginTop: 'var(--nf-space-2)' }}>
4292
- 第 {fact.chapterNo} 章
4293
- </span>
4294
- <span className={css.meta}>{fact.text}</span>
4295
- </div>
4296
- ))}
4297
- </div>
4298
- ) : (
4299
- <span className={css.meta}>暂无事实条目——写一章后会自动生成,或点击上方「回填」。</span>
4300
- )}
4301
- </>
4302
- )}
4303
- </div>
4304
- )}
4305
-
4306
- {activeTab === 'plotlines' && (
4307
- <div className={css.card}>
4308
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4309
- <span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-16)', fontWeight: 700 }}>📜 长线管理</span>
4310
- </div>
4311
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
4312
- <button
4313
- type="button"
4314
- className={`${css.button} ${longlineTab === 'plotlines' ? css.buttonPrimary : ''}`}
4315
- style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
4316
- onClick={() => { changeLonglineTab('plotlines') }}
4317
- title="剧情线:故事明线(主线 / 支线 / 人物 / 悬念)"
4318
- >
4319
- 🧵 剧情线({project?.plotlines?.length ?? 0})
4320
- </button>
4321
- <button
4322
- type="button"
4323
- className={`${css.button} ${longlineTab === 'foreshadow' ? css.buttonPrimary : ''}`}
4324
- style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
4325
- onClick={() => { changeLonglineTab('foreshadow') }}
4326
- title="伏笔:道具 / 事件级暗线(埋设 → 回收)"
4327
- >
4328
- 🔮 伏笔({foreshadows.length})
4329
- </button>
4330
- </div>
4331
-
4332
- {longlineTab === 'foreshadow' ? (
4333
- <>
4334
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4335
- <span className={css.cardTitle}>🔮 {tt('foreshadow.title')}({foreshadows.length})</span>
4336
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSuggestForeshadows() }}>
4337
- {tt('foreshadow.suggest')}
4338
- </button>
4339
- </div>
4340
- {foreshadows.length === 0 ? (
4341
- <span className={css.meta}>{tt('foreshadow.none')}</span>
4342
- ) : (
4343
- <div className={css.chapterList}>
4344
- {foreshadows.map(f => {
4345
- const statusLabel = { planned: tt('foreshadow.planned'), planted: tt('foreshadow.planted'), progressing: tt('foreshadow.progressing'), resolved: tt('foreshadow.resolved'), abandoned: tt('foreshadow.abandoned') }[f.status]
4346
- const statusColor = f.status === 'resolved' ? 'var(--nf-success)' : f.status === 'planted' || f.status === 'progressing' ? 'var(--nf-accent)' : f.status === 'abandoned' ? 'var(--nf-text-3)' : 'var(--nf-info)'
4347
- return (
4348
- <div key={f.id} className={css.chapter}>
4349
- <div className={css.chapterMain}>
4350
- <div className={css.chapterTitle}>
4351
- <span>{f.description}</span>
4352
- </div>
4353
- <div className={css.meta}>
4354
- {f.plantedChapter !== undefined && <span>{tt('foreshadow.plantedAt')} 第{f.plantedChapter}章 · </span>}
4355
- {f.targetChapter !== undefined && <span>{tt('foreshadow.target')} 第{f.targetChapter}章 · </span>}
4356
- {f.resolvedNote !== undefined && f.resolvedNote !== '' && <span>回收:{f.resolvedNote} · </span>}
4357
- </div>
4358
- </div>
4359
- <span className={css.badge} style={{ borderColor: statusColor, color: statusColor }}>{statusLabel}</span>
4360
- <div className={css.row} style={{ gap: 'var(--nf-space-4)' }}>
4361
- {f.status === 'planned' && (
4362
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
4363
- void api.foreshadow({ id: f.id, status: 'planted', plantedChapter: doneCount + 1 }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
4364
- }}>
4365
- {tt('foreshadow.setPlanted')}
4366
- </button>
4367
- )}
4368
- {(f.status === 'planted' || f.status === 'progressing') && (
4369
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
4370
- void api.foreshadow({ id: f.id, status: 'resolved', resolvedNote: `第${doneCount}章回收` }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
4371
- }}>
4372
- {tt('foreshadow.setResolved')}
4373
- </button>
4374
- )}
4375
- </div>
4376
- </div>
4377
- )
4378
- })}
4379
- </div>
4380
- )}
4381
- </>
4382
- ) : (
4383
- <>
4384
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4385
- <span className={css.cardTitle}>🧵 {tt('tab.plotlines')}({project?.plotlines?.length ?? 0})</span>
4386
- <div className={css.row} style={{ flexWrap: 'wrap' }}>
4387
- <button
4388
- type="button"
4389
- className={`${css.button} ${css.buttonSmall}`}
4390
- disabled={busy || outlineText.length < 50}
4391
- onClick={() => { void handlePlotlineHealth() }}
4392
- title="根据已写章节数与各线推进情况,判断是否需要新增剧情线、建议多少章后添加"
4393
- >
4394
- 🩺 剧情健康检查
4395
- </button>
4396
- <button
4397
- type="button"
4398
- className={`${css.button} ${css.buttonSmall}`}
4399
- disabled={busy || outlineText.length < 50}
4400
- onClick={() => { void handlePlotlinePlan() }}
4401
- title="AI 设计下一阶段剧情方案:未来 5-10 章方向 + 2-3 条建议新线"
4402
- >
4403
- ✨ 设计剧情方案
4404
- </button>
4405
- <button
4406
- type="button"
4407
- className={`${css.button} ${css.buttonSmall}`}
4408
- disabled={busy || outlineText.length < 50}
4409
- onClick={() => { void handlePlotlineSuggest() }}
4410
- title="AI 根据大纲/卷计划/已写章节/编年录,提炼候选剧情线"
4411
- >
4412
- ✨ AI 建议剧情线
4413
- </button>
4414
- {plotlineDraft === null && (
4415
- <button
4416
- type="button"
4417
- className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
4418
- onClick={() => { setPlotlineDraft({ id: '', name: '', kind: 'main', goal: '', progress: '', status: 'active' }) }}
4419
- >
4420
- {tt('plotlines.new')}
4421
- </button>
4422
- )}
4423
- </div>
4424
- </div>
4425
- <span className={css.meta}>{tt('plotlines.hint')}</span>
4426
-
4427
- {plotlineHealth !== null && (
4428
- <PlotlineHealthPanel
4429
- report={plotlineHealth}
4430
- disabled={busy}
4431
- onPlan={() => { void handlePlotlinePlan() }}
4432
- onClose={() => { setPlotlineHealth(null) }}
4433
- />
4434
- )}
4435
-
4436
- {plotlinePlan !== null && (
4437
- <PlotlinePlanPanel
4438
- plan={plotlinePlan}
4439
- disabled={busy}
4440
- onAdopt={(s) => { void handlePlanAdopt(s) }}
4441
- onClose={() => { setPlotlinePlan(null) }}
4442
- />
4443
- )}
4444
-
4445
- {plotlineSuggestions !== null && (
4446
- <PlotlineSuggestionPanel
4447
- suggestions={plotlineSuggestions}
4448
- disabled={busy}
4449
- onAdopt={(s) => { void handlePlotlineAdopt(s) }}
4450
- onClose={() => { setPlotlineSuggestions(null) }}
4451
- />
4452
- )}
4453
-
4454
- {plotlineDraft !== null && (
4455
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', border: '1px solid var(--nf-accent)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-10)' }}>
4456
- <div className={css.row} style={{ flexWrap: 'wrap' }}>
4457
- <div className={css.field} style={{ flex: 2, minWidth: 160 }}>
4458
- <label className={css.fieldLabel}>{tt('plotlines.name')}</label>
4459
- <input className={css.input} value={plotlineDraft.name} onChange={e => { setPlotlineDraft({ ...plotlineDraft, name: e.target.value }) }} placeholder="如:集齐古玉残片" />
4460
- </div>
4461
- <div className={css.field} style={{ flex: 1 }}>
4462
- <label className={css.fieldLabel}>{tt('plotlines.kind')}</label>
4463
- <select className={css.input} value={plotlineDraft.kind} onChange={e => { setPlotlineDraft({ ...plotlineDraft, kind: e.target.value as Plotline['kind'] }) }}>
4464
- <option value="main">{tt('plotlines.kindMain')}</option>
4465
- <option value="branch">{tt('plotlines.kindBranch')}</option>
4466
- <option value="character">{tt('plotlines.kindCharacter')}</option>
4467
- <option value="mystery">{tt('plotlines.kindMystery')}</option>
4468
- </select>
4469
- </div>
4470
- <div className={css.field} style={{ flex: 1 }}>
4471
- <label className={css.fieldLabel}>{tt('plotlines.status')}</label>
4472
- <select className={css.input} value={plotlineDraft.status} onChange={e => { setPlotlineDraft({ ...plotlineDraft, status: e.target.value as Plotline['status'] }) }}>
4473
- <option value="active">{tt('plotlines.statusActive')}</option>
4474
- <option value="paused">{tt('plotlines.statusPaused')}</option>
4475
- <option value="resolved">{tt('plotlines.statusResolved')}</option>
4476
- <option value="abandoned">{tt('plotlines.statusAbandoned')}</option>
4477
- </select>
4478
- </div>
4479
- </div>
4480
- <div className={css.field}>
4481
- <label className={css.fieldLabel}>{tt('plotlines.goal')}</label>
4482
- <textarea className={css.textarea} style={{ minHeight: 48 }} value={plotlineDraft.goal} onChange={e => { setPlotlineDraft({ ...plotlineDraft, goal: e.target.value }) }} />
4483
- </div>
4484
- <div className={css.field}>
4485
- <label className={css.fieldLabel}>{tt('plotlines.progress')}</label>
4486
- <textarea className={css.textarea} style={{ minHeight: 40 }} value={plotlineDraft.progress} onChange={e => { setPlotlineDraft({ ...plotlineDraft, progress: e.target.value }) }} placeholder="如:已取得第二枚残片,正追踪第三枚线索" />
4487
- </div>
4488
- <div className={css.row}>
4489
- <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handlePlotlineSave() }}>
4490
- {tt('plotlines.save')}
4491
- </button>
4492
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setPlotlineDraft(null) }}>
4493
- {tt('plotlines.cancel')}
4494
- </button>
4495
- </div>
4496
- </div>
4497
- )}
4498
-
4499
- {(project?.plotlines ?? []).length === 0 ? (
4500
- <span className={css.meta}>{tt('plotlines.empty')}</span>
4501
- ) : (
4502
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', overflowY: 'auto', flex: 1, minHeight: 0 }}>
4503
- {(project?.plotlines ?? []).map(line => (
4504
- <PlotlineCard
4505
- key={line.id}
4506
- line={line}
4507
- disabled={busy}
4508
- onRefresh={() => { void handlePlotlineRefresh(line.id) }}
4509
- onEdit={() => { setPlotlineDraft({ id: line.id, name: line.name, kind: line.kind, goal: line.goal, progress: line.progress, status: line.status }) }}
4510
- onRemove={() => { void handlePlotlineRemove(line.id) }}
4511
- />
4512
- ))}
4513
- </div>
4514
- )}
4515
- </>
4516
- )}
4517
- </div>
4518
- )}
4519
-
4520
- {activeTab === 'director' && <DirectorView api={api} todos={project?.todos ?? []} onTodosChange={(todos) => setProject(prev => prev === null ? prev : { ...prev, todos, updatedAt: new Date().toISOString() })} />}
4521
-
4522
- {activeTab === 'knowledge' && <KnowledgeBaseView api={api} />}
4523
-
4524
- {activeTab === 'run' && (
4525
- <RunPanel api={api} totalChapters={chapters.length} />
4526
- )}
4527
-
4528
- {activeTab === 'manhua' && (
4529
- <MangaWorkspace api={api} project={project} chapters={chapters} onProjectChanged={() => refresh(false)} onProgress={(text, kind) => pushProgress(text, kind ?? 'info')} />
4530
- )}
4531
-
4532
- {activeTab === 'breakdown' && (
4533
- <div className={css.card}>
4534
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4535
- <span className={css.cardTitle}>🔍 拆书分析</span>
4536
- <div className={css.row} style={{ flexWrap: 'wrap' }}>
4537
- <select
4538
- className={css.input}
4539
- style={{ width: 160 }}
4540
- value={breakdownScope}
4541
- onChange={e => { setBreakdownScope(e.target.value as typeof breakdownScope) }}
4542
- title="分析范围"
4543
- >
4544
- <option value="recent">最近 20 章</option>
4545
- {volumes !== undefined && volumes.map(v => (
4546
- <option key={v.no} value={`volume:${v.no}`}>第{v.no}卷</option>
4547
- ))}
4548
- <option value="all">全书</option>
4549
- </select>
4550
- <select
4551
- className={css.input}
4552
- style={{ width: 150 }}
4553
- value={breakdownPreset}
4554
- onChange={e => { setBreakdownPreset(e.target.value as 'quick' | 'standard') }}
4555
- title="分析档位"
4556
- >
4557
- <option value="quick">快速(4 维)</option>
4558
- <option value="standard">标准+卖点</option>
4559
- </select>
4560
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleBreakdown() }} title="对已写章节做结构/人物/文风/卖点体检(约 1-3 分钟,消耗 LLM 额度)">
4561
- {busy ? '⏳ 分析中…' : '✨ 开始拆书'}
4562
- </button>
4563
- </div>
4564
- </div>
4565
- <span className={css.meta}>拆书分析 = 整卷复盘工具:定位、剧情结构、人物系统、文风技法(标准档加商业化卖点)。每条结论基于实际章节归纳,帮你发现「写偏了/人物变形/文风漂移」——与单章审稿互补。</span>
4566
-
4567
- {breakdownResult === null ? (
4568
- <div className={css.shelfEmpty} style={{ minHeight: 140 }}>
4569
- <span className={css.shelfEmptyIcon}>📖</span>
4570
- <span className={css.shelfEmptyTitle}>尚未运行拆书分析</span>
4571
- <span className={css.meta}>选择范围与档位,点「开始拆书」对已写章节做整卷体检</span>
4572
- </div>
4573
- ) : (
4574
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)' }}>
4575
- <span className={css.meta}>
4576
- 分析 {breakdownResult.chaptersScanned} 章 · {breakdownResult.sections.length} 个小节 · 约 {breakdownResult.usedTokens} token
4577
- </span>
4578
- {breakdownResult.sections.map(sec => (
4579
- <div key={sec.key} className={css.wsPreview} style={{ borderColor: 'var(--nf-info)' }}>
4580
- <div className={css.busyRow}>
4581
- <b>{sec.title}</b>
4582
- </div>
4583
- {Object.keys(sec.structured).length > 0 && (
4584
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-2)', fontSize: 'var(--nf-fs-12)', marginBottom: 'var(--nf-space-6)' }}>
4585
- {Object.entries(sec.structured).map(([k, v]) => {
4586
- const label = (typeof v === 'string') ? v : Array.isArray(v) ? (v as string[]).join('、') : JSON.stringify(v)
4587
- return <div key={k} className={css.meta}><b>{k}:</b>{label}</div>
4588
- })}
4589
- </div>
4590
- )}
4591
- <div className={css.meta} style={{ whiteSpace: 'pre-wrap', fontSize: 'var(--nf-fs-12)' }}>{sec.markdown}</div>
4592
- </div>
4593
- ))}
4594
- </div>
4595
- )}
4596
- </div>
4597
- )}
4598
-
4599
- </>)}
4600
- </div>
4601
- </div>
4602
- </>
4603
- )}
4604
-
4605
- {/* AI 助手悬浮窗:可拖动、可拉大小,不占用工作台 */}
4606
- {assistantOpen && (
4607
- <div
4608
- className={css.assistantFloat}
4609
- style={{ left: assistantPos.x, top: assistantPos.y, width: assistantSize.w, height: assistantSize.h }}
4610
- >
4611
- <div
4612
- className={css.assistantFloatHeader}
4613
- onMouseDown={e => {
4614
- e.preventDefault()
4615
- dragState.current = { type: 'move', target: 'assistant', startX: e.clientX, startY: e.clientY, origX: assistantPos.x, origY: assistantPos.y, origW: assistantSize.w, origH: assistantSize.h }
4616
- }}
4617
- >
4618
- <span>💬 AI 编辑 Agent <span className={css.meta}>(拖动标题栏移动 · 右下角拉大小)</span></span>
4619
- <button type="button" className={css.iconButton} title="关闭" aria-label="关闭 AI 编辑 Agent" onClick={() => { setAssistantOpen(false) }}>×</button>
4620
- </div>
4621
- <div className={css.assistantFloatBody}>
4622
- <AssistantTab api={api} />
4623
- </div>
4624
- <div
4625
- className={css.assistantResize}
4626
- onMouseDown={e => {
4627
- e.preventDefault()
4628
- e.stopPropagation()
4629
- dragState.current = { type: 'resize', target: 'assistant', startX: e.clientX, startY: e.clientY, origX: assistantPos.x, origY: assistantPos.y, origW: assistantSize.w, origH: assistantSize.h }
4630
- }}
4631
- />
4632
- </div>
4633
- )}
4634
-
4635
- {/* AI进度悬浮窗:可拖动、可拉大小(同 AI 助手),承接活动输出 + 当前任务进度 */}
4636
- {progressOpen && (
4637
- <div
4638
- className={css.assistantFloat}
4639
- style={{ left: progressPos.x, top: progressPos.y, width: progressSize.w, height: progressSize.h }}
4640
- >
4641
- <div
4642
- className={css.assistantFloatHeader}
4643
- onMouseDown={e => {
4644
- e.preventDefault()
4645
- dragState.current = { type: 'move', target: 'progress', startX: e.clientX, startY: e.clientY, origX: progressPos.x, origY: progressPos.y, origW: progressSize.w, origH: progressSize.h }
4646
- }}
4647
- >
4648
- <span>
4649
- 📊 AI 进度
4650
- {busy && <span style={{ color: 'var(--nf-accent)' }}> 🟢 任务进行中</span>}
4651
- <span className={css.meta}>(拖动标题栏移动 · 右下角拉大小)</span>
4652
- </span>
4653
- <span style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-4)' }}>
4654
- {progress.length > 0 && (
4655
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setProgress([]) }} title="清空活动记录">
4656
- 清空
4657
- </button>
4658
- )}
4659
- <button type="button" className={css.iconButton} title="关闭" aria-label="关闭AI进度" onClick={() => { setProgressOpen(false) }}>×</button>
4660
- </span>
4661
- </div>
4662
- <div className={css.assistantFloatBody} style={{ padding: 'var(--nf-space-10)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', overflow: 'hidden', flex: 1, minHeight: 0 }}>
4663
- {/* 当前任务大进度条:进行中显示 */}
4664
- {(busy && (busyLabel !== '' || liveBar !== null)) && (
4665
- <div style={{ border: '1px solid var(--nf-accent)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-12)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', background: 'color-mix(in srgb, var(--nf-accent) 6%, transparent)' }}>
4666
- <span style={{ fontSize: 'var(--nf-fs-12)', fontWeight: 600, color: 'var(--nf-accent)' }}>
4667
- ✍ {busyLabel !== '' ? busyLabel : (liveBar?.text ?? '任务进行中')}…
4668
- </span>
4669
- {liveBar?.ratio !== undefined && (
4670
- <div className={css.bigProgressBar}>
4671
- <div className={css.bigProgressBarFill} style={{ width: `${Math.round(liveBar.ratio * 100)}%` }} />
4672
- </div>
4673
- )}
4674
- {liveBar?.text !== undefined && <span className={css.liveText}>{liveBar.text}</span>}
4675
- </div>
4676
- )}
4677
- {/* 活动记录列表(标题行已并入窗口标题栏,清空按钮在右上角) */}
4678
- <div className={css.progress} style={{ flex: '0 1 auto', maxHeight: '42%', minHeight: 0, overflowY: 'auto', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', background: 'var(--nf-bg-inset)', padding: 'var(--nf-space-8)', display: 'flex', flexDirection: 'column' }}>
4679
- {progress.length === 0 ? (
4680
- <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-6)', color: 'var(--nf-text-3)', fontSize: 'var(--nf-fs-12)', padding: 'var(--nf-space-4) 2px' }}>
4681
- <span>📭</span>
4682
- <span>暂无活动记录,生成、审稿等操作会显示在这里</span>
4683
- </div>
4684
- ) : (
4685
- progress.map(line => (
4686
- <div key={line.id} className={line.kind === 'done' ? css.progressLineDone : line.kind === 'error' ? css.progressLineError : line.live === true ? css.progressLineLive : css.progressLine}>
4687
- {line.live === true && (
4688
- <span className={css.progressBar}>
4689
- <span className={css.progressBarFill} style={{ width: `${Math.round((line.ratio ?? 0) * 100)}%` }} />
4690
- </span>
4691
- )}
4692
- {line.text}
4693
- </div>
4694
- ))
4695
- )}
4696
- <div ref={progressEndRef} />
4697
- </div>
4698
- <LiveFeedLog />
4699
- </div>
4700
- <div
4701
- className={css.assistantResize}
4702
- onMouseDown={e => {
4703
- e.preventDefault()
4704
- e.stopPropagation()
4705
- dragState.current = { type: 'resize', target: 'progress', startX: e.clientX, startY: e.clientY, origX: progressPos.x, origY: progressPos.y, origW: progressSize.w, origH: progressSize.h }
4706
- }}
4707
- />
4708
- </div>
4709
- )}
4710
- {showImport && (
4711
- <ImportModal
4712
- api={api}
4713
- onClose={() => { setShowImport(false) }}
4714
- onImported={async () => {
4715
- await refreshShelf()
4716
- }}
4717
- />
4718
- )}
4719
- </div>
4720
- )
4721
- }