diff --git a/docs/features/plugin/tools/development.mdx b/docs/features/plugin/tools/development.mdx
index 5bfe2a2..1ae40d7 100644
--- a/docs/features/plugin/tools/development.mdx
+++ b/docs/features/plugin/tools/development.mdx
@@ -1627,6 +1627,18 @@ More info about the header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Re
In the Tools definition metadata you can specify custom packages. When you click `Save` the line will be parsed and `pip install` will be run on all requirements at once.
+:::warning
+
+**🚨 CRITICAL WARNING: Potential for Package Version Conflicts**
+
+When multiple tools define different versions of the same package (e.g., Tool A requires `pandas==1.5.0` and Tool B requires `pandas==2.0.0`), Open WebUI installs them in a non-deterministic order. This can lead to unpredictable behavior and break one or more of your tools.
+
+**The only robust solution to this problem is to use an OpenAPI tool server.**
+
+We strongly recommend using an [OpenAPI tool server](/features/plugin/tools/openapi-servers/) to avoid these dependency conflicts.
+
+:::
+
Keep in mind that as pip is used in the same process as Open WebUI, the UI will be completely unresponsive during the installation.
No measures are taken to handle package conflicts with Open WebUI's requirements. That means that specifying requirements can break Open WebUI if you're not careful. You might be able to work around this by specifying `open-webui` itself as a requirement.
diff --git a/plugins/actions/ACTION_PLUGIN_TEMPLATE.py b/plugins/actions/ACTION_PLUGIN_TEMPLATE.py
index addf720..778ed98 100644
--- a/plugins/actions/ACTION_PLUGIN_TEMPLATE.py
+++ b/plugins/actions/ACTION_PLUGIN_TEMPLATE.py
@@ -117,6 +117,10 @@ class Action:
default=False,
description="Whether to force clear previous plugin results (if True, overwrites instead of merging).",
)
+ MESSAGE_COUNT: int = Field(
+ default=1,
+ description="Number of recent messages to use for generation. Set to 1 for just the last message, or higher for more context.",
+ )
# Add other configuration fields as needed
# MAX_TEXT_LENGTH: int = Field(default=2000, description="...")
@@ -175,6 +179,21 @@ class Action:
pattern = r"```html\s*[\s\S]*?```"
return re.sub(pattern, "", content).strip()
+ def _extract_text_content(self, content) -> str:
+ """Extract text from message content, supporting multimodal message formats."""
+ if isinstance(content, str):
+ return content
+ elif isinstance(content, list):
+ # Multimodal message: [{"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,
@@ -278,10 +297,30 @@ class Action:
# 2. Input Validation
messages = body.get("messages", [])
- if not messages or not messages[-1].get("content"):
+ if not messages:
return body # Or handle error
- original_content = messages[-1]["content"]
+ # Get last N messages based on MESSAGE_COUNT
+ message_count = min(self.valves.MESSAGE_COUNT, len(messages))
+ recent_messages = messages[-message_count:]
+
+ # Aggregate content from selected messages with labels
+ 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 = (
+ "User"
+ if role == "user"
+ else "Assistant" if role == "assistant" else role
+ )
+ aggregated_parts.append(f"[{role_label} Message {i}]\n{text_content}")
+
+ if not aggregated_parts:
+ return body # Or handle error
+
+ original_content = "\n\n---\n\n".join(aggregated_parts)
if len(original_content) < self.valves.MIN_TEXT_LENGTH:
warning_msg = f"Text too short ({len(original_content)} chars). Minimum required: {self.valves.MIN_TEXT_LENGTH}."
diff --git a/plugins/actions/ACTION_PLUGIN_TEMPLATE_CN.py b/plugins/actions/ACTION_PLUGIN_TEMPLATE_CN.py
index 50d0ddb..400af74 100644
--- a/plugins/actions/ACTION_PLUGIN_TEMPLATE_CN.py
+++ b/plugins/actions/ACTION_PLUGIN_TEMPLATE_CN.py
@@ -117,6 +117,10 @@ class Action:
default=False,
description="是否强制清除旧的插件结果(如果为 True,则不合并,直接覆盖)。",
)
+ MESSAGE_COUNT: int = Field(
+ default=1,
+ description="用于生成的最近消息数量。设置为1仅使用最后一条消息,更大值可包含更多上下文。",
+ )
# 根据需要添加其他配置字段
# MAX_TEXT_LENGTH: int = Field(default=2000, description="...")
@@ -186,6 +190,21 @@ class Action:
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,
@@ -289,10 +308,30 @@ class Action:
# 2. 输入验证
messages = body.get("messages", [])
- if not messages or not messages[-1].get("content"):
+ if not messages:
return body # 或者处理错误
- original_content = messages[-1]["content"]
+ # 根据 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"[{role_label} 消息 {i}]\n{text_content}")
+
+ if not aggregated_parts:
+ return body # 或者处理错误
+
+ original_content = "\n\n---\n\n".join(aggregated_parts)
if len(original_content) < self.valves.MIN_TEXT_LENGTH:
warning_msg = f"文本过短 ({len(original_content)} 字符)。最少需要: {self.valves.MIN_TEXT_LENGTH}。"
diff --git a/plugins/actions/infographic/README.md b/plugins/actions/infographic/README.md
new file mode 100644
index 0000000..68821ca
--- /dev/null
+++ b/plugins/actions/infographic/README.md
@@ -0,0 +1,67 @@
+# 信息图 - OpenWebUI Action 插件
+
+将文本内容智能转换为美观的信息图,基于蚂蚁集团 AntV Infographic 引擎。
+
+## 功能特性
+
+- 🤖 **AI 驱动**: 使用 LLM 自动分析文本内容并生成信息图语法
+- 📊 **多种模板**: 支持列表、流程、层级等多种信息图类型
+- 🎨 **自动图标**: 使用 `ref:search` 语法自动匹配高质量图标
+- 💾 **多格式导出**: 支持下载 SVG、PNG 和独立 HTML 文件
+- 🎯 **零配置**: 开箱即用,无需额外设置
+
+## 安装
+
+1. 将 `信息图.py` 文件复制到 Open WebUI 的插件目录:
+ ```
+ plugins/actions/infographic/
+ ```
+
+2. 重启 Open WebUI 或在管理界面重新加载插件
+
+3. 在聊天界面的 Action 菜单中即可看到 "信息图" 选项
+
+## 使用方法
+
+1. 在聊天框输入需要可视化的文本内容(建议 100 字符以上)
+2. 点击 "信息图" Action 按钮
+3. AI 将自动分析文本并生成信息图
+4. 可以下载 SVG、PNG 或 HTML 格式的文件
+
+### 示例文本
+
+```
+我们的产品开发流程包括三个主要阶段:
+1. 需求分析 - 收集和分析用户需求,确定产品方向
+2. 设计开发 - 完成 UI/UX 设计和前后端开发
+3. 测试上线 - 进行质量验证并正式发布
+```
+
+## 配置选项(Valves)
+
+- **SHOW_STATUS**: 是否显示操作状态更新(默认: True)
+- **MODEL_ID**: 用于分析的 LLM 模型 ID(默认: 使用当前对话模型)
+- **MIN_TEXT_LENGTH**: 最小文本长度要求(默认: 100 字符)
+- **CLEAR_PREVIOUS_HTML**: 是否清除之前的插件输出(默认: False)
+
+## 支持的信息图类型
+
+插件会根据文本内容自动选择最合适的模板:
+
+- **列表型**: `list-row-horizontal-icon-arrow`, `list-grid`
+- **层级型**: `tree-vertical`, `tree-horizontal`
+
+## 技术栈
+
+- **后端**: Python, OpenWebUI Action API
+- **前端**: AntV Infographic (CDN)
+- **AI**: 自定义提示词工程
+
+## 许可证
+
+MIT License
+
+## 致谢
+
+- [AntV Infographic](https://infographic.antv.vision/) - 信息图渲染引擎
+- [Open WebUI](https://github.com/open-webui/open-webui) - AI 聊天界面
diff --git a/plugins/actions/infographic/README_CN.md b/plugins/actions/infographic/README_CN.md
new file mode 100644
index 0000000..795b608
--- /dev/null
+++ b/plugins/actions/infographic/README_CN.md
@@ -0,0 +1,43 @@
+# AntV Infographic 智能信息图插件
+
+将文本内容一键转换为精美的信息图。支持列表、层级、流程、关系、对比、分析、图表等多种可视化形式。
+
+## 功能特性
+
+- **智能分析**: 自动识别文本结构,选择最合适的图表模板。
+- **丰富模板**: 支持 20+ 种 AntV 信息图模板,涵盖列表、树图、思维导图、流程图、桑基图、SWOT、象限图、柱状图、饼图等。
+- **自动配图**: 智能搜索并匹配合适的图标。
+- **多格式导出**: 支持导出为 SVG, PNG, HTML 格式。
+- **多语言支持**: 输出语言跟随用户设定。
+
+## 使用方法
+
+在 Open WebUI 聊天框中,直接输入文本或上传文档,然后启用该插件。插件会自动分析内容并生成信息图。
+
+### 支持的图表类型
+
+#### 1. 列表与层级
+- **列表**: 网格卡片 (`list-grid`), 垂直列表 (`list-vertical`)
+- **树图**: 垂直树 (`tree-vertical`), 水平树 (`tree-horizontal`)
+- **思维导图**: `mindmap`
+
+#### 2. 顺序与关系
+- **流程**: 路线图 (`sequence-roadmap`), 之字形流程 (`sequence-zigzag`), 水平流程 (`sequence-horizontal`)
+- **关系**: 桑基图 (`relation-sankey`), 循环关系 (`relation-circle`)
+
+#### 3. 对比与分析
+- **对比**: 二元对比 (`compare-binary`), 对比表 (`compare-table`)
+- **分析**: SWOT 分析 (`compare-swot`), 象限图 (`quadrant-quarter`)
+
+#### 4. 图表与数据
+- **统计**: 统计卡片 (`statistic-card`)
+- **图表**: 柱状图 (`chart-bar`), 条形图 (`chart-column`), 折线图 (`chart-line`), 饼图 (`chart-pie`), 环形图 (`chart-doughnut`)
+
+## 安装
+
+将 `infographic.py` (英文版) 或 `信息图.py` (中文版) 放入 Open WebUI 的插件目录即可。
+
+## 依赖
+
+- 插件依赖 `@antv/infographic` 库 (通过 CDN 加载)。
+- 需要联网权限以加载 CDN 资源和图标。
diff --git a/plugins/actions/infographic/README_EN.md b/plugins/actions/infographic/README_EN.md
new file mode 100644
index 0000000..7b54c74
--- /dev/null
+++ b/plugins/actions/infographic/README_EN.md
@@ -0,0 +1,43 @@
+# AntV Infographic Plugin
+
+Transform text content into beautiful infographics with a single click. Supports lists, hierarchies, processes, relationships, comparisons, analysis, charts, and more.
+
+## Features
+
+- **Smart Analysis**: Automatically identifies text structure and selects the best template.
+- **Rich Templates**: Supports 20+ AntV infographic templates, including lists, trees, mind maps, roadmaps, Sankey diagrams, SWOT, quadrant charts, bar charts, pie charts, etc.
+- **Auto Icons**: Intelligently searches and matches appropriate icons.
+- **Multi-format Export**: Export as SVG, PNG, or HTML.
+- **Multi-language**: Output language follows user settings.
+
+## Usage
+
+In the Open WebUI chat interface, simply input text or upload a document, then enable this plugin. The plugin will analyze the content and generate an infographic.
+
+### Supported Chart Types
+
+#### 1. List & Hierarchy
+- **List**: Grid Cards (`list-grid`), Vertical List (`list-vertical`)
+- **Tree**: Vertical Tree (`tree-vertical`), Horizontal Tree (`tree-horizontal`)
+- **Mindmap**: `mindmap`
+
+#### 2. Sequence & Relationship
+- **Process**: Roadmap (`sequence-roadmap`), Zigzag Process (`sequence-zigzag`), Horizontal Process (`sequence-horizontal`)
+- **Relationship**: Sankey Diagram (`relation-sankey`), Circular Relationship (`relation-circle`)
+
+#### 3. Comparison & Analysis
+- **Comparison**: Binary Comparison (`compare-binary`), Comparison Table (`compare-table`)
+- **Analysis**: SWOT Analysis (`compare-swot`), Quadrant Chart (`quadrant-quarter`)
+
+#### 4. Charts & Data
+- **Statistics**: Statistic Cards (`statistic-card`)
+- **Charts**: Bar Chart (`chart-bar`), Column Chart (`chart-column`), Line Chart (`chart-line`), Pie Chart (`chart-pie`), Doughnut Chart (`chart-doughnut`)
+
+## Installation
+
+Place `infographic.py` (English version) or `信息图.py` (Chinese version) into your Open WebUI plugins directory.
+
+## Dependencies
+
+- Depends on `@antv/infographic` library (loaded via CDN).
+- Requires internet access to load CDN resources and icons.
diff --git a/plugins/actions/infographic/debug_card_spacing.html b/plugins/actions/infographic/debug_card_spacing.html
new file mode 100644
index 0000000..3282715
--- /dev/null
+++ b/plugins/actions/infographic/debug_card_spacing.html
@@ -0,0 +1,277 @@
+
+
+
+
+
+
+ 卡片间距调试工具
+
+
+
+
+
+
+
+
🔧 卡片间距调试面板
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 说明:
+ • 标题与描述间距:控制标题和内容之间的垂直间距
+ • 字体大小:分别控制标题和描述的文字大小
+ • 内边距:控制整个卡片内的留白
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/actions/infographic/debug_styles.html b/plugins/actions/infographic/debug_styles.html
new file mode 100644
index 0000000..bbbcc84
--- /dev/null
+++ b/plugins/actions/infographic/debug_styles.html
@@ -0,0 +1,342 @@
+
+
+
+
+
+
+ 信息图样式调试
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/actions/infographic/infographic.py b/plugins/actions/infographic/infographic.py
new file mode 100644
index 0000000..8cffffc
--- /dev/null
+++ b/plugins/actions/infographic/infographic.py
@@ -0,0 +1,1098 @@
+"""
+title: Infographic
+icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIj48cmVjdCB4PSIzIiB5PSIzIiB3aWR0aD0iMTgiIGhlaWdodD0iMTgiIHJ4PSIyIi8+PHBhdGggZD0iTTcgOGg1Ii8+PHBhdGggZD0iTTcgMTJoNyIvPjxwYXRoIGQ9Ik03IDE2aDkiLz48L3N2Zz4=
+version: 1.1.2
+description: Transform text content into beautiful infographics with multiple templates and automatic icon search.
+"""
+
+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 Prompts
+# =================================================================
+
+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
+
+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), `sequence-horizontal` (Horizontal Process)
+- **Relationship**: `relation-sankey` (Sankey Diagram), `relation-circle` (Circular Relationship)
+
+#### 3. Comparison & Analysis
+- **Comparison**: `compare-binary` (Binary Comparison), `compare-table` (Comparison Table)
+- **Analysis**: `compare-swot` (SWOT Analysis), `quadrant-quarter` (Quadrant Chart)
+
+#### 4. Charts & Data
+- **Statistics**: `statistic-card` (Statistic Cards)
+- **Charts**: `chart-bar` (Bar Chart), `chart-column` (Column Chart), `chart-line` (Line Chart), `chart-pie` (Pie Chart), `chart-doughnut` (Doughnut Chart), `chart-area` (Area Chart)
+
+### Data Structure Examples
+
+#### A. Standard List/Tree (Default)
+Use `items` and `children` structure.
+
+```infographic
+infographic list-grid
+data
+ title Project Modules
+ items
+ - label Module A
+ desc Description of A
+ - label Module B
+ desc Description of B
+```
+
+#### B. SWOT Analysis
+Use `children` to define the 4 quadrants (Strengths, Weaknesses, Opportunities, Threats).
+
+```infographic
+infographic compare-swot
+data
+ title Product SWOT Analysis
+ items
+ - label Internal Factors
+ children
+ - label Strengths
+ children
+ - label High Performance
+ - label Low Cost
+ - label Weaknesses
+ children
+ - label Limited Features
+ - label External Factors
+ children
+ - label Opportunities
+ children
+ - label Growing Market
+ - label Threats
+ children
+ - label New Competitors
+```
+
+#### C. Quadrant Chart
+Use `items` for quadrants and `illus` for icons.
+
+```infographic
+infographic quadrant-quarter
+data
+ title Priority Matrix
+ items
+ - label High Importance
+ children
+ - label Urgent
+ desc Do it now
+ illus mdi/alert
+ - label Not Urgent
+ desc Schedule it
+ illus mdi/calendar
+ - label Low Importance
+ children
+ - label Urgent
+ desc Delegate it
+ illus mdi/account-arrow-right
+ - label Not Urgent
+ desc Delete it
+ illus mdi/delete
+```
+
+#### D. Charts (Bar/Column/Line/Pie)
+Use `items` with `label` and `value`.
+
+```infographic
+infographic chart-bar
+data
+ title Quarterly Revenue
+ items
+ - label Q1
+ value 120
+ - label Q2
+ value 150
+ - label Q3
+ value 180
+ - label Q4
+ value 220
+```
+
+### Common Data Fields
+- `label`: Main title/label (Required)
+- `desc`: Description text
+- `value`: Numeric value (for charts)
+- `icon`: Icon name (e.g., `mdi/home`, `mdi/account`) or `ref:search:`
+- `children`: Nested items (for trees, SWOT, etc.)
+- `illus`: Illustration icon (specific to some templates like Quadrant)
+
+## 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_INFOGRAPHIC = """
+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. Pay attention to correct indentation format (two spaces).
+
+**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**) to maintain visual consistency with all descriptions fitting in 2 lines.
+- Descriptions should be concise and highlight key points.
+"""
+
+# =================================================================
+# HTML Container Template
+# =================================================================
+
+HTML_WRAPPER_TEMPLATE = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+# =================================================================
+# CSS Style Template
+# =================================================================
+
+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;
+ 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;
+}
+/* Main title styles */
+.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;
+}
+/* Page subtitle and card title styles */
+.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;
+}
+/* Card title with extra bottom spacing */
+.infographic-render-container svg foreignObject[data-element-type="item-label"] > * {
+ padding-bottom: 8px !important;
+ display: block !important;
+}
+/* Card description text keeps normal wrapping */
+.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
+# =================================================================
+
+CONTENT_TEMPLATE_INFOGRAPHIC = """
+
+
+
+ User: {user_name}
+ Time: {current_date_time_str}
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+# =================================================================
+# JavaScript Rendering Script
+# =================================================================
+
+SCRIPT_TEMPLATE_INFOGRAPHIC = """
+
+
+"""
+
+
+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="Built-in LLM model ID for text analysis. If empty, uses current conversation model.",
+ )
+ MIN_TEXT_LENGTH: int = Field(
+ default=100,
+ description="Minimum text length (characters) required for infographic analysis.",
+ )
+ CLEAR_PREVIOUS_HTML: bool = Field(
+ default=False,
+ description="Force clear old plugin results (if True, overwrite instead of merge).",
+ )
+ MESSAGE_COUNT: int = Field(
+ default=1,
+ description="Number of recent messages to use for generation. Set to 1 for just the last message, or higher for more context.",
+ )
+
+ def __init__(self):
+ self.valves = self.Valves()
+
+ 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:
+ extracted_content = match.group(1).strip()
+ else:
+ logger.warning(
+ "LLM output did not follow expected format, treating entire output as syntax."
+ )
+ extracted_content = llm_output.strip()
+
+ return extracted_content.replace("", "<\\/script>")
+
+ 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}}
+ )
+
+ async def _emit_notification(self, emitter, content: str, ntype: str = "info"):
+ """Send notification event (info/success/warning/error)"""
+ if emitter:
+ await emitter(
+ {"type": "notification", "data": {"type": ntype, "content": content}}
+ )
+
+ def _remove_existing_html(self, content: str) -> str:
+ """Remove existing plugin-generated HTML code blocks from content"""
+ pattern = r"```html\s*[\s\S]*?```"
+ return re.sub(pattern, "", content).strip()
+
+ def _extract_text_content(self, content) -> str:
+ """Extract text from message content, supporting multimodal message formats"""
+ if isinstance(content, str):
+ return content
+ elif isinstance(content, list):
+ # Multimodal message: [{"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 = "en",
+ ) -> str:
+ """Merge new content into existing HTML container or create a new one"""
+ 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: Infographic started (v1.0.0)")
+
+ # 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]["id"]
+ if __user__ and "id" in __user__[0]
+ 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")
+
+ # Get current time
+ now = datetime.now()
+ current_date_time_str = now.strftime("%Y-%m-%d %H:%M:%S")
+ current_year = now.strftime("%Y")
+
+ original_content = ""
+ try:
+ messages = body.get("messages", [])
+ if not messages:
+ raise ValueError("Unable to get valid user message content.")
+
+ # Get last N messages based on MESSAGE_COUNT
+ message_count = min(self.valves.MESSAGE_COUNT, len(messages))
+ recent_messages = messages[-message_count:]
+
+ # Aggregate content from selected messages with labels
+ 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 = (
+ "User"
+ if role == "user"
+ else "Assistant" if role == "assistant" else role
+ )
+ aggregated_parts.append(
+ f"[{role_label} Message {i}]\n{text_content}"
+ )
+
+ if not aggregated_parts:
+ raise ValueError("Unable to get valid user message content.")
+
+ original_content = "\n\n---\n\n".join(aggregated_parts)
+
+ # Extract non-HTML text
+ 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()
+
+ # Check text length
+ if len(long_text_content) < self.valves.MIN_TEXT_LENGTH:
+ short_text_message = f"Text content too short ({len(long_text_content)} characters). Please provide at least {self.valves.MIN_TEXT_LENGTH} characters for effective analysis."
+ 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__, "📊 Infographic started, generating...", "info"
+ )
+ await self._emit_status(
+ __event_emitter__,
+ "📊 Infographic: Starting generation...",
+ False,
+ )
+
+ # Generate unique ID
+ unique_id = f"id_{int(time.time() * 1000)}"
+
+ # Build prompt
+ await self._emit_status(
+ __event_emitter__,
+ "📊 Infographic: Calling AI model to analyze content...",
+ 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,
+ )
+
+ # Determine model to use
+ 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"Unable to get user object, user 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("Invalid LLM response format or empty.")
+
+ await self._emit_status(
+ __event_emitter__,
+ "📊 Infographic: AI analysis complete, parsing syntax...",
+ False,
+ )
+
+ assistant_response_content = llm_response["choices"][0]["message"][
+ "content"
+ ]
+ infographic_syntax = self._extract_infographic_syntax(
+ assistant_response_content
+ )
+
+ # Prepare content components
+ await self._emit_status(
+ __event_emitter__,
+ "📊 Infographic: Rendering chart...",
+ 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)
+ )
+
+ # Replace placeholder first, then convert {{ to { and }} to }
+ script_html = SCRIPT_TEMPLATE_INFOGRAPHIC.replace("{unique_id}", unique_id)
+ script_html = script_html.replace("{{", "{").replace("}}", "}")
+
+ # Extract existing HTML if any
+ 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__, "✅ Infographic: Generation complete!", True
+ )
+ await self._emit_notification(
+ __event_emitter__,
+ f"📊 Infographic generated, {user_name}!",
+ "success",
+ )
+ logger.info("Infographic generation completed")
+
+ except Exception as e:
+ error_message = f"Infographic processing failed: {str(e)}"
+ logger.error(f"Infographic error: {error_message}", exc_info=True)
+ user_facing_error = f"Sorry, infographic encountered an error during processing: {str(e)}.\nPlease check the Open WebUI backend logs for more details."
+ body["messages"][-1][
+ "content"
+ ] = f"{original_content}\n\n❌ **Error:** {user_facing_error}"
+
+ await self._emit_status(
+ __event_emitter__, "❌ Infographic: Generation failed", True
+ )
+ await self._emit_notification(
+ __event_emitter__,
+ f"❌ Infographic generation failed, {user_name}!",
+ "error",
+ )
+
+ return body
diff --git a/plugins/actions/infographic/infographic_templates.json b/plugins/actions/infographic/infographic_templates.json
new file mode 100644
index 0000000..83f5091
--- /dev/null
+++ b/plugins/actions/infographic/infographic_templates.json
@@ -0,0 +1,194 @@
+{
+ "templates": [
+ "chart-bar-basic-bar",
+ "chart-bar-basic-column",
+ "chart-bar-grouped-bar",
+ "chart-bar-grouped-column",
+ "chart-bar-percent-stacked-bar",
+ "chart-bar-percent-stacked-column",
+ "chart-bar-stacked-bar",
+ "chart-bar-stacked-column",
+ "chart-line-basic-line",
+ "chart-line-curved-line",
+ "chart-line-multi-line",
+ "chart-line-step-line",
+ "chart-pie-basic-donut",
+ "chart-pie-basic-pie",
+ "chart-pie-compact-card",
+ "chart-pie-donut-compact-card",
+ "chart-wordcloud-basic",
+ "compare-binary-circle-progress",
+ "compare-binary-compact-card",
+ "compare-binary-dashed-arrow-compact-card",
+ "compare-binary-dashed-line-compact-card",
+ "compare-binary-horizontal-icon-arrow",
+ "compare-binary-simple-horizontal-arrow",
+ "compare-binary-simple-vertical-arrow",
+ "compare-binary-tech-style-compact-card",
+ "compare-hierarchy-compact-card",
+ "compare-hierarchy-dashed-arrow-compact-card",
+ "compare-hierarchy-dashed-line-compact-card",
+ "compare-hierarchy-horizontal-icon-arrow",
+ "compare-hierarchy-simple-horizontal-arrow",
+ "compare-hierarchy-simple-vertical-arrow",
+ "compare-hierarchy-tech-style-compact-card",
+ "compare-swot-compact-card",
+ "compare-swot-dashed-arrow-compact-card",
+ "compare-swot-dashed-line-compact-card",
+ "compare-swot-horizontal-icon-arrow",
+ "compare-swot-simple-horizontal-arrow",
+ "compare-swot-simple-vertical-arrow",
+ "compare-swot-tech-style-compact-card",
+ "hierarchy-mindmap-branch-gradient-capsule-item",
+ "hierarchy-mindmap-branch-gradient-circle-progress",
+ "hierarchy-mindmap-branch-gradient-compact-card",
+ "hierarchy-mindmap-branch-gradient-lined-palette",
+ "hierarchy-mindmap-branch-gradient-rounded-rect",
+ "hierarchy-mindmap-level-gradient-capsule-item",
+ "hierarchy-mindmap-level-gradient-circle-progress",
+ "hierarchy-mindmap-level-gradient-compact-card",
+ "hierarchy-mindmap-level-gradient-lined-palette",
+ "hierarchy-mindmap-level-gradient-rounded-rect",
+ "hierarchy-tree-bt-curved-line-badge-card",
+ "hierarchy-tree-bt-curved-line-capsule-item",
+ "hierarchy-tree-bt-curved-line-compact-card",
+ "hierarchy-tree-bt-curved-line-ribbon-card",
+ "hierarchy-tree-bt-curved-line-rounded-rect-node",
+ "hierarchy-tree-bt-dashed-arrow-badge-card",
+ "hierarchy-tree-bt-dashed-arrow-capsule-item",
+ "hierarchy-tree-bt-dashed-arrow-compact-card",
+ "hierarchy-tree-bt-dashed-arrow-ribbon-card",
+ "hierarchy-tree-bt-dashed-arrow-rounded-rect-node",
+ "hierarchy-tree-bt-dashed-line-badge-card",
+ "hierarchy-tree-bt-dashed-line-capsule-item",
+ "hierarchy-tree-bt-dashed-line-compact-card",
+ "hierarchy-tree-bt-dashed-line-ribbon-card",
+ "hierarchy-tree-bt-dashed-line-rounded-rect-node",
+ "hierarchy-tree-bt-distributed-origin-badge-card",
+ "hierarchy-tree-bt-distributed-origin-capsule-item",
+ "hierarchy-tree-bt-distributed-origin-compact-card",
+ "hierarchy-tree-bt-distributed-origin-ribbon-card",
+ "hierarchy-tree-bt-distributed-origin-rounded-rect-node",
+ "hierarchy-tree-bt-tech-style-badge-card",
+ "hierarchy-tree-bt-tech-style-capsule-item",
+ "hierarchy-tree-bt-tech-style-compact-card",
+ "hierarchy-tree-bt-tech-style-ribbon-card",
+ "hierarchy-tree-bt-tech-style-rounded-rect-node",
+ "hierarchy-tree-curved-line-badge-card",
+ "hierarchy-tree-curved-line-capsule-item",
+ "hierarchy-tree-curved-line-compact-card",
+ "hierarchy-tree-curved-line-ribbon-card",
+ "hierarchy-tree-curved-line-rounded-rect-node",
+ "hierarchy-tree-dashed-arrow-badge-card",
+ "hierarchy-tree-dashed-arrow-capsule-item",
+ "hierarchy-tree-dashed-arrow-compact-card",
+ "hierarchy-tree-dashed-arrow-ribbon-card",
+ "hierarchy-tree-dashed-arrow-rounded-rect-node",
+ "hierarchy-tree-dashed-line-badge-card",
+ "hierarchy-tree-dashed-line-capsule-item",
+ "hierarchy-tree-dashed-line-compact-card",
+ "hierarchy-tree-dashed-line-ribbon-card",
+ "hierarchy-tree-dashed-line-rounded-rect-node",
+ "hierarchy-tree-distributed-origin-badge-card",
+ "hierarchy-tree-distributed-origin-capsule-item",
+ "hierarchy-tree-distributed-origin-compact-card",
+ "hierarchy-tree-distributed-origin-ribbon-card",
+ "hierarchy-tree-distributed-origin-rounded-rect-node",
+ "hierarchy-tree-lr-curved-line-badge-card",
+ "hierarchy-tree-lr-curved-line-capsule-item",
+ "hierarchy-tree-lr-curved-line-compact-card",
+ "hierarchy-tree-lr-curved-line-ribbon-card",
+ "hierarchy-tree-lr-curved-line-rounded-rect-node",
+ "hierarchy-tree-lr-dashed-arrow-badge-card",
+ "hierarchy-tree-lr-dashed-arrow-capsule-item",
+ "hierarchy-tree-lr-dashed-arrow-compact-card",
+ "hierarchy-tree-lr-dashed-arrow-ribbon-card",
+ "hierarchy-tree-lr-dashed-arrow-rounded-rect-node",
+ "hierarchy-tree-lr-dashed-line-badge-card",
+ "hierarchy-tree-lr-dashed-line-capsule-item",
+ "hierarchy-tree-lr-dashed-line-compact-card",
+ "hierarchy-tree-lr-dashed-line-ribbon-card",
+ "hierarchy-tree-lr-dashed-line-rounded-rect-node",
+ "hierarchy-tree-lr-distributed-origin-badge-card",
+ "hierarchy-tree-lr-distributed-origin-capsule-item",
+ "hierarchy-tree-lr-distributed-origin-compact-card",
+ "hierarchy-tree-lr-distributed-origin-ribbon-card",
+ "hierarchy-tree-lr-distributed-origin-rounded-rect-node",
+ "hierarchy-tree-lr-tech-style-badge-card",
+ "hierarchy-tree-lr-tech-style-capsule-item",
+ "hierarchy-tree-lr-tech-style-compact-card",
+ "hierarchy-tree-lr-tech-style-ribbon-card",
+ "hierarchy-tree-lr-tech-style-rounded-rect-node",
+ "hierarchy-tree-rl-curved-line-badge-card",
+ "hierarchy-tree-rl-curved-line-capsule-item",
+ "hierarchy-tree-rl-curved-line-compact-card",
+ "hierarchy-tree-rl-curved-line-ribbon-card",
+ "hierarchy-tree-rl-curved-line-rounded-rect-node",
+ "hierarchy-tree-rl-dashed-arrow-badge-card",
+ "hierarchy-tree-rl-dashed-arrow-capsule-item",
+ "hierarchy-tree-rl-dashed-arrow-compact-card",
+ "hierarchy-tree-rl-dashed-arrow-ribbon-card",
+ "hierarchy-tree-rl-dashed-arrow-rounded-rect-node",
+ "hierarchy-tree-rl-dashed-line-badge-card",
+ "hierarchy-tree-rl-dashed-line-capsule-item",
+ "hierarchy-tree-rl-dashed-line-compact-card",
+ "hierarchy-tree-rl-dashed-line-ribbon-card",
+ "hierarchy-tree-rl-dashed-line-rounded-rect-node",
+ "hierarchy-tree-rl-distributed-origin-badge-card",
+ "hierarchy-tree-rl-distributed-origin-capsule-item",
+ "hierarchy-tree-rl-distributed-origin-compact-card",
+ "hierarchy-tree-rl-distributed-origin-ribbon-card",
+ "hierarchy-tree-rl-distributed-origin-rounded-rect-node",
+ "hierarchy-tree-rl-tech-style-badge-card",
+ "hierarchy-tree-rl-tech-style-capsule-item",
+ "hierarchy-tree-rl-tech-style-compact-card",
+ "hierarchy-tree-rl-tech-style-ribbon-card",
+ "hierarchy-tree-rl-tech-style-rounded-rect-node",
+ "hierarchy-tree-tech-style-badge-card",
+ "hierarchy-tree-tech-style-capsule-item",
+ "hierarchy-tree-tech-style-compact-card",
+ "hierarchy-tree-tech-style-ribbon-card",
+ "hierarchy-tree-tech-style-rounded-rect-node",
+ "list-column-done-list",
+ "list-column-simple-vertical-arrow",
+ "list-column-vertical-icon-arrow",
+ "list-grid-badge-card",
+ "list-grid-candy-card-lite",
+ "list-grid-circular-progress",
+ "list-grid-compact-card",
+ "list-grid-done-list",
+ "list-grid-horizontal-icon-arrow",
+ "list-grid-progress-card",
+ "list-grid-ribbon-card",
+ "list-grid-simple",
+ "list-pyramid-badge-card",
+ "list-pyramid-compact-card",
+ "list-pyramid-rounded-rect-node",
+ "list-row-circular-progress",
+ "list-row-horizontal-icon-arrow",
+ "list-row-horizontal-icon-line",
+ "list-row-simple-horizontal-arrow",
+ "list-row-simple-illus",
+ "list-sector-half-plain-text",
+ "list-sector-plain-text",
+ "list-sector-simple",
+ "quadrant-scatter-basic",
+ "quadrant-scatter-compact-card",
+ "quadrant-scatter-dashed-arrow-compact-card",
+ "quadrant-scatter-dashed-line-compact-card",
+ "quadrant-scatter-horizontal-icon-arrow",
+ "quadrant-scatter-simple-horizontal-arrow",
+ "quadrant-scatter-simple-vertical-arrow",
+ "quadrant-scatter-tech-style-compact-card",
+ "relation-graph-circular-layout",
+ "relation-graph-force-directed",
+ "sequence-circular-basic",
+ "sequence-roadmap-vertical-simple",
+ "sequence-snake-basic",
+ "sequence-stairs-basic",
+ "sequence-steps-basic",
+ "sequence-timeline-basic",
+ "sequence-timeline-done-list",
+ "sequence-zigzag-basic"
+ ]
+}
\ No newline at end of file
diff --git a/plugins/actions/infographic/test_infographic.html b/plugins/actions/infographic/test_infographic.html
new file mode 100644
index 0000000..f79893e
--- /dev/null
+++ b/plugins/actions/infographic/test_infographic.html
@@ -0,0 +1,688 @@
+
+
+
+
+
+
+ AntV Infographic Debug Test
+
+
+
+
+
+
AntV Infographic 综合测试台
+
+
+
+
+
+
+
准备就绪...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/actions/infographic/verify_generation.py b/plugins/actions/infographic/verify_generation.py
new file mode 100644
index 0000000..7ee2902
--- /dev/null
+++ b/plugins/actions/infographic/verify_generation.py
@@ -0,0 +1,395 @@
+import os
+import sys
+import json
+import requests
+from datetime import datetime
+from dotenv import load_dotenv
+import pathlib
+
+# Load .env from the same directory as this script
+env_path = pathlib.Path(__file__).parent / ".env"
+load_dotenv(dotenv_path=env_path)
+# =================================================================
+# Configuration
+# =================================================================
+
+API_KEY = os.getenv("OPENAI_API_KEY")
+BASE_URL = os.getenv("OPENAI_BASE_URL")
+MODEL = os.getenv("OPENAI_MODEL", "gpt-4o") # Default to gpt-4o if not set
+
+if not API_KEY or not BASE_URL:
+ print(
+ "Error: OPENAI_API_KEY and OPENAI_BASE_URL environment variables must be set."
+ )
+ sys.exit(1)
+
+# =================================================================
+# Prompts (Extracted from 信息图.py)
+# =================================================================
+
+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`
+- **Multi-item Comparison Table** -> `compare-table`
+- **Quadrant Analysis (Importance vs Urgency)** -> `quadrant-quarter`
+
+#### 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 (e.g., `rough`, `flat`)
+
+### 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 (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 语法。注意保持正确的缩进格式(两个空格)。
+"""
+
+# =================================================================
+# Test Cases
+# =================================================================
+
+TEST_CASES = [
+ {
+ "name": "List Grid (Features)",
+ "content": """
+ MiniMax 2025 模型矩阵全解析:
+ 1. 极致 MoE 架构优化:国内首批 MoE 路线坚定者。
+ 2. Video-01 视频生成:杀手锏级多模态能力。
+ 3. 情感陪伴与角色扮演:源自星野基因。
+ 4. 超长上下文与精准召回:支持 128k+ 窗口。
+ """,
+ },
+ {
+ "name": "Tree Vertical (Hierarchy)",
+ "content": """
+ 公司组织架构:
+ - 研发部
+ - 后端组
+ - 前端组
+ - 市场部
+ - 销售组
+ - 推广组
+ """,
+ },
+ {
+ "name": "Statistic Card (Metrics)",
+ "content": """
+ 2024年Q4 核心指标:
+ - 总用户数:1,234,567
+ - 日活跃用户:89%
+ - 营收增长:+45%
+ """,
+ },
+ {
+ "name": "Mindmap (Brainstorming)",
+ "content": """
+ 人工智能的应用领域:
+ - 生成式 AI:文本生成、图像生成、视频生成
+ - 预测性 AI:股市预测、天气预报
+ - 决策 AI:自动驾驶、游戏博弈
+ """,
+ },
+ {
+ "name": "SWOT Analysis",
+ "content": """
+ 某初创咖啡品牌的 SWOT 分析:
+ 优势:产品口味独特,选址精准。
+ 劣势:品牌知名度低,资金压力大。
+ 机会:线上外卖市场增长,年轻人对精品咖啡需求增加。
+ 威胁:行业巨头价格战,原材料成本上涨。
+ """,
+ },
+ {
+ "name": "Sankey (Relationship)",
+ "content": """
+ 家庭月度开支流向:
+ 总收入 10000 元。
+ 其中 4000 元用于房贷。
+ 3000 元用于日常消费(包括 2000 元餐饮,1000 元交通)。
+ 2000 元用于教育培训。
+ 1000 元存入银行。
+ """,
+ },
+ {
+ "name": "Quadrant (Analysis)",
+ "content": """
+ 个人任务象限分析:
+ - 紧急且重要:修复线上紧急 Bug,准备下午的客户会议。
+ - 重要不紧急:制定年度学习计划,健身锻炼。
+ - 紧急不重要:回复琐碎的邮件,接听推销电话。
+ - 不紧急不重要:刷社交媒体,看无聊的综艺。
+ """,
+ },
+ {
+ "name": "Chart Bar (Data)",
+ "content": """
+ 2023年各季度营收情况(亿元):
+ 第一季度:12.5
+ 第二季度:15.8
+ 第三季度:14.2
+ 第四季度:18.9
+ """,
+ },
+]
+
+# =================================================================
+# Helper Functions
+# =================================================================
+
+
+def generate_infographic(content):
+ now = datetime.now()
+ current_date_time_str = now.strftime("%Y年%m月%d日 %H:%M:%S")
+
+ formatted_user_prompt = USER_PROMPT_GENERATE_INFOGRAPHIC.format(
+ user_name="TestUser",
+ current_date_time_str=current_date_time_str,
+ user_language="zh-CN",
+ long_text_content=content,
+ )
+
+ headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
+
+ payload = {
+ "model": MODEL,
+ "messages": [
+ {"role": "system", "content": SYSTEM_PROMPT_INFOGRAPHIC_ASSISTANT},
+ {"role": "user", "content": formatted_user_prompt},
+ ],
+ "stream": False,
+ }
+
+ try:
+ response = requests.post(
+ f"{BASE_URL}/chat/completions", headers=headers, json=payload
+ )
+ response.raise_for_status()
+ return response.json()["choices"][0]["message"]["content"]
+ except Exception as e:
+ print(f"API Request Failed: {e}")
+ return None
+
+
+def validate_syntax(syntax):
+ if not syntax:
+ return False, "Empty output"
+
+ # Basic checks
+ if "infographic" not in syntax.lower():
+ return False, "Missing 'infographic' keyword"
+
+ if "data" not in syntax.lower() and "items" not in syntax.lower():
+ return False, "Missing 'data' or 'items' block"
+
+ # Check for colons in keys (simple heuristic)
+ lines = syntax.split("\n")
+ for line in lines:
+ stripped = line.strip()
+ if not stripped:
+ continue
+ # Ignore lines that are likely values or descriptions containing colons
+ first_word = stripped.split()[0]
+ if first_word in ["title", "desc", "label", "value", "time", "icon"]:
+ continue # These can have colons in value
+
+ # Check for key: pattern at start of line
+ if re.match(r"^\w+:", stripped):
+ return False, f"Found colon in key: {stripped}"
+
+ return True, "Syntax looks valid"
+
+
+# =================================================================
+# Main Execution
+# =================================================================
+
+import re
+
+
+def main():
+ print(f"Starting Infographic Generation Verification...")
+ print(f"API: {BASE_URL}")
+ print(f"Model: {MODEL}")
+ print("-" * 50)
+
+ results = []
+
+ for case in TEST_CASES:
+ print(f"\nTesting Case: {case['name']}")
+ print("Generating...")
+
+ output = generate_infographic(case["content"])
+
+ if output:
+ # Clean output (remove markdown code blocks if present)
+ clean_output = output
+ if "```" in output:
+ match = re.search(
+ r"```(?:infographic|mermaid)?\s*(.*?)\s*```", output, re.DOTALL
+ )
+ if match:
+ clean_output = match.group(1).strip()
+
+ print(f"Output Preview:\n{clean_output[:200]}...")
+
+ is_valid, message = validate_syntax(clean_output)
+ if is_valid:
+ print(f"✅ Validation Passed: {message}")
+ results.append({"name": case["name"], "status": "PASS"})
+ else:
+ print(f"❌ Validation Failed: {message}")
+ print(f"Full Output:\n{output}")
+ results.append({"name": case["name"], "status": "FAIL"})
+ else:
+ print("❌ Generation Failed")
+ results.append({"name": case["name"], "status": "ERROR"})
+
+ print("\n" + "=" * 50)
+ print("Summary")
+ print("=" * 50)
+ for res in results:
+ print(f"{res['name']}: {res['status']}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/plugins/actions/infographic/信息图.py b/plugins/actions/infographic/信息图.py
new file mode 100644
index 0000000..f66b9b2
--- /dev/null
+++ b/plugins/actions/infographic/信息图.py
@@ -0,0 +1,1155 @@
+"""
+title: 信息图
+icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIj48cmVjdCB4PSIzIiB5PSIzIiB3aWR0aD0iMTgiIGhlaWdodD0iMTgiIHJ4PSIyIi8+PHBhdGggZD0iTTcgOGg1Ii8+PHBhdGggZD0iTTcgMTJoNyIvPjxwYXRoIGQ9Ik03IDE2aDkiLz48L3N2Zz4=
+version: 1.2.38
+description: 将文本内容转换为美观的信息图,支持多种模板类型和自动图标搜索。
+"""
+
+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`
+- **Multi-item Comparison Table** -> `compare-table`
+- **Quadrant Analysis (Importance vs Urgency)** -> `quadrant-quarter`
+
+#### 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 (e.g., `rough`, `flat`)
+
+### 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 (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"[{role_label} 消息 {i}]\n{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
diff --git a/plugins/actions/knowledge-card/knowledge_card.py b/plugins/actions/knowledge-card/knowledge_card.py
index 7cc9e8e..a102f9e 100644
--- a/plugins/actions/knowledge-card/knowledge_card.py
+++ b/plugins/actions/knowledge-card/knowledge_card.py
@@ -95,6 +95,10 @@ class Action:
default=False,
description="Whether to force clear previous plugin results (if True, overwrites instead of merging).",
)
+ MESSAGE_COUNT: int = Field(
+ default=1,
+ description="Number of recent messages to use for generation. Set to 1 for just the last message, or higher for more context.",
+ )
def __init__(self):
self.valves = self.Valves()
@@ -111,13 +115,32 @@ class Action:
if not __event_emitter__:
return body
- # Get the last user message
+ # Get messages based on MESSAGE_COUNT
messages = body.get("messages", [])
if not messages:
return body
- # Usually the action is triggered on the last message
- target_message = messages[-1]["content"]
+ # Get last N messages based on MESSAGE_COUNT
+ message_count = min(self.valves.MESSAGE_COUNT, len(messages))
+ recent_messages = messages[-message_count:]
+
+ # Aggregate content from selected messages with labels
+ 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 = (
+ "User"
+ if role == "user"
+ else "Assistant" if role == "assistant" else role
+ )
+ aggregated_parts.append(f"[{role_label} Message {i}]\n{text_content}")
+
+ if not aggregated_parts:
+ return body
+
+ target_message = "\n\n---\n\n".join(aggregated_parts)
# Check text length
text_length = len(target_message)
@@ -140,9 +163,18 @@ class Action:
await self._emit_notification(
__event_emitter__, "⚡ Generating Flash Card...", "info"
)
+ await self._emit_status(
+ __event_emitter__, "⚡ Flash Card: Starting generation...", done=False
+ )
try:
# 1. Extract information using LLM
+ await self._emit_status(
+ __event_emitter__,
+ "⚡ Flash Card: Calling AI model to analyze content...",
+ done=False,
+ )
+
user_id = __user__.get("id") if __user__ else "default"
user_obj = Users.get_user_by_id(user_id)
@@ -187,6 +219,12 @@ Important Principles:
response = await generate_chat_completion(__request__, payload, user_obj)
content = response["choices"][0]["message"]["content"]
+ await self._emit_status(
+ __event_emitter__,
+ "⚡ Flash Card: AI analysis complete, parsing data...",
+ done=False,
+ )
+
# Parse JSON
try:
# simple cleanup in case of markdown code blocks
@@ -198,14 +236,20 @@ Important Principles:
card_data = json.loads(content)
except Exception as e:
logger.error(f"Failed to parse JSON: {e}, content: {content}")
+ await self._emit_status(
+ __event_emitter__, "❌ Flash Card: Data parsing failed", done=True
+ )
await self._emit_notification(
__event_emitter__,
- "Failed to generate card data, please try again.",
+ "❌ Failed to generate card data, please try again.",
"error",
)
return body
# 2. Generate HTML components
+ await self._emit_status(
+ __event_emitter__, "⚡ Flash Card: Rendering card...", done=False
+ )
card_content, card_style = self.generate_html_card_components(card_data)
# 3. Append to message
@@ -245,6 +289,9 @@ Important Principles:
html_embed_tag = f"```html\n{final_html}\n```"
body["messages"][-1]["content"] += f"\n\n{html_embed_tag}"
+ await self._emit_status(
+ __event_emitter__, "✅ Flash Card: Generation complete!", done=True
+ )
await self._emit_notification(
__event_emitter__, "⚡ Flash Card generated successfully!", "success"
)
@@ -253,8 +300,13 @@ Important Principles:
except Exception as e:
logger.error(f"Error generating knowledge card: {e}")
+ await self._emit_status(
+ __event_emitter__, "❌ Flash Card: Generation failed", done=True
+ )
await self._emit_notification(
- __event_emitter__, f"Error generating knowledge card: {str(e)}", "error"
+ __event_emitter__,
+ f"❌ Error generating knowledge card: {str(e)}",
+ "error",
)
return body
@@ -277,6 +329,21 @@ Important Principles:
pattern = r"```html\s*[\s\S]*?```"
return re.sub(pattern, "", content).strip()
+ def _extract_text_content(self, content) -> str:
+ """Extract text from message content, supporting multimodal message formats"""
+ if isinstance(content, str):
+ return content
+ elif isinstance(content, list):
+ # Multimodal message: [{"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,
diff --git a/plugins/actions/knowledge-card/闪记卡.py b/plugins/actions/knowledge-card/闪记卡.py
index 48119fd..5dab4ca 100644
--- a/plugins/actions/knowledge-card/闪记卡.py
+++ b/plugins/actions/knowledge-card/闪记卡.py
@@ -92,6 +92,10 @@ class Action:
default=False,
description="是否强制清除旧的插件结果(如果为 True,则不合并,直接覆盖)。",
)
+ MESSAGE_COUNT: int = Field(
+ default=1,
+ description="用于生成的最近消息数量。设置为1仅使用最后一条消息,更大值可包含更多上下文。",
+ )
def __init__(self):
self.valves = self.Valves()
@@ -108,13 +112,32 @@ class Action:
if not __event_emitter__:
return body
- # Get the last user message
+ # Get messages based on MESSAGE_COUNT
messages = body.get("messages", [])
if not messages:
return body
- # Usually the action is triggered on the last message
- target_message = messages[-1]["content"]
+ # Get last N messages based on MESSAGE_COUNT
+ message_count = min(self.valves.MESSAGE_COUNT, len(messages))
+ recent_messages = messages[-message_count:]
+
+ # Aggregate content from selected messages with labels
+ 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"[{role_label} 消息 {i}]\n{text_content}")
+
+ if not aggregated_parts:
+ return body
+
+ target_message = "\n\n---\n\n".join(aggregated_parts)
# Check text length
text_length = len(target_message)
@@ -135,9 +158,14 @@ class Action:
# Notify user that we are generating the card
await self._emit_notification(__event_emitter__, "⚡ 正在生成闪记卡...", "info")
+ await self._emit_status(__event_emitter__, "⚡ 闪记卡: 开始生成...", done=False)
try:
# 1. Extract information using LLM
+ await self._emit_status(
+ __event_emitter__, "⚡ 闪记卡: 正在调用 AI 模型分析内容...", done=False
+ )
+
user_id = __user__.get("id") if __user__ else "default"
user_obj = Users.get_user_by_id(user_id)
@@ -182,6 +210,10 @@ class Action:
response = await generate_chat_completion(__request__, payload, user_obj)
content = response["choices"][0]["message"]["content"]
+ await self._emit_status(
+ __event_emitter__, "⚡ 闪记卡: AI 分析完成,正在解析数据...", done=False
+ )
+
# Parse JSON
try:
# simple cleanup in case of markdown code blocks
@@ -193,12 +225,18 @@ class Action:
card_data = json.loads(content)
except Exception as e:
logger.error(f"Failed to parse JSON: {e}, content: {content}")
+ await self._emit_status(
+ __event_emitter__, "❌ 闪记卡: 数据解析失败", done=True
+ )
await self._emit_notification(
- __event_emitter__, "生成卡片数据失败,请重试。", "error"
+ __event_emitter__, "❌ 生成卡片数据失败,请重试。", "error"
)
return body
# 2. Generate HTML components
+ await self._emit_status(
+ __event_emitter__, "⚡ 闪记卡: 正在渲染卡片...", done=False
+ )
card_content, card_style = self.generate_html_card_components(card_data)
# 3. Append to message
@@ -238,6 +276,9 @@ class Action:
html_embed_tag = f"```html\n{final_html}\n```"
body["messages"][-1]["content"] += f"\n\n{html_embed_tag}"
+ await self._emit_status(
+ __event_emitter__, "✅ 闪记卡: 生成完成!", done=True
+ )
await self._emit_notification(
__event_emitter__, "⚡ 闪记卡生成成功!", "success"
)
@@ -246,8 +287,9 @@ class Action:
except Exception as e:
logger.error(f"Error generating knowledge card: {e}")
+ await self._emit_status(__event_emitter__, "❌ 闪记卡: 生成失败", done=True)
await self._emit_notification(
- __event_emitter__, f"生成知识卡片时出错: {str(e)}", "error"
+ __event_emitter__, f"❌ 生成知识卡片时出错: {str(e)}", "error"
)
return body
@@ -270,6 +312,21 @@ class Action:
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,
diff --git a/plugins/actions/smart-mind-map/smart_mind_map.py b/plugins/actions/smart-mind-map/smart_mind_map.py
index 35810e1..2a04f37 100644
--- a/plugins/actions/smart-mind-map/smart_mind_map.py
+++ b/plugins/actions/smart-mind-map/smart_mind_map.py
@@ -1,7 +1,7 @@
"""
title: Smart Mind Map
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+CiAgPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMyIgZmlsbD0iY3VycmVudENvbG9yIi8+CiAgPGxpbmUgeDE9IjEyIiB5MT0iOSIgeDI9IjEyIiB5Mj0iNCIvPgogIDxjaXJjbGUgY3g9IjEyIiBjeT0iMyIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEyIiB5MT0iMTUiIHgyPSIxMiIgeTI9IjIwIi8+CiAgPGNpcmNsZSBjeD0iMTIiIGN5PSIyMSIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjkiIHkxPSIxMiIgeDI9IjQiIHkyPSIxMiIvPgogIDxjaXJjbGUgY3g9IjMiIGN5PSIxMiIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjE1IiB5MT0iMTIiIHgyPSIyMCIgeTI9IjEyIi8+CiAgPGNpcmNsZSBjeD0iMjEiIGN5PSIxMiIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEwLjUiIHkxPSIxMC41IiB4Mj0iNiIgeTI9IjYiLz4KICA8Y2lyY2xlIGN4PSI1IiBjeT0iNSIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEzLjUiIHkxPSIxMC41IiB4Mj0iMTgiIHkyPSI2Ii8+CiAgPGNpcmNsZSBjeD0iMTkiIGN5PSI1IiByPSIxLjUiLz4KICA8bGluZSB4MT0iMTAuNSIgeTE9IjEzLjUiIHgyPSI2IiB5Mj0iMTgiLz4KICA8Y2lyY2xlIGN4PSI1IiBjeT0iMTkiIHI9IjEuNSIvPgogIDxsaW5lIHgxPSIxMy41IiB5MT0iMTMuNSIgeDI9IjE4IiB5Mj0iMTgiLz4KICA8Y2lyY2xlIGN4PSIxOSIgY3k9IjE5IiByPSIxLjUiLz4KPC9zdmc+
-version: 0.7.3
+version: 0.7.4
description: Intelligently analyzes long texts and generates interactive mind maps, supporting SVG/Markdown export.
"""
@@ -81,17 +81,14 @@ HTML_WRAPPER_TEMPLATE = """
width: 100%;
}
.plugin-item {
- flex: 1 1 400px; /* Default width, allows shrinking/growing */
+ flex: 1 1 400px; /* Default width, allows stretching */
min-width: 300px;
- background: white;
border-radius: 12px;
- box-shadow: 0 4px 6px rgba(0,0,0,0.05);
overflow: hidden;
- border: 1px solid #e5e7eb;
transition: all 0.3s ease;
}
.plugin-item:hover {
- box-shadow: 0 10px 15px rgba(0,0,0,0.1);
+ transform: translateY(-2px);
}
@media (max-width: 768px) {
.plugin-item { flex: 1 1 100%; }
@@ -128,7 +125,6 @@ CSS_TEMPLATE_MINDMAP = """
color: var(--text-color);
margin: 0;
padding: 0;
- background-color: var(--card-bg-color);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 100%;
@@ -169,7 +165,6 @@ CSS_TEMPLATE_MINDMAP = """
background-size: 20px 20px;
border-radius: 8px;
padding: 16px;
- min-height: 500px;
display: flex;
justify-content: center;
align-items: center;
@@ -287,7 +282,8 @@ SCRIPT_TEMPLATE_MINDMAP = """
try {
const svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgEl.style.width = '100%';
- svgEl.style.height = '500px';
+ svgEl.style.height = 'auto';
+ svgEl.style.minHeight = '300px';
containerEl.innerHTML = '';
containerEl.appendChild(svgEl);
@@ -410,6 +406,10 @@ class Action:
default=False,
description="Whether to force clear previous plugin results (if True, overwrites instead of merging).",
)
+ MESSAGE_COUNT: int = Field(
+ default=1,
+ description="Number of recent messages to use for generation. Set to 1 for just the last message, or higher for more context.",
+ )
def __init__(self):
self.valves = self.Valves()
@@ -453,6 +453,21 @@ class Action:
pattern = r"```html\s*[\s\S]*?```"
return re.sub(pattern, "", content).strip()
+ def _extract_text_content(self, content) -> str:
+ """Extract text from message content, supporting multimodal message formats"""
+ if isinstance(content, str):
+ return content
+ elif isinstance(content, list):
+ # Multimodal message: [{"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,
@@ -544,18 +559,40 @@ class Action:
)
messages = body.get("messages")
- if (
- not messages
- or not isinstance(messages, list)
- or not messages[-1].get("content")
- ):
+ if not messages or not isinstance(messages, list):
error_message = "Unable to retrieve valid user message content."
await self._emit_notification(__event_emitter__, error_message, "error")
return {
"messages": [{"role": "assistant", "content": f"❌ {error_message}"}]
}
- parts = re.split(r"```html.*?```", messages[-1]["content"], flags=re.DOTALL)
+ # Get last N messages based on MESSAGE_COUNT
+ message_count = min(self.valves.MESSAGE_COUNT, len(messages))
+ recent_messages = messages[-message_count:]
+
+ # Aggregate content from selected messages with labels
+ 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 = (
+ "User"
+ if role == "user"
+ else "Assistant" if role == "assistant" else role
+ )
+ aggregated_parts.append(f"[{role_label} Message {i}]\n{text_content}")
+
+ if not aggregated_parts:
+ error_message = "Unable to retrieve valid user message content."
+ await self._emit_notification(__event_emitter__, error_message, "error")
+ return {
+ "messages": [{"role": "assistant", "content": f"❌ {error_message}"}]
+ }
+
+ original_content = "\n\n---\n\n".join(aggregated_parts)
+
+ parts = re.split(r"```html.*?```", original_content, flags=re.DOTALL)
long_text_content = ""
if parts:
for part in reversed(parts):
@@ -564,7 +601,7 @@ class Action:
break
if not long_text_content:
- long_text_content = messages[-1]["content"].strip()
+ long_text_content = original_content.strip()
if len(long_text_content) < self.valves.MIN_TEXT_LENGTH:
short_text_message = f"Text content is too short ({len(long_text_content)} characters), unable to perform effective analysis. Please provide at least {self.valves.MIN_TEXT_LENGTH} characters of text."
diff --git a/plugins/actions/smart-mind-map/思维导图.py b/plugins/actions/smart-mind-map/思维导图.py
index f7ba0ff..f9dcdba 100644
--- a/plugins/actions/smart-mind-map/思维导图.py
+++ b/plugins/actions/smart-mind-map/思维导图.py
@@ -1,7 +1,7 @@
"""
title: 智绘心图
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+CiAgPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMyIgZmlsbD0iY3VycmVudENvbG9yIi8+CiAgPGxpbmUgeDE9IjEyIiB5MT0iOSIgeDI9IjEyIiB5Mj0iNCIvPgogIDxjaXJjbGUgY3g9IjEyIiBjeT0iMyIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEyIiB5MT0iMTUiIHgyPSIxMiIgeTI9IjIwIi8+CiAgPGNpcmNsZSBjeD0iMTIiIGN5PSIyMSIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjkiIHkxPSIxMiIgeDI9IjQiIHkyPSIxMiIvPgogIDxjaXJjbGUgY3g9IjMiIGN5PSIxMiIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjE1IiB5MT0iMTIiIHgyPSIyMCIgeTI9IjEyIi8+CiAgPGNpcmNsZSBjeD0iMjEiIGN5PSIxMiIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEwLjUiIHkxPSIxMC41IiB4Mj0iNiIgeTI9IjYiLz4KICA8Y2lyY2xlIGN4PSI1IiBjeT0iNSIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEzLjUiIHkxPSIxMC41IiB4Mj0iMTgiIHkyPSI2Ii8+CiAgPGNpcmNsZSBjeD0iMTkiIGN5PSI1IiByPSIxLjUiLz4KICA8bGluZSB4MT0iMTAuNSIgeTE9IjEzLjUiIHgyPSI2IiB5Mj0iMTgiLz4KICA8Y2lyY2xlIGN4PSI1IiBjeT0iMTkiIHI9IjEuNSIvPgogIDxsaW5lIHgxPSIxMy41IiB5MT0iMTMuNSIgeDI9IjE4IiB5Mj0iMTgiLz4KICA8Y2lyY2xlIGN4PSIxOSIgY3k9IjE5IiByPSIxLjUiLz4KPC9zdmc+
-version: 0.7.2
+version: 0.7.4
description: 智能分析文本内容,生成交互式思维导图,帮助用户结构化和可视化知识。
"""
@@ -83,15 +83,12 @@ HTML_WRAPPER_TEMPLATE = """
.plugin-item {
flex: 1 1 400px; /* 默认宽度,允许伸缩 */
min-width: 300px;
- background: white;
border-radius: 12px;
- box-shadow: 0 4px 6px rgba(0,0,0,0.05);
overflow: hidden;
- border: 1px solid #e5e7eb;
transition: all 0.3s ease;
}
.plugin-item:hover {
- box-shadow: 0 10px 15px rgba(0,0,0,0.1);
+ transform: translateY(-2px);
}
@media (max-width: 768px) {
.plugin-item { flex: 1 1 100%; }
@@ -128,7 +125,6 @@ CSS_TEMPLATE_MINDMAP = """
color: var(--text-color);
margin: 0;
padding: 0;
- background-color: var(--card-bg-color);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 100%;
@@ -169,7 +165,6 @@ CSS_TEMPLATE_MINDMAP = """
background-size: 20px 20px;
border-radius: 8px;
padding: 16px;
- min-height: 500px;
display: flex;
justify-content: center;
align-items: center;
@@ -287,7 +282,8 @@ SCRIPT_TEMPLATE_MINDMAP = """
try {
const svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgEl.style.width = '100%';
- svgEl.style.height = '500px';
+ svgEl.style.height = 'auto';
+ svgEl.style.minHeight = '300px';
containerEl.innerHTML = '';
containerEl.appendChild(svgEl);
@@ -409,6 +405,10 @@ class Action:
default=False,
description="是否强制清除旧的插件结果(如果为 True,则不合并,直接覆盖)。",
)
+ MESSAGE_COUNT: int = Field(
+ default=1,
+ description="用于生成的最近消息数量。设置为1仅使用最后一条消息,更大值可包含更多上下文。",
+ )
def __init__(self):
self.valves = self.Valves()
@@ -452,6 +452,21 @@ class Action:
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,
@@ -541,18 +556,40 @@ class Action:
)
messages = body.get("messages")
- if (
- not messages
- or not isinstance(messages, list)
- or not messages[-1].get("content")
- ):
+ if not messages or not isinstance(messages, list):
error_message = "无法获取有效的用户消息内容。"
await self._emit_notification(__event_emitter__, error_message, "error")
return {
"messages": [{"role": "assistant", "content": f"❌ {error_message}"}]
}
- parts = re.split(r"```html.*?```", messages[-1]["content"], flags=re.DOTALL)
+ # Get last N messages based on MESSAGE_COUNT
+ message_count = min(self.valves.MESSAGE_COUNT, len(messages))
+ recent_messages = messages[-message_count:]
+
+ # Aggregate content from selected messages with labels
+ 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"[{role_label} 消息 {i}]\n{text_content}")
+
+ if not aggregated_parts:
+ error_message = "无法获取有效的用户消息内容。"
+ await self._emit_notification(__event_emitter__, error_message, "error")
+ return {
+ "messages": [{"role": "assistant", "content": f"❌ {error_message}"}]
+ }
+
+ original_content = "\n\n---\n\n".join(aggregated_parts)
+
+ parts = re.split(r"```html.*?```", original_content, flags=re.DOTALL)
long_text_content = ""
if parts:
for part in reversed(parts):
@@ -561,7 +598,7 @@ class Action:
break
if not long_text_content:
- long_text_content = messages[-1]["content"].strip()
+ 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}字符的文本。"
diff --git a/plugins/actions/summary/summary.py b/plugins/actions/summary/summary.py
index 9b798ea..98b299f 100644
--- a/plugins/actions/summary/summary.py
+++ b/plugins/actions/summary/summary.py
@@ -325,6 +325,10 @@ class Action:
default=False,
description="Whether to force clear previous plugin results (if True, overwrites instead of merging).",
)
+ MESSAGE_COUNT: int = Field(
+ default=1,
+ description="Number of recent messages to use for generation. Set to 1 for just the last message, or higher for more context.",
+ )
def __init__(self):
self.valves = self.Valves()
@@ -400,6 +404,21 @@ class Action:
pattern = r"```html\s*[\s\S]*?```"
return re.sub(pattern, "", content).strip()
+ def _extract_text_content(self, content) -> str:
+ """Extract text from message content, supporting multimodal message formats"""
+ if isinstance(content, str):
+ return content
+ elif isinstance(content, list):
+ # Multimodal message: [{"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,
@@ -492,10 +511,32 @@ class Action:
original_content = ""
try:
messages = body.get("messages", [])
- if not messages or not messages[-1].get("content"):
+ if not messages:
raise ValueError("Unable to get valid user message content.")
- original_content = messages[-1]["content"]
+ # Get last N messages based on MESSAGE_COUNT
+ message_count = min(self.valves.MESSAGE_COUNT, len(messages))
+ recent_messages = messages[-message_count:]
+
+ # Aggregate content from selected messages with labels
+ 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 = (
+ "User"
+ if role == "user"
+ else "Assistant" if role == "assistant" else role
+ )
+ aggregated_parts.append(
+ f"[{role_label} Message {i}]\n{text_content}"
+ )
+
+ if not aggregated_parts:
+ raise ValueError("Unable to get valid user message content.")
+
+ original_content = "\n\n---\n\n".join(aggregated_parts)
if len(original_content) < self.valves.MIN_TEXT_LENGTH:
short_text_message = f"Text content too short ({len(original_content)} chars), recommended at least {self.valves.MIN_TEXT_LENGTH} chars for effective deep analysis.\n\n💡 Tip: For short texts, consider using '⚡ Flash Card' for quick refinement."
diff --git a/plugins/actions/summary/精读.py b/plugins/actions/summary/精读.py
index 63d3975..07aa8c3 100644
--- a/plugins/actions/summary/精读.py
+++ b/plugins/actions/summary/精读.py
@@ -320,6 +320,10 @@ class Action:
default=False,
description="是否强制清除旧的插件结果(如果为 True,则不合并,直接覆盖)。",
)
+ MESSAGE_COUNT: int = Field(
+ default=1,
+ description="用于生成的最近消息数量。设置为1仅使用最后一条消息,更大值可包含更多上下文。",
+ )
def __init__(self):
self.valves = self.Valves()
@@ -398,6 +402,21 @@ class Action:
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,
@@ -491,10 +510,30 @@ class Action:
original_content = ""
try:
messages = body.get("messages", [])
- if not messages or not messages[-1].get("content"):
+ if not messages:
raise ValueError("无法获取有效的用户消息内容。")
- original_content = messages[-1]["content"]
+ # Get last N messages based on MESSAGE_COUNT
+ message_count = min(self.valves.MESSAGE_COUNT, len(messages))
+ recent_messages = messages[-message_count:]
+
+ # Aggregate content from selected messages with labels
+ 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"[{role_label} 消息 {i}]\n{text_content}")
+
+ if not aggregated_parts:
+ raise ValueError("无法获取有效的用户消息内容。")
+
+ original_content = "\n\n---\n\n".join(aggregated_parts)
if len(original_content) < self.valves.MIN_TEXT_LENGTH:
short_text_message = f"文本内容过短({len(original_content)}字符),建议至少{self.valves.MIN_TEXT_LENGTH}字符以获得有效的深度分析。\n\n💡 提示:对于短文本,建议使用'⚡ 闪记卡'进行快速提炼。"
diff --git a/plugins/filters/context_enhancement_filter/README_CITATIONS.md b/plugins/filters/context_enhancement_filter/README_CITATIONS.md
new file mode 100644
index 0000000..20517b5
--- /dev/null
+++ b/plugins/filters/context_enhancement_filter/README_CITATIONS.md
@@ -0,0 +1,301 @@
+# Citations 处理功能文档
+
+## 概述
+
+`context_enhancement_filter.py` 现在支持自动处理模型响应中的 `citations` 和 `grounding_metadata`,将其转换为 Open WebUI 的标准引用格式,使搜索来源能够在聊天界面中正确展示。
+
+## 支持的数据格式
+
+### 1. Gemini Search 格式
+
+模型响应包含顶层的 `citations` 数组和 `grounding_metadata`:
+
+```json
+{
+ "id": "chatcmpl-xxx",
+ "type": "stream",
+ "model": "gemini-3-flash-preview-search",
+ "content": "回答内容...",
+ "citations": [
+ {
+ "source": {
+ "url": "https://example.com/article",
+ "name": "example.com"
+ },
+ "retrieved_at": "2025-12-27T17:50:03.472550"
+ }
+ ],
+ "grounding_metadata": {
+ "web_search_queries": [
+ "搜索查询1",
+ "搜索查询2"
+ ]
+ }
+}
+```
+
+### 2. 嵌套在 messages 中的格式
+
+Citations 数据嵌套在最后一条 assistant 消息中:
+
+```json
+{
+ "messages": [
+ {
+ "role": "assistant",
+ "content": "回答内容...",
+ "citations": [...],
+ "grounding_metadata": {...}
+ }
+ ]
+}
+```
+
+## 功能特性
+
+### 1. 自动引用提取
+
+插件会自动从以下位置提取 citations:
+- 响应体顶层的 `citations` 字段
+- 最后一条 assistant 消息中的 `citations` 字段
+
+### 2. 引用格式转换
+
+将模型原始的 citations 格式转换为 Open WebUI 标准格式:
+
+**输入格式:**
+```json
+{
+ "source": {
+ "url": "https://example.com",
+ "name": "example.com"
+ },
+ "retrieved_at": "2025-12-27T17:50:03.472550"
+}
+```
+
+**输出格式:**
+```json
+{
+ "type": "citation",
+ "data": {
+ "document": ["来源:example.com\n检索时间:2025-12-27T17:50:03.472550"],
+ "metadata": [
+ {
+ "source": "example.com",
+ "url": "https://example.com",
+ "date_accessed": "2025-12-27T17:50:03.472550",
+ "type": "web_search_result"
+ }
+ ],
+ "source": {
+ "name": "example.com",
+ "url": "https://example.com"
+ }
+ }
+}
+```
+
+### 3. 搜索查询展示
+
+如果响应包含 `grounding_metadata.web_search_queries`,会在界面上显示使用的搜索查询:
+
+```
+🔍 使用了 4 个搜索查询
+```
+
+### 4. 处理状态提示
+
+成功处理 citations 后,会显示状态提示:
+
+```
+✓ 已处理 7 个引用来源
+```
+
+## 在 Open WebUI 中的展示效果
+
+### 引用链接
+每个引用来源会在聊天界面中显示为可点击的链接,用户可以:
+- 点击查看原始来源
+- 查看检索时间等元数据
+- 了解信息的出处
+
+### 搜索查询历史
+显示模型使用的搜索查询列表,帮助用户了解:
+- 模型如何理解和分解查询
+- 执行了哪些搜索操作
+- 搜索策略是否合理
+
+## 使用示例
+
+### 示例 1:Gemini Search 模型
+
+当使用支持搜索的 Gemini 模型时:
+
+```python
+# 用户提问
+"MiniMax 最新的模型是什么?"
+
+# 模型响应会包含 citations
+{
+ "content": "MiniMax 最新的模型是 M2.1...",
+ "citations": [
+ {"source": {"url": "https://qbitai.com/...", "name": "qbitai.com"}, ...},
+ {"source": {"url": "https://minimax.io/...", "name": "minimax.io"}, ...}
+ ]
+}
+
+# Open WebUI 界面显示:
+# 1. 模型的回答内容
+# 2. 可点击的引用链接:qbitai.com, minimax.io 等
+# 3. 状态提示:✓ 已处理 7 个引用来源
+```
+
+### 示例 2:自定义 Pipe/Filter
+
+如果你的自定义 Pipe 或 Filter 返回包含 citations 的数据:
+
+```python
+class CustomPipe:
+ def pipe(self, body: dict) -> dict:
+ # 执行搜索或检索操作
+ search_results = self.perform_search(query)
+
+ # 构建响应,包含 citations
+ return {
+ "messages": [{
+ "role": "assistant",
+ "content": "基于搜索结果...",
+ "citations": [
+ {
+ "source": {"url": url, "name": domain},
+ "retrieved_at": datetime.now().isoformat()
+ }
+ for url, domain in search_results
+ ]
+ }]
+ }
+```
+
+`context_enhancement_filter` 会自动处理这些 citations 并在界面中展示。
+
+## 配置说明
+
+### 启用 Filter
+
+1. 在 Open WebUI 中安装 `context_enhancement_filter.py`
+2. 确保 Filter 已启用
+3. Citations 处理功能自动生效,无需额外配置
+
+### 与其他功能的集成
+
+Citations 处理与 Filter 的其他功能(环境变量注入、内容规范化等)无缝集成:
+
+- **环境变量注入**:在 inlet 阶段处理
+- **内容规范化**:在 outlet 阶段处理
+- **Citations 处理**:在 outlet 阶段处理(与内容规范化并行)
+
+## 技术实现细节
+
+### 异步处理
+
+Citations 处理使用异步方式,不会阻塞主响应流:
+
+```python
+asyncio.create_task(self._process_citations(body, __event_emitter__))
+```
+
+### 错误处理
+
+- 完善的异常捕获,确保 citations 处理失败不影响正常响应
+- 详细的日志记录,便于问题排查
+- 优雅降级:如果处理失败,用户仍能看到模型回答
+
+### 兼容性
+
+- 支持多种 citations 数据格式
+- 向后兼容:不包含 citations 的响应不受影响
+- 与 Open WebUI 原生 citations 系统完全兼容
+
+## 常见问题
+
+### Q: Citations 没有显示?
+
+**A:** 检查以下几点:
+1. 模型响应是否包含 `citations` 字段
+2. Filter 是否已启用
+3. `__event_emitter__` 是否正常工作
+4. 浏览器控制台是否有错误信息
+
+### Q: 如何自定义 citations 展示格式?
+
+**A:** 修改 `_process_citations` 方法中的 `document_text` 构建逻辑:
+
+```python
+# 自定义文档文本格式
+document_text = f"""
+📄 **{source_name}**
+🔗 {source_url}
+⏰ {retrieved_at}
+"""
+```
+
+### Q: 可以禁用 citations 处理吗?
+
+**A:** 可以通过以下方式禁用:
+
+1. **临时禁用**:在 outlet 方法中注释掉 citations 处理代码
+2. **条件禁用**:添加配置选项到 Valves:
+
+```python
+class Valves(BaseModel):
+ enable_citations_processing: bool = Field(
+ default=True,
+ description="启用 citations 自动处理"
+ )
+```
+
+## 与 websearch.py 的对比
+
+| 特性 | websearch.py | context_enhancement_filter.py |
+|------|--------------|-------------------------------|
+| 功能 | 主动执行搜索 | 被动处理已有 citations |
+| 时机 | inlet (请求前) | outlet (响应后) |
+| 搜索引擎 | SearxNG | 依赖模型自身 |
+| 内容注入 | 直接修改用户消息 | 不修改消息 |
+| 引用展示 | ✅ 支持 | ✅ 支持 |
+
+**推荐使用场景:**
+- 使用支持搜索的模型(如 Gemini Search):启用 `context_enhancement_filter`
+- 使用不支持搜索的模型:启用 `websearch.py`
+- 需要自定义搜索源:使用 `websearch.py` 配置 SearxNG
+
+## 未来改进方向
+
+1. **更丰富的元数据展示**
+ - 显示网页标题、摘要
+ - 展示相关性评分
+ - 支持缩略图预览
+
+2. **智能内容提取**
+ - 从响应内容中提取被引用的具体段落
+ - 高亮显示引用来源对应的文本
+
+3. **引用验证**
+ - 检查引用链接的有效性
+ - 提供存档链接备份
+
+4. **用户配置选项**
+ - 自定义引用展示格式
+ - 选择性显示/隐藏某些元数据
+ - 引用分组和排序选项
+
+## 参考资源
+
+- [Open WebUI Plugin 开发文档](../../docs/features/plugin/tools/development.mdx)
+- [Event Emitter 使用指南](../../docs/features/plugin/development/events.mdx)
+- [WebSearch Filter 实现](../websearch/websearch.py)
+
+## 贡献
+
+欢迎提交 Issue 和 Pull Request 来改进这个功能!