diff --git a/package.json b/package.json
index 5d32458..2661077 100644
--- a/package.json
+++ b/package.json
@@ -98,6 +98,7 @@
"koa-send": "^5.0.1",
"koa-static": "^5.0.0",
"markdown-it": "^14.1.1",
+ "mermaid": "^11.14.0",
"monaco-editor": "^0.55.1",
"naive-ui": "^2.44.1",
"nodemon": "^3.1.14",
diff --git a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue
index 1131d5b..57e9e5b 100644
--- a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue
+++ b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue
@@ -1,10 +1,19 @@
-
+
diff --git a/packages/client/src/components/hermes/chat/mermaidRenderer.ts b/packages/client/src/components/hermes/chat/mermaidRenderer.ts
new file mode 100644
index 0000000..cb3b7b9
--- /dev/null
+++ b/packages/client/src/components/hermes/chat/mermaidRenderer.ts
@@ -0,0 +1,45 @@
+const MERMAID_LANGUAGE = 'mermaid'
+
+export const MERMAID_MAX_DIAGRAMS_PER_MESSAGE = 4
+export const MERMAID_MAX_SOURCE_LENGTH = 20_000
+export const MERMAID_RENDER_TIMEOUT_MS = 5_000
+
+function escapeHtml(value: string): string {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+}
+
+export function getFenceLanguage(info: string | undefined): string {
+ return info?.trim().split(/\s+/)[0]?.toLowerCase() || ''
+}
+
+export function isMermaidFence(info: string | undefined): boolean {
+ return getFenceLanguage(info) === MERMAID_LANGUAGE
+}
+
+export function encodeMermaidSource(source: string): string {
+ return encodeURIComponent(source)
+}
+
+export function decodeMermaidSource(encoded: string | null | undefined): string {
+ if (!encoded) return ''
+
+ try {
+ return decodeURIComponent(encoded)
+ } catch {
+ return ''
+ }
+}
+
+export function renderMermaidPlaceholder(source: string): string {
+ return [
+ '
`,
+ '
Rendering Mermaid diagram…
',
+ '
',
+ ].join('')
+}
diff --git a/tests/client/markdown-rendering-mermaid-import-timeout.test.ts b/tests/client/markdown-rendering-mermaid-import-timeout.test.ts
new file mode 100644
index 0000000..026f531
--- /dev/null
+++ b/tests/client/markdown-rendering-mermaid-import-timeout.test.ts
@@ -0,0 +1,57 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+
+vi.mock('mermaid', () => new Promise(() => {}))
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('naive-ui', () => ({
+ useMessage: () => ({
+ error: vi.fn(),
+ success: vi.fn(),
+ warning: vi.fn(),
+ info: vi.fn(),
+ }),
+}))
+
+import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
+
+async function flushMermaidRender(): Promise {
+ for (let i = 0; i < 16; i += 1) {
+ await nextTick()
+ await Promise.resolve()
+ }
+}
+
+describe('MarkdownRenderer Mermaid import timeout', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('falls back to copyable code when the mermaid dynamic import never settles', async () => {
+ vi.useFakeTimers()
+
+ const wrapper = mount(MarkdownRenderer, {
+ props: {
+ content: '```mermaid\nflowchart TD\nA --> B\n```',
+ },
+ })
+
+ await nextTick()
+ await Promise.resolve()
+ await vi.advanceTimersByTimeAsync(5_001)
+ await flushMermaidRender()
+
+ expect(wrapper.find('.mermaid-loading').exists()).toBe(false)
+ expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(false)
+ expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
+ expect(wrapper.find('.code-lang').text()).toBe('mermaid')
+ expect(wrapper.find('code.hljs').text()).toContain('flowchart TD')
+ })
+})
diff --git a/tests/client/markdown-rendering.test.ts b/tests/client/markdown-rendering.test.ts
index 34350c8..1f4962b 100644
--- a/tests/client/markdown-rendering.test.ts
+++ b/tests/client/markdown-rendering.test.ts
@@ -1,6 +1,25 @@
// @vitest-environment jsdom
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+
+const mermaidMock = vi.hoisted(() => ({
+ initialize: vi.fn(),
+ render: vi.fn(async (id: string, source: string) => ({
+ svg: ``,
+ })),
+}))
+
+vi.mock('mermaid', () => ({
+ default: mermaidMock,
+}))
+
+async function flushMermaidRender(): Promise {
+ for (let i = 0; i < 16; i += 1) {
+ await nextTick()
+ await Promise.resolve()
+ }
+}
vi.mock('vue-i18n', () => ({
useI18n: () => ({
@@ -20,7 +39,17 @@ vi.mock('naive-ui', () => ({
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
describe('MarkdownRenderer', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
beforeEach(() => {
+ mermaidMock.initialize.mockClear()
+ mermaidMock.render.mockClear()
+ mermaidMock.render.mockImplementation(async (id: string, source: string) => ({
+ svg: ``,
+ }))
+
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
@@ -235,6 +264,195 @@ describe('MarkdownRenderer', () => {
expect(wrapper.find('.markdown-body').text()).toContain('Done outside.')
})
+ it('renders mermaid fences as diagrams instead of raw highlighted code', async () => {
+ const wrapper = mount(MarkdownRenderer, {
+ props: {
+ content: [
+ '```mermaid',
+ 'flowchart TD',
+ 'A[User] --> B[Web UI
command]',
+ '```',
+ '',
+ '具体 behavior:',
+ '- Markdown below still renders.',
+ ].join('\n'),
+ },
+ })
+
+ await flushMermaidRender()
+
+ expect(mermaidMock.initialize).toHaveBeenCalledWith(expect.objectContaining({
+ startOnLoad: false,
+ securityLevel: 'strict',
+ }))
+ expect(mermaidMock.render).toHaveBeenCalledWith(
+ expect.stringMatching(/^hermes-mermaid-/),
+ expect.stringContaining('flowchart TD'),
+ )
+ expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(true)
+ expect(wrapper.findAll('.hljs-code-block')).toHaveLength(0)
+ expect(wrapper.find('.markdown-body').find('ul').exists()).toBe(true)
+ })
+
+ it('renders mermaid inside repaired outer markdown draft fences', async () => {
+ const wrapper = mount(MarkdownRenderer, {
+ props: {
+ content: [
+ '```md',
+ '## Command flow',
+ '',
+ '```Mermaid title',
+ 'flowchart LR',
+ 'A --> B',
+ '```',
+ '',
+ 'Done outside.',
+ '```',
+ ].join('\n'),
+ },
+ })
+
+ await flushMermaidRender()
+
+ expect(wrapper.find('.markdown-body').find('h2').text()).toBe('Command flow')
+ expect(mermaidMock.render).toHaveBeenCalledWith(
+ expect.stringMatching(/^hermes-mermaid-/),
+ expect.stringContaining('flowchart LR'),
+ )
+ expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(true)
+ expect(wrapper.find('.markdown-body').text()).toContain('Done outside.')
+ })
+
+ it('falls back to a copyable code block when mermaid rendering fails', async () => {
+ mermaidMock.render.mockImplementationOnce((id: string) => {
+ const errorContainer = document.createElement('div')
+ errorContainer.id = `d${id}`
+ errorContainer.textContent = 'Syntax error in text\nmermaid version 11.14.0'
+ document.body.appendChild(errorContainer)
+ return Promise.reject(new Error('bad diagram'))
+ })
+ const wrapper = mount(MarkdownRenderer, {
+ props: {
+ content: '```mermaid\nnot valid mermaid\n```',
+ },
+ })
+
+ await flushMermaidRender()
+
+ expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(false)
+ expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
+ expect(wrapper.find('.code-lang').text()).toBe('mermaid')
+ expect(wrapper.find('code.hljs').text()).toContain('not valid mermaid')
+ expect(wrapper.find('[data-copy-code="true"]').exists()).toBe(true)
+ expect(document.body.textContent).not.toContain('Syntax error in text')
+ })
+
+ it('falls back to copyable code blocks when mermaid initialization fails', async () => {
+ mermaidMock.initialize.mockImplementationOnce(() => {
+ throw new Error('init failed')
+ })
+
+ const wrapper = mount(MarkdownRenderer, {
+ props: {
+ content: '```mermaid\nflowchart TD\nA --> B\n```',
+ },
+ })
+
+ await flushMermaidRender()
+
+ expect(mermaidMock.render).not.toHaveBeenCalled()
+ expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
+ expect(wrapper.find('.code-lang').text()).toBe('mermaid')
+ expect(wrapper.find('code.hljs').text()).toContain('flowchart TD')
+ })
+
+ it('falls back without initializing mermaid when every pending diagram is oversized', async () => {
+ const wrapper = mount(MarkdownRenderer, {
+ props: {
+ content: `\`\`\`mermaid\n${'A'.repeat(20_001)}\n\`\`\``,
+ },
+ })
+
+ await flushMermaidRender()
+
+ expect(mermaidMock.initialize).not.toHaveBeenCalled()
+ expect(mermaidMock.render).not.toHaveBeenCalled()
+ expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
+ expect(wrapper.find('.code-lang').text()).toBe('mermaid')
+ })
+
+ it('falls back without initializing mermaid when every pending diagram is empty', async () => {
+ const wrapper = mount(MarkdownRenderer, {
+ props: {
+ content: '```mermaid\n```',
+ },
+ })
+
+ await flushMermaidRender()
+
+ expect(mermaidMock.initialize).not.toHaveBeenCalled()
+ expect(mermaidMock.render).not.toHaveBeenCalled()
+ expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
+ expect(wrapper.find('.code-lang').text()).toBe('mermaid')
+ })
+
+ it('falls back to copyable code when mermaid rendering never settles', async () => {
+ vi.useFakeTimers()
+ mermaidMock.render.mockImplementationOnce(() => new Promise(() => {}))
+
+ const wrapper = mount(MarkdownRenderer, {
+ props: {
+ content: '```mermaid\nflowchart TD\nA --> B\n```',
+ },
+ })
+
+ await nextTick()
+ await Promise.resolve()
+ await vi.advanceTimersByTimeAsync(5_001)
+ await flushMermaidRender()
+
+ expect(wrapper.find('.mermaid-loading').exists()).toBe(false)
+ expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(false)
+ expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
+ expect(wrapper.find('.code-lang').text()).toBe('mermaid')
+ expect(wrapper.find('code.hljs').text()).toContain('flowchart TD')
+ })
+
+ it('does not load or render mermaid when the message has no mermaid block', async () => {
+ const wrapper = mount(MarkdownRenderer, {
+ props: {
+ content: '```ts\nconst answer = 42\n```',
+ },
+ })
+
+ await flushMermaidRender()
+
+ expect(mermaidMock.initialize).not.toHaveBeenCalled()
+ expect(mermaidMock.render).not.toHaveBeenCalled()
+ expect(wrapper.find('.code-lang').text()).toBe('ts')
+ })
+
+ it('does not let stale async mermaid renders mutate newer message content', async () => {
+ let resolveRender: ((value: { svg: string }) => void) | undefined
+ mermaidMock.render.mockImplementationOnce((id: string) => new Promise(resolve => {
+ resolveRender = resolve
+ }))
+
+ const wrapper = mount(MarkdownRenderer, {
+ props: {
+ content: '```mermaid\nflowchart TD\nA --> B\n```',
+ },
+ })
+
+ await nextTick()
+ await wrapper.setProps({ content: 'No diagram now.' })
+ resolveRender?.({ svg: '' })
+ await flushMermaidRender()
+
+ expect(wrapper.find('[data-testid="stale-mermaid-svg"]').exists()).toBe(false)
+ expect(wrapper.find('.markdown-body').text()).toContain('No diagram now.')
+ })
+
it('copies code through the delegated click handler', async () => {
const writeText = vi.mocked(navigator.clipboard.writeText)
const wrapper = mount(MarkdownRenderer, {