feat(chat): Implement real-time SSE streaming with reasoning steps and improved UI indicators.

This commit is contained in:
Yunxiao Xu
2026-02-13 00:00:50 -08:00
parent af731413af
commit 339f69a2a3
14 changed files with 777 additions and 17 deletions

View File

@@ -0,0 +1,38 @@
import * as React from "react"
import { SendIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
interface ChatInputProps {
onSendMessage: (message: string) => void
disabled?: boolean
}
export function ChatInput({ onSendMessage, disabled }: ChatInputProps) {
const [message, setMessage] = React.useState("")
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (message.trim() && !disabled) {
onSendMessage(message)
setMessage("")
}
}
return (
<form onSubmit={handleSubmit} className="flex w-full items-center space-x-2 p-4 border-t bg-background">
<Input
type="text"
placeholder="Type your question about election data..."
value={message}
onChange={(e) => setMessage(e.target.value)}
disabled={disabled}
className="flex-1"
/>
<Button type="submit" size="icon" disabled={disabled || !message.trim()}>
<SendIcon className="h-4 w-4" />
<span className="sr-only">Send</span>
</Button>
</form>
)
}

View File

@@ -0,0 +1,62 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { describe, it, expect, vi, beforeEach } from "vitest"
import { ChatInterface } from "./ChatInterface"
import { ChatService, type StreamCallbacks } from "@/services/chat"
vi.mock("@/services/chat", () => ({
ChatService: {
streamChat: vi.fn(),
}
}))
describe("ChatInterface", () => {
beforeEach(() => {
vi.resetAllMocks()
})
it("renders correctly with initial messages", () => {
const initialMessages = [
{ id: "1", role: "user" as const, content: "Hello", created_at: new Date().toISOString() }
]
render(<ChatInterface threadId="test-thread" initialMessages={initialMessages} />)
expect(screen.getByText("Hello")).toBeInTheDocument()
})
it("calls streamChat when a message is sent", async () => {
render(<ChatInterface threadId="test-thread" />)
const input = screen.getByPlaceholderText(/Type your question/i)
const sendButton = screen.getByRole("button", { name: /send/i })
fireEvent.change(input, { target: { value: "Tell me about New Jersey" } })
fireEvent.click(sendButton)
expect(ChatService.streamChat).toHaveBeenCalledWith(
"Tell me about New Jersey",
"test-thread",
[],
expect.any(Object)
)
})
it("displays error message when stream fails", async () => {
const mockedStreamChat = vi.mocked(ChatService.streamChat)
mockedStreamChat.mockImplementation((_msg: string, _id: string, _msgs: any[], callbacks: StreamCallbacks) => {
if (callbacks.onError) {
callbacks.onError("Connection failed")
}
return Promise.resolve()
})
render(<ChatInterface threadId="test-thread" />)
const input = screen.getByPlaceholderText(/Type your question/i)
fireEvent.change(input, { target: { value: "test" } })
fireEvent.click(screen.getByRole("button", { name: /send/i }))
await waitFor(() => {
expect(screen.getByText("Connection failed")).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { MessageList } from "./MessageList"
import { ChatInput } from "./ChatInput"
import { ChatService, type MessageResponse } from "@/services/chat"
import { AlertCircle } from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
interface ChatInterfaceProps {
threadId: string
initialMessages?: MessageResponse[]
}
const EMPTY_MESSAGES: MessageResponse[] = []
export function ChatInterface({ threadId, initialMessages = EMPTY_MESSAGES }: ChatInterfaceProps) {
const [messages, setMessages] = React.useState<MessageResponse[]>(initialMessages)
const [isStreaming, setIsStreaming] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
// Sync messages if threadId or initialMessages changes
React.useEffect(() => {
console.log("ChatInterface: Syncing messages", initialMessages)
setMessages(initialMessages)
setError(null)
}, [threadId, initialMessages])
// Log messages changes for debugging
React.useEffect(() => {
console.log("ChatInterface: Messages state updated", messages)
}, [messages])
const handleSendMessage = async (text: string) => {
setError(null)
setIsStreaming(true)
try {
await ChatService.streamChat(
text,
threadId,
messages,
{
onMessageUpdate: (updatedMessages) => {
setMessages(updatedMessages)
},
onDone: () => {
setIsStreaming(false)
},
onError: (err) => {
setError(err)
setIsStreaming(false)
}
}
)
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : "Failed to start chat"
setError(errorMessage)
setIsStreaming(false)
}
}
return (
<div className="flex flex-col h-full bg-background rounded-xl border shadow-lg overflow-hidden">
{error && (
<Alert variant="destructive" className="m-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<MessageList messages={messages} />
<ChatInput
onSendMessage={handleSendMessage}
disabled={isStreaming}
/>
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { cn } from "@/lib/utils"
import { type MessageResponse } from "@/services/chat"
interface MessageBubbleProps {
message: MessageResponse
}
export function MessageBubble({ message }: MessageBubbleProps) {
const isAssistant = message.role === "assistant"
return (
<div
className={cn(
"flex w-full mb-4",
isAssistant ? "justify-start" : "justify-end"
)}
>
<div
className={cn(
"max-w-[80%] rounded-lg p-4 shadow-sm",
isAssistant
? "bg-secondary text-secondary-foreground"
: "bg-primary text-primary-foreground"
)}
>
{isAssistant && message.steps && message.steps.length > 0 && (
<div className="mb-3 space-y-1 border-b border-secondary-foreground/10 pb-2">
{message.steps.map((step, index) => {
const isLast = index === message.steps!.length - 1
return (
<div
key={index}
className={cn(
"flex items-center gap-2 transition-all duration-300",
isLast ? "text-xs font-medium opacity-80" : "text-[10px] opacity-40"
)}
>
<div className={cn("rounded-full bg-current", isLast ? "h-1 w-1" : "h-0.5 w-0.5")} />
{step}
</div>
)
})}
</div>
)}
<div className="whitespace-pre-wrap break-words text-sm">
{message.content || (isAssistant && !message.plots?.length ? (
<div className="flex items-center gap-1 py-1">
<div className="flex gap-1">
<div className="h-1.5 w-1.5 rounded-full bg-current animate-bounce [animation-delay:-0.3s]" />
<div className="h-1.5 w-1.5 rounded-full bg-current animate-bounce [animation-delay:-0.15s]" />
<div className="h-1.5 w-1.5 rounded-full bg-current animate-bounce" />
</div>
</div>
) : "")}
</div>
{message.plots && message.plots.length > 0 && (
<div className="mt-4 grid grid-cols-1 gap-2">
{message.plots.map((plot, index) => (
<img
key={index}
src={`data:image/png;base64,${plot}`}
alt="Analysis Plot"
className="rounded-md border bg-white w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
// TODO: Open in modal (Phase 5)
}}
/>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,34 @@
import * as React from "react"
import { MessageBubble } from "./MessageBubble"
import { type MessageResponse } from "@/services/chat"
interface MessageListProps {
messages: MessageResponse[]
}
export function MessageList({ messages }: MessageListProps) {
const scrollRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages])
return (
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-4 flex flex-col"
>
{messages.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
No messages yet. Ask a question to get started!
</div>
) : (
messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))
)}
</div>
)
}

View File

@@ -8,7 +8,7 @@ interface MainLayoutProps {
export function MainLayout({ children }: MainLayoutProps) {
return (
<SidebarProvider>
<div className="flex min-h-screen w-full">
<div className="flex h-screen w-full overflow-hidden">
<Sidebar role="complementary">
<SidebarHeader>
<div className="p-4 font-bold text-xl">EA Chatbot</div>
@@ -20,12 +20,12 @@ export function MainLayout({ children }: MainLayoutProps) {
<div className="p-4 text-xs text-muted-foreground">© 2026 Election Analytics</div>
</SidebarFooter>
</Sidebar>
<SidebarInset className="flex flex-col flex-1">
<SidebarInset className="flex flex-col flex-1 h-full overflow-hidden">
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4" role="navigation">
<SidebarTrigger />
<div className="font-semibold">Chat</div>
</header>
<main className="flex-1 overflow-auto p-6">
<main className="flex-1 flex flex-col p-6 overflow-hidden bg-muted/10">
{children}
</main>
</SidebarInset>

View File

@@ -0,0 +1,58 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }