feat: update Smart Infographic to v1.4.0 with static image output support

This commit is contained in:
fujie
2026-01-06 22:35:31 +08:00
parent 9375df709f
commit 57ebf24c75
8 changed files with 832 additions and 10 deletions

View File

@@ -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>", "<\\/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 = `![📊 Infographic](${{imageUrl}})`;
// 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}"