feat: 升级至 v0.3.7 — 前后端状态同步与错误处理全量修复
核心引擎修复: - CE-1: 上下文压缩摘要 role 从 system 改为 user,避免被 adapter 过滤 - CE-2: 工具失败时优先使用 error 字段(engine/openai-format/ollama 三处) - P0-1: DeadLoopError 终止时正确传 error 参数,前端可见 ERROR 事件 - MT-1: 新增 waitForAbort 方法,abortSession 等待 run 结束再返回 - MT-2: TERMINATED 状态到达时标记步骤完成,避免 Trace Viewer 转圈 - MT-3: 压缩边界检测孤立 tool 消息,避免 API 400 错误 - isRetryableError 与 catch 分支统一 toLowerCase IPC 与主进程修复: - P0-2: createAdapter 配置缺失返回 null,FALLBACK_ADAPTER 兜底 - P0-3: reloadAdapter 失败返回 success:false 通知前端 - P1-5: 校验失败发 ERROR+DONE 流事件,防止 isStreaming 卡死 - P1-6: configLoaded 标志,配置加载前禁用发送按钮 - P1-7: MCP initialize 移到 agentLoop 后,完成后同步工具 - P2-11: beforeLoad 在 loadURL 前注册 IPC handler - P2-12: provider 切换竞态保护 前端状态同步修复: - clearSessions 后同步清空前端会话与消息状态 - clearMemories 通过 memoryVersion 触发 MemoryViewer 重新加载 - ContextMenu 4 个 session 操作补全 IPC 调用与 try/catch - useConfig 配置保存失败回滚 UI 并提示 - handleToggle 工具切换失败回滚单个工具状态 错误处理全量补全: - 所有 await window.metona 调用补全 try/catch 与 toast 反馈 - MCP addServer/toggleServer/removeServer 检查返回值 - showItemInFolder 检查返回值(handleOpen/handleOpenInFolder) - sse-stream/ollama NDJSON 解析失败改为 log.warn - adapter throwHttpError 带 status 属性供 isRetryableError 判断
This commit is contained in:
@@ -2,12 +2,13 @@
|
||||
* SettingsModal — 设置弹窗
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Dialog, DialogContent, DialogTitle, DialogActions, Button, TextField, Select, MenuItem, Tabs, Tab, Checkbox, Box, Typography, Stack, Divider, FormControlLabel, InputLabel, FormControl, IconButton, Chip, Switch, Alert, CircularProgress } from '@mui/material';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { X, Settings, Bot, Wrench, Server, Palette, FileText, Eye, EyeOff, FolderOpen, Globe } from 'lucide-react';
|
||||
import { X, Settings, Bot, Wrench, Server, Palette, FileText, Eye, EyeOff, FolderOpen, Globe, Folder, Copy } from 'lucide-react';
|
||||
import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store';
|
||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
import { useSessionStore } from '@renderer/stores/session-store';
|
||||
import { PROVIDER_LABELS } from '@renderer/lib/constants';
|
||||
import { ErrorBoundary } from '@renderer/components/common/ErrorBoundary';
|
||||
|
||||
@@ -81,8 +82,28 @@ export function SettingsModal(): React.JSX.Element | null {
|
||||
|
||||
function useConfig<T>(key: string, defaultValue: T): [T, (v: T) => void] {
|
||||
const [value, setValue] = useState<T>(defaultValue);
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
// v0.3.6 修复: 配置保存失败时回滚 UI 并提示用户,避免 UI 与 DB 状态不一致
|
||||
// seqRef 防止竞态:连续修改时旧请求失败不回滚覆盖新值
|
||||
const seqRef = useRef(0);
|
||||
useEffect(() => { if (window.metona?.config?.get) window.metona.config.get(key).then((v) => { if (v != null) setValue(v as T); }).catch((err) => { console.error('[SettingsModal]', err); }); }, [key]);
|
||||
const set = useCallback((v: T) => { setValue(v); window.metona?.config?.set(key, v).catch((err) => { console.error('[SettingsModal]', err); }); }, [key]);
|
||||
const set = useCallback((v: T) => {
|
||||
const seq = ++seqRef.current;
|
||||
const prev = valueRef.current;
|
||||
setValue(v);
|
||||
window.metona?.config?.set(key, v).then((r: { success?: boolean; error?: string } | undefined) => {
|
||||
if (r && !r.success) {
|
||||
// 只有当没有后续 set 操作时才回滚,避免覆盖用户的新修改
|
||||
if (seqRef.current === seq) setValue(prev);
|
||||
import('metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败')).catch(() => {});
|
||||
}
|
||||
}).catch((err: unknown) => {
|
||||
console.error('[SettingsModal]', err);
|
||||
if (seqRef.current === seq) setValue(prev);
|
||||
import('metona-toast').then((mod) => mod.default.error('配置保存失败')).catch(() => {});
|
||||
});
|
||||
}, [key]);
|
||||
return [value, set];
|
||||
}
|
||||
|
||||
@@ -163,7 +184,18 @@ function WorkspaceSettings() {
|
||||
setCheckResult(null);
|
||||
};
|
||||
|
||||
const handleOpen = () => { if (workspacePath) window.metona?.app?.showItemInFolder(workspacePath); };
|
||||
const handleOpen = async () => {
|
||||
if (!workspacePath) return;
|
||||
try {
|
||||
const r = await window.metona?.app?.showItemInFolder(workspacePath);
|
||||
if (r && !r.success) {
|
||||
import('metona-toast').then((mod) => mod.default.error(r.error ?? '打开文件夹失败')).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SettingsModal]', err);
|
||||
import('metona-toast').then((mod) => mod.default.error('打开文件夹失败')).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
@@ -500,17 +532,37 @@ function ToolsSettings() {
|
||||
};
|
||||
useEffect(() => { loadTools(); loadAutoExec(); }, []);
|
||||
|
||||
const handleToggle = (name: string, enabled: boolean) => {
|
||||
const handleToggle = async (name: string, enabled: boolean) => {
|
||||
// v0.3.6 修复: 乐观更新失败时回滚 UI,避免开关显示与实际状态不一致
|
||||
// 注意: 只回滚失败的单个工具(用 !enabled),不能用 setTools(prev) 整体回滚,
|
||||
// 否则会覆盖 await 期间用户对其他工具的并发修改
|
||||
setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t));
|
||||
window.metona?.tools?.toggle(name, enabled).catch((err) => { console.error('[SettingsModal]', err); });
|
||||
try {
|
||||
const r = await window.metona?.tools?.toggle(name, enabled);
|
||||
if (r && !r.success) {
|
||||
setTools((p) => p.map((t) => t.name === name ? { ...t, enabled: !enabled } : t));
|
||||
import('metona-toast').then((mod) => mod.default.error(r.error ?? '切换工具失败')).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SettingsModal]', err);
|
||||
setTools((p) => p.map((t) => t.name === name ? { ...t, enabled: !enabled } : t));
|
||||
import('metona-toast').then((mod) => mod.default.error('切换工具失败')).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// 设置/取消自动执行
|
||||
const handleSetAutoExec = async (name: string, enabled: boolean) => {
|
||||
if (!window.metona?.tool?.setAutoExecute) return;
|
||||
const r = await window.metona.tool.setAutoExecute(name, enabled);
|
||||
if (r.success) {
|
||||
setAutoExecList((p) => enabled ? [...p, name] : p.filter((n) => n !== name));
|
||||
try {
|
||||
const r = await window.metona.tool.setAutoExecute(name, enabled);
|
||||
if (r.success) {
|
||||
setAutoExecList((p) => enabled ? [...p, name] : p.filter((n) => n !== name));
|
||||
} else {
|
||||
import('metona-toast').then((mod) => mod.default.error(r.error ?? '设置自动执行失败')).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SettingsModal]', err);
|
||||
import('metona-toast').then((mod) => mod.default.error('设置自动执行失败')).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -656,7 +708,20 @@ function MCPSettings() {
|
||||
})();
|
||||
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 handleAdd = async () => {
|
||||
if (!newName.trim() || !newCommand.trim()) return;
|
||||
try {
|
||||
const r = await window.metona?.mcp?.addServer({ name: newName.trim(), transport: 'stdio', command: newCommand.trim(), args: newArgs.trim() ? newArgs.trim().split(/\s+/) : [], enabled: true });
|
||||
if (r?.success) {
|
||||
setNewName(''); setNewCommand(''); setNewArgs(''); setShowAdd(false); loadServers();
|
||||
} else {
|
||||
import('metona-toast').then((mod) => mod.default.error(r?.error ?? '添加 MCP 服务失败')).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SettingsModal]', err);
|
||||
import('metona-toast').then((mod) => mod.default.error(`添加 MCP 服务失败:${(err as Error).message}`)).catch(() => {});
|
||||
}
|
||||
};
|
||||
const statusColors: Record<string, string> = { connected: 'success.main', connecting: 'warning.main', disconnected: 'text.secondary', error: 'error.main' };
|
||||
|
||||
// L-11 修复: 确认移除 MCP 服务
|
||||
@@ -666,9 +731,13 @@ function MCPSettings() {
|
||||
if (!confirmRemove) return;
|
||||
try {
|
||||
setRemoveError(null);
|
||||
await window.metona?.mcp?.removeServer(confirmRemove);
|
||||
setConfirmRemove(null);
|
||||
loadServers();
|
||||
const r = await window.metona?.mcp?.removeServer(confirmRemove);
|
||||
if (r?.success) {
|
||||
setConfirmRemove(null);
|
||||
loadServers();
|
||||
} else {
|
||||
setRemoveError(r?.error ?? '移除失败');
|
||||
}
|
||||
} catch (err) {
|
||||
setRemoveError((err as Error).message ?? '移除失败');
|
||||
}
|
||||
@@ -686,7 +755,19 @@ function MCPSettings() {
|
||||
{s.toolCount > 0 && <Typography variant="caption" sx={{ color: 'text.secondary' }}>{s.toolCount} 工具</Typography>}
|
||||
</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" sx={{ fontSize: 10, minWidth: 40 }} onClick={async () => {
|
||||
try {
|
||||
const r = await window.metona?.mcp?.toggleServer(s.name, s.status !== 'connected');
|
||||
if (r?.success) {
|
||||
loadServers();
|
||||
} else {
|
||||
import('metona-toast').then((mod) => mod.default.error(r?.error ?? '操作失败')).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SettingsModal]', err);
|
||||
import('metona-toast').then((mod) => mod.default.error(`操作失败:${(err as Error).message}`)).catch(() => {});
|
||||
}
|
||||
}}>{s.status === 'connected' ? '断开' : '连接'}</Button>
|
||||
{/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */}
|
||||
<Button size="small" color="error" sx={{ fontSize: 10, minWidth: 40 }} onClick={() => setConfirmRemove(s.name)}>移除</Button>
|
||||
</Stack>
|
||||
@@ -935,7 +1016,76 @@ function LogsSettings() {
|
||||
// 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(); } };
|
||||
// P3-13: 显示日志文件路径,并提供"打开日志文件夹"按钮
|
||||
// electron-log 默认写入路径为 ${userData}/logs/main.log
|
||||
const [logFilePath, setLogFilePath] = useState<string>('');
|
||||
const [logPathLoading, setLogPathLoading] = useState<boolean>(true);
|
||||
const [copyState, setCopyState] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
|
||||
// P3-13: 组件挂载时获取日志文件路径
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const appData = await window.metona?.app?.getAppDataPath?.();
|
||||
if (cancelled) return;
|
||||
if (appData) {
|
||||
// electron-log 默认日志路径: ${userData}/logs/main.log
|
||||
// 路径分隔符由系统决定,直接拼接避免引入 path 模块
|
||||
const sep = appData.includes('/') && !appData.includes('\\') ? '/' : '\\';
|
||||
setLogFilePath(`${appData}${sep}logs${sep}main.log`);
|
||||
}
|
||||
} catch (e) {
|
||||
// 获取失败不阻塞 UI
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[LogsSettings] Failed to get app data path:', e);
|
||||
} finally {
|
||||
if (!cancelled) setLogPathLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const handleOpenLogFolder = async () => {
|
||||
if (!logFilePath) return;
|
||||
try {
|
||||
const r = await window.metona?.app?.showItemInFolder?.(logFilePath);
|
||||
if (r && !r.success) {
|
||||
setResultAlert({ type: 'error', message: `打开失败: ${r.error ?? '未知错误'}` });
|
||||
}
|
||||
} catch (e) {
|
||||
setResultAlert({ type: 'error', message: `打开失败: ${(e as Error).message}` });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLogPath = async () => {
|
||||
if (!logFilePath) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(logFilePath);
|
||||
setCopyState('success');
|
||||
setTimeout(() => setCopyState('idle'), 1500);
|
||||
} catch (e) {
|
||||
setCopyState('error');
|
||||
setTimeout(() => setCopyState('idle'), 1500);
|
||||
setResultAlert({ type: 'error', message: `复制失败: ${(e as Error).message}` });
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!window.metona?.data?.exportData) return;
|
||||
try {
|
||||
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();
|
||||
} else {
|
||||
setResultAlert({ type: 'error', message: `导出失败: ${r.error ?? '未知错误'}` });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[LogsSettings]', err);
|
||||
setResultAlert({ type: 'error', message: `导出失败: ${(err as Error).message}` });
|
||||
}
|
||||
};
|
||||
const labels: Record<string, string> = { sessions: '所有会话', memories: '所有记忆', auditLogs: '审计日志' };
|
||||
|
||||
// L-11 修复(审计补充): 清理数据改用 Dialog 确认 + Alert 反馈,不再用原生 confirm/alert
|
||||
@@ -951,6 +1101,17 @@ function LogsSettings() {
|
||||
else r = await window.metona?.data?.clearAuditLogs();
|
||||
if (r?.success) {
|
||||
setResultAlert({ type: 'success', message: `${labels[type]}已清理` });
|
||||
// 修复: 清理会话后同步清空前端状态,无需重启应用
|
||||
if (type === 'sessions') {
|
||||
useSessionStore.getState().setSessions([]);
|
||||
useSessionStore.getState().setCurrentSession(null);
|
||||
// 同时清空当前消息列表,防止聊天面板显示已删除的会话内容
|
||||
useAgentStore.getState().setMessages([]);
|
||||
}
|
||||
// v0.3.6 修复: 清理记忆后触发 MemoryViewer 重新加载(之前需重启应用才看到效果)
|
||||
if (type === 'memories') {
|
||||
useUIStore.getState().bumpMemoryVersion();
|
||||
}
|
||||
} else {
|
||||
setResultAlert({ type: 'error', message: `失败: ${r?.error ?? '未知错误'}` });
|
||||
}
|
||||
@@ -968,6 +1129,44 @@ function LogsSettings() {
|
||||
<MenuItem value="debug">DEBUG</MenuItem><MenuItem value="info">INFO</MenuItem><MenuItem value="warn">WARN</MenuItem><MenuItem value="error">ERROR</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* P3-13: 日志文件路径展示与打开按钮 */}
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', mb: 0.5, display: 'block' }}>日志文件</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
value={logFilePath}
|
||||
placeholder={logPathLoading ? '正在获取路径...' : '路径不可用'}
|
||||
readOnly
|
||||
slotProps={{ input: { sx: { fontSize: 11, fontFamily: 'monospace' } } }}
|
||||
/>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<Folder size={14} />}
|
||||
onClick={handleOpenLogFolder}
|
||||
disabled={!logFilePath}
|
||||
>
|
||||
打开日志文件夹
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<Copy size={14} />}
|
||||
onClick={handleCopyLogPath}
|
||||
disabled={!logFilePath}
|
||||
color={copyState === 'success' ? 'success' : copyState === 'error' ? 'error' : 'inherit'}
|
||||
>
|
||||
{copyState === 'success' ? '已复制' : copyState === 'error' ? '复制失败' : '复制路径'}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', mt: 0.5, display: 'block' }}>
|
||||
日志级别变更重启后生效。日志文件按日期滚动,旧日志保留在 logs 目录下。
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>数据管理</Typography>
|
||||
<Button variant="outlined" fullWidth size="small" onClick={handleExport}>📦 导出全部数据(JSON)</Button>
|
||||
|
||||
Reference in New Issue
Block a user