feat(web): implement SSE chat streaming with real-time token rendering (#516)
Some checks failed
ci/woodpecker/push/web Pipeline failed
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:
@@ -64,10 +64,12 @@ function createMockUseChatReturn(
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isStreaming: false,
|
||||
error: null,
|
||||
conversationId: null,
|
||||
conversationTitle: null,
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
abortStream: vi.fn(),
|
||||
loadConversation: vi.fn().mockResolvedValue(undefined),
|
||||
startNewConversation: vi.fn(),
|
||||
setMessages: vi.fn(),
|
||||
|
||||
@@ -59,14 +59,15 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
|
||||
// Use the chat hook for state management
|
||||
const {
|
||||
messages,
|
||||
isLoading: isChatLoading,
|
||||
isStreaming,
|
||||
error,
|
||||
conversationId,
|
||||
conversationTitle,
|
||||
sendMessage,
|
||||
abortStream,
|
||||
loadConversation,
|
||||
startNewConversation,
|
||||
clearError,
|
||||
@@ -75,15 +76,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
||||
});
|
||||
|
||||
// Connect to WebSocket for real-time updates (when we have a user)
|
||||
const { isConnected: isWsConnected } = useWebSocket(
|
||||
user?.id ?? "", // Use user ID as workspace ID for now
|
||||
"", // Token not needed since we use cookies
|
||||
{
|
||||
// Future: Add handlers for chat-related events
|
||||
// onChatMessage: (msg) => { ... }
|
||||
}
|
||||
);
|
||||
const { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {});
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -91,7 +84,10 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
const quipTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const quipIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Expose methods to parent via ref
|
||||
// Identify the streaming message (last assistant message while streaming)
|
||||
const streamingMessageId =
|
||||
isStreaming && messages.length > 0 ? messages[messages.length - 1]?.id : undefined;
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
loadConversation: async (cId: string): Promise<void> => {
|
||||
await loadConversation(cId);
|
||||
@@ -110,7 +106,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
// Notify parent of conversation changes
|
||||
useEffect(() => {
|
||||
if (conversationId && conversationTitle) {
|
||||
onConversationChange?.(conversationId, {
|
||||
@@ -125,7 +120,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
}
|
||||
}, [conversationId, conversationTitle, initialProjectId, onConversationChange]);
|
||||
|
||||
// Global keyboard shortcut: Ctrl+/ to focus input
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
|
||||
@@ -139,20 +133,17 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Show loading quips
|
||||
// Show loading quips only during non-streaming load (initial fetch wait)
|
||||
useEffect(() => {
|
||||
if (isChatLoading) {
|
||||
// Show first quip after 3 seconds
|
||||
if (isChatLoading && !isStreaming) {
|
||||
quipTimerRef.current = setTimeout(() => {
|
||||
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
|
||||
}, 3000);
|
||||
|
||||
// Change quip every 5 seconds
|
||||
quipIntervalRef.current = setInterval(() => {
|
||||
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
|
||||
}, 5000);
|
||||
} else {
|
||||
// Clear timers when loading stops
|
||||
if (quipTimerRef.current) {
|
||||
clearTimeout(quipTimerRef.current);
|
||||
quipTimerRef.current = null;
|
||||
@@ -168,7 +159,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
if (quipTimerRef.current) clearTimeout(quipTimerRef.current);
|
||||
if (quipIntervalRef.current) clearInterval(quipIntervalRef.current);
|
||||
};
|
||||
}, [isChatLoading]);
|
||||
}, [isChatLoading, isStreaming]);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
@@ -177,7 +168,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
[sendMessage]
|
||||
);
|
||||
|
||||
// Show loading state while auth is loading
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div
|
||||
@@ -227,6 +217,8 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
<MessageList
|
||||
messages={messages as (Message & { thinking?: string })[]}
|
||||
isLoading={isChatLoading}
|
||||
isStreaming={isStreaming}
|
||||
{...(streamingMessageId != null ? { streamingMessageId } : {})}
|
||||
loadingQuip={loadingQuip}
|
||||
/>
|
||||
<div ref={messagesEndRef} />
|
||||
@@ -294,6 +286,8 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
onSend={handleSendMessage}
|
||||
disabled={isChatLoading || !user}
|
||||
inputRef={inputRef}
|
||||
isStreaming={isStreaming}
|
||||
onStopStreaming={abortStream}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,13 +7,20 @@ interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
||||
isStreaming?: boolean;
|
||||
onStopStreaming?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React.JSX.Element {
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
disabled,
|
||||
inputRef,
|
||||
isStreaming = false,
|
||||
onStopStreaming,
|
||||
}: ChatInputProps): React.JSX.Element {
|
||||
const [message, setMessage] = useState("");
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
|
||||
// Fetch version from static version.json (generated at build time)
|
||||
useEffect(() => {
|
||||
interface VersionData {
|
||||
version?: string;
|
||||
@@ -24,7 +31,6 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
.then((res) => res.json() as Promise<VersionData>)
|
||||
.then((data) => {
|
||||
if (data.version) {
|
||||
// Format as "version+commit" for full build identification
|
||||
const fullVersion = data.commit ? `${data.version}+${data.commit}` : data.version;
|
||||
setVersion(fullVersion);
|
||||
}
|
||||
@@ -35,20 +41,22 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (message.trim() && !disabled) {
|
||||
if (message.trim() && !disabled && !isStreaming) {
|
||||
onSend(message);
|
||||
setMessage("");
|
||||
}
|
||||
}, [message, onSend, disabled]);
|
||||
}, [message, onSend, disabled, isStreaming]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
onStopStreaming?.();
|
||||
}, [onStopStreaming]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Enter to send (without Shift)
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
// Ctrl/Cmd + Enter to send (alternative)
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
@@ -61,6 +69,7 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
const maxCharacters = 4000;
|
||||
const isNearLimit = characterCount > maxCharacters * 0.9;
|
||||
const isOverLimit = characterCount > maxCharacters;
|
||||
const isInputDisabled = disabled ?? false;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -69,7 +78,10 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
className="relative rounded-lg border transition-all duration-150"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: disabled ? "rgb(var(--border-default))" : "rgb(var(--border-strong))",
|
||||
borderColor:
|
||||
isInputDisabled || isStreaming
|
||||
? "rgb(var(--border-default))"
|
||||
: "rgb(var(--border-strong))",
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
@@ -79,8 +91,8 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
setMessage(e.target.value);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
disabled={disabled}
|
||||
placeholder={isStreaming ? "AI is responding..." : "Type a message..."}
|
||||
disabled={isInputDisabled || isStreaming}
|
||||
rows={1}
|
||||
className="block w-full resize-none bg-transparent px-4 py-3 pr-24 text-sm outline-none placeholder:text-[rgb(var(--text-muted))] disabled:opacity-50"
|
||||
style={{
|
||||
@@ -97,28 +109,47 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
aria-describedby="input-help"
|
||||
/>
|
||||
|
||||
{/* Send Button */}
|
||||
{/* Send / Stop Button */}
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={(disabled ?? !message.trim()) || isOverLimit}
|
||||
className="btn-primary btn-sm rounded-md"
|
||||
style={{
|
||||
opacity: disabled || !message.trim() || isOverLimit ? 0.5 : 1,
|
||||
}}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="btn-sm rounded-md flex items-center gap-1.5"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--semantic-error))",
|
||||
color: "white",
|
||||
padding: "0.25rem 0.75rem",
|
||||
}}
|
||||
aria-label="Stop generating"
|
||||
title="Stop generating"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Send</span>
|
||||
</button>
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="6" y="6" width="12" height="12" rx="1" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline text-sm font-medium">Stop</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isInputDisabled || !message.trim() || isOverLimit}
|
||||
className="btn-primary btn-sm rounded-md"
|
||||
style={{
|
||||
opacity: isInputDisabled || !message.trim() || isOverLimit ? 0.5 : 1,
|
||||
}}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Send</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +159,6 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
id="input-help"
|
||||
>
|
||||
{/* Keyboard Shortcuts */}
|
||||
<div className="hidden items-center gap-4 sm:flex">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="kbd">Enter</span>
|
||||
@@ -142,10 +172,8 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile hint */}
|
||||
<div className="sm:hidden">Tap send or press Enter</div>
|
||||
|
||||
{/* Character Count */}
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
style={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user