@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.
- package/lib/client.js +1439 -816
- package/lib/client.js.map +1 -1
- package/lib/index.js +519 -121
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +13 -1
- package/lib/types/client/locales.d.ts +16 -0
- package/lib/types/client/panel/AuthorHome.d.ts +11 -1
- package/lib/types/client/panel/ModelManager.d.ts +14 -0
- package/lib/types/client/panel/ModelPicker.d.ts +11 -0
- package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
- package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
- package/lib/types/client/panel/SettingsView.d.ts +1 -1
- package/lib/types/engine.d.ts +16 -1
- package/lib/types/protocol.d.ts +147 -0
- package/package.json +1 -1
- package/src/client/api.ts +41 -0
- package/src/client/locales.ts +32 -0
- package/src/client/panel/AdaptModeView.tsx +3 -5
- package/src/client/panel/AuthorHome.tsx +19 -5
- package/src/client/panel/ModelManager.tsx +205 -0
- package/src/client/panel/NovelPanel.tsx +26 -63
- package/src/client/panel/ProgressConsole.tsx +64 -0
- package/src/client/panel/ProgressHomeView.tsx +1 -1
- package/src/client/panel/ReasoningSection.tsx +31 -0
- package/src/client/panel/SettingsView.tsx +56 -49
- package/src/engine.ts +221 -0
- package/src/index.ts +2 -2
- package/src/protocol.ts +160 -0
- package/src/routes.ts +132 -0
|
@@ -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
|
-
|
|
3839
|
-
<
|
|
3840
|
-
|
|
3841
|
-
<
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|