From 3fc4528761da750405fad975b9747080ad5a3642 Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Fri, 9 Oct 2026 14:31:34 +0800 Subject: [PATCH 1/5] feat(mobile-web): render math formulas with katex in chat markdown --- pnpm-lock.yaml | 12 +++++ src/mobile-web/package.json | 4 ++ .../src/components/ChatMarkdown.tsx | 53 ++++++++++++++++++- .../src/styles/components/markdown.scss | 23 ++++++++ src/mobile-web/tests/fixtures/host-queue.tsx | 2 +- .../tests/host-dialog-queue-browser.test.mjs | 3 ++ .../tests/mobile-ui-components.test.mjs | 49 +++++++++++++++++ 7 files changed, 143 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcaad14a73..4084bb021f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -326,6 +326,9 @@ importers: '@xterm/xterm': specifier: 6.1.0-beta.141 version: 6.1.0-beta.141 + katex: + specifier: ^0.16.25 + version: 0.16.28 lucide-react: specifier: ^0.560.0 version: 0.560.0(react@18.3.1) @@ -341,9 +344,15 @@ importers: react-syntax-highlighter: specifier: ^15.6.6 version: 15.6.6(react@18.3.1) + rehype-katex: + specifier: ^7.0.1 + version: 7.0.1 remark-gfm: specifier: ^4.0.1 version: 4.0.1 + remark-math: + specifier: ^6.0.0 + version: 6.0.0 socket.io-client: specifier: 4.8.3 version: 4.8.3 @@ -351,6 +360,9 @@ importers: specifier: ^5.0.10 version: 5.0.11(@types/react@18.3.27)(immer@11.1.4)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) devDependencies: + '@types/katex': + specifier: ^0.16.7 + version: 0.16.8 '@types/react': specifier: ^18.3.0 version: 18.3.27 diff --git a/src/mobile-web/package.json b/src/mobile-web/package.json index ea7c3271d2..683b5c3218 100644 --- a/src/mobile-web/package.json +++ b/src/mobile-web/package.json @@ -34,16 +34,20 @@ "@openbitfun/ui": "workspace:^", "@xterm/addon-fit": "0.12.0-beta.141", "@xterm/xterm": "6.1.0-beta.141", + "katex": "^0.16.25", "lucide-react": "^0.560.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^15.6.6", + "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "socket.io-client": "4.8.3", "zustand": "^5.0.10" }, "devDependencies": { + "@types/katex": "^0.16.7", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/src/mobile-web/src/components/ChatMarkdown.tsx b/src/mobile-web/src/components/ChatMarkdown.tsx index 08229cff9e..64b4f3ded3 100644 --- a/src/mobile-web/src/components/ChatMarkdown.tsx +++ b/src/mobile-web/src/components/ChatMarkdown.tsx @@ -2,6 +2,9 @@ import { Check as LucideCheck, Copy as LucideCopy, FileText as LucideFileText } import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; +import rehypeKatex from 'rehype-katex'; +import 'katex/dist/katex.min.css'; import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash'; @@ -359,6 +362,50 @@ const FileCard: React.FC = ({ path, onGetFileInfo, onDownload }) ); }; +export function normalizeMathDelimiters(content: string): string { + if (!content) return content; + const hasDisplayBracket = content.includes('\\['); + const hasInlineBracket = content.includes('\\('); + const hasRawBlockDollars = /(^|[^\\])\$\$[^\n]+?\$\$/.test(content); + if (!hasDisplayBracket && !hasInlineBracket && !hasRawBlockDollars) { + return content; + } + + const codeBlocks: { key: string; content: string }[] = []; + let placeholderIndex = 0; + + // Protect code fences + let transformed = content.replace(/(^|\n)[ \t]{0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:\n[ \t]{0,3}\2[^\n]*(?=\n|$)|$)/g, (match) => { + const key = `@@MATH_CODE_BLOCK_${placeholderIndex++}@@`; + codeBlocks.push({ key, content: match }); + return key; + }); + + // Protect inline code spans + transformed = transformed.replace(/(`+)((?:[^\n`]|(?!\1)`)*?)\1/g, (match) => { + const key = `@@MATH_CODE_INLINE_${placeholderIndex++}@@`; + codeBlocks.push({ key, content: match }); + return key; + }); + + // Normalize single-line $$...$$ to multi-line $$ \n ... \n $$ so remark-math recognises block math + transformed = transformed.replace(/(^|[^\\])\$\$([^\n]+?)\$\$/g, (_, prefix, math) => `${prefix}\n\n$$\n${math.trim()}\n$$\n\n`); + + // Normalize \[ ... \] to block math $$ ... $$ + transformed = transformed.replace(/\\\[([\s\S]*?)\\\]/g, (_, math) => `\n\n$$\n${math.trim()}\n$$\n\n`); + + // Normalize \( ... \) to inline math $ ... $ + transformed = transformed.replace(/\\\(([\s\S]*?)\\\)/g, (_, math) => `$${math.trim()}$`); + + // Restore protected code blocks using function replacer to avoid special replacement patterns ($$, $&, $', etc.) + for (let i = codeBlocks.length - 1; i >= 0; i--) { + const { key, content: code } = codeBlocks[i]; + transformed = transformed.replace(key, () => code); + } + + return transformed; +} + interface MarkdownContentProps { content: string; onFileDownload?: (path: string, onProgress?: (downloaded: number, total: number) => void) => Promise; @@ -368,6 +415,7 @@ interface MarkdownContentProps { export const MarkdownContent: React.FC = ({ content, onFileDownload, onGetFileInfo }) => { const { isDark } = useTheme(); const syntaxTheme = isDark ? vscDarkPlus : vs; + const normalizedContent = useMemo(() => normalizeMathDelimiters(content), [content]); const fileReferences = useMemo( () => onFileDownload && onGetFileInfo ? projectFileReferences(content) : [], [content, onFileDownload, onGetFileInfo], @@ -480,7 +528,8 @@ export const MarkdownContent: React.FC = ({ content, onFil return ( <> { if (key === 'src' && /^data:image\/(?:png|jpeg|gif|webp|bmp|svg\+xml|avif);base64,/i.test(url)) return url; @@ -495,7 +544,7 @@ export const MarkdownContent: React.FC = ({ content, onFil return ''; }} > - {content} + {normalizedContent} {fileReferences.length > 0 && onGetFileInfo && onFileDownload && (
diff --git a/src/mobile-web/src/styles/components/markdown.scss b/src/mobile-web/src/styles/components/markdown.scss index 5f20cb1b5d..42e79268e6 100644 --- a/src/mobile-web/src/styles/components/markdown.scss +++ b/src/mobile-web/src/styles/components/markdown.scss @@ -480,6 +480,29 @@ margin-right: 6px; accent-color: var(--openbitfun-color-accent-default); } + + // ─── Math (KaTeX) ─────────────────────────────────────────────────────────── + + .katex-display { + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + max-width: 100%; + margin: 0.5rem 0; + padding: var(--openbitfun-space-1) 0; + + > .katex { + display: inline-block; + min-width: 0; + text-align: initial; + } + } + + .katex { + font-size: 1.05em; + line-height: 1.2; + text-indent: 0; + } } // Output bytes can be larger than the viewport. Keep their aspect ratio in diff --git a/src/mobile-web/tests/fixtures/host-queue.tsx b/src/mobile-web/tests/fixtures/host-queue.tsx index 6a48c1960f..59e7358025 100644 --- a/src/mobile-web/tests/fixtures/host-queue.tsx +++ b/src/mobile-web/tests/fixtures/host-queue.tsx @@ -39,7 +39,7 @@ export function mountHostQueueFixture({ count = 1, expanded = true } = {}) { }, []); return
= 900 ? ' chat-page--wide' : ''}`} style={{ '--chat-composer-height': `${height}px` } as React.CSSProperties}>
项目介绍
-
+
} cancelling={false} containerRef={ref} expanded={expanded} imageAnalyzing={false} sending={false} input="继续检查" inputRef={null} modelControls={null} onActivate={noop} onAttach={noop} onCancel={() => calls.push('stop')} diff --git a/src/mobile-web/tests/host-dialog-queue-browser.test.mjs b/src/mobile-web/tests/host-dialog-queue-browser.test.mjs index e73433c7a1..7485eab084 100644 --- a/src/mobile-web/tests/host-dialog-queue-browser.test.mjs +++ b/src/mobile-web/tests/host-dialog-queue-browser.test.mjs @@ -43,6 +43,9 @@ test('mobile running composer keeps send and stop independently available alongs window.queueFixture=mountHostQueueFixture(); }); await page.waitForSelector('.host-message-queue li'); + assert.ok(await page.$eval('.chat-msg__assistant-content .katex', el => !!el), 'katex renders inside assistant message'); + assert.ok(await page.$eval('.chat-msg__assistant-content .katex-display', el => !!el), 'katex block display renders'); + assert.ok((await page.$eval('.code-block-wrapper', el => el.textContent)).includes('echo $$ $1 $&'), 'code blocks preserve special dollar patterns'); assert.ok(await page.$eval('.host-message-queue',el=>el.getBoundingClientRect().height<=100),'one queued message stays compact'); const actions=await page.$$eval('.chat-page__send-btn', buttons=>buttons.map(button=>({disabled:button.disabled,stop:button.classList.contains('is-stop')}))); assert.deepEqual(actions,[{disabled:false,stop:false},{disabled:false,stop:true}]); diff --git a/src/mobile-web/tests/mobile-ui-components.test.mjs b/src/mobile-web/tests/mobile-ui-components.test.mjs index 4c336cbae0..572ae24f1d 100644 --- a/src/mobile-web/tests/mobile-ui-components.test.mjs +++ b/src/mobile-web/tests/mobile-ui-components.test.mjs @@ -322,6 +322,55 @@ test('mobile transcript keeps one user bubble and projects file cards outside ma assert.match(markdownStyles, /\.file-card\s*\{[\s\S]*?inline-size:\s*100%;/); }); +test('mobile chat markdown configures math plugins and mobile katex display styles', async () => { + const markdown = await readFile(path.join(sourceDirectory, 'components/ChatMarkdown.tsx'), 'utf8'); + const markdownStyles = await readFile(path.join(sourceDirectory, 'styles/components/markdown.scss'), 'utf8'); + + assert.match(markdown, /import remarkMath from 'remark-math';/); + assert.match(markdown, /import rehypeKatex from 'rehype-katex';/); + assert.match(markdown, /import 'katex\/dist\/katex\.min\.css';/); + assert.match(markdown, /remarkPlugins=\{\[remarkGfm,\s*remarkMath\]\}/); + assert.match(markdown, /rehypePlugins=\{\[\[rehypeKatex,\s*\{\s*strict:\s*false,\s*throwOnError:\s*false\s*\}\]\]\}/); + assert.match(markdown, /normalizeMathDelimiters/); + + assert.match(markdownStyles, /\.katex-display\s*\{[\s\S]*?overflow-x:\s*auto;/); + assert.match(markdownStyles, /\.katex-display\s*\{[\s\S]*?-webkit-overflow-scrolling:\s*touch;/); + assert.match(markdownStyles, /\.katex\s*\{[\s\S]*?font-size:\s*1\.05em;/); +}); + +test('normalizeMathDelimiters protects code blocks and normalizes LaTeX delimiters', async () => { + const markdown = await readFile(path.join(sourceDirectory, 'components/ChatMarkdown.tsx'), 'utf8'); + const fnMatch = markdown.match(/export function normalizeMathDelimiters[\s\S]*?\n\}/); + assert.ok(fnMatch, 'normalizeMathDelimiters function must be present'); + const code = ts.transpileModule(fnMatch[0], { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText; + const { normalizeMathDelimiters: normalize } = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`); + + // Empty or plain text without math brackets or raw dollar blocks + assert.equal(normalize(''), ''); + assert.equal(normalize('plain text with $10 and $20'), 'plain text with $10 and $20'); + + // Inline bracket \( ... \) to $ ... $ + assert.equal(normalize('Inline \\( a + b = c \\) math'), 'Inline $a + b = c$ math'); + + // Block bracket \[ ... \] to $$ ... $$ + assert.match(normalize('Block \\[\\frac{1}{2}\\] math'), /\$\$\s*\n\\frac\{1\}\{2\}\n\s*\$\$/); + + // Single line $$...$$ to multi-line $$...$$ + assert.match(normalize('$$E=mc^2$$'), /\$\$\s*\nE=mc\^2\n\s*\$\$/); + + // Code fence with special replacement patterns ($$, $&, $1) and escaped characters must not be corrupted + const codeFenceSample = 'Formula \\(x\\)\n```bash\necho $$ $1 $&\n```\nDone'; + const codeFenceNormalized = normalize(codeFenceSample); + assert.ok(codeFenceNormalized.includes('echo $$ $1 $&'), 'code blocks must preserve literal $$ and $& without replace corruption'); + assert.ok(codeFenceNormalized.includes('$x$'), 'formula outside code fence must be normalized'); + + // Multi-backtick inline code with internal backtick or brackets + const multiBacktickSample = 'Look at ``code ` with \\[index\\] inside`` and \\(y\\)'; + const multiBacktickNormalized = normalize(multiBacktickSample); + assert.ok(multiBacktickNormalized.includes('``code ` with \\[index\\] inside``'), 'multi-backtick inline code must be preserved verbatim'); + assert.ok(multiBacktickNormalized.includes('$y$'), 'formula outside inline code must be normalized'); +}); + test('mobile file card failures stay readable and repeatable', async () => { const markdown = await readFile(path.join(sourceDirectory, 'components/ChatMarkdown.tsx'), 'utf8'); const markdownStyles = await readFile(path.join(sourceDirectory, 'styles/components/markdown.scss'), 'utf8'); From 843504c7980be3d14067e63b562a77fdb5c62dfa Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 19 Oct 2026 09:48:00 +0800 Subject: [PATCH 2/5] fix(mobile-web): lazily load katex and gate math activation Keep the KaTeX runtime out of the chat chunk by resolving the math renderer on demand, and only activate math when the message can contain formulas so currency-only dollar pairs keep rendering as text. Move the delimiter normalization into a pure module so both helpers are unit-testable, and drop the redundant @types/katex package because katex ships its own types. --- pnpm-lock.yaml | 3 - src/mobile-web/package.json | 1 - .../src/components/ChatMarkdown.tsx | 122 ++++++++---------- .../src/components/MarkdownMathContent.tsx | 34 +++++ .../src/components/chatMarkdownMath.ts | 83 ++++++++++++ .../tests/mobile-ui-components.test.mjs | 46 +++++-- 6 files changed, 207 insertions(+), 82 deletions(-) create mode 100644 src/mobile-web/src/components/MarkdownMathContent.tsx create mode 100644 src/mobile-web/src/components/chatMarkdownMath.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56c38d8b18..c8de225e9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -360,9 +360,6 @@ importers: specifier: ^5.0.10 version: 5.0.11(@types/react@18.3.27)(immer@11.1.4)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) devDependencies: - '@types/katex': - specifier: ^0.16.7 - version: 0.16.8 '@types/react': specifier: ^18.3.0 version: 18.3.27 diff --git a/src/mobile-web/package.json b/src/mobile-web/package.json index 150235175e..e10573be2c 100644 --- a/src/mobile-web/package.json +++ b/src/mobile-web/package.json @@ -48,7 +48,6 @@ "zustand": "^5.0.10" }, "devDependencies": { - "@types/katex": "^0.16.7", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/src/mobile-web/src/components/ChatMarkdown.tsx b/src/mobile-web/src/components/ChatMarkdown.tsx index 64b4f3ded3..f5e82cee4b 100644 --- a/src/mobile-web/src/components/ChatMarkdown.tsx +++ b/src/mobile-web/src/components/ChatMarkdown.tsx @@ -1,10 +1,8 @@ import { Check as LucideCheck, Copy as LucideCopy, FileText as LucideFileText } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ReactMarkdown from 'react-markdown'; +import type { Components, UrlTransform } from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import remarkMath from 'remark-math'; -import rehypeKatex from 'rehype-katex'; -import 'katex/dist/katex.min.css'; import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash'; @@ -33,6 +31,8 @@ import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml'; import { MobileButton, MobileIconButton, MobileLink } from '@openbitfun/ui/mobile'; import { useI18n } from '../i18n'; import { useTheme } from '../theme'; +import { mayContainMarkdownMath, normalizeMathDelimiters } from './chatMarkdownMath'; +import type { MarkdownMathContentProps } from './MarkdownMathContent'; import { RemoteArtifactImage } from './RemoteArtifactImage'; const SYNTAX_LANGUAGES = { bash, c, cpp, csharp, css, diff, go, java, javascript, json, jsx, kotlin, markdown, markup, php, python, ruby, rust, sql, swift, tsx, typescript, yaml }; @@ -362,49 +362,6 @@ const FileCard: React.FC = ({ path, onGetFileInfo, onDownload }) ); }; -export function normalizeMathDelimiters(content: string): string { - if (!content) return content; - const hasDisplayBracket = content.includes('\\['); - const hasInlineBracket = content.includes('\\('); - const hasRawBlockDollars = /(^|[^\\])\$\$[^\n]+?\$\$/.test(content); - if (!hasDisplayBracket && !hasInlineBracket && !hasRawBlockDollars) { - return content; - } - - const codeBlocks: { key: string; content: string }[] = []; - let placeholderIndex = 0; - - // Protect code fences - let transformed = content.replace(/(^|\n)[ \t]{0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:\n[ \t]{0,3}\2[^\n]*(?=\n|$)|$)/g, (match) => { - const key = `@@MATH_CODE_BLOCK_${placeholderIndex++}@@`; - codeBlocks.push({ key, content: match }); - return key; - }); - - // Protect inline code spans - transformed = transformed.replace(/(`+)((?:[^\n`]|(?!\1)`)*?)\1/g, (match) => { - const key = `@@MATH_CODE_INLINE_${placeholderIndex++}@@`; - codeBlocks.push({ key, content: match }); - return key; - }); - - // Normalize single-line $$...$$ to multi-line $$ \n ... \n $$ so remark-math recognises block math - transformed = transformed.replace(/(^|[^\\])\$\$([^\n]+?)\$\$/g, (_, prefix, math) => `${prefix}\n\n$$\n${math.trim()}\n$$\n\n`); - - // Normalize \[ ... \] to block math $$ ... $$ - transformed = transformed.replace(/\\\[([\s\S]*?)\\\]/g, (_, math) => `\n\n$$\n${math.trim()}\n$$\n\n`); - - // Normalize \( ... \) to inline math $ ... $ - transformed = transformed.replace(/\\\(([\s\S]*?)\\\)/g, (_, math) => `$${math.trim()}$`); - - // Restore protected code blocks using function replacer to avoid special replacement patterns ($$, $&, $', etc.) - for (let i = codeBlocks.length - 1; i >= 0; i--) { - const { key, content: code } = codeBlocks[i]; - transformed = transformed.replace(key, () => code); - } - - return transformed; -} interface MarkdownContentProps { content: string; @@ -415,13 +372,35 @@ interface MarkdownContentProps { export const MarkdownContent: React.FC = ({ content, onFileDownload, onGetFileInfo }) => { const { isDark } = useTheme(); const syntaxTheme = isDark ? vscDarkPlus : vs; - const normalizedContent = useMemo(() => normalizeMathDelimiters(content), [content]); + const hasMath = useMemo(() => mayContainMarkdownMath(content), [content]); + const normalizedContent = useMemo( + () => (hasMath ? normalizeMathDelimiters(content) : content), + [content, hasMath], + ); + const [MathRenderer, setMathRenderer] = useState | null>(null); + + // KaTeX is only downloaded for chats that actually contain math. If the + // optional chunk cannot load, keep the base renderer instead of breaking the + // transcript. + useEffect(() => { + if (!hasMath || MathRenderer) return; + let cancelled = false; + import('./MarkdownMathContent') + .then((module) => { + if (!cancelled) setMathRenderer(() => module.MarkdownMathContent); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [hasMath, MathRenderer]); + const fileReferences = useMemo( () => onFileDownload && onGetFileInfo ? projectFileReferences(content) : [], [content, onFileDownload, onGetFileInfo], ); - const components: React.ComponentProps['components'] = useMemo(() => ({ + const components: Components = useMemo(() => ({ code({ className, children, ...props }: any) { const match = /language-(\w+)/.exec(className || ''); const codeStr = String(children).replace(/\n$/, ''); @@ -525,27 +504,36 @@ export const MarkdownContent: React.FC = ({ content, onFil }, }), [syntaxTheme, isDark, onFileDownload, onGetFileInfo]); + const markdownUrlTransform = useCallback((url, key) => { + if (key === 'src' && /^data:image\/(?:png|jpeg|gif|webp|bmp|svg\+xml|avif);base64,/i.test(url)) return url; + if (/^[A-Za-z]:[\\/]/.test(url)) return url; + if (url.startsWith('computer://') || /^openbitfun:\/\/(?:runtime|current-session)\//.test(url)) return url; + if (/^(https?|mailto|tel|file):/i.test(url) || url.startsWith('#') || url.startsWith('/')) { + return url; + } + // Preserve relative paths without a protocol (e.g. "report.pptx", + // "./output.pdf"). Unknown schemes remain blocked. + if (!url.includes(':')) return url; + return ''; + }, []); + return ( <> - { - if (key === 'src' && /^data:image\/(?:png|jpeg|gif|webp|bmp|svg\+xml|avif);base64,/i.test(url)) return url; - if (/^[A-Za-z]:[\\/]/.test(url)) return url; - if (url.startsWith('computer://') || /^openbitfun:\/\/(?:runtime|current-session)\//.test(url)) return url; - if (/^(https?|mailto|tel|file):/i.test(url) || url.startsWith('#') || url.startsWith('/')) { - return url; - } - // Preserve relative paths without a protocol (e.g. "report.pptx", - // "./output.pdf"). Unknown schemes remain blocked. - if (!url.includes(':')) return url; - return ''; - }} - > - {normalizedContent} - + {hasMath && MathRenderer ? ( + + ) : ( + + {content} + + )} {fileReferences.length > 0 && onGetFileInfo && onFileDownload && (
{fileReferences.map((reference) => ( diff --git a/src/mobile-web/src/components/MarkdownMathContent.tsx b/src/mobile-web/src/components/MarkdownMathContent.tsx new file mode 100644 index 0000000000..83c96ed2fd --- /dev/null +++ b/src/mobile-web/src/components/MarkdownMathContent.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import ReactMarkdown from 'react-markdown'; +import type { Components, UrlTransform } from 'react-markdown'; +import rehypeKatex from 'rehype-katex'; +import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; +import 'katex/dist/katex.min.css'; + +export interface MarkdownMathContentProps { + content: string; + components: Components; + urlTransform: UrlTransform; +} + +/** + * KaTeX-enabled Markdown renderer. It is imported lazily so chats without math + * do not download the KaTeX runtime; the base renderer stays in ChatMarkdown. + */ +export const MarkdownMathContent: React.FC = ({ + content, + components, + urlTransform, +}) => ( + + {content} + +); + +export default MarkdownMathContent; diff --git a/src/mobile-web/src/components/chatMarkdownMath.ts b/src/mobile-web/src/components/chatMarkdownMath.ts new file mode 100644 index 0000000000..90d8f2dbc3 --- /dev/null +++ b/src/mobile-web/src/components/chatMarkdownMath.ts @@ -0,0 +1,83 @@ +/** + * Mobile chat math activation and delimiter normalization. + * + * Math is rendered from a lazily loaded module, so this detection decides + * whether the browser downloads KaTeX at all. Keep the single-dollar boundary + * checks aligned with desktop MarkdownRenderer.mayContainMarkdownMath: a + * closing dollar directly followed by a digit is ordinary currency text + * (`$5 and $10`), not math. + */ +export function mayContainMarkdownMath(content: string): boolean { + if (!content) return false; + if (content.includes('$$') || content.includes('\\(') || content.includes('\\[')) { + return true; + } + return /(^|[^\\$])\$[^$\n]{1,240}\$(?!\d)/.test(content); +} + +const CODE_FENCE = + /(^|\n)[ \t]{0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:\n[ \t]{0,3}\2[^\n]*(?=\n|$)|$)/g; +const INLINE_CODE = /(`+)((?:[^\n`]|(?!\1)`)*?)\1/g; +const SINGLE_LINE_DISPLAY_MATH = /(^|[^\\])\$\$([^\n]+?)\$\$/g; +const BRACKET_DISPLAY_MATH = /(^|[^\\])\\\[([\s\S]*?)\\\]/g; +const BRACKET_INLINE_MATH = /(^|[^\\])\\\(([\s\S]*?)\\\)/g; + +/** + * Convert the LaTeX delimiter styles that common models emit into the + * `$...$`/`$$...$$` forms remark-math parses. Code fences and inline code are + * replaced with placeholders first, so literal `\[`/`$$` examples survive. + */ +export function normalizeMathDelimiters(content: string): string { + if (!content) return content; + const hasDisplayBracket = content.includes('\\['); + const hasInlineBracket = content.includes('\\('); + const hasRawBlockDollars = /(^|[^\\])\$\$[^\n]+?\$\$/.test(content); + if (!hasDisplayBracket && !hasInlineBracket && !hasRawBlockDollars) { + return content; + } + + const codeBlocks: { key: string; content: string }[] = []; + let placeholderIndex = 0; + + // Protect code fences. + let transformed = content.replace(CODE_FENCE, (match) => { + const key = `@@MATH_CODE_BLOCK_${placeholderIndex++}@@`; + codeBlocks.push({ key, content: match }); + return key; + }); + + // Protect inline code spans. + transformed = transformed.replace(INLINE_CODE, (match) => { + const key = `@@MATH_CODE_INLINE_${placeholderIndex++}@@`; + codeBlocks.push({ key, content: match }); + return key; + }); + + // remark-math only treats `$$` as a display fence when it owns its line. + // Single-line display math is common in model output, so expand it. + transformed = transformed.replace( + SINGLE_LINE_DISPLAY_MATH, + (_, prefix, math) => `${prefix}\n\n$$\n${math.trim()}\n$$\n\n`, + ); + + // \[ ... \] is display math. + transformed = transformed.replace( + BRACKET_DISPLAY_MATH, + (_, prefix, math) => `${prefix}\n\n$$\n${math.trim()}\n$$\n\n`, + ); + + // \( ... \) is inline math. + transformed = transformed.replace( + BRACKET_INLINE_MATH, + (_, prefix, math) => `${prefix}$${math.trim()}$`, + ); + + // Restore protected code blocks using a function replacer so `$` sequences + // in the code (`$$`, `$&`, `$1`) are not treated as replacement patterns. + for (let i = codeBlocks.length - 1; i >= 0; i--) { + const { key, content: code } = codeBlocks[i]; + transformed = transformed.replace(key, () => code); + } + + return transformed; +} diff --git a/src/mobile-web/tests/mobile-ui-components.test.mjs b/src/mobile-web/tests/mobile-ui-components.test.mjs index 572ae24f1d..f9a6654ace 100644 --- a/src/mobile-web/tests/mobile-ui-components.test.mjs +++ b/src/mobile-web/tests/mobile-ui-components.test.mjs @@ -322,27 +322,48 @@ test('mobile transcript keeps one user bubble and projects file cards outside ma assert.match(markdownStyles, /\.file-card\s*\{[\s\S]*?inline-size:\s*100%;/); }); -test('mobile chat markdown configures math plugins and mobile katex display styles', async () => { +test('mobile chat markdown lazily loads katex plugins and keeps mobile display styles', async () => { const markdown = await readFile(path.join(sourceDirectory, 'components/ChatMarkdown.tsx'), 'utf8'); + const math = await readFile(path.join(sourceDirectory, 'components/MarkdownMathContent.tsx'), 'utf8'); + const mathDelimiters = await readFile(path.join(sourceDirectory, 'components/chatMarkdownMath.ts'), 'utf8'); const markdownStyles = await readFile(path.join(sourceDirectory, 'styles/components/markdown.scss'), 'utf8'); - assert.match(markdown, /import remarkMath from 'remark-math';/); - assert.match(markdown, /import rehypeKatex from 'rehype-katex';/); - assert.match(markdown, /import 'katex\/dist\/katex\.min\.css';/); - assert.match(markdown, /remarkPlugins=\{\[remarkGfm,\s*remarkMath\]\}/); - assert.match(markdown, /rehypePlugins=\{\[\[rehypeKatex,\s*\{\s*strict:\s*false,\s*throwOnError:\s*false\s*\}\]\]\}/); - assert.match(markdown, /normalizeMathDelimiters/); + // KaTeX stays out of the chat chunk: only the math-capable renderer imports + // its plugins and styles, and ChatMarkdown loads it on demand. + assert.match(math, /import rehypeKatex from 'rehype-katex';/); + assert.match(math, /import remarkMath from 'remark-math';/); + assert.match(math, /import 'katex\/dist\/katex\.min\.css';/); + assert.match(math, /remarkPlugins=\{\[remarkGfm,\s*remarkMath\]\}/); + assert.match(math, /rehypePlugins=\{\[\[rehypeKatex,\s*\{\s*strict:\s*false,\s*throwOnError:\s*false\s*\}\]\]\}/); + assert.match(markdown, /import\('\.\/MarkdownMathContent'\)/); + assert.match(markdown, /mayContainMarkdownMath\(content\)/); + assert.match(markdown, /hasMath && MathRenderer/); + assert.doesNotMatch(markdown, /from 'remark-math'|from 'rehype-katex'|katex\.min\.css/); + assert.match(mathDelimiters, /export function normalizeMathDelimiters/); + assert.match(mathDelimiters, /export function mayContainMarkdownMath/); assert.match(markdownStyles, /\.katex-display\s*\{[\s\S]*?overflow-x:\s*auto;/); assert.match(markdownStyles, /\.katex-display\s*\{[\s\S]*?-webkit-overflow-scrolling:\s*touch;/); assert.match(markdownStyles, /\.katex\s*\{[\s\S]*?font-size:\s*1\.05em;/); }); +test('math activation ignores currency-only dollar pairs', async () => { + const mathDelimiters = await readFile(path.join(sourceDirectory, 'components/chatMarkdownMath.ts'), 'utf8'); + const code = ts.transpileModule(mathDelimiters, { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText; + const { mayContainMarkdownMath: mayContain } = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`); + + assert.equal(mayContain(''), false); + assert.equal(mayContain('plain text without math'), false); + assert.equal(mayContain('Cost is $5 and $10.'), false, 'currency amounts must not activate math rendering'); + assert.equal(mayContain('Inline $x$'), true); + assert.equal(mayContain('Display $$E=mc^2$$'), true); + assert.equal(mayContain('Bracket \\(x\\)'), true); + assert.equal(mayContain('Bracket \\[x\\]'), true); +}); + test('normalizeMathDelimiters protects code blocks and normalizes LaTeX delimiters', async () => { - const markdown = await readFile(path.join(sourceDirectory, 'components/ChatMarkdown.tsx'), 'utf8'); - const fnMatch = markdown.match(/export function normalizeMathDelimiters[\s\S]*?\n\}/); - assert.ok(fnMatch, 'normalizeMathDelimiters function must be present'); - const code = ts.transpileModule(fnMatch[0], { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText; + const mathDelimiters = await readFile(path.join(sourceDirectory, 'components/chatMarkdownMath.ts'), 'utf8'); + const code = ts.transpileModule(mathDelimiters, { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText; const { normalizeMathDelimiters: normalize } = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`); // Empty or plain text without math brackets or raw dollar blocks @@ -358,6 +379,9 @@ test('normalizeMathDelimiters protects code blocks and normalizes LaTeX delimite // Single line $$...$$ to multi-line $$...$$ assert.match(normalize('$$E=mc^2$$'), /\$\$\s*\nE=mc\^2\n\s*\$\$/); + // Escaped delimiters are literal text, not math. + assert.equal(normalize('Escaped \\\\[index\\\\] and \\\\(x\\\\)'), 'Escaped \\\\[index\\\\] and \\\\(x\\\\)'); + // Code fence with special replacement patterns ($$, $&, $1) and escaped characters must not be corrupted const codeFenceSample = 'Formula \\(x\\)\n```bash\necho $$ $1 $&\n```\nDone'; const codeFenceNormalized = normalize(codeFenceSample); From 3ee198b821b1a193f868f0b55a06433429374e8e Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 19 Oct 2026 09:57:53 +0800 Subject: [PATCH 3/5] test(mobile-web): wait for the lazy math renderer before asserting --- src/mobile-web/tests/host-dialog-queue-browser.test.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/mobile-web/tests/host-dialog-queue-browser.test.mjs b/src/mobile-web/tests/host-dialog-queue-browser.test.mjs index 7485eab084..b79f673095 100644 --- a/src/mobile-web/tests/host-dialog-queue-browser.test.mjs +++ b/src/mobile-web/tests/host-dialog-queue-browser.test.mjs @@ -43,6 +43,8 @@ test('mobile running composer keeps send and stop independently available alongs window.queueFixture=mountHostQueueFixture(); }); await page.waitForSelector('.host-message-queue li'); + // The math renderer is a lazy chunk; wait for it before asserting its DOM. + await page.waitForSelector('.chat-msg__assistant-content .katex'); assert.ok(await page.$eval('.chat-msg__assistant-content .katex', el => !!el), 'katex renders inside assistant message'); assert.ok(await page.$eval('.chat-msg__assistant-content .katex-display', el => !!el), 'katex block display renders'); assert.ok((await page.$eval('.code-block-wrapper', el => el.textContent)).includes('echo $$ $1 $&'), 'code blocks preserve special dollar patterns'); From 926ea903be03c2a81fad8e22cfeb44b671a0d0da Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 19 Oct 2026 10:03:05 +0800 Subject: [PATCH 4/5] fix(mobile-web): keep currency and shell dollars out of math Disable remark-math single-dollar parsing and widen only recognized inline formulas to the double-dollar form remark-math still parses. This keeps currency, shell variables, brace expansions, and Windows paths literal even when the same message contains formulas, while \( ... \) no longer pairs with a preceding currency dollar. Display delimiters stay block only on a top-level line and remain inline inside lists, tables, and blockquotes, and blockquoted or list-indented code fences are protected. Extend the unit and browser tests to cover the mixed text cases. --- .../src/components/MarkdownMathContent.tsx | 2 +- .../src/components/chatMarkdownMath.ts | 179 +++++++++++++++--- src/mobile-web/tests/fixtures/host-queue.tsx | 2 +- .../tests/host-dialog-queue-browser.test.mjs | 10 + .../tests/mobile-ui-components.test.mjs | 34 +++- 5 files changed, 188 insertions(+), 39 deletions(-) diff --git a/src/mobile-web/src/components/MarkdownMathContent.tsx b/src/mobile-web/src/components/MarkdownMathContent.tsx index 83c96ed2fd..1b5555e4fb 100644 --- a/src/mobile-web/src/components/MarkdownMathContent.tsx +++ b/src/mobile-web/src/components/MarkdownMathContent.tsx @@ -24,7 +24,7 @@ export const MarkdownMathContent: React.FC = ({ {content} diff --git a/src/mobile-web/src/components/chatMarkdownMath.ts b/src/mobile-web/src/components/chatMarkdownMath.ts index 90d8f2dbc3..4d99b83181 100644 --- a/src/mobile-web/src/components/chatMarkdownMath.ts +++ b/src/mobile-web/src/components/chatMarkdownMath.ts @@ -16,67 +16,186 @@ export function mayContainMarkdownMath(content: string): boolean { } const CODE_FENCE = - /(^|\n)[ \t]{0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:\n[ \t]{0,3}\2[^\n]*(?=\n|$)|$)/g; + /(^|\n)((?:[ \t]*>[ \t]?)*[ \t]*)(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:\n(?:(?:[ \t]*>[ \t]?)*[ \t]*)\3[^\n]*(?=\n|$)|$)/g; const INLINE_CODE = /(`+)((?:[^\n`]|(?!\1)`)*?)\1/g; +const PROPER_DISPLAY_MATH = /(^|\n)([ \t]{0,3}\$\$[^\n]*\n[\s\S]*?\n[ \t]{0,3}\$\$[^\n]*)(?=\n|$)/g; const SINGLE_LINE_DISPLAY_MATH = /(^|[^\\])\$\$([^\n]+?)\$\$/g; const BRACKET_DISPLAY_MATH = /(^|[^\\])\\\[([\s\S]*?)\\\]/g; const BRACKET_INLINE_MATH = /(^|[^\\])\\\(([\s\S]*?)\\\)/g; +type Span = { key: string; content: string }; + +function placeholder(spans: Span[], kind: string, content: string): string { + const key = `@@MATH_${kind}_${spans.length}@@`; + spans.push({ key, content }); + return key; +} + +/** True when the delimiter owns a top-level line; indented/container math stays inline. */ +function startsLine(text: string, index: number): boolean { + const before = text.slice(0, index); + return before === '' || before.endsWith('\n'); +} + +/** + * A bare identifier of one or two characters is commonly a formula (`$x$`, + * `$n$th`). Longer shell-style names, brace expansions, and text containing + * whitespace without operators stay literal. + */ +function looksLikeInlineMath(raw: string): boolean { + const value = raw.trim(); + if (!value) return false; + if (/^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(value)) return false; + if (/[\\^_=+*/<>|{}()[\]]/.test(value)) return true; + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) return value.length <= 2; + if (/\s/.test(value)) return false; + return /^[A-Za-z0-9.,'+-]+$/.test(value); +} + +/** + * `remark-math` is configured with `singleDollarTextMath: false`, so a lone + * `$` always stays literal. Recognized inline formulas are widened to + * `$$...$$`, which `remark-math` treats as inline math, and every other dollar + * pair (currency, shell variables, paths) is left alone. + */ +function convertInlineDollarMath(text: string, spans: Span[]): string { + let output = ''; + let index = 0; + while (index < text.length) { + const character = text[index]; + if (character === '\\') { + output += text.slice(index, index + 2); + index += 2; + continue; + } + if (character !== '$') { + output += character; + index += 1; + continue; + } + // Proper `$$` fences are already placeholders; keep any stray pair intact + // rather than pairing it with a later single dollar. + if (text[index + 1] === '$') { + output += '$$'; + index += 2; + continue; + } + // `${VAR}` is a shell expansion, not a math delimiter. + if (text[index + 1] === '{') { + output += '$'; + index += 1; + continue; + } + let cursor = index + 1; + let closing = -1; + while (cursor < text.length) { + const next = text[cursor]; + if (next === '\n') break; + if (next === '\\') { + cursor += 2; + continue; + } + if (next === '$') { + closing = cursor; + break; + } + cursor += 1; + } + if (closing > index + 1) { + const content = text.slice(index + 1, closing); + if (looksLikeInlineMath(content)) { + output += placeholder(spans, 'INLINE', `$$${content.trim()}$$`); + index = closing + 1; + continue; + } + } + output += '$'; + index += 1; + } + return output; +} + /** * Convert the LaTeX delimiter styles that common models emit into the - * `$...$`/`$$...$$` forms remark-math parses. Code fences and inline code are - * replaced with placeholders first, so literal `\[`/`$$` examples survive. + * `$$...$$` forms remark-math parses. Code fences and inline code are replaced + * with placeholders first, so literal `\[`/`$$` examples survive. + * + * Display delimiters only become a block when they own their line. Inside a + * list item, table row, or blockquote they stay inline so the surrounding + * container is not torn apart. */ export function normalizeMathDelimiters(content: string): string { if (!content) return content; const hasDisplayBracket = content.includes('\\['); const hasInlineBracket = content.includes('\\('); - const hasRawBlockDollars = /(^|[^\\])\$\$[^\n]+?\$\$/.test(content); - if (!hasDisplayBracket && !hasInlineBracket && !hasRawBlockDollars) { + const hasDollar = content.includes('$'); + if (!hasDisplayBracket && !hasInlineBracket && !hasDollar) { return content; } - const codeBlocks: { key: string; content: string }[] = []; - let placeholderIndex = 0; + const spans: Span[] = []; - // Protect code fences. - let transformed = content.replace(CODE_FENCE, (match) => { - const key = `@@MATH_CODE_BLOCK_${placeholderIndex++}@@`; - codeBlocks.push({ key, content: match }); - return key; - }); + // Protect code fences (including blockquoted and list-indented fences). + let transformed = content.replace(CODE_FENCE, (match) => + placeholder(spans, 'CODE_BLOCK', match), + ); // Protect inline code spans. - transformed = transformed.replace(INLINE_CODE, (match) => { - const key = `@@MATH_CODE_INLINE_${placeholderIndex++}@@`; - codeBlocks.push({ key, content: match }); - return key; - }); + transformed = transformed.replace(INLINE_CODE, (match) => + placeholder(spans, 'CODE_INLINE', match), + ); + + // Leave well-formed block math untouched; it already has the shape + // remark-math expects, and its body must not be scanned for `$`. + transformed = transformed.replace(PROPER_DISPLAY_MATH, (match) => + placeholder(spans, 'DISPLAY', match), + ); // remark-math only treats `$$` as a display fence when it owns its line. - // Single-line display math is common in model output, so expand it. + // A single-line `$$...$$` is a display fence at line start and inline math + // everywhere else. transformed = transformed.replace( SINGLE_LINE_DISPLAY_MATH, - (_, prefix, math) => `${prefix}\n\n$$\n${math.trim()}\n$$\n\n`, + (match, prefix: string, math: string, offset: number) => { + const display = startsLine(transformed, offset + prefix.length); + const rendered = display + ? `\n\n$$\n${math.trim()}\n$$\n\n` + : `$$${math.trim()}$$`; + return prefix + placeholder(spans, display ? 'BLOCK' : 'INLINE', rendered); + }, ); - // \[ ... \] is display math. + // \[ ... \] is display math when it owns its line, inline math otherwise. transformed = transformed.replace( BRACKET_DISPLAY_MATH, - (_, prefix, math) => `${prefix}\n\n$$\n${math.trim()}\n$$\n\n`, + (match, prefix: string, math: string, offset: number) => { + const delimiterIndex = offset + prefix.length; + // `C:\[dir\]\file` is a Windows path, not display math. + if (prefix === ':' && /[A-Za-z]/.test(transformed[delimiterIndex - 2] ?? '')) { + return match; + } + const display = startsLine(transformed, delimiterIndex); + const rendered = display + ? `\n\n$$\n${math.trim()}\n$$\n\n` + : `$$${math.trim()}$$`; + return prefix + placeholder(spans, display ? 'BLOCK' : 'INLINE', rendered); + }, ); // \( ... \) is inline math. - transformed = transformed.replace( - BRACKET_INLINE_MATH, - (_, prefix, math) => `${prefix}$${math.trim()}$`, + transformed = transformed.replace(BRACKET_INLINE_MATH, (match, prefix: string, math: string) => + prefix + placeholder(spans, 'INLINE', `$$${math.trim()}$$`), ); - // Restore protected code blocks using a function replacer so `$` sequences - // in the code (`$$`, `$&`, `$1`) are not treated as replacement patterns. - for (let i = codeBlocks.length - 1; i >= 0; i--) { - const { key, content: code } = codeBlocks[i]; - transformed = transformed.replace(key, () => code); + // Remaining single-dollar pairs are only math when they clearly are. + transformed = convertInlineDollarMath(transformed, spans); + + // Restore protected content. Math spans are restored before code spans so a + // code placeholder inside a formula still resolves; reverse order also keeps + // nested placeholders stable. + for (let index = spans.length - 1; index >= 0; index -= 1) { + const { key, content: value } = spans[index]; + transformed = transformed.replace(key, () => value); } return transformed; diff --git a/src/mobile-web/tests/fixtures/host-queue.tsx b/src/mobile-web/tests/fixtures/host-queue.tsx index 59e7358025..fc92816828 100644 --- a/src/mobile-web/tests/fixtures/host-queue.tsx +++ b/src/mobile-web/tests/fixtures/host-queue.tsx @@ -39,7 +39,7 @@ export function mountHostQueueFixture({ count = 1, expanded = true } = {}) { }, []); return
= 900 ? ' chat-page--wide' : ''}`} style={{ '--chat-composer-height': `${height}px` } as React.CSSProperties}>
项目介绍
-
+
} cancelling={false} containerRef={ref} expanded={expanded} imageAnalyzing={false} sending={false} input="继续检查" inputRef={null} modelControls={null} onActivate={noop} onAttach={noop} onCancel={() => calls.push('stop')} diff --git a/src/mobile-web/tests/host-dialog-queue-browser.test.mjs b/src/mobile-web/tests/host-dialog-queue-browser.test.mjs index b79f673095..160baf63c5 100644 --- a/src/mobile-web/tests/host-dialog-queue-browser.test.mjs +++ b/src/mobile-web/tests/host-dialog-queue-browser.test.mjs @@ -47,6 +47,16 @@ test('mobile running composer keeps send and stop independently available alongs await page.waitForSelector('.chat-msg__assistant-content .katex'); assert.ok(await page.$eval('.chat-msg__assistant-content .katex', el => !!el), 'katex renders inside assistant message'); assert.ok(await page.$eval('.chat-msg__assistant-content .katex-display', el => !!el), 'katex block display renders'); + const currency = await page.$$eval('.chat-msg__assistant-content p', paragraphs => { + const paragraph = paragraphs.find(el => el.textContent?.includes('价格')); + return paragraph + ? { text: paragraph.textContent, katex: paragraph.querySelectorAll('.katex').length } + : null; + }); + assert.ok(currency, 'currency paragraph stays in the transcript'); + assert.match(currency.text, /\$5 和 \$10/); + assert.match(currency.text, /\$HOME 和 \$PATH/); + assert.equal(currency.katex, 0, 'currency and shell variables must not become math'); assert.ok((await page.$eval('.code-block-wrapper', el => el.textContent)).includes('echo $$ $1 $&'), 'code blocks preserve special dollar patterns'); assert.ok(await page.$eval('.host-message-queue',el=>el.getBoundingClientRect().height<=100),'one queued message stays compact'); const actions=await page.$$eval('.chat-page__send-btn', buttons=>buttons.map(button=>({disabled:button.disabled,stop:button.classList.contains('is-stop')}))); diff --git a/src/mobile-web/tests/mobile-ui-components.test.mjs b/src/mobile-web/tests/mobile-ui-components.test.mjs index f9a6654ace..78da475c50 100644 --- a/src/mobile-web/tests/mobile-ui-components.test.mjs +++ b/src/mobile-web/tests/mobile-ui-components.test.mjs @@ -333,7 +333,7 @@ test('mobile chat markdown lazily loads katex plugins and keeps mobile display s assert.match(math, /import rehypeKatex from 'rehype-katex';/); assert.match(math, /import remarkMath from 'remark-math';/); assert.match(math, /import 'katex\/dist\/katex\.min\.css';/); - assert.match(math, /remarkPlugins=\{\[remarkGfm,\s*remarkMath\]\}/); + assert.match(math, /remarkPlugins=\{\[remarkGfm,\s*\[remarkMath,\s*\{\s*singleDollarTextMath:\s*false\s*\}\]\]\}/); assert.match(math, /rehypePlugins=\{\[\[rehypeKatex,\s*\{\s*strict:\s*false,\s*throwOnError:\s*false\s*\}\]\]\}/); assert.match(markdown, /import\('\.\/MarkdownMathContent'\)/); assert.match(markdown, /mayContainMarkdownMath\(content\)/); @@ -370,14 +370,30 @@ test('normalizeMathDelimiters protects code blocks and normalizes LaTeX delimite assert.equal(normalize(''), ''); assert.equal(normalize('plain text with $10 and $20'), 'plain text with $10 and $20'); - // Inline bracket \( ... \) to $ ... $ - assert.equal(normalize('Inline \\( a + b = c \\) math'), 'Inline $a + b = c$ math'); + // Inline bracket \( ... \) becomes inline $$ ... $$ math. + assert.equal(normalize('Inline \\( a + b = c \\) math'), 'Inline $$a + b = c$$ math'); - // Block bracket \[ ... \] to $$ ... $$ - assert.match(normalize('Block \\[\\frac{1}{2}\\] math'), /\$\$\s*\n\\frac\{1\}\{2\}\n\s*\$\$/); + // A display bracket that owns its line becomes a block; inside a container + // it stays inline so the list, table, or quote is not torn apart. + assert.match(normalize('\\[\\frac{1}{2}\\]'), /\$\$\s*\n\\frac\{1\}\{2\}\n\s*\$\$/); + assert.equal(normalize('Block \\[\\frac{1}{2}\\] math'), 'Block $$\\frac{1}{2}$$ math'); + assert.equal(normalize('- Energy: $$E=mc^2$$'), '- Energy: $$E=mc^2$$'); + assert.equal(normalize('| \\[x^2\\] | 2 |'), '| $$x^2$$ | 2 |'); + assert.equal(normalize('> \\[x^2\\]'), '> $$x^2$$'); - // Single line $$...$$ to multi-line $$...$$ + // Single line $$...$$ is a block only at a top-level line start. assert.match(normalize('$$E=mc^2$$'), /\$\$\s*\nE=mc\^2\n\s*\$\$/); + assert.equal(normalize('Formula: $$E=mc^2$$'), 'Formula: $$E=mc^2$$'); + + // Single-dollar math is widened to $$ so remark-math can parse it with + // singleDollarTextMath disabled; currency, shell variables, and paths stay text. + assert.equal(normalize('Inline $x$ and $y$'), 'Inline $$x$$ and $$y$$'); + assert.equal(normalize('The $n$th term'), 'The $$n$$th term'); + assert.equal(normalize('Cost $5; formula \\(x\\)'), 'Cost $5; formula $$x$$'); + assert.equal(normalize('Cost is $5 and $10.'), 'Cost is $5 and $10.'); + assert.equal(normalize('Use $HOME and $PATH'), 'Use $HOME and $PATH'); + assert.equal(normalize('Use ${HOME} and ${PATH}'), 'Use ${HOME} and ${PATH}'); + assert.equal(normalize('C:\\[data\\]\\file.txt'), 'C:\\[data\\]\\file.txt'); // Escaped delimiters are literal text, not math. assert.equal(normalize('Escaped \\\\[index\\\\] and \\\\(x\\\\)'), 'Escaped \\\\[index\\\\] and \\\\(x\\\\)'); @@ -386,7 +402,11 @@ test('normalizeMathDelimiters protects code blocks and normalizes LaTeX delimite const codeFenceSample = 'Formula \\(x\\)\n```bash\necho $$ $1 $&\n```\nDone'; const codeFenceNormalized = normalize(codeFenceSample); assert.ok(codeFenceNormalized.includes('echo $$ $1 $&'), 'code blocks must preserve literal $$ and $& without replace corruption'); - assert.ok(codeFenceNormalized.includes('$x$'), 'formula outside code fence must be normalized'); + assert.ok(codeFenceNormalized.includes('$$x$$'), 'formula outside code fence must be normalized'); + + // Blockquoted fences are code too. + const quotedFence = '> ```bash\n> echo \\(x\\)\n> ```'; + assert.equal(normalize(quotedFence), quotedFence); // Multi-backtick inline code with internal backtick or brackets const multiBacktickSample = 'Look at ``code ` with \\[index\\] inside`` and \\(y\\)'; From 4f2052d1f1c67708f17dc6a9e98fec1342fb87f4 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 19 Oct 2026 10:06:57 +0800 Subject: [PATCH 5/5] fix(mobile-web): share katex containment and surface load failures Move the KaTeX display containment rules to the chat page so thinking previews and background-receipt prompts get the same scrolling, theme the parse-error text, and log a failed math chunk load while keeping the base renderer. --- src/mobile-web/src/components/ChatMarkdown.tsx | 4 +++- src/mobile-web/src/styles/components/markdown.scss | 12 +++++++++++- src/mobile-web/tests/mobile-ui-components.test.mjs | 1 + 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/mobile-web/src/components/ChatMarkdown.tsx b/src/mobile-web/src/components/ChatMarkdown.tsx index f5e82cee4b..8bb9c4b430 100644 --- a/src/mobile-web/src/components/ChatMarkdown.tsx +++ b/src/mobile-web/src/components/ChatMarkdown.tsx @@ -389,7 +389,9 @@ export const MarkdownContent: React.FC = ({ content, onFil .then((module) => { if (!cancelled) setMathRenderer(() => module.MarkdownMathContent); }) - .catch(() => {}); + .catch((error) => { + console.error('[ChatMarkdown] math renderer failed to load', error); + }); return () => { cancelled = true; }; diff --git a/src/mobile-web/src/styles/components/markdown.scss b/src/mobile-web/src/styles/components/markdown.scss index 42e79268e6..e8db4eaf78 100644 --- a/src/mobile-web/src/styles/components/markdown.scss +++ b/src/mobile-web/src/styles/components/markdown.scss @@ -480,9 +480,13 @@ margin-right: 6px; accent-color: var(--openbitfun-color-accent-default); } +} - // ─── Math (KaTeX) ─────────────────────────────────────────────────────────── +// ─── Math (KaTeX) ───────────────────────────────────────────────────────────── +// Assistant markdown, thinking previews, and background-receipt prompts can all +// render formulas, so the mobile containment rules are shared by the page. +.chat-page { .katex-display { overflow-x: auto; overflow-y: hidden; @@ -503,6 +507,12 @@ line-height: 1.2; text-indent: 0; } + + // KaTeX hard-codes #cc0000 for parse errors; keep it readable on the dark + // mobile theme. + .katex-error { + color: var(--openbitfun-color-status-danger-content); + } } // Output bytes can be larger than the viewport. Keep their aspect ratio in diff --git a/src/mobile-web/tests/mobile-ui-components.test.mjs b/src/mobile-web/tests/mobile-ui-components.test.mjs index 78da475c50..4375130437 100644 --- a/src/mobile-web/tests/mobile-ui-components.test.mjs +++ b/src/mobile-web/tests/mobile-ui-components.test.mjs @@ -345,6 +345,7 @@ test('mobile chat markdown lazily loads katex plugins and keeps mobile display s assert.match(markdownStyles, /\.katex-display\s*\{[\s\S]*?overflow-x:\s*auto;/); assert.match(markdownStyles, /\.katex-display\s*\{[\s\S]*?-webkit-overflow-scrolling:\s*touch;/); assert.match(markdownStyles, /\.katex\s*\{[\s\S]*?font-size:\s*1\.05em;/); + assert.match(markdownStyles, /\.katex-error\s*\{[\s\S]*?color:\s*var\(--openbitfun-color-status-danger-content\);/); }); test('math activation ignores currency-only dollar pairs', async () => {