@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.
- package/README.md +288 -279
- package/lib/client.js +56309 -12221
- package/lib/index.js +14239 -5595
- package/lib/types/action-guard.d.ts +61 -0
- package/lib/types/ai-scan.d.ts +36 -0
- package/lib/types/assets.d.ts +24 -9
- package/lib/types/assistant.d.ts +29 -0
- package/lib/types/author-assets.d.ts +25 -0
- package/lib/types/bookshelf.d.ts +11 -1
- package/lib/types/client/api.d.ts +186 -10
- package/lib/types/client/chip-prefs.d.ts +36 -0
- package/lib/types/client/docx.d.ts +2 -0
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/llmLive.d.ts +31 -0
- package/lib/types/client/locales.d.ts +28 -67
- package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
- package/lib/types/client/panel/AiTag.d.ts +1 -0
- package/lib/types/client/panel/AssistantTab.d.ts +4 -1
- package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
- package/lib/types/client/panel/AuthorHome.d.ts +34 -0
- package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
- package/lib/types/client/panel/CmdPalette.d.ts +12 -0
- package/lib/types/client/panel/CreateBookView.d.ts +5 -1
- package/lib/types/client/panel/DirectorView.d.ts +7 -0
- package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
- package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
- package/lib/types/client/panel/ImportModal.d.ts +7 -0
- package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
- package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
- package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
- package/lib/types/client/panel/ModelManager.d.ts +14 -0
- package/lib/types/client/panel/NovelPanel.d.ts +6 -1
- package/lib/types/client/panel/PageHeader.d.ts +18 -0
- package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
- package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
- package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
- package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
- package/lib/types/client/panel/SettingsView.d.ts +19 -0
- package/lib/types/client/panel/ShelfView.d.ts +12 -1
- package/lib/types/client/panel/SubPage.d.ts +27 -0
- package/lib/types/client/panel/TensionView.d.ts +18 -0
- package/lib/types/client/panel/TimelineView.d.ts +18 -0
- package/lib/types/client/panel/helpers.d.ts +5 -3
- package/lib/types/client/panel/toast.d.ts +2 -0
- package/lib/types/client/panel/views.d.ts +25 -7
- package/lib/types/client/run-status.d.ts +21 -0
- package/lib/types/client/text.d.ts +9 -0
- package/lib/types/docx-core.d.ts +16 -0
- package/lib/types/docx.d.ts +2 -3
- package/lib/types/engine.d.ts +186 -45
- package/lib/types/global-assets.d.ts +6 -0
- package/lib/types/index.d.ts +22 -8
- package/lib/types/llm-live.d.ts +75 -0
- package/lib/types/llm-retry.d.ts +38 -0
- package/lib/types/market-radar-scan.d.ts +19 -0
- package/lib/types/market-radar-sources.d.ts +31 -0
- package/lib/types/migrate-output.d.ts +25 -0
- package/lib/types/novel-context.d.ts +80 -0
- package/lib/types/prompt-slots.d.ts +57 -0
- package/lib/types/prompt-utils.d.ts +35 -0
- package/lib/types/prompting.d.ts +10 -0
- package/lib/types/protocol.d.ts +1362 -166
- package/lib/types/review-policy.d.ts +97 -0
- package/lib/types/revision.d.ts +76 -0
- package/lib/types/routes.d.ts +4 -0
- package/lib/types/run.d.ts +23 -1
- package/lib/types/snapshots.d.ts +46 -0
- package/lib/types/stage-contract.d.ts +60 -0
- package/lib/types/tension.d.ts +53 -0
- package/lib/types/timeline.d.ts +37 -0
- package/package.json +52 -28
- package/src/action-guard.ts +127 -0
- package/src/ai-scan.ts +179 -0
- package/src/assets.ts +1806 -520
- package/src/assistant.ts +1098 -770
- package/src/author-assets.ts +149 -0
- package/src/bookshelf.ts +187 -151
- package/src/client/api.ts +378 -21
- package/src/client/chip-prefs.ts +121 -0
- package/src/client/docx.ts +37 -69
- package/src/client/index.ts +3 -1
- package/src/client/llmLive.ts +111 -0
- package/src/client/locales.ts +28 -253
- package/src/client/mount.tsx +1 -1
- package/src/client/panel/AdaptModeView.tsx +535 -0
- package/src/client/panel/AiTag.tsx +7 -0
- package/src/client/panel/AssetsTab.tsx +448 -458
- package/src/client/panel/AssistantTab.tsx +355 -301
- package/src/client/panel/AuthorAssetsView.tsx +206 -0
- package/src/client/panel/AuthorHome.tsx +309 -0
- package/src/client/panel/BookAnalysisView.tsx +63 -0
- package/src/client/panel/CmdPalette.tsx +102 -0
- package/src/client/panel/CreateBookView.tsx +366 -348
- package/src/client/panel/DirectorView.tsx +200 -0
- package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
- package/src/client/panel/IdeaInspirationView.tsx +76 -0
- package/src/client/panel/ImportModal.tsx +303 -0
- package/src/client/panel/KnowledgeBaseView.tsx +125 -0
- package/src/client/panel/LiveFeedLog.tsx +204 -0
- package/src/client/panel/MarketRadarView.tsx +377 -0
- package/src/client/panel/ModelManager.tsx +205 -0
- package/src/client/panel/NovelPanel.tsx +3135 -2651
- package/src/client/panel/PageHeader.tsx +37 -0
- package/src/client/panel/ProgressConsole.tsx +63 -0
- package/src/client/panel/PromptSlotsView.tsx +97 -0
- package/src/client/panel/ReaderView.tsx +306 -306
- package/src/client/panel/ReasoningSection.tsx +31 -0
- package/src/client/panel/RunPanel.tsx +64 -32
- package/src/client/panel/SettingsView.tsx +354 -0
- package/src/client/panel/ShelfView.tsx +266 -236
- package/src/client/panel/SubPage.tsx +42 -0
- package/src/client/panel/TensionView.tsx +212 -0
- package/src/client/panel/TimelineView.tsx +208 -0
- package/src/client/panel/WorldTab.tsx +202 -202
- package/src/client/panel/helpers.ts +7 -5
- package/src/client/panel/panel.module.css +4481 -1655
- package/src/client/panel/toast.ts +51 -0
- package/src/client/panel/views.tsx +401 -314
- package/src/client/run-status.ts +171 -0
- package/src/client/sidebar-entry.ts +1 -1
- package/src/client/text.ts +28 -0
- package/src/docx-core.ts +53 -0
- package/src/docx.ts +44 -83
- package/src/engine.ts +4672 -2943
- package/src/global-assets.ts +59 -0
- package/src/index.ts +89 -33
- package/src/llm-live.ts +260 -0
- package/src/llm-retry.ts +77 -0
- package/src/market-radar-scan.ts +41 -0
- package/src/market-radar-sources.ts +173 -0
- package/src/migrate-output.ts +76 -0
- package/src/novel-context.ts +286 -0
- package/src/prompt-slots.ts +125 -0
- package/src/prompting.ts +26 -0
- package/src/protocol.ts +1388 -158
- package/src/review-policy.ts +161 -0
- package/src/revision.ts +283 -0
- package/src/routes.ts +1934 -434
- package/src/run.ts +118 -21
- package/src/snapshots.ts +174 -0
- package/src/stage-contract.ts +189 -0
- package/src/tension.ts +251 -0
- package/src/timeline.ts +159 -0
- package/lib/client.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types/comic-presets.d.ts +0 -12
- package/scripts/assistant-frames.txt +0 -8
- package/scripts/diagnose-adapter.mts +0 -81
- package/scripts/diagnose-assistant.mjs +0 -58
- package/scripts/diagnose-bible.mjs +0 -72
- package/scripts/diagnose-harness.mts +0 -75
- package/scripts/novel-forge-restart.log +0 -10
- package/scripts/novel-forge-web.stderr.log +0 -2
- package/scripts/novel-forge-web.stdout.log +0 -1
- package/scripts/outline-sample.txt +0 -449
- package/scripts/probe.mjs +0 -6
- package/scripts/restart-web.ps1 +0 -88
- package/scripts/smoke.mts +0 -72
- package/scripts/upload-github.ps1 +0 -51
- package/src/client/panel/BookshelfBar.tsx +0 -170
- 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: '
|
|
19
|
-
{ id: 'eye', label: '
|
|
20
|
-
{ id: 'night', label: '
|
|
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: '
|
|
42
|
-
case 'written': return { label: '
|
|
43
|
-
case 'rejected': return { label: '
|
|
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}
|
|
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
|
+
}
|