☰
AionUi ACP 图片输出展示机制全解析:从 base64 清洗到本地文件渲染的完整链路
2026/10/12 11:35:25 网站建设 项目流程

AionUi ACP 图片输出展示机制全解析:从 base64 清洗到本地文件渲染的完整链路

【免费下载链接】AionUiOpen-source 24/7 Cowork app for OpenClaw, Hermes, Claude Code, Codex, OpenCode and 20+ more CLI Agent | Customize your assistants | Team them up|Star if you like it!项目地址: https://gitcode.com/GitHub_Trending/ai/AionUi

导读

本文围绕 AionUi 对 ACP(Agent Client Protocol)工具调用中图片结果的展示方案展开,核心结论是:AionUi 采用"文件路径"而非 inline base64 来承载和渲染 ACP 图片输出。后端在数据入口处清洗掉 Codex 等代理图片生成工具产生的大型 base64,前端按需通过本地文件接口读取图片并交给LocalImageView渲染。读完本文,你将掌握 ACP 图片输出相关的数据约定、清洗边界、渲染流程、聚合视图(View Steps)中的图片展示,以及前端兜底清洗与测试覆盖,能够直接基于仓库源码理解并扩展这套机制。

为什么 ACP 图片输出需要"文件路径"而非 base64

在 ACP 会话中,工具调用(tool call)的raw_output.result常常携带代理生成图片的完整 base64 数据。以 Codex 的图片生成为例,一张普通生成图的 base64 轻松达到MB 级。如果让这类数据原样进入聊天消息列表、WebSocket 事件流和 SQLite 消息记录,会带来三个直接问题:

  1. 消息列表内存压力:每条消息携带 MB 级字符串,渲染与滚动性能明显劣化;
  2. WebSocket 事件膨胀:实时增量推送的tool_call_update事件体积被放大,网络与序列化开销上升;
  3. SQLite 记录膨胀:历史消息持久化占用空间成倍增长,且频繁读写大字段拖慢数据库操作。

AionUi 的解决方案是把"图片内容"与"图片引用"解耦:后端是主要清洗边界,将大型 base64 从result字段中剥除,保留小型本地文件路径字段;前端收到这类消息后,仅在需要展示时才通过本地文件接口按需读取图片二进制,再渲染到界面上。这样聊天数据链路中流动的始终是几十字节的路径字符串,而不是几 MB 的 base64。

数据约定:清洗后保留的字段

根据 docs/guides/acp-image-output.zh-CN.md,后端会把 Codex 图片生成工具的大型raw_output.resultbase64 清洗掉,并保留以下小型字段:

字段含义
rawOutput.image.path或raw_output.image.path生成图片的本地路径(首选字段)
rawOutput.saved_path或raw_output.saved_path兼容 Codex 当前返回结构的本地路径(回退字段)
rawOutput.result_omitted布尔值,表示原始图片 base64 已被省略
rawOutput.result_bytes被省略内容的原始字节长度

这些字段的类型定义可以在 acpTypes.ts 中找到对应源码:

export interface AcpImageOutput { path: string; mime_type?: string; source?: string; } export interface AcpRawOutput { saved_path?: string; image?: AcpImageOutput; result_omitted?: boolean; result_omitted_reason?: string; result_bytes?: number; status?: string; [key: string]: unknown; }

注意类型定义中同时保留了result_omitted_reason(省略原因,如'image_base64')和saved_path,这为前端判断"某个路径是否真的是被清洗的图片"提供了依据。

后端清洗边界:sanitize 的实现细节

清洗逻辑的核心实现在 acpToolCallOutput.ts,几个关键点如下。

1. 判定阈值与图片特征前缀

const INLINE_IMAGE_RESULT_LIMIT = 64 * 1024; const IMAGE_PATH_EXTENSION_RE = /\.(?:png|jpe?g|webp|gif)$/i; const isProbablyInlineImageResult = (value: string): boolean => value.length > INLINE_IMAGE_RESULT_LIMIT && (value.startsWith('iVBORw0KGgo') || // PNG value.startsWith('/9j/') || // JPEG value.startsWith('UklGR') || // WebP value.startsWith('data:image/'));
  • 阈值INLINE_IMAGE_RESULT_LIMIT为64 KB:超过该长度才可能被判定为"大型内联图片结果";
  • 通过 base64 文件头特征识别 PNG / JPEG / WebP,以及data:image/前缀的 Data URL,避免误伤超长的普通文本输出。

2. 清洗与重建 image 引用

const sanitizeAcpRawOutput = (rawOutput?: AcpRawOutput): AcpRawOutput | undefined => { if (!rawOutput) return rawOutput; const result = rawOutput.result; const savedPath = rawOutput.saved_path; if (typeof result !== 'string' || !isProbablyInlineImageResult(result)) { return rawOutput; } const { result: _result, ...rest } = rawOutput; const sanitized: AcpRawOutput = { ...rest, result_omitted: true, result_omitted_reason: rawOutput.result_omitted_reason || 'image_base64', result_bytes: rawOutput.result_bytes || result.length, }; if (rawOutput.image || (typeof savedPath === 'string' && savedPath)) { const path = rawOutput.image?.path || savedPath; sanitized.image = rawOutput.image || { path, mime_type: mimeTypeFromImagePath(path), source: 'codex_image_generation', }; } return sanitized; };

清洗逻辑要点:

  • 只有result是字符串且命中"疑似内联图片"特征时才触发清洗,非字符串 result(如对象)原样保留;
  • 清洗时删除result字段,写入result_omitted: true、result_omitted_reason: 'image_base64'、result_bytes(记录原始长度);
  • 若存在image.path或非空saved_path,则补全image引用对象;image已存在时(例如 Codex 本身返回了image字段)则保留原有元数据不被覆盖;
  • mimeTypeFromImagePath根据扩展名推导 MIME:.jpg/.jpeg→image/jpeg、.webp→image/webp、.gif→image/gif,其余默认image/png。

3. 对外暴露的清洗入口

export const sanitizeAcpToolUpdate = (update: ToolCallUpdate['update']): ToolCallUpdate['update'] => ({ ...update, rawOutput: sanitizeAcpRawOutput(update.rawOutput), raw_output: sanitizeAcpRawOutput(update.raw_output), }); export const sanitizeAcpToolCallContent = (content: ToolCallUpdate): ToolCallUpdate => ({ ...content, update: sanitizeAcpToolUpdate(content.update), });

sanitizeAcpToolUpdate同时处理rawOutput(camelCase)与raw_output(snake_case)两种线上结构,sanitizeAcpToolCallContent则面向整条工具调用消息内容。

图片路径提取:image.path 优先,saved_path 回退

前端渲染前统一通过getAcpImagePath解析展示路径:

export const getAcpImagePath = (update: ToolCallUpdate['update']): string | undefined => { const rawOutput = update.rawOutput || update.raw_output; const imagePath = rawOutput?.image?.path; if (typeof imagePath === 'string' && imagePath) return imagePath; const savedPath = rawOutput?.saved_path; if ( typeof savedPath === 'string' && savedPath && (rawOutput?.result_omitted_reason === 'image_base64' || isImagePath(savedPath)) ) { return savedPath; } return undefined; };

两条规则值得注意:

  • 优先读image.path,取不到再回退到saved_path;
  • saved_path不是无脑采用:只有当result_omitted_reason === 'image_base64',或路径本身命中图片扩展名(png/jpg/jpeg/webp/gif)时才视为图片路径。这样/tmp/result.txt这类非图片保存路径不会被误当作预览图——对应测试见 acpToolCallOutput.test.ts。

文件名生成由getAcpImageFileName负责:取路径最后一段作为文件名,空路径时回退为'generated-image.png'(见 acpToolCallOutput.test.ts)。

渲染流程:MessageAcpToolCall → LocalImageView → /api/fs/image-base64

消息卡片内的图片渲染

ACP 工具调用消息由 MessageAcpToolCall.tsx 渲染,其关键逻辑为:

const imagePath = getAcpImagePath(update); const imageAlt = imagePath?.split(/[/\\]/).pop() || t('acp.image.generated_alt'); ... {imagePath && ( <div className='group relative mt-3 overflow-hidden rounded border bg-1 p-2'> <LocalImageView src={imagePath} alt={imageAlt} className='max-w-full max-h-[520px] object-contain rounded' /> <Tooltip content={t('acp.image.download')}> <Button ... onClick={() => void handleDownloadImage(imagePath)} /> </Tooltip> </div> )}

流程为:先用getAcpImagePath(update)解析出本地路径 → 交给LocalImageView按需加载 → 渲染为最多 520px 高的预览图,并在右上角悬浮一个下载按钮,点击后调用downloadFileFromPath(path, getAcpImageFileName(path)),下载成功/失败分别通过acp.image.download_success/acp.image.download_error提示(见 MessageAcpToolCall.tsx)。

LocalImageView:本地文件如何变成可渲染图片

LocalImageView.tsx 是本地图片按需读取的核心组件:

const root = useConversationContextSafe()?.workspace ?? ''; const absolutePath = useMemo(() => { if (!root) return src; if ( src.startsWith('http') || src.startsWith('data:') || src.startsWith('/') || src.startsWith('file:') || src.startsWith('\\') || /^[A-Za-z]:/.test(src) ) { return src; } return joinPath(root, src); }, [src, root]); useEffect(() => { setLoading(true); ipcBridge.fs.getImageBase64 .invoke({ path: absolutePath, workspace: root || undefined }) .then((base64) => { if (base64) setUrl(base64); setLoading(false); }) .catch((error) => { /* ... */ }); }, [absolutePath]);

要点:

  • 相对路径解析:如果src是相对路径(例如./chart.png),会以当前会话工作区(代理 cwd)为根解析成绝对路径;绝对路径、http(s)、data:、file:、Windows 盘符路径则原样透传;
  • 按需读取:通过 IPC 桥调用/api/fs/image-base64接口(定义见 ipcBridge.ts:getImageBase64: httpPost<string | null, { path: string; workspace?: string }>('/api/fs/image-base64')),把本地图片读成 base64 后再交给<img>渲染;
  • 加载状态:读取期间展示 loading 图标与 alt 文本,读取失败则静默降级(保留 alt),不会让整条消息崩溃。

由于图片只在消息可见、需要渲染时才被读取,聊天列表、WebSocket 事件和 SQLite 记录中都不再承载 MB 级 base64,这正是"按需读取"的价值所在。

聚合视图(View Steps)中的图片展示

对话列表会把同一轮的工具调用聚合到View Steps摘要中(对应 MessageToolGroupSummary.tsx)。ACP 图片工具调用在进入聚合视图前,会先经过标准化层保留image.path。

标准化层是normalizeAcpToolCall(见 normalizeToolCall.ts),其中关键一行:

return { key: update.tool_call_id, name: update.title, status: normalizeAcpStatus(update.status), description: keyParam || (rawInput?.command as string) || update.kind, input, output, truncated: content?._compact?.truncated === true, messageId: message.id, conversationId: message.conversation_id, imagePath: getAcpImagePath(update), // ← 保留图片路径 };

imagePath被写入标准化的NormalizedToolCall,随后在ToolItemDetail中渲染:

{item.imagePath && ( <div className='group relative m-l-20px m-t-8px overflow-hidden rounded border bg-1 p-2 max-w-280px'> <LocalImageView src={item.imagePath} alt={getAcpImageFileName(item.imagePath)} className='max-w-full max-h-320px object-contain rounded' /> <Tooltip content={t('acp.image.download')}> <Button ... onClick={() => void handleDownloadImage(item.imagePath)} /> </Tooltip> </div> )}

因此聚合视图展开后同样能展示同一张本地图片,并带有下载按钮把图片保存到本机。此外,如果条目被截断(truncated),展开时还会通过ipcBridge.database.getConversationMessage拉取完整消息重新标准化,以便拿到完整图片路径。

前端兜底清洗:mergeAcpToolCallContent 与快速合并路径

后端是主要清洗边界,但前端也保留了两处兜底清洗,防止极端情况下大 base64 仍然进入渲染链路。

1. 合并路径 mergeAcpToolCallContent

在消息列表"快速合并"路径中,mergeAcpToolCallContent(位于 chatLib.ts)把增量更新的工具调用合并进既有消息,合并前同样经过sanitizeAcpToolCallContent清洗,测试证据见 acpToolCallOutput.test.ts:

it('sanitizes incoming updates when merging an existing ACP tool call', () => { const existing = createAcpToolCall(undefined).content; const incoming = createAcpToolCall({ saved_path: '/Users/test/.codex/generated_images/session/ig_test_image.webp', result: `UklGR${'A'.repeat(128 * 1024)}`, }).content; const merged = mergeAcpToolCallContent(existing, incoming); expect(merged.update.rawOutput?.result).toBeUndefined(); expect(merged.update.rawOutput?.image?.mime_type).toBe('image/webp'); });

2. 消息插入路径 composeMessage

新收到的工具调用消息在插入消息列表(composeMessage)时同样会被清洗,包括空列表插入、追加到非空列表等场景(见 acpToolCallOutput.test.ts)。兜底清洗逻辑与后端一致:收到包含saved_path且超出阈值(64 KB)的result字符串时,移除result并补齐image.path、result_omitted和result_bytes。

前端兜底与后端清洗使用同一套sanitizeAcpRawOutput实现(见 acpToolCallOutput.ts),保证前后端清洗语义完全一致。

测试覆盖

相关单测位于tests/unit/chat/,覆盖维度如下:

测试文件覆盖内容
acpToolCallOutput.test.ts图片路径提取(image.path优先于saved_path)、文件名生成、base64 清洗(PNG/JPEG/WebP/GIF)、短结果保留、非图片长文本保留、非字符串 result 保留、snake_case 兼容、既有 image 元数据保留、合并与插入路径清洗
messageHooks.dom.test.tsx空列表、非空列表和add=true路径下的 ACP 图片消息清洗
MessageAcpToolCall.dom.test.tsx 与 MessageToolGroupSummary.dom.test.tsx图片预览渲染、下载成功调用和下载失败提示

以清洗判定的边界为例,acpToolCallOutput.test.ts明确了三类场景的预期行为:

  • 超长图片 base64:result被移除、result_omitted === true、result_omitted_reason === 'image_base64'、image引用被补齐(含正确 MIME);
  • 短文本输出:即使saved_path存在也原样保留result,不触发清洗;
  • 超长非图片文本:即使有saved_path也不清洗,result_omitted保持 undefined;
  • 非字符串 result(对象):原样保留,绝不误伤。

总结:整条链路回顾

从一张 Codex 生成的图片到屏幕上的预览,AionUi 的完整链路是:

  1. 后端入口:sanitizeAcpToolCallContent清洗大型内联 base64,保留image.path/saved_path/result_omitted/result_bytes等小型字段;
  2. 数据链路:清洗后的消息经 WebSocket 推送、SQLite 持久化,全程只携带路径引用;
  3. 前端兜底:mergeAcpToolCallContent与消息列表快速合并路径再次清洗,确保异常数据不进入渲染;
  4. 消息卡片:MessageAcpToolCall通过getAcpImagePath解析路径,LocalImageView经/api/fs/image-base64按需读取本地图片并渲染,附下载按钮;
  5. 聚合视图:normalizeAcpToolCall标准化时保留imagePath,MessageToolGroupSummary的 View Steps 展开后复用同一套图片展示与下载逻辑。

这套"路径引用 + 按需读取 + 双层清洗"的方案,让 ACP 图片输出在聊天列表、WebSocket 事件与 SQLite 记录三层数据载体中始终保持轻量,是 AionUi 处理大型工具输出的代表性设计,也适用于其他类型的超大工具输出(如长日志、大 diff)做类似改造。

【免费下载链接】AionUiOpen-source 24/7 Cowork app for OpenClaw, Hermes, Claude Code, Codex, OpenCode and 20+ more CLI Agent | Customize your assistants | Team them up|Star if you like it!项目地址: https://gitcode.com/GitHub_Trending/ai/AionUi

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询