feat(history): Implement conversation history management with artifact restoration and Markdown rendering.

This commit is contained in:
Yunxiao Xu
2026-02-13 01:59:37 -08:00
parent 339f69a2a3
commit dc6e73ec79
18 changed files with 2288 additions and 62 deletions

1
.gitignore vendored
View File

@@ -20,3 +20,4 @@ langchain-docs/
# VS Code / IDEs # VS Code / IDEs
.vscode/ .vscode/
.idea/ .idea/
CODE_REVIEW.md

View File

@@ -59,7 +59,8 @@ async def get_conversation_messages(
"id": str(m.id), "id": str(m.id),
"role": m.role, "role": m.role,
"content": m.content, "content": m.content,
"created_at": m.created_at "created_at": m.created_at,
"plot_ids": [str(p.id) for p in m.plots]
} for m in messages } for m in messages
] ]

View File

@@ -37,7 +37,7 @@ class MessageResponse(BaseModel):
role: str role: str
content: str content: str
created_at: datetime created_at: datetime
# Plots are fetched separately via artifact endpoints plot_ids: List[str] = []
class ConversationUpdate(BaseModel): class ConversationUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None

View File

@@ -328,7 +328,8 @@ def main():
"code_output": None, "code_output": None,
"error": None, "error": None,
"plots": [], "plots": [],
"dfs": {} "dfs": {},
"iterations": 0
} }
# Placeholder for graph output # Placeholder for graph output

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@types/react-syntax-highlighter": "^15.5.13",
"axios": "^1.13.5", "axios": "^1.13.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -21,7 +22,10 @@
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hook-form": "^7.71.1", "react-hook-form": "^7.71.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.13.0",
"react-syntax-highlighter": "^16.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"zod": "^4.3.6" "zod": "^4.3.6"

View File

@@ -6,7 +6,8 @@ import { RegisterForm } from "./components/auth/RegisterForm"
import { AuthCallback } from "./components/auth/AuthCallback" import { AuthCallback } from "./components/auth/AuthCallback"
import { ChatInterface } from "./components/chat/ChatInterface" import { ChatInterface } from "./components/chat/ChatInterface"
import { AuthService, type UserResponse } from "./services/auth" import { AuthService, type UserResponse } from "./services/auth"
import { ChatService } from "./services/chat" import { ChatService, type MessageResponse } from "./services/chat"
import { type Conversation } from "./components/layout/HistorySidebar"
import { registerUnauthorizedCallback } from "./services/api" import { registerUnauthorizedCallback } from "./services/api"
function App() { function App() {
@@ -15,12 +16,17 @@ function App() {
const [authMode, setAuthMode] = useState<"login" | "register">("login") const [authMode, setAuthMode] = useState<"login" | "register">("login")
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null) const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null)
const [conversations, setConversations] = useState<Conversation[]>([])
const [threadMessages, setThreadMessages] = useState<Record<string, MessageResponse[]>>({})
useEffect(() => { useEffect(() => {
// Register callback to handle session expiration from anywhere in the app // Register callback to handle session expiration from anywhere in the app
registerUnauthorizedCallback(() => { registerUnauthorizedCallback(() => {
setIsAuthenticated(false) setIsAuthenticated(false)
setUser(null) setUser(null)
setConversations([])
setSelectedThreadId(null)
setThreadMessages({})
}) })
const initAuth = async () => { const initAuth = async () => {
@@ -28,8 +34,9 @@ function App() {
const userData = await AuthService.getMe() const userData = await AuthService.getMe()
setUser(userData) setUser(userData)
setIsAuthenticated(true) setIsAuthenticated(true)
// Load history after successful auth
loadHistory()
} catch (err: unknown) { } catch (err: unknown) {
// Not logged in or session expired - this is expected if no cookie
console.log("No active session found", err) console.log("No active session found", err)
setIsAuthenticated(false) setIsAuthenticated(false)
} finally { } finally {
@@ -40,11 +47,21 @@ function App() {
initAuth() initAuth()
}, []) }, [])
const loadHistory = async () => {
try {
const history = await ChatService.listConversations()
setConversations(history)
} catch (err) {
console.error("Failed to load conversation history:", err)
}
}
const handleAuthSuccess = async () => { const handleAuthSuccess = async () => {
try { try {
const userData = await AuthService.getMe() const userData = await AuthService.getMe()
setUser(userData) setUser(userData)
setIsAuthenticated(true) setIsAuthenticated(true)
loadHistory()
} catch (err: unknown) { } catch (err: unknown) {
console.error("Failed to fetch user profile after login:", err) console.error("Failed to fetch user profile after login:", err)
} }
@@ -59,19 +76,65 @@ function App() {
setIsAuthenticated(false) setIsAuthenticated(false)
setUser(null) setUser(null)
setSelectedThreadId(null) setSelectedThreadId(null)
setConversations([])
setThreadMessages({})
} }
} }
const handleCreateTempChat = async () => { const handleSelectConversation = async (id: string) => {
setSelectedThreadId(id)
// Always fetch messages to avoid stale cache issues when switching back
// or if the session was updated from elsewhere
try { try {
const conv = await ChatService.createConversation("Temporary Chat") const msgs = await ChatService.getMessages(id)
setSelectedThreadId(conv.id) setThreadMessages(prev => ({ ...prev, [id]: msgs }))
} catch (err) {
console.error("Failed to fetch messages:", err)
}
}
const handleCreateConversation = async () => {
try {
const newConv = await ChatService.createConversation()
setConversations(prev => [newConv, ...prev])
setSelectedThreadId(newConv.id)
setThreadMessages(prev => ({ ...prev, [newConv.id]: [] }))
} catch (err) { } catch (err) {
console.error("Failed to create conversation:", err) console.error("Failed to create conversation:", err)
alert("Failed to start chat session. Please try again.")
} }
} }
const handleRenameConversation = async (id: string, name: string) => {
try {
const updated = await ChatService.renameConversation(id, name)
setConversations(prev => prev.map(c => c.id === id ? updated : c))
} catch (err) {
console.error("Failed to rename conversation:", err)
}
}
const handleDeleteConversation = async (id: string) => {
try {
await ChatService.deleteConversation(id)
setConversations(prev => prev.filter(c => c.id !== id))
// Also clear from cache
setThreadMessages(prev => {
const next = { ...prev }
delete next[id]
return next
})
if (selectedThreadId === id) {
setSelectedThreadId(null)
}
} catch (err) {
console.error("Failed to delete conversation:", err)
}
}
const handleMessagesFinal = (id: string, messages: MessageResponse[]) => {
setThreadMessages(prev => ({ ...prev, [id]: messages }))
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background"> <div className="min-h-screen flex items-center justify-center bg-background">
@@ -101,7 +164,14 @@ function App() {
)} )}
</div> </div>
) : ( ) : (
<MainLayout> <MainLayout
conversations={conversations}
selectedId={selectedThreadId}
onSelect={handleSelectConversation}
onCreate={handleCreateConversation}
onRename={handleRenameConversation}
onDelete={handleDeleteConversation}
>
<div className="flex flex-col h-full gap-4"> <div className="flex flex-col h-full gap-4">
<div className="flex justify-between items-center shrink-0"> <div className="flex justify-between items-center shrink-0">
<div> <div>
@@ -119,7 +189,12 @@ function App() {
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
{selectedThreadId ? ( {selectedThreadId ? (
<ChatInterface threadId={selectedThreadId} /> <ChatInterface
key={selectedThreadId} // Force remount on thread change
threadId={selectedThreadId}
initialMessages={threadMessages[selectedThreadId] || []}
onMessagesFinal={(msgs) => handleMessagesFinal(selectedThreadId, msgs)}
/>
) : ( ) : (
<div className="flex flex-col items-center justify-center h-full text-center space-y-4 bg-muted/30 rounded-xl border border-dashed p-12"> <div className="flex flex-col items-center justify-center h-full text-center space-y-4 bg-muted/30 rounded-xl border border-dashed p-12">
<div className="p-4 bg-background rounded-full shadow-sm"> <div className="p-4 bg-background rounded-full shadow-sm">
@@ -144,10 +219,10 @@ function App() {
Create a new conversation in the sidebar to start asking questions. Create a new conversation in the sidebar to start asking questions.
</p> </p>
<button <button
onClick={handleCreateTempChat} onClick={handleCreateConversation}
className="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90" className="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90"
> >
Start Temporary Chat Start New Chat
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react" import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { describe, it, expect, vi, beforeEach } from "vitest" import { describe, it, expect, vi, beforeEach } from "vitest"
import { ChatInterface } from "./ChatInterface" import { ChatInterface } from "./ChatInterface"
import { ChatService, type StreamCallbacks } from "@/services/chat" import { ChatService, type StreamCallbacks, type MessageResponse } from "@/services/chat"
vi.mock("@/services/chat", () => ({ vi.mock("@/services/chat", () => ({
ChatService: { ChatService: {
@@ -42,7 +42,7 @@ describe("ChatInterface", () => {
it("displays error message when stream fails", async () => { it("displays error message when stream fails", async () => {
const mockedStreamChat = vi.mocked(ChatService.streamChat) const mockedStreamChat = vi.mocked(ChatService.streamChat)
mockedStreamChat.mockImplementation((_msg: string, _id: string, _msgs: any[], callbacks: StreamCallbacks) => { mockedStreamChat.mockImplementation((_msg: string, _id: string, _msgs: MessageResponse[], callbacks: StreamCallbacks) => {
if (callbacks.onError) { if (callbacks.onError) {
callbacks.onError("Connection failed") callbacks.onError("Connection failed")
} }

View File

@@ -8,11 +8,16 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
interface ChatInterfaceProps { interface ChatInterfaceProps {
threadId: string threadId: string
initialMessages?: MessageResponse[] initialMessages?: MessageResponse[]
onMessagesFinal?: (messages: MessageResponse[]) => void
} }
const EMPTY_MESSAGES: MessageResponse[] = [] const EMPTY_MESSAGES: MessageResponse[] = []
export function ChatInterface({ threadId, initialMessages = EMPTY_MESSAGES }: ChatInterfaceProps) { export function ChatInterface({
threadId,
initialMessages = EMPTY_MESSAGES,
onMessagesFinal
}: ChatInterfaceProps) {
const [messages, setMessages] = React.useState<MessageResponse[]>(initialMessages) const [messages, setMessages] = React.useState<MessageResponse[]>(initialMessages)
const [isStreaming, setIsStreaming] = React.useState(false) const [isStreaming, setIsStreaming] = React.useState(false)
const [error, setError] = React.useState<string | null>(null) const [error, setError] = React.useState<string | null>(null)
@@ -42,6 +47,9 @@ export function ChatInterface({ threadId, initialMessages = EMPTY_MESSAGES }: Ch
onMessageUpdate: (updatedMessages) => { onMessageUpdate: (updatedMessages) => {
setMessages(updatedMessages) setMessages(updatedMessages)
}, },
onMessagesFinal: (finalMessages) => {
if (onMessagesFinal) onMessagesFinal(finalMessages)
},
onDone: () => { onDone: () => {
setIsStreaming(false) setIsStreaming(false)
}, },

View File

@@ -0,0 +1,78 @@
import ReactMarkdown, { type Components } from "react-markdown"
import remarkGfm from "remark-gfm"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"
interface MarkdownContentProps {
content: string
}
export function MarkdownContent({ content }: MarkdownContentProps) {
const components: Components = {
code({ children, className }) {
const match = /language-(\w+)/.exec(className || "")
if (match) {
return (
<SyntaxHighlighter
style={oneDark}
language={match[1]}
PreTag="div"
customStyle={{
margin: "1rem 0",
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
)
}
return <code className={className}>{children}</code>
},
table({ children }) {
return (
<div className="my-4 w-full overflow-x-auto rounded-lg border">
<table className="w-full text-sm">{children}</table>
</div>
)
},
thead({ children }) {
return <thead className="bg-muted/50 border-b">{children}</thead>
},
th({ children }) {
return <th className="px-4 py-2 text-left font-semibold">{children}</th>
},
td({ children }) {
return <td className="border-t px-4 py-2">{children}</td>
},
p({ children }) {
return <p className="mb-2 last:mb-0">{children}</p>
},
ul({ children }) {
return <ul className="mb-4 list-disc pl-6 last:mb-0">{children}</ul>
},
ol({ children }) {
return <ol className="mb-4 list-decimal pl-6 last:mb-0">{children}</ol>
},
li({ children }) {
return <li className="mb-1">{children}</li>
},
h1({ children }) {
return <h1 className="mb-4 text-2xl font-bold">{children}</h1>
},
h2({ children }) {
return <h2 className="mb-3 text-xl font-bold">{children}</h2>
},
h3({ children }) {
return <h3 className="mb-2 text-lg font-bold">{children}</h3>
},
}
return (
<div className="text-sm leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{content}
</ReactMarkdown>
</div>
)
}

View File

@@ -1,5 +1,6 @@
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { type MessageResponse } from "@/services/chat" import { type MessageResponse } from "@/services/chat"
import { MarkdownContent } from "./MarkdownContent"
interface MessageBubbleProps { interface MessageBubbleProps {
message: MessageResponse message: MessageResponse
@@ -43,8 +44,11 @@ export function MessageBubble({ message }: MessageBubbleProps) {
</div> </div>
)} )}
<div className="whitespace-pre-wrap break-words text-sm"> <div className="break-words text-sm">
{message.content || (isAssistant && !message.plots?.length ? ( {isAssistant ? (
message.content ? (
<MarkdownContent content={message.content} />
) : !message.plots?.length && !message.plot_ids?.length ? (
<div className="flex items-center gap-1 py-1"> <div className="flex items-center gap-1 py-1">
<div className="flex gap-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.3s]" />
@@ -52,14 +56,17 @@ export function MessageBubble({ message }: MessageBubbleProps) {
<div className="h-1.5 w-1.5 rounded-full bg-current animate-bounce" /> <div className="h-1.5 w-1.5 rounded-full bg-current animate-bounce" />
</div> </div>
</div> </div>
) : "")} ) : null
) : (
<div className="whitespace-pre-wrap">{message.content}</div>
)}
</div> </div>
{message.plots && message.plots.length > 0 && ( {(message.plots || message.plot_ids) && ((message.plots?.length || 0) > 0 || (message.plot_ids?.length || 0) > 0) && (
<div className="mt-4 grid grid-cols-1 gap-2"> <div className="mt-4 grid grid-cols-1 gap-2">
{message.plots.map((plot, index) => ( {message.plots?.map((plot, index) => (
<img <img
key={index} key={`stream-${index}`}
src={`data:image/png;base64,${plot}`} src={`data:image/png;base64,${plot}`}
alt="Analysis Plot" alt="Analysis Plot"
className="rounded-md border bg-white w-full h-auto cursor-pointer hover:opacity-90 transition-opacity" className="rounded-md border bg-white w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
@@ -68,6 +75,17 @@ export function MessageBubble({ message }: MessageBubbleProps) {
}} }}
/> />
))} ))}
{message.plot_ids?.map((plotId, index) => (
<img
key={`history-${index}`}
src={`${import.meta.env.VITE_API_URL || ""}/api/v1/artifacts/plots/${plotId}`}
alt="Historical 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> </div>

View File

@@ -0,0 +1,166 @@
import * as React from "react"
import {
PlusIcon,
MessageSquareIcon,
MoreVerticalIcon,
PencilIcon,
TrashIcon,
CheckIcon,
XIcon
} from "lucide-react"
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuAction
} from "@/components/ui/sidebar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
export interface Conversation {
id: string
name: string
created_at: string
}
interface HistorySidebarProps {
conversations: Conversation[]
selectedId: string | null
onSelect: (id: string) => void
onCreate: () => void
onRename: (id: string, newName: string) => void
onDelete: (id: string) => void
}
export function HistorySidebar({
conversations,
selectedId,
onSelect,
onCreate,
onRename,
onDelete,
}: HistorySidebarProps) {
const [editingId, setEditingId] = React.useState<string | null>(null)
const [editValue, setEditValue] = React.useState("")
const handleStartRename = (conv: Conversation) => {
setEditingId(conv.id)
setEditValue(conv.name)
}
const handleConfirmRename = () => {
if (editingId && editValue.trim()) {
onRename(editingId, editValue.trim())
}
setEditingId(null)
}
const handleCancelRename = () => {
setEditingId(null)
}
const handleDelete = (id: string) => {
if (confirm("Are you sure you want to delete this conversation?")) {
onDelete(id)
}
}
return (
<Sidebar role="complementary">
<SidebarHeader className="border-b p-4">
<Button
onClick={onCreate}
className="w-full justify-start gap-2"
variant="outline"
>
<PlusIcon className="h-4 w-4" />
New Chat
</Button>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Recent Conversations</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{conversations.map((conv) => (
<SidebarMenuItem key={conv.id}>
{editingId === conv.id ? (
<div className="flex items-center gap-1 p-1 w-full">
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
className="h-7 text-sm"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleConfirmRename()
if (e.key === "Escape") handleCancelRename()
}}
/>
<Button size="icon-xs" variant="ghost" onClick={handleConfirmRename}>
<CheckIcon className="h-3 w-3" />
</Button>
<Button size="icon-xs" variant="ghost" onClick={handleCancelRename}>
<XIcon className="h-3 w-3" />
</Button>
</div>
) : (
<>
<SidebarMenuButton
isActive={selectedId === conv.id}
onClick={() => onSelect(conv.id)}
tooltip={conv.name}
>
<MessageSquareIcon className="h-4 w-4" />
<span>{conv.name}</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreVerticalIcon className="h-4 w-4" />
<span className="sr-only">Conversation actions</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => handleStartRename(conv)}>
<PencilIcon className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(conv.id)}
className="text-destructive focus:text-destructive"
>
<TrashIcon className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t p-4">
<div className="text-xs text-muted-foreground">© 2026 Election Analytics</div>
</SidebarFooter>
</Sidebar>
)
}

View File

@@ -1,11 +1,20 @@
import { render, screen } from "@testing-library/react" import { render, screen } from "@testing-library/react"
import { describe, it, expect } from "vitest" import { describe, it, expect, vi } from "vitest"
import { MainLayout } from "./MainLayout" import { MainLayout } from "./MainLayout"
describe("MainLayout", () => { describe("MainLayout", () => {
const mockProps = {
conversations: [],
selectedId: null,
onSelect: vi.fn(),
onCreate: vi.fn(),
onRename: vi.fn(),
onDelete: vi.fn(),
}
it("renders children correctly", () => { it("renders children correctly", () => {
render( render(
<MainLayout> <MainLayout {...mockProps}>
<div data-testid="test-child">Test Content</div> <div data-testid="test-child">Test Content</div>
</MainLayout> </MainLayout>
) )
@@ -14,13 +23,13 @@ describe("MainLayout", () => {
}) })
it("renders the sidebar", () => { it("renders the sidebar", () => {
render(<MainLayout>Content</MainLayout>) render(<MainLayout {...mockProps}>Content</MainLayout>)
// Sidebar should have some identifying text or role // Sidebar should have some identifying text or role
expect(screen.getByRole("complementary")).toBeInTheDocument() expect(screen.getByRole("complementary")).toBeInTheDocument()
}) })
it("renders the header/navigation", () => { it("renders the header/navigation", () => {
render(<MainLayout>Content</MainLayout>) render(<MainLayout {...mockProps}>Content</MainLayout>)
expect(screen.getByRole("navigation")).toBeInTheDocument() expect(screen.getByRole("navigation")).toBeInTheDocument()
}) })
}) })

View File

@@ -1,25 +1,37 @@
import * as React from "react" import * as React from "react"
import { SidebarProvider, Sidebar, SidebarContent, SidebarHeader, SidebarFooter, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar" import { SidebarProvider, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar"
import { HistorySidebar, type Conversation } from "./HistorySidebar"
interface MainLayoutProps { interface MainLayoutProps {
children: React.ReactNode children: React.ReactNode
conversations: Conversation[]
selectedId: string | null
onSelect: (id: string) => void
onCreate: () => void
onRename: (id: string, newName: string) => void
onDelete: (id: string) => void
} }
export function MainLayout({ children }: MainLayoutProps) { export function MainLayout({
children,
conversations,
selectedId,
onSelect,
onCreate,
onRename,
onDelete
}: MainLayoutProps) {
return ( return (
<SidebarProvider> <SidebarProvider>
<div className="flex h-screen w-full overflow-hidden"> <div className="flex h-screen w-full overflow-hidden">
<Sidebar role="complementary"> <HistorySidebar
<SidebarHeader> conversations={conversations}
<div className="p-4 font-bold text-xl">EA Chatbot</div> selectedId={selectedId}
</SidebarHeader> onSelect={onSelect}
<SidebarContent> onCreate={onCreate}
{/* Navigation links or conversation history will go here */} onRename={onRename}
</SidebarContent> onDelete={onDelete}
<SidebarFooter> />
<div className="p-4 text-xs text-muted-foreground">© 2026 Election Analytics</div>
</SidebarFooter>
</Sidebar>
<SidebarInset className="flex flex-col flex-1 h-full overflow-hidden"> <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"> <header className="flex h-16 shrink-0 items-center gap-2 border-b px-4" role="navigation">
<SidebarTrigger /> <SidebarTrigger />

View File

@@ -0,0 +1,199 @@
"use client"
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger"
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = "DropdownMenuSubContent"
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = "DropdownMenuContent"
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 select-none",
inset && "pl-8",
className
)}
{...props}
>
{children}
</DropdownMenuPrimitive.Item>
))
DropdownMenuItem.displayName = "DropdownMenuItem"
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 select-none",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = "DropdownMenuCheckboxItem"
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 select-none",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = "DropdownMenuRadioItem"
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = "DropdownMenuLabel"
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("bg-muted -mx-1 my-1 h-px", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = "DropdownMenuSeparator"
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest" import { describe, it, expect } from "vitest"
import { ChatService, type ChatEvent } from "./chat" import { ChatService, type ChatEvent, type MessageResponse } from "./chat"
describe("ChatService SSE Parsing", () => { describe("ChatService SSE Parsing", () => {
it("should correctly parse a text stream chunk", () => { it("should correctly parse a text stream chunk", () => {
@@ -42,26 +42,26 @@ describe("ChatService SSE Parsing", () => {
describe("ChatService Message State Management", () => { describe("ChatService Message State Management", () => {
it("should append text chunks to the last message content", () => { it("should append text chunks to the last message content", () => {
const messages = [{ id: "1", role: "assistant", content: "Initial", created_at: new Date().toISOString() }] const messages: MessageResponse[] = [{ id: "1", role: "assistant", content: "Initial", created_at: new Date().toISOString() }]
const event: ChatEvent = { const event: ChatEvent = {
type: "on_chat_model_stream", type: "on_chat_model_stream",
node: "summarizer", node: "summarizer",
data: { chunk: { content: " text" } } data: { chunk: { content: " text" } }
} }
const updatedMessages = ChatService.updateMessagesWithEvent(messages as any, event) const updatedMessages = ChatService.updateMessagesWithEvent(messages, event)
expect(updatedMessages[0].content).toBe("Initial text") expect(updatedMessages[0].content).toBe("Initial text")
}) })
it("should add plots to the message state", () => { it("should add plots to the message state", () => {
const messages = [{ id: "1", role: "assistant", content: "Analysis", created_at: new Date().toISOString(), plots: [] }] const messages: MessageResponse[] = [{ id: "1", role: "assistant", content: "Analysis", created_at: new Date().toISOString(), plots: [] }]
const event: ChatEvent = { const event: ChatEvent = {
type: "on_chain_end", type: "on_chain_end",
name: "executor", name: "executor",
data: { encoded_plots: ["plot1"] } data: { encoded_plots: ["plot1"] }
} }
const updatedMessages = ChatService.updateMessagesWithEvent(messages as any, event) const updatedMessages = ChatService.updateMessagesWithEvent(messages, event)
expect(updatedMessages[0].plots).toEqual(["plot1"]) expect(updatedMessages[0].plots).toEqual(["plot1"])
}) })
}) })

View File

@@ -5,7 +5,8 @@ export interface MessageResponse {
role: "user" | "assistant" role: "user" | "assistant"
content: string content: string
created_at: string created_at: string
plots?: string[] // base64 encoded plots plots?: string[] // base64 encoded plots (from stream)
plot_ids?: string[] // plot IDs (from history)
steps?: string[] // reasoning steps steps?: string[] // reasoning steps
} }
@@ -13,11 +14,17 @@ export interface ChatEvent {
type: string type: string
name?: string name?: string
node?: string node?: string
data?: any data?: {
chunk?: { content?: string }
output?: { messages?: { content: string }[] }
encoded_plots?: string[]
message?: string
}
} }
export interface StreamCallbacks { export interface StreamCallbacks {
onMessageUpdate: (messages: MessageResponse[]) => void onMessageUpdate: (messages: MessageResponse[]) => void
onMessagesFinal?: (messages: MessageResponse[]) => void
onDone?: () => void onDone?: () => void
onError?: (error: string) => void onError?: (error: string) => void
} }
@@ -173,7 +180,7 @@ export const ChatService = {
currentMessages: MessageResponse[], currentMessages: MessageResponse[],
callbacks: StreamCallbacks callbacks: StreamCallbacks
) { ) {
const { onMessageUpdate, onDone, onError } = callbacks const { onMessageUpdate, onMessagesFinal, onDone, onError } = callbacks
// Add user message and a placeholder assistant message // Add user message and a placeholder assistant message
let activeMessages: MessageResponse[] = [ let activeMessages: MessageResponse[] = [
@@ -228,6 +235,7 @@ export const ChatService = {
for (const event of events) { for (const event of events) {
if (event.type === "done") { if (event.type === "done") {
if (onMessagesFinal) onMessagesFinal(activeMessages)
if (onDone) onDone() if (onDone) onDone()
continue continue
} }
@@ -256,6 +264,15 @@ export const ChatService = {
return response.data return response.data
}, },
async renameConversation(conversationId: string, name: string) {
const response = await api.patch(`/conversations/${conversationId}`, { name })
return response.data
},
async deleteConversation(conversationId: string) {
await api.delete(`/conversations/${conversationId}`)
},
async getMessages(conversationId: string) { async getMessages(conversationId: string) {
const response = await api.get(`/conversations/${conversationId}/messages`) const response = await api.get(`/conversations/${conversationId}/messages`)
return response.data return response.data

View File

@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import api from "./api"
import { ChatService } from "./chat"
vi.mock("./api")
const mockedApi = api as any
describe("ChatService History Management", () => {
beforeEach(() => {
vi.resetAllMocks()
})
it("should list conversations", async () => {
const mockData = [{ id: "c1", name: "Conv 1" }]
mockedApi.get.mockResolvedValueOnce({ data: mockData })
const result = await ChatService.listConversations()
expect(mockedApi.get).toHaveBeenCalledWith("/conversations")
expect(result).toEqual(mockData)
})
it("should create a conversation", async () => {
const mockData = { id: "c2", name: "New Conv" }
mockedApi.post.mockResolvedValueOnce({ data: mockData })
const result = await ChatService.createConversation("New Conv")
expect(mockedApi.post).toHaveBeenCalledWith("/conversations", { name: "New Conv" })
expect(result).toEqual(mockData)
})
it("should rename a conversation", async () => {
const mockData = { id: "c1", name: "Renamed" }
mockedApi.patch.mockResolvedValueOnce({ data: mockData })
const result = await ChatService.renameConversation("c1", "Renamed")
expect(mockedApi.patch).toHaveBeenCalledWith("/conversations/c1", { name: "Renamed" })
expect(result).toEqual(mockData)
})
it("should delete a conversation", async () => {
mockedApi.delete.mockResolvedValueOnce({})
await ChatService.deleteConversation("c1")
expect(mockedApi.delete).toHaveBeenCalledWith("/conversations/c1")
})
it("should fetch messages", async () => {
const mockData = [{ id: "m1", content: "Hi" }]
mockedApi.get.mockResolvedValueOnce({ data: mockData })
const result = await ChatService.getMessages("c1")
expect(mockedApi.get).toHaveBeenCalledWith("/conversations/c1/messages")
expect(result).toEqual(mockData)
})
})