diff --git a/docs/plugins/actions/index.md b/docs/plugins/actions/index.md
index c206629..ee452c6 100644
--- a/docs/plugins/actions/index.md
+++ b/docs/plugins/actions/index.md
@@ -33,7 +33,7 @@ Actions are interactive plugins that:
Transform text into professional infographics using AntV visualization engine with various templates.
- **Version:** 1.3.0
+ **Version:** 1.4.0
[:octicons-arrow-right-24: Documentation](smart-infographic.md)
diff --git a/docs/plugins/actions/index.zh.md b/docs/plugins/actions/index.zh.md
index 33747a0..66c932f 100644
--- a/docs/plugins/actions/index.zh.md
+++ b/docs/plugins/actions/index.zh.md
@@ -33,7 +33,7 @@ Actions 是交互式插件,能够:
使用 AntV 可视化引擎,将文本转成专业的信息图。
- **版本:** 1.3.0
+ **版本:** 1.4.0
[:octicons-arrow-right-24: 查看文档](smart-infographic.md)
diff --git a/docs/plugins/actions/smart-infographic.md b/docs/plugins/actions/smart-infographic.md
index 095f047..031b96d 100644
--- a/docs/plugins/actions/smart-infographic.md
+++ b/docs/plugins/actions/smart-infographic.md
@@ -1,7 +1,7 @@
# Smart Infographic
Action
-v1.3.0
+v1.4.0
An AntV Infographic engine powered plugin that transforms long text into professional, beautiful infographics with a single click.
@@ -19,6 +19,8 @@ The Smart Infographic plugin uses AI to analyze text content and generate profes
- :material-download: **Multi-Format Export**: Download your infographics as **SVG**, **PNG**, or **Standalone HTML** file
- :material-theme-light-dark: **Theme Support**: Supports Dark/Light modes, auto-adapts theme colors
- :material-cellphone-link: **Responsive Design**: Generated charts look great on both desktop and mobile devices
+- :material-image: **Image Embedding**: Option to embed charts as static images for better compatibility
+- :material-monitor-screenshot: **Adaptive Sizing**: Images automatically adapt to the chat container width
---
@@ -60,6 +62,7 @@ The Smart Infographic plugin uses AI to analyze text content and generate profes
| `MIN_TEXT_LENGTH` | integer | `100` | Minimum characters required to trigger analysis |
| `CLEAR_PREVIOUS_HTML` | boolean | `false` | Whether to clear previous charts |
| `MESSAGE_COUNT` | integer | `1` | Number of recent messages to use for analysis |
+| `OUTPUT_MODE` | string | `html` | `html` for interactive chart (default), `image` for static image embedding |
---
diff --git a/docs/plugins/actions/smart-infographic.zh.md b/docs/plugins/actions/smart-infographic.zh.md
index d2c5ecd..c96159a 100644
--- a/docs/plugins/actions/smart-infographic.zh.md
+++ b/docs/plugins/actions/smart-infographic.zh.md
@@ -1,7 +1,7 @@
# Smart Infographic(智能信息图)
Action
-v1.0.0
+v1.4.0
基于 AntV 信息图引擎,将长文本一键转成专业、美观的信息图。
@@ -19,6 +19,8 @@ Smart Infographic 使用 AI 分析文本,并基于 AntV 可视化引擎生成
- :material-download: **多格式导出**:支持下载 **SVG**、**PNG**、**独立 HTML**
- :material-theme-light-dark: **主题支持**:适配深色/浅色模式
- :material-cellphone-link: **响应式**:桌面与移动端都能良好展示
+- :material-image: **图片嵌入**:支持将图表作为静态图片嵌入,兼容性更好
+- :material-monitor-screenshot: **自适应尺寸**:图片模式下自动适应聊天容器宽度
---
@@ -60,6 +62,7 @@ Smart Infographic 使用 AI 分析文本,并基于 AntV 可视化引擎生成
| `MIN_TEXT_LENGTH` | integer | `100` | 触发分析的最小字符数 |
| `CLEAR_PREVIOUS_HTML` | boolean | `false` | 是否清空之前生成的图表 |
| `MESSAGE_COUNT` | integer | `1` | 参与分析的最近消息条数 |
+| `OUTPUT_MODE` | string | `html` | `html` 为交互式图表(默认),`image` 为静态图片嵌入 |
---
diff --git a/plugins/actions/infographic/README.md b/plugins/actions/infographic/README.md
index a2f1f42..fdd8d9a 100644
--- a/plugins/actions/infographic/README.md
+++ b/plugins/actions/infographic/README.md
@@ -38,6 +38,7 @@ You can adjust the following parameters in the plugin settings to optimize the g
| **Min Text Length (MIN_TEXT_LENGTH)** | `100` | Minimum characters required to trigger analysis, preventing accidental triggers on short text. |
| **Clear Previous (CLEAR_PREVIOUS_HTML)** | `False` | Whether to clear previous charts. If `False`, new charts will be appended below. |
| **Message Count (MESSAGE_COUNT)** | `1` | Number of recent messages to use for analysis. Increase this for more context. |
+| **Output Mode (OUTPUT_MODE)** | `html` | `html` for interactive chart (default), `image` for static image embedding (useful for mobile/non-html clients). |
## 📝 Syntax Example (For Advanced Users)
@@ -66,6 +67,12 @@ MIT License
## Changelog
+### v1.4.0
+
+- ✨ Added **Image Output Mode**: Support embedding infographics as static images (SVG) for better compatibility.
+- 📱 Added **Responsive Sizing**: Images now auto-adapt to the chat container width.
+- 🔧 Added `OUTPUT_MODE` valve configuration.
+
### v1.3.2
- Removed debug messages from output
diff --git a/plugins/actions/infographic/README_CN.md b/plugins/actions/infographic/README_CN.md
index 544017a..9160bae 100644
--- a/plugins/actions/infographic/README_CN.md
+++ b/plugins/actions/infographic/README_CN.md
@@ -38,6 +38,7 @@
| **最小文本长度 (MIN_TEXT_LENGTH)** | `100` | 触发分析所需的最小字符数,防止对过短的对话误操作。 |
| **清除旧结果 (CLEAR_PREVIOUS_HTML)** | `False` | 每次生成是否清除之前的图表。若为 `False`,新图表将追加在下方。 |
| **上下文消息数 (MESSAGE_COUNT)** | `1` | 用于分析的最近消息条数。增加此值可让 AI 参考更多对话背景。 |
+| **输出模式 (OUTPUT_MODE)** | `html` | `html` 为交互式图表(默认),`image` 为静态图片嵌入(适合移动端或不支持 HTML 的客户端)。 |
## 📝 语法示例 (高级用户)
@@ -66,6 +67,12 @@ MIT License
## 更新日志
+### v1.4.0
+
+- ✨ 新增 **图片输出模式**:支持将信息图作为静态图片 (SVG) 嵌入,兼容性更好。
+- 📱 新增 **响应式尺寸**:图片模式下自动适应聊天容器宽度。
+- 🔧 新增 `OUTPUT_MODE` 配置项。
+
### v1.3.2
- 移除输出中的调试信息
diff --git a/plugins/actions/infographic/infographic.py b/plugins/actions/infographic/infographic.py
index a230f29..b8896d6 100644
--- a/plugins/actions/infographic/infographic.py
+++ b/plugins/actions/infographic/infographic.py
@@ -3,12 +3,12 @@ title: 📊 Smart Infographic (AntV)
author: jeff
author_url: https://github.com/Fu-Jie/awesome-openwebui
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPgogIDxsaW5lIHgxPSIxMiIgeTE9IjIwIiB4Mj0iMTIiIHkyPSIxMCIgLz4KICA8bGluZSB4MT0iMTgiIHkxPSIyMCIgeDI9IjE4IiB5Mj0iNCIgLz4KICA8bGluZSB4MT0iNiIgeTE9IjIwIiB4Mj0iNiIgeTI9IjE2IiAvPgo8L3N2Zz4=
-version: 1.3.2
+version: 1.4.0
description: AI-powered infographic generator based on AntV Infographic. Supports professional templates, auto-icon matching, and SVG/PNG downloads.
"""
from pydantic import BaseModel, Field
-from typing import Optional, Dict, Any
+from typing import Optional, Dict, Any, Callable, Awaitable
import logging
import time
import re
@@ -821,10 +821,54 @@ class Action:
default=1,
description="Number of recent messages to use for generation. Set to 1 for just the last message, or higher for more context.",
)
+ OUTPUT_MODE: str = Field(
+ default="html",
+ description="Output mode: 'html' for interactive HTML (default), or 'image' to embed as Markdown image.",
+ )
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):
+ 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):
+ 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 ""
+
def _extract_infographic_syntax(self, llm_output: str) -> str:
"""Extract infographic syntax from LLM output"""
match = re.search(r"```infographic\s*(.*?)\s*```", llm_output, re.DOTALL)
@@ -912,14 +956,332 @@ class Action:
return base_html.strip()
+ def _generate_image_js_code(
+ self,
+ unique_id: str,
+ chat_id: str,
+ message_id: str,
+ infographic_syntax: str,
+ ) -> str:
+ """Generate JavaScript code for frontend SVG rendering and image embedding"""
+
+ # Escape the syntax for JS embedding
+ syntax_escaped = (
+ infographic_syntax.replace("\\", "\\\\")
+ .replace("`", "\\`")
+ .replace("${", "\\${")
+ .replace("", "<\\/script>")
+ )
+
+ return f"""
+(async function() {{
+ const uniqueId = "{unique_id}";
+ const chatId = "{chat_id}";
+ const messageId = "{message_id}";
+ const defaultWidth = 1200;
+ const defaultHeight = 800;
+
+ // Auto-detect chat container width for responsive sizing
+ let svgWidth = defaultWidth;
+ let svgHeight = defaultHeight;
+ const chatContainer = document.getElementById('chat-container');
+ if (chatContainer) {{
+ const containerWidth = chatContainer.clientWidth;
+ if (containerWidth > 100) {{
+ // Use container width with some padding (90% of container)
+ svgWidth = Math.floor(containerWidth * 0.9);
+ // Maintain aspect ratio based on default dimensions
+ svgHeight = Math.floor(svgWidth * (defaultHeight / defaultWidth));
+ console.log("[Infographic Image] Auto-detected container width:", containerWidth, "-> SVG:", svgWidth, "x", svgHeight);
+ }}
+ }}
+
+ console.log("[Infographic Image] Starting render...");
+ console.log("[Infographic Image] chatId:", chatId, "messageId:", messageId);
+
+ try {{
+ // Load AntV Infographic if not loaded
+ if (typeof AntVInfographic === 'undefined') {{
+ console.log("[Infographic Image] Loading AntV Infographic...");
+ await new Promise((resolve, reject) => {{
+ const script = document.createElement('script');
+ script.src = 'https://unpkg.com/@antv/infographic@latest/dist/infographic.min.js';
+ script.onload = resolve;
+ script.onerror = reject;
+ document.head.appendChild(script);
+ }});
+ }}
+
+ const {{ Infographic }} = AntVInfographic;
+
+ // Get syntax content
+ let syntaxContent = `{syntax_escaped}`;
+ console.log("[Infographic Image] Syntax length:", syntaxContent.length);
+
+ // Clean up syntax: remove code block markers
+ const backtick = String.fromCharCode(96);
+ const prefix = backtick + backtick + backtick + 'infographic';
+ const simplePrefix = backtick + backtick + backtick;
+
+ if (syntaxContent.toLowerCase().startsWith(prefix)) {{
+ syntaxContent = syntaxContent.substring(prefix.length).trim();
+ }} else if (syntaxContent.startsWith(simplePrefix)) {{
+ syntaxContent = syntaxContent.substring(simplePrefix.length).trim();
+ }}
+
+ if (syntaxContent.endsWith(simplePrefix)) {{
+ syntaxContent = syntaxContent.substring(0, syntaxContent.length - simplePrefix.length).trim();
+ }}
+
+ // Fix syntax: remove colons after keywords
+ syntaxContent = syntaxContent.replace(/^(data|items|children|theme|config):/gm, '$1');
+ syntaxContent = syntaxContent.replace(/(\\s)(children|items):/g, '$1$2');
+
+ // Ensure infographic prefix
+ if (!syntaxContent.trim().toLowerCase().startsWith('infographic')) {{
+ const firstWord = syntaxContent.trim().split(/\\s+/)[0].toLowerCase();
+ if (!['data', 'theme', 'design', 'items'].includes(firstWord)) {{
+ syntaxContent = 'infographic ' + syntaxContent;
+ }}
+ }}
+
+ // Template mapping
+ const TEMPLATE_MAPPING = {{
+ 'list-grid': 'list-grid-compact-card',
+ 'list-vertical': 'list-column-simple-vertical-arrow',
+ 'tree-vertical': 'hierarchy-tree-tech-style-capsule-item',
+ 'tree-horizontal': 'hierarchy-tree-lr-tech-style-capsule-item',
+ 'mindmap': 'hierarchy-mindmap-branch-gradient-capsule-item',
+ 'sequence-roadmap': 'sequence-roadmap-vertical-simple',
+ 'sequence-zigzag': 'sequence-horizontal-zigzag-simple',
+ 'sequence-horizontal': 'sequence-horizontal-zigzag-simple',
+ 'relation-sankey': 'relation-sankey-simple',
+ 'relation-circle': 'relation-circle-icon-badge',
+ 'compare-binary': 'compare-binary-horizontal-simple-vs',
+ 'compare-swot': 'compare-swot',
+ 'quadrant-quarter': 'quadrant-quarter-simple-card',
+ 'statistic-card': 'list-grid-compact-card',
+ 'chart-bar': 'chart-bar-plain-text',
+ 'chart-column': 'chart-column-simple',
+ 'chart-line': 'chart-line-plain-text',
+ 'chart-area': 'chart-area-simple',
+ 'chart-pie': 'chart-pie-plain-text',
+ 'chart-doughnut': 'chart-pie-donut-plain-text'
+ }};
+
+ for (const [key, value] of Object.entries(TEMPLATE_MAPPING)) {{
+ const regex = new RegExp(`infographic\\\\s+${{key}}(?=\\\\s|$)`, 'i');
+ if (regex.test(syntaxContent)) {{
+ syntaxContent = syntaxContent.replace(regex, `infographic ${{value}}`);
+ break;
+ }}
+ }}
+
+ // Create offscreen container
+ const container = document.createElement('div');
+ container.id = 'infographic-offscreen-' + uniqueId;
+ container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:' + svgWidth + 'px;height:' + svgHeight + 'px;background:#ffffff;';
+ document.body.appendChild(container);
+
+ // Create infographic instance
+ const instance = new Infographic({{
+ container: '#' + container.id,
+ width: svgWidth,
+ height: svgHeight,
+ padding: 24,
+ }});
+
+ console.log("[Infographic Image] Rendering infographic...");
+ instance.render(syntaxContent);
+
+ // Wait for render to complete
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ // Get SVG element
+ const svgEl = container.querySelector('svg');
+ if (!svgEl) {{
+ throw new Error('SVG element not found after rendering');
+ }}
+
+ // Get actual dimensions
+ const bbox = svgEl.getBoundingClientRect();
+ const width = bbox.width || svgWidth;
+ const height = bbox.height || svgHeight;
+
+ // Clone and prepare SVG for export
+ const clonedSvg = svgEl.cloneNode(true);
+ clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
+ clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
+ clonedSvg.setAttribute('width', width);
+ clonedSvg.setAttribute('height', height);
+
+ // Add background rect
+ const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
+ bgRect.setAttribute('width', '100%');
+ bgRect.setAttribute('height', '100%');
+ bgRect.setAttribute('fill', '#ffffff');
+ clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
+
+ // Serialize SVG to string
+ const svgData = new XMLSerializer().serializeToString(clonedSvg);
+
+ // Cleanup container
+ document.body.removeChild(container);
+
+ // Convert SVG string to Blob
+ const blob = new Blob([svgData], {{ type: 'image/svg+xml' }});
+ const file = new File([blob], `infographic-${{uniqueId}}.svg`, {{ type: 'image/svg+xml' }});
+
+ // Upload file to OpenWebUI API
+ console.log("[Infographic Image] Uploading SVG file...");
+ const token = localStorage.getItem("token");
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const uploadResponse = await fetch('/api/v1/files/', {{
+ method: 'POST',
+ headers: {{
+ 'Authorization': `Bearer ${{token}}`
+ }},
+ body: formData
+ }});
+
+ if (!uploadResponse.ok) {{
+ throw new Error(`Upload failed: ${{uploadResponse.statusText}}`);
+ }}
+
+ const fileData = await uploadResponse.json();
+ const fileId = fileData.id;
+ const imageUrl = `/api/v1/files/${{fileId}}/content`;
+
+ console.log("[Infographic Image] File uploaded, ID:", fileId);
+
+ // Generate markdown image with file URL
+ const markdownImage = ``;
+
+ // Update message via API
+ if (chatId && messageId) {{
+
+ // Helper function with retry logic
+ const fetchWithRetry = async (url, options, retries = 3) => {{
+ for (let i = 0; i < retries; i++) {{
+ try {{
+ const response = await fetch(url, options);
+ if (response.ok) return response;
+ if (i < retries - 1) {{
+ console.log(`[Infographic Image] Retry ${{i + 1}}/${{retries}} for ${{url}}`);
+ await new Promise(r => setTimeout(r, 1000 * (i + 1)));
+ }}
+ }} catch (e) {{
+ if (i === retries - 1) throw e;
+ await new Promise(r => setTimeout(r, 1000 * (i + 1)));
+ }}
+ }}
+ return null;
+ }};
+
+ // Get current chat data
+ 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();
+ let updatedMessages = [];
+ let newContent = "";
+
+ if (chatData.chat && chatData.chat.messages) {{
+ updatedMessages = chatData.chat.messages.map(m => {{
+ if (m.id === messageId) {{
+ const originalContent = m.content || "";
+ // Remove existing infographic images
+ const infographicPattern = /\\n*!\\[📊[^\\]]*\\]\\((?:data:image\\/[^)]+|(?:\\/api\\/v1\\/files\\/[^)]+))\\)/g;
+ let cleanedContent = originalContent.replace(infographicPattern, "");
+ cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
+ // Append new image
+ newContent = cleanedContent + "\\n\\n" + markdownImage;
+
+ // Update history object as well
+ if (chatData.chat.history && chatData.chat.history.messages) {{
+ if (chatData.chat.history.messages[messageId]) {{
+ chatData.chat.history.messages[messageId].content = newContent;
+ }}
+ }}
+
+ return {{ ...m, content: newContent }};
+ }}
+ return m;
+ }});
+ }}
+
+ if (!newContent) {{
+ console.warn("[Infographic Image] Could not find message to update");
+ return;
+ }}
+
+ // Try to update frontend display via event API
+ try {{
+ 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 }}
+ }})
+ }});
+ }} catch (eventErr) {{
+ console.log("[Infographic Image] Event API not available, continuing...");
+ }}
+
+ // Persist to database
+ const updatePayload = {{
+ chat: {{
+ ...chatData.chat,
+ messages: updatedMessages
+ }}
+ }};
+
+ const persistResponse = await fetchWithRetry(`/api/v1/chats/${{chatId}}`, {{
+ method: "POST",
+ headers: {{
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${{token}}`
+ }},
+ body: JSON.stringify(updatePayload)
+ }});
+
+ if (persistResponse && persistResponse.ok) {{
+ console.log("[Infographic Image] ✅ Message persisted successfully!");
+ }} else {{
+ console.error("[Infographic Image] ❌ Failed to persist message after retries");
+ }}
+ }} else {{
+ console.warn("[Infographic Image] ⚠️ Missing chatId or messageId, cannot persist");
+ }}
+
+ }} catch (error) {{
+ console.error("[Infographic Image] Error:", error);
+ }}
+}})();
+"""
+
async def action(
self,
body: dict,
__user__: Optional[Dict[str, Any]] = None,
__event_emitter__: Optional[Any] = None,
+ __event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
+ __metadata__: Optional[dict] = None,
__request__: Optional[Request] = None,
) -> Optional[dict]:
- logger.info("Action: Infographic started (v1.0.0)")
+ logger.info("Action: Infographic started (v1.4.0)")
# Get user information
if isinstance(__user__, (list, tuple)):
@@ -1114,6 +1476,45 @@ class Action:
user_language,
)
+ # Check output mode
+ if self.valves.OUTPUT_MODE == "image":
+ # Image mode: use JavaScript to render and embed as Markdown image
+ chat_id = self._extract_chat_id(body, body.get("metadata"))
+ message_id = self._extract_message_id(body, body.get("metadata"))
+
+ await self._emit_status(
+ __event_emitter__,
+ "📊 Infographic: Rendering image...",
+ False,
+ )
+
+ if __event_call__:
+ js_code = self._generate_image_js_code(
+ unique_id=unique_id,
+ chat_id=chat_id,
+ message_id=message_id,
+ infographic_syntax=infographic_syntax,
+ )
+
+ await __event_call__(
+ {
+ "type": "execute",
+ "data": {"code": js_code},
+ }
+ )
+
+ await self._emit_status(
+ __event_emitter__, "✅ Infographic: Image generated!", True
+ )
+ await self._emit_notification(
+ __event_emitter__,
+ f"📊 Infographic image generated, {user_name}!",
+ "success",
+ )
+ logger.info("Infographic generation completed in image mode")
+ return body
+
+ # HTML mode (default): embed as HTML block
html_embed_tag = f"```html\n{final_html}\n```"
body["messages"][-1]["content"] = f"{original_content}\n\n{html_embed_tag}"
diff --git a/plugins/actions/infographic/infographic_cn.py b/plugins/actions/infographic/infographic_cn.py
index a41fa8f..1854708 100644
--- a/plugins/actions/infographic/infographic_cn.py
+++ b/plugins/actions/infographic/infographic_cn.py
@@ -3,12 +3,12 @@ title: 📊 智能信息图 (AntV Infographic)
author: jeff
author_url: https://github.com/Fu-Jie/awesome-openwebui
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPgogIDxsaW5lIHgxPSIxMiIgeTE9IjIwIiB4Mj0iMTIiIHkyPSIxMCIgLz4KICA8bGluZSB4MT0iMTgiIHkxPSIyMCIgeDI9IjE4IiB5Mj0iNCIgLz4KICA8bGluZSB4MT0iNiIgeTE9IjIwIiB4Mj0iNiIgeTI9IjE2IiAvPgo8L3N2Zz4=
-version: 1.3.2
+version: 1.4.0
description: 基于 AntV Infographic 的智能信息图生成插件。支持多种专业模板,自动图标匹配,并提供 SVG/PNG 下载功能。
"""
from pydantic import BaseModel, Field
-from typing import Optional, Dict, Any
+from typing import Optional, Dict, Any, Callable, Awaitable
import logging
import time
import re
@@ -849,6 +849,10 @@ class Action:
default=1,
description="用于生成的最近消息数量。设置为1仅使用最后一条消息,更大值可包含更多上下文。",
)
+ OUTPUT_MODE: str = Field(
+ default="html",
+ description="输出模式:'html' 为交互式HTML(默认),'image' 将嵌入为Markdown图片。",
+ )
def __init__(self):
self.valves = self.Valves()
@@ -862,6 +866,46 @@ class Action:
"Sunday": "星期日",
}
+ def _extract_chat_id(self, body: dict, metadata: Optional[dict]) -> str:
+ """从 body 或 metadata 中提取 chat_id"""
+ if isinstance(body, dict):
+ 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:
+ """从 body 或 metadata 中提取 message_id"""
+ if isinstance(body, dict):
+ 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 ""
+
def _extract_infographic_syntax(self, llm_output: str) -> str:
"""提取LLM输出中的infographic语法"""
# 1. 优先匹配 ```infographic
@@ -973,14 +1017,332 @@ class Action:
return base_html.strip()
+ def _generate_image_js_code(
+ self,
+ unique_id: str,
+ chat_id: str,
+ message_id: str,
+ infographic_syntax: str,
+ ) -> str:
+ """生成前端 SVG 渲染和图片嵌入的 JavaScript 代码"""
+
+ # 转义语法以便在 JS 中嵌入
+ syntax_escaped = (
+ infographic_syntax.replace("\\", "\\\\")
+ .replace("`", "\\`")
+ .replace("${", "\\${")
+ .replace("", "<\\/script>")
+ )
+
+ return f"""
+(async function() {{
+ const uniqueId = "{unique_id}";
+ const chatId = "{chat_id}";
+ const messageId = "{message_id}";
+ const defaultWidth = 1200;
+ const defaultHeight = 800;
+
+ // 自动检测聊天容器宽度以实现响应式尺寸
+ let svgWidth = defaultWidth;
+ let svgHeight = defaultHeight;
+ const chatContainer = document.getElementById('chat-container');
+ if (chatContainer) {{
+ const containerWidth = chatContainer.clientWidth;
+ if (containerWidth > 100) {{
+ // 使用容器宽度的 90%
+ svgWidth = Math.floor(containerWidth * 0.9);
+ // 根据默认尺寸保持宽高比
+ svgHeight = Math.floor(svgWidth * (defaultHeight / defaultWidth));
+ console.log("[Infographic Image] 自动检测容器宽度:", containerWidth, "-> SVG:", svgWidth, "x", svgHeight);
+ }}
+ }}
+
+ console.log("[Infographic Image] 开始渲染...");
+ console.log("[Infographic Image] chatId:", chatId, "messageId:", messageId);
+
+ try {{
+ // 加载 AntV Infographic(如果未加载)
+ if (typeof AntVInfographic === 'undefined') {{
+ console.log("[Infographic Image] 加载 AntV Infographic...");
+ await new Promise((resolve, reject) => {{
+ const script = document.createElement('script');
+ script.src = 'https://registry.npmmirror.com/@antv/infographic/0.2.1/files/dist/infographic.min.js';
+ script.onload = resolve;
+ script.onerror = reject;
+ document.head.appendChild(script);
+ }});
+ }}
+
+ const {{ Infographic }} = AntVInfographic;
+
+ // 获取语法内容
+ let syntaxContent = `{syntax_escaped}`;
+ console.log("[Infographic Image] 语法长度:", syntaxContent.length);
+
+ // 清理语法:移除代码块标记
+ const backtick = String.fromCharCode(96);
+ const prefix = backtick + backtick + backtick + 'infographic';
+ const simplePrefix = backtick + backtick + backtick;
+
+ if (syntaxContent.toLowerCase().startsWith(prefix)) {{
+ syntaxContent = syntaxContent.substring(prefix.length).trim();
+ }} else if (syntaxContent.startsWith(simplePrefix)) {{
+ syntaxContent = syntaxContent.substring(simplePrefix.length).trim();
+ }}
+
+ if (syntaxContent.endsWith(simplePrefix)) {{
+ syntaxContent = syntaxContent.substring(0, syntaxContent.length - simplePrefix.length).trim();
+ }}
+
+ // 修复语法:移除关键字后的冒号
+ syntaxContent = syntaxContent.replace(/^(data|items|children|theme|config):/gm, '$1');
+ syntaxContent = syntaxContent.replace(/(\\s)(children|items):/g, '$1$2');
+
+ // 确保 infographic 前缀
+ if (!syntaxContent.trim().toLowerCase().startsWith('infographic')) {{
+ const firstWord = syntaxContent.trim().split(/\\s+/)[0].toLowerCase();
+ if (!['data', 'theme', 'design', 'items'].includes(firstWord)) {{
+ syntaxContent = 'infographic ' + syntaxContent;
+ }}
+ }}
+
+ // 模板映射
+ const TEMPLATE_MAPPING = {{
+ 'list-grid': 'list-grid-compact-card',
+ 'list-vertical': 'list-column-simple-vertical-arrow',
+ 'tree-vertical': 'hierarchy-tree-tech-style-capsule-item',
+ 'tree-horizontal': 'hierarchy-tree-lr-tech-style-capsule-item',
+ 'mindmap': 'hierarchy-mindmap-branch-gradient-capsule-item',
+ 'sequence-roadmap': 'sequence-roadmap-vertical-simple',
+ 'sequence-zigzag': 'sequence-horizontal-zigzag-simple',
+ 'sequence-horizontal': 'sequence-horizontal-zigzag-simple',
+ 'relation-sankey': 'relation-sankey-simple',
+ 'relation-circle': 'relation-circle-icon-badge',
+ 'compare-binary': 'compare-binary-horizontal-simple-vs',
+ 'compare-swot': 'compare-swot',
+ 'quadrant-quarter': 'quadrant-quarter-simple-card',
+ 'statistic-card': 'list-grid-compact-card',
+ 'chart-bar': 'chart-bar-plain-text',
+ 'chart-column': 'chart-column-simple',
+ 'chart-line': 'chart-line-plain-text',
+ 'chart-area': 'chart-area-simple',
+ 'chart-pie': 'chart-pie-plain-text',
+ 'chart-doughnut': 'chart-pie-donut-plain-text'
+ }};
+
+ for (const [key, value] of Object.entries(TEMPLATE_MAPPING)) {{
+ const regex = new RegExp(`infographic\\\\s+${{key}}(?=\\\\s|$)`, 'i');
+ if (regex.test(syntaxContent)) {{
+ syntaxContent = syntaxContent.replace(regex, `infographic ${{value}}`);
+ break;
+ }}
+ }}
+
+ // 创建离屏容器
+ const container = document.createElement('div');
+ container.id = 'infographic-offscreen-' + uniqueId;
+ container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:' + svgWidth + 'px;height:' + svgHeight + 'px;background:#ffffff;';
+ document.body.appendChild(container);
+
+ // 创建信息图实例
+ const instance = new Infographic({{
+ container: '#' + container.id,
+ width: svgWidth,
+ height: svgHeight,
+ padding: 24,
+ }});
+
+ console.log("[Infographic Image] 渲染信息图...");
+ instance.render(syntaxContent);
+
+ // 等待渲染完成
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ // 获取 SVG 元素
+ const svgEl = container.querySelector('svg');
+ if (!svgEl) {{
+ throw new Error('渲染后未找到 SVG 元素');
+ }}
+
+ // 获取实际尺寸
+ const bbox = svgEl.getBoundingClientRect();
+ const width = bbox.width || svgWidth;
+ const height = bbox.height || svgHeight;
+
+ // 克隆并准备导出的 SVG
+ const clonedSvg = svgEl.cloneNode(true);
+ clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
+ clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
+ clonedSvg.setAttribute('width', width);
+ clonedSvg.setAttribute('height', height);
+
+ // 添加背景矩形
+ const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
+ bgRect.setAttribute('width', '100%');
+ bgRect.setAttribute('height', '100%');
+ bgRect.setAttribute('fill', '#ffffff');
+ clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
+
+ // 序列化 SVG 为字符串
+ const svgData = new XMLSerializer().serializeToString(clonedSvg);
+
+ // 清理容器
+ document.body.removeChild(container);
+
+ // 将 SVG 字符串转换为 Blob
+ const blob = new Blob([svgData], {{ type: 'image/svg+xml' }});
+ const file = new File([blob], `infographic-${{uniqueId}}.svg`, {{ type: 'image/svg+xml' }});
+
+ // 上传文件到 OpenWebUI API
+ console.log("[Infographic Image] 上传 SVG 文件...");
+ const token = localStorage.getItem("token");
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const uploadResponse = await fetch('/api/v1/files/', {{
+ method: 'POST',
+ headers: {{
+ 'Authorization': `Bearer ${{token}}`
+ }},
+ body: formData
+ }});
+
+ if (!uploadResponse.ok) {{
+ throw new Error(`上传失败: ${{uploadResponse.statusText}}`);
+ }}
+
+ const fileData = await uploadResponse.json();
+ const fileId = fileData.id;
+ const imageUrl = `/api/v1/files/${{fileId}}/content`;
+
+ console.log("[Infographic Image] 文件已上传, ID:", fileId);
+
+ // 生成带文件 URL 的 markdown 图片
+ const markdownImage = ``;
+
+ // 通过 API 更新消息
+ if (chatId && messageId) {{
+
+ // 带重试逻辑的辅助函数
+ const fetchWithRetry = async (url, options, retries = 3) => {{
+ for (let i = 0; i < retries; i++) {{
+ try {{
+ const response = await fetch(url, options);
+ if (response.ok) return response;
+ if (i < retries - 1) {{
+ console.log(`[Infographic Image] 重试 ${{i + 1}}/${{retries}} for ${{url}}`);
+ await new Promise(r => setTimeout(r, 1000 * (i + 1)));
+ }}
+ }} catch (e) {{
+ if (i === retries - 1) throw e;
+ await new Promise(r => setTimeout(r, 1000 * (i + 1)));
+ }}
+ }}
+ return null;
+ }};
+
+ // 获取当前聊天数据
+ const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{
+ method: "GET",
+ headers: {{ "Authorization": `Bearer ${{token}}` }}
+ }});
+
+ if (!getResponse.ok) {{
+ throw new Error("获取聊天数据失败: " + getResponse.status);
+ }}
+
+ const chatData = await getResponse.json();
+ let updatedMessages = [];
+ let newContent = "";
+
+ if (chatData.chat && chatData.chat.messages) {{
+ updatedMessages = chatData.chat.messages.map(m => {{
+ if (m.id === messageId) {{
+ const originalContent = m.content || "";
+ // 移除已有的信息图图片
+ const infographicPattern = /\\n*!\\[📊[^\\]]*\\]\\((?:data:image\\/[^)]+|(?:\\/api\\/v1\\/files\\/[^)]+))\\)/g;
+ let cleanedContent = originalContent.replace(infographicPattern, "");
+ cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
+ // 追加新图片
+ newContent = cleanedContent + "\\n\\n" + markdownImage;
+
+ // 同时更新 history 对象
+ if (chatData.chat.history && chatData.chat.history.messages) {{
+ if (chatData.chat.history.messages[messageId]) {{
+ chatData.chat.history.messages[messageId].content = newContent;
+ }}
+ }}
+
+ return {{ ...m, content: newContent }};
+ }}
+ return m;
+ }});
+ }}
+
+ if (!newContent) {{
+ console.warn("[Infographic Image] 找不到要更新的消息");
+ return;
+ }}
+
+ // 尝试通过事件 API 更新前端显示
+ try {{
+ 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 }}
+ }})
+ }});
+ }} catch (eventErr) {{
+ console.log("[Infographic Image] 事件 API 不可用,继续...");
+ }}
+
+ // 持久化到数据库
+ const updatePayload = {{
+ chat: {{
+ ...chatData.chat,
+ messages: updatedMessages
+ }}
+ }};
+
+ const persistResponse = await fetchWithRetry(`/api/v1/chats/${{chatId}}`, {{
+ method: "POST",
+ headers: {{
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${{token}}`
+ }},
+ body: JSON.stringify(updatePayload)
+ }});
+
+ if (persistResponse && persistResponse.ok) {{
+ console.log("[Infographic Image] ✅ 消息持久化成功!");
+ }} else {{
+ console.error("[Infographic Image] ❌ 重试后消息持久化失败");
+ }}
+ }} else {{
+ console.warn("[Infographic Image] ⚠️ 缺少 chatId 或 messageId,无法持久化");
+ }}
+
+ }} catch (error) {{
+ console.error("[Infographic Image] 错误:", error);
+ }}
+}})();
+"""
+
async def action(
self,
body: dict,
__user__: Optional[Dict[str, Any]] = None,
__event_emitter__: Optional[Any] = None,
+ __event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
+ __metadata__: Optional[dict] = None,
__request__: Optional[Request] = None,
) -> Optional[dict]:
- logger.info("Action: 信息图启动 (v1.0.0)")
+ logger.info("Action: 信息图启动 (v1.4.0)")
# 获取用户信息
if isinstance(__user__, (list, tuple)):
@@ -1169,6 +1531,45 @@ class Action:
user_language,
)
+ # 检查输出模式
+ if self.valves.OUTPUT_MODE == "image":
+ # 图片模式:使用 JavaScript 渲染并嵌入为 Markdown 图片
+ chat_id = self._extract_chat_id(body, body.get("metadata"))
+ message_id = self._extract_message_id(body, body.get("metadata"))
+
+ await self._emit_status(
+ __event_emitter__,
+ "📊 信息图: 正在渲染图片...",
+ False,
+ )
+
+ if __event_call__:
+ js_code = self._generate_image_js_code(
+ unique_id=unique_id,
+ chat_id=chat_id,
+ message_id=message_id,
+ infographic_syntax=infographic_syntax,
+ )
+
+ await __event_call__(
+ {
+ "type": "execute",
+ "data": {"code": js_code},
+ }
+ )
+
+ await self._emit_status(
+ __event_emitter__, "✅ 信息图: 图片生成完成!", True
+ )
+ await self._emit_notification(
+ __event_emitter__,
+ f"📊 信息图图片已生成,{user_name}!",
+ "success",
+ )
+ logger.info("信息图生成完成(图片模式)")
+ return body
+
+ # HTML 模式(默认):嵌入为 HTML 块
html_embed_tag = f"```html\n{final_html}\n```"
body["messages"][-1]["content"] = f"{original_content}\n\n{html_embed_tag}"