跳到主要内容

Rich UI 元素嵌入

Tools 和 Actions 都支持 Rich UI 元素嵌入,允许它们返回 HTML 内容以及直接在聊天对话中显示的交互式 iframe。无论函数是由模型触发(Tool)还是由用户触发(Action),此功能都可以实现精美的视觉界面、交互式小部件、图表、仪表盘以及其他丰富的 Web 内容。

当函数返回带有适当标头的 HTMLResponse 时,内容将在聊天界面中嵌入为交互式 iframe,而不是显示为纯文本。

工具使用

要嵌入 HTML 内容,您的工具应当返回一个带有 Content-Disposition: inline 标头的 HTMLResponse

from fastapi.responses import HTMLResponse

def create_visualization_tool(self, data: str) -> HTMLResponse:
    """
    创建在聊天中嵌入的交互式数据可视化。

    :param data: 要可视化的数据
    """
    html_content = """
    <!DOCTYPE html>
    <html>
    <head>
        <title>Data Visualization</title>
        <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
    </head>
    <body>
        <div id="chart" style="width:100%;height:400px;"></div>
        <script>
            // 您的交互式图表代码
            Plotly.newPlot('chart', [{
                y: [1, 2, 3, 4],
                type: 'scatter'
            }]);
        </script>
    </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)元组(tuple),其中第二个元素是 strdictlist

from fastapi.responses import HTMLResponse

def create_chart(self, data: str) -> tuple:
    """
    创建交互式图表并向 LLM 返回上下文。

    :param data: 要绘制图表的数据
    """
    html_content = "<html>...</html>"
    headers = {"Content-Disposition": "inline"}

    # LLM 将接收此上下文,而不是通用消息
    result_context = {
        "status": "success",
        "chart_type": "scatter",
        "data_points": 42,
        "description": "Scatter plot showing correlation between X and Y"
    }

    return HTMLResponse(content=html_content, headers=headers), result_context

上下文可以是:

  • 字符串(string)— 原样发送给 LLM(例如 "Generated a bar chart with 5 categories"
  • 字典(dict)— 序列化为 JSON 以提供结构化上下文
  • 列表(list)— 序列化为 JSON 以提供多个条目

如果缺失第二个元素或者其不是这些类型之一,则将使用通用的备用消息。

适用场景

当您的工具生成动态内容,且 LLM 需要在后续对话中引用所生成的内容时,这特别有用 — 例如,告诉 LLM 使用了哪些参数、正在显示什么数据,或者用户下一步可以采取什么操作。

Action 使用

Actions 的工作方式完全相同。Rich UI 嵌入通过事件发射器(event emitter)传递给聊天:

方案 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 函数使用

当通过 Open WebUI 内置的工具调用层(原生或传统方式)调用 Tool 时,Middleware 会自动检测 HTMLResponse 结果,将 HTML 提取到嵌入中,并触发 "embeds" 事件。无需额外工作。

然而,当 Pipe 函数直接向外部提供商(例如 Azure OpenAI、Anthropic)发起 API 调用时,它会完全绕过 Middleware。Pipe 自行管理工具调用周期 — 向提供商发送工具定义、在响应中接收 tool_calls、执行工具并将结果反馈回去。在这种情况下,Middleware 永远不会看到 HTMLResponse,因此 Pipe 必须手动发射嵌入事件

模式

In your pipe's tool execution logic, detect when a tool returns an HTMLResponse, extract the HTML body, emit it via the event emitter, and return a text summary to the 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)

    # 检测 HTMLResponse 并将其作为嵌入发射
    if isinstance(result, HTMLResponse):
        content_disposition = result.headers.get("Content-Disposition", "")
        if "inline" in content_disposition:
            html_content = result.body.decode("utf-8", "replace")

            # 发射嵌入,以便前端进行渲染
            await __event_emitter__({
                "type": "embeds",
                "data": {"embeds": [html_content]},
            })

            # 向 LLM 返回文本摘要(而不是原始 HTML)
            return json.dumps({
                "status": "success",
                "message": f"{tool_call.name}: UI rendered successfully.",
            })

    # 对于非 HTML 结果,正常返回
    return json.dumps(result)

为什么需要这个

Open WebUI 的 Middleware 在 process_tool_result() 中处理工具结果,该函数会自动处理 HTMLResponse 检测、嵌入提取和事件发射。但该函数仅在 Middleware 编排工具调用周期时才会被调用。当 Pipe 函数自行处理工具调用时(因为它直接向 LLM 提供商发送 HTTP 请求),它必须复制该逻辑的相关部分。

关键步骤

  1. 检测 HTMLResponse — 检查工具的返回值是否是带有 Content-Disposition: inlineHTMLResponse
  2. 提取 HTML — 解码响应体
  3. 发射 "embeds" 事件 — 通过 __event_emitter__ 发送 HTML,以便前端将其渲染为 Rich UI 卡片
  4. 向 LLM 返回文本 — 模型应该收到文本摘要(而不是原始 HTML),以便它可以自然地继续对话

元组上下文支持

与标准 Tools 一样,您可以从工具中返回 (HTMLResponse, context) 元组。在您的 Pipe 执行逻辑中,以同样的方式对其进行解包:

if isinstance(result, tuple) and len(result) == 2 and isinstance(result[0], HTMLResponse):
    html_response, result_context = result
    # ... 将 html_response.body 作为嵌入发射 ...
    # ... 向 LLM 返回 result_context 以替代通用消息 ...
社区参考

Native Tool Calling Pipe 是一个社区构建的 Pipe,它实现了完整的 OpenAI 原生工具调用周期,支持流式传输和多重调用。它可以适用于 Azure OpenAI 或其他提供商,并可作为实现此模式的实用参考。

Iframe 高度和自动调整大小

Rich UI 嵌入在沙箱化的 iframe 中渲染。iframe 需要知道其内容有多高,以便在不显示滚动条的情况下进行展示。对此有两种机制:

allowSameOrigin 处于 关闭(Off)状态(默认值)时,父页面无法直接读取 iframe 的内容高度。您的 HTML 必须通过向父窗口发送消息来报告其自身的高度:

<script>
  function reportHeight() {
    const h = document.documentElement.scrollHeight;
    parent.postMessage({ type: 'iframe:height', height: h }, '*');
  }
  window.addEventListener('load', reportHeight);
  // 在内容大小发生变化时重新报告
  new ResizeObserver(reportHeight).observe(document.body);
</script>

在每个 Rich UI 嵌入的 <body> 结尾添加此脚本。如果没有它,iframe 将保持较小的默认高度,您的内容将被滚动条截断。

同源自动调整大小

allowSameOrigin 处于 开启(On)状态(通过用户设置 iframeSandboxAllowSameOrigin)时,父页面可以直接测量 iframe 的内容高度并自动调整其大小 — 您的 HTML 中无需编写任何脚本。然而,这伴随着安全权衡(见下文)。

沙箱与安全

嵌入的 iframe 在 sandbox 内运行。默认情况下始终启用以下沙箱标志:

  • allow-scripts — JavaScript 执行
  • allow-popups — 弹出窗口(例如 window.open)
  • allow-downloads — 文件下载

用户可以在 设置 → 界面 中切换另外两个标志:

设置默认值描述
允许 Iframe 同源访问❌ 关闭 (Off)允许 iframe 访问父页面上下文
允许 Iframe 表单提交❌ 关闭 (Off)允许在嵌入内容中进行表单提交

allowSameOrigin

这是需要注意的最重要的标志。出于安全原因,它默认关闭

关闭时(默认):

  • iframe 与父页面完全隔离
  • 不能读取父页面的 cookies、localStorage 或 DOM
  • 父页面不能读取 iframe 的内容高度(因此您必须使用上面的 postMessage 模式)
  • 这是最安全的选择,推荐用于大多数用例

开启时:

  • iframe 可以与父页面的上下文进行交互
  • 自动调整大小可以正常工作,无需在 HTML 中编写任何脚本
  • 如果检测到 Chart.js 和 Alpine.js 依赖项,它们将被自动注入
  • ⚠️ 请谨慎使用 — 仅在您信任嵌入内容时才启用此功能

用户可以在 设置 → 界面 → Iframe 同源访问 中切换此设置。

沙箱设置的实际影响

allowSameOrigin 处于 关闭 状态(默认)时,Rich UI iframe 会被严格沙箱化。这意味着:

  • 在嵌入中触发下载会非常困难或不可能 — 特别是在 iOS 上,沙箱化的 iframe 完全无法触发文件下载
  • 嵌入中的 JavaScript 无法与 Open WebUI 本身进行交互 — iframe 无法访问父页面的 DOM、cookies、localStorage 或任何 Open WebUI API
  • 跨框架通信仅限于 postMessage — 不过,Prompt 提交在跨源情况下也可以通过用户确认对话框来工作

如果您的 Rich UI 嵌入需要触发下载、与 Open WebUI 的前端交互,或者执行影响父页面的 JavaScript,则必须启用同源 iframe 访问。在 设置 → 界面 → Iframe 同源访问 中开启它。

作为需要完整页面访问的临时交互的替代方案,请考虑改用 execute 事件,它在主页面上下文中无沙箱运行。

社区展示:在同源启用下流式传输 Rich UI

如果您想了解在启用同源访问时 Rich UI 能达到什么样的高度,请查看社区的 Inline Visualizer v2 工具(也可以通过社区网站上的 Show-and-tell 讨论访问)。

它展示了基础文档中未包含的模式:

  • 实时流式传输 HTML/SVG。 该工具返回一个空容器;然后模型在其正常响应中的纯文本 @@@VIZ-START@@@VIZ-END 标记之间内联发射标记。iframe 内的同源观察器会跟踪父聊天的 DOM,提取不断增长的块,并在 token 到达时将新节点协调到 iframe 中 — 这样仪表盘和图表就能随着 token 的到来实时绘制,而不是在流结束时突然弹出。
  • 双向桥接。 sendPrompt(text) 将任何可点击的节点转化为后续的用户消息。saveState(k, v) / loadState(k, fallback) 代理了每个消息作用域下的父级 localStorage,从而使滑块和开关在重新加载后依然存在。copyTexttoast(msg, kind)openLink 使其更加完善。
  • 开箱即用的设计系统。 主题感知 CSS 变量、9 阶渐变调色板、SVG 实用类、自动亮/暗模式适应,以及横跨 46 种语言的 230 个本地化字符串 — 所有这些都由单个工具提供,无需对核心代码进行任何更改。
  • 渐进式 DOM 协调。 安全截断 the HTML 解析器在每次滴答(tick)时输出最长的有效前缀;协调器仅追加新节点,因此现有元素在流传输过程中永远不会重新挂载,动画也永远不会重新触发。

当您在犹豫是需要修改核心代码来实现生成式 UI / 流式 UI 功能,还是可以纯粹在插件层实现时,这是一个非常有用的参考。(剧透:几乎总是后者。)

渲染位置

  • Tool 嵌入渲染在工具调用结果内部,内联显示在工具调用指示器(即 "View Result from..." 行)处
  • Action 嵌入和消息级别的嵌入渲染在消息文本内容上方

高级通信

iframe 和父窗口的通信不仅限于高度报告。以下模式可用:

负载请求

iframe 可以向父窗口请求数据负载(payload)。这对于在嵌入加载后向其传入动态数据非常有用:

<script>
  // 向父窗口请求负载
  window.addEventListener('message', (e) => {
    if (e.data?.type === 'payload') {
      const data = e.data.payload;
      // 使用负载数据填充您的 UI
      console.log('Received payload:', data);
    }
  });

  // 触发请求
  parent.postMessage({ type: 'payload', requestId: 'my-request' }, '*');
</script>

父窗口会以 { type: 'payload', requestId: ..., payload: ... } 响应,其中包含配置好的负载数据。

Tool 参数注入(仅限 Tools)

Tool 返回 Rich UI 嵌入时,工具调用参数(模型传递给工具的参数)会被自动注入到 iframe 的 window.args 中。这使得您嵌入的 HTML 能够访问工具的输入:

<script>
  window.addEventListener('load', () => {
    // window.args 包含模型传递给此工具的 JSON 参数
    const args = window.args;
    if (args) {
      document.getElementById('output').textContent = JSON.stringify(args, null, 2);
    }
  });
</script>
注意

这仅适用于通过工具调用显示渲染的 Tool 嵌入。Action 嵌入没有 window.args,因为它们是由用户触发的,而不是由模型触发的。

自动注入的库

当启用 allowSameOrigin 时,iframe 组件会自动检测您 HTML 中对特定库的使用并进行自动注入 — 无需 CDN <script> 标签:

  • Alpine.js — 当发现任何 x-datax-initx-showx-bindx-onx-textx-htmlx-modelx-forx-ifx-effectx-transitionx-cloakx-refx-teleportx-id 指令时会被检测到
  • Chart.js — 当 HTML 中出现 new Chart(Chart. 时会被检测到

这意味着您可以直接在 HTML 中编写 Alpine 或 Chart.js 代码,在启用同源访问时它们将正常工作,无需导入脚本。

Ping/Pong 连通性

iframe 可以使用简单的 ping/pong 模式测试与父窗口的连通性:

<script>
  window.addEventListener('message', (e) => {
    if (e.data?.type === 'pong:ack') {
      console.log('Parent is listening!');
    }
  });

  // 发送 pong 测试连通性
  parent.postMessage({ type: 'pong' }, '*');
</script>

Prompt 提交

Rich UI 嵌入可以通过三种消息类型与聊天输入框进行交互:

消息类型行为
input:prompt在聊天输入框中填充文本(不提交)
input:prompt:submit在聊天中填充并提交 Prompt
action:submit提交当前已经在聊天输入框中的文本
<script>
  // 填充聊天输入框但不提交
  parent.postMessage({ type: 'input:prompt', text: 'Analyze this data' }, '*');

  // 填充并提交 Prompt
  parent.postMessage({ type: 'input:prompt:submit', text: 'Show me a summary' }, '*');

  // 提交当前已在聊天输入框中的任何内容
  parent.postMessage({ type: 'action:submit', text: '' }, '*');
</script>

同源 vs 跨源行为:

  • allowSameOrigin 处于 开启 状态时,input:prompt:submit立即提交 Prompt — 无需用户交互。
  • allowSameOrigin 处于 关闭 状态(默认)时,来自跨源 iframe 的 input:prompt:submit 会在提交前向用户显示一个确认对话框。这可以防止滥用,同时在无需同源访问的情况下依然能够实现交互式嵌入。
  • 无论源如何,input:prompt and action:submit 的工作方式都是相同的 — 它们只填充或提交用户已经可以看到的文本。
提示

这意味着您的 Rich UI 嵌入可以包含交互式按钮(例如 "Explain this chart"、"Regenerate with different parameters"),这些按钮可以向聊天提交 Prompt,而无需用户启用同源访问。用户只需看到确认对话框并点击“确认”即可继续。

Rich UI 嵌入 vs Execute 事件

Rich UI 嵌入和 execute 事件是创建交互式体验的互补方式。请根据您的需求进行选择:

Rich UI 嵌入execute 事件
运行于沙箱化 iframe主页面上下文(无沙箱)
持久性持久 — 保存在聊天历史中临时 — 重新加载/导航后消失
页面访问默认与父页面隔离完整(DOM、cookies、localStorage)
表单需要启用 allowForms 设置始终可用(无沙箱)
最适合持久的视觉内容、仪表盘、图表临时交互、副作用、下载、DOM 操作

对于您希望保留在对话中的持久视觉内容,请使用 Rich UI 嵌入。对于临时交互,例如自定义对话框、触发下载或读取页面状态,请使用 execute

使用场景

Rich UI 嵌入非常适合:

  • 交互式仪表盘 — 实时数据可视化和控制
  • 图表与图形 — 使用 Plotly、D3.js 或 Chart.js 等库进行交互式绘图
  • 表单界面 — 具有验证和动态行为的复杂输入表单
  • 媒体播放器 — 视频、音频或交互式媒体内容
  • 下载触发器 — 特别适用于原生下载链接被阻止的 iOS PWA
  • 自定义小部件 — 针对特定工具功能的专用 UI 组件
  • 外部集成 — 嵌入来自外部服务或 API 的内容
  • 人工触发的可视化 — 用户点击按钮时显示结果的 Actions,例如生成报告或触发下载

完整示例 Action

包含 Rich UI 嵌入的完整可用示例 Action

此 Action 返回带有统计数据的精美卡片,并包含推荐的高度报告脚本:

"""
title: Rich UI Demo Action
author: open-webui
version: 0.1.0
description: 演示从 Action 函数嵌入 Rich UI。
"""

from pydantic import BaseModel, Field


class Action:
    class Valves(BaseModel):
        pass

    def __init__(self):
        self.valves = self.Valves()

    async def action(self, body: dict, __user__=None, __event_emitter__=None) -> None:
        from fastapi.responses import HTMLResponse

        html = """
        <!DOCTYPE html>
        <html>
        <head>
            <style>
                * { margin: 0; padding: 0; box-sizing: border-box; }
                body {
                    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
                    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
                    padding: 24px;
                    color: #fff;
                }
                .card {
                    background: rgba(255,255,255,0.15);
                    backdrop-filter: blur(10px);
                    border-radius: 16px;
                    padding: 24px;
                    border: 1px solid rgba(255,255,255,0.2);
                }
                h1 { font-size: 1.4em; margin-bottom: 8px; }
                p { opacity: 0.9; line-height: 1.5; margin-bottom: 12px; }
                .badge {
                    display: inline-block;
                    background: rgba(255,255,255,0.25);
                    padding: 4px 12px;
                    border-radius: 20px;
                    font-size: 0.85em;
                    font-weight: 600;
                }
                .stats {
                    display: flex;
                    gap: 16px;
                    margin-top: 16px;
                }
                .stat {
                    flex: 1;
                    text-align: center;
                    background: rgba(255,255,255,0.1);
                    border-radius: 12px;
                    padding: 12px;
                }
                .stat-value { font-size: 1.8em; font-weight: 700; }
                .stat-label { font-size: 0.8em; opacity: 0.8; margin-top: 4px; }
            </style>
        </head>
        <body>
            <div class="card">
                <h1>Rich UI 嵌入演示</h1>
                <p>此嵌入会渲染在消息文本的<strong>上方</strong>。</p>
                <span class="badge">Action 嵌入</span>
                <div class="stats">
                    <div class="stat">
                        <div class="stat-value">42</div>
                        <div class="stat-label">回答</div>
                    </div>
                    <div class="stat">
                        <div class="stat-value">99%</div>
                        <div class="stat-label">准确率</div>
                    </div>
                    <div class="stat">
                        <div class="stat-value">0ms</div>
                        <div class="stat-label">延迟</div>
                    </div>
                </div>
            </div>
            <script>
                // 向父窗口报告高度,以便 iframe 自动调整大小
                function reportHeight() {
                    const h = document.documentElement.scrollHeight;
                    parent.postMessage({ type: 'iframe:height', height: h }, '*');
                }
                window.addEventListener('load', reportHeight);
                new ResizeObserver(reportHeight).observe(document.body);
            </script>
        </body>
        </html>
        """

        return HTMLResponse(content=html, headers={"Content-Disposition": "inline"})

外部 Tool 示例

对于通过 HTTP 端点提供的外部工具:

@app.post("/tools/dashboard")
async def create_dashboard():
    html = """
    <div style="padding: 20px;">
        <h2>System Dashboard</h2>
        <canvas id="myChart" width="400" height="200"></canvas>
        <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
        <script>
            const ctx = document.getElementById('myChart').getContext('2d');
            new Chart(ctx, {
                type: 'line',
                data: { /* 您的图表数据 */ }
            });
        </script>
    </div>
    """

    return HTMLResponse(
        content=html,
        headers={"Content-Disposition": "inline"}
    )

嵌入的内容会自动继承响应式设计,并与聊天界面无缝集成,为与您的工具交互的用户提供原生般的体验。

CORS 与 Direct Tools

Direct external tools(直接外部工具)是直接从浏览器运行的工具。在这种情况下,工具由用户浏览器中的 JavaScript 调用。 因为我们依赖 Content-Disposition 标头,当在远程工具服务器上使用 CORS 时,Open WebUI 会由于 Access-Control-Expose-Headers 而无法读取该标头,这会阻止从 fetch 结果中读取某些标头。 为了防止这种情况,您必须将 Access-Control-Expose-Headers 设置为 Content-Disposition。请查看下面使用 Node.js 的工具示例:

const app = express();
const cors = require('cors');

app.use(cors())

app.get('/tools/dashboard', (req,res) => {
    let html = `
        <div style="padding: 20px;">
            <h2>System Dashboard</h2>
            <canvas id="myChart" width="400" height="200"></canvas>
            <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
            <script>
                const ctx = document.getElementById('myChart').getContext('2d');
                new Chart(ctx, {
                    type: 'line',
                    data: { /* 您的图表数据 */ }
                });
            </script>
        </div>
    `
    res.set({
        'Content-Disposition': 'inline'
        ,'Access-Control-Expose-Headers':'Content-Disposition'
    })
    res.send(html)
})

有关该标头的更多信息:https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Expose-Headers

This content is for informational purposes only and does not constitute a warranty, guarantee, or contractual commitment. Open WebUI is provided "as is." See your license for applicable terms.