fix: 修复大数据导致应用崩溃 + 图片上传冗余工具调用 + 预存类型错误

## 崩溃修复 (renderer OOM)
view_image 返回的 base64 dataUrl(~6.7MB/张)经 dataUrl 白名单跳过截断后,
在 IPC/store/DOM 链路被反复放大,累积撑爆渲染进程堆导致 OOM 崩溃退出。
- 新增 src/lib/tool-result-display.ts: toDisplayResult() 在显示边界剥离 dataUrl
- ToolResultBlock/TraceStep 渲染前套用,store 原始 result 不变(LLM 看图链路完整)
- data:export 剥离 dataUrl/preview + 每会话 2000 条消息上限 + truncated 标记

## 图片上传修复
用户上传图片并告知 AI 后,AI 仍调 view_image 去磁盘读取。
根因: 附件提示 "do NOT search in workspace" 与 view_image "Read an image file"
语义冲突,AI 认为"读取"≠"搜索"。强化提示明确禁止调用 view_image/read_file 读取已上传图片。

## 预存类型修复
- ConfirmationDialog: MUI v9 移除 disableEscapeKeyDown 顶层 prop,迁移到 onClose reason 拦截
- ContextMenu/Sidebar: TokenUsage 对象字面量补齐 lastInputTokens/lastCompressedSaved

## 依赖升级
- @metona-team/metona-toast 0.1.2 → 0.2.1 (TypeScript 重构,修复 0.1.2 版本号打包 bug)

## 其他
- 删除仓库根目录误生成的 nul 垃圾文件
- bump version to 0.3.22
This commit is contained in:
2026-07-25 19:35:33 +08:00
parent 4e52b24817
commit b9f7ec5118
10 changed files with 171 additions and 32 deletions
+63 -8
View File
@@ -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 等超大 base64sanitizeExportMessage
* 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 含 dataUrlview_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 };