feat(web): render Mermaid diagrams in chat
This commit is contained in:
parent
a00ebfe50e
commit
f217672365
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 时可能找不到文件或错误宣称格式可用的问题;现在会根据服务器实际具备的转换组件提供能力,并在组件缺失时给出明确提示。
|
||||
|
|
|
|||
|
|
@ -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 工具无需重建沙箱镜像。
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.60.23"
|
||||
__version__ = "0.60.24"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
Mermaid 11.16.0
|
||||
Source: https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz
|
||||
Integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue