工程化(从零到一): - 新增 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 双模式)
263 lines
9.5 KiB
TypeScript
263 lines
9.5 KiB
TypeScript
/**
|
||
* SearXNGSettings — SearXNG 元搜索配置 Tab
|
||
*
|
||
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
|
||
* 功能:SearXNG 实例配置(12 项)+ 连接测试。
|
||
*/
|
||
|
||
import { 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 } from 'lucide-react';
|
||
import { useConfig } from './useConfig';
|
||
|
||
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 [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);
|
||
|
||
const handleTest = async () => {
|
||
if (!url.trim() || urlError) return;
|
||
setTesting(true);
|
||
setTestResult(null);
|
||
try {
|
||
const result = await window.metona?.searxng?.testConnection(url.trim(), authKey, authType);
|
||
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);
|
||
};
|
||
|
||
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) => setEnabled(e.target.checked)} size="small" />
|
||
}
|
||
label={<Typography variant="body2">启用 SearXNG</Typography>}
|
||
/>
|
||
|
||
<Divider />
|
||
|
||
{/* API 地址 + 连接测试 */}
|
||
<Stack direction="row" spacing={1} sx={{ alignItems: 'flex-start' }}>
|
||
<TextField
|
||
size="small"
|
||
label="API 地址"
|
||
value={url}
|
||
onChange={(e) => setUrl(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={!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={engines}
|
||
onChange={(e) => setEngines(e.target.value)}
|
||
placeholder="如 google,bing,duckduckgo(留空使用实例默认)"
|
||
/>
|
||
|
||
{/* 语言 + 安全搜索 */}
|
||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||
<FormControl size="small">
|
||
<InputLabel>语言</InputLabel>
|
||
<Select value={language} label="语言" onChange={(e) => setLanguage(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={safesearch}
|
||
label="安全搜索"
|
||
onChange={(e) => setSafesearch(e.target.value as number)}
|
||
>
|
||
<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={timeRange} label="时间范围" onChange={(e) => setTimeRange(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={format} label="返回格式" onChange={(e) => setFormat(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={maxResults}
|
||
onChange={(e) => setMaxResults(Number(e.target.value))}
|
||
placeholder="0 表示使用默认"
|
||
slotProps={{ htmlInput: { min: 0, max: 50 } }}
|
||
/>
|
||
<TextField
|
||
size="small"
|
||
label="自动抓取条数"
|
||
type="number"
|
||
value={fetchCount}
|
||
onChange={(e) => setFetchCount(Number(e.target.value))}
|
||
placeholder="0 表示由 AI 决定"
|
||
slotProps={{ htmlInput: { min: 0, max: 8 } }}
|
||
/>
|
||
</Box>
|
||
|
||
{/* 抓取类型 */}
|
||
<FormControl size="small">
|
||
<InputLabel>抓取类型</InputLabel>
|
||
<Select value={fetchMode} label="抓取类型" onChange={(e) => setFetchMode(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={authType} label="认证类型" onChange={(e) => setAuthType(e.target.value)}>
|
||
<MenuItem value="bearer">Bearer Token</MenuItem>
|
||
<MenuItem value="basic">Basic Auth</MenuItem>
|
||
</Select>
|
||
</FormControl>
|
||
<TextField
|
||
size="small"
|
||
label={authType === 'bearer' ? 'Token' : '用户名:密码'}
|
||
type={showKey ? 'text' : 'password'}
|
||
value={authKey}
|
||
onChange={(e) => setAuthKey(e.target.value)}
|
||
placeholder={authType === 'bearer' ? '访问令牌原值' : 'username:password'}
|
||
slotProps={{
|
||
input: {
|
||
endAdornment: (
|
||
<IconButton size="small" onClick={() => setShowKey(!showKey)}>
|
||
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||
</IconButton>
|
||
),
|
||
},
|
||
}}
|
||
/>
|
||
</Box>
|
||
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||
{authType === 'bearer'
|
||
? 'Bearer: 直接填写令牌原值,原样透传到 Authorization 头。建议配合 HTTPS 使用。'
|
||
: 'Basic: 填写 username:password 明文串,系统自动 Base64 编码。必须配合 HTTPS 使用。'}
|
||
</Typography>
|
||
</Stack>
|
||
);
|
||
}
|