Skip to content

feat(webui): render Mermaid fences in the transcript - #125

Closed
stevenjj33 wants to merge 3 commits into
webuifrom
feat/webui-mermaid-fence
Closed

stevenjj33 wants to merge 3 commits into
webuifrom
feat/webui-mermaid-fence

Conversation

@stevenjj33

Copy link
Copy Markdown
Collaborator

内容

关闭验收表「markdown-图表(mmd) 通过」一行——此前验收表与源码唯一对不上的地方:grep mermaid 零命中,模型生成的流程图/时序图在对话里是纯文本。

  • markdown.tsx 在 math 分支后、通用 code 分支前新增 mermaid 分支(mermaid/mmd,大小写不敏感,支持 title="a.mmd" 属性写法),其余语言一行未碰
  • 新增 webui-mermaid.tsx:动态 import 加载,语法错 / 空图 / 抛错 / 超时四种失败全部回退纯文本围栏(无 gutter)
  • 新增 webui-mermaid 样式,全部使用现有 design token
  • 单测 18 条 + 浏览器用例(真渲染 <svg>,摘掉分支转红)

产物大小(metafile 实测)

base 本 PR
首屏载荷 4.11 MB 4.11 MB(不变)
mermaid — ~11 MB / ~105 个 chunk,仅出现围栏时下载

说明:不开 splitting 时 esbuild 会把动态 import 内联进 client.js(实测 4.11 MB → 15.35 MB),故给两个构建脚本加了 splitting: true + chunk 落 assets/(生产静态解析器本就 Serve 该前缀,产品代码零改动);测试服务器同步 Serve assets/。

验证

  • markdown 单测 36/36(新 18 条 + C-1/C-2 回归 17 条)
  • typecheck client / test 均 0 错误
  • 浏览器套件 7/7(含新 mermaid 用例,真浏览器渲染出 SVG)
  • 依赖方向检查 CLI ok: true(新文件按兄弟组件层登记 bindings)
  • Windows 本地 13 条 suite 失败为既有环境问题(路径含空格、symlink 权限、WebSocket 竞态),在 base commit 上逐条同名复现,与本次改动无关

Closes the acceptance table''s "markdown-图表(mmd)" row: the table said
"通过" while the source had zero Mermaid support, so a model-drawn
flowchart or sequence diagram reached the transcript as plain text.

The fence branch sits in `markdown.tsx` between the math and generic
code branches and fires only for the `mermaid`/`mmd` language words —
`codeFenceLanguage` already strips tag attributes, so a fence tagged
`mermaid title="a.mmd"` is recognised, and the match is
case-insensitive. Every other fence keeps the untouched path, which
C-1 and C-2 pin byte for byte.

Rendering lives in the new `webui-mermaid.tsx`. The library loads
through a dynamic `import()` on the first fence that needs it, and
invalid syntax, an empty body, a thrown render and a render past the
timeout all fall back to the plain fence — the same failure habit as
katex''s `<pre>` fallback, minus the gutter a Mermaid fence never had.
The component is split into a pure block and a thin effect wrapper so
the Node-environment suite can drive each state without a DOM, the
pattern `ReviewSettingsPage.tsx` already uses.

Build: the client bundle is now split (`splitting: true`, chunks under
`assets/`). Without splitting esbuild inlines every dynamic import
into the entry — mermaid''s source took `client.js` from 4.11 MB to
15.35 MB. Measured on the metafile, the initial payload is unchanged
at 4.11 MB and the ~11 MB of diagram chunks download only when a
fence appears. `test/webui-browser/server.mjs` serves the `assets/`
prefix, matching the loopback service''s static resolver.
`webui-dependency-rules.mjs` classifies the new file as `bindings`,
its siblings'' layer, so the direction gate stays green.

Verification: `markdown-mermaid.test.tsx` (18 cases) and the C-1/C-2
suites pass, typecheck:client and typecheck:test report 0 errors, and
the browser suite renders a real `<svg>` — removing the branch turns
that case red, which is the reverse check the task book asks for. On
Windows, 13 pre-existing suite failures (path-with-space
normalisation, symlink privilege, websocket shutdown timing) fail
identically on the base commit.
e40a63f moved composer-store ownership into the application and made the
shell adopt the composition root's application (A4-s2). The root built
its application without a `composer` dep, so `create-application` fell
back to `createWebuiComposerStore()` — storage-less by design, because
the application layer may not import infrastructure. The shell's own
storage-backed store (`providedApplication?.composer ?? create(...)`)
became dead code on the live path, and with it every draft: typing
still worked and switching sessions still restored the in-memory draft,
but nothing ever reached localStorage, so a reload lost them all.

Verified on webui @ 6a455a2 before this change: the drafts spec failed
deterministically (3 runs, same failure — after reload the composer is
empty and `webui.composer.state.v1` is null even before the reload), and
a probe showed the key was never written. With the injection: 7/7 in
composer.spec.mjs, 97/97 in the full browser suite.

The composition root now injects the same storage adapter the shell
would have built, exactly as the shell already does for `unreadStorage`
when it builds its own application. The unread controller has the same
gap at the same call site (no failing gate covers it); left for a
follow-up rather than widening this fix.
@stevenjj33
stevenjj33 force-pushed the feat/webui-mermaid-fence branch from 6439875 to 1939394 Compare October 13, 2026 17:52
@stevenjj33

Copy link
Copy Markdown
Collaborator Author

本地修复记录(拉分支跑通到全绿,未重写功能)

按任务书「拉到本地跑通构建与测试,先修到绿再提评审」执行。功能提交本身一字节未改(冲突均为机械路径适配),以下四项是让它从「CI 从没跑过」到「本地全部门禁绿」的实际修复:

  1. 依赖从未安装(构建直接失败):分支在 packages/webui/package.json 声明了 mermaid@12.1.0 并更新了 lockfile,但 pnpm install 显然没在新 lockfile 下跑过——pnpm build:webui 报 Could not resolve "mermaid"。装依赖后构建通过。这也解释了 head 为什么一条 CI 都没跑起来。
  2. check:source 红:两个新文件(src/client/webui-mermaid.tsx、test/unit/markdown-mermaid.test.tsx)没登记进 release/public-source.json。已重跑 source-inventory.mjs --write 补齐(markdown-mermaid.test.tsx 本身已在 test/vitest-suites.json,测试有在跑)。
  3. 基点落后 63 个提交且自带红门:原基点(5f4f157)的 test:release-tools 门就是红的——test/source-sync.test.mjs 断言的 docs profile 门清单缺 check:webui-commands(webui 后续提交才补的期望)。已把功能提交原样 rebase 到当前 webui HEAD 6a455a2,补丁内容不变;webui-mermaid.tsx 按新布局落到 client/components/transcript/(正是原注释说的「beside markdown.tsx」的位置,webui-dependency-rules.mjs 的 provenance 表在上游已整表删除,按前缀分类即可,无需再加条目)。
  4. 浏览器门被一条与本文无关的既有红堵住:composer.spec.mjs drafts 用例在 webui HEAD 上稳定红(e40a63f 回归:组合根没给 application 传 storage-backed composer,草稿从不落盘)。已把该修复(见 PR fix(webui): runtime.warning 人话渲染 + 草稿持久化回归修复(批次一) #149)一并带上,否则浏览器套件 97/98。

本地全量结果:pnpm build:webui ✓;单测 370 files / 8103 passed;pnpm typecheck:webui-full 0 错误;pnpm verify 22 gates PASS(darwin);pnpm test:webui-browser 98 passed(含真渲染 <svg> 的 mermaid 用例)。

产物大小更正(对当前 webui HEAD 实测,非旧基点)

原文表格的数字取自旧基点,对 6a455a2 实测:

webui HEAD(6a455a2) 本分支
入口 client.js 4,312,482 B 4,119,272 B(小 193 KB)
mermaid 相关 chunk — 109 个懒加载 chunk(elk 3.3M 等),仅出现围栏时下载

入口不增反降是因为本分支给构建加了 splitting: true,共享代码被拆进懒加载 chunk。也就是说:首屏载荷不升,mermaid 全部延迟加载——比原文声称的更优,但原文表格数字已过期,以本节为准。

CI 状态说明

仓库级 Actions 当前被关闭(gh workflow run 返回 HTTP 422: Actions has been disabled for this repository,全仓最后一次成功运行是 10-07),本 PR 同样收不到 check。请管理员恢复后补跑,或以上述本地全量证据先行评审。

@stevenjj33
stevenjj33 requested a review from modacker October 13, 2026 17:52
@stevenjj33 stevenjj33 closed this Oct 14, 2026
@stevenjj33 stevenjj33 reopened this Oct 14, 2026

@modacker modacker left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这条 PR 在当前 webui(6b3b8183)上重新核了一遍。功能本身在 base 上还不存在(git grep -il mermaid origin/webui -- packages/webui scripts test release 只命中文档和一个会话标题字符串测试),所以它不过时;但结论是关掉重提,理由是它绑定的是 2026-10 的旧目录结构和已经被重构的浏览器测试基础设施,加上几处自身的缺陷。

路径全要重定位

PR 里的路径 现在的路径
components/transcript/markdown.tsx features/transcript/ui/markdown.public.tsx(改名 + 搬家 + 加 .public);插入点从旧 :259 变成 base 的 :255 与 :270 之间
components/transcript/webui-mermaid.tsx features/transcript/ui/webui-mermaid.tsx
styles/shell.css(.webui-mermaid 区块) features/transcript/ui/transcript.css:736-786(.webui-code-block 样式已经搬过去了)
test/webui-browser/server.mjs packages/webui/test/browser/server.mjs
test/webui-browser/transcript.spec.mjs packages/webui/test/browser/transcript.spec.mjs
scripts/webui-dependency-rules.mjs scripts/lib/webui-dependency-rules.mjs

三处硬门槛

base 的浏览器测试服务器不 Serve assets/。 packages/webui/test/browser/server.mjs:17-21 只 Serve index.html / client.js / styles.css。开了 splitting: true 之后 chunk 会 404,浏览器用例必红——这正是你改 test/webui-browser/server.mjs 的原因,而那个文件已经改名搬走了。

splitting: true 是全局副作用。 base 客户端已经有动态 import(features/composer/ui/WebuiComposer.tsx:153 的 await import("./slash-palette.js")),开了 splitting 它也会被切成 assets/ chunk,改动面不止 mermaid。composer 的斜杠面板要一起回归。

发布门会审 markdown.public.tsx。 好消息:import("mermaid") 是裸 specifier,不算 foreign,不会被 foreign-dynamic 拦,静态 import 同 business 私有兄弟也允许——这条路是通的。

自身的缺陷

主题完全没接。 base 有完整的明暗主题(styles/tokens.css:633-634 的 .dark 重绑语义 token、settings/projection/settings-view.ts:25 的 resolveThemePreference),而 initialize({startOnLoad:false, securityLevel:"strict"}) 没有传 theme,mermaid 用自带默认亮色主题,SVG 内联自己的配色——深色模式下图表会割裂。PR 自己的 CSS 注释也承认「The SVG itself carries Mermaid's own inline styles」。

不可信内容直接进渲染器。 dangerouslySetInnerHTML={{__html: state.svg}} 的内容是模型输出,可能被仓库内容或提示注入影响。katex 和 hljs 的输出由库自己转义,mermaid 是把用户文本渲染进 SVG,风险面不一样。securityLevel: "strict" 是 mermaid 自己的约束,不等于你这一层做了净化。

流式半截围栏会反复全量重渲染。 base 在流式期间直接对 part.text 跑 WebuiMarkdown(features/transcript/ui/AssistantBody.tsx:87, 324, 330, 338),而 WebuiMermaid 的 effect 依赖是 [text]。没有 debounce,也没有「围栏是否闭合」的判断——流式输出一张图 = 几十到上百次全量解析 + 生成 SVG。

失败回退不等价。 PR 说回退是「byte for byte 等于 Mermaid 之前的样子」,但之前 mmd 走通用 code 路径,多行会有行号 gutter(markdown.public.tsx:273 的 codeFenceGutter),而 failed 态没有。

尺寸处理不足。 .webui-mermaid svg { max-width:100%; height:auto } 加容器 overflow:hidden:宽图被压到 100% 之后文字不可读,而且没有横向滚动容器或最小宽度。

断言只覆盖 happy path

单测用 vi.mock("mermaid") 全 mock,从不跑真库;renderToStaticMarkup 不执行 effect,所以 WebuiMermaid 的 .catch() 失败分支没有任何单测覆盖(只直接测了 WebuiMermaidBlock 的 failed prop)。真库只在浏览器用例里跑了一次成功路径,失败回退在浏览器里也没测。

没覆盖的:深色主题、宽图、流式半截围栏、SVG 里含 javascript: 或 <script> 时的净化。

两处夹带

三个 commit 里第二个(composer store 适配器)与 Mermaid 无关,而 base 的 composer 架构已经大改,不该随 Mermaid 一起移植;第三个是空提交 ci: retrigger。lockfile 里还夹带了 yaml 2.9.0 → 2.9.1 的升级。

依赖和登记

packages/webui/package.json 加 "mermaid": "12.1.0"(base 的 engines.node ">=22.19 <23 || >=24.2 <27" 满足它 @mermaid-js/parser@2.0.1 的 node ≥22.12)。mermaid 不需要进 release/webui-npm/package.json 的 dependencies——客户端被 esbuild 打包了,这一点你判断得对;但 files 含 client/,splitting 之后 client/assets/ 会多出约 11MB、105 个 chunk,预览包体积会明显变大。

release/public-source.json 要登记新文件,test/vitest-suites.json 要加新单测。层级分类 scripts/lib/webui-dependency-rules.mjs:521-527 把 client/features/<business>/ui/ 判为 bindings / feature-ui 并自动匹配,比当年手工登记 provenance 省事。

@stevenjj33

Copy link
Copy Markdown
Collaborator Author

按评审意见关掉重提,内容由 #224 承担:在新目录结构下重做(features/transcript/ui/markdown.public.tsx + webui-mermaid.tsx + transcript.css,浏览器基础设施改到 packages/webui/test/browser/),补齐三处硬门槛(测试服务器 Serve assets/、两个构建脚本开 splitting 并由 composer 斜杠面板的浏览器用例作 chunk 回归、冻结路径元数据 oracle 登记新文件),并修掉自身三处缺陷:主题改走注入的 DOM adapter 读应用主题(不再用 mermaid 自带浅色调色板)、SVG 进 DOM 前过滤 script/on*/javascript:/foreignObject 且关掉 htmlLabels、浏览器用例改跑真库并覆盖失败回退/主题/宽图/半截围栏。旧 PR 的两处夹带(composer store 适配器提交、空的 ci: retrigger 提交)与 lockfile 的 yaml 升级这次都没有,lockfile 是纯新增 853 行零删除。

@stevenjj33 stevenjj33 closed this Oct 22, 2026
stevenjj33 added a commit that referenced this pull request Oct 22, 2026
Resubmission of #125 at the relocated paths, with the review's defects
addressed.

The acceptance table carried a "markdown-图表(mmd) 通过" row while the
source had zero Mermaid support: a ```mermaid fence fell through to the
plain code path, so a model-drawn flowchart arrived as an unreadable blob
of text. The fence branch sits in markdown.public.tsx between the math
and generic code branches and fires only for the mermaid/mmd language
words; every other fence keeps its untouched path.

Three defects from the review, fixed here:

- Theme. Mermaid bakes its palette into the SVG it returns, so a diagram
  drawn with the light palette is a light diagram even on a dark page.
  The renderer now draws with the app's own theme, read through the
  injected DOM adapter (readDocumentTheme) — the dependency rules
  confine the ambient document to the platform layer, so the component
  must not touch it. The library is re-initialised when the theme
  changes, so the next diagram follows a switch.

- Untrusted content. The diagram body is model output and the rendered
  SVG is injected with dangerouslySetInnerHTML, so the SVG is filtered
  before it reaches the DOM: scripts, event handlers, script-ish URLs
  and foreign documents are removed, and the drawing itself is left
  alone. Mermaid's own strict mode stays the primary control; the
  filter is the second layer. htmlLabels is off, so the diagram is pure
  SVG and the labels are escaped text rather than HTML in a
  foreignObject.

- Tests. The unit suite mocks the library (Node has no DOM for it), so
  the browser cases run the real one: a diagram renders to SVG, an
  invalid diagram falls back to the plain fence, the diagram follows
  the app's theme, a wide diagram fits its container, and a
  half-streamed fence falls back instead of breaking the transcript.
  The unit suite covers the sanitizer and the theme read.

Build: the client bundle is split, so a dynamic import is a chunk
instead of inlined source. Measured on the built artifact: the entry
goes 4,647,161 B to 4,456,586 B (shared code moves into chunks) and
mermaid's ~12 MB lands in 109 lazy chunks under assets/. The browser
test server now serves that prefix, matching the loopback service's
static resolver; the composer's slash palette — already a dynamic
import — is chunked too, and its browser case covers the regression.
stevenjj33 added a commit that referenced this pull request Oct 22, 2026
Resubmission of #125 at the relocated paths, with the review's defects
addressed.

The acceptance table carried a "markdown-图表(mmd) 通过" row while the
source had zero Mermaid support: a ```mermaid fence fell through to the
plain code path, so a model-drawn flowchart arrived as an unreadable blob
of text. The fence branch sits in markdown.public.tsx between the math
and generic code branches and fires only for the mermaid/mmd language
words; every other fence keeps its untouched path.

Three defects from the review, fixed here:

- Theme. Mermaid bakes its palette into the SVG it returns, so a diagram
  drawn with the light palette is a light diagram even on a dark page.
  The renderer now draws with the app's own theme, read through the
  injected DOM adapter (readDocumentTheme) — the dependency rules
  confine the ambient document to the platform layer, so the component
  must not touch it. The library is re-initialised when the theme
  changes, so the next diagram follows a switch.

- Untrusted content. The diagram body is model output and the rendered
  SVG is injected with dangerouslySetInnerHTML, so the SVG is filtered
  before it reaches the DOM: scripts, event handlers, script-ish URLs
  and foreign documents are removed, and the drawing itself is left
  alone. Mermaid's own strict mode stays the primary control; the
  filter is the second layer. htmlLabels is off, so the diagram is pure
  SVG and the labels are escaped text rather than HTML in a
  foreignObject.

- Tests. The unit suite mocks the library (Node has no DOM for it), so
  the browser cases run the real one: a diagram renders to SVG, an
  invalid diagram falls back to the plain fence, the diagram follows
  the app's theme, a wide diagram fits its container, and a
  half-streamed fence falls back instead of breaking the transcript.
  The unit suite covers the sanitizer and the theme read.

Build: the client bundle is split, so a dynamic import is a chunk
instead of inlined source. Measured on the built artifact: the entry
goes 4,647,161 B to 4,456,586 B (shared code moves into chunks) and
mermaid's ~12 MB lands in 109 lazy chunks under assets/. The browser
test server now serves that prefix, matching the loopback service's
static resolver; the composer's slash palette — already a dynamic
import — is chunked too, and its browser case covers the regression.
modacker added a commit that referenced this pull request Oct 22, 2026
feat(webui): transcript 渲染 Mermaid 围栏(#125 重提:新路径 + 主题 + 净化)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants