feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m25s
CI / 全量测试 (Electron ABI, experimental) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 10m3s

工程化(从零到一):
- 新增 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:
2026-08-21 13:58:48 +08:00
parent 2230bcec3f
commit 49c9b25538
41 changed files with 6254 additions and 2608 deletions
+262
View File
@@ -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>
);
}