""" title: 📊 智能信息图 (AntV Infographic) author: jeff author_url: https://github.com/Fu-Jie/awesome-openwebui icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPgogIDxsaW5lIHgxPSIxMiIgeTE9IjIwIiB4Mj0iMTIiIHkyPSIxMCIgLz4KICA8bGluZSB4MT0iMTgiIHkxPSIyMCIgeDI9IjE4IiB5Mj0iNCIgLz4KICA8bGluZSB4MT0iNiIgeTE9IjIwIiB4Mj0iNiIgeTI9IjE2IiAvPgo8L3N2Zz4= version: 1.4.1 openwebui_id: e04a48ff-23ee-4a41-8ea7-66c19524e7c8 description: 基于 AntV Infographic 的智能信息图生成插件。支持多种专业模板,自动图标匹配,并提供 SVG/PNG 下载功能。 """ from pydantic import BaseModel, Field from typing import Optional, Dict, Any, Callable, Awaitable 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仅使用最后一条消息,更大值可包含更多上下文。", ) OUTPUT_MODE: str = Field( default="image", description="输出模式:'html' 为交互式HTML,'image' 将嵌入为Markdown图片(默认)。", ) def __init__(self): self.valves = self.Valves() self.weekday_map = { "Monday": "星期一", "Tuesday": "星期二", "Wednesday": "星期三", "Thursday": "星期四", "Friday": "星期五", "Saturday": "星期六", "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 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() 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 = 1100; const defaultHeight = 500; // 自动检测聊天容器宽度以实现响应式尺寸 let svgWidth = defaultWidth; let svgHeight = defaultHeight; const chatContainer = document.getElementById('chat-container'); if (chatContainer) {{ const containerWidth = chatContainer.clientWidth; if (containerWidth > 100) {{ // 使用容器宽度的 80%(右边留更多空间) svgWidth = Math.floor(containerWidth * 0.8); // 根据默认尺寸保持宽高比 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: 12, }}); 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); // 使用 canvas 将 SVG 转换为 PNG 以提高兼容性 console.log("[Infographic Image] 正在将 SVG 转换为 PNG..."); const pngBlob = await new Promise((resolve, reject) => {{ const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const scale = 2; // 更高分辨率以提高清晰度 canvas.width = Math.round(width * scale); canvas.height = Math.round(height * scale); // 填充白色背景 ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.scale(scale, scale); const img = new Image(); img.onload = () => {{ ctx.drawImage(img, 0, 0, width, height); canvas.toBlob((blob) => {{ if (blob) {{ resolve(blob); }} else {{ reject(new Error('Canvas toBlob 失败')); }} }}, 'image/png'); }}; img.onerror = (e) => reject(new Error('加载 SVG 图片失败: ' + e)); img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData))); }}); const file = new File([pngBlob], `infographic-${{uniqueId}}.png`, {{ type: 'image/png' }}); // 上传文件到 OpenWebUI API console.log("[Infographic Image] 上传 PNG 文件..."); 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] PNG 文件已上传, ID:", fileId); // 生成带文件 URL 的 markdown 图片 const markdownImage = `![📊 信息图](${{imageUrl}})`; // 通过 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.4.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, ) # 检查输出模式 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}" 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