@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,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 生产单面板:区间批量生产(计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑)。
|
|
3
|
-
*
|
|
3
|
+
* 与「AI进度」悬浮窗不同:这是标准流水线设备,一键下单、实时进度、日志可查。
|
|
4
4
|
*/
|
|
5
5
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
6
6
|
import type { NovelApi } from '../api.ts'
|
|
7
7
|
import type { RunState } from '../../protocol.ts'
|
|
8
8
|
import css from './panel.module.css'
|
|
9
|
+
import { PageHeader } from './PageHeader.tsx'
|
|
9
10
|
|
|
10
11
|
export function RunPanel({ api, totalChapters }: { api: NovelApi; totalChapters: number }) {
|
|
11
12
|
const [run, setRun] = useState<RunState | null>(null)
|
|
@@ -42,7 +43,6 @@ export function RunPanel({ api, totalChapters }: { api: NovelApi; totalChapters:
|
|
|
42
43
|
logRef.current?.scrollTo({ top: logRef.current.scrollHeight })
|
|
43
44
|
}, [run?.log.length])
|
|
44
45
|
|
|
45
|
-
const effectiveEnd = mode === 'count' ? startNo + count - 1 : endNo
|
|
46
46
|
|
|
47
47
|
const handleStart = async (): Promise<void> => {
|
|
48
48
|
setBusy(true)
|
|
@@ -76,11 +76,11 @@ export function RunPanel({ api, totalChapters }: { api: NovelApi; totalChapters:
|
|
|
76
76
|
const statusLabel = useMemo(() => {
|
|
77
77
|
if (run === null) return '未启动'
|
|
78
78
|
return {
|
|
79
|
-
running: '
|
|
79
|
+
running: ' 生产中',
|
|
80
80
|
paused: '⏸ 已暂停',
|
|
81
|
-
done: '
|
|
81
|
+
done: ' 已完成',
|
|
82
82
|
stopped: '⏹ 已停止',
|
|
83
|
-
error: '
|
|
83
|
+
error: ' 异常',
|
|
84
84
|
}[run.status] ?? run.status
|
|
85
85
|
}, [run])
|
|
86
86
|
|
|
@@ -89,19 +89,49 @@ export function RunPanel({ api, totalChapters }: { api: NovelApi; totalChapters:
|
|
|
89
89
|
: 0
|
|
90
90
|
|
|
91
91
|
return (
|
|
92
|
-
<div className={css.card} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
93
|
-
<
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
92
|
+
<div className={css.card} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)' }}>
|
|
93
|
+
<PageHeader
|
|
94
|
+
eyebrow="Production"
|
|
95
|
+
title="生产单"
|
|
96
|
+
actions={(
|
|
97
|
+
<span className={`${css.badge} ${run?.status === 'running' ? css.badgeWritten : run?.status === 'done' ? css.badgeDone : css.badgePending}`}>{statusLabel}</span>
|
|
98
|
+
)}
|
|
99
|
+
/>
|
|
100
|
+
{run === null || run.status !== 'running' ? (
|
|
101
|
+
<>
|
|
102
|
+
<span className={css.meta}>标准流水线:计划 → 生成 → 审稿 → 分级处理。一键下单,自动补计划,逐章过质量门(生成→摘要+事实→审稿→作者复盘),被拒章分级处理,中断可断点续跑。</span>
|
|
103
|
+
{/* 运行约束说明:并发/绑定/暂停语义/同章冲突,都是操作上会踩的点,放在下单区之前 */}
|
|
104
|
+
<details className={css.meta} style={{ fontSize: 'var(--nf-fs-12)' }}>
|
|
105
|
+
<summary style={{ cursor: 'pointer' }}>运行说明(并发 · 绑定书目 · 暂停续跑)</summary>
|
|
106
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 4, lineHeight: 1.6 }}>
|
|
107
|
+
<span>· <b>一次一本</b>:生产单是全局单实例,逐章串行;换书不会打断,也不会切换到别的批次。</span>
|
|
108
|
+
<span>· <b>绑定下单时的书</b>:本批次只读写下单时那本书的目录,与当前激活书无关。</span>
|
|
109
|
+
<span>· 起点默认 = 最后一章 +1;区间内<b>已通过审稿</b>的章会自动快进跳过。</span>
|
|
110
|
+
<span>· <b>暂停</b>可随时续跑(从断点章继续);<b>停止</b>会终结本批次,再次下单即新建批次。</span>
|
|
111
|
+
<span>· ⚠️ 批次运行中仍可手动生成/修订<b>其它章</b>,但请勿对正在处理的<b>同一章</b>同时手动操作——两个写入会互相覆盖。</span>
|
|
112
|
+
</div>
|
|
113
|
+
</details>
|
|
114
|
+
</>
|
|
115
|
+
) : null}
|
|
116
|
+
|
|
117
|
+
{/* v4 B4 控制台折叠:生产中下单区收起为一行状态摘要,进度与日志成为主体。 */}
|
|
118
|
+
{run !== null && run.status === 'running' ? (
|
|
119
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', gap: 'var(--nf-space-8)' }}>
|
|
120
|
+
<span className={css.meta}> 生产中 · 第 {run.startNo}-{run.endNo} 章 · 当前第 {run.currentNo} 章 · {Math.round(ratio * 100)}%</span>
|
|
121
|
+
<div className={css.row} style={{ gap: 'var(--nf-space-6)' }}>
|
|
122
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleControl('pause') }}>
|
|
123
|
+
⏸ 暂停
|
|
124
|
+
</button>
|
|
125
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleControl('stop') }}>
|
|
126
|
+
⏹ 停止
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
) : (
|
|
131
|
+
<>
|
|
102
132
|
{/* 下单区 */}
|
|
103
|
-
<div className={css.row} style={{ flexWrap: 'wrap', gap: 8, alignItems: 'flex-end' }}>
|
|
104
|
-
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
133
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-8)', alignItems: 'flex-end' }}>
|
|
134
|
+
<div style={{ display: 'flex', gap: 'var(--nf-space-4)', alignItems: 'center' }}>
|
|
105
135
|
<button
|
|
106
136
|
type="button"
|
|
107
137
|
className={`${css.button} ${css.buttonSmall} ${mode === 'count' ? css.buttonPrimary : ''}`}
|
|
@@ -161,40 +191,42 @@ export function RunPanel({ api, totalChapters }: { api: NovelApi; totalChapters:
|
|
|
161
191
|
</button>
|
|
162
192
|
)}
|
|
163
193
|
</div>
|
|
194
|
+
</>
|
|
195
|
+
)}
|
|
164
196
|
|
|
165
|
-
{err !== '' && <div style={{ color: 'var(--nf-error)', fontSize: 12 }}>{err}</div>}
|
|
197
|
+
{err !== '' && <div style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-fs-12)' }}>{err}</div>}
|
|
166
198
|
|
|
167
199
|
{/* 进度区 */}
|
|
168
200
|
{run !== null && (
|
|
169
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
170
|
-
<div className={css.row} style={{ flexWrap: 'wrap', gap: 8 }}>
|
|
171
|
-
<span className={css.meta}
|
|
172
|
-
<span className={css.meta}>新生成 {run.stats
|
|
201
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
|
|
202
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-8)' }}>
|
|
203
|
+
<span className={css.meta}>{run.bookName !== undefined && run.bookName !== '' ? `本书:《${run.bookName}》 · ` : ''}范围:第 {run.startNo} - {run.endNo} 章 · 当前:第 {run.currentNo} 章</span>
|
|
204
|
+
<span className={css.meta}>新生成 {run.stats?.generated ?? 0} · 修订通过 {run.stats?.revised ?? 0} · 豁免 {run.stats?.exempted ?? 0} · 重生成 {run.stats?.regenerated ?? 0} · 失败 {run.stats?.error ?? 0}</span>
|
|
173
205
|
</div>
|
|
174
206
|
<div className={css.bigProgressBar}>
|
|
175
207
|
<div className={css.bigProgressBarFill} style={{ width: `${Math.round(ratio * 100)}%` }} />
|
|
176
208
|
</div>
|
|
177
209
|
{run.pendingManual.length > 0 && (
|
|
178
210
|
<div className={css.meta} style={{ color: 'var(--nf-warn)' }}>
|
|
179
|
-
|
|
211
|
+
待人工:第 {run.pendingManual.join('、')} 章(两轮修订仍不过,保留草稿,可在章节列表处理)
|
|
180
212
|
</div>
|
|
181
213
|
)}
|
|
182
|
-
{run.status === 'done' && <div className={css.meta} style={{ color: 'var(--nf-success)' }}
|
|
214
|
+
{run.status === 'done' && <div className={css.meta} style={{ color: 'var(--nf-success)' }}> 生产单完成:{run.startNo}-{run.endNo} 章处理完毕,待人工 {run.pendingManual.length} 章。</div>}
|
|
183
215
|
{run.error !== undefined && <div className={css.meta} style={{ color: 'var(--nf-error)' }}>异常:{run.error}</div>}
|
|
184
216
|
</div>
|
|
185
217
|
)}
|
|
186
218
|
|
|
187
|
-
{/*
|
|
188
|
-
<div className={css.meta} style={{
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
>
|
|
219
|
+
{/* 日志区(终端风) */}
|
|
220
|
+
<div className={`${css.meta} ${css.titleBlock}`} style={{ gap: 0 }}>
|
|
221
|
+
<span className={css.eyebrow}>Run Log</span>
|
|
222
|
+
<span className={css.cardTitle}>运行日志({run?.log.length ?? 0})</span>
|
|
223
|
+
</div>
|
|
224
|
+
<div ref={logRef} className={css.runLog}>
|
|
193
225
|
{run === null || run.log.length === 0 ? (
|
|
194
226
|
<span className={css.meta}>尚无日志——下单后这里会实时显示每章进度。</span>
|
|
195
227
|
) : run.log.map((l, i) => (
|
|
196
|
-
<div key={i}
|
|
197
|
-
<span
|
|
228
|
+
<div key={i} className={css.runLogLine}>
|
|
229
|
+
<span className={css.runLogTime}>{new Date(l.at).toLocaleTimeString('zh-CN', { hour12: false })}</span>
|
|
198
230
|
<span>{l.text}</span>
|
|
199
231
|
</div>
|
|
200
232
|
))}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享设置页(作者首页「设置」与书内「设置」同入口)。
|
|
3
|
+
* 自给自足:挂载时通过 api.status() 拉取全局配置,支持 model/writing/image/files/appearance 五组,
|
|
4
|
+
* 保存走 api.patchConfig。不与某个书绑定,因此新装用户无书也能用。
|
|
5
|
+
*/
|
|
6
|
+
import { useEffect, useRef, useState } from 'react'
|
|
7
|
+
import { Brain, Folder, Sparkles, RotateCcw, Upload, X, Image } from 'lucide-react'
|
|
8
|
+
import type { NovelApi } from '../api.ts'
|
|
9
|
+
import type { MoveOutputDirResponse, NovelConfig } from '../../protocol.ts'
|
|
10
|
+
import { tt } from './helpers.ts'
|
|
11
|
+
import { RUN_CHIP_MODE_OPTIONS, readRunChipMode, writeRunChipMode, type RunChipMode } from '../chip-prefs.ts'
|
|
12
|
+
import { ModelManager } from './ModelManager.tsx'
|
|
13
|
+
import { ReasoningSection } from './ReasoningSection.tsx'
|
|
14
|
+
import { SubPage } from './SubPage.tsx'
|
|
15
|
+
import css from './panel.module.css'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
const SETTINGS_SECTIONS: Array<{ id: 'model' | 'files' | 'appearance'; label: string; icon: JSX.Element }> = [
|
|
19
|
+
{ id: 'model', label: '模型', icon: <Brain size={16} /> },
|
|
20
|
+
{ id: 'files', label: '路径与文件', icon: <Folder size={16} /> },
|
|
21
|
+
{ id: 'appearance', label: '外观与主题', icon: <Sparkles size={16} /> },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
type SettingsTab = 'model' | 'files' | 'appearance'
|
|
25
|
+
type ThemeMode = 'system' | 'light' | 'dark'
|
|
26
|
+
type ThemeDensity = 'comfort' | 'compact' | 'spacious'
|
|
27
|
+
|
|
28
|
+
function readLS<T extends string>(key: string, fallback: T, allowed: readonly T[]): T {
|
|
29
|
+
try {
|
|
30
|
+
const v = window.localStorage.getItem(key) as T | null
|
|
31
|
+
return v !== null && (allowed as readonly string[]).includes(v) ? v : fallback
|
|
32
|
+
} catch { return fallback }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function SettingsView({ api, variant = 'page', onSettingsTab, onEditorFontSize, onBackground, themeMode, themeDensity, onChangeThemeMode, onChangeThemeDensity }: {
|
|
36
|
+
api: NovelApi;
|
|
37
|
+
/** page = 全屏 SubPage(保留给旧入口);drawer = 右滑抽屉内容(无壳)。 */
|
|
38
|
+
variant?: 'page' | 'drawer';
|
|
39
|
+
onSettingsTab?: (tab: SettingsTab) => void;
|
|
40
|
+
onEditorFontSize?: (n: number) => void;
|
|
41
|
+
onBackground?: (bg: string | undefined, blur: number) => void;
|
|
42
|
+
/** 全局显示模式(联动整个面板,NovelPanel 落 localStorage)。 */
|
|
43
|
+
themeMode: 'system' | 'light' | 'dark';
|
|
44
|
+
/** 全局界面密度。 */
|
|
45
|
+
themeDensity: 'comfort' | 'compact' | 'spacious';
|
|
46
|
+
/** 改变全局显示模式。 */
|
|
47
|
+
onChangeThemeMode: (m: 'system' | 'light' | 'dark') => void;
|
|
48
|
+
/** 改变全局界面密度。 */
|
|
49
|
+
onChangeThemeDensity: (d: 'comfort' | 'compact' | 'spacious') => void;
|
|
50
|
+
}) {
|
|
51
|
+
/** 右上角生成状态芯片的显示偏好(三态;存 localStorage,选择即时生效)。 */
|
|
52
|
+
const [runChipMode, setRunChipMode] = useState<RunChipMode>(() => readRunChipMode())
|
|
53
|
+
const [config, setConfig] = useState<NovelConfig | null>(null);
|
|
54
|
+
const [configDraft, setConfigDraft] = useState<NovelConfig | null>(null);
|
|
55
|
+
const [busy, setBusy] = useState(false);
|
|
56
|
+
const [error, setError] = useState('');
|
|
57
|
+
const [notice, setNotice] = useState('');
|
|
58
|
+
// —— 输出目录迁移弹窗 ——
|
|
59
|
+
const [moveOpen, setMoveOpen] = useState(false);
|
|
60
|
+
const [moveTarget, setMoveTarget] = useState('');
|
|
61
|
+
const [movePreview, setMovePreview] = useState<MoveOutputDirResponse | null>(null);
|
|
62
|
+
const [moveBusy, setMoveBusy] = useState(false);
|
|
63
|
+
const [moveError, setMoveError] = useState('');
|
|
64
|
+
const [settingsTab, setSettingsTabState] = useState<SettingsTab>(() => readLS<SettingsTab>('dsh-novel-forge.settings.tab', 'model', ['model','files','appearance'] as const));
|
|
65
|
+
const [editorFontSize, setEditorFontSize] = useState<number>(() => { try { const v = Number(window.localStorage.getItem('dsh-novel-forge.editor.fontSize')); return v >= 12 && v <= 24 ? v : 14 } catch { return 14 } });
|
|
66
|
+
const loadConfig = async (): Promise<void> => {
|
|
67
|
+
try {
|
|
68
|
+
const res = await api.status();
|
|
69
|
+
setConfig(res.config);
|
|
70
|
+
setConfigDraft(res.config);
|
|
71
|
+
} catch (err) { setError((err as Error).message) }
|
|
72
|
+
};
|
|
73
|
+
useEffect(() => { void loadConfig() }, []);
|
|
74
|
+
|
|
75
|
+
useEffect(() => { try { window.localStorage.setItem('dsh-novel-forge.settings.tab', settingsTab) } catch { /* ignore */ } }, [settingsTab]);
|
|
76
|
+
|
|
77
|
+
const changeSettingsTab = (next: SettingsTab): void => { setSettingsTabState(next); onSettingsTab?.(next) };
|
|
78
|
+
|
|
79
|
+
/** 打开输出目录迁移弹窗:先 dryRun 预览当前目录内容。 */
|
|
80
|
+
const openMoveDialog = async (): Promise<void> => {
|
|
81
|
+
setMoveOpen(true); setMoveTarget(''); setMovePreview(null); setMoveError('');
|
|
82
|
+
try { setMovePreview(await api.moveOutputDir(undefined, true)) } catch (err) { setMoveError((err as Error).message) }
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** 确认迁移:搬迁文件 → 服务端联动书架/默认目录 → 刷新配置。 */
|
|
86
|
+
const confirmMove = async (): Promise<void> => {
|
|
87
|
+
const to = moveTarget.trim();
|
|
88
|
+
if (to === '') { setMoveError('请填写新目录的完整路径'); return }
|
|
89
|
+
setMoveBusy(true); setMoveError('');
|
|
90
|
+
try {
|
|
91
|
+
const r = await api.moveOutputDir(to);
|
|
92
|
+
setMoveOpen(false);
|
|
93
|
+
setNotice(`目录迁移完成:${r.files.length} 个条目 → ${r.to}${r.movedBooks > 0 ? `(书架联动重指向 ${r.movedBooks} 本)` : ''}`);
|
|
94
|
+
await loadConfig();
|
|
95
|
+
} catch (err) { setMoveError((err as Error).message) } finally { setMoveBusy(false) }
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const formatBytes = (n: number): string => n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`;
|
|
99
|
+
|
|
100
|
+
const save = async (): Promise<void> => {
|
|
101
|
+
if (configDraft === null) return;
|
|
102
|
+
setBusy(true); setError(''); setNotice('');
|
|
103
|
+
try {
|
|
104
|
+
const result = await api.patchConfig({
|
|
105
|
+
outlinePath: configDraft.outlinePath,
|
|
106
|
+
outputDir: configDraft.outputDir,
|
|
107
|
+
provider: configDraft.provider,
|
|
108
|
+
model: configDraft.model,
|
|
109
|
+
generateModel: configDraft.generateModel,
|
|
110
|
+
reviewModel: configDraft.reviewModel,
|
|
111
|
+
auditModel: configDraft.auditModel,
|
|
112
|
+
fallbackModel: configDraft.fallbackModel ?? '',
|
|
113
|
+
autoTimeline: configDraft.autoTimeline ?? true,
|
|
114
|
+
reasoningEffort: configDraft.reasoningEffort ?? 'off',
|
|
115
|
+
chapterChars: configDraft.chapterChars,
|
|
116
|
+
maxTokens: configDraft.maxTokens,
|
|
117
|
+
reviewPassScore: configDraft.reviewPassScore,
|
|
118
|
+
autoReview: configDraft.autoReview,
|
|
119
|
+
autoAuthorReview: configDraft.autoAuthorReview,
|
|
120
|
+
autoReviewAfterRevise: configDraft.autoReviewAfterRevise,
|
|
121
|
+
themeBackground: configDraft.themeBackground ?? '',
|
|
122
|
+
themeBackgroundBlur: configDraft.themeBackgroundBlur ?? 0,
|
|
123
|
+
savedModels: configDraft.savedModels ?? [],
|
|
124
|
+
});
|
|
125
|
+
setConfig(result.config);
|
|
126
|
+
setConfigDraft(result.config);
|
|
127
|
+
setNotice(tt('settings.saved'));
|
|
128
|
+
} catch (err) { setError((err as Error).message) } finally { setBusy(false) }
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// 显示模式/密度由全局(NovelPanel)统一管理,设置页只调用 onChange 回调。
|
|
132
|
+
const changeEditorFont = (n: number): void => {
|
|
133
|
+
const v = Math.min(24, Math.max(12, n)); setEditorFontSize(v);
|
|
134
|
+
try { window.localStorage.setItem('dsh-novel-forge.editor.fontSize', String(v)) } catch { /* ignore */ }
|
|
135
|
+
onEditorFontSize?.(v);
|
|
136
|
+
};
|
|
137
|
+
const bgFileRef = useRef<HTMLInputElement | null>(null);
|
|
138
|
+
const applyBackground = (bg: string | undefined): void => {
|
|
139
|
+
setConfigDraft(prev => prev === null ? prev : { ...prev, themeBackground: bg ?? '' });
|
|
140
|
+
onBackground?.(bg ?? undefined, configDraft?.themeBackgroundBlur ?? 0);
|
|
141
|
+
};
|
|
142
|
+
const applyBlur = (blur: number): void => {
|
|
143
|
+
setConfigDraft(prev => prev === null ? prev : { ...prev, themeBackgroundBlur: blur });
|
|
144
|
+
onBackground?.(configDraft?.themeBackground ?? undefined, blur);
|
|
145
|
+
};
|
|
146
|
+
const onBgFile = async (file: File | undefined): Promise<void> => {
|
|
147
|
+
if (file === undefined) return;
|
|
148
|
+
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
149
|
+
const fr = new FileReader();
|
|
150
|
+
fr.onload = () => resolve(fr.result as string);
|
|
151
|
+
fr.onerror = () => reject(fr.error ?? new Error('读取失败'));
|
|
152
|
+
fr.readAsDataURL(file);
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
// 上传到服务端存盘,返回可访问 URL(不往 settings.yaml 塞大 base64)。
|
|
156
|
+
const res = await api.themeBackgroundUpload(dataUrl);
|
|
157
|
+
applyBackground(res.url);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
setError((err as Error).message);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const resetTheme = (): void => {
|
|
163
|
+
onChangeThemeMode('system');
|
|
164
|
+
onChangeThemeDensity('comfort');
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (configDraft === null) {
|
|
168
|
+
if (variant === 'drawer') {
|
|
169
|
+
return <div style={{ padding: 'var(--nf-space-16)' }}><div className={css.meta}>正在加载设置…</div>{error !== '' && <div className={css.noticeError}>{error}</div>}</div>
|
|
170
|
+
}
|
|
171
|
+
return (
|
|
172
|
+
<SubPage title="设置" meta="正在加载…">
|
|
173
|
+
<div className={css.meta}>正在加载设置…</div>
|
|
174
|
+
{error !== '' && <div className={css.noticeError}>{error}</div>}
|
|
175
|
+
</SubPage>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const content = (
|
|
180
|
+
<>
|
|
181
|
+
<div className={css.shelfToolbar} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
|
|
182
|
+
{SETTINGS_SECTIONS.map(s => (
|
|
183
|
+
<button key={s.id} type="button" className={css.button + (settingsTab === s.id ? ' ' + css.buttonPrimary : '')} style={{ flex: 1, minWidth: 104, justifyContent: 'center' }} onClick={() => changeSettingsTab(s.id)}>{s.icon} {s.label}</button>
|
|
184
|
+
))}
|
|
185
|
+
{variant === 'drawer' && (
|
|
186
|
+
<button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy} onClick={() => { void save() }}>{busy ? '保存中…' : tt('settings.save')}</button>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{error !== '' && <div className={css.noticeError}>{error}</div>}
|
|
191
|
+
{notice !== '' && <div className={css.meta}>{notice}</div>}
|
|
192
|
+
|
|
193
|
+
{settingsTab === 'model' && (
|
|
194
|
+
<>
|
|
195
|
+
<div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
|
|
196
|
+
<span className={css.cardTitle}><Brain size={18} style={{ verticalAlign: -3 }} /> 模型与推理</span>
|
|
197
|
+
<ModelManager
|
|
198
|
+
api={api}
|
|
199
|
+
provider={configDraft.provider}
|
|
200
|
+
model={configDraft.model}
|
|
201
|
+
savedModels={configDraft.savedModels ?? []}
|
|
202
|
+
onProvider={v => setConfigDraft({ ...configDraft, provider: v })}
|
|
203
|
+
onModel={v => setConfigDraft({ ...configDraft, model: v })}
|
|
204
|
+
onSavedModels={models => setConfigDraft({ ...configDraft, savedModels: models })}
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
207
|
+
<div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-12)' }}>
|
|
208
|
+
<span className={css.cardTitle}>备用模型(故障自动切换)</span>
|
|
209
|
+
<div className={css.field}>
|
|
210
|
+
<label className={css.fieldLabel}>主模型调用失败(限流 / 超时 / 额度 / 服务不可用)时,自动换它重试一次</label>
|
|
211
|
+
<select
|
|
212
|
+
className={css.input}
|
|
213
|
+
value={configDraft.fallbackModel ?? ''}
|
|
214
|
+
onChange={e => setConfigDraft({ ...configDraft, fallbackModel: e.target.value })}
|
|
215
|
+
>
|
|
216
|
+
<option value="">不切换(关闭)</option>
|
|
217
|
+
{[...new Set([configDraft.model, ...(configDraft.savedModels ?? []).map(m => m.model)])]
|
|
218
|
+
.filter(m => m !== '')
|
|
219
|
+
.map(m => <option key={m} value={m}>{m}</option>)}
|
|
220
|
+
</select>
|
|
221
|
+
</div>
|
|
222
|
+
<span className={css.meta}>
|
|
223
|
+
只对可重试的失败生效:用户取消、参数错误不会切换;正文生成只在「一个字都没产出」时才换模型重试,避免重复生成。
|
|
224
|
+
</span>
|
|
225
|
+
</div>
|
|
226
|
+
<div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-12)' }}>
|
|
227
|
+
<span className={css.cardTitle}>故事时间线</span>
|
|
228
|
+
<label className={css.row} style={{ gap: 'var(--nf-space-8)', alignItems: 'center' }}>
|
|
229
|
+
<input
|
|
230
|
+
type="checkbox"
|
|
231
|
+
checked={configDraft.autoTimeline ?? true}
|
|
232
|
+
onChange={e => setConfigDraft({ ...configDraft, autoTimeline: e.target.checked })}
|
|
233
|
+
/>
|
|
234
|
+
<span>出章后自动抽取时间线事件(时间 / 地点 / 在场角色 / 事件)</span>
|
|
235
|
+
</label>
|
|
236
|
+
<span className={css.meta}>
|
|
237
|
+
抽取失败不会阻断出章;可在侧柜「故事时间线」里手工抽取、修正,并一键检查时间倒流 / 地点瞬移等矛盾。
|
|
238
|
+
</span>
|
|
239
|
+
</div>
|
|
240
|
+
<ReasoningSection
|
|
241
|
+
reasoningEffort={configDraft.reasoningEffort ?? 'off'}
|
|
242
|
+
analysisReasoning={configDraft.analysisReasoning ?? 'low'}
|
|
243
|
+
onChange={patch => setConfigDraft({ ...configDraft, ...patch })}
|
|
244
|
+
/>
|
|
245
|
+
</>
|
|
246
|
+
)}
|
|
247
|
+
|
|
248
|
+
{settingsTab === 'files' && (
|
|
249
|
+
<div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
|
|
250
|
+
<span className={css.cardTitle}><Folder size={18} style={{ verticalAlign: -3 }} /> 路径与文件</span>
|
|
251
|
+
<div className={css.field}>
|
|
252
|
+
<label className={css.fieldLabel}>{tt('settings.outputDir')}</label>
|
|
253
|
+
<div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
|
|
254
|
+
<input className={css.input} style={{ flex: 1, minWidth: 220 }} value={configDraft.outputDir} onChange={e => setConfigDraft({ ...configDraft, outputDir: e.target.value })} />
|
|
255
|
+
<button type="button" className={css.button} onClick={() => { void openMoveDialog() }} title="把当前输出目录的全部文件搬到新位置,书架与配置自动联动">更改位置…</button>
|
|
256
|
+
</div>
|
|
257
|
+
<span className={css.meta}>含章节、项目状态、助手历史等;「更改位置」会整目录搬迁并联动书架,直接改路径仅更新默认值。</span>
|
|
258
|
+
</div>
|
|
259
|
+
<div className={css.row}><button type="button" className={css.button} onClick={() => { void api.openFolder() }}>{tt('settings.openFolder')}</button></div>
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
{settingsTab === 'appearance' && (
|
|
264
|
+
<>
|
|
265
|
+
<div className={css.card + ' ' + css.settingsCard}>
|
|
266
|
+
<span className={css.cardTitle}><Sparkles size={18} style={{ verticalAlign: -3 }} /> 外观与主题</span>
|
|
267
|
+
<div className={css.field}><label className={css.fieldLabel}>显示模式</label><select className={css.input} value={themeMode} onChange={e => onChangeThemeMode(e.target.value as ThemeMode)}><option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option></select></div>
|
|
268
|
+
<div className={css.field}><label className={css.fieldLabel}>主题风格</label><span className={css.input} style={{ display: 'inline-flex', alignItems: 'center' }}>墨纸 · 暖编辑案头</span><span className={css.meta}>统一墨纸风格,无需再选皮肤;切换明暗即达纸白/墨色。</span></div>
|
|
269
|
+
<div className={css.field}><label className={css.fieldLabel}>界面密度</label><select className={css.input} value={themeDensity} onChange={e => onChangeThemeDensity(e.target.value as ThemeDensity)}><option value="comfort">舒适(默认)</option><option value="compact">紧凑</option><option value="spacious">宽松</option></select></div>
|
|
270
|
+
<div className={css.field}><label className={css.fieldLabel}>编辑器字号(正文编辑 / 工作区)</label><select className={css.input} value={editorFontSize} onChange={e => changeEditorFont(Number(e.target.value))}>{[12,13,14,15,16,18,20,22,24].map(v => <option key={v} value={v}>{v}px</option>)}</select></div>
|
|
271
|
+
<div className={css.field}>
|
|
272
|
+
<label className={css.fieldLabel}>生成状态芯片(右上角)</label>
|
|
273
|
+
<select
|
|
274
|
+
className={css.input}
|
|
275
|
+
value={runChipMode}
|
|
276
|
+
onChange={e => { const next = e.target.value as RunChipMode; setRunChipMode(next); writeRunChipMode(next) }}
|
|
277
|
+
>
|
|
278
|
+
{RUN_CHIP_MODE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
279
|
+
</select>
|
|
280
|
+
<span className={css.meta}>固定在窗口右上角,显示生产单 生成中 / 已暂停 / 批完成 / 出错,点击可打开小说工坊。「关闭」时同时停掉状态轮询。存于浏览器本地,即时生效。</span>
|
|
281
|
+
</div>
|
|
282
|
+
<div className={css.row} style={{ justifyContent: 'flex-end' }}><button type="button" className={css.button} onClick={resetTheme}><RotateCcw size={14} style={{ verticalAlign: -2 }} /> 恢复默认主题</button></div>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-12)' }}>
|
|
286
|
+
<span className={css.cardTitle}><Image size={18} style={{ verticalAlign: -3 }} /> 自定义背景</span>
|
|
287
|
+
<span className={css.meta}>填图片 URL 或上传图片作为小说工坊背景(首页 + 书内)。留空 = 使用主题默认背景。</span>
|
|
288
|
+
<div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
|
|
289
|
+
<input className={css.input} style={{ flex: 1, minWidth: 220 }} placeholder="https://… 图片地址" value={(configDraft.themeBackground ?? '').startsWith('data:') ? '' : (configDraft.themeBackground ?? '')} onChange={e => applyBackground(e.target.value.trim() !== '' ? e.target.value.trim() : undefined)} />
|
|
290
|
+
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => bgFileRef.current?.click()}><Upload size={13} style={{ verticalAlign: -2 }} /> 上传图片</button>
|
|
291
|
+
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => applyBackground(undefined)}><X size={13} style={{ verticalAlign: -2 }} /> 清除</button>
|
|
292
|
+
</div>
|
|
293
|
+
<input ref={bgFileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={e => { void onBgFile(e.target.files?.[0]); e.target.value = '' }} />
|
|
294
|
+
{(configDraft.themeBackground ?? '') !== '' && (
|
|
295
|
+
<div className={css.adaptInputCard} style={{ padding: 8 }}>
|
|
296
|
+
<div style={{ width: 180, height: 100, borderRadius: 10, backgroundImage: 'url(' + configDraft.themeBackground + ')', backgroundSize: 'cover', backgroundPosition: 'center' }} />
|
|
297
|
+
<div className={css.meta}>已设置背景(预览)</div>
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
<div className={css.field}><label className={css.fieldLabel}>遮罩 / 模糊强度(0-80)</label><input className={css.input} type="range" min={0} max={80} value={configDraft.themeBackgroundBlur ?? 0} onChange={e => applyBlur(Number(e.target.value))} /><span className={css.meta}>越大背景越暗,保证文字可读(图很花时调高)。</span></div>
|
|
301
|
+
</div>
|
|
302
|
+
</>
|
|
303
|
+
)}
|
|
304
|
+
|
|
305
|
+
{moveOpen && (
|
|
306
|
+
<div className={css.importModalOverlay} role="dialog" aria-modal="true" aria-label="更改输出目录" onClick={e => { if (e.target === e.currentTarget && !moveBusy) setMoveOpen(false) }}>
|
|
307
|
+
<div className={css.importModal}>
|
|
308
|
+
<div className={css.importModalHead}>
|
|
309
|
+
<span className={css.cardTitle}>更改输出目录</span>
|
|
310
|
+
<button type="button" className={css.buttonSmall + ' ' + css.button} disabled={moveBusy} onClick={() => { setMoveOpen(false) }}><X size={14} /></button>
|
|
311
|
+
</div>
|
|
312
|
+
<div className={css.importModalBody}>
|
|
313
|
+
<div className={css.field}>
|
|
314
|
+
<label className={css.fieldLabel}>当前目录</label>
|
|
315
|
+
<span className={css.meta}>{movePreview?.from ?? configDraft.outputDir}</span>
|
|
316
|
+
</div>
|
|
317
|
+
{movePreview !== null && (
|
|
318
|
+
<div className={css.field}>
|
|
319
|
+
<label className={css.fieldLabel}>将被搬走的内容</label>
|
|
320
|
+
<span className={css.meta}>{movePreview.files.length} 个条目 · 约 {formatBytes(movePreview.bytes)}{movePreview.files.length > 0 ? `(如 ${movePreview.files.slice(0, 3).join('、')}${movePreview.files.length > 3 ? ' …' : ''})` : '(空目录)'}</span>
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
<div className={css.field}>
|
|
324
|
+
<label className={css.fieldLabel}>新目录(绝对路径)</label>
|
|
325
|
+
<input className={css.input} placeholder="例如 D:\\Novels\\我的书" value={moveTarget} onChange={e => setMoveTarget(e.target.value)} disabled={moveBusy} autoFocus />
|
|
326
|
+
<span className={css.meta}>目标必须为空或不存在;文件搬迁后书架与默认配置自动联动,成功后才删除旧目录,中途失败数据无损。</span>
|
|
327
|
+
</div>
|
|
328
|
+
{moveError !== '' && <span className={css.meta} style={{ color: 'var(--nf-danger, #e5484d)' }}>{moveError}</span>}
|
|
329
|
+
</div>
|
|
330
|
+
<div className={css.importModalActions}>
|
|
331
|
+
<button type="button" className={css.button} disabled={moveBusy} onClick={() => { setMoveOpen(false) }}>取消</button>
|
|
332
|
+
<button type="button" className={css.button} disabled={moveBusy || moveTarget.trim() === ''} onClick={() => { void confirmMove() }}>{moveBusy ? '搬迁中…' : '确认搬迁'}</button>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
)}
|
|
337
|
+
</>
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
if (variant === 'drawer') {
|
|
341
|
+
return <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-12)', padding: 'var(--nf-space-16)' }}>{content}</div>;
|
|
342
|
+
}
|
|
343
|
+
return (
|
|
344
|
+
<SubPage
|
|
345
|
+
title="设置"
|
|
346
|
+
meta={`当前模型:${config?.provider} / ${config?.model} · 输出目录:${config?.outputDir}`}
|
|
347
|
+
actions={(
|
|
348
|
+
<button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy} onClick={() => { void save() }}>{busy ? '保存中…' : tt('settings.save')}</button>
|
|
349
|
+
)}
|
|
350
|
+
>
|
|
351
|
+
{content}
|
|
352
|
+
</SubPage>
|
|
353
|
+
);
|
|
354
|
+
}
|