feat: v0.7.0 四阶段全量迭代 — 修复面收口 · 安全纵深 · 架构还债 · 能力演进
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m45s
CI / 全量测试 (Electron ABI) (push) Failing after 5m22s
CI / 产物编译验证 (push) Successful in 10m3s

P1 修复面收口: v0.6.3 截断自愈推全量(Anthropic/Ollama/非流式/引擎兜底); SSE 上游错误帧检测进重试通道;
clearMessages 摘要游标根治; truncateResult 内联图片白名单统一; 前端四 bug(确认弹窗锁死/MemoryViewer/
Virtuoso Footer/abort 尾部过滤) + reasoning 缓冲跨迭代污染; 托盘通知过滤与新建会话死链接线

P2 安全纵深: MCP 审批闭环(ConfirmationHook×PolicyEngine 联动+重名拒注册); SSRF 收敛 ssrf-guard 共享模块
(web_fetch 双通道校验+重定向终态复检); Electron 加固(preload CJS 化→sandbox:true/CSP/权限白名单/will-navigate);
run_command cmd.exe 白名单通道元字符守门; diff_viewer 10MB 预检; Anthropic thinking 预算下限; Agnes 思考显式关闭

P3 架构还债: OpenAICompatibleAdapter 中间基类收敛四家样板; 错误分类单轨化(删 mapError/getFetchSignal,
超时显式 ETIMEDOUT); PRAGMA user_version 迁移版本化; 死代码清理专项(cn.ts/SHORTCUTS/ContextMenu 分支/
getWindowState/modifiedArgs/sandbox 空壳); i18next 引入; a11y 第一轮; SearXNG 页批量草稿模型统一

P4 能力演进: Ollama pull 可取消/capabilities 探测/num_ctx 实测缓存; UpdateService feed 比对式自动更新
(app:updateCheck IPC + StatusBar 入口); MiMo providerOptions(web_search 服务端工具/strict JSON);
web_fetch extract_mode=markdown(turndown); network.proxyUrl 全局代理(Chromium sessions+undici dispatcher)

测试: 264 → 507 用例(Electron ABI 全绿零跳过), 覆盖引擎压缩管线/重试竞速/MEMORY.md 闸门/file_editor 五操作/
filesystem 七工具实体夹具/git 真实仓库/SSE 错误帧/全线截断自愈/Provider 请求形态矩阵/SSRF 表测/钩子分级矩阵/
OutputValidator 全量/SLO 指标/MCP 安全纯函数/task_manager 链路/渲染层纯域/i18n 桥契约
This commit is contained in:
2026-08-27 17:06:58 +08:00
parent b6e2a8bd25
commit 3940716dc2
78 changed files with 6369 additions and 1341 deletions
+243 -43
View File
@@ -3,9 +3,15 @@
*
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
* 功能:SearXNG 实例配置(12 项)+ 连接测试。
*
* v0.6.4 P3-7 重构(批量草稿模型统一):原先 12 个字段各自经 useConfig
* "逐键实时落库" —— 数字输入每敲一个字符就触发一次 IPC 写 + reloadAdapter 副作用,
* 且 SearXNG 的数字字段无范围 guard 会把中间态写进配置。现改为与 LLMSettings 同一
* 草稿范式:mount 时一次读入 → 本地草稿编辑(dirty 标记)→ 显式"保存"批量提交。
* 启用开关保持即时生效(它是功能总开关,语义上属于立即动作而非表单字段)。
*/
import { useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Button,
TextField,
@@ -24,36 +30,162 @@ import {
IconButton,
CircularProgress,
} from '@mui/material';
import { Eye, EyeOff } from 'lucide-react';
import { useConfig } from './useConfig';
import { Eye, EyeOff, Save } from 'lucide-react';
interface DraftConfig {
url: string;
engines: string;
language: string;
safesearch: number;
time_range: string;
max_results: number;
auth_key: string;
auth_type: string;
format: string;
fetch_count: number;
fetch_mode: string;
}
type DraftKey = keyof DraftConfig;
const DRAFT_DEFAULTS: DraftConfig = {
url: '',
engines: '',
language: 'zh-CN',
safesearch: 1,
time_range: '',
max_results: 0,
auth_key: '',
auth_type: 'bearer',
format: 'json',
fetch_count: 0,
fetch_mode: 'sequential',
};
/** 配置键 → 草稿字段的映射与读取类型转换 */
const KEY_MAP: Array<{ key: string; field: DraftKey; type: 'string' | 'number' }> = [
{ key: 'searxng.url', field: 'url', type: 'string' },
{ key: 'searxng.engines', field: 'engines', type: 'string' },
{ key: 'searxng.language', field: 'language', type: 'string' },
{ key: 'searxng.safesearch', field: 'safesearch', type: 'number' },
{ key: 'searxng.time_range', field: 'time_range', type: 'string' },
{ key: 'searxng.max_results', field: 'max_results', type: 'number' },
{ key: 'searxng.auth_key', field: 'auth_key', type: 'string' },
{ key: 'searxng.auth_type', field: 'auth_type', type: 'string' },
{ key: 'searxng.format', field: 'format', type: 'string' },
{ key: 'searxng.fetch_count', field: 'fetch_count', type: 'number' },
{ key: 'searxng.fetch_mode', field: 'fetch_mode', type: 'string' },
];
export function SearXNGSettings() {
// ===== 12 项配置(useConfig 实时持久化) =====
const [enabled, setEnabled] = useConfig('searxng.enabled', false);
const [url, setUrl] = useConfig('searxng.url', '');
const [engines, setEngines] = useConfig('searxng.engines', '');
const [language, setLanguage] = useConfig('searxng.language', 'zh-CN');
const [safesearch, setSafesearch] = useConfig('searxng.safesearch', 1);
const [timeRange, setTimeRange] = useConfig('searxng.time_range', '');
const [maxResults, setMaxResults] = useConfig('searxng.max_results', 0);
const [authKey, setAuthKey] = useConfig('searxng.auth_key', '');
const [authType, setAuthType] = useConfig('searxng.auth_type', 'bearer');
const [format, setFormat] = useConfig('searxng.format', 'json');
const [fetchCount, setFetchCount] = useConfig('searxng.fetch_count', 0);
const [fetchMode, setFetchMode] = useConfig('searxng.fetch_mode', 'sequential');
const [enabled, setEnabledState] = useState<boolean>(false);
const [enabledLoaded, setEnabledLoaded] = useState(false);
const [draft, setDraft] = useState<DraftConfig>(DRAFT_DEFAULTS);
/** 首次加载完成(或最近一次成功保存)时的草稿快照 —— dirty 判定基准 */
const [baseline, setBaseline] = useState<DraftConfig | null>(null);
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const [savedNotice, setSavedNotice] = useState(false);
const [showKey, setShowKey] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const urlError = !!url && !/^https?:\/\//.test(url);
// ===== mount:一次性读入全部配置(12 次 IPC 并行,替代原逐键写路径)=====
useEffect(() => {
let cancelled = false;
void (async () => {
const entries = await Promise.all(
KEY_MAP.map(async ({ key }) => ({ key, value: await window.metona?.config?.get(key) })),
);
if (cancelled) return;
const next = { ...DRAFT_DEFAULTS };
for (const entry of entries) {
const spec = KEY_MAP.find((k) => k.key === entry.key)!;
if (entry.value === null || entry.value === undefined) continue;
(next[spec.field] as unknown) =
spec.type === 'number' ? Number(entry.value) : String(entry.value);
}
setDraft(next);
// 精确 dirty 基准:以加载时快照对比,而非"任何交互即 dirty"
setBaseline(next);
setLoaded(true);
})();
void window.metona?.config
?.get('searxng.enabled')
.then((v) => {
if (!cancelled) {
setEnabledState(v === true);
setEnabledLoaded(true);
}
})
.catch(() => setEnabledLoaded(true));
return () => {
cancelled = true;
};
}, []);
const updateField = useCallback(<K extends DraftKey>(field: K, value: DraftConfig[K]) => {
setSavedNotice(false);
setDraft((prev) => ({ ...prev, [field]: value }));
}, []);
// v0.6.4 P3-7: dirty 精确判定 —— 当前草稿与基线快照逐字段比较
const dirty = useMemo(() => {
if (!loaded || !baseline) return false;
return KEY_MAP.some(({ field }) => draft[field] !== baseline[field]);
}, [draft, baseline, loaded]);
const handleSave = async () => {
setSaving(true);
try {
await window.metona?.config?.setBatch(
KEY_MAP.map(({ key, field }) => ({ key, value: draft[field] as string | number })),
);
setBaseline(draft);
setSavedNotice(true);
import('@metona-team/metona-toast')
.then((mod) => mod.default.success('SearXNG 配置已保存'))
.catch(() => {});
} catch (err) {
console.error('[SearXNGSettings]', err);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('保存失败'))
.catch(() => {});
}
setSaving(false);
};
// ===== 数字输入安全钳制(v0.6.4: 原 useConfig 无 guard 导致中间态逐键落库)=====
const clampNumber = (value: number, min: number, max: number): number =>
Number.isFinite(value) ? Math.min(max, Math.max(min, Math.trunc(value))) : min;
const handleToggleEnabled = async (checked: boolean): Promise<void> => {
// 总开关保持即时落库(立即动作语义),并向前端状态同步
setEnabledState(checked);
try {
await window.metona?.config?.set('searxng.enabled', checked);
} catch (err) {
console.error('[SearXNGSettings] toggle failed:', err);
setEnabledState(!checked);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('开关保存失败'))
.catch(() => {});
}
};
const urlError = !!draft.url && !/^https?:\/\//.test(draft.url);
const handleTest = async () => {
if (!url.trim() || urlError) return;
if (!draft.url.trim() || urlError) return;
setTesting(true);
setTestResult(null);
try {
const result = await window.metona?.searxng?.testConnection(url.trim(), authKey, authType);
const result = await window.metona?.searxng?.testConnection(
draft.url.trim(),
draft.auth_key,
draft.auth_type,
);
if (result?.success) {
setTestResult({ success: true, message: `连接成功(${result.latencyMs}ms` });
} else {
@@ -68,6 +200,17 @@ export function SearXNGSettings() {
setTesting(false);
};
if (!enabledLoaded || !loaded) {
return (
<Stack spacing={1} sx={{ alignItems: 'center', py: 3 }}>
<CircularProgress size={18} />
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
...
</Typography>
</Stack>
);
}
return (
<Stack spacing={2}>
{/* 标题 + 状态徽章 */}
@@ -87,10 +230,15 @@ export function SearXNGSettings() {
退 Bing + + + 360
</Typography>
{/* 启用开关 */}
{/* 启用开关(即时生效的总开关) */}
<FormControlLabel
control={
<Switch checked={enabled} onChange={(e) => setEnabled(e.target.checked)} size="small" />
<Switch
checked={enabled}
onChange={(e) => void handleToggleEnabled(e.target.checked)}
size="small"
slotProps={{ input: { 'aria-label': '启用 SearXNG' } }}
/>
}
label={<Typography variant="body2"> SearXNG</Typography>}
/>
@@ -102,8 +250,8 @@ export function SearXNGSettings() {
<TextField
size="small"
label="API 地址"
value={url}
onChange={(e) => setUrl(e.target.value)}
value={draft.url}
onChange={(e) => updateField('url', e.target.value)}
placeholder="如 https://searxng.example.com"
error={urlError}
helperText={
@@ -115,7 +263,7 @@ export function SearXNGSettings() {
variant="outlined"
size="small"
onClick={handleTest}
disabled={!url.trim() || urlError || testing}
disabled={!draft.url.trim() || urlError || testing}
sx={{ mt: 0.5, minWidth: 90, height: 40 }}
>
{testing ? <CircularProgress size={14} /> : '测试连接'}
@@ -136,8 +284,8 @@ export function SearXNGSettings() {
<TextField
size="small"
label="搜索引擎(逗号分隔)"
value={engines}
onChange={(e) => setEngines(e.target.value)}
value={draft.engines}
onChange={(e) => updateField('engines', e.target.value)}
placeholder="如 google,bing,duckduckgo(留空使用实例默认)"
/>
@@ -145,7 +293,11 @@ export function SearXNGSettings() {
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<FormControl size="small">
<InputLabel></InputLabel>
<Select value={language} label="语言" onChange={(e) => setLanguage(e.target.value)}>
<Select
value={draft.language}
label="语言"
onChange={(e) => updateField('language', e.target.value)}
>
<MenuItem value="zh-CN"></MenuItem>
<MenuItem value="zh-TW"></MenuItem>
<MenuItem value="en">English</MenuItem>
@@ -157,9 +309,9 @@ export function SearXNGSettings() {
<FormControl size="small">
<InputLabel></InputLabel>
<Select
value={safesearch}
value={draft.safesearch}
label="安全搜索"
onChange={(e) => setSafesearch(e.target.value as number)}
onChange={(e) => updateField('safesearch', Number(e.target.value))}
>
<MenuItem value={0}></MenuItem>
<MenuItem value={1}></MenuItem>
@@ -172,7 +324,11 @@ export function SearXNGSettings() {
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<FormControl size="small">
<InputLabel></InputLabel>
<Select value={timeRange} label="时间范围" onChange={(e) => setTimeRange(e.target.value)}>
<Select
value={draft.time_range}
label="时间范围"
onChange={(e) => updateField('time_range', e.target.value)}
>
<MenuItem value=""></MenuItem>
<MenuItem value="day"></MenuItem>
<MenuItem value="week"></MenuItem>
@@ -182,7 +338,11 @@ export function SearXNGSettings() {
</FormControl>
<FormControl size="small">
<InputLabel></InputLabel>
<Select value={format} label="返回格式" onChange={(e) => setFormat(e.target.value)}>
<Select
value={draft.format}
label="返回格式"
onChange={(e) => updateField('format', e.target.value)}
>
<MenuItem value="json">JSON</MenuItem>
<MenuItem value="html">HTML</MenuItem>
</Select>
@@ -195,8 +355,10 @@ export function SearXNGSettings() {
size="small"
label="最大结果数"
type="number"
value={maxResults}
onChange={(e) => setMaxResults(Number(e.target.value))}
value={draft.max_results}
onChange={(e) =>
updateField('max_results', clampNumber(Number(e.target.value), 0, 50))
}
placeholder="0 表示使用默认"
slotProps={{ htmlInput: { min: 0, max: 50 } }}
/>
@@ -204,8 +366,10 @@ export function SearXNGSettings() {
size="small"
label="自动抓取条数"
type="number"
value={fetchCount}
onChange={(e) => setFetchCount(Number(e.target.value))}
value={draft.fetch_count}
onChange={(e) =>
updateField('fetch_count', clampNumber(Number(e.target.value), 0, 8))
}
placeholder="0 表示由 AI 决定"
slotProps={{ htmlInput: { min: 0, max: 8 } }}
/>
@@ -214,7 +378,11 @@ export function SearXNGSettings() {
{/* 抓取类型 */}
<FormControl size="small">
<InputLabel></InputLabel>
<Select value={fetchMode} label="抓取类型" onChange={(e) => setFetchMode(e.target.value)}>
<Select
value={draft.fetch_mode}
label="抓取类型"
onChange={(e) => updateField('fetch_mode', e.target.value)}
>
<MenuItem value="sequential"></MenuItem>
<MenuItem value="random"></MenuItem>
</Select>
@@ -229,22 +397,30 @@ export function SearXNGSettings() {
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 2 }}>
<FormControl size="small">
<InputLabel></InputLabel>
<Select value={authType} label="认证类型" onChange={(e) => setAuthType(e.target.value)}>
<Select
value={draft.auth_type}
label="认证类型"
onChange={(e) => updateField('auth_type', e.target.value)}
>
<MenuItem value="bearer">Bearer Token</MenuItem>
<MenuItem value="basic">Basic Auth</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
label={authType === 'bearer' ? 'Token' : '用户名:密码'}
label={draft.auth_type === 'bearer' ? 'Token' : '用户名:密码'}
type={showKey ? 'text' : 'password'}
value={authKey}
onChange={(e) => setAuthKey(e.target.value)}
placeholder={authType === 'bearer' ? '访问令牌原值' : 'username:password'}
value={draft.auth_key}
onChange={(e) => updateField('auth_key', e.target.value)}
placeholder={draft.auth_type === 'bearer' ? '访问令牌原值' : 'username:password'}
slotProps={{
input: {
endAdornment: (
<IconButton size="small" onClick={() => setShowKey(!showKey)}>
<IconButton
size="small"
onClick={() => setShowKey(!showKey)}
aria-label={showKey ? '隐藏密钥' : '显示密钥'}
>
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
</IconButton>
),
@@ -253,10 +429,34 @@ export function SearXNGSettings() {
/>
</Box>
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
{authType === 'bearer'
{draft.auth_type === 'bearer'
? 'Bearer: 直接填写令牌原值,原样透传到 Authorization 头。建议配合 HTTPS 使用。'
: 'Basic: 填写 username:password 明文串,系统自动 Base64 编码。必须配合 HTTPS 使用。'}
</Typography>
{/* 保存区(批量草稿提交 + 变更提示) */}
<Divider />
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Button
variant="contained"
size="small"
startIcon={<Save size={14} />}
disabled={!dirty || saving}
onClick={() => void handleSave()}
>
{saving ? '保存中...' : '保存更改'}
</Button>
{dirty && (
<Typography variant="caption" sx={{ color: 'warning.main' }}>
</Typography>
)}
{!dirty && savedNotice && (
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
</Typography>
)}
</Stack>
</Stack>
);
}
+59 -1
View File
@@ -6,8 +6,9 @@
*/
import { useState, useEffect } from 'react';
import { Button, Stack, Typography, Checkbox, Divider, Chip, Box } from '@mui/material';
import { Button, Stack, Typography, Checkbox, Divider, Chip, Box, TextField } from '@mui/material';
import { alpha } from '@mui/material/styles';
import { useConfig } from './useConfig';
export function ToolsSettings() {
const [tools, setTools] = useState<
@@ -306,6 +307,63 @@ export function ToolsSettings() {
))}
</>
)}
{/* ===== 网络代理(v0.6.4 P4-5===== */}
<Divider sx={{ my: 1 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
</Typography>
<ProxyField />
</Stack>
);
}
/**
* v0.6.4 P4-5: network.proxyUrl 编辑项 —— useConfig 保存即触发主进程
* applySessionProxyshared.ts 副作用联动),Chromium session 与主进程 fetch
* 双通道同时生效。格式示例:http://127.0.0.1:7890 或 socks5://user:pass@host:1080。
*/
function ProxyField(): React.JSX.Element {
const [proxyUrl, setProxyUrl] = useConfig('network.proxyUrl', '');
const [draft, setDraft] = useState<string>('');
// 首次载入后把配置值同步到草稿(此后用户自由编辑,blur/save 时提交)
const [synced, setSynced] = useState(false);
useEffect(() => {
if (!synced && proxyUrl !== null) {
setDraft(proxyUrl);
setSynced(true);
}
}, [proxyUrl, synced]);
const invalid =
!!draft.trim() && !/^(https?|socks[45]):\/\//i.test(draft.trim());
return (
<>
<TextField
size="small"
label="代理地址(可选)"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
if (draft.trim() !== (proxyUrl ?? '') && !invalid) {
setProxyUrl(draft.trim());
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
}
}}
placeholder="http://127.0.0.1:7890 或 socks5://…(留空直连)"
error={invalid}
helperText={
invalid
? '需以 http://、https://、socks5:// 或 socks4:// 开头'
: '应用于 Chromium 会话与主进程全部网络请求;未填时回退系统环境变量 HTTPS_PROXY。'
}
/>
</>
);
}