feat(history): Implement conversation history management with artifact restoration and Markdown rendering.
This commit is contained in:
@@ -6,7 +6,8 @@ import { RegisterForm } from "./components/auth/RegisterForm"
|
||||
import { AuthCallback } from "./components/auth/AuthCallback"
|
||||
import { ChatInterface } from "./components/chat/ChatInterface"
|
||||
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"
|
||||
|
||||
function App() {
|
||||
@@ -15,12 +16,17 @@ function App() {
|
||||
const [authMode, setAuthMode] = useState<"login" | "register">("login")
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null)
|
||||
const [conversations, setConversations] = useState<Conversation[]>([])
|
||||
const [threadMessages, setThreadMessages] = useState<Record<string, MessageResponse[]>>({})
|
||||
|
||||
useEffect(() => {
|
||||
// Register callback to handle session expiration from anywhere in the app
|
||||
registerUnauthorizedCallback(() => {
|
||||
setIsAuthenticated(false)
|
||||
setUser(null)
|
||||
setConversations([])
|
||||
setSelectedThreadId(null)
|
||||
setThreadMessages({})
|
||||
})
|
||||
|
||||
const initAuth = async () => {
|
||||
@@ -28,8 +34,9 @@ function App() {
|
||||
const userData = await AuthService.getMe()
|
||||
setUser(userData)
|
||||
setIsAuthenticated(true)
|
||||
// Load history after successful auth
|
||||
loadHistory()
|
||||
} catch (err: unknown) {
|
||||
// Not logged in or session expired - this is expected if no cookie
|
||||
console.log("No active session found", err)
|
||||
setIsAuthenticated(false)
|
||||
} finally {
|
||||
@@ -40,11 +47,21 @@ function App() {
|
||||
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 () => {
|
||||
try {
|
||||
const userData = await AuthService.getMe()
|
||||
setUser(userData)
|
||||
setIsAuthenticated(true)
|
||||
loadHistory()
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to fetch user profile after login:", err)
|
||||
}
|
||||
@@ -59,19 +76,65 @@ function App() {
|
||||
setIsAuthenticated(false)
|
||||
setUser(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 {
|
||||
const conv = await ChatService.createConversation("Temporary Chat")
|
||||
setSelectedThreadId(conv.id)
|
||||
const msgs = await ChatService.getMessages(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) {
|
||||
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) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
@@ -101,7 +164,14 @@ function App() {
|
||||
)}
|
||||
</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 justify-between items-center shrink-0">
|
||||
<div>
|
||||
@@ -119,7 +189,12 @@ function App() {
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{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="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.
|
||||
</p>
|
||||
<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"
|
||||
>
|
||||
Start Temporary Chat
|
||||
Start New Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
78
frontend/src/components/chat/MarkdownContent.tsx
Normal file
78
frontend/src/components/chat/MarkdownContent.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
166
frontend/src/components/layout/HistorySidebar.tsx
Normal file
166
frontend/src/components/layout/HistorySidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 />
|
||||
|
||||
199
frontend/src/components/ui/dropdown-menu.tsx
Normal file
199
frontend/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import { ChatService, type ChatEvent } from "./chat"
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { ChatService, type ChatEvent, type MessageResponse } from "./chat"
|
||||
|
||||
describe("ChatService SSE Parsing", () => {
|
||||
it("should correctly parse a text stream chunk", () => {
|
||||
@@ -42,26 +42,26 @@ describe("ChatService SSE Parsing", () => {
|
||||
|
||||
describe("ChatService Message State Management", () => {
|
||||
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 = {
|
||||
type: "on_chat_model_stream",
|
||||
node: "summarizer",
|
||||
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")
|
||||
})
|
||||
|
||||
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 = {
|
||||
type: "on_chain_end",
|
||||
name: "executor",
|
||||
data: { encoded_plots: ["plot1"] }
|
||||
}
|
||||
|
||||
const updatedMessages = ChatService.updateMessagesWithEvent(messages as any, event)
|
||||
const updatedMessages = ChatService.updateMessagesWithEvent(messages, event)
|
||||
expect(updatedMessages[0].plots).toEqual(["plot1"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,8 @@ export interface MessageResponse {
|
||||
role: "user" | "assistant"
|
||||
content: 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
|
||||
}
|
||||
|
||||
@@ -13,11 +14,17 @@ export interface ChatEvent {
|
||||
type: string
|
||||
name?: string
|
||||
node?: string
|
||||
data?: any
|
||||
data?: {
|
||||
chunk?: { content?: string }
|
||||
output?: { messages?: { content: string }[] }
|
||||
encoded_plots?: string[]
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface StreamCallbacks {
|
||||
onMessageUpdate: (messages: MessageResponse[]) => void
|
||||
onMessagesFinal?: (messages: MessageResponse[]) => void
|
||||
onDone?: () => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
@@ -173,7 +180,7 @@ export const ChatService = {
|
||||
currentMessages: MessageResponse[],
|
||||
callbacks: StreamCallbacks
|
||||
) {
|
||||
const { onMessageUpdate, onDone, onError } = callbacks
|
||||
const { onMessageUpdate, onMessagesFinal, onDone, onError } = callbacks
|
||||
|
||||
// Add user message and a placeholder assistant message
|
||||
let activeMessages: MessageResponse[] = [
|
||||
@@ -228,6 +235,7 @@ export const ChatService = {
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type === "done") {
|
||||
if (onMessagesFinal) onMessagesFinal(activeMessages)
|
||||
if (onDone) onDone()
|
||||
continue
|
||||
}
|
||||
@@ -256,6 +264,15 @@ export const ChatService = {
|
||||
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) {
|
||||
const response = await api.get(`/conversations/${conversationId}/messages`)
|
||||
return response.data
|
||||
|
||||
55
frontend/src/services/chat_history.test.ts
Normal file
55
frontend/src/services/chat_history.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user