This commit is contained in:
Zacharias-Brohn
2026-01-14 21:18:58 +01:00
parent aa92152144
commit a521353022
7 changed files with 966 additions and 109 deletions
@@ -31,3 +31,34 @@
opacity: 1;
}
}
/* Task lists (GFM) */
.taskList {
list-style: none;
padding-left: 0;
margin: 0.5em 0;
}
.taskListItem {
display: flex;
align-items: flex-start;
gap: 0.5em;
margin-bottom: 0.25em;
}
/* Images */
.imageWrapper {
display: block;
margin: 0.5em 0;
}
/* KaTeX overrides for better integration */
.markdown :global(.katex) {
font-size: 1em;
}
.markdown :global(.katex-display) {
margin: 0.5em 0;
overflow-x: auto;
overflow-y: hidden;
}
+120 -25
View File
@@ -1,10 +1,26 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Image from 'next/image';
import ReactMarkdown from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
import { Anchor, Blockquote, Code, List, Text, Title } from '@mantine/core';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import {
Anchor,
Blockquote,
Checkbox,
Code,
Divider,
List,
Table,
Text,
Title,
} from '@mantine/core';
import classes from './MarkdownMessage.module.css';
// Import KaTeX CSS for LaTeX rendering
import 'katex/dist/katex.min.css';
interface MarkdownMessageProps {
content: string;
@@ -80,28 +96,64 @@ export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessag
return (
<div className={classes.markdown}>
<ReactMarkdown
rehypePlugins={[rehypeRaw]}
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>
),
h1: ({ children }) => (
<Title order={3} mb="xs">
strong: ({ children }) => (
<Text component="strong" fw={700} inherit>
{children}
</Title>
</Text>
),
h2: ({ children }) => (
<Title order={4} mb="xs">
em: ({ children }) => (
<Text component="em" fs="italic" inherit>
{children}
</Title>
</Text>
),
h3: ({ children }) => (
<Title order={5} mb="xs">
del: ({ children }) => (
<Text component="del" td="line-through" inherit>
{children}
</Title>
</Text>
),
// Code
code: ({ className, children }) => {
const isInline = !className;
if (isInline) {
@@ -114,33 +166,76 @@ export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessag
);
},
pre: ({ children }) => <div className={classes.preWrapper}>{children}</div>,
ul: ({ children }) => (
<List size="sm" mb="xs">
{children}
</List>
),
// 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 }) => <List.Item>{children}</List.Item>,
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>,
strong: ({ children }) => (
<Text component="strong" fw={700} inherit>
hr: () => <Divider my="md" />,
// Tables (GFM)
table: ({ children }) => (
<Table striped highlightOnHover withTableBorder withColumnBorders mb="md">
{children}
</Text>
),
em: ({ children }) => (
<Text component="em" fs="italic" inherit>
{children}
</Text>
</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>,
}}
-17
View File
@@ -1,17 +0,0 @@
.streamingText {
white-space: pre-wrap;
word-wrap: break-word;
}
.fadeIn {
animation: textFadeIn 0.3s ease-out forwards;
}
@keyframes textFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
-63
View File
@@ -1,63 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import classes from './StreamingText.module.css';
interface StreamingTextProps {
content: string;
isStreaming: boolean;
}
interface TextChunk {
id: number;
text: string;
}
/**
* Component that renders text with a smooth fade-in animation for new chunks.
* Each chunk gets its own span element with a fade-in animation.
*/
export function StreamingText({ content, isStreaming }: StreamingTextProps) {
const [chunks, setChunks] = useState<TextChunk[]>([]);
const prevLengthRef = useRef(0);
const chunkIdRef = useRef(0);
useEffect(() => {
const prevLength = prevLengthRef.current;
const currentLength = content.length;
if (currentLength > prevLength) {
// New content arrived - create a new chunk for it
const newText = content.slice(prevLength);
const newChunk: TextChunk = {
id: chunkIdRef.current++,
text: newText,
};
setChunks((prev) => [...prev, newChunk]);
prevLengthRef.current = currentLength;
} else if (currentLength < prevLength) {
// Content was reset (new message)
setChunks([]);
prevLengthRef.current = 0;
chunkIdRef.current = 0;
}
}, [content]);
// When streaming stops, consolidate all chunks into one (removes animations)
useEffect(() => {
if (!isStreaming && content.length > 0) {
setChunks([{ id: 0, text: content }]);
prevLengthRef.current = content.length;
}
}, [isStreaming, content]);
return (
<span className={classes.streamingText}>
{chunks.map((chunk) => (
<span key={chunk.id} className={isStreaming ? classes.fadeIn : undefined}>
{chunk.text}
</span>
))}
</span>
);
}