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 桥契约
463 lines
16 KiB
TypeScript
463 lines
16 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|