Skip to main content
Glama
visualization_generator.py20.9 kB
""" Visualization generator - 统一可视化生成器 从 mcp-chart-python 项目整合,适配 mcp-filesystem 工作区系统 """ import asyncio import uuid import os import json import sys from pathlib import Path from typing import Any, Dict, Optional from .auto_detector import AutoChartDetector from .style_enhancer import StyleEnhancer from .api_client import ChartAPIClient # Mermaid 主题配置 MERMAID_BUILTIN_THEMES = { "default": "默认主题 - 标准样式", "dark": "深色主题 - 适合暗色背景", "forest": "森林主题 - 绿色系", "neutral": "中性主题 - 黑白灰" } MERMAID_CUSTOM_THEMES = { "tech-cyan": { # 科技青蓝主题 - 基于平台配色 ⭐ 推荐 "theme": "base", "themeVariables": { "primaryColor": "#06b6d4", "primaryTextColor": "#ffffff", "primaryBorderColor": "#0891b2", "secondaryColor": "#3b82f6", "secondaryBorderColor": "#2563eb", "secondaryTextColor": "#ffffff", "tertiaryColor": "#10b981", "tertiaryBorderColor": "#0d9488", "tertiaryTextColor": "#ffffff", "lineColor": "#0e7490", "edgeLabelBackground": "#f0fdfa", "background": "#ffffff", "mainBkg": "#ecfeff", "secondBkg": "#cffafe", "nodeBorder": "#0891b2", "clusterBkg": "#f0fdfa", "clusterBorder": "#0e7490", "fontFamily": "Arial, 'Microsoft YaHei', sans-serif", "fontSize": "16px", "noteBkgColor": "#FEF3C7", "noteTextColor": "#1f2937", "noteBorderColor": "#f59e0b", "actorBkg": "#06b6d4", "actorBorder": "#0891b2", "actorTextColor": "#ffffff", "signalColor": "#0e7490", "signalTextColor": "#1f2937", "gridColor": "#e5e7eb", "doneTaskBkgColor": "#10b981", "activeTaskBkgColor": "#3b82f6", "critBkgColor": "#ef4444", } }, "tech-blue": { "theme": "base", "themeVariables": { "primaryColor": "#3b82f6", "primaryTextColor": "#ffffff", "primaryBorderColor": "#2563eb", "lineColor": "#1d4ed8", "secondaryColor": "#10b981", "tertiaryColor": "#06b6d4", "background": "#ffffff", "mainBkg": "#eff6ff", "fontFamily": "Arial, 'Microsoft YaHei', sans-serif" } }, "warm-orange": { "theme": "base", "themeVariables": { "primaryColor": "#f97316", "primaryTextColor": "#ffffff", "primaryBorderColor": "#ea580c", "lineColor": "#c2410c", "secondaryColor": "#fb923c", "tertiaryColor": "#fbbf24", "background": "#ffffff", "mainBkg": "#fff7ed", "fontFamily": "Arial, 'Microsoft YaHei', sans-serif" } } } class VisualizationGenerator: """可视化图表生成器""" def __init__( self, workspace_path: Optional[Path] = None, api_endpoint: Optional[str] = None, chart_default_width: int = 800, chart_default_height: int = 600, mermaid_default_theme: str = "forest", mermaid_format: str = "svg", base_url: Optional[str] = None ): """ 初始化生成器 :param workspace_path: 工作区路径,用于保存生成的图片 :param api_endpoint: 图表API端点 :param chart_default_width: 图表默认宽度 :param chart_default_height: 图表默认高度 :param mermaid_default_theme: Mermaid默认主题 :param mermaid_format: Mermaid输出格式,可选 "svg" 或 "png" :param base_url: 图片访问的基础URL,用于生成完整的图片访问地址 """ self.detector = AutoChartDetector() self.enhancer = StyleEnhancer() self.api_client = ChartAPIClient(api_endpoint=api_endpoint) self.workspace_path = workspace_path self.chart_default_width = chart_default_width self.chart_default_height = chart_default_height self.mermaid_default_theme = mermaid_default_theme self.mermaid_format = mermaid_format.lower() if mermaid_format else "svg" self.base_url = base_url.rstrip("/") if base_url else None # 在工作区创建 images 目录 if workspace_path: self.images_dir = workspace_path / "images" self.images_dir.mkdir(parents=True, exist_ok=True) else: self.images_dir = None async def generate( self, type: str, chart_type: Optional[str], data: Any, options: Optional[Dict] = None ) -> Dict[str, Any]: """ 生成可视化图表 :param type: 类型 (chart/mermaid) :param chart_type: 图表类型 :param data: 数据或Mermaid代码 :param options: 额外选项 :return: 生成结果 """ options = options or {} # 处理chart类型 if type == "chart": return await self._generate_chart(chart_type, data, options) # 处理mermaid类型 elif type == "mermaid": return await self._generate_mermaid(data, options) # 处理html类型 elif type == "html": return await self._generate_html(data, options) else: return { "success": False, "error": f"不支持的类型: {type}", "type": type } async def _generate_chart( self, chart_type: Optional[str], data: Any, options: Dict ) -> Dict[str, Any]: """生成数据图表""" # 如果chart_type没填或auto,自动判断 if not chart_type or chart_type.lower() == "auto": chart_type = self.detector.detect(data, type_hint="chart") # 自动增强数据样式 if isinstance(data, list): enhanced_data = self.enhancer.enhance(data, chart_type) else: enhanced_data = data # 构建完整的chart_data chart_data = { "type": chart_type, "data": enhanced_data, "width": options.get("width", self.chart_default_width), "height": options.get("height", self.chart_default_height) } if options.get("title"): chart_data["title"] = options["title"] if options.get("axisXTitle"): chart_data["axisXTitle"] = options["axisXTitle"] if options.get("axisYTitle"): chart_data["axisYTitle"] = options["axisYTitle"] # 调用图床API result = await self.api_client.render_chart(chart_data) return result async def _generate_mermaid( self, code: str, options: Dict ) -> Dict[str, Any]: """生成Mermaid图表""" try: # 获取输出格式(优先使用 options 中的格式,否则使用配置的格式) output_format = options.get("format", self.mermaid_format).lower() if output_format not in ["svg", "png"]: output_format = "svg" # 默认使用 SVG # 渲染Mermaid代码 image_data = await self._render_mermaid(code, options, output_format) # 生成唯一文件名 filename = f"mermaid_{uuid.uuid4()}.{output_format}" # 保存到工作区的 images 目录 if not self.images_dir: return { "success": False, "error": "工作区路径未设置,无法保存图片", "type": "mermaid" } file_path = self.images_dir / filename # 保存到工作区 images 目录 with open(file_path, "wb") as f: f.write(image_data) # 同时复制到公共图床目录(按日期组织) try: from datetime import datetime import shutil # 获取图床目录路径:MCP_WORKSPACES_DIR/YYYYMMDD/ workspaces_dir_env = os.environ.get("MCP_WORKSPACES_DIR", "") if workspaces_dir_env: workspaces_dir = Path(workspaces_dir_env) else: # 如果没有设置环境变量,使用默认路径 default_user_data_dir = Path(__file__).parent.parent.parent / "user_data" workspaces_dir = default_user_data_dir # 创建日期目录(格式:YYYYMMDD) date_str = datetime.now().strftime("%Y%m%d") gallery_dir = workspaces_dir / date_str gallery_dir.mkdir(parents=True, exist_ok=True) # 复制文件到图床目录 gallery_file = gallery_dir / filename shutil.copy2(file_path, gallery_file) except Exception as e: # 如果复制失败,记录日志但不影响主流程 import sys print(f"Warning: Failed to copy image to gallery: {e}", file=sys.stderr) # 使用公开路由,构建可访问的URL 无需 user_id 和 chat_id(类似图床) relative_url = f"/api/public/image/{filename}" # 如果配置了 base_url,则拼接完整 URL image_url = f"{self.base_url}{relative_url}" if self.base_url else relative_url return { "success": True, "url": image_url, "type": "mermaid", "chart_type": "flowchart", "message": "Mermaid图表渲染成功" } except Exception as e: return { "success": False, "error": f"Mermaid渲染失败: {str(e)}", "type": "mermaid" } async def _generate_html( self, html_code: str, options: Dict ) -> Dict[str, Any]: """生成HTML渲染的PNG图片""" try: if not self.workspace_path: return { "success": False, "error": "工作区路径未设置,无法保存文件", "type": "html" } # 生成唯一文件名 unique_id = uuid.uuid4() html_filename = f"html_{unique_id}.html" png_filename = f"html_{unique_id}.png" # 保存HTML文件到工作区 html_file_path = self.workspace_path / html_filename with open(html_file_path, "w", encoding="utf-8") as f: f.write(html_code) # 保存 PNG 到工作区 images 目录 if not self.images_dir: return { "success": False, "error": "工作区路径未设置,无法保存图片", "type": "html" } images_png_path = self.images_dir / png_filename # 使用 playwright 渲染 HTML 为 PNG from playwright.async_api import async_playwright width = options.get("width", self.chart_default_width) height = options.get("height", self.chart_default_height) async with async_playwright() as p: browser = await p.chromium.launch(headless=True) page = await browser.new_page(viewport={"width": width, "height": height}) # 加载 HTML 文件 await page.goto(f"file://{html_file_path.resolve()}") # 等待页面加载完成 await page.wait_for_load_state("networkidle", timeout=5000) # 截图直接保存到 images 目录 await page.screenshot(path=str(images_png_path), type="png", full_page=True) await browser.close() # 同时复制到公共图床目录(按日期组织) try: from datetime import datetime import shutil # 获取图床目录路径:MCP_WORKSPACES_DIR/YYYYMMDD/ workspaces_dir_env = os.environ.get("MCP_WORKSPACES_DIR", "") if workspaces_dir_env: workspaces_dir = Path(workspaces_dir_env) else: # 如果没有设置环境变量,使用默认路径 default_user_data_dir = Path(__file__).parent.parent.parent / "user_data" workspaces_dir = default_user_data_dir # 创建日期目录(格式:YYYYMMDD) date_str = datetime.now().strftime("%Y%m%d") gallery_dir = workspaces_dir / date_str gallery_dir.mkdir(parents=True, exist_ok=True) # 复制文件到图床目录 gallery_file = gallery_dir / png_filename shutil.copy2(images_png_path, gallery_file) except Exception as e: # 如果复制失败,记录日志但不影响主流程 import sys print(f"Warning: Failed to copy image to gallery: {e}", file=sys.stderr) # 使用公开路由,构建可访问的URL 无需 user_id 和 chat_id(类似图床) relative_url = f"/api/public/image/{png_filename}" # 如果配置了 base_url,则拼接完整 URL image_url = f"{self.base_url}{relative_url}" if self.base_url else relative_url return { "success": True, "url": image_url, "type": "html", "html_file": html_filename, "png_file": png_filename, "message": "HTML渲染为PNG成功" } except Exception as e: return { "success": False, "error": f"HTML渲染失败: {str(e)}", "type": "html" } async def _render_mermaid(self, code: str, options: Dict, output_format: str) -> bytes: """使用mermaid-py渲染Mermaid代码为图片""" try: from mermaid import Mermaid from mermaid.graph import Graph # 预处理 mermaid 代码 code = self._preprocess_mermaid_code(code) # 获取主题名称(优先使用用户指定的主题,否则使用配置的默认主题) theme_name = options.get("theme", self.mermaid_default_theme) # 判断是自定义主题还是内置主题 if theme_name in MERMAID_CUSTOM_THEMES: theme_config = MERMAID_CUSTOM_THEMES[theme_name].copy() elif theme_name in MERMAID_BUILTIN_THEMES: theme_config = {"theme": theme_name} if "themeVariables" in options: theme_config["themeVariables"] = options["themeVariables"] else: # 未知主题,使用默认 if self.mermaid_default_theme in MERMAID_CUSTOM_THEMES: theme_config = MERMAID_CUSTOM_THEMES[self.mermaid_default_theme].copy() else: theme_config = {"theme": self.mermaid_default_theme} # 注入主题配置到 Mermaid 代码 theme_init = f"%%{{init: {json.dumps(theme_config)}}}%%" # 组合代码:主题配置 + 标题(可选)+ 原始代码 code_parts = [theme_init] # 如果有标题,使用YAML前置语法添加 title = options.get("title", "") if title: code_parts.append(f"---\ntitle: {title}\n---") code_parts.append(code) code_with_theme = "\n".join(code_parts) # 创建Graph对象 graph = Graph('diagram', code_with_theme) # 创建 Mermaid 对象 diagram = Mermaid(graph) # 根据输出格式选择返回内容 if output_format == "png": # 直接使用 mermaid-py 的 PNG 支持 return diagram.img_response.content else: # 返回 SVG return diagram.svg_response.content except ImportError: # 如果mermaid-py未安装,回退到mermaid-cli return await self._render_mermaid_cli(code, options, output_format) except Exception as e: raise Exception(f"Mermaid渲染失败: {str(e)}") def _preprocess_mermaid_code(self, code: str) -> str: """ 预处理 Mermaid 代码,兼容 AI 智能体生成的各种格式 处理内容: 1. 将字面 \\n \\t \\r 转换为真正的换行符/制表符 2. 将 subgraph 名称和节点标签中的英文括号 () 替换为中文括号(),避免解析错误 """ import re # 1. 将字面转义字符转换为真正的控制字符 code = code.replace('\\n', '\n').replace('\\t', '\t').replace('\\r', '\r') # 2. 处理 subgraph 中的括号 # 匹配: subgraph "名称 (备注)" 或 subgraph 名称 (备注) # 将英文括号 () 替换为中文括号() def replace_parens_to_chinese(text): return text.replace('(', '(').replace(')', ')') # 处理带引号的 subgraph: subgraph "xxx (yyy)" code = re.sub( r'(subgraph\s+)"([^"]*)"', lambda m: m.group(1) + '"' + replace_parens_to_chinese(m.group(2)) + '"', code ) # 处理不带引号的 subgraph: subgraph xxx (yyy) 直到换行 code = re.sub( r'(subgraph\s+)([^\n"]+?)(\n)', lambda m: m.group(1) + replace_parens_to_chinese(m.group(2)) + m.group(3), code ) # 3. 处理节点标签中的括号(方括号内的内容) # 匹配: [名称 (备注)] 将其中的英文括号替换为中文括号 code = re.sub( r'\[([^\]]*)\]', lambda m: '[' + replace_parens_to_chinese(m.group(1)) + ']', code ) # 4. 处理连接标签中的括号 # 匹配: -- "标签 (备注)" --> 或 |标签 (备注)| code = re.sub( r'(--\s*")([^"]*)(">)', lambda m: m.group(1) + replace_parens_to_chinese(m.group(2)) + m.group(3), code ) code = re.sub( r'(\|)([^|]*)(\|)', lambda m: m.group(1) + replace_parens_to_chinese(m.group(2)) + m.group(3), code ) return code async def _render_mermaid_cli(self, code: str, options: Dict, output_format: str = "svg") -> bytes: """使用mermaid-cli渲染(备用方案)""" import tempfile # 预处理 mermaid 代码 code = self._preprocess_mermaid_code(code) temp_dir = tempfile.mkdtemp() temp_file = os.path.join(temp_dir, f"diagram_{uuid.uuid4()}.mmd") output_file = os.path.join(temp_dir, f"diagram_{uuid.uuid4()}.{output_format}") # 写入Mermaid代码 with open(temp_file, "w", encoding="utf-8") as f: f.write(code) # 执行mermaid-cli(使用配置的默认值) width = options.get("width", self.chart_default_width) height = options.get("height", self.chart_default_height) cmd = [ "mmdc", "-i", temp_file, "-o", output_file, "-t", "default", "-b", "white", "-w", str(width), "-H", str(height), "-f", output_format ] process = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, stderr = await process.communicate() if process.returncode != 0: error_msg = stderr.decode() if stderr else "Unknown error" raise Exception(f"mermaid-cli执行失败: {error_msg}") # 读取生成的图片 if not os.path.exists(output_file): raise Exception("图片文件未生成") with open(output_file, "rb") as f: return f.read()

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/answerlink/MCP-Workspace-Server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server