Files
metona-ai-desktop/src/components/settings/LLMSettings.tsx
T
thzxx 26169b7be4
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m25s
CI / 产物编译验证 (push) Successful in 10m1s
feat: v0.7.2 安全收口 · 断链接线 · 观测补洞 — 230 用例扩充与全量回归
P1 修复面收口: /clear 全链路根治(前端清空联动 DB messages+摘要游标+TRACE 快照,
IPC 语义改"操作完成"; 流式中拒绝); web_browser open 补 SSRF 校验(Chromium 旁路关闭,
与 web_fetch/http_request 同源 validateSSRF); MCP 工具结果纳入注入扫描(mcp_* 前缀
按网络来源同级 full 模式, 收敛 resolveScanMode 单点); Trace 落库/入 store 双重瘦身
(tool_result base64/超长字段剥离, metadata 防 MB 级膨胀); 文本附件 512KB 闸门
(file.slice 首段读取+truncated 标志随消息持久化+主进程附件提示感知截断);
单实例锁(requestSingleInstanceLock + second-instance 聚焦已有窗口)

P2 安全纵深: ConfirmationHook 多窗口化(确认请求/超时提示改全窗口广播,
getAllWindows 空时回退 mainWindow, fail-closed 判定升级双通道); mcp_servers.headers
全链路接线(safeParseHeaders 容错解析+SSE/StreamableHTTP requestInit 注入+IPC 逐项
校验+设置页 JSON 输入, 远程 MCP 鉴权头可用)

P3 断链接线: llm:listModels IPC(六家 adapter 动态模型发现首次接线, 配置完整性
前置校验); Ollama pullModel IPC+设置页下载卡片(进度/取消/能力徽标, v0.7.0 死代码
激活); 后台会话运行指示(sessionRunStates 图+Sidebar 状态点, 多会话并发可见);
IR 卫生(移除 THINKING_START/END 死枚举, constraints 标注预留)

P4 质量与文档: i18n 第二阶段(确认弹框/侧栏/状态栏/AgentMonitor/终止原因出层,
外观设置 zh-CN/en-US 切换, ui.locale 持久化, 渲染时求值规避异步注册); README/D1
文档对齐(http_request 风险等级/用例数/实现状态注记); 版本号 0.7.2

测试: 507 → 737 用例(+230, 11 个新文件)。覆盖补齐: context-builder/consolidator/
orchestrator/workspace.service/session-recorder/config-layering/secure-config/
network-proxy + IPC mcp/tasks/memory/app/data 域 + 渲染层 store 与流事件管线纯函数。
测试驱动修复: workspace.appendMemory 中文分区 \b 词边界失效(JS \b 不含 CJK),
固化条目恒追加文件末尾产生重复分区头 → (?=\n|$) 前瞻断言根治

回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 687 通过 50 跳过;
Electron ABI 全量 737/737 零跳过
2026-08-30 00:09:25 +08:00

866 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string>('');
const [model, setModel] = useState<string>('');
const [apiKey, setApiKey] = useState<string>('');
const [baseURL, setBaseURL] = useState<string>('');
const [numCtx, setNumCtx] = useState<number | null>(null);
// v0.3.1: DeepSeek/Agnes/MiMo contextWindow 可配置(不再写死)
const [dsCtxWindow, setDsCtxWindow] = useState<number>(1000000);
const [agnesCtxWindow, setAgnesCtxWindow] = useState<number>(1000000);
const [mimoCtxWindow, setMimoCtxWindow] = useState<number>(1000000);
// P3: OpenAI/Anthropic contextWindow
const [oaCtxWindow, setOaCtxWindow] = useState<number>(128000);
const [anthropicCtxWindow, setAnthropicCtxWindow] = useState<number>(200000);
// P1: 故障转移 Provider 配置
const [fbProvider, setFbProvider] = useState<string>('');
const [fbModel, setFbModel] = useState<string>('');
const [fbApiKey, setFbApiKey] = useState<string>('');
const [fbBaseURL, setFbBaseURL] = useState<string>('');
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<string | null>(null);
const [balanceLoading, setBalanceLoading] = useState(false);
// ===== v0.7.2 P3-9: 动态模型列表 =====
const [modelOptions, setModelOptions] = useState<ModelOption[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(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 (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
LLM 配置
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
加载中...
</Typography>
</Stack>
);
}
const apiKeyEmpty = provider !== 'ollama' && !apiKey.trim();
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
LLM 配置
</Typography>
<FormControl size="small">
<InputLabel>Provider</InputLabel>
<Select
value={provider}
label="Provider"
onChange={(e) => handleProviderChange(e.target.value)}
>
<MenuItem value="deepseek">DeepSeek</MenuItem>
<MenuItem value="agnes">Agnes AI</MenuItem>
<MenuItem value="mimo">MiMo (小米)</MenuItem>
<MenuItem value="ollama">Ollama (本地)</MenuItem>
<MenuItem value="openai">OpenAI</MenuItem>
<MenuItem value="anthropic">Anthropic</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
label="API Base URL"
value={baseURL}
onChange={(e) => setBaseURL(e.target.value)}
placeholder="如 https://api.deepseek.com"
error={urlError}
helperText={urlError ? '需以 http:// 或 https:// 开头' : ' '}
/>
{/* v0.7.2 P3-9: 模型名称 — 自由输入 + 动态模型列表候选(freeSolo */}
<Autocomplete
freeSolo
size="small"
disableClearable
options={modelOptions}
getOptionLabel={(option) => (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<string, unknown>;
const opt = option as ModelOption;
return (
<Box component="li" key={key} {...optionProps}>
<Stack sx={{ minWidth: 0 }}>
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
{opt.id}
</Typography>
{opt.supportsToolCalling === true && (
<Chip
label="tools"
size="small"
variant="outlined"
sx={{ height: 16, fontSize: 9 }}
/>
)}
{opt.supportsThinking === true && (
<Chip
label="thinking"
size="small"
variant="outlined"
sx={{ height: 16, fontSize: 9 }}
/>
)}
</Stack>
{opt.description && (
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 10 }}>
{opt.description}
</Typography>
)}
</Stack>
</Box>
);
}}
renderInput={(params) => (
<TextField
{...params}
size="small"
label="模型名称"
placeholder="如 deepseek-v4-pro、gpt-4o、claude-sonnet-4-5"
error={modelHasSpace}
helperText={
modelHasSpace
? '模型名称不能包含空格'
: modelsError
? modelsError
: modelsLoaded
? ' '
: '可手动输入,或点击下方按钮获取模型列表'
}
/>
)}
/>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Button
size="small"
variant="outlined"
onClick={loadModels}
disabled={modelsLoading}
startIcon={modelsLoading ? <CircularProgress size={12} /> : undefined}
sx={{ minWidth: 120, fontSize: 11 }}
>
获取模型列表
</Button>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
基于「已保存」的配置查询(修改后请先保存)
</Typography>
</Stack>
{/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
<FormControlLabel
control={
<Switch
size="small"
checked={multimodalEnabled}
onChange={(e) => setMultimodalEnabled(e.target.checked)}
/>
}
label={
<Stack>
<Typography variant="body2">启用多模态(图片输入)</Typography>
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
开启后可在输入框上传图片;DeepSeek vision
系列模型。历史会话图片会随上下文回传(最近 10 张)
</Typography>
</Stack>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
{provider !== 'ollama' && (
<>
<TextField
size="small"
label="API Key"
type={showKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
error={apiKeyEmpty}
helperText={
apiKeyEmpty
? `必填,未填 ${PROVIDER_LABELS[provider] ?? provider} 的 API Key 会 401`
: ' '
}
slotProps={{
input: {
endAdornment: (
<IconButton size="small" onClick={() => setShowKey(!showKey)}>
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
</IconButton>
),
},
}}
/>
</>
)}
{provider === 'ollama' && (
<TextField
size="small"
label="上下文长度 (num_ctx)"
type="number"
value={numCtx ?? ''}
onChange={(e) => {
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' && (
<Stack
spacing={1}
sx={{
p: 1.5,
borderRadius: 1.5,
bgcolor: 'secondary.main',
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
下载 Ollama 模型
</Typography>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<TextField
size="small"
fullWidth
value={pullModelName}
onChange={(e) => setPullModelName(e.target.value)}
placeholder="模型名,如 qwen3:8b"
disabled={pulling}
slotProps={{ input: { sx: { fontFamily: 'monospace', fontSize: 12 } } }}
/>
{pulling ? (
<Button
size="small"
color="error"
variant="outlined"
onClick={handlePullCancel}
sx={{ flexShrink: 0 }}
>
取消
</Button>
) : (
<Button
size="small"
variant="contained"
onClick={handlePull}
disabled={!pullModelName.trim()}
sx={{ flexShrink: 0 }}
>
下载
</Button>
)}
</Stack>
{pulling && pullStatus && (
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<LinearProgress
variant={pullStatus.percent != null ? 'determinate' : 'indeterminate'}
value={pullStatus.percent ?? undefined}
sx={{ flex: 1, height: 6, borderRadius: 3 }}
/>
<Typography
variant="caption"
noWrap
sx={{ minWidth: 140, textAlign: 'right', color: 'text.secondary', fontSize: 10 }}
>
{pullStatus.percent != null ? `${pullStatus.percent.toFixed(0)}% · ` : ''}
{pullStatus.label}
</Typography>
</Stack>
)}
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
Ollama 服务拉取模型(大模型下载耗时取决于网络,可随时取消);完成后获取模型列表即可见
</Typography>
</Stack>
)}
{/* v0.3.1: DeepSeek/Agnes 上下文窗口配置(用于 Engine 压缩判断和 UI 显示,不传给 API) */}
{provider === 'deepseek' && (
<TextField
size="small"
label="上下文窗口 (contextWindow)"
type="number"
value={dsCtxWindow}
onChange={(e) => 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' && (
<Stack
direction="row"
spacing={1}
sx={{
alignItems: 'center',
px: 1.5,
py: 1,
borderRadius: 1.5,
bgcolor: 'secondary.main',
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
账户余额
</Typography>
{balanceLoading ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
查询中...
</Typography>
) : balance ? (
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 600 }}>
{balance.totalBalance} {balance.currency}
<Typography component="span" variant="caption" sx={{ color: 'text.disabled', ml: 1 }}>
(赠送 {balance.grantedBalance} + 充值 {balance.toppedUpBalance}
</Typography>
</Typography>
) : (
<Typography
variant="caption"
sx={{ color: balanceError ? 'error.main' : 'text.disabled' }}
>
{balanceError ?? '未查询(需已保存 API Key'}
</Typography>
)}
<Button
size="small"
variant="outlined"
onClick={loadBalance}
disabled={balanceLoading || !apiKey.trim()}
sx={{ ml: 'auto', minWidth: 64, fontSize: 11 }}
>
刷新
</Button>
</Stack>
)}
{provider === 'agnes' && (
<TextField
size="small"
label="上下文窗口 (contextWindow)"
type="number"
value={agnesCtxWindow}
onChange={(e) => setAgnesCtxWindow(Number(e.target.value) || 1000000)}
placeholder="如 64000、128000、1000000"
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
error={agnesCtxError}
helperText={agnesCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'}
/>
)}
{provider === 'mimo' && (
<TextField
size="small"
label="上下文窗口 (contextWindow)"
type="number"
value={mimoCtxWindow}
onChange={(e) => setMimoCtxWindow(Number(e.target.value) || 1000000)}
placeholder="如 65536、131072、1000000"
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
error={mimoCtxError}
helperText={mimoCtxError ? '最小值为 4096' : '默认 10000001M),用于上下文压缩判断'}
/>
)}
{provider === 'openai' && (
<TextField
size="small"
label="上下文窗口 (contextWindow)"
type="number"
value={oaCtxWindow}
onChange={(e) => setOaCtxWindow(Number(e.target.value) || 128000)}
placeholder="如 128000、200000、1000000"
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
error={oaCtxError}
helperText={oaCtxError ? '最小值为 4096' : 'gpt-4o 默认 128Kgpt-4.1 默认 1M'}
/>
)}
{provider === 'anthropic' && (
<TextField
size="small"
label="上下文窗口 (contextWindow)"
type="number"
value={anthropicCtxWindow}
onChange={(e) => setAnthropicCtxWindow(Number(e.target.value) || 200000)}
placeholder="如 200000"
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
error={anthropicCtxError}
helperText={anthropicCtxError ? '最小值为 4096' : 'Claude 默认 200K'}
/>
)}
{/* ===== P1: 故障转移 Provider(主 Provider 请求失败时自动切换) ===== */}
<Divider />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
故障转移(可选)
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Provider 请求失败(重试耗尽或密钥失效)时自动切换到备用 Provider 重发。留空禁用。
</Typography>
<FormControl size="small">
<InputLabel>备用 Provider</InputLabel>
<Select
value={fbProvider}
label="备用 Provider"
onChange={(e) => {
const v = e.target.value;
setFbProvider(v);
setFbModel('');
setFbApiKey('');
setFbBaseURL(PROVIDER_URLS[v] ?? '');
}}
>
<MenuItem value="">
<em>禁用</em>
</MenuItem>
<MenuItem value="deepseek">DeepSeek</MenuItem>
<MenuItem value="agnes">Agnes AI</MenuItem>
<MenuItem value="mimo">MiMo (小米)</MenuItem>
<MenuItem value="ollama">Ollama (本地)</MenuItem>
<MenuItem value="openai">OpenAI</MenuItem>
<MenuItem value="anthropic">Anthropic</MenuItem>
</Select>
</FormControl>
{fbProvider && (
<>
<TextField
size="small"
label="备用 Base URL"
value={fbBaseURL}
onChange={(e) => setFbBaseURL(e.target.value)}
placeholder="如 https://api.deepseek.com"
/>
<TextField
size="small"
label="备用模型名称"
value={fbModel}
onChange={(e) => setFbModel(e.target.value)}
placeholder="如 deepseek-v4-flash"
/>
{fbProvider !== 'ollama' && (
<TextField
size="small"
label="备用 API Key"
type={showFbKey ? 'text' : 'password'}
value={fbApiKey}
onChange={(e) => setFbApiKey(e.target.value)}
placeholder="sk-..."
slotProps={{
input: {
endAdornment: (
<IconButton size="small" onClick={() => setShowFbKey(!showFbKey)}>
{showFbKey ? <EyeOff size={14} /> : <Eye size={14} />}
</IconButton>
),
},
}}
/>
)}
</>
)}
{/* Save 按钮:批量提交,取消 onChange 实时落库 */}
<Stack direction="row" spacing={1} sx={{ mt: 1, alignItems: 'center' }}>
<Button
variant="contained"
size="small"
onClick={handleSave}
disabled={saving || hasBlockingError}
startIcon={saving ? <CircularProgress size={12} /> : undefined}
>
{saving ? '保存中...' : '保存配置'}
</Button>
{hasBlockingError && (
<Typography variant="caption" sx={{ color: 'error.main', fontSize: 11 }}>
请修正表单错误后再保存
</Typography>
)}
</Stack>
</Stack>
);
}