@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.
@@ -8,27 +8,20 @@ import { Brain, PenLine, Palette, Folder, Sparkles, RotateCcw, PlugZap, Plus, Tr
8
8
  import type { NovelApi } from '../api.ts'
9
9
  import type { NovelConfig, ImageModelConfig } from '../../protocol.ts'
10
10
  import { tt } from './helpers.ts'
11
+ import { ModelManager } from './ModelManager.tsx'
12
+ import { ReasoningSection } from './ReasoningSection.tsx'
11
13
  import css from './panel.module.css'
12
14
 
13
- const MODEL_PRESETS = ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] as const
14
- const REASONING_OPTIONS = ['off', 'low', 'high', 'max'] as const
15
- const REASONING_LABEL: Record<string, string> = {
16
- off: tt('settings.reasoning.off'),
17
- low: tt('settings.reasoning.low'),
18
- high: tt('settings.reasoning.high'),
19
- max: tt('settings.reasoning.max'),
20
- }
21
15
 
22
- const SETTINGS_SECTIONS: Array<{ id: 'model' | 'writing' | 'image' | 'files' | 'appearance' | 'background'; label: string; icon: JSX.Element }> = [
16
+ const SETTINGS_SECTIONS: Array<{ id: 'model' | 'writing' | 'image' | 'files' | 'appearance'; label: string; icon: JSX.Element }> = [
23
17
  { id: 'model', label: '模型与推理', icon: <Brain size={16} /> },
24
18
  { id: 'writing', label: '写作与审稿', icon: <PenLine size={16} /> },
25
19
  { id: 'image', label: '生图模型', icon: <Palette size={16} /> },
26
20
  { id: 'files', label: '路径与文件', icon: <Folder size={16} /> },
27
21
  { id: 'appearance', label: '外观与主题', icon: <Sparkles size={16} /> },
28
- { id: 'background', label: '自定义背景', icon: <Image size={16} /> },
29
22
  ];
30
23
 
31
- type SettingsTab = 'model' | 'writing' | 'image' | 'files' | 'appearance' | 'background'
24
+ type SettingsTab = 'model' | 'writing' | 'image' | 'files' | 'appearance'
32
25
  type ThemeMode = 'system' | 'light' | 'dark'
33
26
  type ThemeName = 'liquid' | 'neumorph' | 'macos' | 'clay'
34
27
  type ThemeDensity = 'comfort' | 'compact' | 'spacious'
@@ -53,8 +46,7 @@ export function SettingsView({ api, onTheme, onSettingsTab, onEditorFontSize, on
53
46
  const [busy, setBusy] = useState(false);
54
47
  const [error, setError] = useState('');
55
48
  const [notice, setNotice] = useState('');
56
- const [modelCustomMode, setModelCustomMode] = useState(false);
57
- const [settingsTab, setSettingsTabState] = useState<SettingsTab>(() => readLS<SettingsTab>('dsh-novel-forge.settings.tab', 'model', ['model','writing','image','files','appearance','background'] as const));
49
+ const [settingsTab, setSettingsTabState] = useState<SettingsTab>(() => readLS<SettingsTab>('dsh-novel-forge.settings.tab', 'model', ['model','writing','image','files','appearance'] as const));
58
50
  const [imageTestState, setImageTestState] = useState<Record<string, { testing: boolean; ok?: boolean; ms?: number; message?: string; modelFound?: boolean }>>({});
59
51
  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 } });
60
52
  const [panelTheme, setPanelTheme] = useState<ThemeName>(() => readLS<ThemeName>('dsh-novel-forge.theme', 'liquid', ['liquid','neumorph','macos','clay'] as const));
@@ -97,6 +89,7 @@ export function SettingsView({ api, onTheme, onSettingsTab, onEditorFontSize, on
97
89
  themeBackgroundBlur: configDraft.themeBackgroundBlur ?? 0,
98
90
  themeOpacity: configDraft.themeOpacity ?? 100,
99
91
  imageModels: configDraft.imageModels ?? [],
92
+ savedModels: configDraft.savedModels ?? [],
100
93
  });
101
94
  setConfig(result.config);
102
95
  setConfigDraft(result.config);
@@ -180,8 +173,10 @@ export function SettingsView({ api, onTheme, onSettingsTab, onEditorFontSize, on
180
173
 
181
174
  return (
182
175
  <div className={css.authorPageBody} data-nf-theme={panelTheme} data-nf-mode={themeMode === 'system' ? undefined : themeMode} data-nf-density={themeDensity}>
183
- <div className={css.authorPageHeader}>
184
- <div><h2 className={css.panelTitle} style={{ margin: 0 }}>⚙️ 设置</h2><span className={css.meta}>当前:{config?.provider} / {config?.model} · {config?.outputDir}</span></div>
176
+ <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-6)' }}>
177
+ <h2 className={css.panelTitle} style={{ margin: 0 }}>⚙️ 设置</h2>
178
+ <span className={css.meta}>当前模型:{config?.provider} / {config?.model}</span>
179
+ <span className={css.meta}>输出目录:{config?.outputDir}</span>
185
180
  </div>
186
181
 
187
182
  <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
@@ -194,13 +189,25 @@ export function SettingsView({ api, onTheme, onSettingsTab, onEditorFontSize, on
194
189
  {notice !== '' && <div className={css.meta}>{notice}</div>}
195
190
 
196
191
  {settingsTab === 'model' && (
197
- <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
198
- <span className={css.cardTitle}><Brain size={18} style={{ verticalAlign: -3 }} /> 模型与推理</span>
199
- <div className={css.field}><label className={css.fieldLabel}>{tt('settings.provider')}</label><input className={css.input} value={configDraft.provider} onChange={e => setConfigDraft({ ...configDraft, provider: e.target.value })} /></div>
200
- <div className={css.field}><label className={css.fieldLabel}>{tt('settings.model')}</label><select className={css.input} value={modelCustomMode ? '__custom__' : configDraft.model} onChange={e => { const v = e.target.value; if (v === '__custom__') setModelCustomMode(true); else { setModelCustomMode(false); setConfigDraft({ ...configDraft, model: v }) } }}>{MODEL_PRESETS.map(m => <option key={m} value={m}>{m}</option>)}<option value="__custom__">{tt('settings.modelCustom')}</option></select>{modelCustomMode && <input className={css.input} value={configDraft.model} placeholder={tt('settings.modelCustomPlaceholder')} onChange={e => setConfigDraft({ ...configDraft, model: e.target.value })} />}</div>
201
- <div className={css.field}><label className={css.fieldLabel}>{tt('settings.reasoningEffort')}</label><select className={css.input} value={configDraft.reasoningEffort ?? 'off'} onChange={e => setConfigDraft({ ...configDraft, 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>
202
- <div className={css.field}><label className={css.fieldLabel}>{tt('settings.analysisReasoning')}</label><select className={css.input} value={configDraft.analysisReasoning ?? 'low'} onChange={e => setConfigDraft({ ...configDraft, 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>
203
- </div>
192
+ <>
193
+ <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
194
+ <span className={css.cardTitle}><Brain size={18} style={{ verticalAlign: -3 }} /> 模型与推理</span>
195
+ <ModelManager
196
+ api={api}
197
+ provider={configDraft.provider}
198
+ model={configDraft.model}
199
+ savedModels={configDraft.savedModels ?? []}
200
+ onProvider={v => setConfigDraft({ ...configDraft, provider: v })}
201
+ onModel={v => setConfigDraft({ ...configDraft, model: v })}
202
+ onSavedModels={models => setConfigDraft({ ...configDraft, savedModels: models })}
203
+ />
204
+ </div>
205
+ <ReasoningSection
206
+ reasoningEffort={configDraft.reasoningEffort ?? 'off'}
207
+ analysisReasoning={configDraft.analysisReasoning ?? 'low'}
208
+ onChange={patch => setConfigDraft({ ...configDraft, ...patch })}
209
+ />
210
+ </>
204
211
  )}
205
212
 
206
213
  {settingsTab === 'writing' && (
@@ -218,7 +225,7 @@ export function SettingsView({ api, onTheme, onSettingsTab, onEditorFontSize, on
218
225
 
219
226
  {settingsTab === 'image' && (
220
227
  <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
221
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', alignItems: 'center' }}><span className={css.cardTitle}><Palette size={18} style={{ verticalAlign: -3 }} /> 生图模型</span><span className={css.meta}>启用生图功能</span></div>
228
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', alignItems: 'center' }}><span className={css.cardTitle}><Palette size={18} style={{ verticalAlign: -3 }} /> 生图模型</span><span className={css.row} style={{ gap: 'var(--nf-space-8)', alignItems: 'center' }}><span className={css.meta}>启用生图功能</span><button type="button" role="switch" aria-checked={configDraft.imageApiEnabled === true} className={`${css.switch} ${configDraft.imageApiEnabled === true ? css.switchOn : ''}`} onClick={() => { setConfigDraft(prev => prev === null ? prev : { ...prev, imageApiEnabled: prev.imageApiEnabled !== true }) }}><span className={css.switchKnob} /></button></span></div>
222
229
  <span className={css.meta}>支持多套 OpenAI 兼容生图接口(豆包 / 即梦 / 其他平台);启用一条作为生成模型。</span>
223
230
  {(configDraft.imageModels ?? []).map(m => (
224
231
  <div key={m.id} style={{ border: m.enabled ? '1px solid var(--nf-accent)' : '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-14)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-12)', background: m.enabled ? 'var(--nf-accent-soft)' : 'transparent' }}>
@@ -249,34 +256,34 @@ export function SettingsView({ api, onTheme, onSettingsTab, onEditorFontSize, on
249
256
  )}
250
257
 
251
258
  {settingsTab === 'appearance' && (
252
- <div className={css.card + ' ' + css.settingsCard}>
253
- <span className={css.cardTitle}><Sparkles size={18} style={{ verticalAlign: -3 }} /> 外观与主题</span>
254
- <div className={css.field}><label className={css.fieldLabel}>显示模式</label><select className={css.input} value={themeMode} onChange={e => changeTheme(panelTheme, e.target.value as ThemeMode, themeDensity)}><option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option></select></div>
255
- <div className={css.field}><label className={css.fieldLabel}>主题风格</label><select className={css.input} value={panelTheme} onChange={e => changeTheme(e.target.value as ThemeName, themeMode, themeDensity)}><option value="liquid">液态玻璃 · 清新绿(默认)</option><option value="neumorph">新拟物 · 柔和浅色</option><option value="macos">macOS · 玻璃(蓝,随外观浅/深)</option><option value="clay">粘土拟态 · 柔和黏土</option></select></div>
256
- <div className={css.field}><label className={css.fieldLabel}>界面密度</label><select className={css.input} value={themeDensity} onChange={e => changeTheme(panelTheme, themeMode, e.target.value as ThemeDensity)}><option value="comfort">舒适(默认)</option><option value="compact">紧凑</option><option value="spacious">宽松</option></select></div>
257
- <div className={css.field}><label className={css.fieldLabel}>玻璃透明度(0-100)</label><input className={css.input} type="range" min={0} max={100} value={configDraft.themeOpacity ?? 100} onChange={e => applyOpacity(Number(e.target.value))} /><span className={css.meta}>100=当前主题原样;调低会让玻璃/卡片变透,新拟态、黏土等实色主题能透出背景图。</span></div>
258
- <div className={css.row} style={{ justifyContent: 'flex-end' }}><button type="button" className={css.button} onClick={resetTheme}><RotateCcw size={14} style={{ verticalAlign: -2 }} /> 恢复默认主题</button></div>
259
- </div>
260
- )}
261
-
262
- {settingsTab === 'background' && (
263
- <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-12)' }}>
264
- <span className={css.cardTitle}><Image size={18} style={{ verticalAlign: -3 }} /> 自定义背景</span>
265
- <span className={css.meta}>填图片 URL 或上传图片作为小说工坊背景(首页 + 书内)。留空 = 使用主题默认背景。</span>
266
- <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
267
- <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)} />
268
- <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => bgFileRef.current?.click()}><Upload size={13} style={{ verticalAlign: -2 }} /> 上传图片</button>
269
- <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => applyBackground(undefined)}><X size={13} style={{ verticalAlign: -2 }} /> 清除</button>
259
+ <>
260
+ <div className={css.card + ' ' + css.settingsCard}>
261
+ <span className={css.cardTitle}><Sparkles size={18} style={{ verticalAlign: -3 }} /> 外观与主题</span>
262
+ <div className={css.field}><label className={css.fieldLabel}>显示模式</label><select className={css.input} value={themeMode} onChange={e => changeTheme(panelTheme, e.target.value as ThemeMode, themeDensity)}><option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option></select></div>
263
+ <div className={css.field}><label className={css.fieldLabel}>主题风格</label><select className={css.input} value={panelTheme} onChange={e => changeTheme(e.target.value as ThemeName, themeMode, themeDensity)}><option value="liquid">液态玻璃 · 清新绿(默认)</option><option value="neumorph">新拟物 · 柔和浅色</option><option value="macos">macOS · 玻璃(蓝,随外观浅/深)</option><option value="clay">粘土拟态 · 柔和黏土</option></select></div>
264
+ <div className={css.field}><label className={css.fieldLabel}>界面密度</label><select className={css.input} value={themeDensity} onChange={e => changeTheme(panelTheme, themeMode, e.target.value as ThemeDensity)}><option value="comfort">舒适(默认)</option><option value="compact">紧凑</option><option value="spacious">宽松</option></select></div>
265
+ <div className={css.field}><label className={css.fieldLabel}>玻璃透明度(0-100)</label><input className={css.input} type="range" min={0} max={100} value={configDraft.themeOpacity ?? 100} onChange={e => applyOpacity(Number(e.target.value))} /><span className={css.meta}>100=当前主题原样;调低会让玻璃/卡片变透,新拟态、黏土等实色主题能透出背景图。</span></div>
266
+ <div className={css.row} style={{ justifyContent: 'flex-end' }}><button type="button" className={css.button} onClick={resetTheme}><RotateCcw size={14} style={{ verticalAlign: -2 }} /> 恢复默认主题</button></div>
270
267
  </div>
271
- <input ref={bgFileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={e => { void onBgFile(e.target.files?.[0]); e.target.value = '' }} />
272
- {(configDraft.themeBackground ?? '') !== '' && (
273
- <div className={css.adaptInputCard} style={{ padding: 8 }}>
274
- <div style={{ width: 180, height: 100, borderRadius: 10, backgroundImage: 'url(' + configDraft.themeBackground + ')', backgroundSize: 'cover', backgroundPosition: 'center' }} />
275
- <div className={css.meta}>已设置背景(预览)</div>
268
+
269
+ <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-12)' }}>
270
+ <span className={css.cardTitle}><Image size={18} style={{ verticalAlign: -3 }} /> 自定义背景</span>
271
+ <span className={css.meta}>填图片 URL 或上传图片作为小说工坊背景(首页 + 书内)。留空 = 使用主题默认背景。</span>
272
+ <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
273
+ <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)} />
274
+ <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => bgFileRef.current?.click()}><Upload size={13} style={{ verticalAlign: -2 }} /> 上传图片</button>
275
+ <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => applyBackground(undefined)}><X size={13} style={{ verticalAlign: -2 }} /> 清除</button>
276
276
  </div>
277
- )}
278
- <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>
279
- </div>
277
+ <input ref={bgFileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={e => { void onBgFile(e.target.files?.[0]); e.target.value = '' }} />
278
+ {(configDraft.themeBackground ?? '') !== '' && (
279
+ <div className={css.adaptInputCard} style={{ padding: 8 }}>
280
+ <div style={{ width: 180, height: 100, borderRadius: 10, backgroundImage: 'url(' + configDraft.themeBackground + ')', backgroundSize: 'cover', backgroundPosition: 'center' }} />
281
+ <div className={css.meta}>已设置背景(预览)</div>
282
+ </div>
283
+ )}
284
+ <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>
285
+ </div>
286
+ </>
280
287
  )}
281
288
 
282
289
  <div className={css.row}><button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy} onClick={() => { void save() }}>{busy ? '保存中…' : tt('settings.save')}</button></div>
package/src/engine.ts CHANGED
@@ -75,11 +75,24 @@ import type {
75
75
  StoryboardShot,
76
76
  StoryboardPrompt,
77
77
  ChapterStoryboard,
78
+ AddModelRequest,
79
+ AddModelResponse,
80
+ SavedModel,
81
+ LlmModelOption,
82
+ LlmModelsResponse,
83
+ LlmVendorOption,
84
+ LlmVendorsResponse,
85
+ LlmProvidersResponse,
86
+ RemoveProviderRequest,
87
+ RemoveProviderResponse,
88
+ LlmTestRequest,
89
+ LlmTestResponse,
78
90
  MangaRoleCandidate,
79
91
  MangaRoleCard,
80
92
  Volume,
81
93
  WorldState,
82
94
  } from './protocol.ts'
95
+ import { LLM_VENDORS } from './protocol.ts'
83
96
 
84
97
  /** Project state file name inside the output dir. */
85
98
  export const PROJECT_FILE = 'novel-project.json'
@@ -2343,6 +2356,214 @@ export async function testImageEndpoint(baseURL: string, apiKey: string, model?:
2343
2356
  }
2344
2357
  }
2345
2358
 
2359
+ // ---------------------------------------------------------- llm catalog/test
2360
+
2361
+ /** LLM 连通性失败的错误码 → 人话(供设置页“测试连通”回显)。 */
2362
+ const LLM_TEST_ERROR_HINT: Record<string, string> = {
2363
+ NO_ADAPTER: '提供商路由不存在或未启用',
2364
+ UNKNOWN_MODEL: '模型不在该提供商的目录里',
2365
+ MISSING_CREDENTIAL: 'API Key 未配置(检查 DSH 凭据里的引用)',
2366
+ INVALID_CREDENTIAL: 'API Key 格式无效',
2367
+ AUTH: '认证失败:API Key 无效或无权限',
2368
+ RATE_LIMIT: '触发提供商限流,请稍后再试',
2369
+ QUOTA: '配额/余额不足',
2370
+ CONTEXT_WINDOW_EXCEEDED: '上下文超限(测试调用不应触发,请核实模型配置)',
2371
+ EMPTY_RESPONSE: '端点连通但返回空响应(模型可能暂不可用)',
2372
+ TIMEOUT: '连接超时',
2373
+ ABORTED: '测试超时(30 秒无响应)',
2374
+ UNSUPPORTED_REASONING_EFFORT: '推理档位不受此模型支持',
2375
+ }
2376
+
2377
+ function describeLlmTestError(err: Error & { code?: string }): string {
2378
+ const code = typeof err.code === 'string' ? err.code : ''
2379
+ const hint = code !== '' ? LLM_TEST_ERROR_HINT[code] : undefined
2380
+ const detail = err.message !== '' ? err.message : '未知错误'
2381
+ return hint !== undefined ? `${hint}(${detail})` : detail
2382
+ }
2383
+
2384
+ /** 对选中的提供商/模型发一次最小真实调用(maxTokens=16),验证 Key / 端点 / 模型可用。 */
2385
+ export async function testLlmModel(ctx: Context, provider: string, model: string): Promise<LlmTestResponse> {
2386
+ const start = Date.now()
2387
+ const messages: Message[] = [createUserMessage({
2388
+ content: [{ type: 'text', text: '只回复两个字:OK' }],
2389
+ source: { kind: 'plugin', plugin: 'dsh-novel-forge' },
2390
+ })]
2391
+ const request: GenerateOptions = {
2392
+ provider,
2393
+ model,
2394
+ messages,
2395
+ maxTokens: 16,
2396
+ temperature: 0,
2397
+ }
2398
+ // 真实流式调用 + 30 秒超时(GenerateOptions.signal 由适配器响应并中止)。
2399
+ const controller = new AbortController()
2400
+ const timer = setTimeout(() => controller.abort(), 30_000)
2401
+ try {
2402
+ const assembler = new BlockAssembler()
2403
+ let sawBlock = false
2404
+ for await (const chunk of ctx.llm.stream({ ...request, signal: controller.signal })) {
2405
+ assembler.push(chunk)
2406
+ // 拿到第一个完整块即判定连通并提前结束,省 token。
2407
+ if (chunk.type === 'block-end') { sawBlock = true; break }
2408
+ }
2409
+ if (sawBlock) return { ok: true, ms: Date.now() - start }
2410
+ const finish = assembler.finish
2411
+ if (finish !== undefined && (finish.kind === 'error' || finish.kind === 'aborted')) {
2412
+ const failure = finish.failure
2413
+ throw Object.assign(new Error(failure.message), { code: failure.code })
2414
+ }
2415
+ // 正常 finish(stop/max-tokens)但没有文本块,同样视为已连通。
2416
+ return { ok: true, ms: Date.now() - start }
2417
+ } catch (error) {
2418
+ const err = error as Error & { code?: string }
2419
+ return { ok: false, ms: Date.now() - start, code: err.code, message: describeLlmTestError(err) }
2420
+ } finally {
2421
+ clearTimeout(timer)
2422
+ }
2423
+ }
2424
+
2425
+ /** 确保 pi-ai 的一条 provider 路由存在(settings seam 深度合并,保留已有字段)。 */
2426
+ async function ensurePiAiProvider(ctx: Context, route: string, cfg: Record<string, unknown>): Promise<void> {
2427
+ const settings = ctx.get('settings') as { update: (ns: string, patch: object) => Promise<void> } | undefined
2428
+ if (settings === undefined) throw new Error('DSH settings 服务不可用,无法注册路由')
2429
+ await settings.update('llm-pi-ai', { providers: { [route]: cfg } })
2430
+ }
2431
+
2432
+ /** 运行时厂商目录:DSH pi-ai 可配置提供方 + 内置适配器,作为「添加模型」下拉。 */
2433
+ export async function listLlmVendors(ctx: Context): Promise<LlmVendorsResponse> {
2434
+ const map = new Map<string, LlmVendorOption>()
2435
+ // 预置厂商(有名称/模型建议/apiKeyEnv)
2436
+ for (const v of LLM_VENDORS) {
2437
+ map.set(v.route, { id: v.route, name: v.name, models: v.models, apiKeyEnv: v.apiKeyEnv, builtin: v.builtin })
2438
+ }
2439
+ // DSH pi-ai 可配置提供方目录(DSH 添加模型下拉的数据源)
2440
+ try {
2441
+ for (const p of ctx.llm.listConfigurableProviders()) {
2442
+ if (!map.has(p.provider)) {
2443
+ map.set(p.provider, {
2444
+ id: p.provider,
2445
+ name: p.displayName !== '' ? p.displayName : p.provider,
2446
+ models: [],
2447
+ apiKeyEnv: 'PI_AI_' + p.provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_API_KEY',
2448
+ })
2449
+ }
2450
+ }
2451
+ } catch { /* 单个目录读取失败跳过 */ }
2452
+ // 已注册的适配器路由(如 deepseek-official)
2453
+ try {
2454
+ for (const p of ctx.llm.listProviders()) {
2455
+ if (!map.has(p.id)) {
2456
+ map.set(p.id, { id: p.id, name: p.name !== '' && p.name !== p.id ? p.name : p.id, models: [], builtin: true })
2457
+ }
2458
+ }
2459
+ } catch { /* ignore */ }
2460
+ return { vendors: [...map.values()] }
2461
+ }
2462
+
2463
+ /** 查询某个 provider 当前可用模型(添加成功后可即时刷新下拉)。 */
2464
+ export async function listLlmModels(ctx: Context, provider: string): Promise<LlmModelsResponse> {
2465
+ if (provider.trim() === '') return { models: [] }
2466
+ try {
2467
+ const models = await ctx.llm.listModels(provider.trim())
2468
+ return { models: models.map(m => ({ id: m.id, name: m.name })) }
2469
+ } catch {
2470
+ // provider 未激活/目录不可用 → 返回空,让前端回退到手填。
2471
+ return { models: [] }
2472
+ }
2473
+ }
2474
+
2475
+ /** 当前已注册的提供方路由列表(提供方管理卡片)。 */
2476
+ export async function listLlmProviders(ctx: Context): Promise<LlmProvidersResponse> {
2477
+ try {
2478
+ const providers = ctx.llm.listProviders().map(p => ({ id: p.id, name: p.name !== '' && p.name !== p.id ? p.name : p.id }))
2479
+ return { providers }
2480
+ } catch {
2481
+ // 兜底:至少总是有内置 DeepSeek。
2482
+ return { providers: [{ id: 'deepseek-official', name: 'DeepSeek' }] }
2483
+ }
2484
+ }
2485
+
2486
+ /** 移除一个提供方:unset 凭据 ref + 移除 llm-pi-ai providers 路由。 */
2487
+ export async function removeLlmProvider(ctx: Context, req: RemoveProviderRequest): Promise<RemoveProviderResponse> {
2488
+ const provider = (req.provider ?? '').trim()
2489
+ if (provider === '') throw new Error('缺少 provider')
2490
+ if (provider === 'deepseek-official') throw new Error('内置 DeepSeek 提供方不可删除')
2491
+
2492
+ const creds = ctx.get('credentials') as { unset?: (ref: string) => Promise<void> } | undefined
2493
+ if (creds?.unset !== undefined && req.apiKeyEnv !== undefined && req.apiKeyEnv.trim() !== '') {
2494
+ await creds.unset(req.apiKeyEnv.trim())
2495
+ }
2496
+
2497
+ const settings = ctx.get('settings') as { mutate?: (ns: string, ops: { op: 'unset'; path: string[] }[]) => Promise<void> } | undefined
2498
+ if (settings?.mutate !== undefined) {
2499
+ await settings.mutate('llm-pi-ai', [{ op: 'unset', path: ['providers', provider] }])
2500
+ }
2501
+
2502
+ return { ok: true, message: '已移除提供方 ' + provider }
2503
+ }
2504
+
2505
+ /**
2506
+ * 添加模型(DSH 同款体验):厂商直填 API Key,或自定义 OpenAI 兼容路由。
2507
+ * 写入 DSH 凭据 refs,并(必要时)注册/更新 llm-pi-ai provider 路由。
2508
+ */
2509
+ export async function registerLlmModel(ctx: Context, req: AddModelRequest): Promise<AddModelResponse> {
2510
+ const apiKey = req.apiKey?.trim() ?? ''
2511
+ const model = req.model?.trim() ?? ''
2512
+ if (apiKey === '') throw new Error('API Key 不能为空')
2513
+ if (model === '') throw new Error('模型 id 不能为空')
2514
+
2515
+ const creds = ctx.get('credentials') as { set(ref: string, value: string): Promise<void> } | undefined
2516
+ if (creds === undefined) throw new Error('DSH credentials 服务不可用,无法写入 API Key')
2517
+
2518
+ let route: string
2519
+ let env: string
2520
+ let displayName: string
2521
+ let message: string
2522
+
2523
+ if (req.mode === 'vendor') {
2524
+ const vendorId = (req.vendor ?? '').trim()
2525
+ if (vendorId === '') throw new Error('请选择厂商')
2526
+ const v = LLM_VENDORS.find(x => x.route === vendorId)
2527
+ route = vendorId
2528
+ env = req.apiKeyEnv?.trim() || v?.apiKeyEnv || ('PI_AI_' + vendorId.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_API_KEY')
2529
+ displayName = v?.name ?? vendorId
2530
+ const isBuiltin = v?.builtin === true || vendorId === 'deepseek-official'
2531
+ await creds.set(env, apiKey)
2532
+ // 内置适配器(如 deepseek-official)不需 pi-ai 路由;其余 catalog 路由写 apiKeyEnv 即可。
2533
+ if (isBuiltin) {
2534
+ message = '已写入 DSH 凭据(' + env + ')'
2535
+ } else {
2536
+ await ensurePiAiProvider(ctx, route, { apiKeyEnv: env })
2537
+ message = '已写入 DSH 凭据并注册路由 ' + route
2538
+ }
2539
+ } else {
2540
+ route = (req.provider ?? '').trim()
2541
+ const baseURL = (req.baseURL ?? '').trim()
2542
+ if (route === '') throw new Error('自定义模式需填提供商路由 id')
2543
+ if (baseURL === '') throw new Error('自定义模式需填接口地址 (baseURL)')
2544
+ env = 'NOVEL_CUSTOM_' + route.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_API_KEY'
2545
+ displayName = req.name?.trim() || ('Custom ' + route)
2546
+ await creds.set(env, apiKey)
2547
+ await ensurePiAiProvider(ctx, route, {
2548
+ displayName,
2549
+ apiKeyEnv: env,
2550
+ api: 'openai-completions',
2551
+ baseURL,
2552
+ models: [{ id: model }],
2553
+ compat: { supportsDeveloperRole: false, maxTokensField: 'max_tokens' },
2554
+ })
2555
+ message = '已写入 DSH 凭据并注册路由 ' + route
2556
+ }
2557
+
2558
+ const saved: SavedModel = {
2559
+ id: 'saved-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6),
2560
+ name: req.name?.trim() || (displayName + ' · ' + model),
2561
+ provider: route,
2562
+ model,
2563
+ }
2564
+ return { ok: true, saved, provider: route, message }
2565
+ }
2566
+
2346
2567
  /** 底层:按形象锚点调用生图接口生成定妆图(不写库;写库由上层调用方负责)。 */
2347
2568
  async function generateReferenceImageFrom(
2348
2569
  config: NovelConfig,
package/src/index.ts CHANGED
@@ -109,7 +109,7 @@ export const Config: z<Config> = z.object({
109
109
  themeBackground: z.string().default(''),
110
110
  themeBackgroundBlur: z.number().default(0),
111
111
  themeOpacity: z.number().default(100),
112
- enableAdaptMode: z.boolean().default(false),
112
+ enableAdaptMode: z.boolean().default(true),
113
113
  })
114
114
 
115
115
  /** Schema defaults, re-read for hand-built test contexts. */
@@ -172,7 +172,7 @@ export function resolveConfig(value: Partial<Config> | undefined): NovelConfig {
172
172
  themeBackground: value?.themeBackground ?? '',
173
173
  themeBackgroundBlur: value?.themeBackgroundBlur ?? 0,
174
174
  themeOpacity: value?.themeOpacity ?? 100,
175
- enableAdaptMode: value?.enableAdaptMode ?? false,
175
+ enableAdaptMode: value?.enableAdaptMode ?? true,
176
176
  }
177
177
  }
178
178
 
package/src/protocol.ts CHANGED
@@ -64,6 +64,18 @@ export const NOVEL_API = {
64
64
  storyboardPrompts: '/api/dsh-novel-forge/storyboard/prompts',
65
65
  /** 生图接口连通性测试(设置页每个模型条目用)。 */
66
66
  imageTest: '/api/dsh-novel-forge/image-test',
67
+ /** LLM 模型连通性测试:真实最小调用,验证 Key / 端点 / 模型可用。 */
68
+ llmTest: '/api/dsh-novel-forge/llm-test',
69
+ /** 添加模型:厂商直填 key 或自定义路由,写进 DSH 凭据与 llm-pi-ai 路由。 */
70
+ addModel: '/api/dsh-novel-forge/llm-add',
71
+ /** 运行时厂商目录(DSH pi-ai 可配置提供方 + 内置适配器)。 */
72
+ llmVendors: '/api/dsh-novel-forge/llm-vendors',
73
+ /** 查询某个 provider 当前可用的模型(添加成功后可即时刷新)。 */
74
+ llmModels: '/api/dsh-novel-forge/llm-models',
75
+ /** 已注册的提供方路由列表(提供方管理)。 */
76
+ llmProviders: '/api/dsh-novel-forge/llm-providers',
77
+ /** 移除一个提供方。 */
78
+ llmRemove: '/api/dsh-novel-forge/llm-remove',
67
79
  /** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
68
80
  reset: '/api/dsh-novel-forge/reset',
69
81
  /** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
@@ -1057,6 +1069,23 @@ export interface ImageTestResponse {
1057
1069
  modelFound?: boolean
1058
1070
  }
1059
1071
 
1072
+ /** POST /llm-test 请求:对选中的提供商/模型发一次最小真实调用。 */
1073
+ export interface LlmTestRequest {
1074
+ provider: string
1075
+ model: string
1076
+ }
1077
+
1078
+ /** POST /llm-test 响应。 */
1079
+ export interface LlmTestResponse {
1080
+ ok: boolean
1081
+ /** 连通延迟(毫秒)。 */
1082
+ ms?: number
1083
+ /** 失败原因(已映射为人话)。 */
1084
+ message?: string
1085
+ /** 稳定错误码(LlmError code),便于排查。 */
1086
+ code?: string
1087
+ }
1088
+
1060
1089
  /** POST /roles 响应。 */
1061
1090
  export interface RolesResponse {
1062
1091
  roles: RoleRecord[]
@@ -1235,6 +1264,133 @@ export interface ImageModelConfig {
1235
1264
  }
1236
1265
 
1237
1266
  /** Runtime config surface exposed to the panel (subset of plugin Config). */
1267
+ /** 手动添加的模型库条目(只存插件内,不改 DSH 全局)。 */
1268
+ export interface SavedModel {
1269
+ /** 唯一 id(前端生成的短 id)。 */
1270
+ id: string
1271
+ /** 展示名(可为空,回退到 model)。 */
1272
+ name: string
1273
+ /** DSH 提供商路由(如 zai-coding-cn)。 */
1274
+ provider: string
1275
+ /** 模型 id(如 glm-5.3-flash)。 */
1276
+ model: string
1277
+ }
1278
+
1279
+ /** DSH 模型添加:厂商预设(选择厂商,直接填 API Key)。 */
1280
+ export interface LlmVendor {
1281
+ id: string
1282
+ name: string
1283
+ /** 该厂商对应的 provider 路由。 */
1284
+ route: string
1285
+ /** DSH 凭据引用名(写入 .credentials.yaml 的 refs)。 */
1286
+ apiKeyEnv: string
1287
+ /** 默认模型 id。 */
1288
+ defaultModel: string
1289
+ /** 建议可选模型 id 列表(下拉 data-list 用)。 */
1290
+ models: string[]
1291
+ /** 内置适配器(如 deepseek-official),无需注册 pi-ai 路由。 */
1292
+ builtin?: boolean
1293
+ }
1294
+
1295
+ /** 预置的常见厂商(id=provider 路由;添加模型下拉兜底用,其余厂商由运行时目录动态补充)。 */
1296
+ export const LLM_VENDORS: LlmVendor[] = [
1297
+ { id: 'deepseek-official', name: 'DeepSeek', route: 'deepseek-official', apiKeyEnv: 'DEEPSEEK_API_KEY', defaultModel: 'deepseek-v4-flash', models: ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'], builtin: true },
1298
+ { id: 'zai-coding-cn', name: '智谱 GLM', route: 'zai-coding-cn', apiKeyEnv: 'ZAI_CODING_CN_API_KEY', defaultModel: 'glm-5.3-flash', models: ['glm-4.5-air', 'glm-4.7', 'glm-5-turbo', 'glm-5.1', 'glm-5.2', 'glm-5.3', 'glm-5.3-flash', 'glm-5v-turbo'] },
1299
+ { id: 'qwen-token-plan-cn', name: '千问百炼', route: 'qwen-token-plan-cn', apiKeyEnv: 'QWEN_TOKEN_PLAN_CN_API_KEY', defaultModel: 'qwen3.7-max', models: ['qwen3.6-flash', 'qwen3.6-plus', 'qwen3.7-max', 'qwen3.7-plus', 'qwen3.8-max', 'qwen3.8-max-preview'] },
1300
+ { id: 'openrouter', name: 'OpenRouter', route: 'openrouter', apiKeyEnv: 'OPENROUTER_API_KEY', defaultModel: 'z-ai/glm-5.3-flash', models: ['z-ai/glm-4.6', 'z-ai/glm-4.7', 'z-ai/glm-5', 'z-ai/glm-5-turbo', 'z-ai/glm-5.1', 'z-ai/glm-5.2', 'z-ai/glm-5.3-flash', 'auto', 'deepseek/deepseek-v4-flash', 'anthropic/claude-sonnet-4.6'] },
1301
+ ]
1302
+
1303
+ /** 运行时厂商目录的一项(添加到模型下拉;由 DSH 的 pi-ai 可配置提供方动态生成)。 */
1304
+ export interface LlmVendorOption {
1305
+ /** provider 路由 id(也是厂商下拉的值)。 */
1306
+ id: string
1307
+ /** 展示名。 */
1308
+ name: string
1309
+ /** 建议模型 id(可为空,用户手填)。 */
1310
+ models: string[]
1311
+ /** 已知 DSH 凭据引用名;为空时 host 生成 `PI_AI_<ID>_API_KEY`。 */
1312
+ apiKeyEnv?: string
1313
+ /** 内置适配器(只写凭据,不注册 pi-ai 路由)。 */
1314
+ builtin?: boolean
1315
+ }
1316
+
1317
+ /** GET /llm-vendors 响应。 */
1318
+ export interface LlmVendorsResponse {
1319
+ vendors: LlmVendorOption[]
1320
+ }
1321
+
1322
+ /** /llm-models 里的一条模型。 */
1323
+ export interface LlmModelOption { id: string; name: string }
1324
+
1325
+ /** GET /llm-models?provider=x 响应。 */
1326
+ export interface LlmModelsResponse {
1327
+ models: LlmModelOption[]
1328
+ }
1329
+
1330
+ /** GET /llm-providers 响应:当前已注册的提供方路由。 */
1331
+ export interface LlmProvidersResponse {
1332
+ providers: { id: string; name: string }[]
1333
+ }
1334
+
1335
+ /** POST /llm-remove 请求:移除一个提供方(unset key + 移除 llm-pi-ai 路由)。 */
1336
+ export interface RemoveProviderRequest {
1337
+ provider: string
1338
+ /** 该提供方对应的 DSH 凭据引用名(用于 unset)。 */
1339
+ apiKeyEnv?: string
1340
+ }
1341
+
1342
+ /** POST /llm-remove 响应。 */
1343
+ export interface RemoveProviderResponse {
1344
+ ok: boolean
1345
+ message?: string
1346
+ }
1347
+
1348
+ /** GET /llm-providers 响应:当前已注册的提供方路由。 */
1349
+ export interface LlmProvidersResponse {
1350
+ providers: { id: string; name: string }[]
1351
+ }
1352
+
1353
+ /** POST /llm-remove 请求:移除一个提供方(unset key + 移除 llm-pi-ai 路由)。 */
1354
+ export interface RemoveProviderRequest {
1355
+ provider: string
1356
+ /** 该提供方对应的 DSH 凭据引用名(用于 unset)。 */
1357
+ apiKeyEnv?: string
1358
+ }
1359
+
1360
+ /** POST /llm-remove 响应。 */
1361
+ export interface RemoveProviderResponse {
1362
+ ok: boolean
1363
+ message?: string
1364
+ }
1365
+
1366
+ /** POST /llm-add 请求:添加一个模型(厂商直填 key,或自定义路由)。 */
1367
+ export interface AddModelRequest {
1368
+ mode: 'vendor' | 'custom'
1369
+ /** 厂商模式用:provider 路由 id(来自 /llm-vendors)。 */
1370
+ vendor?: string
1371
+ /** 厂商已知的 DSH 凭据引用名(可选;为空时 host 生成)。 */
1372
+ apiKeyEnv?: string
1373
+ /** 自定义模式用:provider 路由 id。 */
1374
+ provider?: string
1375
+ /** 模型 id。 */
1376
+ model: string
1377
+ /** API Key。 */
1378
+ apiKey: string
1379
+ /** 展示名(可选)。 */
1380
+ name?: string
1381
+ /** 自定义模式用:OpenAI 兼容 base URL。 */
1382
+ baseURL?: string
1383
+ }
1384
+
1385
+ /** POST /llm-add 响应。 */
1386
+ export interface AddModelResponse {
1387
+ ok: boolean
1388
+ saved: SavedModel
1389
+ provider: string
1390
+ message?: string
1391
+ }
1392
+
1393
+ /** 手动添加的模型库条目(只存插件内,不改 DSH 全局)。 */
1238
1394
  export interface NovelConfig {
1239
1395
  /** Absolute path of the default docx outline to load. */
1240
1396
  outlinePath: string
@@ -1278,6 +1434,8 @@ export interface NovelConfig {
1278
1434
  themeOpacity?: number
1279
1435
  /** 是否启用改编模式(默认关闭;发布后开启)。 */
1280
1436
  enableAdaptMode?: boolean
1437
+ /** 手动添加的模型库(「我的模型」条目;只存插件内)。 */
1438
+ savedModels?: SavedModel[]
1281
1439
  }
1282
1440
 
1283
1441
  /** GET /status response. */
@@ -1503,6 +1661,8 @@ export interface ConfigPatch {
1503
1661
  enableAdaptMode?: boolean
1504
1662
  /** 生图模型库(完整替换保存)。 */
1505
1663
  imageModels?: ImageModelConfig[]
1664
+ /** 手动添加的模型库(完整替换保存)。 */
1665
+ savedModels?: SavedModel[]
1506
1666
  }
1507
1667
 
1508
1668
  // ------------------------------------------------------------ assistant