返回博客

学会画画了,但"发"不过来——Agent 在 Web UI 里显示图片有多难

moeyui moeyui
·

背景

第一篇聊了怎么给 OpenClaw 龙虾 cc 接上图片生成能力,结尾埋了一个坑:cc 能生成图片了,但它没法在 Web UI 里把图发给我看。

这个问题听起来简单——不就是在聊天里发个图嘛。但实际操作起来,我和 cc 在这上面折腾了整整一上午。这篇就来聊聊这个看似简单、实则到处是坑的问题。

为什么会有这个问题

先解释一下为什么 OpenClaw 的 Web UI 在图片显示上”不太行”。

OpenClaw 的大多数用户是通过 IM(Signal、Telegram、WhatsApp 等)跟龙虾聊天的。在 IM 里,图片就是图片,发送和显示都由 IM 客户端原生支持,不需要 Agent 操心。但我用的是 OpenClaw 自带的 Control UI——一个 Web 管理界面,主要用来做配置和调试,图片显示并不是它的设计重点。

所以当 cc 学会画画之后,它在 IM 渠道发图没问题,但在 Control UI 里就傻眼了。

问题是什么

cc 生成的图片保存在服务器本地的 output/images/ 目录里,问题是——怎么让 cc 在 Control UI 的聊天中把图片展示给我看?

这事乍一看有很多种办法。但每一种,都以不同的姿势翻了车。

四种方案对比

方案一:直接读文件

最直觉的想法——cc 用 read 工具读取图片文件,图片不就出现了嘛。

cc:我用 read 工具读了一张图,你看到了吗?
我:啥也没有啊。

原来 read 工具读取图片文件只是让 Agent 自己 看到了图片内容,并不会把图片发送到聊天界面。相当于 cc 打开了相册自己看了一眼,然后问我”看到了吗”——我当然看不到。

方案二:Markdown 图片链接

第二个想法——用 Markdown 的图片语法 ![](url),链接指向 HTTPS 的图片地址。Control UI 是通过 Tailscale 的 HTTPS 访问的,图片也走同域名的 HTTPS 链接,理论上应该没问题。

![cc](https://claw-home.tail.../img/1255779bc049.jpg)

图片没出来。

浏览器开发者工具里看到图片请求返回了 401 Unauthorized。原因是 OpenClaw gateway 有 auth 中间件,所有请求都需要携带 Auth header。但 Control UI 里的 <img> 标签发起的图片请求不会自动携带 Auth header,gateway 直接拒绝了。

也就是说,同一个 URL 你在浏览器地址栏里能打开,但在 <img> 标签里加载就不行。

方案三:HTML img 标签

试试直接写 HTML?

<img src="https://claw-home.tail.../img/xxx.jpg" width="300" />

Control UI 的聊天渲染器使用了 DOMPurify 做 XSS 防护。所有 HTML 标签都被转义成纯文本了,页面上原样显示了一行 <img src=...>

方案四:data:image base64

前三种都扑街了,但我注意到一个细节——翻 Control UI 的前端代码后发现,它的 Markdown 渲染器里有一个特殊判断:

if (INLINE_DATA_IMAGE_RE.test(href)) {
  return `<img ...>`;  // ← 只有 data: URI 才渲染为图片
} else {
  return escapeHtml(label);  // ← 其他 URL 全部转义为文本
}

这个渲染器设计上就只认 data:image/...;base64,... 格式的图片。不是 bug,是 feature——外部 URL 全部 escape,只有内联 base64 数据才会渲染成真正的图片。

先看一下完整的排除过程:

方案排除流程

试了一下——让 cc 把图片读出来、编码成 base64、用 chat.inject 注入一条带 data URI 的 Markdown 消息。发了一个小蓝方块测试,显示了!

但这个方案问题不少:payload 巨大(一张 JPEG 编码后几十到几百 KB)、Web UI 有时候会截断过长的消息导致图片无法显示、每次都要走编码注入的流程。能应急,但不是正经解法。

最终方案:本地图床

既然 Control UI 不认 gateway 路径的 HTTPS URL(401 Auth),那就搞一个不经过 gateway、但走 HTTPS 的独立图片服务

最终方案很简单:

  1. 写一个极简的 Python HTTP server(imagehost/server.py),只做一件事——serve output/images/ 目录
  2. 跑在 8200 端口,注册为 systemd 服务
  3. 用 Tailscale serve 把 /cc-static 路径代理到这个服务

整个路由架构长这样:

最终架构

这样图片的 URL 就变成了:

https://claw-home.tail.../cc-static/filename.jpg
  • 同域名、同 HTTPS ✅
  • 不走 gateway,不需要 Auth ✅
  • 没有 Mixed Content ✅
  • 没有 CORS 问题 ✅

等等——但前面说了,Control UI 的渲染器只认 data:image 啊?

没错。所以最终还需要动一下 Control UI 的前端代码,让它也支持渲染 https:// 的图片 URL。改了 markdown 渲染器里的正则判断,让同域名的 HTTPS 图片也能通过:

// 改前:只认 data:image
if (INLINE_DATA_IMAGE_RE.test(href)) { ... }

// 改后:data:image 和 HTTPS URL 都认
if (INLINE_DATA_IMAGE_RE.test(href) || /^https?:\/\//.test(href)) { ... }

刷新页面,发一张图:

我:能看到了 ✅

一个有点哲学的问题

这个问题的根源其实挺有意思:cc 能看到自己生成的所有图片,但它没有办法”发”给我。

Agent 的 read 工具能读图片,但那只是 Agent 自己的感知。Agent 的文字回复能到达用户,但图片数据太大塞不进文字消息。Web UI 的渲染器出于安全考虑只信任最保守的格式。每一层都有自己的合理逻辑,但叠在一起就形成了一个断层。

这让我想起了一个场景:你在电话里跟人描述一幅画。你看得到画,对方能听到你说话,但你就是没法把画传过去。不是谁做错了什么,是通信协议不支持。

解决方法?加一条支持图片的通道——就像从打电话切换到发微信一样。本地图床就是那条新通道。

经验总结

几条实际踩出来的经验:

1. Agent “看到”不等于用户”看到”read 工具读文件是 Agent 侧的操作,不会自动反映在用户的界面上。这个概念不直觉,但很重要。

2. 安全策略是层叠的。DOMPurify 防 XSS,gateway 的 auth 防未授权访问,Markdown 渲染器只信任 data URI——每一层都有道理,但组合起来会让看似简单的操作变得不可能。理解这些层的叠加效应比单独理解每一层更重要。

3. 简单问题的简单解法往往最好。最终方案就是一个十几行的 Python HTTP server + 一条 Tailscale 路由。不优雅,但管用。

下一步

图片显示的问题解决了,cc 终于能在聊天里给我发图了。但光给我看还不够——我还想在其他地方也能查看和管理 cc 生成的所有内容。于是接下来,我给它搭了一个内容管理后台。这个故事,下篇再聊。

moeyui

moeyui

不是很懂你们程序员