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 零跳过
This commit is contained in:
@@ -5,9 +5,13 @@
|
||||
* 功能:主 Provider / 故障转移 Provider / 上下文窗口配置,批量保存。
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
@@ -26,6 +30,15 @@ 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 机制与连续输入冲突)
|
||||
@@ -64,6 +77,101 @@ export function LLMSettings() {
|
||||
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);
|
||||
@@ -280,6 +388,9 @@ export function LLMSettings() {
|
||||
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')
|
||||
@@ -334,15 +445,86 @@ export function LLMSettings() {
|
||||
error={urlError}
|
||||
helperText={urlError ? '需以 http:// 或 https:// 开头' : ' '}
|
||||
/>
|
||||
<TextField
|
||||
{/* v0.7.2 P3-9: 模型名称 — 自由输入 + 动态模型列表候选(freeSolo) */}
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
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 ? '模型名称不能包含空格' : ' '}
|
||||
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={
|
||||
@@ -406,6 +588,75 @@ export function LLMSettings() {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user