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

View File

@@ -1,7 +1,7 @@
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"
import { ChatService, type StreamCallbacks, type MessageResponse } from "@/services/chat"
vi.mock("@/services/chat", () => ({
ChatService: {
@@ -42,7 +42,7 @@ describe("ChatInterface", () => {
it("displays error message when stream fails", async () => {
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) {
callbacks.onError("Connection failed")
}

View File

@@ -8,11 +8,16 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
interface ChatInterfaceProps {
threadId: string
initialMessages?: MessageResponse[]
onMessagesFinal?: (messages: MessageResponse[]) => void
}
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 [isStreaming, setIsStreaming] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
@@ -42,6 +47,9 @@ export function ChatInterface({ threadId, initialMessages = EMPTY_MESSAGES }: Ch
onMessageUpdate: (updatedMessages) => {
setMessages(updatedMessages)
},
onMessagesFinal: (finalMessages) => {
if (onMessagesFinal) onMessagesFinal(finalMessages)
},
onDone: () => {
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 { type MessageResponse } from "@/services/chat"
import { MarkdownContent } from "./MarkdownContent"
interface MessageBubbleProps {
message: MessageResponse
@@ -43,23 +44,29 @@ export function MessageBubble({ message }: MessageBubbleProps) {
</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 className="break-words text-sm">
{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 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>
) : "")}
) : null
) : (
<div className="whitespace-pre-wrap">{message.content}</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">
{message.plots.map((plot, index) => (
{message.plots?.map((plot, index) => (
<img
key={index}
key={`stream-${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"
@@ -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>

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

View File

@@ -1,25 +1,37 @@
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 {
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 (
<SidebarProvider>
<div className="flex h-screen w-full overflow-hidden">
<Sidebar role="complementary">
<SidebarHeader>
<div className="p-4 font-bold text-xl">EA Chatbot</div>
</SidebarHeader>
<SidebarContent>
{/* Navigation links or conversation history will go here */}
</SidebarContent>
<SidebarFooter>
<div className="p-4 text-xs text-muted-foreground">© 2026 Election Analytics</div>
</SidebarFooter>
</Sidebar>
<HistorySidebar
conversations={conversations}
selectedId={selectedId}
onSelect={onSelect}
onCreate={onCreate}
onRename={onRename}
onDelete={onDelete}
/>
<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 />

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,
}