Files
Fu-Jie_openwebui-extensions/docs/js-visualization-guide.md

8.0 KiB
Raw Permalink Blame History

使用 JavaScript 生成可视化内容的技术方案

概述

本文档描述了在 OpenWebUI Action 插件中使用浏览器端 JavaScript 代码生成可视化内容(如思维导图、信息图等)并将结果保存到消息中的技术方案。

核心架构

sequenceDiagram
    participant Plugin as Python 插件
    participant EventCall as __event_call__
    participant Browser as 浏览器 (JS)
    participant API as OpenWebUI API
    participant DB as 数据库

    Plugin->>EventCall: 1. 发送 execute 事件 (含 JS 代码)
    EventCall->>Browser: 2. 执行 JS 代码
    Browser->>Browser: 3. 加载可视化库 (D3/Markmap/AntV)
    Browser->>Browser: 4. 渲染可视化内容
    Browser->>Browser: 5. 转换为 Base64 Data URI
    Browser->>API: 6. GET 获取当前消息内容
    API-->>Browser: 7. 返回消息数据
    Browser->>API: 8. POST 追加 Markdown 图片到消息
    API->>DB: 9. 保存更新后的消息

关键步骤

1. Python 端通过 __event_call__ 执行 JS

Python 插件不直接修改 body["messages"],而是通过 __event_call__ 发送 JS 代码让浏览器执行:

async def action(
    self,
    body: dict,
    __user__: dict = None,
    __event_emitter__=None,
    __event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
    __metadata__: Optional[dict] = None,
    __request__: Request = None,
) -> dict:
    # 从 body 获取 chat_id 和 message_id
    chat_id = body.get("chat_id", "")
    message_id = body.get("id", "")  # 注意body["id"] 是 message_id
    
    # 通过 __event_call__ 执行 JS 代码
    if __event_call__:
        await __event_call__({
            "type": "execute",
            "data": {
                "code": f"""
(async function() {{
    const chatId = "{chat_id}";
    const messageId = "{message_id}";
    // ... JS 渲染和 API 更新逻辑 ...
}})();
                """
            },
        })
    
    # 不修改 body直接返回
    return body

2. JavaScript 加载可视化库

在浏览器端动态加载所需的 JS 库:

// 加载 D3.js
if (!window.d3) {
    await new Promise((resolve, reject) => {
        const script = document.createElement('script');
        script.src = 'https://cdn.jsdelivr.net/npm/d3@7';
        script.onload = resolve;
        script.onerror = reject;
        document.head.appendChild(script);
    });
}

// 加载 Markmap (思维导图)
if (!window.markmap) {
    await loadScript('https://cdn.jsdelivr.net/npm/markmap-lib@0.17');
    await loadScript('https://cdn.jsdelivr.net/npm/markmap-view@0.17');
}

3. 渲染并转换为 Data URI

// 创建 SVG 元素
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '800');
svg.setAttribute('height', '600');
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');

// ... 执行渲染逻辑 (添加图形元素) ...

// 转换为 Base64 Data URI
const svgData = new XMLSerializer().serializeToString(svg);
const base64 = btoa(unescape(encodeURIComponent(svgData)));
const dataUri = 'data:image/svg+xml;base64,' + base64;

4. 获取当前消息内容

由于 Python 端不传递原始内容JS 需要通过 API 获取:

const token = localStorage.getItem('token');

// 获取当前聊天数据
const getResponse = await fetch(`/api/v1/chats/${chatId}`, {
    method: 'GET',
    headers: {
        'Authorization': `Bearer ${token}`
    }
});

const chatData = await getResponse.json();

// 查找目标消息
let originalContent = '';
if (chatData.chat && chatData.chat.messages) {
    const targetMsg = chatData.chat.messages.find(m => m.id === messageId);
    if (targetMsg && targetMsg.content) {
        originalContent = targetMsg.content;
    }
}

5. 调用 API 更新消息

// 构造新内容:原始内容 + Markdown 图片
const markdownImage = `![可视化图片](${dataUri})`;
const newContent = originalContent + '\n\n' + markdownImage;

// 调用 API 更新消息
const response = await fetch(`/api/v1/chats/${chatId}/messages/${messageId}/event`, {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`
    },
    body: JSON.stringify({
        type: 'chat:message',
        data: { content: newContent }
    })
});

if (response.ok) {
    console.log('消息更新成功!');
}

完整示例

参考 js_render_poc.py 获取完整的 PoC 实现。

事件类型

类型 用途
chat:message:delta 增量更新(追加文本)
chat:message 完全替换消息内容
// 增量更新
{ type: "chat:message:delta", data: { content: "追加的内容" } }

// 完全替换
{ type: "chat:message", data: { content: "完整的新内容" } }

关键数据来源

数据 来源 说明
chat_id body["chat_id"] 聊天会话 ID
message_id body["id"] ⚠️ 注意:是 body["id"],不是 body["message_id"]
token localStorage.getItem('token') 用户认证 Token
originalContent 通过 API GET /api/v1/chats/{chatId} 获取 当前消息内容

Python 端 API

参数 类型 说明
__event_emitter__ Callable 发送状态/通知事件
__event_call__ Callable 执行 JS 代码(用于可视化渲染)
__metadata__ dict 元数据(可能为 None
body dict 请求体,包含 messages、chat_id、id 等

body 结构示例

{
  "model": "gemini-3-flash-preview",
  "messages": [...],
  "chat_id": "ac2633a3-5731-4944-98e3-bf9b3f0ef0ab",
  "id": "2e0bb7d4-dfc0-43d7-b028-fd9e06c6fdc8",
  "session_id": "bX30sHI8r4_CKxCdAAAL"
}

常用事件

# 发送状态更新
await __event_emitter__({
    "type": "status",
    "data": {"description": "正在渲染...", "done": False}
})

# 执行 JS 代码
await __event_call__({
    "type": "execute",
    "data": {"code": "console.log('Hello from Python!')"}
})

# 发送通知
await __event_emitter__({
    "type": "notification",
    "data": {"type": "success", "content": "渲染完成!"}
})

适用场景

  • 思维导图 (Markmap)
  • 信息图 (AntV Infographic)
  • 流程图 (Mermaid)
  • 数据图表 (ECharts, Chart.js)
  • 任何需要 JS 渲染的可视化内容

注意事项

1. 竞态条件问题

⚠️ 多次快速点击会导致内容覆盖问题

由于 API 调用是异步的,如果用户快速多次触发 Action

  • 第一次点击:获取原始内容 A → 渲染 → 更新为 A+图片1
  • 第二次点击:可能获取到旧内容 A第一次还没保存完→ 更新为 A+图片2

结果图片1 被覆盖丢失!

解决方案

  • 添加防抖debounce机制
  • 使用锁/标志位防止重复执行
  • 或使用 chat:message:delta 增量更新

2. 不要直接修改 body["messages"]

消息更新应由 JS 通过 API 完成,确保获取最新内容。

3. f-string 限制

Python f-string 内不能直接使用反斜杠,需要将转义字符串预先处理:

# 转义 JSON 中的特殊字符
body_json = json.dumps(data, ensure_ascii=False)
escaped = body_json.replace("\\", "\\\\").replace("`", "\\`").replace("${", "\\${")

4. Data URI 大小限制

Base64 编码会增加约 33% 的体积,复杂图片可能导致消息过大。

5. 跨域问题

确保 CDN 资源支持 CORS。

6. API 权限

确保用户 token 有权限访问和更新目标消息。

与传统方式对比

特性 传统方式 (修改 body) 新方式 (event_call)
消息更新 Python 直接修改 JS 通过 API 更新
原始内容 Python 传递给 JS JS 通过 API 获取
灵活性
实时性 一次性 可多次更新
复杂度 简单 中等
竞态风险 ⚠️ 需要处理