feat: message history and PostgreSQL checkpointer integration

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2025-12-21 03:30:10 -08:00
parent 3906ba52e0
commit 73f0f772a8
11 changed files with 434 additions and 115 deletions

View file

@ -14,7 +14,7 @@ import {
Sparkles,
} from "lucide-react";
import type React from "react";
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { type ReactNode, forwardRef, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Button } from "@/components/ui/button";
@ -44,83 +44,70 @@ const formatDocumentType = (type: string) => {
.join(" ");
};
// Chunk card component with enhanced animations
const ChunkCard = ({
chunk,
index,
totalChunks,
isCited,
isActive,
}: {
// Chunk card component
// For large documents (>30 chunks), we disable animation to prevent layout shifts
// which break auto-scroll functionality
interface ChunkCardProps {
chunk: { id: number; content: string };
index: number;
totalChunks: number;
isCited: boolean;
isActive: boolean;
}) => {
const shouldReduceMotion = useReducedMotion();
disableLayoutAnimation?: boolean;
}
return (
<motion.div
data-chunk-index={index}
initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring",
stiffness: 100,
damping: 15,
delay: shouldReduceMotion ? 0 : Math.min(index * 0.05, 0.3),
}}
className={cn(
"group relative rounded-2xl border-2 transition-all duration-300",
isCited
? "bg-linear-to-br from-primary/5 via-primary/10 to-primary/5 border-primary shadow-lg shadow-primary/10"
: "bg-card border-border/50 hover:border-border hover:shadow-md"
)}
>
{/* Cited indicator glow effect */}
{isCited && (
<div className="absolute inset-0 rounded-2xl bg-primary/5 blur-xl -z-10" />
)}
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50">
<div className="flex items-center gap-3">
<div
className={cn(
"flex items-center justify-center w-8 h-8 rounded-full text-sm font-semibold transition-colors",
isCited
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground group-hover:bg-muted/80"
)}
>
{index + 1}
</div>
<span className="text-sm text-muted-foreground">
of {totalChunks} chunks
</span>
</div>
const ChunkCard = forwardRef<HTMLDivElement, ChunkCardProps>(
({ chunk, index, totalChunks, isCited, isActive, disableLayoutAnimation }, ref) => {
return (
<div
ref={ref}
data-chunk-index={index}
className={cn(
"group relative rounded-2xl border-2 transition-all duration-300",
isCited
? "bg-linear-to-br from-primary/5 via-primary/10 to-primary/5 border-primary shadow-lg shadow-primary/10"
: "bg-card border-border/50 hover:border-border hover:shadow-md"
)}
>
{/* Cited indicator glow effect */}
{isCited && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 200, damping: 15, delay: 0.2 }}
>
<div className="absolute inset-0 rounded-2xl bg-primary/5 blur-xl -z-10" />
)}
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50">
<div className="flex items-center gap-3">
<div
className={cn(
"flex items-center justify-center w-8 h-8 rounded-full text-sm font-semibold transition-colors",
isCited
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground group-hover:bg-muted/80"
)}
>
{index + 1}
</div>
<span className="text-sm text-muted-foreground">
of {totalChunks} chunks
</span>
</div>
{isCited && (
<Badge variant="default" className="gap-1.5 px-3 py-1">
<Sparkles className="h-3 w-3" />
Cited Source
</Badge>
</motion.div>
)}
</div>
)}
</div>
{/* Content */}
<div className="p-5 overflow-hidden">
<MarkdownViewer content={chunk.content} />
{/* Content */}
<div className="p-5 overflow-hidden">
<MarkdownViewer content={chunk.content} />
</div>
</div>
</motion.div>
);
};
);
}
);
ChunkCard.displayName = "ChunkCard";
export function SourceDetailPanel({
open,
@ -133,6 +120,7 @@ export function SourceDetailPanel({
children,
}: SourceDetailPanelProps) {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const hasScrolledRef = useRef(false); // Use ref to avoid stale closures
const [summaryOpen, setSummaryOpen] = useState(false);
const [activeChunkIndex, setActiveChunkIndex] = useState<number | null>(null);
const [mounted, setMounted] = useState(false);
@ -163,30 +151,92 @@ export function SourceDetailPanel({
// Find cited chunk index
const citedChunkIndex = documentData?.chunks?.findIndex((chunk) => chunk.id === chunkId) ?? -1;
// Auto-scroll to cited chunk when data loads
useEffect(() => {
if (documentData?.chunks && citedChunkIndex !== -1 && !hasScrolledToCited && open) {
// Wait for animations to complete then scroll
const timer = setTimeout(() => {
const chunkElement = scrollAreaRef.current?.querySelector(
`[data-chunk-index="${citedChunkIndex}"]`
);
if (chunkElement) {
chunkElement.scrollIntoView({
behavior: shouldReduceMotion ? "auto" : "smooth",
block: "center",
});
setHasScrolledToCited(true);
setActiveChunkIndex(citedChunkIndex);
}
}, 400);
return () => clearTimeout(timer);
// Simple scroll function that scrolls to a chunk by index
const scrollToChunkByIndex = useCallback((chunkIndex: number, smooth = true) => {
const scrollContainer = scrollAreaRef.current;
if (!scrollContainer) return;
const viewport = scrollContainer.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLElement | null;
if (!viewport) return;
const chunkElement = scrollContainer.querySelector(
`[data-chunk-index="${chunkIndex}"]`
) as HTMLElement | null;
if (!chunkElement) return;
// Get positions using getBoundingClientRect for accuracy
const viewportRect = viewport.getBoundingClientRect();
const chunkRect = chunkElement.getBoundingClientRect();
// Calculate where to scroll to center the chunk
const currentScrollTop = viewport.scrollTop;
const chunkTopRelativeToViewport = chunkRect.top - viewportRect.top + currentScrollTop;
const scrollTarget = chunkTopRelativeToViewport - (viewportRect.height / 2) + (chunkRect.height / 2);
viewport.scrollTo({
top: Math.max(0, scrollTarget),
behavior: smooth && !shouldReduceMotion ? "smooth" : "auto",
});
setActiveChunkIndex(chunkIndex);
}, [shouldReduceMotion]);
// Callback ref for the cited chunk - scrolls when the element mounts
const citedChunkRefCallback = useCallback((node: HTMLDivElement | null) => {
if (node && !hasScrolledRef.current && open) {
hasScrolledRef.current = true; // Mark immediately to prevent duplicate scrolls
// Store the node reference for the delayed scroll
const scrollToCitedChunk = () => {
const scrollContainer = scrollAreaRef.current;
if (!scrollContainer || !node.isConnected) return false;
const viewport = scrollContainer.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLElement | null;
if (!viewport) return false;
// Get positions
const viewportRect = viewport.getBoundingClientRect();
const chunkRect = node.getBoundingClientRect();
// Calculate scroll position to center the chunk
const currentScrollTop = viewport.scrollTop;
const chunkTopRelativeToViewport = chunkRect.top - viewportRect.top + currentScrollTop;
const scrollTarget = chunkTopRelativeToViewport - (viewportRect.height / 2) + (chunkRect.height / 2);
viewport.scrollTo({
top: Math.max(0, scrollTarget),
behavior: "auto", // Instant scroll for initial positioning
});
return true;
};
// Scroll multiple times with delays to handle progressive content rendering
// Each subsequent scroll will correct for any layout shifts
const scrollAttempts = [50, 150, 300, 600, 1000];
scrollAttempts.forEach((delay) => {
setTimeout(() => {
scrollToCitedChunk();
}, delay);
});
// After final attempt, mark state as scrolled
setTimeout(() => {
setHasScrolledToCited(true);
setActiveChunkIndex(citedChunkIndex);
}, scrollAttempts[scrollAttempts.length - 1] + 50);
}
}, [documentData, citedChunkIndex, hasScrolledToCited, open, shouldReduceMotion]);
}, [open, citedChunkIndex]);
// Reset scroll state when panel closes
useEffect(() => {
if (!open) {
hasScrolledRef.current = false;
setHasScrolledToCited(false);
setActiveChunkIndex(null);
}
@ -222,12 +272,8 @@ export function SourceDetailPanel({
};
const scrollToChunk = useCallback((index: number) => {
setActiveChunkIndex(index);
const chunkElement = scrollAreaRef.current?.querySelector(
`[data-chunk-index="${index}"]`
);
chunkElement?.scrollIntoView({ behavior: "smooth", block: "center" });
}, []);
scrollToChunkByIndex(index, true);
}, [scrollToChunkByIndex]);
const panelContent = (
<AnimatePresence mode="wait">
@ -388,9 +434,9 @@ export function SourceDetailPanel({
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
className="hidden lg:flex flex-col w-16 border-r bg-muted/10"
className="hidden lg:flex flex-col w-16 border-r bg-muted/10 overflow-hidden"
>
<ScrollArea className="flex-1">
<ScrollArea className="flex-1 h-full">
<div className="p-2 pt-3 flex flex-col gap-1.5">
{documentData.chunks.map((chunk, idx) => {
const isCited = chunk.id === chunkId;
@ -514,16 +560,21 @@ export function SourceDetailPanel({
{/* Chunks */}
<div className="space-y-4">
{documentData.chunks.map((chunk, idx) => (
<ChunkCard
key={chunk.id}
chunk={chunk}
index={idx}
totalChunks={documentData.chunks.length}
isCited={chunk.id === chunkId}
isActive={activeChunkIndex === idx}
/>
))}
{documentData.chunks.map((chunk, idx) => {
const isCited = chunk.id === chunkId;
return (
<ChunkCard
key={chunk.id}
ref={isCited ? citedChunkRefCallback : undefined}
chunk={chunk}
index={idx}
totalChunks={documentData.chunks.length}
isCited={isCited}
isActive={activeChunkIndex === idx}
disableLayoutAnimation={documentData.chunks.length > 30}
/>
);
})}
</div>
</div>
</ScrollArea>

View file

@ -11,12 +11,41 @@ interface NewChatAdapterConfig {
chatId: number;
}
interface ChatMessageForBackend {
role: "user" | "assistant";
content: string;
}
/**
* Converts assistant-ui messages to a simple format for the backend
*/
function convertMessagesToBackendFormat(
messages: ChatModelRunOptions["messages"]
): ChatMessageForBackend[] {
return messages
.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => {
// Extract text content from the message parts
let content = "";
for (const part of m.content) {
if (part.type === "text") {
content += part.text;
}
}
return {
role: m.role as "user" | "assistant",
content: content.trim(),
};
})
.filter((m) => m.content.length > 0); // Filter out empty messages
}
/**
* Creates a ChatModelAdapter that connects to the FastAPI new_chat endpoint.
*
* The backend expects:
* - POST /api/v1/new_chat
* - Body: { chat_id: number, user_query: string, search_space_id: number }
* - Body: { chat_id: number, user_query: string, search_space_id: number, messages: [...] }
* - Returns: SSE stream with Vercel AI SDK Data Stream Protocol
*/
export function createNewChatAdapter(config: NewChatAdapterConfig): ChatModelAdapter {
@ -31,7 +60,7 @@ export function createNewChatAdapter(config: NewChatAdapterConfig): ChatModelAda
throw new Error("No user message found");
}
// Extract text content from the message
// Extract text content from the last user message
let userQuery = "";
for (const part of lastUserMessage.content) {
if (part.type === "text") {
@ -48,6 +77,9 @@ export function createNewChatAdapter(config: NewChatAdapterConfig): ChatModelAda
throw new Error("Not authenticated. Please log in again.");
}
// Convert all messages to backend format for chat history
const messageHistory = convertMessagesToBackendFormat(messages);
const response = await fetch(`${backendUrl}/api/v1/new_chat`, {
method: "POST",
headers: {
@ -58,6 +90,7 @@ export function createNewChatAdapter(config: NewChatAdapterConfig): ChatModelAda
chat_id: config.chatId,
user_query: userQuery.trim(),
search_space_id: config.searchSpaceId,
messages: messageHistory,
}),
signal: abortSignal,
});
@ -165,3 +198,4 @@ export function createNewChatAdapter(config: NewChatAdapterConfig): ChatModelAda
},
};
}