Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
"buffer": "^6.0.3",
"cmdk": "^1.1.1",
"debug": "^4.4.3",
"katex": "^0.16.47",
"lottie-react": "^2.4.1",
"os-browserify": "^0.3.0",
"process": "^0.11.10",
Expand All @@ -106,6 +107,8 @@
"react-redux": "^9.2.0",
"react-router-dom": "^7.13.0",
"redux-persist": "^6.0.0",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
"remotion": "4.0.454",
"socket.io-client": "^4.8.3",
"tauri-plugin-ptt-api": "workspace:*",
Expand All @@ -114,8 +117,8 @@
"zod": "4.3.6"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@eslint/js": "^9.39.2",
"@playwright/test": "^1.56.1",
"@sentry/vite-plugin": "^2.22.6",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
Expand Down
1 change: 1 addition & 0 deletions app/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// IMPORTANT: Polyfills must be imported FIRST
import { getCurrentWindow } from '@tauri-apps/api/window';
import 'katex/dist/katex.min.css';
import React from 'react';
import ReactDOM from 'react-dom/client';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,43 @@ describe('AgentMessageBubble markdown links', () => {
expect(mocks.openWorkspacePath).not.toHaveBeenCalled();
});
});

describe('BubbleMarkdown math rendering', () => {
test('renders \\[ ... \\] block math (raw delimiters consumed, math visible)', () => {
const { container } = render(<BubbleMarkdown content={'\\[ x^2 + y^2 = z^2 \\]'} />);
const text = container.textContent ?? '';
expect(text).not.toContain('\\[');
expect(text).not.toContain('\\]');
expect(text).toContain('x');
expect(text).toContain('y');
expect(text).toContain('z');
});

test('renders inline \\( ... \\) math (raw delimiters consumed, math visible)', () => {
const { container } = render(<BubbleMarkdown content={'value \\(a+b\\) here'} />);
const text = container.textContent ?? '';
expect(text).not.toContain('\\(');
expect(text).not.toContain('\\)');
expect(text).toContain('value');
expect(text).toContain('here');
expect(text).toContain('a');
expect(text).toContain('b');
});

test('renders bare bracket vmatrix block (math rendered, not raw text)', () => {
const { container } = render(
<BubbleMarkdown content={'[ \\begin{vmatrix} 1 & 2 \\\\ 3 & 4 \\end{vmatrix} = -2 ]'} />
);
const text = container.textContent ?? '';
// KaTeX renders visible glyphs (∣ for vmatrix bars) — confirm rendering happened.
expect(text).toContain('∣');
expect(text).toContain('1');
expect(text).toContain('4');
});

test('does NOT treat currency mentions as math', () => {
const { container } = render(<BubbleMarkdown content={'total is $10 versus $20'} />);
expect(container.textContent).toContain('$10');
expect(container.textContent).toContain('$20');
});
});
28 changes: 24 additions & 4 deletions app/src/pages/conversations/components/AgentMessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { ReactNode } from 'react';
import Markdown, { defaultUrlTransform } from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import remarkMath from 'remark-math';

import { OPENHUMAN_LINK_EVENT } from '../../../components/OpenhumanLinkModal';
import { parseMarkdownTable } from '../../../utils/agentMessageBubbles';
import { hasLatexContent, normalizeLatexDelimiters } from '../../../utils/latex';
import { openUrl } from '../../../utils/openUrl';
import { openWorkspacePath } from '../../../utils/tauriCommands/workspacePaths';
import { parseWorkspaceHref } from '../../../utils/workspaceLinks';
Expand All @@ -13,6 +16,10 @@ import {
parseBubbleSegments,
} from '../utils/format';

const MATH_REMARK_PLUGINS = [remarkMath];
const MATH_REHYPE_PLUGINS = [rehypeKatex];
const EMPTY_PLUGINS: [] = [];

/**
* Pill rendered below an agent bubble for each
* `<openhuman-link path="...">label</openhuman-link>` tag the agent
Expand Down Expand Up @@ -80,23 +87,36 @@ export function BubbleMarkdown({
? 'prose-invert prose-p:text-white prose-li:text-white prose-a:text-white prose-code:text-white prose-strong:text-white prose-headings:text-white [&_li::marker]:text-white/85'
: 'dark:prose-invert prose-a:text-primary-500 prose-code:text-primary-700 dark:prose-code:text-primary-300 prose-headings:text-sm [&_li::marker]:text-stone-700 dark:[&_li::marker]:text-neutral-300';

const hasMath = hasLatexContent(content);
const rendered = hasMath ? normalizeLatexDelimiters(content) : content;

return (
<div
className={`text-sm prose prose-sm max-w-none prose-p:my-1 prose-pre:my-2 prose-pre:rounded-lg prose-code:text-xs prose-headings:font-semibold prose-ul:my-0 prose-ol:my-0 prose-li:my-0 ${proseTone} ${
tone === 'user' ? 'prose-pre:bg-white/10' : 'prose-pre:bg-stone-300/50'
} [&_ul]:my-0 [&_ol]:my-0 [&_ul]:pl-0 [&_ol]:pl-0 [&_ul]:list-inside [&_ol]:list-inside [&_li]:my-0 [&_li]:pl-0 [&_li_p]:inline [&_li_p]:m-0`}>
<Markdown urlTransform={transformMarkdownUrl} components={{ a: MarkdownAnchor }}>
{content}
<Markdown
urlTransform={transformMarkdownUrl}
components={{ a: MarkdownAnchor }}
remarkPlugins={hasMath ? MATH_REMARK_PLUGINS : EMPTY_PLUGINS}
rehypePlugins={hasMath ? MATH_REHYPE_PLUGINS : EMPTY_PLUGINS}>
{rendered}
</Markdown>
</div>
);
}

export function TableCellMarkdown({ content }: { content: string }) {
const hasMath = hasLatexContent(content);
const rendered = hasMath ? normalizeLatexDelimiters(content) : content;
return (
<div className="prose prose-sm dark:prose-invert max-w-none text-sm text-stone-700 dark:text-neutral-200 prose-p:my-0 prose-ul:my-0 prose-ol:my-0 prose-li:my-0 prose-code:text-xs prose-code:text-primary-700 dark:prose-code:text-primary-300 prose-a:text-primary-500 prose-strong:text-stone-900 dark:prose-strong:text-neutral-100 prose-headings:text-sm prose-headings:font-semibold [&_li::marker]:text-stone-700 dark:[&_li::marker]:text-neutral-300 [&_ul]:my-0 [&_ol]:my-0 [&_ul]:pl-0 [&_ol]:pl-0 [&_ul]:list-inside [&_ol]:list-inside [&_li]:pl-0 [&_li_p]:inline [&_li_p]:m-0">
<Markdown urlTransform={transformMarkdownUrl} components={{ a: MarkdownAnchor }}>
{content}
<Markdown
urlTransform={transformMarkdownUrl}
components={{ a: MarkdownAnchor }}
remarkPlugins={hasMath ? MATH_REMARK_PLUGINS : EMPTY_PLUGINS}
rehypePlugins={hasMath ? MATH_REHYPE_PLUGINS : EMPTY_PLUGINS}>
{rendered}
</Markdown>
</div>
);
Expand Down
91 changes: 91 additions & 0 deletions app/src/utils/__tests__/latex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';

import { hasLatexContent, normalizeLatexDelimiters } from '../latex';

describe('normalizeLatexDelimiters', () => {
it('converts \\[ ... \\] to $$ ... $$', () => {
expect(normalizeLatexDelimiters('\\[ x^2 + y^2 = z^2 \\]')).toContain('$$ x^2 + y^2 = z^2 $$');
});

it('converts \\( ... \\) to $ ... $', () => {
expect(normalizeLatexDelimiters('inline \\(a+b\\) here')).toBe('inline $a+b$ here');
});

it('converts bare bracketed LaTeX-only block to $$', () => {
const input =
'直接套公式:\n\n[ V_3 = (x_2 - x_1)(x_3 - x_1)(x_3 - x_2) = 1 \\times 3 \\times 2 = 6 ]';
const out = normalizeLatexDelimiters(input);
expect(out).toContain('$$');
expect(out).toContain('\\times');
expect(out).not.toMatch(/\[ V_3/);
});

it('preserves markdown link syntax', () => {
const input = 'see [link](https://example.com) and more';
expect(normalizeLatexDelimiters(input)).toBe(input);
});

it('returns input unchanged when no LaTeX present', () => {
const input = 'plain text with no math';
expect(normalizeLatexDelimiters(input)).toBe(input);
});

it('handles vmatrix blocks', () => {
const input = '[ \\begin{vmatrix} 1 & 2 \\\\ 3 & 4 \\end{vmatrix} = -2 ]';
const out = normalizeLatexDelimiters(input);
expect(out).toContain('$$');
expect(out).toContain('\\begin{vmatrix}');
});

it('preserves \\[...\\] inside inline code spans', () => {
const input = 'use `\\[x^2\\]` for display math and \\[a+b\\] renders';
const out = normalizeLatexDelimiters(input);
expect(out).toContain('`\\[x^2\\]`');
expect(out).toContain('$$a+b$$');
});

it('preserves \\(...\\) inside inline code spans', () => {
const input = 'use `\\(x\\)` for inline math and \\(a+b\\) renders';
const out = normalizeLatexDelimiters(input);
expect(out).toContain('`\\(x\\)`');
expect(out).toContain('$a+b$');
});

it('preserves LaTeX delimiters inside fenced code blocks', () => {
const input = '```\n\\[x^2\\]\n\\(y\\)\n```\n\nthen \\(a+b\\) here';
const out = normalizeLatexDelimiters(input);
expect(out).toContain('```\n\\[x^2\\]\n\\(y\\)\n```');
expect(out).toContain('$a+b$');
});

it('does not corrupt math bodies containing digits when code blocks present', () => {
const input = 'use `x` and \\[a^2 + 7\\] and `y` and \\(b_3\\)';
const out = normalizeLatexDelimiters(input);
expect(out).toContain('`x`');
expect(out).toContain('`y`');
expect(out).toContain('$$a^2 + 7$$');
expect(out).toContain('$b_3$');
expect(out).not.toContain('undefined');
});
});

describe('hasLatexContent', () => {
it('detects backslash math commands', () => {
expect(hasLatexContent('use \\frac{1}{2} here')).toBe(true);
expect(hasLatexContent('\\begin{vmatrix} 1 \\end{vmatrix}')).toBe(true);
expect(hasLatexContent('\\[ a \\]')).toBe(true);
expect(hasLatexContent('\\(a\\)')).toBe(true);
expect(hasLatexContent('$$x$$')).toBe(true);
});

it('rejects currency mentions', () => {
expect(hasLatexContent('total is $10 and $20')).toBe(false);
expect(hasLatexContent('$100')).toBe(false);
});

it('rejects plain text and markdown', () => {
expect(hasLatexContent('see [link](https://example.com)')).toBe(false);
expect(hasLatexContent('hello world')).toBe(false);
expect(hasLatexContent('')).toBe(false);
});
});
68 changes: 68 additions & 0 deletions app/src/utils/latex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Normalize LaTeX math delimiters emitted by upstream LLMs into the
* `$...$` / `$$...$$` form that `remark-math` understands.
*
* Models frequently emit `\[ ... \]` (display) and `\( ... \)` (inline)
* or even bare `[ ... ]` blocks containing `\begin{vmatrix}`, `\cdot`,
* `x_1`, etc. Without this normalization those land in chat as raw
* source instead of rendered math.
*/

const DISPLAY_BACKSLASH = /\\\[([\s\S]+?)\\\]/g;
const INLINE_BACKSLASH = /\\\(([\s\S]+?)\\\)/g;

// Bare `[ ... ]` block that contains a LaTeX-only signal (\begin, \cdot,
// \times, etc.) and lives on its own line. Conservative: avoids matching
// markdown link/image syntax (`[text](url)`, `![alt](src)`).
const DISPLAY_BARE_BRACKETS =
/(^|\n)[ \t]*\[[ \t]*((?:[^[\]\n]|\n(?!\n))*?\\(?:begin|end|frac|sqrt|cdot|times|sum|int|prod|lim|left|right|vmatrix|pmatrix|bmatrix|matrix|mathrm|mathbf|mathbb|alpha|beta|gamma|delta|theta|pi|sigma|infty)[^[\]]*?)[ \t]*\][ \t]*(?=\n|$)/g;

// Match fenced code blocks (```...```) and inline code spans (`...`) so
// they can be masked out before delimiter normalization. Without this,
// content like "use `\[x^2\]` for display math" would get its inline
// code corrupted into `$$x^2$$`.
const CODE_BLOCKS = /```[\s\S]*?```|`[^`\n]+`/g;

// Sentinel uses Unicode Private Use Area (U+E000 / U+E001):
// - non-control (ESLint `no-control-regex` does not fire)
// - reserved by Unicode for private use, will not appear in real chat text
// - wrapped on both sides so the restore regex matches *only* placeholders
// and never stray digits inside math bodies (e.g. `$$a^2$$`).
const PLACEHOLDER_OPEN = '';
const PLACEHOLDER_CLOSE = '';
const PLACEHOLDER = /(\d+)/g;

export function normalizeLatexDelimiters(input: string): string {
if (!input || (!input.includes('\\') && !input.includes('['))) return input;

const codeSegments: string[] = [];
let out = input.replace(CODE_BLOCKS, match => {
codeSegments.push(match);
return `${PLACEHOLDER_OPEN}${codeSegments.length - 1}${PLACEHOLDER_CLOSE}`;
});

out = out.replace(DISPLAY_BACKSLASH, (_m, body) => `\n\n$$${body}$$\n\n`);
out = out.replace(INLINE_BACKSLASH, (_m, body) => `$${body}$`);
Comment thread
YellowSnnowmann marked this conversation as resolved.
out = out.replace(DISPLAY_BARE_BRACKETS, (_m, lead, body) => `${lead}\n$$${body}$$\n`);

if (codeSegments.length > 0) {
out = out.replace(PLACEHOLDER, (_m, i) => codeSegments[Number(i)] ?? '');
}
return out;
}

/**
* Heuristic: does this string likely contain LaTeX math?
*
* We use this to gate `remark-math` + `rehype-katex` so plain chat
* messages (e.g. "$10 vs $20", "[link](url)") are never reinterpreted as
* math. Only content the LLM clearly intended as math turns the plugins
* on.
*/
const LATEX_SIGNATURE =
/\\(?:begin|end|frac|sqrt|cdot|times|sum|int|prod|lim|left|right|vmatrix|pmatrix|bmatrix|matrix|mathrm|mathbf|mathbb|alpha|beta|gamma|delta|theta|pi|sigma|infty)\b|\\\[|\\\(|\$\$/;

export function hasLatexContent(input: string): boolean {
if (!input) return false;
return LATEX_SIGNATURE.test(input);
}
Loading
Loading