跳到主要内容

Rich UI 嵌入

Tools 与 Actions 都支持 Rich UI 元素嵌入。这意味着它们可以直接在聊天中返回 HTML 内容或交互式 iframe,而不是只能返回纯文本。你可以据此构建图表、仪表盘、交互控件、可视化面板,甚至生成式 UI。

当函数返回带有特定 header 的 HTMLResponse 时, OPL 数据空间会把它当作可嵌入的 iframe 内容,而不是普通文本。

Tool 中的用法

要嵌入 HTML,Tool 应返回一个带 Content-Disposition: inlineHTMLResponse

from fastapi.responses import HTMLResponse

def create_visualization_tool(self, data: str) -> HTMLResponse:
    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)

给模型返回额外上下文

默认情况下,Tool 返回 HTMLResponse 后,LLM 只会收到一条泛化说明,例如:

"<tool_name>: Embedded UI result is active and visible to the user."

如果你希望模型知道“到底生成了什么”,可以返回 (HTMLResponse, context) 这样的二元组,其中 context 可以是 strdictlist

from fastapi.responses import HTMLResponse

def create_chart(self, data: str) -> tuple:
    html_content = "<html>...</html>"
    headers = {"Content-Disposition": "inline"}

    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

适合用在:

  • 让模型知道图表类型
  • 告诉模型 UI 中显示了哪些数据
  • 让模型在后续对话里能引用刚生成的可视化结果

Action 中的用法

Action 的工作方式完全一样:

方式 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:返回 (html, headers) 元组

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 是通过 OPL 数据空间内建的 tool calling 中间层执行时,HTMLResponse 会被自动识别并转换成 embed,无需额外处理。

但如果 Pipe 自己直接请求外部模型提供方,并自行完成工具调用循环,那么中间层就不会看到这个 HTMLResponse。此时,Pipe 需要自己发出 embed 事件。

基本模式

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)

    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]},
            })

            return json.dumps({
                "status": "success",
                "message": f"{tool_call.name}: UI rendered successfully.",
            })

    return json.dumps(result)

为什么要这样做

因为只有当 OPL 数据空间的中间件接管整个 tool call 生命周期时,process_tool_result() 才会自动识别 HTMLResponse、提取 HTML、发送 embed 事件。Pipe 自己处理工具调用时,必须手工复制这段逻辑。

关键步骤

  1. 检测返回值是否是 HTMLResponse
  2. 检查是否带有 Content-Disposition: inline
  3. 解码 HTML 内容
  4. 通过 __event_emitter__ 发送 "embeds" 事件
  5. 给 LLM 返回文字性摘要,而不是直接把原始 HTML 再塞回模型

Pipe 中也支持 (HTMLResponse, context)

如果工具返回的是 (HTMLResponse, context),你也可以在 Pipe 里拆开后同时发出嵌入与上下文结果。

iframe 高度与自动尺寸

Rich 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);
  new ResizeObserver(reportHeight).observe(document.body);
</script>

没有这段逻辑时,iframe 往往会保持默认小高度,出现裁切和滚动条。

Same-Origin 自动自适应

当用户开启 iframeSandboxAllowSameOrigin 后,父页面可以直接读取 iframe 内容高度并自动调整,无需额外脚本。但这会带来更高的安全风险。

沙箱与安全

嵌入 iframe 运行在 sandbox 中。默认启用的标志包括:

  • allow-scripts
  • allow-popups
  • allow-downloads

设置 → 界面 中,用户还能切换两个额外能力:

Setting默认值说明
Allow Iframe Same-Origin Access关闭允许 iframe 与父页面共享上下文
Allow Iframe Form Submissions关闭允许 iframe 内表单提交

allowSameOrigin

这是最重要的安全选项之一,默认关闭。

关闭时(默认)

  • iframe 与父页面完全隔离
  • 无法读取父页面的 cookies、localStorage 或 DOM
  • 父页面也不能直接读 iframe 高度
  • 最安全,适合大多数场景

开启时

  • iframe 可以与父页面上下文交互
  • 无需额外脚本即可自动调整高度
  • 检测到 Chart.js / Alpine.js 时可自动注入依赖
  • 风险更高,只适合你信任嵌入内容的场景
Same-Origin 关闭时的实际影响

默认沙箱非常严格,这意味着:

  • iframe 内下载文件可能很困难,尤其在 iOS 上几乎不可行
  • embed 内 JavaScript 无法直接操作 OPL 数据空间前端
  • 跨 frame 交互基本只能依赖 postMessage

如果你需要让嵌入内容触发下载、读写父页面状态、或更深度参与前端交互,那么就需要开启 same-origin。

社区参考

Inline Visualizer v2 是一个很好的 Rich UI 参考实现。它展示了:

  • 流式 HTML/SVG 增量渲染
  • 与父页面的双向桥接
  • 本地状态持久化
  • 增量 DOM 协调而不重复挂载组件

如果你在评估“这个生成式 UI 能不能只靠插件完成”,这个案例通常能给你非常实际的答案。