This commit is contained in:
Zacharias-Brohn
2026-01-14 22:51:46 +01:00
parent e222977f5c
commit c51b3c3fab
14 changed files with 452 additions and 216 deletions
+257 -154
View File
@@ -18,6 +18,7 @@ import {
Text,
Title,
} from '@mantine/core';
import { ToolCallDisplay } from './ToolCallDisplay';
import classes from './MarkdownMessage.module.css';
// Import KaTeX CSS for LaTeX rendering
import 'katex/dist/katex.min.css';
@@ -27,6 +28,71 @@ interface MarkdownMessageProps {
isStreaming?: boolean;
}
interface ParsedToolCall {
toolName: string;
args: Record<string, unknown>;
result: string;
}
interface ContentSegment {
type: 'text' | 'tool';
content?: string;
toolCall?: ParsedToolCall;
}
/**
* Parse content to extract tool calls and regular text segments
*/
function parseContentWithToolCalls(content: string): ContentSegment[] {
const segments: ContentSegment[] = [];
const toolPattern = /<!--TOOL_START:(\w+):(\{.*?\})-->([\s\S]*?)<!--TOOL_END-->/g;
let lastIndex = 0;
let match;
while ((match = toolPattern.exec(content)) !== null) {
// Add text before this tool call
if (match.index > lastIndex) {
const textBefore = content.slice(lastIndex, match.index).trim();
if (textBefore) {
segments.push({ type: 'text', content: textBefore });
}
}
// Parse the tool call
const toolName = match[1];
let args: Record<string, unknown> = {};
try {
args = JSON.parse(match[2]);
} catch {
// Invalid JSON, use empty args
}
const result = match[3].trim();
segments.push({
type: 'tool',
toolCall: { toolName, args, result },
});
lastIndex = match.index + match[0].length;
}
// Add remaining text after last tool call
if (lastIndex < content.length) {
const remainingText = content.slice(lastIndex).trim();
if (remainingText) {
segments.push({ type: 'text', content: remainingText });
}
}
// If no tool calls found, return the whole content as text
if (segments.length === 0 && content.trim()) {
segments.push({ type: 'text', content });
}
return segments;
}
/**
* Hook that tracks content changes and returns content with fade-in markers for new text
*/
@@ -90,165 +156,202 @@ function escapeHtml(text: string): string {
.replace(/'/g, '&#039;');
}
/**
* Strip tool call markers from content for streaming display
*/
function stripToolMarkers(content: string): string {
return content.replace(/<!--TOOL_START:\w+:\{.*?\}-->/g, '').replace(/<!--TOOL_END-->/g, '');
}
function MarkdownContent({ content }: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeRaw, rehypeKatex]}
components={{
// Headings
h1: ({ children }) => (
<Title order={2} mb="xs">
{children}
</Title>
),
h2: ({ children }) => (
<Title order={3} mb="xs">
{children}
</Title>
),
h3: ({ children }) => (
<Title order={4} mb="xs">
{children}
</Title>
),
h4: ({ children }) => (
<Title order={5} mb="xs">
{children}
</Title>
),
h5: ({ children }) => (
<Title order={6} mb="xs">
{children}
</Title>
),
h6: ({ children }) => (
<Text size="sm" fw={700} mb="xs">
{children}
</Text>
),
// Paragraphs and text
p: ({ children }) => (
<Text size="sm" style={{ lineHeight: 1.6, marginBottom: '0.5em' }}>
{children}
</Text>
),
strong: ({ children }) => (
<Text component="strong" fw={700} inherit>
{children}
</Text>
),
em: ({ children }) => (
<Text component="em" fs="italic" inherit>
{children}
</Text>
),
del: ({ children }) => (
<Text component="del" td="line-through" inherit>
{children}
</Text>
),
// Code
code: ({ className, children }) => {
const isInline = !className;
if (isInline) {
return <Code>{children}</Code>;
}
return (
<Code block className={classes.codeBlock}>
{children}
</Code>
);
},
pre: ({ children }) => <div className={classes.preWrapper}>{children}</div>,
// Lists
ul: ({ children, className }) => {
// Check if this is a task list (GFM)
if (className?.includes('contains-task-list')) {
return <ul className={classes.taskList}>{children}</ul>;
}
return (
<List size="sm" mb="xs">
{children}
</List>
);
},
ol: ({ children }) => (
<List type="ordered" size="sm" mb="xs">
{children}
</List>
),
li: ({ children, className }) => {
// Task list items have a checkbox
if (className?.includes('task-list-item')) {
return <li className={classes.taskListItem}>{children}</li>;
}
return <List.Item>{children}</List.Item>;
},
input: ({ checked, type }) => {
if (type === 'checkbox') {
return <Checkbox checked={checked} readOnly size="xs" mr="xs" />;
}
return null;
},
// Links and media
a: ({ href, children }) => (
<Anchor href={href} target="_blank" rel="noopener noreferrer">
{children}
</Anchor>
),
img: ({ src, alt }) => {
const imgSrc = typeof src === 'string' ? src : '';
return (
<span className={classes.imageWrapper}>
<Image
src={imgSrc}
alt={alt || ''}
width={600}
height={400}
style={{ maxWidth: '100%', height: 'auto' }}
unoptimized // Allow external images
/>
</span>
);
},
// Block elements
blockquote: ({ children }) => <Blockquote mb="xs">{children}</Blockquote>,
hr: () => <Divider my="md" />,
// Tables (GFM)
table: ({ children }) => (
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
mb="md"
className={classes.table}
>
{children}
</Table>
),
thead: ({ children }) => <Table.Thead>{children}</Table.Thead>,
tbody: ({ children }) => <Table.Tbody>{children}</Table.Tbody>,
tr: ({ children }) => <Table.Tr>{children}</Table.Tr>,
th: ({ children }) => <Table.Th>{children}</Table.Th>,
td: ({ children }) => <Table.Td>{children}</Table.Td>,
// Allow the fade-in spans to pass through
span: ({ className, children }) => <span className={className}>{children}</span>,
}}
>
{content}
</ReactMarkdown>
);
}
export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessageProps) {
const processedContent = useStreamingContent(content, isStreaming);
// During streaming, just show the raw content (with tool markers stripped)
if (isStreaming) {
return (
<div className={classes.markdown}>
<MarkdownContent content={stripToolMarkers(processedContent)} />
</div>
);
}
// When not streaming, parse and render tool calls
const segments = parseContentWithToolCalls(content);
return (
<div className={classes.markdown}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeRaw, rehypeKatex]}
components={{
// Headings
h1: ({ children }) => (
<Title order={2} mb="xs">
{children}
</Title>
),
h2: ({ children }) => (
<Title order={3} mb="xs">
{children}
</Title>
),
h3: ({ children }) => (
<Title order={4} mb="xs">
{children}
</Title>
),
h4: ({ children }) => (
<Title order={5} mb="xs">
{children}
</Title>
),
h5: ({ children }) => (
<Title order={6} mb="xs">
{children}
</Title>
),
h6: ({ children }) => (
<Text size="sm" fw={700} mb="xs">
{children}
</Text>
),
// Paragraphs and text
p: ({ children }) => (
<Text size="sm" style={{ lineHeight: 1.6, marginBottom: '0.5em' }}>
{children}
</Text>
),
strong: ({ children }) => (
<Text component="strong" fw={700} inherit>
{children}
</Text>
),
em: ({ children }) => (
<Text component="em" fs="italic" inherit>
{children}
</Text>
),
del: ({ children }) => (
<Text component="del" td="line-through" inherit>
{children}
</Text>
),
// Code
code: ({ className, children }) => {
const isInline = !className;
if (isInline) {
return <Code>{children}</Code>;
}
return (
<Code block className={classes.codeBlock}>
{children}
</Code>
);
},
pre: ({ children }) => <div className={classes.preWrapper}>{children}</div>,
// Lists
ul: ({ children, className }) => {
// Check if this is a task list (GFM)
if (className?.includes('contains-task-list')) {
return <ul className={classes.taskList}>{children}</ul>;
}
return (
<List size="sm" mb="xs">
{children}
</List>
);
},
ol: ({ children }) => (
<List type="ordered" size="sm" mb="xs">
{children}
</List>
),
li: ({ children, className }) => {
// Task list items have a checkbox
if (className?.includes('task-list-item')) {
return <li className={classes.taskListItem}>{children}</li>;
}
return <List.Item>{children}</List.Item>;
},
input: ({ checked, type }) => {
if (type === 'checkbox') {
return <Checkbox checked={checked} readOnly size="xs" mr="xs" />;
}
return null;
},
// Links and media
a: ({ href, children }) => (
<Anchor href={href} target="_blank" rel="noopener noreferrer">
{children}
</Anchor>
),
img: ({ src, alt }) => {
const imgSrc = typeof src === 'string' ? src : '';
return (
<span className={classes.imageWrapper}>
<Image
src={imgSrc}
alt={alt || ''}
width={600}
height={400}
style={{ maxWidth: '100%', height: 'auto' }}
unoptimized // Allow external images
/>
</span>
);
},
// Block elements
blockquote: ({ children }) => <Blockquote mb="xs">{children}</Blockquote>,
hr: () => <Divider my="md" />,
// Tables (GFM)
table: ({ children }) => (
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
mb="md"
className={classes.table}
>
{children}
</Table>
),
thead: ({ children }) => <Table.Thead>{children}</Table.Thead>,
tbody: ({ children }) => <Table.Tbody>{children}</Table.Tbody>,
tr: ({ children }) => <Table.Tr>{children}</Table.Tr>,
th: ({ children }) => <Table.Th>{children}</Table.Th>,
td: ({ children }) => <Table.Td>{children}</Table.Td>,
// Allow the fade-in spans to pass through
span: ({ className, children }) => <span className={className}>{children}</span>,
}}
>
{processedContent}
</ReactMarkdown>
{segments.map((segment, index) => {
if (segment.type === 'tool' && segment.toolCall) {
return (
<ToolCallDisplay
key={`tool-${index}`}
toolName={segment.toolCall.toolName}
args={segment.toolCall.args}
result={segment.toolCall.result}
/>
);
}
return <MarkdownContent key={`text-${index}`} content={segment.content || ''} />;
})}
</div>
);
}