Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9f7ec5118 | ||
|
|
4e52b24817 |
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-0.3.20-blue?style=flat-square" alt="Version" />
|
||||
<img src="https://img.shields.io/badge/version-0.3.22-blue?style=flat-square" alt="Version" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" />
|
||||
<img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" />
|
||||
<img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" />
|
||||
|
||||
@@ -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<string, unknown>): Record<string, unknown> => {
|
||||
const toolResult = msg.toolResult as Record<string, unknown> | 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>));
|
||||
return { success: true, data: sanitized };
|
||||
}
|
||||
// 导出所有会话
|
||||
// 导出所有会话:剥离 dataUrl + 每会话限条数,防渲染进程 Blob 序列化 OOM
|
||||
const sessions = sessionService.list();
|
||||
const allData: Record<string, unknown> = { 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<string, unknown>));
|
||||
(allData.sessions as Array<Record<string, unknown>>).push({
|
||||
...session,
|
||||
messages: sessionService.getMessages(session.id),
|
||||
messages: sanitizedMessages,
|
||||
truncated: rawMessages.length >= MAX_EXPORT_MESSAGES_PER_SESSION,
|
||||
});
|
||||
}
|
||||
return { success: true, data: allData };
|
||||
|
||||
Generated
+12
-12
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "metona-ai-desktop",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.22",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "metona-ai-desktop",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.22",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@metona-team/metona-toast": "^2.0.1",
|
||||
"@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": "2.0.1",
|
||||
"resolved": "https://git.metona.cn/api/packages/MetonaTeam/npm/%40metona-team%2Fmetona-toast/-/2.0.1/metona-toast-2.0.1.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": {
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "metona-ai-desktop",
|
||||
"version": "0.3.20",
|
||||
"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": "^2.0.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
|
||||
@@ -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 {
|
||||
</Dialog>
|
||||
|
||||
{/* #40 修复: autoExecute 二次确认 Dialog — 避免误点击导致永久自动执行 */}
|
||||
{/* 审查修复: disableEscapeKeyDown + onKeyDown stopPropagation 防止 ESC 事件穿透到外层 Dialog */}
|
||||
{/* 审查修复: MUI v9 移除了 disableEscapeKeyDown 顶层 prop,改为在 onClose 中按 reason 拦截 escapeKeyDown;
|
||||
onKeyDown stopPropagation 阻止 ESC 事件穿透到外层 Dialog */}
|
||||
<Dialog
|
||||
open={confirmAutoExecute}
|
||||
onClose={() => setConfirmAutoExecute(false)}
|
||||
onClose={(_, reason) => {
|
||||
// 禁用 ESC 关闭(仅允许点击遮罩/按钮关闭),与原 disableEscapeKeyDown 行为一致
|
||||
if (reason === 'escapeKeyDown') return;
|
||||
setConfirmAutoExecute(false);
|
||||
}}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
disableEscapeKeyDown
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogTitle sx={{ fontSize: 14 }}>确认永久自动执行?</DialogTitle>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<Box sx={{ borderRadius: 1.5, border: '1px solid', borderColor: 'divider', borderLeft: '3px solid', borderLeftColor: 'info.main', bgcolor: 'secondary.main', px: 1.5, py: 1, mb: 1, animation: 'fadeInUp 300ms ease-out' }}>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = stripLargeStrings(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user