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:
thzxx
2026-07-16 22:40:32 +08:00
parent 656c6b7af1
commit ceb8ee644d
28 changed files with 617 additions and 100 deletions
+5
View File
@@ -52,7 +52,12 @@ export default function App(): React.JSX.Element {
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);
// P1-6 修复: 配置加载完成,启用发送按钮
useAgentStore.getState().setConfigLoaded(true);
});
} else {
// 无 IPC 可用时也标记为已加载(避免永久禁用)
useAgentStore.getState().setConfigLoaded(true);
}
}, []);
+14 -1
View File
@@ -35,7 +35,20 @@ export function CommandPalette(): React.JSX.Element {
const getCommands = useCallback((): CommandItem[] => {
const cmds: CommandItem[] = [
{ id: 'new-session', icon: Plus, label: '新建会话', description: '创建一个新的对话会话', action: async () => { if (window.metona?.sessions?.create) { const r = await window.metona.sessions.create() as MetonaSessionInfo; useSessionStore.getState().addSession(r); useSessionStore.getState().setCurrentSession(r.id); useAgentStore.getState().setCurrentSession(r.id); } setOpen(false); } },
{ id: 'new-session', icon: Plus, label: '新建会话', description: '创建一个新的对话会话', action: async () => {
if (window.metona?.sessions?.create) {
try {
const r = await window.metona.sessions.create() as MetonaSessionInfo;
useSessionStore.getState().addSession(r);
useSessionStore.getState().setCurrentSession(r.id);
useAgentStore.getState().setCurrentSession(r.id);
} catch (err) {
console.error('[CommandPalette]', err);
import('metona-toast').then((mod) => mod.default.error('创建会话失败')).catch(() => {});
}
}
setOpen(false);
} },
{ id: 'clear', icon: Trash2, label: '清空当前会话', action: () => { useAgentStore.getState().clearMessages(); setOpen(false); } },
{ id: 'settings', icon: Settings, label: '打开设置', action: () => { useUIStore.getState().openSettings(); setOpen(false); } },
];
+72 -8
View File
@@ -75,24 +75,88 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
case 'session': {
const sid = (data as { sessionId?: string })?.sessionId;
// 修复: 4 个 session 操作统一改为 await IPC + 失败回滚,避免 UI 与 DB 状态不一致
// 之前 archive 仅调用前端 store 未调用 IPCrename/pin/delete 未等待 IPC 结果即更新 UI
const showError = (msg: string) => {
import('metona-toast').then((mod) => mod.default.error(msg)).catch(() => {});
};
return [
{ id: 'rename', icon: Edit, label: '重命名', action: () => {
{ id: 'rename', icon: Edit, label: '重命名', action: async () => {
if (!sid) return;
// window.prompt may be unreliable in Electron; fall back to sidebar double-click rename
if (sid) { try { const t = prompt('新会话名称:'); if (t?.trim()) { window.metona?.sessions.rename(sid, t.trim()); useSessionStore.getState().updateSession(sid, { title: t.trim() }); } } catch { /* prompt unavailable, user can rename via sidebar double-click */ } }
let t: string | null = null;
try { t = prompt('新会话名称:'); } catch { /* prompt unavailable, user can rename via sidebar double-click */ return; }
if (!t?.trim()) return;
try {
const r = await window.metona?.sessions?.rename(sid, t.trim());
if (r?.success) {
useSessionStore.getState().updateSession(sid, { title: t.trim() });
} else {
showError(r?.error ?? '重命名失败');
}
} catch (err) {
console.error('[ContextMenu]', err);
showError('重命名失败');
}
}},
{ id: 'pin', icon: Pin, label: '置顶', action: () => {
if (sid) { const s = useSessionStore.getState().sessions.find((x) => x.id === sid); if (s) { window.metona?.sessions.pin(sid, !s.pinned); useSessionStore.getState().pinSession(sid, !s.pinned); } }
{ id: 'pin', icon: Pin, label: '置顶', action: async () => {
if (!sid) return;
const s = useSessionStore.getState().sessions.find((x) => x.id === sid);
if (!s) return;
const newPinned = !s.pinned;
try {
const r = await window.metona?.sessions?.pin(sid, newPinned);
if (r?.success) {
useSessionStore.getState().pinSession(sid, newPinned);
} else {
showError(r?.error ?? '置顶失败');
}
} catch (err) {
console.error('[ContextMenu]', err);
showError('置顶失败');
}
}},
// 修复: archive 之前完全未调用 IPC,UI 改了但 DB 没改,重启后状态丢失
{ id: 'archive', icon: Archive, label: '归档', action: async () => {
if (!sid) return;
const s = useSessionStore.getState().sessions.find((x) => x.id === sid);
const newArchived = s ? !s.archived : true;
try {
const r = await window.metona?.sessions?.archive(sid, newArchived);
if (r?.success) {
useSessionStore.getState().archiveSession(sid, newArchived);
} else {
showError(r?.error ?? '归档失败');
}
} catch (err) {
console.error('[ContextMenu]', err);
showError('归档失败');
}
}},
{ id: 'archive', icon: Archive, label: '归档', action: () => { if (sid) useSessionStore.getState().archiveSession(sid, true); }},
{ id: 'export', icon: FileDown, label: '导出', action: () => {
if (sid) window.metona?.sessions.getMessages(sid).then((msgs) => {
const b = new Blob([JSON.stringify(msgs, null, 2)], { type: 'application/json' });
const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `session-${sid}.json`; a.click();
}).catch((err) => console.error('[ContextMenu]', err));
}).catch((err) => {
console.error('[ContextMenu]', err);
showError('导出失败');
});
}},
{ id: 'delete', icon: Trash2, label: '删除', action: () => {
{ id: 'delete', icon: Trash2, label: '删除', action: async () => {
if (!sid) return;
// window.confirm may be unreliable in Electron
if (sid) { try { if (confirm('确定删除此会话?')) { window.metona?.sessions.delete(sid); useSessionStore.getState().removeSession(sid); } } catch { /* confirm unavailable */ } }
try { if (!confirm('确定删除此会话?')) return; } catch { /* confirm unavailable */ return; }
try {
const r = await window.metona?.sessions?.delete(sid);
if (r?.success) {
useSessionStore.getState().removeSession(sid);
} else {
showError(r?.error ?? '删除失败');
}
} catch (err) {
console.error('[ContextMenu]', err);
showError('删除失败');
}
}},
];
}
+5 -3
View File
@@ -46,6 +46,7 @@ export function ChatInput(): React.JSX.Element {
const sendMessage = useAgentStore((s) => s.sendMessage);
const abort = useAgentStore((s) => s.abort);
const isStreaming = useAgentStore((s) => s.isStreaming);
const configLoaded = useAgentStore((s) => s.configLoaded);
const tokenUsage = useAgentStore((s) => s.tokenUsage);
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const provider = useAgentStore((s) => s.provider);
@@ -171,6 +172,7 @@ export function ChatInput(): React.JSX.Element {
const trimmed = input.trim();
if (!trimmed && attachments.length === 0) return;
if (isStreaming) return;
if (!configLoaded) return; // P1-6: 配置未加载完成时禁止发送
// 处理 / 命令
if (trimmed.startsWith('/')) {
@@ -326,8 +328,8 @@ export function ChatInput(): React.JSX.Element {
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)"
disabled={isStreaming}
placeholder={configLoaded ? '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)' : '正在加载配置...'}
disabled={isStreaming || !configLoaded}
multiline
rows={1}
sx={{
@@ -363,7 +365,7 @@ export function ChatInput(): React.JSX.Element {
<Square size={12} style={{ marginRight: 6 }} />
</Button>
) : (
<Button variant="contained" size="small" onClick={handleSend} disabled={!input.trim() && attachments.length === 0} sx={{ height: 28, fontSize: 12, opacity: (input.trim() || attachments.length > 0) ? 1 : 0.5 }}>
<Button variant="contained" size="small" onClick={handleSend} disabled={(!input.trim() && attachments.length === 0) || !configLoaded} sx={{ height: 28, fontSize: 12, opacity: (input.trim() || attachments.length > 0) && configLoaded ? 1 : 0.5 }}>
<Send size={12} style={{ marginRight: 6 }} />
</Button>
)}
+6 -1
View File
@@ -176,7 +176,12 @@ function ToolManagerPanel() {
if (window.metona?.tools?.list) {
window.metona.tools.list().then((list) => {
if (!cancelled) setTools(list as MetonaToolInfo[]);
}).catch((err) => { console.error('[Sidebar]', err); });
}).catch((err) => {
console.error('[Sidebar]', err);
if (!cancelled) {
import('metona-toast').then((mod) => mod.default.error('加载工具列表失败')).catch(() => {});
}
});
}
return () => { cancelled = true; };
}, []);
+11
View File
@@ -12,6 +12,7 @@ import {
} from '@mui/material';
import { Brain, Search, Trash2, ChevronDown } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { useUIStore } from '@renderer/stores/ui-store';
import { formatTime, truncate } from '@renderer/lib/formatters';
// ===== 类型 =====
@@ -83,6 +84,8 @@ export function MemoryViewer(): React.JSX.Element {
// Agent 完成时自动刷新:监听 agentStatus 从 thinking/executing -> idle
const agentStatus = useAgentStore((s) => s.agentStatus);
const prevStatus = useRef(agentStatus);
// v0.3.6: 监听 memoryVersion 变化(SettingsModal 清理记忆后自增),触发重新加载
const memoryVersion = useUIStore((s) => s.memoryVersion);
// M-53 修复: 竞态保护 ref,防止多次 loadMemories 调用乱序完成导致旧数据覆盖
const loadReqIdRef = useRef(0);
// 审计补充修复: handleSearch 使用独立 ref,避免与 loadMemories 共用导致 searching 状态卡死
@@ -130,6 +133,14 @@ export function MemoryViewer(): React.JSX.Element {
}
}, [agentStatus, loadMemories]);
// v0.3.6: SettingsModal 清理记忆后 memoryVersion 自增,触发重新加载
// 之前清理后需重启应用 MemoryViewer 才会刷新(与 clearSessions 同样的 bug
useEffect(() => {
if (memoryVersion > 0) {
loadMemories();
}
}, [memoryVersion, loadMemories]);
const handleSearch = async () => {
const q = searchQuery.trim();
if (!q || !window.metona?.memory?.search) return;
+11 -1
View File
@@ -44,6 +44,8 @@ export function OnboardingWizard(): React.JSX.Element | null {
return;
}
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
// P1-6 修复: Onboarding 完成后标记配置已加载
useAgentStore.getState().setConfigLoaded(true);
}
setOnboardingCompleted(true);
} catch (err) {
@@ -107,7 +109,15 @@ export function OnboardingWizard(): React.JSX.Element | null {
<Stack direction="row" spacing={1} sx={{ mb: 2, alignItems: 'center' }}>
<TextField size="small" value={workspacePath} onChange={(e) => setWorkspacePath(e.target.value)} placeholder="~/MetonaWorkspaces/default/" sx={{ flex: 1 }} />
<Button variant="outlined" size="small" onClick={async () => {
if (window.metona?.app?.selectFolder) { const r = await window.metona.app.selectFolder(workspacePath || undefined); if (!r.canceled && r.path) setWorkspacePath(r.path); }
if (window.metona?.app?.selectFolder) {
try {
const r = await window.metona.app.selectFolder(workspacePath || undefined);
if (!r.canceled && r.path) setWorkspacePath(r.path);
} catch (err) {
console.error('[OnboardingWizard]', err);
import('metona-toast').then((mod) => mod.default.error('选择文件夹失败')).catch(() => {});
}
}
}}></Button>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}> SOUL.mdAGENTS.mdMEMORY.mdUSERS.md </Typography>
+214 -15
View File
@@ -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>
+7 -2
View File
@@ -65,8 +65,13 @@ export function WorkspaceViewer(): React.JSX.Element {
}
}, [agentStatus, loadInfo]);
const handleOpenInFolder = (path: string) => {
window.metona?.app?.showItemInFolder(path);
const handleOpenInFolder = async (path: string) => {
try {
await window.metona?.app?.showItemInFolder(path);
} catch (err) {
console.error('[WorkspaceViewer]', err);
import('metona-toast').then((mod) => mod.default.error('打开文件夹失败')).catch(() => {});
}
};
if (error) {
+27 -10
View File
@@ -428,20 +428,37 @@ export function useAgentStream(): void {
store.updateLastTraceStep({
state: data.state,
states: [...currentStates, data.state],
// MT-2 修复: TERMINATED 状态到达时,标记步骤为已完成
// 防止 abort 后最后一个步骤永远显示转圈
...(data.state === 'TERMINATED' ? { completedAt: Date.now() } : {}),
});
}
} else {
// 新迭代 → 标记上一步完成,创建新步骤
if (lastStep && !lastStep.completedAt) {
store.updateLastTraceStep({ completedAt: Date.now() });
// MT-2 修复: 当 state === 'TERMINATED' 且 iteration 不匹配时,
// 说明 abort 发生在循环条件检查时,currentIteration 已自增但没有对应 step。
// 此时不应创建孤立的只含 TERMINATED 的步骤,而是更新最后一个步骤的 states 数组
if (data.state === 'TERMINATED' && lastStep) {
const currentStates = lastStep.states ?? [lastStep.state];
if (currentStates[currentStates.length - 1] !== 'TERMINATED') {
store.updateLastTraceStep({
state: 'TERMINATED',
states: [...currentStates, 'TERMINATED'],
completedAt: Date.now(),
});
}
} else {
// 新迭代 → 标记上一步完成,创建新步骤
if (lastStep && !lastStep.completedAt) {
store.updateLastTraceStep({ completedAt: Date.now() });
}
store.addTraceStep({
id: `trace_${data.iteration}_${data.state}_${Date.now()}`,
iteration: data.iteration,
state: data.state,
states: [data.state],
startedAt: Date.now(),
});
}
store.addTraceStep({
id: `trace_${data.iteration}_${data.state}_${Date.now()}`,
iteration: data.iteration,
state: data.state,
states: [data.state],
startedAt: Date.now(),
});
}
}
+9
View File
@@ -99,6 +99,9 @@ interface AgentState {
// 流式
isStreaming: boolean;
// P1-6 修复: 配置加载完成标志 — 加载完成前禁用发送按钮
configLoaded: boolean;
// Run 标识(用于过滤旧流事件)
currentRunId: string | null;
@@ -140,6 +143,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
traceSteps: [],
isStreaming: false,
configLoaded: false,
currentRunId: null,
provider: '',
model: '',
@@ -342,6 +346,8 @@ export const useAgentStore = create<AgentState>((set, get) => ({
setStreaming: (streaming) => set({ isStreaming: streaming }),
setConfigLoaded: (loaded) => set({ configLoaded: loaded }),
setCurrentRunId: (runId) => set({ currentRunId: runId }),
addTraceStep: (step) =>
@@ -382,7 +388,10 @@ export const useAgentStore = create<AgentState>((set, get) => ({
if (window.metona?.config?.get) {
// Ollama 用 ollama.numCtxDeepSeek 用 deepseek.contextWindowAgnes 用 agnes.contextWindow
const configKey = provider === 'ollama' ? 'ollama.numCtx' : `${provider}.contextWindow`;
// P2-12 修复: 竞态保护 — 快速切换 provider 时,旧 Promise resolve 不覆盖新值
const expectedProvider = provider;
window.metona.config.get(configKey).then((v) => {
if (get().provider !== expectedProvider) return; // provider 已切换,丢弃旧结果
if (v != null && typeof v === 'number' && v > 0) {
set({ contextWindow: v });
}
+10
View File
@@ -37,6 +37,10 @@ interface UIState {
preFocusSidebarVisible: boolean;
preFocusDetailVisible: boolean;
// v0.3.6: 记忆数据版本号 — 清理记忆后自增,MemoryViewer 监听变化重新加载
// 避免 SettingsModal 清理记忆后 MemoryViewer 仍显示旧数据(需重启应用才刷新)
memoryVersion: number;
// Actions
setTheme: (theme: ThemeMode) => void;
setResolvedTheme: (theme: 'dark' | 'light') => void;
@@ -49,6 +53,8 @@ interface UIState {
toggleSidebar: () => void;
toggleDetail: () => void;
toggleFocusMode: () => void;
// v0.3.6: 记忆清理后触发前端刷新
bumpMemoryVersion: () => void;
}
export const useUIStore = create<UIState>((set) => ({
@@ -64,6 +70,8 @@ export const useUIStore = create<UIState>((set) => ({
detailTab: 'trace',
preFocusSidebarVisible: true,
preFocusDetailVisible: true,
// v0.3.6: 记忆数据版本号初始为 0
memoryVersion: 0,
setTheme: (theme) => set({ theme }),
setResolvedTheme: (resolvedTheme) => set({ resolvedTheme }),
@@ -101,4 +109,6 @@ export const useUIStore = create<UIState>((set) => ({
detailVisible: s.preFocusDetailVisible,
};
}),
// v0.3.6: 清理记忆后自增版本号,触发 MemoryViewer 重新加载
bumpMemoryVersion: () => set((s) => ({ memoryVersion: s.memoryVersion + 1 })),
}));
+2 -2
View File
@@ -169,8 +169,8 @@ interface MetonaConfigAPI {
interface MetonaAppAPI {
getVersion: () => Promise<string>;
getAppDataPath: () => Promise<string>;
openExternal: (url: string) => Promise<void>;
showItemInFolder: (path: string) => Promise<void>;
openExternal: (url: string) => Promise<{ success: boolean; error?: string }>;
showItemInFolder: (path: string) => Promise<{ success: boolean; error?: string }>;
selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>;
restart: () => Promise<{ success: boolean }>;
}