diff --git a/packages/ui/package.json b/packages/ui/package.json
index a2c2c59f..41fec451 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -3,7 +3,9 @@
"version": "0.1.0",
"private": true,
"type": "module",
- "sideEffects": ["**/*.css"],
+ "sideEffects": [
+ "**/*.css"
+ ],
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint src"
@@ -29,14 +31,17 @@
"@tiptap/starter-kit": "^3.19.0",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
+ "katex": "^0.16.28",
"linkify-it": "^5.0.0",
"next-themes": "^0.4.6",
"qr-scanner": "^1.4.2",
"react": "catalog:",
"react-dom": "catalog:",
"react-markdown": "^10.1.0",
+ "rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
+ "remark-math": "^6.0.0",
"shadcn": "^3.7.0",
"shiki": "^3.21.0",
"sonner": "^2.0.7",
diff --git a/packages/ui/src/components/markdown/Markdown.tsx b/packages/ui/src/components/markdown/Markdown.tsx
index 52e22ddf..d201b9c1 100644
--- a/packages/ui/src/components/markdown/Markdown.tsx
+++ b/packages/ui/src/components/markdown/Markdown.tsx
@@ -1,10 +1,13 @@
import * as React from 'react'
import ReactMarkdown, { type Components } from 'react-markdown'
+import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'
import remarkGfm from 'remark-gfm'
+import remarkMath from 'remark-math'
import { cn } from '@multica/ui/lib/utils'
import { CodeBlock, InlineCode } from './CodeBlock'
import { preprocessLinks } from './linkify'
+import 'katex/dist/katex.min.css'
/**
* Render modes for markdown content:
@@ -270,8 +273,8 @@ export function Markdown({
return (
{processedContent}
diff --git a/packages/ui/src/components/markdown/StreamingMarkdown.tsx b/packages/ui/src/components/markdown/StreamingMarkdown.tsx
index fc50963f..86e2b72b 100644
--- a/packages/ui/src/components/markdown/StreamingMarkdown.tsx
+++ b/packages/ui/src/components/markdown/StreamingMarkdown.tsx
@@ -49,6 +49,7 @@ function splitIntoBlocks(content: string): Block[] {
const lines = content.split('\n')
let currentBlock = ''
let inCodeBlock = false
+ let inMathBlock = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? ''
@@ -73,6 +74,26 @@ function splitIntoBlocks(content: string): Block[] {
} else if (inCodeBlock) {
// Inside code block - append line
currentBlock += line + '\n'
+ // Check for display math fence ($$)
+ } else if (line.trim() === '$$') {
+ if (!inMathBlock) {
+ // Starting a math block - flush current paragraph first
+ if (currentBlock.trim()) {
+ blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
+ currentBlock = ''
+ }
+ inMathBlock = true
+ currentBlock = line + '\n'
+ } else {
+ // Ending a math block
+ currentBlock += line
+ blocks.push({ content: currentBlock, isCodeBlock: false })
+ currentBlock = ''
+ inMathBlock = false
+ }
+ } else if (inMathBlock) {
+ // Inside math block - append line (don't split on blank lines)
+ currentBlock += line + '\n'
} else if (line === '') {
// Empty line outside code block = paragraph boundary
if (currentBlock.trim()) {
@@ -92,8 +113,8 @@ function splitIntoBlocks(content: string): Block[] {
// Flush remaining content
if (currentBlock) {
blocks.push({
- content: inCodeBlock ? currentBlock : currentBlock.trim(),
- isCodeBlock: inCodeBlock // Unclosed code block = still streaming
+ content: inCodeBlock || inMathBlock ? currentBlock : currentBlock.trim(),
+ isCodeBlock: inCodeBlock
})
}
diff --git a/packages/ui/src/components/markdown/linkify.ts b/packages/ui/src/components/markdown/linkify.ts
index ed06a9b7..f4ee1809 100644
--- a/packages/ui/src/components/markdown/linkify.ts
+++ b/packages/ui/src/components/markdown/linkify.ts
@@ -42,14 +42,34 @@ function findCodeRanges(text: string): CodeRange[] {
ranges.push({ start: match.index, end: match.index + match[0].length })
}
+ // Find display math blocks ($$...$$)
+ const displayMathRegex = /\$\$[\s\S]*?\$\$/g
+ while ((match = displayMathRegex.exec(text)) !== null) {
+ const pos = match.index
+ const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
+ if (!insideOther) {
+ ranges.push({ start: pos, end: pos + match[0].length })
+ }
+ }
+
+ // Find inline math ($...$)
+ const inlineMathRegex = /(? pos >= r.start && pos < r.end)
+ if (!insideOther) {
+ ranges.push({ start: pos, end: pos + match[0].length })
+ }
+ }
+
// Find inline code (`...`)
// But skip escaped backticks and code inside fenced blocks
const inlineRegex = /(? pos >= r.start && pos < r.end)
- if (!insideFenced) {
+ // Check if this is inside a fenced block or math block
+ const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
+ if (!insideOther) {
ranges.push({ start: pos, end: pos + match[0].length })
}
}
diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css
index c7ea571b..ddcef476 100644
--- a/packages/ui/src/styles/globals.css
+++ b/packages/ui/src/styles/globals.css
@@ -143,6 +143,19 @@
color: var(--shiki-dark) !important;
}
+/* KaTeX math: inherit text color for light/dark theme support */
+.katex {
+ color: inherit;
+ font-size: 1em;
+}
+
+.katex-display {
+ margin: 1em 0;
+ overflow-x: auto;
+ overflow-y: hidden;
+ padding: 0.5em 0;
+}
+
/* Scroll fade hint utilities — mask content edges to hint at scrollable overflow */
.mask-fade-y {
mask-image: linear-gradient(to bottom, transparent 0%, black 32px, black calc(100% - 32px), transparent 100%);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 70b7bad9..288cdcae 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -761,6 +761,9 @@ importers:
clsx:
specifier: 'catalog:'
version: 2.1.1
+ katex:
+ specifier: ^0.16.28
+ version: 0.16.28
linkify-it:
specifier: ^5.0.0
version: 5.0.0
@@ -779,12 +782,18 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.13)(react@19.2.3)
+ rehype-katex:
+ specifier: ^7.0.1
+ version: 7.0.1
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
+ remark-math:
+ specifier: ^6.0.0
+ version: 6.0.0
shadcn:
specifier: ^3.7.0
version: 3.8.4(@types/node@25.2.2)(typescript@5.9.3)
@@ -4006,6 +4015,9 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
+ '@types/katex@0.16.8':
+ resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
+
'@types/keyv@3.1.4':
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
@@ -5019,6 +5031,10 @@ packages:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
+ commander@8.3.0:
+ resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
+ engines: {node: '>= 12'}
+
commander@9.5.0:
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
engines: {node: ^12.20.0 || >=14}
@@ -6361,9 +6377,21 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
+ hast-util-from-dom@5.0.1:
+ resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==}
+
+ hast-util-from-html-isomorphic@2.0.0:
+ resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==}
+
+ hast-util-from-html@2.0.3:
+ resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
+
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
+ hast-util-is-element@3.0.0:
+ resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
+
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
@@ -6379,6 +6407,9 @@ packages:
hast-util-to-parse5@8.0.1:
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
+ hast-util-to-text@4.0.2:
+ resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
+
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
@@ -6936,6 +6967,10 @@ packages:
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
+ katex@0.16.28:
+ resolution: {integrity: sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==}
+ hasBin: true
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -7364,6 +7399,9 @@ packages:
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
+ mdast-util-math@3.0.0:
+ resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==}
+
mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
@@ -7498,6 +7536,9 @@ packages:
micromark-extension-gfm@3.0.0:
resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
+ micromark-extension-math@3.1.0:
+ resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==}
+
micromark-factory-destination@2.0.1:
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
@@ -8717,12 +8758,18 @@ packages:
resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==}
hasBin: true
+ rehype-katex@7.0.1:
+ resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
+
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
+ remark-math@6.0.0:
+ resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==}
+
remark-parse@11.0.0:
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
@@ -9720,12 +9767,18 @@ packages:
resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==}
engines: {node: '>=8'}
+ unist-util-find-after@5.0.0:
+ resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
+
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
unist-util-position@5.0.0:
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
+ unist-util-remove-position@5.0.0:
+ resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==}
+
unist-util-stringify-position@4.0.0:
resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
@@ -13990,6 +14043,8 @@ snapshots:
'@types/json5@0.0.29': {}
+ '@types/katex@0.16.8': {}
+
'@types/keyv@3.1.4':
dependencies:
'@types/node': 25.2.2
@@ -15153,6 +15208,8 @@ snapshots:
commander@7.2.0: {}
+ commander@8.3.0: {}
+
commander@9.5.0:
optional: true
@@ -16944,6 +17001,28 @@ snapshots:
dependencies:
function-bind: 1.1.2
+ hast-util-from-dom@5.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ hastscript: 9.0.1
+ web-namespaces: 2.0.1
+
+ hast-util-from-html-isomorphic@2.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-from-dom: 5.0.1
+ hast-util-from-html: 2.0.3
+ unist-util-remove-position: 5.0.0
+
+ hast-util-from-html@2.0.3:
+ dependencies:
+ '@types/hast': 3.0.4
+ devlop: 1.1.0
+ hast-util-from-parse5: 8.0.3
+ parse5: 7.3.0
+ vfile: 6.0.3
+ vfile-message: 4.0.3
+
hast-util-from-parse5@8.0.3:
dependencies:
'@types/hast': 3.0.4
@@ -16955,6 +17034,10 @@ snapshots:
vfile-location: 5.0.3
web-namespaces: 2.0.1
+ hast-util-is-element@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
hast-util-parse-selector@4.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -17019,6 +17102,13 @@ snapshots:
web-namespaces: 2.0.1
zwitch: 2.0.4
+ hast-util-to-text@4.0.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ hast-util-is-element: 3.0.0
+ unist-util-find-after: 5.0.0
+
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -17577,6 +17667,10 @@ snapshots:
jwa: 2.0.1
safe-buffer: 5.2.1
+ katex@0.16.28:
+ dependencies:
+ commander: 8.3.0
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -17977,6 +18071,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ mdast-util-math@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ longest-streak: 3.1.0
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ unist-util-remove-position: 5.0.0
+ transitivePeerDependencies:
+ - supports-color
+
mdast-util-mdx-expression@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
@@ -18319,6 +18425,16 @@ snapshots:
micromark-util-combine-extensions: 2.0.1
micromark-util-types: 2.0.2
+ micromark-extension-math@3.1.0:
+ dependencies:
+ '@types/katex': 0.16.8
+ devlop: 1.1.0
+ katex: 0.16.28
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
micromark-factory-destination@2.0.1:
dependencies:
micromark-util-character: 2.1.1
@@ -19806,6 +19922,16 @@ snapshots:
dependencies:
jsesc: 3.1.0
+ rehype-katex@7.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/katex': 0.16.8
+ hast-util-from-html-isomorphic: 2.0.0
+ hast-util-to-text: 4.0.2
+ katex: 0.16.28
+ unist-util-visit-parents: 6.0.2
+ vfile: 6.0.3
+
rehype-raw@7.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -19823,6 +19949,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ remark-math@6.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-math: 3.0.0
+ micromark-extension-math: 3.1.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
remark-parse@11.0.0:
dependencies:
'@types/mdast': 4.0.4
@@ -21002,6 +21137,11 @@ snapshots:
dependencies:
crypto-random-string: 2.0.0
+ unist-util-find-after@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+
unist-util-is@6.0.1:
dependencies:
'@types/unist': 3.0.3
@@ -21010,6 +21150,11 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
+ unist-util-remove-position@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-visit: 5.1.0
+
unist-util-stringify-position@4.0.0:
dependencies:
'@types/unist': 3.0.3