[verified] feat: render latex in chat markdown
This commit is contained in:
+4
-1
@@ -56,8 +56,10 @@
|
|||||||
"dist/"
|
"dist/"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@vscode/markdown-it-katex": "^1.1.2",
|
||||||
"eventsource": "^4.1.0",
|
"eventsource": "^4.1.0",
|
||||||
"js-tiktoken": "^1.0.21",
|
"js-tiktoken": "^1.0.21",
|
||||||
|
"katex": "^0.17.0",
|
||||||
"node-edge-tts": "^1.2.10",
|
"node-edge-tts": "^1.2.10",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
@@ -72,6 +74,7 @@
|
|||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"@types/eventsource": "^1.1.15",
|
"@types/eventsource": "^1.1.15",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"@types/katex": "^0.16.8",
|
||||||
"@types/koa": "^2.15.0",
|
"@types/koa": "^2.15.0",
|
||||||
"@types/koa__cors": "^5.0.0",
|
"@types/koa__cors": "^5.0.0",
|
||||||
"@types/koa__router": "^12.0.5",
|
"@types/koa__router": "^12.0.5",
|
||||||
@@ -120,4 +123,4 @@
|
|||||||
"vue-tsc": "^3.2.8",
|
"vue-tsc": "^3.2.8",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { NDrawer, NDrawerContent, NSpin, useMessage } from 'naive-ui'
|
import { NDrawer, NDrawerContent, NSpin, useMessage } from 'naive-ui'
|
||||||
import type MarkdownIt from 'markdown-it'
|
import type MarkdownIt from 'markdown-it'
|
||||||
import MarkdownItConstructor from 'markdown-it'
|
import MarkdownItConstructor from 'markdown-it'
|
||||||
|
import markdownItKatex from '@vscode/markdown-it-katex'
|
||||||
import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight'
|
import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight'
|
||||||
import { repairNestedMarkdownFences } from './markdownFenceRepair'
|
import { repairNestedMarkdownFences } from './markdownFenceRepair'
|
||||||
import {
|
import {
|
||||||
@@ -41,6 +42,10 @@ const md: MarkdownIt = new MarkdownItConstructor({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
md.use(markdownItKatex, {
|
||||||
|
throwOnError: false,
|
||||||
|
})
|
||||||
|
|
||||||
const defaultFenceRenderer = md.renderer.rules.fence?.bind(md.renderer.rules)
|
const defaultFenceRenderer = md.renderer.rules.fence?.bind(md.renderer.rules)
|
||||||
|
|
||||||
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import router from './router'
|
|||||||
import { i18n } from './i18n'
|
import { i18n } from './i18n'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import './styles/global.scss'
|
import './styles/global.scss'
|
||||||
|
import 'katex/dist/katex.min.css'
|
||||||
|
|
||||||
// Apply theme classes before mount to prevent FOUC (Flash of Unstyled Content)
|
// Apply theme classes before mount to prevent FOUC (Flash of Unstyled Content)
|
||||||
const savedBrightness = localStorage.getItem('hermes_brightness') || 'system'
|
const savedBrightness = localStorage.getItem('hermes_brightness') || 'system'
|
||||||
|
|||||||
@@ -584,6 +584,78 @@ describe('MarkdownRenderer', () => {
|
|||||||
expect(wrapper.find('.markdown-body').text()).toContain('No diagram now.')
|
expect(wrapper.find('.markdown-body').text()).toContain('No diagram now.')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders inline latex math with katex', () => {
|
||||||
|
const wrapper = mount(MarkdownRenderer, {
|
||||||
|
props: {
|
||||||
|
content: 'Pythagoras: $x^2 + y^2 = z^2$.',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = wrapper.find('.markdown-body')
|
||||||
|
expect(body.find('.katex').exists()).toBe(true)
|
||||||
|
expect(body.html()).toContain('x')
|
||||||
|
expect(body.html()).toContain('z')
|
||||||
|
expect(body.text()).not.toContain('$x^2 + y^2 = z^2$')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders display latex math with katex', () => {
|
||||||
|
const wrapper = mount(MarkdownRenderer, {
|
||||||
|
props: {
|
||||||
|
content: '$$\n\\int_0^1 x^2 dx = \\frac{1}{3}\n$$',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = wrapper.find('.markdown-body')
|
||||||
|
expect(body.find('.katex-display').exists()).toBe(true)
|
||||||
|
expect(body.find('.katex').exists()).toBe(true)
|
||||||
|
expect(body.text()).not.toContain('$$')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render latex inside fenced code blocks', () => {
|
||||||
|
const wrapper = mount(MarkdownRenderer, {
|
||||||
|
props: {
|
||||||
|
content: '```ts\nconst formula = "$x^2 + y^2 = z^2$"\n```',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.markdown-body').find('.katex').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('code.hljs').text()).toContain('$x^2 + y^2 = z^2$')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not treat currency-like dollar text as latex math', () => {
|
||||||
|
const wrapper = mount(MarkdownRenderer, {
|
||||||
|
props: {
|
||||||
|
content: 'Price is $5 and $6 today.',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = wrapper.find('.markdown-body')
|
||||||
|
expect(body.find('.katex').exists()).toBe(false)
|
||||||
|
expect(body.text()).toContain('Price is $5 and $6 today.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render escaped dollar-delimited text as latex math', () => {
|
||||||
|
const wrapper = mount(MarkdownRenderer, {
|
||||||
|
props: {
|
||||||
|
content: 'Escaped: \\$x^2$',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = wrapper.find('.markdown-body')
|
||||||
|
expect(body.find('.katex').exists()).toBe(false)
|
||||||
|
expect(body.text()).toContain('Escaped: $x^2$')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps rendering when latex syntax is invalid', () => {
|
||||||
|
const wrapper = mount(MarkdownRenderer, {
|
||||||
|
props: {
|
||||||
|
content: 'Before $\\notacommand{ after',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.markdown-body').text()).toContain('Before')
|
||||||
|
})
|
||||||
|
|
||||||
it('copies code through the delegated click handler', async () => {
|
it('copies code through the delegated click handler', async () => {
|
||||||
const writeText = vi.mocked(navigator.clipboard.writeText)
|
const writeText = vi.mocked(navigator.clipboard.writeText)
|
||||||
const wrapper = mount(MarkdownRenderer, {
|
const wrapper = mount(MarkdownRenderer, {
|
||||||
|
|||||||
Reference in New Issue
Block a user