Skip to content

Commit 44fce0d

Browse files
authored
📊 feat: support mermaid render (#43)
1 parent 3972986 commit 44fce0d

File tree

7 files changed

+1056
-9
lines changed

7 files changed

+1056
-9
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
[![thanks](https://badgen.net/badge/thanks/♥/pink)](https://github.com/pdsuwwz)
66
[![License](https://img.shields.io/github/license/pdsuwwz/chatgpt-vue3-light-mvp?color=466fe8)](https://github.com/pdsuwwz/chatgpt-vue3-light-mvp/blob/main/LICENSE)
77

8-
💭 一个可二次开发 Chat Bot 对话 Web 端原型模板, 基于 Vue3、Vite 6、TypeScript、Naive UI、Pinia、UnoCSS 等主流技术构建, 🧤简单集成大模型 API, 采用单轮 AI 问答对话模式, 每次提问独立响应, 无需上下文, 支持打字机效果流式输出, 集成 markdown-it, highlight.js, 数学公式语法高亮预览, 💼 易于定制和快速搭建 Chat 类大语言模型产品
8+
💭 一个可二次开发 Chat Bot 对话 Web 端原型模板, 基于 Vue3、Vite 6、TypeScript、Naive UI、Pinia、UnoCSS 等主流技术构建, 🧤简单集成大模型 API, 采用单轮 AI 问答对话模式, 每次提问独立响应, 无需上下文, 支持打字机效果流式输出, 集成 markdown-it, highlight.js, 数学公式, Mermaid 图表语法高亮预览, 💼 易于定制和快速搭建 Chat 类大语言模型产品
99

1010

1111
__[🌈 Live Demo 在线体验](https://pdsuwwz.github.io/chatgpt-vue3-light-mvp)__
@@ -23,9 +23,10 @@ __[🌈 Live Demo 在线体验](https://pdsuwwz.github.io/chatgpt-vue3-light-mvp
2323
* 🌟 **图标支持**:内置 **UnoCSS + Iconify**,实现原子化样式内联和图标按需自动导入
2424
* 💬 **AI 对话**:支持单轮对话,用户输入即得 AI 独立响应回复,无需上下文
2525
* 📝 **Markdown 预览**:支持多种编程语言代码高亮,集成 `markdown-it``highlight.js`
26+
* 📊 **可视化支持**:内置 `Mermaid` 解析,轻松绘制流程图和时序图等;支持 KaTex/LaTeX 数学公式渲染,助力技术文档编写
2627
* 🧪 **模拟开发模式**:提供本地模拟开发模式,无需真实 API 即可开始开发
2728
* 🔑 **环境变量管理**:通过 `.env` 文件管理 API 密钥,支持不同大模型的配置
28-
* 🌍 **大语言模型 API**:兼容 Spark 星火认知大模型、Kimi Moonshot 月之暗面大模型、SiliconFlow、Ollama 等,允许自由扩展
29+
* 🌍 **大语言模型 API**:兼容 Deepseek V3/R1, Spark 星火认知大模型、Kimi Moonshot 月之暗面大模型、SiliconFlow、Ollama 等,允许自由扩展
2930
* 🚀 **灵活扩展**:轻量级模块化 MVP 设计,纯前端开发,项目结构清晰,快速搭建 AI 对话原型
3031

3132
### 🧠 已支持的模型

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"js-cookie": "^3.0.5",
3434
"lodash-es": "^4.17.21",
3535
"marked": "^15.0.7",
36+
"mermaid": "^11.5.0",
3637
"naive-ui": "^2.41.0",
3738
"nprogress": "^0.2.0",
3839
"pinia": "^3.0.0",
@@ -64,6 +65,7 @@
6465
"@vue/babel-plugin-jsx": "^1.2.5",
6566
"@vue/compiler-sfc": "^3.5.13",
6667
"cross-env": "^7.0.3",
68+
"crypto-js": "^4.2.0",
6769
"eslint": "^9.20.1",
6870
"eslint-define-config": "^2.1.0",
6971
"eslint-plugin-html": "8.1.2",

pnpm-lock.yaml

Lines changed: 840 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/MarkdownPreview/index.vue

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="tsx" setup>
2-
import { renderMarkdownText } from './plugins/markdown'
2+
import { renderMarkdownText, renderMermaidProcess } from './plugins/markdown'
33
44
import type { CrossTransformFunction, TransformFunction } from './models'
55
import { defaultMockModelName } from './models'
@@ -42,7 +42,7 @@ const renderedMarkdown = computed(() => {
4242
return renderMarkdownText(displayText.value)
4343
})
4444
45-
// 接口响应是否正则排队等待
45+
// 接口响应是否正在排队等待
4646
const waitingForQueue = ref(false)
4747
4848
const WaitTextRender = defineComponent({
@@ -241,11 +241,13 @@ const showText = () => {
241241
// 若 reader 还没结束,则保持打字行为
242242
if (!readIsOver.value) {
243243
runReadBuffer()
244+
renderMermaidProcess()
244245
typingAnimationFrame = requestAnimationFrame(showText)
245246
} else {
246247
// 读取剩余的 buffer
247248
runReadBuffer(
248249
() => {
250+
renderMermaidProcess()
249251
typingAnimationFrame = requestAnimationFrame(showText)
250252
},
251253
() => {
@@ -566,5 +568,11 @@ const emptyPlaceholder = computed(() => {
566568
--at-apply: line-height-26;
567569
}
568570
}
571+
572+
.mermaid {
573+
contain: layout;
574+
transform: translateZ(0);
575+
}
576+
569577
}
570578
</style>

src/components/MarkdownPreview/plugins/markdown.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import splitAtDelimiters from 'katex/contrib/auto-render/splitAtDelimiters'
99
import 'katex/dist/katex.min.css'
1010
import 'katex/dist/contrib/mhchem.min.js'
1111

12+
import { mermaidPlugin, processMermaid } from './mermaid'
13+
1214
const md = new MarkdownIt({
1315
html: true,
1416
linkify: true,
@@ -17,10 +19,20 @@ const md = new MarkdownIt({
1719

1820
md.use(markdownItHighlight, {
1921
hljs
20-
}).use(preWrapperPlugin, {
21-
hasSingleTheme: true
22-
}).use(markdownItKatex)
23-
22+
})
23+
.use(preWrapperPlugin, {
24+
hasSingleTheme: true
25+
})
26+
.use(markdownItKatex)
27+
.use(mermaidPlugin)
28+
29+
30+
const transformMermaid = (content: string): string => {
31+
const mermaidBlockRegex = /^```mermaid\n([\s\S]*?)\n```$/gm
32+
return content.replace(mermaidBlockRegex, (match) => {
33+
return match
34+
})
35+
}
2436

2537
const transformMathMarkdown = (markdownText: string) => {
2638
const data = splitAtDelimiters(markdownText, [
@@ -104,6 +116,15 @@ const transformThinkMarkdown = (source: string): string => {
104116
export const renderMarkdownText = (content: string) => {
105117
const thinkTransformed = transformThinkMarkdown(content)
106118
const mathTransformed = transformMathMarkdown(thinkTransformed)
107-
return md.render(mathTransformed)
119+
const mermaidTransformed = transformMermaid(mathTransformed)
120+
return md.render(mermaidTransformed)
108121
}
109122

123+
const debounceRenderMermaid = _.debounce(() => {
124+
processMermaid.fn()
125+
}, 10)
126+
127+
// 触发 Mermaid 渲染
128+
export const renderMermaidProcess = () => {
129+
debounceRenderMermaid()
130+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import mermaid from 'mermaid'
2+
import CryptoJs from 'crypto-js'
3+
4+
export const processMermaid = {
5+
fn: () => {}
6+
}
7+
8+
const computeHash = (str) => {
9+
return CryptoJs.SHA256(str).toString(CryptoJs.enc.Hex)
10+
}
11+
12+
const verifyMermaid = (content: string) => {
13+
return new Promise<{ isValid: boolean; }>((resolve) => {
14+
mermaid.parse(content)
15+
.then(() => {
16+
resolve({
17+
isValid: true
18+
})
19+
}).catch((err) => {
20+
resolve({
21+
isValid: false
22+
})
23+
})
24+
})
25+
}
26+
27+
export const mermaidPlugin = (md, options = {}) => {
28+
// 缓存渲染结果
29+
const cache = new Map()
30+
// 记录正在渲染中的图表
31+
const pendingQueue = new Map()
32+
33+
const defaultFence = md.renderer.rules.fence
34+
md.renderer.rules.fence = (tokens, idx, opts, env, self) => {
35+
const token = tokens[idx]
36+
37+
if (token.info.trim() !== 'mermaid') {
38+
return defaultFence(tokens, idx, opts, env, self)
39+
}
40+
41+
42+
const content = token.content
43+
const hash = computeHash(content)
44+
const encodedContent = encodeURIComponent(content)
45+
46+
47+
if (cache.has(hash)) {
48+
return `<div data-mermaid-hash="${ hash }">${ cache.get(hash) }</div>`
49+
}
50+
51+
return `
52+
<div data-mermaid-hash="${ hash }"
53+
data-mermaid-content="${ encodedContent }"
54+
data-mermaid-status="pending">
55+
<pre>${ md.utils.escapeHtml(content) }</pre>
56+
</div>
57+
`
58+
}
59+
60+
// 后处理渲染 mermaid
61+
const renderMermaid = async (container) => {
62+
const encodedContent = container.dataset.mermaidContent
63+
const content = decodeURIComponent(encodedContent)
64+
const hash = container.dataset.mermaidHash
65+
66+
67+
// 如果已经在渲染队列中,则直接返回
68+
if (pendingQueue.has(hash)) return
69+
70+
try {
71+
pendingQueue.set(hash, true)
72+
let svg
73+
74+
// 检查缓存
75+
if (cache.has(hash)) {
76+
svg = cache.get(hash)
77+
} else {
78+
const { isValid } = await verifyMermaid(content)
79+
80+
if (!isValid) {
81+
return
82+
}
83+
84+
// 使用唯一 ID 渲染(避免图表冲突)
85+
const { svg: renderedSvg } = await mermaid.render(`mermaid-${ hash }`, content)
86+
svg = renderedSvg
87+
cache.set(hash, svg)
88+
}
89+
90+
const fragment = document.createDocumentFragment()
91+
const wrapper = document.createElement('div')
92+
wrapper.innerHTML = svg
93+
fragment.appendChild(wrapper)
94+
95+
container.replaceWith(fragment)
96+
} catch (err) {
97+
// console.error('Mermaid 渲染失败:', err)
98+
container.dataset.mermaidStatus = 'error'
99+
} finally {
100+
pendingQueue.delete(hash)
101+
}
102+
}
103+
104+
// 全局渲染控制器
105+
const processContainers = () => {
106+
const containers = document.querySelectorAll(`
107+
div[data-mermaid-status="pending"],
108+
div[data-mermaid-hash]:not([data-mermaid-status])
109+
`) as NodeListOf<HTMLElement>
110+
111+
112+
containers.forEach(container => {
113+
if (!document.body.contains(container)) {
114+
return
115+
}
116+
117+
if (container.dataset.mermaidStatus !== 'pending') {
118+
container.dataset.mermaidStatus = 'pending'
119+
}
120+
renderMermaid(container)
121+
})
122+
}
123+
124+
// 初始化 Mermaid
125+
mermaid.initialize({
126+
startOnLoad: false,
127+
securityLevel: 'antiscript',
128+
markdownAutoWrap: true,
129+
suppressErrorRendering: true,
130+
...options
131+
})
132+
133+
// 触发 Mermaid 图表渲染 export
134+
processMermaid.fn = () => {
135+
requestAnimationFrame(processContainers)
136+
}
137+
}
138+

src/data/mock-md.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,43 @@ button:hover {
100100
- **实际应用**: 可以根据项目需求,对该组件进行扩展和修改,应用到实际项目中。
101101
102102
103+
## 公式示例
104+
105+
* 纳维-斯托克斯方程
106+
107+
$$
108+
\rho\left(\frac{\partial \vec{u}}{\partial t} + \vec{u} \cdot \nabla\vec{u}\right) = -\nabla p + \nabla \cdot \left[\mu\left(\nabla\vec{u} + (\nabla\vec{u})^T\right)\right] + \vec{f}
109+
$$
110+
111+
* 薛定谔波动方程
112+
113+
$$
114+
i\hbar\frac{\partial \psi(\vec{r},t)}{\partial t} = \left[-\frac{\hbar^2}{2m}\nabla^2 + V(\vec{r},t)\right]\psi(\vec{r},t)
115+
$$
116+
117+
* 薛定谔化学键积分方程
118+
119+
$$
120+
H\Psi = E\Psi, \quad H = -\frac{\hbar^2}{2m}\sum_{i}\nabla_i^2 - \sum_{i,I}\frac{Z_I}{r_{iI}} + \sum_{i<j}\frac{1}{r_{ij}}
121+
$$
122+
123+
* DNA 蛋白质转录翻译综合模型
124+
125+
$$
126+
\frac{d[mRNA]}{dt} = k_s \cdot \frac{[DNA]_{active}}{K_M + [DNA]_{active}} - k_d \cdot [mRNA]
127+
$$
128+
129+
130+
## Mermaid 示例
131+
132+
```mermaid
133+
graph LR
134+
A[方形矩形] -- 连接文本 --> B((圆形))
135+
A --> C(圆角矩形)
136+
B --> D{菱形}
137+
C --> D
138+
```
139+
103140
**如果您有其他问题或需要进一步的帮助,请随时告知!**
104141
105142
## 🌹 说明

0 commit comments

Comments
 (0)