/** * LLMSettings — LLM 配置 Tab * * 从 SettingsModal.tsx 提取(v0.4.1 拆分)。 * 功能:主 Provider / 故障转移 Provider / 上下文窗口配置,批量保存。 */ import { useState, useEffect, useCallback, useRef } from 'react'; import { Autocomplete, Box, Button, Chip, LinearProgress, TextField, Select, MenuItem, Stack, Typography, Divider, InputLabel, FormControl, IconButton, CircularProgress, FormControlLabel, Switch, } from '@mui/material'; import { Eye, EyeOff } from 'lucide-react'; import { useAgentStore } from '@renderer/stores/agent-store'; import { PROVIDER_LABELS } from '@renderer/lib/constants'; import { PROVIDER_URLS } from './useConfig'; /** v0.7.2 P3-9: 动态模型条目(渲染端子集,与 global.d.ts MetonaModelInfoLite 对齐) */ interface ModelOption { id: string; name?: string; supportsToolCalling?: boolean; supportsThinking?: boolean; description?: string; } export function LLMSettings() { // 改为本地 state + Save 按钮统一提交,避免 onChange 实时落库导致: // 1. 改 Base URL 时被回滚卡死(useConfig seqRef 机制与连续输入冲突) // 2. 每按一个字符就触发一次 IPC + DB + reloadAdapter,浪费且会打断输入 // 3. 错误提示笼统(不指向具体字段) const [provider, setProvider] = useState(''); const [model, setModel] = useState(''); const [apiKey, setApiKey] = useState(''); const [baseURL, setBaseURL] = useState(''); const [numCtx, setNumCtx] = useState(null); // v0.3.1: DeepSeek/Agnes/MiMo contextWindow 可配置(不再写死) const [dsCtxWindow, setDsCtxWindow] = useState(1000000); const [agnesCtxWindow, setAgnesCtxWindow] = useState(1000000); const [mimoCtxWindow, setMimoCtxWindow] = useState(1000000); // P3: OpenAI/Anthropic contextWindow const [oaCtxWindow, setOaCtxWindow] = useState(128000); const [anthropicCtxWindow, setAnthropicCtxWindow] = useState(200000); // P1: 故障转移 Provider 配置 const [fbProvider, setFbProvider] = useState(''); const [fbModel, setFbModel] = useState(''); const [fbApiKey, setFbApiKey] = useState(''); const [fbBaseURL, setFbBaseURL] = useState(''); const [showKey, setShowKey] = useState(false); const [showFbKey, setShowFbKey] = useState(false); const [loaded, setLoaded] = useState(false); const [saving, setSaving] = useState(false); // v0.5.4: 多模态总开关(未开启时禁止上传图片,即使模型支持) const [multimodalEnabled, setMultimodalEnabled] = useState(false); // v0.5.0: DeepSeek 余额显示(复用主进程 getBalance,原为适配器死代码) const [balance, setBalance] = useState<{ currency: string; totalBalance: string; grantedBalance: string; toppedUpBalance: string; } | null>(null); const [balanceError, setBalanceError] = useState(null); const [balanceLoading, setBalanceLoading] = useState(false); // ===== v0.7.2 P3-9: 动态模型列表 ===== const [modelOptions, setModelOptions] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(null); const [modelsLoaded, setModelsLoaded] = useState(false); // ===== v0.7.2 P3-10: Ollama 模型下载 ===== const [pullModelName, setPullModelName] = useState(''); const [pulling, setPulling] = useState(false); const [pullStatus, setPullStatus] = useState<{ label: string; percent: number | null } | null>( null, ); const loadModels = useCallback(async () => { if (!window.metona?.llm?.listModels) return; setModelsLoading(true); setModelsError(null); try { const r = await window.metona.llm.listModels(); if (r.success && r.data) { setModelOptions(r.data); setModelsLoaded(true); } else { setModelOptions([]); setModelsError(r.error ?? '获取模型列表失败'); } } catch (err) { setModelOptions([]); setModelsError((err as Error).message); } finally { setModelsLoading(false); } }, []); const handlePull = useCallback(async () => { const name = pullModelName.trim(); if (!name || pulling || !window.metona?.llm?.pullModel) return; setPulling(true); setPullStatus({ label: '连接 Ollama 服务…', percent: null }); try { const r = await window.metona.llm.pullModel(name); if (r.success) { import('@metona-team/metona-toast') .then((mod) => mod.default.success(`模型 ${name} 下载完成`)) .catch(() => {}); } else if (!r.aborted) { import('@metona-team/metona-toast') .then((mod) => mod.default.error(`模型下载失败:${r.error ?? '未知错误'}`)) .catch(() => {}); } } catch (err) { import('@metona-team/metona-toast') .then((mod) => mod.default.error(`模型下载失败:${(err as Error).message}`)) .catch(() => {}); } finally { // onOllamaPullEnded 也会复位(时序竞态下双保险),此处兜底同步复位 setPulling(false); setPullStatus(null); } }, [pullModelName, pulling]); const handlePullCancel = useCallback(async () => { if (!window.metona?.llm?.cancelPullModel) return; try { await window.metona.llm.cancelPullModel(); } catch (err) { console.error('[LLMSettings] cancelPullModel failed:', err); } }, []); // 订阅下载进度/结束事件(全局单任务广播,无需按 model 过滤) const pullingRef = useRef(false); pullingRef.current = pulling; useEffect(() => { if (!window.metona?.llm?.onOllamaPullProgress) return; const offProgress = window.metona.llm.onOllamaPullProgress((d) => { const percent = d.total != null && d.completed != null && d.total > 0 ? Math.min(100, (d.completed / d.total) * 100) : null; setPullStatus({ label: d.status, percent }); }); const offEnded = window.metona.llm.onOllamaPullEnded(() => { if (!pullingRef.current) return; setPulling(false); setPullStatus(null); // 本地库新增了模型 — 刷新已加载的列表(未加载过则保持未加载语义) void loadModels(); }); return () => { offProgress(); offEnded(); }; }, [loadModels]); const loadBalance = useCallback(async () => { if (!window.metona?.llm?.getBalance) return; setBalanceLoading(true); setBalanceError(null); try { const r = await window.metona.llm.getBalance(); if (r.success && r.data) { setBalance(r.data); } else { setBalance(null); setBalanceError(r.error ?? '查询失败'); } } catch (err) { setBalance(null); setBalanceError((err as Error).message); } finally { setBalanceLoading(false); } }, []); // DeepSeek Provider 且配置加载完成后自动查询一次(仅一次,避免频繁请求 API) // 依赖故意不含 apiKey —— 仅在加载完成与 Provider 切换时查询,apiKey 输入变化不重复请求 useEffect(() => { if (loaded && provider === 'deepseek' && apiKey.trim()) { void loadBalance(); } else { setBalance(null); setBalanceError(null); } }, [loaded, provider]); // 初始化:一次性加载所有 LLM 配置字段 useEffect(() => { let cancelled = false; const load = async () => { if (!window.metona?.config?.get) { setLoaded(true); return; } try { const results = await Promise.all([ window.metona.config.get('llm.provider'), window.metona.config.get('llm.model'), window.metona.config.get('llm.apiKey'), window.metona.config.get('llm.baseURL'), window.metona.config.get('ollama.numCtx'), window.metona.config.get('deepseek.contextWindow'), window.metona.config.get('agnes.contextWindow'), window.metona.config.get('mimo.contextWindow'), window.metona.config.get('openai.contextWindow'), window.metona.config.get('anthropic.contextWindow'), // P1: 故障转移配置 window.metona.config.get('llm.fallbackProvider'), window.metona.config.get('llm.fallbackModel'), window.metona.config.get('llm.fallbackApiKey'), window.metona.config.get('llm.fallbackBaseURL'), // v0.5.4: 多模态开关 window.metona.config.get('llm.multimodalEnabled'), ]); if (cancelled) return; const [p, m, k, u, nc, ds, ag, mi, oa, an, fbp, fbm, fbk, fbu, mm] = results; setProvider((p as string) ?? ''); setModel((m as string) ?? ''); setApiKey((k as string) ?? ''); setBaseURL((u as string) ?? ''); setNumCtx((nc as number | null) ?? null); if (typeof ds === 'number' && ds > 0) setDsCtxWindow(ds); if (typeof ag === 'number' && ag > 0) setAgnesCtxWindow(ag); if (typeof mi === 'number' && mi > 0) setMimoCtxWindow(mi); if (typeof oa === 'number' && oa > 0) setOaCtxWindow(oa); if (typeof an === 'number' && an > 0) setAnthropicCtxWindow(an); setFbProvider((fbp as string) ?? ''); setFbModel((fbm as string) ?? ''); setFbApiKey((fbk as string) ?? ''); setFbBaseURL((fbu as string) ?? ''); setMultimodalEnabled(mm === true); } catch (err) { console.error('[LLMSettings]', err); } finally { if (!cancelled) setLoaded(true); } }; load(); return () => { cancelled = true; }; }, []); // 同步 Provider/Model 到 Agent Store(含 contextWindow) // 注意:仅同步运行时状态,不落库 useEffect(() => { useAgentStore.getState().setProvider(provider, model); }, [provider, model]); // v0.3.1: contextWindow 变化时同步到 Agent Store(支持所有 Provider) useEffect(() => { if (provider === 'ollama') { if (numCtx != null && numCtx > 0) useAgentStore.setState({ contextWindow: numCtx }); } else if (provider === 'deepseek') { if (dsCtxWindow != null && dsCtxWindow > 0) useAgentStore.setState({ contextWindow: dsCtxWindow }); } else if (provider === 'agnes') { if (agnesCtxWindow != null && agnesCtxWindow > 0) useAgentStore.setState({ contextWindow: agnesCtxWindow }); } else if (provider === 'mimo') { if (mimoCtxWindow != null && mimoCtxWindow > 0) useAgentStore.setState({ contextWindow: mimoCtxWindow }); } else if (provider === 'openai') { if (oaCtxWindow != null && oaCtxWindow > 0) useAgentStore.setState({ contextWindow: oaCtxWindow }); } else if (provider === 'anthropic') { if (anthropicCtxWindow != null && anthropicCtxWindow > 0) useAgentStore.setState({ contextWindow: anthropicCtxWindow }); } }, [ provider, numCtx, dsCtxWindow, agnesCtxWindow, mimoCtxWindow, oaCtxWindow, anthropicCtxWindow, ]); // ===== 字段级 inline 校验 ===== // Base URL:非空时必须以 http:// 或 https:// 开头(避免漏写协议头导致发消息时报 Invalid URL) const urlError = !!baseURL && !/^https?:\/\/.+/.test(baseURL); // Model:非空时不允许包含空格(OpenAI API 会把空格后的部分当作额外参数) const modelHasSpace = !!model && /\s/.test(model); // contextWindow / numCtx:必须为有限正数且不低于最小值 const numCtxError = numCtx != null && (!Number.isFinite(numCtx) || numCtx < 512); const dsCtxError = !Number.isFinite(dsCtxWindow) || dsCtxWindow < 4096; const agnesCtxError = !Number.isFinite(agnesCtxWindow) || agnesCtxWindow < 4096; const mimoCtxError = !Number.isFinite(mimoCtxWindow) || mimoCtxWindow < 4096; const oaCtxError = !Number.isFinite(oaCtxWindow) || oaCtxWindow < 4096; const anthropicCtxError = !Number.isFinite(anthropicCtxWindow) || anthropicCtxWindow < 4096; // 是否存在阻断保存的错误(API Key 为空只警告,不阻断 — 允许先填其他字段再回来填 key) const hasBlockingError = urlError || modelHasSpace || numCtxError || (provider === 'deepseek' && dsCtxError) || (provider === 'agnes' && agnesCtxError) || (provider === 'mimo' && mimoCtxError) || (provider === 'openai' && oaCtxError) || (provider === 'anthropic' && anthropicCtxError); // 切换 Provider 时:清空 apiKey + 清空 model + 自动填充默认 URL // 不同 Provider 的 key/model 互不通用,避免用旧值调用新 API 导致 401 / model not found const handleProviderChange = (newProvider: string) => { const oldProvider = provider; setProvider(newProvider); // 切换 Provider 时清空 apiKey(不同 Provider 的 key 格式不同) if (oldProvider !== newProvider && apiKey) { setApiKey(''); } // 切换 Provider 时清空 model(不同 Provider 支持的模型名不同,如 deepseek-v4-pro 不适用于 ollama) if (oldProvider !== newProvider && model) { setModel(''); } // 自动填充默认 URL(仅在 URL 为空或与旧 provider 默认 URL 匹配时覆盖) const currentUrl = baseURL.trim(); const isDefaultUrl = Object.values(PROVIDER_URLS).includes(currentUrl); if (isDefaultUrl || !currentUrl) { setBaseURL(PROVIDER_URLS[newProvider] ?? ''); } }; const handleSave = async () => { if (hasBlockingError) { import('@metona-team/metona-toast') .then((mod) => mod.default.error('请修正表单中的错误后再保存')) .catch(() => {}); return; } setSaving(true); try { const setBatch = window.metona?.config?.setBatch; if (!setBatch) { import('@metona-team/metona-toast') .then((mod) => mod.default.error('配置 API 不可用')) .catch(() => {}); return; } // v0.3.9: 批量保存,避免串行保存中间态触发 reloadAdapter 失败 const entries: Array<{ key: string; value: unknown }> = [ { key: 'llm.provider', value: provider }, { key: 'llm.model', value: model }, { key: 'llm.apiKey', value: apiKey }, { key: 'llm.baseURL', value: baseURL }, // v0.5.4: 多模态总开关 { key: 'llm.multimodalEnabled', value: multimodalEnabled }, { key: 'ollama.numCtx', value: numCtx }, { key: 'deepseek.contextWindow', value: dsCtxWindow }, { key: 'agnes.contextWindow', value: agnesCtxWindow }, { key: 'mimo.contextWindow', value: mimoCtxWindow }, { key: 'openai.contextWindow', value: oaCtxWindow }, { key: 'anthropic.contextWindow', value: anthropicCtxWindow }, // P1: 故障转移 Provider(主 Provider 失败时切换) { key: 'llm.fallbackProvider', value: fbProvider }, { key: 'llm.fallbackModel', value: fbModel }, { key: 'llm.fallbackApiKey', value: fbApiKey }, { key: 'llm.fallbackBaseURL', value: fbBaseURL }, ]; const r = await setBatch(entries); if (r && !r.success) { import('@metona-team/metona-toast') .then((mod) => mod.default.error(r.error ?? '配置保存失败')) .catch(() => {}); } else { // v0.5.4: 保存成功后同步多模态开关到 Agent Store(立即生效,控制上传入口) useAgentStore.getState().setMultimodalEnabled(multimodalEnabled); import('@metona-team/metona-toast') .then((mod) => mod.default.success('配置已保存')) .catch(() => {}); // v0.7.2 P3-9: 保存成功后配置与引擎 adapter 一致 —— 若列表已加载过则静默刷新, // 保证候选列表与刚保存的 Provider/BaseURL 同源 if (modelsLoaded) void loadModels(); } } catch (err) { import('@metona-team/metona-toast') .then((mod) => mod.default.error(`保存失败:${(err as Error).message}`)) .catch(() => {}); } finally { setSaving(false); } }; if (!loaded) { return ( LLM 配置 加载中... ); } const apiKeyEmpty = provider !== 'ollama' && !apiKey.trim(); return ( LLM 配置 Provider setBaseURL(e.target.value)} placeholder="如 https://api.deepseek.com" error={urlError} helperText={urlError ? '需以 http:// 或 https:// 开头' : ' '} /> {/* v0.7.2 P3-9: 模型名称 — 自由输入 + 动态模型列表候选(freeSolo) */} (typeof option === 'string' ? option : option.id)} inputValue={model} onInputChange={(_, newValue) => setModel(newValue)} loading={modelsLoading} renderOption={(props, option) => { const { key, ...optionProps } = props as { key: string } & Record; const opt = option as ModelOption; return ( {opt.id} {opt.supportsToolCalling === true && ( )} {opt.supportsThinking === true && ( )} {opt.description && ( {opt.description} )} ); }} renderInput={(params) => ( )} /> 基于「已保存」的配置查询(修改后请先保存) {/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */} setMultimodalEnabled(e.target.checked)} /> } label={ 启用多模态(图片输入) 开启后可在输入框上传图片;DeepSeek 需 vision 系列模型。历史会话图片会随上下文回传(最近 10 张) } sx={{ alignItems: 'flex-start', m: 0 }} /> {provider !== 'ollama' && ( <> setApiKey(e.target.value)} placeholder="sk-..." error={apiKeyEmpty} helperText={ apiKeyEmpty ? `必填,未填 ${PROVIDER_LABELS[provider] ?? provider} 的 API Key 会 401` : ' ' } slotProps={{ input: { endAdornment: ( setShowKey(!showKey)}> {showKey ? : } ), }, }} /> )} {provider === 'ollama' && ( { const v = e.target.value; setNumCtx(v === '' ? null : Number(v)); }} placeholder="默认由模型决定(如 2048、4096、128000)" slotProps={{ htmlInput: { min: 512, step: 512 } }} error={numCtxError} helperText={numCtxError ? '最小值为 512' : ' '} /> )} {/* ===== v0.7.2 P3-10: Ollama 模型下载(adapter.pullModel 首次接线 UI) ===== */} {provider === 'ollama' && ( 下载 Ollama 模型 setPullModelName(e.target.value)} placeholder="模型名,如 qwen3:8b" disabled={pulling} slotProps={{ input: { sx: { fontFamily: 'monospace', fontSize: 12 } } }} /> {pulling ? ( ) : ( )} {pulling && pullStatus && ( {pullStatus.percent != null ? `${pullStatus.percent.toFixed(0)}% · ` : ''} {pullStatus.label} )} 从 Ollama 服务拉取模型(大模型下载耗时取决于网络,可随时取消);完成后获取模型列表即可见 )} {/* v0.3.1: DeepSeek/Agnes 上下文窗口配置(用于 Engine 压缩判断和 UI 显示,不传给 API) */} {provider === 'deepseek' && ( setDsCtxWindow(Number(e.target.value) || 1000000)} placeholder="如 64000、128000、1000000" slotProps={{ htmlInput: { min: 4096, step: 4096 } }} error={dsCtxError} helperText={dsCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'} /> )} {/* v0.5.0: DeepSeek 账户余额显示 */} {provider === 'deepseek' && ( 账户余额 {balanceLoading ? ( 查询中... ) : balance ? ( {balance.totalBalance} {balance.currency} (赠送 {balance.grantedBalance} + 充值 {balance.toppedUpBalance}) ) : ( {balanceError ?? '未查询(需已保存 API Key)'} )} )} {provider === 'agnes' && ( setAgnesCtxWindow(Number(e.target.value) || 1000000)} placeholder="如 64000、128000、1000000" slotProps={{ htmlInput: { min: 4096, step: 4096 } }} error={agnesCtxError} helperText={agnesCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'} /> )} {provider === 'mimo' && ( setMimoCtxWindow(Number(e.target.value) || 1000000)} placeholder="如 65536、131072、1000000" slotProps={{ htmlInput: { min: 4096, step: 4096 } }} error={mimoCtxError} helperText={mimoCtxError ? '最小值为 4096' : '默认 1000000(1M),用于上下文压缩判断'} /> )} {provider === 'openai' && ( setOaCtxWindow(Number(e.target.value) || 128000)} placeholder="如 128000、200000、1000000" slotProps={{ htmlInput: { min: 4096, step: 4096 } }} error={oaCtxError} helperText={oaCtxError ? '最小值为 4096' : 'gpt-4o 默认 128K,gpt-4.1 默认 1M'} /> )} {provider === 'anthropic' && ( setAnthropicCtxWindow(Number(e.target.value) || 200000)} placeholder="如 200000" slotProps={{ htmlInput: { min: 4096, step: 4096 } }} error={anthropicCtxError} helperText={anthropicCtxError ? '最小值为 4096' : 'Claude 默认 200K'} /> )} {/* ===== P1: 故障转移 Provider(主 Provider 请求失败时自动切换) ===== */} 故障转移(可选) 主 Provider 请求失败(重试耗尽或密钥失效)时自动切换到备用 Provider 重发。留空禁用。 备用 Provider {fbProvider && ( <> setFbBaseURL(e.target.value)} placeholder="如 https://api.deepseek.com" /> setFbModel(e.target.value)} placeholder="如 deepseek-v4-flash" /> {fbProvider !== 'ollama' && ( setFbApiKey(e.target.value)} placeholder="sk-..." slotProps={{ input: { endAdornment: ( setShowFbKey(!showFbKey)}> {showFbKey ? : } ), }, }} /> )} )} {/* Save 按钮:批量提交,取消 onChange 实时落库 */} {hasBlockingError && ( 请修正表单错误后再保存 )} ); }