feat: integrate Streamdown for markdown rendering and enhance citation handling

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2025-12-21 00:04:52 -08:00
parent 947087452f
commit 3906ba52e0
8 changed files with 1807 additions and 224 deletions

View file

@ -158,3 +158,4 @@ button {
}
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
@source '../node_modules/streamdown/dist/*.js';

View file

@ -2,22 +2,22 @@
import type { FC } from "react";
import { useState } from "react";
import { SheetTrigger } from "@/components/ui/sheet";
import { SourceDetailSheet } from "@/components/chat/SourceDetailSheet";
import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel";
interface InlineCitationProps {
chunkId: number;
citationNumber: number;
}
/**
* Inline citation component for the new chat.
* Renders a clickable badge that opens the SourceDetailSheet with document chunk details.
* Renders a clickable numbered badge that opens the SourceDetailPanel with document chunk details.
*/
export const InlineCitation: FC<InlineCitationProps> = ({ chunkId }) => {
export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, citationNumber }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<SourceDetailSheet
<SourceDetailPanel
open={isOpen}
onOpenChange={setIsOpen}
chunkId={chunkId}
@ -26,22 +26,17 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId }) => {
description=""
url=""
>
<SheetTrigger asChild>
<span
className="text-[10px] font-bold bg-primary/80 hover:bg-primary text-primary-foreground rounded-full w-4 h-4 inline-flex items-center justify-center align-super cursor-pointer transition-colors ml-0.5"
title={`View source (chunk ${chunkId})`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
className="w-2.5 h-2.5"
>
<path d="M6.22 8.72a.75.75 0 0 0 1.06 1.06l5.22-5.22v1.69a.75.75 0 0 0 1.5 0v-3.5a.75.75 0 0 0-.75-.75h-3.5a.75.75 0 0 0 0 1.5h1.69L6.22 8.72Z" />
<path d="M3.5 6.75c0-.69.56-1.25 1.25-1.25H7A.75.75 0 0 0 7 4H4.75A2.75 2.75 0 0 0 2 6.75v4.5A2.75 2.75 0 0 0 4.75 14h4.5A2.75 2.75 0 0 0 12 11.25V9a.75.75 0 0 0-1.5 0v2.25c0 .69-.56 1.25-1.25 1.25h-4.5c-.69 0-1.25-.56-1.25-1.25v-4.5Z" />
</svg>
</span>
</SheetTrigger>
</SourceDetailSheet>
<span
onClick={() => setIsOpen(true)}
onKeyDown={(e) => e.key === "Enter" && setIsOpen(true)}
className="text-[10px] font-bold bg-primary/80 hover:bg-primary text-primary-foreground rounded-full min-w-4 h-4 px-1 inline-flex items-center justify-center align-super cursor-pointer transition-colors ml-0.5"
title={`View source #${citationNumber}`}
role="button"
tabIndex={0}
>
{citationNumber}
</span>
</SourceDetailPanel>
);
};

View file

@ -19,6 +19,29 @@ import { cn } from "@/lib/utils";
// Citation pattern: [citation:CHUNK_ID]
const CITATION_REGEX = /\[citation:(\d+)\]/g;
// Track chunk IDs to citation numbers mapping for consistent numbering
// This map is reset when a new message starts rendering
let chunkIdToCitationNumber: Map<number, number> = new Map();
let nextCitationNumber = 1;
/**
* Resets the citation counter - should be called at the start of each message
*/
export function resetCitationCounter() {
chunkIdToCitationNumber = new Map();
nextCitationNumber = 1;
}
/**
* Gets or assigns a citation number for a chunk ID
*/
function getCitationNumber(chunkId: number): number {
if (!chunkIdToCitationNumber.has(chunkId)) {
chunkIdToCitationNumber.set(chunkId, nextCitationNumber++);
}
return chunkIdToCitationNumber.get(chunkId)!;
}
/**
* Parses text and replaces [citation:XXX] patterns with InlineCitation components
*/
@ -26,7 +49,7 @@ function parseTextWithCitations(text: string): ReactNode[] {
const parts: ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
let citationIndex = 0;
let instanceIndex = 0;
// Reset regex state
CITATION_REGEX.lastIndex = 0;
@ -39,12 +62,17 @@ function parseTextWithCitations(text: string): ReactNode[] {
// Add the citation component
const chunkId = Number.parseInt(match[1], 10);
const citationNumber = getCitationNumber(chunkId);
parts.push(
<InlineCitation key={`citation-${chunkId}-${citationIndex}`} chunkId={chunkId} />
<InlineCitation
key={`citation-${chunkId}-${instanceIndex}`}
chunkId={chunkId}
citationNumber={citationNumber}
/>
);
lastIndex = match.index + match[0].length;
citationIndex++;
instanceIndex++;
}
// Add any remaining text after the last citation
@ -56,6 +84,10 @@ function parseTextWithCitations(text: string): ReactNode[] {
}
const MarkdownTextImpl = () => {
// Reset citation counter at the start of each render
// This ensures consistent numbering as the message streams in
resetCitationCounter();
return (
<MarkdownTextPrimitive
remarkPlugins={[remarkGfm]}

View file

@ -1,14 +1,6 @@
import { Check, Copy } from "lucide-react";
import Image from "next/image";
import { useTheme } from "next-themes";
import { useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark, oneLight } from "react-syntax-highlighter/dist/cjs/styles/prism";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import remarkGfm from "remark-gfm";
import { Button } from "@/components/ui/button";
import { Streamdown } from "streamdown";
import type { Components } from "react-markdown";
import { cn } from "@/lib/utils";
interface MarkdownViewerProps {
@ -17,203 +9,100 @@ interface MarkdownViewerProps {
}
export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
const ref = useRef<HTMLDivElement>(null);
// Memoize the markdown components to prevent unnecessary re-renders
const components = useMemo(() => {
return {
// Define custom components for markdown elements
p: ({ node, children, ...props }: any) => (
<p className="my-2" {...props}>
{children}
</p>
),
a: ({ node, children, ...props }: any) => (
<a
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
),
li: ({ node, children, ...props }: any) => <li {...props}>{children}</li>,
ul: ({ node, ...props }: any) => <ul className="list-disc pl-5 my-2" {...props} />,
ol: ({ node, ...props }: any) => <ol className="list-decimal pl-5 my-2" {...props} />,
h1: ({ node, children, ...props }: any) => (
<h1 className="text-2xl font-bold mt-6 mb-2" {...props}>
{children}
</h1>
),
h2: ({ node, children, ...props }: any) => (
<h2 className="text-xl font-bold mt-5 mb-2" {...props}>
{children}
</h2>
),
h3: ({ node, children, ...props }: any) => (
<h3 className="text-lg font-bold mt-4 mb-2" {...props}>
{children}
</h3>
),
h4: ({ node, children, ...props }: any) => (
<h4 className="text-base font-bold mt-3 mb-1" {...props}>
{children}
</h4>
),
blockquote: ({ node, ...props }: any) => (
<blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props} />
),
hr: ({ node, ...props }: any) => <hr className="my-4 border-muted" {...props} />,
img: ({ node, ...props }: any) => (
<Image
className="max-w-full h-auto my-4 rounded"
alt="markdown image"
height={100}
width={100}
{...props}
/>
),
table: ({ node, ...props }: any) => (
<div className="overflow-x-auto my-4">
<table className="min-w-full divide-y divide-border" {...props} />
</div>
),
th: ({ node, ...props }: any) => (
<th className="px-3 py-2 text-left font-medium bg-muted" {...props} />
),
td: ({ node, ...props }: any) => (
<td className="px-3 py-2 border-t border-border" {...props} />
),
code: ({ node, className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || "");
const language = match ? match[1] : "";
const isInline = !match;
const components: Components = {
// Define custom components for markdown elements
p: ({ children, ...props }) => (
<p className="my-2" {...props}>
{children}
</p>
),
a: ({ children, ...props }) => (
<a
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
),
li: ({ children, ...props }) => <li {...props}>{children}</li>,
ul: ({ ...props }) => <ul className="list-disc pl-5 my-2" {...props} />,
ol: ({ ...props }) => <ol className="list-decimal pl-5 my-2" {...props} />,
h1: ({ children, ...props }) => (
<h1 className="text-2xl font-bold mt-6 mb-2" {...props}>
{children}
</h1>
),
h2: ({ children, ...props }) => (
<h2 className="text-xl font-bold mt-5 mb-2" {...props}>
{children}
</h2>
),
h3: ({ children, ...props }) => (
<h3 className="text-lg font-bold mt-4 mb-2" {...props}>
{children}
</h3>
),
h4: ({ children, ...props }) => (
<h4 className="text-base font-bold mt-3 mb-1" {...props}>
{children}
</h4>
),
blockquote: ({ ...props }) => (
<blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props} />
),
hr: ({ ...props }) => <hr className="my-4 border-muted" {...props} />,
img: ({ src, alt, width: _w, height: _h, ...props }) => (
<Image
className="max-w-full h-auto my-4 rounded"
alt={alt || "markdown image"}
height={100}
width={100}
src={typeof src === "string" ? src : ""}
{...props}
/>
),
table: ({ ...props }) => (
<div className="overflow-x-auto my-4">
<table className="min-w-full divide-y divide-border" {...props} />
</div>
),
th: ({ ...props }) => (
<th className="px-3 py-2 text-left font-medium bg-muted" {...props} />
),
td: ({ ...props }) => (
<td className="px-3 py-2 border-t border-border" {...props} />
),
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match;
if (isInline) {
return (
<code className="bg-muted px-1 py-0.5 rounded text-xs" {...props}>
{children}
</code>
);
}
// For code blocks, add syntax highlighting and copy functionality
if (isInline) {
return (
<CodeBlock language={language} {...props}>
{String(children).replace(/\n$/, "")}
</CodeBlock>
<code className="bg-muted px-1 py-0.5 rounded text-xs" {...props}>
{children}
</code>
);
},
};
}, []);
}
// For code blocks, let Streamdown handle syntax highlighting
return (
<code className={className} {...props}>
{children}
</code>
);
},
};
return (
<div className={cn("prose prose-sm dark:prose-invert max-w-none", className)} ref={ref}>
<ReactMarkdown
rehypePlugins={[rehypeRaw, rehypeSanitize]}
remarkPlugins={[remarkGfm]}
<div className={cn("prose prose-sm dark:prose-invert max-w-none overflow-hidden [&_pre]:overflow-x-auto [&_code]:wrap-break-word [&_table]:block [&_table]:overflow-x-auto", className)}>
<Streamdown
components={components}
shikiTheme={["github-light", "github-dark"]}
>
{content}
</ReactMarkdown>
</Streamdown>
</div>
);
}
// Code block component with syntax highlighting and copy functionality
const CodeBlock = ({ children, language }: { children: string; language: string }) => {
const [copied, setCopied] = useState(false);
const { resolvedTheme, theme } = useTheme();
const [mounted, setMounted] = useState(false);
// Prevent hydration issues
useEffect(() => {
setMounted(true);
}, []);
const handleCopy = async () => {
await navigator.clipboard.writeText(children);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
// Choose theme based on current system/user preference
const isDarkTheme = mounted && (resolvedTheme === "dark" || theme === "dark");
const syntaxTheme = isDarkTheme ? oneDark : oneLight;
return (
<div className="relative my-4 group">
<div className="absolute right-2 top-2 z-10">
<Button
variant="ghost"
onClick={handleCopy}
className="p-1.5 rounded-md bg-background/80 hover:bg-background border border-border flex items-center justify-center transition-colors"
aria-label="Copy code"
>
{copied ? (
<Check size={14} className="text-green-500" />
) : (
<Copy size={14} className="text-muted-foreground" />
)}
</Button>
</div>
{mounted ? (
<SyntaxHighlighter
language={language || "text"}
style={{
...syntaxTheme,
'pre[class*="language-"]': {
...syntaxTheme['pre[class*="language-"]'],
margin: 0,
border: "none",
borderRadius: "0.375rem",
background: "var(--syntax-bg)",
},
'code[class*="language-"]': {
...syntaxTheme['code[class*="language-"]'],
border: "none",
background: "var(--syntax-bg)",
},
}}
customStyle={{
margin: 0,
borderRadius: "0.375rem",
fontSize: "0.75rem",
lineHeight: "1.5rem",
backgroundColor: "var(--syntax-bg)",
border: "none",
}}
codeTagProps={{
className: "font-mono",
style: {
border: "none",
background: "var(--syntax-bg)",
},
}}
showLineNumbers={false}
wrapLines={false}
lineProps={{
style: {
wordBreak: "break-all",
whiteSpace: "pre-wrap",
border: "none",
borderBottom: "none",
paddingLeft: 0,
paddingRight: 0,
margin: "0.25rem 0",
},
}}
PreTag="div"
>
{children}
</SyntaxHighlighter>
) : (
<div className="bg-muted p-4 rounded-md">
<pre className="m-0 p-0 border-0">
<code className="text-xs font-mono border-0 leading-6">{children}</code>
</pre>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,546 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
ChevronDown,
ChevronUp,
ExternalLink,
Loader2,
X,
FileText,
Hash,
BookOpen,
Sparkles,
} from "lucide-react";
import type React from "react";
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
interface SourceDetailPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
chunkId: number;
sourceType: string;
title: string;
description?: string;
url?: string;
children?: ReactNode;
}
const formatDocumentType = (type: string) => {
if (!type) return "";
return type
.split("_")
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
.join(" ");
};
// Chunk card component with enhanced animations
const ChunkCard = ({
chunk,
index,
totalChunks,
isCited,
isActive,
}: {
chunk: { id: number; content: string };
index: number;
totalChunks: number;
isCited: boolean;
isActive: boolean;
}) => {
const shouldReduceMotion = useReducedMotion();
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>
{isCited && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 200, damping: 15, delay: 0.2 }}
>
<Badge variant="default" className="gap-1.5 px-3 py-1">
<Sparkles className="h-3 w-3" />
Cited Source
</Badge>
</motion.div>
)}
</div>
{/* Content */}
<div className="p-5 overflow-hidden">
<MarkdownViewer content={chunk.content} />
</div>
</motion.div>
);
};
export function SourceDetailPanel({
open,
onOpenChange,
chunkId,
sourceType,
title,
description,
url,
children,
}: SourceDetailPanelProps) {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const [summaryOpen, setSummaryOpen] = useState(false);
const [activeChunkIndex, setActiveChunkIndex] = useState<number | null>(null);
const [mounted, setMounted] = useState(false);
const [hasScrolledToCited, setHasScrolledToCited] = useState(false);
const shouldReduceMotion = useReducedMotion();
useEffect(() => {
setMounted(true);
}, []);
const {
data: documentData,
isLoading: isDocumentByChunkFetching,
error: documentByChunkFetchingError,
} = useQuery({
queryKey: cacheKeys.documents.byChunk(chunkId.toString()),
queryFn: () => documentsApiService.getDocumentByChunk({ chunk_id: chunkId }),
enabled: !!chunkId && open,
staleTime: 5 * 60 * 1000,
});
const isDirectRenderSource =
sourceType === "TAVILY_API" ||
sourceType === "LINKUP_API" ||
sourceType === "SEARXNG_API" ||
sourceType === "BAIDU_SEARCH_API";
// 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);
}
}, [documentData, citedChunkIndex, hasScrolledToCited, open, shouldReduceMotion]);
// Reset scroll state when panel closes
useEffect(() => {
if (!open) {
setHasScrolledToCited(false);
setActiveChunkIndex(null);
}
}, [open]);
// Handle escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
onOpenChange(false);
}
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
// Prevent body scroll when open
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
const handleUrlClick = (e: React.MouseEvent, clickUrl: string) => {
e.preventDefault();
e.stopPropagation();
window.open(clickUrl, "_blank", "noopener,noreferrer");
};
const scrollToChunk = useCallback((index: number) => {
setActiveChunkIndex(index);
const chunkElement = scrollAreaRef.current?.querySelector(
`[data-chunk-index="${index}"]`
);
chunkElement?.scrollIntoView({ behavior: "smooth", block: "center" });
}, []);
const panelContent = (
<AnimatePresence mode="wait">
{open && (
<>
{/* Backdrop */}
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
onClick={() => onOpenChange(false)}
/>
{/* Panel */}
<motion.div
key="panel"
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: 20 }}
transition={{
type: "spring",
damping: 30,
stiffness: 300,
}}
className="fixed inset-3 sm:inset-6 md:inset-10 lg:inset-16 z-50 flex flex-col bg-background rounded-3xl shadow-2xl border overflow-hidden"
>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="flex items-center justify-between px-6 py-5 border-b bg-linear-to-r from-muted/50 to-muted/30"
>
<div className="min-w-0 flex-1">
<h2 className="text-xl font-semibold truncate">
{documentData?.title || title || "Source Document"}
</h2>
<p className="text-sm text-muted-foreground mt-0.5">
{documentData
? formatDocumentType(documentData.document_type)
: sourceType && formatDocumentType(sourceType)}
{documentData?.chunks && (
<span className="ml-2">
{documentData.chunks.length} chunk{documentData.chunks.length !== 1 ? "s" : ""}
</span>
)}
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
{url && (
<Button
size="sm"
variant="outline"
onClick={(e) => handleUrlClick(e, url)}
className="hidden sm:flex gap-2 rounded-xl"
>
<ExternalLink className="h-4 w-4" />
Open Source
</Button>
)}
<Button
size="icon"
variant="ghost"
onClick={() => onOpenChange(false)}
className="rounded-xl h-10 w-10 hover:bg-destructive/10 hover:text-destructive transition-colors"
>
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</Button>
</div>
</motion.div>
{/* Loading State */}
{!isDirectRenderSource && isDocumentByChunkFetching && (
<div className="flex-1 flex items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4"
>
<div className="relative">
<div className="absolute inset-0 rounded-full bg-primary/20 blur-xl" />
<Loader2 className="h-12 w-12 animate-spin text-primary relative" />
</div>
<p className="text-sm text-muted-foreground font-medium">Loading document...</p>
</motion.div>
</div>
)}
{/* Error State */}
{!isDirectRenderSource && documentByChunkFetchingError && (
<div className="flex-1 flex items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4 text-center px-6"
>
<div className="w-20 h-20 rounded-full bg-destructive/10 flex items-center justify-center">
<X className="h-10 w-10 text-destructive" />
</div>
<div>
<p className="font-semibold text-destructive text-lg">Failed to load document</p>
<p className="text-sm text-muted-foreground mt-2 max-w-md">
{documentByChunkFetchingError.message || "An unexpected error occurred. Please try again."}
</p>
</div>
<Button variant="outline" onClick={() => onOpenChange(false)} className="mt-2">
Close Panel
</Button>
</motion.div>
</div>
)}
{/* Direct render for web search providers */}
{isDirectRenderSource && (
<ScrollArea className="flex-1">
<div className="p-6 max-w-3xl mx-auto">
{url && (
<Button
size="default"
variant="outline"
onClick={(e) => handleUrlClick(e, url)}
className="w-full mb-6 sm:hidden rounded-xl"
>
<ExternalLink className="mr-2 h-4 w-4" />
Open in Browser
</Button>
)}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="p-6 bg-muted/50 rounded-2xl border"
>
<h3 className="text-base font-semibold mb-4 flex items-center gap-2">
<BookOpen className="h-4 w-4" />
Source Information
</h3>
<div className="text-sm text-muted-foreground mb-3 font-medium">
{title || "Untitled"}
</div>
<div className="text-sm text-foreground leading-relaxed">
{description || "No content available"}
</div>
</motion.div>
</div>
</ScrollArea>
)}
{/* API-fetched document content */}
{!isDirectRenderSource && documentData && (
<div className="flex-1 flex overflow-hidden">
{/* Chunk Navigation Sidebar */}
{documentData.chunks.length > 1 && (
<motion.div
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"
>
<ScrollArea className="flex-1">
<div className="p-2 pt-3 flex flex-col gap-1.5">
{documentData.chunks.map((chunk, idx) => {
const isCited = chunk.id === chunkId;
const isActive = activeChunkIndex === idx;
return (
<motion.button
key={chunk.id}
type="button"
onClick={() => scrollToChunk(idx)}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: Math.min(idx * 0.02, 0.2) }}
className={cn(
"relative w-11 h-9 mx-auto rounded-lg text-xs font-semibold transition-all duration-200 flex items-center justify-center",
isCited
? "bg-primary text-primary-foreground shadow-md"
: isActive
? "bg-muted text-foreground"
: "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground"
)}
title={isCited ? `Chunk ${idx + 1} (Cited)` : `Chunk ${idx + 1}`}
>
{idx + 1}
{isCited && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-primary rounded-full border-2 border-background">
<Sparkles className="h-2 w-2 text-primary-foreground absolute top-0.5 left-0.5" />
</span>
)}
</motion.button>
);
})}
</div>
</ScrollArea>
</motion.div>
)}
{/* Main Content */}
<ScrollArea className="flex-1" ref={scrollAreaRef}>
<div className="p-6 lg:p-8 max-w-4xl mx-auto space-y-6">
{/* Document Metadata */}
{documentData.document_metadata &&
Object.keys(documentData.document_metadata).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="p-5 bg-muted/30 rounded-2xl border"
>
<h3 className="text-sm font-semibold mb-4 text-muted-foreground uppercase tracking-wider flex items-center gap-2">
<FileText className="h-4 w-4" />
Document Information
</h3>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
{Object.entries(documentData.document_metadata).map(
([key, value]) => (
<div key={key} className="space-y-1">
<dt className="font-medium text-muted-foreground capitalize text-xs">
{key.replace(/_/g, " ")}
</dt>
<dd className="text-foreground wrap-break-word">
{String(value)}
</dd>
</div>
)
)}
</dl>
</motion.div>
)}
{/* Summary Collapsible */}
{documentData.content && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
>
<Collapsible open={summaryOpen} onOpenChange={setSummaryOpen}>
<CollapsibleTrigger className="w-full flex items-center justify-between p-5 rounded-2xl bg-linear-to-r from-muted/50 to-muted/30 border hover:from-muted/70 hover:to-muted/50 transition-all duration-200">
<span className="font-semibold flex items-center gap-2">
<BookOpen className="h-4 w-4" />
Document Summary
</span>
<motion.div
animate={{ rotate: summaryOpen ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown className="h-5 w-5 text-muted-foreground" />
</motion.div>
</CollapsibleTrigger>
<CollapsibleContent>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-3 p-5 bg-muted/20 rounded-2xl border"
>
<MarkdownViewer content={documentData.content} />
</motion.div>
</CollapsibleContent>
</Collapsible>
</motion.div>
)}
{/* Chunks Header */}
<div className="flex items-center justify-between pt-4">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
<Hash className="h-4 w-4" />
Content Chunks
</h3>
{citedChunkIndex !== -1 && (
<Button
variant="ghost"
size="sm"
onClick={() => scrollToChunk(citedChunkIndex)}
className="gap-2 text-primary hover:text-primary"
>
<Sparkles className="h-3.5 w-3.5" />
Jump to cited
</Button>
)}
</div>
{/* 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}
/>
))}
</div>
</div>
</ScrollArea>
</div>
)}
</motion.div>
</>
)}
</AnimatePresence>
);
if (!mounted) return <>{children}</>;
return (
<>
{children}
{createPortal(panelContent, globalThis.document.body)}
</>
);
}

View file

@ -98,6 +98,7 @@
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1",
"sonner": "^2.0.6",
"streamdown": "^1.6.10",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"unist-util-visit": "^5.0.0",

File diff suppressed because it is too large Load diff

View file

@ -33,3 +33,4 @@
],
"exclude": ["node_modules"]
}