feat(chat): Implement real-time SSE streaming with reasoning steps and improved UI indicators.
This commit is contained in:
38
frontend/src/components/chat/ChatInput.tsx
Normal file
38
frontend/src/components/chat/ChatInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
frontend/src/components/chat/ChatInterface.test.tsx
Normal file
62
frontend/src/components/chat/ChatInterface.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
79
frontend/src/components/chat/ChatInterface.tsx
Normal file
79
frontend/src/components/chat/ChatInterface.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
frontend/src/components/chat/MessageBubble.tsx
Normal file
76
frontend/src/components/chat/MessageBubble.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
frontend/src/components/chat/MessageList.tsx
Normal file
34
frontend/src/components/chat/MessageList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
58
frontend/src/components/ui/alert.tsx
Normal file
58
frontend/src/components/ui/alert.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user