diff --git a/package.json b/package.json index 4474325..b9abcb9 100644 --- a/package.json +++ b/package.json @@ -56,8 +56,10 @@ "dist/" ], "dependencies": { + "@vscode/markdown-it-katex": "^1.1.2", "eventsource": "^4.1.0", "js-tiktoken": "^1.0.21", + "katex": "^0.17.0", "node-edge-tts": "^1.2.10", "node-pty": "^1.1.0", "socket.io": "^4.8.3", @@ -72,6 +74,7 @@ "@playwright/test": "^1.60.0", "@types/eventsource": "^1.1.15", "@types/js-yaml": "^4.0.9", + "@types/katex": "^0.16.8", "@types/koa": "^2.15.0", "@types/koa__cors": "^5.0.0", "@types/koa__router": "^12.0.5", @@ -120,4 +123,4 @@ "vue-tsc": "^3.2.8", "ws": "^8.20.0" } -} \ No newline at end of file +} diff --git a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue index 5f586ca..fcf29a2 100644 --- a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue +++ b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n' import { NDrawer, NDrawerContent, NSpin, useMessage } from 'naive-ui' import type MarkdownIt from 'markdown-it' import MarkdownItConstructor from 'markdown-it' +import markdownItKatex from '@vscode/markdown-it-katex' import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight' import { repairNestedMarkdownFences } from './markdownFenceRepair' 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) md.renderer.rules.fence = (tokens, idx, options, env, self) => { diff --git a/packages/client/src/main.ts b/packages/client/src/main.ts index 2a10cc1..609c3e0 100644 --- a/packages/client/src/main.ts +++ b/packages/client/src/main.ts @@ -4,6 +4,7 @@ import router from './router' import { i18n } from './i18n' import App from './App.vue' import './styles/global.scss' +import 'katex/dist/katex.min.css' // Apply theme classes before mount to prevent FOUC (Flash of Unstyled Content) const savedBrightness = localStorage.getItem('hermes_brightness') || 'system' diff --git a/tests/client/markdown-rendering.test.ts b/tests/client/markdown-rendering.test.ts index 224a04e..ef4f595 100644 --- a/tests/client/markdown-rendering.test.ts +++ b/tests/client/markdown-rendering.test.ts @@ -584,6 +584,78 @@ describe('MarkdownRenderer', () => { 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 () => { const writeText = vi.mocked(navigator.clipboard.writeText) const wrapper = mount(MarkdownRenderer, {