核心引擎修复: - 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 判断
188 lines
8.8 KiB
TypeScript
188 lines
8.8 KiB
TypeScript
/**
|
||
* 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 未调用 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<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 [];
|
||
}
|
||
}
|