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 零跳过
866 lines
32 KiB
TypeScript
866 lines
32 KiB
TypeScript
/**
|
||
* 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' : '默认 1000000(1M),用于上下文压缩判断'}
|
||
/>
|
||
)}
|
||
{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 默认 128K,gpt-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>
|
||
);
|
||
}
|