diff --git a/README.md b/README.md index 9307e96..40a405b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version + Version License Electron React diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 047c545..28dda27 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -173,20 +173,22 @@ export function registerAllIPCHandlers( } // 附件提示注入:用户直接上传的文件/图片,避免 LLM 误以为需要在工作空间查找 - // 解决场景:用户上传图片后,AI 先在工作空间找图片,找不到才意识到是用户上传的 + // 解决场景:用户上传图片后,AI 仍调 view_image 去磁盘读取——根因是提示措辞 + // "do NOT search in workspace" 与 view_image "Read an image file" 语义冲突, + // AI 认为"读取"不等于"搜索"。强化为明确禁止调用 view_image 读取已上传的图片。 const attachments = (userMessage as MetonaMessage & { attachments?: Array<{ name: string; type: string }> }).attachments; if (Array.isArray(attachments) && attachments.length > 0) { const attachmentList = attachments.map((att, i) => { const typeLabel = att.type === 'image' ? 'image' : att.type === 'text' ? 'text file' : 'file'; const note = att.type === 'image' - ? 'visible via vision capability, do NOT search in workspace' + ? 'already provided to you via vision capability — you can SEE it directly, do NOT call view_image or any tool to read it again' : att.type === 'text' - ? 'content already inlined in the user message, do NOT search in workspace' + ? 'content already inlined in the user message, do NOT search in workspace or read it again' : 'uploaded directly by user, do NOT search in workspace'; return `${i + 1}. [${typeLabel}] ${att.name} — ${note}`; }).join('\n'); - const attachmentBlock = `## User Attachments (Direct Upload)\nThe following files were uploaded directly by the user to this conversation. They are inline attachments, NOT workspace files:\n${attachmentList}`; + const attachmentBlock = `## User Attachments (Direct Upload)\nThe following files were uploaded directly by the user to this conversation. They are inline attachments, NOT workspace files:\n${attachmentList}\n\n**IMPORTANT**: Images listed above are already visible to you in this conversation. Do NOT call \`view_image\`, \`read_file\`, or any file tool to read them — doing so wastes a tool call and may fail (they are not workspace files).`; systemPrompt.dynamicReminders = systemPrompt.dynamicReminders ? `${systemPrompt.dynamicReminders}\n\n---\n\n${attachmentBlock}` @@ -1403,20 +1405,73 @@ export function registerAllIPCHandlers( // ===== 数据管理 ===== + /** + * 导出限流常量:单会话导出的最大消息条数。 + * + * 背景:导出会把所有会话×所有消息×完整 toolResult 一次性 JSON.stringify 成单个 Blob + * 传到渲染进程(SettingsModal handleExport → new Blob([JSON.stringify(...)]))。 + * 含 view_image 的会话单条 tool_result 就有 ~6.7MB base64 dataUrl,全量导出会直接 + * 撑爆渲染进程堆导致 OOM 崩溃。 + * + * 双重防护: + * 1. 剥离每条 tool 消息中的 dataUrl 等超大 base64(sanitizeExportMessage) + * 2. 全量导出时按会话限制消息条数(MAX_EXPORT_MESSAGES_PER_SESSION),避免超长会话 + * 的纯文本累积也拖垮序列化 + */ + const MAX_EXPORT_MESSAGES_PER_SESSION = 2000; + + /** + * 剥离导出消息中的超大 base64 字段(如 view_image 的 dataUrl)。 + * 仅用于导出快照——原数据库数据不受影响。 + */ + const sanitizeExportMessage = (msg: Record): Record => { + const toolResult = msg.toolResult as Record | undefined; + if (toolResult && typeof toolResult === 'object' && 'result' in toolResult) { + const result = toolResult.result; + // result 含 dataUrl(view_image 等图片结果):剥离 dataUrl,保留 path/size/mimeType 等小字段 + if (result && typeof result === 'object' && !Array.isArray(result) && 'dataUrl' in result) { + const { dataUrl: _omit, ...rest } = result as Record; + void _omit; + return { + ...msg, + toolResult: { ...toolResult, result: { ...rest, _displayNote: '[image base64 omitted in export]' } }, + }; + } + } + // attachments[].preview(用户上传图片的 base64 缩略图)也一并剥离,避免导出文件膨胀 + const attachments = msg.attachments; + if (Array.isArray(attachments)) { + const sanitized = attachments.map((att) => { + if (att && typeof att === 'object' && 'preview' in att) { + const { preview: _omit, ...rest } = att as Record; + void _omit; + return { ...rest, _displayNote: '[preview omitted in export]' }; + } + return att; + }); + return { ...msg, attachments: sanitized }; + } + return msg; + }; + ipcMain.handle('data:export', async (_event, sessionId?: string) => { try { if (sessionId) { - // 导出单个会话 + // 导出单个会话:剥离超大 base64 后返回 const messages = sessionService.getMessages(sessionId); - return { success: true, data: messages }; + const sanitized = messages.map((m) => sanitizeExportMessage(m as unknown as Record)); + return { success: true, data: sanitized }; } - // 导出所有会话 + // 导出所有会话:剥离 dataUrl + 每会话限条数,防渲染进程 Blob 序列化 OOM const sessions = sessionService.list(); const allData: Record = { sessions: [], config: configService.getAll() }; for (const session of sessions) { + const rawMessages = sessionService.getMessages(session.id, { limit: MAX_EXPORT_MESSAGES_PER_SESSION }); + const sanitizedMessages = rawMessages.map((m) => sanitizeExportMessage(m as unknown as Record)); (allData.sessions as Array>).push({ ...session, - messages: sessionService.getMessages(session.id), + messages: sanitizedMessages, + truncated: rawMessages.length >= MAX_EXPORT_MESSAGES_PER_SESSION, }); } return { success: true, data: allData }; diff --git a/package-lock.json b/package-lock.json index 25c4e29..7aa7ae5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "metona-ai-desktop", - "version": "0.3.21", + "version": "0.3.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.3.21", + "version": "0.3.22", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@metona-team/metona-toast": "^0.1.2", + "@metona-team/metona-toast": "^0.2.1", "@modelcontextprotocol/sdk": "^1.12.1", "@mui/icons-material": "^9.1.1", "@mui/material": "^9.1.2", @@ -703,9 +703,9 @@ } }, "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "11.3.6", - "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.6.tgz", - "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "version": "11.4.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", "dev": true, "license": "MIT", "dependencies": { @@ -779,9 +779,9 @@ } }, "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.3.6", - "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.6.tgz", - "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "version": "11.4.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", "dev": true, "license": "MIT", "optional": true, @@ -1871,9 +1871,9 @@ } }, "node_modules/@metona-team/metona-toast": { - "version": "0.1.2", - "resolved": "https://git.metona.cn/api/packages/MetonaTeam/npm/%40metona-team%2Fmetona-toast/-/0.1.2/metona-toast-0.1.2.tgz", - "integrity": "sha512-r8q9CYhtYbsz2hH3ffpqNeo7hV0f5qXWiL65boisc0VXFNht0ZDNn/ZbWneYM2/bOqC7Vh7dJlHe0GSVbPJ3Lg==", + "version": "0.2.1", + "resolved": "https://git.metona.cn/api/packages/MetonaTeam/npm/%40metona-team%2Fmetona-toast/-/0.2.1/metona-toast-0.2.1.tgz", + "integrity": "sha512-JPK+83T6pULRbMxZ67Baxsdnggl0aGCteY8A6mLg1ePK6dLfl2WBtxH0x4Q9CL/+Llbh5ozCVKPKcUurTRjAbw==", "license": "MIT" }, "node_modules/@modelcontextprotocol/sdk": { diff --git a/package.json b/package.json index 8099f7a..86ebd94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.3.21", + "version": "0.3.22", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "main": "dist-electron/main/main.js", "author": "Metona Team", @@ -25,6 +25,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@metona-team/metona-toast": "^0.2.1", "@modelcontextprotocol/sdk": "^1.12.1", "@mui/icons-material": "^9.1.1", "@mui/material": "^9.1.2", @@ -35,7 +36,6 @@ "electron-store": "^10.0.1", "fuse.js": "^7.1.0", "lru-cache": "^11.1.0", - "@metona-team/metona-toast": "^0.1.2", "nanoid": "^5.1.5", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/src/components/ConfirmationDialog.tsx b/src/components/ConfirmationDialog.tsx index 52ed815..05ff17b 100644 --- a/src/components/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog.tsx @@ -311,7 +311,6 @@ export function ConfirmationDialog(): React.JSX.Element | null { }} maxWidth="md" fullWidth - disableEscapeKeyDown={confirmAutoExecute} slotProps={{ paper: { sx: { bgcolor: 'background.paper' }, @@ -588,13 +587,17 @@ export function ConfirmationDialog(): React.JSX.Element | null { {/* #40 修复: autoExecute 二次确认 Dialog — 避免误点击导致永久自动执行 */} - {/* 审查修复: disableEscapeKeyDown + onKeyDown stopPropagation 防止 ESC 事件穿透到外层 Dialog */} + {/* 审查修复: MUI v9 移除了 disableEscapeKeyDown 顶层 prop,改为在 onClose 中按 reason 拦截 escapeKeyDown; + onKeyDown stopPropagation 阻止 ESC 事件穿透到外层 Dialog */}

setConfirmAutoExecute(false)} + onClose={(_, reason) => { + // 禁用 ESC 关闭(仅允许点击遮罩/按钮关闭),与原 disableEscapeKeyDown 行为一致 + if (reason === 'escapeKeyDown') return; + setConfirmAutoExecute(false); + }} maxWidth="xs" fullWidth - disableEscapeKeyDown onKeyDown={(e) => e.stopPropagation()} > 确认永久自动执行? diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 1d162d7..8db399b 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -284,7 +284,7 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C currentSessionId: null, messages: [], traceSteps: [], - tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 }, currentRunId: null, currentIteration: 0, }); diff --git a/src/components/chat/ToolResultBlock.tsx b/src/components/chat/ToolResultBlock.tsx index d7bf321..f647a81 100644 --- a/src/components/chat/ToolResultBlock.tsx +++ b/src/components/chat/ToolResultBlock.tsx @@ -6,12 +6,16 @@ import { Box, Typography, Stack } from '@mui/material'; import { FileText } from 'lucide-react'; import type { ToolCallInfo } from '@renderer/stores/agent-store'; import { formatDuration } from '@renderer/lib/formatters'; +import { toDisplayResult } from '@renderer/lib/tool-result-display'; interface ToolResultBlockProps { toolCall: ToolCallInfo; } export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.Element { if (toolCall.status !== 'success' || toolCall.result == null) return <>; - const resultStr = typeof toolCall.result === 'string' ? toolCall.result : JSON.stringify(toolCall.result, null, 2); + // 渲染前剥离 dataUrl 等超大 base64 字段,避免 ~6.7MB/张的 dataUrl 进 DOM 导致渲染进程 OOM + // store 内原始 result 不变(LLM 看图能力不受影响) + const displayResult = toDisplayResult(toolCall.result); + const resultStr = typeof displayResult === 'string' ? displayResult : JSON.stringify(displayResult, null, 2); return ( diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index bddbbba..89f8e6b 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -135,7 +135,7 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S currentSessionId: null, messages: [], traceSteps: [], - tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 }, currentRunId: null, currentIteration: 0, }); diff --git a/src/components/trace/TraceStep.tsx b/src/components/trace/TraceStep.tsx index 70dea12..8d06eb1 100644 --- a/src/components/trace/TraceStep.tsx +++ b/src/components/trace/TraceStep.tsx @@ -9,6 +9,7 @@ import { Box, Typography, IconButton, Collapse, Stack } from '@mui/material'; import { ChevronDown, ChevronRight, CircleDot, CheckCircle, Loader2, Clock, XCircle, Ban } from 'lucide-react'; import { TRACE_STATE_COLORS, TRACE_STATE_LABELS } from '@renderer/lib/constants'; import { formatDuration, formatTokens } from '@renderer/lib/formatters'; +import { toDisplayResult } from '@renderer/lib/tool-result-display'; import type { ToolCallInfo } from '@renderer/stores/agent-store'; import type { TraceStep as TraceStepType } from '@renderer/stores/agent-store'; @@ -118,10 +119,11 @@ function ToolCallRow({ tc }: { tc: ToolCallInfo }): React.JSX.Element { const statusColor = TOOL_STATUS_COLORS[tc.status] ?? '#8b8fa7'; const statusLabel = TOOL_STATUS_LABELS[tc.status] ?? tc.status; - // 结果摘要(不截断,限制高度 + 滚动) + // 结果摘要(限制高度 + 滚动)。渲染前剥离 dataUrl 等超大 base64,避免 ~6.7MB/张进 DOM 导致 OOM const hasResult = tc.result != null; + const displayResult = toDisplayResult(tc.result); const resultStr = hasResult - ? (typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result, null, 2)) + ? (typeof displayResult === 'string' ? displayResult : JSON.stringify(displayResult, null, 2)) : ''; return ( diff --git a/src/lib/tool-result-display.ts b/src/lib/tool-result-display.ts new file mode 100644 index 0000000..cb64239 --- /dev/null +++ b/src/lib/tool-result-display.ts @@ -0,0 +1,75 @@ +/** + * 工具结果显示层裁剪 — 防止 base64 大字段撑爆渲染进程内存 + * + * 背景:view_image 等工具返回的 dataUrl(最大 ~6.7MB/张 base64)会经 IPC 流式事件 + * 完整进入渲染进程,并写入 store 的 message / traceStep。若直接 JSON.stringify 渲染, + * 单条 tool_result 就会把 ~6.7MB 字符串塞进 DOM,多轮多图累积导致渲染进程 OOM 崩溃。 + * + * 策略:仅在最外层显示边界剥离 dataUrl 这类超大 base64 字段,返回轻量摘要。 + * - 主进程 / DB / LLM 链路保留完整 dataUrl(多模态 LLM 看图依赖它,不可裁剪) + * - 渲染进程只用于"展示"的 result 先过 toDisplayResult,store 内原始 result 不变 + * + * 副作用:纯函数,不修改入参(返回新对象或原值引用)。 + */ + +/** 视为"超大"的字符串阈值(字符数),超过即判定为需裁剪的大字段 */ +const LARGE_FIELD_THRESHOLD = 8_192; + +/** + * 判断一个值是否为"含大 base64 的对象"——即 view_image 等工具的返回结构。 + * 仅当对象包含 dataUrl(或同等量级的 base64)字段时才裁剪,普通结构原样返回。 + */ +function hasLargeBase64(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + && 'dataUrl' in value; +} + +/** + * 将工具结果转换为"适合显示"的轻量版本:剥离 dataUrl 等超大字段。 + * + * @param result 原始工具结果(来自 store 的 message.toolCalls[].result) + * @returns 用于 UI 展示的裁剪后结果(原值或新对象),永不修改入参 + */ +export function toDisplayResult(result: unknown): unknown { + // 非对象(string / number / null 等)无需裁剪 + if (typeof result !== 'object' || result === null) { + return result; + } + + // 含 dataUrl 的结果(view_image 等):剥离 dataUrl,保留 path/size/mimeType 等小字段 + if (hasLargeBase64(result)) { + const { dataUrl: _omit, ...rest } = result as Record; + void _omit; // 显式标记丢弃 + return { + ...rest, + // 标注被裁剪,便于用户/开发者理解为何看不到完整 dataUrl + _displayNote: '[image base64 omitted for display — visible to LLM only]', + }; + } + + // 其余对象/数组:递归剔除任意超长字符串字段(防御性,覆盖未来可能的 base64 字段) + return stripLargeStrings(result); +} + +/** + * 递归剔除对象/数组中超过阈值的超长字符串字段,避免任何大 base64 进入 DOM。 + * 对小字符串、数字、布尔等原样保留。 + */ +function stripLargeStrings(value: unknown): unknown { + if (typeof value === 'string') { + return value.length > LARGE_FIELD_THRESHOLD + ? `${value.slice(0, LARGE_FIELD_THRESHOLD)}… [truncated for display]` + : value; + } + if (Array.isArray(value)) { + return value.map(stripLargeStrings); + } + if (typeof value === 'object' && value !== null) { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = stripLargeStrings(v); + } + return out; + } + return value; +}