Files
Fu-Jie_openwebui-extensions/plugins/actions/js-render-poc/infographic_markdown_cn.py
Jeff fu 6de0d6fbe4 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
2026-01-05 17:29:52 +08:00

593 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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