""" 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 description: 基于 AntV Infographic 的智能信息图生成插件。支持多种专业模板,自动图标匹配,并提供 SVG/PNG 下载功能。 """ from pydantic import BaseModel, Field from typing import Optional, Dict, Any import logging import time import re 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, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) # ================================================================= # LLM 提示词 # ================================================================= SYSTEM_PROMPT_INFOGRAPHIC_ASSISTANT = """ 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 ` - 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 #### 1. List & Hierarchy (Text-heavy) - **Linear & Short (Steps/Phases)** -> `list-row-horizontal-icon-arrow` - **Linear & Long (Rankings/Details)** -> `list-vertical` - **Grouped / Parallel (Features/Catalog)** -> `list-grid` - **Hierarchical (Org Chart/Taxonomy)** -> `tree-vertical` or `tree-horizontal` - **Central Idea (Brainstorming)** -> `mindmap` #### 2. Sequence & Relationship (Flow-based) - **Time-based (History/Plan)** -> `sequence-roadmap-vertical-simple` - **Process Flow (Complex)** -> `sequence-zigzag` or `sequence-horizontal` - **Resource Flow / Distribution** -> `relation-sankey` - **Circular Relationship** -> `relation-circle` #### 3. Comparison & Analysis - **Binary Comparison (A vs B)** -> `compare-binary` - **SWOT Analysis** -> `compare-swot` - **Quadrant Analysis (Importance vs Urgency)** -> `quadrant-quarter` - **Multi-item Grid Comparison** -> `list-grid` (use for comparing multiple items) #### 4. Charts & Data (Metric-heavy) - **Key Metrics / Data Cards** -> `statistic-card` - **Distribution / Comparison** -> `chart-bar` or `chart-column` - **Trend over Time** -> `chart-line` or `chart-area` - **Proportion / Part-to-Whole** -> `chart-pie` or `chart-doughnut` ### Infographic Syntax Guide #### 1. Structure - **Entry**: `infographic ` - **Blocks**: `data`, `theme`, `design` (optional) - **Format**: Key-value pairs separated by spaces, 2-space indentation. - **Arrays**: Object arrays use `-` (newline), simple arrays use inline values. #### 2. Data Block (`data`) - `title`: Main title - `desc`: Subtitle or description - `items`: List of data items - - `label`: Item title - - `value`: Numerical value (required for Charts/Stats) - - `desc`: Item description (optional) - - `icon`: Icon name (e.g., `mdi/rocket-launch`) - - `time`: Time label (Optional, for Roadmap/Sequence) - - `children`: Nested items (ONLY for Tree/Mindmap/Sankey/SWOT) - - `illus`: Illustration name (ONLY for Quadrant) #### 3. Theme Block (`theme`) - `colorPrimary`: Main color (Hex) - `colorBg`: Background color (Hex) - `palette`: Color list (Space separated) - `textColor`: Text color (Hex) - `stylize`: Style effect configuration - `type`: Style type (`rough`, `pattern`, `linear-gradient`, `radial-gradient`) #### 4. Stylize Examples **Rough Style (Hand-drawn):** ```infographic infographic list-row-simple-horizontal-arrow theme stylize rough data ... ``` **Gradient Style:** ```infographic infographic chart-bar theme stylize linear-gradient data ... ``` ### Examples #### Chart (Bar Chart) infographic chart-bar data title Revenue Growth desc Monthly revenue in 2024 items - label Jan value 1200 - label Feb value 1500 - label Mar value 1800 #### Comparison (Binary Comparison) infographic compare-binary data title Advantages vs Disadvantages desc Compare two aspects side by side items - label Advantages children - label Strong R&D desc Leading technology and innovation capability - label High customer loyalty desc Repurchase rate over 60% - label Disadvantages children - label Weak brand exposure desc Insufficient marketing, low awareness - label Narrow channel coverage desc Limited online channels #### Comparison (SWOT) infographic compare-swot data title Project SWOT items - label Strengths children - label Strong team - label Innovative tech - label Weaknesses children - label Limited budget - label Opportunities children - label Emerging market - label Threats children - label High competition #### Relationship (Sankey) infographic relation-sankey data title Energy Flow items - label Solar value 100 children - label Grid value 60 - label Battery value 40 - label Wind value 80 children - label Grid value 80 #### Quadrant (Importance vs Urgency) infographic quadrant-quarter data title Task Management items - label Critical Bug desc Fix immediately illus mdi/bug - label Feature Request desc Plan for next sprint illus mdi/star ### Output Rules 1. **Strict Syntax**: Follow the indentation and formatting rules exactly. 2. **No Explanations**: Output ONLY the syntax code block. 3. **Language**: Use the user's requested language for content. """ USER_PROMPT_GENERATE_INFOGRAPHIC = """ 请分析以下文本内容,将其核心信息转换为 AntV Infographic 语法格式。 --- **用户上下文信息:** 用户姓名: {user_name} 当前日期时间: {current_date_time_str} 用户语言: {user_language} --- **文本内容:** {long_text_content} 请根据文本特点选择最合适的信息图模板,并输出规范的 infographic 语法。注意保持正确的缩进格式(两个空格)。 **重要提示:** - 如果使用 `list-grid` 格式,请确保每个卡片的 `desc` 描述文字控制在 **30个汉字**(或约60个英文字符)**以内**,以保证所有卡片描述都只占用2行,维持视觉一致性。 - 描述应简洁精炼,突出核心要点。 """ # ================================================================= # HTML 容器模板 # ================================================================= HTML_WRAPPER_TEMPLATE = """
""" # ================================================================= # CSS 样式模板 # ================================================================= CSS_TEMPLATE_INFOGRAPHIC = """ :root { --ig-primary-color: #6366f1; --ig-secondary-color: #8b5cf6; --ig-tertiary-color: #10b981; --ig-background-color: #f8fafc; --ig-card-bg-color: #ffffff; --ig-text-color: #1e293b; --ig-muted-text-color: #64748b; --ig-border-color: #e2e8f0; --ig-header-gradient: linear-gradient(135deg, #6366f1, #8b5cf6); } .infographic-container-wrapper { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.6; color: var(--ig-text-color); height: 100%; display: flex; flex-direction: column; } .infographic-container-wrapper .header { background: var(--ig-header-gradient); color: white; padding: 20px 24px; text-align: center; } .infographic-container-wrapper .header h1 { margin: 0; font-size: 1.5em; font-weight: 600; } .infographic-container-wrapper .user-context { font-size: 0.8em; color: var(--ig-muted-text-color); background-color: #f1f5f9; padding: 8px 16px; display: flex; justify-content: space-around; flex-wrap: wrap; border-bottom: 1px solid var(--ig-border-color); } .infographic-container-wrapper .content-area { padding: 20px; flex-grow: 1; } .infographic-container-wrapper .infographic-render-container { border-radius: 8px; padding: 16px; min-height: 600px; background: #fff; overflow: visible; /* Ensure content is visible */ transition: height 0.3s ease; } .infographic-render-container svg text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif !important; } .infographic-render-container svg foreignObject { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif !important; line-height: 1.4 !important; } /* 主标题样式 */ .infographic-render-container svg foreignObject[data-element-type="title"] > * { font-size: 1.5em !important; font-weight: bold !important; line-height: 1.4 !important; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; } /* 页面副标题和卡片标题样式 */ .infographic-render-container svg foreignObject[data-element-type="desc"] > *, .infographic-render-container svg foreignObject[data-element-type="item-label"] > * { font-size: 0.6em !important; line-height: 1.4 !important; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; } /* 卡片标题额外增加底部间距 */ .infographic-render-container svg foreignObject[data-element-type="item-label"] > * { padding-bottom: 8px !important; display: block !important; } /* 卡片描述文字保持正常换行 */ .infographic-render-container svg foreignObject[data-element-type="item-desc"] > * { line-height: 1.4 !important; white-space: normal !important; } .infographic-container-wrapper .download-area { text-align: center; padding-top: 20px; margin-top: 20px; border-top: 1px solid var(--ig-border-color); } .infographic-container-wrapper .download-btn { background-color: var(--ig-primary-color); color: white; border: none; padding: 8px 16px; border-radius: 6px; font-size: 0.9em; cursor: pointer; transition: all 0.2s; margin: 4px 6px; display: inline-flex; align-items: center; gap: 6px; } .infographic-container-wrapper .download-btn.secondary { background-color: var(--ig-secondary-color); } .infographic-container-wrapper .download-btn.tertiary { background-color: var(--ig-tertiary-color); } .infographic-container-wrapper .download-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0,0,0,0.1); } .infographic-container-wrapper .footer { text-align: center; padding: 16px; font-size: 0.8em; color: var(--ig-muted-text-color); background-color: #f8fafc; border-top: 1px solid var(--ig-border-color); } .infographic-container-wrapper .error-message { color: #dc2626; background-color: #fef2f2; border: 1px solid #fecaca; padding: 16px; border-radius: 8px; text-align: center; } """ # ================================================================= # HTML 内容模板 # ================================================================= CONTENT_TEMPLATE_INFOGRAPHIC = """

📊 智能信息图

用户: {user_name} 时间: {current_date_time_str}
""" # ================================================================= # JavaScript 渲染脚本 # ================================================================= SCRIPT_TEMPLATE_INFOGRAPHIC = """ """ class Action: class Valves(BaseModel): SHOW_STATUS: bool = Field( default=True, description="是否在聊天界面显示操作状态更新。" ) MODEL_ID: str = Field( default="", description="用于文本分析的内置LLM模型ID。如果为空,则使用当前对话的模型。", ) MIN_TEXT_LENGTH: int = Field( default=100, description="进行信息图分析所需的最小文本长度(字符数)。", ) CLEAR_PREVIOUS_HTML: bool = Field( default=False, description="是否强制清除旧的插件结果(如果为 True,则不合并,直接覆盖)。", ) MESSAGE_COUNT: int = Field( default=1, description="用于生成的最近消息数量。设置为1仅使用最后一条消息,更大值可包含更多上下文。", ) def __init__(self): self.valves = self.Valves() self.weekday_map = { "Monday": "星期一", "Tuesday": "星期二", "Wednesday": "星期三", "Thursday": "星期四", "Friday": "星期五", "Saturday": "星期六", "Sunday": "星期日", } def _extract_infographic_syntax(self, llm_output: str) -> str: """提取LLM输出中的infographic语法""" # 1. 优先匹配 ```infographic match = re.search(r"```infographic\s*(.*?)\s*```", llm_output, re.DOTALL) if match: return match.group(1).strip().replace("", "<\\/script>") # 2. 其次匹配 ```mermaid (有时 LLM 会混淆) match = re.search(r"```mermaid\s*(.*?)\s*```", llm_output, re.DOTALL) if match: content = match.group(1).strip() # 简单检查是否包含 infographic 关键字 if "infographic" in content or "data" in content: return content.replace("", "<\\/script>") # 3. 再次匹配通用 ``` (无语言标记) match = re.search(r"```\s*(.*?)\s*```", llm_output, re.DOTALL) if match: content = match.group(1).strip() # 简单的启发式检查 if "infographic" in content or "data" in content: return content.replace("", "<\\/script>") # 4. 兜底:如果看起来像直接输出了语法(以 infographic 或 list-grid 等开头) cleaned_output = llm_output.strip() first_line = cleaned_output.split("\n")[0].lower() if ( first_line.startswith("infographic") or first_line.startswith("list-") or first_line.startswith("tree-") or first_line.startswith("mindmap") ): return cleaned_output.replace("", "<\\/script>") logger.warning("LLM输出未严格遵循预期格式,将整个输出作为语法处理。") return cleaned_output.replace("", "<\\/script>") async def _emit_status(self, emitter, description: str, done: bool = False): """发送状态更新事件""" if self.valves.SHOW_STATUS and emitter: await emitter( {"type": "status", "data": {"description": description, "done": done}} ) async def _emit_notification(self, emitter, content: str, ntype: str = "info"): """发送通知事件 (info/success/warning/error)""" if emitter: await emitter( {"type": "notification", "data": {"type": ntype, "content": content}} ) def _remove_existing_html(self, content: str) -> str: """移除内容中已有的插件生成 HTML 代码块""" pattern = r"```html\s*[\s\S]*?```" return re.sub(pattern, "", content).strip() def _extract_text_content(self, content) -> str: """从消息内容中提取文本,支持多模态消息格式""" if isinstance(content, str): return content elif isinstance(content, list): # 多模态消息: [{"type": "text", "text": "..."}, {"type": "image_url", ...}] 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 "" def _merge_html( self, existing_html_code: str, new_content: str, new_styles: str = "", new_scripts: str = "", user_language: str = "zh-CN", ) -> str: """将新内容合并到现有的 HTML 容器中,或者创建一个新的容器""" if ( "" in existing_html_code and "" in existing_html_code ): base_html = existing_html_code base_html = re.sub(r"^```html\s*", "", base_html) base_html = re.sub(r"\s*```$", "", base_html) else: base_html = HTML_WRAPPER_TEMPLATE.replace("{user_language}", user_language) wrapped_content = f'
\n{new_content}\n
' if new_styles: base_html = base_html.replace( "/* STYLES_INSERTION_POINT */", f"{new_styles}\n/* STYLES_INSERTION_POINT */", ) base_html = base_html.replace( "", f"{wrapped_content}\n", ) if new_scripts: base_html = base_html.replace( "", f"{new_scripts}\n", ) return base_html.strip() async def action( self, body: dict, __user__: Optional[Dict[str, Any]] = None, __event_emitter__: Optional[Any] = None, __request__: Optional[Request] = None, ) -> Optional[dict]: logger.info("Action: 信息图启动 (v1.0.0)") # 获取用户信息 if isinstance(__user__, (list, tuple)): user_language = ( __user__[0].get("language", "zh-CN") if __user__ else "zh-CN" ) user_name = __user__[0].get("name", "用户") if __user__[0] else "用户" user_id = ( __user__[0]["id"] if __user__ and "id" in __user__[0] else "unknown_user" ) elif isinstance(__user__, dict): user_language = __user__.get("language", "zh-CN") user_name = __user__.get("name", "用户") user_id = __user__.get("id", "unknown_user") # 获取当前时间 now = datetime.now() current_date_time_str = now.strftime("%Y年%m月%d日 %H:%M:%S") current_weekday_en = now.strftime("%A") current_weekday = self.weekday_map.get(current_weekday_en, current_weekday_en) current_year = now.strftime("%Y") original_content = "" try: messages = body.get("messages", []) if not messages: raise ValueError("无法获取有效的用户消息内容。") # 根据 MESSAGE_COUNT 获取最近 N 条消息 message_count = min(self.valves.MESSAGE_COUNT, len(messages)) recent_messages = messages[-message_count:] # 聚合选中消息的内容,带标签 aggregated_parts = [] for i, msg in enumerate(recent_messages, 1): text_content = self._extract_text_content(msg.get("content")) if text_content: role = msg.get("role", "unknown") role_label = ( "用户" if role == "user" else "助手" if role == "assistant" else role ) aggregated_parts.append(f"{text_content}") if not aggregated_parts: raise ValueError("无法获取有效的用户消息内容。") original_content = "\n\n---\n\n".join(aggregated_parts) # 提取非HTML部分的文本 parts = re.split(r"```html.*?```", original_content, flags=re.DOTALL) long_text_content = "" if parts: for part in reversed(parts): if part.strip(): long_text_content = part.strip() break if not long_text_content: long_text_content = original_content.strip() # 检查文本长度 if len(long_text_content) < self.valves.MIN_TEXT_LENGTH: short_text_message = f"文本内容过短({len(long_text_content)}字符),无法进行有效分析。请提供至少{self.valves.MIN_TEXT_LENGTH}字符的文本。" await self._emit_notification( __event_emitter__, short_text_message, "warning" ) return { "messages": [ {"role": "assistant", "content": f"⚠️ {short_text_message}"} ] } await self._emit_notification( __event_emitter__, "📊 信息图已启动,正在生成...", "info" ) await self._emit_status(__event_emitter__, "📊 信息图: 开始生成...", False) # 生成唯一ID unique_id = f"id_{int(time.time() * 1000)}" # 构建提示词 await self._emit_status( __event_emitter__, "📊 信息图: 正在调用 AI 模型分析内容...", False ) formatted_user_prompt = USER_PROMPT_GENERATE_INFOGRAPHIC.format( user_name=user_name, current_date_time_str=current_date_time_str, user_language=user_language, long_text_content=long_text_content, ) # 确定使用的模型 target_model = self.valves.MODEL_ID if not target_model: target_model = body.get("model") llm_payload = { "model": target_model, "messages": [ {"role": "system", "content": SYSTEM_PROMPT_INFOGRAPHIC_ASSISTANT}, {"role": "user", "content": formatted_user_prompt}, ], "stream": False, } user_obj = Users.get_user_by_id(user_id) if not user_obj: raise ValueError(f"无法获取用户对象,用户ID: {user_id}") 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("无效的 LLM 响应格式或为空。") await self._emit_status( __event_emitter__, "📊 信息图: AI 分析完成,正在解析语法...", False ) assistant_response_content = llm_response["choices"][0]["message"][ "content" ] infographic_syntax = self._extract_infographic_syntax( assistant_response_content ) # 准备内容组件 await self._emit_status( __event_emitter__, "📊 信息图: 正在渲染图表...", False ) content_html = ( CONTENT_TEMPLATE_INFOGRAPHIC.replace("{unique_id}", unique_id) .replace("{user_name}", user_name) .replace("{current_date_time_str}", current_date_time_str) .replace("{current_year}", current_year) .replace("{infographic_syntax}", infographic_syntax) ) # 先替换占位符,然后将 {{ 转为 { 和 }} 转为 } script_html = SCRIPT_TEMPLATE_INFOGRAPHIC.replace("{unique_id}", unique_id) script_html = script_html.replace("{{", "{").replace("}}", "}") # 提取现有HTML(如果有) existing_html_block = "" match = re.search( r"```html\s*([\s\S]*?)```", original_content, ) if match: existing_html_block = match.group(1) if self.valves.CLEAR_PREVIOUS_HTML: original_content = self._remove_existing_html(original_content) final_html = self._merge_html( "", content_html, CSS_TEMPLATE_INFOGRAPHIC, script_html, user_language, ) else: if existing_html_block: original_content = self._remove_existing_html(original_content) final_html = self._merge_html( existing_html_block, content_html, CSS_TEMPLATE_INFOGRAPHIC, script_html, user_language, ) else: final_html = self._merge_html( "", content_html, CSS_TEMPLATE_INFOGRAPHIC, script_html, user_language, ) html_embed_tag = f"```html\n{final_html}\n```" body["messages"][-1]["content"] = f"{original_content}\n\n{html_embed_tag}" await self._emit_status(__event_emitter__, "✅ 信息图: 生成完成!", True) await self._emit_notification( __event_emitter__, f"📊 信息图已生成,{user_name}!", "success", ) logger.info("信息图生成完成") except Exception as e: error_message = f"信息图处理失败: {str(e)}" logger.error(f"信息图错误: {error_message}", exc_info=True) user_facing_error = f"抱歉,信息图在处理时遇到错误: {str(e)}。\n请检查Open WebUI后端日志获取更多详情。" body["messages"][-1][ "content" ] = f"{original_content}\n\n❌ **错误:** {user_facing_error}" await self._emit_status(__event_emitter__, "❌ 信息图: 生成失败", True) await self._emit_notification( __event_emitter__, f"❌ 信息图生成失败, {user_name}!", "error" ) return body