Merge upstream/dev and accept upstream deletions

This commit is contained in:
CREDO23 2025-12-26 14:50:52 +02:00
commit f05a313d73
260 changed files with 50971 additions and 36069 deletions

View file

@ -0,0 +1,116 @@
import { loader } from "fumadocs-core/source";
import { changelog } from "@/.source/server";
import { formatDate } from "@/lib/utils";
import { getMDXComponents } from "@/mdx-components";
const source = loader({
baseUrl: "/changelog",
source: changelog.toFumadocsSource(),
});
interface ChangelogData {
title: string;
date: string;
version?: string;
tags?: string[];
body: React.ComponentType<{ components?: Record<string, React.ComponentType> }>;
}
interface ChangelogPageItem {
url: string;
data: ChangelogData;
}
export default async function ChangelogPage() {
const allPages = source.getPages() as ChangelogPageItem[];
const sortedChangelogs = allPages.sort((a, b) => {
const dateA = new Date(a.data.date).getTime();
const dateB = new Date(b.data.date).getTime();
return dateB - dateA;
});
return (
<div className="min-h-screen relative pt-20">
{/* Header */}
<div className="border-b border-border/50">
<div className="max-w-5xl mx-auto relative">
<div className="p-6 flex items-center justify-between">
<div>
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400 bg-clip-text text-transparent">
Changelog
</h1>
<p className="text-muted-foreground mt-2">
Stay up to date with the latest updates and improvements to SurfSense.
</p>
</div>
</div>
</div>
</div>
{/* Timeline */}
<div className="max-w-5xl mx-auto px-6 lg:px-10 pt-10 pb-20">
<div className="relative">
{sortedChangelogs.map((changelog) => {
const MDX = changelog.data.body;
const date = new Date(changelog.data.date);
const formattedDate = formatDate(date);
return (
<div key={changelog.url} className="relative">
<div className="flex flex-col md:flex-row gap-y-6">
<div className="md:w-48 flex-shrink-0">
<div className="md:sticky md:top-24 pb-10">
<time className="text-sm font-medium text-muted-foreground block mb-3">
{formattedDate}
</time>
{changelog.data.version && (
<div className="inline-flex relative z-10 items-center justify-center w-12 h-12 text-foreground border border-border rounded-xl text-sm font-bold bg-card shadow-sm">
{changelog.data.version}
</div>
)}
</div>
</div>
{/* Right side - Content */}
<div className="flex-1 md:pl-8 relative pb-10">
{/* Vertical timeline line */}
<div className="hidden md:block absolute top-2 left-0 w-px h-full bg-border">
{/* Timeline dot */}
<div className="hidden md:block absolute -translate-x-1/2 size-3 bg-primary rounded-full z-10" />
</div>
<div className="space-y-6">
<div className="relative z-10 flex flex-col gap-2">
<h2 className="text-2xl font-semibold tracking-tight text-balance">
{changelog.data.title}
</h2>
{/* Tags */}
{changelog.data.tags && changelog.data.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{changelog.data.tags.map((tag: string) => (
<span
key={tag}
className="h-6 w-fit px-2.5 text-xs font-medium bg-muted text-muted-foreground rounded-full border flex items-center justify-center"
>
{tag}
</span>
))}
</div>
)}
</div>
<div className="prose dark:prose-invert max-w-none prose-headings:scroll-mt-8 prose-headings:font-semibold prose-a:no-underline prose-headings:tracking-tight prose-headings:text-balance prose-p:tracking-tight prose-p:text-balance prose-img:rounded-xl prose-img:shadow-lg">
<MDX components={getMDXComponents()} />
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View file

@ -3,14 +3,21 @@ import { IconBrandGoogleFilled } from "@tabler/icons-react";
import { motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Logo } from "@/components/Logo";
import { trackLoginAttempt, trackLoginFailure } from "@/lib/posthog/events";
import { AmbientBackground } from "./AmbientBackground";
export function GoogleLoginButton() {
const t = useTranslations("auth");
const handleGoogleLogin = () => {
// Track Google login attempt
trackLoginAttempt("google");
// Redirect to Google OAuth authorization URL
fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize`)
// credentials: 'include' is required to accept the CSRF cookie from cross-origin response
fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize`, {
credentials: "include",
})
.then((response) => {
if (!response.ok) {
throw new Error("Failed to get authorization URL");
@ -21,10 +28,12 @@ export function GoogleLoginButton() {
if (data.authorization_url) {
window.location.href = data.authorization_url;
} else {
trackLoginFailure("google", "No authorization URL received");
console.error("No authorization URL received");
}
})
.catch((error) => {
trackLoginFailure("google", error?.message || "Unknown error");
console.error("Error during Google login:", error);
});
};

View file

@ -10,6 +10,7 @@ import { toast } from "sonner";
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { ValidationError } from "@/lib/error";
import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events";
export function LocalLoginForm() {
const t = useTranslations("auth");
@ -37,6 +38,9 @@ export function LocalLoginForm() {
e.preventDefault();
setError({ title: null, message: null }); // Clear any previous errors
// Track login attempt
trackLoginAttempt("local");
// Show loading toast
const loadingToast = toast.loading(tCommon("loading"));
@ -47,6 +51,9 @@ export function LocalLoginForm() {
grant_type: "password",
});
// Track successful login
trackLoginSuccess("local");
// Success toast
toast.success(t("login_success"), {
id: loadingToast,
@ -60,6 +67,7 @@ export function LocalLoginForm() {
}, 500);
} catch (err) {
if (err instanceof ValidationError) {
trackLoginFailure("local", err.message);
setError({ title: err.name, message: err.message });
toast.error(err.name, {
id: loadingToast,
@ -78,6 +86,9 @@ export function LocalLoginForm() {
errorCode = "NETWORK_ERROR";
}
// Track login failure
trackLoginFailure("local", errorCode);
// Get detailed error information from auth-errors utility
const errorDetails = getAuthErrorDetails(errorCode);

View file

@ -3,10 +3,8 @@
import { CTAHomepage } from "@/components/homepage/cta";
import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid";
import { FeaturesCards } from "@/components/homepage/features-card";
import { Footer } from "@/components/homepage/footer";
import { HeroSection } from "@/components/homepage/hero-section";
import ExternalIntegrations from "@/components/homepage/integrations";
import { Navbar } from "@/components/homepage/navbar";
export default function HomePage() {
return (

View file

@ -11,6 +11,11 @@ import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { Logo } from "@/components/Logo";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { AppError, ValidationError } from "@/lib/error";
import {
trackRegistrationAttempt,
trackRegistrationFailure,
trackRegistrationSuccess,
} from "@/lib/posthog/events";
import { AmbientBackground } from "../login/AmbientBackground";
export default function RegisterPage() {
@ -52,6 +57,9 @@ export default function RegisterPage() {
setError({ title: null, message: null }); // Clear any previous errors
// Track registration attempt
trackRegistrationAttempt();
// Show loading toast
const loadingToast = toast.loading(t("creating_account"));
@ -64,6 +72,9 @@ export default function RegisterPage() {
is_verified: false,
});
// Track successful registration
trackRegistrationSuccess();
// Success toast
toast.success(t("register_success"), {
id: loadingToast,
@ -81,6 +92,7 @@ export default function RegisterPage() {
case 403: {
const friendlyMessage =
"Registrations are currently closed. If you need access, contact your administrator.";
trackRegistrationFailure("Registration disabled");
setError({ title: "Registration is disabled", message: friendlyMessage });
toast.error("Registration is disabled", {
id: loadingToast,
@ -94,6 +106,7 @@ export default function RegisterPage() {
}
if (err instanceof ValidationError) {
trackRegistrationFailure(err.message);
setError({ title: err.name, message: err.message });
toast.error(err.name, {
id: loadingToast,
@ -113,6 +126,9 @@ export default function RegisterPage() {
errorCode = "NETWORK_ERROR";
}
// Track registration failure
trackRegistrationFailure(errorCode);
// Get detailed error information from auth-errors utility
const errorDetails = getAuthErrorDetails(errorCode);

View file

@ -6,9 +6,9 @@ import { usersTable } from "@/app/db/schema";
// Define validation schema matching the database schema
const contactSchema = z.object({
name: z.string().min(1, "Name is required").max(255, "Name is too long"),
email: z.string().email("Invalid email address").max(255, "Email is too long"),
email: z.email("Invalid email address").max(255, "Email is too long"),
company: z.string().min(1, "Company is required").max(255, "Company name is too long"),
message: z.string().optional().default(""),
message: z.string().optional().prefault(""),
});
export async function POST(request: NextRequest) {
@ -43,7 +43,7 @@ export async function POST(request: NextRequest) {
{
success: false,
message: "Validation error",
errors: error.errors,
errors: error.issues,
},
{ status: 400 }
);

View file

@ -1,458 +0,0 @@
"use client";
import { format } from "date-fns";
import { useAtom, useAtomValue } from "jotai";
import {
Calendar,
ExternalLink,
MessageCircleMore,
MoreHorizontal,
Search,
Tag,
Trash2,
} from "lucide-react";
import { AnimatePresence, motion, type Variants } from "motion/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms";
import { chatsAtom } from "@/atoms/chats/chat-query.atoms";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export interface Chat {
created_at: string;
id: number;
type: "QNA";
title: string;
search_space_id: number;
state_version: number;
}
export interface ChatDetails {
type: "QNA";
title: string;
initial_connectors: string[];
messages: any[];
created_at: string;
id: number;
search_space_id: number;
state_version: number;
}
interface ChatsPageClientProps {
searchSpaceId: string;
}
const pageVariants: Variants = {
initial: { opacity: 0 },
enter: { opacity: 1, transition: { duration: 0.3, ease: "easeInOut" } },
exit: { opacity: 0, transition: { duration: 0.3, ease: "easeInOut" } },
};
const chatCardVariants: Variants = {
initial: { y: 20, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -20, opacity: 0 },
};
const MotionCard = motion(Card);
export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) {
const router = useRouter();
const [filteredChats, setFilteredChats] = useState<Chat[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [selectedType, setSelectedType] = useState<string>("all");
const [sortOrder, setSortOrder] = useState<string>("newest");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [chatToDelete, setChatToDelete] = useState<{
id: number;
title: string;
} | null>(null);
const { isFetching: isFetchingChats, data: chats, error: fetchError } = useAtomValue(chatsAtom);
const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] =
useAtom(deleteChatMutationAtom);
const chatsPerPage = 9;
const searchParams = useSearchParams();
// Get initial page from URL params if it exists
useEffect(() => {
const pageParam = searchParams.get("page");
if (pageParam) {
const pageNumber = parseInt(pageParam, 10);
if (!Number.isNaN(pageNumber) && pageNumber > 0) {
setCurrentPage(pageNumber);
}
}
}, [searchParams]);
// Filter and sort chats based on search query, type, and sort order
useEffect(() => {
let result = [...(chats || [])];
// Filter by search term
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter((chat) => chat.title.toLowerCase().includes(query));
}
// Filter by type
if (selectedType !== "all") {
result = result.filter((chat) => chat.type === selectedType);
}
// Sort chats
result.sort((a, b) => {
const dateA = new Date(a.created_at).getTime();
const dateB = new Date(b.created_at).getTime();
return sortOrder === "newest" ? dateB - dateA : dateA - dateB;
});
setFilteredChats(result);
setTotalPages(Math.max(1, Math.ceil(result.length / chatsPerPage)));
// Reset to first page when filters change
if (currentPage !== 1 && (searchQuery || selectedType !== "all" || sortOrder !== "newest")) {
setCurrentPage(1);
}
}, [chats, searchQuery, selectedType, sortOrder, currentPage]);
// Function to handle chat deletion
const handleDeleteChat = async () => {
if (!chatToDelete) return;
await deleteChat({ id: chatToDelete.id });
setDeleteDialogOpen(false);
setChatToDelete(null);
};
// Calculate pagination
const indexOfLastChat = currentPage * chatsPerPage; // Index of last chat in the current page
const indexOfFirstChat = indexOfLastChat - chatsPerPage; // Index of first chat in the current page
const currentChats = filteredChats.slice(indexOfFirstChat, indexOfLastChat);
// Get unique chat types for filter dropdown
const chatTypes = chats ? ["all", ...Array.from(new Set(chats.map((chat) => chat.type)))] : [];
return (
<motion.div
className="container p-6 mx-auto"
initial="initial"
animate="enter"
exit="exit"
variants={pageVariants}
>
<div className="flex flex-col space-y-4 md:space-y-6">
<div className="flex flex-col space-y-2">
<h1 className="text-3xl font-bold tracking-tight">All Chats</h1>
<p className="text-muted-foreground">View, search, and manage all your chats.</p>
</div>
{/* Filter and Search Bar */}
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:justify-between md:space-y-0">
<div className="flex flex-1 items-center gap-2">
<div className="relative w-full md:w-80">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search chats..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Select value={selectedType} onValueChange={setSelectedType}>
<SelectTrigger className="w-full md:w-40">
<SelectValue placeholder="Filter by type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{chatTypes.map((type) => (
<SelectItem key={type} value={type}>
{type === "all" ? "All Types" : type.charAt(0).toUpperCase() + type.slice(1)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Sort order" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="newest">Newest First</SelectItem>
<SelectItem value="oldest">Oldest First</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
{/* Status Messages */}
{isFetchingChats && (
<div className="flex items-center justify-center h-40">
<div className="flex flex-col items-center gap-2">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
<p className="text-sm text-muted-foreground">Loading chats...</p>
</div>
</div>
)}
{fetchError && !isFetchingChats && (
<div className="border border-destructive/50 text-destructive p-4 rounded-md">
<h3 className="font-medium">Error loading chats</h3>
<p className="text-sm">{fetchError.message}</p>
</div>
)}
{!isFetchingChats && !fetchError && filteredChats.length === 0 && (
<div className="flex flex-col items-center justify-center h-40 gap-2 text-center">
<MessageCircleMore className="h-8 w-8 text-muted-foreground" />
<h3 className="font-medium">No chats found</h3>
<p className="text-sm text-muted-foreground">
{searchQuery || selectedType !== "all"
? "Try adjusting your search filters"
: "Start a new chat to get started"}
</p>
</div>
)}
{/* Chat Grid */}
{!isFetchingChats && !fetchError && filteredChats.length > 0 && (
<AnimatePresence mode="wait">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{currentChats.map((chat, index) => (
<MotionCard
key={chat.id}
variants={chatCardVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2, delay: index * 0.05 }}
className="overflow-hidden hover:shadow-md transition-shadow"
>
<CardHeader className="pb-3">
<div className="flex justify-between items-start">
<div className="space-y-1">
<CardTitle className="line-clamp-1">
{chat.title || `Chat ${chat.id}`}
</CardTitle>
<CardDescription>
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
<span>{format(new Date(chat.created_at), "MMM d, yyyy")}</span>
</span>
</CardDescription>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
router.push(
`/dashboard/${chat.search_space_id}/researcher/${chat.id}`
)
}
>
<ExternalLink className="mr-2 h-4 w-4" />
<span>View Chat</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
setChatToDelete({
id: chat.id,
title: chat.title || `Chat ${chat.id}`,
});
setDeleteDialogOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete Chat</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardFooter className="flex items-center justify-between gap-2 w-full">
<Badge variant="secondary" className="text-xs">
<Tag className="mr-1 h-3 w-3" />
{chat.type || "Unknown"}
</Badge>
<Button
size="sm"
onClick={() =>
router.push(`/dashboard/${chat.search_space_id}/researcher/${chat.id}`)
}
>
<MessageCircleMore className="h-4 w-4" />
<span>View Chat</span>
</Button>
</CardFooter>
</MotionCard>
))}
</div>
</AnimatePresence>
)}
{/* Pagination */}
{!isFetchingChats && !fetchError && totalPages > 1 && (
<Pagination className="mt-8">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={`?page=${Math.max(1, currentPage - 1)}`}
onClick={(e) => {
e.preventDefault();
if (currentPage > 1) setCurrentPage(currentPage - 1);
}}
className={currentPage <= 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
{Array.from({ length: totalPages }).map((_, index) => {
const pageNumber = index + 1;
const isVisible =
pageNumber === 1 ||
pageNumber === totalPages ||
(pageNumber >= currentPage - 1 && pageNumber <= currentPage + 1);
if (!isVisible) {
// Show ellipsis at appropriate positions
if (pageNumber === 2 || pageNumber === totalPages - 1) {
return (
<PaginationItem key={pageNumber}>
<span className="flex h-9 w-9 items-center justify-center">...</span>
</PaginationItem>
);
}
return null;
}
return (
<PaginationItem key={pageNumber}>
<PaginationLink
href={`?page=${pageNumber}`}
onClick={(e) => {
e.preventDefault();
setCurrentPage(pageNumber);
}}
isActive={pageNumber === currentPage}
>
{pageNumber}
</PaginationLink>
</PaginationItem>
);
})}
<PaginationItem>
<PaginationNext
href={`?page=${Math.min(totalPages, currentPage + 1)}`}
onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages) setCurrentPage(currentPage + 1);
}}
className={currentPage >= totalPages ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
<span>Delete Chat</span>
</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-medium">{chatToDelete?.title}</span>? This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
disabled={isDeletingChat}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteChat}
disabled={isDeletingChat}
className="gap-2"
>
{isDeletingChat ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Deleting...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Delete
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</motion.div>
);
}

View file

@ -1,25 +0,0 @@
import { Suspense } from "react";
import ChatsPageClient from "./chats-client";
interface PageProps {
params: {
search_space_id: string;
};
}
export default async function ChatsPage({ params }: PageProps) {
// Get search space ID from the route parameter
const { search_space_id: searchSpaceId } = await Promise.resolve(params);
return (
<Suspense
fallback={
<div className="flex items-center justify-center h-[60vh]">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
</div>
}
>
<ChatsPageClient searchSpaceId={searchSpaceId} />
</Suspense>
);
}

View file

@ -1,24 +1,25 @@
"use client";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { Loader2, PanelRight } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useAtomValue, useSetAtom } from "jotai";
import { Loader2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { activeChathatUIAtom, activeChatIdAtom } from "@/atoms/chats/ui.atoms";
import { llmPreferencesAtom } from "@/atoms/llm-config/llm-config-query.atoms";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import {
globalNewLLMConfigsAtom,
llmPreferencesAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContainer";
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { useUserAccess } from "@/hooks/use-rbac";
import { cn } from "@/lib/utils";
export function DashboardClientLayout({
children,
@ -34,43 +35,27 @@ export function DashboardClientLayout({
const t = useTranslations("dashboard");
const router = useRouter();
const pathname = usePathname();
const searchSpaceIdNum = Number(searchSpaceId);
const { search_space_id, chat_id } = useParams();
const [chatUIState, setChatUIState] = useAtom(activeChathatUIAtom);
const activeChatId = useAtomValue(activeChatIdAtom);
const { search_space_id } = useParams();
const setActiveSearchSpaceIdState = useSetAtom(activeSearchSpaceIdAtom);
const setActiveChatIdState = useSetAtom(activeChatIdAtom);
const [showIndicator, setShowIndicator] = useState(false);
const { isChatPannelOpen } = chatUIState;
// Check if we're on the researcher page
const isResearcherPage = pathname?.includes("/researcher");
// Show indicator when chat becomes active and panel is closed
useEffect(() => {
if (activeChatId && !isChatPannelOpen) {
setShowIndicator(true);
// Hide indicator after 5 seconds
const timer = setTimeout(() => setShowIndicator(false), 5000);
return () => clearTimeout(timer);
} else {
setShowIndicator(false);
}
}, [activeChatId, isChatPannelOpen]);
const { data: preferences = {}, isFetching: loading, error } = useAtomValue(llmPreferencesAtom);
const {
data: preferences = {},
isFetching: loading,
error,
refetch: refetchPreferences,
} = useAtomValue(llmPreferencesAtom);
const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
useAtomValue(globalNewLLMConfigsAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const isOnboardingComplete = useCallback(() => {
return !!(
preferences.long_context_llm_id &&
preferences.fast_llm_id &&
preferences.strategic_llm_id
);
return !!(preferences.agent_llm_id && preferences.document_summary_llm_id);
}, [preferences]);
const { access, loading: accessLoading } = useUserAccess(searchSpaceIdNum);
const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom);
const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false);
const [isAutoConfiguring, setIsAutoConfiguring] = useState(false);
const hasAttemptedAutoConfig = useRef(false);
// Skip onboarding check if we're already on the onboarding page
const isOnboardingPage = pathname?.includes("/onboard");
@ -115,27 +100,82 @@ export function DashboardClientLayout({
return;
}
// Wait for both preferences and access data to load
if (!loading && !accessLoading && !hasCheckedOnboarding) {
// Wait for all data to load
if (
!loading &&
!accessLoading &&
!globalConfigsLoading &&
!hasCheckedOnboarding &&
!isAutoConfiguring
) {
const onboardingComplete = isOnboardingComplete();
// Only redirect to onboarding if user is the owner and onboarding is not complete
// Invited members (non-owners) should skip onboarding and use existing config
if (!onboardingComplete && isOwner) {
router.push(`/dashboard/${searchSpaceId}/onboard`);
// If onboarding is complete, nothing to do
if (onboardingComplete) {
setHasCheckedOnboarding(true);
return;
}
// Only handle onboarding for owners
if (!isOwner) {
setHasCheckedOnboarding(true);
return;
}
// If global configs available, auto-configure without going to onboard page
if (globalConfigs.length > 0 && !hasAttemptedAutoConfig.current) {
hasAttemptedAutoConfig.current = true;
setIsAutoConfiguring(true);
const autoConfigureWithGlobal = async () => {
try {
const firstGlobalConfig = globalConfigs[0];
await updatePreferences({
search_space_id: Number(searchSpaceId),
data: {
agent_llm_id: firstGlobalConfig.id,
document_summary_llm_id: firstGlobalConfig.id,
},
});
await refetchPreferences();
toast.success("AI configured automatically!", {
description: `Using ${firstGlobalConfig.name}. Customize in Settings.`,
});
setHasCheckedOnboarding(true);
} catch (error) {
console.error("Auto-configuration failed:", error);
// Fall back to onboard page
router.push(`/dashboard/${searchSpaceId}/onboard`);
} finally {
setIsAutoConfiguring(false);
}
};
autoConfigureWithGlobal();
return;
}
// No global configs - redirect to onboard page
router.push(`/dashboard/${searchSpaceId}/onboard`);
setHasCheckedOnboarding(true);
}
}, [
loading,
accessLoading,
globalConfigsLoading,
isOnboardingComplete,
isOnboardingPage,
isOwner,
isAutoConfiguring,
globalConfigs,
router,
searchSpaceId,
hasCheckedOnboarding,
updatePreferences,
refetchPreferences,
]);
// Synchronize active search space and chat IDs with URL
@ -148,27 +188,27 @@ export function DashboardClientLayout({
: "";
if (!activeSeacrhSpaceId) return;
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
}, [search_space_id]);
}, [search_space_id, setActiveSearchSpaceIdState]);
useEffect(() => {
const activeChatId =
typeof chat_id === "string"
? chat_id
: Array.isArray(chat_id) && chat_id.length > 0
? chat_id[0]
: "";
if (!activeChatId) return;
setActiveChatIdState(activeChatId);
}, [chat_id, search_space_id]);
// Show loading screen while checking onboarding status (only on first load)
if (!hasCheckedOnboarding && (loading || accessLoading) && !isOnboardingPage) {
// Show loading screen while checking onboarding status or auto-configuring
if (
(!hasCheckedOnboarding &&
(loading || accessLoading || globalConfigsLoading) &&
!isOnboardingPage) ||
isAutoConfiguring
) {
return (
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium">{t("loading_config")}</CardTitle>
<CardDescription>{t("checking_llm_prefs")}</CardDescription>
<CardTitle className="text-xl font-medium">
{isAutoConfiguring ? "Setting up AI..." : t("loading_config")}
</CardTitle>
<CardDescription>
{isAutoConfiguring
? "Auto-configuring with available settings"
: t("checking_llm_prefs")}
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<Loader2 className="h-12 w-12 text-primary animate-spin" />
@ -212,123 +252,20 @@ export function DashboardClientLayout({
navMain={translatedNavMain}
/>
<SidebarInset className="h-full ">
<main className="flex h-full">
<div className="flex grow flex-col h-full border-r">
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 border-b">
<div className="flex items-center justify-between w-full gap-2 px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="h-6" />
<DashboardBreadcrumb />
</div>
<div className="flex items-center gap-2">
<LanguageSwitcher />
{/* Only show artifacts toggle on researcher page */}
{isResearcherPage && (
<motion.div
className="relative"
animate={
showIndicator
? {
scale: [1, 1.05, 1],
}
: {}
}
transition={{
duration: 2,
repeat: showIndicator ? Number.POSITIVE_INFINITY : 0,
ease: "easeInOut",
}}
>
<motion.button
type="button"
onClick={() => {
setChatUIState((prev) => ({
...prev,
isChatPannelOpen: !isChatPannelOpen,
}));
setShowIndicator(false);
}}
className={cn(
"shrink-0 rounded-full p-2 transition-all duration-300 relative",
showIndicator
? "bg-primary/20 hover:bg-primary/30 shadow-lg shadow-primary/25"
: "hover:bg-muted",
activeChatId && !showIndicator && "hover:bg-primary/10"
)}
title="Toggle Artifacts Panel"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<motion.div
animate={
showIndicator
? {
rotate: [0, -10, 10, -10, 0],
}
: {}
}
transition={{
duration: 0.5,
repeat: showIndicator ? Number.POSITIVE_INFINITY : 0,
repeatDelay: 2,
}}
>
<PanelRight
className={cn(
"h-4 w-4 transition-colors",
showIndicator && "text-primary"
)}
/>
</motion.div>
</motion.button>
{/* Pulsing indicator badge */}
<AnimatePresence>
{showIndicator && (
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
className="absolute -right-1 -top-1 pointer-events-none"
>
<motion.div
animate={{
scale: [1, 1.3, 1],
}}
transition={{
duration: 1.5,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="relative"
>
<div className="h-2.5 w-2.5 rounded-full bg-primary shadow-lg" />
<motion.div
animate={{
scale: [1, 2.5, 1],
opacity: [0.6, 0, 0.6],
}}
transition={{
duration: 1.5,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="absolute inset-0 h-2.5 w-2.5 rounded-full bg-primary"
/>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</div>
<main className="flex flex-col h-full">
<header className="sticky top-0 flex h-16 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 border-b">
<div className="flex items-center justify-between w-full gap-2 px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="h-6" />
<DashboardBreadcrumb />
</div>
</header>
<div className="grow flex-1 overflow-auto min-h-[calc(100vh-64px)]">{children}</div>
</div>
{/* Only render chat panel on researcher page */}
{isResearcherPage && <ChatPanelContainer />}
<div className="flex items-center gap-2">
<LanguageSwitcher />
</div>
</div>
</header>
<div className="flex-1 overflow-hidden">{children}</div>
</main>
</SidebarInset>
</SidebarProvider>

View file

@ -273,7 +273,7 @@ export default function ConnectorsPage() {
};
return (
<div className="container mx-auto py-8 max-w-6xl">
<div className="container mx-auto py-8 px-4 max-w-6xl min-h-[calc(100vh-64px)]">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}

View file

@ -153,15 +153,6 @@ export default function EditConnectorPage() {
placeholder="Begins with secret_..."
/>
)}
{/* == Serper == */}
{connector.connector_type === "SERPER_API" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="SERPER_API_KEY"
fieldLabel="Serper API Key"
fieldDescription="Update the Serper API Key if needed."
/>
)}
{/* == Tavily == */}
{connector.connector_type === "TAVILY_API" && (
<EditSimpleTokenForm

View file

@ -40,7 +40,6 @@ const apiConnectorFormSchema = z.object({
// Helper function to get connector type display name
const getConnectorTypeDisplay = (type: string): string => {
const typeMap: Record<string, string> = {
SERPER_API: "Serper API",
TAVILY_API: "Tavily API",
SLACK_CONNECTOR: "Slack Connector",
NOTION_CONNECTOR: "Notion Connector",
@ -68,7 +67,6 @@ type ApiConnectorFormValues = z.infer<typeof apiConnectorFormSchema>;
// Get API key field name based on connector type
const getApiKeyFieldName = (connectorType: string): string => {
const fieldMap: Record<string, string> = {
SERPER_API: "SERPER_API_KEY",
TAVILY_API: "TAVILY_API_KEY",
SLACK_CONNECTOR: "SLACK_BOT_TOKEN",
NOTION_CONNECTOR: "NOTION_INTEGRATION_TOKEN",

View file

@ -189,7 +189,7 @@ export default function DocumentsTable() {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="w-full px-6 py-4"
className="w-full px-6 py-4 min-h-[calc(100vh-64px)]"
>
<DocumentsFilters
typeCounts={typeCounts ?? {}}

View file

@ -1,11 +1,13 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { AlertCircle, ArrowLeft, FileText, Loader2, Save } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor";
import {
AlertDialog,
@ -23,17 +25,31 @@ import { Separator } from "@/components/ui/separator";
import { notesApiService } from "@/lib/apis/notes-api.service";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
// BlockNote types
type BlockNoteInlineContent =
| string
| { text?: string; type?: string; styles?: Record<string, unknown> };
interface BlockNoteBlock {
type: string;
content?: BlockNoteInlineContent[];
children?: BlockNoteBlock[];
props?: Record<string, unknown>;
}
type BlockNoteDocument = BlockNoteBlock[] | null | undefined;
interface EditorContent {
document_id: number;
title: string;
document_type?: string;
blocknote_document: any;
blocknote_document: BlockNoteDocument;
updated_at: string | null;
}
// Helper function to extract title from BlockNote document
// Takes the text content from the first block (should be a heading for notes)
function extractTitleFromBlockNote(blocknoteDocument: any[] | null | undefined): string {
function extractTitleFromBlockNote(blocknoteDocument: BlockNoteDocument): string {
if (!blocknoteDocument || !Array.isArray(blocknoteDocument) || blocknoteDocument.length === 0) {
return "Untitled";
}
@ -47,9 +63,9 @@ function extractTitleFromBlockNote(blocknoteDocument: any[] | null | undefined):
// BlockNote blocks have a content array with inline content
if (firstBlock.content && Array.isArray(firstBlock.content)) {
const textContent = firstBlock.content
.map((item: any) => {
.map((item: BlockNoteInlineContent) => {
if (typeof item === "string") return item;
if (item?.text) return item.text;
if (typeof item === "object" && item?.text) return item.text;
return "";
})
.join("")
@ -71,11 +87,51 @@ export default function EditorPage() {
const [document, setDocument] = useState<EditorContent | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editorContent, setEditorContent] = useState<any>(null);
const [editorContent, setEditorContent] = useState<BlockNoteDocument>(null);
const [error, setError] = useState<string | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
// Global state for cross-component communication
const [, setGlobalHasUnsavedChanges] = useAtom(hasUnsavedEditorChangesAtom);
const [pendingNavigation, setPendingNavigation] = useAtom(pendingEditorNavigationAtom);
// Sync local unsaved changes state with global atom
useEffect(() => {
setGlobalHasUnsavedChanges(hasUnsavedChanges);
}, [hasUnsavedChanges, setGlobalHasUnsavedChanges]);
// Cleanup global state when component unmounts
useEffect(() => {
return () => {
setGlobalHasUnsavedChanges(false);
setPendingNavigation(null);
};
}, [setGlobalHasUnsavedChanges, setPendingNavigation]);
// Handle pending navigation from sidebar (e.g., when user clicks "+" to create new note)
useEffect(() => {
if (pendingNavigation) {
if (hasUnsavedChanges) {
// Show dialog to confirm navigation
setShowUnsavedDialog(true);
} else {
// No unsaved changes, navigate immediately
router.push(pendingNavigation);
setPendingNavigation(null);
}
}
}, [pendingNavigation, hasUnsavedChanges, router, setPendingNavigation]);
// Reset state when documentId changes (e.g., navigating from existing note to new note)
useEffect(() => {
setDocument(null);
setEditorContent(null);
setError(null);
setHasUnsavedChanges(false);
setLoading(true);
}, [documentId]);
// Fetch document content - DIRECT CALL TO FASTAPI
// Skip fetching if this is a new note
useEffect(() => {
@ -281,13 +337,29 @@ export default function EditorPage() {
if (hasUnsavedChanges) {
setShowUnsavedDialog(true);
} else {
router.push(`/dashboard/${searchSpaceId}/researcher`);
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
};
const handleConfirmLeave = () => {
setShowUnsavedDialog(false);
router.push(`/dashboard/${searchSpaceId}/researcher`);
// Clear global unsaved state
setGlobalHasUnsavedChanges(false);
setHasUnsavedChanges(false);
// If there's a pending navigation (from sidebar), use that; otherwise go back to chat
if (pendingNavigation) {
router.push(pendingNavigation);
setPendingNavigation(null);
} else {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
};
const handleCancelLeave = () => {
setShowUnsavedDialog(false);
// Clear pending navigation if user cancels
setPendingNavigation(null);
};
if (loading) {
@ -321,7 +393,7 @@ export default function EditorPage() {
</CardHeader>
<CardContent>
<Button
onClick={() => router.push(`/dashboard/${searchSpaceId}/researcher`)}
onClick={() => router.push(`/dashboard/${searchSpaceId}/new-chat`)}
variant="outline"
className="gap-2"
>
@ -352,7 +424,7 @@ export default function EditorPage() {
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col h-full w-full"
className="flex flex-col min-h-screen w-full"
>
{/* Toolbar */}
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-4 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-6">
@ -386,7 +458,7 @@ export default function EditorPage() {
</div>
{/* Editor Container */}
<div className="flex-1 overflow-visible relative">
<div className="flex-1 min-h-0 overflow-hidden relative">
<div className="h-full w-full overflow-auto p-6">
{error && (
<motion.div
@ -402,6 +474,7 @@ export default function EditorPage() {
)}
<div className="max-w-4xl mx-auto">
<BlockNoteEditor
key={documentId} // Force re-mount when document changes
initialContent={isNewNote ? undefined : editorContent}
onChange={setEditorContent}
useTitleBlock={isNote}
@ -411,7 +484,12 @@ export default function EditorPage() {
</div>
{/* Unsaved Changes Dialog */}
<AlertDialog open={showUnsavedDialog} onOpenChange={setShowUnsavedDialog}>
<AlertDialog
open={showUnsavedDialog}
onOpenChange={(open) => {
if (!open) handleCancelLeave();
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
@ -420,7 +498,7 @@ export default function EditorPage() {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel onClick={handleCancelLeave}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmLeave}>OK</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View file

@ -29,7 +29,7 @@ export default function DashboardLayout({
const customNavMain = [
{
title: "Chat",
url: `/dashboard/${search_space_id}/researcher`,
url: `/dashboard/${search_space_id}/new-chat`,
icon: "SquareTerminal",
items: [],
},

View file

@ -444,7 +444,7 @@ export default function LogsManagePage() {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="w-full px-6 py-4 space-y-6"
className="w-full px-6 py-4 space-y-6 min-h-[calc(100vh-64px)]"
>
{/* Summary Dashboard */}
<LogsSummaryDashboard

View file

@ -0,0 +1,800 @@
"use client";
import {
type AppendMessage,
AssistantRuntimeProvider,
type ThreadMessageLike,
useExternalStoreRuntime,
} from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import {
type MentionedDocumentInfo,
mentionedDocumentIdsAtom,
mentionedDocumentsAtom,
messageDocumentsMapAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { Thread } from "@/components/assistant-ui/thread";
import { ChatHeader } from "@/components/new-chat/chat-header";
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
import { getBearerToken } from "@/lib/auth-utils";
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
import {
isPodcastGenerating,
looksLikePodcastRequest,
setActivePodcastTaskId,
} from "@/lib/chat/podcast-state";
import {
appendMessage,
createThread,
getThreadMessages,
type MessageRecord,
} from "@/lib/chat/thread-persistence";
import {
trackChatCreated,
trackChatError,
trackChatMessageSent,
trackChatResponseReceived,
} from "@/lib/posthog/events";
/**
* Extract thinking steps from message content
*/
function extractThinkingSteps(content: unknown): ThinkingStep[] {
if (!Array.isArray(content)) return [];
const thinkingPart = content.find(
(part: unknown) =>
typeof part === "object" &&
part !== null &&
"type" in part &&
(part as { type: string }).type === "thinking-steps"
) as { type: "thinking-steps"; steps: ThinkingStep[] } | undefined;
return thinkingPart?.steps || [];
}
/**
* Zod schema for mentioned document info (for type-safe parsing)
*/
const MentionedDocumentInfoSchema = z.object({
id: z.number(),
title: z.string(),
document_type: z.string(),
});
const MentionedDocumentsPartSchema = z.object({
type: z.literal("mentioned-documents"),
documents: z.array(MentionedDocumentInfoSchema),
});
/**
* Extract mentioned documents from message content (type-safe with Zod)
*/
function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
if (!Array.isArray(content)) return [];
for (const part of content) {
const result = MentionedDocumentsPartSchema.safeParse(part);
if (result.success) {
return result.data.documents;
}
}
return [];
}
/**
* Convert backend message to assistant-ui ThreadMessageLike format
* Filters out 'thinking-steps' part as it's handled separately via messageThinkingSteps
*/
function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
let content: ThreadMessageLike["content"];
if (typeof msg.content === "string") {
content = [{ type: "text", text: msg.content }];
} else if (Array.isArray(msg.content)) {
// Filter out custom metadata parts - they're handled separately
const filteredContent = msg.content.filter((part: unknown) => {
if (typeof part !== "object" || part === null || !("type" in part)) return true;
const partType = (part as { type: string }).type;
// Filter out thinking-steps and mentioned-documents
return partType !== "thinking-steps" && partType !== "mentioned-documents";
});
content =
filteredContent.length > 0
? (filteredContent as ThreadMessageLike["content"])
: [{ type: "text", text: "" }];
} else {
content = [{ type: "text", text: String(msg.content) }];
}
return {
id: `msg-${msg.id}`,
role: msg.role,
content,
createdAt: new Date(msg.created_at),
};
}
/**
* Tools that should render custom UI in the chat.
*/
const TOOLS_WITH_UI = new Set([
"generate_podcast",
"link_preview",
"display_image",
"scrape_webpage",
]);
/**
* Type for thinking step data from the backend
*/
interface ThinkingStepData {
id: string;
title: string;
status: "pending" | "in_progress" | "completed";
items: string[];
}
export default function NewChatPage() {
const params = useParams();
const [isInitializing, setIsInitializing] = useState(true);
const [threadId, setThreadId] = useState<number | null>(null);
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
const [isRunning, setIsRunning] = useState(false);
// Store thinking steps per message ID - kept separate from content to avoid
// "unsupported part type" errors from assistant-ui
const [messageThinkingSteps, setMessageThinkingSteps] = useState<Map<string, ThinkingStep[]>>(
new Map()
);
const abortControllerRef = useRef<AbortController | null>(null);
// Get mentioned document IDs from the composer
const mentionedDocumentIds = useAtomValue(mentionedDocumentIdsAtom);
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
// Create the attachment adapter for file processing
const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []);
// Extract search_space_id from URL params
const searchSpaceId = useMemo(() => {
const id = params.search_space_id;
const parsed = typeof id === "string" ? Number.parseInt(id, 10) : 0;
return Number.isNaN(parsed) ? 0 : parsed;
}, [params.search_space_id]);
// Extract chat_id from URL params
const urlChatId = useMemo(() => {
const id = params.chat_id;
let parsed = 0;
if (Array.isArray(id) && id.length > 0) {
parsed = Number.parseInt(id[0], 10);
} else if (typeof id === "string") {
parsed = Number.parseInt(id, 10);
}
return Number.isNaN(parsed) ? 0 : parsed;
}, [params.chat_id]);
// Initialize thread and load messages
// For new chats (no urlChatId), we use lazy creation - thread is created on first message
const initializeThread = useCallback(async () => {
setIsInitializing(true);
// Reset all state when switching between chats to prevent stale data
setMessages([]);
setThreadId(null);
setMessageThinkingSteps(new Map());
setMentionedDocumentIds([]);
setMentionedDocuments([]);
setMessageDocumentsMap({});
try {
if (urlChatId > 0) {
// Thread exists - load messages
setThreadId(urlChatId);
const response = await getThreadMessages(urlChatId);
if (response.messages && response.messages.length > 0) {
const loadedMessages = response.messages.map(convertToThreadMessage);
setMessages(loadedMessages);
// Extract and restore thinking steps from persisted messages
const restoredThinkingSteps = new Map<string, ThinkingStep[]>();
// Extract and restore mentioned documents from persisted messages
const restoredDocsMap: Record<string, MentionedDocumentInfo[]> = {};
for (const msg of response.messages) {
if (msg.role === "assistant") {
const steps = extractThinkingSteps(msg.content);
if (steps.length > 0) {
restoredThinkingSteps.set(`msg-${msg.id}`, steps);
}
}
if (msg.role === "user") {
const docs = extractMentionedDocuments(msg.content);
if (docs.length > 0) {
restoredDocsMap[`msg-${msg.id}`] = docs;
}
}
}
if (restoredThinkingSteps.size > 0) {
setMessageThinkingSteps(restoredThinkingSteps);
}
if (Object.keys(restoredDocsMap).length > 0) {
setMessageDocumentsMap(restoredDocsMap);
}
}
}
// For new chats (urlChatId === 0), don't create thread yet
// Thread will be created lazily when user sends first message
// This improves UX (instant load) and avoids orphan threads
} catch (error) {
console.error("[NewChatPage] Failed to initialize thread:", error);
// Keep threadId as null - don't use Date.now() as it creates an invalid ID
// that will cause 404 errors on subsequent API calls
setThreadId(null);
toast.error("Failed to load chat. Please try again.");
} finally {
setIsInitializing(false);
}
}, [urlChatId, setMessageDocumentsMap, setMentionedDocumentIds, setMentionedDocuments]);
// Initialize on mount
useEffect(() => {
initializeThread();
}, [initializeThread]);
// Cancel ongoing request
const cancelRun = useCallback(async () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setIsRunning(false);
}, []);
// Handle new message from user
const onNew = useCallback(
async (message: AppendMessage) => {
// Abort any previous streaming request to prevent race conditions
// when user sends a second query while the first is still streaming
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
// Extract user query text from content parts
let userQuery = "";
for (const part of message.content) {
if (part.type === "text") {
userQuery += part.text;
}
}
// Extract attachments from message
// AppendMessage.attachments contains the processed attachment objects (from adapter.send())
const messageAttachments: Array<Record<string, unknown>> = [];
if (message.attachments && message.attachments.length > 0) {
for (const att of message.attachments) {
messageAttachments.push(att as unknown as Record<string, unknown>);
}
}
if (!userQuery.trim() && messageAttachments.length === 0) return;
// Check if podcast is already generating
if (isPodcastGenerating() && looksLikePodcastRequest(userQuery)) {
toast.warning("A podcast is already being generated.");
return;
}
const token = getBearerToken();
if (!token) {
toast.error("Not authenticated. Please log in again.");
return;
}
// Lazy thread creation: create thread on first message if it doesn't exist
let currentThreadId = threadId;
if (!currentThreadId) {
try {
const newThread = await createThread(searchSpaceId, "New Chat");
currentThreadId = newThread.id;
setThreadId(currentThreadId);
// Track chat creation
trackChatCreated(searchSpaceId, currentThreadId);
// Update URL silently using browser API (not router.replace) to avoid
// interrupting the ongoing fetch/streaming with React navigation
window.history.replaceState(
null,
"",
`/dashboard/${searchSpaceId}/new-chat/${currentThreadId}`
);
} catch (error) {
console.error("[NewChatPage] Failed to create thread:", error);
toast.error("Failed to start chat. Please try again.");
return;
}
}
// Add user message to state
const userMsgId = `msg-user-${Date.now()}`;
const userMessage: ThreadMessageLike = {
id: userMsgId,
role: "user",
content: message.content,
createdAt: new Date(),
// Include attachments so they can be displayed
attachments: message.attachments || [],
};
setMessages((prev) => [...prev, userMessage]);
// Track message sent
trackChatMessageSent(searchSpaceId, currentThreadId, {
hasAttachments: messageAttachments.length > 0,
hasMentionedDocuments: mentionedDocumentIds.length > 0,
messageLength: userQuery.length,
});
// Store mentioned documents with this message for display
if (mentionedDocuments.length > 0) {
const docsInfo: MentionedDocumentInfo[] = mentionedDocuments.map((doc) => ({
id: doc.id,
title: doc.title,
document_type: doc.document_type,
}));
setMessageDocumentsMap((prev) => ({
...prev,
[userMsgId]: docsInfo,
}));
}
// Persist user message with mentioned documents (don't await, fire and forget)
const persistContent =
mentionedDocuments.length > 0
? [
...message.content,
{
type: "mentioned-documents",
documents: mentionedDocuments.map((doc) => ({
id: doc.id,
title: doc.title,
document_type: doc.document_type,
})),
},
]
: message.content;
appendMessage(currentThreadId, {
role: "user",
content: persistContent,
}).catch((err) => console.error("Failed to persist user message:", err));
// Start streaming response
setIsRunning(true);
const controller = new AbortController();
abortControllerRef.current = controller;
// Prepare assistant message
const assistantMsgId = `msg-assistant-${Date.now()}`;
const currentThinkingSteps = new Map<string, ThinkingStepData>();
// Ordered content parts to preserve inline tool call positions
// Each part is either a text segment or a tool call
type ContentPart =
| { type: "text"; text: string }
| {
type: "tool-call";
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
result?: unknown;
};
const contentParts: ContentPart[] = [];
// Track the current text segment index (for appending text deltas)
let currentTextPartIndex = -1;
// Map to track tool call indices for updating results
const toolCallIndices = new Map<string, number>();
// Helper to get or create the current text part for appending text
const appendText = (delta: string) => {
if (currentTextPartIndex >= 0 && contentParts[currentTextPartIndex]?.type === "text") {
// Append to existing text part
(contentParts[currentTextPartIndex] as { type: "text"; text: string }).text += delta;
} else {
// Create new text part
contentParts.push({ type: "text", text: delta });
currentTextPartIndex = contentParts.length - 1;
}
};
// Helper to add a tool call (this "breaks" the current text segment)
const addToolCall = (toolCallId: string, toolName: string, args: Record<string, unknown>) => {
if (TOOLS_WITH_UI.has(toolName)) {
contentParts.push({
type: "tool-call",
toolCallId,
toolName,
args,
});
toolCallIndices.set(toolCallId, contentParts.length - 1);
// Reset text part index so next text creates a new segment
currentTextPartIndex = -1;
}
};
// Helper to update a tool call's args or result
const updateToolCall = (
toolCallId: string,
update: { args?: Record<string, unknown>; result?: unknown }
) => {
const index = toolCallIndices.get(toolCallId);
if (index !== undefined && contentParts[index]?.type === "tool-call") {
const tc = contentParts[index] as ContentPart & { type: "tool-call" };
if (update.args) tc.args = update.args;
if (update.result !== undefined) tc.result = update.result;
}
};
// Helper to build content for UI (without thinking-steps to avoid assistant-ui errors)
const buildContentForUI = (): ThreadMessageLike["content"] => {
// Filter to only include text parts with content and tool-calls with UI
const filtered = contentParts.filter((part) => {
if (part.type === "text") return part.text.length > 0;
if (part.type === "tool-call") return TOOLS_WITH_UI.has(part.toolName);
return false;
});
return filtered.length > 0
? (filtered as ThreadMessageLike["content"])
: [{ type: "text", text: "" }];
};
// Helper to build content for persistence (includes thinking-steps for restoration)
const buildContentForPersistence = (): unknown[] => {
const parts: unknown[] = [];
// Include thinking steps for persistence
if (currentThinkingSteps.size > 0) {
parts.push({
type: "thinking-steps",
steps: Array.from(currentThinkingSteps.values()),
});
}
// Add content parts (filtered)
for (const part of contentParts) {
if (part.type === "text" && part.text.length > 0) {
parts.push(part);
} else if (part.type === "tool-call" && TOOLS_WITH_UI.has(part.toolName)) {
parts.push(part);
}
}
return parts.length > 0 ? parts : [{ type: "text", text: "" }];
};
// Add placeholder assistant message
setMessages((prev) => [
...prev,
{
id: assistantMsgId,
role: "assistant",
content: [{ type: "text", text: "" }],
createdAt: new Date(),
},
]);
try {
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
// Build message history for context
const messageHistory = messages
.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => {
let text = "";
for (const part of m.content) {
if (typeof part === "object" && part.type === "text" && "text" in part) {
text += part.text;
}
}
return { role: m.role, content: text };
})
.filter((m) => m.content.length > 0);
// Extract attachment content to send with the request
const attachments = extractAttachmentContent(messageAttachments);
// Get mentioned document IDs for context
const documentIds = mentionedDocumentIds.length > 0 ? [...mentionedDocumentIds] : undefined;
// Clear mentioned documents after capturing them
if (mentionedDocumentIds.length > 0) {
setMentionedDocumentIds([]);
setMentionedDocuments([]);
}
const response = await fetch(`${backendUrl}/api/v1/new_chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
chat_id: currentThreadId,
user_query: userQuery.trim(),
search_space_id: searchSpaceId,
messages: messageHistory,
attachments: attachments.length > 0 ? attachments : undefined,
mentioned_document_ids: documentIds,
}),
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Backend error: ${response.status}`);
}
if (!response.body) {
throw new Error("No response body");
}
// Parse SSE stream
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split(/\r?\n\r?\n/);
buffer = events.pop() || "";
for (const event of events) {
const lines = event.split(/\r?\n/);
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (!data || data === "[DONE]") continue;
try {
const parsed = JSON.parse(data);
switch (parsed.type) {
case "text-delta":
appendText(parsed.delta);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m
)
);
break;
case "tool-input-start":
// Add tool call inline - this breaks the current text segment
addToolCall(parsed.toolCallId, parsed.toolName, {});
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m
)
);
break;
case "tool-input-available": {
// Update existing tool call's args, or add if not exists
if (toolCallIndices.has(parsed.toolCallId)) {
updateToolCall(parsed.toolCallId, { args: parsed.input || {} });
} else {
addToolCall(parsed.toolCallId, parsed.toolName, parsed.input || {});
}
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m
)
);
break;
}
case "tool-output-available": {
// Update the tool call with its result
updateToolCall(parsed.toolCallId, { result: parsed.output });
// Handle podcast-specific logic
if (parsed.output?.status === "processing" && parsed.output?.task_id) {
// Check if this is a podcast tool by looking at the content part
const idx = toolCallIndices.get(parsed.toolCallId);
if (idx !== undefined) {
const part = contentParts[idx];
if (part?.type === "tool-call" && part.toolName === "generate_podcast") {
setActivePodcastTaskId(parsed.output.task_id);
}
}
}
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m
)
);
break;
}
case "data-thinking-step": {
// Handle thinking step events for chain-of-thought display
const stepData = parsed.data as ThinkingStepData;
if (stepData?.id) {
currentThinkingSteps.set(stepData.id, stepData);
// Update thinking steps state for rendering
// The ThinkingStepsScrollHandler in Thread component
// will handle auto-scrolling when this state changes
setMessageThinkingSteps((prev) => {
const newMap = new Map(prev);
newMap.set(assistantMsgId, Array.from(currentThinkingSteps.values()));
return newMap;
});
}
break;
}
case "error":
throw new Error(parsed.errorText || "Server error");
}
} catch (e) {
if (e instanceof SyntaxError) continue;
throw e;
}
}
}
}
} finally {
reader.releaseLock();
}
// Persist assistant message (with thinking steps for restoration on refresh)
const finalContent = buildContentForPersistence();
if (contentParts.length > 0) {
appendMessage(currentThreadId, {
role: "assistant",
content: finalContent,
}).catch((err) => console.error("Failed to persist assistant message:", err));
// Track successful response
trackChatResponseReceived(searchSpaceId, currentThreadId);
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
// Request was cancelled
return;
}
console.error("[NewChatPage] Chat error:", error);
// Track chat error
trackChatError(
searchSpaceId,
currentThreadId,
error instanceof Error ? error.message : "Unknown error"
);
toast.error("Failed to get response. Please try again.");
// Update assistant message with error
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? {
...m,
content: [
{
type: "text",
text: "Sorry, there was an error. Please try again.",
},
],
}
: m
)
);
} finally {
setIsRunning(false);
abortControllerRef.current = null;
// Note: We no longer clear thinking steps - they persist with the message
}
},
[
threadId,
searchSpaceId,
messages,
mentionedDocumentIds,
mentionedDocuments,
setMentionedDocumentIds,
setMentionedDocuments,
setMessageDocumentsMap,
]
);
// Convert message (pass through since already in correct format)
const convertMessage = useCallback(
(message: ThreadMessageLike): ThreadMessageLike => message,
[]
);
// Handle editing a message - removes messages after the edited one and sends as new
const onEdit = useCallback(
async (message: AppendMessage) => {
// Find the message being edited by looking at the parentId
// The parentId tells us which message's response we're editing
// For now, we'll just treat edits like new messages
// A more sophisticated implementation would truncate the history
await onNew(message);
},
[onNew]
);
// Create external store runtime with attachment support
const runtime = useExternalStoreRuntime({
messages,
isRunning,
onNew,
onEdit,
convertMessage,
onCancel: cancelRun,
adapters: {
attachments: attachmentAdapter,
},
});
// Show loading state only when loading an existing thread
if (isInitializing) {
return (
<div className="flex h-[calc(100vh-64px)] items-center justify-center">
<div className="text-muted-foreground">Loading chat...</div>
</div>
);
}
// Show error state only if we tried to load an existing thread but failed
// For new chats (urlChatId === 0), threadId being null is expected (lazy creation)
if (!threadId && urlChatId > 0) {
return (
<div className="flex h-[calc(100vh-64px)] flex-col items-center justify-center gap-4">
<div className="text-destructive">Failed to load chat</div>
<button
type="button"
onClick={() => {
setIsInitializing(true);
initializeThread();
}}
className="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
Try Again
</button>
</div>
);
}
return (
<AssistantRuntimeProvider runtime={runtime}>
<GeneratePodcastToolUI />
<LinkPreviewToolUI />
<DisplayImageToolUI />
<ScrapeWebpageToolUI />
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden">
<Thread
messageThinkingSteps={messageThinkingSteps}
header={<ChatHeader searchSpaceId={searchSpaceId} />}
/>
</div>
</AssistantRuntimeProvider>
);
}

View file

@ -1,312 +1,268 @@
"use client";
import { useAtomValue } from "jotai";
import { FileText, MessageSquare, UserPlus, Users } from "lucide-react";
import { Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { updateLLMPreferencesMutationAtom } from "@/atoms/llm-config/llm-config-mutation.atoms";
import {
globalLLMConfigsAtom,
llmConfigsAtom,
createNewLLMConfigMutationAtom,
updateLLMPreferencesMutationAtom,
} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import {
globalNewLLMConfigsAtom,
llmPreferencesAtom,
} from "@/atoms/llm-config/llm-config-query.atoms";
import { OnboardActionCard } from "@/components/onboard/onboard-action-card";
import { OnboardAdvancedSettings } from "@/components/onboard/onboard-advanced-settings";
import { OnboardHeader } from "@/components/onboard/onboard-header";
import { OnboardLLMSetup } from "@/components/onboard/onboard-llm-setup";
import { OnboardLoading } from "@/components/onboard/onboard-loading";
import { OnboardStats } from "@/components/onboard/onboard-stats";
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { Logo } from "@/components/Logo";
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
const OnboardPage = () => {
const t = useTranslations("onboard");
export default function OnboardPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
// Queries
const {
data: llmConfigs = [],
isFetching: configsLoading,
refetch: refreshConfigs,
} = useAtomValue(llmConfigsAtom);
const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
useAtomValue(globalLLMConfigsAtom);
const {
data: preferences = {},
isFetching: preferencesLoading,
refetch: refreshPreferences,
} = useAtomValue(llmPreferencesAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
data: globalConfigs = [],
isFetching: globalConfigsLoading,
isSuccess: globalConfigsLoaded,
} = useAtomValue(globalNewLLMConfigsAtom);
const { data: preferences = {}, isFetching: preferencesLoading } =
useAtomValue(llmPreferencesAtom);
// Compute isOnboardingComplete
const isOnboardingComplete = useMemo(() => {
return !!(
preferences.long_context_llm_id &&
preferences.fast_llm_id &&
preferences.strategic_llm_id
);
}, [preferences]);
// Mutations
const { mutateAsync: createConfig, isPending: isCreating } = useAtomValue(
createNewLLMConfigMutationAtom
);
const { mutateAsync: updatePreferences, isPending: isUpdatingPreferences } = useAtomValue(
updateLLMPreferencesMutationAtom
);
// State
const [isAutoConfiguring, setIsAutoConfiguring] = useState(false);
const [autoConfigComplete, setAutoConfigComplete] = useState(false);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const [showPromptSettings, setShowPromptSettings] = useState(false);
const handleRefreshPreferences = useCallback(async () => {
await refreshPreferences();
}, []);
// Track if we've already attempted auto-configuration
const hasAttemptedAutoConfig = useRef(false);
// Track if onboarding was complete on initial mount
const wasCompleteOnMount = useRef<boolean | null>(null);
const hasCheckedInitialState = useRef(false);
// Check if user is authenticated
// Check authentication
useEffect(() => {
const token = getBearerToken();
if (!token) {
// Save current path and redirect to login
redirectToLogin();
return;
}
}, []);
// Capture onboarding state on first load
// Check if onboarding is already complete
const isOnboardingComplete = preferences.agent_llm_id && preferences.document_summary_llm_id;
// If onboarding is already complete, redirect immediately
useEffect(() => {
if (
!hasCheckedInitialState.current &&
!preferencesLoading &&
!configsLoading &&
!globalConfigsLoading
) {
wasCompleteOnMount.current = isOnboardingComplete;
hasCheckedInitialState.current = true;
if (!preferencesLoading && isOnboardingComplete) {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
}, [preferencesLoading, configsLoading, globalConfigsLoading, isOnboardingComplete]);
}, [preferencesLoading, isOnboardingComplete, router, searchSpaceId]);
// Redirect to dashboard if onboarding was already complete
// Auto-configure if global configs are available
useEffect(() => {
if (
wasCompleteOnMount.current === true &&
!preferencesLoading &&
!configsLoading &&
!globalConfigsLoading
) {
const timer = setTimeout(() => {
router.push(`/dashboard/${searchSpaceId}`);
}, 300);
return () => clearTimeout(timer);
}
}, [preferencesLoading, configsLoading, globalConfigsLoading, router, searchSpaceId]);
const autoConfigureWithGlobal = async () => {
if (hasAttemptedAutoConfig.current) return;
if (globalConfigsLoading || preferencesLoading) return;
if (!globalConfigsLoaded) return;
if (isOnboardingComplete) return;
// Auto-configure LLM roles if global configs are available
const autoConfigureLLMs = useCallback(async () => {
if (hasAttemptedAutoConfig.current) return;
if (globalConfigs.length === 0) return;
if (isOnboardingComplete) {
setAutoConfigComplete(true);
return;
}
// Only auto-configure if we have global configs
if (globalConfigs.length > 0) {
hasAttemptedAutoConfig.current = true;
setIsAutoConfiguring(true);
hasAttemptedAutoConfig.current = true;
setIsAutoConfiguring(true);
try {
const firstGlobalConfig = globalConfigs[0];
try {
const allConfigs = [...globalConfigs, ...llmConfigs];
await updatePreferences({
search_space_id: searchSpaceId,
data: {
agent_llm_id: firstGlobalConfig.id,
document_summary_llm_id: firstGlobalConfig.id,
},
});
if (allConfigs.length === 0) {
setIsAutoConfiguring(false);
return;
toast.success("AI configured automatically!", {
description: `Using ${firstGlobalConfig.name}. You can customize this later in Settings.`,
});
// Redirect to new-chat
router.push(`/dashboard/${searchSpaceId}/new-chat`);
} catch (error) {
console.error("Auto-configuration failed:", error);
toast.error("Auto-configuration failed. Please add a configuration manually.");
setIsAutoConfiguring(false);
}
}
};
// Use first available config for all roles
const defaultConfigId = allConfigs[0].id;
autoConfigureWithGlobal();
}, [
globalConfigs,
globalConfigsLoading,
globalConfigsLoaded,
preferencesLoading,
isOnboardingComplete,
updatePreferences,
searchSpaceId,
router,
]);
const newPreferences = {
long_context_llm_id: defaultConfigId,
fast_llm_id: defaultConfigId,
strategic_llm_id: defaultConfigId,
};
// Handle form submission
const handleSubmit = async (formData: LLMConfigFormData) => {
try {
// Create the config
const newConfig = await createConfig(formData);
// Auto-assign to all roles
await updatePreferences({
search_space_id: searchSpaceId,
data: newPreferences,
data: {
agent_llm_id: newConfig.id,
document_summary_llm_id: newConfig.id,
},
});
await refreshPreferences();
setAutoConfigComplete(true);
toast.success("AI models configured automatically!", {
description: "You can customize these in advanced settings.",
toast.success("Configuration created!", {
description: "Redirecting to chat...",
});
// Redirect to new-chat
router.push(`/dashboard/${searchSpaceId}/new-chat`);
} catch (error) {
console.error("Auto-configuration failed:", error);
} finally {
setIsAutoConfiguring(false);
console.error("Failed to create config:", error);
if (error instanceof Error) {
toast.error(error.message || "Failed to create configuration");
}
}
}, [globalConfigs, llmConfigs, isOnboardingComplete, updatePreferences, refreshPreferences]);
};
// Trigger auto-configuration once data is loaded
useEffect(() => {
if (!configsLoading && !globalConfigsLoading && !preferencesLoading) {
autoConfigureLLMs();
}
}, [configsLoading, globalConfigsLoading, preferencesLoading, autoConfigureLLMs]);
const allConfigs = [...globalConfigs, ...llmConfigs];
const isReady = autoConfigComplete || isOnboardingComplete;
const isSubmitting = isCreating || isUpdatingPreferences;
// Loading state
if (configsLoading || preferencesLoading || globalConfigsLoading || isAutoConfiguring) {
if (globalConfigsLoading || preferencesLoading || isAutoConfiguring) {
return (
<OnboardLoading
title={isAutoConfiguring ? "Setting up your AI assistant..." : t("loading_config")}
subtitle={
isAutoConfiguring
? "Auto-configuring optimal settings for you"
: "Please wait while we load your configuration"
}
/>
);
}
// Show LLM setup if no configs available OR if roles are not assigned yet
// This forces users to complete role assignment before seeing the final screen
if (allConfigs.length === 0 || !isOnboardingComplete) {
return (
<OnboardLLMSetup
searchSpaceId={searchSpaceId}
title={t("welcome_title")}
configTitle={
allConfigs.length === 0 ? t("setup_llm_configuration") : t("assign_llm_roles_title")
}
configDescription={
allConfigs.length === 0
? t("configure_providers_and_assign_roles")
: t("complete_role_assignment")
}
onConfigCreated={() => refreshConfigs()}
onConfigDeleted={() => refreshConfigs()}
onPreferencesUpdated={handleRefreshPreferences}
/>
);
}
// Main onboarding view
return (
<div className="min-h-screen bg-background">
<div className="flex items-center justify-center min-h-screen p-4 md:p-8">
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20 flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6 }}
className="w-full max-w-5xl"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center space-y-6"
>
<div className="relative">
<div className="absolute inset-0 blur-3xl bg-gradient-to-r from-violet-500/20 to-cyan-500/20 rounded-full" />
<div className="relative flex items-center justify-center w-24 h-24 mx-auto rounded-2xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-2xl shadow-violet-500/25">
<Loader2 className="h-12 w-12 text-white animate-spin" />
</div>
</div>
<div className="space-y-2">
<h2 className="text-2xl font-bold tracking-tight">
{isAutoConfiguring ? "Setting up your AI..." : "Loading..."}
</h2>
<p className="text-muted-foreground">
{isAutoConfiguring
? "Auto-configuring with available settings"
: "Please wait while we check your configuration"}
</p>
</div>
<div className="flex justify-center gap-1">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
className="w-2 h-2 rounded-full bg-violet-500"
animate={{ scale: [1, 1.5, 1], opacity: [0.5, 1, 0.5] }}
transition={{ duration: 1, repeat: Infinity, delay: i * 0.2 }}
/>
))}
</div>
</motion.div>
</div>
);
}
// If global configs exist but auto-config failed, show simple message
if (globalConfigs.length > 0 && !isAutoConfiguring) {
return null; // Will redirect via useEffect
}
// No global configs - show the config form
return (
<div className="min-h-screen bg-gradient-to-b from-background via-background to-muted/30">
<div className="container mx-auto px-4 py-8 md:py-12 max-w-3xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="space-y-8"
>
{/* Header */}
<OnboardHeader
title={t("welcome_title")}
subtitle={
isReady ? "You're all set! Choose what you'd like to do next." : t("welcome_subtitle")
}
isReady={isReady}
/>
<div className="text-center space-y-4">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", delay: 0.2 }}
className="relative inline-block"
>
<Logo className="w-20 h-20 mx-auto rounded-full" />
</motion.div>
{/* Quick Stats */}
<OnboardStats
globalConfigsCount={globalConfigs.length}
userConfigsCount={llmConfigs.length}
/>
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Configure Your AI</h1>
<p className="text-muted-foreground text-lg">
Add your LLM provider to get started with SurfSense
</p>
</div>
</div>
{/* Action Cards */}
{/* Config Form */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<OnboardActionCard
title="Start Chatting"
description="Jump right into the AI researcher and start asking questions"
icon={MessageSquare}
features={[
"AI-powered conversations",
"Research and explore topics",
"Get instant insights",
]}
buttonText="Start Chatting"
onClick={() => router.push(`/dashboard/${searchSpaceId}/researcher`)}
colorScheme="violet"
delay={0.9}
/>
<OnboardActionCard
title="Add Sources"
description="Connect your data sources to start building your knowledge base"
icon={FileText}
features={[
"Connect documents and files",
"Import from various sources",
"Build your knowledge base",
]}
buttonText="Add Sources"
onClick={() => router.push(`/dashboard/${searchSpaceId}/sources/add`)}
colorScheme="blue"
delay={0.8}
/>
<OnboardActionCard
title="Manage Team"
description="Invite team members and collaborate on your search space"
icon={Users}
features={[
"Invite team members",
"Assign roles & permissions",
"Collaborate together",
]}
buttonText="Manage Team"
onClick={() => router.push(`/dashboard/${searchSpaceId}/team`)}
colorScheme="emerald"
delay={0.7}
/>
<Card className="border-2 border-muted shadow-xl overflow-hidden">
<CardHeader className="pb-4">
<CardTitle className="text-xl">LLM Configuration</CardTitle>
</CardHeader>
<CardContent>
<LLMConfigForm
searchSpaceId={searchSpaceId}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
mode="create"
showAdvanced={true}
submitLabel="Start Using SurfSense"
initialData={{
citations_enabled: true,
use_default_system_instructions: true,
}}
/>
</CardContent>
</Card>
</motion.div>
{/* Advanced Settings */}
<OnboardAdvancedSettings
searchSpaceId={searchSpaceId}
showLLMSettings={showAdvancedSettings}
setShowLLMSettings={setShowAdvancedSettings}
showPromptSettings={showPromptSettings}
setShowPromptSettings={setShowPromptSettings}
onConfigCreated={() => refreshConfigs()}
onConfigDeleted={() => refreshConfigs()}
onPreferencesUpdated={handleRefreshPreferences}
/>
{/* Footer */}
<motion.div
{/* Footer note */}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.1 }}
className="text-center mt-10 text-muted-foreground text-sm"
transition={{ delay: 0.5 }}
className="text-center text-sm text-muted-foreground"
>
<p>
You can always adjust these settings later in{" "}
<button
type="button"
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings`)}
className="text-primary hover:underline underline-offset-2 transition-colors"
>
Settings
</button>
</p>
</motion.div>
You can add more configurations and customize settings anytime in{" "}
<button
type="button"
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings`)}
className="text-violet-500 hover:underline"
>
Settings
</button>
</motion.p>
</motion.div>
</div>
</div>
);
};
export default OnboardPage;
}

View file

@ -1,24 +0,0 @@
import { Suspense } from "react";
import PodcastsPageClient from "./podcasts-client";
interface PageProps {
params: {
search_space_id: string;
};
}
export default async function PodcastsPage({ params }: PageProps) {
const { search_space_id: searchSpaceId } = await Promise.resolve(params);
return (
<Suspense
fallback={
<div className="flex items-center justify-center h-[60vh]">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
</div>
}
>
<PodcastsPageClient searchSpaceId={searchSpaceId} />
</Suspense>
);
}

View file

@ -1,957 +0,0 @@
"use client";
import { format } from "date-fns";
import { useAtom, useAtomValue } from "jotai";
import {
Calendar,
MoreHorizontal,
Pause,
Play,
Podcast as PodcastIcon,
Search,
SkipBack,
SkipForward,
Trash2,
Volume2,
VolumeX,
X,
} from "lucide-react";
import { AnimatePresence, motion, type Variants } from "motion/react";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { deletePodcastMutationAtom } from "@/atoms/podcasts/podcast-mutation.atoms";
import { podcastsAtom } from "@/atoms/podcasts/podcast-query.atoms";
// UI Components
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import type { Podcast } from "@/contracts/types/podcast.types";
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
interface PodcastsPageClientProps {
searchSpaceId: string;
}
const pageVariants: Variants = {
initial: { opacity: 0 },
enter: {
opacity: 1,
transition: { duration: 0.4, ease: "easeInOut", staggerChildren: 0.1 },
},
exit: { opacity: 0, transition: { duration: 0.3, ease: "easeInOut" } },
};
const podcastCardVariants: Variants = {
initial: { scale: 0.95, y: 20, opacity: 0 },
animate: {
scale: 1,
y: 0,
opacity: 1,
transition: { type: "spring", stiffness: 300, damping: 25 },
},
exit: { scale: 0.95, y: -20, opacity: 0 },
hover: { y: -5, scale: 1.02, transition: { duration: 0.2 } },
};
const MotionCard = motion(Card);
export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClientProps) {
const [filteredPodcasts, setFilteredPodcasts] = useState<Podcast[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [sortOrder, setSortOrder] = useState<string>("newest");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [podcastToDelete, setPodcastToDelete] = useState<{
id: number;
title: string;
} | null>(null);
// Audio player state
const [currentPodcast, setCurrentPodcast] = useState<Podcast | null>(null);
const [audioSrc, setAudioSrc] = useState<string | undefined>(undefined);
const [isAudioLoading, setIsAudioLoading] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(0.7);
const [isMuted, setIsMuted] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const currentObjectUrlRef = useRef<string | null>(null);
const [{ isPending: isDeletingPodcast, mutateAsync: deletePodcast, error: deleteError }] =
useAtom(deletePodcastMutationAtom);
const {
data: podcasts,
isLoading: isFetchingPodcasts,
error: fetchError,
} = useAtomValue(podcastsAtom);
// Add podcast image URL constant
const PODCAST_IMAGE_URL =
"https://static.vecteezy.com/system/resources/thumbnails/002/157/611/small_2x/illustrations-concept-design-podcast-channel-free-vector.jpg";
useEffect(() => {
if (isFetchingPodcasts) return;
if (fetchError) {
console.error("Error fetching podcasts:", fetchError);
setFilteredPodcasts([]);
return;
}
if (!podcasts) {
setFilteredPodcasts([]);
return;
}
setFilteredPodcasts(podcasts);
}, []);
// Filter and sort podcasts based on search query and sort order
useEffect(() => {
if (!podcasts) return;
let result = [...podcasts];
// Filter by search term
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter((podcast) => podcast.title.toLowerCase().includes(query));
}
// Filter by search space
result = result.filter((podcast) => podcast.search_space_id === parseInt(searchSpaceId));
// Sort podcasts
result.sort((a, b) => {
const dateA = new Date(a.created_at).getTime();
const dateB = new Date(b.created_at).getTime();
return sortOrder === "newest" ? dateB - dateA : dateA - dateB;
});
setFilteredPodcasts(result);
}, [podcasts, searchQuery, sortOrder, searchSpaceId]);
// Cleanup object URL on unmount or when currentPodcast changes
useEffect(() => {
return () => {
if (currentObjectUrlRef.current) {
URL.revokeObjectURL(currentObjectUrlRef.current);
currentObjectUrlRef.current = null;
}
};
}, []);
// Audio player time update handler
const handleTimeUpdate = () => {
if (audioRef.current) {
setCurrentTime(audioRef.current.currentTime);
}
};
// Audio player metadata loaded handler
const handleMetadataLoaded = () => {
if (audioRef.current) {
setDuration(audioRef.current.duration);
}
};
// Play/pause toggle
const togglePlayPause = () => {
if (audioRef.current) {
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
// To close player
const closePlayer = () => {
if (isPlaying) {
audioRef.current?.pause();
}
setIsPlaying(false);
setAudioSrc(undefined);
setCurrentTime(0);
setCurrentPodcast(null);
};
// Seek to position
const handleSeek = (value: number[]) => {
if (audioRef.current) {
audioRef.current.currentTime = value[0];
setCurrentTime(value[0]);
}
};
// Volume change
const handleVolumeChange = (value: number[]) => {
if (audioRef.current) {
const newVolume = value[0];
// Set volume
audioRef.current.volume = newVolume;
setVolume(newVolume);
// Handle mute state based on volume
if (newVolume === 0) {
audioRef.current.muted = true;
setIsMuted(true);
} else {
audioRef.current.muted = false;
setIsMuted(false);
}
}
};
// Toggle mute
const toggleMute = () => {
if (audioRef.current) {
const newMutedState = !isMuted;
audioRef.current.muted = newMutedState;
setIsMuted(newMutedState);
// If unmuting, restore previous volume if it was 0
if (!newMutedState && volume === 0) {
const restoredVolume = 0.5;
audioRef.current.volume = restoredVolume;
setVolume(restoredVolume);
}
}
};
// Skip forward 10 seconds
const skipForward = () => {
if (audioRef.current) {
audioRef.current.currentTime = Math.min(
audioRef.current.duration,
audioRef.current.currentTime + 10
);
}
};
// Skip backward 10 seconds
const skipBackward = () => {
if (audioRef.current) {
audioRef.current.currentTime = Math.max(0, audioRef.current.currentTime - 10);
}
};
// Format time in MM:SS
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
};
// Play podcast - Fetch blob and set object URL
const playPodcast = async (podcast: Podcast) => {
// If the same podcast is selected, just toggle play/pause
if (currentPodcast && currentPodcast.id === podcast.id) {
togglePlayPause();
return;
}
// Prevent multiple simultaneous loading requests
if (isAudioLoading) {
return;
}
try {
// Reset player state and show loading
setCurrentPodcast(podcast);
setAudioSrc(undefined);
setCurrentTime(0);
setDuration(0);
setIsPlaying(false);
setIsAudioLoading(true);
// Revoke previous object URL if exists (only after we've started the new request)
if (currentObjectUrlRef.current) {
URL.revokeObjectURL(currentObjectUrlRef.current);
currentObjectUrlRef.current = null;
}
// Use AbortController to handle timeout or cancellation
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
try {
const response = await podcastsApiService.loadPodcast({
request: { id: podcast.id },
controller,
});
const objectUrl = URL.createObjectURL(response);
currentObjectUrlRef.current = objectUrl;
// Set audio source
setAudioSrc(objectUrl);
// Wait for the audio to be ready before playing
// We'll handle actual playback in the onLoadedData event instead of here
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw new Error("Request timed out. Please try again.");
}
throw error;
} finally {
clearTimeout(timeoutId);
}
} catch (error) {
console.error("Error fetching or playing podcast:", error);
toast.error(error instanceof Error ? error.message : "Failed to load podcast audio.");
// Reset state on error
setCurrentPodcast(null);
setAudioSrc(undefined);
} finally {
setIsAudioLoading(false);
}
};
// Function to handle podcast deletion
const handleDeletePodcast = async () => {
if (!podcastToDelete) return;
try {
await deletePodcast({ id: podcastToDelete.id });
// Close dialog
setDeleteDialogOpen(false);
setPodcastToDelete(null);
// If the current playing podcast is deleted, stop playback
if (currentPodcast && currentPodcast.id === podcastToDelete.id) {
if (audioRef.current) {
audioRef.current.pause();
}
setCurrentPodcast(null);
setIsPlaying(false);
}
} catch (error) {
console.error("Error deleting podcast:", error);
toast.error(error instanceof Error ? error.message : "Failed to delete podcast");
}
};
return (
<motion.div
className="container p-6 mx-auto"
initial="initial"
animate="enter"
exit="exit"
variants={pageVariants}
>
<div className="flex flex-col space-y-4 md:space-y-6">
<div className="flex flex-col space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Podcasts</h1>
<p className="text-muted-foreground">Listen to generated podcasts.</p>
</div>
{/* Filter and Search Bar */}
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:justify-between md:space-y-0">
<div className="flex flex-1 items-center gap-2">
<div className="relative w-full md:w-80">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search podcasts..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div>
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Sort order" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="newest">Newest First</SelectItem>
<SelectItem value="oldest">Oldest First</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
{/* Status Messages */}
{isFetchingPodcasts && (
<div className="flex items-center justify-center h-40">
<div className="flex flex-col items-center gap-2">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
<p className="text-sm text-muted-foreground">Loading podcasts...</p>
</div>
</div>
)}
{fetchError && !isFetchingPodcasts && (
<div className="border border-destructive/50 text-destructive p-4 rounded-md">
<h3 className="font-medium">Error loading podcasts</h3>
<p className="text-sm">{fetchError.message ?? "Failed to load podcasts"}</p>
</div>
)}
{!isFetchingPodcasts && !fetchError && filteredPodcasts.length === 0 && (
<div className="flex flex-col items-center justify-center h-40 gap-2 text-center">
<PodcastIcon className="h-8 w-8 text-muted-foreground" />
<h3 className="font-medium">No podcasts found</h3>
<p className="text-sm text-muted-foreground">
{searchQuery
? "Try adjusting your search filters"
: "Generate podcasts from your chats to get started"}
</p>
</div>
)}
{/* Podcast Grid */}
{!isFetchingPodcasts && !fetchError && filteredPodcasts.length > 0 && (
<AnimatePresence mode="wait">
<motion.div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
variants={pageVariants}
initial="initial"
animate="enter"
exit="exit"
>
{filteredPodcasts.map((podcast, index) => (
<MotionCard
key={podcast.id}
variants={podcastCardVariants}
initial="initial"
animate="animate"
exit="exit"
whileHover="hover"
transition={{ duration: 0.2, delay: index * 0.05 }}
className={`
bg-card/60 dark:bg-card/40 backdrop-blur-lg rounded-xl p-4
shadow-md hover:shadow-xl transition-all duration-300
border-border overflow-hidden cursor-pointer
${currentPodcast?.id === podcast.id ? "ring-2 ring-primary ring-offset-2 ring-offset-background" : ""}
`}
layout
onClick={() => playPodcast(podcast)}
>
<div className="relative w-full aspect-[16/10] mb-4 rounded-lg overflow-hidden">
{/* Podcast image with gradient overlay */}
<Image
src={PODCAST_IMAGE_URL}
alt="Podcast illustration"
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105 brightness-[0.85] contrast-[1.1]"
loading="lazy"
width={100}
height={100}
/>
{/* Better overlay with gradient for improved text legibility */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-black/10 transition-opacity duration-300"></div>
{/* Loading indicator with improved animation */}
{currentPodcast?.id === podcast.id && isAudioLoading && (
<motion.div
className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-md z-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<motion.div
className="flex flex-col items-center gap-3"
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
transition={{ type: "spring", damping: 20 }}
>
<div className="h-14 w-14 rounded-full border-4 border-primary/30 border-t-primary animate-spin"></div>
<p className="text-sm text-foreground font-medium">Loading podcast...</p>
</motion.div>
</motion.div>
)}
{/* Play button with animations */}
{!(currentPodcast?.id === podcast.id && (isPlaying || isAudioLoading)) && (
<motion.div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<Button
variant="secondary"
size="icon"
className="h-16 w-16 rounded-full
bg-background/80 hover:bg-background/95 backdrop-blur-md
transition-all duration-200 shadow-xl border-0
flex items-center justify-center"
onClick={(e) => {
e.stopPropagation();
playPodcast(podcast);
}}
disabled={isAudioLoading}
>
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{
type: "spring",
stiffness: 400,
damping: 10,
}}
className="text-primary w-10 h-10 flex items-center justify-center"
>
<Play className="h-8 w-8 ml-1" />
</motion.div>
</Button>
</motion.div>
)}
{/* Pause button with animations */}
{currentPodcast?.id === podcast.id && isPlaying && !isAudioLoading && (
<motion.div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<Button
variant="secondary"
size="icon"
className="h-16 w-16 rounded-full
bg-background/80 hover:bg-background/95 backdrop-blur-md
transition-all duration-200 shadow-xl border-0
flex items-center justify-center"
onClick={(e) => {
e.stopPropagation();
togglePlayPause();
}}
disabled={isAudioLoading}
>
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{
type: "spring",
stiffness: 400,
damping: 10,
}}
className="text-primary w-10 h-10 flex items-center justify-center"
>
<Pause className="h-8 w-8" />
</motion.div>
</Button>
</motion.div>
)}
{/* Now playing indicator */}
{currentPodcast?.id === podcast.id && !isAudioLoading && (
<div className="absolute top-2 left-2 bg-primary text-primary-foreground text-xs px-2 py-1 rounded-full z-10 flex items-center gap-1.5">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary-foreground opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary-foreground"></span>
</span>
Now Playing
</div>
)}
</div>
<div className="mb-3 px-1">
<h3
className="text-base font-semibold text-foreground truncate"
title={podcast.title}
>
{podcast.title || "Untitled Podcast"}
</h3>
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1.5">
<Calendar className="h-3 w-3" />
{format(new Date(podcast.created_at), "MMM d, yyyy")}
</p>
</div>
{currentPodcast?.id === podcast.id && !isAudioLoading && (
<motion.div
className="mb-3 px-1"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Button
variant="ghost"
className="h-1.5 bg-muted rounded-full cursor-pointer group relative overflow-hidden"
onClick={(e) => {
e.stopPropagation();
if (!audioRef.current || !duration) return;
const container = e.currentTarget;
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width));
const newTime = percentage * duration;
handleSeek([newTime]);
}}
>
<motion.div
className="h-full bg-primary rounded-full relative"
style={{
width: `${(currentTime / duration) * 100}%`,
}}
transition={{ ease: "linear" }}
>
<motion.div
className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3
bg-primary rounded-full shadow-md transform scale-0
group-hover:scale-100 transition-transform"
whileHover={{ scale: 1.5 }}
/>
</motion.div>
</Button>
<div className="flex justify-between mt-1.5 text-xs text-muted-foreground">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</motion.div>
)}
{currentPodcast?.id === podcast.id && !isAudioLoading && (
<motion.div
className="flex items-center justify-between px-2 mt-1"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
skipBackward();
}}
className="w-9 h-9 text-muted-foreground hover:text-primary transition-colors"
title="Rewind 10 seconds"
disabled={!duration}
>
<SkipBack className="w-5 h-5" />
</Button>
</motion.div>
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
togglePlayPause();
}}
className="w-10 h-10 text-primary hover:bg-primary/10 rounded-full transition-colors"
disabled={!duration}
>
{isPlaying ? (
<Pause className="w-6 h-6" />
) : (
<Play className="w-6 h-6 ml-0.5" />
)}
</Button>
</motion.div>
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
skipForward();
}}
className="w-9 h-9 text-muted-foreground hover:text-primary transition-colors"
title="Forward 10 seconds"
disabled={!duration}
>
<SkipForward className="w-5 h-5" />
</Button>
</motion.div>
</motion.div>
)}
<div className="absolute top-2 right-2 z-20">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 bg-background/50 hover:bg-background/80 rounded-full backdrop-blur-sm"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
setPodcastToDelete({
id: podcast.id,
title: podcast.title,
});
setDeleteDialogOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete Podcast</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</MotionCard>
))}
</motion.div>
</AnimatePresence>
)}
{/* Current Podcast Player (Fixed at bottom) */}
{currentPodcast && !isAudioLoading && audioSrc && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur-sm border-t p-4 shadow-lg z-50"
>
<div className="container mx-auto">
<div className="flex flex-col md:flex-row items-center gap-4">
<div className="flex-shrink-0">
<motion.div
className="w-12 h-12 bg-primary/20 rounded-md flex items-center justify-center"
animate={{ scale: isPlaying ? [1, 1.05, 1] : 1 }}
transition={{
repeat: isPlaying ? Infinity : 0,
duration: 2,
}}
>
<PodcastIcon className="h-6 w-6 text-primary" />
</motion.div>
</div>
<div className="flex-grow min-w-0">
<h4 className="font-medium text-sm line-clamp-1">{currentPodcast.title}</h4>
<div className="flex items-center gap-2 mt-2">
<div className="flex-grow relative">
<Slider
value={[currentTime]}
min={0}
max={duration || 100}
step={0.1}
onValueChange={handleSeek}
className="relative z-10"
/>
<motion.div
className="absolute left-0 top-1/2 h-2 bg-primary/25 rounded-full -translate-y-1/2"
style={{
width: `${(currentTime / (duration || 100)) * 100}%`,
}}
transition={{ ease: "linear" }}
/>
</div>
<div className="flex-shrink-0 text-xs text-muted-foreground whitespace-nowrap">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
<Button variant="ghost" size="icon" onClick={skipBackward} className="h-8 w-8">
<SkipBack className="h-4 w-4" />
</Button>
</motion.div>
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
<Button
variant="default"
size="icon"
onClick={togglePlayPause}
className="h-10 w-10 rounded-full"
>
{isPlaying ? (
<Pause className="h-5 w-5" />
) : (
<Play className="h-5 w-5 ml-0.5" />
)}
</Button>
</motion.div>
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
<Button variant="ghost" size="icon" onClick={skipForward} className="h-8 w-8">
<SkipForward className="h-4 w-4" />
</Button>
</motion.div>
<div className="hidden md:flex items-center gap-2 ml-4 w-32">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
<Button
variant="ghost"
size="icon"
onClick={toggleMute}
className={`h-8 w-8 ${isMuted ? "text-muted-foreground" : "text-primary"}`}
>
{isMuted ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Button>
</motion.div>
<div className="relative w-full">
<Slider
value={[isMuted ? 0 : volume]}
min={0}
max={1}
step={0.01}
onValueChange={handleVolumeChange}
className="w-full"
disabled={isMuted}
/>
<motion.div
className={`absolute left-0 bottom-0 h-1 bg-primary/30 rounded-full ${isMuted ? "opacity-50" : ""}`}
initial={false}
animate={{ width: `${(isMuted ? 0 : volume) * 100}%` }}
/>
</div>
</div>
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
<Button
variant="default"
size="icon"
onClick={closePlayer}
className="h-10 w-10 rounded-full"
>
<X />
</Button>
</motion.div>
</div>
</div>
</div>
</motion.div>
)}
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
<span>Delete Podcast</span>
</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-medium">{podcastToDelete?.title}</span>? This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
disabled={isDeletingPodcast}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeletePodcast}
disabled={isDeletingPodcast}
className="gap-2"
>
{isDeletingPodcast ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Deleting...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Delete
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Hidden audio element for playback */}
<audio
ref={audioRef}
src={audioSrc}
preload="auto"
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleMetadataLoaded}
onLoadedData={() => {
// Only auto-play when audio is fully loaded
if (audioRef.current && currentPodcast && audioSrc) {
// Small delay to ensure browser is ready to play
setTimeout(() => {
if (audioRef.current) {
audioRef.current
.play()
.then(() => {
setIsPlaying(true);
})
.catch((error) => {
console.error("Error playing audio:", error);
// Don't show error if it's just the user navigating away
if (error.name !== "AbortError") {
toast.error("Failed to play audio.");
}
setIsPlaying(false);
});
}
}, 100);
}
}}
onEnded={() => setIsPlaying(false)}
onError={(e) => {
console.error("Audio error:", e);
if (audioRef.current?.error) {
// Log the specific error code for debugging
console.error("Audio error code:", audioRef.current.error.code);
// Don't show error message for aborted loads
if (audioRef.current.error.code !== audioRef.current.error.MEDIA_ERR_ABORTED) {
toast.error("Error playing audio. Please try again.");
}
}
// Reset playing state on error
setIsPlaying(false);
}}
>
<track kind="captions" />
</audio>
</motion.div>
);
}

View file

@ -13,11 +13,12 @@ import {
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
import { ModelConfigManager } from "@/components/settings/model-config-manager";
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
import { Button } from "@/components/ui/button";
import { trackSettingsViewed } from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
interface SettingsNavItem {
@ -30,20 +31,20 @@ interface SettingsNavItem {
const settingsNavItems: SettingsNavItem[] = [
{
id: "models",
label: "Model Configs",
description: "Configure AI models and providers",
label: "Agent Configs",
description: "LLM models with prompts & citations",
icon: Bot,
},
{
id: "roles",
label: "LLM Roles",
description: "Manage language model roles",
label: "Role Assignments",
description: "Assign configs to agent roles",
icon: Brain,
},
{
id: "prompts",
label: "System Instructions",
description: "Customize system prompts",
description: "SearchSpace-wide AI instructions",
icon: MessageSquare,
},
];
@ -236,9 +237,6 @@ function SettingsContent({
<h1 className="text-xl md:text-2xl font-bold tracking-tight truncate">
{activeItem?.label}
</h1>
<p className="text-sm text-muted-foreground mt-0.5 truncate">
{activeItem?.description}
</p>
</div>
</div>
</motion.div>
@ -274,8 +272,13 @@ export default function SettingsPage() {
const [activeSection, setActiveSection] = useState("models");
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
// Track settings section view
useEffect(() => {
trackSettingsViewed(searchSpaceId, activeSection);
}, [searchSpaceId, activeSection]);
const handleBackToApp = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/researcher`);
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}, [router, searchSpaceId]);
return (

View file

@ -9,6 +9,7 @@ import { ConnectorsTab } from "@/components/sources/ConnectorsTab";
import { DocumentUploadTab } from "@/components/sources/DocumentUploadTab";
import { YouTubeTab } from "@/components/sources/YouTubeTab";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { trackSourcesTabViewed } from "@/lib/posthog/events";
export default function AddSourcesPage() {
const params = useParams();
@ -30,11 +31,18 @@ export default function AddSourcesPage() {
router.push(`/dashboard/${search_space_id}/connectors/add/webcrawler-connector`);
} else {
setActiveTab(value);
// Track tab view
trackSourcesTabViewed(Number(search_space_id), value);
}
};
// Track initial tab view
useEffect(() => {
trackSourcesTabViewed(Number(search_space_id), activeTab);
}, []);
return (
<div className="container mx-auto py-8 px-4">
<div className="container mx-auto py-8 px-4 min-h-[calc(100vh-64px)]">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}

View file

@ -46,6 +46,15 @@ import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import {
createInviteMutationAtom,
deleteInviteMutationAtom,
} from "@/atoms/invites/invites-mutation.atoms";
import {
deleteMemberMutationAtom,
updateMemberMutationAtom,
} from "@/atoms/members/members-mutation.atoms";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms";
import {
createRoleMutationAtom,
@ -107,20 +116,23 @@ import {
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import type {
CreateInviteRequest,
DeleteInviteRequest,
Invite,
} from "@/contracts/types/invites.types";
import type {
DeleteMembershipRequest,
Membership,
UpdateMembershipRequest,
} from "@/contracts/types/members.types";
import type {
CreateRoleRequest,
DeleteRoleRequest,
Role,
UpdateRoleRequest,
} from "@/contracts/types/roles.types";
import {
type Invite,
type InviteCreate,
type Member,
useInvites,
useMembers,
useUserAccess,
} from "@/hooks/use-rbac";
import { invitesApiService } from "@/lib/apis/invites-api.service";
import { rolesApiService } from "@/lib/apis/roles-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
@ -154,18 +166,54 @@ export default function TeamManagementPage() {
const searchSpaceId = Number(params.search_space_id);
const [activeTab, setActiveTab] = useState("members");
const { access, loading: accessLoading, hasPermission } = useUserAccess(searchSpaceId);
const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom);
const hasPermission = useCallback(
(permission: string) => {
if (!access) return false;
if (access.is_owner) return true;
return access.permissions?.includes(permission) ?? false;
},
[access]
);
const {
members,
loading: membersLoading,
fetchMembers,
updateMemberRole,
removeMember,
} = useMembers(searchSpaceId);
data: members = [],
isLoading: membersLoading,
refetch: fetchMembers,
} = useAtomValue(membersAtom);
const { mutateAsync: createRole } = useAtomValue(createRoleMutationAtom);
const { mutateAsync: updateRole } = useAtomValue(updateRoleMutationAtom);
const { mutateAsync: deleteRole } = useAtomValue(deleteRoleMutationAtom);
const { mutateAsync: updateMember } = useAtomValue(updateMemberMutationAtom);
const { mutateAsync: deleteMember } = useAtomValue(deleteMemberMutationAtom);
const { mutateAsync: createInvite } = useAtomValue(createInviteMutationAtom);
const { mutateAsync: revokeInvite } = useAtomValue(deleteInviteMutationAtom);
const handleRevokeInvite = useCallback(
async (inviteId: number): Promise<boolean> => {
const request: DeleteInviteRequest = {
search_space_id: searchSpaceId,
invite_id: inviteId,
};
await revokeInvite(request);
return true;
},
[revokeInvite, searchSpaceId]
);
const handleCreateInvite = useCallback(
async (inviteData: CreateInviteRequest["data"]) => {
const request: CreateInviteRequest = {
search_space_id: searchSpaceId,
data: inviteData,
};
return await createInvite(request);
},
[createInvite, searchSpaceId]
);
const handleUpdateRole = useCallback(
async (roleId: number, data: { permissions?: string[] }): Promise<Role> => {
@ -202,6 +250,32 @@ export default function TeamManagementPage() {
[createRole, searchSpaceId]
);
const handleUpdateMember = useCallback(
async (membershipId: number, roleId: number | null): Promise<Membership> => {
const request: UpdateMembershipRequest = {
search_space_id: searchSpaceId,
membership_id: membershipId,
data: {
role_id: roleId,
},
};
return (await updateMember(request)) as Membership;
},
[updateMember, searchSpaceId]
);
const handleRemoveMember = useCallback(
async (membershipId: number) => {
const request: DeleteMembershipRequest = {
search_space_id: searchSpaceId,
membership_id: membershipId,
};
await deleteMember(request);
return true;
},
[deleteMember, searchSpaceId]
);
const {
data: roles = [],
isLoading: rolesLoading,
@ -212,12 +286,14 @@ export default function TeamManagementPage() {
enabled: !!searchSpaceId,
});
const {
invites,
loading: invitesLoading,
fetchInvites,
createInvite,
revokeInvite,
} = useInvites(searchSpaceId);
data: invites = [],
isLoading: invitesLoading,
refetch: fetchInvites,
} = useQuery({
queryKey: cacheKeys.invites.all(searchSpaceId.toString()),
queryFn: () => invitesApiService.getInvites({ search_space_id: searchSpaceId }),
staleTime: 5 * 60 * 1000,
});
const { data: permissionsData, isLoading: permissionsLoading } = useAtomValue(permissionsAtom);
const permissions = permissionsData?.permissions || [];
@ -387,7 +463,7 @@ export default function TeamManagementPage() {
{activeTab === "invites" && canInvite && (
<CreateInviteDialog
roles={roles}
onCreateInvite={createInvite}
onCreateInvite={handleCreateInvite}
searchSpaceId={searchSpaceId}
/>
)}
@ -404,8 +480,8 @@ export default function TeamManagementPage() {
members={members}
roles={roles}
loading={membersLoading}
onUpdateRole={updateMemberRole}
onRemoveMember={removeMember}
onUpdateRole={handleUpdateMember}
onRemoveMember={handleRemoveMember}
canManageRoles={hasPermission("members:manage_roles")}
canRemove={hasPermission("members:remove")}
/>
@ -427,7 +503,7 @@ export default function TeamManagementPage() {
<InvitesTab
invites={invites}
loading={invitesLoading}
onRevokeInvite={revokeInvite}
onRevokeInvite={handleRevokeInvite}
canRevoke={canInvite}
/>
</TabsContent>
@ -449,10 +525,10 @@ function MembersTab({
canManageRoles,
canRemove,
}: {
members: Member[];
members: Membership[];
roles: Role[];
loading: boolean;
onUpdateRole: (membershipId: number, roleId: number | null) => Promise<Member>;
onUpdateRole: (membershipId: number, roleId: number | null) => Promise<Membership>;
onRemoveMember: (membershipId: number) => Promise<boolean>;
canManageRoles: boolean;
canRemove: boolean;
@ -731,7 +807,6 @@ function RolesTab({
<DropdownMenuItem
onClick={() => {
// TODO: Implement edit role dialog/modal
console.log("Edit role not yet implemented", role);
}}
>
<Edit2 className="h-4 w-4 mr-2" />
@ -1016,7 +1091,7 @@ function CreateInviteDialog({
searchSpaceId,
}: {
roles: Role[];
onCreateInvite: (data: InviteCreate) => Promise<Invite>;
onCreateInvite: (data: CreateInviteRequest["data"]) => Promise<Invite>;
searchSpaceId: number;
}) {
const [open, setOpen] = useState(false);
@ -1031,7 +1106,7 @@ function CreateInviteDialog({
const handleCreate = async () => {
setCreating(true);
try {
const data: InviteCreate = {};
const data: CreateInviteRequest["data"] = {};
if (name) data.name = name;
if (roleId && roleId !== "default") data.role_id = Number(roleId);
if (maxUses) data.max_uses = Number(maxUses);

View file

@ -244,7 +244,7 @@ const DashboardPage = () => {
/>
<div className="flex flex-col h-full justify-between overflow-hidden rounded-xl border bg-muted/30 backdrop-blur-sm transition-all hover:border-primary/50">
<div className="relative h-32 w-full overflow-hidden">
<Link href={`/dashboard/${space.id}/researcher`} key={space.id}>
<Link href={`/dashboard/${space.id}/new-chat`} key={space.id}>
<Image
src="https://images.unsplash.com/photo-1519389950473-47ba0277781c?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1740&q=80"
alt={space.name}
@ -289,7 +289,7 @@ const DashboardPage = () => {
</div>
<Link
className="flex flex-1 flex-col p-4 cursor-pointer"
href={`/dashboard/${space.id}/researcher`}
href={`/dashboard/${space.id}/new-chat`}
key={space.id}
>
<div className="flex flex-1 flex-col justify-between p-1">

View file

@ -5,6 +5,7 @@ import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { SearchSpaceForm } from "@/components/search-space-form";
import { trackSearchSpaceCreated } from "@/lib/posthog/events";
export default function SearchSpacesPage() {
const router = useRouter();
@ -16,6 +17,9 @@ export default function SearchSpacesPage() {
description: data.description || "",
});
// Track search space creation
trackSearchSpaceCreated(result.id, data.name);
// Redirect to the newly created search space's onboarding
router.push(`/dashboard/${result.id}/onboard`);

View file

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

View file

@ -1,5 +1,7 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import {
AlertCircle,
ArrowRight,
@ -16,7 +18,9 @@ import { motion } from "motion/react";
import Image from "next/image";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { use, useEffect, useState } from "react";
import { use, useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { acceptInviteMutationAtom } from "@/atoms/invites/invites-mutation.atoms";
import { Button } from "@/components/ui/button";
import {
Card,
@ -26,22 +30,48 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useInviteInfo } from "@/hooks/use-rbac";
import type { AcceptInviteResponse } from "@/contracts/types/invites.types";
import { invitesApiService } from "@/lib/apis/invites-api.service";
import { getBearerToken } from "@/lib/auth-utils";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export default function InviteAcceptPage() {
const params = useParams();
const router = useRouter();
const inviteCode = params.invite_code as string;
const { inviteInfo, loading, acceptInvite } = useInviteInfo(inviteCode);
const { data: inviteInfo = null, isLoading: loading } = useQuery({
queryKey: cacheKeys.invites.info(inviteCode),
enabled: !!inviteCode,
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!inviteCode) return null;
return invitesApiService.getInviteInfo({
invite_code: inviteCode,
});
},
});
const { mutateAsync: acceptInviteMutation } = useAtomValue(acceptInviteMutationAtom);
const acceptInvite = useCallback(async () => {
if (!inviteCode) {
toast.error("No invite code provided");
return null;
}
try {
const result = await acceptInviteMutation({ invite_code: inviteCode });
return result;
} catch (err: any) {
toast.error(err.message || "Failed to accept invite");
throw err;
}
}, [inviteCode, acceptInviteMutation]);
const [accepting, setAccepting] = useState(false);
const [accepted, setAccepted] = useState(false);
const [acceptedData, setAcceptedData] = useState<{
search_space_id: number;
search_space_name: string;
role_name: string;
} | null>(null);
const [acceptedData, setAcceptedData] = useState<AcceptInviteResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null);

View file

@ -1,9 +1,10 @@
import type { Metadata } from "next";
import "./globals.css";
import { GoogleAnalytics } from "@next/third-parties/google";
import { RootProvider } from "fumadocs-ui/provider";
import { RootProvider } from "fumadocs-ui/provider/next";
import { Roboto } from "next/font/google";
import { I18nProvider } from "@/components/providers/I18nProvider";
import { PostHogProvider } from "@/components/providers/PostHogProvider";
import { ThemeProvider } from "@/components/theme/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { LocaleProvider } from "@/contexts/LocaleContext";
@ -93,21 +94,23 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning>
<GoogleAnalytics gaId="G-T4CHE7W3TE" />
<body className={cn(roboto.className, "bg-white dark:bg-black antialiased h-full w-full ")}>
<LocaleProvider>
<I18nProvider>
<ThemeProvider
attribute="class"
enableSystem
disableTransitionOnChange
defaultTheme="light"
>
<RootProvider>
<ReactQueryClientProvider>{children}</ReactQueryClientProvider>
<Toaster />
</RootProvider>
</ThemeProvider>
</I18nProvider>
</LocaleProvider>
<PostHogProvider>
<LocaleProvider>
<I18nProvider>
<ThemeProvider
attribute="class"
enableSystem
disableTransitionOnChange
defaultTheme="light"
>
<RootProvider>
<ReactQueryClientProvider>{children}</ReactQueryClientProvider>
<Toaster />
</RootProvider>
</ThemeProvider>
</I18nProvider>
</LocaleProvider>
</PostHogProvider>
</body>
</html>
);

View file

@ -0,0 +1,31 @@
"use client";
import { atom } from "jotai";
import type { Document } from "@/contracts/types/document.types";
/**
* Atom to store the IDs of documents mentioned in the current chat composer.
* This is used to pass document context to the backend when sending a message.
*/
export const mentionedDocumentIdsAtom = atom<number[]>([]);
/**
* Atom to store the full document objects mentioned in the current chat composer.
* This persists across component remounts.
*/
export const mentionedDocumentsAtom = atom<Document[]>([]);
/**
* Simplified document info for display purposes
*/
export interface MentionedDocumentInfo {
id: number;
title: string;
document_type: string;
}
/**
* Atom to store mentioned documents per message ID.
* This allows displaying which documents were mentioned with each user message.
*/
export const messageDocumentsMapAtom = atom<Record<string, MentionedDocumentInfo[]>>({});

View file

@ -1,78 +0,0 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type { Chat } from "@/app/dashboard/[search_space_id]/chats/chats-client";
import type {
CreateChatRequest,
DeleteChatRequest,
UpdateChatRequest,
} from "@/contracts/types/chat.types";
import { chatsApiService } from "@/lib/apis/chats-api.service";
import { getBearerToken } from "@/lib/auth-utils";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
import { globalChatsQueryParamsAtom } from "./ui.atoms";
export const deleteChatMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
const authToken = getBearerToken();
const chatsQueryParams = get(globalChatsQueryParamsAtom);
return {
mutationKey: cacheKeys.chats.globalQueryParams(chatsQueryParams),
enabled: !!searchSpaceId && !!authToken,
mutationFn: async (request: DeleteChatRequest) => {
return chatsApiService.deleteChat(request);
},
onSuccess: (_, request: DeleteChatRequest) => {
toast.success("Chat deleted successfully");
queryClient.setQueryData(
cacheKeys.chats.globalQueryParams(chatsQueryParams),
(oldData: Chat[]) => {
return oldData.filter((chat) => chat.id !== request.id);
}
);
},
};
});
export const createChatMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
const authToken = getBearerToken();
const chatsQueryParams = get(globalChatsQueryParamsAtom);
return {
mutationKey: cacheKeys.chats.globalQueryParams(chatsQueryParams),
enabled: !!searchSpaceId && !!authToken,
mutationFn: async (request: CreateChatRequest) => {
return chatsApiService.createChat(request);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: cacheKeys.chats.globalQueryParams(chatsQueryParams),
});
},
};
});
export const updateChatMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
const authToken = getBearerToken();
const chatsQueryParams = get(globalChatsQueryParamsAtom);
return {
mutationKey: cacheKeys.chats.globalQueryParams(chatsQueryParams),
enabled: !!searchSpaceId && !!authToken,
mutationFn: async (request: UpdateChatRequest) => {
return chatsApiService.updateChat(request);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: cacheKeys.chats.globalQueryParams(chatsQueryParams),
});
},
};
});

View file

@ -1,48 +0,0 @@
import { atomWithQuery } from "jotai-tanstack-query";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { chatsApiService } from "@/lib/apis/chats-api.service";
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
import { getBearerToken } from "@/lib/auth-utils";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { activeChatIdAtom, globalChatsQueryParamsAtom } from "./ui.atoms";
export const activeChatAtom = atomWithQuery((get) => {
const activeChatId = get(activeChatIdAtom);
const authToken = getBearerToken();
return {
queryKey: cacheKeys.chats.activeChat(activeChatId ?? ""),
enabled: !!activeChatId && !!authToken,
queryFn: async () => {
if (!authToken) {
throw new Error("No authentication token found");
}
if (!activeChatId) {
throw new Error("No active chat id found");
}
const [podcast, chatDetails] = await Promise.all([
podcastsApiService.getPodcastByChatId({ chat_id: Number(activeChatId) }),
chatsApiService.getChatDetails({ id: Number(activeChatId) }),
]);
return { chatId: activeChatId, chatDetails, podcast };
},
};
});
export const chatsAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
const authToken = getBearerToken();
const queryParams = get(globalChatsQueryParamsAtom);
return {
queryKey: cacheKeys.chats.globalQueryParams(queryParams),
enabled: !!searchSpaceId && !!authToken,
queryFn: async () => {
return chatsApiService.getChats({
queryParams: queryParams,
});
},
};
});

View file

@ -1,17 +0,0 @@
import { atom } from "jotai";
import type { GetChatsRequest } from "@/contracts/types/chat.types";
type ActiveChathatUIState = {
isChatPannelOpen: boolean;
};
export const activeChathatUIAtom = atom<ActiveChathatUIState>({
isChatPannelOpen: false,
});
export const activeChatIdAtom = atom<string | null>(null);
export const globalChatsQueryParamsAtom = atom<GetChatsRequest["queryParams"]>({
limit: 5,
skip: 0,
});

View file

@ -0,0 +1,27 @@
import { atom } from "jotai";
interface EditorUIState {
hasUnsavedChanges: boolean;
pendingNavigation: string | null; // URL to navigate to after user confirms
}
export const editorUIAtom = atom<EditorUIState>({
hasUnsavedChanges: false,
pendingNavigation: null,
});
// Derived atom for just the unsaved changes state
export const hasUnsavedEditorChangesAtom = atom(
(get) => get(editorUIAtom).hasUnsavedChanges,
(get, set, value: boolean) => {
set(editorUIAtom, { ...get(editorUIAtom), hasUnsavedChanges: value });
}
);
// Derived atom for pending navigation
export const pendingEditorNavigationAtom = atom(
(get) => get(editorUIAtom).pendingNavigation,
(get, set, value: string | null) => {
set(editorUIAtom, { ...get(editorUIAtom), pendingNavigation: value });
}
);

View file

@ -0,0 +1,85 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
AcceptInviteRequest,
CreateInviteRequest,
DeleteInviteRequest,
UpdateInviteRequest,
} from "@/contracts/types/invites.types";
import { invitesApiService } from "@/lib/apis/invites-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
/**
* Mutation atom for creating an invite
*/
export const createInviteMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: CreateInviteRequest) => {
return invitesApiService.createInvite(request);
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: cacheKeys.invites.all(variables.search_space_id.toString()),
});
toast.success("Invite created successfully");
},
onError: (error: Error) => {
console.error("Error creating invite:", error);
toast.error("Failed to create invite");
},
}));
/**
* Mutation atom for updating an invite
*/
export const updateInviteMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: UpdateInviteRequest) => {
return invitesApiService.updateInvite(request);
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: cacheKeys.invites.all(variables.search_space_id.toString()),
});
toast.success("Invite updated successfully");
},
onError: (error: Error) => {
console.error("Error updating invite:", error);
toast.error("Failed to update invite");
},
}));
/**
* Mutation atom for deleting an invite
*/
export const deleteInviteMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: DeleteInviteRequest) => {
return invitesApiService.deleteInvite(request);
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: cacheKeys.invites.all(variables.search_space_id.toString()),
});
toast.success("Invite deleted successfully");
},
onError: (error: Error) => {
console.error("Error deleting invite:", error);
toast.error("Failed to delete invite");
},
}));
/**
* Mutation atom for accepting an invite
*/
export const acceptInviteMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: AcceptInviteRequest) => {
return invitesApiService.acceptInvite(request);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: cacheKeys.searchSpaces.all });
toast.success("Invite accepted successfully");
},
onError: (error: Error) => {
console.error("Error accepting invite:", error);
toast.error("Failed to accept invite");
},
}));

View file

@ -0,0 +1,22 @@
import { atomWithQuery } from "jotai-tanstack-query";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { invitesApiService } from "@/lib/apis/invites-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export const invitesAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
queryKey: cacheKeys.invites.all(searchSpaceId?.toString() ?? ""),
enabled: !!searchSpaceId,
staleTime: 5 * 60 * 1000, // 5 minutes
queryFn: async () => {
if (!searchSpaceId) {
return [];
}
return invitesApiService.getInvites({
search_space_id: Number(searchSpaceId),
});
},
};
});

View file

@ -1,110 +0,0 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
CreateLLMConfigRequest,
DeleteLLMConfigRequest,
GetLLMConfigsResponse,
UpdateLLMConfigRequest,
UpdateLLMConfigResponse,
UpdateLLMPreferencesRequest,
} from "@/contracts/types/llm-config.types";
import { llmConfigApiService } from "@/lib/apis/llm-config-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
export const createLLMConfigMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
mutationKey: cacheKeys.llmConfigs.all(searchSpaceId!),
enabled: !!searchSpaceId,
mutationFn: async (request: CreateLLMConfigRequest) => {
return llmConfigApiService.createLLMConfig(request);
},
onSuccess: () => {
toast.success("LLM configuration created successfully");
queryClient.invalidateQueries({
queryKey: cacheKeys.llmConfigs.all(searchSpaceId!),
});
queryClient.invalidateQueries({
queryKey: cacheKeys.llmConfigs.global(),
});
},
};
});
export const updateLLMConfigMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
mutationKey: cacheKeys.llmConfigs.all(searchSpaceId!),
enabled: !!searchSpaceId,
mutationFn: async (request: UpdateLLMConfigRequest) => {
return llmConfigApiService.updateLLMConfig(request);
},
onSuccess: (_: UpdateLLMConfigResponse, request: UpdateLLMConfigRequest) => {
toast.success("LLM configuration updated successfully");
queryClient.invalidateQueries({
queryKey: cacheKeys.llmConfigs.all(searchSpaceId!),
});
queryClient.invalidateQueries({
queryKey: cacheKeys.llmConfigs.byId(String(request.id)),
});
queryClient.invalidateQueries({
queryKey: cacheKeys.llmConfigs.global(),
});
},
};
});
export const deleteLLMConfigMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
const authToken = localStorage.getItem("surfsense_bearer_token");
return {
mutationKey: cacheKeys.llmConfigs.all(searchSpaceId!),
enabled: !!searchSpaceId && !!authToken,
mutationFn: async (request: DeleteLLMConfigRequest) => {
return llmConfigApiService.deleteLLMConfig(request);
},
onSuccess: (_, request: DeleteLLMConfigRequest) => {
toast.success("LLM configuration deleted successfully");
queryClient.setQueryData(
cacheKeys.llmConfigs.all(searchSpaceId!),
(oldData: GetLLMConfigsResponse | undefined) => {
if (!oldData) return oldData;
return oldData.filter((config) => config.id !== request.id);
}
);
queryClient.invalidateQueries({
queryKey: cacheKeys.llmConfigs.byId(String(request.id)),
});
queryClient.invalidateQueries({
queryKey: cacheKeys.llmConfigs.global(),
});
},
};
});
export const updateLLMPreferencesMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
mutationKey: cacheKeys.llmConfigs.preferences(searchSpaceId!),
enabled: !!searchSpaceId,
mutationFn: async (request: UpdateLLMPreferencesRequest) => {
return llmConfigApiService.updateLLMPreferences(request);
},
onSuccess: () => {
toast.success("LLM preferences updated successfully");
queryClient.invalidateQueries({
queryKey: cacheKeys.llmConfigs.preferences(searchSpaceId!),
});
},
};
});

View file

@ -1,46 +0,0 @@
import { atomWithQuery } from "jotai-tanstack-query";
import { llmConfigApiService } from "@/lib/apis/llm-config-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
export const llmConfigsAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
queryKey: cacheKeys.llmConfigs.all(searchSpaceId!),
enabled: !!searchSpaceId,
staleTime: 5 * 60 * 1000, // 5 minutes
queryFn: async () => {
return llmConfigApiService.getLLMConfigs({
queryParams: {
search_space_id: searchSpaceId!,
},
});
},
};
});
export const globalLLMConfigsAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.llmConfigs.global(),
staleTime: 10 * 60 * 1000, // 10 minutes
queryFn: async () => {
return llmConfigApiService.getGlobalLLMConfigs();
},
};
});
export const llmPreferencesAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
queryKey: cacheKeys.llmConfigs.preferences(String(searchSpaceId)),
enabled: !!searchSpaceId,
staleTime: 5 * 60 * 1000, // 5 minutes
queryFn: async () => {
return llmConfigApiService.getLLMPreferences({
search_space_id: Number(searchSpaceId),
});
},
};
});

View file

@ -0,0 +1,64 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
DeleteMembershipRequest,
DeleteMembershipResponse,
LeaveSearchSpaceRequest,
LeaveSearchSpaceResponse,
UpdateMembershipRequest,
UpdateMembershipResponse,
} from "@/contracts/types/members.types";
import { membersApiService } from "@/lib/apis/members-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
export const updateMemberMutationAtom = atomWithMutation(() => {
return {
mutationFn: async (request: UpdateMembershipRequest) => {
return membersApiService.updateMember(request);
},
onSuccess: (_: UpdateMembershipResponse, request: UpdateMembershipRequest) => {
toast.success("Member updated successfully");
queryClient.invalidateQueries({
queryKey: cacheKeys.members.all(request.search_space_id.toString()),
});
},
onError: () => {
toast.error("Failed to update member");
},
};
});
export const deleteMemberMutationAtom = atomWithMutation(() => {
return {
mutationFn: async (request: DeleteMembershipRequest) => {
return membersApiService.deleteMember(request);
},
onSuccess: (_: DeleteMembershipResponse, request: DeleteMembershipRequest) => {
toast.success("Member removed successfully");
queryClient.invalidateQueries({
queryKey: cacheKeys.members.all(request.search_space_id.toString()),
});
},
onError: () => {
toast.error("Failed to remove member");
},
};
});
export const leaveSearchSpaceMutationAtom = atomWithMutation(() => {
return {
mutationFn: async (request: LeaveSearchSpaceRequest) => {
return membersApiService.leaveSearchSpace(request);
},
onSuccess: (_: LeaveSearchSpaceResponse, request: LeaveSearchSpaceRequest) => {
toast.success("Successfully left the search space");
queryClient.invalidateQueries({
queryKey: cacheKeys.members.all(request.search_space_id.toString()),
});
},
onError: () => {
toast.error("Failed to leave search space");
},
};
});

View file

@ -0,0 +1,40 @@
import { atomWithQuery } from "jotai-tanstack-query";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { membersApiService } from "@/lib/apis/members-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export const membersAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""),
enabled: !!searchSpaceId,
staleTime: 5 * 60 * 1000, // 5 minutes
queryFn: async () => {
if (!searchSpaceId) {
return [];
}
return membersApiService.getMembers({
search_space_id: Number(searchSpaceId),
});
},
};
});
export const myAccessAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
queryKey: cacheKeys.members.myAccess(searchSpaceId?.toString() ?? ""),
enabled: !!searchSpaceId,
staleTime: 5 * 60 * 1000, // 5 minutes
queryFn: async () => {
if (!searchSpaceId) {
return null;
}
return membersApiService.getMyAccess({
search_space_id: Number(searchSpaceId),
});
},
};
});

View file

@ -0,0 +1,116 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
CreateNewLLMConfigRequest,
DeleteNewLLMConfigRequest,
GetNewLLMConfigsResponse,
UpdateLLMPreferencesRequest,
UpdateNewLLMConfigRequest,
UpdateNewLLMConfigResponse,
} from "@/contracts/types/new-llm-config.types";
import { newLLMConfigApiService } from "@/lib/apis/new-llm-config-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
/**
* Mutation atom for creating a new NewLLMConfig
*/
export const createNewLLMConfigMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
mutationKey: ["new-llm-configs", "create"],
enabled: !!searchSpaceId,
mutationFn: async (request: CreateNewLLMConfigRequest) => {
return newLLMConfigApiService.createConfig(request);
},
onSuccess: () => {
toast.success("Configuration created successfully");
queryClient.invalidateQueries({
queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
});
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create configuration");
},
};
});
/**
* Mutation atom for updating an existing NewLLMConfig
*/
export const updateNewLLMConfigMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
mutationKey: ["new-llm-configs", "update"],
enabled: !!searchSpaceId,
mutationFn: async (request: UpdateNewLLMConfigRequest) => {
return newLLMConfigApiService.updateConfig(request);
},
onSuccess: (_: UpdateNewLLMConfigResponse, request: UpdateNewLLMConfigRequest) => {
toast.success("Configuration updated successfully");
queryClient.invalidateQueries({
queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
});
queryClient.invalidateQueries({
queryKey: cacheKeys.newLLMConfigs.byId(request.id),
});
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update configuration");
},
};
});
/**
* Mutation atom for deleting a NewLLMConfig
*/
export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
mutationKey: ["new-llm-configs", "delete"],
enabled: !!searchSpaceId,
mutationFn: async (request: DeleteNewLLMConfigRequest) => {
return newLLMConfigApiService.deleteConfig(request);
},
onSuccess: (_, request: DeleteNewLLMConfigRequest) => {
toast.success("Configuration deleted successfully");
queryClient.setQueryData(
cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
(oldData: GetNewLLMConfigsResponse | undefined) => {
if (!oldData) return oldData;
return oldData.filter((config) => config.id !== request.id);
}
);
},
onError: (error: Error) => {
toast.error(error.message || "Failed to delete configuration");
},
};
});
/**
* Mutation atom for updating LLM preferences (role assignments)
*/
export const updateLLMPreferencesMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
mutationKey: ["llm-preferences", "update"],
enabled: !!searchSpaceId,
mutationFn: async (request: UpdateLLMPreferencesRequest) => {
return newLLMConfigApiService.updateLLMPreferences(request);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: cacheKeys.newLLMConfigs.preferences(Number(searchSpaceId)),
});
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update LLM preferences");
},
};
});

View file

@ -0,0 +1,64 @@
import { atomWithQuery } from "jotai-tanstack-query";
import { newLLMConfigApiService } from "@/lib/apis/new-llm-config-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
/**
* Query atom for fetching all NewLLMConfigs for the active search space
*/
export const newLLMConfigsAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
enabled: !!searchSpaceId,
staleTime: 5 * 60 * 1000, // 5 minutes
queryFn: async () => {
return newLLMConfigApiService.getConfigs({
search_space_id: Number(searchSpaceId),
});
},
};
});
/**
* Query atom for fetching global NewLLMConfigs (from YAML, negative IDs)
*/
export const globalNewLLMConfigsAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.newLLMConfigs.global(),
staleTime: 10 * 60 * 1000, // 10 minutes - global configs rarely change
queryFn: async () => {
return newLLMConfigApiService.getGlobalConfigs();
},
};
});
/**
* Query atom for fetching LLM preferences (role assignments) for the active search space
*/
export const llmPreferencesAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
queryKey: cacheKeys.newLLMConfigs.preferences(Number(searchSpaceId)),
enabled: !!searchSpaceId,
staleTime: 5 * 60 * 1000, // 5 minutes
queryFn: async () => {
return newLLMConfigApiService.getLLMPreferences(Number(searchSpaceId));
},
};
});
/**
* Query atom for fetching default system instructions template
*/
export const defaultSystemInstructionsAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.newLLMConfigs.defaultInstructions(),
staleTime: 60 * 60 * 1000, // 1 hour - this rarely changes
queryFn: async () => {
return newLLMConfigApiService.getDefaultSystemInstructions();
},
};
});

View file

@ -1,51 +0,0 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import type {
DeletePodcastRequest,
GeneratePodcastRequest,
Podcast,
} from "@/contracts/types/podcast.types";
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
import { getBearerToken } from "@/lib/auth-utils";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
import { globalPodcastsQueryParamsAtom } from "./ui.atoms";
export const deletePodcastMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
const authToken = getBearerToken();
const podcastsQueryParams = get(globalPodcastsQueryParamsAtom);
return {
mutationKey: cacheKeys.podcasts.globalQueryParams(podcastsQueryParams),
enabled: !!searchSpaceId && !!authToken,
mutationFn: async (request: DeletePodcastRequest) => {
return podcastsApiService.deletePodcast(request);
},
onSuccess: (_, request: DeletePodcastRequest) => {
toast.success("Podcast deleted successfully");
queryClient.setQueryData(
cacheKeys.podcasts.globalQueryParams(podcastsQueryParams),
(oldData: Podcast[]) => {
return oldData.filter((podcast) => podcast.id !== request.id);
}
);
},
};
});
export const generatePodcastMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
const authToken = getBearerToken();
const podcastsQueryParams = get(globalPodcastsQueryParamsAtom);
return {
mutationKey: cacheKeys.podcasts.globalQueryParams(podcastsQueryParams),
enabled: !!searchSpaceId && !!authToken,
mutationFn: async (request: GeneratePodcastRequest) => {
return podcastsApiService.generatePodcast(request);
},
};
});

View file

@ -1,17 +0,0 @@
import { atomWithQuery } from "jotai-tanstack-query";
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { globalPodcastsQueryParamsAtom } from "./ui.atoms";
export const podcastsAtom = atomWithQuery((get) => {
const queryParams = get(globalPodcastsQueryParamsAtom);
return {
queryKey: cacheKeys.podcasts.globalQueryParams(queryParams),
queryFn: async () => {
return podcastsApiService.getPodcasts({
queryParams: queryParams,
});
},
};
});

View file

@ -1,7 +0,0 @@
import { atom } from "jotai";
import type { GetPodcastsRequest } from "@/contracts/types/podcast.types";
export const globalPodcastsQueryParamsAtom = atom<GetPodcastsRequest["queryParams"]>({
limit: 5,
skip: 0,
});

View file

@ -25,13 +25,3 @@ export const searchSpacesAtom = atomWithQuery((get) => {
},
};
});
export const communityPromptsAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.searchSpaces.communityPrompts,
staleTime: 30 * 60 * 1000,
queryFn: async () => {
return searchSpacesApiService.getCommunityPrompts();
},
};
});

View file

@ -0,0 +1,46 @@
---
title: "SurfSense v0.0.9 - Introducing the Agentic Architecture"
description: "SurfSense v0.0.9 introduces a new agentic architecture with intelligent source selection, temporal query understanding, and MCP compatibility."
date: "2025-12-24"
tags: ["Agentic", "Agent", "MCP"]
version: "0.0.9"
---
<Video src="/demo.mp4" />
## What's New in v0.0.9
This release introduces the **SurfSense Agent**, a shift from pre-determined workflows (DAG) to dynamic, LLM-driven decision making.
- **Smarter Source Selection**: Intelligently searches all connected sources based on your query, not just the ones you specify
- **Temporal Queries**: Understands time-based questions like *"Slack messages from last week"*
- **Reasoning & Refinement**: Iteratively refines answers for better accuracy
- **Better Prompt Handling**: Knows when to respond in chat vs generate a podcast
- **Faster Indexing**: Improved speed when syncing connected sources
- **MCP Compatibility**: Extensible architecture supporting Model Context Protocol servers
<Accordion type="multiple" className="w-full not-prose">
<AccordionItem value="item-1">
<AccordionTrigger>For Developers</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<ul className="list-disc space-y-2 pl-4">
<li>More extensible architecture for adding connectors and tools</li>
<li>Supports long-running agents for complex tasks</li>
<li>Cleaner codebase enables faster contributions</li>
</ul>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Notes</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<ul className="list-disc space-y-2 pl-4">
<li>May increase API costs due to dynamic LLM calls</li>
<li>Users with small local models may see varied performance</li>
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
SurfSense is an AI-powered federated search solution that connects all your knowledge sources in one place.
🎄 Merry Christmas from the SurfSense team!

View file

@ -0,0 +1,326 @@
"use client";
import {
AttachmentPrimitive,
ComposerPrimitive,
MessagePrimitive,
useAssistantApi,
useAssistantState,
} from "@assistant-ui/react";
import { FileText, Loader2, PlusIcon, XIcon } from "lucide-react";
import Image from "next/image";
import { type FC, type PropsWithChildren, useEffect, useState } from "react";
import { useShallow } from "zustand/shallow";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
const useFileSrc = (file: File | undefined) => {
const [src, setSrc] = useState<string | undefined>(undefined);
useEffect(() => {
if (!file) {
setSrc(undefined);
return;
}
const objectUrl = URL.createObjectURL(file);
setSrc(objectUrl);
return () => {
URL.revokeObjectURL(objectUrl);
};
}, [file]);
return src;
};
const useAttachmentSrc = () => {
const { file, src } = useAssistantState(
useShallow(({ attachment }): { file?: File; src?: string } => {
if (!attachment || attachment.type !== "image") return {};
// First priority: use File object if available (for new uploads)
if (attachment.file) return { file: attachment.file };
// Second priority: use stored imageDataUrl (for persisted messages)
// This is stored in our custom ChatAttachment interface
const customAttachment = attachment as { imageDataUrl?: string };
if (customAttachment.imageDataUrl) {
return { src: customAttachment.imageDataUrl };
}
// Third priority: try to extract from content array (standard assistant-ui format)
if (Array.isArray(attachment.content)) {
const contentSrc = attachment.content.filter((c) => c.type === "image")[0]?.image;
if (contentSrc) return { src: contentSrc };
}
return {};
})
);
return useFileSrc(file) ?? src;
};
type AttachmentPreviewProps = {
src: string;
};
const AttachmentPreview: FC<AttachmentPreviewProps> = ({ src }) => {
const [isLoaded, setIsLoaded] = useState(false);
return (
<Image
src={src}
alt="Image Preview"
width={1}
height={1}
className={
isLoaded
? "aui-attachment-preview-image-loaded block h-auto max-h-[80vh] w-auto max-w-full object-contain"
: "aui-attachment-preview-image-loading hidden"
}
onLoadingComplete={() => setIsLoaded(true)}
priority={false}
/>
);
};
const AttachmentPreviewDialog: FC<PropsWithChildren> = ({ children }) => {
const src = useAttachmentSrc();
if (!src) return children;
return (
<Dialog>
<DialogTrigger
className="aui-attachment-preview-trigger cursor-pointer transition-colors hover:bg-accent/50"
asChild
>
{children}
</DialogTrigger>
<DialogContent className="aui-attachment-preview-dialog-content p-2 sm:max-w-3xl [&>button]:rounded-full [&>button]:bg-foreground/60 [&>button]:p-1 [&>button]:opacity-100 [&>button]:ring-0! [&_svg]:text-background [&>button]:hover:[&_svg]:text-destructive">
<DialogTitle className="aui-sr-only sr-only">Image Attachment Preview</DialogTitle>
<div className="aui-attachment-preview relative mx-auto flex max-h-[80dvh] w-full items-center justify-center overflow-hidden bg-background">
<AttachmentPreview src={src} />
</div>
</DialogContent>
</Dialog>
);
};
const AttachmentThumb: FC = () => {
const isImage = useAssistantState(({ attachment }) => attachment?.type === "image");
// Check if actively processing (running AND progress < 100)
// When progress is 100, processing is done but waiting for send()
const isProcessing = useAssistantState(({ attachment }) => {
const status = attachment?.status;
if (status?.type !== "running") return false;
// If progress is defined and equals 100, processing is complete
const progress = (status as { type: "running"; progress?: number }).progress;
return progress === undefined || progress < 100;
});
const src = useAttachmentSrc();
// Show loading spinner only when actively processing (not when done and waiting for send)
if (isProcessing) {
return (
<div className="flex h-full w-full items-center justify-center bg-muted">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<Avatar className="aui-attachment-tile-avatar h-full w-full rounded-none">
<AvatarImage
src={src}
alt="Attachment preview"
className="aui-attachment-tile-image object-cover"
/>
<AvatarFallback delayMs={isImage ? 200 : 0}>
<FileText className="aui-attachment-tile-fallback-icon size-8 text-muted-foreground" />
</AvatarFallback>
</Avatar>
);
};
const AttachmentUI: FC = () => {
const api = useAssistantApi();
const isComposer = api.attachment.source === "composer";
const isImage = useAssistantState(({ attachment }) => attachment?.type === "image");
// Check if actively processing (running AND progress < 100)
// When progress is 100, processing is done but waiting for send()
const isProcessing = useAssistantState(({ attachment }) => {
const status = attachment?.status;
if (status?.type !== "running") return false;
const progress = (status as { type: "running"; progress?: number }).progress;
return progress === undefined || progress < 100;
});
const typeLabel = useAssistantState(({ attachment }) => {
const type = attachment?.type;
switch (type) {
case "image":
return "Image";
case "document":
return "Document";
case "file":
return "File";
default:
return "File"; // Default fallback for unknown types
}
});
return (
<Tooltip>
<AttachmentPrimitive.Root
className={cn(
"aui-attachment-root relative",
isImage && "aui-attachment-root-composer only:[&>#attachment-tile]:size-24"
)}
>
<AttachmentPreviewDialog>
<TooltipTrigger asChild>
<div
className={cn(
"aui-attachment-tile size-14 cursor-pointer overflow-hidden rounded-[14px] border bg-muted transition-opacity hover:opacity-75",
isComposer && "aui-attachment-tile-composer border-foreground/20",
isProcessing && "animate-pulse"
)}
role="button"
id="attachment-tile"
aria-label={isProcessing ? "Processing attachment..." : `${typeLabel} attachment`}
>
<AttachmentThumb />
</div>
</TooltipTrigger>
</AttachmentPreviewDialog>
{isComposer && !isProcessing && <AttachmentRemove />}
</AttachmentPrimitive.Root>
<TooltipContent side="top">
{isProcessing ? (
<span className="flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
Processing...
</span>
) : (
<AttachmentPrimitive.Name />
)}
</TooltipContent>
</Tooltip>
);
};
const AttachmentRemove: FC = () => {
return (
<AttachmentPrimitive.Remove asChild>
<TooltipIconButton
tooltip="Remove file"
className="aui-attachment-tile-remove absolute top-1.5 right-1.5 size-3.5 rounded-full bg-white text-muted-foreground opacity-100 shadow-sm hover:bg-white! [&_svg]:text-black hover:[&_svg]:text-destructive"
side="top"
>
<XIcon className="aui-attachment-remove-icon size-3 dark:stroke-[2.5px]" />
</TooltipIconButton>
</AttachmentPrimitive.Remove>
);
};
/**
* Image attachment with preview thumbnail (click to expand)
*/
const MessageImageAttachment: FC = () => {
const attachmentName = useAssistantState(({ attachment }) => attachment?.name || "Image");
const src = useAttachmentSrc();
if (!src) return null;
return (
<AttachmentPreviewDialog>
<div
className="relative group cursor-pointer overflow-hidden rounded-xl border border-border/50 bg-muted transition-all hover:border-primary/30 hover:shadow-md"
title={`Click to expand: ${attachmentName}`}
>
<Image
src={src}
alt={attachmentName}
width={120}
height={90}
className="object-cover w-[120px] h-[90px] transition-transform group-hover:scale-105"
/>
{/* Hover overlay with filename */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
<div className="absolute bottom-1.5 left-1.5 right-1.5">
<span className="text-[10px] text-white/90 font-medium truncate block">
{attachmentName}
</span>
</div>
</div>
</div>
</AttachmentPreviewDialog>
);
};
/**
* Document/file attachment as chip (similar to mentioned documents)
*/
const MessageDocumentAttachment: FC = () => {
const attachmentName = useAssistantState(({ attachment }) => attachment?.name || "Attachment");
return (
<AttachmentPreviewDialog>
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20 cursor-pointer hover:bg-primary/20 transition-colors"
title={attachmentName}
>
<FileText className="size-3" />
<span className="max-w-[150px] truncate">{attachmentName}</span>
</span>
</AttachmentPreviewDialog>
);
};
/**
* Attachment component for user messages
* Shows image preview for images, chip for documents
*/
const MessageAttachmentChip: FC = () => {
const isImage = useAssistantState(({ attachment }) => attachment?.type === "image");
if (isImage) {
return <MessageImageAttachment />;
}
return <MessageDocumentAttachment />;
};
export const UserMessageAttachments: FC = () => {
return <MessagePrimitive.Attachments components={{ Attachment: MessageAttachmentChip }} />;
};
export const ComposerAttachments: FC = () => {
return (
<div className="aui-composer-attachments mb-2 flex w-full flex-row items-center gap-2 overflow-x-auto px-1.5 pt-0.5 pb-1 empty:hidden">
<ComposerPrimitive.Attachments components={{ Attachment: AttachmentUI }} />
</div>
);
};
export const ComposerAddAttachment: FC = () => {
return (
<ComposerPrimitive.AddAttachment asChild>
<TooltipIconButton
tooltip="Add Attachment"
side="bottom"
variant="ghost"
size="icon"
className="aui-composer-add-attachment size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
aria-label="Add Attachment"
>
<PlusIcon className="aui-attachment-add-icon size-5 stroke-[1.5px]" />
</TooltipIconButton>
</ComposerPrimitive.AddAttachment>
);
};

View file

@ -0,0 +1,41 @@
"use client";
import type { FC } from "react";
import { useState } from "react";
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 numbered badge that opens the SourceDetailPanel with document chunk details.
*/
export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, citationNumber }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<SourceDetailPanel
open={isOpen}
onOpenChange={setIsOpen}
chunkId={chunkId}
sourceType=""
title="Source"
description=""
url=""
>
<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

@ -0,0 +1,527 @@
"use client";
import { X } from "lucide-react";
import {
createElement,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import ReactDOMServer from "react-dom/server";
import type { Document } from "@/contracts/types/document.types";
import { cn } from "@/lib/utils";
export interface MentionedDocument {
id: number;
title: string;
document_type?: string;
}
export interface InlineMentionEditorRef {
focus: () => void;
clear: () => void;
getText: () => string;
getMentionedDocuments: () => MentionedDocument[];
insertDocumentChip: (doc: Document) => void;
}
interface InlineMentionEditorProps {
placeholder?: string;
onMentionTrigger?: (query: string) => void;
onMentionClose?: () => void;
onSubmit?: () => void;
onChange?: (text: string, docs: MentionedDocument[]) => void;
onDocumentRemove?: (docId: number) => void;
onKeyDown?: (e: React.KeyboardEvent) => void;
disabled?: boolean;
className?: string;
initialDocuments?: MentionedDocument[];
}
// Unique data attribute to identify chip elements
const CHIP_DATA_ATTR = "data-mention-chip";
const CHIP_ID_ATTR = "data-mention-id";
/**
* Type guard to check if a node is a chip element
*/
function isChipElement(node: Node | null): node is HTMLSpanElement {
return (
node !== null &&
node.nodeType === Node.ELEMENT_NODE &&
(node as Element).hasAttribute(CHIP_DATA_ATTR)
);
}
/**
* Safely parse chip ID from element attribute
*/
function getChipId(element: Element): number | null {
const idStr = element.getAttribute(CHIP_ID_ATTR);
if (!idStr) return null;
const id = parseInt(idStr, 10);
return Number.isNaN(id) ? null : id;
}
export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMentionEditorProps>(
(
{
placeholder = "Type @ to mention documents...",
onMentionTrigger,
onMentionClose,
onSubmit,
onChange,
onDocumentRemove,
onKeyDown,
disabled = false,
className,
initialDocuments = [],
},
ref
) => {
const editorRef = useRef<HTMLDivElement>(null);
const [isEmpty, setIsEmpty] = useState(true);
const [mentionedDocs, setMentionedDocs] = useState<Map<number, MentionedDocument>>(
() => new Map(initialDocuments.map((d) => [d.id, d]))
);
const isComposingRef = useRef(false);
// Sync initial documents
useEffect(() => {
if (initialDocuments.length > 0) {
setMentionedDocs(new Map(initialDocuments.map((d) => [d.id, d])));
}
}, [initialDocuments]);
// Focus at the end of the editor
const focusAtEnd = useCallback(() => {
if (!editorRef.current) return;
editorRef.current.focus();
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(editorRef.current);
range.collapse(false);
selection?.removeAllRanges();
selection?.addRange(range);
}, []);
// Get plain text content (excluding chips)
const getText = useCallback((): string => {
if (!editorRef.current) return "";
let text = "";
const walker = document.createTreeWalker(
editorRef.current,
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
{
acceptNode: (node) => {
// Skip chip elements entirely
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as Element;
if (el.hasAttribute(CHIP_DATA_ATTR)) {
return NodeFilter.FILTER_REJECT; // Skip this subtree
}
return NodeFilter.FILTER_SKIP; // Continue into children
}
return NodeFilter.FILTER_ACCEPT;
},
}
);
let node: Node | null = walker.nextNode();
while (node) {
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent;
}
node = walker.nextNode();
}
return text.trim();
}, []);
// Get all mentioned documents
const getMentionedDocuments = useCallback((): MentionedDocument[] => {
return Array.from(mentionedDocs.values());
}, [mentionedDocs]);
// Create a chip element for a document
const createChipElement = useCallback(
(doc: MentionedDocument): HTMLSpanElement => {
const chip = document.createElement("span");
chip.setAttribute(CHIP_DATA_ATTR, "true");
chip.setAttribute(CHIP_ID_ATTR, String(doc.id));
chip.contentEditable = "false";
chip.className =
"inline-flex items-center gap-0.5 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary border border-primary/10 select-none";
chip.style.userSelect = "none";
chip.style.verticalAlign = "baseline";
const titleSpan = document.createElement("span");
titleSpan.className = "max-w-[80px] truncate";
titleSpan.textContent = doc.title;
titleSpan.title = doc.title;
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className =
"size-3 flex items-center justify-center rounded-full hover:bg-primary/20 transition-colors ml-0.5";
removeBtn.innerHTML = ReactDOMServer.renderToString(
createElement(X, { className: "h-2.5 w-2.5", strokeWidth: 2.5 })
);
removeBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
chip.remove();
setMentionedDocs((prev) => {
const next = new Map(prev);
next.delete(doc.id);
return next;
});
// Notify parent that a document was removed
onDocumentRemove?.(doc.id);
focusAtEnd();
};
chip.appendChild(titleSpan);
chip.appendChild(removeBtn);
return chip;
},
[focusAtEnd, onDocumentRemove]
);
// Insert a document chip at the current cursor position
const insertDocumentChip = useCallback(
(doc: Document) => {
if (!editorRef.current) return;
// Validate required fields for type safety
if (typeof doc.id !== "number" || typeof doc.title !== "string") {
console.warn("[InlineMentionEditor] Invalid document passed to insertDocumentChip:", doc);
return;
}
const mentionDoc: MentionedDocument = {
id: doc.id,
title: doc.title,
document_type: doc.document_type,
};
// Add to mentioned docs map
setMentionedDocs((prev) => new Map(prev).set(doc.id, mentionDoc));
// Find and remove the @query text
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
// No selection, just append
const chip = createChipElement(mentionDoc);
editorRef.current.appendChild(chip);
editorRef.current.appendChild(document.createTextNode(" "));
focusAtEnd();
return;
}
// Find the @ symbol before the cursor and remove it along with any query text
const range = selection.getRangeAt(0);
const textNode = range.startContainer;
if (textNode.nodeType === Node.TEXT_NODE) {
const text = textNode.textContent || "";
const cursorPos = range.startOffset;
// Find the @ symbol before cursor
let atIndex = -1;
for (let i = cursorPos - 1; i >= 0; i--) {
if (text[i] === "@") {
atIndex = i;
break;
}
}
if (atIndex !== -1) {
// Remove @query and insert chip
const beforeAt = text.slice(0, atIndex);
const afterCursor = text.slice(cursorPos);
// Create chip
const chip = createChipElement(mentionDoc);
// Replace text node content
const parent = textNode.parentNode;
if (parent) {
const beforeNode = document.createTextNode(beforeAt);
const afterNode = document.createTextNode(` ${afterCursor}`);
parent.insertBefore(beforeNode, textNode);
parent.insertBefore(chip, textNode);
parent.insertBefore(afterNode, textNode);
parent.removeChild(textNode);
// Set cursor after the chip
const newRange = document.createRange();
newRange.setStart(afterNode, 1);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
}
} else {
// No @ found, just insert at cursor
const chip = createChipElement(mentionDoc);
range.insertNode(chip);
range.setStartAfter(chip);
range.collapse(true);
// Add space after chip
const space = document.createTextNode(" ");
range.insertNode(space);
range.setStartAfter(space);
range.collapse(true);
}
} else {
// Not in a text node, append to editor
const chip = createChipElement(mentionDoc);
editorRef.current.appendChild(chip);
editorRef.current.appendChild(document.createTextNode(" "));
focusAtEnd();
}
// Update empty state
setIsEmpty(false);
// Trigger onChange
if (onChange) {
setTimeout(() => {
onChange(getText(), getMentionedDocuments());
}, 0);
}
},
[createChipElement, focusAtEnd, getText, getMentionedDocuments, onChange]
);
// Clear the editor
const clear = useCallback(() => {
if (editorRef.current) {
editorRef.current.innerHTML = "";
setIsEmpty(true);
setMentionedDocs(new Map());
}
}, []);
// Expose methods via ref
useImperativeHandle(ref, () => ({
focus: () => editorRef.current?.focus(),
clear,
getText,
getMentionedDocuments,
insertDocumentChip,
}));
// Handle input changes
const handleInput = useCallback(() => {
if (!editorRef.current) return;
const text = getText();
const empty = text.length === 0 && mentionedDocs.size === 0;
setIsEmpty(empty);
// Check for @ mentions
const selection = window.getSelection();
let shouldTriggerMention = false;
let mentionQuery = "";
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const textNode = range.startContainer;
if (textNode.nodeType === Node.TEXT_NODE) {
const textContent = textNode.textContent || "";
const cursorPos = range.startOffset;
// Look for @ before cursor
let atIndex = -1;
for (let i = cursorPos - 1; i >= 0; i--) {
if (textContent[i] === "@") {
atIndex = i;
break;
}
// Stop if we hit a space (@ must be at word boundary)
if (textContent[i] === " " || textContent[i] === "\n") {
break;
}
}
if (atIndex !== -1) {
const query = textContent.slice(atIndex + 1, cursorPos);
// Only trigger if query doesn't start with space
if (!query.startsWith(" ")) {
shouldTriggerMention = true;
mentionQuery = query;
}
}
}
}
// If no @ found before cursor, check if text contains @ at all
// If text is empty or doesn't contain @, close the mention
if (!shouldTriggerMention) {
if (text.length === 0 || !text.includes("@")) {
onMentionClose?.();
} else {
// Text contains @ but not before cursor, close mention
onMentionClose?.();
}
} else {
onMentionTrigger?.(mentionQuery);
}
// Notify parent of change
onChange?.(text, Array.from(mentionedDocs.values()));
}, [getText, mentionedDocs, onChange, onMentionTrigger, onMentionClose]);
// Handle keydown
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
// Let parent handle navigation keys when mention popover is open
if (onKeyDown) {
onKeyDown(e);
if (e.defaultPrevented) return;
}
// Handle Enter for submit (without shift)
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit?.();
return;
}
// Handle backspace on chips
if (e.key === "Backspace") {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
if (range.collapsed) {
// Check if cursor is right after a chip
const node = range.startContainer;
const offset = range.startOffset;
if (node.nodeType === Node.TEXT_NODE && offset === 0) {
// Check previous sibling using type guard
const prevSibling = node.previousSibling;
if (isChipElement(prevSibling)) {
e.preventDefault();
const chipId = getChipId(prevSibling);
if (chipId !== null) {
prevSibling.remove();
setMentionedDocs((prev) => {
const next = new Map(prev);
next.delete(chipId);
return next;
});
// Notify parent that a document was removed
onDocumentRemove?.(chipId);
}
return;
}
// Check if we're about to delete @ at the start
const textContent = node.textContent || "";
if (textContent.length > 0 && textContent[0] === "@") {
// Will delete @, close mention popover
setTimeout(() => {
onMentionClose?.();
}, 0);
}
} else if (node.nodeType === Node.TEXT_NODE && offset > 0) {
// Check if we're about to delete @
const textContent = node.textContent || "";
if (textContent[offset - 1] === "@") {
// Will delete @, close mention popover
setTimeout(() => {
onMentionClose?.();
}, 0);
}
} else if (node.nodeType === Node.ELEMENT_NODE && offset > 0) {
// Check if previous child is a chip using type guard
const prevChild = (node as Element).childNodes[offset - 1];
if (isChipElement(prevChild)) {
e.preventDefault();
const chipId = getChipId(prevChild);
if (chipId !== null) {
prevChild.remove();
setMentionedDocs((prev) => {
const next = new Map(prev);
next.delete(chipId);
return next;
});
// Notify parent that a document was removed
onDocumentRemove?.(chipId);
}
}
}
}
}
}
},
[onKeyDown, onSubmit, onDocumentRemove, onMentionClose]
);
// Handle paste - strip formatting
const handlePaste = useCallback((e: React.ClipboardEvent) => {
e.preventDefault();
const text = e.clipboardData.getData("text/plain");
document.execCommand("insertText", false, text);
}, []);
// Handle composition (for IME input)
const handleCompositionStart = useCallback(() => {
isComposingRef.current = true;
}, []);
const handleCompositionEnd = useCallback(() => {
isComposingRef.current = false;
handleInput();
}, [handleInput]);
return (
<div className="relative w-full">
{/** biome-ignore lint/a11y/useSemanticElements: <not important> */}
<div
ref={editorRef}
contentEditable={!disabled}
suppressContentEditableWarning
tabIndex={disabled ? -1 : 0}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
className={cn(
"min-h-[24px] max-h-32 overflow-y-auto",
"text-sm outline-none",
"whitespace-pre-wrap break-words",
disabled && "opacity-50 cursor-not-allowed",
className
)}
style={{ wordBreak: "break-word" }}
data-placeholder={placeholder}
aria-label="Message input with inline mentions"
role="textbox"
aria-multiline="true"
/>
{/* Placeholder */}
{isEmpty && (
<div
className="absolute top-0 left-0 pointer-events-none text-muted-foreground text-sm"
aria-hidden="true"
>
{placeholder}
</div>
)}
</div>
);
}
);
InlineMentionEditor.displayName = "InlineMentionEditor";

View file

@ -0,0 +1,325 @@
"use client";
import "@assistant-ui/react-markdown/styles/dot.css";
import {
type CodeHeaderProps,
MarkdownTextPrimitive,
unstable_memoizeMarkdownComponents as memoizeMarkdownComponents,
useIsMarkdownCodeBlock,
} from "@assistant-ui/react-markdown";
import { CheckIcon, CopyIcon } from "lucide-react";
import { type FC, memo, type ReactNode, useState } from "react";
import remarkGfm from "remark-gfm";
import { InlineCitation } from "@/components/assistant-ui/inline-citation";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
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
*/
function parseTextWithCitations(text: string): ReactNode[] {
const parts: ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
let instanceIndex = 0;
// Reset regex state
CITATION_REGEX.lastIndex = 0;
while ((match = CITATION_REGEX.exec(text)) !== null) {
// Add text before the citation
if (match.index > lastIndex) {
parts.push(text.substring(lastIndex, match.index));
}
// Add the citation component
const chunkId = Number.parseInt(match[1], 10);
const citationNumber = getCitationNumber(chunkId);
parts.push(
<InlineCitation
key={`citation-${chunkId}-${instanceIndex}`}
chunkId={chunkId}
citationNumber={citationNumber}
/>
);
lastIndex = match.index + match[0].length;
instanceIndex++;
}
// Add any remaining text after the last citation
if (lastIndex < text.length) {
parts.push(text.substring(lastIndex));
}
return parts.length > 0 ? parts : [text];
}
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]}
className="aui-md"
components={defaultComponents}
/>
);
};
export const MarkdownText = memo(MarkdownTextImpl);
const CodeHeader: FC<CodeHeaderProps> = ({ language, code }) => {
const { isCopied, copyToClipboard } = useCopyToClipboard();
const onCopy = () => {
if (!code || isCopied) return;
copyToClipboard(code);
};
return (
<div className="aui-code-header-root mt-4 flex items-center justify-between gap-4 rounded-t-lg bg-muted-foreground/15 px-4 py-2 font-semibold text-foreground text-sm dark:bg-muted-foreground/20">
<span className="aui-code-header-language lowercase [&>span]:text-xs">{language}</span>
<TooltipIconButton tooltip="Copy" onClick={onCopy}>
{!isCopied && <CopyIcon />}
{isCopied && <CheckIcon />}
</TooltipIconButton>
</div>
);
};
const useCopyToClipboard = ({ copiedDuration = 3000 }: { copiedDuration?: number } = {}) => {
const [isCopied, setIsCopied] = useState<boolean>(false);
const copyToClipboard = (value: string) => {
if (!value) return;
navigator.clipboard.writeText(value).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), copiedDuration);
});
};
return { isCopied, copyToClipboard };
};
/**
* Helper to process children and replace citation patterns with components
*/
function processChildrenWithCitations(children: ReactNode): ReactNode {
if (typeof children === "string") {
const parsed = parseTextWithCitations(children);
return parsed.length === 1 && typeof parsed[0] === "string" ? children : <>{parsed}</>;
}
if (Array.isArray(children)) {
return children.map((child, index) => {
if (typeof child === "string") {
const parsed = parseTextWithCitations(child);
return parsed.length === 1 && typeof parsed[0] === "string" ? (
child
) : (
<span key={index}>{parsed}</span>
);
}
return child;
});
}
return children;
}
const defaultComponents = memoizeMarkdownComponents({
h1: ({ className, children, ...props }) => (
<h1
className={cn(
"aui-md-h1 mb-8 scroll-m-20 font-extrabold text-4xl tracking-tight last:mb-0",
className
)}
{...props}
>
{processChildrenWithCitations(children)}
</h1>
),
h2: ({ className, children, ...props }) => (
<h2
className={cn(
"aui-md-h2 mt-8 mb-4 scroll-m-20 font-semibold text-3xl tracking-tight first:mt-0 last:mb-0",
className
)}
{...props}
>
{processChildrenWithCitations(children)}
</h2>
),
h3: ({ className, children, ...props }) => (
<h3
className={cn(
"aui-md-h3 mt-6 mb-4 scroll-m-20 font-semibold text-2xl tracking-tight first:mt-0 last:mb-0",
className
)}
{...props}
>
{processChildrenWithCitations(children)}
</h3>
),
h4: ({ className, children, ...props }) => (
<h4
className={cn(
"aui-md-h4 mt-6 mb-4 scroll-m-20 font-semibold text-xl tracking-tight first:mt-0 last:mb-0",
className
)}
{...props}
>
{processChildrenWithCitations(children)}
</h4>
),
h5: ({ className, children, ...props }) => (
<h5
className={cn("aui-md-h5 my-4 font-semibold text-lg first:mt-0 last:mb-0", className)}
{...props}
>
{processChildrenWithCitations(children)}
</h5>
),
h6: ({ className, children, ...props }) => (
<h6 className={cn("aui-md-h6 my-4 font-semibold first:mt-0 last:mb-0", className)} {...props}>
{processChildrenWithCitations(children)}
</h6>
),
p: ({ className, children, ...props }) => (
<p className={cn("aui-md-p mt-5 mb-5 leading-7 first:mt-0 last:mb-0", className)} {...props}>
{processChildrenWithCitations(children)}
</p>
),
a: ({ className, children, ...props }) => (
<a
className={cn("aui-md-a font-medium text-primary underline underline-offset-4", className)}
{...props}
>
{processChildrenWithCitations(children)}
</a>
),
blockquote: ({ className, children, ...props }) => (
<blockquote className={cn("aui-md-blockquote border-l-2 pl-6 italic", className)} {...props}>
{processChildrenWithCitations(children)}
</blockquote>
),
ul: ({ className, ...props }) => (
<ul className={cn("aui-md-ul my-5 ml-6 list-disc [&>li]:mt-2", className)} {...props} />
),
ol: ({ className, ...props }) => (
<ol className={cn("aui-md-ol my-5 ml-6 list-decimal [&>li]:mt-2", className)} {...props} />
),
li: ({ className, children, ...props }) => (
<li className={cn("aui-md-li", className)} {...props}>
{processChildrenWithCitations(children)}
</li>
),
hr: ({ className, ...props }) => (
<hr className={cn("aui-md-hr my-5 border-b", className)} {...props} />
),
table: ({ className, ...props }) => (
<table
className={cn(
"aui-md-table my-5 w-full border-separate border-spacing-0 overflow-y-auto",
className
)}
{...props}
/>
),
th: ({ className, children, ...props }) => (
<th
className={cn(
"aui-md-th bg-muted px-4 py-2 text-left font-bold first:rounded-tl-lg last:rounded-tr-lg [[align=center]]:text-center [[align=right]]:text-right",
className
)}
{...props}
>
{processChildrenWithCitations(children)}
</th>
),
td: ({ className, children, ...props }) => (
<td
className={cn(
"aui-md-td border-b border-l px-4 py-2 text-left last:border-r [[align=center]]:text-center [[align=right]]:text-right",
className
)}
{...props}
>
{processChildrenWithCitations(children)}
</td>
),
tr: ({ className, ...props }) => (
<tr
className={cn(
"aui-md-tr m-0 border-b p-0 first:border-t [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg",
className
)}
{...props}
/>
),
sup: ({ className, ...props }) => (
<sup className={cn("aui-md-sup [&>a]:text-xs [&>a]:no-underline", className)} {...props} />
),
pre: ({ className, ...props }) => (
<pre
className={cn(
"aui-md-pre overflow-x-auto rounded-t-none! rounded-b-lg bg-black p-4 text-white",
className
)}
{...props}
/>
),
code: function Code({ className, ...props }) {
const isCodeBlock = useIsMarkdownCodeBlock();
return (
<code
className={cn(
!isCodeBlock && "aui-md-inline-code rounded border bg-muted font-semibold",
className
)}
{...props}
/>
);
},
strong: ({ className, children, ...props }) => (
<strong className={cn("aui-md-strong font-semibold", className)} {...props}>
{processChildrenWithCitations(children)}
</strong>
),
em: ({ className, children, ...props }) => (
<em className={cn("aui-md-em", className)} {...props}>
{processChildrenWithCitations(children)}
</em>
),
CodeHeader,
});

View file

@ -0,0 +1,299 @@
"use client";
import {
ArchiveIcon,
MessageSquareIcon,
MoreVerticalIcon,
PlusIcon,
RotateCcwIcon,
TrashIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
createThreadListManager,
type ThreadListItem,
type ThreadListState,
} from "@/lib/chat/thread-persistence";
import { cn } from "@/lib/utils";
interface ThreadListProps {
searchSpaceId: number;
currentThreadId?: number;
className?: string;
}
export function ThreadList({ searchSpaceId, currentThreadId, className }: ThreadListProps) {
const router = useRouter();
const [state, setState] = useState<ThreadListState>({
threads: [],
archivedThreads: [],
isLoading: true,
error: null,
});
const [showArchived, setShowArchived] = useState(false);
// Create the thread list manager
const manager = useCallback(
() =>
createThreadListManager({
searchSpaceId,
currentThreadId: currentThreadId ?? null,
onThreadSwitch: (threadId) => {
router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`);
},
onNewThread: (threadId) => {
router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`);
},
}),
[searchSpaceId, currentThreadId, router]
);
// Load threads on mount and when searchSpaceId changes
const loadThreads = useCallback(async () => {
setState((prev) => ({ ...prev, isLoading: true }));
const newState = await manager().loadThreads();
setState(newState);
}, [manager]);
useEffect(() => {
loadThreads();
}, [loadThreads]);
// Handle new thread creation
const handleNewThread = async () => {
await manager().createNewThread();
await loadThreads();
};
// Handle thread actions
const handleArchive = async (threadId: number) => {
const success = await manager().archiveThread(threadId);
if (success) await loadThreads();
};
const handleUnarchive = async (threadId: number) => {
const success = await manager().unarchiveThread(threadId);
if (success) await loadThreads();
};
const handleDelete = async (threadId: number) => {
const success = await manager().deleteThread(threadId);
if (success) {
await loadThreads();
// If we deleted the current thread, redirect to new chat
if (threadId === currentThreadId) {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
}
};
const handleSwitchToThread = (threadId: number) => {
manager().switchToThread(threadId);
};
const displayedThreads = showArchived ? state.archivedThreads : state.threads;
if (state.isLoading) {
return (
<div className={cn("flex h-full flex-col", className)}>
<div className="flex items-center justify-center p-4">
<span className="text-muted-foreground text-sm">Loading threads...</span>
</div>
</div>
);
}
if (state.error) {
return (
<div className={cn("flex h-full flex-col", className)}>
<div className="p-4 text-center">
<span className="text-destructive text-sm">{state.error}</span>
<Button variant="ghost" size="sm" className="mt-2" onClick={loadThreads}>
Retry
</Button>
</div>
</div>
);
}
return (
<div className={cn("flex h-full flex-col", className)}>
{/* Header with New Chat button */}
<div className="flex items-center justify-between border-b p-3">
<h2 className="font-semibold text-sm">Conversations</h2>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={handleNewThread}
title="New Chat"
>
<PlusIcon className="size-4" />
</Button>
</div>
{/* Tab toggle for active/archived */}
<div className="flex border-b">
<button
type="button"
onClick={() => setShowArchived(false)}
className={cn(
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
!showArchived
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
Active ({state.threads.length})
</button>
<button
type="button"
onClick={() => setShowArchived(true)}
className={cn(
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
showArchived
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
Archived ({state.archivedThreads.length})
</button>
</div>
{/* Thread list */}
<div className="flex-1 overflow-y-auto">
{displayedThreads.length === 0 ? (
<div className="flex flex-col items-center justify-center p-6 text-center">
<MessageSquareIcon className="mb-2 size-8 text-muted-foreground/50" />
<p className="text-muted-foreground text-sm">
{showArchived ? "No archived conversations" : "No conversations yet"}
</p>
{!showArchived && (
<Button variant="outline" size="sm" className="mt-3" onClick={handleNewThread}>
<PlusIcon className="mr-1 size-3" />
Start a conversation
</Button>
)}
</div>
) : (
<div className="space-y-1 p-2">
{displayedThreads.map((thread) => (
<ThreadListItemComponent
key={thread.id}
thread={thread}
isActive={thread.id === currentThreadId}
isArchived={showArchived}
onClick={() => handleSwitchToThread(thread.id)}
onArchive={() => handleArchive(thread.id)}
onUnarchive={() => handleUnarchive(thread.id)}
onDelete={() => handleDelete(thread.id)}
/>
))}
</div>
)}
</div>
</div>
);
}
interface ThreadListItemComponentProps {
thread: ThreadListItem;
isActive: boolean;
isArchived: boolean;
onClick: () => void;
onArchive: () => void;
onUnarchive: () => void;
onDelete: () => void;
}
function ThreadListItemComponent({
thread,
isActive,
isArchived,
onClick,
onArchive,
onUnarchive,
onDelete,
}: ThreadListItemComponentProps) {
return (
<div
className={cn(
"group flex items-center gap-2 rounded-lg px-3 py-2 transition-colors cursor-pointer",
isActive ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
)}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onClick();
}}
role="button"
tabIndex={0}
>
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{thread.title || "New Chat"}</p>
<p className="truncate text-xs text-muted-foreground">
{formatRelativeTime(new Date(thread.updatedAt))}
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<MoreVerticalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isArchived ? (
<DropdownMenuItem onClick={onUnarchive}>
<RotateCcwIcon className="mr-2 size-4" />
Unarchive
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={onArchive}>
<ArchiveIcon className="mr-2 size-4" />
Archive
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
<TrashIcon className="mr-2 size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
/**
* Format a date as relative time (e.g., "2 hours ago", "Yesterday")
*/
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return "Just now";
if (diffMins < 60) return `${diffMins} min${diffMins === 1 ? "" : "s"} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
if (diffDays === 1) return "Yesterday";
if (diffDays < 7) return `${diffDays} days ago`;
return date.toLocaleDateString();
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,76 @@
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export const ToolFallback: ToolCallMessagePartComponent = ({
toolName,
argsText,
result,
status,
}) => {
const [isCollapsed, setIsCollapsed] = useState(true);
const isCancelled = status?.type === "incomplete" && status.reason === "cancelled";
const cancelledReason =
isCancelled && status.error
? typeof status.error === "string"
? status.error
: JSON.stringify(status.error)
: null;
return (
<div
className={cn(
"aui-tool-fallback-root mb-4 flex w-full flex-col gap-3 rounded-lg border py-3",
isCancelled && "border-muted-foreground/30 bg-muted/30"
)}
>
<div className="aui-tool-fallback-header flex items-center gap-2 px-4">
{isCancelled ? (
<XCircleIcon className="aui-tool-fallback-icon size-4 text-muted-foreground" />
) : (
<CheckIcon className="aui-tool-fallback-icon size-4" />
)}
<p
className={cn(
"aui-tool-fallback-title grow",
isCancelled && "text-muted-foreground line-through"
)}
>
{isCancelled ? "Cancelled tool: " : "Used tool: "}
<b>{toolName}</b>
</p>
<Button onClick={() => setIsCollapsed(!isCollapsed)}>
{isCollapsed ? <ChevronUpIcon /> : <ChevronDownIcon />}
</Button>
</div>
{!isCollapsed && (
<div className="aui-tool-fallback-content flex flex-col gap-2 border-t pt-2">
{cancelledReason && (
<div className="aui-tool-fallback-cancelled-root px-4">
<p className="aui-tool-fallback-cancelled-header font-semibold text-muted-foreground">
Cancelled reason:
</p>
<p className="aui-tool-fallback-cancelled-reason text-muted-foreground">
{cancelledReason}
</p>
</div>
)}
<div className={cn("aui-tool-fallback-args-root px-4", isCancelled && "opacity-60")}>
<pre className="aui-tool-fallback-args-value whitespace-pre-wrap">{argsText}</pre>
</div>
{!isCancelled && result !== undefined && (
<div className="aui-tool-fallback-result-root border-t border-dashed px-4 pt-2">
<p className="aui-tool-fallback-result-header font-semibold">Result:</p>
<pre className="aui-tool-fallback-result-content whitespace-pre-wrap">
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,36 @@
"use client";
import { Slottable } from "@radix-ui/react-slot";
import { type ComponentPropsWithRef, forwardRef } from "react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & {
tooltip: string;
side?: "top" | "bottom" | "left" | "right";
};
export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButtonProps>(
({ children, tooltip, side = "bottom", className, ...rest }, ref) => {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
{...rest}
className={cn("aui-button-icon size-6 p-1", className)}
ref={ref}
>
<Slottable>{children}</Slottable>
<span className="aui-sr-only sr-only">{tooltip}</span>
</Button>
</TooltipTrigger>
<TooltipContent side={side}>{tooltip}</TooltipContent>
</Tooltip>
);
}
);
TooltipIconButton.displayName = "TooltipIconButton";

View file

@ -1,151 +0,0 @@
"use client";
import { useInView } from "motion/react";
import { Manrope } from "next/font/google";
import { useEffect, useMemo, useReducer, useRef } from "react";
import { RoughNotation, RoughNotationGroup } from "react-rough-notation";
import { useSidebar } from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
// Font configuration - could be moved to a global font config file
const manrope = Manrope({
subsets: ["latin"],
weight: ["400", "700"],
display: "swap", // Optimize font loading
variable: "--font-manrope",
});
// Constants for timing - makes it easier to adjust and more maintainable
const TIMING = {
SIDEBAR_TRANSITION: 300, // Wait for sidebar transition + buffer
LAYOUT_SETTLE: 100, // Small delay to ensure layout is fully settled
} as const;
// Animation configuration
const ANIMATION_CONFIG = {
HIGHLIGHT: {
type: "highlight" as const,
animationDuration: 2000,
iterations: 3,
color: "#3b82f680",
multiline: true,
},
UNDERLINE: {
type: "underline" as const,
animationDuration: 2000,
iterations: 3,
color: "#10b981",
},
} as const;
// State management with useReducer for better organization
interface HighlightState {
shouldShowHighlight: boolean;
layoutStable: boolean;
}
type HighlightAction =
| { type: "SIDEBAR_CHANGED" }
| { type: "LAYOUT_STABILIZED" }
| { type: "SHOW_HIGHLIGHT" }
| { type: "HIDE_HIGHLIGHT" };
const highlightReducer = (state: HighlightState, action: HighlightAction): HighlightState => {
switch (action.type) {
case "SIDEBAR_CHANGED":
return {
shouldShowHighlight: false,
layoutStable: false,
};
case "LAYOUT_STABILIZED":
return {
...state,
layoutStable: true,
};
case "SHOW_HIGHLIGHT":
return {
...state,
shouldShowHighlight: true,
};
case "HIDE_HIGHLIGHT":
return {
...state,
shouldShowHighlight: false,
};
default:
return state;
}
};
const initialState: HighlightState = {
shouldShowHighlight: false,
layoutStable: true,
};
export function AnimatedEmptyState() {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref);
const [{ shouldShowHighlight, layoutStable }, dispatch] = useReducer(
highlightReducer,
initialState
);
// Memoize class names to prevent unnecessary recalculations
const headingClassName = useMemo(
() =>
cn(
"text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-neutral-900 dark:text-neutral-50 mb-6",
manrope.className
),
[]
);
const paragraphClassName = useMemo(
() => "text-lg sm:text-xl text-neutral-600 dark:text-neutral-300 mb-8 max-w-2xl mx-auto",
[]
);
// Handle sidebar state changes
useEffect(() => {
dispatch({ type: "SIDEBAR_CHANGED" });
const stabilizeTimer = setTimeout(() => {
dispatch({ type: "LAYOUT_STABILIZED" });
}, TIMING.SIDEBAR_TRANSITION);
return () => clearTimeout(stabilizeTimer);
}, []);
// Handle highlight visibility based on layout stability and viewport visibility
useEffect(() => {
if (!layoutStable || !isInView) {
dispatch({ type: "HIDE_HIGHLIGHT" });
return;
}
const showTimer = setTimeout(() => {
dispatch({ type: "SHOW_HIGHLIGHT" });
}, TIMING.LAYOUT_SETTLE);
return () => clearTimeout(showTimer);
}, [layoutStable, isInView]);
return (
<div ref={ref} className="flex-1 flex items-center justify-center w-full min-h-fit">
<div className="max-w-4xl mx-auto px-4 py-10 text-center">
<RoughNotationGroup show={shouldShowHighlight}>
<h1 className={headingClassName}>
<RoughNotation {...ANIMATION_CONFIG.HIGHLIGHT}>
<span>SurfSense</span>
</RoughNotation>
</h1>
<p className={paragraphClassName}>
<RoughNotation {...ANIMATION_CONFIG.UNDERLINE}>Let's Start Surfing</RoughNotation>{" "}
through your knowledge base.
</p>
</RoughNotationGroup>
</div>
</div>
);
}

View file

@ -1,30 +0,0 @@
"use client";
import type React from "react";
import { useState } from "react";
import { SheetTrigger } from "@/components/ui/sheet";
import { SourceDetailSheet } from "./SourceDetailSheet";
export const CitationDisplay: React.FC<{ index: number; node: any }> = ({ index, node }) => {
const chunkId = Number(node?.id);
const sourceType = node?.metadata?.source_type;
const [isOpen, setIsOpen] = useState(false);
return (
<SourceDetailSheet
open={isOpen}
onOpenChange={setIsOpen}
chunkId={chunkId}
sourceType={sourceType}
title={node?.metadata?.title || node?.metadata?.group_name || "Source"}
description={node?.text}
url={node?.url}
>
<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>
</SheetTrigger>
</SourceDetailSheet>
);
};

View file

@ -1,36 +0,0 @@
"use client";
import { getAnnotationData, type Message, useChatUI } from "@llamaindex/chat-ui";
import { SuggestedQuestions } from "@llamaindex/chat-ui/widgets";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
export const ChatFurtherQuestions: React.FC<{ message: Message }> = ({ message }) => {
const annotations: string[][] = getAnnotationData(message, "FURTHER_QUESTIONS");
const { append, requestData } = useChatUI();
if (annotations.length !== 1 || annotations[0].length === 0) {
return null;
}
return (
<Accordion type="single" collapsible className="w-full border rounded-md bg-card shadow-sm">
<AccordionItem value="suggested-questions" className="border-0">
<AccordionTrigger className="px-4 py-3 text-sm font-medium text-foreground transition-colors">
Further Suggested Questions
</AccordionTrigger>
<AccordionContent className="px-4 pb-4 pt-0">
<SuggestedQuestions
questions={annotations[0]}
append={append}
requestData={requestData}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
);
};

View file

@ -1,845 +0,0 @@
"use client";
import { ChatInput } from "@llamaindex/chat-ui";
import { useAtom, useAtomValue } from "jotai";
import { Brain, Check, FolderOpen, Minus, Plus, PlusCircle, Zap } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import React, { Suspense, useCallback, useMemo, useState } from "react";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { updateLLMPreferencesMutationAtom } from "@/atoms/llm-config/llm-config-mutation.atoms";
import {
globalLLMConfigsAtom,
llmConfigsAtom,
llmPreferencesAtom,
} from "@/atoms/llm-config/llm-config-query.atoms";
import { DocumentsDataTable } from "@/components/chat/DocumentsDataTable";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { Document } from "@/contracts/types/document.types";
const DocumentSelector = React.memo(
({
onSelectionChange,
selectedDocuments = [],
}: {
onSelectionChange?: (documents: Document[]) => void;
selectedDocuments?: Document[];
}) => {
const { search_space_id } = useParams();
const [isOpen, setIsOpen] = useState(false);
const handleOpenChange = useCallback((open: boolean) => {
setIsOpen(open);
}, []);
const handleSelectionChange = useCallback(
(documents: Document[]) => {
onSelectionChange?.(documents);
},
[onSelectionChange]
);
const handleDone = useCallback(() => {
setIsOpen(false);
}, []);
const selectedCount = React.useMemo(() => selectedDocuments.length, [selectedDocuments.length]);
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9 gap-2 px-3 border-dashed hover:border-solid hover:bg-accent/50 transition-all"
>
<FolderOpen className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">
{selectedCount > 0 ? `Selected` : "Documents"}
</span>
{selectedCount > 0 && (
<Badge variant="secondary" className="h-5 px-1.5 text-xs font-medium">
{selectedCount}
</Badge>
)}
</Button>
</DialogTrigger>
<DialogContent className="max-w-[95vw] md:max-w-5xl h-[90vh] md:h-[85vh] p-0 flex flex-col">
<div className="flex flex-col h-full">
<div className="px-4 md:px-6 py-4 border-b flex-shrink-0 bg-muted/30">
<DialogTitle className="text-lg md:text-xl font-semibold">
Select Documents
</DialogTitle>
<DialogDescription className="mt-1.5 text-sm">
Choose specific documents to include in your research context
</DialogDescription>
</div>
<div className="flex-1 min-h-0 p-4 md:p-6">
<DocumentsDataTable
searchSpaceId={Number(search_space_id)}
onSelectionChange={handleSelectionChange}
onDone={handleDone}
initialSelectedDocuments={selectedDocuments}
/>
</div>
</div>
</DialogContent>
</Dialog>
);
}
);
DocumentSelector.displayName = "DocumentSelector";
const ConnectorSelector = React.memo(
({
onSelectionChange,
selectedConnectors = [],
}: {
onSelectionChange?: (connectorTypes: string[]) => void;
selectedConnectors?: string[];
}) => {
const { search_space_id } = useParams();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
// Use the documentTypeCountsAtom for fetching document types
const [documentTypeCountsQuery] = useAtom(documentTypeCountsAtom);
const {
data: documentTypeCountsData,
isLoading,
refetch: fetchDocumentTypes,
} = documentTypeCountsQuery;
// Transform the response into the expected format
const documentTypes = useMemo(() => {
if (!documentTypeCountsData) return [];
return Object.entries(documentTypeCountsData).map(([type, count]) => ({
type,
count,
}));
}, [documentTypeCountsData]);
const isLoaded = !!documentTypeCountsData;
const { data: searchConnectors = [], isLoading: connectorsLoading } =
useAtomValue(connectorsAtom);
const liveSearchConnectors = React.useMemo(
() => searchConnectors.filter((connector) => !connector.is_indexable),
[searchConnectors]
);
const handleOpenChange = useCallback((open: boolean) => {
setIsOpen(open);
// Data is already loaded on mount, no need to fetch again
}, []);
const handleConnectorToggle = useCallback(
(connectorType: string) => {
const isSelected = selectedConnectors.includes(connectorType);
const newSelection = isSelected
? selectedConnectors.filter((type) => type !== connectorType)
: [...selectedConnectors, connectorType];
onSelectionChange?.(newSelection);
},
[selectedConnectors, onSelectionChange]
);
const handleSelectAll = useCallback(() => {
const allTypes = [
...documentTypes.map((dt) => dt.type),
...liveSearchConnectors.map((c) => c.connector_type),
];
onSelectionChange?.(allTypes);
}, [documentTypes, liveSearchConnectors, onSelectionChange]);
const handleClearAll = useCallback(() => {
onSelectionChange?.([]);
}, [onSelectionChange]);
// Get display name for connector type
const getDisplayName = (type: string) => {
return type
.split("_")
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
.join(" ");
};
// Get selected document types with their counts
const selectedDocTypes = documentTypes.filter((dt) => selectedConnectors.includes(dt.type));
const selectedLiveConnectors = liveSearchConnectors.filter((c) =>
selectedConnectors.includes(c.connector_type)
);
// Total selected count
const totalSelectedCount = selectedDocTypes.length + selectedLiveConnectors.length;
const totalAvailableCount = documentTypes.length + liveSearchConnectors.length;
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="relative h-9 gap-2 px-3 border-dashed hover:border-solid hover:bg-accent/50 transition-all"
>
<div className="flex items-center gap-1.5">
{totalSelectedCount > 0 ? (
<>
<div className="flex items-center -space-x-2">
{selectedDocTypes.slice(0, 2).map((docType) => (
<div
key={docType.type}
className="flex h-6 w-6 items-center justify-center rounded-full border-2 border-background bg-muted"
>
{getConnectorIcon(docType.type, "h-3 w-3")}
</div>
))}
{selectedLiveConnectors
.slice(0, 3 - selectedDocTypes.slice(0, 2).length)
.map((connector) => (
<div
key={connector.id}
className="flex h-6 w-6 items-center justify-center rounded-full border-2 border-background bg-muted"
>
{getConnectorIcon(connector.connector_type, "h-3 w-3")}
</div>
))}
</div>
<span className="text-xs font-medium">
{totalSelectedCount} {totalSelectedCount === 1 ? "source" : "sources"}
</span>
</>
) : (
<>
<Brain className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">Sources</span>
</>
)}
</div>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl max-h-[85vh] flex flex-col">
<div className="space-y-4 flex-1 overflow-y-auto pr-2">
<div>
<DialogTitle className="text-xl">Select Sources</DialogTitle>
<DialogDescription className="mt-1.5">
Choose indexed document types and live search connectors to include in your search
</DialogDescription>
</div>
{isLoading || connectorsLoading ? (
<div className="flex justify-center py-8">
<div className="animate-spin h-8 w-8 border-3 border-primary border-t-transparent rounded-full" />
</div>
) : totalAvailableCount === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<Brain className="h-8 w-8 text-muted-foreground" />
</div>
<h4 className="text-sm font-medium mb-1">No sources found</h4>
<p className="text-xs text-muted-foreground max-w-xs mb-4">
Add documents or configure search connectors for this search space
</p>
<Button
onClick={() => {
setIsOpen(false);
router.push(`/dashboard/${search_space_id}/sources/add`);
}}
className="gap-2"
>
<PlusCircle className="h-4 w-4" />
Add Sources
</Button>
</div>
) : (
<>
{/* Live Search Connectors Section */}
{liveSearchConnectors.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 pb-2">
<Zap className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">Live Search Connectors</h3>
<Badge variant="outline" className="text-xs">
Real-time
</Badge>
</div>
<div className="grid grid-cols-2 gap-3">
{liveSearchConnectors.map((connector) => {
const isSelected = selectedConnectors.includes(connector.connector_type);
return (
<button
key={connector.id}
onClick={() => handleConnectorToggle(connector.connector_type)}
type="button"
className={`group relative flex items-center gap-3 p-3 rounded-lg border-2 transition-all ${
isSelected
? "border-primary bg-primary/5 shadow-sm"
: "border-border hover:border-primary/50 hover:bg-accent/50"
}`}
>
<div
className={`flex h-10 w-10 items-center justify-center rounded-md transition-colors ${
isSelected ? "bg-primary/10" : "bg-muted group-hover:bg-primary/5"
}`}
>
{getConnectorIcon(
connector.connector_type,
`h-5 w-5 ${isSelected ? "text-primary" : "text-muted-foreground group-hover:text-primary"}`
)}
</div>
<div className="flex-1 text-left min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{connector.name}</p>
{isSelected && (
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-primary">
<Check className="h-3 w-3 text-primary-foreground" />
</div>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{getDisplayName(connector.connector_type)}
</p>
</div>
</button>
);
})}
</div>
</div>
)}
{/* Document Types Section */}
{documentTypes.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 pb-2">
<FolderOpen className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">Indexed Document Types</h3>
<Badge variant="outline" className="text-xs">
Stored
</Badge>
</div>
<div className="grid grid-cols-2 gap-3">
{documentTypes.map((docType) => {
const isSelected = selectedConnectors.includes(docType.type);
return (
<button
key={docType.type}
onClick={() => handleConnectorToggle(docType.type)}
type="button"
className={`group relative flex items-center gap-3 p-3 rounded-lg border-2 transition-all ${
isSelected
? "border-primary bg-primary/5 shadow-sm"
: "border-border hover:border-primary/50 hover:bg-accent/50"
}`}
>
<div
className={`flex h-10 w-10 items-center justify-center rounded-md transition-colors ${
isSelected ? "bg-primary/10" : "bg-muted group-hover:bg-primary/5"
}`}
>
{getConnectorIcon(
docType.type,
`h-5 w-5 ${isSelected ? "text-primary" : "text-muted-foreground group-hover:text-primary"}`
)}
</div>
<div className="flex-1 text-left min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">
{getDisplayName(docType.type)}
</p>
{isSelected && (
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-primary">
<Check className="h-3 w-3 text-primary-foreground" />
</div>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{docType.count} {docType.count === 1 ? "document" : "documents"}
</p>
</div>
</button>
);
})}
</div>
</div>
)}
</>
)}
</div>
{totalAvailableCount > 0 && (
<DialogFooter className="flex flex-row justify-between items-center gap-2 pt-4 border-t">
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
disabled={selectedConnectors.length === 0}
className="text-xs"
>
Clear All
</Button>
<Button
size="sm"
onClick={handleSelectAll}
disabled={selectedConnectors.length === totalAvailableCount}
className="text-xs"
>
Select All ({totalAvailableCount})
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
);
ConnectorSelector.displayName = "ConnectorSelector";
const TopKSelector = React.memo(
({ topK = 10, onTopKChange }: { topK?: number; onTopKChange?: (topK: number) => void }) => {
const MIN_VALUE = 1;
const MAX_VALUE = 100;
const handleIncrement = React.useCallback(() => {
if (topK < MAX_VALUE) {
onTopKChange?.(topK + 1);
}
}, [topK, onTopKChange]);
const handleDecrement = React.useCallback(() => {
if (topK > MIN_VALUE) {
onTopKChange?.(topK - 1);
}
}, [topK, onTopKChange]);
const handleInputChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Allow empty input for editing
if (value === "") {
return;
}
const numValue = parseInt(value, 10);
if (!isNaN(numValue) && numValue >= MIN_VALUE && numValue <= MAX_VALUE) {
onTopKChange?.(numValue);
}
},
[onTopKChange]
);
const handleInputBlur = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
const value = e.target.value;
if (value === "") {
// Reset to default if empty
onTopKChange?.(10);
return;
}
const numValue = parseInt(value, 10);
if (isNaN(numValue) || numValue < MIN_VALUE) {
onTopKChange?.(MIN_VALUE);
} else if (numValue > MAX_VALUE) {
onTopKChange?.(MAX_VALUE);
}
},
[onTopKChange]
);
return (
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<div className="flex items-center h-8 border rounded-md bg-background hover:bg-accent/50 transition-colors">
<Button
type="button"
variant="ghost"
size="icon"
className="h-full w-7 rounded-l-md rounded-r-none hover:bg-accent border-r"
onClick={handleDecrement}
disabled={topK <= MIN_VALUE}
>
<Minus className="h-3.5 w-3.5" />
</Button>
<div className="flex flex-col items-center justify-center px-2 min-w-[60px]">
<Input
type="number"
value={topK}
onChange={handleInputChange}
onBlur={handleInputBlur}
min={MIN_VALUE}
max={MAX_VALUE}
className="h-5 w-full px-1 text-center text-sm font-semibold border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<span className="text-[10px] text-muted-foreground leading-none">Results</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-full w-7 rounded-r-md rounded-l-none hover:bg-accent border-l"
onClick={handleIncrement}
disabled={topK >= MAX_VALUE}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="space-y-2">
<p className="text-sm font-semibold">Results per Source</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Control how many results to fetch from each data source. Set a higher number to get
more information, or a lower number for faster, more focused results.
</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground pt-1 border-t">
<span>Recommended: 5-20</span>
<span></span>
<span>
Range: {MIN_VALUE}-{MAX_VALUE}
</span>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
);
TopKSelector.displayName = "TopKSelector";
const LLMSelector = React.memo(() => {
const { search_space_id } = useParams();
const searchSpaceId = Number(search_space_id);
const {
data: llmConfigs = [],
isFetching: llmLoading,
isError: error,
} = useAtomValue(llmConfigsAtom);
const {
data: globalConfigs = [],
isFetching: globalConfigsLoading,
isError: globalConfigsError,
} = useAtomValue(globalLLMConfigsAtom);
// Replace useLLMPreferences with jotai atoms
const { data: preferences = {}, isFetching: preferencesLoading } =
useAtomValue(llmPreferencesAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const isLoading = llmLoading || preferencesLoading || globalConfigsLoading;
// Combine global and custom configs
const allConfigs = React.useMemo(() => {
return [...globalConfigs.map((config) => ({ ...config, is_global: true })), ...llmConfigs];
}, [globalConfigs, llmConfigs]);
// Memoize the selected config to avoid repeated lookups
const selectedConfig = React.useMemo(() => {
if (!preferences.fast_llm_id || !allConfigs.length) return null;
return allConfigs.find((config) => config.id === preferences.fast_llm_id) || null;
}, [preferences.fast_llm_id, allConfigs]);
// Memoize the display value for the trigger
const displayValue = React.useMemo(() => {
if (!selectedConfig) return null;
return (
<div className="flex items-center gap-1">
<span className="font-medium text-xs">{selectedConfig.provider}</span>
<span className="text-muted-foreground"></span>
<span className="hidden sm:inline text-muted-foreground text-xs truncate max-w-[60px]">
{selectedConfig.name}
</span>
{"is_global" in selectedConfig && selectedConfig.is_global && (
<span className="text-xs">🌐</span>
)}
</div>
);
}, [selectedConfig]);
const handleValueChange = React.useCallback(
(value: string) => {
const llmId = value ? parseInt(value, 10) : undefined;
updatePreferences({
search_space_id: searchSpaceId,
data: { fast_llm_id: llmId },
});
},
[updatePreferences, searchSpaceId]
);
// Loading skeleton
if (isLoading) {
return (
<div className="h-8 min-w-[100px] sm:min-w-[120px]">
<div className="h-8 rounded-md bg-muted animate-pulse flex items-center px-3">
<div className="w-3 h-3 rounded bg-muted-foreground/20 mr-2" />
<div className="h-3 w-16 rounded bg-muted-foreground/20" />
</div>
</div>
);
}
// Error state
if (error || globalConfigsError) {
return (
<div className="h-8 min-w-[100px] sm:min-w-[120px]">
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs text-destructive border-destructive/50 hover:bg-destructive/10"
disabled
>
<span className="text-xs">Error</span>
</Button>
</div>
);
}
return (
<div className="h-8 min-w-0">
<Select
value={preferences.fast_llm_id?.toString() || ""}
onValueChange={handleValueChange}
disabled={isLoading}
>
<SelectTrigger className="h-8 w-auto min-w-[100px] sm:min-w-[120px] px-3 text-xs border-border bg-background hover:bg-muted/50 transition-colors duration-200 focus:ring-2 focus:ring-primary/20">
<div className="flex items-center gap-2 min-w-0">
<Zap className="h-3 w-3 text-primary flex-shrink-0" />
<SelectValue placeholder="Fast LLM" className="text-xs">
{displayValue || <span className="text-muted-foreground">Select LLM</span>}
</SelectValue>
</div>
</SelectTrigger>
<SelectContent align="end" className="w-[300px] max-h-[400px]">
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
<div className="flex items-center gap-2">
<Zap className="h-3 w-3" />
Fast LLM Selection
</div>
</div>
{allConfigs.length === 0 ? (
<div className="px-4 py-6 text-center">
<div className="mx-auto w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3">
<Brain className="h-5 w-5 text-muted-foreground" />
</div>
<h4 className="text-sm font-medium mb-1">No LLM configurations</h4>
<p className="text-xs text-muted-foreground mb-3">
Configure AI models to get started
</p>
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => window.open("/settings", "_blank")}
>
Open Settings
</Button>
</div>
) : (
<div className="py-1">
{/* Global Configurations */}
{globalConfigs.length > 0 && (
<>
<div className="px-3 py-1.5 text-xs font-semibold text-muted-foreground">
Global Configurations
</div>
{globalConfigs.map((config) => (
<SelectItem
key={config.id}
value={config.id.toString()}
className="px-3 py-2 cursor-pointer hover:bg-accent/50 focus:bg-accent"
>
<div className="flex items-center justify-between w-full min-w-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 flex-shrink-0">
<Brain className="h-4 w-4 text-primary" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-medium text-sm truncate">{config.name}</span>
<Badge
variant="outline"
className="text-xs px-1.5 py-0.5 flex-shrink-0"
>
{config.provider}
</Badge>
<Badge
variant="secondary"
className="text-xs px-1.5 py-0.5 flex-shrink-0"
>
🌐 Global
</Badge>
</div>
<p className="text-xs text-muted-foreground font-mono truncate">
{config.model_name}
</p>
</div>
</div>
</div>
</SelectItem>
))}
</>
)}
{/* Custom Configurations */}
{llmConfigs.length > 0 && (
<>
<div className="px-3 py-1.5 text-xs font-semibold text-muted-foreground">
Your Configurations
</div>
{llmConfigs.map((config) => (
<SelectItem
key={config.id}
value={config.id.toString()}
className="px-3 py-2 cursor-pointer hover:bg-accent/50 focus:bg-accent"
>
<div className="flex items-center justify-between w-full min-w-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 flex-shrink-0">
<Brain className="h-4 w-4 text-primary" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">{config.name}</span>
<Badge
variant="outline"
className="text-xs px-1.5 py-0.5 flex-shrink-0"
>
{config.provider}
</Badge>
</div>
<p className="text-xs text-muted-foreground font-mono truncate">
{config.model_name}
</p>
</div>
</div>
</div>
</SelectItem>
))}
</>
)}
</div>
)}
</SelectContent>
</Select>
</div>
);
});
LLMSelector.displayName = "LLMSelector";
const CustomChatInputOptions = React.memo(
({
onDocumentSelectionChange,
selectedDocuments,
onConnectorSelectionChange,
selectedConnectors,
topK,
onTopKChange,
}: {
onDocumentSelectionChange?: (documents: Document[]) => void;
selectedDocuments?: Document[];
onConnectorSelectionChange?: (connectorTypes: string[]) => void;
selectedConnectors?: string[];
topK?: number;
onTopKChange?: (topK: number) => void;
}) => {
// Memoize the loading fallback to prevent recreation
const loadingFallback = React.useMemo(
() => <div className="h-9 w-24 animate-pulse bg-muted/50 rounded-md" />,
[]
);
return (
<div className="flex flex-wrap gap-2 items-center">
<div className="flex items-center gap-2">
<Suspense fallback={loadingFallback}>
<DocumentSelector
onSelectionChange={onDocumentSelectionChange}
selectedDocuments={selectedDocuments}
/>
</Suspense>
<Suspense fallback={loadingFallback}>
<ConnectorSelector
onSelectionChange={onConnectorSelectionChange}
selectedConnectors={selectedConnectors}
/>
</Suspense>
</div>
<div className="h-4 w-px bg-border hidden sm:block" />
<TopKSelector topK={topK} onTopKChange={onTopKChange} />
<div className="h-4 w-px bg-border hidden sm:block" />
<LLMSelector />
</div>
);
}
);
CustomChatInputOptions.displayName = "CustomChatInputOptions";
export const ChatInputUI = React.memo(
({
onDocumentSelectionChange,
selectedDocuments,
onConnectorSelectionChange,
selectedConnectors,
topK,
onTopKChange,
}: {
onDocumentSelectionChange?: (documents: Document[]) => void;
selectedDocuments?: Document[];
onConnectorSelectionChange?: (connectorTypes: string[]) => void;
selectedConnectors?: string[];
topK?: number;
onTopKChange?: (topK: number) => void;
}) => {
return (
<ChatInput className="p-2">
<ChatInput.Form className="flex gap-2">
<ChatInput.Field className="flex-1" />
<ChatInput.Submit />
</ChatInput.Form>
<CustomChatInputOptions
onDocumentSelectionChange={onDocumentSelectionChange}
selectedDocuments={selectedDocuments}
onConnectorSelectionChange={onConnectorSelectionChange}
selectedConnectors={selectedConnectors}
topK={topK}
onTopKChange={onTopKChange}
/>
</ChatInput>
);
}
);
ChatInputUI.displayName = "ChatInputUI";

View file

@ -1,47 +0,0 @@
"use client";
import { type ChatHandler, ChatSection as LlamaIndexChatSection } from "@llamaindex/chat-ui";
import { useParams } from "next/navigation";
import { ChatInputUI } from "@/components/chat/ChatInputGroup";
import { ChatMessagesUI } from "@/components/chat/ChatMessages";
import type { Document } from "@/contracts/types/document.types";
interface ChatInterfaceProps {
handler: ChatHandler;
onDocumentSelectionChange?: (documents: Document[]) => void;
selectedDocuments?: Document[];
onConnectorSelectionChange?: (connectorTypes: string[]) => void;
selectedConnectors?: string[];
topK?: number;
onTopKChange?: (topK: number) => void;
}
export default function ChatInterface({
handler,
onDocumentSelectionChange,
selectedDocuments = [],
onConnectorSelectionChange,
selectedConnectors = [],
topK = 10,
onTopKChange,
}: ChatInterfaceProps) {
const { chat_id, search_space_id } = useParams();
return (
<LlamaIndexChatSection handler={handler} className="flex h-full max-w-7xl mx-auto">
<div className="flex grow-1 flex-col">
<ChatMessagesUI />
<div className="border-1 rounded-4xl p-2">
<ChatInputUI
onDocumentSelectionChange={onDocumentSelectionChange}
selectedDocuments={selectedDocuments}
onConnectorSelectionChange={onConnectorSelectionChange}
selectedConnectors={selectedConnectors}
topK={topK}
onTopKChange={onTopKChange}
/>
</div>
</div>
</LlamaIndexChatSection>
);
}

View file

@ -1,73 +0,0 @@
"use client";
import {
ChatMessage as LlamaIndexChatMessage,
ChatMessages as LlamaIndexChatMessages,
type Message,
useChatUI,
} from "@llamaindex/chat-ui";
import { useEffect, useRef } from "react";
import { AnimatedEmptyState } from "@/components/chat/AnimatedEmptyState";
import { CitationDisplay } from "@/components/chat/ChatCitation";
import { ChatFurtherQuestions } from "@/components/chat/ChatFurtherQuestions";
import ChatSourcesDisplay from "@/components/chat/ChatSources";
import TerminalDisplay from "@/components/chat/ChatTerminal";
import { languageRenderers } from "@/components/chat/CodeBlock";
export function ChatMessagesUI() {
const { messages } = useChatUI();
return (
<LlamaIndexChatMessages className="flex-1">
<LlamaIndexChatMessages.Empty>
<AnimatedEmptyState />
</LlamaIndexChatMessages.Empty>
<LlamaIndexChatMessages.List className="p-2">
{messages.map((message, index) => (
<ChatMessageUI
key={`Message-${index}`}
message={message}
isLast={index === messages.length - 1}
/>
))}
</LlamaIndexChatMessages.List>
<LlamaIndexChatMessages.Loading />
</LlamaIndexChatMessages>
);
}
function ChatMessageUI({ message, isLast }: { message: Message; isLast: boolean }) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isLast && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [isLast]);
return (
<LlamaIndexChatMessage message={message} isLast={isLast} className="flex flex-col ">
{message.role === "assistant" ? (
<div className="flex-1 flex flex-col space-y-4">
<TerminalDisplay message={message} open={isLast} />
<ChatSourcesDisplay message={message} />
<LlamaIndexChatMessage.Content className="flex-1">
<LlamaIndexChatMessage.Content.Markdown
citationComponent={CitationDisplay}
languageRenderers={languageRenderers}
/>
</LlamaIndexChatMessage.Content>
<div ref={bottomRef} />
<div className="flex flex-row justify-end gap-2">
{isLast && <ChatFurtherQuestions message={message} />}
<LlamaIndexChatMessage.Actions className="flex-1 flex-col" />
</div>
</div>
) : (
<LlamaIndexChatMessage.Content className="flex-1">
<LlamaIndexChatMessage.Content.Markdown languageRenderers={languageRenderers} />
</LlamaIndexChatMessage.Content>
)}
</LlamaIndexChatMessage>
);
}

View file

@ -1,60 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { LoaderIcon, TriangleAlert } from "lucide-react";
import { toast } from "sonner";
import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
import { activeChathatUIAtom, activeChatIdAtom } from "@/atoms/chats/ui.atoms";
import { generatePodcastMutationAtom } from "@/atoms/podcasts/podcast-mutation.atoms";
import type { GeneratePodcastRequest } from "@/contracts/types/podcast.types";
import { cn } from "@/lib/utils";
import { ChatPanelView } from "./ChatPanelView";
export function ChatPanelContainer() {
const {
data: activeChatState,
isLoading: isChatLoading,
error: chatError,
} = useAtomValue(activeChatAtom);
const activeChatIdState = useAtomValue(activeChatIdAtom);
const { isChatPannelOpen } = useAtomValue(activeChathatUIAtom);
const { mutateAsync: generatePodcast, error: generatePodcastError } = useAtomValue(
generatePodcastMutationAtom
);
const handleGeneratePodcast = async (request: GeneratePodcastRequest) => {
try {
generatePodcast(request);
toast.success(`Podcast generation started!`);
} catch (error) {
toast.error("Error generating podcast. Please try again later.");
console.error("Error generating podcast:", JSON.stringify(generatePodcastError));
}
};
return activeChatIdState ? (
<div
className={cn(
"shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 flex flex-col h-full transition-all",
isChatPannelOpen ? "w-96" : "w-0"
)}
>
{isChatLoading || chatError ? (
<div className="border-b p-2">
{isChatLoading ? (
<div title="Loading chat" className="flex items-center justify-center h-full">
<LoaderIcon strokeWidth={1.5} className="h-5 w-5 animate-spin" />
</div>
) : chatError ? (
<div title="Failed to load chat" className="flex items-center justify-center h-full">
<TriangleAlert strokeWidth={1.5} className="h-5 w-5 text-red-600" />
</div>
) : null}
</div>
) : null}
{!isChatLoading && !chatError && activeChatState?.chatDetails && (
<ChatPanelView generatePodcast={handleGeneratePodcast} />
)}
</div>
) : null;
}

View file

@ -1,207 +0,0 @@
"use client";
import { useAtom, useAtomValue } from "jotai";
import { AlertCircle, Play, RefreshCw, Sparkles } from "lucide-react";
import { motion } from "motion/react";
import { useCallback } from "react";
import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms";
import { cn } from "@/lib/utils";
import { getPodcastStalenessMessage, isPodcastStale } from "../PodcastUtils";
import type { GeneratePodcastRequest } from "./ChatPanelContainer";
import { ConfigModal } from "./ConfigModal";
import { PodcastPlayer } from "./PodcastPlayer";
interface ChatPanelViewProps {
generatePodcast: (request: GeneratePodcastRequest) => Promise<void>;
}
export function ChatPanelView(props: ChatPanelViewProps) {
const [chatUIState, setChatUIState] = useAtom(activeChathatUIAtom);
const { data: activeChatState } = useAtomValue(activeChatAtom);
const { isChatPannelOpen } = chatUIState;
const podcast = activeChatState?.podcast;
const chatDetails = activeChatState?.chatDetails;
const { generatePodcast } = props;
// Check if podcast is stale
const podcastIsStale =
podcast && chatDetails && isPodcastStale(chatDetails.state_version, podcast.chat_state_version);
const handleGeneratePost = useCallback(async () => {
if (!chatDetails) return;
await generatePodcast({
type: "CHAT",
ids: [chatDetails.id],
search_space_id: chatDetails.search_space_id,
podcast_title: chatDetails.title,
});
}, [chatDetails, generatePodcast]);
// biome-ignore-start lint/a11y/useSemanticElements: using div for custom layout — will convert later
return (
<div className="w-full">
<div className={cn("w-full p-4", !isChatPannelOpen && "flex items-center justify-center")}>
{isChatPannelOpen ? (
<div className="space-y-3">
{/* Show stale podcast warning if applicable */}
{podcastIsStale && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-xl p-3 bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/20 border border-amber-200/50 dark:border-amber-800/50 shadow-sm"
>
<div className="flex gap-2 items-start">
<motion.div
animate={{ rotate: [0, 10, -10, 0] }}
transition={{ duration: 0.5, repeat: Infinity, repeatDelay: 3 }}
>
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
</motion.div>
<div className="text-sm text-amber-900 dark:text-amber-100">
<p className="font-semibold">Podcast Outdated</p>
<p className="text-xs mt-1 opacity-80">
{getPodcastStalenessMessage(
chatDetails?.state_version || 0,
podcast?.chat_state_version
)}
</p>
</div>
</div>
</motion.div>
)}
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="relative"
>
<button
type="button"
onClick={handleGeneratePost}
className={cn(
"relative w-full rounded-2xl p-4 transition-all duration-300 cursor-pointer group overflow-hidden",
"border-2",
podcastIsStale
? "bg-gradient-to-br from-amber-500/10 via-orange-500/10 to-amber-500/10 dark:from-amber-500/20 dark:via-orange-500/20 dark:to-amber-500/20 border-amber-400/50 hover:border-amber-400 hover:shadow-lg hover:shadow-amber-500/20"
: "bg-gradient-to-br from-primary/10 via-primary/5 to-primary/10 border-primary/30 hover:border-primary/60 hover:shadow-lg hover:shadow-primary/20"
)}
>
{/* Background gradient animation */}
<motion.div
className={cn(
"absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500",
podcastIsStale
? "bg-gradient-to-r from-transparent via-amber-400/10 to-transparent"
: "bg-gradient-to-r from-transparent via-primary/10 to-transparent"
)}
animate={{
x: ["-100%", "100%"],
}}
transition={{
duration: 3,
repeat: Infinity,
ease: "linear",
}}
/>
<div className="relative z-10 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<motion.div
className={cn(
"p-2.5 rounded-xl",
podcastIsStale
? "bg-amber-500/20 dark:bg-amber-500/30"
: "bg-primary/20 dark:bg-primary/30"
)}
animate={{
rotate: podcastIsStale ? [0, 360] : 0,
}}
transition={{
duration: 2,
repeat: podcastIsStale ? Infinity : 0,
ease: "linear",
}}
>
{podcastIsStale ? (
<RefreshCw className="h-5 w-5 text-amber-600 dark:text-amber-400" />
) : (
<Sparkles className="h-5 w-5 text-primary" />
)}
</motion.div>
<div>
<p className="text-sm font-semibold">
{podcastIsStale ? "Regenerate Podcast" : "Generate Podcast"}
</p>
<p className="text-xs text-muted-foreground">
{podcastIsStale
? "Update with latest changes"
: "Create podcasts of your chat"}
</p>
</div>
</div>
</div>
</div>
</button>
{/* ConfigModal positioned absolutely to avoid nesting buttons */}
<div className="absolute top-4 right-4 z-20">
<ConfigModal generatePodcast={generatePodcast} />
</div>
</motion.div>
</div>
) : (
<motion.button
title={podcastIsStale ? "Regenerate Podcast" : "Generate Podcast"}
type="button"
onClick={() =>
setChatUIState((prev) => ({
...prev,
isChatPannelOpen: !isChatPannelOpen,
}))
}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className={cn(
"p-2.5 rounded-full transition-colors shadow-sm",
podcastIsStale
? "bg-amber-500/20 hover:bg-amber-500/30 text-amber-600 dark:text-amber-400"
: "bg-primary/20 hover:bg-primary/30 text-primary"
)}
>
{podcastIsStale ? <RefreshCw className="h-5 w-5" /> : <Sparkles className="h-5 w-5" />}
</motion.button>
)}
</div>
{podcast ? (
<div
className={cn(
"w-full border-t",
!isChatPannelOpen && "flex items-center justify-center p-4"
)}
>
{isChatPannelOpen ? (
<PodcastPlayer compact podcast={podcast} />
) : podcast ? (
<motion.button
title="Play Podcast"
type="button"
onClick={() => setChatUIState((prev) => ({ ...prev, isChatPannelOpen: true }))}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="p-2.5 rounded-full bg-green-500/20 hover:bg-green-500/30 text-green-600 dark:text-green-400 transition-colors shadow-sm"
>
<Play className="h-5 w-5" />
</motion.button>
) : null}
</div>
) : null}
</div>
);
// biome-ignore-end lint/a11y/useSemanticElements : using div for custom layout — will convert later
}

View file

@ -1,84 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { Pencil } from "lucide-react";
import { useCallback, useContext, useState } from "react";
import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import type { GeneratePodcastRequest } from "./ChatPanelContainer";
interface ConfigModalProps {
generatePodcast: (request: GeneratePodcastRequest) => Promise<void>;
}
export function ConfigModal(props: ConfigModalProps) {
const { data: activeChatState } = useAtomValue(activeChatAtom);
const chatDetails = activeChatState?.chatDetails;
const podcast = activeChatState?.podcast;
const { generatePodcast } = props;
const [userPromt, setUserPrompt] = useState("");
const handleGeneratePost = useCallback(async () => {
if (!chatDetails) return;
await generatePodcast({
type: "CHAT",
ids: [chatDetails.id],
search_space_id: chatDetails.search_space_id,
podcast_title: podcast?.title || chatDetails.title,
user_prompt: userPromt,
});
}, [chatDetails, userPromt]);
return (
<Popover>
<PopoverTrigger
title="Edit the prompt"
className="rounded-full p-2 bg-slate-400/30 hover:bg-slate-400/40"
onClick={(e) => e.stopPropagation()}
>
<Pencil strokeWidth={1} className="h-4 w-4" />
</PopoverTrigger>
<PopoverContent onClick={(e) => e.stopPropagation()} align="end" className="bg-sidebar w-96 ">
<form className="flex flex-col gap-3 w-full">
<label className="text-sm font-medium" htmlFor="prompt">
Special user instructions
</label>
<p className="text-xs text-slate-500 dark:text-slate-400">
Leave empty to use the default prompt
</p>
<div className="text-xs text-slate-500 dark:text-slate-400 space-y-1">
<p>Examples:</p>
<ul className="list-disc list-inside space-y-0.5">
<li>Make hosts speak in London street language</li>
<li>Use real-world analogies and metaphors</li>
<li>Add dramatic pauses like a late-night radio show</li>
<li>Include 90s pop culture references</li>
</ul>
</div>
<textarea
name="prompt"
id="prompt"
defaultValue={userPromt}
className="w-full rounded-md border border-slate-400/40 p-2"
onChange={(e) => {
e.stopPropagation();
setUserPrompt(e.target.value);
}}
></textarea>
<button
type="button"
onClick={handleGeneratePost}
className="w-full rounded-md bg-foreground text-white dark:text-black p-2"
>
Generate Podcast
</button>
</form>
</PopoverContent>
</Popover>
);
}

View file

@ -1,329 +0,0 @@
"use client";
import { Pause, Play, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
import { motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import type { Podcast } from "@/contracts/types/podcast.types";
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
import { PodcastPlayerCompactSkeleton } from "./PodcastPlayerCompactSkeleton";
interface PodcastPlayerProps {
podcast: Podcast | null;
isLoading?: boolean;
onClose?: () => void;
compact?: boolean;
}
export function PodcastPlayer({
podcast,
isLoading = false,
onClose,
compact = false,
}: PodcastPlayerProps) {
const [audioSrc, setAudioSrc] = useState<string | undefined>(undefined);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(0.7);
const [isMuted, setIsMuted] = useState(false);
const [isFetching, setIsFetching] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const currentObjectUrlRef = useRef<string | null>(null);
// Cleanup object URL on unmount
useEffect(() => {
return () => {
if (currentObjectUrlRef.current) {
URL.revokeObjectURL(currentObjectUrlRef.current);
currentObjectUrlRef.current = null;
}
};
}, []);
// Load podcast audio when podcast changes
useEffect(() => {
if (!podcast) {
setAudioSrc(undefined);
setCurrentTime(0);
setDuration(0);
setIsPlaying(false);
setIsFetching(false);
return;
}
const loadPodcast = async () => {
setIsFetching(true);
try {
// Revoke previous object URL if exists
if (currentObjectUrlRef.current) {
URL.revokeObjectURL(currentObjectUrlRef.current);
currentObjectUrlRef.current = null;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
try {
const response = await podcastsApiService.loadPodcast({
request: { id: podcast.id },
controller,
});
const objectUrl = URL.createObjectURL(response);
currentObjectUrlRef.current = objectUrl;
setAudioSrc(objectUrl);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw new Error("Request timed out. Please try again.");
}
throw error;
} finally {
clearTimeout(timeoutId);
}
} catch (error) {
console.error("Error fetching podcast:", error);
toast.error(error instanceof Error ? error.message : "Failed to load podcast audio.");
setAudioSrc(undefined);
} finally {
setIsFetching(false);
}
};
loadPodcast();
}, [podcast]);
const handleTimeUpdate = () => {
if (audioRef.current) {
setCurrentTime(audioRef.current.currentTime);
}
};
const handleMetadataLoaded = () => {
if (audioRef.current) {
setDuration(audioRef.current.duration);
}
};
const togglePlayPause = () => {
if (audioRef.current) {
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
const handleSeek = (value: number[]) => {
if (audioRef.current) {
audioRef.current.currentTime = value[0];
setCurrentTime(value[0]);
}
};
const handleVolumeChange = (value: number[]) => {
if (audioRef.current) {
const newVolume = value[0];
audioRef.current.volume = newVolume;
setVolume(newVolume);
if (newVolume === 0) {
audioRef.current.muted = true;
setIsMuted(true);
} else {
audioRef.current.muted = false;
setIsMuted(false);
}
}
};
const toggleMute = () => {
if (audioRef.current) {
const newMutedState = !isMuted;
audioRef.current.muted = newMutedState;
setIsMuted(newMutedState);
if (!newMutedState && volume === 0) {
const restoredVolume = 0.5;
audioRef.current.volume = restoredVolume;
setVolume(restoredVolume);
}
}
};
const skipForward = () => {
if (audioRef.current) {
audioRef.current.currentTime = Math.min(
audioRef.current.duration,
audioRef.current.currentTime + 10
);
}
};
const skipBackward = () => {
if (audioRef.current) {
audioRef.current.currentTime = Math.max(0, audioRef.current.currentTime - 10);
}
};
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
};
// Show skeleton while fetching
if (isFetching && compact) {
return <PodcastPlayerCompactSkeleton />;
}
if (!podcast || !audioSrc) {
return null;
}
if (compact) {
return (
<>
<div className="flex flex-col gap-4 p-4">
{/* Audio Visualizer */}
<motion.div
className="relative h-1 bg-gradient-to-r from-primary/20 via-primary/40 to-primary/20 rounded-full overflow-hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
{isPlaying && (
<motion.div
className="absolute inset-0 bg-gradient-to-r from-transparent via-primary to-transparent"
animate={{
x: ["-100%", "100%"],
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "linear",
}}
/>
)}
</motion.div>
{/* Progress Bar with Time */}
<div className="space-y-2">
<Slider
value={[currentTime]}
min={0}
max={duration || 100}
step={0.1}
onValueChange={handleSeek}
className="w-full cursor-pointer"
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="font-mono">{formatTime(currentTime)}</span>
<span className="font-mono">{formatTime(duration)}</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center justify-between">
{/* Left: Volume */}
<div className="flex items-center gap-2 flex-1">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
<Button variant="ghost" size="icon" onClick={toggleMute} className="h-8 w-8">
{isMuted ? (
<VolumeX className="h-4 w-4 text-muted-foreground" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Button>
</motion.div>
</div>
{/* Center: Playback Controls */}
<div className="flex items-center gap-1">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
<Button
variant="ghost"
size="icon"
onClick={skipBackward}
className="h-9 w-9"
disabled={!duration}
>
<SkipBack className="h-4 w-4" />
</Button>
</motion.div>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
animate={
isPlaying
? {
boxShadow: [
"0 0 0 0 rgba(var(--primary), 0)",
"0 0 0 8px rgba(var(--primary), 0.1)",
"0 0 0 0 rgba(var(--primary), 0)",
],
}
: {}
}
transition={{ duration: 1.5, repeat: isPlaying ? Infinity : 0 }}
>
<Button
variant="default"
size="icon"
onClick={togglePlayPause}
className="h-10 w-10 rounded-full"
disabled={!duration}
>
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 ml-0.5" />}
</Button>
</motion.div>
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
<Button
variant="ghost"
size="icon"
onClick={skipForward}
className="h-9 w-9"
disabled={!duration}
>
<SkipForward className="h-4 w-4" />
</Button>
</motion.div>
</div>
{/* Right: Placeholder for symmetry */}
<div className="flex-1" />
</div>
</div>
<audio
ref={audioRef}
src={audioSrc}
preload="auto"
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleMetadataLoaded}
onEnded={() => setIsPlaying(false)}
onError={(e) => {
console.error("Audio error:", e);
if (audioRef.current?.error) {
console.error("Audio error code:", audioRef.current.error.code);
if (audioRef.current.error.code !== audioRef.current.error.MEDIA_ERR_ABORTED) {
toast.error("Error playing audio. Please try again.");
}
}
setIsPlaying(false);
}}
>
<track kind="captions" />
</audio>
</>
);
}
return null;
}

View file

@ -1,40 +0,0 @@
"use client";
import { Podcast } from "lucide-react";
import { motion } from "motion/react";
export function PodcastPlayerCompactSkeleton() {
return (
<div className="flex flex-col gap-3 p-3">
{/* Header with icon and title */}
<div className="flex items-center gap-2">
<motion.div
className="w-8 h-8 bg-primary/20 rounded-md flex items-center justify-center flex-shrink-0"
animate={{ scale: [1, 1.05, 1] }}
transition={{
repeat: Infinity,
duration: 2,
}}
>
<Podcast className="h-4 w-4 text-primary" />
</motion.div>
{/* Title skeleton */}
<div className="h-4 bg-muted rounded w-32 flex-grow animate-pulse" />
</div>
{/* Progress bar skeleton */}
<div className="flex items-center gap-1">
<div className="h-1 bg-muted rounded flex-grow animate-pulse" />
<div className="h-4 bg-muted rounded w-12 animate-pulse" />
</div>
{/* Controls skeleton */}
<div className="flex items-center justify-between gap-1">
<div className="h-7 w-7 bg-muted rounded-full animate-pulse" />
<div className="h-8 w-8 bg-primary/20 rounded-full animate-pulse" />
<div className="h-7 w-7 bg-muted rounded-full animate-pulse" />
<div className="h-7 w-7 bg-muted rounded-full animate-pulse" />
</div>
</div>
);
}

View file

@ -1,2 +0,0 @@
export { PodcastPlayer } from "./PodcastPlayer";
export { PodcastPlayerCompactSkeleton } from "./PodcastPlayerCompactSkeleton";

View file

@ -1,226 +0,0 @@
"use client";
import { getAnnotationData, type Message } from "@llamaindex/chat-ui";
import { ExternalLink, FileText } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { SourceDetailSheet } from "./SourceDetailSheet";
interface Source {
id: string;
title: string;
description: string;
url: string;
sourceType: string;
}
interface SourceGroup {
id: number;
name: string;
type: string;
sources: Source[];
}
// New interfaces for the updated data format
interface NodeMetadata {
title: string;
source_type: string;
group_name: string;
}
interface SourceNode {
id: string;
text: string;
url: string;
metadata: NodeMetadata;
}
function getSourceIcon(type: string) {
// Handle USER_SELECTED_ prefix
const normalizedType = type.startsWith("USER_SELECTED_")
? type.replace("USER_SELECTED_", "")
: type;
return getConnectorIcon(normalizedType, "h-4 w-4");
}
function SourceCard({ source }: { source: Source }) {
const hasUrl = source.url && source.url.trim() !== "";
const chunkId = Number(source.id);
const sourceType = source.sourceType;
const [isOpen, setIsOpen] = useState(false);
// Clean up the description for better display
const cleanDescription = source.description
.replace(/## Metadata\n\n/g, "")
.replace(/\n+/g, " ")
.trim();
const handleUrlClick = (e: React.MouseEvent, url: string) => {
e.preventDefault();
e.stopPropagation();
window.open(url, "_blank", "noopener,noreferrer");
};
return (
<SourceDetailSheet
open={isOpen}
onOpenChange={setIsOpen}
chunkId={chunkId}
sourceType={sourceType}
title={source.title}
description={source.description}
url={source.url}
>
<SheetTrigger asChild>
<Card className="border-muted hover:border-muted-foreground/20 transition-colors cursor-pointer">
<CardHeader className="pb-3 pt-3">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-sm font-medium leading-tight line-clamp-2 flex-1">
{source.title}
</CardTitle>
<div className="flex items-center gap-1 flex-shrink-0">
<Badge variant="secondary" className="text-[10px] h-5 px-2 font-mono">
#{chunkId}
</Badge>
{hasUrl && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 flex-shrink-0 hover:bg-muted"
onClick={(e) => handleUrlClick(e, source.url)}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="pt-0 pb-3">
<CardDescription className="text-xs line-clamp-3 leading-relaxed text-muted-foreground">
{cleanDescription}
</CardDescription>
</CardContent>
</Card>
</SheetTrigger>
</SourceDetailSheet>
);
}
export default function ChatSourcesDisplay({ message }: { message: Message }) {
const [open, setOpen] = useState(false);
const annotations = getAnnotationData(message, "sources");
// Transform the new data format to the expected SourceGroup format
const sourceGroups: SourceGroup[] = [];
if (Array.isArray(annotations) && annotations.length > 0) {
// Extract all nodes from the response
const allNodes: SourceNode[] = [];
annotations.forEach((item) => {
if (item && typeof item === "object" && "nodes" in item && Array.isArray(item.nodes)) {
allNodes.push(...item.nodes);
}
});
// Group nodes by source_type
const groupedByType = allNodes.reduce(
(acc, node) => {
const sourceType = node.metadata.source_type;
if (!acc[sourceType]) {
acc[sourceType] = [];
}
acc[sourceType].push(node);
return acc;
},
{} as Record<string, SourceNode[]>
);
// Convert grouped nodes to SourceGroup format
Object.entries(groupedByType).forEach(([sourceType, nodes], index) => {
if (nodes.length > 0) {
const firstNode = nodes[0];
sourceGroups.push({
id: index + 100, // Generate unique ID
name: firstNode.metadata.group_name,
type: sourceType,
sources: nodes.map((node) => ({
id: node.id,
title: node.metadata.title,
description: node.text,
url: node.url || "",
sourceType: sourceType,
})),
});
}
});
}
if (sourceGroups.length === 0) {
return null;
}
const totalSources = sourceGroups.reduce((acc, group) => acc + group.sources.length, 0);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="sm" className="w-fit">
<FileText className="h-4 w-4 mr-2" />
View Sources ({totalSources})
</Button>
</SheetTrigger>
<SheetContent className="w-[400px] sm:w-[540px] md:w-[640px] lg:w-[720px] xl:w-[800px] sm:max-w-[540px] md:max-w-[640px] lg:max-w-[720px] xl:max-w-[800px] flex flex-col p-0 overflow-hidden">
<SheetHeader className="px-6 py-4 border-b flex-shrink-0">
<div className="flex items-center justify-between">
<SheetTitle className="text-lg font-semibold">Sources</SheetTitle>
<Badge variant="outline" className="font-normal">
{totalSources} {totalSources === 1 ? "source" : "sources"}
</Badge>
</div>
</SheetHeader>
<Tabs defaultValue={sourceGroups[0]?.type} className="flex-1 flex flex-col min-h-0">
<div className="flex-shrink-0 w-full overflow-x-auto px-6 pt-4 scrollbar-none">
<TabsList className="flex w-max min-w-full bg-muted/50">
{sourceGroups.map((group) => (
<TabsTrigger
key={group.type}
value={group.type}
className="flex items-center gap-2 whitespace-nowrap px-4 data-[state=active]:bg-background data-[state=active]:shadow-sm"
>
{getSourceIcon(group.type)}
<span className="truncate max-w-[120px] md:max-w-[180px] lg:max-w-none">
{group.name}
</span>
<Badge variant="secondary" className="ml-1.5 h-5 text-xs flex-shrink-0">
{group.sources.length}
</Badge>
</TabsTrigger>
))}
</TabsList>
</div>
{sourceGroups.map((group) => (
<TabsContent
key={group.type}
value={group.type}
className="flex-1 min-h-0 mt-0 px-6 pb-6 data-[state=active]:flex data-[state=active]:flex-col"
>
<div className="h-full overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
<div className="grid gap-3 pt-4 grid-cols-1 lg:grid-cols-2">
{group.sources.map((source) => (
<SourceCard key={source.id} source={source} />
))}
</div>
</div>
</TabsContent>
))}
</Tabs>
</SheetContent>
</Sheet>
);
}

View file

@ -1,105 +0,0 @@
"use client";
import { getAnnotationData, type Message } from "@llamaindex/chat-ui";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
export default function TerminalDisplay({ message, open }: { message: Message; open: boolean }) {
const [isCollapsed, setIsCollapsed] = useState(!open);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (bottomRef.current) {
bottomRef.current.scrollTo({
top: bottomRef.current.scrollHeight,
behavior: "smooth",
});
}
}, []);
// Get the last assistant message that's not being typed
if (!message) {
return null;
}
interface TerminalInfo {
id: number;
text: string;
type: string;
}
const events = getAnnotationData(message, "TERMINAL_INFO") as TerminalInfo[];
if (events.length === 0) {
return null;
}
return (
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden font-mono text-sm shadow-lg">
{/* Terminal Header */}
<Button
className="w-full bg-gray-800 px-4 py-2 flex items-center gap-2 border-b border-gray-700 cursor-pointer hover:bg-gray-750 transition-colors"
onClick={() => setIsCollapsed(!isCollapsed)}
variant="ghost"
type="button"
>
<div className="flex gap-2">
<div className="w-3 h-3 rounded-full bg-red-500"></div>
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
<div className="w-3 h-3 rounded-full bg-green-500"></div>
</div>
<div className="text-gray-400 text-xs ml-2 flex-1">
Agent Process Terminal ({events.length} events)
</div>
<div className="text-gray-400">
{isCollapsed ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<title>Collapse</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<title>Expand</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 15l7-7 7 7"
/>
</svg>
)}
</div>
</Button>
{/* Terminal Content (animated expand/collapse) */}
<div
className={`overflow-hidden bg-gray-900 transition-[max-height,opacity] duration-300 ease-in-out ${
isCollapsed ? "max-h-0 opacity-0" : "max-h-64 opacity-100"
}`}
style={{ maxHeight: isCollapsed ? "0px" : "16rem" }}
aria-hidden={isCollapsed}
>
<div ref={bottomRef} className="h-64 overflow-y-auto p-4 space-y-1">
{events.map((event, index) => (
<div key={`${event.id}-${index}`} className="text-green-400">
<span className="text-blue-400">$</span>
<span className="text-yellow-400 ml-2">[{event.type || ""}]</span>
<span className="text-gray-300 ml-4 mt-1 pl-2 border-l-2 border-gray-600">
{event.text || ""}...
</span>
</div>
))}
{events.length === 0 && (
<div className="text-gray-500 italic">No agent events to display...</div>
)}
</div>
</div>
</div>
);
}

View file

@ -1,118 +0,0 @@
import { ExternalLink } from "lucide-react";
import { memo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getConnectorIcon } from "./ConnectorComponents";
import type { Source } from "./types";
type CitationProps = {
citationId: number;
citationText: string;
position: number;
source: Source | null;
};
/**
* Citation component to handle individual citations
*/
export const Citation = memo(({ citationId, citationText, position, source }: CitationProps) => {
const [open, setOpen] = useState(false);
const citationKey = `citation-${citationId}-${position}`;
if (!source) return <>{citationText}</>;
return (
<span key={citationKey} className="relative inline-flex items-center">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<sup>
<span className="inline-flex items-center justify-center text-primary cursor-pointer bg-primary/10 hover:bg-primary/15 w-4 h-4 rounded-full text-[10px] font-medium ml-0.5 transition-colors border border-primary/20 shadow-sm">
{citationId}
</span>
</sup>
</DropdownMenuTrigger>
{open && (
<DropdownMenuContent align="start" className="w-80 p-0" forceMount>
<Card className="border-0 shadow-none">
<div className="p-3 flex items-start gap-3">
<div className="flex-shrink-0 w-7 h-7 flex items-center justify-center bg-muted rounded-full">
{getConnectorIcon(source.connectorType || "")}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-sm text-card-foreground">{source.title}</h3>
</div>
<p className="text-sm text-muted-foreground mt-0.5">{source.description}</p>
<div className="mt-2 flex items-center text-xs text-muted-foreground">
<span className="truncate max-w-[200px]">{source.url}</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full"
onClick={() => window.open(source.url, "_blank", "noopener,noreferrer")}
title="Open in new tab"
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
</Card>
</DropdownMenuContent>
)}
</DropdownMenu>
</span>
);
});
Citation.displayName = "Citation";
/**
* Function to render text with citations
*/
export const renderTextWithCitations = (
text: string,
getCitationSource: (id: number) => Source | null
) => {
// Regular expression to find citation patterns like [1], [2], etc.
const citationRegex = /\[(\d+)\]/g;
const parts = [];
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);
parts.push(
<Citation
key={`citation-${citationId}-${position}`}
citationId={citationId}
citationText={match[0]}
position={position}
source={getCitationSource(citationId)}
/>
);
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

@ -1,211 +0,0 @@
"use client";
import { Check, Copy } from "lucide-react";
import { useTheme } from "next-themes";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark, oneLight } from "react-syntax-highlighter/dist/cjs/styles/prism";
// Constants for styling and configuration
const COPY_TIMEOUT = 2000;
const BASE_CUSTOM_STYLE = {
margin: 0,
borderRadius: "0.375rem",
fontSize: "0.75rem",
lineHeight: "1.5rem",
border: "none",
} as const;
const LINE_PROPS_STYLE = {
wordBreak: "break-all" as const,
whiteSpace: "pre-wrap" as const,
border: "none",
borderBottom: "none",
paddingLeft: 0,
paddingRight: 0,
margin: "0.25rem 0",
} as const;
const CODE_TAG_PROPS = {
className: "font-mono",
style: {
border: "none",
background: "var(--syntax-bg)",
},
} as const;
// TypeScript interfaces
interface CodeBlockProps {
children: string;
language: string;
}
type LanguageRenderer = (props: { code: string }) => React.JSX.Element;
interface SyntaxStyle {
[key: string]: React.CSSProperties;
}
// Memoized fallback component for SSR/hydration
const FallbackCodeBlock = memo(({ children }: { children: string }) => (
<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>
));
FallbackCodeBlock.displayName = "FallbackCodeBlock";
// Code block component with syntax highlighting and copy functionality
export const CodeBlock = memo<CodeBlockProps>(({ children, language }) => {
const [copied, setCopied] = useState(false);
const { resolvedTheme, theme } = useTheme();
const [mounted, setMounted] = useState(false);
// Prevent hydration issues
useEffect(() => {
setMounted(true);
}, []);
// Memoize theme detection
const isDarkTheme = useMemo(
() => mounted && (resolvedTheme === "dark" || theme === "dark"),
[mounted, resolvedTheme, theme]
);
// Memoize syntax theme selection
const syntaxTheme = useMemo(() => (isDarkTheme ? oneDark : oneLight), [isDarkTheme]);
// Memoize enhanced style with theme-specific modifications
const enhancedStyle = useMemo<SyntaxStyle>(
() => ({
...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)",
},
}),
[syntaxTheme]
);
// Memoize custom style with background
const customStyle = useMemo(
() => ({
...BASE_CUSTOM_STYLE,
backgroundColor: "var(--syntax-bg)",
}),
[]
);
// Memoized copy handler
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(children);
setCopied(true);
const timeoutId = setTimeout(() => setCopied(false), COPY_TIMEOUT);
return () => clearTimeout(timeoutId);
} catch (error) {
console.warn("Failed to copy code to clipboard:", error);
}
}, [children]);
// Memoized line props with style
const lineProps = useMemo(
() => ({
style: LINE_PROPS_STYLE,
}),
[]
);
// Early return for non-mounted state
if (!mounted) {
return <FallbackCodeBlock>{children}</FallbackCodeBlock>;
}
return (
<div className="relative my-4 group">
<div className="absolute right-2 top-2 z-10">
<button
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"
type="button"
>
{copied ? (
<Check size={14} className="text-green-500" />
) : (
<Copy size={14} className="text-muted-foreground" />
)}
</button>
</div>
<SyntaxHighlighter
language={language || "text"}
style={enhancedStyle}
customStyle={customStyle}
codeTagProps={CODE_TAG_PROPS}
showLineNumbers={false}
wrapLines={false}
lineProps={lineProps}
PreTag="div"
>
{children}
</SyntaxHighlighter>
</div>
);
});
CodeBlock.displayName = "CodeBlock";
// Optimized language renderer factory with memoization
const createLanguageRenderer = (lang: string): LanguageRenderer => {
const renderer = ({ code }: { code: string }) => <CodeBlock language={lang}>{code}</CodeBlock>;
renderer.displayName = `LanguageRenderer(${lang})`;
return renderer;
};
// Pre-defined supported languages for better maintainability
const SUPPORTED_LANGUAGES = [
"javascript",
"typescript",
"python",
"java",
"csharp",
"cpp",
"c",
"php",
"ruby",
"go",
"rust",
"swift",
"kotlin",
"scala",
"sql",
"json",
"xml",
"yaml",
"bash",
"shell",
"powershell",
"dockerfile",
"html",
"css",
"scss",
"less",
"markdown",
"text",
] as const;
// Generate language renderers efficiently
export const languageRenderers: Record<string, LanguageRenderer> = Object.fromEntries(
SUPPORTED_LANGUAGES.map((lang) => [lang, createLanguageRenderer(lang)])
);

View file

@ -1,109 +0,0 @@
import { ChevronDown, Plus } from "lucide-react";
import type React from "react";
import { Button } from "@/components/ui/button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { Connector } from "./types";
/**
* Displays a small icon for a connector type
*/
export const ConnectorIcon = ({ type, index = 0 }: { type: string; index?: number }) => (
<div
className="w-4 h-4 rounded-full flex items-center justify-center bg-muted border border-background"
style={{ zIndex: 10 - index }}
>
{getConnectorIcon(type)}
</div>
);
/**
* Displays a count indicator for additional connectors
*/
export const ConnectorCountBadge = ({ count }: { count: number }) => (
<div className="w-4 h-4 rounded-full flex items-center justify-center bg-primary text-primary-foreground text-[8px] font-medium border border-background z-0">
+{count}
</div>
);
type ConnectorButtonProps = {
selectedConnectors: string[];
onClick: () => void;
connectorSources: Connector[];
};
/**
* Button that displays selected connectors and opens connector selection dialog
*/
export const ConnectorButton = ({
selectedConnectors,
onClick,
connectorSources,
}: ConnectorButtonProps) => {
const totalConnectors = connectorSources.length;
const selectedCount = selectedConnectors.length;
const progressPercentage = (selectedCount / totalConnectors) * 100;
// Get the name of a single selected connector
const getSingleConnectorName = () => {
const connector = connectorSources.find((c) => c.type === selectedConnectors[0]);
return connector?.name || "";
};
// Get display text based on selection count
const getDisplayText = () => {
if (selectedCount === totalConnectors) return "All Connectors";
if (selectedCount === 1) return getSingleConnectorName();
return `${selectedCount} Connectors`;
};
// Render the empty state (no connectors selected)
const renderEmptyState = () => (
<>
<Plus className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">Select Connectors</span>
</>
);
// Render the selected connectors preview
const renderSelectedConnectors = () => (
<>
<div className="flex -space-x-1.5 mr-1">
{/* Show up to 3 connector icons */}
{selectedConnectors.slice(0, 3).map((type, index) => (
<ConnectorIcon key={type} type={type} index={index} />
))}
{/* Show count indicator if more than 3 connectors are selected */}
{selectedCount > 3 && <ConnectorCountBadge count={selectedCount - 3} />}
</div>
{/* Display text */}
<span className="font-medium">{getDisplayText()}</span>
</>
);
return (
<Button
variant="outline"
className="h-8 px-2 text-xs font-medium rounded-md border-border relative overflow-hidden group"
onClick={onClick}
aria-label={
selectedCount === 0 ? "Select Connectors" : `${selectedCount} connectors selected`
}
>
{/* Progress indicator */}
<div
className="absolute bottom-0 left-0 h-1 bg-primary"
style={{
width: `${progressPercentage}%`,
transition: "width 0.3s ease",
}}
/>
<div className="flex items-center gap-1.5 z-10 relative">
{selectedCount === 0 ? renderEmptyState() : renderSelectedConnectors()}
<ChevronDown className="h-3 w-3 ml-0.5 text-muted-foreground opacity-70" />
</div>
</Button>
);
};

View file

@ -1,604 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { useAtomValue } from "jotai";
import { ArrowUpDown, Calendar, FileText, Filter, Plus, Search } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { Document, DocumentTypeEnum } from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
interface DocumentsDataTableProps {
searchSpaceId: number;
onSelectionChange: (documents: Document[]) => void;
onDone: () => void;
initialSelectedDocuments?: Document[];
}
function useDebounced<T>(value: T, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}
const columns: ColumnDef<Document>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
size: 40,
},
{
accessorKey: "title",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-8 px-1 sm:px-2 font-medium text-left justify-start"
>
<FileText className="mr-1 sm:mr-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" />
<span className="hidden sm:inline">Title</span>
<span className="sm:hidden">Doc</span>
<ArrowUpDown className="ml-1 sm:ml-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" />
</Button>
),
cell: ({ row }) => {
const title = row.getValue("title") as string;
return (
<div
className="font-medium max-w-[120px] sm:max-w-[250px] truncate text-xs sm:text-sm"
title={title}
>
{title}
</div>
);
},
},
{
accessorKey: "document_type",
header: "Type",
cell: ({ row }) => {
const type = row.getValue("document_type") as DocumentType;
return (
<div className="flex items-center gap-2" title={String(type)}>
<span className="text-primary">{getConnectorIcon(String(type))}</span>
</div>
);
},
size: 80,
meta: {
className: "hidden sm:table-cell",
},
},
{
accessorKey: "content",
header: "Preview",
cell: ({ row }) => {
const content = row.getValue("content") as string;
return (
<div
className="text-muted-foreground max-w-[150px] sm:max-w-[350px] truncate text-[10px] sm:text-sm"
title={content}
>
<span className="sm:hidden">{content.substring(0, 30)}...</span>
<span className="hidden sm:inline">{content.substring(0, 100)}...</span>
</div>
);
},
enableSorting: false,
meta: {
className: "hidden md:table-cell",
},
},
{
accessorKey: "created_at",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-8 px-1 sm:px-2 font-medium"
>
<Calendar className="mr-1 sm:mr-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" />
<span className="hidden sm:inline">Created</span>
<span className="sm:hidden">Date</span>
<ArrowUpDown className="ml-1 sm:ml-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" />
</Button>
),
cell: ({ row }) => {
const date = new Date(row.getValue("created_at"));
return (
<div className="text-xs sm:text-sm whitespace-nowrap">
<span className="hidden sm:inline">
{date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
<span className="sm:hidden">
{date.toLocaleDateString("en-US", {
month: "numeric",
day: "numeric",
})}
</span>
</div>
);
},
size: 80,
},
];
export function DocumentsDataTable({
searchSpaceId,
onSelectionChange,
onDone,
initialSelectedDocuments = [],
}: DocumentsDataTableProps) {
const router = useRouter();
const [sorting, setSorting] = useState<SortingState>([]);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounced(search, 300);
const [documentTypeFilter, setDocumentTypeFilter] = useState<DocumentTypeEnum[]>([]);
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const { data: typeCounts } = useAtomValue(documentTypeCountsAtom);
const fetchQueryParams = useMemo(
() => ({
search_space_id: searchSpaceId,
page: pageIndex,
page_size: pageSize,
...(documentTypeFilter.length > 0 && { document_types: documentTypeFilter }),
}),
[searchSpaceId, pageIndex, pageSize, documentTypeFilter, debouncedSearch]
);
const searchQueryParams = useMemo(() => {
return {
search_space_id: searchSpaceId,
page: pageIndex,
page_size: pageSize,
...(documentTypeFilter.length > 0 && { document_types: documentTypeFilter }),
title: debouncedSearch,
};
}, [debouncedSearch, searchSpaceId, pageIndex, pageSize, documentTypeFilter, debouncedSearch]);
// Use query for fetching documents
const { data: documents, isLoading: isDocumentsLoading } = useQuery({
queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams),
queryFn: () => documentsApiService.getDocuments({ queryParams: fetchQueryParams }),
staleTime: 3 * 60 * 1000, // 3 minutes
enabled: !!searchSpaceId && !debouncedSearch.trim(),
});
// Seaching
const { data: searchedDocuments, isLoading: isSearchedDocumentsLoading } = useQuery({
queryKey: cacheKeys.documents.withQueryParams(searchQueryParams),
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
staleTime: 3 * 60 * 1000, // 3 minutes
enabled: !!searchSpaceId && !!debouncedSearch.trim(),
});
// Use query data when not searching, otherwise use hook data
const actualDocuments = debouncedSearch.trim()
? searchedDocuments?.items || []
: documents?.items || [];
const actualTotal = debouncedSearch.trim()
? searchedDocuments?.total || 0
: documents?.total || 0;
const actualLoading = debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading;
// Memoize initial row selection to prevent infinite loops
const initialRowSelection = useMemo(() => {
if (!initialSelectedDocuments.length) return {};
const selection: Record<string, boolean> = {};
initialSelectedDocuments.forEach((selectedDoc) => {
selection[selectedDoc.id] = true;
});
return selection;
}, [initialSelectedDocuments]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>(
() => initialRowSelection
);
// Maintain a separate state for actually selected documents (across all pages)
const [selectedDocumentsMap, setSelectedDocumentsMap] = useState<Map<number, Document>>(() => {
const map = new Map<number, Document>();
initialSelectedDocuments.forEach((doc) => map.set(doc.id, doc));
return map;
});
// Track the last notified selection to avoid redundant parent calls
const lastNotifiedSelection = useRef<string>("");
// Update row selection only when initialSelectedDocuments changes (not rowSelection itself)
useEffect(() => {
const initialKeys = Object.keys(initialRowSelection);
if (initialKeys.length === 0) return;
const currentKeys = Object.keys(rowSelection);
// Quick length check before expensive comparison
if (currentKeys.length === initialKeys.length) {
// Check if all keys match (order doesn't matter for Sets)
const hasAllKeys = initialKeys.every((key) => rowSelection[key]);
if (hasAllKeys) return;
}
setRowSelection(initialRowSelection);
}, [initialRowSelection]); // Remove rowSelection from dependencies to prevent loop
// Update the selected documents map when row selection changes
useEffect(() => {
if (!actualDocuments || actualDocuments.length === 0) return;
setSelectedDocumentsMap((prev) => {
const newMap = new Map(prev);
let hasChanges = false;
// Process only current page documents
for (const doc of actualDocuments) {
const docId = doc.id;
const isSelected = rowSelection[docId.toString()];
const wasInMap = newMap.has(docId);
if (isSelected && !wasInMap) {
newMap.set(docId, doc);
hasChanges = true;
} else if (!isSelected && wasInMap) {
newMap.delete(docId);
hasChanges = true;
}
}
// Return same reference if no changes to avoid unnecessary re-renders
return hasChanges ? newMap : prev;
});
}, [rowSelection, documents]);
// Memoize selected documents array
const selectedDocumentsArray = useMemo(() => {
return Array.from(selectedDocumentsMap.values());
}, [selectedDocumentsMap]);
// Notify parent of selection changes only when content actually changes
useEffect(() => {
// Create a stable string representation for comparison
const selectionKey = selectedDocumentsArray
.map((d) => d.id)
.sort()
.join(",");
// Skip if selection hasn't actually changed
if (selectionKey === lastNotifiedSelection.current) return;
lastNotifiedSelection.current = selectionKey;
onSelectionChange(selectedDocumentsArray);
}, [selectedDocumentsArray, onSelectionChange]);
const table = useReactTable({
data: actualDocuments || [],
columns,
getRowId: (row) => row.id.toString(),
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: setRowSelection,
manualPagination: true,
pageCount: Math.ceil(actualTotal / pageSize),
state: { sorting, rowSelection, pagination: { pageIndex, pageSize } },
});
const handleClearAll = useCallback(() => {
setRowSelection({});
setSelectedDocumentsMap(new Map());
}, []);
const handleSelectPage = useCallback(() => {
const currentPageRows = table.getRowModel().rows;
const newSelection = { ...rowSelection };
currentPageRows.forEach((row) => {
newSelection[row.id] = true;
});
setRowSelection(newSelection);
}, [table, rowSelection]);
const handleToggleType = useCallback((type: DocumentTypeEnum, checked: boolean) => {
setDocumentTypeFilter((prev) => {
if (checked) {
return [...prev, type];
}
return prev.filter((t) => t !== type);
});
setPageIndex(0); // Reset to first page when filter changes
}, []);
const selectedCount = selectedDocumentsMap.size;
// Get available document types from type counts (memoized)
const availableTypes = useMemo(() => {
const types = typeCounts ? (Object.keys(typeCounts) as DocumentTypeEnum[]) : [];
return types.length > 0 ? types.sort() : [];
}, [typeCounts]);
return (
<div className="flex flex-col h-full space-y-3 md:space-y-4">
{/* Header Controls */}
<div className="space-y-3 md:space-y-4 flex-shrink-0">
{/* Search and Filter Row */}
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<div className="relative flex-1 max-w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search documents..."
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPageIndex(0); // Reset to first page on search
}}
className="pl-10 text-sm"
/>
</div>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full sm:w-auto">
<Filter className="mr-2 h-4 w-4 opacity-60" />
Type
{documentTypeFilter.length > 0 && (
<span className="ml-2 inline-flex h-5 items-center rounded border border-border bg-background px-1.5 text-[0.625rem] font-medium text-muted-foreground/70">
{documentTypeFilter.length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" align="start">
<div className="space-y-3">
<div className="text-xs font-medium text-muted-foreground">Filter by Type</div>
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{availableTypes.map((type) => (
<div key={type} className="flex items-center gap-2">
<Checkbox
id={`type-${type}`}
checked={documentTypeFilter.includes(type)}
onCheckedChange={(checked) => handleToggleType(type, !!checked)}
/>
<Label
htmlFor={`type-${type}`}
className="flex grow justify-between gap-2 font-normal text-sm cursor-pointer"
>
<span>{type.replace(/_/g, " ")}</span>
<span className="text-xs text-muted-foreground">{typeCounts?.[type]}</span>
</Label>
</div>
))}
</div>
{documentTypeFilter.length > 0 && (
<Button
variant="ghost"
size="sm"
className="w-full text-xs"
onClick={() => {
setDocumentTypeFilter([]);
setPageIndex(0);
}}
>
Clear Filters
</Button>
)}
</div>
</PopoverContent>
</Popover>
</div>
{/* Action Controls Row */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{selectedCount} selected {actualLoading && "· Loading..."}
</span>
<div className="hidden sm:block h-4 w-px bg-border mx-2" />
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
disabled={selectedCount === 0}
className="text-xs sm:text-sm"
>
Clear All
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleSelectPage}
className="text-xs sm:text-sm"
disabled={actualLoading}
>
Select Page
</Button>
<Select
value={pageSize.toString()}
onValueChange={(v) => {
setPageSize(Number(v));
setPageIndex(0);
}}
>
<SelectTrigger className="w-[100px] h-8 text-xs">
<SelectValue>{pageSize} per page</SelectValue>
</SelectTrigger>
<SelectContent>
{[10, 25, 50, 100].map((size) => (
<SelectItem key={size} value={size.toString()}>
{size} per page
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button
onClick={onDone}
disabled={selectedCount === 0}
className="w-full sm:w-auto sm:min-w-[100px]"
>
Done ({selectedCount})
</Button>
</div>
</div>
{/* Table Container */}
<div className="border rounded-lg flex-1 min-h-0 overflow-hidden bg-background">
<div className="overflow-auto h-full">
{actualLoading ? (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full mx-auto" />
<p className="text-sm text-muted-foreground">Loading documents...</p>
</div>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="border-b">
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="h-12 text-xs sm:text-sm">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="hover:bg-muted/30"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-3 text-xs sm:text-sm">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-64">
<div className="flex flex-col items-center justify-center gap-4 py-8">
<div className="rounded-full bg-muted p-3">
<FileText className="h-6 w-6 text-muted-foreground" />
</div>
<div className="space-y-2 text-center max-w-sm">
<h3 className="font-semibold">No documents found</h3>
<p className="text-sm text-muted-foreground">
Get started by adding your first data source to build your knowledge
base.
</p>
</div>
<Button
size="sm"
onClick={() => router.push(`/dashboard/${searchSpaceId}/sources/add`)}
>
<Plus className="mr-2 h-4 w-4" />
Add Sources
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
</div>
{/* Footer Pagination */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs sm:text-sm text-muted-foreground border-t pt-3 md:pt-4 flex-shrink-0">
<div className="text-center sm:text-left">
Showing {pageIndex * pageSize + 1} to {Math.min((pageIndex + 1) * pageSize, actualTotal)}{" "}
of {actualTotal} documents
</div>
<div className="flex items-center justify-center sm:justify-end space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
disabled={pageIndex === 0 || actualLoading}
className="text-xs sm:text-sm"
>
Previous
</Button>
<div className="flex items-center space-x-1 text-xs sm:text-sm">
<span>Page</span>
<strong>{pageIndex + 1}</strong>
<span>of</span>
<strong>{Math.ceil(actualTotal / pageSize)}</strong>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex((p) => p + 1)}
disabled={pageIndex >= Math.ceil(actualTotal / pageSize) - 1 || actualLoading}
className="text-xs sm:text-sm"
>
Next
</Button>
</div>
</div>
</div>
);
}

View file

@ -1,43 +0,0 @@
/**
* Determines if a podcast is stale compared to the current chat state.
* A podcast is considered stale if:
* - The chat's current state_version is greater than the podcast's chat_state_version
*
* @param chatVersion - The current state_version of the chat
* @param podcastVersion - The chat_state_version stored when the podcast was generated (nullable)
* @returns true if the podcast is stale, false otherwise
*/
export function isPodcastStale(
chatVersion: number,
podcastVersion: number | null | undefined
): boolean {
// If podcast has no version, it's stale (generated before this feature)
if (!podcastVersion) {
return true;
}
// If chat version is greater than podcast version, it's stale : We can change this condition to consider staleness after a huge number of updates
return chatVersion > podcastVersion;
}
/**
* Gets a human-readable message about podcast staleness
*
* @param chatVersion - The current state_version of the chat
* @param podcastVersion - The chat_state_version stored when the podcast was generated
* @returns A descriptive message about the podcast's staleness status
*/
export function getPodcastStalenessMessage(
chatVersion: number,
podcastVersion: number | null | undefined
): string {
if (!podcastVersion) {
return "This podcast was generated before chat updates were tracked. Consider regenerating it.";
}
if (chatVersion > podcastVersion) {
const versionDiff = chatVersion - podcastVersion;
return `This podcast is outdated. The chat has been updated ${versionDiff} time${versionDiff > 1 ? "s" : ""} since this podcast was generated.`;
}
return "This podcast is up to date with the current chat.";
}

View file

@ -1,81 +0,0 @@
import { type RefObject, useEffect } from "react";
/**
* Function to scroll to the bottom of a container
*/
export const scrollToBottom = (ref: RefObject<HTMLDivElement>) => {
ref.current?.scrollIntoView({ behavior: "smooth" });
};
/**
* Hook to scroll to bottom when messages change
*/
export const useScrollToBottom = (ref: RefObject<HTMLDivElement>, dependencies: any[]) => {
useEffect(() => {
scrollToBottom(ref);
}, dependencies);
};
/**
* Function to check scroll position and update indicators
*/
export const updateScrollIndicators = (
tabsListRef: RefObject<HTMLDivElement>,
setCanScrollLeft: (value: boolean) => void,
setCanScrollRight: (value: boolean) => void
) => {
if (tabsListRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = tabsListRef.current;
setCanScrollLeft(scrollLeft > 0);
setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 10); // 10px buffer
}
};
/**
* Hook to initialize scroll indicators and add resize listener
*/
export const useScrollIndicators = (
tabsListRef: RefObject<HTMLDivElement>,
setCanScrollLeft: (value: boolean) => void,
setCanScrollRight: (value: boolean) => void
) => {
const updateIndicators = () =>
updateScrollIndicators(tabsListRef, setCanScrollLeft, setCanScrollRight);
useEffect(() => {
updateIndicators();
// Add resize listener to update indicators when window size changes
window.addEventListener("resize", updateIndicators);
return () => window.removeEventListener("resize", updateIndicators);
}, [updateIndicators]);
return updateIndicators;
};
/**
* Function to scroll tabs list left
*/
export const scrollTabsLeft = (
tabsListRef: RefObject<HTMLDivElement>,
updateIndicators: () => void
) => {
if (tabsListRef.current) {
tabsListRef.current.scrollBy({ left: -200, behavior: "smooth" });
// Update indicators after scrolling
setTimeout(updateIndicators, 300);
}
};
/**
* Function to scroll tabs list right
*/
export const scrollTabsRight = (
tabsListRef: RefObject<HTMLDivElement>,
updateIndicators: () => void
) => {
if (tabsListRef.current) {
tabsListRef.current.scrollBy({ left: 200, behavior: "smooth" });
// Update indicators after scrolling
setTimeout(updateIndicators, 300);
}
};

View file

@ -1,41 +0,0 @@
import type React from "react";
import { Button } from "@/components/ui/button";
type SegmentedControlProps<T extends string> = {
value: T;
onChange: (value: T) => void;
options: Array<{
value: T;
label: string;
icon: React.ReactNode;
}>;
};
/**
* A segmented control component for selecting between different options
*/
function SegmentedControl<T extends string>({
value,
onChange,
options,
}: SegmentedControlProps<T>) {
return (
<div className="flex h-7 rounded-md border border-border overflow-hidden">
{options.map((option) => (
<Button
key={option.value}
className={`flex h-full items-center gap-1 px-2 text-xs transition-colors ${
value === option.value ? "bg-primary text-primary-foreground" : "hover:bg-muted"
}`}
onClick={() => onChange(option.value)}
aria-pressed={value === option.value}
>
{option.icon}
<span>{option.label}</span>
</Button>
))}
</div>
);
}
export default SegmentedControl;

View file

@ -1,254 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, ChevronUp, ExternalLink, Loader2 } from "lucide-react";
import type React from "react";
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
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 {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
interface SourceDetailSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
chunkId: number;
sourceType: string;
title: string;
description?: string;
url?: string;
children?: ReactNode;
}
const formatDocumentType = (type: string) => {
return type
.split("_")
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
.join(" ");
};
export function SourceDetailSheet({
open,
onOpenChange,
chunkId,
sourceType,
title,
description,
url,
children,
}: SourceDetailSheetProps) {
const chunksContainerRef = useRef<HTMLDivElement>(null);
const highlightedChunkRef = useRef<HTMLDivElement>(null);
const [summaryOpen, setSummaryOpen] = useState(false);
const {
data: document,
isLoading: isDocumentByChunkFetching,
error: documentByChunkFetchingError,
} = useQuery({
queryKey: cacheKeys.documents.byChunk(chunkId.toString()),
queryFn: () => documentsApiService.getDocumentByChunk({ chunk_id: chunkId }),
enabled: !!chunkId && open,
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Check if this is a source type that should render directly from node
const isDirectRenderSource =
sourceType === "TAVILY_API" ||
sourceType === "LINKUP_API" ||
sourceType === "SEARXNG_API" ||
sourceType === "BAIDU_SEARCH_API";
useEffect(() => {
// Scroll to highlighted chunk when document loads
if (document) {
setTimeout(() => {
highlightedChunkRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
}, 100);
}
}, [document, open]);
const handleUrlClick = (e: React.MouseEvent, clickUrl: string) => {
e.preventDefault();
e.stopPropagation();
window.open(clickUrl, "_blank", "noopener,noreferrer");
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
{children}
<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">
{getConnectorIcon(sourceType)}
{document?.title || title}
</SheetTitle>
<SheetDescription className="text-base mt-2">
{document
? formatDocumentType(document.document_type)
: sourceType && formatDocumentType(sourceType)}
</SheetDescription>
</SheetHeader>
{!isDirectRenderSource && isDocumentByChunkFetching && (
<div className="flex items-center justify-center h-64 px-6">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{!isDirectRenderSource && documentByChunkFetchingError && (
<div className="flex items-center justify-center h-64 px-6">
<p className="text-sm text-destructive">
{documentByChunkFetchingError.message || "Failed to load document"}
</p>
</div>
)}
{/* Direct render for web search providers */}
{isDirectRenderSource && (
<ScrollArea className="h-[calc(100vh-10rem)]">
<div className="px-6 py-4">
{/* External Link */}
{url && (
<div className="mb-8">
<Button
size="default"
variant="outline"
onClick={(e) => handleUrlClick(e, url)}
className="w-full py-3"
>
<ExternalLink className="mr-2 h-4 w-4" />
Open in Browser
</Button>
</div>
)}
{/* 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">
{title || "Untitled"}
</div>
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
{description || "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 */}
{url && (
<div className="mb-8">
<Button
size="default"
variant="outline"
onClick={(e) => handleUrlClick(e, 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}>
<div className="mb-4">
{/* Header row: header and button side by side */}
<div className="flex flex-row items-center gap-4">
<h3 className="text-base font-semibold mb-2 md:mb-0">Document Content</h3>
{document.content && (
<Collapsible open={summaryOpen} onOpenChange={setSummaryOpen}>
<CollapsibleTrigger className="flex items-center gap-2 py-2 px-3 font-medium border rounded-md bg-muted hover:bg-muted/80 transition-colors">
<span>Summary</span>
{summaryOpen ? (
<ChevronUp className="h-4 w-4 transition-transform" />
) : (
<ChevronDown className="h-4 w-4 transition-transform" />
)}
</CollapsibleTrigger>
</Collapsible>
)}
</div>
{/* Expanded summary content: always full width, below the row */}
{document.content && (
<Collapsible open={summaryOpen} onOpenChange={setSummaryOpen}>
<CollapsibleContent className="pt-2 w-full">
<div className="p-6 bg-muted/50 rounded-lg border">
<MarkdownViewer content={document.content} />
</div>
</CollapsibleContent>
</Collapsible>
)}
</div>
{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,69 +0,0 @@
import type { Connector, Source } from "./types";
/**
* Function to get sources for the main view
*/
export const getMainViewSources = (connector: Connector, initialSourcesDisplay: number) => {
return connector.sources?.slice(0, initialSourcesDisplay);
};
/**
* Function to get filtered sources for the dialog
*/
export const getFilteredSources = (connector: Connector, sourceFilter: string) => {
if (!sourceFilter.trim()) {
return connector.sources;
}
const filter = sourceFilter.toLowerCase().trim();
return connector.sources?.filter(
(source) =>
source.title.toLowerCase().includes(filter) ||
source.description.toLowerCase().includes(filter)
);
};
/**
* Function to get paginated and filtered sources for the dialog
*/
export const getPaginatedDialogSources = (
connector: Connector,
sourceFilter: string,
expandedSources: boolean,
sourcesPage: number,
sourcesPerPage: number
) => {
const filteredSources = getFilteredSources(connector, sourceFilter);
if (expandedSources) {
return filteredSources;
}
return filteredSources?.slice(0, sourcesPage * sourcesPerPage);
};
/**
* Function to get the count of sources for a connector type
*/
export const getSourcesCount = (connectorSources: Connector[], connectorType: string) => {
const connector = connectorSources.find((c) => c.type === connectorType);
return connector?.sources?.length || 0;
};
/**
* Function to get a citation source by ID
*/
export const getCitationSource = (
citationId: number,
connectorSources: Connector[]
): Source | null => {
for (const connector of connectorSources) {
const source = connector.sources?.find((s) => s.id === citationId);
if (source) {
return {
...source,
connectorType: connector.type,
};
}
}
return null;
};

View file

@ -1,9 +0,0 @@
// Export all components and utilities from the chat folder
export * from "./Citation";
export * from "./CodeBlock";
export * from "./ConnectorComponents";
export * from "./ScrollUtils";
export { default as SegmentedControl } from "./SegmentedControl";
export * from "./SourceUtils";
export * from "./types";

View file

@ -1,50 +0,0 @@
/**
* Types for chat components
*/
export type Source = {
id: number;
title: string;
description: string;
url: string;
connectorType?: string;
};
export type Connector = {
id: number;
type: string;
name: string;
sources?: Source[];
};
export type StatusMessage = {
id: number;
message: string;
type: "info" | "success" | "error" | "warning";
timestamp: string;
};
export type ChatMessage = {
id: string;
role: "user" | "assistant";
content: string;
timestamp?: string;
};
// Define message types to match useChat() structure
export type MessageRole = "user" | "assistant" | "system" | "data";
export interface ToolInvocation {
state: "call" | "result";
toolCallId: string;
toolName: string;
args: any;
result?: any;
}
export interface ToolInvocationUIPart {
type: "tool-invocation";
toolInvocation: ToolInvocation;
}
export type ResearchMode = "QNA";

View file

@ -14,9 +14,9 @@ import { cn } from "@/lib/utils";
// Define validation schema matching the database schema
const contactFormSchema = z.object({
name: z.string().min(1, "Name is required").max(255, "Name is too long"),
email: z.string().email("Invalid email address").max(255, "Email is too long"),
email: z.email("Invalid email address").max(255, "Email is too long"),
company: z.string().min(1, "Company is required").max(255, "Company name is too long"),
message: z.string().optional().default(""),
message: z.string().optional().prefault(""),
});
type ContactFormData = z.infer<typeof contactFormSchema>;

View file

@ -1,11 +1,9 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import React, { useEffect, useState } from "react";
import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
import {
Breadcrumb,
BreadcrumbItem,
@ -26,7 +24,6 @@ interface BreadcrumbItemInterface {
export function DashboardBreadcrumb() {
const t = useTranslations("breadcrumb");
const pathname = usePathname();
const { data: activeChatState } = useAtomValue(activeChatAtom);
// Extract search space ID and chat ID from pathname
const segments = pathname.split("/").filter(Boolean);
const searchSpaceId = segments[0] === "dashboard" && segments[1] ? segments[1] : null;
@ -98,13 +95,11 @@ export function DashboardBreadcrumb() {
// Map section names to more readable labels
const sectionLabels: Record<string, string> = {
researcher: t("researcher"),
"new-chat": t("chat") || "Chat",
documents: t("documents"),
connectors: t("connectors"),
sources: "Sources",
podcasts: t("podcasts"),
logs: t("logs"),
chats: t("chats"),
settings: t("settings"),
editor: t("editor"),
};
@ -169,15 +164,12 @@ export function DashboardBreadcrumb() {
return breadcrumbs;
}
// Handle researcher sub-sections (chat IDs)
if (section === "researcher") {
// Use the actual chat title if available, otherwise fall back to the ID
const chatLabel = activeChatState?.chatDetails?.title || subSection;
// Handle new-chat sub-sections (thread IDs)
// Don't show thread ID in breadcrumb - users identify chats by content, not by ID
if (section === "new-chat") {
breadcrumbs.push({
label: t("researcher"),
href: `/dashboard/${segments[1]}/researcher`,
label: t("chat") || "Chat",
});
breadcrumbs.push({ label: chatLabel });
return breadcrumbs;
}
@ -197,7 +189,6 @@ export function DashboardBreadcrumb() {
"slack-connector": "Slack",
"notion-connector": "Notion",
"tavily-api": "Tavily API",
"serper-api": "Serper API",
"linkup-api": "LinkUp API",
"luma-connector": "Luma",
"elasticsearch-connector": "Elasticsearch",

View file

@ -28,7 +28,6 @@ export const editConnectorSchema = z.object({
name: z.string().min(3, { message: "Connector name must be at least 3 characters." }),
SLACK_BOT_TOKEN: z.string().optional(),
NOTION_INTEGRATION_TOKEN: z.string().optional(),
SERPER_API_KEY: z.string().optional(),
TAVILY_API_KEY: z.string().optional(),
SEARXNG_HOST: z.string().optional(),
SEARXNG_API_KEY: z.string().optional(),

View file

@ -4,9 +4,7 @@ import {
IconBrandLinkedin,
IconBrandTwitter,
} from "@tabler/icons-react";
import Image from "next/image";
import Link from "next/link";
import React from "react";
import { Logo } from "@/components/Logo";
export function FooterNew() {

View file

@ -1,97 +0,0 @@
"use client";
import {
IconBrandDiscord,
IconBrandGithub,
IconBrandLinkedin,
IconBrandTwitter,
} from "@tabler/icons-react";
import Link from "next/link";
import type React from "react";
import { cn } from "@/lib/utils";
export function Footer() {
const pages = [
{
title: "Privacy",
href: "/privacy",
},
{
title: "Terms",
href: "/terms",
},
];
return (
<div className="border-t border-neutral-100 dark:border-white/[0.1] px-8 py-20 w-full relative overflow-hidden">
<div className="max-w-7xl mx-auto text-sm text-neutral-500 justify-between items-start md:px-8">
<div className="flex flex-col items-center justify-center w-full relative">
<div className="mr-0 md:mr-4 md:flex mb-4">
<div className="flex items-center">
<span className="font-medium text-black dark:text-white ml-2">SurfSense</span>
</div>
</div>
<ul className="transition-colors flex sm:flex-row flex-col hover:text-text-neutral-800 text-neutral-600 dark:text-neutral-300 list-none gap-4">
{pages.map((page) => (
<li key={`pages-${page.title}`} className="list-none">
<Link className="transition-colors hover:text-text-neutral-800" href={page.href}>
{page.title}
</Link>
</li>
))}
</ul>
<GridLineHorizontal className="max-w-7xl mx-auto mt-8" />
</div>
<div className="flex sm:flex-row flex-col justify-between mt-8 items-center w-full">
<p className="text-neutral-500 dark:text-neutral-400 mb-8 sm:mb-0">
&copy; SurfSense 2025
</p>
<div className="flex gap-4">
<Link href="https://x.com/mod_setter">
<IconBrandTwitter className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
</Link>
<Link href="https://www.linkedin.com/in/rohan-verma-sde/">
<IconBrandLinkedin className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
</Link>
<Link href="https://github.com/MODSetter">
<IconBrandGithub className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
</Link>
<Link href="https://discord.gg/ejRNvftDp9">
<IconBrandDiscord className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
</Link>
</div>
</div>
</div>
</div>
);
}
const GridLineHorizontal = ({ className, offset }: { className?: string; offset?: string }) => {
return (
<div
style={
{
"--background": "#ffffff",
"--color": "rgba(0, 0, 0, 0.2)",
"--height": "1px",
"--width": "5px",
"--fade-stop": "90%",
"--offset": offset || "200px", //-100px if you want to keep the line inside
"--color-dark": "rgba(255, 255, 255, 0.2)",
maskComposite: "exclude",
} as React.CSSProperties
}
className={cn(
"w-[calc(100%+var(--offset))] h-[var(--height)]",
"bg-[linear-gradient(to_right,var(--color),var(--color)_50%,transparent_0,transparent)]",
"[background-size:var(--width)_var(--height)]",
"[mask:linear-gradient(to_left,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_right,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
"[mask-composite:exclude]",
"z-30",
"dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
className
)}
></div>
);
};

View file

@ -93,19 +93,21 @@ export function HeroSection() {
<div className="rounded-[24px] border border-neutral-200 bg-white p-2 dark:border-neutral-700 dark:bg-black">
{/* Light mode image */}
<Image
src="/homepage/temp_hero_light.png"
src="/homepage/main_demo.webp"
alt="header"
width={1920}
height={1080}
className="rounded-[20px] block dark:hidden"
unoptimized
/>
{/* Dark mode image */}
<Image
src="/homepage/temp_hero_dark.png"
src="/homepage/main_demo.webp"
alt="header"
width={1920}
height={1080}
className="rounded-[20px] hidden dark:block"
unoptimized
/>
</div>
</div>

View file

@ -16,7 +16,10 @@ const INTEGRATIONS: Integration[] = [
{ name: "Elasticsearch", icon: "https://cdn.simpleicons.org/elastic/00A9E5" },
// Communication
{ name: "Slack", icon: "https://cdn.simpleicons.org/slack/4A154B" },
{
name: "Slack",
icon: "https://upload.wikimedia.org/wikipedia/commons/d/d5/Slack_icon_2019.svg",
},
{ name: "Discord", icon: "https://cdn.simpleicons.org/discord/5865F2" },
{ name: "Gmail", icon: "https://cdn.simpleicons.org/gmail/EA4335" },

View file

@ -14,6 +14,7 @@ export const Navbar = () => {
const navItems = [
// { name: "Home", link: "/" },
{ name: "Pricing", link: "/pricing" },
{ name: "Changelog", link: "/changelog" },
// { name: "Sign In", link: "/login" },
{ name: "Docs", link: "/docs" },
];

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 type { Components } from "react-markdown";
import { Streamdown } from "streamdown";
import { cn } from "@/lib/utils";
interface MarkdownViewerProps {
@ -17,203 +9,98 @@ 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]}
components={components}
>
<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,61 @@
"use client";
import { useCallback, useState } from "react";
import type {
GlobalNewLLMConfig,
NewLLMConfigPublic,
} from "@/contracts/types/new-llm-config.types";
import { ModelConfigSidebar } from "./model-config-sidebar";
import { ModelSelector } from "./model-selector";
interface ChatHeaderProps {
searchSpaceId: number;
}
export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [selectedConfig, setSelectedConfig] = useState<
NewLLMConfigPublic | GlobalNewLLMConfig | null
>(null);
const [isGlobal, setIsGlobal] = useState(false);
const [sidebarMode, setSidebarMode] = useState<"create" | "edit" | "view">("view");
const handleEditConfig = useCallback(
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
setSelectedConfig(config);
setIsGlobal(global);
setSidebarMode(global ? "view" : "edit");
setSidebarOpen(true);
},
[]
);
const handleAddNew = useCallback(() => {
setSelectedConfig(null);
setIsGlobal(false);
setSidebarMode("create");
setSidebarOpen(true);
}, []);
const handleSidebarClose = useCallback((open: boolean) => {
setSidebarOpen(open);
if (!open) {
// Reset state when closing
setSelectedConfig(null);
}
}, []);
return (
<>
<ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} />
<ModelConfigSidebar
open={sidebarOpen}
onOpenChange={handleSidebarClose}
config={selectedConfig}
isGlobal={isGlobal}
searchSpaceId={searchSpaceId}
mode={sidebarMode}
/>
</>
);
}

View file

@ -0,0 +1,243 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { FileText } from "lucide-react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { Document } from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
export interface DocumentMentionPickerRef {
selectHighlighted: () => void;
moveUp: () => void;
moveDown: () => void;
}
interface DocumentMentionPickerProps {
searchSpaceId: number;
onSelectionChange: (documents: Document[]) => void;
onDone: () => void;
initialSelectedDocuments?: Document[];
externalSearch?: string;
}
function useDebounced<T>(value: T, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}
export const DocumentMentionPicker = forwardRef<
DocumentMentionPickerRef,
DocumentMentionPickerProps
>(function DocumentMentionPicker(
{ searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" },
ref
) {
// Use external search
const search = externalSearch;
const debouncedSearch = useDebounced(search, 150);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
const fetchQueryParams = useMemo(
() => ({
search_space_id: searchSpaceId,
page: 0,
page_size: 20,
}),
[searchSpaceId]
);
const searchQueryParams = useMemo(() => {
return {
search_space_id: searchSpaceId,
page: 0,
page_size: 20,
title: debouncedSearch,
};
}, [debouncedSearch, searchSpaceId]);
// Use query for fetching documents
const { data: documents, isLoading: isDocumentsLoading } = useQuery({
queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams),
queryFn: () => documentsApiService.getDocuments({ queryParams: fetchQueryParams }),
staleTime: 3 * 60 * 1000,
enabled: !!searchSpaceId && !debouncedSearch.trim(),
});
// Searching
const { data: searchedDocuments, isLoading: isSearchedDocumentsLoading } = useQuery({
queryKey: cacheKeys.documents.withQueryParams(searchQueryParams),
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
staleTime: 3 * 60 * 1000,
enabled: !!searchSpaceId && !!debouncedSearch.trim(),
});
const actualDocuments = debouncedSearch.trim()
? searchedDocuments?.items || []
: documents?.items || [];
const actualLoading = debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading;
// Track already selected document IDs
const selectedIds = useMemo(
() => new Set(initialSelectedDocuments.map((d) => d.id)),
[initialSelectedDocuments]
);
// Filter out already selected documents for navigation
const selectableDocuments = useMemo(
() => actualDocuments.filter((doc) => !selectedIds.has(doc.id)),
[actualDocuments, selectedIds]
);
const handleSelectDocument = useCallback(
(doc: Document) => {
onSelectionChange([...initialSelectedDocuments, doc]);
onDone();
},
[initialSelectedDocuments, onSelectionChange, onDone]
);
// Scroll highlighted item into view
useEffect(() => {
const item = itemRefs.current.get(highlightedIndex);
if (item) {
item.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [highlightedIndex]);
// Reset highlighted index when external search changes
const prevSearchRef = useRef(search);
if (prevSearchRef.current !== search) {
prevSearchRef.current = search;
if (highlightedIndex !== 0) {
setHighlightedIndex(0);
}
}
// Expose methods to parent via ref
useImperativeHandle(
ref,
() => ({
selectHighlighted: () => {
if (selectableDocuments[highlightedIndex]) {
handleSelectDocument(selectableDocuments[highlightedIndex]);
}
},
moveUp: () => {
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableDocuments.length - 1));
},
moveDown: () => {
setHighlightedIndex((prev) => (prev < selectableDocuments.length - 1 ? prev + 1 : 0));
},
}),
[selectableDocuments, highlightedIndex, handleSelectDocument]
);
// Handle keyboard navigation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (selectableDocuments.length === 0) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightedIndex((prev) => (prev < selectableDocuments.length - 1 ? prev + 1 : 0));
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableDocuments.length - 1));
break;
case "Enter":
e.preventDefault();
if (selectableDocuments[highlightedIndex]) {
handleSelectDocument(selectableDocuments[highlightedIndex]);
}
break;
case "Escape":
e.preventDefault();
onDone();
break;
}
},
[selectableDocuments, highlightedIndex, handleSelectDocument, onDone]
);
return (
<div
className="flex flex-col w-[280px] sm:w-[320px] bg-popover rounded-lg"
onKeyDown={handleKeyDown}
role="listbox"
tabIndex={-1}
>
{/* Document List */}
<div className="max-h-[280px] overflow-y-auto">
{actualLoading ? (
<div className="flex items-center justify-center py-4">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
</div>
) : actualDocuments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-4 text-center px-4">
<FileText className="h-5 w-5 text-muted-foreground/50 mb-1" />
<p className="text-sm text-muted-foreground">No documents found</p>
</div>
) : (
<div className="py-1">
{actualDocuments.map((doc) => {
const isAlreadySelected = selectedIds.has(doc.id);
const selectableIndex = selectableDocuments.findIndex((d) => d.id === doc.id);
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
return (
<button
key={doc.id}
ref={(el) => {
if (el && selectableIndex >= 0) {
itemRefs.current.set(selectableIndex, el);
}
}}
type="button"
onClick={() => !isAlreadySelected && handleSelectDocument(doc)}
onMouseEnter={() => {
if (!isAlreadySelected && selectableIndex >= 0) {
setHighlightedIndex(selectableIndex);
}
}}
disabled={isAlreadySelected}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors",
isAlreadySelected ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
isHighlighted && "bg-accent"
)}
>
{/* Type icon */}
<span className="flex-shrink-0 text-muted-foreground text-sm">
{getConnectorIcon(doc.document_type)}
</span>
{/* Title */}
<span className="flex-1 text-sm truncate" title={doc.title}>
{doc.title}
</span>
</button>
);
})}
</div>
)}
</div>
</div>
);
});

View file

@ -0,0 +1,369 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Bot, ChevronRight, Globe, User, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
createNewLLMConfigMutationAtom,
updateLLMPreferencesMutationAtom,
updateNewLLMConfigMutationAtom,
} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type {
GlobalNewLLMConfig,
NewLLMConfigPublic,
} from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
interface ModelConfigSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
isGlobal: boolean;
searchSpaceId: number;
mode: "create" | "edit" | "view";
}
export function ModelConfigSidebar({
open,
onOpenChange,
config,
isGlobal,
searchSpaceId,
mode,
}: ModelConfigSidebarProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
// Mutations - use mutateAsync from the atom value
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
// 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]);
// Get title based on mode
const getTitle = () => {
if (mode === "create") return "Add New Configuration";
if (isGlobal) return "View Global Configuration";
return "Edit Configuration";
};
// Handle form submit
const handleSubmit = useCallback(
async (data: LLMConfigFormData) => {
setIsSubmitting(true);
try {
if (mode === "create") {
// Create new config
const result = await createConfig({
...data,
search_space_id: searchSpaceId,
});
// Assign the new config to the agent role
if (result?.id) {
await updatePreferences({
search_space_id: searchSpaceId,
data: {
agent_llm_id: result.id,
},
});
}
toast.success("Configuration created and assigned!");
onOpenChange(false);
} else if (!isGlobal && config) {
// Update existing user config
await updateConfig({
id: config.id,
data: {
name: data.name,
description: data.description,
provider: data.provider,
custom_provider: data.custom_provider,
model_name: data.model_name,
api_key: data.api_key,
api_base: data.api_base,
litellm_params: data.litellm_params,
system_instructions: data.system_instructions,
use_default_system_instructions: data.use_default_system_instructions,
citations_enabled: data.citations_enabled,
},
});
toast.success("Configuration updated!");
onOpenChange(false);
}
} catch (error) {
console.error("Failed to save configuration:", error);
toast.error("Failed to save configuration");
} finally {
setIsSubmitting(false);
}
},
[
mode,
isGlobal,
config,
searchSpaceId,
createConfig,
updateConfig,
updatePreferences,
onOpenChange,
]
);
// Handle "Use this model" for global configs
const handleUseGlobalConfig = useCallback(async () => {
if (!config || !isGlobal) return;
setIsSubmitting(true);
try {
await updatePreferences({
search_space_id: searchSpaceId,
data: {
agent_llm_id: config.id,
},
});
toast.success(`Now using ${config.name}`);
onOpenChange(false);
} catch (error) {
console.error("Failed to set model:", error);
toast.error("Failed to set model");
} finally {
setIsSubmitting(false);
}
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
return (
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm"
onClick={() => onOpenChange(false)}
/>
{/* Sidebar Panel */}
<motion.div
initial={{ x: "100%", opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: "100%", opacity: 0 }}
transition={{
type: "spring",
damping: 30,
stiffness: 300,
}}
className={cn(
"fixed right-0 top-0 z-50 h-full w-full sm:w-[480px] lg:w-[540px]",
"bg-background border-l border-border/50 shadow-2xl",
"flex flex-col"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-muted/20">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-xl bg-primary/10">
<Bot className="size-5 text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold">{getTitle()}</h2>
<div className="flex items-center gap-2 mt-0.5">
{isGlobal ? (
<Badge variant="secondary" className="gap-1 text-xs">
<Globe className="size-3" />
Global
</Badge>
) : mode !== "create" ? (
<Badge variant="outline" className="gap-1 text-xs">
<User className="size-3" />
Custom
</Badge>
) : null}
{config && (
<span className="text-xs text-muted-foreground">{config.model_name}</span>
)}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="rounded-xl hover:bg-destructive/10 hover:text-destructive"
>
<X className="size-5" />
</Button>
</div>
{/* Content - use overflow-y-auto instead of ScrollArea for better compatibility */}
<div className="flex-1 overflow-y-auto">
<div className="p-6">
{/* Global config notice */}
{isGlobal && mode !== "create" && (
<Alert className="mb-6 border-amber-500/30 bg-amber-500/5">
<AlertCircle className="size-4 text-amber-500" />
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
Global configurations are read-only. To customize settings, create a new
configuration based on this template.
</AlertDescription>
</Alert>
)}
{/* Form */}
{mode === "create" ? (
<LLMConfigForm
searchSpaceId={searchSpaceId}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
isSubmitting={isSubmitting}
mode="create"
submitLabel="Create & Use"
/>
) : isGlobal && config ? (
// Read-only view for global configs
<div className="space-y-6">
{/* Config Details */}
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Configuration Name
</label>
<p className="text-sm font-medium">{config.name}</p>
</div>
{config.description && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Description
</label>
<p className="text-sm text-muted-foreground">{config.description}</p>
</div>
)}
</div>
<div className="h-px bg-border/50" />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Provider
</label>
<p className="text-sm font-medium">{config.provider}</p>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Model
</label>
<p className="text-sm font-medium font-mono">{config.model_name}</p>
</div>
</div>
<div className="h-px bg-border/50" />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Citations
</label>
<Badge
variant={config.citations_enabled ? "default" : "secondary"}
className="w-fit"
>
{config.citations_enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</div>
{config.system_instructions && (
<>
<div className="h-px bg-border/50" />
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
System Instructions
</label>
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs font-mono text-muted-foreground whitespace-pre-wrap line-clamp-10">
{config.system_instructions}
</p>
</div>
</div>
</>
)}
</div>
{/* Action Buttons */}
<div className="flex gap-3 pt-4 border-t border-border/50">
<Button
variant="outline"
className="flex-1"
onClick={() => onOpenChange(false)}
>
Close
</Button>
<Button
className="flex-1 gap-2"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? (
<>Loading...</>
) : (
<>
<ChevronRight className="size-4" />
Use This Model
</>
)}
</Button>
</div>
</div>
) : config ? (
// Edit form for user configs
<LLMConfigForm
searchSpaceId={searchSpaceId}
initialData={{
name: config.name,
description: config.description,
provider: config.provider,
custom_provider: config.custom_provider,
model_name: config.model_name,
api_key: config.api_key,
api_base: config.api_base,
litellm_params: config.litellm_params,
system_instructions: config.system_instructions,
use_default_system_instructions: config.use_default_system_instructions,
citations_enabled: config.citations_enabled,
search_space_id: searchSpaceId,
}}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
isSubmitting={isSubmitting}
mode="edit"
submitLabel="Save Changes"
/>
) : null}
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}

View file

@ -0,0 +1,387 @@
"use client";
import { useAtomValue } from "jotai";
import {
Bot,
Check,
ChevronDown,
Cloud,
Edit3,
Globe,
Loader2,
Plus,
Settings2,
Sparkles,
User,
Zap,
} from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import {
globalNewLLMConfigsAtom,
llmPreferencesAtom,
newLLMConfigsAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import type {
GlobalNewLLMConfig,
NewLLMConfigPublic,
} from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
// Provider icons mapping
const getProviderIcon = (provider: string) => {
const iconClass = "size-4";
switch (provider?.toUpperCase()) {
case "OPENAI":
return <Sparkles className={cn(iconClass, "text-emerald-500")} />;
case "ANTHROPIC":
return <Bot className={cn(iconClass, "text-amber-600")} />;
case "GOOGLE":
return <Cloud className={cn(iconClass, "text-blue-500")} />;
case "GROQ":
return <Zap className={cn(iconClass, "text-orange-500")} />;
case "OLLAMA":
return <Settings2 className={cn(iconClass, "text-gray-500")} />;
case "XAI":
return <Bot className={cn(iconClass, "text-violet-500")} />;
default:
return <Bot className={cn(iconClass, "text-muted-foreground")} />;
}
};
interface ModelSelectorProps {
onEdit: (config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => void;
onAddNew: () => void;
className?: string;
}
export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [isSwitching, setIsSwitching] = useState(false);
// Fetch configs
const { data: userConfigs, isLoading: userConfigsLoading } = useAtomValue(newLLMConfigsAtom);
const { data: globalConfigs, isLoading: globalConfigsLoading } =
useAtomValue(globalNewLLMConfigsAtom);
const { data: preferences, isLoading: preferencesLoading } = useAtomValue(llmPreferencesAtom);
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const isLoading = userConfigsLoading || globalConfigsLoading || preferencesLoading;
// Get current agent LLM config
const currentConfig = useMemo(() => {
if (!preferences) return null;
const agentLlmId = preferences.agent_llm_id;
if (agentLlmId === null || agentLlmId === undefined) return null;
// Check if it's a global config (negative ID)
if (agentLlmId < 0) {
return globalConfigs?.find((c) => c.id === agentLlmId) ?? null;
}
// Otherwise, check user configs
return userConfigs?.find((c) => c.id === agentLlmId) ?? null;
}, [preferences, globalConfigs, userConfigs]);
// Filter configs based on search
const filteredGlobalConfigs = useMemo(() => {
if (!globalConfigs) return [];
if (!searchQuery) return globalConfigs;
const query = searchQuery.toLowerCase();
return globalConfigs.filter(
(c) =>
c.name.toLowerCase().includes(query) ||
c.model_name.toLowerCase().includes(query) ||
c.provider.toLowerCase().includes(query)
);
}, [globalConfigs, searchQuery]);
const filteredUserConfigs = useMemo(() => {
if (!userConfigs) return [];
if (!searchQuery) return userConfigs;
const query = searchQuery.toLowerCase();
return userConfigs.filter(
(c) =>
c.name.toLowerCase().includes(query) ||
c.model_name.toLowerCase().includes(query) ||
c.provider.toLowerCase().includes(query)
);
}, [userConfigs, searchQuery]);
const handleSelectConfig = useCallback(
async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => {
// If already selected, just close
if (currentConfig?.id === config.id) {
setOpen(false);
return;
}
if (!searchSpaceId) {
toast.error("No search space selected");
return;
}
setIsSwitching(true);
try {
await updatePreferences({
search_space_id: Number(searchSpaceId),
data: {
agent_llm_id: config.id,
},
});
toast.success(`Switched to ${config.name}`);
setOpen(false);
} catch (error) {
console.error("Failed to switch model:", error);
toast.error("Failed to switch model");
} finally {
setIsSwitching(false);
}
},
[currentConfig, searchSpaceId, updatePreferences]
);
const handleEditConfig = useCallback(
(e: React.MouseEvent, config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => {
e.stopPropagation();
onEdit(config, isGlobal);
setOpen(false);
},
[onEdit]
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
role="combobox"
aria-expanded={open}
className={cn(
"h-9 gap-2 px-3 rounded-xl border border-border/80 bg-background/50 backdrop-blur-sm",
"hover:bg-muted/80 hover:border-border/30 transition-all duration-200",
"text-sm font-medium text-foreground",
"focus-visible:ring-0 focus-visible:ring-offset-0",
className
)}
>
{isLoading ? (
<>
<Loader2 className="size-4 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Loading...</span>
</>
) : currentConfig ? (
<>
{getProviderIcon(currentConfig.provider)}
<span className="max-w-[150px] truncate">{currentConfig.name}</span>
<Badge variant="secondary" className="ml-1 text-[10px] px-1.5 py-0 h-4 bg-muted/80">
{currentConfig.model_name.split("/").pop()?.slice(0, 15) ||
currentConfig.model_name.slice(0, 15)}
</Badge>
</>
) : (
<>
<Bot className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">Select Model</span>
</>
)}
<ChevronDown className="size-3.5 text-muted-foreground ml-1 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[360px] p-0 rounded-xl shadow-lg border-border/30"
align="start"
sideOffset={8}
>
<Command
shouldFilter={false}
className="rounded-xl relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
>
{/* Switching overlay */}
{isSwitching && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Switching model...</span>
</div>
</div>
)}
<div className="flex items-center gap-2 border-b border-border/30 px-3 py-2">
<CommandInput
placeholder="Search models..."
value={searchQuery}
onValueChange={setSearchQuery}
className="h-8 border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60"
disabled={isSwitching}
/>
</div>
<CommandList className="max-h-[400px] overflow-y-auto">
<CommandEmpty className="py-8 text-center">
<div className="flex flex-col items-center gap-2">
<Bot className="size-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No models found</p>
<p className="text-xs text-muted-foreground/60">Try a different search term</p>
</div>
</CommandEmpty>
{/* Global Configs Section */}
{filteredGlobalConfigs.length > 0 && (
<CommandGroup>
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
<Globe className="size-3.5" />
Global Models
</div>
{filteredGlobalConfigs.map((config) => {
const isSelected = currentConfig?.id === config.id;
return (
<CommandItem
key={`global-${config.id}`}
value={`global-${config.id}`}
onSelect={() => handleSelectConfig(config)}
className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer",
"aria-selected:bg-accent/50",
isSelected && "bg-accent/80"
)}
>
<div className="flex items-center justify-between w-full gap-2">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="shrink-0">{getProviderIcon(config.provider)}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{config.name}</span>
{isSelected && <Check className="size-3.5 text-primary shrink-0" />}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs text-muted-foreground truncate">
{config.model_name}
</span>
{config.citations_enabled && (
<Badge
variant="outline"
className="text-[9px] px-1 py-0 h-3.5 bg-primary/10 text-primary border-primary/20"
>
Citations
</Badge>
)}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 rounded-md hover:bg-muted"
onClick={(e) => handleEditConfig(e, config, true)}
>
<Edit3 className="size-3.5 text-muted-foreground" />
</Button>
</div>
</CommandItem>
);
})}
</CommandGroup>
)}
{filteredGlobalConfigs.length > 0 && filteredUserConfigs.length > 0 && (
<CommandSeparator className="my-1 bg-border/30" />
)}
{/* User Configs Section */}
{filteredUserConfigs.length > 0 && (
<CommandGroup>
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
<User className="size-3.5" />
Your Configurations
</div>
{filteredUserConfigs.map((config) => {
const isSelected = currentConfig?.id === config.id;
return (
<CommandItem
key={`user-${config.id}`}
value={`user-${config.id}`}
onSelect={() => handleSelectConfig(config)}
className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer",
"aria-selected:bg-accent/50",
isSelected && "bg-accent/80"
)}
>
<div className="flex items-center justify-between w-full gap-2">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="shrink-0">{getProviderIcon(config.provider)}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{config.name}</span>
{isSelected && <Check className="size-3.5 text-primary shrink-0" />}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs text-muted-foreground truncate">
{config.model_name}
</span>
{config.citations_enabled && (
<Badge
variant="outline"
className="text-[9px] px-1 py-0 h-3.5 bg-primary/10 text-primary border-primary/20"
>
Citations
</Badge>
)}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 rounded-md hover:bg-muted"
onClick={(e) => handleEditConfig(e, config, false)}
>
<Edit3 className="size-3.5 text-muted-foreground" />
</Button>
</div>
</CommandItem>
);
})}
</CommandGroup>
)}
{/* Add New Config Button */}
<div className="p-2 bg-muted/20">
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50"
onClick={() => {
setOpen(false);
onAddNew();
}}
>
<Plus className="size-4 text-primary" />
<span className="text-sm font-medium">Add New Configuration</span>
</Button>
</div>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View file

@ -0,0 +1,607 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
BookOpen,
ChevronDown,
ChevronUp,
ExternalLink,
FileText,
Hash,
Loader2,
Sparkles,
X,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type React from "react";
import { forwardRef, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
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
// 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;
disableLayoutAnimation?: boolean;
}
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 && <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>
)}
</div>
{/* Content */}
<div className="p-5 overflow-hidden">
<MarkdownViewer content={chunk.content} />
</div>
</div>
);
}
);
ChunkCard.displayName = "ChunkCard";
export function SourceDetailPanel({
open,
onOpenChange,
chunkId,
sourceType,
title,
description,
url,
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);
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;
// 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
);
}
},
[open, citedChunkIndex]
);
// Reset scroll state when panel closes
useEffect(() => {
if (!open) {
hasScrolledRef.current = false;
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) => {
scrollToChunkByIndex(index, true);
},
[scrollToChunkByIndex]
);
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 overflow-hidden"
>
<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;
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) => {
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>
</div>
)}
</motion.div>
</>
)}
</AnimatePresence>
);
if (!mounted) return <>{children}</>;
return (
<>
{children}
{createPortal(panelContent, globalThis.document.body)}
</>
);
}

View file

@ -1,8 +0,0 @@
export { OnboardActionCard } from "./onboard-action-card";
export { OnboardAdvancedSettings } from "./onboard-advanced-settings";
export { OnboardHeader } from "./onboard-header";
export { OnboardLLMSetup } from "./onboard-llm-setup";
export { OnboardLoading } from "./onboard-loading";
export { OnboardStats } from "./onboard-stats";
export { SetupLLMStep } from "./setup-llm-step";
export { SetupPromptStep } from "./setup-prompt-step";

View file

@ -1,114 +0,0 @@
"use client";
import { ArrowRight, CheckCircle, type LucideIcon } from "lucide-react";
import { motion } from "motion/react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface OnboardActionCardProps {
title: string;
description: string;
icon: LucideIcon;
features: string[];
buttonText: string;
onClick: () => void;
colorScheme: "emerald" | "blue" | "violet";
delay?: number;
}
const colorSchemes = {
emerald: {
iconBg: "bg-emerald-500/10 dark:bg-emerald-500/20",
iconRing: "ring-emerald-500/20 dark:ring-emerald-500/30",
iconColor: "text-emerald-600 dark:text-emerald-400",
checkColor: "text-emerald-500",
buttonBg: "bg-emerald-600 hover:bg-emerald-500",
hoverBorder: "hover:border-emerald-500/50",
},
blue: {
iconBg: "bg-blue-500/10 dark:bg-blue-500/20",
iconRing: "ring-blue-500/20 dark:ring-blue-500/30",
iconColor: "text-blue-600 dark:text-blue-400",
checkColor: "text-blue-500",
buttonBg: "bg-blue-600 hover:bg-blue-500",
hoverBorder: "hover:border-blue-500/50",
},
violet: {
iconBg: "bg-violet-500/10 dark:bg-violet-500/20",
iconRing: "ring-violet-500/20 dark:ring-violet-500/30",
iconColor: "text-violet-600 dark:text-violet-400",
checkColor: "text-violet-500",
buttonBg: "bg-violet-600 hover:bg-violet-500",
hoverBorder: "hover:border-violet-500/50",
},
};
export function OnboardActionCard({
title,
description,
icon: Icon,
features,
buttonText,
onClick,
colorScheme,
delay = 0,
}: OnboardActionCardProps) {
const colors = colorSchemes[colorScheme];
return (
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay, type: "spring", stiffness: 200 }}
whileHover={{ y: -6, transition: { duration: 0.2 } }}
>
<Card
className={cn(
"h-full cursor-pointer group relative overflow-hidden transition-all duration-300",
"border bg-card hover:shadow-lg",
colors.hoverBorder
)}
onClick={onClick}
>
<CardHeader className="relative pb-4">
<motion.div
className={cn(
"w-14 h-14 rounded-2xl flex items-center justify-center mb-4 ring-1 transition-all duration-300",
colors.iconBg,
colors.iconRing,
"group-hover:scale-110"
)}
whileHover={{ rotate: [0, -5, 5, 0] }}
transition={{ duration: 0.5 }}
>
<Icon className={cn("w-7 h-7", colors.iconColor)} />
</motion.div>
<CardTitle className="text-xl">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="relative space-y-4">
<div className="space-y-2.5 text-sm text-muted-foreground">
{features.map((feature, index) => (
<div key={index} className="flex items-center gap-2.5">
<CheckCircle className={cn("w-4 h-4", colors.checkColor)} />
<span>{feature}</span>
</div>
))}
</div>
<Button
className={cn(
"w-full text-white border-0 transition-all duration-300",
colors.buttonBg
)}
>
{buttonText}
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
</CardContent>
</Card>
</motion.div>
);
}

View file

@ -1,144 +0,0 @@
"use client";
import { ChevronDown, MessageSquare, Settings2 } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { SetupLLMStep } from "@/components/onboard/setup-llm-step";
import { SetupPromptStep } from "@/components/onboard/setup-prompt-step";
import { Card, CardContent } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
interface OnboardAdvancedSettingsProps {
searchSpaceId: number;
showLLMSettings: boolean;
setShowLLMSettings: (show: boolean) => void;
showPromptSettings: boolean;
setShowPromptSettings: (show: boolean) => void;
onConfigCreated: () => void;
onConfigDeleted: () => void;
onPreferencesUpdated: () => Promise<void>;
}
export function OnboardAdvancedSettings({
searchSpaceId,
showLLMSettings,
setShowLLMSettings,
showPromptSettings,
setShowPromptSettings,
onConfigCreated,
onConfigDeleted,
onPreferencesUpdated,
}: OnboardAdvancedSettingsProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1 }}
className="space-y-4"
>
{/* LLM Configuration */}
<Collapsible open={showLLMSettings} onOpenChange={setShowLLMSettings}>
<CollapsibleTrigger asChild>
<Card className="hover:bg-muted/50 transition-colors cursor-pointer">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-fuchsia-500/10 dark:bg-fuchsia-500/20 border border-fuchsia-500/20">
<Settings2 className="w-5 h-5 text-fuchsia-600 dark:text-fuchsia-400" />
</div>
<div>
<h3 className="font-semibold">LLM Configuration</h3>
<p className="text-sm text-muted-foreground">
Customize AI models and role assignments
</p>
</div>
</div>
<motion.div
animate={{ rotate: showLLMSettings ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown className="w-5 h-5 text-muted-foreground" />
</motion.div>
</div>
</CardContent>
</Card>
</CollapsibleTrigger>
<CollapsibleContent>
<AnimatePresence>
{showLLMSettings && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<Card className="mt-2">
<CardContent className="pt-6">
<SetupLLMStep
searchSpaceId={searchSpaceId}
onConfigCreated={onConfigCreated}
onConfigDeleted={onConfigDeleted}
onPreferencesUpdated={onPreferencesUpdated}
/>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</CollapsibleContent>
</Collapsible>
{/* Prompt Configuration */}
<Collapsible open={showPromptSettings} onOpenChange={setShowPromptSettings}>
<CollapsibleTrigger asChild>
<Card className="hover:bg-muted/50 transition-colors cursor-pointer">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-cyan-500/10 dark:bg-cyan-500/20 border border-cyan-500/20">
<MessageSquare className="w-5 h-5 text-cyan-600 dark:text-cyan-400" />
</div>
<div>
<h3 className="font-semibold">AI Response Settings</h3>
<p className="text-sm text-muted-foreground">
Configure citations and custom instructions (Optional)
</p>
</div>
</div>
<motion.div
animate={{ rotate: showPromptSettings ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown className="w-5 h-5 text-muted-foreground" />
</motion.div>
</div>
</CardContent>
</Card>
</CollapsibleTrigger>
<CollapsibleContent>
<AnimatePresence>
{showPromptSettings && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<Card className="mt-2">
<CardContent className="pt-6">
<SetupPromptStep
searchSpaceId={searchSpaceId}
onComplete={() => setShowPromptSettings(false)}
/>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</CollapsibleContent>
</Collapsible>
</motion.div>
);
}

View file

@ -1,56 +0,0 @@
"use client";
import { CheckCircle } from "lucide-react";
import { motion } from "motion/react";
import { Logo } from "@/components/Logo";
import { Badge } from "@/components/ui/badge";
interface OnboardHeaderProps {
title: string;
subtitle: string;
isReady?: boolean;
}
export function OnboardHeader({ title, subtitle, isReady }: OnboardHeaderProps) {
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="text-center mb-10"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200, delay: 0.2 }}
className="inline-flex items-center justify-center mb-6"
>
<Logo className="w-20 h-20 rounded-2xl shadow-lg" />
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="space-y-2"
>
<h1 className="text-4xl md:text-5xl font-bold text-foreground">{title}</h1>
<p className="text-muted-foreground text-lg md:text-xl max-w-2xl mx-auto">{subtitle}</p>
</motion.div>
{isReady && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.4, type: "spring" }}
className="mt-4"
>
<Badge className="px-4 py-2 text-sm bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400">
<CheckCircle className="w-4 h-4 mr-2" />
AI Configuration Complete
</Badge>
</motion.div>
)}
</motion.div>
);
}

View file

@ -1,93 +0,0 @@
"use client";
import { Bot } from "lucide-react";
import { motion } from "motion/react";
import { Logo } from "@/components/Logo";
import { SetupLLMStep } from "@/components/onboard/setup-llm-step";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
interface OnboardLLMSetupProps {
searchSpaceId: number;
title: string;
configTitle: string;
configDescription: string;
onConfigCreated: () => void;
onConfigDeleted: () => void;
onPreferencesUpdated: () => Promise<void>;
}
export function OnboardLLMSetup({
searchSpaceId,
title,
configTitle,
configDescription,
onConfigCreated,
onConfigDeleted,
onPreferencesUpdated,
}: OnboardLLMSetupProps) {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="w-full max-w-4xl"
>
{/* Header */}
<div className="text-center mb-8">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200, delay: 0.1 }}
className="inline-flex items-center justify-center mb-6"
>
<Logo className="w-16 h-16 rounded-2xl shadow-lg" />
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="text-4xl font-bold text-foreground mb-3"
>
{title}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="text-muted-foreground text-lg"
>
Configure your AI model to get started
</motion.p>
</div>
{/* LLM Setup Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<Card className="shadow-lg">
<CardHeader className="text-center border-b pb-6">
<div className="flex items-center justify-center gap-3 mb-2">
<div className="p-2 rounded-xl bg-primary/10 border border-primary/20">
<Bot className="w-6 h-6 text-primary" />
</div>
<CardTitle className="text-2xl">{configTitle}</CardTitle>
</div>
<CardDescription>{configDescription}</CardDescription>
</CardHeader>
<CardContent className="pt-6">
<SetupLLMStep
searchSpaceId={searchSpaceId}
onConfigCreated={onConfigCreated}
onConfigDeleted={onConfigDeleted}
onPreferencesUpdated={onPreferencesUpdated}
/>
</CardContent>
</Card>
</motion.div>
</motion.div>
</div>
);
}

View file

@ -1,47 +0,0 @@
"use client";
import { Wand2 } from "lucide-react";
import { motion } from "motion/react";
interface OnboardLoadingProps {
title: string;
subtitle: string;
}
export function OnboardLoading({ title, subtitle }: OnboardLoadingProps) {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
className="text-center"
>
<div className="relative mb-8 flex justify-center">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
>
<Wand2 className="w-16 h-16 text-primary" />
</motion.div>
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">{title}</h2>
<p className="text-muted-foreground">{subtitle}</p>
<div className="mt-6 flex justify-center gap-1.5">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
className="w-2 h-2 rounded-full bg-primary"
animate={{ scale: [1, 1.5, 1], opacity: [0.5, 1, 0.5] }}
transition={{
duration: 1,
repeat: Infinity,
delay: i * 0.2,
}}
/>
))}
</div>
</motion.div>
</div>
);
}

View file

@ -1,38 +0,0 @@
"use client";
import { Bot, Brain, Sparkles } from "lucide-react";
import { motion } from "motion/react";
import { Badge } from "@/components/ui/badge";
interface OnboardStatsProps {
globalConfigsCount: number;
userConfigsCount: number;
}
export function OnboardStats({ globalConfigsCount, userConfigsCount }: OnboardStatsProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="flex flex-wrap justify-center gap-3 mb-10"
>
{globalConfigsCount > 0 && (
<Badge variant="secondary" className="px-3 py-1.5">
<Sparkles className="w-3 h-3 mr-1.5 text-violet-500" />
{globalConfigsCount} Global Model{globalConfigsCount > 1 ? "s" : ""}
</Badge>
)}
{userConfigsCount > 0 && (
<Badge variant="secondary" className="px-3 py-1.5">
<Bot className="w-3 h-3 mr-1.5 text-blue-500" />
{userConfigsCount} Custom Config{userConfigsCount > 1 ? "s" : ""}
</Badge>
)}
<Badge variant="secondary" className="px-3 py-1.5">
<Brain className="w-3 h-3 mr-1.5 text-fuchsia-500" />
All Roles Assigned
</Badge>
</motion.div>
);
}

Some files were not shown because too many files have changed in this diff Show more