feat(infographic-markdown): add new plugin for JS render to Markdown

- Add infographic_markdown.py (English) and infographic_markdown_cn.py (Chinese)
- AI-powered infographic generator using AntV library
- Renders SVG on frontend and embeds as Markdown Data URL image
- Supports 18+ infographic templates (lists, charts, comparisons, etc.)

Docs:
- Add plugin README.md and README_CN.md
- Add docs detail pages (infographic-markdown.md)
- Update docs index pages with new plugin
- Add 'JS Render to Markdown' pattern to plugin development guides
- Update copilot-instructions.md with new advanced development pattern

Version: 1.0.0
This commit is contained in:
Jeff fu
2026-01-05 17:29:52 +08:00
parent 28d55c1469
commit 6de0d6fbe4
11 changed files with 2155 additions and 5 deletions

View File

@@ -841,10 +841,136 @@ def add_math(paragraph, latex_str):
omml = mathml2omml(mathml)
# ... 插入 OMML 到 paragraph._element ...
```
- [ ] 更新 `README.md` 插件列表
- [ ] 更新 `README_CN.md` 插件列表
- [ ] 更新/创建 `docs/` 下的对应文档
- [ ] 确保文档版本号与代码一致
### JS 渲染并嵌入 Markdown (JS Render to Markdown)
对于需要复杂前端渲染(如 AntV 图表、Mermaid 图表、ECharts但希望结果**持久化为纯 Markdown 格式**的场景,推荐使用 Data URL 嵌入模式:
#### 工作流程
```
┌─────────────────────────────────────────────────────────────┐
│ Plugin Workflow │
├─────────────────────────────────────────────────────────────┤
│ 1. Python Action │
│ ├── 分析消息内容 │
│ ├── 调用 LLM 生成结构化数据(可选) │
│ └── 通过 __event_call__ 发送 JS 代码到前端 │
├─────────────────────────────────────────────────────────────┤
│ 2. Browser JS (via __event_call__) │
│ ├── 动态加载可视化库(如 AntV、Mermaid
│ ├── 离屏渲染 SVG/Canvas │
│ ├── 使用 toDataURL() 导出 Base64 Data URL │
│ └── 通过 REST API 更新消息内容 │
├─────────────────────────────────────────────────────────────┤
│ 3. Markdown 渲染 │
│ └── 显示 ![描述](data:image/svg+xml;base64,...) │
└─────────────────────────────────────────────────────────────┘
```
#### 核心实现代码
**Python 端(发送 JS 执行):**
```python
async def action(self, body, __event_call__, __metadata__, ...):
chat_id = self._extract_chat_id(body, __metadata__)
message_id = self._extract_message_id(body, __metadata__)
# 生成 JS 代码
js_code = self._generate_js_code(
chat_id=chat_id,
message_id=message_id,
data=processed_data, # 可视化所需数据
)
# 执行 JS
if __event_call__:
await __event_call__({
"type": "execute",
"data": {"code": js_code}
})
```
**JavaScript 端(渲染并回写):**
```javascript
(async function() {
// 1. 动态加载可视化库
if (typeof VisualizationLib === 'undefined') {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdn.example.com/lib.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// 2. 创建离屏容器
const container = document.createElement('div');
container.style.cssText = 'position:absolute;left:-9999px;';
document.body.appendChild(container);
// 3. 渲染可视化
const instance = new VisualizationLib({ container, ... });
instance.render(data);
// 4. 导出为 Data URL
const dataUrl = await instance.toDataURL({ type: 'svg', embedResources: true });
// 或手动转换 SVG:
// const svgData = new XMLSerializer().serializeToString(svgElement);
// const base64 = btoa(unescape(encodeURIComponent(svgData)));
// const dataUrl = "data:image/svg+xml;base64," + base64;
// 5. 清理
instance.destroy();
document.body.removeChild(container);
// 6. 生成 Markdown 图片
const markdownImage = `![描述](${dataUrl})`;
// 7. 通过 API 更新消息
const token = localStorage.getItem("token");
await fetch(`/api/v1/chats/${chatId}/messages/${messageId}/event`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
type: "chat:message",
data: { content: originalContent + "\n\n" + markdownImage }
})
});
})();
```
#### 优势
- **纯 Markdown 输出**:结果是标准的 Markdown 图片语法,无需 HTML 代码块
- **自包含**:图片以 Base64 Data URL 嵌入,无外部依赖
- **持久化**:通过 API 回写,消息重新加载后图片仍然存在
- **跨平台**:任何支持 Markdown 图片的客户端都能显示
- **无服务端渲染依赖**:利用用户浏览器的渲染能力
#### 与 HTML 注入模式对比
| 特性 | HTML 注入 (`\`\`\`html`) | JS 渲染 + Markdown 图片 |
|------|-------------------------|------------------------|
| 输出格式 | HTML 代码块 | Markdown 图片 |
| 交互性 | ✅ 支持按钮、动画 | ❌ 静态图片 |
| 外部依赖 | 需要加载 JS 库 | 无(图片自包含) |
| 持久化 | 依赖浏览器渲染 | ✅ 永久可见 |
| 文件导出 | 需特殊处理 | ✅ 直接导出 |
| 适用场景 | 交互式内容 | 信息图、图表快照 |
#### 参考实现
- `plugins/actions/js-render-poc/infographic_markdown.py` - AntV Infographic 生成并嵌入
- `plugins/actions/js-render-poc/js_render_poc.py` - 基础概念验证
---

View File

@@ -235,6 +235,125 @@ llm_response = await generate_chat_completion(
)
```
### 4.4 JS Render to Markdown (Data URL Embedding)
For scenarios requiring complex frontend rendering (e.g., AntV charts, Mermaid diagrams) but wanting **persistent pure Markdown output**, use the Data URL embedding pattern:
#### Workflow
```
┌──────────────────────────────────────────────────────────────┐
│ 1. Python Action │
│ ├── Analyze message content │
│ ├── Call LLM to generate structured data (optional) │
│ └── Send JS code to frontend via __event_call__ │
├──────────────────────────────────────────────────────────────┤
│ 2. Browser JS (via __event_call__) │
│ ├── Dynamically load visualization library │
│ ├── Render SVG/Canvas offscreen │
│ ├── Export to Base64 Data URL via toDataURL() │
│ └── Update message content via REST API │
├──────────────────────────────────────────────────────────────┤
│ 3. Markdown Rendering │
│ └── Display ![description](data:image/svg+xml;base64,...) │
└──────────────────────────────────────────────────────────────┘
```
#### Python Side (Send JS for Execution)
```python
async def action(self, body, __event_call__, __metadata__, ...):
chat_id = self._extract_chat_id(body, __metadata__)
message_id = self._extract_message_id(body, __metadata__)
# Generate JS code
js_code = self._generate_js_code(
chat_id=chat_id,
message_id=message_id,
data=processed_data,
)
# Execute JS
if __event_call__:
await __event_call__({
"type": "execute",
"data": {"code": js_code}
})
```
#### JavaScript Side (Render and Write-back)
```javascript
(async function() {
// 1. Load visualization library
if (typeof VisualizationLib === 'undefined') {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdn.example.com/lib.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// 2. Create offscreen container
const container = document.createElement('div');
container.style.cssText = 'position:absolute;left:-9999px;';
document.body.appendChild(container);
// 3. Render visualization
const instance = new VisualizationLib({ container });
instance.render(data);
// 4. Export to Data URL
const dataUrl = await instance.toDataURL({ type: 'svg', embedResources: true });
// 5. Cleanup
instance.destroy();
document.body.removeChild(container);
// 6. Generate Markdown image
const markdownImage = `![Chart](${dataUrl})`;
// 7. Update message via API
const token = localStorage.getItem("token");
await fetch(`/api/v1/chats/${chatId}/messages/${messageId}/event`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
type: "chat:message",
data: { content: originalContent + "\n\n" + markdownImage }
})
});
})();
```
#### Benefits
- **Pure Markdown Output**: Standard Markdown image syntax, no HTML code blocks
- **Self-Contained**: Images embedded as Base64 Data URL, no external dependencies
- **Persistent**: Via API write-back, images remain after page reload
- **Cross-Platform**: Works on any client supporting Markdown images
#### HTML Injection vs JS Render to Markdown
| Feature | HTML Injection | JS Render + Markdown |
|---------|----------------|----------------------|
| Output Format | HTML code block | Markdown image |
| Interactivity | ✅ Buttons, animations | ❌ Static image |
| External Deps | Requires JS libraries | None (self-contained) |
| Persistence | Depends on browser | ✅ Permanent |
| File Export | Needs special handling | ✅ Direct export |
| Use Case | Interactive content | Infographics, chart snapshots |
#### Reference Implementations
- `plugins/actions/js-render-poc/infographic_markdown.py` - AntV Infographic + Data URL
- `plugins/actions/js-render-poc/js_render_poc.py` - Basic proof of concept
---
## 5. Best Practices & Design Principles

View File

@@ -199,7 +199,124 @@ async def background_job(self, chat_id):
pass
```
---
### 4.3 JS 渲染并嵌入 Markdown (Data URL 嵌入)
对于需要复杂前端渲染(如 AntV 图表、Mermaid 图表)但希望结果**持久化为纯 Markdown 格式**的场景,推荐使用 Data URL 嵌入模式:
#### 工作流程
```
┌──────────────────────────────────────────────────────────────┐
│ 1. Python Action │
│ ├── 分析消息内容 │
│ ├── 调用 LLM 生成结构化数据(可选) │
│ └── 通过 __event_call__ 发送 JS 代码到前端 │
├──────────────────────────────────────────────────────────────┤
│ 2. Browser JS (通过 __event_call__) │
│ ├── 动态加载可视化库 │
│ ├── 离屏渲染 SVG/Canvas │
│ ├── 使用 toDataURL() 导出 Base64 Data URL │
│ └── 通过 REST API 更新消息内容 │
├──────────────────────────────────────────────────────────────┤
│ 3. Markdown 渲染 │
│ └── 显示 ![描述](data:image/svg+xml;base64,...) │
└──────────────────────────────────────────────────────────────┘
```
#### Python 端(发送 JS 执行)
```python
async def action(self, body, __event_call__, __metadata__, ...):
chat_id = self._extract_chat_id(body, __metadata__)
message_id = self._extract_message_id(body, __metadata__)
# 生成 JS 代码
js_code = self._generate_js_code(
chat_id=chat_id,
message_id=message_id,
data=processed_data,
)
# 执行 JS
if __event_call__:
await __event_call__({
"type": "execute",
"data": {"code": js_code}
})
```
#### JavaScript 端(渲染并回写)
```javascript
(async function() {
// 1. 加载可视化库
if (typeof VisualizationLib === 'undefined') {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdn.example.com/lib.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// 2. 创建离屏容器
const container = document.createElement('div');
container.style.cssText = 'position:absolute;left:-9999px;';
document.body.appendChild(container);
// 3. 渲染可视化
const instance = new VisualizationLib({ container });
instance.render(data);
// 4. 导出为 Data URL
const dataUrl = await instance.toDataURL({ type: 'svg', embedResources: true });
// 5. 清理
instance.destroy();
document.body.removeChild(container);
// 6. 生成 Markdown 图片
const markdownImage = `![图表](${dataUrl})`;
// 7. 通过 API 更新消息
const token = localStorage.getItem("token");
await fetch(`/api/v1/chats/${chatId}/messages/${messageId}/event`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
type: "chat:message",
data: { content: originalContent + "\n\n" + markdownImage }
})
});
})();
```
#### 优势
- **纯 Markdown 输出**:结果是标准的 Markdown 图片语法,无需 HTML 代码块
- **自包含**:图片以 Base64 Data URL 嵌入,无外部依赖
- **持久化**:通过 API 回写,消息重新加载后图片仍然存在
- **跨平台**:任何支持 Markdown 图片的客户端都能显示
#### HTML 注入 vs JS 渲染嵌入 Markdown
| 特性 | HTML 注入 | JS 渲染 + Markdown 图片 |
|------|----------|------------------------|
| 输出格式 | HTML 代码块 | Markdown 图片 |
| 交互性 | ✅ 支持按钮、动画 | ❌ 静态图片 |
| 外部依赖 | 需要加载 JS 库 | 无(图片自包含) |
| 持久化 | 依赖浏览器渲染 | ✅ 永久可见 |
| 文件导出 | 需特殊处理 | ✅ 直接导出 |
| 适用场景 | 交互式内容 | 信息图、图表快照 |
#### 参考实现
- `plugins/actions/js-render-poc/infographic_markdown.py` - AntV 信息图 + Data URL
- `plugins/actions/js-render-poc/js_render_poc.py` - 基础概念验证
## 5. 最佳实践与设计原则

View File

@@ -77,6 +77,16 @@ Actions are interactive plugins that:
[:octicons-arrow-right-24: Documentation](summary.md)
- :material-image-text:{ .lg .middle } **Infographic to Markdown**
---
AI-powered infographic generator that renders SVG and embeds it as Markdown Data URL image.
**Version:** 1.0.0
[:octicons-arrow-right-24: Documentation](infographic-markdown.md)
</div>
---

View File

@@ -77,6 +77,16 @@ Actions 是交互式插件,能够:
[:octicons-arrow-right-24: 查看文档](summary.md)
- :material-image-text:{ .lg .middle } **信息图转 Markdown**
---
AI 驱动的信息图生成器,渲染 SVG 并以 Markdown Data URL 图片嵌入。
**版本:** 1.0.0
[:octicons-arrow-right-24: 查看文档](infographic-markdown.zh.md)
</div>
---

View File

@@ -0,0 +1,120 @@
# Infographic to Markdown
> **Version:** 1.0.0 | **Author:** Fu-Jie
AI-powered infographic generator that renders SVG on the frontend and embeds it directly into Markdown as a Data URL image.
## Overview
This plugin combines the power of AI text analysis with AntV Infographic visualization to create beautiful infographics that are embedded directly into chat messages as Markdown images.
### Key Features
- :robot: **AI-Powered**: Automatically analyzes text and selects the best infographic template
- :bar_chart: **Multiple Templates**: Supports 18+ infographic templates (lists, charts, comparisons, etc.)
- :framed_picture: **Self-Contained**: SVG/PNG embedded as Data URL, no external dependencies
- :memo: **Markdown Native**: Results are pure Markdown images, compatible everywhere
- :arrows_counterclockwise: **API Writeback**: Updates message content via REST API for persistence
### How It Works
```mermaid
graph TD
A[User triggers action] --> B[Python extracts message content]
B --> C[LLM generates Infographic syntax]
C --> D[Frontend JS loads AntV library]
D --> E[Render SVG offscreen]
E --> F[Export to Data URL]
F --> G[Update message via API]
G --> H[Display as Markdown image]
```
## Installation
1. Download `infographic_markdown.py` (English) or `infographic_markdown_cn.py` (Chinese)
2. Navigate to **Admin Panel****Settings****Functions**
3. Upload the file and configure settings
4. Use the action button in chat messages
## Configuration
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `SHOW_STATUS` | bool | `true` | Show operation status updates |
| `MODEL_ID` | string | `""` | LLM model ID (empty = use current model) |
| `MIN_TEXT_LENGTH` | int | `50` | Minimum text length required |
| `MESSAGE_COUNT` | int | `1` | Number of recent messages to use |
| `SVG_WIDTH` | int | `800` | Width of generated SVG (pixels) |
| `EXPORT_FORMAT` | string | `"svg"` | Export format: `svg` or `png` |
## Supported Templates
| Category | Template | Description |
|----------|----------|-------------|
| List | `list-grid` | Grid cards |
| List | `list-vertical` | Vertical list |
| Tree | `tree-vertical` | Vertical tree |
| Tree | `tree-horizontal` | Horizontal tree |
| Mind Map | `mindmap` | Mind map |
| Process | `sequence-roadmap` | Roadmap |
| Process | `sequence-zigzag` | Zigzag process |
| Relation | `relation-sankey` | Sankey diagram |
| Relation | `relation-circle` | Circular relation |
| Compare | `compare-binary` | Binary comparison |
| Analysis | `compare-swot` | SWOT analysis |
| Quadrant | `quadrant-quarter` | Quadrant chart |
| Chart | `chart-bar` | Bar chart |
| Chart | `chart-column` | Column chart |
| Chart | `chart-line` | Line chart |
| Chart | `chart-pie` | Pie chart |
| Chart | `chart-doughnut` | Doughnut chart |
| Chart | `chart-area` | Area chart |
## Usage Example
1. Generate some text content in the chat (or have the AI generate it)
2. Click the **📊 Infographic to Markdown** action button
3. Wait for AI analysis and SVG rendering
4. The infographic will be embedded as a Markdown image
## Technical Details
### Data URL Embedding
The plugin converts SVG graphics to Base64-encoded Data URLs:
```javascript
const svgData = new XMLSerializer().serializeToString(svg);
const base64 = btoa(unescape(encodeURIComponent(svgData)));
const dataUri = "data:image/svg+xml;base64," + base64;
const markdownImage = `![description](${dataUri})`;
```
### AntV toDataURL API
```javascript
// Export as SVG (recommended)
const svgUrl = await instance.toDataURL({
type: 'svg',
embedResources: true
});
// Export as PNG
const pngUrl = await instance.toDataURL({
type: 'png',
dpr: 2
});
```
## Notes
1. **Browser Compatibility**: Requires modern browsers with ES6+ and Fetch API support
2. **Network Dependency**: First use requires loading AntV library from CDN
3. **Data URL Size**: Base64 encoding increases size by ~33%
4. **Chinese Fonts**: SVG export embeds fonts for correct display
## Related Resources
- [AntV Infographic Documentation](https://infographic.antv.vision/)
- [Infographic API Reference](https://infographic.antv.vision/reference/infographic-api)
- [Infographic Syntax Guide](https://infographic.antv.vision/learn/infographic-syntax)

View File

@@ -0,0 +1,120 @@
# 信息图转 Markdown
> **版本:** 1.0.0 | **作者:** Fu-Jie
AI 驱动的信息图生成器,在前端渲染 SVG 并以 Data URL 图片格式直接嵌入到 Markdown 中。
## 概述
这个插件结合了 AI 文本分析能力和 AntV Infographic 可视化引擎,生成精美的信息图并以 Markdown 图片格式直接嵌入到聊天消息中。
### 主要特性
- :robot: **AI 驱动**: 自动分析文本并选择最佳的信息图模板
- :bar_chart: **多种模板**: 支持 18+ 种信息图模板(列表、图表、对比等)
- :framed_picture: **自包含**: SVG/PNG 以 Data URL 嵌入,无外部依赖
- :memo: **Markdown 原生**: 结果是纯 Markdown 图片,兼容任何平台
- :arrows_counterclockwise: **API 回写**: 通过 REST API 更新消息内容实现持久化
### 工作原理
```mermaid
graph TD
A[用户触发动作] --> B[Python 提取消息内容]
B --> C[LLM 生成 Infographic 语法]
C --> D[前端 JS 加载 AntV 库]
D --> E[离屏渲染 SVG]
E --> F[导出为 Data URL]
F --> G[通过 API 更新消息]
G --> H[显示为 Markdown 图片]
```
## 安装
1. 下载 `infographic_markdown.py`(英文版)或 `infographic_markdown_cn.py`(中文版)
2. 进入 **管理面板****设置****功能**
3. 上传文件并配置设置
4. 在聊天消息中使用动作按钮
## 配置选项
| 参数 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `SHOW_STATUS` | bool | `true` | 是否显示操作状态 |
| `MODEL_ID` | string | `""` | LLM 模型 ID空则使用当前模型 |
| `MIN_TEXT_LENGTH` | int | `50` | 最小文本长度要求 |
| `MESSAGE_COUNT` | int | `1` | 用于生成的最近消息数量 |
| `SVG_WIDTH` | int | `800` | 生成的 SVG 宽度(像素) |
| `EXPORT_FORMAT` | string | `"svg"` | 导出格式:`svg``png` |
## 支持的模板
| 类别 | 模板名称 | 描述 |
|------|----------|------|
| 列表 | `list-grid` | 网格卡片 |
| 列表 | `list-vertical` | 垂直列表 |
| 树形 | `tree-vertical` | 垂直树 |
| 树形 | `tree-horizontal` | 水平树 |
| 思维导图 | `mindmap` | 思维导图 |
| 流程 | `sequence-roadmap` | 路线图 |
| 流程 | `sequence-zigzag` | 折线流程 |
| 关系 | `relation-sankey` | 桑基图 |
| 关系 | `relation-circle` | 圆形关系 |
| 对比 | `compare-binary` | 二元对比 |
| 分析 | `compare-swot` | SWOT 分析 |
| 象限 | `quadrant-quarter` | 四象限图 |
| 图表 | `chart-bar` | 条形图 |
| 图表 | `chart-column` | 柱状图 |
| 图表 | `chart-line` | 折线图 |
| 图表 | `chart-pie` | 饼图 |
| 图表 | `chart-doughnut` | 环形图 |
| 图表 | `chart-area` | 面积图 |
## 使用示例
1. 在聊天中生成一些文本内容(或让 AI 生成)
2. 点击 **📊 信息图转 Markdown** 动作按钮
3. 等待 AI 分析和 SVG 渲染
4. 信息图将以 Markdown 图片形式嵌入
## 技术细节
### Data URL 嵌入
插件将 SVG 图形转换为 Base64 编码的 Data URL
```javascript
const svgData = new XMLSerializer().serializeToString(svg);
const base64 = btoa(unescape(encodeURIComponent(svgData)));
const dataUri = "data:image/svg+xml;base64," + base64;
const markdownImage = `![描述](${dataUri})`;
```
### AntV toDataURL API
```javascript
// 导出 SVG推荐
const svgUrl = await instance.toDataURL({
type: 'svg',
embedResources: true
});
// 导出 PNG
const pngUrl = await instance.toDataURL({
type: 'png',
dpr: 2
});
```
## 注意事项
1. **浏览器兼容性**: 需要现代浏览器支持 ES6+ 和 Fetch API
2. **网络依赖**: 首次使用需要从 CDN 加载 AntV Infographic 库
3. **Data URL 大小**: Base64 编码会增加约 33% 的体积
4. **中文字体**: SVG 导出时会嵌入字体以确保正确显示
## 相关资源
- [AntV Infographic 官方文档](https://infographic.antv.vision/)
- [Infographic API 参考](https://infographic.antv.vision/reference/infographic-api)
- [Infographic 语法规范](https://infographic.antv.vision/learn/infographic-syntax)

View File

@@ -0,0 +1,170 @@
# Infographic to Markdown
> **Version:** 1.0.0
AI-powered infographic generator that renders SVG on the frontend and embeds it directly into Markdown as a Data URL image.
## Overview
This plugin combines the power of AI text analysis with AntV Infographic visualization to create beautiful infographics that are embedded directly into chat messages as Markdown images.
### How It Works
```
┌─────────────────────────────────────────────────────────────┐
│ Open WebUI Plugin │
├─────────────────────────────────────────────────────────────┤
│ 1. Python Action │
│ ├── Receive message content │
│ ├── Call LLM to generate Infographic syntax │
│ └── Send __event_call__ to execute frontend JS │
├─────────────────────────────────────────────────────────────┤
│ 2. Browser JS (via __event_call__) │
│ ├── Dynamically load AntV Infographic library │
│ ├── Render SVG offscreen │
│ ├── Export to Data URL via toDataURL() │
│ └── Update message content via REST API │
├─────────────────────────────────────────────────────────────┤
│ 3. Markdown Rendering │
│ └── Display ![description](data:image/svg+xml;base64,...) │
└─────────────────────────────────────────────────────────────┘
```
## Features
- 🤖 **AI-Powered**: Automatically analyzes text and selects the best infographic template
- 📊 **Multiple Templates**: Supports 18+ infographic templates (lists, charts, comparisons, etc.)
- 🖼️ **Self-Contained**: SVG/PNG embedded as Data URL, no external dependencies
- 📝 **Markdown Native**: Results are pure Markdown images, compatible everywhere
- 🔄 **API Writeback**: Updates message content via REST API for persistence
## Plugins in This Directory
### 1. `infographic_markdown.py` - Main Plugin ⭐
- **Purpose**: Production use
- **Features**: Full AI + AntV Infographic + Data URL embedding
### 2. `js_render_poc.py` - Proof of Concept
- **Purpose**: Learning and testing
- **Features**: Simple SVG creation demo, `__event_call__` pattern
## Configuration (Valves)
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `SHOW_STATUS` | bool | `true` | Show operation status updates |
| `MODEL_ID` | string | `""` | LLM model ID (empty = use current model) |
| `MIN_TEXT_LENGTH` | int | `50` | Minimum text length required |
| `MESSAGE_COUNT` | int | `1` | Number of recent messages to use |
| `SVG_WIDTH` | int | `800` | Width of generated SVG (pixels) |
| `EXPORT_FORMAT` | string | `"svg"` | Export format: `svg` or `png` |
## Supported Templates
| Category | Template | Description |
|----------|----------|-------------|
| List | `list-grid` | Grid cards |
| List | `list-vertical` | Vertical list |
| Tree | `tree-vertical` | Vertical tree |
| Tree | `tree-horizontal` | Horizontal tree |
| Mind Map | `mindmap` | Mind map |
| Process | `sequence-roadmap` | Roadmap |
| Process | `sequence-zigzag` | Zigzag process |
| Relation | `relation-sankey` | Sankey diagram |
| Relation | `relation-circle` | Circular relation |
| Compare | `compare-binary` | Binary comparison |
| Analysis | `compare-swot` | SWOT analysis |
| Quadrant | `quadrant-quarter` | Quadrant chart |
| Chart | `chart-bar` | Bar chart |
| Chart | `chart-column` | Column chart |
| Chart | `chart-line` | Line chart |
| Chart | `chart-pie` | Pie chart |
| Chart | `chart-doughnut` | Doughnut chart |
| Chart | `chart-area` | Area chart |
## Syntax Examples
### Grid List
```infographic
infographic list-grid
data
title Project Overview
items
- label Module A
desc Description of module A
- label Module B
desc Description of module B
```
### Binary Comparison
```infographic
infographic compare-binary
data
title Pros vs Cons
items
- label Pros
children
- label Strong R&D
desc Technology leadership
- label Cons
children
- label Weak brand
desc Insufficient marketing
```
### Bar Chart
```infographic
infographic chart-bar
data
title Quarterly Revenue
items
- label Q1
value 120
- label Q2
value 150
```
## Technical Details
### Data URL Embedding
```javascript
// SVG to Base64 Data URL
const svgData = new XMLSerializer().serializeToString(svg);
const base64 = btoa(unescape(encodeURIComponent(svgData)));
const dataUri = "data:image/svg+xml;base64," + base64;
// Markdown image syntax
const markdownImage = `![description](${dataUri})`;
```
### AntV toDataURL API
```javascript
// Export as SVG (recommended, supports embedded resources)
const svgUrl = await instance.toDataURL({
type: 'svg',
embedResources: true
});
// Export as PNG (more compatible but larger)
const pngUrl = await instance.toDataURL({
type: 'png',
dpr: 2
});
```
## Notes
1. **Browser Compatibility**: Requires modern browsers with ES6+ and Fetch API support
2. **Network Dependency**: First use requires loading AntV library from CDN
3. **Data URL Size**: Base64 encoding increases size by ~33%
4. **Chinese Fonts**: SVG export embeds fonts for correct display
## Related Resources
- [AntV Infographic Documentation](https://infographic.antv.vision/)
- [Infographic API Reference](https://infographic.antv.vision/reference/infographic-api)
- [Infographic Syntax Guide](https://infographic.antv.vision/learn/infographic-syntax)
## License
MIT License

View File

@@ -0,0 +1,174 @@
# 信息图转 Markdown
> **版本:** 1.0.0
AI 驱动的信息图生成器,在前端渲染 SVG 并以 Data URL 图片格式直接嵌入到 Markdown 中。
## 概述
这个插件结合了 AI 文本分析能力和 AntV Infographic 可视化引擎,生成精美的信息图并以 Markdown 图片格式直接嵌入到聊天消息中。
### 工作原理
```
┌─────────────────────────────────────────────────────────────┐
│ Open WebUI 插件 │
├─────────────────────────────────────────────────────────────┤
│ 1. Python Action │
│ ├── 接收消息内容 │
│ ├── 调用 LLM 生成 Infographic 语法 │
│ └── 发送 __event_call__ 执行前端 JS │
├─────────────────────────────────────────────────────────────┤
│ 2. 浏览器 JS (通过 __event_call__) │
│ ├── 动态加载 AntV Infographic 库 │
│ ├── 离屏渲染 SVG │
│ ├── 使用 toDataURL() 导出 Data URL │
│ └── 通过 REST API 更新消息内容 │
├─────────────────────────────────────────────────────────────┤
│ 3. Markdown 渲染 │
│ └── 显示 ![描述](data:image/svg+xml;base64,...) │
└─────────────────────────────────────────────────────────────┘
```
## 功能特点
- 🤖 **AI 驱动**: 自动分析文本并选择最佳的信息图模板
- 📊 **多种模板**: 支持 18+ 种信息图模板(列表、图表、对比等)
- 🖼️ **自包含**: SVG/PNG 以 Data URL 嵌入,无外部依赖
- 📝 **Markdown 原生**: 结果是纯 Markdown 图片,兼容任何平台
- 🔄 **API 回写**: 通过 REST API 更新消息内容实现持久化
## 目录中的插件
### 1. `infographic_markdown.py` - 主插件 ⭐
- **用途**: 生产使用
- **功能**: 完整的 AI + AntV Infographic + Data URL 嵌入
### 2. `infographic_markdown_cn.py` - 主插件(中文版)
- **用途**: 生产使用
- **功能**: 与英文版相同,界面文字为中文
### 3. `js_render_poc.py` - 概念验证
- **用途**: 学习和测试
- **功能**: 简单的 SVG 创建演示,`__event_call__` 模式
## 配置选项 (Valves)
| 参数 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `SHOW_STATUS` | bool | `true` | 是否显示操作状态 |
| `MODEL_ID` | string | `""` | LLM 模型 ID空则使用当前模型 |
| `MIN_TEXT_LENGTH` | int | `50` | 最小文本长度要求 |
| `MESSAGE_COUNT` | int | `1` | 用于生成的最近消息数量 |
| `SVG_WIDTH` | int | `800` | 生成的 SVG 宽度(像素) |
| `EXPORT_FORMAT` | string | `"svg"` | 导出格式:`svg``png` |
## 支持的模板
| 类别 | 模板名称 | 描述 |
|------|----------|------|
| 列表 | `list-grid` | 网格卡片 |
| 列表 | `list-vertical` | 垂直列表 |
| 树形 | `tree-vertical` | 垂直树 |
| 树形 | `tree-horizontal` | 水平树 |
| 思维导图 | `mindmap` | 思维导图 |
| 流程 | `sequence-roadmap` | 路线图 |
| 流程 | `sequence-zigzag` | 折线流程 |
| 关系 | `relation-sankey` | 桑基图 |
| 关系 | `relation-circle` | 圆形关系 |
| 对比 | `compare-binary` | 二元对比 |
| 分析 | `compare-swot` | SWOT 分析 |
| 象限 | `quadrant-quarter` | 四象限图 |
| 图表 | `chart-bar` | 条形图 |
| 图表 | `chart-column` | 柱状图 |
| 图表 | `chart-line` | 折线图 |
| 图表 | `chart-pie` | 饼图 |
| 图表 | `chart-doughnut` | 环形图 |
| 图表 | `chart-area` | 面积图 |
## 语法示例
### 网格列表
```infographic
infographic list-grid
data
title 项目概览
items
- label 模块一
desc 这是第一个模块的描述
- label 模块二
desc 这是第二个模块的描述
```
### 二元对比
```infographic
infographic compare-binary
data
title 优劣对比
items
- label 优势
children
- label 研发能力强
desc 技术领先
- label 劣势
children
- label 品牌曝光不足
desc 营销力度不够
```
### 条形图
```infographic
infographic chart-bar
data
title 季度收入
items
- label Q1
value 120
- label Q2
value 150
```
## 技术细节
### Data URL 嵌入
```javascript
// SVG 转 Base64 Data URL
const svgData = new XMLSerializer().serializeToString(svg);
const base64 = btoa(unescape(encodeURIComponent(svgData)));
const dataUri = "data:image/svg+xml;base64," + base64;
// Markdown 图片语法
const markdownImage = `![描述](${dataUri})`;
```
### AntV toDataURL API
```javascript
// 导出 SVG推荐支持嵌入资源
const svgUrl = await instance.toDataURL({
type: 'svg',
embedResources: true
});
// 导出 PNG更兼容但体积更大
const pngUrl = await instance.toDataURL({
type: 'png',
dpr: 2
});
```
## 注意事项
1. **浏览器兼容性**: 需要现代浏览器支持 ES6+ 和 Fetch API
2. **网络依赖**: 首次使用需要从 CDN 加载 AntV Infographic 库
3. **Data URL 大小**: Base64 编码会增加约 33% 的体积
4. **中文字体**: SVG 导出时会嵌入字体以确保正确显示
## 相关资源
- [AntV Infographic 官方文档](https://infographic.antv.vision/)
- [Infographic API 参考](https://infographic.antv.vision/reference/infographic-api)
- [Infographic 语法规范](https://infographic.antv.vision/learn/infographic-syntax)
## 许可证
MIT License

View File

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

View File

@@ -0,0 +1,592 @@
"""
title: 📊 信息图转 Markdown
author: Fu-Jie
version: 1.0.0
description: AI 生成信息图语法,前端渲染 SVG 并转换为 Markdown 图片格式嵌入消息。支持 AntV Infographic 模板。
"""
import time
import json
import logging
import re
from typing import Optional, Callable, Awaitable, Any, Dict
from pydantic import BaseModel, Field
from fastapi import Request
from datetime import datetime
from open_webui.utils.chat import generate_chat_completion
from open_webui.models.users import Users
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# =================================================================
# LLM 提示词
# =================================================================
SYSTEM_PROMPT_INFOGRAPHIC = """
你是一位专业的信息图设计专家,能够分析用户提供的文本内容并将其转换为 AntV Infographic 语法格式。
## 信息图语法规范
信息图语法是一种类似 Mermaid 的声明式语法,用于描述信息图模板、数据和主题。
### 语法规则
- 入口使用 `infographic <模板名>`
- 键值对用空格分隔,**绝对不允许使用冒号**
- 使用两个空格缩进
- 对象数组使用 `-` 加换行
⚠️ **重要警告:这不是 YAML 格式!**
- ❌ 错误:`children:` `items:` `data:`(带冒号)
- ✅ 正确:`children` `items` `data`(不带冒号)
### 模板库与选择指南
根据内容结构选择最合适的模板:
#### 1. 列表与层级
- **列表**`list-grid`(网格卡片)、`list-vertical`(垂直列表)
- **树形**`tree-vertical`(垂直树)、`tree-horizontal`(水平树)
- **思维导图**`mindmap`(思维导图)
#### 2. 序列与关系
- **流程**`sequence-roadmap`(路线图)、`sequence-zigzag`(折线流程)
- **关系**`relation-sankey`(桑基图)、`relation-circle`(圆形关系)
#### 3. 对比与分析
- **对比**`compare-binary`(二元对比)
- **分析**`compare-swot`SWOT 分析)、`quadrant-quarter`(象限图)
#### 4. 图表与数据
- **图表**`chart-bar`、`chart-column`、`chart-line`、`chart-pie`、`chart-doughnut`、`chart-area`
### 数据结构示例
#### A. 标准列表/树形
```infographic
infographic list-grid
data
title 项目模块
items
- label 模块 A
desc 模块 A 的描述
- label 模块 B
desc 模块 B 的描述
```
#### B. 二元对比
```infographic
infographic compare-binary
data
title 优势与劣势
items
- label 优势
children
- label 研发能力强
desc 技术领先
- label 劣势
children
- label 品牌曝光弱
desc 营销不足
```
#### C. 图表
```infographic
infographic chart-bar
data
title 季度收入
items
- label Q1
value 120
- label Q2
value 150
```
### 常用数据字段
- `label`:主标题/标签(必填)
- `desc`:描述文字(`list-grid` 最多 30 个中文字符)
- `value`:数值(用于图表)
- `children`:嵌套项
## 输出要求
1. **语言**:使用用户的语言输出内容。
2. **格式**:用 ```infographic ... ``` 包裹输出。
3. **无冒号**:键后面不要使用冒号。
4. **缩进**:使用 2 个空格。
"""
USER_PROMPT_GENERATE = """
请分析以下文本内容,将其核心信息转换为 AntV Infographic 语法格式。
---
**用户上下文:**
用户名:{user_name}
当前时间:{current_date_time_str}
用户语言:{user_language}
---
**文本内容:**
{long_text_content}
请根据文本特征选择最合适的信息图模板,输出标准的信息图语法。
**重要提示:**
- 如果使用 `list-grid` 格式,确保每个卡片的 `desc` 描述限制在 **最多 30 个中文字符**。
- 描述应简洁,突出重点。
"""
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=50,
description="信息图分析所需的最小文本长度(字符数)。",
)
MESSAGE_COUNT: int = Field(
default=1,
description="用于生成的最近消息数量。",
)
SVG_WIDTH: int = Field(
default=800,
description="生成的 SVG 宽度(像素)。",
)
EXPORT_FORMAT: str = Field(
default="svg",
description="导出格式:'svg''png'",
)
def __init__(self):
self.valves = self.Valves()
def _extract_chat_id(self, body: dict, metadata: Optional[dict]) -> str:
"""从 body 或 metadata 中提取 chat_id"""
if isinstance(body, dict):
chat_id = body.get("chat_id")
if isinstance(chat_id, str) and chat_id.strip():
return chat_id.strip()
body_metadata = body.get("metadata", {})
if isinstance(body_metadata, dict):
chat_id = body_metadata.get("chat_id")
if isinstance(chat_id, str) and chat_id.strip():
return chat_id.strip()
if isinstance(metadata, dict):
chat_id = metadata.get("chat_id")
if isinstance(chat_id, str) and chat_id.strip():
return chat_id.strip()
return ""
def _extract_message_id(self, body: dict, metadata: Optional[dict]) -> str:
"""从 body 或 metadata 中提取 message_id"""
if isinstance(body, dict):
message_id = body.get("id")
if isinstance(message_id, str) and message_id.strip():
return message_id.strip()
body_metadata = body.get("metadata", {})
if isinstance(body_metadata, dict):
message_id = body_metadata.get("message_id")
if isinstance(message_id, str) and message_id.strip():
return message_id.strip()
if isinstance(metadata, dict):
message_id = metadata.get("message_id")
if isinstance(message_id, str) and message_id.strip():
return message_id.strip()
return ""
def _extract_infographic_syntax(self, llm_output: str) -> str:
"""从 LLM 输出中提取信息图语法"""
match = re.search(r"```infographic\s*(.*?)\s*```", llm_output, re.DOTALL)
if match:
return match.group(1).strip()
else:
logger.warning("LLM 输出未遵循预期格式,将整个输出作为语法处理。")
return llm_output.strip()
def _extract_text_content(self, content) -> str:
"""从消息内容中提取文本,支持多模态格式"""
if isinstance(content, str):
return content
elif isinstance(content, list):
text_parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
text_parts.append(item.get("text", ""))
elif isinstance(item, str):
text_parts.append(item)
return "\n".join(text_parts)
return str(content) if content else ""
async def _emit_status(self, emitter, description: str, done: bool = False):
"""发送状态更新事件"""
if self.valves.SHOW_STATUS and emitter:
await emitter(
{"type": "status", "data": {"description": description, "done": done}}
)
def _generate_js_code(
self,
unique_id: str,
chat_id: str,
message_id: str,
infographic_syntax: str,
svg_width: int,
export_format: str,
) -> str:
"""生成用于前端 SVG 渲染的 JavaScript 代码"""
# 转义语法以便嵌入 JS
syntax_escaped = (
infographic_syntax
.replace("\\", "\\\\")
.replace("`", "\\`")
.replace("${", "\\${")
.replace("</script>", "<\\/script>")
)
# 模板映射
template_mapping_js = """
const TEMPLATE_MAPPING = {
'list-grid': 'list-grid-compact-card',
'list-vertical': 'list-column-simple-vertical-arrow',
'tree-vertical': 'hierarchy-tree-tech-style-capsule-item',
'tree-horizontal': 'hierarchy-tree-lr-tech-style-capsule-item',
'mindmap': 'hierarchy-mindmap-branch-gradient-capsule-item',
'sequence-roadmap': 'sequence-roadmap-vertical-simple',
'sequence-zigzag': 'sequence-horizontal-zigzag-simple',
'sequence-horizontal': 'sequence-horizontal-zigzag-simple',
'relation-sankey': 'relation-sankey-simple',
'relation-circle': 'relation-circle-icon-badge',
'compare-binary': 'compare-binary-horizontal-simple-vs',
'compare-swot': 'compare-swot',
'quadrant-quarter': 'quadrant-quarter-simple-card',
'statistic-card': 'list-grid-compact-card',
'chart-bar': 'chart-bar-plain-text',
'chart-column': 'chart-column-simple',
'chart-line': 'chart-line-plain-text',
'chart-area': 'chart-area-simple',
'chart-pie': 'chart-pie-plain-text',
'chart-doughnut': 'chart-pie-donut-plain-text'
};
"""
return f"""
(async function() {{
const uniqueId = "{unique_id}";
const chatId = "{chat_id}";
const messageId = "{message_id}";
const svgWidth = {svg_width};
const exportFormat = "{export_format}";
console.log("[信息图 Markdown] 开始渲染...");
console.log("[信息图 Markdown] chatId:", chatId, "messageId:", messageId);
try {{
// 加载 AntV Infographic如果尚未加载
if (typeof AntVInfographic === 'undefined') {{
console.log("[信息图 Markdown] 正在加载 AntV Infographic 库...");
await new Promise((resolve, reject) => {{
const script = document.createElement('script');
script.src = 'https://unpkg.com/@antv/infographic@latest/dist/infographic.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
}});
console.log("[信息图 Markdown] 库加载完成。");
}}
const {{ Infographic }} = AntVInfographic;
// 获取信息图语法
let syntaxContent = `{syntax_escaped}`;
console.log("[信息图 Markdown] 原始语法:", syntaxContent.substring(0, 200) + "...");
// 清理语法
const backtick = String.fromCharCode(96);
const prefix = backtick + backtick + backtick + 'infographic';
const simplePrefix = backtick + backtick + backtick;
if (syntaxContent.toLowerCase().startsWith(prefix)) {{
syntaxContent = syntaxContent.substring(prefix.length).trim();
}} else if (syntaxContent.startsWith(simplePrefix)) {{
syntaxContent = syntaxContent.substring(simplePrefix.length).trim();
}}
if (syntaxContent.endsWith(simplePrefix)) {{
syntaxContent = syntaxContent.substring(0, syntaxContent.length - simplePrefix.length).trim();
}}
// 修复关键字后的冒号
syntaxContent = syntaxContent.replace(/^(data|items|children|theme|config):/gm, '$1');
syntaxContent = syntaxContent.replace(/(\\s)(children|items):/g, '$1$2');
// 确保有 infographic 前缀
if (!syntaxContent.trim().toLowerCase().startsWith('infographic')) {{
syntaxContent = 'infographic list-grid\\n' + syntaxContent;
}}
// 应用模板映射
{template_mapping_js}
for (const [key, value] of Object.entries(TEMPLATE_MAPPING)) {{
const regex = new RegExp(`infographic\\\\s+${{key}}(?=\\\\s|$)`, 'i');
if (regex.test(syntaxContent)) {{
console.log(`[信息图 Markdown] 自动映射: ${{key}} -> ${{value}}`);
syntaxContent = syntaxContent.replace(regex, `infographic ${{value}}`);
break;
}}
}}
console.log("[信息图 Markdown] 清理后语法:", syntaxContent.substring(0, 200) + "...");
// 创建离屏容器
const container = document.createElement('div');
container.id = 'infographic-offscreen-' + uniqueId;
container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:' + svgWidth + 'px;';
document.body.appendChild(container);
// 创建并渲染信息图
const instance = new Infographic({{
container: '#' + container.id,
width: svgWidth,
padding: 24,
}});
console.log("[信息图 Markdown] 正在渲染信息图...");
instance.render(syntaxContent);
// 等待渲染完成并导出
await new Promise(resolve => setTimeout(resolve, 1000));
let dataUrl;
if (exportFormat === 'png') {{
dataUrl = await instance.toDataURL({{ type: 'png', dpr: 2 }});
}} else {{
dataUrl = await instance.toDataURL({{ type: 'svg', embedResources: true }});
}}
console.log("[信息图 Markdown] Data URL 已生成,长度:", dataUrl.length);
// 清理
instance.destroy();
document.body.removeChild(container);
// 生成 Markdown 图片
const markdownImage = `![📊 AI 生成的信息图](${{dataUrl}})`;
// 通过 API 更新消息
if (chatId && messageId) {{
const token = localStorage.getItem("token");
// 获取当前消息内容
const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{
method: "GET",
headers: {{ "Authorization": `Bearer ${{token}}` }}
}});
if (!getResponse.ok) {{
throw new Error("获取对话数据失败: " + getResponse.status);
}}
const chatData = await getResponse.json();
let originalContent = "";
if (chatData.chat && chatData.chat.messages) {{
const targetMsg = chatData.chat.messages.find(m => m.id === messageId);
if (targetMsg && targetMsg.content) {{
originalContent = targetMsg.content;
}}
}}
// 移除已有的信息图图片
const infographicPattern = /\\n*!\\[📊[^\\]]*\\]\\(data:image\\/[^)]+\\)/g;
let cleanedContent = originalContent.replace(infographicPattern, "");
cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
// 追加新图片
const newContent = cleanedContent + "\\n\\n" + markdownImage;
// 更新消息
const updateResponse = await fetch(`/api/v1/chats/${{chatId}}/messages/${{messageId}}/event`, {{
method: "POST",
headers: {{
"Content-Type": "application/json",
"Authorization": `Bearer ${{token}}`
}},
body: JSON.stringify({{
type: "chat:message",
data: {{ content: newContent }}
}})
}});
if (updateResponse.ok) {{
console.log("[信息图 Markdown] ✅ 消息更新成功!");
}} else {{
console.error("[信息图 Markdown] API 错误:", updateResponse.status);
}}
}} else {{
console.warn("[信息图 Markdown] ⚠️ 缺少 chatId 或 messageId");
}}
}} catch (error) {{
console.error("[信息图 Markdown] 错误:", error);
}}
}})();
"""
async def action(
self,
body: dict,
__user__: dict = None,
__event_emitter__=None,
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
__metadata__: Optional[dict] = None,
__request__: Request = None,
) -> dict:
"""
使用 AntV 生成信息图并作为 Markdown 图片嵌入。
"""
logger.info("动作:信息图转 Markdown 开始")
# 获取用户信息
if isinstance(__user__, (list, tuple)):
user_language = __user__[0].get("language", "zh") if __user__ else "zh"
user_name = __user__[0].get("name", "用户") if __user__[0] else "用户"
user_id = __user__[0].get("id", "unknown_user") if __user__ else "unknown_user"
elif isinstance(__user__, dict):
user_language = __user__.get("language", "zh")
user_name = __user__.get("name", "用户")
user_id = __user__.get("id", "unknown_user")
else:
user_language = "zh"
user_name = "用户"
user_id = "unknown_user"
# 获取当前时间
now = datetime.now()
current_date_time_str = now.strftime("%Y-%m-%d %H:%M:%S")
try:
messages = body.get("messages", [])
if not messages:
raise ValueError("没有可用的消息。")
# 获取最近的消息
message_count = min(self.valves.MESSAGE_COUNT, len(messages))
recent_messages = messages[-message_count:]
# 聚合内容
aggregated_parts = []
for msg in recent_messages:
text_content = self._extract_text_content(msg.get("content"))
if text_content:
aggregated_parts.append(text_content)
if not aggregated_parts:
raise ValueError("消息中未找到文本内容。")
long_text_content = "\n\n---\n\n".join(aggregated_parts)
# 移除已有的 HTML 块
parts = re.split(r"```html.*?```", long_text_content, flags=re.DOTALL)
clean_content = ""
for part in reversed(parts):
if part.strip():
clean_content = part.strip()
break
if not clean_content:
clean_content = long_text_content.strip()
# 检查最小长度
if len(clean_content) < self.valves.MIN_TEXT_LENGTH:
await self._emit_status(
__event_emitter__,
f"⚠️ 内容太短({len(clean_content)} 字符),至少需要 {self.valves.MIN_TEXT_LENGTH} 字符",
True,
)
return body
await self._emit_status(__event_emitter__, "📊 正在分析内容...", False)
# 通过 LLM 生成信息图语法
formatted_user_prompt = USER_PROMPT_GENERATE.format(
user_name=user_name,
current_date_time_str=current_date_time_str,
user_language=user_language,
long_text_content=clean_content,
)
target_model = self.valves.MODEL_ID or body.get("model")
llm_payload = {
"model": target_model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT_INFOGRAPHIC},
{"role": "user", "content": formatted_user_prompt},
],
"stream": False,
}
user_obj = Users.get_user_by_id(user_id)
if not user_obj:
raise ValueError(f"无法获取用户对象:{user_id}")
await self._emit_status(__event_emitter__, "📊 AI 正在生成信息图语法...", False)
llm_response = await generate_chat_completion(__request__, llm_payload, user_obj)
if not llm_response or "choices" not in llm_response or not llm_response["choices"]:
raise ValueError("无效的 LLM 响应。")
assistant_content = llm_response["choices"][0]["message"]["content"]
infographic_syntax = self._extract_infographic_syntax(assistant_content)
logger.info(f"生成的语法:{infographic_syntax[:200]}...")
# 提取 API 回调所需的 ID
chat_id = self._extract_chat_id(body, __metadata__)
message_id = self._extract_message_id(body, __metadata__)
unique_id = f"ig_{int(time.time() * 1000)}"
await self._emit_status(__event_emitter__, "📊 正在渲染 SVG...", False)
# 执行 JS 进行渲染和嵌入
if __event_call__:
js_code = self._generate_js_code(
unique_id=unique_id,
chat_id=chat_id,
message_id=message_id,
infographic_syntax=infographic_syntax,
svg_width=self.valves.SVG_WIDTH,
export_format=self.valves.EXPORT_FORMAT,
)
await __event_call__(
{
"type": "execute",
"data": {"code": js_code},
}
)
await self._emit_status(__event_emitter__, "✅ 信息图生成完成!", True)
logger.info("信息图转 Markdown 完成")
except Exception as e:
error_message = f"信息图生成失败:{str(e)}"
logger.error(error_message, exc_info=True)
await self._emit_status(__event_emitter__, f"{error_message}", True)
return body