@waterwx/dsh-novel-forge 1.2.0 → 1.2.1-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 (125) hide show
  1. package/README.md +237 -349
  2. package/lib/client.js +54447 -10560
  3. package/lib/index.js +12008 -5071
  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 +18 -3
  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 +147 -9
  11. package/lib/types/client/docx.d.ts +2 -0
  12. package/lib/types/client/index.d.ts +1 -1
  13. package/lib/types/client/llmLive.d.ts +33 -0
  14. package/lib/types/client/locales.d.ts +28 -11
  15. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  16. package/lib/types/client/panel/AiTag.d.ts +1 -0
  17. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  18. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  19. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  20. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  21. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  22. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  23. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  24. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  25. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  26. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  27. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  28. package/lib/types/client/panel/LiveFeedLog.d.ts +8 -0
  29. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  30. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  31. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  32. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  33. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  34. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  35. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  36. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  37. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  38. package/lib/types/client/panel/SubPage.d.ts +27 -0
  39. package/lib/types/client/panel/toast.d.ts +2 -0
  40. package/lib/types/client/panel/views.d.ts +25 -7
  41. package/lib/types/client/run-status.d.ts +19 -0
  42. package/lib/types/client/text.d.ts +9 -0
  43. package/lib/types/docx-core.d.ts +16 -0
  44. package/lib/types/docx.d.ts +2 -3
  45. package/lib/types/engine.d.ts +146 -40
  46. package/lib/types/global-assets.d.ts +6 -0
  47. package/lib/types/index.d.ts +18 -2
  48. package/lib/types/llm-live.d.ts +44 -0
  49. package/lib/types/market-radar-scan.d.ts +19 -0
  50. package/lib/types/market-radar-sources.d.ts +31 -0
  51. package/lib/types/migrate-output.d.ts +25 -0
  52. package/lib/types/novel-context.d.ts +80 -0
  53. package/lib/types/prompt-utils.d.ts +35 -0
  54. package/lib/types/prompting.d.ts +10 -0
  55. package/lib/types/protocol.d.ts +1029 -123
  56. package/lib/types/review-policy.d.ts +97 -0
  57. package/lib/types/routes.d.ts +4 -0
  58. package/lib/types/run.d.ts +3 -1
  59. package/lib/types/stage-contract.d.ts +60 -0
  60. package/package.json +51 -27
  61. package/scripts/patch-schemastery.mjs +47 -0
  62. package/src/action-guard.ts +127 -0
  63. package/src/ai-scan.ts +179 -0
  64. package/src/assets.ts +1774 -520
  65. package/src/assistant.ts +1082 -770
  66. package/src/author-assets.ts +149 -0
  67. package/src/bookshelf.ts +187 -151
  68. package/src/client/api.ts +684 -410
  69. package/src/client/docx.ts +37 -69
  70. package/src/client/index.ts +3 -1
  71. package/src/client/llmLive.ts +101 -0
  72. package/src/client/locales.ts +418 -383
  73. package/src/client/mount.tsx +1 -1
  74. package/src/client/panel/AdaptModeView.tsx +535 -0
  75. package/src/client/panel/AiTag.tsx +7 -0
  76. package/src/client/panel/AssetsTab.tsx +448 -458
  77. package/src/client/panel/AssistantTab.tsx +355 -301
  78. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  79. package/src/client/panel/AuthorHome.tsx +309 -0
  80. package/src/client/panel/BookAnalysisView.tsx +63 -0
  81. package/src/client/panel/CmdPalette.tsx +102 -0
  82. package/src/client/panel/CreateBookView.tsx +366 -348
  83. package/src/client/panel/DirectorView.tsx +200 -0
  84. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  85. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  86. package/src/client/panel/ImportModal.tsx +303 -0
  87. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  88. package/src/client/panel/LiveFeedLog.tsx +67 -0
  89. package/src/client/panel/MarketRadarView.tsx +377 -0
  90. package/src/client/panel/ModelManager.tsx +205 -0
  91. package/src/client/panel/NovelPanel.tsx +5403 -4597
  92. package/src/client/panel/PageHeader.tsx +37 -0
  93. package/src/client/panel/ProgressConsole.tsx +63 -0
  94. package/src/client/panel/ReaderView.tsx +306 -306
  95. package/src/client/panel/ReasoningSection.tsx +31 -0
  96. package/src/client/panel/RunPanel.tsx +223 -204
  97. package/src/client/panel/SettingsView.tsx +305 -0
  98. package/src/client/panel/ShelfView.tsx +266 -236
  99. package/src/client/panel/SubPage.tsx +42 -0
  100. package/src/client/panel/WorldTab.tsx +202 -202
  101. package/src/client/panel/panel.module.css +5764 -2941
  102. package/src/client/panel/toast.ts +51 -0
  103. package/src/client/panel/views.tsx +401 -314
  104. package/src/client/run-status.ts +143 -0
  105. package/src/client/sidebar-entry.ts +1 -1
  106. package/src/client/text.ts +28 -0
  107. package/src/docx-core.ts +53 -0
  108. package/src/docx.ts +44 -83
  109. package/src/engine.ts +4231 -2787
  110. package/src/global-assets.ts +59 -0
  111. package/src/index.ts +311 -252
  112. package/src/llm-live.ts +68 -0
  113. package/src/market-radar-scan.ts +41 -0
  114. package/src/market-radar-sources.ts +173 -0
  115. package/src/migrate-output.ts +76 -0
  116. package/src/novel-context.ts +286 -0
  117. package/src/prompting.ts +26 -0
  118. package/src/protocol.ts +2072 -1182
  119. package/src/review-policy.ts +161 -0
  120. package/src/routes.ts +3405 -2151
  121. package/src/run.ts +342 -330
  122. package/src/stage-contract.ts +189 -0
  123. package/lib/client.js.map +0 -1
  124. package/lib/index.js.map +0 -1
  125. package/src/client/panel/BookshelfBar.tsx +0 -170
@@ -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
+ }