From f217672365bea15e7875b1a6770bf17404d6b857 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 3 Aug 2026 10:26:39 +0800 Subject: [PATCH] feat(web): render Mermaid diagrams in chat --- .gitattributes | 4 + CHANGELOG.md | 4 + PROGRESS.md | 3 +- core/__init__.py | 2 +- tests/frontend_markdown_guard.test.mjs | 63 +- tests/test_static_vendor.py | 11 +- web/static/dev.html | 1 + web/static/js/chat.js | 11 +- web/static/js/markdown.js | 83 + web/static/ui.css | 34 + web/static/vendor/mermaid/LICENSE.txt | 21 + web/static/vendor/mermaid/VERSION.txt | 3 + web/static/vendor/mermaid/mermaid.min.js | 3587 ++++++++++++++++++++++ 13 files changed, 3819 insertions(+), 8 deletions(-) create mode 100644 web/static/vendor/mermaid/LICENSE.txt create mode 100644 web/static/vendor/mermaid/VERSION.txt create mode 100644 web/static/vendor/mermaid/mermaid.min.js diff --git a/.gitattributes b/.gitattributes index a1abf55..f11ddf1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index e743c03..b0c6077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 时可能找不到文件或错误宣称格式可用的问题;现在会根据服务器实际具备的转换组件提供能力,并在组件缺失时给出明确提示。 diff --git a/PROGRESS.md b/PROGRESS.md index 103ac8c..e80bf3d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 工具无需重建沙箱镜像。 diff --git a/core/__init__.py b/core/__init__.py index 5542092..682cd5c 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.60.23" +__version__ = "0.60.24" diff --git a/tests/frontend_markdown_guard.test.mjs b/tests/frontend_markdown_guard.test.mjs index f2128cc..d7306f2 100644 --- a/tests/frontend_markdown_guard.test.mjs +++ b/tests/frontend_markdown_guard.test.mjs @@ -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: "" }; + }, + }; + 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, ""); +}); + +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"); +}); diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py index a32998e..bc53524 100644 --- a/tests/test_static_vendor.py +++ b/tests/test_static_vendor.py @@ -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") diff --git a/web/static/dev.html b/web/static/dev.html index f175b8e..866f04c 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -10,6 +10,7 @@ +