富 UI 元素嵌入
Tools 和 Actions 都支 持富 UI 元素嵌入,允许它们返回直接显示在对话中的 HTML 内容和交互式 iframe。此功能支持复杂的视觉界面、交互式小部件、图表、仪表板和其他富 Web 内容——无论函数是由模型(Tool)还是用户(Action)触发的。
当函数返回带有适当标头的 HTMLResponse 时,内容将作为交互式 iframe 嵌入到对话界面中,而不是显示为纯文本。
Tool 用法
要嵌入 HTML 内容,你的工具应返回带有 Content-Disposition: inline 标头的 HTMLResponse:
from fastapi.responses import HTMLResponse
def render_checklist(self, items: list[str]) -> HTMLResponse:
"""
Renders an interactive checklist that embeds in the chat.
:param items: The items to show in the checklist
"""
items_html = "".join(
f'<li><label><input type="checkbox"> {item}</label></li>' for item in items
)
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<title>Checklist</title>
<style>
body {{ font-family: system-ui, sans-serif; padding: 1rem; }}
ul {{ list-style: none; padding: 0; }}
li {{ padding: 0.25rem 0; }}
</style>
</head>
<body>
<ul>{items_html}</ul>
</body>
</html>
"""
headers = {"Content-Disposition": "inline"}
return HTMLResponse(content=html_content, headers=headers)自定义结果上下文
默认情况下,当工具返回 HTMLResponse 时,LLM 会收到一条通用消息:"<tool_name>: Embedded UI result is active and visible to the user."。这不会向模型提供关于实际生成了什么的信息。
要 为 LLM 提供关于嵌入的可操作上下文,请返回 (HTMLResponse, context) 的元组,其中第二个元素是 str、dict 或 list:
from fastapi.responses import HTMLResponse
def render_feedback_form(self, prompt: str) -> tuple:
"""
Renders an interactive feedback form and returns context to the LLM.
:param prompt: The question to show the user above the form
"""
html_content = "<html>...</html>"
headers = {"Content-Disposition": "inline"}
# The LLM receives this context instead of the generic message
result_context = {
"status": "success",
"form_type": "feedback",
"fields": ["rating", "comment"],
"description": f"Rendered a feedback form asking: {prompt!r}"
}
return HTMLResponse(content=html_content, headers=headers), result_context上下文可以是:
- 字符串 — 原样发送给 LLM(如
"Generated a bar chart with 5 categories") - dict — 序列化为 JSON 提供结构化上下文
- list — 序列化为 JSON 提供多个项目
如果第二个元素缺失或不是这些类型之一,则使用通用回退消息。
当你的工具生成动态内容,且 LLM 需要在后续对话中引用所生成内容时,这尤为有用——例如,告诉 LLM 使用了哪些参数、显示了哪些数据,或用户接下来可以执行什么操作。
Action 用法
Action 的工作方式完全相同。富 UI 嵌入会通过事件发射器传递到对话中:
方案 A — HTMLResponse:
from fastapi.responses import HTMLResponse
async def action(self, body, __event_emitter__=None):
html = "<html><body><h1>Dashboard</h1></body></html>"
return HTMLResponse(content=html, headers={"Content-Disposition": "inline"})方案 B — 带标头的元组:
async def action(self, body, __event_emitter__=None):
html = "<h1>Interactive Chart</h1><script>...</script>"
return (html, {"Content-Disposition": "inline", "Content-Type": "text/html"})Pipe 函数用法
当 Tool 通过 Open WebUI 内置的工具调用层(无论是原生模式还是旧版模式)被调用时,中间件会自动识别 HTMLResponse 结果、提取 HTML 并将其作为嵌入内容发出,同时触发 "embeds" 事件,无需额外处理。
不过,当 Pipe 函数 直接调用外部提供商的 API(例如 Azure OpenAI、Anthropic)时,它会完全绕过中间件。此时 Pipe 需要自己管理工具调用流程——向提供商发送工具定义、接收响应中的 tool_calls、执行工具并把结果回传。在这种场景下,中间件根本看不到 HTMLResponse,因此 Pipe 必须手动发出嵌入内容。
实现模式
在 Pipe 的工具执行逻辑中,检测工具是否返回 HTMLResponse,提取 HTML 正文,通过事件发射器发出,并向 LLM 返回一段文本摘要:
from fastapi.responses import HTMLResponse
async def execute_tool(self, tool_call, tools, __event_emitter__):
tool = tools.get(tool_call.name)
if not tool:
return "Tool not found"
parsed_args = json.loads(tool_call.arguments) if tool_call.arguments else {}
result = await tool["callable"](**parsed_args)
# Detect HTMLResponse and emit as embed
if isinstance(result, HTMLResponse):
content_disposition = result.headers.get("Content-Disposition", "")
if "inline" in content_disposition:
html_content = result.body.decode("utf-8", "replace")
# Emit the embed so the frontend renders it
await __event_emitter__({
"type": "embeds",
"data": {"embeds": [html_content]},
})
# Return a text summary to the LLM (not the raw HTML)
return json.dumps({
"status": "success",
"message": f"{tool_call.name}: UI rendered successfully.",
})
# For non-HTML results, return as normal
return json.dumps(result)为什么需要这样做
Open WebUI 的中间件会在 process_tool_result() 中处理工具结果,自动完成 HTMLResponse 检测、嵌入提取和事件发出。但只有当 中间件 负责协调工具调用流程时,这个函数才会被调用。若 Pipe 自己处理工具调用(因为它直接向 LLM 提供商发出 HTTP 请求),就必须自行复现这部分逻辑。
关键步骤
- 检测
HTMLResponse—— 检查工具返回值是否为带有Content-Disposition: inline的HTMLResponse - 提取 HTML —— 对响应正文进行解码
- 发出
"embeds"事件 —— 通过__event_emitter__发送 HTML,让前端将其渲染为富 UI 卡片 - 向 LLM 返回文本 —— 模型应收到文本摘要(而不是原始 HTML),这样它才能自然地继续对话
元组上下文支持
和标准 Tool 一样,你也可以从工具中返回 (HTMLResponse, context) 元组。在 Pipe 的执行逻辑里,按同样方式解包即可:
if isinstance(result, tuple) and len(result) == 2 and isinstance(result[0], HTMLResponse):
html_response, result_context = result
# ... emit html_response.body as embed ...
# ... return result_context to the LLM instead of the generic message ...Native Tool Calling Pipe 是社区维护的一个 Pipe,完整实现了 OpenAI 原生工具调用流程,并支持流式与多次调用。它可以改造用于 Azure OpenAI 或其他提供商,是实现该模式的实用参考。
iframe 高度与自动调整
富 UI 嵌入会渲染在一个沙箱化的 iframe 中。为了让内容在不出现滚动条的情况下完整显示,iframe 需要知道自身内容有多高。这里有两种机制:
通过 postMessage 报告高度(推荐)
当 allowSameOrigin 关闭(默认值)时,父页面无法直接读取 iframe 的内容高度。你的 HTML 必须通过向父窗口发送消息来报告自身高度:
<script>
function reportHeight() {
const h = document.documentElement.scrollHeight;
parent.postMessage({ type: 'iframe:height', height: h }, '*');
}
window.addEventListener('load', reportHeight);
// Also re-report when content changes size
new ResizeObserver(reportHeight).observe(document.body);
</script>请把这段脚本放到每个 Rich UI 嵌入的 <body> 末尾。否则 iframe 会保持较小的默认高度,内容会被截断并出现滚动条。
同源自动调整大小
当 allowSameOrigin 开启时(通过用户设置 iframeSandboxAllowSameOrigin),父页面可以直接测量 iframe 的内容高度并自动调整大小——你的 HTML 中不需要任何脚本。不过,这会带来安全上的权衡(见下文)。
沙箱与安全性
嵌入的 iframe 运行在 sandbox 中。以下沙箱标志默认始终启用:
allow-scripts—— 允许执行 JavaScriptallow-popups—— 允许弹出窗口(例如window.open)allow-downloads—— 允许文件下载
用户可以在 设置 → 界面 中切换另外两个标志:
| 设置 | 默认值 | 说明 |
|---|---|---|
| 允许 Iframe 同源访问 | ❌ 关闭 | 允许 iframe 访问父页面上下文 |
| 允许 Iframe 表单提交 | ❌ 关闭 | 允许在嵌入内容中提交表单 |
allowSameOrigin
这是最需要关注的标志。出于安全原因,它默认 关闭。
关闭时(默认):
- iframe 与父页面完全隔离
- 它 无法 读取父页面的 cookie、localStorage 或 DOM
- 父页面 无法 读取 iframe 的内容高度(因此你必须使用上面的 postMessage 模式)
- 这是最安全的选择,也最适合大多数场景
开启时:
- iframe 可以与父页面上下文交互
- 无需在 HTML 中写任何脚本即可自动调整大小
- 如果检测到 Chart.js 和 Alpine.js 依赖,会自动注入
- ⚠️ 请谨慎使用 —— 只有在你信任嵌入内容时才开启
用户可以在 设置 → 界面 → Iframe 同源访问 中切换此设置。
当 allowSameOrigin 关闭(默认)时,Rich UI iframe 会受到强沙箱限制。这意味着:
- 嵌入内部的下载 很难甚至无法完成——尤其是在 iOS 上,沙箱化 iframe 完全无法触发文件下载
- 嵌入中的 JavaScript 无法与 Open WebUI 本身交互 —— iframe 无法访问父页面的 DOM、cookie、localStorage 或任何 Open WebUI API
- 跨框架通信 仅限于
postMessage—— 不过,提示提交 在跨域场景下仍可通过用户确认对话框工作
如果你的 Rich UI 嵌入需要触发下载、与 Open WebUI 前端交互,或执行会影响父页面的 JavaScript,就必须开启同源 iframe 访问。请在 设置 → 界面 → Iframe 同源访问 中启用它。
如果你只是需要一些临时交互,并且需要完整页面访问权限,可以考虑改用 execute 事件,它会在主页面上下文中以未沙箱化的方式运行。
如果你想看看开启同源后 Rich UI 能走多远,可以看看社区的 Inline Visualizer v2 工具(也可在社区站点中通过 演示讨论 访问)。
它展示了基础文档里没有的几种模式:
- 实时流式 HTML/SVG。 这个工具返回一个空壳,模型随后会在正常响应中通过纯文本
@@@VIZ-START / @@@VIZ-END标记之间插入标记内容。iframe 内部的同源观察者会跟踪父聊天的 DOM,提取不断增长的代码块,并在 token 到来时把新节点合并到 iframe 中——因此仪表盘和图表会逐步“画出来”,而不是等流结束后一次性出现。 - 双向桥接。
sendPrompt(text)可以把任意可点击节点变成一条后续用户消息。saveState(k, v)/loadState(k, fallback)会代理父页面中按消息隔离的localStorage,让滑块和开关在刷新后仍能保留。copyText、toast(msg, kind)和openLink则补齐了其他常用能力。 - 可直接交付的设计系统。 主题感知的 CSS 变量、9 档颜色调色板、SVG 工具类、自动明暗主题适配,以及覆盖 46 种语言的 230 条本地化字符串——全部由一个工具直接提供,无需修改核心代码。
- 渐进式 DOM 合并。 安全截断的 HTML 解析器会在每个 tick 只释放最长有效前缀;合并器只追加新节点,因此现有元素不会重新挂载,动画也不会在流式过程中重复触发。
当你需要判断某个生成式 UI / 流式 UI 功能是应该改核心还是只放在插件层时,这个案例很有参考价值。(剧透:大多数时候是后者。)
渲染位置
- Tool 嵌入 会在工具调用结果中 内联 显示,位置在工具调用指示器处(也就是 “View Result from...” 那一行)
- Action 嵌入 和消息级嵌入会显示在消息正文的 上方
高级通信
iframe 与父窗口之间的通信不止于高度上报。还可以使用以下模式:
负载请求
iframe 可以向父页面请求数据负载。加载完成后把动态数据传入嵌入内容时,这很有用:
<script>
// Listen for the response
window.addEventListener('message', (e) => {
if (e.data?.type === 'payload') {
const data = e.data.payload;
// Use the payload data to populate your UI
console.log('Received payload:', data);
}
});
// Trigger the request
parent.postMessage({ type: 'payload', requestId: 'my-request' }, '*');
</script>父页面会返回 { type: 'payload', requestId: ..., payload: ... }。
并没有一个独立的"设置 payload"调用。payload 就是父组件在实例化 iframe 时已经配置好的内容——目前只有一条路径会真正配置它:
- ✅ 在 chat-controls 嵌入面板中通过引用打开的嵌入 —— 当用户点击某个带有 embed URL 的引用徽章时,侧边面板会打开,并把完整的引用/source 对象(也就是你通过
__event_emitter__发出的source/citation事件中传入的那个 dict)作为 payload 暴露出来。要设置它,就发出一个source事件,把你想让 iframe 获取的内容放在data里。iframe 再通过上面的 postMessage 发起请求,父页面就会把那个引用对象原样返回。 - ❌ 内联工具调用嵌入(由 Tool 方法返回
HTMLResponse或(HTMLResponse, context)产生)—— 这条路径父页面不会配置 payload,因此 payload 请求会返回{ type: 'payload', requestId: ..., payload: null }。请改用工具参数注入(需要allowSameOrigin)把数据传入工具调用嵌入。 - ❌
__event_emitter__({"type": "embeds", ...})与 Action 嵌入 —— 这两条路径也不会配置 payload;返回值为null。
简而言之:payload-request 走的是"侧边面板引用"专用通道,并不是通用的 iframe 数据通道。请按需要的数据流选择合适的渲染路径。