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

## 概述

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

## 核心架构

```mermaid
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 代码让浏览器执行：

```python
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 库：

```javascript
// 加载 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

```javascript
// 创建 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 获取：

```javascript
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 更新消息

```javascript
// 构造新内容：原始内容 + 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](https://github.com/Fu-Jie/openwebui-extensions/blob/main/plugins/actions/js-render-poc/js_render_poc.py) 获取完整的 PoC 实现。

## 事件类型

| 类型 | 用途 |
|------|------|
| `chat:message:delta` | 增量更新（追加文本） |
| `chat:message` | 完全替换消息内容 |

```javascript
// 增量更新
{ 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 结构示例

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

### 常用事件

```python
# 发送状态更新
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 内不能直接使用反斜杠，需要将转义字符串预先处理：

```python
# 转义 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 获取 |
| 灵活性 | 低 | 高 |
| 实时性 | 一次性 | 可多次更新 |
| 复杂度 | 简单 | 中等 |
| 竞态风险 | 低 | ⚠️ 需要处理 |
