- Add infographic_markdown.py (English) and infographic_markdown_cn.py (Chinese) - AI-powered infographic generator using AntV library - Renders SVG on frontend and embeds as Markdown Data URL image - Supports 18+ infographic templates (lists, charts, comparisons, etc.) Docs: - Add plugin README.md and README_CN.md - Add docs detail pages (infographic-markdown.md) - Update docs index pages with new plugin - Add 'JS Render to Markdown' pattern to plugin development guides - Update copilot-instructions.md with new advanced development pattern Version: 1.0.0
593 lines
22 KiB
Python
593 lines
22 KiB
Python
"""
|
||
title: 📊 Infographic to Markdown
|
||
author: Fu-Jie
|
||
version: 1.0.0
|
||
description: AI生成信息图语法,前端渲染SVG并转换为Markdown图片格式嵌入消息。支持AntV Infographic模板。
|
||
"""
|
||
|
||
import time
|
||
import json
|
||
import logging
|
||
import re
|
||
from typing import Optional, Callable, Awaitable, Any, Dict
|
||
from pydantic import BaseModel, Field
|
||
from fastapi import Request
|
||
from datetime import datetime
|
||
|
||
from open_webui.utils.chat import generate_chat_completion
|
||
from open_webui.models.users import Users
|
||
|
||
logging.basicConfig(level=logging.INFO)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# =================================================================
|
||
# LLM Prompts
|
||
# =================================================================
|
||
|
||
SYSTEM_PROMPT_INFOGRAPHIC = """
|
||
You are a professional infographic design expert who can analyze user-provided text content and convert it into AntV Infographic syntax format.
|
||
|
||
## Infographic Syntax Specification
|
||
|
||
Infographic syntax is a Mermaid-like declarative syntax for describing infographic templates, data, and themes.
|
||
|
||
### Syntax Rules
|
||
- Entry uses `infographic <template-name>`
|
||
- Key-value pairs are separated by spaces, **absolutely NO colons allowed**
|
||
- Use two spaces for indentation
|
||
- Object arrays use `-` with line breaks
|
||
|
||
⚠️ **IMPORTANT WARNING: This is NOT YAML format!**
|
||
- ❌ Wrong: `children:` `items:` `data:` (with colons)
|
||
- ✅ Correct: `children` `items` `data` (without colons)
|
||
|
||
### Template Library & Selection Guide
|
||
|
||
Choose the most appropriate template based on the content structure:
|
||
|
||
#### 1. List & Hierarchy
|
||
- **List**: `list-grid` (Grid Cards), `list-vertical` (Vertical List)
|
||
- **Tree**: `tree-vertical` (Vertical Tree), `tree-horizontal` (Horizontal Tree)
|
||
- **Mindmap**: `mindmap` (Mind Map)
|
||
|
||
#### 2. Sequence & Relationship
|
||
- **Process**: `sequence-roadmap` (Roadmap), `sequence-zigzag` (Zigzag Process)
|
||
- **Relationship**: `relation-sankey` (Sankey Diagram), `relation-circle` (Circular)
|
||
|
||
#### 3. Comparison & Analysis
|
||
- **Comparison**: `compare-binary` (Binary Comparison)
|
||
- **Analysis**: `compare-swot` (SWOT Analysis), `quadrant-quarter` (Quadrant Chart)
|
||
|
||
#### 4. Charts & Data
|
||
- **Charts**: `chart-bar`, `chart-column`, `chart-line`, `chart-pie`, `chart-doughnut`, `chart-area`
|
||
|
||
### Data Structure Examples
|
||
|
||
#### A. Standard List/Tree
|
||
```infographic
|
||
infographic list-grid
|
||
data
|
||
title Project Modules
|
||
items
|
||
- label Module A
|
||
desc Description of A
|
||
- label Module B
|
||
desc Description of B
|
||
```
|
||
|
||
#### B. Binary Comparison
|
||
```infographic
|
||
infographic compare-binary
|
||
data
|
||
title Advantages vs Disadvantages
|
||
items
|
||
- label Advantages
|
||
children
|
||
- label Strong R&D
|
||
desc Leading technology
|
||
- label Disadvantages
|
||
children
|
||
- label Weak brand
|
||
desc Insufficient marketing
|
||
```
|
||
|
||
#### C. Charts
|
||
```infographic
|
||
infographic chart-bar
|
||
data
|
||
title Quarterly Revenue
|
||
items
|
||
- label Q1
|
||
value 120
|
||
- label Q2
|
||
value 150
|
||
```
|
||
|
||
### Common Data Fields
|
||
- `label`: Main title/label (Required)
|
||
- `desc`: Description text (max 30 Chinese chars / 60 English chars for `list-grid`)
|
||
- `value`: Numeric value (for charts)
|
||
- `children`: Nested items
|
||
|
||
## Output Requirements
|
||
1. **Language**: Output content in the user's language.
|
||
2. **Format**: Wrap output in ```infographic ... ```.
|
||
3. **No Colons**: Do NOT use colons after keys.
|
||
4. **Indentation**: Use 2 spaces.
|
||
"""
|
||
|
||
USER_PROMPT_GENERATE = """
|
||
Please analyze the following text content and convert its core information into AntV Infographic syntax format.
|
||
|
||
---
|
||
**User Context:**
|
||
User Name: {user_name}
|
||
Current Date/Time: {current_date_time_str}
|
||
User Language: {user_language}
|
||
---
|
||
|
||
**Text Content:**
|
||
{long_text_content}
|
||
|
||
Please select the most appropriate infographic template based on text characteristics and output standard infographic syntax.
|
||
|
||
**Important Note:**
|
||
- If using `list-grid` format, ensure each card's `desc` description is limited to **maximum 30 Chinese characters** (or **approximately 60 English characters**).
|
||
- Descriptions should be concise and highlight key points.
|
||
"""
|
||
|
||
|
||
class Action:
|
||
class Valves(BaseModel):
|
||
SHOW_STATUS: bool = Field(
|
||
default=True, description="Show operation status updates in chat interface."
|
||
)
|
||
MODEL_ID: str = Field(
|
||
default="",
|
||
description="LLM model ID for text analysis. If empty, uses current conversation model.",
|
||
)
|
||
MIN_TEXT_LENGTH: int = Field(
|
||
default=50,
|
||
description="Minimum text length (characters) required for infographic analysis.",
|
||
)
|
||
MESSAGE_COUNT: int = Field(
|
||
default=1,
|
||
description="Number of recent messages to use for generation.",
|
||
)
|
||
SVG_WIDTH: int = Field(
|
||
default=800,
|
||
description="Width of generated SVG in pixels.",
|
||
)
|
||
EXPORT_FORMAT: str = Field(
|
||
default="svg",
|
||
description="Export format: 'svg' or 'png'.",
|
||
)
|
||
|
||
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)
|
||
if match:
|
||
return match.group(1).strip()
|
||
else:
|
||
logger.warning("LLM output did not follow expected format, treating entire output as syntax.")
|
||
return llm_output.strip()
|
||
|
||
def _extract_text_content(self, content) -> str:
|
||
"""Extract text from message content, supporting multimodal formats"""
|
||
if isinstance(content, str):
|
||
return content
|
||
elif isinstance(content, list):
|
||
text_parts = []
|
||
for item in content:
|
||
if isinstance(item, dict) and item.get("type") == "text":
|
||
text_parts.append(item.get("text", ""))
|
||
elif isinstance(item, str):
|
||
text_parts.append(item)
|
||
return "\n".join(text_parts)
|
||
return str(content) if content else ""
|
||
|
||
async def _emit_status(self, emitter, description: str, done: bool = False):
|
||
"""Send status update event"""
|
||
if self.valves.SHOW_STATUS and emitter:
|
||
await emitter(
|
||
{"type": "status", "data": {"description": description, "done": done}}
|
||
)
|
||
|
||
def _generate_js_code(
|
||
self,
|
||
unique_id: str,
|
||
chat_id: str,
|
||
message_id: str,
|
||
infographic_syntax: str,
|
||
svg_width: int,
|
||
export_format: str,
|
||
) -> str:
|
||
"""Generate JavaScript code for frontend SVG rendering"""
|
||
|
||
# Escape the syntax for JS embedding
|
||
syntax_escaped = (
|
||
infographic_syntax
|
||
.replace("\\", "\\\\")
|
||
.replace("`", "\\`")
|
||
.replace("${", "\\${")
|
||
.replace("</script>", "<\\/script>")
|
||
)
|
||
|
||
# Template mapping (same as infographic.py)
|
||
template_mapping_js = """
|
||
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'
|
||
};
|
||
"""
|
||
|
||
return f"""
|
||
(async function() {{
|
||
const uniqueId = "{unique_id}";
|
||
const chatId = "{chat_id}";
|
||
const messageId = "{message_id}";
|
||
const svgWidth = {svg_width};
|
||
const exportFormat = "{export_format}";
|
||
|
||
console.log("[Infographic Markdown] Starting render...");
|
||
console.log("[Infographic Markdown] chatId:", chatId, "messageId:", messageId);
|
||
|
||
try {{
|
||
// Load AntV Infographic if not loaded
|
||
if (typeof AntVInfographic === 'undefined') {{
|
||
console.log("[Infographic Markdown] Loading AntV Infographic library...");
|
||
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);
|
||
}});
|
||
console.log("[Infographic Markdown] Library loaded.");
|
||
}}
|
||
|
||
const {{ Infographic }} = AntVInfographic;
|
||
|
||
// Get infographic syntax
|
||
let syntaxContent = `{syntax_escaped}`;
|
||
console.log("[Infographic Markdown] Original syntax:", syntaxContent.substring(0, 200) + "...");
|
||
|
||
// Clean up syntax
|
||
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 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')) {{
|
||
syntaxContent = 'infographic list-grid\\n' + syntaxContent;
|
||
}}
|
||
|
||
// Apply template mapping
|
||
{template_mapping_js}
|
||
|
||
for (const [key, value] of Object.entries(TEMPLATE_MAPPING)) {{
|
||
const regex = new RegExp(`infographic\\\\s+${{key}}(?=\\\\s|$)`, 'i');
|
||
if (regex.test(syntaxContent)) {{
|
||
console.log(`[Infographic Markdown] Auto-mapping: ${{key}} -> ${{value}}`);
|
||
syntaxContent = syntaxContent.replace(regex, `infographic ${{value}}`);
|
||
break;
|
||
}}
|
||
}}
|
||
|
||
console.log("[Infographic Markdown] Cleaned syntax:", syntaxContent.substring(0, 200) + "...");
|
||
|
||
// 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;';
|
||
document.body.appendChild(container);
|
||
|
||
// Create and render infographic
|
||
const instance = new Infographic({{
|
||
container: '#' + container.id,
|
||
width: svgWidth,
|
||
padding: 24,
|
||
}});
|
||
|
||
console.log("[Infographic Markdown] Rendering infographic...");
|
||
instance.render(syntaxContent);
|
||
|
||
// Wait for render and export
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
|
||
let dataUrl;
|
||
if (exportFormat === 'png') {{
|
||
dataUrl = await instance.toDataURL({{ type: 'png', dpr: 2 }});
|
||
}} else {{
|
||
dataUrl = await instance.toDataURL({{ type: 'svg', embedResources: true }});
|
||
}}
|
||
|
||
console.log("[Infographic Markdown] Data URL generated, length:", dataUrl.length);
|
||
|
||
// Cleanup
|
||
instance.destroy();
|
||
document.body.removeChild(container);
|
||
|
||
// Generate markdown image
|
||
const markdownImage = ``;
|
||
|
||
// Update message via API
|
||
if (chatId && messageId) {{
|
||
const token = localStorage.getItem("token");
|
||
|
||
// Get current message content
|
||
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 originalContent = "";
|
||
|
||
if (chatData.chat && chatData.chat.messages) {{
|
||
const targetMsg = chatData.chat.messages.find(m => m.id === messageId);
|
||
if (targetMsg && targetMsg.content) {{
|
||
originalContent = targetMsg.content;
|
||
}}
|
||
}}
|
||
|
||
// Remove existing infographic images
|
||
const infographicPattern = /\\n*!\\[📊[^\\]]*\\]\\(data:image\\/[^)]+\\)/g;
|
||
let cleanedContent = originalContent.replace(infographicPattern, "");
|
||
cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
|
||
|
||
// Append new image
|
||
const newContent = cleanedContent + "\\n\\n" + markdownImage;
|
||
|
||
// Update 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("[Infographic Markdown] ✅ Message updated successfully!");
|
||
}} else {{
|
||
console.error("[Infographic Markdown] API error:", updateResponse.status);
|
||
}}
|
||
}} else {{
|
||
console.warn("[Infographic Markdown] ⚠️ Missing chatId or messageId");
|
||
}}
|
||
|
||
}} catch (error) {{
|
||
console.error("[Infographic Markdown] Error:", error);
|
||
}}
|
||
}})();
|
||
"""
|
||
|
||
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:
|
||
"""
|
||
Generate infographic using AntV and embed as Markdown image.
|
||
"""
|
||
logger.info("Action: Infographic to Markdown started")
|
||
|
||
# Get user information
|
||
if isinstance(__user__, (list, tuple)):
|
||
user_language = __user__[0].get("language", "en") if __user__ else "en"
|
||
user_name = __user__[0].get("name", "User") if __user__[0] else "User"
|
||
user_id = __user__[0].get("id", "unknown_user") if __user__ else "unknown_user"
|
||
elif isinstance(__user__, dict):
|
||
user_language = __user__.get("language", "en")
|
||
user_name = __user__.get("name", "User")
|
||
user_id = __user__.get("id", "unknown_user")
|
||
else:
|
||
user_language = "en"
|
||
user_name = "User"
|
||
user_id = "unknown_user"
|
||
|
||
# Get current time
|
||
now = datetime.now()
|
||
current_date_time_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
try:
|
||
messages = body.get("messages", [])
|
||
if not messages:
|
||
raise ValueError("No messages available.")
|
||
|
||
# Get recent messages
|
||
message_count = min(self.valves.MESSAGE_COUNT, len(messages))
|
||
recent_messages = messages[-message_count:]
|
||
|
||
# Aggregate content
|
||
aggregated_parts = []
|
||
for msg in recent_messages:
|
||
text_content = self._extract_text_content(msg.get("content"))
|
||
if text_content:
|
||
aggregated_parts.append(text_content)
|
||
|
||
if not aggregated_parts:
|
||
raise ValueError("No text content found in messages.")
|
||
|
||
long_text_content = "\n\n---\n\n".join(aggregated_parts)
|
||
|
||
# Remove existing HTML blocks
|
||
parts = re.split(r"```html.*?```", long_text_content, flags=re.DOTALL)
|
||
clean_content = ""
|
||
for part in reversed(parts):
|
||
if part.strip():
|
||
clean_content = part.strip()
|
||
break
|
||
|
||
if not clean_content:
|
||
clean_content = long_text_content.strip()
|
||
|
||
# Check minimum length
|
||
if len(clean_content) < self.valves.MIN_TEXT_LENGTH:
|
||
await self._emit_status(
|
||
__event_emitter__,
|
||
f"⚠️ 内容太短 ({len(clean_content)} 字符),至少需要 {self.valves.MIN_TEXT_LENGTH} 字符",
|
||
True,
|
||
)
|
||
return body
|
||
|
||
await self._emit_status(__event_emitter__, "📊 正在分析内容...", False)
|
||
|
||
# Generate infographic syntax via LLM
|
||
formatted_user_prompt = USER_PROMPT_GENERATE.format(
|
||
user_name=user_name,
|
||
current_date_time_str=current_date_time_str,
|
||
user_language=user_language,
|
||
long_text_content=clean_content,
|
||
)
|
||
|
||
target_model = self.valves.MODEL_ID or body.get("model")
|
||
|
||
llm_payload = {
|
||
"model": target_model,
|
||
"messages": [
|
||
{"role": "system", "content": SYSTEM_PROMPT_INFOGRAPHIC},
|
||
{"role": "user", "content": formatted_user_prompt},
|
||
],
|
||
"stream": False,
|
||
}
|
||
|
||
user_obj = Users.get_user_by_id(user_id)
|
||
if not user_obj:
|
||
raise ValueError(f"Unable to get user object: {user_id}")
|
||
|
||
await self._emit_status(__event_emitter__, "📊 AI 正在生成信息图语法...", False)
|
||
|
||
llm_response = await generate_chat_completion(__request__, llm_payload, user_obj)
|
||
|
||
if not llm_response or "choices" not in llm_response or not llm_response["choices"]:
|
||
raise ValueError("Invalid LLM response.")
|
||
|
||
assistant_content = llm_response["choices"][0]["message"]["content"]
|
||
infographic_syntax = self._extract_infographic_syntax(assistant_content)
|
||
|
||
logger.info(f"Generated syntax: {infographic_syntax[:200]}...")
|
||
|
||
# Extract IDs for API callback
|
||
chat_id = self._extract_chat_id(body, __metadata__)
|
||
message_id = self._extract_message_id(body, __metadata__)
|
||
unique_id = f"ig_{int(time.time() * 1000)}"
|
||
|
||
await self._emit_status(__event_emitter__, "📊 正在渲染 SVG...", False)
|
||
|
||
# Execute JS to render and embed
|
||
if __event_call__:
|
||
js_code = self._generate_js_code(
|
||
unique_id=unique_id,
|
||
chat_id=chat_id,
|
||
message_id=message_id,
|
||
infographic_syntax=infographic_syntax,
|
||
svg_width=self.valves.SVG_WIDTH,
|
||
export_format=self.valves.EXPORT_FORMAT,
|
||
)
|
||
|
||
await __event_call__(
|
||
{
|
||
"type": "execute",
|
||
"data": {"code": js_code},
|
||
}
|
||
)
|
||
|
||
await self._emit_status(__event_emitter__, "✅ 信息图生成完成!", True)
|
||
logger.info("Infographic to Markdown completed")
|
||
|
||
except Exception as e:
|
||
error_message = f"Infographic generation failed: {str(e)}"
|
||
logger.error(error_message, exc_info=True)
|
||
await self._emit_status(__event_emitter__, f"❌ {error_message}", True)
|
||
|
||
return body
|