mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-24 21:38:09 +02:00
Merge upstream/dev and accept upstream deletions
This commit is contained in:
commit
f05a313d73
260 changed files with 50971 additions and 36069 deletions
116
surfsense_web/app/(home)/changelog/page.tsx
Normal file
116
surfsense_web/app/(home)/changelog/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ?? {}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
||||
|
|
|
|||
|
|
@ -158,3 +158,4 @@ button {
|
|||
}
|
||||
|
||||
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
|
||||
@source '../node_modules/streamdown/dist/*.js';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
31
surfsense_web/atoms/chat/mentioned-documents.atom.ts
Normal file
31
surfsense_web/atoms/chat/mentioned-documents.atom.ts
Normal 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[]>>({});
|
||||
|
|
@ -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),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
27
surfsense_web/atoms/editor/ui.atoms.ts
Normal file
27
surfsense_web/atoms/editor/ui.atoms.ts
Normal 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 });
|
||||
}
|
||||
);
|
||||
85
surfsense_web/atoms/invites/invites-mutation.atoms.ts
Normal file
85
surfsense_web/atoms/invites/invites-mutation.atoms.ts
Normal 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");
|
||||
},
|
||||
}));
|
||||
22
surfsense_web/atoms/invites/invites-query.atoms.ts
Normal file
22
surfsense_web/atoms/invites/invites-query.atoms.ts
Normal 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),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -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!),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -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),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
64
surfsense_web/atoms/members/members-mutation.atoms.ts
Normal file
64
surfsense_web/atoms/members/members-mutation.atoms.ts
Normal 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");
|
||||
},
|
||||
};
|
||||
});
|
||||
40
surfsense_web/atoms/members/members-query.atoms.ts
Normal file
40
surfsense_web/atoms/members/members-query.atoms.ts
Normal 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),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -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");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
46
surfsense_web/changelog/content/2025-12-24.mdx
Normal file
46
surfsense_web/changelog/content/2025-12-24.mdx
Normal 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!
|
||||
326
surfsense_web/components/assistant-ui/attachment.tsx
Normal file
326
surfsense_web/components/assistant-ui/attachment.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
41
surfsense_web/components/assistant-ui/inline-citation.tsx
Normal file
41
surfsense_web/components/assistant-ui/inline-citation.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
527
surfsense_web/components/assistant-ui/inline-mention-editor.tsx
Normal file
527
surfsense_web/components/assistant-ui/inline-mention-editor.tsx
Normal 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";
|
||||
325
surfsense_web/components/assistant-ui/markdown-text.tsx
Normal file
325
surfsense_web/components/assistant-ui/markdown-text.tsx
Normal 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,
|
||||
});
|
||||
299
surfsense_web/components/assistant-ui/thread-list.tsx
Normal file
299
surfsense_web/components/assistant-ui/thread-list.tsx
Normal 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();
|
||||
}
|
||||
1083
surfsense_web/components/assistant-ui/thread.tsx
Normal file
1083
surfsense_web/components/assistant-ui/thread.tsx
Normal file
File diff suppressed because it is too large
Load diff
76
surfsense_web/components/assistant-ui/tool-fallback.tsx
Normal file
76
surfsense_web/components/assistant-ui/tool-fallback.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { PodcastPlayer } from "./PodcastPlayer";
|
||||
export { PodcastPlayerCompactSkeleton } from "./PodcastPlayerCompactSkeleton";
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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)])
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.";
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
© 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
61
surfsense_web/components/new-chat/chat-header.tsx
Normal file
61
surfsense_web/components/new-chat/chat-header.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
243
surfsense_web/components/new-chat/document-mention-picker.tsx
Normal file
243
surfsense_web/components/new-chat/document-mention-picker.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
369
surfsense_web/components/new-chat/model-config-sidebar.tsx
Normal file
369
surfsense_web/components/new-chat/model-config-sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
387
surfsense_web/components/new-chat/model-selector.tsx
Normal file
387
surfsense_web/components/new-chat/model-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
607
surfsense_web/components/new-chat/source-detail-panel.tsx
Normal file
607
surfsense_web/components/new-chat/source-detail-panel.tsx
Normal 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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue