Files
metona-ai-desktop/src/components/settings/SearXNGSettings.tsx
T
thzxx 3940716dc2
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m45s
CI / 全量测试 (Electron ABI) (push) Failing after 5m22s
CI / 产物编译验证 (push) Successful in 10m3s
feat: v0.7.0 四阶段全量迭代 — 修复面收口 · 安全纵深 · 架构还债 · 能力演进
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 桥契约
2026-08-27 17:06:58 +08:00

463 lines
16 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.
/**
* SearXNGSettings — SearXNG 元搜索配置 Tab
*
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
* 功能:SearXNG 实例配置(12 项)+ 连接测试。
*
* v0.6.4 P3-7 重构(批量草稿模型统一):原先 12 个字段各自经 useConfig
* "逐键实时落库" —— 数字输入每敲一个字符就触发一次 IPC 写 + reloadAdapter 副作用,
* 且 SearXNG 的数字字段无范围 guard 会把中间态写进配置。现改为与 LLMSettings 同一
* 草稿范式:mount 时一次读入 → 本地草稿编辑(dirty 标记)→ 显式"保存"批量提交。
* 启用开关保持即时生效(它是功能总开关,语义上属于立即动作而非表单字段)。
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Button,
TextField,
Select,
MenuItem,
Stack,
Typography,
Divider,
Chip,
Switch,
Alert,
Box,
FormControlLabel,
InputLabel,
FormControl,
IconButton,
CircularProgress,
} from '@mui/material';
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() {
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);
// ===== 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 (!draft.url.trim() || urlError) return;
setTesting(true);
setTestResult(null);
try {
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 {
setTestResult({
success: false,
message: result?.error || `连接失败(HTTP ${result?.statusCode}`,
});
}
} catch (e) {
setTestResult({ success: false, message: (e as Error).message });
}
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}>
{/* 标题 + 状态徽章 */}
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
SearXNG 元搜索
</Typography>
<Chip
label={enabled ? '已启用' : '未启用'}
size="small"
color={enabled ? 'success' : 'default'}
variant={enabled ? 'filled' : 'outlined'}
/>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
SearXNG 是开源元搜索引擎,支持 70+
搜索引擎聚合。启用后替代内置四引擎搜索通道,未启用时回退到 Bing + 百度 + 搜狗 + 360 搜索。
</Typography>
{/* 启用开关(即时生效的总开关) */}
<FormControlLabel
control={
<Switch
checked={enabled}
onChange={(e) => void handleToggleEnabled(e.target.checked)}
size="small"
slotProps={{ input: { 'aria-label': '启用 SearXNG' } }}
/>
}
label={<Typography variant="body2">启用 SearXNG</Typography>}
/>
<Divider />
{/* API 地址 + 连接测试 */}
<Stack direction="row" spacing={1} sx={{ alignItems: 'flex-start' }}>
<TextField
size="small"
label="API 地址"
value={draft.url}
onChange={(e) => updateField('url', e.target.value)}
placeholder="如 https://searxng.example.com"
error={urlError}
helperText={
urlError ? '需以 http:// 或 https:// 开头' : '实例根地址(不含 /search 路径)'
}
sx={{ flex: 1 }}
/>
<Button
variant="outlined"
size="small"
onClick={handleTest}
disabled={!draft.url.trim() || urlError || testing}
sx={{ mt: 0.5, minWidth: 90, height: 40 }}
>
{testing ? <CircularProgress size={14} /> : '测试连接'}
</Button>
</Stack>
{/* 测试结果 */}
{testResult && (
<Alert
severity={testResult.success ? 'success' : 'error'}
sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}
>
{testResult.message}
</Alert>
)}
{/* 搜索引擎 */}
<TextField
size="small"
label="搜索引擎(逗号分隔)"
value={draft.engines}
onChange={(e) => updateField('engines', e.target.value)}
placeholder="如 google,bing,duckduckgo(留空使用实例默认)"
/>
{/* 语言 + 安全搜索 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<FormControl size="small">
<InputLabel>语言</InputLabel>
<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>
<MenuItem value="ja">日本語</MenuItem>
<MenuItem value="ko">한국어</MenuItem>
<MenuItem value="auto">自动检测</MenuItem>
</Select>
</FormControl>
<FormControl size="small">
<InputLabel>安全搜索</InputLabel>
<Select
value={draft.safesearch}
label="安全搜索"
onChange={(e) => updateField('safesearch', Number(e.target.value))}
>
<MenuItem value={0}>关闭</MenuItem>
<MenuItem value={1}>中等</MenuItem>
<MenuItem value={2}>严格</MenuItem>
</Select>
</FormControl>
</Box>
{/* 时间范围 + 返回格式 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<FormControl size="small">
<InputLabel>时间范围</InputLabel>
<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>
<MenuItem value="month">一月</MenuItem>
<MenuItem value="year">一年</MenuItem>
</Select>
</FormControl>
<FormControl size="small">
<InputLabel>返回格式</InputLabel>
<Select
value={draft.format}
label="返回格式"
onChange={(e) => updateField('format', e.target.value)}
>
<MenuItem value="json">JSON(结构化解析)</MenuItem>
<MenuItem value="html">HTML(原始网页)</MenuItem>
</Select>
</FormControl>
</Box>
{/* 最大结果数 + 自动抓取条数 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<TextField
size="small"
label="最大结果数"
type="number"
value={draft.max_results}
onChange={(e) =>
updateField('max_results', clampNumber(Number(e.target.value), 0, 50))
}
placeholder="0 表示使用默认"
slotProps={{ htmlInput: { min: 0, max: 50 } }}
/>
<TextField
size="small"
label="自动抓取条数"
type="number"
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 } }}
/>
</Box>
{/* 抓取类型 */}
<FormControl size="small">
<InputLabel>抓取类型</InputLabel>
<Select
value={draft.fetch_mode}
label="抓取类型"
onChange={(e) => updateField('fetch_mode', e.target.value)}
>
<MenuItem value="sequential">顺序抓取</MenuItem>
<MenuItem value="random">随机抓取</MenuItem>
</Select>
</FormControl>
<Divider />
{/* 认证设置 */}
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
认证设置
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 2 }}>
<FormControl size="small">
<InputLabel>认证类型</InputLabel>
<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={draft.auth_type === 'bearer' ? 'Token' : '用户名:密码'}
type={showKey ? 'text' : '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)}
aria-label={showKey ? '隐藏密钥' : '显示密钥'}
>
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
</IconButton>
),
},
}}
/>
</Box>
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
{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>
);
}