更新 “本周有趣项目,Mermaid 流程图生成器!” — V2 及更多…

发布: (2025年12月7日 GMT+8 17:29)
7 min read
原文: Dev.to

Source: Dev.to

介绍

在之前的文章中,我演示了如何使用 Ollama 和本地 LLM 生成 Mermaid 图表(因为我实在受不了那些要求订阅付费的网站),于是决定对我的应用进行增强和更新。原因有两个:

  • 我的第一个应用把要使用的 LLM 硬编码进了代码。
  • 该应用不是迭代式的。

关键增强

本次增强的核心是抽象化 LLM 的选择,并创建一个干净、可重复的工作流。

动态 LLM 选择(解决硬编码问题)

不要只使用单一的硬编码模型,应用应当能够动态发现并使用本地 Ollama 实例中可用的任何模型。

  • 实现模型发现 – 向 Ollama 的 /api/tags 接口(http://localhost:11434/api/tags)发送请求。该接口会返回本地已安装模型的 JSON 列表。
  • 创建选择界面 – CLI – 将发现的模型列表按编号展示,并提示用户输入编号进行选择。
  • 创建选择界面 – GUI – 使用下拉框或单选按钮组(例如 Streamlit),将检索到的模型名称填入。
  • 传递模型名称 – 选中的模型名称(例如 llama3:8b-instruct-q4_0)必须作为变量放入后续所有对 /api/chat 接口的请求负载中。

迭代工作流与错误处理(解决非迭代性)

非迭代式的应用每生成一个图表都需要重新启动,体验极差。迭代性不仅仅是循环,更是要在同一会话中优雅地处理成功与失败。

  • 主执行循环 – 将核心逻辑(提示用户 → 调用 LLM → 生成图片)包装在 while True 循环中,只有用户明确选择退出时才跳出。
  • 会话状态(GUI) – 使用像 Streamlit 这样的 GUI 框架时,利用 st.session_state 在按钮点击和页面重新渲染之间保持生成的 Mermaid 代码和图片路径。
  • 输入校验 – 检查用户的提示是否为空。
  • 连接检查 – 在尝试获取模型或生成代码之前,先确认 Ollama 服务器已启动。
  • 文件处理安全 – 由于会为 mmdc 创建临时文件,确保清理逻辑可调试(例如,仅在 DEBUG_MODE 关闭时删除临时文件)。

V3 想法(未完待续)

增强项描述价值主张
代码审查/修复模式mmdc 因语法错误渲染失败,自动将 Mermaid 代码 以及 mmdc 错误日志发送回 LLM(使用特定系统提示),请求其修复语法。减少用户挫败感,自动修复常见的 LLM 引发的语法错误。
图表历史将生成的文本提示、输出代码以及对应的图片文件路径存入本地简易数据库(如 SQLite)或结构化文件(如 JSON/YAML)。让用户能够轻松回顾并复用过去的图表,无需重新生成。
输出格式选项增加除 PNG 之外的输出格式,如 SVG(更好缩放)或 PDF为需要高质量矢量图的用户提供更高的灵活性。
持久化设置将上次使用的 LLM 模型保存到配置文件(如 config.json)。启动时自动选中用户偏好的模型,节省时间。

代码与实现

1 — 控制台版本

创建虚拟 Python 环境并安装所需依赖:

pip install --upgrade pip
pip install requests

npm install -g @mermaid-js/mermaid-cli

代码 🧑‍💻

# app_V3.py
import subprocess
import os
import requests
import json
import re
import glob
import sys
import time
from pathlib import Path

DEBUG_MODE = True 

OLLAMA_BASE_URL = "http://localhost:11434"
OLLAMA_CHAT_URL = f"{OLLAMA_BASE_URL}/api/chat"
OLLAMA_TAGS_URL = f"{OLLAMA_BASE_URL}/api/tags"

INPUT_DIR = Path("./input")
OUTPUT_DIR = Path("./output")

def check_mmdc_installed():
    """Checks if 'mmdc' is installed."""
    try:
        subprocess.run(['mmdc', '--version'], check=True, capture_output=True, timeout=5)
        return True
    except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.CalledProcessError):
        print("Error: Mermaid CLI (mmdc) not found or misconfigured.")
        print("Try: npm install -g @mermaid-js/mermaid-cli")
        return False

# MODEL SELECTION
def get_installed_models():
    """Fetches locally installed Ollama models."""
    try:
        response = requests.get(OLLAMA_TAGS_URL, timeout=5)
        response.raise_for_status()
        return sorted([m['name'] for m in response.json().get('models', [])])
    except:
        return []

def select_model_interactive():
    """Interactive menu to choose a model."""
    print("\n--- Ollama Model Selection ---")
    models = get_installed_models()

    if not models:
        return input("No models found. Enter model name manually (e.g., llama3): ").strip() or "llama3"

    for idx, model in enumerate(models, 1):
        print(f"{idx}. {model}")

    while True:
        choice = input(f"\nSelect a model (1-{len(models)}) or type custom name: ").strip()
        if choice.isdigit() and 1 <= int(choice) <= len(models):
            return models[int(choice) - 1]
        elif choice:
            return choice

def clean_mermaid_code(code_string):
    """Clean common LLM formatting errors from Mermaid code."""
    cleaned = code_string.replace(u'\xa0', ' ').replace(u'\u200b', '')

    cleaned = cleaned.replace("```mermaid", "").replace("```", "")

    cleaned = re.sub(r'[ \t\r\f\v]+', ' ', cleaned)

    lines = cleaned.splitlines()
    rebuilt = []
    for line in lines:
        s_line = line.strip()
        if s_line:
            rebuilt.append(s_line)

    final = '\n'.join(rebuilt)
    final = re.sub(r'(\])([A-Za-z0-9])', r'\1\n\2', final)
    return final.strip()

def generate_mermaid_code(user_prompt, model_name):
    """Calls Ollama to generate the code."""
    system_msg = (
        "You are a Mermaid Diagram Generator. Output ONLY valid Mermaid code. "
        "Do not include explanations. Start with 'graph TD' or 'flowchart LR'. "
        "Use simple ASCII characters for node IDs."
    )

    payload = {
        "model": model_name,
        "messages": [{"role": "system", "content": system_msg}, {"role": "user", "content": user_prompt}],
        "stream": False,
        "options": {"temperature": 0.1}
    }

    try:
        print(f"Thinking ({model_name})...")
        response = requests.post(OLLAMA_CHAT_URL, json=payload, timeout=60)
        response.raise_for_status()
        content = response.json().get("message", {}).get("content", "").strip()

        match = re.search(r"```mermaid\\n(.*?)```", content, re.DOTALL)
        if match:
            return clean_mermaid_code(match.group(1))
        else:
            return clean_mermaid_code(content)
    except Exception as e:
        print(f"Error generating Mermaid code: {e}")
        return None

def render_mermaid_to_png(mermaid_code, output_path):
    """Uses mmdc to render Mermaid code to PNG."""
    temp_mmd = INPUT_DIR / "temp.mmd"
    temp_mmd.write_text(mermaid_code, encoding="utf-8")

    try:
        subprocess.run(
            ["mmdc", "-i", str(temp_mmd), "-o", str(output_path)],
            check=True,
            capture_output=True,
            timeout=30,
        )
        return True
    except subprocess.CalledProcessError as e:
        print(f"mmdc error: {e.stderr.decode()}")
        return False
    finally:
        if not DEBUG_MODE:
            try:
                temp_mmd.unlink()
            except FileNotFoundError:
                pass

def main():
    if not check_mmdc_installed():
        sys.exit(1)

    INPUT_DIR.mkdir(exist_ok=True)
    OUTPUT_DIR.mkdir(exist_ok=True)

    model_name = select_model_interactive()

    while True:
        user_prompt = input("\nEnter a description for your diagram (or 'quit' to exit): ").strip()
        if user_prompt.lower() in {"quit", "exit"}:
            print("Goodbye!")
            break
        if not user_prompt:
            print("Prompt cannot be empty.")
            continue

        mermaid_code = generate_mermaid_code(user_prompt, model_name)
        if not mermaid_code:
            continue

        timestamp = int(time.time())
        output_file = OUTPUT_DIR / f"diagram_{timestamp}.png"

        if render_mermaid_to_png(mermaid_code, output_file):
            print(f"Diagram saved to: {output_file}")
        else:
            print("Failed to render diagram.")

if __name__ == "__main__":
    main()

如果需要 GUI 版本(例如 Streamlit),或者想要实现上面列出的 V3 想法,完全可以在此基础上进行改造。

Back to Blog

相关文章

阅读更多 »

我终于消除的一个小摩擦

封面图片:我最终去除的一个小摩擦 https://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fde...

开源邮件预热:完整指南

引言 开源电子邮件预热是逐步与邮箱提供商建立信任的过程,使您的邮件进入收件箱,而不是垃圾邮件文件夹....