From 28d55c1469969ce4add3d49f6694a96464ac1c33 Mon Sep 17 00:00:00 2001 From: fujie Date: Mon, 5 Jan 2026 09:01:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20JavaScript=20?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=20PoC=EF=BC=8C=E6=94=AF=E6=8C=81=E9=80=9A?= =?UTF-8?q?=E8=BF=87=20API=20=E6=9B=B4=E6=96=B0=E6=B6=88=E6=81=AF=E5=86=85?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/js-visualization-guide.md | 290 ++++++++++++++++++ .../actions/js-render-poc/js_render_poc.py | 257 ++++++++++++++++ 2 files changed, 547 insertions(+) create mode 100644 docs/js-visualization-guide.md create mode 100644 plugins/actions/js-render-poc/js_render_poc.py diff --git a/docs/js-visualization-guide.md b/docs/js-visualization-guide.md new file mode 100644 index 0000000..3fbb642 --- /dev/null +++ b/docs/js-visualization-guide.md @@ -0,0 +1,290 @@ +# 使用 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](../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 获取 | +| 灵活性 | 低 | 高 | +| 实时性 | 一次性 | 可多次更新 | +| 复杂度 | 简单 | 中等 | +| 竞态风险 | 低 | ⚠️ 需要处理 | diff --git a/plugins/actions/js-render-poc/js_render_poc.py b/plugins/actions/js-render-poc/js_render_poc.py new file mode 100644 index 0000000..fdb1cd9 --- /dev/null +++ b/plugins/actions/js-render-poc/js_render_poc.py @@ -0,0 +1,257 @@ +""" +title: JS Render PoC +author: Fu-Jie +version: 0.6.0 +description: Proof of concept for JS rendering + API write-back pattern. JS renders SVG and updates message via API. +""" + +import time +import json +import logging +from typing import Optional, Callable, Awaitable, Any +from pydantic import BaseModel, Field +from fastapi import Request + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class Action: + class Valves(BaseModel): + pass + + def __init__(self): + self.valves = self.Valves() + + def _extract_chat_id(self, body: dict, metadata: Optional[dict]) -> str: + """Extract chat_id from body or metadata""" + if isinstance(body, dict): + # body["chat_id"] 是 chat_id + chat_id = body.get("chat_id") + if isinstance(chat_id, str) and chat_id.strip(): + return chat_id.strip() + + body_metadata = body.get("metadata", {}) + if isinstance(body_metadata, dict): + chat_id = body_metadata.get("chat_id") + if isinstance(chat_id, str) and chat_id.strip(): + return chat_id.strip() + + if isinstance(metadata, dict): + chat_id = metadata.get("chat_id") + if isinstance(chat_id, str) and chat_id.strip(): + return chat_id.strip() + + return "" + + def _extract_message_id(self, body: dict, metadata: Optional[dict]) -> str: + """Extract message_id from body or metadata""" + if isinstance(body, dict): + # body["id"] 是 message_id + message_id = body.get("id") + if isinstance(message_id, str) and message_id.strip(): + return message_id.strip() + + body_metadata = body.get("metadata", {}) + if isinstance(body_metadata, dict): + message_id = body_metadata.get("message_id") + if isinstance(message_id, str) and message_id.strip(): + return message_id.strip() + + if isinstance(metadata, dict): + message_id = metadata.get("message_id") + if isinstance(message_id, str) and message_id.strip(): + return message_id.strip() + + return "" + + 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: + """ + PoC: Use __event_call__ to execute JS that renders SVG and updates message via API. + """ + # 准备调试数据 + body_for_log = {} + for k, v in body.items(): + if k == "messages": + body_for_log[k] = f"[{len(v)} messages]" + else: + body_for_log[k] = v + + body_json = json.dumps(body_for_log, ensure_ascii=False, default=str) + metadata_json = ( + json.dumps(__metadata__, ensure_ascii=False, default=str) + if __metadata__ + else "null" + ) + + # 转义 JSON 中的特殊字符以便嵌入 JS + body_json_escaped = ( + body_json.replace("\\", "\\\\").replace("`", "\\`").replace("${", "\\${") + ) + metadata_json_escaped = ( + metadata_json.replace("\\", "\\\\") + .replace("`", "\\`") + .replace("${", "\\${") + ) + + chat_id = self._extract_chat_id(body, __metadata__) + message_id = self._extract_message_id(body, __metadata__) + + unique_id = f"poc_{int(time.time() * 1000)}" + + if __event_emitter__: + await __event_emitter__( + { + "type": "status", + "data": {"description": "🔄 正在渲染...", "done": False}, + } + ) + + if __event_call__: + await __event_call__( + { + "type": "execute", + "data": { + "code": f""" +(async function() {{ + const uniqueId = "{unique_id}"; + const chatId = "{chat_id}"; + const messageId = "{message_id}"; + + // ===== DEBUG: 输出 Python 端的数据 ===== + console.log("[JS Render PoC] ===== DEBUG INFO (from Python) ====="); + console.log("[JS Render PoC] body:", `{body_json_escaped}`); + console.log("[JS Render PoC] __metadata__:", `{metadata_json_escaped}`); + console.log("[JS Render PoC] Extracted: chatId=", chatId, "messageId=", messageId); + console.log("[JS Render PoC] ========================================="); + + try {{ + console.log("[JS Render PoC] Starting SVG render..."); + + // Create SVG + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("width", "200"); + svg.setAttribute("height", "200"); + svg.setAttribute("viewBox", "0 0 200 200"); + svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + + const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); + const gradient = document.createElementNS("http://www.w3.org/2000/svg", "linearGradient"); + gradient.setAttribute("id", "grad-" + uniqueId); + gradient.innerHTML = ` + + + `; + defs.appendChild(gradient); + svg.appendChild(defs); + + const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circle.setAttribute("cx", "100"); + circle.setAttribute("cy", "100"); + circle.setAttribute("r", "80"); + circle.setAttribute("fill", `url(#grad-${{uniqueId}})`); + svg.appendChild(circle); + + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("x", "100"); + text.setAttribute("y", "105"); + text.setAttribute("text-anchor", "middle"); + text.setAttribute("fill", "white"); + text.setAttribute("font-size", "16"); + text.setAttribute("font-weight", "bold"); + text.textContent = "PoC Success!"; + svg.appendChild(text); + + // Convert to Base64 Data URI + const svgData = new XMLSerializer().serializeToString(svg); + const base64 = btoa(unescape(encodeURIComponent(svgData))); + const dataUri = "data:image/svg+xml;base64," + base64; + + console.log("[JS Render PoC] SVG rendered, data URI length:", dataUri.length); + + // Call API - 完全替换方案(更稳定) + if (chatId && messageId) {{ + const token = localStorage.getItem("token"); + + // 1. 获取当前消息内容 + const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{ + method: "GET", + headers: {{ "Authorization": `Bearer ${{token}}` }} + }}); + + if (!getResponse.ok) {{ + throw new Error("Failed to get chat data: " + getResponse.status); + }} + + const chatData = await getResponse.json(); + console.log("[JS Render PoC] Got chat data"); + + 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; + console.log("[JS Render PoC] Found original content, length:", originalContent.length); + }} + }} + + // 2. 移除已存在的 PoC 图片(如果有的话) + // 匹配 ![JS Render PoC 生成的 SVG](data:...) 格式 + const pocImagePattern = /\\n*!\\[JS Render PoC[^\\]]*\\]\\(data:image\\/svg\\+xml;base64,[^)]+\\)/g; + let cleanedContent = originalContent.replace(pocImagePattern, ""); + // 移除可能残留的多余空行 + cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim(); + + if (cleanedContent !== originalContent) {{ + console.log("[JS Render PoC] Removed existing PoC image(s)"); + }} + + // 3. 添加新的 Markdown 图片 + const markdownImage = `![JS Render PoC 生成的 SVG](${{dataUri}})`; + const newContent = cleanedContent + "\\n\\n" + markdownImage; + + // 3. 使用 chat:message 完全替换 + const updateResponse = 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 (updateResponse.ok) {{ + console.log("[JS Render PoC] ✅ Message updated successfully!"); + }} else {{ + console.error("[JS Render PoC] API error:", updateResponse.status, await updateResponse.text()); + }} + }} else {{ + console.warn("[JS Render PoC] ⚠️ Missing chatId or messageId, cannot persist."); + }} + + }} catch (error) {{ + console.error("[JS Render PoC] Error:", error); + }} +}})(); + """ + }, + } + ) + + if __event_emitter__: + await __event_emitter__( + {"type": "status", "data": {"description": "✅ 渲染完成", "done": True}} + ) + + return body