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
34 changes: 34 additions & 0 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,40 @@
font-weight: var(--font-chat-weight);
}

.chat-font-content .katex,
.chat-font-content .katex
:where(
.mathnormal,
.mathit,
.mathrm,
.mathbf,
.boldsymbol,
.textrm,
.mainrm,
.textbf,
.textit,
.mord,
.mop,
.mbin,
.mrel,
.mopen,
.mclose,
.mpunct,
.minner
) {
font-family: "Times New Roman", Times, serif !important;
}

.chat-font-content .katex,
.chat-font-content .katex :where(.base, .strut, .mord, .mop, .mbin, .mrel, .mopen, .mclose, .mpunct, .minner) {
font-weight: 400 !important;
}

.chat-font-content .katex
:where(.textbf, .mathbf, .boldsymbol, .mathboldfrak, .textboldfrak, .mathboldsf, .textboldsf) {
font-weight: 700 !important;
}

.from-bg-200\/5 {
--tw-gradient-from: color-mix(in oklch, var(--background) 5%, transparent) var(--tw-gradient-from-position);
--tw-gradient-to: color-mix(in oklch, var(--background) 0%, transparent) var(--tw-gradient-to-position);
Expand Down
180 changes: 146 additions & 34 deletions frontend/features/chat/components/markdown/streamdown-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,26 +43,148 @@ export function normalizeContent(input: unknown): string {
return "";
}

export function normalizeMathDelimiters(source: string): string {
if (!source || (!source.includes("\\(") && !source.includes("\\["))) {
return source;
}
const MARKDOWN_LITERAL_FRAGMENT_RE = /(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`)/g;
const INLINE_DOLLAR_MATH_RE = /(^|[^\\$])\$([^$\n]{1,800})\$/g;
const ESCAPED_INLINE_DOLLAR_MATH_RE = /\\\$([^$\n]{1,400})\\\$/g;
const DISPLAY_DOLLAR_MATH_RE = /(\${2,})([\s\S]*?)(\1)/g;

const fragments = source.split(/(```[\s\S]*?```|`[^`\n]*`)/g);
function isMarkdownLiteralFragment(fragment: string): boolean {
return fragment.startsWith("```") || fragment.startsWith("~~~") || fragment.startsWith("`");
}

return fragments
function mapMarkdownTextFragments(source: string, transform: (fragment: string) => string): string {
return source
.split(MARKDOWN_LITERAL_FRAGMENT_RE)
.map((fragment) => {
if (!fragment || fragment.startsWith("```") || fragment.startsWith("`")) {
if (!fragment || isMarkdownLiteralFragment(fragment)) {
return fragment;
}

return fragment
.replace(/\\\[\s*\n?([\s\S]*?)\n?\s*\\\]/g, (_, mathContent: string) => `$$\n${mathContent.trim()}\n$$`)
.replace(/\\\(([\s\S]*?)\\\)/g, (_, mathContent: string) => `$${mathContent.trim()}$`);
return transform(fragment);
})
.join("");
}

function looksLikeLatexMathContent(value: string): boolean {
const trimmedValue = value.trim();
if (!trimmedValue || /^\d+(?:[.,]\d+)?$/.test(trimmedValue)) {
return false;
}

return (
/\\[A-Za-z]+/.test(trimmedValue) ||
/[\^_{}=<>+\-*/]/.test(trimmedValue) ||
(trimmedValue.includes("|") && /[A-Za-z\\Α-ω]|[\^_{}=<>+\-*/]/.test(trimmedValue)) ||
/^[A-Za-z]$/.test(trimmedValue) ||
/[Α-ω]/.test(trimmedValue)
);
}

function normalizeLatexPipes(mathContent: string): string {
return mathContent.replace(/(^|[^\\])\|/g, "$1\\vert{}");
}

function isEscapedCharacter(source: string, index: number): boolean {
let slashCount = 0;
for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) {
slashCount += 1;
}
return slashCount % 2 === 1;
}

function getDollarMathDelimiterLength(source: string, index: number): number {
if (source[index] !== "$" || isEscapedCharacter(source, index) || source[index - 1] === "$") {
return 0;
}

if (source[index + 1] === "$" && source[index + 2] !== "$") {
return 2;
}

return source[index + 1] === "$" ? 0 : 1;
}

function normalizeDollarMathContent(mathContent: string, inline: boolean): string {
const normalizedContent = inline ? mathContent.replace(/\s*\n\s*/g, " ") : mathContent;
return normalizeLatexPipes(normalizedContent);
}

function normalizeDollarMathSegments(source: string): string {
if (!source.includes("$")) {
return source;
}

let normalizedSource = "";
let consumedUntil = 0;

for (let index = 0; index < source.length; index += 1) {
const delimiterLength = getDollarMathDelimiterLength(source, index);
if (!delimiterLength) {
continue;
}

const openingDelimiterIndex = index;
let closingDelimiterIndex = -1;
for (let cursor = openingDelimiterIndex + delimiterLength; cursor < source.length; cursor += 1) {
if (getDollarMathDelimiterLength(source, cursor) === delimiterLength) {
closingDelimiterIndex = cursor;
break;
}
}

if (closingDelimiterIndex < 0) {
break;
}

const mathContent = source.slice(openingDelimiterIndex + delimiterLength, closingDelimiterIndex);
const inline = delimiterLength === 1;
const shouldNormalize =
(mathContent.includes("|") || (inline && mathContent.includes("\n"))) &&
looksLikeLatexMathContent(mathContent);

if (shouldNormalize) {
normalizedSource += source.slice(consumedUntil, openingDelimiterIndex + delimiterLength);
normalizedSource += normalizeDollarMathContent(mathContent, inline);
normalizedSource += source.slice(closingDelimiterIndex, closingDelimiterIndex + delimiterLength);
consumedUntil = closingDelimiterIndex + delimiterLength;
}

index = closingDelimiterIndex + delimiterLength - 1;
}

if (!consumedUntil) {
return source;
}

return normalizedSource + source.slice(consumedUntil);
}

function normalizeLatexDelimitersInText(source: string): string {
return source
.replace(/\\\[\s*\n?([\s\S]*?)\n?\s*\\\]/g, (_, mathContent: string) => `$$\n${mathContent.trim()}\n$$`)
.replace(/\\\(([\s\S]*?)\\\)/g, (_, mathContent: string) => `$${mathContent.trim()}$`)
.replace(ESCAPED_INLINE_DOLLAR_MATH_RE, (match: string, mathContent: string) => {
const trimmedMathContent = mathContent.trim();
return looksLikeLatexMathContent(trimmedMathContent) ? `$${trimmedMathContent}$` : match;
});
}

export function normalizeMathDelimiters(source: string): string {
if (!source) {
return source;
}

const shouldNormalizeDelimiters = source.includes("\\(") || source.includes("\\[") || source.includes("\\$");
const hasDollarMath = source.includes("$");
if (!shouldNormalizeDelimiters && !hasDollarMath) {
return source;
}

return mapMarkdownTextFragments(source, (fragment) => {
const normalizedFragment = shouldNormalizeDelimiters ? normalizeLatexDelimitersInText(fragment) : fragment;
return normalizedFragment.includes("$") ? normalizeDollarMathSegments(normalizedFragment) : normalizedFragment;
});
}

const LATEX_UNICODE_SYMBOLS: Array<[RegExp, string]> = [
[/→/g, " \\to "],
[/←/g, " \\leftarrow "],
Expand All @@ -72,7 +194,6 @@ const LATEX_UNICODE_SYMBOLS: Array<[RegExp, string]> = [
[/⇔/g, " \\Leftrightarrow "],
];

const INLINE_CODE_OR_FENCE_RE = /(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`)/g;
const THINKING_LIKE_HTML_TAG_RE = /<\/?\s*think[\w-]*\b[^>]*>/gi;

function escapeHtmlTag(value: string): string {
Expand All @@ -87,16 +208,7 @@ function escapeThinkingLikeHtmlTags(source: string): string {
return source;
}

return source
.split(INLINE_CODE_OR_FENCE_RE)
.map((fragment) => {
if (!fragment || fragment.startsWith("```") || fragment.startsWith("~~~") || fragment.startsWith("`")) {
return fragment;
}

return fragment.replace(THINKING_LIKE_HTML_TAG_RE, escapeHtmlTag);
})
.join("");
return mapMarkdownTextFragments(source, (fragment) => fragment.replace(THINKING_LIKE_HTML_TAG_RE, escapeHtmlTag));
}

function normalizeLatexSymbols(mathContent: string): string {
Expand All @@ -111,23 +223,23 @@ export function normalizeLatexUnicodeSymbols(source: string): string {
return source;
}

const fragments = source.split(/(```[\s\S]*?```|`[^`\n]*`)/g);

return fragments
.map((fragment) => {
if (!fragment || fragment.startsWith("```") || fragment.startsWith("`")) {
return fragment;
}

return fragment.replace(/(\${2,})([\s\S]*?)(\1)/g, (match, openingDelimiter: string, mathContent: string, closingDelimiter: string) => {
return mapMarkdownTextFragments(source, (fragment) =>
fragment
.replace(DISPLAY_DOLLAR_MATH_RE, (match, openingDelimiter: string, mathContent: string, closingDelimiter: string) => {
if (!mathContent) {
return match;
}

return `${openingDelimiter}${normalizeLatexSymbols(mathContent)}${closingDelimiter}`;
});
})
.join("");
})
.replace(INLINE_DOLLAR_MATH_RE, (match: string, prefix: string, mathContent: string) => {
if (!mathContent) {
return match;
}

return `${prefix}$${normalizeLatexSymbols(mathContent)}$`;
}),
);
}

export function normalizeMermaidBlocks(source: string): string {
Expand Down
Loading
Loading