/** * 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 ( {items.map((item) => { const Icon = item.icon; return ( { if (!item.disabled) { item.action(); onClose(); } }} disabled={item.disabled} dense> {item.label} ); })} ); } /** * 创建右键菜单项的工厂函数 */ 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('[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; 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 未调用 IPC,rename/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 }> } | 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 []; } }