feat(web): render Mermaid diagrams in chat

This commit is contained in:
caoqianming 2026-08-03 10:26:39 +08:00
parent a00ebfe50e
commit f217672365
13 changed files with 3819 additions and 8 deletions

4
.gitattributes vendored
View File

@ -4,3 +4,7 @@
*.sh text eol=lf
Dockerfile text eol=lf
*.Dockerfile text eol=lf
# Preserve the verified upstream Mermaid bundle byte-for-byte. Its embedded
# template literals intentionally contain trailing spaces.
web/static/vendor/mermaid/mermaid.min.js -text -whitespace

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.60.24 — 2026-08-03
- 对话中的 Mermaid 流程图、柱状图和折线图现在可以直接显示;图表语法有误时会保留源码并给出提示。另修复嵌套代码块格式异常时后续正文整段变成代码的问题。
## 0.60.23 — 2026-08-03
- 修复已有 Word、PowerPoint、Excel 文件转 PDF 时可能找不到文件或错误宣称格式可用的问题;现在会根据服务器实际具备的转换组件提供能力,并在组件缺失时给出明确提示。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-08-03(Office→PDF 组件感知 + 路径兼容,bump 0.60.23)
最后更新:2026-08-03(Web Mermaid 直出 + Markdown 围栏容错,bump 0.60.24)
---
@ -23,6 +23,7 @@
### 2026-08-03
- **08-03 / 0.60.24 / Web Mermaid 直出 + Markdown 围栏容错**:模型偶发用同长度围栏嵌套 Markdown/Mermaid 示例CommonMark 会把后续正文吞进未闭合代码块;新增仅针对该明确形态的前后端确定性修复,提示词统一要求外层使用更长异类围栏,历史上下文加载时同样修正且不批量回写生产数据。聊天页本地 vendoring Mermaid 11.16.0,仅在助手文字段定稿后顺序渲染 `language-mermaid`,采用 strict 安全级别、文本/边数上限,语法错误或组件不可用时保留源码并提示;真实 Edge 冒烟确认中文流程图与 XYChart 柱线组合图可生成 SVG。Python 27 项、Node 9 项、Ruff、JS/Python 语法及 diff 检查通过;无 schema、migration、HTTP API 或 Python 依赖变化。
- **08-03 / 0.60.23 / Office→PDF 组件感知 + 展示路径兼容**:生产 task `92ac20cf` 暴露两层问题:上传消息给出 user-root 相对的 `测试pdf/x.doc`host tool 又按 task_dir 拼接导致首次找不到改用裸文件名后host 仅安装 `libreoffice-impress` 却因只检测 `soffice` 而错误宣称支持 DOC最终 Writer 导入返回 `source file could not be loaded`。现 `office_to_pdf` 同时解析 task 相对、user-root 展示路径与 `/workspace` 路径Debian/Ubuntu 按 Writer/Calc/Impress 实际安装包缓存支持后缀,工具 schema 只声明可用格式缺组件在启动转换前给出明确管理员提示。RUN bootstrap 与故障表同步要求 host 安装三组件;相关 30 项 unittest、Python 编译及 diff 格式检查通过,本机无 LibreOffice真实 `.doc/.xlsx/.pptx` 冒烟留部署 host无 schema、migration、HTTP API 或 Python 依赖变化,无需重建沙箱镜像。
- **08-03 / 0.60.22 / 通用 PDF 路由修复 + host Office 转换**:复盘生产 task 发现通用 system prompt 只展示 `report --format docx`,模型遂误判平台渲染器只能出 Word进而在 Docker 内探测 LibreOffice、临时安装 WeasyPrint 并手写转换脚本;现补齐 `report --format pdf` 唯一入口,新写 Markdown 报告直接由沙盒 Chromium 出 PDF。新增按 host `soffice` 实际可用性注册的 `office_to_pdf` typed tool已有 DOCX/PPTX/XLSX/ODF 文件通过 backend host LibreOffice 转换,复用 `/workspace`→user_root 路径翻译并强制用户目录边界Docker 内不再探测宿主命令。相关 34 项 unittest、Python 编译及 diff 格式检查通过;本机未安装 LibreOffice真实 soffice 冒烟留部署 host 执行;无 schema、migration、HTTP API 或依赖变化,新增 host 工具无需重建沙箱镜像。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.60.23"
__version__ = "0.60.24"

View File

@ -6,7 +6,7 @@ const require = createRequire(import.meta.url);
const marked = require("../web/static/vendor/markdown/marked.umd.js");
globalThis.window = { marked };
const { normalizeMarkdownFences, renderMd } = await import("../web/static/js/markdown.js");
const { normalizeMarkdownFences, renderMd, renderMermaidIn } = await import("../web/static/js/markdown.js");
test("repairs equal-length nested markdown fences", () => {
const broken = [
@ -33,3 +33,64 @@ test("preserves unrelated unclosed fences", () => {
const broken = "正文\n```python\nprint('x')\n";
assert.equal(normalizeMarkdownFences(broken), broken);
});
function fakeMermaidBlock(source = "flowchart LR\nA --> B") {
const created = [];
const doc = {
createElement(tag) {
const el = {
tag,
children: [],
attributes: {},
appendChild(child) { this.children.push(child); },
setAttribute(name, value) { this.attributes[name] = value; },
};
created.push(el);
return el;
},
};
const pre = {
isConnected: true,
ownerDocument: doc,
beforeNodes: [],
before(node) { this.beforeNodes.push(node); },
replaceWith(node) { this.replacement = node; },
};
const code = { dataset: {}, textContent: source, parentElement: pre };
const container = { querySelectorAll() { return [code]; } };
return { code, pre, container, created };
}
test("renders completed mermaid blocks with strict local configuration", async () => {
const calls = { initialize: [], render: [] };
window.mermaid = {
initialize(config) { calls.initialize.push(config); },
async render(id, source) {
calls.render.push({ id, source });
return { svg: "<svg viewBox='0 0 10 10'></svg>" };
},
};
const fixture = fakeMermaidBlock();
const result = await renderMermaidIn(fixture.container);
assert.deepEqual(result, { rendered: 1, failed: 0, unavailable: 0 });
assert.equal(calls.initialize.length, 1);
assert.equal(calls.initialize[0].securityLevel, "strict");
assert.equal(calls.initialize[0].startOnLoad, false);
assert.equal(calls.render[0].source, "flowchart LR\nA --> B");
assert.equal(fixture.pre.replacement.className, "mermaid-diagram");
assert.equal(fixture.pre.replacement.children[0].innerHTML, "<svg viewBox='0 0 10 10'></svg>");
});
test("keeps mermaid source visible when syntax is invalid", async () => {
window.mermaid.render = async () => { throw new Error("parse failed"); };
const fixture = fakeMermaidBlock("not valid");
const result = await renderMermaidIn(fixture.container);
assert.deepEqual(result, { rendered: 0, failed: 1, unavailable: 0 });
assert.equal(fixture.pre.replacement, undefined);
assert.equal(fixture.pre.beforeNodes[0].textContent, "Mermaid 图表语法有误,已保留源码。");
assert.equal(fixture.code.dataset.mermaidState, "error");
});

View File

@ -1,19 +1,19 @@
from pathlib import Path
import tempfile
import unittest
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from web.static_files import NoCacheStaticFiles
ROOT = Path(__file__).resolve().parents[1]
STATIC_DIR = ROOT / "web" / "static"
DEV_HTML = STATIC_DIR / "dev.html"
UI_CSS = STATIC_DIR / "ui.css"
JS_DIR = STATIC_DIR / "js"
VENDOR_DIR = STATIC_DIR / "vendor" / "markdown"
MERMAID_VENDOR_DIR = STATIC_DIR / "vendor" / "mermaid"
def _frontend_source() -> str:
@ -31,6 +31,7 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn('src="vendor/markdown/marked.umd.js"', html)
self.assertIn('src="vendor/markdown/purify.min.js"', html)
self.assertIn('src="vendor/markdown/highlight.min.js"', html)
self.assertIn('src="vendor/mermaid/mermaid.min.js"', html)
self.assertIn('href="vendor/markdown/github.min.css"', html)
for name in (
@ -43,6 +44,12 @@ class StaticVendorTests(unittest.TestCase):
self.assertTrue(path.exists(), f"missing vendored asset: {path}")
self.assertGreater(path.stat().st_size, 0, f"empty vendored asset: {path}")
mermaid_bundle = MERMAID_VENDOR_DIR / "mermaid.min.js"
mermaid_license = MERMAID_VENDOR_DIR / "LICENSE.txt"
self.assertTrue(mermaid_bundle.exists(), f"missing vendored asset: {mermaid_bundle}")
self.assertGreater(mermaid_bundle.stat().st_size, 1_000_000)
self.assertTrue(mermaid_license.exists(), f"missing vendored license: {mermaid_license}")
def test_dev_html_uses_local_ui_foundation(self) -> None:
html = DEV_HTML.read_text(encoding="utf-8")

View File

@ -10,6 +10,7 @@
<script src="vendor/markdown/marked.umd.js"></script>
<script src="vendor/markdown/purify.min.js"></script>
<script src="vendor/markdown/highlight.min.js"></script>
<script src="vendor/mermaid/mermaid.min.js"></script>
<link rel="stylesheet" href="vendor/markdown/github.min.css" />
<style>

View File

@ -8,7 +8,7 @@ import { state, LS_TASK_FILTERS_COLLAPSED } from "./state.js";
import { $, showMenu, showMenuAtPoint } from "./dom.js";
import { api } from "./api.js";
import { escapeHtml, fmtTime, fmtTokens, fmtTimeAgo, taskUsageTooltip, formatTaskUsage, formatContextStats, formatUsageStats, ctxRingHtml } from "./format.js";
import { renderMd, highlightIn } from "./markdown.js";
import { renderMd, highlightIn, renderMermaidIn } from "./markdown.js";
import { mqPhone, setMobileView } from "./layout.js";
import { dialogConfirm, dialogPrompt, message } from "./dialog.js";
@ -1222,6 +1222,7 @@ function closeTextSeg(run) {
} else {
seg.el.classList.remove("streaming");
highlightIn(seg.el);
void renderMermaidIn(seg.el);
}
run.curSeg = null;
}
@ -1637,8 +1638,9 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
// Once that tool call is filtered, do not leave an empty assistant shell.
if (role === "assistant" && !hasVisibleAssistantContent) continue;
card.innerHTML = html;
highlightIn(card);
wrap.appendChild(card);
highlightIn(card);
if (role === "assistant") void renderMermaidIn(card);
}
// 底部 sentinel:从目录跳到旧消息后,下方还有更新的未加载 → 进视口自动向下补
if (state.msgHasMoreNewer) {
@ -2662,7 +2664,10 @@ async function fetchSse(url, run) {
if (ctx.curSeg.acc) ctx.curSeg.el.innerHTML = renderMd(ctx.curSeg.acc);
else ctx.curSeg.el.remove(); // 收尾时仍是空占位段 → 移除
}
if (ctx.card) highlightIn(ctx.card);
if (ctx.card) {
highlightIn(ctx.card);
void renderMermaidIn(ctx.card);
}
} finally {
finishRunningTool(ctx); // 工具执行中被杀/断流 → 去 spinner
if (ctx.card) {

View File

@ -7,6 +7,17 @@ if (window.marked && window.marked.setOptions) {
}
const FENCE_RE = /^( {0,3})(`{3,}|~{3,})([^\r\n]*)(\r?\n)?$/;
const MERMAID_CONFIG = Object.freeze({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
theme: "neutral",
maxTextSize: 50000,
maxEdges: 500,
fontFamily: '-apple-system, "Segoe UI", "Microsoft YaHei", sans-serif',
});
let mermaidInitialized = false;
let mermaidSeq = 0;
function parseFence(line) {
const m = String(line || "").match(FENCE_RE);
@ -81,10 +92,82 @@ export function renderMd(text) {
return html;
}
function getMermaidApi() {
const api = window.mermaid;
if (!api || typeof api.initialize !== "function" || typeof api.render !== "function") {
return null;
}
if (!mermaidInitialized) {
api.initialize(MERMAID_CONFIG);
mermaidInitialized = true;
}
return api;
}
function mermaidNotice(pre, className, text) {
const doc = pre.ownerDocument || document;
const notice = doc.createElement("div");
notice.className = className;
notice.textContent = text;
pre.before(notice);
}
/** Render completed assistant Mermaid blocks, preserving source on failure. */
export async function renderMermaidIn(container) {
const result = { rendered: 0, failed: 0, unavailable: 0 };
if (!container || typeof container.querySelectorAll !== "function") return result;
const blocks = Array.from(container.querySelectorAll("pre > code.language-mermaid"))
.filter((code) => !code.dataset.mermaidState);
if (!blocks.length) return result;
const api = getMermaidApi();
if (!api) {
for (const code of blocks) {
code.dataset.mermaidState = "unavailable";
mermaidNotice(code.parentElement, "mermaid-notice", "图表组件未加载,已保留 Mermaid 源码。");
result.unavailable++;
}
return result;
}
// Mermaid owns shared configuration/state; render sequentially to avoid races.
for (const code of blocks) {
const pre = code.parentElement;
code.dataset.mermaidState = "rendering";
try {
const id = `zcbot-mermaid-${Date.now()}-${++mermaidSeq}`;
const rendered = await api.render(id, code.textContent || "");
if (!pre || !pre.isConnected) continue;
const doc = pre.ownerDocument || document;
const figure = doc.createElement("figure");
figure.className = "mermaid-diagram";
figure.setAttribute("role", "img");
figure.setAttribute("aria-label", "Mermaid 图表");
const viewport = doc.createElement("div");
viewport.className = "mermaid-viewport";
viewport.innerHTML = rendered.svg;
figure.appendChild(viewport);
pre.replaceWith(figure);
if (typeof rendered.bindFunctions === "function") rendered.bindFunctions(viewport);
result.rendered++;
} catch (_error) {
code.dataset.mermaidState = "error";
if (pre && pre.isConnected) {
mermaidNotice(pre, "mermaid-notice error", "Mermaid 图表语法有误,已保留源码。");
}
result.failed++;
}
}
return result;
}
export function highlightIn(container) {
if (!window.hljs || !container) return;
container.querySelectorAll("pre code").forEach((b) => {
if (b.dataset.hl === "1") return;
if (b.classList && b.classList.contains("language-mermaid")) return;
try { window.hljs.highlightElement(b); b.dataset.hl = "1"; } catch (e) {}
});
}

View File

@ -434,6 +434,40 @@ header .who {
line-height: 1.35;
}
.mermaid-diagram {
margin: 14px 0;
padding: 14px;
overflow-x: auto;
border: 1px solid var(--border-soft);
border-radius: var(--r-lg);
background: #ffffff;
}
.mermaid-viewport {
min-width: min-content;
}
.mermaid-viewport svg {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
}
.mermaid-notice {
margin: 10px 0 4px;
padding: 7px 9px;
color: var(--muted);
border-left: 3px solid var(--border);
background: var(--panel-muted);
font-size: 12px;
}
.mermaid-notice.error {
color: var(--c-red);
border-left-color: var(--c-red);
}
.msg-actions {
margin-top: 8px;
}

21
web/static/vendor/mermaid/LICENSE.txt vendored Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 - 2022 Knut Sveidqvist
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

3
web/static/vendor/mermaid/VERSION.txt vendored Normal file
View File

@ -0,0 +1,3 @@
Mermaid 11.16.0
Source: https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz
Integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==

3587
web/static/vendor/mermaid/mermaid.min.js vendored Normal file

File diff suppressed because one or more lines are too long