Files
metona-ai-desktop/src/components/ContextMenu.tsx
T
thzxx ceb8ee644d 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 判断
2026-07-16 22:40:32 +08:00

188 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ContextMenu — 右键菜单组件
*
* 支持 5 种对象的右键菜单:消息、工具调用卡片、会话项、代码块、Trace 步骤。
*
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 右键菜单设计
*/
import { Menu, MenuItem, ListItemIcon, ListItemText } from '@mui/material';
import { Copy, Quote, Edit, Trash2, RotateCcw, Eye, Code, ExternalLink, Pin, Archive, FileDown } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store';
export type ContextMenuType = 'message' | 'tool-call' | 'session' | 'code-block' | 'trace-step';
interface ContextMenuItem {
id: string;
icon: typeof Copy;
label: string;
action: () => void;
disabled?: boolean;
}
interface ContextMenuProps {
x: number;
y: number;
onClose: () => void;
items: ContextMenuItem[];
}
export function ContextMenu({ x, y, onClose, items }: ContextMenuProps): React.JSX.Element {
return (
<Menu open onClose={onClose} anchorReference="anchorPosition" anchorPosition={{ top: y, left: x }} slotProps={{ paper: { sx: { minWidth: 160 } } }}>
{items.map((item) => {
const Icon = item.icon;
return (
<MenuItem key={item.id} onClick={() => { if (!item.disabled) { item.action(); onClose(); } }} disabled={item.disabled} dense>
<ListItemIcon sx={{ minWidth: 28 }}><Icon size={12} /></ListItemIcon>
<ListItemText slotProps={{ primary: { sx: { fontSize: 12 } } }}>{item.label}</ListItemText>
</MenuItem>
);
})}
</Menu>
);
}
/**
* 创建右键菜单项的工厂函数
*/
export function createContextMenuItems(type: ContextMenuType, data?: unknown): ContextMenuItem[] {
switch (type) {
case 'message': {
const content = (data as { content?: string })?.content ?? '';
return [
{ id: 'copy', icon: Copy, label: '复制', action: () => navigator.clipboard.writeText(content).catch((err) => console.error('[Clipboard]', err)) },
{ id: 'quote', icon: Quote, label: '引用回复', action: () => {
const input = document.querySelector<HTMLTextAreaElement>('[data-chat-input]');
if (input) { input.value = content.split('\n').map((l: string) => `> ${l}`).join('\n') + '\n\n'; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); }
}},
];
}
case 'tool-call': {
const tc = data as { args?: Record<string, unknown>; result?: unknown } | undefined;
return [
{ id: 'view-params', icon: Eye, label: '查看参数', action: () => { if (tc?.args) navigator.clipboard.writeText(JSON.stringify(tc.args, null, 2)).catch((err) => console.error('[Clipboard]', err)); }},
{ id: 'view-result', icon: Eye, label: '查看完整结果', action: () => { if (tc?.result) navigator.clipboard.writeText(JSON.stringify(tc.result, null, 2)).catch((err) => console.error('[Clipboard]', err)); }},
{ id: 'copy-result', icon: Copy, label: '复制结果', action: () => { if (tc?.result) navigator.clipboard.writeText(typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result)).catch((err) => console.error('[Clipboard]', err)); }},
{ id: 're-execute', icon: RotateCcw, label: '重新执行', action: () => {
const m = [...useAgentStore.getState().messages].reverse().find((m) => m.role === 'user');
if (m) useAgentStore.getState().sendMessage(m.content);
}},
];
}
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: async () => {
if (!sid) return;
// window.prompt may be unreliable in Electron; fall back to sidebar double-click rename
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: 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: '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);
showError('导出失败');
});
}},
{ id: 'delete', icon: Trash2, label: '删除', action: async () => {
if (!sid) return;
// window.confirm may be unreliable in Electron
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('删除失败');
}
}},
];
}
case 'code-block': {
const code = (data as { code?: string })?.code;
return [
{ id: 'copy-code', icon: Copy, label: '复制代码', action: () => { if (code) navigator.clipboard.writeText(code).catch((err) => console.error('[Clipboard]', err)); }},
{ id: 'open-editor', icon: Code, label: '在编辑器中打开', action: () => { if (code) window.open(URL.createObjectURL(new Blob([code], { type: 'text/plain' })), '_blank'); }},
];
}
case 'trace-step': {
const step = data as { thought?: string; toolCalls?: Array<{ name: string; args: Record<string, unknown> }> } | undefined;
return [
{ id: 'copy-thought', icon: Copy, label: '复制 Thought', action: () => { if (step?.thought) navigator.clipboard.writeText(step.thought).catch((err) => console.error('[Clipboard]', err)); }},
{ id: 'copy-params', icon: Copy, label: '复制工具参数', action: () => {
if (step?.toolCalls) navigator.clipboard.writeText(step.toolCalls.map((tc) => `${tc.name}: ${JSON.stringify(tc.args, null, 2)}`).join('\n')).catch((err) => console.error('[Clipboard]', err));
}},
{ id: 'export', icon: ExternalLink, label: '导出步骤详情', action: () => {
if (step) { const b = new Blob([JSON.stringify(step, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `trace-step-${Date.now()}.json`; a.click(); }
}},
];
}
default: return [];
}
}