feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
工程化(从零到一): - 新增 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 双模式)
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user