@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
|
@@ -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'
|
|
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'
|
|
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 [
|
|
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.
|
|
184
|
-
<
|
|
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
|
-
|
|
198
|
-
<
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
253
|
-
<
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
272
|
-
{
|
|
273
|
-
<
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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(
|
|
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 ??
|
|
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
|