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 = ``;
+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 图片(如果有的话)
+ // 匹配  格式
+ 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 = ``;
+ 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