feat: 增加katex 公式渲染

This commit is contained in:
ViperEkura 2026-03-24 16:13:33 +08:00
parent 8af4fb1c27
commit 46c8f85e9b
7 changed files with 122 additions and 29 deletions

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Chat</title> <title>Chat</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head> </head>
<body> <body>

View File

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"highlight.js": "^11.10.0", "highlight.js": "^11.10.0",
"katex": "^0.16.40",
"marked": "^15.0.0", "marked": "^15.0.0",
"vue": "^3.4.0" "vue": "^3.4.0"
}, },
@ -990,6 +991,15 @@
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@ -1111,6 +1121,22 @@
"js-yaml": "bin/js-yaml.js" "js-yaml": "bin/js-yaml.js"
} }
}, },
"node_modules/katex": {
"version": "0.16.40",
"resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.40.tgz",
"integrity": "sha512-1DJcK/L05k1Y9Gf7wMcyuqFOL6BiY3vY0CFcAM/LPRN04NALxcl6u7lOWNsp3f/bCHWxigzQl6FbR95XJ4R84Q==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",

View File

@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"highlight.js": "^11.10.0", "highlight.js": "^11.10.0",
"katex": "^0.16.40",
"marked": "^15.0.0", "marked": "^15.0.0",
"vue": "^3.4.0" "vue": "^3.4.0"
}, },

View File

@ -68,19 +68,7 @@
import { ref, computed, watch, nextTick } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import MessageBubble from './MessageBubble.vue' import MessageBubble from './MessageBubble.vue'
import MessageInput from './MessageInput.vue' import MessageInput from './MessageInput.vue'
import { marked } from 'marked' import { renderMarkdown } from '../utils/markdown'
import hljs from 'highlight.js'
marked.setOptions({
highlight(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
return hljs.highlightAuto(code).value
},
breaks: true,
gfm: true,
})
const props = defineProps({ const props = defineProps({
conversation: { type: Object, default: null }, conversation: { type: Object, default: null },
@ -99,7 +87,7 @@ const inputRef = ref(null)
const renderedStreamContent = computed(() => { const renderedStreamContent = computed(() => {
if (!props.streamingContent) return '' if (!props.streamingContent) return ''
return marked.parse(props.streamingContent) return renderMarkdown(props.streamingContent)
}) })
function scrollToBottom(smooth = true) { function scrollToBottom(smooth = true) {
@ -375,4 +363,13 @@ defineExpose({ scrollToBottom })
.streaming-content :deep(.placeholder) { .streaming-content :deep(.placeholder) {
color: #94a3b8; color: #94a3b8;
} }
.streaming-content :deep(.math-block),
.message-content :deep(.math-block) {
display: block;
text-align: center;
padding: 12px 0;
margin: 8px 0;
overflow-x: auto;
}
</style> </style>

View File

@ -38,8 +38,7 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { marked } from 'marked' import { renderMarkdown } from '../utils/markdown'
import hljs from 'highlight.js'
const props = defineProps({ const props = defineProps({
role: { type: String, required: true }, role: { type: String, required: true },
@ -54,20 +53,9 @@ defineEmits(['delete'])
const showThinking = ref(false) const showThinking = ref(false)
marked.setOptions({
highlight(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
return hljs.highlightAuto(code).value
},
breaks: true,
gfm: true,
})
const renderedContent = computed(() => { const renderedContent = computed(() => {
if (!props.content) return '' if (!props.content) return ''
return marked.parse(props.content) return renderMarkdown(props.content)
}) })
function formatTime(iso) { function formatTime(iso) {
@ -287,4 +275,12 @@ function copyContent() {
color: #ef4444; color: #ef4444;
background: rgba(239, 68, 68, 0.08); background: rgba(239, 68, 68, 0.08);
} }
.message-content :deep(.math-block) {
display: block;
text-align: center;
padding: 12px 0;
margin: 8px 0;
overflow-x: auto;
}
</style> </style>

View File

@ -1,5 +1,6 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import './styles/highlight.css' import './styles/highlight.css'
import 'katex/dist/katex.min.css'
createApp(App).mount('#app') createApp(App).mount('#app')

View File

@ -0,0 +1,72 @@
import { marked } from 'marked'
import katex from 'katex'
import hljs from 'highlight.js'
function renderMath(text, displayMode) {
try {
return katex.renderToString(text, {
displayMode,
throwOnError: false,
strict: false,
})
} catch {
return text
}
}
// marked extension for inline math $...$
const mathExtension = {
name: 'math',
level: 'inline',
start(src) {
// Find $ not followed by $ (to avoid matching $$)
const idx = src.search(/(?<!\$)\$(?!\$)/)
return idx === -1 ? undefined : idx
},
tokenizer(src) {
// Match $...$ (single $, not $$)
const match = src.match(/^(?<!\$)\$(?!\$)([^\$\n]+?)\$(?!\$)/)
if (match) {
return { type: 'math', raw: match[0], text: match[1].trim(), displayMode: false }
}
},
renderer(token) {
return renderMath(token.text, token.displayMode)
},
}
// marked extension for block math $$...$$
const blockMathExtension = {
name: 'blockMath',
level: 'block',
start(src) {
const idx = src.indexOf('$$')
return idx === -1 ? undefined : idx
},
tokenizer(src) {
const match = src.match(/^\$\$([\s\S]+?)\$\$\n?/)
if (match) {
return { type: 'blockMath', raw: match[0], text: match[1].trim() }
}
},
renderer(token) {
return `<div class="math-block">${renderMath(token.text, true)}</div>`
},
}
marked.use({ extensions: [blockMathExtension, mathExtension] })
marked.setOptions({
highlight(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
return hljs.highlightAuto(code).value
},
breaks: true,
gfm: true,
})
export function renderMarkdown(text) {
return marked.parse(text)
}