feat: added jump to source referencing of citations

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2025-08-23 18:48:18 -07:00
parent 9b91bea51d
commit 76732c36ba
8 changed files with 818 additions and 559 deletions

View file

@ -1,58 +1,202 @@
"use client";
import { ExternalLink } from "lucide-react";
import { ExternalLink, FileText, Loader2 } from "lucide-react";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { useDocumentByChunk } from "@/hooks/use-document-by-chunk";
import { cn } from "@/lib/utils";
export const CitationDisplay: React.FC<{ index: number; node: any }> = ({ index, node }) => {
const truncateText = (text: string, maxLength: number = 200) => {
if (text.length <= maxLength) return text;
return `${text.substring(0, maxLength)}...`;
const chunkId = Number(node?.id);
const sourceType = node?.metadata?.source_type;
const [isOpen, setIsOpen] = useState(false);
const { document, loading, error, fetchDocumentByChunk, clearDocument } = useDocumentByChunk();
const chunksContainerRef = useRef<HTMLDivElement>(null);
const highlightedChunkRef = useRef<HTMLDivElement>(null);
// Check if this is a source type that should render directly from node
const isDirectRenderSource = sourceType === "TAVILY_API" || sourceType === "LINKUP_API";
const handleOpenChange = async (open: boolean) => {
setIsOpen(open);
if (open && chunkId && !isDirectRenderSource) {
await fetchDocumentByChunk(chunkId);
} else if (!open && !isDirectRenderSource) {
clearDocument();
}
};
useEffect(() => {
// Scroll to highlighted chunk when document loads
if (document && highlightedChunkRef.current && chunksContainerRef.current) {
setTimeout(() => {
highlightedChunkRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
}, 100);
}
}, [document]);
const handleUrlClick = (e: React.MouseEvent, url: string) => {
e.preventDefault();
e.stopPropagation();
window.open(url, "_blank", "noopener,noreferrer");
};
const formatDocumentType = (type: string) => {
return type
.split("_")
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
.join(" ");
};
return (
<Popover>
<PopoverTrigger asChild>
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
<SheetTrigger asChild>
<span className="text-[10px] font-bold bg-slate-500 hover:bg-slate-600 text-white rounded-full w-4 h-4 inline-flex items-center justify-center align-super cursor-pointer transition-colors">
{index + 1}
</span>
</PopoverTrigger>
<PopoverContent className="w-80 p-4 space-y-3 relative" align="start">
{/* External Link Button - Top Right */}
{node?.url && (
<Button
size="icon"
variant="ghost"
onClick={(e) => handleUrlClick(e, node.url)}
className="absolute top-3 right-3 inline-flex items-center justify-center w-6 h-6 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
title="Open in new tab"
>
<ExternalLink size={14} />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-full sm:max-w-5xl lg:max-w-7xl">
<SheetHeader className="px-6 py-4 border-b">
<SheetTitle className="flex items-center gap-3 text-lg">
<FileText className="h-6 w-6" />
{document?.title || node?.metadata?.title || node?.metadata?.group_name || "Source"}
</SheetTitle>
<SheetDescription className="text-base mt-2">
{document
? formatDocumentType(document.document_type)
: sourceType && formatDocumentType(sourceType)}
</SheetDescription>
</SheetHeader>
{!isDirectRenderSource && loading && (
<div className="flex items-center justify-center h-64 px-6">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{/* Heading */}
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100 pr-8">
{node?.metadata?.group_name || "Source"}
</div>
{!isDirectRenderSource && error && (
<div className="flex items-center justify-center h-64 px-6">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{/* Source */}
<div className="text-xs text-slate-600 dark:text-slate-400 font-medium">
{node?.metadata?.title || "Untitled"}
</div>
{/* Direct render for TAVILY_API and LINEAR_API */}
{isDirectRenderSource && (
<ScrollArea className="h-[calc(100vh-10rem)]">
<div className="px-6 py-4">
{/* External Link */}
{node?.url && (
<div className="mb-8">
<Button
size="default"
variant="outline"
onClick={(e) => handleUrlClick(e, node.url)}
className="w-full py-3"
>
<ExternalLink className="mr-2 h-4 w-4" />
Open in Browser
</Button>
</div>
)}
{/* Body */}
<div className="text-xs text-slate-700 dark:text-slate-300 leading-relaxed">
{truncateText(node?.text || "No content available")}
</div>
</PopoverContent>
</Popover>
{/* Source Information */}
<div className="mb-8 p-6 bg-muted/50 rounded-lg border">
<h3 className="text-base font-semibold mb-4">Source Information</h3>
<div className="text-sm text-muted-foreground mb-3 font-medium">
{node?.metadata?.title || "Untitled"}
</div>
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
{node?.text || "No content available"}
</div>
</div>
</div>
</ScrollArea>
)}
{/* API-fetched document content */}
{!isDirectRenderSource && document && (
<ScrollArea className="h-[calc(100vh-10rem)]">
<div className="px-6 py-4">
{/* Document Metadata */}
{document.document_metadata && Object.keys(document.document_metadata).length > 0 && (
<div className="mb-8 p-6 bg-muted/50 rounded-lg border">
<h3 className="text-base font-semibold mb-4">Document Information</h3>
<dl className="grid grid-cols-1 gap-3 text-sm">
{Object.entries(document.document_metadata).map(([key, value]) => (
<div key={key} className="flex gap-3">
<dt className="font-medium text-muted-foreground capitalize min-w-0 flex-shrink-0">
{key.replace(/_/g, " ")}:
</dt>
<dd className="text-foreground break-words">{String(value)}</dd>
</div>
))}
</dl>
</div>
)}
{/* External Link */}
{node?.url && (
<div className="mb-8">
<Button
size="default"
variant="outline"
onClick={(e) => handleUrlClick(e, node.url)}
className="w-full py-3"
>
<ExternalLink className="mr-2 h-4 w-4" />
Open in Browser
</Button>
</div>
)}
{/* Chunks */}
<div className="space-y-6" ref={chunksContainerRef}>
<h3 className="text-base font-semibold mb-4">Document Content</h3>
{document.chunks.map((chunk, idx) => (
<div
key={chunk.id}
ref={chunk.id === chunkId ? highlightedChunkRef : null}
className={cn(
"p-6 rounded-lg border transition-all duration-300",
chunk.id === chunkId
? "bg-primary/10 border-primary shadow-md ring-1 ring-primary/20"
: "bg-background border-border hover:bg-muted/50 hover:border-muted-foreground/20"
)}
>
<div className="mb-4 flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
Chunk {idx + 1} of {document.chunks.length}
</span>
{chunk.id === chunkId && (
<span className="text-sm font-medium text-primary bg-primary/10 px-3 py-1 rounded-full">
Referenced Chunk
</span>
)}
</div>
<div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
<MarkdownViewer content={chunk.content} className="max-w-fit" />
</div>
</div>
))}
</div>
</div>
</ScrollArea>
)}
</SheetContent>
</Sheet>
);
};

View file

@ -1,7 +1,7 @@
import { Check, Copy } from "lucide-react";
import Image from "next/image";
import { useTheme } from "next-themes";
import React, { useEffect, useMemo, useRef, useState } from "react";
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";
@ -10,105 +10,51 @@ import rehypeSanitize from "rehype-sanitize";
import remarkGfm from "remark-gfm";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Citation } from "./chat/Citation";
import type { Source } from "./chat/types";
import CopyButton from "./copy-button";
interface MarkdownViewerProps {
content: string;
className?: string;
getCitationSource?: (id: number) => Source | null;
type?: "user" | "ai";
}
export function MarkdownViewer({
content,
className,
getCitationSource,
type = "user",
}: 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) => {
// If there's no getCitationSource function, just render normally
if (!getCitationSource) {
return (
<p className="my-2" {...props}>
{children}
</p>
);
}
// Process citations within paragraph content
return (
<p className="my-2" {...props}>
{processCitationsInReactChildren(children, getCitationSource)}
</p>
);
},
a: ({ node, children, ...props }: any) => {
// Process citations within link content if needed
const processedChildren = getCitationSource
? processCitationsInReactChildren(children, getCitationSource)
: children;
return (
<a className="text-primary hover:underline" {...props}>
{processedChildren}
</a>
);
},
li: ({ node, children, ...props }: any) => {
// Process citations within list item content
const processedChildren = getCitationSource
? processCitationsInReactChildren(children, getCitationSource)
: children;
return <li {...props}>{processedChildren}</li>;
},
p: ({ node, children, ...props }: any) => (
<p className="my-2" {...props}>
{children}
</p>
),
a: ({ node, children, ...props }: any) => (
<a className="text-primary hover:underline" {...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) => {
const processedChildren = getCitationSource
? processCitationsInReactChildren(children, getCitationSource)
: children;
return (
<h1 className="text-2xl font-bold mt-6 mb-2" {...props}>
{processedChildren}
</h1>
);
},
h2: ({ node, children, ...props }: any) => {
const processedChildren = getCitationSource
? processCitationsInReactChildren(children, getCitationSource)
: children;
return (
<h2 className="text-xl font-bold mt-5 mb-2" {...props}>
{processedChildren}
</h2>
);
},
h3: ({ node, children, ...props }: any) => {
const processedChildren = getCitationSource
? processCitationsInReactChildren(children, getCitationSource)
: children;
return (
<h3 className="text-lg font-bold mt-4 mb-2" {...props}>
{processedChildren}
</h3>
);
},
h4: ({ node, children, ...props }: any) => {
const processedChildren = getCitationSource
? processCitationsInReactChildren(children, getCitationSource)
: children;
return (
<h4 className="text-base font-bold mt-3 mb-1" {...props}>
{processedChildren}
</h4>
);
},
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} />
),
@ -154,7 +100,7 @@ export function MarkdownViewer({
);
},
};
}, [getCitationSource]);
}, []);
return (
<div className={cn("prose prose-sm dark:prose-invert max-w-none", className)} ref={ref}>
@ -165,7 +111,6 @@ export function MarkdownViewer({
>
{content}
</ReactMarkdown>
{type === "ai" && <CopyButton ref={ref} />}
</div>
);
}
@ -267,77 +212,3 @@ const CodeBlock = ({ children, language }: { children: string; language: string
</div>
);
};
// Helper function to process citations within React children
const processCitationsInReactChildren = (
children: React.ReactNode,
getCitationSource: (id: number) => Source | null
): React.ReactNode => {
// If children is not an array or string, just return it
if (!children || (typeof children !== "string" && !Array.isArray(children))) {
return children;
}
// Handle string content directly - this is where we process citation references
if (typeof children === "string") {
return processCitationsInText(children, getCitationSource);
}
// Handle arrays of children recursively
if (Array.isArray(children)) {
return React.Children.map(children, (child) => {
if (typeof child === "string") {
return processCitationsInText(child, getCitationSource);
}
return child;
});
}
return children;
};
// Process citation references in text content
const processCitationsInText = (
text: string,
getCitationSource: (id: number) => Source | null
): React.ReactNode[] => {
// Use improved regex to catch citation numbers more reliably
// This will match patterns like [1], [42], etc. including when they appear at the end of a line or sentence
const citationRegex = /\[(\d+)\]/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null = citationRegex.exec(text);
let position = 0;
while (match !== null) {
// Add text before the citation
if (match.index > lastIndex) {
parts.push(text.substring(lastIndex, match.index));
}
// Add the citation component
const citationId = parseInt(match[1], 10);
const source = getCitationSource(citationId);
parts.push(
<Citation
key={`citation-${citationId}-${position}`}
citationId={citationId}
citationText={match[0]}
position={position}
source={source}
/>
);
lastIndex = match.index + match[0].length;
position++;
match = citationRegex.exec(text);
}
// Add any remaining text after the last citation
if (lastIndex < text.length) {
parts.push(text.substring(lastIndex));
}
return parts;
};

View file

@ -0,0 +1,56 @@
"use client";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import type * as React from "react";
import { cn } from "@/lib/utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };