feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
工程化(从零到一): - 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证 - 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁) - 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync - 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies 安全加固: - PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额) - ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道) - Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面 - web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级) 缺陷修复(测试驱动发现): - mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试 - 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢 - 百度复合类名重复收录:class="result c-container" 被双重匹配 测试补齐(113 → 194 用例): - 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器 - 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批 体验升级: - OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡) - SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护) - MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销) - MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
This commit is contained in:
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* LLMSettings — LLM 配置 Tab
|
||||
*
|
||||
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
|
||||
* 功能:主 Provider / 故障转移 Provider / 上下文窗口配置,批量保存。
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Button,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
Stack,
|
||||
Typography,
|
||||
Divider,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
} 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';
|
||||
|
||||
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);
|
||||
|
||||
// 初始化:一次性加载所有 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'),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const [p, m, k, u, nc, ds, ag, mi, oa, an, fbp, fbm, fbk, fbu] = 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) ?? '');
|
||||
} 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 },
|
||||
{ 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 {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.success('配置已保存'))
|
||||
.catch(() => {});
|
||||
}
|
||||
} 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:// 开头' : ' '}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="模型名称"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="如 deepseek-v4-pro、gpt-4o、claude-sonnet-4-5"
|
||||
error={modelHasSpace}
|
||||
helperText={modelHasSpace ? '模型名称不能包含空格' : ' '}
|
||||
/>
|
||||
{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.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'}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user