Skip to content

Commit c13cd43

Browse files
authored
Merge pull request #41 from DEEIX-AI/enhance_latex
feat: enhance math rendering and copy functionality in chat
2 parents 6d2c62d + 38f7b57 commit c13cd43

6 files changed

Lines changed: 462 additions & 50 deletions

File tree

frontend/app/globals.css

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,6 +1115,40 @@
11151115
font-weight: var(--font-chat-weight);
11161116
}
11171117

1118+
.chat-font-content .katex,
1119+
.chat-font-content .katex
1120+
:where(
1121+
.mathnormal,
1122+
.mathit,
1123+
.mathrm,
1124+
.mathbf,
1125+
.boldsymbol,
1126+
.textrm,
1127+
.mainrm,
1128+
.textbf,
1129+
.textit,
1130+
.mord,
1131+
.mop,
1132+
.mbin,
1133+
.mrel,
1134+
.mopen,
1135+
.mclose,
1136+
.mpunct,
1137+
.minner
1138+
) {
1139+
font-family: "Times New Roman", Times, serif !important;
1140+
}
1141+
1142+
.chat-font-content .katex,
1143+
.chat-font-content .katex :where(.base, .strut, .mord, .mop, .mbin, .mrel, .mopen, .mclose, .mpunct, .minner) {
1144+
font-weight: 400 !important;
1145+
}
1146+
1147+
.chat-font-content .katex
1148+
:where(.textbf, .mathbf, .boldsymbol, .mathboldfrak, .textboldfrak, .mathboldsf, .textboldsf) {
1149+
font-weight: 700 !important;
1150+
}
1151+
11181152
.from-bg-200\/5 {
11191153
--tw-gradient-from: color-mix(in oklch, var(--background) 5%, transparent) var(--tw-gradient-from-position);
11201154
--tw-gradient-to: color-mix(in oklch, var(--background) 0%, transparent) var(--tw-gradient-to-position);

frontend/features/chat/components/markdown/streamdown-content.ts

Lines changed: 146 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -43,26 +43,148 @@ export function normalizeContent(input: unknown): string {
4343
return "";
4444
}
4545

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

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

53-
return fragments
55+
function mapMarkdownTextFragments(source: string, transform: (fragment: string) => string): string {
56+
return source
57+
.split(MARKDOWN_LITERAL_FRAGMENT_RE)
5458
.map((fragment) => {
55-
if (!fragment || fragment.startsWith("```") || fragment.startsWith("`")) {
59+
if (!fragment || isMarkdownLiteralFragment(fragment)) {
5660
return fragment;
5761
}
58-
59-
return fragment
60-
.replace(/\\\[\s*\n?([\s\S]*?)\n?\s*\\\]/g, (_, mathContent: string) => `$$\n${mathContent.trim()}\n$$`)
61-
.replace(/\\\(([\s\S]*?)\\\)/g, (_, mathContent: string) => `$${mathContent.trim()}$`);
62+
return transform(fragment);
6263
})
6364
.join("");
6465
}
6566

67+
function looksLikeLatexMathContent(value: string): boolean {
68+
const trimmedValue = value.trim();
69+
if (!trimmedValue || /^\d+(?:[.,]\d+)?$/.test(trimmedValue)) {
70+
return false;
71+
}
72+
73+
return (
74+
/\\[A-Za-z]+/.test(trimmedValue) ||
75+
/[\^_{}=<>+\-*/]/.test(trimmedValue) ||
76+
(trimmedValue.includes("|") && /[A-Za-z\\Α-ω]|[\^_{}=<>+\-*/]/.test(trimmedValue)) ||
77+
/^[A-Za-z]$/.test(trimmedValue) ||
78+
/[Α-ω]/.test(trimmedValue)
79+
);
80+
}
81+
82+
function normalizeLatexPipes(mathContent: string): string {
83+
return mathContent.replace(/(^|[^\\])\|/g, "$1\\vert{}");
84+
}
85+
86+
function isEscapedCharacter(source: string, index: number): boolean {
87+
let slashCount = 0;
88+
for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) {
89+
slashCount += 1;
90+
}
91+
return slashCount % 2 === 1;
92+
}
93+
94+
function getDollarMathDelimiterLength(source: string, index: number): number {
95+
if (source[index] !== "$" || isEscapedCharacter(source, index) || source[index - 1] === "$") {
96+
return 0;
97+
}
98+
99+
if (source[index + 1] === "$" && source[index + 2] !== "$") {
100+
return 2;
101+
}
102+
103+
return source[index + 1] === "$" ? 0 : 1;
104+
}
105+
106+
function normalizeDollarMathContent(mathContent: string, inline: boolean): string {
107+
const normalizedContent = inline ? mathContent.replace(/\s*\n\s*/g, " ") : mathContent;
108+
return normalizeLatexPipes(normalizedContent);
109+
}
110+
111+
function normalizeDollarMathSegments(source: string): string {
112+
if (!source.includes("$")) {
113+
return source;
114+
}
115+
116+
let normalizedSource = "";
117+
let consumedUntil = 0;
118+
119+
for (let index = 0; index < source.length; index += 1) {
120+
const delimiterLength = getDollarMathDelimiterLength(source, index);
121+
if (!delimiterLength) {
122+
continue;
123+
}
124+
125+
const openingDelimiterIndex = index;
126+
let closingDelimiterIndex = -1;
127+
for (let cursor = openingDelimiterIndex + delimiterLength; cursor < source.length; cursor += 1) {
128+
if (getDollarMathDelimiterLength(source, cursor) === delimiterLength) {
129+
closingDelimiterIndex = cursor;
130+
break;
131+
}
132+
}
133+
134+
if (closingDelimiterIndex < 0) {
135+
break;
136+
}
137+
138+
const mathContent = source.slice(openingDelimiterIndex + delimiterLength, closingDelimiterIndex);
139+
const inline = delimiterLength === 1;
140+
const shouldNormalize =
141+
(mathContent.includes("|") || (inline && mathContent.includes("\n"))) &&
142+
looksLikeLatexMathContent(mathContent);
143+
144+
if (shouldNormalize) {
145+
normalizedSource += source.slice(consumedUntil, openingDelimiterIndex + delimiterLength);
146+
normalizedSource += normalizeDollarMathContent(mathContent, inline);
147+
normalizedSource += source.slice(closingDelimiterIndex, closingDelimiterIndex + delimiterLength);
148+
consumedUntil = closingDelimiterIndex + delimiterLength;
149+
}
150+
151+
index = closingDelimiterIndex + delimiterLength - 1;
152+
}
153+
154+
if (!consumedUntil) {
155+
return source;
156+
}
157+
158+
return normalizedSource + source.slice(consumedUntil);
159+
}
160+
161+
function normalizeLatexDelimitersInText(source: string): string {
162+
return source
163+
.replace(/\\\[\s*\n?([\s\S]*?)\n?\s*\\\]/g, (_, mathContent: string) => `$$\n${mathContent.trim()}\n$$`)
164+
.replace(/\\\(([\s\S]*?)\\\)/g, (_, mathContent: string) => `$${mathContent.trim()}$`)
165+
.replace(ESCAPED_INLINE_DOLLAR_MATH_RE, (match: string, mathContent: string) => {
166+
const trimmedMathContent = mathContent.trim();
167+
return looksLikeLatexMathContent(trimmedMathContent) ? `$${trimmedMathContent}$` : match;
168+
});
169+
}
170+
171+
export function normalizeMathDelimiters(source: string): string {
172+
if (!source) {
173+
return source;
174+
}
175+
176+
const shouldNormalizeDelimiters = source.includes("\\(") || source.includes("\\[") || source.includes("\\$");
177+
const hasDollarMath = source.includes("$");
178+
if (!shouldNormalizeDelimiters && !hasDollarMath) {
179+
return source;
180+
}
181+
182+
return mapMarkdownTextFragments(source, (fragment) => {
183+
const normalizedFragment = shouldNormalizeDelimiters ? normalizeLatexDelimitersInText(fragment) : fragment;
184+
return normalizedFragment.includes("$") ? normalizeDollarMathSegments(normalizedFragment) : normalizedFragment;
185+
});
186+
}
187+
66188
const LATEX_UNICODE_SYMBOLS: Array<[RegExp, string]> = [
67189
[//g, " \\to "],
68190
[//g, " \\leftarrow "],
@@ -72,7 +194,6 @@ const LATEX_UNICODE_SYMBOLS: Array<[RegExp, string]> = [
72194
[//g, " \\Leftrightarrow "],
73195
];
74196

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

78199
function escapeHtmlTag(value: string): string {
@@ -87,16 +208,7 @@ function escapeThinkingLikeHtmlTags(source: string): string {
87208
return source;
88209
}
89210

90-
return source
91-
.split(INLINE_CODE_OR_FENCE_RE)
92-
.map((fragment) => {
93-
if (!fragment || fragment.startsWith("```") || fragment.startsWith("~~~") || fragment.startsWith("`")) {
94-
return fragment;
95-
}
96-
97-
return fragment.replace(THINKING_LIKE_HTML_TAG_RE, escapeHtmlTag);
98-
})
99-
.join("");
211+
return mapMarkdownTextFragments(source, (fragment) => fragment.replace(THINKING_LIKE_HTML_TAG_RE, escapeHtmlTag));
100212
}
101213

102214
function normalizeLatexSymbols(mathContent: string): string {
@@ -111,23 +223,23 @@ export function normalizeLatexUnicodeSymbols(source: string): string {
111223
return source;
112224
}
113225

114-
const fragments = source.split(/(```[\s\S]*?```|`[^`\n]*`)/g);
115-
116-
return fragments
117-
.map((fragment) => {
118-
if (!fragment || fragment.startsWith("```") || fragment.startsWith("`")) {
119-
return fragment;
120-
}
121-
122-
return fragment.replace(/(\${2,})([\s\S]*?)(\1)/g, (match, openingDelimiter: string, mathContent: string, closingDelimiter: string) => {
226+
return mapMarkdownTextFragments(source, (fragment) =>
227+
fragment
228+
.replace(DISPLAY_DOLLAR_MATH_RE, (match, openingDelimiter: string, mathContent: string, closingDelimiter: string) => {
123229
if (!mathContent) {
124230
return match;
125231
}
126232

127233
return `${openingDelimiter}${normalizeLatexSymbols(mathContent)}${closingDelimiter}`;
128-
});
129-
})
130-
.join("");
234+
})
235+
.replace(INLINE_DOLLAR_MATH_RE, (match: string, prefix: string, mathContent: string) => {
236+
if (!mathContent) {
237+
return match;
238+
}
239+
240+
return `${prefix}$${normalizeLatexSymbols(mathContent)}$`;
241+
}),
242+
);
131243
}
132244

133245
export function normalizeMermaidBlocks(source: string): string {

0 commit comments

Comments
 (0)