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:
+10
-4
@@ -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 />
|
||||
|
||||
@@ -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' } } }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)'} />
|
||||
|
||||
@@ -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,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 保持一致)
|
||||
* - 云端 Provider(DeepSeek/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 固定 1M,Ollama 从配置读取
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user