changes
This commit is contained in:
@@ -7,28 +7,6 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Streaming cursor that blinks at the end */
|
|
||||||
.streamingCursor {
|
|
||||||
display: inline-block;
|
|
||||||
width: 0.5em;
|
|
||||||
height: 1em;
|
|
||||||
background-color: currentColor;
|
|
||||||
margin-left: 2px;
|
|
||||||
animation: blink 1s step-end infinite;
|
|
||||||
vertical-align: text-bottom;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blink {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.preWrapper {
|
.preWrapper {
|
||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import { Anchor, Blockquote, Code, List, Text, Title } from '@mantine/core';
|
import { Anchor, Blockquote, Code, List, Text, Title } from '@mantine/core';
|
||||||
|
import { StreamingText } from './StreamingText';
|
||||||
import classes from './MarkdownMessage.module.css';
|
import classes from './MarkdownMessage.module.css';
|
||||||
|
|
||||||
interface MarkdownMessageProps {
|
interface MarkdownMessageProps {
|
||||||
@@ -8,6 +9,18 @@ interface MarkdownMessageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessageProps) {
|
export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessageProps) {
|
||||||
|
// While streaming, render plain text with fade-in animation
|
||||||
|
// Once done, render full markdown
|
||||||
|
if (isStreaming) {
|
||||||
|
return (
|
||||||
|
<div className={classes.markdown}>
|
||||||
|
<Text size="sm" style={{ lineHeight: 1.6 }}>
|
||||||
|
<StreamingText content={content} isStreaming={isStreaming} />
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.markdown}>
|
<div className={classes.markdown}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
@@ -75,7 +88,6 @@ export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessag
|
|||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
{isStreaming && <span className={classes.streamingCursor} />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import classes from './StreamingText.module.css';
|
||||||
|
|
||||||
|
interface StreamingTextProps {
|
||||||
|
content: string;
|
||||||
|
isStreaming: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that renders text with a smooth fade-in animation for new chunks.
|
||||||
|
* Splits content into "committed" (already visible) and "new" (fading in) portions.
|
||||||
|
*/
|
||||||
|
export function StreamingText({ content, isStreaming }: StreamingTextProps) {
|
||||||
|
const [committedLength, setCommittedLength] = useState(0);
|
||||||
|
const animationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// When content grows, schedule the new content to become "committed" after animation
|
||||||
|
useEffect(() => {
|
||||||
|
if (content.length > committedLength) {
|
||||||
|
// Clear any pending timeout
|
||||||
|
if (animationTimeoutRef.current) {
|
||||||
|
clearTimeout(animationTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the fade-in animation completes, commit the new content
|
||||||
|
animationTimeoutRef.current = setTimeout(() => {
|
||||||
|
setCommittedLength(content.length);
|
||||||
|
}, 300); // Match CSS animation duration
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationTimeoutRef.current) {
|
||||||
|
clearTimeout(animationTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [content, committedLength]);
|
||||||
|
|
||||||
|
// When streaming stops, immediately commit all content
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isStreaming) {
|
||||||
|
setCommittedLength(content.length);
|
||||||
|
}
|
||||||
|
}, [isStreaming, content.length]);
|
||||||
|
|
||||||
|
// Reset when content is cleared (new message)
|
||||||
|
useEffect(() => {
|
||||||
|
if (content.length === 0) {
|
||||||
|
setCommittedLength(0);
|
||||||
|
}
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
const committedText = content.slice(0, committedLength);
|
||||||
|
const newText = content.slice(committedLength);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={classes.streamingText}>
|
||||||
|
<span>{committedText}</span>
|
||||||
|
{newText && <span className={classes.fadeIn}>{newText}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user