feat: 添加信息图插件,并更新相关插件模板和开发文档。

This commit is contained in:
fujie
2025-12-28 20:08:50 +08:00
parent 3ddddb69d7
commit 2f27267b42
20 changed files with 5020 additions and 49 deletions

View File

@@ -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*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\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}."

View File

@@ -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*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\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}"

View File

@@ -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 聊天界面

View File

@@ -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 资源和图标。

View File

@@ -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.

View File

@@ -0,0 +1,277 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>卡片间距调试工具</title>
<script src="https://registry.npmmirror.com/@antv/infographic/0.2.1/files/dist/infographic.min.js"></script>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 20px;
background: #f0f0f0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.debug-container {
display: flex;
gap: 20px;
max-width: 1400px;
margin: 0 auto;
}
.controls {
width: 320px;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
height: fit-content;
position: sticky;
top: 20px;
}
.controls h2 {
margin-top: 0;
font-size: 16px;
color: #333;
}
.controls label {
display: block;
margin: 16px 0 6px;
font-size: 14px;
color: #666;
font-weight: 500;
}
.controls input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.controls button {
width: 100%;
padding: 10px;
margin-top: 16px;
background: #6366f1;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.controls button:hover {
background: #4f46e5;
}
.controls .info {
margin-top: 20px;
padding: 12px;
background: #f1f5f9;
border-radius: 6px;
font-size: 13px;
color: #475569;
line-height: 1.6;
}
.preview {
flex: 1;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
#infographic-container {
min-height: 500px;
padding: 24px;
}
/* 基础样式 */
#infographic-container svg text {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif !important;
}
#infographic-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-container svg foreignObject[data-element-type="title"]>* {
font-size: 1.2em !important;
font-weight: bold !important;
line-height: 1.4 !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
/* 页面副标题样式 */
#infographic-container svg foreignObject[data-element-type="desc"]>* {
font-size: 0.6em !important;
line-height: 1.4 !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
/* 卡片标题和描述样式(这里是重点调试区域) */
#infographic-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-container svg foreignObject[data-element-type="item-desc"]>* {
line-height: 1.4 !important;
white-space: normal !important;
}
</style>
</head>
<body>
<div class="debug-container">
<div class="controls">
<h2>🔧 卡片间距调试面板</h2>
<label>卡片标题与描述间距 (margin-top)</label>
<input type="text" id="label-desc-gap" value="8px" placeholder="例如: 8px, 12px, 1em">
<label>卡片标题字体大小</label>
<input type="text" id="label-size" value="0.6em" placeholder="例如: 0.6em, 12px">
<label>卡片描述字体大小</label>
<input type="text" id="desc-size" value="0.5em" placeholder="例如: 0.5em, 10px">
<label>卡片内边距 (padding)</label>
<input type="text" id="card-padding" value="8px" placeholder="例如: 8px, 12px">
<button onclick="applyStyles()">应用样式</button>
<button onclick="reRender()" style="background: #10b981; margin-top: 8px;">重新渲染</button>
<div class="info">
<strong>说明:</strong><br>
• 标题与描述间距:控制标题和内容之间的垂直间距<br>
• 字体大小:分别控制标题和描述的文字大小<br>
• 内边距:控制整个卡片内的留白
</div>
</div>
<div class="preview">
<div id="infographic-container"></div>
</div>
</div>
<script>
function applyStyles() {
console.log('[Debug] 应用卡片样式...');
const labelDescGap = document.getElementById('label-desc-gap').value;
const labelSize = document.getElementById('label-size').value;
const descSize = document.getElementById('desc-size').value;
const cardPadding = document.getElementById('card-padding').value;
const container = document.getElementById('infographic-container');
// 卡片标题
const itemLabels = container.querySelectorAll('svg foreignObject[data-element-type="item-label"]');
itemLabels.forEach(fo => {
const firstChild = fo.querySelector(':scope > *');
if (firstChild) {
firstChild.style.setProperty('font-size', labelSize, 'important');
firstChild.style.setProperty('margin-bottom', labelDescGap, 'important');
}
});
// 卡片描述
const itemDescs = container.querySelectorAll('svg foreignObject[data-element-type="item-desc"]');
itemDescs.forEach(fo => {
const firstChild = fo.querySelector(':scope > *');
if (firstChild) {
firstChild.style.setProperty('font-size', descSize, 'important');
}
});
// 尝试调整卡片整体内边距(通过调整 g 元素的 padding
const cardGroups = container.querySelectorAll('svg g[data-element-type="items-group"] > g');
cardGroups.forEach(group => {
// 这里可能需要通过调整内部 g 的 transform 来实现
// AntV 的布局比较复杂padding 可能需要通过其他方式实现
});
console.log('[Debug] 卡片样式已应用:', {
labelDescGap,
labelSize,
descSize,
cardPadding
});
}
function reRender() {
const container = document.getElementById('infographic-container');
container.innerHTML = '';
const syntaxContent = `infographic list-grid-compact-card
data
title 🚀 功能亮点说明
desc 系统核心逻辑与交互视觉特性
items
- label 核心流程演示
icon mdi/database-cog
desc 包含4个数据源、4条分流管道运费/商品/退货以及UNION ALL与GROUP BY聚合节点
- label 灵动动画效果
icon mdi/motion-play
desc 支持5个订单多色并行流转、管道霓虹闪烁光晕、节点旋转及仪表盘数值跳动反馈
- label 极简交互控制
icon mdi/cursor-default-click
desc 内置开始/暂停/重置逻辑支持1x至4x倍速调整并集成实时仪表板与日志系统
- label 赛博朋克风格
icon mdi/palette
desc 采用深蓝紫色调、霓虹绿文字影、网格背景以及渐变发光边框,打造极客视觉体验`;
if (typeof AntVInfographic === 'undefined') {
container.innerHTML = '<div style="color: red; padding: 20px;">⚠️ AntV Infographic 库未加载</div>';
return;
}
try {
const { Infographic } = AntVInfographic;
const instance = new Infographic({
container: '#infographic-container',
padding: 24,
});
instance.render(syntaxContent);
console.log('[Debug] 渲染完成');
// 渲染完成后自动应用样式
setTimeout(() => {
applyStyles();
}, 300);
} catch (error) {
container.innerHTML = '<div style="color: red; padding: 20px;">⚠️ 渲染错误: ' + error.message + '</div>';
console.error(error);
}
}
// 页面加载后自动渲染
window.onload = function () {
setTimeout(reRender, 500);
};
</script>
</body>
</html>

View File

@@ -0,0 +1,342 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>信息图样式调试</title>
<script src="https://registry.npmmirror.com/@antv/infographic/0.2.1/files/dist/infographic.min.js"></script>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 20px;
background: #f0f0f0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.debug-container {
display: flex;
gap: 20px;
max-width: 1400px;
margin: 0 auto;
}
.controls {
width: 300px;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
height: fit-content;
position: sticky;
top: 20px;
}
.controls h2 {
margin-top: 0;
font-size: 16px;
}
.controls label {
display: block;
margin: 12px 0 4px;
font-size: 14px;
color: #666;
}
.controls input,
.controls select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.controls button {
width: 100%;
padding: 10px;
margin-top: 16px;
background: #6366f1;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.controls button:hover {
background: #4f46e5;
}
.preview {
flex: 1;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
#infographic-container {
min-height: 500px;
padding: 24px;
}
/* ========== 可调试的样式 ========== */
/* 这些样式可以直接在这里修改,刷新页面即可看到效果 */
/* 基础字体 */
#infographic-container svg text {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif !important;
}
#infographic-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-container svg foreignObject h1,
#infographic-container svg foreignObject [class*="title"] {
font-size: 0.8em !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
#infographic-container svg foreignObject h2,
#infographic-container svg foreignObject [class*="subtitle"] {
font-size: 0.85em !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
/* ========== 调试样式结束 ========== */
</style>
</head>
<body>
<div class="debug-container">
<div class="controls">
<h2>🔧 样式调试面板</h2>
<label>一级标题字体大小</label>
<input type="text" id="h1-size" value="1.2em" placeholder="例如: 1.2em, 20px">
<label>二级标题字体大小</label>
<input type="text" id="h2-size" value="0.6em" placeholder="例如: 0.6em, 12px">
<label>标题换行设置</label>
<select id="title-wrap">
<option value="nowrap">不换行 (nowrap)</option>
<option value="normal">自动换行 (normal)</option>
</select>
<label>行高</label>
<input type="text" id="line-height" value="1.4" placeholder="例如: 1.4, 1.6">
<button onclick="applyStyles()">应用样式</button>
<button onclick="reRender()" style="background: #10b981; margin-top: 8px;">重新渲染</button>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
<label>自定义语法内容</label>
<textarea id="syntax-input" rows="10" style="width: 100%; font-size: 12px; font-family: monospace;">infographic list-grid
data
title 2025职业足球运动员成功要素解析
desc 在高度竞争的现代足球体系下,从天赋到职业的进化路径
items
- label 核心入场券:天赋与身体
desc 速度、爆发力及极佳的球感是底线。基本功必须化为肌肉记忆。
icon mdi/lightning-bolt
- label 决定上限:极度自律
desc 包含严苛的饮食、睡眠管理及强大的抗压心态。
icon mdi/shield-star
- label 现代智慧:球商 (IQ)
desc 复杂的战术理解力与阅读比赛能力。
icon mdi/brain
- label 隐形推手:机遇与平台
desc 专业的青训体系、能发掘你的伯乐。
icon mdi/star-shooting</textarea>
</div>
<div class="preview">
<div id="infographic-container"></div>
</div>
</div>
<script>
function applyStyles() {
console.log('[Debug] 应用样式到已渲染的元素...');
const h1Size = document.getElementById('h1-size').value;
const h2Size = document.getElementById('h2-size').value;
const titleWrap = document.getElementById('title-wrap').value;
const lineHeight = document.getElementById('line-height').value;
const container = document.getElementById('infographic-container');
// 直接操作 SVG 内的所有文本元素
const allTexts = container.querySelectorAll('svg text');
allTexts.forEach(text => {
text.style.setProperty('font-size', h1Size, 'important');
});
// 操作 foreignObject 内的所有元素
const foreignObjects = container.querySelectorAll('svg foreignObject');
let mainTitleCount = 0;
let itemLabelCount = 0;
let itemDescCount = 0;
let otherCount = 0;
foreignObjects.forEach(fo => {
// 检查 foreignObject 自身的 data-element-type 属性
const elementType = fo.getAttribute('data-element-type');
// 设置 foreignObject 本身的行高
fo.style.setProperty('line-height', lineHeight, 'important');
// 获取内部的第一个元素(通常是 span
const firstChild = fo.querySelector(':scope > *');
if (!firstChild) return;
// 根据 foreignObject 的类型设置样式
if (elementType === 'title') {
// 主标题
mainTitleCount++;
firstChild.style.setProperty('font-size', h1Size, 'important');
firstChild.style.setProperty('font-weight', 'bold', 'important');
firstChild.style.setProperty('line-height', lineHeight, 'important');
if (titleWrap === 'nowrap') {
firstChild.style.setProperty('white-space', 'nowrap', 'important');
firstChild.style.setProperty('overflow', 'hidden', 'important');
firstChild.style.setProperty('text-overflow', 'ellipsis', 'important');
} else {
firstChild.style.setProperty('white-space', 'normal', 'important');
firstChild.style.setProperty('overflow', 'visible', 'important');
firstChild.style.setProperty('text-overflow', 'clip', 'important');
}
} else if (elementType === 'desc') {
// 页面副标题(主标题下方的描述)
itemLabelCount++; // 归入副标题计数
firstChild.style.setProperty('font-size', h2Size, 'important');
firstChild.style.setProperty('line-height', lineHeight, 'important');
if (titleWrap === 'nowrap') {
firstChild.style.setProperty('white-space', 'nowrap', 'important');
firstChild.style.setProperty('overflow', 'hidden', 'important');
firstChild.style.setProperty('text-overflow', 'ellipsis', 'important');
} else {
firstChild.style.setProperty('white-space', 'normal', 'important');
firstChild.style.setProperty('overflow', 'visible', 'important');
firstChild.style.setProperty('text-overflow', 'clip', 'important');
}
} else if (elementType === 'item-label') {
// 卡片标题
itemLabelCount++;
firstChild.style.setProperty('font-size', h2Size, 'important');
firstChild.style.setProperty('line-height', lineHeight, 'important');
if (titleWrap === 'nowrap') {
firstChild.style.setProperty('white-space', 'nowrap', 'important');
firstChild.style.setProperty('overflow', 'hidden', 'important');
firstChild.style.setProperty('text-overflow', 'ellipsis', 'important');
} else {
firstChild.style.setProperty('white-space', 'normal', 'important');
firstChild.style.setProperty('overflow', 'visible', 'important');
firstChild.style.setProperty('text-overflow', 'clip', 'important');
}
} else if (elementType === 'item-desc') {
// 卡片描述
itemDescCount++;
firstChild.style.setProperty('line-height', lineHeight, 'important');
firstChild.style.setProperty('white-space', 'normal', 'important');
} else {
// 其他元素
otherCount++;
firstChild.style.setProperty('line-height', lineHeight, 'important');
firstChild.style.setProperty('white-space', 'normal', 'important');
}
});
console.log('[Debug] foreignObject 处理:', '主标题:', mainTitleCount, '卡片标题:', itemLabelCount, '卡片描述:', itemDescCount, '其他:', otherCount);
console.log('[Debug] 样式已应用到', allTexts.length, '个文本元素和', foreignObjects.length, '个 foreignObject');
}
function reRender() {
const container = document.getElementById('infographic-container');
container.innerHTML = '';
let syntaxContent = document.getElementById('syntax-input').value.trim();
if (typeof AntVInfographic === 'undefined') {
container.innerHTML = '<div style="color: red; padding: 20px;">⚠️ AntV Infographic 库未加载</div>';
return;
}
// 应用模板映射(与插件保持一致)
const TEMPLATE_MAPPING = {
'list-grid': 'list-grid-compact-card',
'list-vertical': 'list-column-simple-vertical-arrow',
'tree-vertical': 'hierarchy-tree-tech-style-capsule-item',
'tree-horizontal': 'hierarchy-tree-lr-tech-style-capsule-item',
'mindmap': 'hierarchy-mindmap-branch-gradient-capsule-item',
'sequence-roadmap': 'sequence-roadmap-vertical-simple',
'sequence-zigzag': 'sequence-horizontal-zigzag-simple',
'sequence-horizontal': 'sequence-horizontal-zigzag-simple',
'relation-sankey': 'relation-sankey-simple',
'relation-circle': 'relation-circle-icon-badge',
'compare-binary': 'compare-binary-horizontal-simple-vs',
'compare-swot': 'compare-swot',
'compare-table': 'compare-table-simple',
'quadrant-quarter': 'quadrant-quarter-simple-card',
'statistic-card': 'list-grid-compact-card',
'chart-bar': 'chart-bar-plain-text',
'chart-column': 'chart-column-simple',
'chart-line': 'chart-line-plain-text',
'chart-area': 'chart-area-simple',
'chart-pie': 'chart-pie-plain-text',
'chart-doughnut': 'chart-pie-donut-plain-text'
};
for (const [key, value] of Object.entries(TEMPLATE_MAPPING)) {
const regex = new RegExp(`infographic\\s+${key}(?=\\s|$)`, 'i');
if (regex.test(syntaxContent)) {
console.log(`[Debug] 模板映射: ${key} -> ${value}`);
syntaxContent = syntaxContent.replace(regex, `infographic ${value}`);
break;
}
}
try {
const { Infographic } = AntVInfographic;
const instance = new Infographic({
container: '#infographic-container',
padding: 24,
});
instance.render(syntaxContent);
console.log('[Debug] 渲染完成');
// 渲染完成后应用样式
setTimeout(() => {
applyStyles();
}, 300);
} catch (error) {
container.innerHTML = '<div style="color: red; padding: 20px;">⚠️ 渲染错误: ' + error.message + '</div>';
console.error(error);
}
}
// 页面加载后自动渲染
window.onload = function () {
setTimeout(reRender, 500);
};
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -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"
]
}

View File

@@ -0,0 +1,688 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AntV Infographic Debug Test</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 1000px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.infographic-render-container {
height: 800px;
border: 1px dashed #ccc;
border-radius: 4px;
margin-top: 20px;
overflow: auto;
}
h1 {
margin-top: 0;
}
.status {
margin-top: 10px;
padding: 10px;
background: #e0f2fe;
border-radius: 4px;
font-family: monospace;
white-space: pre-wrap;
/* 保留换行符 */
}
</style>
</head>
<body>
<div class="container">
<h1>AntV Infographic 综合测试台</h1>
<div class="controls">
<label>选择测试用例:</label>
<select id="case-selector">
<option value="case-list-basic">Case A: List Basic (Standard)</option>
<option value="case-tree-vertical">Case B: Tree Vertical (Hierarchy)</option>
<option value="case-theme-extended">Case C: Theme Extended (Bg/Text Color)</option>
<option value="case-tree-horizontal">Case D: Tree Horizontal</option>
<option value="case-list-children">Case E: List with Children</option>
<option value="case-tree-indented">Case F: Indented Tree</option>
<option value="case-mindmap">Case G: Mindmap</option>
<option value="case-l">Case L: List Grid (MiniMax)</option>
<option value="case-list-vertical">Case H: List Vertical</option>
<option value="case-statistic-card">Case I: Statistic Card</option>
<option value="case-k">Case K: 柠檬霜 Controversy (Roadmap)</option>
<option value="case-l">Case L: List Grid (MiniMax)</option>
<option value="case-llm-list-grid">LLM: List Grid (Features)</option>
<option value="case-llm-tree-vertical">LLM: Tree Vertical (Hierarchy)</option>
<option value="case-llm-statistic-card">LLM: Statistic Card (Metrics)</option>
<option value="case-llm-mindmap">LLM: Mindmap (Brainstorming)</option>
<option value="case-chart-bar">Case M: Chart Bar</option>
<option value="case-compare-swot">Case N: Comparison SWOT</option>
<option value="case-relation-sankey">Case O: Relationship Sankey</option>
<option value="case-quadrant-quarter">Case P: Quadrant Analysis</option>
</select>
<button onclick="runTest()">运行测试</button>
</div>
<div class="status" id="status-log">准备就绪...</div>
<div id="infographic-container-test" class="infographic-render-container"></div>
</div>
<!-- Case A: 基础列表 -->
<script type="text/template" id="case-list-basic">
infographic list-row-simple-horizontal-arrow
data
title 基础列表测试
items
- label 步骤一
icon ref:search:one
- label 步骤二
icon ref:search:two
theme
colorPrimary #3b82f6
palette #3b82f6 #8b5cf6
</script>
<!-- Case B: 垂直树 (尝试添加 ID) -->
<script type="text/template" id="case-tree-vertical">
infographic tree-vertical
data
title 垂直树测试
items
- label 根节点
id root
icon ref:search:root
children
- label 子节点 A
id child-a
- label 子节点 B
id child-b
theme
colorPrimary #10b981
palette #10b981 #34d399
</script>
<!-- Case C: 扩展主题 (测试清理逻辑) -->
<script type="text/template" id="case-theme-extended">
infographic list-row-simple-horizontal-arrow
data
title 扩展主题测试
items
- label 暗色模式
icon ref:search:moon
theme
colorPrimary #6366f1
palette #6366f1 #8b5cf6
backgroundColor #1e293b
textColor #f8fafc
roughness 0.5
</script>
<!-- Case D: 水平树 -->
<script type="text/template" id="case-tree-horizontal">
infographic tree-horizontal
data
title 水平树测试
items
- label 根节点
id root-h
children
- label 子节点 1
id child-1
- label 子节点 2
id child-2
theme
colorPrimary #f59e0b
</script>
<!-- Case E: 列表带子节点 (测试兼容性) -->
<script type="text/template" id="case-list-children">
infographic list-row-simple-horizontal-arrow
data
title 列表带子节点
items
- label 父项 1
children
- label 子项 1.1
- label 父项 2
theme
colorPrimary #ec4899
</script>
<!-- Case F: Indented Tree (缩进树) -->
<script type="text/template" id="case-tree-indented">
infographic tree-indented
data
title 缩进树测试
items
- label 根节点
id root-i
children
- label 子节点 1
id child-i-1
- label 子节点 2
id child-i-2
theme
colorPrimary #8b5cf6
</script>
<!-- Case G: Mindmap -->
<script type="text/template" id="case-mindmap">
infographic mindmap
data
title 脑图测试
items
- label 中心主题
children
- label 主题 A
children
- label 子主题 A1
- label 子主题 A2
- label 主题 B
theme
colorPrimary #06b6d4
</script>
<!-- Case H: List Vertical (垂直列表) -->
<script type="text/template" id="case-list-vertical">
infographic list-vertical
data
title 垂直列表测试
items
- label 第一项
desc 描述文本 1
icon ref:search:one
- label 第二项
desc 描述文本 2
icon ref:search:two
theme
colorPrimary #8b5cf6
</script>
<!-- Case I: Statistic Card (统计卡片) -->
<script type="text/template" id="case-statistic-card">
infographic statistic-card
data
title 核心指标
items
- label 总用户数
value 1,234
icon ref:search:users
- label 日活
value 89%
icon ref:search:activity
theme
colorPrimary #10b981
</script>
<!-- Case J: Official Tree Example -->
<script type="text/template" id="case-official-tree">
infographic hierarchy-tree-tech-style-capsule-item
data
title 官方树形示例
items
- label 根节点
icon ref:search:root
children
- label 子节点 A
icon ref:search:a
children
- label 叶子 A1
- label 叶子 A2
- label 子节点 B
icon ref:search:b
theme
colorPrimary #1677ff
</script>
<!-- Case K: User Fail Case (Lemon Frost) -->
<script type="text/template" id="case-user-fail">
infographic list-vertical
data
title 柠檬霜豹纹守宫美丽的诅咒
desc 揭露极端审美背后的遗传缺陷伦理代价与商业真相
items
- label 审美霸权与病态追求
desc 追求极致黄色而筛选出致癌基因让生命背负终身癌症风险是伦理上的霸权行为
icon mdi/eye-off-outline
- label 致命的基因多效性
desc 品系不同柠檬霜导致的是实体肿瘤溃烂致癌机制与颜色性状不可剥离
icon mdi/dna
- label 商业层面的误导欺诈
desc 所谓低肿瘤率改良版多为营销谎言只要表现出性状就注定携带致癌机制
icon mdi/alert-octagon
- label 科学研究与宠物价值
desc 其价值应仅限于作为黑色素瘤的医学研究模型而非在宠物交易市场中流通
icon mdi/microscope
- label 行动呼吁坚决拒绝
desc 拒绝购买与繁育将柠檬霜留在教科书中作为警示而非留在饲养箱中承受痛苦
icon mdi/cancel
theme
colorPrimary #FFD700
colorBg #1A1A1A
textColor #FFFFFF
palette #FFD700 #FF4D4F #E6E6E6
stylize rough
roughness 0.2
</script>
<!-- Case L: List Grid (MiniMax) -->
<script type="text/template" id="case-l">
list-grid
data
title MiniMax 2025 模型矩阵全解析
desc abab 7 为核心的国产顶尖大模型
items
- label 极致 MoE 架构优化
desc 国内首批 MoE 路线坚定者
icon mdi/cpu-64-bit
- label Video-01 视频生成
desc 杀手锏级多模态能力
icon mdi/movie-filter
- label 情感陪伴与角色扮演
desc 源自星野基因
icon mdi/emoticon-heart
- label 超长上下文与精准召回
desc 支持 128k+ 窗口
icon mdi/database-search
theme
colorPrimary #6366f1
colorBg #ffffff
textColor #1f2937
</script>
<!-- LLM Generated Cases for Verification -->
<script type="text/template" id="case-llm-list-grid">
infographic list-grid
data
title MiniMax 2025 模型矩阵全解析
desc 极致架构与多模态能力的全面进阶
items
- label 极致 MoE 架构优化
desc 国内首批 MoE 路线坚定者兼顾模型性能与推理效率
icon mdi/layers-triple
- label Video-01 视频生成
desc 杀手锏级多模态能力支持高品质视频内容创作
icon mdi/video-vintage
- label 情感陪伴与角色扮演
desc 源自星野基因提供深度的情感连接与人格化交互
icon mdi/robot-happy
- label 超长上下文与精准召回
desc 支持 128k+ 超长窗口实现海量信息的精准处理
icon mdi/file-find
theme
colorPrimary #0052D9
colorBg #F2F3F5
palette #0052D9 #2BA471 #E37318 #D54941
stylize flat
</script>
<script type="text/template" id="case-llm-tree-vertical">
infographic tree-vertical
data
title 公司组织架构
desc 2025年度企业内部编制示意图
items
- label 研发部
icon mdi/xml
children
- label 后端组
icon mdi/server
- label 前端组
icon mdi/language-javascript
- label 市场部
icon mdi/chart-bell-curve
children
- label 销售组
icon mdi/account-cash
- label 推广组
icon mdi/bullhorn
</script>
<script type="text/template" id="case-llm-statistic-card">
infographic statistic-card
data
title 2024年Q4 核心指标
desc 统计日期2024年第四季度
items
- label 总用户数
value 1,234,567
icon mdi/account-group
- label 日活跃用户
value 89%
icon mdi/chart-line
- label 营收增长
value +45%
icon mdi/trending-up
</script>
<script type="text/template" id="case-llm-mindmap">
infographic mindmap
data
title 人工智能应用领域
desc 核心应用场景分类与展示
items
- label 生成式 AI
icon mdi/brain
children
- label 文本生成
icon mdi/text-recognition
- label 图像生成
icon mdi/image-multiple
- label 视频生成
icon mdi/video-input-component
- label 预测性 AI
icon mdi/chart-line
children
- label 股市预测
icon mdi/finance
- label 天气预报
icon mdi/weather-partly-cloudy
- label 决策 AI
icon mdi/robot
children
- label 自动驾驶
icon mdi/car-autonomous
- label 游戏博弈
icon mdi/controller-classic
theme
colorPrimary #2F54EB
colorBg #FFFFFF
</script>
<!-- New Styles Test Cases -->
<script type="text/template" id="case-chart-bar">
infographic chart-bar
data
title 2023年季度营收分析
desc 单位亿元
items
- label 第一季度
value 12.5
- label 第二季度
value 15.8
- label 第三季度
value 14.2
- label 第四季度
value 18.9
</script>
<script type="text/template" id="case-relation-circle">
infographic relation-circle
data
title 生态循环
items
- label 生产者
- label 消费者
- label 分解者
</script>
<script type="text/template" id="case-compare-swot">
infographic compare-swot
data
title 某初创咖啡品牌 SWOT 分析
items
- label 优势
children
- label 产品口味独特
- label 选址精准
- label 劣势
children
- label 品牌知名度低
- label 资金压力大
- label 机会
children
- label 线上外卖市场增长
- label 年轻人对精品咖啡需求增加
- label 威胁
children
- label 行业巨头价格战
- label 原材料成本上涨
</script>
<script type="text/template" id="case-relation-sankey">
infographic relation-sankey
data
title 家庭月度开支流向
desc 2025年12月家庭总收入 10000 元分配明细
items
- label 总收入
value 10000
children
- label 房贷
value 4000
- label 日常消费
value 3000
children
- label 餐饮
value 2000
- label 交通
value 1000
- label 教育培训
value 2000
- label 存入银行
value 1000
</script>
<script type="text/template" id="case-quadrant-quarter">
infographic quadrant-quarter
data
title 个人任务象限分析
desc 2025年12月28日 任务优先级管理
items
- label 修复线上紧急 Bug
desc 紧急且重要立即优先处理
illus mdi/bug
- label 准备下午的客户会议
desc 紧急且重要核心商务产出
illus mdi/account-group
- label 制定年度学习计划
desc 重要不紧急长期价值积累
illus mdi/calendar-check
- label 健身锻炼
desc 重要不紧急身体健康投资
illus mdi/dumbbell
- label 回复琐碎的邮件
desc 紧急不重要低价值干扰
illus mdi/email-outline
- label 接听推销电话
desc 紧急不重要时间黑洞
illus mdi/phone-cancel
- label 刷社交媒体
desc 不紧急不重要消遣娱乐
illus mdi/instagram
- label 看无聊的综艺
desc 不紧急不重要无谓消耗
illus mdi/television-classic
</script>
<script type="text/template" id="case-compare-binary">
infographic compare-binary
data
title IDE vs Open WebUI
desc 核心差异对比
items
- label IDE
desc 代码工程中心
children
- label 深度上下文
- label 实时补全
- label Open WebUI
desc 对话交互中心
children
- label 模型管理
- label 知识库 RAG
</script>
<script src="https://unpkg.com/@antv/infographic@latest/dist/infographic.min.js"></script>
<script>
function log(msg) {
console.log(msg);
const el = document.getElementById('status-log');
el.textContent += '\n' + msg;
}
function logError(err) {
console.error('Detailed Error:', err);
let msg = `❌ 内部错误: ${err.message}`;
if (err.stack) msg += `\nStack: ${err.stack}`;
for (const key in err) {
if (key !== 'message' && key !== 'stack') {
msg += `\n${key}: ${JSON.stringify(err[key])}`;
}
}
log(msg);
}
function clearLog() {
document.getElementById('status-log').textContent = '准备就绪...';
}
window.listTemplates = () => {
clearLog();
log('[Explore] 正在获取模版列表...');
if (typeof AntVInfographic !== 'undefined') {
if (typeof AntVInfographic.getTemplates === 'function') {
const templates = AntVInfographic.getTemplates();
log(`[Explore] getTemplates() result (${templates.length}):\n${JSON.stringify(templates, null, 2)}`);
console.log(templates);
} else {
log('❌ AntVInfographic.getTemplates is not a function.');
log('Available keys: ' + Object.keys(AntVInfographic).join(', '));
}
} else {
log('❌ AntVInfographic is undefined.');
}
};
window.runTest = () => {
clearLog();
const selector = document.getElementById('case-selector');
const caseId = selector.value;
const sourceEl = document.getElementById(caseId);
const containerEl = document.getElementById('infographic-container-test');
if (!sourceEl) return log('❌ 未找到测试用例');
// 重置容器
containerEl.innerHTML = '';
containerEl.style = '';
let syntaxContent = sourceEl.textContent.trim();
log(`[Test] 运行用例: ${caseId} (${selector.options[selector.selectedIndex].text})`);
log(`[Step 1] 原始内容:\n${syntaxContent}`);
// --- 模拟插件的清理逻辑 ---
const bgMatch = syntaxContent.match(/backgroundColor\s+(#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3}|[a-zA-Z]+)/);
if (bgMatch && bgMatch[1]) {
containerEl.style.backgroundColor = bgMatch[1];
}
const textMatch = syntaxContent.match(/textColor\s+(#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3}|[a-zA-Z]+)/);
if (textMatch && textMatch[1]) {
containerEl.style.color = textMatch[1];
}
// 使用更强的正则:匹配 stylize 及其后续缩进的行
const nl = String.fromCharCode(10);
const cleanRegex = new RegExp('^\\s*(roughness|stylize|backgroundColor|textColor|colorBg).*(' + nl + '\\s+.*)*', 'gm');
if (cleanRegex.test(syntaxContent)) {
log('[Fix] 移除扩展 Theme 属性...');
syntaxContent = syntaxContent.replace(cleanRegex, '');
}
// 2. 模板映射配置
const TEMPLATE_MAPPING = {
// 列表与层级
'list-grid': 'list-grid-compact-card',
'list-vertical': 'list-column-simple-vertical-arrow',
'tree-vertical': 'hierarchy-tree-tech-style-capsule-item',
'tree-horizontal': 'hierarchy-tree-lr-tech-style-capsule-item',
'mindmap': 'hierarchy-mindmap-branch-gradient-capsule-item',
// 顺序与关系
'sequence-roadmap': 'sequence-roadmap-vertical-simple',
'sequence-zigzag': 'sequence-horizontal-zigzag-simple',
'sequence-horizontal': 'sequence-horizontal-zigzag-simple',
'relation-sankey': 'relation-sankey-simple', // 暂无直接对应,保留原值或需移除
'relation-circle': 'relation-circle-icon-badge',
// 对比与分析
'compare-binary': 'compare-binary-horizontal-simple-vs',
'compare-swot': 'compare-swot',
'compare-table': 'compare-table-simple', // 暂无直接对应
'quadrant-quarter': 'quadrant-quarter-simple-card',
// 图表与数据
'statistic-card': 'list-grid-compact-card',
'chart-bar': 'chart-bar-plain-text',
'chart-column': 'chart-column-simple',
'chart-line': 'chart-line-plain-text',
'chart-area': 'chart-area-simple', // 暂无直接对应
'chart-pie': 'chart-pie-plain-text',
'chart-doughnut': 'chart-pie-donut-plain-text'
};
// 3. 应用映射策略
for (const [key, value] of Object.entries(TEMPLATE_MAPPING)) {
const regex = new RegExp(`infographic\\s+${key}(?=\\s|$)`, 'i');
if (regex.test(syntaxContent)) {
log(`[Fix] 自动映射模板: ${key} -> ${value}`);
syntaxContent = syntaxContent.replace(regex, `infographic ${value}`);
break; // 找到一个匹配后即停止
}
}
// 兜底检查:确保以 infographic 开头
if (!syntaxContent.trim().toLowerCase().startsWith('infographic')) {
const firstWord = syntaxContent.trim().split(/\s+/)[0].toLowerCase();
if (!['data', 'theme', 'design', 'items'].includes(firstWord)) {
log('[Fix] 检测到缺失 infographic 前缀,自动补全');
syntaxContent = 'infographic ' + syntaxContent;
}
}
syntaxContent = syntaxContent.trim();
log(`[Step 2] 清理后内容:\n${syntaxContent}`);
try {
const { Infographic } = AntVInfographic;
const instance = new Infographic({
container: '#infographic-container-test',
padding: 24,
});
instance.on('error', (err) => {
logError(err);
});
instance.render(syntaxContent);
if (instance.node) {
log('✅ 渲染成功 (instance.node 存在)');
if (!containerEl.querySelector('svg')) {
log('⚠️ 自动挂载失败,手动挂载...');
containerEl.appendChild(instance.node);
}
} else {
log('❌ 渲染失败 (instance.node 为空)');
}
} catch (e) {
logError(e);
}
};
</script>
</body>
</html>

View File

@@ -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 <template-name>`
- Key-value pairs are separated by spaces, **absolutely NO colons allowed**
- Use two spaces for indentation
- Object arrays use `-` with line breaks
⚠️ **IMPORTANT WARNING: This is NOT YAML format!**
- ❌ Wrong: `children:` `items:` `data:` (with colons)
- ✅ Correct: `children` `items` `data` (without colons)
### Template Library & Selection Guide
#### 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 <template-name>`
- **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()

File diff suppressed because one or more lines are too long

View File

@@ -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*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\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,

View File

@@ -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*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\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,

View File

@@ -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*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\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."

View File

@@ -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*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\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}字符的文本。"

View File

@@ -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*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\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."

View File

@@ -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*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\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💡 提示:对于短文本,建议使用'⚡ 闪记卡'进行快速提炼。"

View File

@@ -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 中的展示效果
### 引用链接
每个引用来源会在聊天界面中显示为可点击的链接,用户可以:
- 点击查看原始来源
- 查看检索时间等元数据
- 了解信息的出处
### 搜索查询历史
显示模型使用的搜索查询列表,帮助用户了解:
- 模型如何理解和分解查询
- 执行了哪些搜索操作
- 搜索策略是否合理
## 使用示例
### 示例 1Gemini 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 来改进这个功能!