feat(web): implement SSE chat streaming with real-time token rendering (#516)
Some checks failed
ci/woodpecker/push/web Pipeline failed

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #516.
This commit is contained in:
2026-02-26 02:39:43 +00:00
committed by jason.woltje
parent 6290fc3d53
commit 7de0e734b0
8 changed files with 797 additions and 297 deletions

View File

@@ -1,11 +1,13 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Message } from "@/hooks/useChat";
interface MessageListProps {
messages: Message[];
isLoading: boolean;
isStreaming?: boolean;
streamingMessageId?: string;
loadingQuip?: string | null;
}
@@ -14,7 +16,6 @@ interface MessageListProps {
* Extracts <thinking>...</thinking> or <think>...</think> blocks.
*/
function parseThinking(content: string): { thinking: string | null; response: string } {
// Match <thinking>...</thinking> or <think>...</think> blocks
const thinkingRegex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
const matches = content.match(thinkingRegex);
@@ -22,14 +23,12 @@ function parseThinking(content: string): { thinking: string | null; response: st
return { thinking: null, response: content };
}
// Extract thinking content
let thinking = "";
for (const match of matches) {
const innerContent = match.replace(/<\/?(?:thinking|think)>/gi, "");
thinking += innerContent.trim() + "\n";
}
// Remove thinking blocks from response
const response = content.replace(thinkingRegex, "").trim();
const trimmedThinking = thinking.trim();
@@ -42,25 +41,47 @@ function parseThinking(content: string): { thinking: string | null; response: st
export function MessageList({
messages,
isLoading,
isStreaming = false,
streamingMessageId,
loadingQuip,
}: MessageListProps): React.JSX.Element {
const bottomRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when messages change or streaming tokens arrive
useEffect(() => {
if (isStreaming || isLoading) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [messages, isStreaming, isLoading]);
return (
<div className="space-y-6" role="log" aria-label="Chat messages">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
<MessageBubble
key={message.id}
message={message}
isStreaming={isStreaming && message.id === streamingMessageId}
/>
))}
{isLoading && <LoadingIndicator {...(loadingQuip != null && { quip: loadingQuip })} />}
{isLoading && !isStreaming && (
<LoadingIndicator {...(loadingQuip != null && { quip: loadingQuip })} />
)}
<div ref={bottomRef} />
</div>
);
}
function MessageBubble({ message }: { message: Message }): React.JSX.Element {
interface MessageBubbleProps {
message: Message;
isStreaming?: boolean;
}
function MessageBubble({ message, isStreaming = false }: MessageBubbleProps): React.JSX.Element {
const isUser = message.role === "user";
const [copied, setCopied] = useState(false);
const [thinkingExpanded, setThinkingExpanded] = useState(false);
// Parse thinking from content (or use pre-parsed thinking field)
const { thinking, response } = message.thinking
? { thinking: message.thinking, response: message.content }
: parseThinking(message.content);
@@ -73,7 +94,6 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
setCopied(false);
}, 2000);
} catch (err) {
// Silently fail - clipboard copy is non-critical
void err;
}
}, [response]);
@@ -106,8 +126,21 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
{isUser ? "You" : "AI Assistant"}
</span>
{/* Streaming indicator in header */}
{!isUser && isStreaming && (
<span
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{
backgroundColor: "rgb(var(--accent-primary) / 0.15)",
color: "rgb(var(--accent-primary))",
}}
aria-label="Streaming"
>
streaming
</span>
)}
{/* Model indicator for assistant messages */}
{!isUser && message.model && (
{!isUser && message.model && !isStreaming && (
<span
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{
@@ -200,43 +233,54 @@ function MessageBubble({ message }: { message: Message }): React.JSX.Element {
border: isUser ? "none" : "1px solid rgb(var(--border-default))",
}}
>
<p className="whitespace-pre-wrap text-sm leading-relaxed">{response}</p>
{/* Copy Button - appears on hover */}
<button
onClick={handleCopy}
className="absolute -right-2 -top-2 rounded-md border p-1.5 opacity-0 transition-all group-hover:opacity-100 focus:opacity-100"
style={{
backgroundColor: "rgb(var(--surface-0))",
borderColor: "rgb(var(--border-default))",
color: copied ? "rgb(var(--semantic-success))" : "rgb(var(--text-muted))",
}}
aria-label={copied ? "Copied!" : "Copy message"}
title={copied ? "Copied!" : "Copy to clipboard"}
>
{copied ? (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{response}
{/* Blinking cursor during streaming */}
{isStreaming && !isUser && (
<span
className="streaming-cursor inline-block ml-0.5 align-middle"
aria-hidden="true"
/>
)}
</button>
</p>
{/* Copy Button - hidden while streaming */}
{!isStreaming && (
<button
onClick={handleCopy}
className="absolute -right-2 -top-2 rounded-md border p-1.5 opacity-0 transition-all group-hover:opacity-100 focus:opacity-100"
style={{
backgroundColor: "rgb(var(--surface-0))",
borderColor: "rgb(var(--border-default))",
color: copied ? "rgb(var(--semantic-success))" : "rgb(var(--text-muted))",
}}
aria-label={copied ? "Copied!" : "Copy message"}
title={copied ? "Copied!" : "Copy to clipboard"}
>
{copied ? (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
)}
</div>
</div>
</div>