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
+7 -4
View File
@@ -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>
+1 -1
View File
@@ -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,
});
+5 -1
View File
@@ -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' }}>
+1 -1
View File
@@ -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,
});
+4 -2
View File
@@ -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 (
+75
View File
@@ -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 先过 toDisplayResultstore 内原始 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;
}