feat: MEMORY.md 访问保护、格式简化及预存问题修复
MEMORY.md 保护: 新建 file-guard.ts 共享守卫模块; read_file/write_file/search_files/run_command 禁止访问根目录 MEMORY.md; permissions.ts 增加 deniedPatterns 深度防御。格式简化: 元数据从 # 注释改为 > 引用语法; 校验规则从 6 条简化为 3 条; context-builder extractContent 适配。预存问题: 修复路径遍历前缀碰撞漏洞; 移除 browser_extract 内容截断; SearXNG 配置实时读取; web_search/web_fetch 移除截断; 侧边栏动态获取工具列表; 版本号 0.1.1
This commit is contained in:
@@ -135,28 +135,33 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
|
||||
|
||||
function ToolManagerPanel() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const tools = [
|
||||
{ name: 'read_file', label: '读取文件', risk: 'SAFE' }, { name: 'write_file', label: '写入文件', risk: 'MEDIUM' },
|
||||
{ name: 'list_directory', label: '列出目录', risk: 'SAFE' }, { name: 'search_files', label: '搜索文件', risk: 'SAFE' },
|
||||
{ name: 'web_search', label: '网络搜索', risk: 'LOW' }, { name: 'web_extract', label: '网页抓取', risk: 'LOW' },
|
||||
{ name: 'memory_store', label: '存储记忆', risk: 'MEDIUM' }, { name: 'memory_search', label: '搜索记忆', risk: 'SAFE' },
|
||||
{ name: 'run_command', label: '执行命令', risk: 'HIGH' },
|
||||
];
|
||||
const riskColors: Record<string, string> = { SAFE: 'success.main', LOW: 'info.main', MEDIUM: 'warning.main', HIGH: 'error.main' };
|
||||
const [tools, setTools] = useState<Array<{ name: string; description: string; category: string; riskLevel: string; requiresPermission: boolean; enabled: boolean }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.metona?.tools?.list) {
|
||||
window.metona.tools.list().then((list) => {
|
||||
setTools(list as MetonaToolInfo[]);
|
||||
}).catch((err) => { console.error('[Sidebar]', err); });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const readyCount = tools.filter((t) => t.enabled).length;
|
||||
const riskColors: Record<string, string> = { safe: 'success.main', low: 'info.main', medium: 'warning.main', high: 'error.main' };
|
||||
const riskLabels: Record<string, string> = { safe: 'SAFE', low: 'LOW', medium: 'MEDIUM', high: 'HIGH' };
|
||||
return (
|
||||
<Box>
|
||||
<ListItemButton onClick={() => setExpanded(!expanded)} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}>
|
||||
<ListItemIcon sx={{ minWidth: 24 }}>{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}<Wrench size={12} style={{ marginLeft: 4 }} /></ListItemIcon>
|
||||
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12 }}>工具管理</Typography>} />
|
||||
<Typography variant="caption" sx={{ color: 'success.main', fontSize: 10 }}>9 就绪</Typography>
|
||||
<Typography variant="caption" sx={{ color: readyCount > 0 ? 'success.main' : 'text.disabled', fontSize: 10 }}>{readyCount} 就绪</Typography>
|
||||
</ListItemButton>
|
||||
<Collapse in={expanded}>
|
||||
<List dense disablePadding sx={{ pl: 3 }}>
|
||||
{tools.map((t) => (
|
||||
<ListItemButton key={t.name} dense sx={{ py: 0.25, px: 1, borderRadius: 1 }}>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: riskColors[t.risk], mr: 1, flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ flex: 1, fontSize: 11, color: 'text.secondary' }}>{t.label}</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: 9, color: riskColors[t.risk] }}>{t.risk}</Typography>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: t.enabled ? riskColors[t.riskLevel] ?? 'text.disabled' : 'text.disabled', mr: 1, flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ flex: 1, fontSize: 11, color: t.enabled ? 'text.secondary' : 'text.disabled' }}>{t.name}</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: 9, color: t.enabled ? (riskColors[t.riskLevel] ?? 'text.disabled') : 'text.disabled' }}>{riskLabels[t.riskLevel] ?? t.riskLevel}</Typography>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
|
||||
@@ -18,7 +18,7 @@ export function StatusBar(): React.JSX.Element {
|
||||
const model = useAgentStore((s) => s.model);
|
||||
const tokenUsage = useAgentStore((s) => s.tokenUsage);
|
||||
const openSettings = useUIStore((s) => s.openSettings);
|
||||
const [version, setVersion] = useState('v0.1.0');
|
||||
const [version, setVersion] = useState('v0.1.1');
|
||||
|
||||
useEffect(() => {
|
||||
if (window.metona?.app?.getVersion) {
|
||||
|
||||
@@ -3,16 +3,17 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Dialog, DialogContent, DialogTitle, Button, TextField, Select, MenuItem, Tabs, Tab, Checkbox, Box, Typography, Stack, Divider, FormControlLabel, InputLabel, FormControl, IconButton, Chip } from '@mui/material';
|
||||
import { X, Settings, Bot, Wrench, Server, Palette, FileText, Eye, EyeOff, FolderOpen } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogTitle, Button, TextField, Select, MenuItem, Tabs, Tab, Checkbox, Box, Typography, Stack, Divider, FormControlLabel, InputLabel, FormControl, IconButton, Chip, Switch, Alert, CircularProgress } from '@mui/material';
|
||||
import { X, Settings, Bot, Wrench, Server, Palette, FileText, Eye, EyeOff, FolderOpen, Globe } from 'lucide-react';
|
||||
import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
|
||||
type SettingsTab = 'workspace' | 'llm' | 'agent' | 'tools' | 'mcp' | 'appearance' | 'logs';
|
||||
type SettingsTab = 'workspace' | 'llm' | 'agent' | 'tools' | 'mcp' | 'searxng' | 'appearance' | 'logs';
|
||||
const TABS: { id: SettingsTab; label: string; icon: typeof Settings }[] = [
|
||||
{ id: 'workspace', label: '工作空间', icon: FolderOpen },
|
||||
{ id: 'llm', label: 'LLM 配置', icon: Bot }, { id: 'agent', label: 'Agent 配置', icon: Settings },
|
||||
{ id: 'tools', label: '工具管理', icon: Wrench }, { id: 'mcp', label: 'MCP 服务', icon: Server },
|
||||
{ id: 'searxng', label: 'SearXNG', icon: Globe },
|
||||
{ id: 'appearance', label: '外观', icon: Palette }, { id: 'logs', label: '日志与数据', icon: FileText },
|
||||
];
|
||||
|
||||
@@ -50,6 +51,7 @@ export function SettingsModal(): React.JSX.Element | null {
|
||||
{tab === 'agent' && <AgentSettings />}
|
||||
{tab === 'tools' && <ToolsSettings />}
|
||||
{tab === 'mcp' && <MCPSettings />}
|
||||
{tab === 'searxng' && <SearXNGSettings />}
|
||||
{tab === 'appearance' && <AppearanceSettings theme={theme} setTheme={setTheme} />}
|
||||
{tab === 'logs' && <LogsSettings />}
|
||||
</Box>
|
||||
@@ -193,7 +195,7 @@ function AgentSettings() {
|
||||
|
||||
function ToolsSettings() {
|
||||
const [tools, setTools] = useState<Array<{ name: string; description: string; riskLevel: string; enabled: boolean }>>([]);
|
||||
useEffect(() => { if (window.metona?.tools?.list) window.metona.tools.list().then((l) => setTools((l as MetonaToolInfo[]).map((t) => ({ ...t, enabled: true })))).catch((err) => { console.error('[SettingsModal]', err); }); }, []);
|
||||
useEffect(() => { if (window.metona?.tools?.list) window.metona.tools.list().then((l) => setTools((l as MetonaToolInfo[]).map((t) => ({ name: t.name, description: t.description, riskLevel: t.riskLevel, enabled: t.enabled })))).catch((err) => { console.error('[SettingsModal]', err); }); }, []);
|
||||
const handleToggle = (name: string, enabled: boolean) => { setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t)); window.metona?.tools?.toggle(name, enabled).catch((err) => { console.error('[SettingsModal]', err); }); };
|
||||
const riskColors: Record<string, 'success' | 'info' | 'warning' | 'error'> = { safe: 'success', low: 'info', medium: 'warning', high: 'error' };
|
||||
|
||||
@@ -259,6 +261,193 @@ function MCPSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceSettings({ theme, setTheme }: { theme: ThemeMode; setTheme: (t: ThemeMode) => void }) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
|
||||
@@ -159,8 +159,8 @@ export const appAPI = {
|
||||
return getBridge().app.openExternal(url);
|
||||
},
|
||||
|
||||
showItemInFolder(path: string): void {
|
||||
getBridge().app.showItemInFolder(path);
|
||||
showItemInFolder(path: string): Promise<void> {
|
||||
return getBridge().app.showItemInFolder(path);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Vendored
+17
-1
@@ -157,7 +157,7 @@ interface MetonaAppAPI {
|
||||
getVersion: () => Promise<string>;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
showItemInFolder: (path: string) => void;
|
||||
showItemInFolder: (path: string) => Promise<void>;
|
||||
selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>;
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ interface MetonaToolInfo {
|
||||
category: string;
|
||||
riskLevel: string;
|
||||
requiresPermission: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface MetonaToolsAPI {
|
||||
@@ -186,6 +187,20 @@ interface MetonaToolsAPI {
|
||||
toggle: (toolName: string, enabled: boolean) => Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
// ===== SearXNG API =====
|
||||
|
||||
interface MetonaSearXNGTestResult {
|
||||
success: boolean;
|
||||
statusCode?: number;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface MetonaSearXNGAPI {
|
||||
/** 测试 SearXNG 实例连接可达性与认证有效性 */
|
||||
testConnection: (url: string, authKey: string, authType: string) => Promise<MetonaSearXNGTestResult>;
|
||||
}
|
||||
|
||||
// ===== Data API =====
|
||||
|
||||
interface MetonaDataAPI {
|
||||
@@ -207,6 +222,7 @@ interface MetonaBridge {
|
||||
toast: MetonaToastAPI;
|
||||
tools: MetonaToolsAPI;
|
||||
data: MetonaDataAPI;
|
||||
searxng: MetonaSearXNGAPI;
|
||||
}
|
||||
|
||||
// ===== 全局 Window 增强 =====
|
||||
|
||||
Reference in New Issue
Block a user