The redesign of the chat UI to use StreamingBubble mistakenly omitted the actual rendering of the visible 'content' (and fallback raw text). It only showed the thinking/reasoning block and the loading dots, leaving the main content invisible until the generation finished. Added the markdown rendering block back to StreamingBubble to show text in real-time.
102 lines
2.9 KiB
TypeScript
102 lines
2.9 KiB
TypeScript
import { useEffect, useRef, memo } from "react";
|
|
import { renderMarkdown } from "../lib/markdown";
|
|
import "./StreamingBubble.css";
|
|
|
|
interface Props {
|
|
content: string;
|
|
reasoning: string;
|
|
thinkingOpen: boolean;
|
|
raw?: string;
|
|
}
|
|
|
|
const StreamingBubble = memo(function StreamingBubble({
|
|
content,
|
|
reasoning,
|
|
thinkingOpen,
|
|
raw = "",
|
|
}: Props) {
|
|
const reasoningRef = useRef<HTMLDivElement>(null);
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
|
|
const hasReasoning = reasoning.trim().length > 0 || thinkingOpen;
|
|
const hasContent = content.trim().length > 0;
|
|
const fallbackVisible = !hasContent && !hasReasoning && raw.trim().length > 0 ? raw : "";
|
|
|
|
useEffect(() => {
|
|
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
}, [content, reasoning, raw]);
|
|
|
|
return (
|
|
<div className="msg-wrapper assistant streaming-wrapper" aria-live="polite" aria-label="AI is generating a response">
|
|
{(hasReasoning || (!hasContent && !fallbackVisible)) && (
|
|
<div className="stream-thinking-block">
|
|
<div className="stream-thinking-header">
|
|
<span className="stream-thinking-pulse" aria-hidden="true" />
|
|
<span className="stream-thinking-label">
|
|
{(thinkingOpen || (!hasContent && !fallbackVisible)) ? "Thinking…" : "Thinking"}
|
|
</span>
|
|
</div>
|
|
<div ref={reasoningRef} className="stream-thinking-body">
|
|
{reasoning.trim().length > 0 ? (
|
|
<WordStream text={reasoning} />
|
|
) : (
|
|
<span className="stream-thinking-placeholder">Generating reasoning…</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!hasReasoning && !hasContent && !fallbackVisible && (
|
|
<div className="stream-loading-dots" aria-label="Waiting for response">
|
|
<span />
|
|
<span />
|
|
<span />
|
|
</div>
|
|
)}
|
|
|
|
{hasContent && (
|
|
<div
|
|
className="msg-content msg-content--assistant manus-markdown"
|
|
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
|
|
/>
|
|
)}
|
|
|
|
{fallbackVisible && !hasContent && (
|
|
<div
|
|
className="msg-content msg-content--assistant manus-markdown"
|
|
dangerouslySetInnerHTML={{ __html: renderMarkdown(fallbackVisible) }}
|
|
/>
|
|
)}
|
|
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export default StreamingBubble;
|
|
|
|
interface WordStreamProps {
|
|
text: string;
|
|
}
|
|
|
|
const WordStream = memo(function WordStream({ text }: WordStreamProps) {
|
|
const tokens = text.split(/(\s+)/);
|
|
|
|
return (
|
|
<span className="word-stream">
|
|
{tokens.map((token, i) => {
|
|
if (/^\s+$/.test(token)) return <span key={i}>{token}</span>;
|
|
return (
|
|
<span
|
|
key={i}
|
|
className="word-stream-token"
|
|
style={{ animationDelay: `${Math.min(i * 8, 400)}ms` }}
|
|
>
|
|
{token}
|
|
</span>
|
|
);
|
|
})}
|
|
</span>
|
|
);
|
|
});
|