@waterwx/dsh-novel-forge 1.3.1 → 1.3.2-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +288 -279
  2. package/lib/client.js +56309 -12221
  3. package/lib/index.js +14239 -5595
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +36 -0
  6. package/lib/types/assets.d.ts +24 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +25 -0
  9. package/lib/types/bookshelf.d.ts +11 -1
  10. package/lib/types/client/api.d.ts +186 -10
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -0
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +28 -67
  16. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  19. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  20. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  21. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  22. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  23. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  24. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  25. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  26. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  27. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  28. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  29. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  30. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  31. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  32. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  33. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  34. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  35. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  36. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  37. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  38. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  39. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  40. package/lib/types/client/panel/SubPage.d.ts +27 -0
  41. package/lib/types/client/panel/TensionView.d.ts +18 -0
  42. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  43. package/lib/types/client/panel/helpers.d.ts +5 -3
  44. package/lib/types/client/panel/toast.d.ts +2 -0
  45. package/lib/types/client/panel/views.d.ts +25 -7
  46. package/lib/types/client/run-status.d.ts +21 -0
  47. package/lib/types/client/text.d.ts +9 -0
  48. package/lib/types/docx-core.d.ts +16 -0
  49. package/lib/types/docx.d.ts +2 -3
  50. package/lib/types/engine.d.ts +186 -45
  51. package/lib/types/global-assets.d.ts +6 -0
  52. package/lib/types/index.d.ts +22 -8
  53. package/lib/types/llm-live.d.ts +75 -0
  54. package/lib/types/llm-retry.d.ts +38 -0
  55. package/lib/types/market-radar-scan.d.ts +19 -0
  56. package/lib/types/market-radar-sources.d.ts +31 -0
  57. package/lib/types/migrate-output.d.ts +25 -0
  58. package/lib/types/novel-context.d.ts +80 -0
  59. package/lib/types/prompt-slots.d.ts +57 -0
  60. package/lib/types/prompt-utils.d.ts +35 -0
  61. package/lib/types/prompting.d.ts +10 -0
  62. package/lib/types/protocol.d.ts +1362 -166
  63. package/lib/types/review-policy.d.ts +97 -0
  64. package/lib/types/revision.d.ts +76 -0
  65. package/lib/types/routes.d.ts +4 -0
  66. package/lib/types/run.d.ts +23 -1
  67. package/lib/types/snapshots.d.ts +46 -0
  68. package/lib/types/stage-contract.d.ts +60 -0
  69. package/lib/types/tension.d.ts +53 -0
  70. package/lib/types/timeline.d.ts +37 -0
  71. package/package.json +52 -28
  72. package/src/action-guard.ts +127 -0
  73. package/src/ai-scan.ts +179 -0
  74. package/src/assets.ts +1806 -520
  75. package/src/assistant.ts +1098 -770
  76. package/src/author-assets.ts +149 -0
  77. package/src/bookshelf.ts +187 -151
  78. package/src/client/api.ts +378 -21
  79. package/src/client/chip-prefs.ts +121 -0
  80. package/src/client/docx.ts +37 -69
  81. package/src/client/index.ts +3 -1
  82. package/src/client/llmLive.ts +111 -0
  83. package/src/client/locales.ts +28 -253
  84. package/src/client/mount.tsx +1 -1
  85. package/src/client/panel/AdaptModeView.tsx +535 -0
  86. package/src/client/panel/AiTag.tsx +7 -0
  87. package/src/client/panel/AssetsTab.tsx +448 -458
  88. package/src/client/panel/AssistantTab.tsx +355 -301
  89. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  90. package/src/client/panel/AuthorHome.tsx +309 -0
  91. package/src/client/panel/BookAnalysisView.tsx +63 -0
  92. package/src/client/panel/CmdPalette.tsx +102 -0
  93. package/src/client/panel/CreateBookView.tsx +366 -348
  94. package/src/client/panel/DirectorView.tsx +200 -0
  95. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  96. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  97. package/src/client/panel/ImportModal.tsx +303 -0
  98. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  99. package/src/client/panel/LiveFeedLog.tsx +204 -0
  100. package/src/client/panel/MarketRadarView.tsx +377 -0
  101. package/src/client/panel/ModelManager.tsx +205 -0
  102. package/src/client/panel/NovelPanel.tsx +3135 -2651
  103. package/src/client/panel/PageHeader.tsx +37 -0
  104. package/src/client/panel/ProgressConsole.tsx +63 -0
  105. package/src/client/panel/PromptSlotsView.tsx +97 -0
  106. package/src/client/panel/ReaderView.tsx +306 -306
  107. package/src/client/panel/ReasoningSection.tsx +31 -0
  108. package/src/client/panel/RunPanel.tsx +64 -32
  109. package/src/client/panel/SettingsView.tsx +354 -0
  110. package/src/client/panel/ShelfView.tsx +266 -236
  111. package/src/client/panel/SubPage.tsx +42 -0
  112. package/src/client/panel/TensionView.tsx +212 -0
  113. package/src/client/panel/TimelineView.tsx +208 -0
  114. package/src/client/panel/WorldTab.tsx +202 -202
  115. package/src/client/panel/helpers.ts +7 -5
  116. package/src/client/panel/panel.module.css +4481 -1655
  117. package/src/client/panel/toast.ts +51 -0
  118. package/src/client/panel/views.tsx +401 -314
  119. package/src/client/run-status.ts +171 -0
  120. package/src/client/sidebar-entry.ts +1 -1
  121. package/src/client/text.ts +28 -0
  122. package/src/docx-core.ts +53 -0
  123. package/src/docx.ts +44 -83
  124. package/src/engine.ts +4672 -2943
  125. package/src/global-assets.ts +59 -0
  126. package/src/index.ts +89 -33
  127. package/src/llm-live.ts +260 -0
  128. package/src/llm-retry.ts +77 -0
  129. package/src/market-radar-scan.ts +41 -0
  130. package/src/market-radar-sources.ts +173 -0
  131. package/src/migrate-output.ts +76 -0
  132. package/src/novel-context.ts +286 -0
  133. package/src/prompt-slots.ts +125 -0
  134. package/src/prompting.ts +26 -0
  135. package/src/protocol.ts +1388 -158
  136. package/src/review-policy.ts +161 -0
  137. package/src/revision.ts +283 -0
  138. package/src/routes.ts +1934 -434
  139. package/src/run.ts +118 -21
  140. package/src/snapshots.ts +174 -0
  141. package/src/stage-contract.ts +189 -0
  142. package/src/tension.ts +251 -0
  143. package/src/timeline.ts +159 -0
  144. package/lib/client.js.map +0 -1
  145. package/lib/index.js.map +0 -1
  146. package/lib/types/comic-presets.d.ts +0 -12
  147. package/scripts/assistant-frames.txt +0 -8
  148. package/scripts/diagnose-adapter.mts +0 -81
  149. package/scripts/diagnose-assistant.mjs +0 -58
  150. package/scripts/diagnose-bible.mjs +0 -72
  151. package/scripts/diagnose-harness.mts +0 -75
  152. package/scripts/novel-forge-restart.log +0 -10
  153. package/scripts/novel-forge-web.stderr.log +0 -2
  154. package/scripts/novel-forge-web.stdout.log +0 -1
  155. package/scripts/outline-sample.txt +0 -449
  156. package/scripts/probe.mjs +0 -6
  157. package/scripts/restart-web.ps1 +0 -88
  158. package/scripts/smoke.mts +0 -72
  159. package/scripts/upload-github.ps1 +0 -51
  160. package/src/client/panel/BookshelfBar.tsx +0 -170
  161. package/src/comic-presets.ts +0 -67
@@ -1,306 +1,306 @@
1
- /**
2
- * 阅读页:沉浸式章节阅读(纯只读,零 LLM 消耗)。
3
- * 布局:顶部工具栏 + 左侧可折叠目录栏(按卷分组)+ 阅读区。
4
- * 轻量渲染:去掉 md 首行标题,按行分段;字号/主题/阅读进度 localStorage 记忆。
5
- */
6
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
7
- import type { NovelApi } from '../api.ts'
8
- import type { ProjectState, ChapterPlan } from '../../protocol.ts'
9
- import css from './panel.module.css'
10
-
11
- const FONT_SIZES = [
12
- { id: 'sm', px: 15, label: 'A-' },
13
- { id: 'md', px: 17, label: 'A' },
14
- { id: 'lg', px: 20, label: 'A+' },
15
- ] as const
16
-
17
- const THEMES = [
18
- { id: 'paper', label: '📄 纸白', bg: '#f7f1e3', fg: '#3b3226', accent: '#8b6f47', dim: '#8a7d66' },
19
- { id: 'eye', label: '🌿 护眼', bg: '#e8f0e3', fg: '#2f3d2e', accent: '#5f7d5a', dim: '#6f836b' },
20
- { id: 'night', label: '🌙 夜间', bg: '#1d2226', fg: '#c9d1d9', accent: '#8aa5c0', dim: '#6d7882' },
21
- ] as const
22
-
23
- type FontId = typeof FONT_SIZES[number]['id']
24
- type ThemeId = typeof THEMES[number]['id']
25
-
26
- function readPref<T>(key: string, fallback: T): T {
27
- try {
28
- const raw = window.localStorage.getItem(key)
29
- if (raw !== null) return JSON.parse(raw) as T
30
- } catch { /* ignore */ }
31
- return fallback
32
- }
33
-
34
- function writePref(key: string, value: unknown): void {
35
- try { window.localStorage.setItem(key, JSON.stringify(value)) } catch { /* ignore */ }
36
- }
37
-
38
- /** 章节状态小标(阅读视角)。 */
39
- function statusBadge(ch: ChapterPlan): { label: string; cls: string } {
40
- switch (ch.status) {
41
- case 'approved': return { label: '✅', cls: css.badgeDone }
42
- case 'written': return { label: '📝', cls: css.badgeWritten }
43
- case 'rejected': return { label: '⚠️', cls: css.badgePending }
44
- default: return { label: '', cls: css.badgePending }
45
- }
46
- }
47
-
48
- /** 目录分组:按卷区间过滤可读章节;卷外章节归入「未分卷」。 */
49
- function buildGroups(project: ProjectState, readable: ChapterPlan[]): Array<{ title: string; no: number; chapters: ChapterPlan[] }> {
50
- const vols = project.volumes ?? []
51
- if (vols.length === 0) return [{ title: '全部章节', no: 0, chapters: readable }]
52
- const groups: Array<{ title: string; no: number; chapters: ChapterPlan[] }> = []
53
- for (const v of vols) {
54
- const inVol = readable.filter(c => c.no >= v.chapterStart && c.no <= v.chapterEnd)
55
- if (inVol.length > 0) groups.push({ title: `第${v.no}卷 ${v.title}`, no: v.no, chapters: inVol })
56
- }
57
- const rest = readable.filter(c => !vols.some(v => c.no >= v.chapterStart && c.no <= v.chapterEnd))
58
- if (rest.length > 0) groups.push({ title: '未分卷', no: 0, chapters: rest })
59
- return groups
60
- }
61
-
62
- export function ReaderView({
63
- api,
64
- project,
65
- onBack,
66
- onOpenWorkspace,
67
- }: {
68
- api: NovelApi
69
- project: ProjectState
70
- onBack: () => void
71
- onOpenWorkspace: () => void
72
- }) {
73
- const prefKey = useMemo(() => {
74
- const id = project.bookName || 'book'
75
- return `dsh-novel-forge.reader.${id}`
76
- }, [project.bookName])
77
-
78
- /** 可读章节(有正文落盘):approved / written / rejected。 */
79
- const readable = useMemo(() => project.chapters.filter(c =>
80
- c.status === 'approved' || c.status === 'written' || c.status === 'rejected',
81
- ), [project.chapters])
82
-
83
- const groups = useMemo(() => buildGroups(project, readable), [project, readable])
84
-
85
- const [currentNo, setCurrentNo] = useState<number>(() => {
86
- const saved = readPref<number | null>(`${prefKey}.no`, null)
87
- if (saved !== null && readable.some(c => c.no === saved)) return saved
88
- // 无记忆 → 最后一章已通过
89
- const lastApproved = [...readable].reverse().find(c => c.status === 'approved')
90
- return lastApproved?.no ?? readable[0]?.no ?? 0
91
- })
92
- const [markdown, setMarkdown] = useState<string>('')
93
- const [loading, setLoading] = useState(false)
94
- const [loadError, setLoadError] = useState<string>('')
95
- const [font, setFont] = useState<FontId>(() => readPref(`${prefKey}.font`, 'md'))
96
- const [theme, setTheme] = useState<ThemeId>(() => readPref(`${prefKey}.theme`, 'paper'))
97
- const [navOpen, setNavOpen] = useState<boolean>(() => readPref(`${prefKey}.nav`, true))
98
- /** 折叠的卷号(点击卷标题切换;记忆在 localStorage)。 */
99
- const [collapsedVols, setCollapsedVols] = useState<number[]>(() => readPref(`${prefKey}.collapsed`, []))
100
- const sidebarRef = useRef<HTMLDivElement>(null)
101
-
102
- const toggleVol = useCallback((no: number) => {
103
- setCollapsedVols(prev => {
104
- const next = prev.includes(no) ? prev.filter(v => v !== no) : [...prev, no]
105
- writePref(`${prefKey}.collapsed`, next)
106
- return next
107
- })
108
- }, [prefKey])
109
-
110
- const current = useMemo(() => project.chapters.find(c => c.no === currentNo), [project.chapters, currentNo])
111
- const idx = readable.findIndex(c => c.no === currentNo)
112
- const prevCh = idx > 0 ? readable[idx - 1] : undefined
113
- const nextCh = idx >= 0 && idx < readable.length - 1 ? readable[idx + 1] : undefined
114
-
115
- const load = useCallback(async (no: number) => {
116
- if (no === 0) return
117
- setLoading(true)
118
- setLoadError('')
119
- try {
120
- const res = await api.chapter(no)
121
- setMarkdown(res.markdown)
122
- setCurrentNo(no)
123
- writePref(`${prefKey}.no`, no)
124
- } catch (err) {
125
- setLoadError((err as Error).message)
126
- } finally {
127
- setLoading(false)
128
- }
129
- }, [api, prefKey])
130
-
131
- // 初始加载
132
- const initialNo = useRef<number>(0)
133
- useEffect(() => {
134
- if (initialNo.current === currentNo && markdown !== '') return
135
- if (currentNo === 0) return
136
- initialNo.current = currentNo
137
- void load(currentNo)
138
- }, [currentNo, load, markdown])
139
-
140
- // 键盘翻章
141
- useEffect(() => {
142
- const onKey = (e: KeyboardEvent) => {
143
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
144
- if (e.key === 'ArrowLeft' && prevCh !== undefined) void load(prevCh.no)
145
- if (e.key === 'ArrowRight' && nextCh !== undefined) void load(nextCh.no)
146
- }
147
- window.addEventListener('keydown', onKey)
148
- return () => window.removeEventListener('keydown', onKey)
149
- }, [prevCh, nextCh, load])
150
-
151
- // 当前章滚动到可见(目录栏内)
152
- useEffect(() => {
153
- sidebarRef.current?.querySelector<HTMLElement>(`[data-no="${currentNo}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
154
- }, [currentNo])
155
-
156
- const themeMeta = THEMES.find(t => t.id === theme) ?? THEMES[0]
157
- const fontMeta = FONT_SIZES.find(f => f.id === font) ?? FONT_SIZES[1]
158
-
159
- /** 轻量渲染:去标题行、按行分段(网文每行一段)。 */
160
- const paragraphs = useMemo(() => {
161
- return markdown
162
- .split(/\r?\n/)
163
- .map(l => l.replace(/^#{1,6}\s*/, '').trim())
164
- .filter(Boolean)
165
- }, [markdown])
166
-
167
- const itemBg = theme === 'night' ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.025)'
168
-
169
- return (
170
- <div className={css.readerView} style={{ background: themeMeta.bg, color: themeMeta.fg }}>
171
- {/* 顶部栏 */}
172
- <div className={css.readerHeader} style={{ borderColor: themeMeta.dim }}>
173
- <button type="button" className={css.iconButton} title="返回书架" aria-label="返回书架" onClick={onBack}>←</button>
174
- <span className={css.readerTitle}>📖 《{project.bookName}》</span>
175
- <span className={css.meta} style={{ color: themeMeta.dim }}>
176
- {readable.length > 0 && currentNo > 0 ? `第 ${currentNo} 章 · ${idx + 1}/${readable.length}` : ''}
177
- </span>
178
- <button
179
- type="button"
180
- className={`${css.button} ${css.buttonSmall} ${navOpen ? css.buttonPrimary : ''}`}
181
- style={{ marginLeft: 4 }}
182
- onClick={() => { setNavOpen(v => { const next = !v; writePref(`${prefKey}.nav`, next); return next }) }}
183
- title={navOpen ? '收起目录' : '展开目录'}
184
- >
185
- ☰ 目录
186
- </button>
187
- <div style={{ flex: 1 }} />
188
- <span className={css.meta} style={{ color: themeMeta.dim }}>字号</span>
189
- <div className={css.readerSeg}>
190
- {FONT_SIZES.map(f => (
191
- <button
192
- key={f.id}
193
- type="button"
194
- className={`${css.readerSegBtn} ${font === f.id ? css.readerSegActive : ''}`}
195
- style={font === f.id ? { background: themeMeta.accent, color: themeMeta.bg } : undefined}
196
- onClick={() => { setFont(f.id); writePref(`${prefKey}.font`, f.id) }}
197
- title={`字号 ${f.label}`}
198
- >
199
- {f.label}
200
- </button>
201
- ))}
202
- </div>
203
- <div className={css.readerSeg} style={{ marginLeft: 8 }}>
204
- {THEMES.map(t => (
205
- <button
206
- key={t.id}
207
- type="button"
208
- className={`${css.readerSegBtn} ${theme === t.id ? css.readerSegActive : ''}`}
209
- style={theme === t.id ? { background: themeMeta.accent, color: themeMeta.bg } : undefined}
210
- onClick={() => { setTheme(t.id); writePref(`${prefKey}.theme`, t.id) }}
211
- title={t.label}
212
- >
213
- {t.label}
214
- </button>
215
- ))}
216
- </div>
217
- <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ marginLeft: 10 }} onClick={onOpenWorkspace} title="回到创作工作台">
218
- ✏️ 去工作台
219
- </button>
220
- </div>
221
-
222
- {/* 主体:左目录 + 右阅读区 */}
223
- <div className={css.readerBody}>
224
- {navOpen && (
225
- <aside className={css.readerSidebar} style={{ borderColor: themeMeta.dim, background: theme === 'night' ? 'rgba(0,0,0,0.25)' : 'rgba(255,255,255,0.35)' }} ref={sidebarRef}>
226
- {groups.length === 0 ? (
227
- <div className={css.meta} style={{ color: themeMeta.dim, padding: 12 }}>还没有已写章节,先去工作台创作吧</div>
228
- ) : groups.map(g => {
229
- const collapsed = collapsedVols.includes(g.no)
230
- return (
231
- <div key={g.no} className={css.readerGroup}>
232
- <button
233
- type="button"
234
- className={css.readerGroupTitle}
235
- style={{ color: themeMeta.accent }}
236
- onClick={() => { toggleVol(g.no) }}
237
- title={collapsed ? `展开${g.title}` : `收起${g.title}`}
238
- >
239
- <span className={css.readerGroupArrow}>{collapsed ? '▸' : '▾'}</span>
240
- <span>{g.title}</span>
241
- <span className={css.readerGroupCount}>{g.chapters.length}</span>
242
- </button>
243
- {!collapsed && g.chapters.map(ch => {
244
- const badge = statusBadge(ch)
245
- const active = ch.no === currentNo
246
- return (
247
- <button
248
- key={ch.no}
249
- type="button"
250
- data-no={ch.no}
251
- className={`${css.readerItem} ${active ? css.readerItemActive : ''}`}
252
- style={active ? { background: themeMeta.accent, color: themeMeta.bg, borderColor: themeMeta.accent } : { borderColor: 'transparent', color: themeMeta.fg }}
253
- onClick={() => { void load(ch.no) }}
254
- title={`第${ch.no}章 ${ch.title}`}
255
- >
256
- <span className={css.readerItemNo}>{ch.no}</span>
257
- <span className={css.readerItemTitle}>{ch.title}</span>
258
- {badge.label !== '' && <span className={css.readerItemBadge}>{badge.label}</span>}
259
- </button>
260
- )
261
- })}
262
- </div>
263
- )
264
- })}
265
- </aside>
266
- )}
267
-
268
- {/* 阅读区 */}
269
- <div className={css.readerMain} style={{ background: itemBg }}>
270
- {loadError !== '' ? (
271
- <div className={css.readerError}>{loadError}</div>
272
- ) : loading && markdown === '' ? (
273
- <div className={css.readerLoading}>⏳ 加载中…</div>
274
- ) : current !== undefined && currentNo > 0 ? (
275
- <article className={css.readerArticle} style={{ maxWidth: 720 }}>
276
- <h1 className={css.readerChapterTitle} style={{ color: themeMeta.accent }}>{current.title}</h1>
277
- <div className={css.readerChapterMeta} style={{ color: themeMeta.dim }}>
278
- <span>{statusBadge(current).label}</span>
279
- <span>第 {current.no} 章</span>
280
- {current.chars !== undefined && <span> · {current.chars.toLocaleString()} 字</span>}
281
- </div>
282
- <div className={css.readerText} style={{ fontSize: fontMeta.px, lineHeight: 1.9 }}>
283
- {paragraphs.map((p, i) => <p key={i} className={css.readerPara}>{p}</p>)}
284
- </div>
285
- <div className={css.readerFoot}>
286
- {prevCh !== undefined && (
287
- <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ borderColor: themeMeta.dim, color: themeMeta.fg }} onClick={() => { void load(prevCh.no) }} title="← 上一章">
288
- ← 第{prevCh.no}章
289
- </button>
290
- )}
291
- <span className={css.meta} style={{ color: themeMeta.dim }}>← → 键盘翻章</span>
292
- {nextCh !== undefined && (
293
- <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ borderColor: themeMeta.accent, color: themeMeta.accent }} onClick={() => { void load(nextCh.no) }} title="下一章 →">
294
- 第{nextCh.no}章 →
295
- </button>
296
- )}
297
- </div>
298
- </article>
299
- ) : (
300
- <div className={css.readerLoading}>暂无内容</div>
301
- )}
302
- </div>
303
- </div>
304
- </div>
305
- )
306
- }
1
+ /**
2
+ * 阅读页:沉浸式章节阅读(纯只读,零 LLM 消耗)。
3
+ * 布局:顶部工具栏 + 左侧可折叠目录栏(按卷分组)+ 阅读区。
4
+ * 轻量渲染:去掉 md 首行标题,按行分段;字号/主题/阅读进度 localStorage 记忆。
5
+ */
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
7
+ import type { NovelApi } from '../api.ts'
8
+ import type { ProjectState, ChapterPlan } from '../../protocol.ts'
9
+ import css from './panel.module.css'
10
+
11
+ const FONT_SIZES = [
12
+ { id: 'sm', px: 15, label: 'A-' },
13
+ { id: 'md', px: 17, label: 'A' },
14
+ { id: 'lg', px: 20, label: 'A+' },
15
+ ] as const
16
+
17
+ const THEMES = [
18
+ { id: 'paper', label: ' 纸白', bg: '#f7f1e3', fg: '#3b3226', accent: '#8b6f47', dim: '#8a7d66' },
19
+ { id: 'eye', label: ' 护眼', bg: '#e8f0e3', fg: '#2f3d2e', accent: '#5f7d5a', dim: '#6f836b' },
20
+ { id: 'night', label: ' 夜间', bg: '#1d2226', fg: '#c9d1d9', accent: '#8aa5c0', dim: '#6d7882' },
21
+ ] as const
22
+
23
+ type FontId = typeof FONT_SIZES[number]['id']
24
+ type ThemeId = typeof THEMES[number]['id']
25
+
26
+ function readPref<T>(key: string, fallback: T): T {
27
+ try {
28
+ const raw = window.localStorage.getItem(key)
29
+ if (raw !== null) return JSON.parse(raw) as T
30
+ } catch { /* ignore */ }
31
+ return fallback
32
+ }
33
+
34
+ function writePref(key: string, value: unknown): void {
35
+ try { window.localStorage.setItem(key, JSON.stringify(value)) } catch { /* ignore */ }
36
+ }
37
+
38
+ /** 章节状态小标(阅读视角)。 */
39
+ function statusBadge(ch: ChapterPlan): { label: string; cls: string } {
40
+ switch (ch.status) {
41
+ case 'approved': return { label: '', cls: css.badgeDone }
42
+ case 'written': return { label: '', cls: css.badgeWritten }
43
+ case 'rejected': return { label: '', cls: css.badgePending }
44
+ default: return { label: '', cls: css.badgePending }
45
+ }
46
+ }
47
+
48
+ /** 目录分组:按卷区间过滤可读章节;卷外章节归入「未分卷」。 */
49
+ function buildGroups(project: ProjectState, readable: ChapterPlan[]): Array<{ title: string; no: number; chapters: ChapterPlan[] }> {
50
+ const vols = project.volumes ?? []
51
+ if (vols.length === 0) return [{ title: '全部章节', no: 0, chapters: readable }]
52
+ const groups: Array<{ title: string; no: number; chapters: ChapterPlan[] }> = []
53
+ for (const v of vols) {
54
+ const inVol = readable.filter(c => c.no >= v.chapterStart && c.no <= v.chapterEnd)
55
+ if (inVol.length > 0) groups.push({ title: `第${v.no}卷 ${v.title}`, no: v.no, chapters: inVol })
56
+ }
57
+ const rest = readable.filter(c => !vols.some(v => c.no >= v.chapterStart && c.no <= v.chapterEnd))
58
+ if (rest.length > 0) groups.push({ title: '未分卷', no: 0, chapters: rest })
59
+ return groups
60
+ }
61
+
62
+ export function ReaderView({
63
+ api,
64
+ project,
65
+ onBack,
66
+ onOpenWorkspace,
67
+ }: {
68
+ api: NovelApi
69
+ project: ProjectState
70
+ onBack: () => void
71
+ onOpenWorkspace: () => void
72
+ }) {
73
+ const prefKey = useMemo(() => {
74
+ const id = project.bookName || 'book'
75
+ return `dsh-novel-forge.reader.${id}`
76
+ }, [project.bookName])
77
+
78
+ /** 可读章节(有正文落盘):approved / written / rejected。 */
79
+ const readable = useMemo(() => project.chapters.filter(c =>
80
+ c.status === 'approved' || c.status === 'written' || c.status === 'rejected',
81
+ ), [project.chapters])
82
+
83
+ const groups = useMemo(() => buildGroups(project, readable), [project, readable])
84
+
85
+ const [currentNo, setCurrentNo] = useState<number>(() => {
86
+ const saved = readPref<number | null>(`${prefKey}.no`, null)
87
+ if (saved !== null && readable.some(c => c.no === saved)) return saved
88
+ // 无记忆 → 最后一章已通过
89
+ const lastApproved = [...readable].reverse().find(c => c.status === 'approved')
90
+ return lastApproved?.no ?? readable[0]?.no ?? 0
91
+ })
92
+ const [markdown, setMarkdown] = useState<string>('')
93
+ const [loading, setLoading] = useState(false)
94
+ const [loadError, setLoadError] = useState<string>('')
95
+ const [font, setFont] = useState<FontId>(() => readPref(`${prefKey}.font`, 'md'))
96
+ const [theme, setTheme] = useState<ThemeId>(() => readPref(`${prefKey}.theme`, 'paper'))
97
+ const [navOpen, setNavOpen] = useState<boolean>(() => readPref(`${prefKey}.nav`, true))
98
+ /** 折叠的卷号(点击卷标题切换;记忆在 localStorage)。 */
99
+ const [collapsedVols, setCollapsedVols] = useState<number[]>(() => readPref(`${prefKey}.collapsed`, []))
100
+ const sidebarRef = useRef<HTMLDivElement>(null)
101
+
102
+ const toggleVol = useCallback((no: number) => {
103
+ setCollapsedVols(prev => {
104
+ const next = prev.includes(no) ? prev.filter(v => v !== no) : [...prev, no]
105
+ writePref(`${prefKey}.collapsed`, next)
106
+ return next
107
+ })
108
+ }, [prefKey])
109
+
110
+ const current = useMemo(() => project.chapters.find(c => c.no === currentNo), [project.chapters, currentNo])
111
+ const idx = readable.findIndex(c => c.no === currentNo)
112
+ const prevCh = idx > 0 ? readable[idx - 1] : undefined
113
+ const nextCh = idx >= 0 && idx < readable.length - 1 ? readable[idx + 1] : undefined
114
+
115
+ const load = useCallback(async (no: number) => {
116
+ if (no === 0) return
117
+ setLoading(true)
118
+ setLoadError('')
119
+ try {
120
+ const res = await api.chapter(no)
121
+ setMarkdown(res.markdown)
122
+ setCurrentNo(no)
123
+ writePref(`${prefKey}.no`, no)
124
+ } catch (err) {
125
+ setLoadError((err as Error).message)
126
+ } finally {
127
+ setLoading(false)
128
+ }
129
+ }, [api, prefKey])
130
+
131
+ // 初始加载
132
+ const initialNo = useRef<number>(0)
133
+ useEffect(() => {
134
+ if (initialNo.current === currentNo && markdown !== '') return
135
+ if (currentNo === 0) return
136
+ initialNo.current = currentNo
137
+ void load(currentNo)
138
+ }, [currentNo, load, markdown])
139
+
140
+ // 键盘翻章
141
+ useEffect(() => {
142
+ const onKey = (e: KeyboardEvent) => {
143
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
144
+ if (e.key === 'ArrowLeft' && prevCh !== undefined) void load(prevCh.no)
145
+ if (e.key === 'ArrowRight' && nextCh !== undefined) void load(nextCh.no)
146
+ }
147
+ window.addEventListener('keydown', onKey)
148
+ return () => window.removeEventListener('keydown', onKey)
149
+ }, [prevCh, nextCh, load])
150
+
151
+ // 当前章滚动到可见(目录栏内)
152
+ useEffect(() => {
153
+ sidebarRef.current?.querySelector<HTMLElement>(`[data-no="${currentNo}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
154
+ }, [currentNo])
155
+
156
+ const themeMeta = THEMES.find(t => t.id === theme) ?? THEMES[0]
157
+ const fontMeta = FONT_SIZES.find(f => f.id === font) ?? FONT_SIZES[1]
158
+
159
+ /** 轻量渲染:去标题行、按行分段(网文每行一段)。 */
160
+ const paragraphs = useMemo(() => {
161
+ return markdown
162
+ .split(/\r?\n/)
163
+ .map(l => l.replace(/^#{1,6}\s*/, '').trim())
164
+ .filter(Boolean)
165
+ }, [markdown])
166
+
167
+ const itemBg = theme === 'night' ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.025)'
168
+
169
+ return (
170
+ <div className={css.readerView} style={{ background: themeMeta.bg, color: themeMeta.fg }}>
171
+ {/* 顶部栏 */}
172
+ <div className={css.readerHeader} style={{ borderColor: themeMeta.dim }}>
173
+ <button type="button" className={css.iconButton} title="返回书架" aria-label="返回书架" onClick={onBack}>←</button>
174
+ <span className={css.readerTitle}> 《{project.bookName}》</span>
175
+ <span className={css.meta} style={{ color: themeMeta.dim }}>
176
+ {readable.length > 0 && currentNo > 0 ? `第 ${currentNo} 章 · ${idx + 1}/${readable.length}` : ''}
177
+ </span>
178
+ <button
179
+ type="button"
180
+ className={`${css.button} ${css.buttonSmall} ${navOpen ? css.buttonPrimary : ''}`}
181
+ style={{ marginLeft: 'var(--nf-space-4)' }}
182
+ onClick={() => { setNavOpen(v => { const next = !v; writePref(`${prefKey}.nav`, next); return next }) }}
183
+ title={navOpen ? '收起目录' : '展开目录'}
184
+ >
185
+ ☰ 目录
186
+ </button>
187
+ <div style={{ flex: 1 }} />
188
+ <span className={css.meta} style={{ color: themeMeta.dim }}>字号</span>
189
+ <div className={css.readerSeg}>
190
+ {FONT_SIZES.map(f => (
191
+ <button
192
+ key={f.id}
193
+ type="button"
194
+ className={`${css.readerSegBtn} ${font === f.id ? css.readerSegActive : ''}`}
195
+ style={font === f.id ? { background: themeMeta.accent, color: themeMeta.bg } : undefined}
196
+ onClick={() => { setFont(f.id); writePref(`${prefKey}.font`, f.id) }}
197
+ title={`字号 ${f.label}`}
198
+ >
199
+ {f.label}
200
+ </button>
201
+ ))}
202
+ </div>
203
+ <div className={css.readerSeg} style={{ marginLeft: 'var(--nf-space-8)' }}>
204
+ {THEMES.map(t => (
205
+ <button
206
+ key={t.id}
207
+ type="button"
208
+ className={`${css.readerSegBtn} ${theme === t.id ? css.readerSegActive : ''}`}
209
+ style={theme === t.id ? { background: themeMeta.accent, color: themeMeta.bg } : undefined}
210
+ onClick={() => { setTheme(t.id); writePref(`${prefKey}.theme`, t.id) }}
211
+ title={t.label}
212
+ >
213
+ {t.label}
214
+ </button>
215
+ ))}
216
+ </div>
217
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ marginLeft: 'var(--nf-space-10)' }} onClick={onOpenWorkspace} title="回到创作工作台">
218
+ 去工作台
219
+ </button>
220
+ </div>
221
+
222
+ {/* 主体:左目录 + 右阅读区 */}
223
+ <div className={css.readerBody}>
224
+ {navOpen && (
225
+ <aside className={css.readerSidebar} style={{ borderColor: themeMeta.dim, background: theme === 'night' ? 'rgba(0,0,0,0.25)' : 'rgba(255,255,255,0.35)' }} ref={sidebarRef}>
226
+ {groups.length === 0 ? (
227
+ <div className={css.meta} style={{ color: themeMeta.dim, padding: 'var(--nf-space-12)' }}>还没有已写章节,先去工作台创作吧</div>
228
+ ) : groups.map(g => {
229
+ const collapsed = collapsedVols.includes(g.no)
230
+ return (
231
+ <div key={g.no} className={css.readerGroup}>
232
+ <button
233
+ type="button"
234
+ className={css.readerGroupTitle}
235
+ style={{ color: themeMeta.accent }}
236
+ onClick={() => { toggleVol(g.no) }}
237
+ title={collapsed ? `展开${g.title}` : `收起${g.title}`}
238
+ >
239
+ <span className={css.readerGroupArrow}>{collapsed ? '▸' : '▾'}</span>
240
+ <span>{g.title}</span>
241
+ <span className={css.readerGroupCount}>{g.chapters.length}</span>
242
+ </button>
243
+ {!collapsed && g.chapters.map(ch => {
244
+ const badge = statusBadge(ch)
245
+ const active = ch.no === currentNo
246
+ return (
247
+ <button
248
+ key={ch.no}
249
+ type="button"
250
+ data-no={ch.no}
251
+ className={`${css.readerItem} ${active ? css.readerItemActive : ''}`}
252
+ style={active ? { background: themeMeta.accent, color: themeMeta.bg, borderColor: themeMeta.accent } : { borderColor: 'transparent', color: themeMeta.fg }}
253
+ onClick={() => { void load(ch.no) }}
254
+ title={`第${ch.no}章 ${ch.title}`}
255
+ >
256
+ <span className={css.readerItemNo}>{ch.no}</span>
257
+ <span className={css.readerItemTitle}>{ch.title}</span>
258
+ {badge.label !== '' && <span className={css.readerItemBadge}>{badge.label}</span>}
259
+ </button>
260
+ )
261
+ })}
262
+ </div>
263
+ )
264
+ })}
265
+ </aside>
266
+ )}
267
+
268
+ {/* 阅读区 */}
269
+ <div className={css.readerMain} style={{ background: itemBg }}>
270
+ {loadError !== '' ? (
271
+ <div className={css.readerError}>{loadError}</div>
272
+ ) : loading && markdown === '' ? (
273
+ <div className={css.readerLoading}>⏳ 加载中…</div>
274
+ ) : current !== undefined && currentNo > 0 ? (
275
+ <article className={css.readerArticle} style={{ maxWidth: 720 }}>
276
+ <h1 className={css.readerChapterTitle} style={{ color: themeMeta.accent }}>{current.title}</h1>
277
+ <div className={css.readerChapterMeta} style={{ color: themeMeta.dim }}>
278
+ <span>{statusBadge(current).label}</span>
279
+ <span>第 {current.no} 章</span>
280
+ {current.chars !== undefined && <span> · {current.chars.toLocaleString()} 字</span>}
281
+ </div>
282
+ <div className={css.readerText} style={{ fontSize: fontMeta.px, lineHeight: 1.9 }}>
283
+ {paragraphs.map((p, i) => <p key={i} className={css.readerPara}>{p}</p>)}
284
+ </div>
285
+ <div className={css.readerFoot}>
286
+ {prevCh !== undefined && (
287
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ borderColor: themeMeta.dim, color: themeMeta.fg }} onClick={() => { void load(prevCh.no) }} title="← 上一章">
288
+ ← 第{prevCh.no}章
289
+ </button>
290
+ )}
291
+ <span className={css.meta} style={{ color: themeMeta.dim }}>← → 键盘翻章</span>
292
+ {nextCh !== undefined && (
293
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ borderColor: themeMeta.accent, color: themeMeta.accent }} onClick={() => { void load(nextCh.no) }} title="下一章 →">
294
+ 第{nextCh.no}章 →
295
+ </button>
296
+ )}
297
+ </div>
298
+ </article>
299
+ ) : (
300
+ <div className={css.readerLoading}>暂无内容</div>
301
+ )}
302
+ </div>
303
+ </div>
304
+ </div>
305
+ )
306
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * 推理强度(思考强度)独立容器:写作类/分析类两个 reasoningEffort 下拉。
3
+ */
4
+ import { Brain } from 'lucide-react'
5
+ import type { NovelConfig } from '../../protocol.ts'
6
+ import { tt } from './helpers.ts'
7
+ import css from './panel.module.css'
8
+
9
+ const REASONING_OPTIONS = ['off', 'low', 'high', 'max'] as const
10
+ const REASONING_LABEL: Record<string, string> = {
11
+ off: tt('settings.reasoning.off'),
12
+ low: tt('settings.reasoning.low'),
13
+ high: tt('settings.reasoning.high'),
14
+ max: tt('settings.reasoning.max'),
15
+ }
16
+
17
+ export interface ReasoningSectionProps {
18
+ reasoningEffort: NovelConfig['reasoningEffort']
19
+ analysisReasoning: NovelConfig['analysisReasoning']
20
+ onChange: (patch: { reasoningEffort?: NovelConfig['reasoningEffort']; analysisReasoning?: NovelConfig['analysisReasoning'] }) => void
21
+ }
22
+
23
+ export function ReasoningSection({ reasoningEffort, analysisReasoning, onChange }: ReasoningSectionProps): JSX.Element {
24
+ return (
25
+ <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
26
+ <span className={css.cardTitle}><Brain size={18} style={{ verticalAlign: -3 }} /> {tt('settings.reasoningSection')}</span>
27
+ <div className={css.field}><label className={css.fieldLabel}>{tt('settings.reasoningEffort')}</label><select className={css.input} value={reasoningEffort ?? 'off'} onChange={e => onChange({ reasoningEffort: e.target.value as NovelConfig['reasoningEffort'] })}>{REASONING_OPTIONS.map(v => <option key={v} value={v}>{REASONING_LABEL[v]}</option>)}</select><span className={css.meta}>{tt('settings.reasoningHint')}</span></div>
28
+ <div className={css.field}><label className={css.fieldLabel}>{tt('settings.analysisReasoning')}</label><select className={css.input} value={analysisReasoning ?? 'low'} onChange={e => onChange({ analysisReasoning: e.target.value as NovelConfig['analysisReasoning'] })}>{REASONING_OPTIONS.map(v => <option key={v} value={v}>{REASONING_LABEL[v]}</option>)}</select><span className={css.meta}>{tt('settings.analysisReasoningHint')}</span></div>
29
+ </div>
30
+ )
31
+ }