feat: 升级至 v0.3.1 — 全量代码审计修复 + 安全增强

本次升级基于完整代码审查,修复 Critical/High/Medium/Low 四级共 96 项问题,
并通过返工审计修复 10 项遗留问题,tsc 双端类型检查零错误。

Critical (10/10 完成):
- C-4: command.ts 接入 shell-quote 进行 token-level 注入检测,替代原有正则匹配
  可防御 r"m" -rf /、$'rm'、$(echo rm) 等字符串拼接绕过

High (11/11 完成):
- 竞态保护、Promise.allSettled、AbortController 资源泄漏、IPC 参数校验等

Medium (55/55 完成):
- 事务保护、敏感数据脱敏、枚举校验、MUI v9 Stack prop 迁移、
  React 组件 cancelled 标志、类型收窄等

Low (20/20 完成):
- 辅助方法提取(flushToolCallBuffer/scoreAndPushMemory/tryAddColumn 等)
- nanoid 统一替代 Date.now()+Math.random()
- confirm() 替换为 MUI Dialog、useMemo 缓存、魔法数字命名化等

返工审计修复 (10/10 完成):
- L-11: LogsSettings 残留的原生 confirm()/alert() 全部替换为 MUI Dialog/Alert
- M-53: MemoryViewer handleSearch 独立 ref,修复 searching 状态卡死
- M-42: 脱敏短值(length <= 4)泄露修复
- M-47: tasks:update 补全 title/description 类型校验
- L-9: ollama.adapter 非流式路径 nanoid 统一
- M-45: audit:query limit 策略与 memory:listAll 一致化
- SettingsModal handleConfirmRemove 补全 try/catch + loadServers cleanup
- L-15: CommandPalette useMemo 补全 sessions 响应式依赖
- useAgentStream 事件类型补全 seq/timestamp 字段

新增依赖: shell-quote + @types/shell-quote
版本号: 0.3.0 -> 0.3.1
This commit is contained in:
thzxx
2026-07-13 22:36:58 +08:00
parent 4f5f570ac8
commit e4d81d8247
47 changed files with 2247 additions and 475 deletions
+10 -4
View File
@@ -9,6 +9,7 @@
import { ThemeProvider, CssBaseline } from '@mui/material';
import { useEffect } from 'react';
import { Header } from '@renderer/components/layout/Header';
import { Sidebar } from '@renderer/components/layout/Sidebar';
import { DetailPanel } from '@renderer/components/layout/DetailPanel';
import { StatusBar } from '@renderer/components/layout/StatusBar';
@@ -43,12 +44,15 @@ export default function App(): React.JSX.Element {
window.metona.config.get('onboarding.completed').then((v) => {
if (v === true) useUIStore.getState().setOnboardingCompleted(true);
}).catch((err) => { console.error('[App]', err); });
Promise.all([
// M-25 修复: 改用 Promise.allSettled,单个配置读取失败不影响另一个
Promise.allSettled([
window.metona.config.get('llm.provider'),
window.metona.config.get('llm.model'),
]).then(([provider, model]) => {
if (provider || model) useAgentStore.getState().setProvider((provider as string) || '', (model as string) || '');
}).catch((err) => { console.error('[App]', err); });
]).then(([providerResult, modelResult]) => {
const provider = providerResult.status === 'fulfilled' ? (providerResult.value as string) || '' : '';
const model = modelResult.status === 'fulfilled' ? (modelResult.value as string) || '' : '';
if (provider || model) useAgentStore.getState().setProvider(provider, model);
});
}
}, []);
@@ -59,6 +63,8 @@ export default function App(): React.JSX.Element {
className="h-screen w-screen flex flex-col overflow-hidden select-none"
style={{ background: muiTheme.palette.background.default, color: muiTheme.palette.text.primary }}
>
{/* H-7 修复: 添加 Header Bar — 规范要求布局为 Header (全宽) + [Sidebar|ChatPanel|DetailPanel] + StatusBar */}
<Header />
<div className="flex-1 flex overflow-hidden min-h-0">
{sidebarVisible && <Sidebar />}
<ChatPanel />
+16 -5
View File
@@ -2,7 +2,7 @@
* CommandPalette — 快速搜索面板 (Cmd+K)
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Dialog, DialogContent, InputBase, List, ListItemButton, ListItemIcon, ListItemText, Typography, Box, Divider } from '@mui/material';
import { Search, MessageSquare, Settings, Plus, Trash2 } from 'lucide-react';
import { useUIStore } from '@renderer/stores/ui-store';
@@ -16,13 +16,22 @@ export function CommandPalette(): React.JSX.Element {
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
// 审计补充修复: 响应式订阅 sessions,使 getCommands 在 sessions 变化时重新构建
// 原问题:getCommands 内部通过 useSessionStore.getState() 非响应式访问 sessions
// 当 sessions 变化但 query 不变时 commands 列表陈旧
const sessions = useSessionStore((s) => s.sessions);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); setOpen((p) => !p); setQuery(''); setSelectedIndex(0); } };
window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h);
}, []);
useEffect(() => { if (open) setTimeout(() => inputRef.current?.focus(), 50); }, [open]);
// M-19 修复: useEffect 返回 cleanup 清理 setTimeout,防止快速开关时 timer 堆积
useEffect(() => {
if (!open) return;
const t = setTimeout(() => inputRef.current?.focus(), 50);
return () => clearTimeout(t);
}, [open]);
const getCommands = useCallback((): CommandItem[] => {
const cmds: CommandItem[] = [
@@ -31,13 +40,15 @@ export function CommandPalette(): React.JSX.Element {
{ id: 'settings', icon: Settings, label: '打开设置', action: () => { useUIStore.getState().openSettings(); setOpen(false); } },
];
if (query) {
const filtered = useSessionStore.getState().sessions.filter((s) => s.title.toLowerCase().includes(query.toLowerCase()));
// 审计补充修复: 使用响应式 sessions(闭包捕获),而非 useSessionStore.getState()
const filtered = sessions.filter((s) => s.title.toLowerCase().includes(query.toLowerCase()));
for (const s of filtered.slice(0, 5)) cmds.push({ id: `s-${s.id}`, icon: MessageSquare, label: s.title, description: `${s.messageCount} 条消息`, action: () => { useSessionStore.getState().setCurrentSession(s.id); useAgentStore.getState().setCurrentSession(s.id); setOpen(false); } });
}
return cmds;
}, [query]);
}, [query, sessions]);
const commands = getCommands();
// L-15 修复: 使用 useMemo 缓存 commands 列表,避免每次渲染都重新构建
const commands = useMemo(() => getCommands(), [getCommands]);
return (
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth slotProps={{ paper: { sx: { mt: '20vh', maxWidth: 520, borderRadius: 3, overflow: 'hidden' } } }}>
+3 -1
View File
@@ -273,6 +273,7 @@ export function ConfirmationDialog(): React.JSX.Element | null {
color="error"
variant="outlined"
size="small"
disabled={isExpired}
>
</Button>
@@ -282,8 +283,9 @@ export function ConfirmationDialog(): React.JSX.Element | null {
variant="contained"
size="small"
autoFocus
disabled={isExpired}
>
{isExpired ? '已超时' : '确认执行'}
</Button>
</DialogActions>
</Dialog>
+14 -4
View File
@@ -184,14 +184,24 @@ function CodeBlock({ language, code }: { language: string; code: string }): Reac
);
}
/** 从 React children(可能含高亮 span)递归提取纯文本 */
function extractTextContent(children: React.ReactNode): string {
/**
* 从 React children(可能含高亮 span)递归提取纯文本
*
* L-1 修复: 添加 maxDepth 参数防止循环引用导致的栈溢出
* React children 正常深度不会超过 10,50 已足够安全裕量
*/
function extractTextContent(children: React.ReactNode, maxDepth = 50): string {
if (maxDepth <= 0) return ''; // 超出深度上限,停止递归
if (typeof children === 'string') return children;
if (typeof children === 'number') return String(children);
if (!children) return '';
if (Array.isArray(children)) return children.map(extractTextContent).join('');
if (Array.isArray(children)) return children.map((c) => extractTextContent(c, maxDepth - 1)).join('');
if (typeof children === 'object' && 'props' in children) {
return extractTextContent((children as React.ReactElement).props.children as React.ReactNode);
// 使用 React.ReactElement<{ children?: React.ReactNode }> 显式声明 props.children 类型
return extractTextContent(
(children as React.ReactElement<{ children?: React.ReactNode }>).props.children as React.ReactNode,
maxDepth - 1,
);
}
return '';
}
+52 -10
View File
@@ -10,8 +10,9 @@
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { Box, Typography, IconButton, Tooltip, Stack, Button, Paper } from '@mui/material';
import { Box, Typography, IconButton, Tooltip, Stack, Button, Paper, InputBase } from '@mui/material';
import { Send, Paperclip, Square, X, FileText, Image as ImageIcon } from 'lucide-react';
import { nanoid } from 'nanoid';
import { useAgentStore } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store';
import { useUIStore } from '@renderer/stores/ui-store';
@@ -67,7 +68,8 @@ export function ChatInput(): React.JSX.Element {
const processFile = useCallback(async (file: File): Promise<Attachment> => {
const type = classifyFile(file);
const attachment: Attachment = { id: `att_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, file, type };
// L-10 修复: 统一使用 nanoid 生成附件 ID(与项目其他位置一致)
const attachment: Attachment = { id: `att_${nanoid(6)}`, file, type };
if (type === 'image') {
// 图片转 base64 data URL → 压缩(限制 1024px, JPEG 0.7
@@ -241,8 +243,18 @@ export function ChatInput(): React.JSX.Element {
const handleAbort = useCallback(() => { abort(); }, [abort]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
// Ctrl+Enter — 换行
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
// H-9 修复: 快捷键对齐规范
// @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 快捷键规范
// 规范要求: Cmd/Ctrl+Enter = 发送消息, Cmd/Ctrl+Shift+Enter = 换行
// 之前代码是 Ctrl+Enter=换行, Enter=发送,与规范相反
// 修复后:
// Cmd/Ctrl+Enter = 发送消息(规范要求)
// Cmd/Ctrl+Shift+Enter = 换行(规范要求)
// Enter = 发送消息(保持聊天应用习惯)
// Shift+Enter = 换行(textarea 默认行为,无需处理)
// Cmd/Ctrl+Shift+Enter — 换行(规范要求)
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && e.shiftKey) {
e.preventDefault();
const t = e.currentTarget as HTMLTextAreaElement;
const s = t.selectionStart;
@@ -251,12 +263,19 @@ export function ChatInput(): React.JSX.Element {
requestAnimationFrame(() => { t.selectionStart = t.selectionEnd = s + 1; });
return;
}
// Enter — 发送
// Cmd/Ctrl+Enter — 发送消息(规范要求,优先于 Enter
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSend();
return;
}
// Enter(不带修饰键)— 发送消息(保持聊天应用习惯)
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); return; }
if (e.key === 'Escape' && showSlashMenu) { setShowSlashMenu(false); return; }
}, [handleSend, showSlashMenu]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
// H-8 修复: 使用 InputBase 后,onChange 类型需兼容 HTMLInputElement | HTMLTextAreaElement
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
const v = e.target.value; setInput(v);
if (v === '/') { setShowSlashMenu(true); setSlashFilter(''); }
else if (v.startsWith('/') && !v.includes(' ')) { setShowSlashMenu(true); setSlashFilter(v.slice(1).toLowerCase()); }
@@ -298,12 +317,35 @@ export function ChatInput(): React.JSX.Element {
<input ref={fileInputRef} type="file" multiple accept={supportsImages ? 'image/*,.txt,.md,.json,.csv,.ts,.tsx,.js,.jsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yaml,.yml,.toml,.ini,.sh,.sql,.log,.pdf' : '.txt,.md,.json,.csv,.ts,.tsx,.js,.jsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yaml,.yml,.toml,.ini,.sh,.sql,.log,.pdf'} style={{ display: 'none' }} onChange={handleFileChange} />
<textarea
ref={textareaRef} data-chat-input value={input} onChange={handleChange} onKeyDown={handleKeyDown}
{/* H-8 修复: 使用 MUI InputBase 替代原生 textarea — 遵循 MUI 强制使用规范 */}
{/* @see standard/开发规范.md — MUI 强制使用、禁止自写 UI 组件 */}
<InputBase
inputRef={textareaRef}
data-chat-input
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="输入消息... (Enter 发送, Ctrl+Enter 换行, / 命令)" disabled={isStreaming}
placeholder="输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)"
disabled={isStreaming}
multiline
rows={1}
style={{ width: '100%', background: 'transparent', border: 'none', outline: 'none', color: 'inherit', fontSize: 14, lineHeight: '28px', resize: 'none', fontFamily: 'inherit', minHeight: 42, maxHeight: 160 }}
sx={{
width: '100%',
color: 'inherit',
fontSize: 14,
lineHeight: '28px',
fontFamily: 'inherit',
'& .MuiInputBase-input': {
padding: 0,
resize: 'none',
minHeight: 42,
maxHeight: 160,
},
'&::before, &::after': {
display: 'none',
},
}}
/>
<Stack direction="row" sx={{ mt: 1, minHeight: 32, justifyContent: 'space-between', alignItems: 'center' }}>
+15 -3
View File
@@ -6,7 +6,7 @@
import { Box, Typography, Stack, Table, TableBody, TableRow, TableCell, Chip } from '@mui/material';
import { Activity, Cpu } from 'lucide-react';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { useAgentStore, type AgentStatus } from '@renderer/stores/agent-store';
import { AGENT_STATUS_COLORS, AGENT_STATUS_LABELS, PROVIDER_LABELS } from '@renderer/lib/constants';
import { formatDuration } from '@renderer/lib/formatters';
@@ -23,17 +23,29 @@ export function AgentMonitor(): React.JSX.Element {
const setMaxIterations = useAgentStore((s) => s.setMaxIterations);
useEffect(() => {
// M-11/M-28 修复: 添加 cancelled 标志 + 错误日志记录
let cancelled = false;
if (window.metona?.config?.get) {
window.metona.config.get('agent.maxIterations').then((v) => {
if (cancelled) return;
if (typeof v === 'number' && v > 0) setMaxIterations(v);
}).catch(() => {});
}).catch((err) => {
// M-11 修复: 记录错误而非静默吞掉,便于诊断配置加载异常
console.error('[AgentMonitor] Failed to load maxIterations:', err);
});
}
return () => { cancelled = true; };
}, [setMaxIterations]);
// L-14 修复: 使用 useMemo 缓存 totalDuration,避免每次渲染都遍历 traceSteps 数组
const totalDuration = useMemo(
() => traceSteps.reduce((sum, s) => sum + (s.completedAt ? s.completedAt - s.startedAt : 0), 0),
[traceSteps],
);
const StatusIcon = STATUS_ICONS[agentStatus];
const statusColor = AGENT_STATUS_COLORS[agentStatus];
const statusLabel = AGENT_STATUS_LABELS[agentStatus];
const totalDuration = traceSteps.reduce((sum, s) => sum + (s.completedAt ? s.completedAt - s.startedAt : 0), 0);
return (
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}>
+155
View File
@@ -0,0 +1,155 @@
/**
* Header Bar — 应用顶部栏
*
* H-7 修复: 规范要求布局包含 Header Bar(全宽),之前 App.tsx 缺失此组件。
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 整体布局
*
* 布局: Header Bar (全宽) + [Sidebar | ChatPanel | DetailPanel] + StatusBar
*
* 功能:
* - 应用标题/Logo
* - 当前 Provider/Model 显示
* - 面板切换(侧边栏、详情面板、专注模式)
* - 主题切换
* - 设置入口
*/
import { AppBar, Toolbar, Typography, IconButton, Box, Chip, Tooltip, Divider } from '@mui/material';
import {
PanelLeft,
PanelRight,
Focus,
Settings,
Sun,
Moon,
Monitor,
} from 'lucide-react';
import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store';
import { useAgentStore } from '@renderer/stores/agent-store';
const PROVIDER_LABELS: Record<string, string> = {
deepseek: 'DeepSeek',
agnes: 'Agnes',
ollama: 'Ollama',
};
export function Header(): React.JSX.Element {
const sidebarVisible = useUIStore((s) => s.sidebarVisible);
const detailVisible = useUIStore((s) => s.detailVisible);
const focusMode = useUIStore((s) => s.focusMode);
const theme = useUIStore((s) => s.theme);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const toggleDetail = useUIStore((s) => s.toggleDetail);
const toggleFocusMode = useUIStore((s) => s.toggleFocusMode);
const setTheme = useUIStore((s) => s.setTheme);
const openSettings = useUIStore((s) => s.openSettings);
const provider = useAgentStore((s) => s.provider);
const model = useAgentStore((s) => s.model);
const cycleTheme = () => {
const order: ThemeMode[] = ['light', 'dark', 'auto'];
const idx = order.indexOf(theme);
setTheme(order[(idx + 1) % order.length]);
};
const ThemeIcon = theme === 'light' ? Sun : theme === 'dark' ? Moon : Monitor;
const themeLabel = theme === 'light' ? '浅色主题' : theme === 'dark' ? '深色主题' : '跟随系统';
return (
<AppBar
position="static"
elevation={0}
sx={{
flexShrink: 0,
borderBottom: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
}}
>
<Toolbar variant="dense" sx={{ minHeight: 48, gap: 1 }}>
{/* 左侧:应用标题 */}
<Typography
variant="h6"
component="span"
sx={{
fontWeight: 700,
background: 'linear-gradient(135deg, #6366f1, #a855f7)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
mr: 1,
}}
>
MetonaAI
</Typography>
{/* Provider/Model 显示 */}
{provider && (
<>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<Chip
size="small"
label={PROVIDER_LABELS[provider] ?? provider}
color="primary"
variant="outlined"
/>
{model && (
<Typography variant="caption" color="text.secondary" sx={{ ml: 0.5 }}>
{model}
</Typography>
)}
</>
)}
{/* 右侧:操作按钮 */}
<Box sx={{ flexGrow: 1 }} />
<Tooltip title={sidebarVisible ? '隐藏侧边栏' : '显示侧边栏'}>
<IconButton
size="small"
onClick={toggleSidebar}
color={sidebarVisible ? 'primary' : 'default'}
disabled={focusMode}
>
<PanelLeft size={18} />
</IconButton>
</Tooltip>
<Tooltip title={detailVisible ? '隐藏详情面板' : '显示详情面板'}>
<IconButton
size="small"
onClick={toggleDetail}
color={detailVisible ? 'primary' : 'default'}
disabled={focusMode}
>
<PanelRight size={18} />
</IconButton>
</Tooltip>
<Tooltip title={focusMode ? '退出专注模式' : '进入专注模式'}>
<IconButton
size="small"
onClick={toggleFocusMode}
color={focusMode ? 'primary' : 'default'}
>
<Focus size={18} />
</IconButton>
</Tooltip>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<Tooltip title={themeLabel}>
<IconButton size="small" onClick={cycleTheme}>
<ThemeIcon size={18} />
</IconButton>
</Tooltip>
<Tooltip title="设置">
<IconButton size="small" onClick={openSettings}>
<Settings size={18} />
</IconButton>
</Tooltip>
</Toolbar>
</AppBar>
);
}
+45 -8
View File
@@ -4,7 +4,7 @@
* 始终显示,包含会话列表、新建会话按钮、搜索。
*/
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Box, Typography, Button, IconButton, TextField, Stack, Collapse, List, ListItemButton, ListItemIcon, ListItemText, Badge, Divider, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
import Fuse from 'fuse.js';
import { Plus, Search, MessageSquare, Pin, Wrench, ChevronDown, ChevronRight, Trash2 } from 'lucide-react';
@@ -23,14 +23,26 @@ export function Sidebar(): React.JSX.Element {
const agentStatus = useAgentStore((s) => s.agentStatus);
useEffect(() => {
if (window.metona?.sessions?.list) window.metona.sessions.list().then((list) => useSessionStore.getState().setSessions((list as any[]).map((s) => ({ id: s.id, title: s.title, createdAt: s.createdAt, updatedAt: s.updatedAt, messageCount: s.messageCount, pinned: s.pinned, archived: s.archived })))).catch(() => {});
// M-27 修复: 添加 cancelled 标志防止组件卸载后 setState
let cancelled = false;
if (window.metona?.sessions?.list) {
window.metona.sessions.list().then((list) => {
if (cancelled) return;
useSessionStore.getState().setSessions((list as Array<{ id: string; title: string; createdAt: number; updatedAt: number; messageCount: number; pinned: boolean; archived: boolean }>).map((s) => ({ id: s.id, title: s.title, createdAt: s.createdAt, updatedAt: s.updatedAt, messageCount: s.messageCount, pinned: s.pinned, archived: s.archived })));
}).catch((err) => {
// M-11 修复: 记录错误而非静默吞掉,便于诊断
console.error('[Sidebar] Failed to load sessions:', err);
});
}
return () => { cancelled = true; };
}, []);
const filteredSessions = (() => {
// L-13 修复: 使用 useMemo 缓存 filteredSessions,避免每次渲染都重建 Fuse 索引
const filteredSessions = useMemo(() => {
let list = sessions.filter((s) => !s.archived);
if (searchQuery) { const fuse = new Fuse(list, { keys: ['title'], threshold: 0.4, ignoreLocation: true }); list = fuse.search(searchQuery).map((r) => r.item); }
return list.sort((a, b) => a.pinned === b.pinned ? b.updatedAt - a.updatedAt : a.pinned ? -1 : 1);
})();
}, [sessions, searchQuery]);
const handleNewSession = async () => {
if (window.metona?.sessions?.create) {
@@ -40,7 +52,15 @@ export function Sidebar(): React.JSX.Element {
setCurrentSession(r.id);
loadSessionMessages(r.id);
return;
} catch {}
} catch (err) {
// M-9 修复: 显示错误提示而非静默吞错后创建本地假会话
// 之前的行为:catch 后继续创建本地 s_${Date.now()} 会话,但该会话在主进程不存在,下次刷新消失
console.error('[Sidebar] Failed to create session:', err);
import('metona-toast').then((mod) => {
mod.default.error('创建会话失败,请检查数据库状态');
}).catch(() => {});
return; // 不创建本地假会话
}
}
const newSession: Session = { id: `s_${Date.now()}`, title: '新会话', createdAt: Date.now(), updatedAt: Date.now(), messageCount: 0, pinned: false, archived: false };
useSessionStore.getState().addSession(newSession);
@@ -91,8 +111,22 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
setShowDeleteDialog(true);
};
const confirmDelete = () => {
window.metona?.sessions.delete(session.id).catch(() => {});
const confirmDelete = async () => {
// M-10 修复: 乐观更新失败时回滚,避免 UI 与数据库状态不一致
// 之前行为:catch 静默吞错,但下一行已 removeSession,导致用户以为已删除实际未删
if (window.metona?.sessions?.delete) {
try {
await window.metona.sessions.delete(session.id);
} catch (err) {
console.error('[Sidebar] Failed to delete session:', err);
import('metona-toast').then((mod) => {
mod.default.error('删除会话失败,请重试');
}).catch(() => {});
// 不调用 removeSession,保留会话在 UI 中(与数据库状态一致)
setShowDeleteDialog(false);
return;
}
}
useSessionStore.getState().removeSession(session.id);
setShowDeleteDialog(false);
};
@@ -137,11 +171,14 @@ function ToolManagerPanel() {
const [tools, setTools] = useState<Array<{ name: string; description: string; category: string; riskLevel: string; requiresPermission: boolean; enabled: boolean }>>([]);
useEffect(() => {
// M-54 修复: 添加 cancelled 标志,防止组件卸载后 setState
let cancelled = false;
if (window.metona?.tools?.list) {
window.metona.tools.list().then((list) => {
setTools(list as MetonaToolInfo[]);
if (!cancelled) setTools(list as MetonaToolInfo[]);
}).catch((err) => { console.error('[Sidebar]', err); });
}
return () => { cancelled = true; };
}, []);
const readyCount = tools.filter((t) => t.enabled).length;
+6 -1
View File
@@ -21,9 +21,14 @@ export function StatusBar(): React.JSX.Element {
const [version, setVersion] = useState('v0.1.1');
useEffect(() => {
// M-28 修复: 添加 cancelled 标志防止组件卸载后 setState
let cancelled = false;
if (window.metona?.app?.getVersion) {
window.metona.app.getVersion().then((v) => setVersion(`v${v}`)).catch((err) => { console.error('[StatusBar]', err); });
window.metona.app.getVersion().then((v) => {
if (!cancelled) setVersion(`v${v}`);
}).catch((err) => { console.error('[StatusBar]', err); });
}
return () => { cancelled = true; };
}, []);
return (
+19
View File
@@ -83,12 +83,21 @@ export function MemoryViewer(): React.JSX.Element {
// Agent 完成时自动刷新:监听 agentStatus 从 thinking/executing -> idle
const agentStatus = useAgentStore((s) => s.agentStatus);
const prevStatus = useRef(agentStatus);
// M-53 修复: 竞态保护 ref,防止多次 loadMemories 调用乱序完成导致旧数据覆盖
const loadReqIdRef = useRef(0);
// 审计补充修复: handleSearch 使用独立 ref,避免与 loadMemories 共用导致 searching 状态卡死
// 原问题:handleSearch 与 loadMemories 共用 loadReqIdRef,当 agent 完成触发 loadMemories 时
// 会 ++ref,使 handleSearch 的 finally 检查失败,setSearching(false) 不执行,UI 永久显示"搜索中..."
const searchReqIdRef = useRef(0);
const loadMemories = useCallback(async () => {
if (!window.metona?.memory?.listAll) return;
const reqId = ++loadReqIdRef.current;
try {
setError(null);
const res = await window.metona.memory.listAll();
// 竞态保护:若已被新请求取代或组件已卸载,放弃本次结果
if (loadReqIdRef.current !== reqId) return;
if (!res.success) {
setError(res.error ?? '加载记忆失败');
return;
@@ -100,6 +109,7 @@ export function MemoryViewer(): React.JSX.Element {
working: (data.working as MemoryItem[] | undefined) ?? [],
});
} catch (err) {
if (loadReqIdRef.current !== reqId) return;
setError((err as Error).message ?? '加载记忆失败');
}
}, []);
@@ -107,6 +117,8 @@ export function MemoryViewer(): React.JSX.Element {
// 初次挂载加载
useEffect(() => {
loadMemories();
// cleanup: 使当前请求失效(防止卸载后 setState)
return () => { loadReqIdRef.current++; };
}, [loadMemories]);
// Agent 完成自动刷新
@@ -123,13 +135,20 @@ export function MemoryViewer(): React.JSX.Element {
if (!q || !window.metona?.memory?.search) return;
setSearching(true);
setError(null);
// 审计补充修复: 使用独立 searchReqIdRef,不再与 loadMemories 共用
const reqId = ++searchReqIdRef.current;
try {
const results = await window.metona.memory.search(q, { topK: 20 });
// 竞态保护:若已被新搜索请求取代或组件已卸载,放弃本次结果
if (searchReqIdRef.current !== reqId) return;
setSearchResults(results);
} catch (err) {
if (searchReqIdRef.current !== reqId) return;
setError((err as Error).message ?? '搜索失败');
setSearchResults([]);
} finally {
// 审计补充修复: 无条件清理 searching 状态,避免被 loadMemories 取代时卡死
// searching 仅对当前搜索有意义,请求被取代后应停止 spinner
setSearching(false);
}
};
@@ -34,7 +34,15 @@ export function OnboardingWizard(): React.JSX.Element | null {
if (apiKey.trim()) configSets.push(window.metona.config.set('llm.apiKey', apiKey.trim()));
if (workspacePath.trim()) configSets.push(window.metona.config.set('workspace.path', workspacePath.trim()));
configSets.push(window.metona.config.set('onboarding.completed', true));
await Promise.all(configSets);
// M-26 修复: 改用 Promise.allSettled,单个配置写入失败不阻止 onboarding 完成
// 但 onboarding.completed 必须成功,否则用户重启后仍会看到引导
const results = await Promise.allSettled(configSets);
const failedCount = results.filter((r) => r.status === 'rejected').length;
if (failedCount > 0) {
console.error('[OnboardingWizard]', `${failedCount} config(s) failed to save`);
// 不调用 setOnboardingCompleted(true),让用户重试
return;
}
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
}
setOnboardingCompleted(true);
+125 -7
View File
@@ -580,11 +580,51 @@ function MCPSettings() {
const [newName, setNewName] = useState('');
const [newCommand, setNewCommand] = useState('');
const [newArgs, setNewArgs] = useState('');
const loadServers = () => { if (window.metona?.mcp?.listServers) window.metona.mcp.listServers().then((l) => setServers(l as MetonaMCPServerStatus[])).catch((err) => { console.error('[SettingsModal]', err); }); };
useEffect(() => { loadServers(); }, []);
// L-11 修复: 用 MUI Dialog 替换浏览器原生 confirm(),保持 UI 一致性
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
// L-20 修复: loadServers 改为多行 async/await 写法,提升可读性
const loadServers = useCallback(async () => {
if (!window.metona?.mcp?.listServers) return;
try {
const list = await window.metona.mcp.listServers();
setServers(list as MetonaMCPServerStatus[]);
} catch (err) {
console.error('[SettingsModal]', err);
}
}, []);
// 审计补充修复: useEffect 添加 cancelled 标志,防止卸载后 setState
useEffect(() => {
let cancelled = false;
(async () => {
if (!window.metona?.mcp?.listServers) return;
try {
const list = await window.metona.mcp.listServers();
if (!cancelled) setServers(list as MetonaMCPServerStatus[]);
} catch (err) {
if (!cancelled) console.error('[SettingsModal]', err);
}
})();
return () => { cancelled = true; };
}, []);
const handleAdd = async () => { if (!newName.trim() || !newCommand.trim()) return; await window.metona?.mcp?.addServer({ name: newName.trim(), transport: 'stdio', command: newCommand.trim(), args: newArgs.trim() ? newArgs.trim().split(/\s+/) : [], enabled: true }); setNewName(''); setNewCommand(''); setNewArgs(''); setShowAdd(false); loadServers(); };
const statusColors: Record<string, string> = { connected: 'success.main', connecting: 'warning.main', disconnected: 'text.secondary', error: 'error.main' };
// L-11 修复: 确认移除 MCP 服务
// 审计补充修复: 添加 try/catch,避免 removeServer reject 时 Dialog 卡死无法关闭
const [removeError, setRemoveError] = useState<string | null>(null);
const handleConfirmRemove = async () => {
if (!confirmRemove) return;
try {
setRemoveError(null);
await window.metona?.mcp?.removeServer(confirmRemove);
setConfirmRemove(null);
loadServers();
} catch (err) {
setRemoveError((err as Error).message ?? '移除失败');
}
};
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>MCP </Typography>
@@ -598,10 +638,33 @@ function MCPSettings() {
</Stack>
<Stack direction="row" spacing={0.5}>
<Button size="small" sx={{ fontSize: 10, minWidth: 40 }} onClick={async () => { await window.metona?.mcp?.toggleServer(s.name, s.status !== 'connected'); loadServers(); }}>{s.status === 'connected' ? '断开' : '连接'}</Button>
<Button size="small" color="error" sx={{ fontSize: 10, minWidth: 40 }} onClick={async () => { if (confirm(`确定移除 "${s.name}"`)) { await window.metona?.mcp?.removeServer(s.name); loadServers(); } }}></Button>
{/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */}
<Button size="small" color="error" sx={{ fontSize: 10, minWidth: 40 }} onClick={() => setConfirmRemove(s.name)}></Button>
</Stack>
</Stack>
))}
{/* L-11 修复: MUI Dialog 替代原生 confirm() */}
<Dialog
open={confirmRemove !== null}
onClose={() => { setConfirmRemove(null); setRemoveError(null); }}
maxWidth="xs"
fullWidth
>
<DialogTitle></DialogTitle>
<DialogContent>
<Typography variant="body2">
MCP "{confirmRemove}"
</Typography>
{removeError && (
<Alert severity="error" sx={{ mt: 1, fontSize: 12 }}>{removeError}</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => { setConfirmRemove(null); setRemoveError(null); }} color="inherit"></Button>
<Button onClick={handleConfirmRemove} color="error" variant="contained"></Button>
</DialogActions>
</Dialog>
{showAdd ? (
<Stack spacing={1} sx={{ p: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main', border: '1px solid', borderColor: 'divider' }}>
<TextField size="small" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="服务名称" />
@@ -820,8 +883,33 @@ function AppearanceSettings({ theme, setTheme }: { theme: ThemeMode; setTheme: (
function LogsSettings() {
const [logLevel, setLogLevel] = useConfig('logging.level', 'info');
const [clearing, setClearing] = useState<string | null>(null);
// L-11 修复(审计补充): 用 MUI Dialog 替换原生 confirm() 和 alert(),保持 UI 一致性
const [confirmClear, setConfirmClear] = useState<'sessions' | 'memories' | 'auditLogs' | null>(null);
const [resultAlert, setResultAlert] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const handleExport = async () => { if (!window.metona?.data?.exportData) return; const r = await window.metona.data.exportData(); if (r.success && r.data) { const b = new Blob([JSON.stringify(r.data, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `metona-export-${Date.now()}.json`; a.click(); } };
const handleClear = async (type: 'sessions' | 'memories' | 'auditLogs') => { const labels: Record<string, string> = { sessions: '所有会话', memories: '所有记忆', auditLogs: '审计日志' }; if (!confirm(`确定清理${labels[type]}`)) return; setClearing(type); try { let r; if (type === 'sessions') r = await window.metona?.data?.clearSessions(); else if (type === 'memories') r = await window.metona?.data?.clearMemories(); else r = await window.metona?.data?.clearAuditLogs(); if (r?.success) alert(`${labels[type]}已清理`); else alert(`失败: ${r?.error}`); } catch (e) { alert(`失败: ${(e as Error).message}`); } setClearing(null); };
const labels: Record<string, string> = { sessions: '所有会话', memories: '所有记忆', auditLogs: '审计日志' };
// L-11 修复(审计补充): 清理数据改用 Dialog 确认 + Alert 反馈,不再用原生 confirm/alert
const handleClearConfirm = async () => {
if (!confirmClear) return;
const type = confirmClear;
setClearing(type);
setConfirmClear(null);
try {
let r;
if (type === 'sessions') r = await window.metona?.data?.clearSessions();
else if (type === 'memories') r = await window.metona?.data?.clearMemories();
else r = await window.metona?.data?.clearAuditLogs();
if (r?.success) {
setResultAlert({ type: 'success', message: `${labels[type]}已清理` });
} else {
setResultAlert({ type: 'error', message: `失败: ${r?.error ?? '未知错误'}` });
}
} catch (e) {
setResultAlert({ type: 'error', message: `失败: ${(e as Error).message}` });
}
setClearing(null);
};
return (
<Stack spacing={2}>
@@ -834,9 +922,39 @@ function LogsSettings() {
<Divider />
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}></Typography>
<Button variant="outlined" fullWidth size="small" onClick={handleExport}>📦 JSON</Button>
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => handleClear('sessions')} disabled={clearing !== null}>{clearing === 'sessions' ? '清理中...' : '🗑️ 清理所有会话'}</Button>
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => handleClear('memories')} disabled={clearing !== null}>{clearing === 'memories' ? '清理中...' : '🗑️ 清理所有记忆'}</Button>
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => handleClear('auditLogs')} disabled={clearing !== null}>{clearing === 'auditLogs' ? '清理中...' : '🗑️ 清理审计日志'}</Button>
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => { setResultAlert(null); setConfirmClear('sessions'); }} disabled={clearing !== null}>{clearing === 'sessions' ? '清理中...' : '🗑️ 清理所有会话'}</Button>
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => { setResultAlert(null); setConfirmClear('memories'); }} disabled={clearing !== null}>{clearing === 'memories' ? '清理中...' : '🗑️ 清理所有记忆'}</Button>
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => { setResultAlert(null); setConfirmClear('auditLogs'); }} disabled={clearing !== null}>{clearing === 'auditLogs' ? '清理中...' : '🗑️ 清理审计日志'}</Button>
{/* L-11 修复(审计补充): 清理数据确认 Dialog(替代原生 confirm() */}
<Dialog
open={confirmClear !== null}
onClose={() => setConfirmClear(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle></DialogTitle>
<DialogContent>
<Typography variant="body2">
{confirmClear ? labels[confirmClear] : ''}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmClear(null)} color="inherit"></Button>
<Button onClick={handleClearConfirm} color="error" variant="contained"></Button>
</DialogActions>
</Dialog>
{/* L-11 修复(审计补充): 清理结果反馈 Alert(替代原生 alert()),3 秒后自动消失 */}
{resultAlert && (
<Alert
severity={resultAlert.type}
onClose={() => setResultAlert(null)}
sx={{ fontSize: 12 }}
>
{resultAlert.message}
</Alert>
)}
</Stack>
);
}
+9 -1
View File
@@ -4,7 +4,7 @@
* 显示当前会话的任务,支持新增、完成切换、删除、展开查看描述。
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Box, Typography, Stack, List, ListItem, ListItemIcon,
Checkbox, Chip, IconButton, TextField, Button, Select, MenuItem, Collapse,
@@ -63,12 +63,17 @@ export function TaskList(): React.JSX.Element {
const [newPriority, setNewPriority] = useState<TaskPriority>('medium');
const [expandedId, setExpandedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
// M-51 修复: 竞态保护 ref,防止快速切换会话时旧请求覆盖新数据
const loadReqIdRef = useRef(0);
const loadTasks = useCallback(async (sid?: string) => {
if (!window.metona?.tasks?.list) return;
const reqId = ++loadReqIdRef.current;
try {
setError(null);
const res = await window.metona.tasks.list(sid);
// 竞态保护:若已被新请求取代或组件已卸载,放弃本次结果
if (loadReqIdRef.current !== reqId) return;
if (!res.success) {
setError('加载任务失败');
return;
@@ -79,12 +84,15 @@ export function TaskList(): React.JSX.Element {
});
setTasks(list);
} catch (err) {
if (loadReqIdRef.current !== reqId) return;
setError((err as Error).message ?? '加载任务失败');
}
}, []);
useEffect(() => {
loadTasks(sessionId ?? undefined);
// cleanup: 使当前请求失效(防止卸载后 setState + 竞态)
return () => { loadReqIdRef.current++; };
}, [sessionId, loadTasks]);
const handleCreate = async () => {
+13 -5
View File
@@ -28,24 +28,32 @@ export function WorkspaceViewer(): React.JSX.Element {
// Agent 完成时自动刷新
const agentStatus = useAgentStore((s) => s.agentStatus);
const prevStatus = useRef(agentStatus);
// M-52 修复: 竞态保护 ref,防止多次 loadInfo 调用乱序完成导致旧数据覆盖
const loadReqIdRef = useRef(0);
const loadInfo = useCallback(async () => {
if (!window.metona?.workspace?.getInfo) return;
const reqId = ++loadReqIdRef.current;
setLoading(true);
setError(null);
try {
const res = await window.metona.workspace.getInfo();
// 竞态保护:若已被新请求取代或组件已卸载,放弃本次结果
if (loadReqIdRef.current !== reqId) return;
setInfo(res);
} catch (err) {
if (loadReqIdRef.current !== reqId) return;
setError((err as Error).message ?? '加载工作空间信息失败');
} finally {
setLoading(false);
if (loadReqIdRef.current === reqId) setLoading(false);
}
}, []);
// 初次挂载加载
useEffect(() => {
loadInfo();
// cleanup: 使当前请求失效(防止卸载后 setState)
return () => { loadReqIdRef.current++; };
}, [loadInfo]);
// Agent 完成自动刷新
@@ -73,7 +81,7 @@ export function WorkspaceViewer(): React.JSX.Element {
<Stack spacing={1.5} sx={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
{/* 工作空间根路径 */}
<Box>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 0.5 }}>
<Stack direction="row" sx={{ mb: 0.5, alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
</Typography>
@@ -83,7 +91,7 @@ export function WorkspaceViewer(): React.JSX.Element {
</IconButton>
</Tooltip>
</Stack>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ flexWrap: 'wrap' }}>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
<Typography
variant="body2"
sx={{
@@ -126,7 +134,7 @@ export function WorkspaceViewer(): React.JSX.Element {
}}
>
<AccordionSummary expandIcon={<ChevronDown size={14} />} sx={{ minHeight: 32, '& .MuiAccordionSummary-content': { my: 0, alignItems: 'center' } }}>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ flex: 1, mr: 1 }}>
<Stack direction="row" spacing={0.5} sx={{ flex: 1, mr: 1, alignItems: 'center' }}>
{file.exists ? (
<CheckCircle2 size={12} color="var(--mui-palette-success-main)" />
) : (
@@ -193,13 +201,13 @@ export function WorkspaceViewer(): React.JSX.Element {
key={dir.name}
direction="row"
spacing={0.5}
alignItems="center"
sx={{
p: 0.5,
border: 1,
borderColor: 'divider',
borderRadius: 1,
bgcolor: 'background.default',
alignItems: 'center',
}}
>
<Folder size={12} color={dir.exists ? 'var(--mui-palette-info-main)' : 'var(--mui-palette-text-disabled)'} />
+8
View File
@@ -31,8 +31,16 @@ export function useAgentStream(): void {
requestId?: string;
sessionId?: string;
iteration?: number;
/** 事件序列号(与 MetonaStreamEvent.seq 对齐,规范必填字段) */
seq?: number;
/** 事件时间戳(与 MetonaStreamEvent.timestamp 对齐,规范必填字段) */
timestamp?: number;
/** 当前 run 的唯一标识(前端用于过滤旧流事件,abort 后重发场景) */
runId?: string;
delta?: string;
content?: string;
/** 工具调用增量(流式参数拼接) */
toolCallDelta?: { index: number; name?: string; argsDelta?: string };
toolCall?: { id: string; name: string; args: Record<string, unknown> };
toolResult?: { toolCallId: string; success: boolean; result?: unknown; error?: string; durationMs?: number };
usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number };
+11 -1
View File
@@ -11,6 +11,15 @@ import { useSessionStore } from './session-store';
let _msgIdCounter = 0;
export const genMsgId = (role: string) => `msg_${Date.now()}_${role}_${_msgIdCounter++}`;
/**
* L-16 修复: 提取上下文窗口默认值为命名常量
* - Ollama 默认 4096(与 OllamaAdapter.DEFAULT_CONTEXT_WINDOW 保持一致)
* - 云端 ProviderDeepSeek/Agnes)默认 1M(与各 adapter MODEL_INFO 保持一致)
* 实际值会通过 getContextWindow() 或 listModels() 获取,此处仅为 UI 占位默认值
*/
const DEFAULT_OLLAMA_CONTEXT_WINDOW = 4096;
const DEFAULT_CLOUD_CONTEXT_WINDOW = 1_000_000;
// ===== 消息类型 =====
export type MessageRole = 'user' | 'assistant' | 'system' | 'tool';
@@ -365,8 +374,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })),
setProvider: (provider, model) => {
// L-16 修复: 使用命名常量替代魔法数字
// DeepSeek/Agnes 固定 1MOllama 从配置读取
const ollamaCtx = provider === 'ollama' ? 4096 : 1_000_000;
const ollamaCtx = provider === 'ollama' ? DEFAULT_OLLAMA_CONTEXT_WINDOW : DEFAULT_CLOUD_CONTEXT_WINDOW;
set({ provider, model, contextWindow: ollamaCtx });
// 异步读取 Ollama 实际配置
if (provider === 'ollama' && window.metona?.config?.get) {