@waterwx/dsh-novel-forge 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * 模型管理(DSH 风格):两块分区——
3
+ * ① 模型(提供方管理):提供方卡片(编辑/删除)+ 添加提供方 / 添加自定义提供方;
4
+ * ② 当前写作模型:提供商/模型下拉 + 测试连通 + 推理强度。
5
+ */
6
+ import { useCallback, useEffect, useState } from 'react'
7
+ import { PlugZap, Plus, Trash2, RefreshCw, X } from 'lucide-react'
8
+ import type { NovelApi } from '../api.ts'
9
+ import type { SavedModel, AddModelRequest, LlmModelOption, LlmVendorOption } from '../../protocol.ts'
10
+ import { LLM_VENDORS } from '../../protocol.ts'
11
+ import { tt } from './helpers.ts'
12
+ import css from './panel.module.css'
13
+
14
+ /** 目录不可用时回退的内置预设(历史行为)。 */
15
+ export const FALLBACK_MODEL_PRESETS = ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] as const
16
+
17
+ const CUSTOM = '__custom__'
18
+ interface TestState { testing: boolean; ok?: boolean; ms?: number; message?: string }
19
+ interface ProviderInfo { id: string; name: string }
20
+ type AddPanel = 'none' | 'standard' | 'custom'
21
+
22
+ export interface ModelManagerProps {
23
+ api: NovelApi
24
+ provider: string
25
+ model: string
26
+ savedModels: SavedModel[]
27
+ onProvider: (provider: string) => void
28
+ onModel: (model: string) => void
29
+ onSavedModels: (models: SavedModel[]) => void
30
+ }
31
+
32
+ function fallbackVendors(): LlmVendorOption[] {
33
+ return LLM_VENDORS.map(v => ({ id: v.route, name: v.name, models: v.models, apiKeyEnv: v.apiKeyEnv, builtin: v.builtin }))
34
+ }
35
+
36
+ export function ModelManager({ api, provider, model, onProvider, onModel }: ModelManagerProps): JSX.Element {
37
+ // ---- 当前写作模型
38
+ const [configuredProviders, setConfiguredProviders] = useState<ProviderInfo[]>([])
39
+ const [providerModels, setProviderModels] = useState<LlmModelOption[]>([])
40
+ const [modelsLoading, setModelsLoading] = useState(false)
41
+ const [providerCustom, setProviderCustom] = useState(false)
42
+ const [modelCustom, setModelCustom] = useState(false)
43
+ const [test, setTest] = useState<TestState>({ testing: false })
44
+
45
+ // ---- 提供方管理
46
+ const [vendors, setVendors] = useState<LlmVendorOption[]>(fallbackVendors())
47
+ const [addPanel, setAddPanel] = useState<AddPanel>('none')
48
+ const [editTarget, setEditTarget] = useState('')
49
+ const [vendorId, setVendorId] = useState('zai-coding-cn')
50
+ const [addModel, setAddModel] = useState('')
51
+ const [addApiKey, setAddApiKey] = useState('')
52
+ const [addName, setAddName] = useState('')
53
+ const [custProvider, setCustProvider] = useState('')
54
+ const [custName, setCustName] = useState('')
55
+ const [custBaseURL, setCustBaseURL] = useState('')
56
+ const [custProtocol, setCustProtocol] = useState('openai-completions')
57
+ const [custModel, setCustModel] = useState('')
58
+ const [custApiKey, setCustApiKey] = useState('')
59
+ const [savingAdd, setSavingAdd] = useState(false)
60
+ const [addMsg, setAddMsg] = useState('')
61
+ const [addErr, setAddErr] = useState('')
62
+
63
+
64
+ const loadProviders = useCallback(async (): Promise<void> => {
65
+ try { const r = await api.llmProviders(); if (r.providers.length > 0) setConfiguredProviders(r.providers) } catch { /* 兜底 */ }
66
+ }, [api])
67
+ const loadVendors = useCallback(async (): Promise<void> => {
68
+ try { const r = await api.llmVendors(); if (r.vendors.length > 0) setVendors(r.vendors) } catch { /* 静态兜底 */ }
69
+ }, [api])
70
+ const loadProviderModels = useCallback(async (p: string): Promise<void> => {
71
+ setModelsLoading(true)
72
+ try { const r = await api.llmModels(p); setProviderModels(r.models) } catch { setProviderModels([]) } finally { setModelsLoading(false) }
73
+ }, [api])
74
+
75
+ useEffect(() => { void loadProviders(); void loadVendors() }, [api, loadProviders, loadVendors])
76
+ useEffect(() => { if (provider !== '') void loadProviderModels(provider) }, [provider, loadProviderModels])
77
+
78
+ const currentVendor = vendors.find(v => v.id === vendorId)
79
+
80
+ const runTest = async (): Promise<void> => {
81
+ const p = provider.trim(); const m = model.trim()
82
+ if (p === '' || m === '' || test.testing) return
83
+ setTest({ testing: true })
84
+ try { const r = await api.llmTest(p, m); setTest({ testing: false, ok: r.ok, ms: r.ms, message: r.message }) }
85
+ catch (err) { setTest({ testing: false, ok: false, message: (err as Error).message }) }
86
+ }
87
+
88
+ const openPanel = (panel: AddPanel, target = ''): void => {
89
+ setAddPanel(panel); setEditTarget(target); setAddMsg(''); setAddErr('')
90
+ if (panel === 'standard') { setVendorId(target !== '' ? target : 'zai-coding-cn'); setAddModel(''); setAddApiKey(''); setAddName('') }
91
+ else if (panel === 'custom') { setCustProvider(target); setCustName(''); setCustBaseURL(''); setCustModel(''); setCustApiKey('') }
92
+ }
93
+
94
+ const saveStandard = async (): Promise<void> => {
95
+ if (vendorId.trim() === '') { setAddErr('请选择提供方'); return }
96
+ if (addApiKey.trim() === '') { setAddErr('请填写 API 密钥(或留空使用环境认证)'); return }
97
+ const modelId = addModel.trim()
98
+ setSavingAdd(true); setAddMsg(''); setAddErr('')
99
+ try {
100
+ const res = await api.addModel({ mode: 'vendor', vendor: vendorId, model: modelId || vendorId, apiKey: addApiKey.trim(), apiKeyEnv: currentVendor?.apiKeyEnv, name: addName.trim() || undefined })
101
+ setAddMsg(res.message ?? '已保存'); setAddPanel('none'); setAddApiKey(''); setAddModel(''); setAddName(''); setEditTarget('')
102
+ await loadProviders()
103
+ } catch (err) { setAddErr((err as Error).message) } finally { setSavingAdd(false) }
104
+ }
105
+
106
+ const saveCustom = async (): Promise<void> => {
107
+ if (custProvider.trim() === '') { setAddErr('请填 Provider ID'); return }
108
+ if (custBaseURL.trim() === '') { setAddErr('请填 API 地址'); return }
109
+ if (custApiKey.trim() === '') { setAddErr('请填 API 密钥(或留空使用环境认证)'); return }
110
+ if (custModel.trim() === '') { setAddErr('请填模型 id(或点“获取可用模型”)'); return }
111
+ setSavingAdd(true); setAddMsg(''); setAddErr('')
112
+ try {
113
+ const res = await api.addModel({ mode: 'custom', provider: custProvider.trim(), model: custModel.trim(), apiKey: custApiKey.trim(), baseURL: custBaseURL.trim(), name: custName.trim() || undefined })
114
+ setAddMsg(res.message ?? '已创建提供方'); setAddPanel('none'); setCustProvider(''); setCustName(''); setCustBaseURL(''); setCustModel(''); setCustApiKey('')
115
+ await loadProviders()
116
+ } catch (err) { setAddErr((err as Error).message) } finally { setSavingAdd(false) }
117
+ }
118
+
119
+ const removeProvider = async (p: ProviderInfo): Promise<void> => {
120
+ if (!window.confirm('删除提供方「' + (p.name !== '' ? p.name : p.id) + '」?将移除 DSH 配置与 API 密钥。')) return
121
+ try { const vendor = vendors.find(v => v.id === p.id); await api.removeProvider({ provider: p.id, apiKeyEnv: vendor?.apiKeyEnv }); await loadProviders() }
122
+ catch (err) { setAddErr((err as Error).message) }
123
+ }
124
+
125
+
126
+ const providerOptions: ProviderInfo[] = configuredProviders.some(p => p.id === provider) ? configuredProviders : (provider !== '' && provider !== CUSTOM ? [{ id: provider, name: provider }, ...configuredProviders] : configuredProviders)
127
+
128
+ const modelOptions: LlmModelOption[] = modelCustom ? [] : (provider === 'deepseek-official'
129
+ ? [...FALLBACK_MODEL_PRESETS.map(id => ({ id, name: id })), ...providerModels.filter(m => !(FALLBACK_MODEL_PRESETS as readonly string[]).includes(m.id))]
130
+ : providerModels)
131
+ const modelInList = modelOptions.some(m => m.id === model)
132
+ const modelValue = modelCustom || (!modelInList && model !== '' && modelOptions.length > 0) ? CUSTOM : model
133
+ const providerValue = providerCustom ? CUSTOM : provider
134
+
135
+
136
+ return (
137
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-24)' }}>
138
+ {/* ① 模型(提供方管理) */}
139
+ <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-16)' }}>
140
+ <span className={css.cardTitle}>模型</span>
141
+ <span className={css.meta}>填入各提供方的 API 密钥即可使用其模型。</span>
142
+ {configuredProviders.map(p => {
143
+ const isBuiltin = p.id === 'deepseek-official'
144
+ const isKnownVendor = vendors.some(v => v.id === p.id)
145
+ return (
146
+ <div key={p.id} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-12)', display: 'flex', alignItems: 'center', gap: 'var(--nf-space-8)', flexWrap: 'wrap', justifyContent: 'space-between' }}>
147
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-8)' }}><span style={{ fontWeight: 600 }}>{p.name !== '' && p.name !== p.id ? p.name : p.id}</span><span style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--nf-success)' }} /></div>
148
+ <div style={{ display: 'flex', gap: 'var(--nf-space-6)', alignItems: 'center' }}>
149
+ {!isBuiltin && <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => openPanel(isKnownVendor ? 'standard' : 'custom', p.id)}>{tt('settings.edit')}</button>}
150
+ {!isBuiltin && <button type="button" className={css.button + ' ' + css.buttonSmall} style={{ color: 'var(--nf-error)' }} onClick={() => { void removeProvider(p) }}>{tt('settings.delete')}</button>}
151
+ </div>
152
+ </div>
153
+ )
154
+ })}
155
+ <div className={css.row} style={{ gap: 'var(--nf-space-10)', flexWrap: 'wrap' }}>
156
+ <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => openPanel('standard')}><Plus size={13} style={{ verticalAlign: -2 }} /> 添加提供方</button>
157
+ <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => openPanel('custom')}><Plus size={13} style={{ verticalAlign: -2 }} /> 添加自定义提供方</button>
158
+ </div>
159
+
160
+ {addPanel !== 'none' && (
161
+ <div style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-12)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-12)' }}>
162
+ <div className={css.row} style={{ justifyContent: 'space-between', alignItems: 'center' }}><span className={css.fieldLabel}>{addPanel === 'standard' ? (editTarget !== '' ? '编辑提供方' : '添加提供方') : '自定义提供方'}</span><button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => openPanel('none')}><X size={13} style={{ verticalAlign: -2 }} /> 关闭</button></div>
163
+ {addPanel === 'standard' ? (
164
+ <>
165
+ <div className={css.field}><label className={css.fieldLabel}>提供方</label><select className={css.input} value={vendorId} onChange={e => setVendorId(e.target.value)}>{vendors.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}</select></div>
166
+ <div className={css.field}><label className={css.fieldLabel}>API 密钥</label><input className={css.input} type="password" value={addApiKey} onChange={e => setAddApiKey(e.target.value)} placeholder="输入 API 密钥,或留空使用环境认证" /></div>
167
+ <div className={css.field}><label className={css.fieldLabel}>模型 id(可选)</label><input className={css.input} value={addModel} onChange={e => setAddModel(e.target.value)} placeholder="例如 glm-5.3-flash" /></div>
168
+ <div className={css.row} style={{ justifyContent: 'flex-end', gap: 'var(--nf-space-8)' }}><button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => openPanel('none')}>取消</button><button type="button" className={css.button + ' ' + css.buttonSmall + ' ' + css.buttonPrimary} disabled={savingAdd} onClick={() => { void saveStandard() }}>{savingAdd ? '保存中…' : '保存'}</button></div>
169
+ </>
170
+ ) : (
171
+ <>
172
+ <div className={css.field}><label className={css.fieldLabel}>Provider ID</label><input className={css.input} value={custProvider} onChange={e => setCustProvider(e.target.value)} placeholder="acme-gateway" /></div>
173
+ <div className={css.field}><label className={css.fieldLabel}>显示名称</label><input className={css.input} value={custName} onChange={e => setCustName(e.target.value)} placeholder="显示名称" /></div>
174
+ <div className={css.field}><label className={css.fieldLabel}>API 地址</label><input className={css.input} value={custBaseURL} onChange={e => setCustBaseURL(e.target.value)} placeholder="https://gateway.example/v1" /></div>
175
+ <div className={css.field}><label className={css.fieldLabel}>API 协议</label><select className={css.input} value={custProtocol} onChange={e => setCustProtocol(e.target.value)}><option value="openai-completions">openai-completions</option></select></div>
176
+ <div className={css.field}><label className={css.fieldLabel}>API 密钥</label><input className={css.input} type="password" value={custApiKey} onChange={e => setCustApiKey(e.target.value)} placeholder="输入 API 密钥" /></div>
177
+ <div className={css.field}><label className={css.fieldLabel}>模型目录 / 模型 id</label><input className={css.input} value={custModel} onChange={e => setCustModel(e.target.value)} placeholder="例如 glm-5.3-flash" /></div>
178
+ <div className={css.row} style={{ justifyContent: 'flex-end', gap: 'var(--nf-space-8)' }}><button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => openPanel('none')}>取消</button><button type="button" className={css.button + ' ' + css.buttonSmall + ' ' + css.buttonPrimary} disabled={savingAdd} onClick={() => { void saveCustom() }}>{savingAdd ? '创建中…' : '创建提供方'}</button></div>
179
+ </>
180
+ )}
181
+ </div>
182
+ )}
183
+ {addMsg !== '' && <span className={css.meta} style={{ color: 'var(--nf-success)' }}>✓ {addMsg}</span>}
184
+ {addErr !== '' && <span className={css.meta} style={{ color: 'var(--nf-error)' }}>✗ {addErr}</span>}
185
+ </div>
186
+
187
+ {/* ② 当前写作模型 */}
188
+ <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-16)' }}>
189
+ <span className={css.cardTitle}>当前写作模型</span>
190
+ <div className={css.field}><label className={css.fieldLabel}>{tt('settings.provider')}</label><select className={css.input} value={providerValue} onChange={e => { const v = e.target.value; if (v === CUSTOM) setProviderCustom(true); else { setProviderCustom(false); onProvider(v) } }}>{providerOptions.map(p => <option key={p.id} value={p.id}>{p.name !== '' && p.name !== p.id ? p.name + '(' + p.id + ')' : p.id}</option>)}<option value={CUSTOM}>{tt('settings.customProvider')}</option></select>{providerCustom && <input className={css.input} style={{ marginTop: 'var(--nf-space-6)' }} value={provider} placeholder="provider 路由 id" onChange={e => onProvider(e.target.value)} />}</div>
191
+ <div className={css.field}>
192
+ <label className={css.fieldLabel}>{tt('settings.model')}</label>
193
+ <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
194
+ <select className={css.input} style={{ flex: 1, minWidth: 200 }} value={modelValue} onChange={e => { const v = e.target.value; if (v === CUSTOM) setModelCustom(true); else { setModelCustom(false); onModel(v) } }}>{modelOptions.map(m => <option key={m.id} value={m.id}>{m.name !== '' && m.name !== m.id ? m.name + '(' + m.id + ')' : m.id}</option>)}<option value={CUSTOM}>{tt('settings.modelCustom')}</option></select>
195
+ <button type="button" className={css.button + ' ' + css.buttonSmall} disabled={modelsLoading || provider === ''} title="刷新模型" onClick={() => { void loadProviderModels(provider) }}><RefreshCw size={13} style={{ verticalAlign: -2 }} /> {modelsLoading ? '…' : tt('settings.refreshModels')}</button>
196
+ <button type="button" className={css.button + ' ' + css.buttonSmall} disabled={test.testing || provider.trim() === '' || model.trim() === ''} onClick={() => { void runTest() }} title={tt('settings.testHint')}><PlugZap size={13} style={{ verticalAlign: -2 }} /> {test.testing ? tt('settings.testRunning') : tt('settings.testConnection')}</button>
197
+ </div>
198
+ {modelCustom && <input className={css.input} style={{ marginTop: 'var(--nf-space-6)' }} value={model} placeholder={tt('settings.modelCustomPlaceholder')} onChange={e => onModel(e.target.value)} />}
199
+ <span className={css.meta} style={{ color: test.ok === true ? 'var(--nf-success)' : test.ok === false ? 'var(--nf-error)' : undefined }}>{test.testing ? tt('settings.testRunning') + '…' : test.ok === true ? '✓ ' + tt('settings.testOk') + ' · ' + (test.ms ?? '?') + 'ms' : test.ok === false ? '✗ ' + tt('settings.testFail') + (test.message !== undefined ? ':' + test.message : '') : tt('settings.testHint')}</span>
200
+ </div>
201
+ </div>
202
+ </div>
203
+ )
204
+ }
205
+
@@ -9,6 +9,8 @@ import type { ReactElement } from 'react'
9
9
  import type { NovelApi } from '../api.ts'
10
10
  import type { PanelController } from './controller.ts'
11
11
  import { tt } from './helpers.ts'
12
+ import { ModelManager } from './ModelManager.tsx'
13
+ import { ReasoningSection } from './ReasoningSection.tsx'
12
14
  import { BarChart3, Book, BookMarked, BookOpen, Brain, Clapperboard, Factory, FileText, Folder, GitBranch, Library, MessageSquare, Palette, PenLine, PlugZap, RotateCcw, ScrollText, Search, Settings, Sparkles, Wrench } from 'lucide-react'
13
15
  import { AssistantTab } from './AssistantTab.tsx'
14
16
  import { AssetsTab } from './AssetsTab.tsx'
@@ -105,9 +107,6 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
105
107
  /** Settings tab — pinned to the bottom of the nav rail. */
106
108
  const SETTINGS_TAB: { id: NovelTab; label: string; icon: ReactElement } = { id: 'settings', label: tt('tab.settings'), icon: <Settings size={18} /> }
107
109
 
108
- /** Common DeepSeek model presets shown in settings; users can also type any model id. */
109
- const MODEL_PRESETS = ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] as const
110
- const REASONING_OPTIONS = ['off', 'low', 'high', 'max'] as const
111
110
 
112
111
  /** 设置页内子导航分组。 */
113
112
  const SETTINGS_SECTIONS: ReadonlyArray<{ id: 'model' | 'writing' | 'image' | 'files' | 'appearance'; label: string; icon: ReactElement }> = [
@@ -337,7 +336,6 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
337
336
  const [notice, setNotice] = useState('')
338
337
  const [progress, setProgress] = useState<ProgressLine[]>([])
339
338
  const [configDraft, setConfigDraft] = useState<NovelConfig | null>(null)
340
- const [modelCustomMode, setModelCustomMode] = useState(false)
341
339
  const [expandedChapter, setExpandedChapter] = useState<number | null>(null)
342
340
  /** 复盘记录页:当前展开的章节号。 */
343
341
  const [expandedReviewChapter, setExpandedReviewChapter] = useState<number | null>(null)
@@ -706,7 +704,6 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
706
704
  const status = await api.status()
707
705
  setConfig(status.config)
708
706
  setConfigDraft(status.config)
709
- setModelCustomMode(!(MODEL_PRESETS as readonly string[]).includes(status.config.model))
710
707
  setAuditStatus(status.audit ?? null)
711
708
  setProject(status.project ?? null)
712
709
  // 人物志存档同步:有 roleStatus 存档直接显示,无需重新刷新计算。
@@ -2084,6 +2081,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2084
2081
  imageApiModel: configDraft.imageApiModel,
2085
2082
  imageApiEnabled: configDraft.imageApiEnabled ?? false,
2086
2083
  imageModels: configDraft.imageModels ?? [],
2084
+ savedModels: configDraft.savedModels ?? [],
2087
2085
  })
2088
2086
  setConfig(result.config)
2089
2087
  setConfigDraft(result.config)
@@ -2411,6 +2409,11 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2411
2409
  onBackground={(bg, blur) => { setThemeBg(bg); setThemeBgBlur(blur) }}
2412
2410
  onOpacity={(n) => { setThemeOpacity(n) }}
2413
2411
  adaptEnabled={config?.enableAdaptMode === true}
2412
+ progress={progress}
2413
+ busy={busy}
2414
+ busyLabel={busyLabel}
2415
+ liveBar={liveBar}
2416
+ onClearProgress={() => { setProgress([]) }}
2414
2417
  />
2415
2418
  ) : viewMode === 'create' ? (
2416
2419
  /* 开书向导:独立页面 */
@@ -3835,65 +3838,25 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3835
3838
  </div>
3836
3839
 
3837
3840
  {settingsTab === 'model' && (
3838
- <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
3839
- <span className={css.cardTitle}><Brain size={18} style={{ verticalAlign: -3 }} /> 模型与推理</span>
3840
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-24)' }}>
3841
- <div className={css.field}>
3842
- <label className={css.fieldLabel}>{tt('settings.provider')}</label>
3843
- <input className={css.input} value={configDraft.provider} onChange={e => { setConfigDraft({ ...configDraft, provider: e.target.value }) }} />
3844
- </div>
3845
- <div className={css.field}>
3846
- <label className={css.fieldLabel}>{tt('settings.model')}</label>
3847
- <select
3848
- className={css.input}
3849
- value={modelCustomMode ? '__custom__' : configDraft.model}
3850
- onChange={e => {
3851
- const v = e.target.value
3852
- if (v === '__custom__') {
3853
- setModelCustomMode(true)
3854
- } else {
3855
- setModelCustomMode(false)
3856
- setConfigDraft({ ...configDraft, model: v })
3857
- }
3858
- }}
3859
- >
3860
- {MODEL_PRESETS.map(m => <option key={m} value={m}>{m}</option>)}
3861
- <option value="__custom__">{tt('settings.modelCustom')}</option>
3862
- </select>
3863
- {modelCustomMode && (
3864
- <input
3865
- className={css.input}
3866
- style={{ marginTop: 'var(--nf-space-6)' }}
3867
- value={configDraft.model}
3868
- placeholder={tt('settings.modelCustomPlaceholder')}
3869
- onChange={e => { setConfigDraft({ ...configDraft, model: e.target.value }) }}
3870
- />
3871
- )}
3872
- </div>
3873
- <div className={css.field}>
3874
- <label className={css.fieldLabel}>{tt('settings.reasoningEffort')}</label>
3875
- <select
3876
- className={css.input}
3877
- value={configDraft.reasoningEffort ?? 'off'}
3878
- onChange={e => { setConfigDraft({ ...configDraft, reasoningEffort: e.target.value as NovelConfig['reasoningEffort'] }) }}
3879
- >
3880
- {REASONING_OPTIONS.map(v => <option key={v} value={v}>{tt(`settings.reasoning.${v}`)}</option>)}
3881
- </select>
3882
- <span className={css.meta}>{tt('settings.reasoningHint')}</span>
3883
- </div>
3884
- <div className={css.field}>
3885
- <label className={css.fieldLabel}>{tt('settings.analysisReasoning')}</label>
3886
- <select
3887
- className={css.input}
3888
- value={configDraft.analysisReasoning ?? 'low'}
3889
- onChange={e => { setConfigDraft({ ...configDraft, analysisReasoning: e.target.value as NovelConfig['analysisReasoning'] }) }}
3890
- >
3891
- {REASONING_OPTIONS.map(v => <option key={v} value={v}>{tt(`settings.reasoning.${v}`)}</option>)}
3892
- </select>
3893
- <span className={css.meta}>{tt('settings.analysisReasoningHint')}</span>
3894
- </div>
3841
+ <>
3842
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
3843
+ <span className={css.cardTitle}><Brain size={18} style={{ verticalAlign: -3 }} /> 模型与推理</span>
3844
+ <ModelManager
3845
+ api={api}
3846
+ provider={configDraft.provider}
3847
+ model={configDraft.model}
3848
+ savedModels={configDraft.savedModels ?? []}
3849
+ onProvider={v => { setConfigDraft({ ...configDraft, provider: v }) }}
3850
+ onModel={v => { setConfigDraft({ ...configDraft, model: v }) }}
3851
+ onSavedModels={models => setConfigDraft({ ...configDraft, savedModels: models })}
3852
+ />
3895
3853
  </div>
3896
- </div>
3854
+ <ReasoningSection
3855
+ reasoningEffort={configDraft.reasoningEffort ?? 'off'}
3856
+ analysisReasoning={configDraft.analysisReasoning ?? 'low'}
3857
+ onChange={patch => setConfigDraft({ ...configDraft, ...patch })}
3858
+ />
3859
+ </>
3897
3860
  )}
3898
3861
 
3899
3862
  {settingsTab === 'writing' && (
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 共享「AI 进度」实时控制台:当前任务大进度条 + 全量活动日志。
3
+ * 书内与首页共用一个表现,内容来自同一份进度状态。
4
+ */
5
+ import { useEffect, useRef } from 'react'
6
+ import css from './panel.module.css'
7
+
8
+ export interface ProgressLine {
9
+ id: number;
10
+ text: string;
11
+ kind: 'info' | 'done' | 'error';
12
+ live?: boolean;
13
+ ratio?: number;
14
+ }
15
+
16
+ export function ProgressConsole({ progress, busy, busyLabel, liveBar, onClear }: {
17
+ progress: ProgressLine[];
18
+ busy: boolean;
19
+ busyLabel: string;
20
+ liveBar: { text: string; ratio?: number } | null;
21
+ onClear: () => void;
22
+ }) {
23
+ const endRef = useRef<HTMLDivElement | null>(null);
24
+ useEffect(() => {
25
+ endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
26
+ }, [progress]);
27
+
28
+ return (
29
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)', height: '100%', minHeight: 0 }}>
30
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
31
+ <span style={{ fontWeight: 600 }}>📊 AI 进度</span>
32
+ <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={onClear} title="清空活动记录">清空</button>
33
+ </div>
34
+ {(busy && (busyLabel !== '' || liveBar !== null)) && (
35
+ <div style={{ border: '1px solid var(--nf-accent)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-12)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', background: 'color-mix(in srgb, var(--nf-accent) 6%, transparent)' }}>
36
+ <span style={{ fontSize: 'var(--nf-fs-12)', fontWeight: 600, color: 'var(--nf-accent)' }}>✍ {busyLabel !== '' ? busyLabel : (liveBar?.text ?? '任务进行中')}…</span>
37
+ {liveBar?.ratio !== undefined && (
38
+ <div className={css.bigProgressBar}><div className={css.bigProgressBarFill} style={{ width: Math.round(liveBar.ratio * 100) + '%' }} /></div>
39
+ )}
40
+ {liveBar?.text !== undefined && <span className={css.liveText}>{liveBar.text}</span>}
41
+ </div>
42
+ )}
43
+ <div className={css.progress} style={{ flex: 1, minHeight: 0, overflowY: 'auto', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', background: 'var(--nf-bg-inset)', padding: 'var(--nf-space-8)', display: 'flex', flexDirection: 'column' }}>
44
+ {progress.length === 0 ? (
45
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 'var(--nf-space-4)', color: 'var(--nf-text-3)', fontSize: 'var(--nf-fs-12)', minHeight: 120 }}>
46
+ <span style={{ fontSize: 'var(--nf-fs-22)' }}>📭</span>
47
+ <span>暂无活动记录</span>
48
+ <span>生成、审稿等操作会显示在这里</span>
49
+ </div>
50
+ ) : (
51
+ progress.map(line => (
52
+ <div key={line.id} className={line.kind === 'done' ? css.progressLineDone : line.kind === 'error' ? css.progressLineError : line.live === true ? css.progressLineLive : css.progressLine}>
53
+ {line.live === true && (
54
+ <span className={css.progressBar}><span className={css.progressBarFill} style={{ width: Math.round((line.ratio ?? 0) * 100) + '%' }} /></span>
55
+ )}
56
+ {line.text}
57
+ </div>
58
+ ))
59
+ )}
60
+ <div ref={endRef} />
61
+ </div>
62
+ </div>
63
+ );
64
+ }
@@ -42,7 +42,7 @@ export function ProgressHomeView({ shelf, onOpenBook }: {
42
42
 
43
43
  return (
44
44
  <div className={css.authorPageBody}>
45
- <div className={css.authorPageHeader}><h2 className={css.panelTitle} style={{ margin: 0 }}>📊 AI 进度</h2><span className={css.meta}>跨书架聚合进度与最近活动</span></div>
45
+ <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-6)' }}><h2 className={css.panelTitle} style={{ margin: 0 }}>📊 AI 进度</h2><span className={css.meta}>跨书架聚合进度与最近活动</span></div>
46
46
 
47
47
  <div className={css.progressOverview}>
48
48
  <div className={css.progressOverviewCard}><span className={css.progressOverviewNum}>{shelf.books.length}</span><span className={css.meta}>总书数</span></div>
@@ -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
+ }