From 93f6056a91cfd1aa852149b8f289ed79163897b9 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Wed, 12 Nov 2025 12:03:06 +0200 Subject: [PATCH 01/26] add active search space chats atom with query --- .../[search_space_id]/client-layout.tsx | 4 +- .../components/chat/ChatInterface.tsx | 2 +- .../chat/ChatPanel/ChatPanelContainer.tsx | 4 +- .../chat/ChatPanel/ChatPanelView.tsx | 4 +- .../components/chat/ChatPanel/ConfigModal.tsx | 2 +- surfsense_web/lib/apis/chat-apis.ts | 70 +++++++++++++------ surfsense_web/lib/query-client/cache-keys.ts | 1 + .../active-chat-ui.atom.ts} | 0 .../{chat => chats}/active-chat.atom.ts | 0 .../chats/active-search-space-chats.atom.ts | 23 ++++++ 10 files changed, 81 insertions(+), 29 deletions(-) rename surfsense_web/stores/{chat/chat-ui.atom.ts => chats/active-chat-ui.atom.ts} (100%) rename surfsense_web/stores/{chat => chats}/active-chat.atom.ts (100%) create mode 100644 surfsense_web/stores/chats/active-search-space-chats.atom.ts diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index d5c08b797..cc35bdb97 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -17,8 +17,8 @@ import { Separator } from "@/components/ui/separator"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { useLLMPreferences } from "@/hooks/use-llm-configs"; import { cn } from "@/lib/utils"; -import { activeChatIdAtom } from "@/stores/chat/active-chat.atom"; -import { chatUIAtom } from "@/stores/chat/chat-ui.atom"; +import { activeChatIdAtom } from "@/stores/chats/active-chat.atom"; +import { chatUIAtom } from "@/stores/chats/active-chat-ui.atom"; export function DashboardClientLayout({ children, diff --git a/surfsense_web/components/chat/ChatInterface.tsx b/surfsense_web/components/chat/ChatInterface.tsx index 56632f1a2..9aa280eb7 100644 --- a/surfsense_web/components/chat/ChatInterface.tsx +++ b/surfsense_web/components/chat/ChatInterface.tsx @@ -7,7 +7,7 @@ import { useEffect } from "react"; import { ChatInputUI } from "@/components/chat/ChatInputGroup"; import { ChatMessagesUI } from "@/components/chat/ChatMessages"; import type { Document } from "@/hooks/use-documents"; -import { activeChatIdAtom } from "@/stores/chat/active-chat.atom"; +import { activeChatIdAtom } from "@/stores/chats/active-chat.atom"; import { ChatPanelContainer } from "./ChatPanel/ChatPanelContainer"; interface ChatInterfaceProps { diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx index 098d6324a..172e85334 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx @@ -4,8 +4,8 @@ import { LoaderIcon, PanelRight, TriangleAlert } from "lucide-react"; import { toast } from "sonner"; import { generatePodcast } from "@/lib/apis/podcast-apis"; import { cn } from "@/lib/utils"; -import { activeChatAtom, activeChatIdAtom } from "@/stores/chat/active-chat.atom"; -import { chatUIAtom } from "@/stores/chat/chat-ui.atom"; +import { activeChatAtom, activeChatIdAtom } from "@/stores/chats/active-chat.atom"; +import { chatUIAtom } from "@/stores/chats/active-chat-ui.atom"; import { ChatPanelView } from "./ChatPanelView"; export interface GeneratePodcastRequest { diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx index 40c2650a5..bb88935f2 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx @@ -5,8 +5,8 @@ import { AlertCircle, Play, RefreshCw, Sparkles } from "lucide-react"; import { motion } from "motion/react"; import { useCallback } from "react"; import { cn } from "@/lib/utils"; -import { activeChatAtom } from "@/stores/chat/active-chat.atom"; -import { chatUIAtom } from "@/stores/chat/chat-ui.atom"; +import { activeChatAtom } from "@/stores/chats/active-chat.atom"; +import { chatUIAtom } from "@/stores/chats/active-chat-ui.atom"; import { getPodcastStalenessMessage, isPodcastStale } from "../PodcastUtils"; import type { GeneratePodcastRequest } from "./ChatPanelContainer"; import { ConfigModal } from "./ConfigModal"; diff --git a/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx b/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx index f1efdd089..b67918a9a 100644 --- a/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx +++ b/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx @@ -4,7 +4,7 @@ import { useAtomValue } from "jotai"; import { Pencil } from "lucide-react"; import { useCallback, useContext, useState } from "react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { activeChatAtom } from "@/stores/chat/active-chat.atom"; +import { activeChatAtom } from "@/stores/chats/active-chat.atom"; import type { GeneratePodcastRequest } from "./ChatPanelContainer"; interface ConfigModalProps { diff --git a/surfsense_web/lib/apis/chat-apis.ts b/surfsense_web/lib/apis/chat-apis.ts index fb1c98708..4ae8a8feb 100644 --- a/surfsense_web/lib/apis/chat-apis.ts +++ b/surfsense_web/lib/apis/chat-apis.ts @@ -1,28 +1,56 @@ import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; export const fetchChatDetails = async ( - chatId: string, - authToken: string + chatId: string, + authToken: string ): Promise => { - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number(chatId)}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - } - ); + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number( + chatId + )}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + } + ); - if (!response.ok) { - throw new Error(`Failed to fetch chat details: ${response.statusText}`); - } + if (!response.ok) { + throw new Error(`Failed to fetch chat details: ${response.statusText}`); + } - return await response.json(); - } catch (err) { - console.error("Error fetching chat details:", err); - return null; - } + return await response.json(); + } catch (err) { + console.error("Error fetching chat details:", err); + return null; + } +}; + +export const fetchChatsBySearchSpace = async ( + searchSpaceId: string, + authToken: string +): Promise => { + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats?search_space_id=${searchSpaceId}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + } + ); + if (!response.ok) { + throw new Error(`Failed to fetch chats: ${response.statusText}`); + } + + return await response.json(); + } catch (err) { + console.error("Error fetching chats:", err); + return null; + } }; diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 48f7e6052..0b66f1450 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -1,3 +1,4 @@ export const cacheKeys = { activeChat: (chatId: string) => ["activeChat", chatId], + activeSearchSpaceChats: (searchSpaceId: string) => ["activeSearchSpaceChats", searchSpaceId], }; diff --git a/surfsense_web/stores/chat/chat-ui.atom.ts b/surfsense_web/stores/chats/active-chat-ui.atom.ts similarity index 100% rename from surfsense_web/stores/chat/chat-ui.atom.ts rename to surfsense_web/stores/chats/active-chat-ui.atom.ts diff --git a/surfsense_web/stores/chat/active-chat.atom.ts b/surfsense_web/stores/chats/active-chat.atom.ts similarity index 100% rename from surfsense_web/stores/chat/active-chat.atom.ts rename to surfsense_web/stores/chats/active-chat.atom.ts diff --git a/surfsense_web/stores/chats/active-search-space-chats.atom.ts b/surfsense_web/stores/chats/active-search-space-chats.atom.ts new file mode 100644 index 000000000..df5dd2dcd --- /dev/null +++ b/surfsense_web/stores/chats/active-search-space-chats.atom.ts @@ -0,0 +1,23 @@ +import { atomWithQuery } from "jotai-tanstack-query"; +import { fetchChatsBySearchSpace } from "@/lib/apis/chat-apis"; +import { activeSearchSpaceIdAtom } from "../seach-space/active-seach-space.atom"; + +export const activeSearchSpaceChatsAtom = atomWithQuery((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + const authToken = localStorage.getItem("surfsense_bearer_token"); + + return { + queryKey: ["chatsBySearchSpace", searchSpaceId], + enabled: !!searchSpaceId && !!authToken, + queryFn: async () => { + if (!authToken) { + throw new Error("No authentication token found"); + } + if (!searchSpaceId) { + throw new Error("No search space id found"); + } + + return fetchChatsBySearchSpace(searchSpaceId, authToken); + }, + }; +}); From b2887543a279fb17cde5be0565badc32f7e80fad Mon Sep 17 00:00:00 2001 From: thierryverse Date: Wed, 12 Nov 2025 12:32:04 +0200 Subject: [PATCH 02/26] refactor search cpace chats fetching - with tanstack query --- .../[search_space_id]/chats/chats-client.tsx | 178 +++---- .../[search_space_id]/client-layout.tsx | 450 ++++++++++-------- .../components/chat/ChatInterface.tsx | 54 +-- 3 files changed, 364 insertions(+), 318 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx index 9517431b4..afeb42475 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx @@ -50,6 +50,8 @@ import { SelectValue, } from "@/components/ui/select"; import { cn } from "@/lib/utils"; +import { useAtomValue } from "jotai"; +import { activeSearchSpaceChatsAtom } from "@/stores/chats/active-search-space-chats.atom"; export interface Chat { created_at: string; @@ -91,10 +93,10 @@ const MotionCard = motion(Card); export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) { const router = useRouter(); - const [chats, setChats] = useState([]); + // const [chats, setChats] = useState([]); const [filteredChats, setFilteredChats] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); + // const [isFetching, setIsLoading] = useState(true); + // const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); @@ -103,6 +105,7 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [chatToDelete, setChatToDelete] = useState<{ id: number; title: string } | null>(null); const [isDeleting, setIsDeleting] = useState(false); + const {isFetching , data : chats, error} = useAtomValue(activeSearchSpaceChatsAtom); const chatsPerPage = 9; const searchParams = useSearchParams(); @@ -118,58 +121,67 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) } }, [searchParams]); - // Fetch chats from API + useEffect(() => { - const fetchChats = async () => { - try { - setIsLoading(true); + if (error) { + console.error("Error fetching chats:", error); + } + }, [error]); - // Get token from localStorage - const token = localStorage.getItem("surfsense_bearer_token"); + // Fetch chats from API + // useEffect(() => { + // const fetchChats = async () => { + // try { + // setIsLoading(true); - if (!token) { - setError("Authentication token not found. Please log in again."); - setIsLoading(false); - return; - } + // // Get token from localStorage + // const token = localStorage.getItem("surfsense_bearer_token"); - // Fetch all chats for this search space - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats?search_space_id=${searchSpaceId}`, - { - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - cache: "no-store", - } - ); + // if (!token) { + // setError("Authentication token not found. Please log in again."); + // setIsLoading(false); + // return; + // } - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(`Failed to fetch chats: ${response.status} ${errorData?.error || ""}`); - } + // // Fetch all chats for this search space + // const response = await fetch( + // `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats?search_space_id=${searchSpaceId}`, + // { + // headers: { + // Authorization: `Bearer ${token}`, + // "Content-Type": "application/json", + // }, + // cache: "no-store", + // } + // ); - const data: Chat[] = await response.json(); - setChats(data); - setFilteredChats(data); - setError(null); - } catch (error) { - console.error("Error fetching chats:", error); - setError(error instanceof Error ? error.message : "Unknown error occurred"); - setChats([]); - setFilteredChats([]); - } finally { - setIsLoading(false); - } - }; + // if (!response.ok) { + // const errorData = await response.json().catch(() => null); + // throw new Error(`Failed to fetch chats: ${response.status} ${errorData?.error || ""}`); + // } - fetchChats(); - }, [searchSpaceId]); + // const data: Chat[] = await response.json(); + // setChats(data); + // setFilteredChats(data); + // setError(null); + // } catch (error) { + // console.error("Error fetching chats:", error); + // setError(error instanceof Error ? error.message : "Unknown error occurred"); + // setChats([]); + // setFilteredChats([]); + // } finally { + // setIsLoading(false); + // } + // }; + + // fetchChats(); + // }, [searchSpaceId]); // Filter and sort chats based on search query, type, and sort order useEffect(() => { - let result = [...chats]; + let result = [...(chats || [])]; + + console.log("chats", chats); // Filter by search term if (searchQuery) { @@ -201,42 +213,42 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) // Function to handle chat deletion const handleDeleteChat = async () => { - if (!chatToDelete) return; + // if (!chatToDelete) return; - setIsDeleting(true); - try { - const token = localStorage.getItem("surfsense_bearer_token"); - if (!token) { - setIsDeleting(false); - return; - } + // setIsDeleting(true); + // try { + // const token = localStorage.getItem("surfsense_bearer_token"); + // if (!token) { + // setIsDeleting(false); + // return; + // } - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${chatToDelete.id}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - } - ); + // const response = await fetch( + // `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${chatToDelete.id}`, + // { + // method: "DELETE", + // headers: { + // Authorization: `Bearer ${token}`, + // "Content-Type": "application/json", + // }, + // } + // ); - if (!response.ok) { - throw new Error(`Failed to delete chat: ${response.statusText}`); - } + // if (!response.ok) { + // throw new Error(`Failed to delete chat: ${response.statusText}`); + // } - // Close dialog and refresh chats - setDeleteDialogOpen(false); - setChatToDelete(null); + // // Close dialog and refresh chats + // setDeleteDialogOpen(false); + // setChatToDelete(null); - // Update local state by removing the deleted chat - setChats((prevChats) => prevChats.filter((chat) => chat.id !== chatToDelete.id)); - } catch (error) { - console.error("Error deleting chat:", error); - } finally { - setIsDeleting(false); - } + // // Update local state by removing the deleted chat + // setChats((prevChats) => prevChats.filter((chat) => chat.id !== chatToDelete.id)); + // } catch (error) { + // console.error("Error deleting chat:", error); + // } finally { + // setIsDeleting(false); + // } }; // Calculate pagination @@ -245,7 +257,7 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) const currentChats = filteredChats.slice(indexOfFirstChat, indexOfLastChat); // Get unique chat types for filter dropdown - const chatTypes = ["all", ...Array.from(new Set(chats.map((chat) => chat.type)))]; + const chatTypes = chats ? ["all", ...Array.from(new Set(chats.map((chat) => chat.type)))] : []; return ( {/* Status Messages */} - {isLoading && ( + {isFetching && (
@@ -316,14 +328,14 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
)} - {error && !isLoading && ( + {error && !isFetching && (

Error loading chats

-

{error}

+

{error.message}

)} - {!isLoading && !error && filteredChats.length === 0 && ( + {!isFetching && !error && filteredChats.length === 0 && (

No chats found

@@ -336,7 +348,7 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) )} {/* Chat Grid */} - {!isLoading && !error && filteredChats.length > 0 && ( + {!isFetching && !error && filteredChats.length > 0 && (
{currentChats.map((chat, index) => ( @@ -422,7 +434,7 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) )} {/* Pagination */} - {!isLoading && !error && totalPages > 1 && ( + {!isFetching && !error && totalPages > 1 && ( diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index cc35bdb97..3ad1e94b5 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -1,9 +1,9 @@ "use client"; -import { useAtom, useAtomValue } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { Loader2, PanelRight } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; -import { usePathname, useRouter } from "next/navigation"; +import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import type React from "react"; import { useEffect, useMemo, useState } from "react"; @@ -12,233 +12,275 @@ import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider"; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +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 { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar"; import { useLLMPreferences } from "@/hooks/use-llm-configs"; import { cn } from "@/lib/utils"; import { activeChatIdAtom } from "@/stores/chats/active-chat.atom"; import { chatUIAtom } from "@/stores/chats/active-chat-ui.atom"; +import { activeSearchSpaceIdAtom } from "@/stores/seach-space/active-seach-space.atom"; export function DashboardClientLayout({ - children, - searchSpaceId, - navSecondary, - navMain, + children, + searchSpaceId, + navSecondary, + navMain, }: { - children: React.ReactNode; - searchSpaceId: string; - navSecondary: any[]; - navMain: any[]; + children: React.ReactNode; + searchSpaceId: string; + navSecondary: any[]; + navMain: any[]; }) { - const t = useTranslations("dashboard"); - const router = useRouter(); - const pathname = usePathname(); - const searchSpaceIdNum = Number(searchSpaceId); + 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(chatUIAtom); + const activeChatId = useAtomValue(activeChatIdAtom); + const setActiveSearchSpaceIdState = useSetAtom(activeSearchSpaceIdAtom); + const setActiveChatIdState = useSetAtom(activeChatIdAtom); + const [showIndicator, setShowIndicator] = useState(false); - const [chatUIState, setChatUIState] = useAtom(chatUIAtom); - const activeChatId = useAtomValue(activeChatIdAtom); - const [showIndicator, setShowIndicator] = useState(false); + const { isChatPannelOpen } = chatUIState; - const { isChatPannelOpen } = chatUIState; + // Check if we're on the researcher page + const isResearcherPage = pathname?.includes("/researcher"); - // 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]); - // 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 { loading, error, isOnboardingComplete } = + useLLMPreferences(searchSpaceIdNum); + const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false); - const { loading, error, isOnboardingComplete } = useLLMPreferences(searchSpaceIdNum); - const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false); + // Skip onboarding check if we're already on the onboarding page + const isOnboardingPage = pathname?.includes("/onboard"); - // Skip onboarding check if we're already on the onboarding page - const isOnboardingPage = pathname?.includes("/onboard"); + // Translate navigation items + const tNavMenu = useTranslations("nav_menu"); + const translatedNavMain = useMemo(() => { + return navMain.map((item) => ({ + ...item, + title: tNavMenu(item.title.toLowerCase().replace(/ /g, "_")), + items: item.items?.map((subItem: any) => ({ + ...subItem, + title: tNavMenu(subItem.title.toLowerCase().replace(/ /g, "_")), + })), + })); + }, [navMain, tNavMenu]); - // Translate navigation items - const tNavMenu = useTranslations("nav_menu"); - const translatedNavMain = useMemo(() => { - return navMain.map((item) => ({ - ...item, - title: tNavMenu(item.title.toLowerCase().replace(/ /g, "_")), - items: item.items?.map((subItem: any) => ({ - ...subItem, - title: tNavMenu(subItem.title.toLowerCase().replace(/ /g, "_")), - })), - })); - }, [navMain, tNavMenu]); + const translatedNavSecondary = useMemo(() => { + return navSecondary.map((item) => ({ + ...item, + title: + item.title === "All Search Spaces" + ? tNavMenu("all_search_spaces") + : item.title, + })); + }, [navSecondary, tNavMenu]); - const translatedNavSecondary = useMemo(() => { - return navSecondary.map((item) => ({ - ...item, - title: item.title === "All Search Spaces" ? tNavMenu("all_search_spaces") : item.title, - })); - }, [navSecondary, tNavMenu]); + const [open, setOpen] = useState(() => { + try { + const match = document.cookie.match(/(?:^|; )sidebar_state=([^;]+)/); + if (match) return match[1] === "true"; + } catch { + // ignore + } + return true; + }); - const [open, setOpen] = useState(() => { - try { - const match = document.cookie.match(/(?:^|; )sidebar_state=([^;]+)/); - if (match) return match[1] === "true"; - } catch { - // ignore - } - return true; - }); + useEffect(() => { + // Skip check if already on onboarding page + if (isOnboardingPage) { + setHasCheckedOnboarding(true); + return; + } - useEffect(() => { - // Skip check if already on onboarding page - if (isOnboardingPage) { - setHasCheckedOnboarding(true); - return; - } + // Only check once after preferences have loaded + if (!loading && !hasCheckedOnboarding) { + const onboardingComplete = isOnboardingComplete(); - // Only check once after preferences have loaded - if (!loading && !hasCheckedOnboarding) { - const onboardingComplete = isOnboardingComplete(); + if (!onboardingComplete) { + router.push(`/dashboard/${searchSpaceId}/onboard`); + } - if (!onboardingComplete) { - router.push(`/dashboard/${searchSpaceId}/onboard`); - } + setHasCheckedOnboarding(true); + } + }, [ + loading, + isOnboardingComplete, + isOnboardingPage, + router, + searchSpaceId, + hasCheckedOnboarding, + ]); - setHasCheckedOnboarding(true); - } - }, [ - loading, - isOnboardingComplete, - isOnboardingPage, - router, - searchSpaceId, - hasCheckedOnboarding, - ]); + // Synchronize active search space and chat IDs with URL + useEffect(() => { + const activeSeacrhSpaceId = + typeof search_space_id === "string" + ? search_space_id + : Array.isArray(search_space_id) && search_space_id.length > 0 + ? search_space_id[0] + : ""; + if (!activeSeacrhSpaceId) return; + setActiveSearchSpaceIdState(activeSeacrhSpaceId); + }, [search_space_id]); - // Show loading screen while checking onboarding status (only on first load) - if (!hasCheckedOnboarding && loading && !isOnboardingPage) { - return ( -
- - - {t("loading_config")} - {t("checking_llm_prefs")} - - - - - -
- ); - } + 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 error screen if there's an error loading preferences (but not on onboarding page) - if (error && !hasCheckedOnboarding && !isOnboardingPage) { - return ( -
- - - - {t("config_error")} - - {t("failed_load_llm_config")} - - -

{error}

-
-
-
- ); - } + // Show loading screen while checking onboarding status (only on first load) + if (!hasCheckedOnboarding && loading && !isOnboardingPage) { + return ( +
+ + + + {t("loading_config")} + + {t("checking_llm_prefs")} + + + + + +
+ ); + } - return ( - - {/* Use AppSidebarProvider which fetches user, search space, and recent chats */} - - -
-
-
-
-
- - - -
-
- - - {/* Only show artifacts toggle on researcher page */} - {isResearcherPage && ( - - { - 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 }} - > - - - - + // Show error screen if there's an error loading preferences (but not on onboarding page) + if (error && !hasCheckedOnboarding && !isOnboardingPage) { + return ( +
+ + + + {t("config_error")} + + {t("failed_load_llm_config")} + + +

{error}

+
+
+
+ ); + } + + return ( + + {/* Use AppSidebarProvider which fetches user, search space, and recent chats */} + + +
+
+
+
+
+ + + +
+
+ + + {/* Only show artifacts toggle on researcher page */} + {isResearcherPage && ( + + { + 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 }} + > + + + + {/* Pulsing indicator badge */} diff --git a/surfsense_web/components/chat/ChatInterface.tsx b/surfsense_web/components/chat/ChatInterface.tsx index 9aa280eb7..8a2b5c851 100644 --- a/surfsense_web/components/chat/ChatInterface.tsx +++ b/surfsense_web/components/chat/ChatInterface.tsx @@ -1,46 +1,38 @@ "use client"; -import { type ChatHandler, ChatSection as LlamaIndexChatSection } from "@llamaindex/chat-ui"; -import { useSetAtom } from "jotai"; +import { + type ChatHandler, + ChatSection as LlamaIndexChatSection, +} from "@llamaindex/chat-ui"; import { useParams } from "next/navigation"; -import { useEffect } from "react"; import { ChatInputUI } from "@/components/chat/ChatInputGroup"; import { ChatMessagesUI } from "@/components/chat/ChatMessages"; import type { Document } from "@/hooks/use-documents"; -import { activeChatIdAtom } from "@/stores/chats/active-chat.atom"; -import { ChatPanelContainer } from "./ChatPanel/ChatPanelContainer"; interface ChatInterfaceProps { - handler: ChatHandler; - onDocumentSelectionChange?: (documents: Document[]) => void; - selectedDocuments?: Document[]; - onConnectorSelectionChange?: (connectorTypes: string[]) => void; - selectedConnectors?: string[]; - searchMode?: "DOCUMENTS" | "CHUNKS"; - onSearchModeChange?: (mode: "DOCUMENTS" | "CHUNKS") => void; - topK?: number; - onTopKChange?: (topK: number) => void; + handler: ChatHandler; + onDocumentSelectionChange?: (documents: Document[]) => void; + selectedDocuments?: Document[]; + onConnectorSelectionChange?: (connectorTypes: string[]) => void; + selectedConnectors?: string[]; + searchMode?: "DOCUMENTS" | "CHUNKS"; + onSearchModeChange?: (mode: "DOCUMENTS" | "CHUNKS") => void; + topK?: number; + onTopKChange?: (topK: number) => void; } export default function ChatInterface({ - handler, - onDocumentSelectionChange, - selectedDocuments = [], - onConnectorSelectionChange, - selectedConnectors = [], - searchMode, - onSearchModeChange, - topK = 10, - onTopKChange, + handler, + onDocumentSelectionChange, + selectedDocuments = [], + onConnectorSelectionChange, + selectedConnectors = [], + searchMode, + onSearchModeChange, + topK = 10, + onTopKChange, }: ChatInterfaceProps) { - const { chat_id, search_space_id } = useParams(); - const setActiveChatIdState = useSetAtom(activeChatIdAtom); - - useEffect(() => { - const id = typeof chat_id === "string" ? chat_id : chat_id ? chat_id[0] : ""; - if (!id) return; - setActiveChatIdState(id); - }, [chat_id, search_space_id]); + const { chat_id, search_space_id } = useParams(); return ( From 207a284e9da91f332eed5b8432db856c39d5a62c Mon Sep 17 00:00:00 2001 From: thierryverse Date: Wed, 12 Nov 2025 12:43:32 +0200 Subject: [PATCH 03/26] clean up --- .../[search_space_id]/chats/chats-client.tsx | 56 +------------------ 1 file changed, 2 insertions(+), 54 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx index afeb42475..22b3bba77 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx @@ -93,10 +93,7 @@ const MotionCard = motion(Card); export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) { const router = useRouter(); - // const [chats, setChats] = useState([]); const [filteredChats, setFilteredChats] = useState([]); - // const [isFetching, setIsLoading] = useState(true); - // const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); @@ -128,55 +125,6 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) } }, [error]); - // Fetch chats from API - // useEffect(() => { - // const fetchChats = async () => { - // try { - // setIsLoading(true); - - // // Get token from localStorage - // const token = localStorage.getItem("surfsense_bearer_token"); - - // if (!token) { - // setError("Authentication token not found. Please log in again."); - // setIsLoading(false); - // return; - // } - - // // Fetch all chats for this search space - // const response = await fetch( - // `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats?search_space_id=${searchSpaceId}`, - // { - // headers: { - // Authorization: `Bearer ${token}`, - // "Content-Type": "application/json", - // }, - // cache: "no-store", - // } - // ); - - // if (!response.ok) { - // const errorData = await response.json().catch(() => null); - // throw new Error(`Failed to fetch chats: ${response.status} ${errorData?.error || ""}`); - // } - - // const data: Chat[] = await response.json(); - // setChats(data); - // setFilteredChats(data); - // setError(null); - // } catch (error) { - // console.error("Error fetching chats:", error); - // setError(error instanceof Error ? error.message : "Unknown error occurred"); - // setChats([]); - // setFilteredChats([]); - // } finally { - // setIsLoading(false); - // } - // }; - - // fetchChats(); - // }, [searchSpaceId]); - // Filter and sort chats based on search query, type, and sort order useEffect(() => { let result = [...(chats || [])]; @@ -252,8 +200,8 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) }; // Calculate pagination - const indexOfLastChat = currentPage * chatsPerPage; - const indexOfFirstChat = indexOfLastChat - chatsPerPage; + 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 From bd4e5d627d237f0cb8cd597a76a2748b9cd9fc11 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Wed, 12 Nov 2025 13:27:15 +0200 Subject: [PATCH 04/26] improve naming --- .../[search_space_id]/chats/chats-client.tsx | 4 +- .../[search_space_id]/client-layout.tsx | 6 +-- .../{stores => atoms}/announcement.atom.ts | 0 .../chats/active-chat-ui.atom.ts | 0 .../atoms/chats/queries/active-chat.atom.ts | 50 +++++++++++++++++++ .../active-search-space-chats.atom.ts | 2 +- .../seach-spaces}/active-seach-space.atom.ts | 0 .../components/announcement-banner.tsx | 2 +- .../chat/ChatPanel/ChatPanelContainer.tsx | 4 +- .../chat/ChatPanel/ChatPanelView.tsx | 4 +- .../components/chat/ChatPanel/ConfigModal.tsx | 2 +- surfsense_web/lib/apis/chat-apis.ts | 24 +++++++++ surfsense_web/lib/query-client/cache-keys.ts | 7 ++- .../stores/chats/active-chat.atom.ts | 39 --------------- 14 files changed, 90 insertions(+), 54 deletions(-) rename surfsense_web/{stores => atoms}/announcement.atom.ts (100%) rename surfsense_web/{stores => atoms}/chats/active-chat-ui.atom.ts (100%) create mode 100644 surfsense_web/atoms/chats/queries/active-chat.atom.ts rename surfsense_web/{stores/chats => atoms/chats/queries}/active-search-space-chats.atom.ts (88%) rename surfsense_web/{stores/seach-space => atoms/seach-spaces}/active-seach-space.atom.ts (100%) delete mode 100644 surfsense_web/stores/chats/active-chat.atom.ts diff --git a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx index 22b3bba77..24be65cce 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx @@ -51,7 +51,7 @@ import { } from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { useAtomValue } from "jotai"; -import { activeSearchSpaceChatsAtom } from "@/stores/chats/active-search-space-chats.atom"; +import { activeSearchSpaceChatsAtom } from "@/atoms/chats/queries/active-search-space-chats.atom"; export interface Chat { created_at: string; @@ -129,8 +129,6 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) useEffect(() => { let result = [...(chats || [])]; - console.log("chats", chats); - // Filter by search term if (searchQuery) { const query = searchQuery.toLowerCase(); diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index 3ad1e94b5..b1526a3ad 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -27,9 +27,9 @@ import { } from "@/components/ui/sidebar"; import { useLLMPreferences } from "@/hooks/use-llm-configs"; import { cn } from "@/lib/utils"; -import { activeChatIdAtom } from "@/stores/chats/active-chat.atom"; -import { chatUIAtom } from "@/stores/chats/active-chat-ui.atom"; -import { activeSearchSpaceIdAtom } from "@/stores/seach-space/active-seach-space.atom"; +import { activeChatIdAtom } from "@/atoms/chats/queries/active-chat.atom"; +import { chatUIAtom } from "@/atoms/chats/active-chat-ui.atom"; +import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/active-seach-space.atom"; export function DashboardClientLayout({ children, diff --git a/surfsense_web/stores/announcement.atom.ts b/surfsense_web/atoms/announcement.atom.ts similarity index 100% rename from surfsense_web/stores/announcement.atom.ts rename to surfsense_web/atoms/announcement.atom.ts diff --git a/surfsense_web/stores/chats/active-chat-ui.atom.ts b/surfsense_web/atoms/chats/active-chat-ui.atom.ts similarity index 100% rename from surfsense_web/stores/chats/active-chat-ui.atom.ts rename to surfsense_web/atoms/chats/active-chat-ui.atom.ts diff --git a/surfsense_web/atoms/chats/queries/active-chat.atom.ts b/surfsense_web/atoms/chats/queries/active-chat.atom.ts new file mode 100644 index 000000000..48f650768 --- /dev/null +++ b/surfsense_web/atoms/chats/queries/active-chat.atom.ts @@ -0,0 +1,50 @@ +import { atom } from "jotai"; +import { atomWithQuery } from "jotai-tanstack-query"; +import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; +import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; +import { fetchChatDetails } from "@/lib/apis/chat-apis"; +import { getPodcastByChatId } from "@/lib/apis/podcast-apis"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +type ActiveChatState = { + chatId: string | null; + chatDetails: ChatDetails | null; + podcast: PodcastItem | null; +}; + +export const activeChatIdAtom = atom(null); + +export const activeChatAtom = atomWithQuery((get) => { + const activeChatId = get(activeChatIdAtom); + const authToken = localStorage.getItem("surfsense_bearer_token"); + + if (!activeChatId) { + return { + queryKey: [], + enabled: false, + queryFn: async () => { + return { chatId: null, chatDetails: null, podcast: null }; + }, + }; + } + + return { + queryKey: cacheKeys.activeSearchSpace.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([ + getPodcastByChatId(activeChatId, authToken), + fetchChatDetails(activeChatId, authToken), + ]); + + return { chatId: activeChatId, chatDetails, podcast }; + }, + }; +}); diff --git a/surfsense_web/stores/chats/active-search-space-chats.atom.ts b/surfsense_web/atoms/chats/queries/active-search-space-chats.atom.ts similarity index 88% rename from surfsense_web/stores/chats/active-search-space-chats.atom.ts rename to surfsense_web/atoms/chats/queries/active-search-space-chats.atom.ts index df5dd2dcd..0b8b30eb0 100644 --- a/surfsense_web/stores/chats/active-search-space-chats.atom.ts +++ b/surfsense_web/atoms/chats/queries/active-search-space-chats.atom.ts @@ -1,6 +1,6 @@ import { atomWithQuery } from "jotai-tanstack-query"; import { fetchChatsBySearchSpace } from "@/lib/apis/chat-apis"; -import { activeSearchSpaceIdAtom } from "../seach-space/active-seach-space.atom"; +import { activeSearchSpaceIdAtom } from "../../seach-spaces/active-seach-space.atom"; export const activeSearchSpaceChatsAtom = atomWithQuery((get) => { const searchSpaceId = get(activeSearchSpaceIdAtom); diff --git a/surfsense_web/stores/seach-space/active-seach-space.atom.ts b/surfsense_web/atoms/seach-spaces/active-seach-space.atom.ts similarity index 100% rename from surfsense_web/stores/seach-space/active-seach-space.atom.ts rename to surfsense_web/atoms/seach-spaces/active-seach-space.atom.ts diff --git a/surfsense_web/components/announcement-banner.tsx b/surfsense_web/components/announcement-banner.tsx index c8ac05def..cbc48b444 100644 --- a/surfsense_web/components/announcement-banner.tsx +++ b/surfsense_web/components/announcement-banner.tsx @@ -3,7 +3,7 @@ import { useAtom } from "jotai"; import { ExternalLink, Info, X } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { announcementDismissedAtom } from "@/stores/announcement.atom"; +import { announcementDismissedAtom } from "@/atoms/announcement.atom"; export function AnnouncementBanner() { const [isDismissed, setIsDismissed] = useAtom(announcementDismissedAtom); diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx index 172e85334..a412f99a8 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx @@ -4,8 +4,8 @@ import { LoaderIcon, PanelRight, TriangleAlert } from "lucide-react"; import { toast } from "sonner"; import { generatePodcast } from "@/lib/apis/podcast-apis"; import { cn } from "@/lib/utils"; -import { activeChatAtom, activeChatIdAtom } from "@/stores/chats/active-chat.atom"; -import { chatUIAtom } from "@/stores/chats/active-chat-ui.atom"; +import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/queries/active-chat.atom"; +import { chatUIAtom } from "@/atoms/chats/active-chat-ui.atom"; import { ChatPanelView } from "./ChatPanelView"; export interface GeneratePodcastRequest { diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx index bb88935f2..ca220856d 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx @@ -5,8 +5,8 @@ import { AlertCircle, Play, RefreshCw, Sparkles } from "lucide-react"; import { motion } from "motion/react"; import { useCallback } from "react"; import { cn } from "@/lib/utils"; -import { activeChatAtom } from "@/stores/chats/active-chat.atom"; -import { chatUIAtom } from "@/stores/chats/active-chat-ui.atom"; +import { activeChatAtom } from "@/atoms/chats/queries/active-chat.atom"; +import { chatUIAtom } from "@/atoms/chats/active-chat-ui.atom"; import { getPodcastStalenessMessage, isPodcastStale } from "../PodcastUtils"; import type { GeneratePodcastRequest } from "./ChatPanelContainer"; import { ConfigModal } from "./ConfigModal"; diff --git a/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx b/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx index b67918a9a..175520d39 100644 --- a/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx +++ b/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx @@ -4,7 +4,7 @@ import { useAtomValue } from "jotai"; import { Pencil } from "lucide-react"; import { useCallback, useContext, useState } from "react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { activeChatAtom } from "@/stores/chats/active-chat.atom"; +import { activeChatAtom } from "@/atoms/chats/queries/active-chat.atom"; import type { GeneratePodcastRequest } from "./ChatPanelContainer"; interface ConfigModalProps { diff --git a/surfsense_web/lib/apis/chat-apis.ts b/surfsense_web/lib/apis/chat-apis.ts index 4ae8a8feb..ff3076744 100644 --- a/surfsense_web/lib/apis/chat-apis.ts +++ b/surfsense_web/lib/apis/chat-apis.ts @@ -54,3 +54,27 @@ export const fetchChatsBySearchSpace = async ( return null; } }; + + +export const deleteChat = async (chatId: number, authToken: string) => { + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${chatId}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${authToken}`, + }, + } + ); + + if (!response.ok) { + throw new Error(`Failed to delete chat: ${response.statusText}`); + } + + return true; + } catch (err) { + console.error("Error deleting chat:", err); + return false; + } +}; diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 0b66f1450..335125bb8 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -1,4 +1,7 @@ export const cacheKeys = { - activeChat: (chatId: string) => ["activeChat", chatId], - activeSearchSpaceChats: (searchSpaceId: string) => ["activeSearchSpaceChats", searchSpaceId], + activeSearchSpace: { + chats : (searchSpaceId: string) => ["active-search-space", "chats", searchSpaceId] as const, + activeChat : (chatId: string) => ["active-search-space", "active-chat", chatId] as const, + deleteChat : ( searchSpaceId: string, chatId: string) => ["active-search-space", "chats", searchSpaceId, "delete", chatId] as const, + }, }; diff --git a/surfsense_web/stores/chats/active-chat.atom.ts b/surfsense_web/stores/chats/active-chat.atom.ts deleted file mode 100644 index a0d40a96c..000000000 --- a/surfsense_web/stores/chats/active-chat.atom.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { atom } from "jotai"; -import { atomWithQuery } from "jotai-tanstack-query"; -import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; -import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; -import { fetchChatDetails } from "@/lib/apis/chat-apis"; -import { getPodcastByChatId } from "@/lib/apis/podcast-apis"; - -type ActiveChatState = { - chatId: string | null; - chatDetails: ChatDetails | null; - podcast: PodcastItem | null; -}; - -export const activeChatIdAtom = atom(null); - -export const activeChatAtom = atomWithQuery((get) => { - const activeChatId = get(activeChatIdAtom); - const authToken = localStorage.getItem("surfsense_bearer_token"); - - return { - queryKey: ["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([ - getPodcastByChatId(activeChatId, authToken), - fetchChatDetails(activeChatId, authToken), - ]); - - return { chatId: activeChatId, chatDetails, podcast }; - }, - }; -}); From b866170e6a15c78eb40f586033108bb81475bf27 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Wed, 12 Nov 2025 14:12:07 +0200 Subject: [PATCH 05/26] add delete chat mutation --- .../[search_space_id]/chats/chats-client.tsx | 866 +++++++++--------- .../[search_space_id]/client-layout.tsx | 4 +- ...ve-chat-ui.atom.ts => active-chat.atom.ts} | 0 .../mutations/delete-chat.mutation.atom.ts | 33 + ...chat.atom.ts => active-chat.query.atom.ts} | 12 +- ...> active-search-space-chats.query.atom.ts} | 3 +- .../chat/ChatPanel/ChatPanelContainer.tsx | 4 +- .../chat/ChatPanel/ChatPanelView.tsx | 4 +- .../components/chat/ChatPanel/ConfigModal.tsx | 2 +- surfsense_web/lib/apis/chat-apis.ts | 2 - surfsense_web/lib/query-client/cache-keys.ts | 1 - 11 files changed, 486 insertions(+), 445 deletions(-) rename surfsense_web/atoms/chats/{active-chat-ui.atom.ts => active-chat.atom.ts} (100%) create mode 100644 surfsense_web/atoms/chats/mutations/delete-chat.mutation.atom.ts rename surfsense_web/atoms/chats/queries/{active-chat.atom.ts => active-chat.query.atom.ts} (87%) rename surfsense_web/atoms/chats/queries/{active-search-space-chats.atom.ts => active-search-space-chats.query.atom.ts} (84%) diff --git a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx index 24be65cce..272f34509 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx @@ -2,491 +2,511 @@ import { format } from "date-fns"; import { - Calendar, - ExternalLink, - MessageCircleMore, - MoreHorizontal, - Search, - Tag, - Trash2, + 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 { toast } from "sonner"; 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, + 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, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, } from "@/components/ui/pagination"; import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, } from "@/components/ui/select"; -import { cn } from "@/lib/utils"; -import { useAtomValue } from "jotai"; -import { activeSearchSpaceChatsAtom } from "@/atoms/chats/queries/active-search-space-chats.atom"; +import { useAtom, useAtomValue } from "jotai"; +import { activeSearchSpaceChatsAtom } from "@/atoms/chats/queries/active-search-space-chats.query.atom"; +import { deleteChatMutationAtom } from "@/atoms/chats/mutations/delete-chat.mutation.atom"; export interface Chat { - created_at: string; - id: number; - type: "DOCUMENT" | "CHAT"; - title: string; - search_space_id: number; - state_version: number; + created_at: string; + id: number; + type: "DOCUMENT" | "CHAT"; + title: string; + search_space_id: number; + state_version: number; } export interface ChatDetails { - type: "DOCUMENT" | "CHAT"; - title: string; - initial_connectors: string[]; - messages: any[]; - created_at: string; - id: number; - search_space_id: number; - state_version: number; + type: "DOCUMENT" | "CHAT"; + title: string; + initial_connectors: string[]; + messages: any[]; + created_at: string; + id: number; + search_space_id: number; + state_version: number; } interface ChatsPageClientProps { - searchSpaceId: string; + 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" } }, + 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 }, + 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([]); - const [searchQuery, setSearchQuery] = useState(""); - const [currentPage, setCurrentPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - const [selectedType, setSelectedType] = useState("all"); - const [sortOrder, setSortOrder] = useState("newest"); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [chatToDelete, setChatToDelete] = useState<{ id: number; title: string } | null>(null); - const [isDeleting, setIsDeleting] = useState(false); - const {isFetching , data : chats, error} = useAtomValue(activeSearchSpaceChatsAtom); +export default function ChatsPageClient({ + searchSpaceId, +}: ChatsPageClientProps) { + const router = useRouter(); + const [filteredChats, setFilteredChats] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [selectedType, setSelectedType] = useState("all"); + const [sortOrder, setSortOrder] = useState("newest"); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [chatToDelete, setChatToDelete] = useState<{ + id: number; + title: string; + } | null>(null); + const { + isFetching: isFetchingChats, + data: chats, + error: fetchError, + } = useAtomValue(activeSearchSpaceChatsAtom); + const [ + { isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError } + ] = useAtom(deleteChatMutationAtom); - const chatsPerPage = 9; - const searchParams = useSearchParams(); + 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]); + // 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]); + useEffect(() => { + if (fetchError) { + console.error("Error fetching chats:", fetchError); + } + }, [fetchError]); - useEffect(() => { - if (error) { - console.error("Error fetching chats:", error); - } - }, [error]); + useEffect(() => { + if (deleteError) { + console.error("Error deleting chat:", deleteError); + } + }, [deleteError]); - // Filter and sort chats based on search query, type, and sort order - useEffect(() => { - let result = [...(chats || [])]; + // 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 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); - } + // 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(); + // 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; - }); + return sortOrder === "newest" ? dateB - dateA : dateA - dateB; + }); - setFilteredChats(result); - setTotalPages(Math.max(1, Math.ceil(result.length / chatsPerPage))); + 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]); + // 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; + // Function to handle chat deletion + const handleDeleteChat = async () => { + if (!chatToDelete) return; - // setIsDeleting(true); - // try { - // const token = localStorage.getItem("surfsense_bearer_token"); - // if (!token) { - // setIsDeleting(false); - // return; - // } + await deleteChat(chatToDelete.id); - // const response = await fetch( - // `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${chatToDelete.id}`, - // { - // method: "DELETE", - // headers: { - // Authorization: `Bearer ${token}`, - // "Content-Type": "application/json", - // }, - // } - // ); + setDeleteDialogOpen(false); + setChatToDelete(null); + }; - // if (!response.ok) { - // throw new Error(`Failed to delete chat: ${response.statusText}`); - // } + // 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); - // // Close dialog and refresh chats - // setDeleteDialogOpen(false); - // setChatToDelete(null); + // Get unique chat types for filter dropdown + const chatTypes = chats + ? ["all", ...Array.from(new Set(chats.map((chat) => chat.type)))] + : []; - // // Update local state by removing the deleted chat - // setChats((prevChats) => prevChats.filter((chat) => chat.id !== chatToDelete.id)); - // } catch (error) { - // console.error("Error deleting chat:", error); - // } finally { - // setIsDeleting(false); - // } - }; + return ( + +
+
+

All Chats

+

+ View, search, and manage all your chats. +

+
- // 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); + {/* Filter and Search Bar */} +
+
+
+ + setSearchQuery(e.target.value)} + /> +
- // Get unique chat types for filter dropdown - const chatTypes = chats ? ["all", ...Array.from(new Set(chats.map((chat) => chat.type)))] : []; + +
- return ( - -
-
-

All Chats

-

View, search, and manage all your chats.

-
+
+ +
+
- {/* Filter and Search Bar */} -
-
-
- - setSearchQuery(e.target.value)} - /> -
+ {/* Status Messages */} + {isFetchingChats && ( +
+
+
+

Loading chats...

+
+
+ )} - -
+ {fetchError && !isFetchingChats && ( +
+

Error loading chats

+

{fetchError.message}

+
+ )} -
- -
-
+ {!isFetchingChats && !fetchError && filteredChats.length === 0 && ( +
+ +

No chats found

+

+ {searchQuery || selectedType !== "all" + ? "Try adjusting your search filters" + : "Start a new chat to get started"} +

+
+ )} - {/* Status Messages */} - {isFetching && ( -
-
-
-

Loading chats...

-
-
- )} + {/* Chat Grid */} + {!isFetchingChats && !fetchError && filteredChats.length > 0 && ( + +
+ {currentChats.map((chat, index) => ( + + +
+
+ + {chat.title || `Chat ${chat.id}`} + + + + + + {format(new Date(chat.created_at), "MMM d, yyyy")} + + + +
+ + + + + + + router.push( + `/dashboard/${chat.search_space_id}/researcher/${chat.id}` + ) + } + > + + View Chat + + + { + e.stopPropagation(); + setChatToDelete({ + id: chat.id, + title: chat.title || `Chat ${chat.id}`, + }); + setDeleteDialogOpen(true); + }} + > + + Delete Chat + + + +
+
- {error && !isFetching && ( -
-

Error loading chats

-

{error.message}

-
- )} + + + + {chat.type || "Unknown"} + + + +
+ ))} +
+
+ )} - {!isFetching && !error && filteredChats.length === 0 && ( -
- -

No chats found

-

- {searchQuery || selectedType !== "all" - ? "Try adjusting your search filters" - : "Start a new chat to get started"} -

-
- )} + {/* Pagination */} + {!isFetchingChats && !fetchError && totalPages > 1 && ( + + + + { + e.preventDefault(); + if (currentPage > 1) setCurrentPage(currentPage - 1); + }} + className={ + currentPage <= 1 ? "pointer-events-none opacity-50" : "" + } + /> + - {/* Chat Grid */} - {!isFetching && !error && filteredChats.length > 0 && ( - -
- {currentChats.map((chat, index) => ( - - -
-
- - {chat.title || `Chat ${chat.id}`} - - - - - {format(new Date(chat.created_at), "MMM d, yyyy")} - - -
- - - - - - - router.push( - `/dashboard/${chat.search_space_id}/researcher/${chat.id}` - ) - } - > - - View Chat - - - { - e.stopPropagation(); - setChatToDelete({ - id: chat.id, - title: chat.title || `Chat ${chat.id}`, - }); - setDeleteDialogOpen(true); - }} - > - - Delete Chat - - - -
-
+ {Array.from({ length: totalPages }).map((_, index) => { + const pageNumber = index + 1; + const isVisible = + pageNumber === 1 || + pageNumber === totalPages || + (pageNumber >= currentPage - 1 && + pageNumber <= currentPage + 1); - - - - {chat.type || "Unknown"} - - - -
- ))} -
-
- )} + if (!isVisible) { + // Show ellipsis at appropriate positions + if (pageNumber === 2 || pageNumber === totalPages - 1) { + return ( + + + ... + + + ); + } + return null; + } - {/* Pagination */} - {!isFetching && !error && totalPages > 1 && ( - - - - { - e.preventDefault(); - if (currentPage > 1) setCurrentPage(currentPage - 1); - }} - className={currentPage <= 1 ? "pointer-events-none opacity-50" : ""} - /> - + return ( + + { + e.preventDefault(); + setCurrentPage(pageNumber); + }} + isActive={pageNumber === currentPage} + > + {pageNumber} + + + ); + })} - {Array.from({ length: totalPages }).map((_, index) => { - const pageNumber = index + 1; - const isVisible = - pageNumber === 1 || - pageNumber === totalPages || - (pageNumber >= currentPage - 1 && pageNumber <= currentPage + 1); + + { + e.preventDefault(); + if (currentPage < totalPages) + setCurrentPage(currentPage + 1); + }} + className={ + currentPage >= totalPages + ? "pointer-events-none opacity-50" + : "" + } + /> + + + + )} +
- if (!isVisible) { - // Show ellipsis at appropriate positions - if (pageNumber === 2 || pageNumber === totalPages - 1) { - return ( - - ... - - ); - } - return null; - } - - return ( - - { - e.preventDefault(); - setCurrentPage(pageNumber); - }} - isActive={pageNumber === currentPage} - > - {pageNumber} - - - ); - })} - - - { - e.preventDefault(); - if (currentPage < totalPages) setCurrentPage(currentPage + 1); - }} - className={currentPage >= totalPages ? "pointer-events-none opacity-50" : ""} - /> - - - - )} -
- - {/* Delete Confirmation Dialog */} - - - - - - Delete Chat - - - Are you sure you want to delete{" "} - {chatToDelete?.title}? This action cannot be - undone. - - - - - - - - -
- ); + {/* Delete Confirmation Dialog */} + + + + + + Delete Chat + + + Are you sure you want to delete{" "} + {chatToDelete?.title}? This + action cannot be undone. + + + + + + + + +
+ ); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index b1526a3ad..ceeb1e7f4 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -27,8 +27,8 @@ import { } from "@/components/ui/sidebar"; import { useLLMPreferences } from "@/hooks/use-llm-configs"; import { cn } from "@/lib/utils"; -import { activeChatIdAtom } from "@/atoms/chats/queries/active-chat.atom"; -import { chatUIAtom } from "@/atoms/chats/active-chat-ui.atom"; +import { activeChatIdAtom } from "@/atoms/chats/queries/active-chat.query.atom"; +import { chatUIAtom } from "@/atoms/chats/active-chat.atom"; import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/active-seach-space.atom"; export function DashboardClientLayout({ diff --git a/surfsense_web/atoms/chats/active-chat-ui.atom.ts b/surfsense_web/atoms/chats/active-chat.atom.ts similarity index 100% rename from surfsense_web/atoms/chats/active-chat-ui.atom.ts rename to surfsense_web/atoms/chats/active-chat.atom.ts diff --git a/surfsense_web/atoms/chats/mutations/delete-chat.mutation.atom.ts b/surfsense_web/atoms/chats/mutations/delete-chat.mutation.atom.ts new file mode 100644 index 000000000..994906c24 --- /dev/null +++ b/surfsense_web/atoms/chats/mutations/delete-chat.mutation.atom.ts @@ -0,0 +1,33 @@ +import { atomWithMutation } from "jotai-tanstack-query"; +import { deleteChat } from "@/lib/apis/chat-apis"; +import { activeSearchSpaceIdAtom } from "../../seach-spaces/active-seach-space.atom"; +import { queryClient } from "@/lib/query-client/client"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { toast } from "sonner"; + +export const deleteChatMutationAtom = atomWithMutation((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + const authToken = localStorage.getItem("surfsense_bearer_token"); + + return { + mutationKey: cacheKeys.activeSearchSpace.chats(searchSpaceId ?? ""), + enabled: !!searchSpaceId && !!authToken, + mutationFn: async (chatId: number) => { + if (!authToken) { + throw new Error("No authentication token found"); + } + if (!searchSpaceId) { + throw new Error("No search space id found"); + } + + return deleteChat(chatId, authToken); + }, + + onSuccess: () => { + toast.success("Chat deleted successfully"); + queryClient.invalidateQueries({ + queryKey: cacheKeys.activeSearchSpace.chats(searchSpaceId!), + }); + }, + }; +}); diff --git a/surfsense_web/atoms/chats/queries/active-chat.atom.ts b/surfsense_web/atoms/chats/queries/active-chat.query.atom.ts similarity index 87% rename from surfsense_web/atoms/chats/queries/active-chat.atom.ts rename to surfsense_web/atoms/chats/queries/active-chat.query.atom.ts index 48f650768..cb4ad9201 100644 --- a/surfsense_web/atoms/chats/queries/active-chat.atom.ts +++ b/surfsense_web/atoms/chats/queries/active-chat.query.atom.ts @@ -18,18 +18,8 @@ export const activeChatAtom = atomWithQuery((get) => { const activeChatId = get(activeChatIdAtom); const authToken = localStorage.getItem("surfsense_bearer_token"); - if (!activeChatId) { - return { - queryKey: [], - enabled: false, - queryFn: async () => { - return { chatId: null, chatDetails: null, podcast: null }; - }, - }; - } - return { - queryKey: cacheKeys.activeSearchSpace.activeChat(activeChatId), + queryKey: cacheKeys.activeSearchSpace.activeChat(activeChatId ?? ""), enabled: !!activeChatId && !!authToken, queryFn: async () => { if (!authToken) { diff --git a/surfsense_web/atoms/chats/queries/active-search-space-chats.atom.ts b/surfsense_web/atoms/chats/queries/active-search-space-chats.query.atom.ts similarity index 84% rename from surfsense_web/atoms/chats/queries/active-search-space-chats.atom.ts rename to surfsense_web/atoms/chats/queries/active-search-space-chats.query.atom.ts index 0b8b30eb0..feafcf434 100644 --- a/surfsense_web/atoms/chats/queries/active-search-space-chats.atom.ts +++ b/surfsense_web/atoms/chats/queries/active-search-space-chats.query.atom.ts @@ -1,13 +1,14 @@ import { atomWithQuery } from "jotai-tanstack-query"; import { fetchChatsBySearchSpace } from "@/lib/apis/chat-apis"; import { activeSearchSpaceIdAtom } from "../../seach-spaces/active-seach-space.atom"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; export const activeSearchSpaceChatsAtom = atomWithQuery((get) => { const searchSpaceId = get(activeSearchSpaceIdAtom); const authToken = localStorage.getItem("surfsense_bearer_token"); return { - queryKey: ["chatsBySearchSpace", searchSpaceId], + queryKey: cacheKeys.activeSearchSpace.chats(searchSpaceId ?? ""), enabled: !!searchSpaceId && !!authToken, queryFn: async () => { if (!authToken) { diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx index a412f99a8..bc443d143 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx @@ -4,8 +4,8 @@ import { LoaderIcon, PanelRight, TriangleAlert } from "lucide-react"; import { toast } from "sonner"; import { generatePodcast } from "@/lib/apis/podcast-apis"; import { cn } from "@/lib/utils"; -import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/queries/active-chat.atom"; -import { chatUIAtom } from "@/atoms/chats/active-chat-ui.atom"; +import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/queries/active-chat.query.atom"; +import { chatUIAtom } from "@/atoms/chats/active-chat.atom"; import { ChatPanelView } from "./ChatPanelView"; export interface GeneratePodcastRequest { diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx index ca220856d..c1672a7ea 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx @@ -5,8 +5,8 @@ import { AlertCircle, Play, RefreshCw, Sparkles } from "lucide-react"; import { motion } from "motion/react"; import { useCallback } from "react"; import { cn } from "@/lib/utils"; -import { activeChatAtom } from "@/atoms/chats/queries/active-chat.atom"; -import { chatUIAtom } from "@/atoms/chats/active-chat-ui.atom"; +import { activeChatAtom } from "@/atoms/chats/queries/active-chat.query.atom"; +import { chatUIAtom } from "@/atoms/chats/active-chat.atom"; import { getPodcastStalenessMessage, isPodcastStale } from "../PodcastUtils"; import type { GeneratePodcastRequest } from "./ChatPanelContainer"; import { ConfigModal } from "./ConfigModal"; diff --git a/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx b/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx index 175520d39..25b6446c0 100644 --- a/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx +++ b/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx @@ -4,7 +4,7 @@ import { useAtomValue } from "jotai"; import { Pencil } from "lucide-react"; import { useCallback, useContext, useState } from "react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { activeChatAtom } from "@/atoms/chats/queries/active-chat.atom"; +import { activeChatAtom } from "@/atoms/chats/queries/active-chat.query.atom"; import type { GeneratePodcastRequest } from "./ChatPanelContainer"; interface ConfigModalProps { diff --git a/surfsense_web/lib/apis/chat-apis.ts b/surfsense_web/lib/apis/chat-apis.ts index ff3076744..5a4789be7 100644 --- a/surfsense_web/lib/apis/chat-apis.ts +++ b/surfsense_web/lib/apis/chat-apis.ts @@ -72,9 +72,7 @@ export const deleteChat = async (chatId: number, authToken: string) => { throw new Error(`Failed to delete chat: ${response.statusText}`); } - return true; } catch (err) { console.error("Error deleting chat:", err); - return false; } }; diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 335125bb8..5b74092e1 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -2,6 +2,5 @@ export const cacheKeys = { activeSearchSpace: { chats : (searchSpaceId: string) => ["active-search-space", "chats", searchSpaceId] as const, activeChat : (chatId: string) => ["active-search-space", "active-chat", chatId] as const, - deleteChat : ( searchSpaceId: string, chatId: string) => ["active-search-space", "chats", searchSpaceId, "delete", chatId] as const, }, }; From 43362a383eca077beec0f88b24681681f7b6805c Mon Sep 17 00:00:00 2001 From: thierryverse Date: Wed, 12 Nov 2025 14:15:46 +0200 Subject: [PATCH 06/26] add delete chat mutation --- .../atoms/chats/mutations/delete-chat.mutation.atom.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/surfsense_web/atoms/chats/mutations/delete-chat.mutation.atom.ts b/surfsense_web/atoms/chats/mutations/delete-chat.mutation.atom.ts index 994906c24..b29f54f4e 100644 --- a/surfsense_web/atoms/chats/mutations/delete-chat.mutation.atom.ts +++ b/surfsense_web/atoms/chats/mutations/delete-chat.mutation.atom.ts @@ -4,6 +4,7 @@ import { activeSearchSpaceIdAtom } from "../../seach-spaces/active-seach-space.a import { queryClient } from "@/lib/query-client/client"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { toast } from "sonner"; +import { Chat } from "@/app/dashboard/[search_space_id]/chats/chats-client"; export const deleteChatMutationAtom = atomWithMutation((get) => { const searchSpaceId = get(activeSearchSpaceIdAtom); @@ -23,10 +24,10 @@ export const deleteChatMutationAtom = atomWithMutation((get) => { return deleteChat(chatId, authToken); }, - onSuccess: () => { + onSuccess: (_, chatId) => { toast.success("Chat deleted successfully"); - queryClient.invalidateQueries({ - queryKey: cacheKeys.activeSearchSpace.chats(searchSpaceId!), + queryClient.setQueryData(cacheKeys.activeSearchSpace.chats(searchSpaceId!), (oldData: Chat[]) => { + return oldData.filter((chat) => chat.id !== chatId); }); }, }; From 78ab020d808e7798f020222fbfa1fd3c181cf6ea Mon Sep 17 00:00:00 2001 From: thierryverse Date: Wed, 12 Nov 2025 16:22:30 +0200 Subject: [PATCH 07/26] organize seach space apis --- .../[search_space_id]/chats/chats-client.tsx | 4 +- .../[search_space_id]/client-layout.tsx | 6 +- surfsense_web/app/dashboard/page.tsx | 2 +- ...utation.atom.ts => chat-mutations.atom.ts} | 4 +- ...hat.query.atom.ts => chat-queries.atom.ts} | 28 ++++- .../{active-chat.atom.ts => chat-uis.atom.ts} | 0 .../active-search-space-chats.query.atom.ts | 24 ---- ...ce.atom.ts => seach-space-queries.atom.ts} | 2 +- .../chat/ChatPanel/ChatPanelContainer.tsx | 6 +- .../chat/ChatPanel/ChatPanelView.tsx | 4 +- .../components/chat/ChatPanel/ConfigModal.tsx | 2 +- .../components/dashboard-breadcrumb.tsx | 2 +- .../lib/apis/{chat-apis.ts => chats.api.ts} | 0 .../apis/{podcast-apis.ts => podcasts.api.ts} | 0 surfsense_web/lib/apis/search-spaces.api.ts | 112 ++++++++++++++++++ 15 files changed, 154 insertions(+), 42 deletions(-) rename surfsense_web/atoms/chats/{mutations/delete-chat.mutation.atom.ts => chat-mutations.atom.ts} (89%) rename surfsense_web/atoms/chats/{queries/active-chat.query.atom.ts => chat-queries.atom.ts} (60%) rename surfsense_web/atoms/chats/{active-chat.atom.ts => chat-uis.atom.ts} (100%) delete mode 100644 surfsense_web/atoms/chats/queries/active-search-space-chats.query.atom.ts rename surfsense_web/atoms/seach-spaces/{active-seach-space.atom.ts => seach-space-queries.atom.ts} (97%) rename surfsense_web/lib/apis/{chat-apis.ts => chats.api.ts} (100%) rename surfsense_web/lib/apis/{podcast-apis.ts => podcasts.api.ts} (100%) create mode 100644 surfsense_web/lib/apis/search-spaces.api.ts diff --git a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx index 272f34509..a0383d886 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx @@ -55,8 +55,8 @@ import { SelectValue, } from "@/components/ui/select"; import { useAtom, useAtomValue } from "jotai"; -import { activeSearchSpaceChatsAtom } from "@/atoms/chats/queries/active-search-space-chats.query.atom"; -import { deleteChatMutationAtom } from "@/atoms/chats/mutations/delete-chat.mutation.atom"; +import { activeSearchSpaceChatsAtom } from "@/atoms/chats/chat-queries.atom"; +import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutations.atom"; export interface Chat { created_at: string; diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index ceeb1e7f4..40f6b2255 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -27,9 +27,9 @@ import { } from "@/components/ui/sidebar"; import { useLLMPreferences } from "@/hooks/use-llm-configs"; import { cn } from "@/lib/utils"; -import { activeChatIdAtom } from "@/atoms/chats/queries/active-chat.query.atom"; -import { chatUIAtom } from "@/atoms/chats/active-chat.atom"; -import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/active-seach-space.atom"; +import { activeChatIdAtom } from "@/atoms/chats/chat-queries.atom"; +import { chatUIAtom } from "@/atoms/chats/chat-uis.atom"; +import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; export function DashboardClientLayout({ children, diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx index 4c9e30232..d61e714c6 100644 --- a/surfsense_web/app/dashboard/page.tsx +++ b/surfsense_web/app/dashboard/page.tsx @@ -236,7 +236,7 @@ const DashboardPage = () => { {searchSpaces && searchSpaces.length > 0 && searchSpaces.map((space) => ( - + ((get) => { }, }; }); + +export const activeSearchSpaceChatsAtom = atomWithQuery((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + const authToken = localStorage.getItem("surfsense_bearer_token"); + + return { + queryKey: cacheKeys.activeSearchSpace.chats(searchSpaceId ?? ""), + enabled: !!searchSpaceId && !!authToken, + queryFn: async () => { + if (!authToken) { + throw new Error("No authentication token found"); + } + if (!searchSpaceId) { + throw new Error("No search space id found"); + } + + return fetchChatsBySearchSpace(searchSpaceId, authToken); + }, + }; +}); diff --git a/surfsense_web/atoms/chats/active-chat.atom.ts b/surfsense_web/atoms/chats/chat-uis.atom.ts similarity index 100% rename from surfsense_web/atoms/chats/active-chat.atom.ts rename to surfsense_web/atoms/chats/chat-uis.atom.ts diff --git a/surfsense_web/atoms/chats/queries/active-search-space-chats.query.atom.ts b/surfsense_web/atoms/chats/queries/active-search-space-chats.query.atom.ts deleted file mode 100644 index feafcf434..000000000 --- a/surfsense_web/atoms/chats/queries/active-search-space-chats.query.atom.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { atomWithQuery } from "jotai-tanstack-query"; -import { fetchChatsBySearchSpace } from "@/lib/apis/chat-apis"; -import { activeSearchSpaceIdAtom } from "../../seach-spaces/active-seach-space.atom"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; - -export const activeSearchSpaceChatsAtom = atomWithQuery((get) => { - const searchSpaceId = get(activeSearchSpaceIdAtom); - const authToken = localStorage.getItem("surfsense_bearer_token"); - - return { - queryKey: cacheKeys.activeSearchSpace.chats(searchSpaceId ?? ""), - enabled: !!searchSpaceId && !!authToken, - queryFn: async () => { - if (!authToken) { - throw new Error("No authentication token found"); - } - if (!searchSpaceId) { - throw new Error("No search space id found"); - } - - return fetchChatsBySearchSpace(searchSpaceId, authToken); - }, - }; -}); diff --git a/surfsense_web/atoms/seach-spaces/active-seach-space.atom.ts b/surfsense_web/atoms/seach-spaces/seach-space-queries.atom.ts similarity index 97% rename from surfsense_web/atoms/seach-spaces/active-seach-space.atom.ts rename to surfsense_web/atoms/seach-spaces/seach-space-queries.atom.ts index 4bccf496f..dcdf6d550 100644 --- a/surfsense_web/atoms/seach-spaces/active-seach-space.atom.ts +++ b/surfsense_web/atoms/seach-spaces/seach-space-queries.atom.ts @@ -1,3 +1,3 @@ import { atom } from "jotai"; -export const activeSearchSpaceIdAtom = atom(null); +export const activeSearchSpaceIdAtom = atom(null); \ No newline at end of file diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx index bc443d143..b2dc67ba8 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx @@ -2,10 +2,10 @@ import { useAtom, useAtomValue } from "jotai"; import { LoaderIcon, PanelRight, TriangleAlert } from "lucide-react"; import { toast } from "sonner"; -import { generatePodcast } from "@/lib/apis/podcast-apis"; +import { generatePodcast } from "@/lib/apis/podcasts.api"; import { cn } from "@/lib/utils"; -import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/queries/active-chat.query.atom"; -import { chatUIAtom } from "@/atoms/chats/active-chat.atom"; +import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/chat-queries.atom"; +import { chatUIAtom } from "@/atoms/chats/chat-uis.atom"; import { ChatPanelView } from "./ChatPanelView"; export interface GeneratePodcastRequest { diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx index c1672a7ea..5b490b16c 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx @@ -5,8 +5,8 @@ import { AlertCircle, Play, RefreshCw, Sparkles } from "lucide-react"; import { motion } from "motion/react"; import { useCallback } from "react"; import { cn } from "@/lib/utils"; -import { activeChatAtom } from "@/atoms/chats/queries/active-chat.query.atom"; -import { chatUIAtom } from "@/atoms/chats/active-chat.atom"; +import { activeChatAtom } from "@/atoms/chats/chat-queries.atom"; +import { chatUIAtom } from "@/atoms/chats/chat-uis.atom"; import { getPodcastStalenessMessage, isPodcastStale } from "../PodcastUtils"; import type { GeneratePodcastRequest } from "./ChatPanelContainer"; import { ConfigModal } from "./ConfigModal"; diff --git a/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx b/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx index 25b6446c0..c7ac58e9b 100644 --- a/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx +++ b/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx @@ -4,7 +4,7 @@ import { useAtomValue } from "jotai"; import { Pencil } from "lucide-react"; import { useCallback, useContext, useState } from "react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { activeChatAtom } from "@/atoms/chats/queries/active-chat.query.atom"; +import { activeChatAtom } from "@/atoms/chats/chat-queries.atom"; import type { GeneratePodcastRequest } from "./ChatPanelContainer"; interface ConfigModalProps { diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx index 2348ddb42..2b032f8fd 100644 --- a/surfsense_web/components/dashboard-breadcrumb.tsx +++ b/surfsense_web/components/dashboard-breadcrumb.tsx @@ -13,7 +13,7 @@ import { BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { useSearchSpace } from "@/hooks/use-search-space"; -import { fetchChatDetails } from "@/lib/apis/chat-apis"; +import { fetchChatDetails } from "@/lib/apis/chats.api"; interface BreadcrumbItemInterface { label: string; diff --git a/surfsense_web/lib/apis/chat-apis.ts b/surfsense_web/lib/apis/chats.api.ts similarity index 100% rename from surfsense_web/lib/apis/chat-apis.ts rename to surfsense_web/lib/apis/chats.api.ts diff --git a/surfsense_web/lib/apis/podcast-apis.ts b/surfsense_web/lib/apis/podcasts.api.ts similarity index 100% rename from surfsense_web/lib/apis/podcast-apis.ts rename to surfsense_web/lib/apis/podcasts.api.ts diff --git a/surfsense_web/lib/apis/search-spaces.api.ts b/surfsense_web/lib/apis/search-spaces.api.ts new file mode 100644 index 000000000..f4b2ad74d --- /dev/null +++ b/surfsense_web/lib/apis/search-spaces.api.ts @@ -0,0 +1,112 @@ +export const fetchSearchSpaces = async () => { + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem( + "surfsense_bearer_token" + )}`, + }, + method: "GET", + } + ); + + if (!response.ok) { + throw new Error("Not authenticated"); + } + + const data = await response.json(); + return data; + } catch (err: any) { + console.error("Error fetching search spaces:", err); + return null; + } +}; + +export const handleDeleteSearchSpace = async (id: number) => { + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${id}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${localStorage.getItem( + "surfsense_bearer_token" + )}`, + }, + } + ); + + if (!response.ok) { + throw new Error("Failed to delete search space"); + } + } catch (error) { + console.error("Error deleting search space:", error); + return; + } +}; + +export const handleCreateSearchSpace = async (data: { + name: string; + description: string; +}) => { + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem( + "surfsense_bearer_token" + )}`, + }, + body: JSON.stringify(data), + } + ); + + if (!response.ok) { + throw new Error("Failed to create search space"); + } + + const result = await response.json(); + + return result; + } catch (error) { + console.error("Error creating search space:", error); + throw error; + } +}; + +export const fetchSearchSpace = async (searchSpaceId: string) => { + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem( + "surfsense_bearer_token" + )}`, + }, + method: "GET", + } + ); + + if (response.status === 401) { + // Clear token and redirect to home + localStorage.removeItem("surfsense_bearer_token"); + window.location.href = "/"; + throw new Error("Unauthorized: Redirecting to login page"); + } + + if (!response.ok) { + throw new Error(`Failed to fetch search space: ${response.status}`); + } + + const data = await response.json(); + return data; + } catch (err: any) { + console.error("Error fetching search space:", err); + } +}; From 0e488c799dcb5621a1ec6f721840db37bb8f14d0 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Wed, 12 Nov 2025 16:59:56 +0200 Subject: [PATCH 08/26] organiza podcasts apis --- surfsense_web/lib/apis/chats.api.ts | 201 ++++++++++++++------ surfsense_web/lib/apis/documents.api.ts | 20 ++ surfsense_web/lib/apis/podcasts.api.ts | 109 +++++++---- surfsense_web/lib/apis/search-spaces.api.ts | 157 +++++++-------- 4 files changed, 302 insertions(+), 185 deletions(-) create mode 100644 surfsense_web/lib/apis/documents.api.ts diff --git a/surfsense_web/lib/apis/chats.api.ts b/surfsense_web/lib/apis/chats.api.ts index 5a4789be7..ddd1704ae 100644 --- a/surfsense_web/lib/apis/chats.api.ts +++ b/surfsense_web/lib/apis/chats.api.ts @@ -1,78 +1,167 @@ -import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; +import type { + Chat, + ChatDetails, +} from "@/app/dashboard/[search_space_id]/chats/chats-client"; +import { ResearchMode } from "@/components/chat/types"; +import { Message } from "@ai-sdk/react"; export const fetchChatDetails = async ( chatId: string, authToken: string ): Promise => { - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number( - chatId - )}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - } - ); - - if (!response.ok) { - throw new Error(`Failed to fetch chat details: ${response.statusText}`); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number( + chatId + )}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, } + ); - return await response.json(); - } catch (err) { - console.error("Error fetching chat details:", err); - return null; + if (!response.ok) { + throw new Error(`Failed to fetch chat details: ${response.statusText}`); } + + return await response.json(); }; export const fetchChatsBySearchSpace = async ( searchSpaceId: string, authToken: string ): Promise => { - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats?search_space_id=${searchSpaceId}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - } - ); - if (!response.ok) { - throw new Error(`Failed to fetch chats: ${response.statusText}`); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats?search_space_id=${searchSpaceId}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, } + ); + if (!response.ok) { + throw new Error(`Failed to fetch chats: ${response.statusText}`); + } - return await response.json(); - } catch (err) { - console.error("Error fetching chats:", err); - return null; + return await response.json(); +}; + +export const deleteChat = async (chatId: number, authToken: string) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${chatId}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${authToken}`, + }, + } + ); + + if (!response.ok) { + throw new Error(`Failed to delete chat: ${response.statusText}`); + } + + return await response.json(); +}; + +export const createChat = async ( + initialMessage: string, + researchMode: ResearchMode, + selectedConnectors: string[], + authToken: string, + searchSpaceId: number +): Promise => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ + type: researchMode, + title: "Untitled Chat", + initial_connectors: selectedConnectors, + messages: [ + { + role: "user", + content: initialMessage, + }, + ], + search_space_id: searchSpaceId, + }), + } + ); + + if (!response.ok) { + throw new Error(`Failed to create chat: ${response.statusText}`); + } + + return await response.json(); +}; + +export const updateChat = async ( + chatId: string, + messages: Message[], + researchMode: ResearchMode, + selectedConnectors: string[], + authToken: string, + searchSpaceId: number +) => { + const userMessages = messages.filter((msg) => msg.role === "user"); + if (userMessages.length === 0) return; + + const title = userMessages[0].content; + + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number( + chatId + )}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ + type: researchMode, + title: title, + initial_connectors: selectedConnectors, + messages: messages, + search_space_id: searchSpaceId, + }), + } + ); + + if (!response.ok) { + throw new Error(`Failed to update chat: ${response.statusText}`); } }; +export const fetchChats = async ( + searchSpaceId: string, + limit: number, + skip: number, + authToken: string +) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats?limit=${limit}&skip=${skip}&search_space_id=${searchSpaceId}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "GET", + } + ); -export const deleteChat = async (chatId: number, authToken: string) => { - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${chatId}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${authToken}`, - }, - } - ); + if (!response.ok) { + throw new Error(`Failed to fetch chats: ${response.status}`); + } - if (!response.ok) { - throw new Error(`Failed to delete chat: ${response.statusText}`); - } - - } catch (err) { - console.error("Error deleting chat:", err); - } + return await response.json(); }; diff --git a/surfsense_web/lib/apis/documents.api.ts b/surfsense_web/lib/apis/documents.api.ts new file mode 100644 index 000000000..bc9822b00 --- /dev/null +++ b/surfsense_web/lib/apis/documents.api.ts @@ -0,0 +1,20 @@ +const uploadDocument = async (formData: FormData) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/fileupload`, + { + method: "POST", + headers: { + Authorization: `Bearer ${window.localStorage.getItem( + "surfsense_bearer_token" + )}`, + }, + body: formData, + } + ); + + if (!response.ok) { + throw new Error("Upload failed"); + } + + await response.json(); +}; diff --git a/surfsense_web/lib/apis/podcasts.api.ts b/surfsense_web/lib/apis/podcasts.api.ts index 0324d7066..fa9c680fe 100644 --- a/surfsense_web/lib/apis/podcasts.api.ts +++ b/surfsense_web/lib/apis/podcasts.api.ts @@ -2,49 +2,78 @@ import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/pod import type { GeneratePodcastRequest } from "@/components/chat/ChatPanel/ChatPanelContainer"; export const getPodcastByChatId = async (chatId: string, authToken: string) => { - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/by-chat/${Number(chatId)}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "GET", - } - ); + const response = await fetch( + `${ + process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL + }/api/v1/podcasts/by-chat/${Number(chatId)}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "GET", + } + ); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to fetch podcast"); - } + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || "Failed to fetch podcast"); + } - return (await response.json()) as PodcastItem | null; - } catch (err: any) { - console.error("Error fetching podcast:", err); - - return null; - } + return (await response.json()) as PodcastItem | null; }; -export const generatePodcast = async (request: GeneratePodcastRequest, authToken: string) => { - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/generate/`, - { - method: "POST", - headers: { - Authorization: `Bearer ${authToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(request), - } - ); +export const generatePodcast = async ( + request: GeneratePodcastRequest, + authToken: string +) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/generate/`, + { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + } + ); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to generate podcast"); - } - } catch (error) { - console.error("Error generating podcast:", error); - } + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || "Failed to generate podcast"); + } + + return await response.json(); +}; + +export const loadPodcast = async (podcast: PodcastItem, authToken: string) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + signal: controller.signal, + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch audio stream: ${response.statusText}`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + return objectUrl; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error("Request timed out. Please try again."); + } + throw error; + } finally { + clearTimeout(timeoutId); + } }; diff --git a/surfsense_web/lib/apis/search-spaces.api.ts b/surfsense_web/lib/apis/search-spaces.api.ts index f4b2ad74d..89a437e9f 100644 --- a/surfsense_web/lib/apis/search-spaces.api.ts +++ b/surfsense_web/lib/apis/search-spaces.api.ts @@ -1,112 +1,91 @@ export const fetchSearchSpaces = async () => { - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, - { - headers: { - Authorization: `Bearer ${localStorage.getItem( - "surfsense_bearer_token" - )}`, - }, - method: "GET", - } - ); - - if (!response.ok) { - throw new Error("Not authenticated"); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem( + "surfsense_bearer_token" + )}`, + }, + method: "GET", } + ); - const data = await response.json(); - return data; - } catch (err: any) { - console.error("Error fetching search spaces:", err); - return null; + if (!response.ok) { + throw new Error("Not authenticated"); } + + return await response.json(); }; -export const handleDeleteSearchSpace = async (id: number) => { - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${id}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${localStorage.getItem( - "surfsense_bearer_token" - )}`, - }, - } - ); - - if (!response.ok) { - throw new Error("Failed to delete search space"); +export const deleteSearchSpace = async (id: number) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${id}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${localStorage.getItem( + "surfsense_bearer_token" + )}`, + }, } - } catch (error) { - console.error("Error deleting search space:", error); - return; + ); + + if (!response.ok) { + throw new Error("Failed to delete search space"); } + + return await response.json(); }; -export const handleCreateSearchSpace = async (data: { +export const createSearchSpace = async (data: { name: string; description: string; }) => { - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${localStorage.getItem( - "surfsense_bearer_token" - )}`, - }, - body: JSON.stringify(data), - } - ); - - if (!response.ok) { - throw new Error("Failed to create search space"); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem( + "surfsense_bearer_token" + )}`, + }, + body: JSON.stringify(data), } + ); - const result = await response.json(); - - return result; - } catch (error) { - console.error("Error creating search space:", error); - throw error; + if (!response.ok) { + throw new Error("Failed to create search space"); } + + return await response.json(); }; export const fetchSearchSpace = async (searchSpaceId: string) => { - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`, - { - headers: { - Authorization: `Bearer ${localStorage.getItem( - "surfsense_bearer_token" - )}`, - }, - method: "GET", - } - ); - - if (response.status === 401) { - // Clear token and redirect to home - localStorage.removeItem("surfsense_bearer_token"); - window.location.href = "/"; - throw new Error("Unauthorized: Redirecting to login page"); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem( + "surfsense_bearer_token" + )}`, + }, + method: "GET", } + ); - if (!response.ok) { - throw new Error(`Failed to fetch search space: ${response.status}`); - } - - const data = await response.json(); - return data; - } catch (err: any) { - console.error("Error fetching search space:", err); + if (response.status === 401) { + // Clear token and redirect to home + localStorage.removeItem("surfsense_bearer_token"); + window.location.href = "/"; + throw new Error("Unauthorized: Redirecting to login page"); } + + if (!response.ok) { + throw new Error(`Failed to fetch search space: ${response.status}`); + } + + return await response.json(); }; From a368b2a7d3bf080719961c5b9df394dae6e92f32 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Wed, 12 Nov 2025 17:22:34 +0200 Subject: [PATCH 09/26] organiza documents apis --- surfsense_web/lib/apis/documents.api.ts | 267 +++++++++++++++++++++++- 1 file changed, 261 insertions(+), 6 deletions(-) diff --git a/surfsense_web/lib/apis/documents.api.ts b/surfsense_web/lib/apis/documents.api.ts index bc9822b00..155717911 100644 --- a/surfsense_web/lib/apis/documents.api.ts +++ b/surfsense_web/lib/apis/documents.api.ts @@ -1,20 +1,275 @@ -const uploadDocument = async (formData: FormData) => { +import { DocumentWithChunks } from "@/hooks/use-document-by-chunk"; +import { DocumentTypeCount } from "@/hooks/use-document-types"; +import { normalizeListResponse } from "../pagination"; + +export const uploadDocument = async (formData: FormData, authToken: string) => { const response = await fetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/fileupload`, { method: "POST", headers: { - Authorization: `Bearer ${window.localStorage.getItem( - "surfsense_bearer_token" - )}`, + Authorization: `Bearer ${authToken}`, }, body: formData, } ); if (!response.ok) { - throw new Error("Upload failed"); + throw new Error("Failed to upload document"); } - await response.json(); + return await response.json(); +}; + +export const createDocument = async (request: { + documentType: string; + content: any; + searchSpaceId: number; + authToken: string; +}) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${request.authToken}`, + }, + body: JSON.stringify(request), + } + ); + + if (!response.ok) { + throw new Error("Failed to process document"); + } + + return await response.json(); +}; + +export const fetchDocumentByChunk = async ( + chunkId: number, + authToken: string +) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/by-chunk/${chunkId}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + method: "GET", + } + ); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = "Failed to fetch document"; + + try { + const errorData = JSON.parse(errorText); + errorMessage = errorData.detail || errorMessage; + } catch { + // If parsing fails, use default message + } + + if (response.status === 404) { + errorMessage = "Chunk not found or you don't have access to it"; + } + throw new Error(errorMessage); + } + + const data: DocumentWithChunks = await response.json(); + + return data; +}; + +export const fetchDocumentTypes = async (authToken: string) => { + if (!authToken) { + throw new Error("No authentication token found"); + } + + // Build URL with optional search_space_id query parameter + const url = new URL( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/type-counts` + ); + + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch document types: ${response.statusText}`); + } + + const data = await response.json(); + + // Convert the object to an array of DocumentTypeCount + const typeCounts: DocumentTypeCount[] = Object.entries(data).map( + ([type, count]) => ({ + type, + count: count as number, + }) + ); + + return typeCounts; +}; + +export const fetchDocuments = async ( + searchSpaceId: number, + authToken: string, + fetchPage?: number, + fetchPageSize?: number, + fetchDocumentTypes?: string[] +) => { + // Build query params + const params = new URLSearchParams({ + search_space_id: searchSpaceId.toString(), + }); + + // // Use passed parameters or fall back to state/options + // const effectivePage = fetchPage !== undefined ? fetchPage : page; + // const effectivePageSize = + // fetchPageSize !== undefined ? fetchPageSize : pageSize; + // const effectiveDocumentTypes = + // fetchDocumentTypes !== undefined ? fetchDocumentTypes : documentTypes; + + // if (effectivePage !== undefined) { + // params.append("page", effectivePage.toString()); + // } + // if (effectivePageSize !== undefined) { + // params.append("page_size", effectivePageSize.toString()); + // } + // if (effectiveDocumentTypes && effectiveDocumentTypes.length > 0) { + // params.append("document_types", effectiveDocumentTypes.join(",")); + // } + + const response = await fetch( + `${ + process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL + }/api/v1/documents?${params.toString()}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "GET", + } + ); + + if (!response.ok) { + throw new Error("Failed to fetch documents"); + } + + const data = await response.json(); + return normalizeListResponse(data); +}; + +export const searchDocuments = async ( + searchSpaceId: number, + authToken: string, + searchQuery: string, + fetchPage?: number, + fetchPageSize?: number, + fetchDocumentTypes?: string[] +) => { + // if (!searchQuery.trim()) { + // // If search is empty, fetch all documents + // // return fetchDocuments(fetchPage, fetchPageSize, fetchDocumentTypes); + // } + + // Build query params + const params = new URLSearchParams({ + search_space_id: searchSpaceId.toString(), + title: searchQuery, + }); + + // // Use passed parameters or fall back to state/options + // const effectivePage = fetchPage !== undefined ? fetchPage : page; + // const effectivePageSize = fetchPageSize !== undefined ? fetchPageSize : pageSize; + // const effectiveDocumentTypes = + // fetchDocumentTypes !== undefined ? fetchDocumentTypes : documentTypes; + + // if (effectivePage !== undefined) { + // params.append("page", effectivePage.toString()); + // } + // if (effectivePageSize !== undefined) { + // params.append("page_size", effectivePageSize.toString()); + // } + // if (effectiveDocumentTypes && effectiveDocumentTypes.length > 0) { + // params.append("document_types", effectiveDocumentTypes.join(",")); + // } + + const response = await fetch( + `${ + process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL + }/api/v1/documents/search?${params.toString()}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "GET", + } + ); + + if (!response.ok) { + throw new Error("Failed to search documents"); + } + + const data = await response.json(); + const normalized = normalizeListResponse(data); + return normalized; +}; + +export const deleteDocument = async (documentId: number, authToken: string) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "DELETE", + } + ); + + if (!response.ok) { + throw new Error("Failed to delete document"); + } + + return await response.json(); +}; + +export const getDocumentTypeCounts = async ( + searchSpaceId: number, + authToken: string +) => { + try { + const params = new URLSearchParams({ + search_space_id: searchSpaceId.toString(), + }); + + const response = await fetch( + `${ + process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL + }/api/v1/documents/type-counts?${params.toString()}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "GET", + } + ); + + if (!response.ok) { + throw new Error("Failed to fetch document type counts"); + } + + const counts = await response.json(); + return counts as Record; + } catch (err: any) { + console.error("Error fetching document type counts:", err); + return {}; + } }; From f10a918ca706be6ee5c979077b5ae6f749558f42 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Wed, 12 Nov 2025 17:30:49 +0200 Subject: [PATCH 10/26] organize llm configs apis --- surfsense_web/lib/apis/llm-configs.api.ts | 98 +++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 surfsense_web/lib/apis/llm-configs.api.ts diff --git a/surfsense_web/lib/apis/llm-configs.api.ts b/surfsense_web/lib/apis/llm-configs.api.ts new file mode 100644 index 000000000..86495f7e2 --- /dev/null +++ b/surfsense_web/lib/apis/llm-configs.api.ts @@ -0,0 +1,98 @@ +import { CreateLLMConfig, LLMConfig, UpdateLLMConfig } from "@/hooks/use-llm-configs"; + +export const fetchLLMConfigs = async ( + searchSpaceId: number, + authToken: string +) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs?search_space_id=${searchSpaceId}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "GET", + } + ); + + if (!response.ok) { + throw new Error("Failed to fetch LLM configurations"); + } + + return await response.json(); +}; + +export const createLLMConfig = async ( + config: CreateLLMConfig, + authToken: string +): Promise => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(config), + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to create LLM configuration"); + } + + const newConfig = await response.json(); + + return newConfig; +}; + +export const deleteLLMConfig = async ( + id: number, + authToken: string +): Promise => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs/${id}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${authToken}`, + }, + } + ); + + if (!response.ok) { + throw new Error("Failed to delete LLM configuration"); + } + + return await response.json(); +}; + +export const updateLLMConfig = async ( + id: number, + config: UpdateLLMConfig, + authToken: string +): Promise => { + + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs/${id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(config), + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to update LLM configuration"); + } + + const updatedConfig = await response.json(); + + return updatedConfig; + +}; From 54d29c60648ac294a20615401a2783c57185311e Mon Sep 17 00:00:00 2001 From: thierryverse Date: Wed, 12 Nov 2025 18:48:50 +0200 Subject: [PATCH 11/26] organize search source connectors --- surfsense_web/lib/apis/chats/chats.api.ts | 1 + surfsense_web/lib/apis/chats/contracts.ts | 1 + .../lib/apis/search-source-connectors.api.ts | 113 ++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 surfsense_web/lib/apis/chats/chats.api.ts create mode 100644 surfsense_web/lib/apis/chats/contracts.ts create mode 100644 surfsense_web/lib/apis/search-source-connectors.api.ts diff --git a/surfsense_web/lib/apis/chats/chats.api.ts b/surfsense_web/lib/apis/chats/chats.api.ts new file mode 100644 index 000000000..a264f30af --- /dev/null +++ b/surfsense_web/lib/apis/chats/chats.api.ts @@ -0,0 +1 @@ +// Will contain a ChatApiService class that will be used to make API calls \ No newline at end of file diff --git a/surfsense_web/lib/apis/chats/contracts.ts b/surfsense_web/lib/apis/chats/contracts.ts new file mode 100644 index 000000000..c5ced8a4f --- /dev/null +++ b/surfsense_web/lib/apis/chats/contracts.ts @@ -0,0 +1 @@ +// Will contains contracts for all chat related APIs diff --git a/surfsense_web/lib/apis/search-source-connectors.api.ts b/surfsense_web/lib/apis/search-source-connectors.api.ts new file mode 100644 index 000000000..2a9e59301 --- /dev/null +++ b/surfsense_web/lib/apis/search-source-connectors.api.ts @@ -0,0 +1,113 @@ +import { Connector, CreateConnectorRequest } from "@/hooks/use-connectors"; + +export const createConnector = async ( + data: CreateConnectorRequest, + authToken: string +): Promise => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(data), + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to create connector"); + } + + return response.json(); +}; + +export const getConnectors = async ( + skip = 0, + limit = 100, + authToken: string +): Promise => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors?skip=${skip}&limit=${limit}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to fetch connectors"); + } + + return response.json(); +}; + +export const getConnector = async ( + connectorId: number, + authToken: string +): Promise => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to fetch connector"); + } + + return response.json(); +}; + +export const updateConnector = async ( + connectorId: number, + data: CreateConnectorRequest, + authToken: string +): Promise => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(data), + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to update connector"); + } + + return response.json(); +}; + +export const deleteConnector = async ( + connectorId: number, + authToken: string +): Promise => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${authToken}`, + }, + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to delete connector"); + } +}; From c8fae413d26f1665b52d30b7a9bee956e468b1ed Mon Sep 17 00:00:00 2001 From: thierryverse Date: Wed, 12 Nov 2025 19:18:17 +0200 Subject: [PATCH 12/26] organize auth apis --- surfsense_web/lib/apis/auth.api.ts | 55 +++++++++++++++++++++ surfsense_web/lib/apis/search-spaces.api.ts | 21 ++++++++ 2 files changed, 76 insertions(+) create mode 100644 surfsense_web/lib/apis/auth.api.ts diff --git a/surfsense_web/lib/apis/auth.api.ts b/surfsense_web/lib/apis/auth.api.ts new file mode 100644 index 000000000..94865fcdb --- /dev/null +++ b/surfsense_web/lib/apis/auth.api.ts @@ -0,0 +1,55 @@ +export const login = async (request: { + username: string; + password: string; + grant_type?: string; +}) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/login`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: JSON.stringify({ + username: request.username, + password: request.password, + grant_type: request.grant_type || "password", + }), + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.detail || `HTTP ${response.status}`); + } + + return data; +}; + +export const register = async (request: { + email: string; + password: string; + is_active: boolean; + is_superuser: boolean; + is_verified: boolean; +}) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.detail || `HTTP ${response.status}`); + } + + return data; +}; diff --git a/surfsense_web/lib/apis/search-spaces.api.ts b/surfsense_web/lib/apis/search-spaces.api.ts index 89a437e9f..2ded72f44 100644 --- a/surfsense_web/lib/apis/search-spaces.api.ts +++ b/surfsense_web/lib/apis/search-spaces.api.ts @@ -89,3 +89,24 @@ export const fetchSearchSpace = async (searchSpaceId: string) => { return await response.json(); }; + +export const fetchSearchSpacePreferences = async ( + searchSpaceId: number, + authToken: string +) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/llm-preferences`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "GET", + } + ); + + if (!response.ok) { + throw new Error("Failed to fetch LLM preferences"); + } + + return await response.json(); +}; From 4d02c2eeed8012786bfb1c6af5057ab5d4f71a5a Mon Sep 17 00:00:00 2001 From: thierryverse Date: Thu, 13 Nov 2025 19:18:13 +0200 Subject: [PATCH 13/26] add auth api service --- surfsense_web/lib/apis/auth.api.ts | 55 --------------------- surfsense_web/lib/apis/auth/auth.service.ts | 50 +++++++++++++++++++ surfsense_web/lib/apis/auth/contracts.ts | 34 +++++++++++++ 3 files changed, 84 insertions(+), 55 deletions(-) delete mode 100644 surfsense_web/lib/apis/auth.api.ts create mode 100644 surfsense_web/lib/apis/auth/auth.service.ts create mode 100644 surfsense_web/lib/apis/auth/contracts.ts diff --git a/surfsense_web/lib/apis/auth.api.ts b/surfsense_web/lib/apis/auth.api.ts deleted file mode 100644 index 94865fcdb..000000000 --- a/surfsense_web/lib/apis/auth.api.ts +++ /dev/null @@ -1,55 +0,0 @@ -export const login = async (request: { - username: string; - password: string; - grant_type?: string; -}) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/login`, - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: JSON.stringify({ - username: request.username, - password: request.password, - grant_type: request.grant_type || "password", - }), - } - ); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.detail || `HTTP ${response.status}`); - } - - return data; -}; - -export const register = async (request: { - email: string; - password: string; - is_active: boolean; - is_superuser: boolean; - is_verified: boolean; -}) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(request), - } - ); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.detail || `HTTP ${response.status}`); - } - - return data; -}; diff --git a/surfsense_web/lib/apis/auth/auth.service.ts b/surfsense_web/lib/apis/auth/auth.service.ts new file mode 100644 index 000000000..de13b670f --- /dev/null +++ b/surfsense_web/lib/apis/auth/auth.service.ts @@ -0,0 +1,50 @@ +import { LoginRequest, LoginResponse, RegisterRequest, RegisterResponse } from "./contracts"; + +export class AuthApiService { + login = async (request: LoginRequest) : Promise => { + const requestBody = new URLSearchParams(); + requestBody.append("username", request.email); + requestBody.append("password", request.password); + requestBody.append("grant_type", "password"); + + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/login`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: requestBody.toString(), + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.detail || `HTTP ${response.status}`); + } + + return data; + }; + + register = async (request: RegisterRequest) : Promise => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.detail || `HTTP ${response.status}`); + } + + return data; + }; +} diff --git a/surfsense_web/lib/apis/auth/contracts.ts b/surfsense_web/lib/apis/auth/contracts.ts new file mode 100644 index 000000000..5fa574cf3 --- /dev/null +++ b/surfsense_web/lib/apis/auth/contracts.ts @@ -0,0 +1,34 @@ +/** + * LOGIN + */ +export type LoginRequest = { + email: string; + password: string; + grant_type?: string; +}; + +export type LoginResponse = { + access_token: string; + token_type: string; +}; + +/** + * REGISTER + */ +export type RegisterRequest = { + email: string; + password: string; + is_active: boolean; + is_superuser: boolean; + is_verified: boolean; +}; + +export type RegisterResponse = { + id: number; + email: string; + is_active: boolean; + is_superuser: boolean; + is_verified: boolean; + pages_limit: number; + pages_used: number; +}; From 7221659de75105ab05e5a07c9d6c52427ff7096e Mon Sep 17 00:00:00 2001 From: thierryverse Date: Thu, 13 Nov 2025 23:59:14 +0200 Subject: [PATCH 14/26] add a flexible base api service --- surfsense_web/lib/apis/base-api.service.ts | 171 +++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 surfsense_web/lib/apis/base-api.service.ts diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts new file mode 100644 index 000000000..0b3639b4f --- /dev/null +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -0,0 +1,171 @@ +import z from "zod"; +import { + AppError, + AuthenticationError, + AuthorizationError, + ValidationError, +} from "../error"; + +export type RequestOptions = { + method: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record; + contentType?: "application/json" | "application/x-www-form-urlencoded"; + signal?: AbortSignal; + body?: any; + // Add more options as needed +}; + +export class BaseApiService { + bearerToken: string; + baseUrl: string; + + constructor(bearerToken: string, baseUrl: string) { + this.bearerToken = bearerToken; + this.baseUrl = baseUrl; + } + + setBearerToken(bearerToken: string) { + this.bearerToken = bearerToken; + } + + async request( + url: string, + body?: any, + responseSchema?: z.ZodSchema, + options?: RequestOptions + ) { + const defaultOptions: RequestOptions = { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.bearerToken}`, + }, + method: "GET", + }; + + const mergedOptions: RequestOptions = { + ...defaultOptions, + ...(options ?? {}), + headers: { + ...defaultOptions.headers, + ...(options?.headers ?? {}), + }, + }; + + let requestBody; + + // Serialize body + if (body) { + if (mergedOptions.headers?.["Content-Type"].toLocaleLowerCase() === "application/json") { + requestBody = JSON.stringify(body); + } + + if ( + mergedOptions.headers?.["Content-Type"].toLocaleLowerCase() === + "application/x-www-form-urlencoded" + ) { + requestBody = new URLSearchParams(body); + } + + mergedOptions.body = requestBody; + } + + if (!this.baseUrl) { + throw new AppError("Base URL is not set."); + } + + if (!this.bearerToken) { + throw new AuthenticationError( + "You are not authenticated. Please login again." + ); + } + + const fullUrl = new URL(url, this.baseUrl).toString(); + + const response = await fetch(fullUrl, mergedOptions); + + if (!response.ok) { + if (response.status === 401) { + throw new AuthenticationError( + "You are not authenticated. Please login again." + ); + } + + if (response.status === 403) { + throw new AuthorizationError( + "You don't have permission to access this resource." + ); + } + + throw new AppError(`API Error: ${response.statusText}`); + } + + let data; + + try { + data = await response.json(); + } catch (error) { + throw new AppError(`Failed to parse response as JSON: ${error}`); + } + + if (!responseSchema) { + return data; + } + + const parsedData = responseSchema.safeParse(data); + + if (!parsedData.success) { + throw new ValidationError( + `Invalid response: ${parsedData.error.message}` + ); + } + + return parsedData.data; + } + + async get( + url: string, + responseSchema?: z.ZodSchema, + options?: RequestOptions + ) { + return this.request(url, undefined, responseSchema, { + ...options, + method: "GET", + }); + } + + async post( + url: string, + body?: any, + responseSchema?: z.ZodSchema, + options?: RequestOptions + ) { + return this.request(url, body, responseSchema, { + ...options, + method: "POST", + }); + } + + async put( + url: string, + body?: any, + responseSchema?: z.ZodSchema, + options?: RequestOptions + ) { + return this.request(url, body, responseSchema, { + ...options, + method: "PUT", + }); + } + + async delete( + url: string, + body?: any, + responseSchema?: z.ZodSchema, + options?: RequestOptions + ) { + return this.request(url, body, responseSchema, { + ...options, + method: "DELETE", + }); + } +} From aa9bb15c13da1a19946c058c66751832535a5c35 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Fri, 14 Nov 2025 00:23:21 +0200 Subject: [PATCH 15/26] add the auth api service --- surfsense_web/lib/apis/auth-api.service.ts | 44 ++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 surfsense_web/lib/apis/auth-api.service.ts diff --git a/surfsense_web/lib/apis/auth-api.service.ts b/surfsense_web/lib/apis/auth-api.service.ts new file mode 100644 index 000000000..e283c2965 --- /dev/null +++ b/surfsense_web/lib/apis/auth-api.service.ts @@ -0,0 +1,44 @@ +import { + loginRequest, + LoginRequest, + loginResponse, + registerRequest, + RegisterRequest, + registerResponse, +} from "@/contracts/types/auth.types"; +import { baseApiService } from "./base-api.service"; + +export class AuthApiService { + login = async (request: LoginRequest) => { + // Validate the request + const parsedRequest = loginRequest.safeParse(request); + + if (!parsedRequest.success) { + throw new Error(`Invalid request: ${parsedRequest.error.message}`); + } + + return baseApiService.post( + `/auth/jwt/login`, + parsedRequest.data, + loginResponse, + { + contentType: "application/x-www-form-urlencoded", + } + ); + }; + + register = async (request: RegisterRequest) => { + // Validate the request + const parsedRequest = registerRequest.safeParse(request); + + if (!parsedRequest.success) { + throw new Error(`Invalid request: ${parsedRequest.error.message}`); + } + + return baseApiService.post( + `/auth/register`, + parsedRequest.data, + registerResponse + ); + }; +} From 0c41e487d8c27d5d0deaf223d2cdf484b9c4c741 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Fri, 14 Nov 2025 00:23:50 +0200 Subject: [PATCH 16/26] add the chats api service --- .../lib/apis/chats-api.service.api.ts | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 surfsense_web/lib/apis/chats-api.service.api.ts diff --git a/surfsense_web/lib/apis/chats-api.service.api.ts b/surfsense_web/lib/apis/chats-api.service.api.ts new file mode 100644 index 000000000..65dd88413 --- /dev/null +++ b/surfsense_web/lib/apis/chats-api.service.api.ts @@ -0,0 +1,114 @@ +import { ResearchMode } from "@/components/chat/types"; +import { Message } from "@ai-sdk/react"; +import { + chatDetails, + chatSummary, + createChatRequest, + CreateChatRequest, + deleteChatRequest, + DeleteChatRequest, + getChatDetailsRequest, + GetChatDetailsRequest, + getChatsBySearchSpaceRequest, + GetChatsBySearchSpaceRequest, + deleteChatResponse, + UpdateChatRequest, + updateChatRequest, +} from "@/contracts/types/chat.types"; +import { z } from "zod"; +import { baseApiService } from "./base-api.service"; + +export class ChatApiService { + fetchChatDetails = async ( + request: GetChatDetailsRequest + ) => { + // Validate the request + const parsedRequest = getChatDetailsRequest.safeParse(request); + + if (!parsedRequest.success) { + throw new Error(`Invalid request: ${parsedRequest.error.message}`); + } + + return baseApiService.get(`/api/v1/chats/${request.id}`, chatDetails); + }; + + fetchChatsBySearchSpace = async ( + request: GetChatsBySearchSpaceRequest + )=> { + // Validate the request + const parsedRequest = getChatsBySearchSpaceRequest.safeParse(request); + + if (!parsedRequest.success) { + throw new Error(`Invalid request: ${parsedRequest.error.message}`); + } + + return baseApiService.get( + `/api/v1/chats?search_space_id=${request.search_space_id}`, + z.array(chatSummary) + ); + }; + + deleteChat = async (request: DeleteChatRequest) => { + // Validate the request + const parsedRequest = deleteChatRequest.safeParse(request); + + if (!parsedRequest.success) { + throw new Error(`Invalid request: ${parsedRequest.error.message}`); + } + + + return baseApiService.delete(`/api/v1/chats/${request.id}`, undefined, deleteChatResponse); + }; + + createChat = async ( + request: CreateChatRequest + ) => { + // Validate the request + const parsedRequest = createChatRequest.safeParse(request); + + if (!parsedRequest.success) { + throw new Error(`Invalid request: ${parsedRequest.error.message}`); + } + + const { type, title, initial_connectors, messages, search_space_id } = + parsedRequest.data; + + return baseApiService.post( + `/api/v1/chats`, + { + type, + title, + initial_connectors, + messages, + search_space_id, + }, + chatSummary + ); + }; + + updateChat = async ( + request: UpdateChatRequest + ) => { + // Validate the request + const parsedRequest = updateChatRequest.safeParse(request); + + if (!parsedRequest.success) { + throw new Error(`Invalid request: ${parsedRequest.error.message}`); + } + + const { type, title, initial_connectors, messages, search_space_id, id } = + parsedRequest.data; + + return baseApiService.put( + `/api/v1/chats/${id}`, + { + type, + title, + initial_connectors, + messages, + search_space_id, + }, + chatSummary + ); + }; +} From 77d49ca11c6552159dc6e5f4b94cdeabfa5fc3e0 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Fri, 14 Nov 2025 00:25:08 +0200 Subject: [PATCH 17/26] add zod schemas & inferences --- surfsense_web/contracts/types/auth.types.ts | 35 +++++++++++++++ surfsense_web/contracts/types/chat.types.ts | 48 ++++++++++++++++++++ surfsense_web/contracts/types/index.ts | 8 ++++ surfsense_web/lib/apis/auth/auth.service.ts | 50 --------------------- surfsense_web/lib/apis/auth/contracts.ts | 34 -------------- surfsense_web/lib/apis/base-api.service.ts | 20 ++++++--- surfsense_web/lib/apis/chats/chats.api.ts | 1 - surfsense_web/lib/apis/chats/contracts.ts | 1 - surfsense_web/lib/error.ts | 30 +++++++++++++ surfsense_web/lib/utils.ts | 8 ++++ 10 files changed, 143 insertions(+), 92 deletions(-) create mode 100644 surfsense_web/contracts/types/auth.types.ts create mode 100644 surfsense_web/contracts/types/chat.types.ts create mode 100644 surfsense_web/contracts/types/index.ts delete mode 100644 surfsense_web/lib/apis/auth/auth.service.ts delete mode 100644 surfsense_web/lib/apis/auth/contracts.ts delete mode 100644 surfsense_web/lib/apis/chats/chats.api.ts delete mode 100644 surfsense_web/lib/apis/chats/contracts.ts create mode 100644 surfsense_web/lib/error.ts diff --git a/surfsense_web/contracts/types/auth.types.ts b/surfsense_web/contracts/types/auth.types.ts new file mode 100644 index 000000000..507ea18fc --- /dev/null +++ b/surfsense_web/contracts/types/auth.types.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +export const loginRequest = z.object({ + email: z.string().email(), + password: z.string().min(1), + grant_type: z.string().optional(), +}); + +export const loginResponse = z.object({ + access_token: z.string(), + token_type: z.string(), +}); + +export const registerRequest = z.object({ + email: z.string().email(), + password: z.string().min(1), + is_active: z.boolean().optional(), + is_superuser: z.boolean().optional(), + is_verified: z.boolean().optional(), +}); + +export const registerResponse = z.object({ + id: z.number(), + email: z.string().email(), + is_active: z.boolean(), + is_superuser: z.boolean(), + is_verified: z.boolean(), + pages_limit: z.number(), + pages_used: z.number(), +}); + +export type LoginRequest = z.infer; +export type LoginResponse = z.infer; +export type RegisterRequest = z.infer; +export type RegisterResponse = z.infer; diff --git a/surfsense_web/contracts/types/chat.types.ts b/surfsense_web/contracts/types/chat.types.ts new file mode 100644 index 000000000..3e7eb6911 --- /dev/null +++ b/surfsense_web/contracts/types/chat.types.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import { type Message } from "@ai-sdk/react"; +import { paginationQueryParams } from "."; + +export const chatTypeEnum = z.enum(["QNA"]); + +export const chatSummary = z.object({ + created_at: z.string(), + id: z.number(), + type: chatTypeEnum, + title: z.string(), + search_space_id: z.number(), + state_version: z.number(), +}); + +export const chatDetails = chatSummary.extend({ + initial_connectors: z.array(z.string()), + messages: z.array(z.any()), +}); + +export const getChatDetailsRequest = chatSummary.pick({ id: true }); + +export const getChatsBySearchSpaceRequest = chatSummary.pick({ + search_space_id: true, +}).merge(paginationQueryParams); + +export const deleteChatResponse = z.object({ + message: z.literal("Chat deleted successfully"), +}); + +export const deleteChatRequest = chatSummary.pick({ id: true }); + +export const createChatRequest = chatDetails + .omit({ created_at: true, id: true, state_version: true }); + +export const updateChatRequest = chatDetails + .omit({ created_at: true, state_version: true }); + +export type ChatSummary = z.infer; +export type ChatDetails = z.infer & { messages: Message[] }; +export type GetChatDetailsRequest = z.infer; +export type GetChatsBySearchSpaceRequest = z.infer< + typeof getChatsBySearchSpaceRequest +>; +export type DeleteChatResponse = z.infer; +export type DeleteChatRequest = z.infer; +export type CreateChatRequest = z.infer; +export type UpdateChatRequest = z.infer; diff --git a/surfsense_web/contracts/types/index.ts b/surfsense_web/contracts/types/index.ts new file mode 100644 index 000000000..d6c65b5ec --- /dev/null +++ b/surfsense_web/contracts/types/index.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const paginationQueryParams = z.object({ + limit: z.number().optional(), + skip: z.number().optional(), +}); + +export type PaginationQueryParams = z.infer; diff --git a/surfsense_web/lib/apis/auth/auth.service.ts b/surfsense_web/lib/apis/auth/auth.service.ts deleted file mode 100644 index de13b670f..000000000 --- a/surfsense_web/lib/apis/auth/auth.service.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { LoginRequest, LoginResponse, RegisterRequest, RegisterResponse } from "./contracts"; - -export class AuthApiService { - login = async (request: LoginRequest) : Promise => { - const requestBody = new URLSearchParams(); - requestBody.append("username", request.email); - requestBody.append("password", request.password); - requestBody.append("grant_type", "password"); - - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/login`, - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: requestBody.toString(), - } - ); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.detail || `HTTP ${response.status}`); - } - - return data; - }; - - register = async (request: RegisterRequest) : Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(request), - } - ); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.detail || `HTTP ${response.status}`); - } - - return data; - }; -} diff --git a/surfsense_web/lib/apis/auth/contracts.ts b/surfsense_web/lib/apis/auth/contracts.ts deleted file mode 100644 index 5fa574cf3..000000000 --- a/surfsense_web/lib/apis/auth/contracts.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * LOGIN - */ -export type LoginRequest = { - email: string; - password: string; - grant_type?: string; -}; - -export type LoginResponse = { - access_token: string; - token_type: string; -}; - -/** - * REGISTER - */ -export type RegisterRequest = { - email: string; - password: string; - is_active: boolean; - is_superuser: boolean; - is_verified: boolean; -}; - -export type RegisterResponse = { - id: number; - email: string; - is_active: boolean; - is_superuser: boolean; - is_verified: boolean; - pages_limit: number; - pages_used: number; -}; diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index 0b3639b4f..f94481547 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -33,7 +33,7 @@ export class BaseApiService { body?: any, responseSchema?: z.ZodSchema, options?: RequestOptions - ) { + ) : Promise { const defaultOptions: RequestOptions = { headers: { "Content-Type": "application/json", @@ -55,7 +55,10 @@ export class BaseApiService { // Serialize body if (body) { - if (mergedOptions.headers?.["Content-Type"].toLocaleLowerCase() === "application/json") { + if ( + mergedOptions.headers?.["Content-Type"].toLocaleLowerCase() === + "application/json" + ) { requestBody = JSON.stringify(body); } @@ -125,7 +128,7 @@ export class BaseApiService { async get( url: string, responseSchema?: z.ZodSchema, - options?: RequestOptions + options?: Omit ) { return this.request(url, undefined, responseSchema, { ...options, @@ -137,7 +140,7 @@ export class BaseApiService { url: string, body?: any, responseSchema?: z.ZodSchema, - options?: RequestOptions + options?: Omit ) { return this.request(url, body, responseSchema, { ...options, @@ -149,7 +152,7 @@ export class BaseApiService { url: string, body?: any, responseSchema?: z.ZodSchema, - options?: RequestOptions + options?: Omit ) { return this.request(url, body, responseSchema, { ...options, @@ -161,7 +164,7 @@ export class BaseApiService { url: string, body?: any, responseSchema?: z.ZodSchema, - options?: RequestOptions + options?: Omit ) { return this.request(url, body, responseSchema, { ...options, @@ -169,3 +172,8 @@ export class BaseApiService { }); } } + +export const baseApiService = new BaseApiService( + typeof window !== "undefined" ? localStorage.getItem("surfsense_bearer_token") || "" : "", + process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "" +); diff --git a/surfsense_web/lib/apis/chats/chats.api.ts b/surfsense_web/lib/apis/chats/chats.api.ts deleted file mode 100644 index a264f30af..000000000 --- a/surfsense_web/lib/apis/chats/chats.api.ts +++ /dev/null @@ -1 +0,0 @@ -// Will contain a ChatApiService class that will be used to make API calls \ No newline at end of file diff --git a/surfsense_web/lib/apis/chats/contracts.ts b/surfsense_web/lib/apis/chats/contracts.ts deleted file mode 100644 index c5ced8a4f..000000000 --- a/surfsense_web/lib/apis/chats/contracts.ts +++ /dev/null @@ -1 +0,0 @@ -// Will contains contracts for all chat related APIs diff --git a/surfsense_web/lib/error.ts b/surfsense_web/lib/error.ts new file mode 100644 index 000000000..03259c7b5 --- /dev/null +++ b/surfsense_web/lib/error.ts @@ -0,0 +1,30 @@ +export class AppError extends Error { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + } +} + +export class NetworkError extends AppError { + constructor(message: string) { + super(message); + } +} + +export class ValidationError extends AppError { + constructor(message: string) { + super(message); + } +} + +export class AuthenticationError extends AppError { + constructor(message: string) { + super(message); + } +} + +export class AuthorizationError extends AppError { + constructor(message: string) { + super(message); + } +} diff --git a/surfsense_web/lib/utils.ts b/surfsense_web/lib/utils.ts index ac680b303..3b27a4c93 100644 --- a/surfsense_web/lib/utils.ts +++ b/surfsense_web/lib/utils.ts @@ -1,6 +1,14 @@ +import { Message } from "@ai-sdk/react"; import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + + +export function getChatTitleFromMessages(messages: Message[]) { + const userMessages = messages.filter((msg) => msg.role === "user"); + if (userMessages.length === 0) return "Untitled Chat"; + return userMessages[0].content; +} \ No newline at end of file From 81ee04c2a5ca77120c6e4b0050f3ad12462eaf51 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Fri, 14 Nov 2025 00:42:19 +0200 Subject: [PATCH 18/26] format with biome --- .../[search_space_id]/chats/chats-client.tsx | 842 +++++++++--------- .../[search_space_id]/client-layout.tsx | 468 +++++----- .../atoms/chats/chat-mutations.atom.ts | 45 +- .../atoms/chats/chat-queries.atom.ts | 79 +- .../seach-spaces/seach-space-queries.atom.ts | 2 +- .../components/chat/ChatInterface.tsx | 43 +- surfsense_web/contracts/types/auth.types.ts | 34 +- surfsense_web/contracts/types/chat.types.ts | 40 +- surfsense_web/contracts/types/index.ts | 4 +- surfsense_web/lib/apis/auth-api.service.ts | 57 +- surfsense_web/lib/apis/base-api.service.ts | 272 +++--- .../lib/apis/chats-api.service.api.ts | 169 ++-- surfsense_web/lib/apis/chats.api.ts | 250 +++--- surfsense_web/lib/apis/documents.api.ts | 417 +++++---- surfsense_web/lib/apis/llm-configs.api.ts | 142 ++- surfsense_web/lib/apis/podcasts.api.ts | 119 ++- .../lib/apis/search-source-connectors.api.ts | 168 ++-- surfsense_web/lib/apis/search-spaces.api.ts | 164 ++-- surfsense_web/lib/error.ts | 32 +- surfsense_web/lib/query-client/cache-keys.ts | 4 +- surfsense_web/lib/utils.ts | 3 +- 21 files changed, 1602 insertions(+), 1752 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx index a0383d886..b9f28e56d 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx @@ -2,511 +2,473 @@ import { format } from "date-fns"; import { - Calendar, - ExternalLink, - MessageCircleMore, - MoreHorizontal, - Search, - Tag, - Trash2, + 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 { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { - Card, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, } from "@/components/ui/pagination"; import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, } from "@/components/ui/select"; import { useAtom, useAtomValue } from "jotai"; import { activeSearchSpaceChatsAtom } from "@/atoms/chats/chat-queries.atom"; import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutations.atom"; export interface Chat { - created_at: string; - id: number; - type: "DOCUMENT" | "CHAT"; - title: string; - search_space_id: number; - state_version: number; + created_at: string; + id: number; + type: "DOCUMENT" | "CHAT"; + title: string; + search_space_id: number; + state_version: number; } export interface ChatDetails { - type: "DOCUMENT" | "CHAT"; - title: string; - initial_connectors: string[]; - messages: any[]; - created_at: string; - id: number; - search_space_id: number; - state_version: number; + type: "DOCUMENT" | "CHAT"; + title: string; + initial_connectors: string[]; + messages: any[]; + created_at: string; + id: number; + search_space_id: number; + state_version: number; } interface ChatsPageClientProps { - searchSpaceId: string; + 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" } }, + 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 }, + 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([]); - const [searchQuery, setSearchQuery] = useState(""); - const [currentPage, setCurrentPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - const [selectedType, setSelectedType] = useState("all"); - const [sortOrder, setSortOrder] = useState("newest"); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [chatToDelete, setChatToDelete] = useState<{ - id: number; - title: string; - } | null>(null); - const { - isFetching: isFetchingChats, - data: chats, - error: fetchError, - } = useAtomValue(activeSearchSpaceChatsAtom); - const [ - { isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError } - ] = useAtom(deleteChatMutationAtom); +export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) { + const router = useRouter(); + const [filteredChats, setFilteredChats] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [selectedType, setSelectedType] = useState("all"); + const [sortOrder, setSortOrder] = useState("newest"); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [chatToDelete, setChatToDelete] = useState<{ + id: number; + title: string; + } | null>(null); + const { + isFetching: isFetchingChats, + data: chats, + error: fetchError, + } = useAtomValue(activeSearchSpaceChatsAtom); + const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] = + useAtom(deleteChatMutationAtom); - const chatsPerPage = 9; - const searchParams = useSearchParams(); + 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]); + // 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]); - useEffect(() => { - if (fetchError) { - console.error("Error fetching chats:", fetchError); - } - }, [fetchError]); + useEffect(() => { + if (fetchError) { + console.error("Error fetching chats:", fetchError); + } + }, [fetchError]); - useEffect(() => { - if (deleteError) { - console.error("Error deleting chat:", deleteError); - } - }, [deleteError]); + useEffect(() => { + if (deleteError) { + console.error("Error deleting chat:", deleteError); + } + }, [deleteError]); - // Filter and sort chats based on search query, type, and sort order - useEffect(() => { - let result = [...(chats || [])]; + // 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 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); - } + // 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(); + // 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; - }); + return sortOrder === "newest" ? dateB - dateA : dateA - dateB; + }); - setFilteredChats(result); - setTotalPages(Math.max(1, Math.ceil(result.length / chatsPerPage))); + 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]); + // 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; + // Function to handle chat deletion + const handleDeleteChat = async () => { + if (!chatToDelete) return; - await deleteChat(chatToDelete.id); + await deleteChat(chatToDelete.id); - setDeleteDialogOpen(false); - setChatToDelete(null); - }; + 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); + // 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)))] - : []; + // Get unique chat types for filter dropdown + const chatTypes = chats ? ["all", ...Array.from(new Set(chats.map((chat) => chat.type)))] : []; - return ( - -
-
-

All Chats

-

- View, search, and manage all your chats. -

-
+ return ( + +
+
+

All Chats

+

View, search, and manage all your chats.

+
- {/* Filter and Search Bar */} -
-
-
- - setSearchQuery(e.target.value)} - /> -
+ {/* Filter and Search Bar */} +
+
+
+ + setSearchQuery(e.target.value)} + /> +
- -
+ +
-
- -
-
+
+ +
+
- {/* Status Messages */} - {isFetchingChats && ( -
-
-
-

Loading chats...

-
-
- )} + {/* Status Messages */} + {isFetchingChats && ( +
+
+
+

Loading chats...

+
+
+ )} - {fetchError && !isFetchingChats && ( -
-

Error loading chats

-

{fetchError.message}

-
- )} + {fetchError && !isFetchingChats && ( +
+

Error loading chats

+

{fetchError.message}

+
+ )} - {!isFetchingChats && !fetchError && filteredChats.length === 0 && ( -
- -

No chats found

-

- {searchQuery || selectedType !== "all" - ? "Try adjusting your search filters" - : "Start a new chat to get started"} -

-
- )} + {!isFetchingChats && !fetchError && filteredChats.length === 0 && ( +
+ +

No chats found

+

+ {searchQuery || selectedType !== "all" + ? "Try adjusting your search filters" + : "Start a new chat to get started"} +

+
+ )} - {/* Chat Grid */} - {!isFetchingChats && !fetchError && filteredChats.length > 0 && ( - -
- {currentChats.map((chat, index) => ( - - -
-
- - {chat.title || `Chat ${chat.id}`} - - - - - - {format(new Date(chat.created_at), "MMM d, yyyy")} - - - -
- - - - - - - router.push( - `/dashboard/${chat.search_space_id}/researcher/${chat.id}` - ) - } - > - - View Chat - - - { - e.stopPropagation(); - setChatToDelete({ - id: chat.id, - title: chat.title || `Chat ${chat.id}`, - }); - setDeleteDialogOpen(true); - }} - > - - Delete Chat - - - -
-
+ {/* Chat Grid */} + {!isFetchingChats && !fetchError && filteredChats.length > 0 && ( + +
+ {currentChats.map((chat, index) => ( + + +
+
+ + {chat.title || `Chat ${chat.id}`} + + + + + {format(new Date(chat.created_at), "MMM d, yyyy")} + + +
+ + + + + + + router.push( + `/dashboard/${chat.search_space_id}/researcher/${chat.id}` + ) + } + > + + View Chat + + + { + e.stopPropagation(); + setChatToDelete({ + id: chat.id, + title: chat.title || `Chat ${chat.id}`, + }); + setDeleteDialogOpen(true); + }} + > + + Delete Chat + + + +
+
- - - - {chat.type || "Unknown"} - - - -
- ))} -
-
- )} + + + + {chat.type || "Unknown"} + + + +
+ ))} +
+
+ )} - {/* Pagination */} - {!isFetchingChats && !fetchError && totalPages > 1 && ( - - - - { - e.preventDefault(); - if (currentPage > 1) setCurrentPage(currentPage - 1); - }} - className={ - currentPage <= 1 ? "pointer-events-none opacity-50" : "" - } - /> - + {/* Pagination */} + {!isFetchingChats && !fetchError && totalPages > 1 && ( + + + + { + e.preventDefault(); + if (currentPage > 1) setCurrentPage(currentPage - 1); + }} + className={currentPage <= 1 ? "pointer-events-none opacity-50" : ""} + /> + - {Array.from({ length: totalPages }).map((_, index) => { - const pageNumber = index + 1; - const isVisible = - pageNumber === 1 || - pageNumber === totalPages || - (pageNumber >= currentPage - 1 && - pageNumber <= currentPage + 1); + {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 ( - - - ... - - - ); - } - return null; - } + if (!isVisible) { + // Show ellipsis at appropriate positions + if (pageNumber === 2 || pageNumber === totalPages - 1) { + return ( + + ... + + ); + } + return null; + } - return ( - - { - e.preventDefault(); - setCurrentPage(pageNumber); - }} - isActive={pageNumber === currentPage} - > - {pageNumber} - - - ); - })} + return ( + + { + e.preventDefault(); + setCurrentPage(pageNumber); + }} + isActive={pageNumber === currentPage} + > + {pageNumber} + + + ); + })} - - { - e.preventDefault(); - if (currentPage < totalPages) - setCurrentPage(currentPage + 1); - }} - className={ - currentPage >= totalPages - ? "pointer-events-none opacity-50" - : "" - } - /> - - - - )} -
+ + { + e.preventDefault(); + if (currentPage < totalPages) setCurrentPage(currentPage + 1); + }} + className={currentPage >= totalPages ? "pointer-events-none opacity-50" : ""} + /> + + + + )} +
- {/* Delete Confirmation Dialog */} - - - - - - Delete Chat - - - Are you sure you want to delete{" "} - {chatToDelete?.title}? This - action cannot be undone. - - - - - - - - -
- ); + {/* Delete Confirmation Dialog */} + + + + + + Delete Chat + + + Are you sure you want to delete{" "} + {chatToDelete?.title}? This action cannot be + undone. + + + + + + + + +
+ ); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index 40f6b2255..ee2922130 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -12,19 +12,9 @@ import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider"; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +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 { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { useLLMPreferences } from "@/hooks/use-llm-configs"; import { cn } from "@/lib/utils"; import { activeChatIdAtom } from "@/atoms/chats/chat-queries.atom"; @@ -32,255 +22,249 @@ import { chatUIAtom } from "@/atoms/chats/chat-uis.atom"; import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; export function DashboardClientLayout({ - children, - searchSpaceId, - navSecondary, - navMain, + children, + searchSpaceId, + navSecondary, + navMain, }: { - children: React.ReactNode; - searchSpaceId: string; - navSecondary: any[]; - navMain: any[]; + children: React.ReactNode; + searchSpaceId: string; + navSecondary: any[]; + navMain: any[]; }) { - 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(chatUIAtom); - const activeChatId = useAtomValue(activeChatIdAtom); - const setActiveSearchSpaceIdState = useSetAtom(activeSearchSpaceIdAtom); - const setActiveChatIdState = useSetAtom(activeChatIdAtom); - const [showIndicator, setShowIndicator] = useState(false); + 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(chatUIAtom); + const activeChatId = useAtomValue(activeChatIdAtom); + const setActiveSearchSpaceIdState = useSetAtom(activeSearchSpaceIdAtom); + const setActiveChatIdState = useSetAtom(activeChatIdAtom); + const [showIndicator, setShowIndicator] = useState(false); - const { isChatPannelOpen } = chatUIState; + const { isChatPannelOpen } = chatUIState; - // Check if we're on the researcher page - const isResearcherPage = pathname?.includes("/researcher"); + // 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]); + // 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 { loading, error, isOnboardingComplete } = - useLLMPreferences(searchSpaceIdNum); - const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false); + const { loading, error, isOnboardingComplete } = useLLMPreferences(searchSpaceIdNum); + const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false); - // Skip onboarding check if we're already on the onboarding page - const isOnboardingPage = pathname?.includes("/onboard"); + // Skip onboarding check if we're already on the onboarding page + const isOnboardingPage = pathname?.includes("/onboard"); - // Translate navigation items - const tNavMenu = useTranslations("nav_menu"); - const translatedNavMain = useMemo(() => { - return navMain.map((item) => ({ - ...item, - title: tNavMenu(item.title.toLowerCase().replace(/ /g, "_")), - items: item.items?.map((subItem: any) => ({ - ...subItem, - title: tNavMenu(subItem.title.toLowerCase().replace(/ /g, "_")), - })), - })); - }, [navMain, tNavMenu]); + // Translate navigation items + const tNavMenu = useTranslations("nav_menu"); + const translatedNavMain = useMemo(() => { + return navMain.map((item) => ({ + ...item, + title: tNavMenu(item.title.toLowerCase().replace(/ /g, "_")), + items: item.items?.map((subItem: any) => ({ + ...subItem, + title: tNavMenu(subItem.title.toLowerCase().replace(/ /g, "_")), + })), + })); + }, [navMain, tNavMenu]); - const translatedNavSecondary = useMemo(() => { - return navSecondary.map((item) => ({ - ...item, - title: - item.title === "All Search Spaces" - ? tNavMenu("all_search_spaces") - : item.title, - })); - }, [navSecondary, tNavMenu]); + const translatedNavSecondary = useMemo(() => { + return navSecondary.map((item) => ({ + ...item, + title: item.title === "All Search Spaces" ? tNavMenu("all_search_spaces") : item.title, + })); + }, [navSecondary, tNavMenu]); - const [open, setOpen] = useState(() => { - try { - const match = document.cookie.match(/(?:^|; )sidebar_state=([^;]+)/); - if (match) return match[1] === "true"; - } catch { - // ignore - } - return true; - }); + const [open, setOpen] = useState(() => { + try { + const match = document.cookie.match(/(?:^|; )sidebar_state=([^;]+)/); + if (match) return match[1] === "true"; + } catch { + // ignore + } + return true; + }); - useEffect(() => { - // Skip check if already on onboarding page - if (isOnboardingPage) { - setHasCheckedOnboarding(true); - return; - } + useEffect(() => { + // Skip check if already on onboarding page + if (isOnboardingPage) { + setHasCheckedOnboarding(true); + return; + } - // Only check once after preferences have loaded - if (!loading && !hasCheckedOnboarding) { - const onboardingComplete = isOnboardingComplete(); + // Only check once after preferences have loaded + if (!loading && !hasCheckedOnboarding) { + const onboardingComplete = isOnboardingComplete(); - if (!onboardingComplete) { - router.push(`/dashboard/${searchSpaceId}/onboard`); - } + if (!onboardingComplete) { + router.push(`/dashboard/${searchSpaceId}/onboard`); + } - setHasCheckedOnboarding(true); - } - }, [ - loading, - isOnboardingComplete, - isOnboardingPage, - router, - searchSpaceId, - hasCheckedOnboarding, - ]); + setHasCheckedOnboarding(true); + } + }, [ + loading, + isOnboardingComplete, + isOnboardingPage, + router, + searchSpaceId, + hasCheckedOnboarding, + ]); - // Synchronize active search space and chat IDs with URL - useEffect(() => { - const activeSeacrhSpaceId = - typeof search_space_id === "string" - ? search_space_id - : Array.isArray(search_space_id) && search_space_id.length > 0 - ? search_space_id[0] - : ""; - if (!activeSeacrhSpaceId) return; - setActiveSearchSpaceIdState(activeSeacrhSpaceId); - }, [search_space_id]); + // Synchronize active search space and chat IDs with URL + useEffect(() => { + const activeSeacrhSpaceId = + typeof search_space_id === "string" + ? search_space_id + : Array.isArray(search_space_id) && search_space_id.length > 0 + ? search_space_id[0] + : ""; + if (!activeSeacrhSpaceId) return; + setActiveSearchSpaceIdState(activeSeacrhSpaceId); + }, [search_space_id]); - 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]); + 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 && !isOnboardingPage) { - return ( -
- - - - {t("loading_config")} - - {t("checking_llm_prefs")} - - - - - -
- ); - } + // Show loading screen while checking onboarding status (only on first load) + if (!hasCheckedOnboarding && loading && !isOnboardingPage) { + return ( +
+ + + {t("loading_config")} + {t("checking_llm_prefs")} + + + + + +
+ ); + } - // Show error screen if there's an error loading preferences (but not on onboarding page) - if (error && !hasCheckedOnboarding && !isOnboardingPage) { - return ( -
- - - - {t("config_error")} - - {t("failed_load_llm_config")} - - -

{error}

-
-
-
- ); - } + // Show error screen if there's an error loading preferences (but not on onboarding page) + if (error && !hasCheckedOnboarding && !isOnboardingPage) { + return ( +
+ + + + {t("config_error")} + + {t("failed_load_llm_config")} + + +

{error}

+
+
+
+ ); + } - return ( - - {/* Use AppSidebarProvider which fetches user, search space, and recent chats */} - - -
-
-
-
-
- - - -
-
- - - {/* Only show artifacts toggle on researcher page */} - {isResearcherPage && ( - - { - 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 }} - > - - - - + return ( + + {/* Use AppSidebarProvider which fetches user, search space, and recent chats */} + + +
+
+
+
+
+ + + +
+
+ + + {/* Only show artifacts toggle on researcher page */} + {isResearcherPage && ( + + { + 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 }} + > + + + + {/* Pulsing indicator badge */} diff --git a/surfsense_web/atoms/chats/chat-mutations.atom.ts b/surfsense_web/atoms/chats/chat-mutations.atom.ts index b1817faad..fbb30a01e 100644 --- a/surfsense_web/atoms/chats/chat-mutations.atom.ts +++ b/surfsense_web/atoms/chats/chat-mutations.atom.ts @@ -7,28 +7,31 @@ import { toast } from "sonner"; import { Chat } from "@/app/dashboard/[search_space_id]/chats/chats-client"; export const deleteChatMutationAtom = atomWithMutation((get) => { - const searchSpaceId = get(activeSearchSpaceIdAtom); - const authToken = localStorage.getItem("surfsense_bearer_token"); + const searchSpaceId = get(activeSearchSpaceIdAtom); + const authToken = localStorage.getItem("surfsense_bearer_token"); - return { - mutationKey: cacheKeys.activeSearchSpace.chats(searchSpaceId ?? ""), - enabled: !!searchSpaceId && !!authToken, - mutationFn: async (chatId: number) => { - if (!authToken) { - throw new Error("No authentication token found"); - } - if (!searchSpaceId) { - throw new Error("No search space id found"); - } + return { + mutationKey: cacheKeys.activeSearchSpace.chats(searchSpaceId ?? ""), + enabled: !!searchSpaceId && !!authToken, + mutationFn: async (chatId: number) => { + if (!authToken) { + throw new Error("No authentication token found"); + } + if (!searchSpaceId) { + throw new Error("No search space id found"); + } - return deleteChat(chatId, authToken); - }, + return deleteChat(chatId, authToken); + }, - onSuccess: (_, chatId) => { - toast.success("Chat deleted successfully"); - queryClient.setQueryData(cacheKeys.activeSearchSpace.chats(searchSpaceId!), (oldData: Chat[]) => { - return oldData.filter((chat) => chat.id !== chatId); - }); - }, - }; + onSuccess: (_, chatId) => { + toast.success("Chat deleted successfully"); + queryClient.setQueryData( + cacheKeys.activeSearchSpace.chats(searchSpaceId!), + (oldData: Chat[]) => { + return oldData.filter((chat) => chat.id !== chatId); + } + ); + }, + }; }); diff --git a/surfsense_web/atoms/chats/chat-queries.atom.ts b/surfsense_web/atoms/chats/chat-queries.atom.ts index 6cf8139d2..2463b65ef 100644 --- a/surfsense_web/atoms/chats/chat-queries.atom.ts +++ b/surfsense_web/atoms/chats/chat-queries.atom.ts @@ -2,63 +2,60 @@ import { atom } from "jotai"; import { atomWithQuery } from "jotai-tanstack-query"; import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; -import { - fetchChatDetails, - fetchChatsBySearchSpace, -} from "@/lib/apis/chats.api"; +import { fetchChatDetails, fetchChatsBySearchSpace } from "@/lib/apis/chats.api"; import { getPodcastByChatId } from "@/lib/apis/podcasts.api"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; type ActiveChatState = { - chatId: string | null; - chatDetails: ChatDetails | null; - podcast: PodcastItem | null; + chatId: string | null; + chatDetails: ChatDetails | null; + podcast: PodcastItem | null; }; export const activeChatIdAtom = atom(null); export const activeChatAtom = atomWithQuery((get) => { - const activeChatId = get(activeChatIdAtom); - const authToken = localStorage.getItem("surfsense_bearer_token"); + const activeChatId = get(activeChatIdAtom); + const authToken = localStorage.getItem("surfsense_bearer_token"); - return { - queryKey: cacheKeys.activeSearchSpace.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"); - } + return { + queryKey: cacheKeys.activeSearchSpace.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([ - getPodcastByChatId(activeChatId, authToken), - fetchChatDetails(activeChatId, authToken), - ]); + const [podcast, chatDetails] = await Promise.all([ + getPodcastByChatId(activeChatId, authToken), + fetchChatDetails(activeChatId, authToken), + ]); - return { chatId: activeChatId, chatDetails, podcast }; - }, - }; + return { chatId: activeChatId, chatDetails, podcast }; + }, + }; }); export const activeSearchSpaceChatsAtom = atomWithQuery((get) => { - const searchSpaceId = get(activeSearchSpaceIdAtom); - const authToken = localStorage.getItem("surfsense_bearer_token"); + const searchSpaceId = get(activeSearchSpaceIdAtom); + const authToken = localStorage.getItem("surfsense_bearer_token"); - return { - queryKey: cacheKeys.activeSearchSpace.chats(searchSpaceId ?? ""), - enabled: !!searchSpaceId && !!authToken, - queryFn: async () => { - if (!authToken) { - throw new Error("No authentication token found"); - } - if (!searchSpaceId) { - throw new Error("No search space id found"); - } + return { + queryKey: cacheKeys.activeSearchSpace.chats(searchSpaceId ?? ""), + enabled: !!searchSpaceId && !!authToken, + queryFn: async () => { + if (!authToken) { + throw new Error("No authentication token found"); + } + if (!searchSpaceId) { + throw new Error("No search space id found"); + } - return fetchChatsBySearchSpace(searchSpaceId, authToken); - }, - }; + return fetchChatsBySearchSpace(searchSpaceId, authToken); + }, + }; }); diff --git a/surfsense_web/atoms/seach-spaces/seach-space-queries.atom.ts b/surfsense_web/atoms/seach-spaces/seach-space-queries.atom.ts index dcdf6d550..4bccf496f 100644 --- a/surfsense_web/atoms/seach-spaces/seach-space-queries.atom.ts +++ b/surfsense_web/atoms/seach-spaces/seach-space-queries.atom.ts @@ -1,3 +1,3 @@ import { atom } from "jotai"; -export const activeSearchSpaceIdAtom = atom(null); \ No newline at end of file +export const activeSearchSpaceIdAtom = atom(null); diff --git a/surfsense_web/components/chat/ChatInterface.tsx b/surfsense_web/components/chat/ChatInterface.tsx index 8a2b5c851..799e45ef6 100644 --- a/surfsense_web/components/chat/ChatInterface.tsx +++ b/surfsense_web/components/chat/ChatInterface.tsx @@ -1,38 +1,35 @@ "use client"; -import { - type ChatHandler, - ChatSection as LlamaIndexChatSection, -} from "@llamaindex/chat-ui"; +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 "@/hooks/use-documents"; interface ChatInterfaceProps { - handler: ChatHandler; - onDocumentSelectionChange?: (documents: Document[]) => void; - selectedDocuments?: Document[]; - onConnectorSelectionChange?: (connectorTypes: string[]) => void; - selectedConnectors?: string[]; - searchMode?: "DOCUMENTS" | "CHUNKS"; - onSearchModeChange?: (mode: "DOCUMENTS" | "CHUNKS") => void; - topK?: number; - onTopKChange?: (topK: number) => void; + handler: ChatHandler; + onDocumentSelectionChange?: (documents: Document[]) => void; + selectedDocuments?: Document[]; + onConnectorSelectionChange?: (connectorTypes: string[]) => void; + selectedConnectors?: string[]; + searchMode?: "DOCUMENTS" | "CHUNKS"; + onSearchModeChange?: (mode: "DOCUMENTS" | "CHUNKS") => void; + topK?: number; + onTopKChange?: (topK: number) => void; } export default function ChatInterface({ - handler, - onDocumentSelectionChange, - selectedDocuments = [], - onConnectorSelectionChange, - selectedConnectors = [], - searchMode, - onSearchModeChange, - topK = 10, - onTopKChange, + handler, + onDocumentSelectionChange, + selectedDocuments = [], + onConnectorSelectionChange, + selectedConnectors = [], + searchMode, + onSearchModeChange, + topK = 10, + onTopKChange, }: ChatInterfaceProps) { - const { chat_id, search_space_id } = useParams(); + const { chat_id, search_space_id } = useParams(); return ( diff --git a/surfsense_web/contracts/types/auth.types.ts b/surfsense_web/contracts/types/auth.types.ts index 507ea18fc..318b656c9 100644 --- a/surfsense_web/contracts/types/auth.types.ts +++ b/surfsense_web/contracts/types/auth.types.ts @@ -1,32 +1,32 @@ import { z } from "zod"; export const loginRequest = z.object({ - email: z.string().email(), - password: z.string().min(1), - grant_type: z.string().optional(), + email: z.string().email(), + password: z.string().min(1), + grant_type: z.string().optional(), }); export const loginResponse = z.object({ - access_token: z.string(), - token_type: z.string(), + access_token: z.string(), + token_type: z.string(), }); export const registerRequest = z.object({ - email: z.string().email(), - password: z.string().min(1), - is_active: z.boolean().optional(), - is_superuser: z.boolean().optional(), - is_verified: z.boolean().optional(), + email: z.string().email(), + password: z.string().min(1), + is_active: z.boolean().optional(), + is_superuser: z.boolean().optional(), + is_verified: z.boolean().optional(), }); export const registerResponse = z.object({ - id: z.number(), - email: z.string().email(), - is_active: z.boolean(), - is_superuser: z.boolean(), - is_verified: z.boolean(), - pages_limit: z.number(), - pages_used: z.number(), + id: z.number(), + email: z.string().email(), + is_active: z.boolean(), + is_superuser: z.boolean(), + is_verified: z.boolean(), + pages_limit: z.number(), + pages_used: z.number(), }); export type LoginRequest = z.infer; diff --git a/surfsense_web/contracts/types/chat.types.ts b/surfsense_web/contracts/types/chat.types.ts index 3e7eb6911..7a8767940 100644 --- a/surfsense_web/contracts/types/chat.types.ts +++ b/surfsense_web/contracts/types/chat.types.ts @@ -5,43 +5,45 @@ import { paginationQueryParams } from "."; export const chatTypeEnum = z.enum(["QNA"]); export const chatSummary = z.object({ - created_at: z.string(), - id: z.number(), - type: chatTypeEnum, - title: z.string(), - search_space_id: z.number(), - state_version: z.number(), + created_at: z.string(), + id: z.number(), + type: chatTypeEnum, + title: z.string(), + search_space_id: z.number(), + state_version: z.number(), }); export const chatDetails = chatSummary.extend({ - initial_connectors: z.array(z.string()), - messages: z.array(z.any()), + initial_connectors: z.array(z.string()), + messages: z.array(z.any()), }); export const getChatDetailsRequest = chatSummary.pick({ id: true }); -export const getChatsBySearchSpaceRequest = chatSummary.pick({ - search_space_id: true, -}).merge(paginationQueryParams); +export const getChatsBySearchSpaceRequest = chatSummary + .pick({ + search_space_id: true, + }) + .merge(paginationQueryParams); export const deleteChatResponse = z.object({ - message: z.literal("Chat deleted successfully"), + message: z.literal("Chat deleted successfully"), }); export const deleteChatRequest = chatSummary.pick({ id: true }); -export const createChatRequest = chatDetails - .omit({ created_at: true, id: true, state_version: true }); +export const createChatRequest = chatDetails.omit({ + created_at: true, + id: true, + state_version: true, +}); -export const updateChatRequest = chatDetails - .omit({ created_at: true, state_version: true }); +export const updateChatRequest = chatDetails.omit({ created_at: true, state_version: true }); export type ChatSummary = z.infer; export type ChatDetails = z.infer & { messages: Message[] }; export type GetChatDetailsRequest = z.infer; -export type GetChatsBySearchSpaceRequest = z.infer< - typeof getChatsBySearchSpaceRequest ->; +export type GetChatsBySearchSpaceRequest = z.infer; export type DeleteChatResponse = z.infer; export type DeleteChatRequest = z.infer; export type CreateChatRequest = z.infer; diff --git a/surfsense_web/contracts/types/index.ts b/surfsense_web/contracts/types/index.ts index d6c65b5ec..d00f7903f 100644 --- a/surfsense_web/contracts/types/index.ts +++ b/surfsense_web/contracts/types/index.ts @@ -1,8 +1,8 @@ import { z } from "zod"; export const paginationQueryParams = z.object({ - limit: z.number().optional(), - skip: z.number().optional(), + limit: z.number().optional(), + skip: z.number().optional(), }); export type PaginationQueryParams = z.infer; diff --git a/surfsense_web/lib/apis/auth-api.service.ts b/surfsense_web/lib/apis/auth-api.service.ts index e283c2965..a9eea9be7 100644 --- a/surfsense_web/lib/apis/auth-api.service.ts +++ b/surfsense_web/lib/apis/auth-api.service.ts @@ -1,44 +1,35 @@ import { - loginRequest, - LoginRequest, - loginResponse, - registerRequest, - RegisterRequest, - registerResponse, + loginRequest, + LoginRequest, + loginResponse, + registerRequest, + RegisterRequest, + registerResponse, } from "@/contracts/types/auth.types"; import { baseApiService } from "./base-api.service"; export class AuthApiService { - login = async (request: LoginRequest) => { - // Validate the request - const parsedRequest = loginRequest.safeParse(request); + login = async (request: LoginRequest) => { + // Validate the request + const parsedRequest = loginRequest.safeParse(request); - if (!parsedRequest.success) { - throw new Error(`Invalid request: ${parsedRequest.error.message}`); - } + if (!parsedRequest.success) { + throw new Error(`Invalid request: ${parsedRequest.error.message}`); + } - return baseApiService.post( - `/auth/jwt/login`, - parsedRequest.data, - loginResponse, - { - contentType: "application/x-www-form-urlencoded", - } - ); - }; + return baseApiService.post(`/auth/jwt/login`, parsedRequest.data, loginResponse, { + contentType: "application/x-www-form-urlencoded", + }); + }; - register = async (request: RegisterRequest) => { - // Validate the request - const parsedRequest = registerRequest.safeParse(request); + register = async (request: RegisterRequest) => { + // Validate the request + const parsedRequest = registerRequest.safeParse(request); - if (!parsedRequest.success) { - throw new Error(`Invalid request: ${parsedRequest.error.message}`); - } + if (!parsedRequest.success) { + throw new Error(`Invalid request: ${parsedRequest.error.message}`); + } - return baseApiService.post( - `/auth/register`, - parsedRequest.data, - registerResponse - ); - }; + return baseApiService.post(`/auth/register`, parsedRequest.data, registerResponse); + }; } diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index f94481547..777da0094 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -1,179 +1,163 @@ import z from "zod"; -import { - AppError, - AuthenticationError, - AuthorizationError, - ValidationError, -} from "../error"; +import { AppError, AuthenticationError, AuthorizationError, ValidationError } from "../error"; export type RequestOptions = { - method: "GET" | "POST" | "PUT" | "DELETE"; - headers?: Record; - contentType?: "application/json" | "application/x-www-form-urlencoded"; - signal?: AbortSignal; - body?: any; - // Add more options as needed + method: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record; + contentType?: "application/json" | "application/x-www-form-urlencoded"; + signal?: AbortSignal; + body?: any; + // Add more options as needed }; export class BaseApiService { - bearerToken: string; - baseUrl: string; + bearerToken: string; + baseUrl: string; - constructor(bearerToken: string, baseUrl: string) { - this.bearerToken = bearerToken; - this.baseUrl = baseUrl; - } + constructor(bearerToken: string, baseUrl: string) { + this.bearerToken = bearerToken; + this.baseUrl = baseUrl; + } - setBearerToken(bearerToken: string) { - this.bearerToken = bearerToken; - } + setBearerToken(bearerToken: string) { + this.bearerToken = bearerToken; + } - async request( - url: string, - body?: any, - responseSchema?: z.ZodSchema, - options?: RequestOptions - ) : Promise { - const defaultOptions: RequestOptions = { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.bearerToken}`, - }, - method: "GET", - }; + async request( + url: string, + body?: any, + responseSchema?: z.ZodSchema, + options?: RequestOptions + ): Promise { + const defaultOptions: RequestOptions = { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.bearerToken}`, + }, + method: "GET", + }; - const mergedOptions: RequestOptions = { - ...defaultOptions, - ...(options ?? {}), - headers: { - ...defaultOptions.headers, - ...(options?.headers ?? {}), - }, - }; + const mergedOptions: RequestOptions = { + ...defaultOptions, + ...(options ?? {}), + headers: { + ...defaultOptions.headers, + ...(options?.headers ?? {}), + }, + }; - let requestBody; + let requestBody; - // Serialize body - if (body) { - if ( - mergedOptions.headers?.["Content-Type"].toLocaleLowerCase() === - "application/json" - ) { - requestBody = JSON.stringify(body); - } + // Serialize body + if (body) { + if (mergedOptions.headers?.["Content-Type"].toLocaleLowerCase() === "application/json") { + requestBody = JSON.stringify(body); + } - if ( - mergedOptions.headers?.["Content-Type"].toLocaleLowerCase() === - "application/x-www-form-urlencoded" - ) { - requestBody = new URLSearchParams(body); - } + if ( + mergedOptions.headers?.["Content-Type"].toLocaleLowerCase() === + "application/x-www-form-urlencoded" + ) { + requestBody = new URLSearchParams(body); + } - mergedOptions.body = requestBody; - } + mergedOptions.body = requestBody; + } - if (!this.baseUrl) { - throw new AppError("Base URL is not set."); - } + if (!this.baseUrl) { + throw new AppError("Base URL is not set."); + } - if (!this.bearerToken) { - throw new AuthenticationError( - "You are not authenticated. Please login again." - ); - } + if (!this.bearerToken) { + throw new AuthenticationError("You are not authenticated. Please login again."); + } - const fullUrl = new URL(url, this.baseUrl).toString(); + const fullUrl = new URL(url, this.baseUrl).toString(); - const response = await fetch(fullUrl, mergedOptions); + const response = await fetch(fullUrl, mergedOptions); - if (!response.ok) { - if (response.status === 401) { - throw new AuthenticationError( - "You are not authenticated. Please login again." - ); - } + if (!response.ok) { + if (response.status === 401) { + throw new AuthenticationError("You are not authenticated. Please login again."); + } - if (response.status === 403) { - throw new AuthorizationError( - "You don't have permission to access this resource." - ); - } + if (response.status === 403) { + throw new AuthorizationError("You don't have permission to access this resource."); + } - throw new AppError(`API Error: ${response.statusText}`); - } + throw new AppError(`API Error: ${response.statusText}`); + } - let data; + let data; - try { - data = await response.json(); - } catch (error) { - throw new AppError(`Failed to parse response as JSON: ${error}`); - } + try { + data = await response.json(); + } catch (error) { + throw new AppError(`Failed to parse response as JSON: ${error}`); + } - if (!responseSchema) { - return data; - } + if (!responseSchema) { + return data; + } - const parsedData = responseSchema.safeParse(data); + const parsedData = responseSchema.safeParse(data); - if (!parsedData.success) { - throw new ValidationError( - `Invalid response: ${parsedData.error.message}` - ); - } + if (!parsedData.success) { + throw new ValidationError(`Invalid response: ${parsedData.error.message}`); + } - return parsedData.data; - } + return parsedData.data; + } - async get( - url: string, - responseSchema?: z.ZodSchema, - options?: Omit - ) { - return this.request(url, undefined, responseSchema, { - ...options, - method: "GET", - }); - } + async get( + url: string, + responseSchema?: z.ZodSchema, + options?: Omit + ) { + return this.request(url, undefined, responseSchema, { + ...options, + method: "GET", + }); + } - async post( - url: string, - body?: any, - responseSchema?: z.ZodSchema, - options?: Omit - ) { - return this.request(url, body, responseSchema, { - ...options, - method: "POST", - }); - } + async post( + url: string, + body?: any, + responseSchema?: z.ZodSchema, + options?: Omit + ) { + return this.request(url, body, responseSchema, { + ...options, + method: "POST", + }); + } - async put( - url: string, - body?: any, - responseSchema?: z.ZodSchema, - options?: Omit - ) { - return this.request(url, body, responseSchema, { - ...options, - method: "PUT", - }); - } + async put( + url: string, + body?: any, + responseSchema?: z.ZodSchema, + options?: Omit + ) { + return this.request(url, body, responseSchema, { + ...options, + method: "PUT", + }); + } - async delete( - url: string, - body?: any, - responseSchema?: z.ZodSchema, - options?: Omit - ) { - return this.request(url, body, responseSchema, { - ...options, - method: "DELETE", - }); - } + async delete( + url: string, + body?: any, + responseSchema?: z.ZodSchema, + options?: Omit + ) { + return this.request(url, body, responseSchema, { + ...options, + method: "DELETE", + }); + } } export const baseApiService = new BaseApiService( - typeof window !== "undefined" ? localStorage.getItem("surfsense_bearer_token") || "" : "", - process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "" + typeof window !== "undefined" ? localStorage.getItem("surfsense_bearer_token") || "" : "", + process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "" ); diff --git a/surfsense_web/lib/apis/chats-api.service.api.ts b/surfsense_web/lib/apis/chats-api.service.api.ts index 65dd88413..4019d35ea 100644 --- a/surfsense_web/lib/apis/chats-api.service.api.ts +++ b/surfsense_web/lib/apis/chats-api.service.api.ts @@ -1,114 +1,103 @@ import { ResearchMode } from "@/components/chat/types"; import { Message } from "@ai-sdk/react"; import { - chatDetails, - chatSummary, - createChatRequest, - CreateChatRequest, - deleteChatRequest, - DeleteChatRequest, - getChatDetailsRequest, - GetChatDetailsRequest, - getChatsBySearchSpaceRequest, - GetChatsBySearchSpaceRequest, - deleteChatResponse, - UpdateChatRequest, - updateChatRequest, + chatDetails, + chatSummary, + createChatRequest, + CreateChatRequest, + deleteChatRequest, + DeleteChatRequest, + getChatDetailsRequest, + GetChatDetailsRequest, + getChatsBySearchSpaceRequest, + GetChatsBySearchSpaceRequest, + deleteChatResponse, + UpdateChatRequest, + updateChatRequest, } from "@/contracts/types/chat.types"; import { z } from "zod"; import { baseApiService } from "./base-api.service"; export class ChatApiService { - fetchChatDetails = async ( - request: GetChatDetailsRequest - ) => { - // Validate the request - const parsedRequest = getChatDetailsRequest.safeParse(request); + fetchChatDetails = async (request: GetChatDetailsRequest) => { + // Validate the request + const parsedRequest = getChatDetailsRequest.safeParse(request); - if (!parsedRequest.success) { - throw new Error(`Invalid request: ${parsedRequest.error.message}`); - } + if (!parsedRequest.success) { + throw new Error(`Invalid request: ${parsedRequest.error.message}`); + } - return baseApiService.get(`/api/v1/chats/${request.id}`, chatDetails); - }; + return baseApiService.get(`/api/v1/chats/${request.id}`, chatDetails); + }; - fetchChatsBySearchSpace = async ( - request: GetChatsBySearchSpaceRequest - )=> { - // Validate the request - const parsedRequest = getChatsBySearchSpaceRequest.safeParse(request); + fetchChatsBySearchSpace = async (request: GetChatsBySearchSpaceRequest) => { + // Validate the request + const parsedRequest = getChatsBySearchSpaceRequest.safeParse(request); - if (!parsedRequest.success) { - throw new Error(`Invalid request: ${parsedRequest.error.message}`); - } + if (!parsedRequest.success) { + throw new Error(`Invalid request: ${parsedRequest.error.message}`); + } - return baseApiService.get( - `/api/v1/chats?search_space_id=${request.search_space_id}`, - z.array(chatSummary) - ); - }; + return baseApiService.get( + `/api/v1/chats?search_space_id=${request.search_space_id}`, + z.array(chatSummary) + ); + }; - deleteChat = async (request: DeleteChatRequest) => { - // Validate the request - const parsedRequest = deleteChatRequest.safeParse(request); + deleteChat = async (request: DeleteChatRequest) => { + // Validate the request + const parsedRequest = deleteChatRequest.safeParse(request); - if (!parsedRequest.success) { - throw new Error(`Invalid request: ${parsedRequest.error.message}`); - } - - - return baseApiService.delete(`/api/v1/chats/${request.id}`, undefined, deleteChatResponse); - }; + if (!parsedRequest.success) { + throw new Error(`Invalid request: ${parsedRequest.error.message}`); + } - createChat = async ( - request: CreateChatRequest - ) => { - // Validate the request - const parsedRequest = createChatRequest.safeParse(request); + return baseApiService.delete(`/api/v1/chats/${request.id}`, undefined, deleteChatResponse); + }; - if (!parsedRequest.success) { - throw new Error(`Invalid request: ${parsedRequest.error.message}`); - } + createChat = async (request: CreateChatRequest) => { + // Validate the request + const parsedRequest = createChatRequest.safeParse(request); - const { type, title, initial_connectors, messages, search_space_id } = - parsedRequest.data; + if (!parsedRequest.success) { + throw new Error(`Invalid request: ${parsedRequest.error.message}`); + } - return baseApiService.post( - `/api/v1/chats`, - { - type, - title, - initial_connectors, - messages, - search_space_id, - }, - chatSummary - ); - }; + const { type, title, initial_connectors, messages, search_space_id } = parsedRequest.data; - updateChat = async ( - request: UpdateChatRequest - ) => { - // Validate the request - const parsedRequest = updateChatRequest.safeParse(request); + return baseApiService.post( + `/api/v1/chats`, + { + type, + title, + initial_connectors, + messages, + search_space_id, + }, + chatSummary + ); + }; - if (!parsedRequest.success) { - throw new Error(`Invalid request: ${parsedRequest.error.message}`); - } + updateChat = async (request: UpdateChatRequest) => { + // Validate the request + const parsedRequest = updateChatRequest.safeParse(request); - const { type, title, initial_connectors, messages, search_space_id, id } = - parsedRequest.data; + if (!parsedRequest.success) { + throw new Error(`Invalid request: ${parsedRequest.error.message}`); + } - return baseApiService.put( - `/api/v1/chats/${id}`, - { - type, - title, - initial_connectors, - messages, - search_space_id, - }, - chatSummary - ); - }; + const { type, title, initial_connectors, messages, search_space_id, id } = parsedRequest.data; + + return baseApiService.put( + `/api/v1/chats/${id}`, + { + type, + title, + initial_connectors, + messages, + search_space_id, + }, + chatSummary + ); + }; } diff --git a/surfsense_web/lib/apis/chats.api.ts b/surfsense_web/lib/apis/chats.api.ts index ddd1704ae..c5e90ecd5 100644 --- a/surfsense_web/lib/apis/chats.api.ts +++ b/surfsense_web/lib/apis/chats.api.ts @@ -1,167 +1,157 @@ -import type { - Chat, - ChatDetails, -} from "@/app/dashboard/[search_space_id]/chats/chats-client"; +import type { Chat, ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; import { ResearchMode } from "@/components/chat/types"; import { Message } from "@ai-sdk/react"; export const fetchChatDetails = async ( - chatId: string, - authToken: string + chatId: string, + authToken: string ): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number( - chatId - )}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - } - ); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number(chatId)}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + } + ); - if (!response.ok) { - throw new Error(`Failed to fetch chat details: ${response.statusText}`); - } + if (!response.ok) { + throw new Error(`Failed to fetch chat details: ${response.statusText}`); + } - return await response.json(); + return await response.json(); }; export const fetchChatsBySearchSpace = async ( - searchSpaceId: string, - authToken: string + searchSpaceId: string, + authToken: string ): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats?search_space_id=${searchSpaceId}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - } - ); - if (!response.ok) { - throw new Error(`Failed to fetch chats: ${response.statusText}`); - } + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats?search_space_id=${searchSpaceId}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + } + ); + if (!response.ok) { + throw new Error(`Failed to fetch chats: ${response.statusText}`); + } - return await response.json(); + return await response.json(); }; export const deleteChat = async (chatId: number, authToken: string) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${chatId}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${authToken}`, - }, - } - ); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${chatId}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${authToken}`, + }, + } + ); - if (!response.ok) { - throw new Error(`Failed to delete chat: ${response.statusText}`); - } + if (!response.ok) { + throw new Error(`Failed to delete chat: ${response.statusText}`); + } - return await response.json(); + return await response.json(); }; export const createChat = async ( - initialMessage: string, - researchMode: ResearchMode, - selectedConnectors: string[], - authToken: string, - searchSpaceId: number + initialMessage: string, + researchMode: ResearchMode, + selectedConnectors: string[], + authToken: string, + searchSpaceId: number ): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - body: JSON.stringify({ - type: researchMode, - title: "Untitled Chat", - initial_connectors: selectedConnectors, - messages: [ - { - role: "user", - content: initialMessage, - }, - ], - search_space_id: searchSpaceId, - }), - } - ); + const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ + type: researchMode, + title: "Untitled Chat", + initial_connectors: selectedConnectors, + messages: [ + { + role: "user", + content: initialMessage, + }, + ], + search_space_id: searchSpaceId, + }), + }); - if (!response.ok) { - throw new Error(`Failed to create chat: ${response.statusText}`); - } + if (!response.ok) { + throw new Error(`Failed to create chat: ${response.statusText}`); + } - return await response.json(); + return await response.json(); }; export const updateChat = async ( - chatId: string, - messages: Message[], - researchMode: ResearchMode, - selectedConnectors: string[], - authToken: string, - searchSpaceId: number + chatId: string, + messages: Message[], + researchMode: ResearchMode, + selectedConnectors: string[], + authToken: string, + searchSpaceId: number ) => { - const userMessages = messages.filter((msg) => msg.role === "user"); - if (userMessages.length === 0) return; + const userMessages = messages.filter((msg) => msg.role === "user"); + if (userMessages.length === 0) return; - const title = userMessages[0].content; + const title = userMessages[0].content; - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number( - chatId - )}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - body: JSON.stringify({ - type: researchMode, - title: title, - initial_connectors: selectedConnectors, - messages: messages, - search_space_id: searchSpaceId, - }), - } - ); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number(chatId)}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ + type: researchMode, + title: title, + initial_connectors: selectedConnectors, + messages: messages, + search_space_id: searchSpaceId, + }), + } + ); - if (!response.ok) { - throw new Error(`Failed to update chat: ${response.statusText}`); - } + if (!response.ok) { + throw new Error(`Failed to update chat: ${response.statusText}`); + } }; export const fetchChats = async ( - searchSpaceId: string, - limit: number, - skip: number, - authToken: string + searchSpaceId: string, + limit: number, + skip: number, + authToken: string ) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats?limit=${limit}&skip=${skip}&search_space_id=${searchSpaceId}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "GET", - } - ); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats?limit=${limit}&skip=${skip}&search_space_id=${searchSpaceId}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "GET", + } + ); - if (!response.ok) { - throw new Error(`Failed to fetch chats: ${response.status}`); - } + if (!response.ok) { + throw new Error(`Failed to fetch chats: ${response.status}`); + } - return await response.json(); + return await response.json(); }; diff --git a/surfsense_web/lib/apis/documents.api.ts b/surfsense_web/lib/apis/documents.api.ts index 155717911..7ef306a4c 100644 --- a/surfsense_web/lib/apis/documents.api.ts +++ b/surfsense_web/lib/apis/documents.api.ts @@ -3,273 +3,258 @@ import { DocumentTypeCount } from "@/hooks/use-document-types"; import { normalizeListResponse } from "../pagination"; export const uploadDocument = async (formData: FormData, authToken: string) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/fileupload`, - { - method: "POST", - headers: { - Authorization: `Bearer ${authToken}`, - }, - body: formData, - } - ); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/fileupload`, + { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + }, + body: formData, + } + ); - if (!response.ok) { - throw new Error("Failed to upload document"); - } + if (!response.ok) { + throw new Error("Failed to upload document"); + } - return await response.json(); + return await response.json(); }; export const createDocument = async (request: { - documentType: string; - content: any; - searchSpaceId: number; - authToken: string; + documentType: string; + content: any; + searchSpaceId: number; + authToken: string; }) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${request.authToken}`, - }, - body: JSON.stringify(request), - } - ); + const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${request.authToken}`, + }, + body: JSON.stringify(request), + }); - if (!response.ok) { - throw new Error("Failed to process document"); - } + if (!response.ok) { + throw new Error("Failed to process document"); + } - return await response.json(); + return await response.json(); }; -export const fetchDocumentByChunk = async ( - chunkId: number, - authToken: string -) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/by-chunk/${chunkId}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - "Content-Type": "application/json", - }, - method: "GET", - } - ); +export const fetchDocumentByChunk = async (chunkId: number, authToken: string) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/by-chunk/${chunkId}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + method: "GET", + } + ); - if (!response.ok) { - const errorText = await response.text(); - let errorMessage = "Failed to fetch document"; + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = "Failed to fetch document"; - try { - const errorData = JSON.parse(errorText); - errorMessage = errorData.detail || errorMessage; - } catch { - // If parsing fails, use default message - } + try { + const errorData = JSON.parse(errorText); + errorMessage = errorData.detail || errorMessage; + } catch { + // If parsing fails, use default message + } - if (response.status === 404) { - errorMessage = "Chunk not found or you don't have access to it"; - } - throw new Error(errorMessage); - } + if (response.status === 404) { + errorMessage = "Chunk not found or you don't have access to it"; + } + throw new Error(errorMessage); + } - const data: DocumentWithChunks = await response.json(); + const data: DocumentWithChunks = await response.json(); - return data; + return data; }; export const fetchDocumentTypes = async (authToken: string) => { - if (!authToken) { - throw new Error("No authentication token found"); - } + if (!authToken) { + throw new Error("No authentication token found"); + } - // Build URL with optional search_space_id query parameter - const url = new URL( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/type-counts` - ); + // Build URL with optional search_space_id query parameter + const url = new URL( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/type-counts` + ); - const response = await fetch(url.toString(), { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - }); + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + }); - if (!response.ok) { - throw new Error(`Failed to fetch document types: ${response.statusText}`); - } + if (!response.ok) { + throw new Error(`Failed to fetch document types: ${response.statusText}`); + } - const data = await response.json(); + const data = await response.json(); - // Convert the object to an array of DocumentTypeCount - const typeCounts: DocumentTypeCount[] = Object.entries(data).map( - ([type, count]) => ({ - type, - count: count as number, - }) - ); + // Convert the object to an array of DocumentTypeCount + const typeCounts: DocumentTypeCount[] = Object.entries(data).map(([type, count]) => ({ + type, + count: count as number, + })); - return typeCounts; + return typeCounts; }; export const fetchDocuments = async ( - searchSpaceId: number, - authToken: string, - fetchPage?: number, - fetchPageSize?: number, - fetchDocumentTypes?: string[] + searchSpaceId: number, + authToken: string, + fetchPage?: number, + fetchPageSize?: number, + fetchDocumentTypes?: string[] ) => { - // Build query params - const params = new URLSearchParams({ - search_space_id: searchSpaceId.toString(), - }); + // Build query params + const params = new URLSearchParams({ + search_space_id: searchSpaceId.toString(), + }); - // // Use passed parameters or fall back to state/options - // const effectivePage = fetchPage !== undefined ? fetchPage : page; - // const effectivePageSize = - // fetchPageSize !== undefined ? fetchPageSize : pageSize; - // const effectiveDocumentTypes = - // fetchDocumentTypes !== undefined ? fetchDocumentTypes : documentTypes; + // // Use passed parameters or fall back to state/options + // const effectivePage = fetchPage !== undefined ? fetchPage : page; + // const effectivePageSize = + // fetchPageSize !== undefined ? fetchPageSize : pageSize; + // const effectiveDocumentTypes = + // fetchDocumentTypes !== undefined ? fetchDocumentTypes : documentTypes; - // if (effectivePage !== undefined) { - // params.append("page", effectivePage.toString()); - // } - // if (effectivePageSize !== undefined) { - // params.append("page_size", effectivePageSize.toString()); - // } - // if (effectiveDocumentTypes && effectiveDocumentTypes.length > 0) { - // params.append("document_types", effectiveDocumentTypes.join(",")); - // } + // if (effectivePage !== undefined) { + // params.append("page", effectivePage.toString()); + // } + // if (effectivePageSize !== undefined) { + // params.append("page_size", effectivePageSize.toString()); + // } + // if (effectiveDocumentTypes && effectiveDocumentTypes.length > 0) { + // params.append("document_types", effectiveDocumentTypes.join(",")); + // } - const response = await fetch( - `${ - process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL - }/api/v1/documents?${params.toString()}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "GET", - } - ); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents?${params.toString()}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "GET", + } + ); - if (!response.ok) { - throw new Error("Failed to fetch documents"); - } + if (!response.ok) { + throw new Error("Failed to fetch documents"); + } - const data = await response.json(); - return normalizeListResponse(data); + const data = await response.json(); + return normalizeListResponse(data); }; export const searchDocuments = async ( - searchSpaceId: number, - authToken: string, - searchQuery: string, - fetchPage?: number, - fetchPageSize?: number, - fetchDocumentTypes?: string[] + searchSpaceId: number, + authToken: string, + searchQuery: string, + fetchPage?: number, + fetchPageSize?: number, + fetchDocumentTypes?: string[] ) => { - // if (!searchQuery.trim()) { - // // If search is empty, fetch all documents - // // return fetchDocuments(fetchPage, fetchPageSize, fetchDocumentTypes); - // } + // if (!searchQuery.trim()) { + // // If search is empty, fetch all documents + // // return fetchDocuments(fetchPage, fetchPageSize, fetchDocumentTypes); + // } - // Build query params - const params = new URLSearchParams({ - search_space_id: searchSpaceId.toString(), - title: searchQuery, - }); + // Build query params + const params = new URLSearchParams({ + search_space_id: searchSpaceId.toString(), + title: searchQuery, + }); - // // Use passed parameters or fall back to state/options - // const effectivePage = fetchPage !== undefined ? fetchPage : page; - // const effectivePageSize = fetchPageSize !== undefined ? fetchPageSize : pageSize; - // const effectiveDocumentTypes = - // fetchDocumentTypes !== undefined ? fetchDocumentTypes : documentTypes; + // // Use passed parameters or fall back to state/options + // const effectivePage = fetchPage !== undefined ? fetchPage : page; + // const effectivePageSize = fetchPageSize !== undefined ? fetchPageSize : pageSize; + // const effectiveDocumentTypes = + // fetchDocumentTypes !== undefined ? fetchDocumentTypes : documentTypes; - // if (effectivePage !== undefined) { - // params.append("page", effectivePage.toString()); - // } - // if (effectivePageSize !== undefined) { - // params.append("page_size", effectivePageSize.toString()); - // } - // if (effectiveDocumentTypes && effectiveDocumentTypes.length > 0) { - // params.append("document_types", effectiveDocumentTypes.join(",")); - // } + // if (effectivePage !== undefined) { + // params.append("page", effectivePage.toString()); + // } + // if (effectivePageSize !== undefined) { + // params.append("page_size", effectivePageSize.toString()); + // } + // if (effectiveDocumentTypes && effectiveDocumentTypes.length > 0) { + // params.append("document_types", effectiveDocumentTypes.join(",")); + // } - const response = await fetch( - `${ - process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL - }/api/v1/documents/search?${params.toString()}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "GET", - } - ); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/search?${params.toString()}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "GET", + } + ); - if (!response.ok) { - throw new Error("Failed to search documents"); - } + if (!response.ok) { + throw new Error("Failed to search documents"); + } - const data = await response.json(); - const normalized = normalizeListResponse(data); - return normalized; + const data = await response.json(); + const normalized = normalizeListResponse(data); + return normalized; }; export const deleteDocument = async (documentId: number, authToken: string) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "DELETE", - } - ); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "DELETE", + } + ); - if (!response.ok) { - throw new Error("Failed to delete document"); - } + if (!response.ok) { + throw new Error("Failed to delete document"); + } - return await response.json(); + return await response.json(); }; -export const getDocumentTypeCounts = async ( - searchSpaceId: number, - authToken: string -) => { - try { - const params = new URLSearchParams({ - search_space_id: searchSpaceId.toString(), - }); +export const getDocumentTypeCounts = async (searchSpaceId: number, authToken: string) => { + try { + const params = new URLSearchParams({ + search_space_id: searchSpaceId.toString(), + }); - const response = await fetch( - `${ - process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL - }/api/v1/documents/type-counts?${params.toString()}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "GET", - } - ); + const response = await fetch( + `${ + process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL + }/api/v1/documents/type-counts?${params.toString()}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "GET", + } + ); - if (!response.ok) { - throw new Error("Failed to fetch document type counts"); - } + if (!response.ok) { + throw new Error("Failed to fetch document type counts"); + } - const counts = await response.json(); - return counts as Record; - } catch (err: any) { - console.error("Error fetching document type counts:", err); - return {}; - } + const counts = await response.json(); + return counts as Record; + } catch (err: any) { + console.error("Error fetching document type counts:", err); + return {}; + } }; diff --git a/surfsense_web/lib/apis/llm-configs.api.ts b/surfsense_web/lib/apis/llm-configs.api.ts index 86495f7e2..2f9608d2f 100644 --- a/surfsense_web/lib/apis/llm-configs.api.ts +++ b/surfsense_web/lib/apis/llm-configs.api.ts @@ -1,98 +1,90 @@ import { CreateLLMConfig, LLMConfig, UpdateLLMConfig } from "@/hooks/use-llm-configs"; -export const fetchLLMConfigs = async ( - searchSpaceId: number, - authToken: string -) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs?search_space_id=${searchSpaceId}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "GET", - } - ); +export const fetchLLMConfigs = async (searchSpaceId: number, authToken: string) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs?search_space_id=${searchSpaceId}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "GET", + } + ); - if (!response.ok) { - throw new Error("Failed to fetch LLM configurations"); - } + if (!response.ok) { + throw new Error("Failed to fetch LLM configurations"); + } - return await response.json(); + return await response.json(); }; export const createLLMConfig = async ( - config: CreateLLMConfig, - authToken: string + config: CreateLLMConfig, + authToken: string ): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - body: JSON.stringify(config), - } - ); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(config), + } + ); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to create LLM configuration"); - } + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to create LLM configuration"); + } - const newConfig = await response.json(); + const newConfig = await response.json(); - return newConfig; + return newConfig; }; -export const deleteLLMConfig = async ( - id: number, - authToken: string -): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs/${id}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${authToken}`, - }, - } - ); +export const deleteLLMConfig = async (id: number, authToken: string): Promise => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs/${id}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${authToken}`, + }, + } + ); - if (!response.ok) { - throw new Error("Failed to delete LLM configuration"); - } + if (!response.ok) { + throw new Error("Failed to delete LLM configuration"); + } - return await response.json(); + return await response.json(); }; export const updateLLMConfig = async ( - id: number, - config: UpdateLLMConfig, - authToken: string + id: number, + config: UpdateLLMConfig, + authToken: string ): Promise => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs/${id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(config), + } + ); - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs/${id}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - body: JSON.stringify(config), - } - ); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to update LLM configuration"); + } - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to update LLM configuration"); - } - - const updatedConfig = await response.json(); - - return updatedConfig; + const updatedConfig = await response.json(); + return updatedConfig; }; diff --git a/surfsense_web/lib/apis/podcasts.api.ts b/surfsense_web/lib/apis/podcasts.api.ts index fa9c680fe..beaa475ca 100644 --- a/surfsense_web/lib/apis/podcasts.api.ts +++ b/surfsense_web/lib/apis/podcasts.api.ts @@ -2,78 +2,73 @@ import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/pod import type { GeneratePodcastRequest } from "@/components/chat/ChatPanel/ChatPanelContainer"; export const getPodcastByChatId = async (chatId: string, authToken: string) => { - const response = await fetch( - `${ - process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL - }/api/v1/podcasts/by-chat/${Number(chatId)}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "GET", - } - ); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/by-chat/${Number(chatId)}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "GET", + } + ); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to fetch podcast"); - } + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || "Failed to fetch podcast"); + } - return (await response.json()) as PodcastItem | null; + return (await response.json()) as PodcastItem | null; }; -export const generatePodcast = async ( - request: GeneratePodcastRequest, - authToken: string -) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/generate/`, - { - method: "POST", - headers: { - Authorization: `Bearer ${authToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(request), - } - ); +export const generatePodcast = async (request: GeneratePodcastRequest, authToken: string) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/generate/`, + { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + } + ); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to generate podcast"); - } + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || "Failed to generate podcast"); + } - return await response.json(); + return await response.json(); }; export const loadPodcast = async (podcast: PodcastItem, authToken: string) => { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 30000); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - signal: controller.signal, - } - ); + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + signal: controller.signal, + } + ); - if (!response.ok) { - throw new Error(`Failed to fetch audio stream: ${response.statusText}`); - } + if (!response.ok) { + throw new Error(`Failed to fetch audio stream: ${response.statusText}`); + } - const blob = await response.blob(); - const objectUrl = URL.createObjectURL(blob); - return objectUrl; - } catch (error) { - if (error instanceof DOMException && error.name === "AbortError") { - throw new Error("Request timed out. Please try again."); - } - throw error; - } finally { - clearTimeout(timeoutId); - } + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + return objectUrl; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error("Request timed out. Please try again."); + } + throw error; + } finally { + clearTimeout(timeoutId); + } }; diff --git a/surfsense_web/lib/apis/search-source-connectors.api.ts b/surfsense_web/lib/apis/search-source-connectors.api.ts index 2a9e59301..c84d0be20 100644 --- a/surfsense_web/lib/apis/search-source-connectors.api.ts +++ b/surfsense_web/lib/apis/search-source-connectors.api.ts @@ -1,113 +1,107 @@ import { Connector, CreateConnectorRequest } from "@/hooks/use-connectors"; export const createConnector = async ( - data: CreateConnectorRequest, - authToken: string + data: CreateConnectorRequest, + authToken: string ): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - body: JSON.stringify(data), - } - ); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(data), + } + ); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to create connector"); - } + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to create connector"); + } - return response.json(); + return response.json(); }; export const getConnectors = async ( - skip = 0, - limit = 100, - authToken: string + skip = 0, + limit = 100, + authToken: string ): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors?skip=${skip}&limit=${limit}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - } - ); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors?skip=${skip}&limit=${limit}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + } + ); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to fetch connectors"); - } + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to fetch connectors"); + } - return response.json(); + return response.json(); }; -export const getConnector = async ( - connectorId: number, - authToken: string -): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - } - ); +export const getConnector = async (connectorId: number, authToken: string): Promise => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + } + ); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to fetch connector"); - } + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to fetch connector"); + } - return response.json(); + return response.json(); }; export const updateConnector = async ( - connectorId: number, - data: CreateConnectorRequest, - authToken: string + connectorId: number, + data: CreateConnectorRequest, + authToken: string ): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - body: JSON.stringify(data), - } - ); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(data), + } + ); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to update connector"); - } + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to update connector"); + } - return response.json(); + return response.json(); }; -export const deleteConnector = async ( - connectorId: number, - authToken: string -): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${authToken}`, - }, - } - ); +export const deleteConnector = async (connectorId: number, authToken: string): Promise => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${authToken}`, + }, + } + ); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to delete connector"); - } + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to delete connector"); + } }; diff --git a/surfsense_web/lib/apis/search-spaces.api.ts b/surfsense_web/lib/apis/search-spaces.api.ts index 2ded72f44..d63273f9d 100644 --- a/surfsense_web/lib/apis/search-spaces.api.ts +++ b/surfsense_web/lib/apis/search-spaces.api.ts @@ -1,112 +1,98 @@ export const fetchSearchSpaces = async () => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, - { - headers: { - Authorization: `Bearer ${localStorage.getItem( - "surfsense_bearer_token" - )}`, - }, - method: "GET", - } - ); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`, + }, + method: "GET", + } + ); - if (!response.ok) { - throw new Error("Not authenticated"); - } + if (!response.ok) { + throw new Error("Not authenticated"); + } - return await response.json(); + return await response.json(); }; export const deleteSearchSpace = async (id: number) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${id}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${localStorage.getItem( - "surfsense_bearer_token" - )}`, - }, - } - ); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${id}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`, + }, + } + ); - if (!response.ok) { - throw new Error("Failed to delete search space"); - } + if (!response.ok) { + throw new Error("Failed to delete search space"); + } - return await response.json(); + return await response.json(); }; -export const createSearchSpace = async (data: { - name: string; - description: string; -}) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${localStorage.getItem( - "surfsense_bearer_token" - )}`, - }, - body: JSON.stringify(data), - } - ); +export const createSearchSpace = async (data: { name: string; description: string }) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`, + }, + body: JSON.stringify(data), + } + ); - if (!response.ok) { - throw new Error("Failed to create search space"); - } + if (!response.ok) { + throw new Error("Failed to create search space"); + } - return await response.json(); + return await response.json(); }; export const fetchSearchSpace = async (searchSpaceId: string) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`, - { - headers: { - Authorization: `Bearer ${localStorage.getItem( - "surfsense_bearer_token" - )}`, - }, - method: "GET", - } - ); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`, + }, + method: "GET", + } + ); - if (response.status === 401) { - // Clear token and redirect to home - localStorage.removeItem("surfsense_bearer_token"); - window.location.href = "/"; - throw new Error("Unauthorized: Redirecting to login page"); - } + if (response.status === 401) { + // Clear token and redirect to home + localStorage.removeItem("surfsense_bearer_token"); + window.location.href = "/"; + throw new Error("Unauthorized: Redirecting to login page"); + } - if (!response.ok) { - throw new Error(`Failed to fetch search space: ${response.status}`); - } + if (!response.ok) { + throw new Error(`Failed to fetch search space: ${response.status}`); + } - return await response.json(); + return await response.json(); }; -export const fetchSearchSpacePreferences = async ( - searchSpaceId: number, - authToken: string -) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/llm-preferences`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "GET", - } - ); +export const fetchSearchSpacePreferences = async (searchSpaceId: number, authToken: string) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/llm-preferences`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "GET", + } + ); - if (!response.ok) { - throw new Error("Failed to fetch LLM preferences"); - } + if (!response.ok) { + throw new Error("Failed to fetch LLM preferences"); + } - return await response.json(); + return await response.json(); }; diff --git a/surfsense_web/lib/error.ts b/surfsense_web/lib/error.ts index 03259c7b5..4e8ab0e9d 100644 --- a/surfsense_web/lib/error.ts +++ b/surfsense_web/lib/error.ts @@ -1,30 +1,30 @@ export class AppError extends Error { - constructor(message: string) { - super(message); - this.name = this.constructor.name; - } + constructor(message: string) { + super(message); + this.name = this.constructor.name; + } } export class NetworkError extends AppError { - constructor(message: string) { - super(message); - } + constructor(message: string) { + super(message); + } } export class ValidationError extends AppError { - constructor(message: string) { - super(message); - } + constructor(message: string) { + super(message); + } } export class AuthenticationError extends AppError { - constructor(message: string) { - super(message); - } + constructor(message: string) { + super(message); + } } export class AuthorizationError extends AppError { - constructor(message: string) { - super(message); - } + constructor(message: string) { + super(message); + } } diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 5b74092e1..6b9b2df04 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -1,6 +1,6 @@ export const cacheKeys = { activeSearchSpace: { - chats : (searchSpaceId: string) => ["active-search-space", "chats", searchSpaceId] as const, - activeChat : (chatId: string) => ["active-search-space", "active-chat", chatId] as const, + chats: (searchSpaceId: string) => ["active-search-space", "chats", searchSpaceId] as const, + activeChat: (chatId: string) => ["active-search-space", "active-chat", chatId] as const, }, }; diff --git a/surfsense_web/lib/utils.ts b/surfsense_web/lib/utils.ts index 3b27a4c93..e8b097d15 100644 --- a/surfsense_web/lib/utils.ts +++ b/surfsense_web/lib/utils.ts @@ -6,9 +6,8 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } - export function getChatTitleFromMessages(messages: Message[]) { const userMessages = messages.filter((msg) => msg.role === "user"); if (userMessages.length === 0) return "Untitled Chat"; return userMessages[0].content; -} \ No newline at end of file +} From 82fea0ceee953b645828274e1ad07eb7f9fe463a Mon Sep 17 00:00:00 2001 From: thierryverse Date: Fri, 14 Nov 2025 00:51:17 +0200 Subject: [PATCH 19/26] refactor auth types --- surfsense_web/contracts/types/auth.types.ts | 36 +++++++++------------ 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/surfsense_web/contracts/types/auth.types.ts b/surfsense_web/contracts/types/auth.types.ts index 318b656c9..80feb52f7 100644 --- a/surfsense_web/contracts/types/auth.types.ts +++ b/surfsense_web/contracts/types/auth.types.ts @@ -1,33 +1,29 @@ import { z } from "zod"; export const loginRequest = z.object({ - email: z.string().email(), - password: z.string().min(1), - grant_type: z.string().optional(), + email: z.string().email(), + password: z.string().min(3), + grant_type: z.string().optional(), }); export const loginResponse = z.object({ - access_token: z.string(), - token_type: z.string(), + access_token: z.string(), + token_type: z.string(), }); -export const registerRequest = z.object({ - email: z.string().email(), - password: z.string().min(1), - is_active: z.boolean().optional(), - is_superuser: z.boolean().optional(), - is_verified: z.boolean().optional(), +export const registerRequest = loginRequest.omit({ grant_type: true }).extend({ + is_active: z.boolean().optional(), + is_superuser: z.boolean().optional(), + is_verified: z.boolean().optional(), }); -export const registerResponse = z.object({ - id: z.number(), - email: z.string().email(), - is_active: z.boolean(), - is_superuser: z.boolean(), - is_verified: z.boolean(), - pages_limit: z.number(), - pages_used: z.number(), -}); +export const registerResponse = registerRequest + .omit({ password: true }) + .extend({ + id: z.number(), + pages_limit: z.number(), + pages_used: z.number(), + }); export type LoginRequest = z.infer; export type LoginResponse = z.infer; From 41a938cec05ffd7c25d54954ba7386e13e28d743 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Sat, 15 Nov 2025 02:07:20 +0200 Subject: [PATCH 20/26] Refactor register page --- surfsense_web/app/(home)/register/page.tsx | 117 ++++++------ .../[search_space_id]/chats/chats-client.tsx | 6 +- .../[search_space_id]/client-layout.tsx | 8 +- .../atoms/auth/auth-mutation.atoms.ts | 22 +++ ...tations.atom.ts => chat-mutation.atoms.ts} | 10 +- ...t-queries.atom.ts => chat-querie.atoms.ts} | 2 +- surfsense_web/atoms/chats/chat-uis.atom.ts | 9 - surfsense_web/atoms/chats/ui.atoms.ts | 9 + .../components/announcement-banner.tsx | 2 +- .../chat/ChatPanel/ChatPanelContainer.tsx | 6 +- .../chat/ChatPanel/ChatPanelView.tsx | 8 +- .../components/chat/ChatPanel/ConfigModal.tsx | 2 +- surfsense_web/contracts/types/auth.types.ts | 28 ++- surfsense_web/contracts/types/chat.types.ts | 2 +- surfsense_web/lib/apis/auth-api.service.ts | 19 +- surfsense_web/lib/apis/base-api.service.ts | 173 +++++++++++------- .../lib/apis/chats-api.service.api.ts | 18 +- surfsense_web/lib/apis/chats.api.ts | 4 +- surfsense_web/lib/apis/documents.api.ts | 4 +- surfsense_web/lib/apis/llm-configs.api.ts | 2 +- .../lib/apis/search-source-connectors.api.ts | 2 +- surfsense_web/lib/error.ts | 30 ++- surfsense_web/lib/query-client/cache-keys.ts | 3 + surfsense_web/lib/utils.ts | 2 +- 24 files changed, 292 insertions(+), 196 deletions(-) create mode 100644 surfsense_web/atoms/auth/auth-mutation.atoms.ts rename surfsense_web/atoms/chats/{chat-mutations.atom.ts => chat-mutation.atoms.ts} (93%) rename surfsense_web/atoms/chats/{chat-queries.atom.ts => chat-querie.atoms.ts} (100%) delete mode 100644 surfsense_web/atoms/chats/chat-uis.atom.ts create mode 100644 surfsense_web/atoms/chats/ui.atoms.ts diff --git a/surfsense_web/app/(home)/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx index 9e3b42e2a..c535832be 100644 --- a/surfsense_web/app/(home)/register/page.tsx +++ b/surfsense_web/app/(home)/register/page.tsx @@ -1,13 +1,16 @@ "use client"; +import { useAtom } from "jotai"; import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { toast } from "sonner"; +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 { AmbientBackground } from "../login/AmbientBackground"; export default function RegisterPage() { @@ -16,10 +19,15 @@ export default function RegisterPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); - const [error, setError] = useState(null); - const [errorTitle, setErrorTitle] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<{ + title: string | null; + message: string | null; + }>({ + title: null, + message: null, + }); const router = useRouter(); + const [{ mutateAsync: register, isPending: isRegistering }] = useAtom(registerMutationAtom); // Check authentication type and redirect if not LOCAL useEffect(() => { @@ -34,8 +42,7 @@ export default function RegisterPage() { // Form validation if (password !== confirmPassword) { - setError(t("passwords_no_match")); - setErrorTitle(t("password_mismatch")); + setError({ title: t("password_mismatch"), message: t("passwords_no_match_desc") }); toast.error(t("password_mismatch"), { description: t("passwords_no_match_desc"), duration: 4000, @@ -43,48 +50,20 @@ export default function RegisterPage() { return; } - setIsLoading(true); - setError(null); // Clear any previous errors - setErrorTitle(null); + setError({ title: null, message: null }); // Clear any previous errors // Show loading toast const loadingToast = toast.loading(t("creating_account")); try { - const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - email, - password, - is_active: true, - is_superuser: false, - is_verified: false, - }), + await register({ + email, + password, + is_active: true, + is_superuser: false, + is_verified: false, }); - const data = await response.json(); - - if (!response.ok && response.status === 403) { - const friendlyMessage = - "Registrations are currently closed. If you need access, contact your administrator."; - setErrorTitle("Registration is disabled"); - setError(friendlyMessage); - toast.error("Registration is disabled", { - id: loadingToast, - description: friendlyMessage, - duration: 6000, - }); - setIsLoading(false); - return; - } - - if (!response.ok) { - throw new Error(data.detail || `HTTP ${response.status}`); - } - // Success toast toast.success(t("register_success"), { id: loadingToast, @@ -97,6 +76,34 @@ export default function RegisterPage() { router.push("/login?registered=true"); }, 500); } catch (err) { + if (err instanceof AppError) { + switch (err.status) { + case 403: { + const friendlyMessage = + "Registrations are currently closed. If you need access, contact your administrator."; + setError({ title: "Registration is disabled", message: friendlyMessage }); + toast.error("Registration is disabled", { + id: loadingToast, + description: friendlyMessage, + duration: 6000, + }); + return; + } + default: + break; + } + + if (err instanceof ValidationError) { + setError({ title: err.name, message: err.message }); + toast.error(err.name, { + id: loadingToast, + description: err.message, + duration: 6000, + }); + return; + } + } + // Use auth-errors utility to get proper error details let errorCode = "UNKNOWN_ERROR"; @@ -110,8 +117,7 @@ export default function RegisterPage() { const errorDetails = getAuthErrorDetails(errorCode); // Set persistent error display - setErrorTitle(errorDetails.title); - setError(errorDetails.description); + setError({ title: errorDetails.title, message: errorDetails.description }); // Show error toast with conditional retry action const toastOptions: any = { @@ -129,8 +135,6 @@ export default function RegisterPage() { } toast.error(errorDetails.title, toastOptions); - } finally { - setIsLoading(false); } }; @@ -147,7 +151,7 @@ export default function RegisterPage() {
{/* Enhanced Error Display */} - {error && errorTitle && ( + {error && error.title && (
-

{errorTitle}

-

{error}

+

{error.title}

+

{error.message}

@@ -243,11 +246,11 @@ export default function RegisterPage() { value={password} onChange={(e) => setPassword(e.target.value)} className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${ - error + error.title ? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700" }`} - disabled={isLoading} + disabled={isRegistering} />
@@ -265,20 +268,20 @@ export default function RegisterPage() { value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${ - error + error.title ? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700" }`} - disabled={isLoading} + disabled={isRegistering} />
diff --git a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx index b9f28e56d..1cfec056a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx @@ -1,6 +1,7 @@ "use client"; import { format } from "date-fns"; +import { useAtom, useAtomValue } from "jotai"; import { Calendar, ExternalLink, @@ -13,6 +14,8 @@ import { 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 { activeSearchSpaceChatsAtom } from "@/atoms/chats/chat-querie.atoms"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; @@ -48,9 +51,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { useAtom, useAtomValue } from "jotai"; -import { activeSearchSpaceChatsAtom } from "@/atoms/chats/chat-queries.atom"; -import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutations.atom"; export interface Chat { created_at: string; diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index ee2922130..213868314 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -7,6 +7,9 @@ import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import type React from "react"; import { useEffect, useMemo, useState } from "react"; +import { activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms"; +import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms"; +import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContainer"; import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; @@ -17,9 +20,6 @@ import { Separator } from "@/components/ui/separator"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { useLLMPreferences } from "@/hooks/use-llm-configs"; import { cn } from "@/lib/utils"; -import { activeChatIdAtom } from "@/atoms/chats/chat-queries.atom"; -import { chatUIAtom } from "@/atoms/chats/chat-uis.atom"; -import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; export function DashboardClientLayout({ children, @@ -37,7 +37,7 @@ export function DashboardClientLayout({ const pathname = usePathname(); const searchSpaceIdNum = Number(searchSpaceId); const { search_space_id, chat_id } = useParams(); - const [chatUIState, setChatUIState] = useAtom(chatUIAtom); + const [chatUIState, setChatUIState] = useAtom(activeChathatUIAtom); const activeChatId = useAtomValue(activeChatIdAtom); const setActiveSearchSpaceIdState = useSetAtom(activeSearchSpaceIdAtom); const setActiveChatIdState = useSetAtom(activeChatIdAtom); diff --git a/surfsense_web/atoms/auth/auth-mutation.atoms.ts b/surfsense_web/atoms/auth/auth-mutation.atoms.ts new file mode 100644 index 000000000..6bf4ac948 --- /dev/null +++ b/surfsense_web/atoms/auth/auth-mutation.atoms.ts @@ -0,0 +1,22 @@ +import { atomWithMutation } from "jotai-tanstack-query"; +import type { LoginRequest, RegisterRequest } from "@/contracts/types/auth.types"; +import { authApiService } from "@/lib/apis/auth-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +export const registerMutationAtom = atomWithMutation(() => { + return { + mutationKey: cacheKeys.auth.user, + mutationFn: async (request: RegisterRequest) => { + return authApiService.register(request); + }, + }; +}); + +export const loginMutationAtom = atomWithMutation(() => { + return { + mutationKey: cacheKeys.auth.user, + mutationFn: async (request: LoginRequest) => { + return authApiService.login(request); + }, + }; +}); diff --git a/surfsense_web/atoms/chats/chat-mutations.atom.ts b/surfsense_web/atoms/chats/chat-mutation.atoms.ts similarity index 93% rename from surfsense_web/atoms/chats/chat-mutations.atom.ts rename to surfsense_web/atoms/chats/chat-mutation.atoms.ts index fbb30a01e..a6dd1c9dc 100644 --- a/surfsense_web/atoms/chats/chat-mutations.atom.ts +++ b/surfsense_web/atoms/chats/chat-mutation.atoms.ts @@ -1,10 +1,10 @@ import { atomWithMutation } from "jotai-tanstack-query"; -import { deleteChat } from "@/lib/apis/chats.api"; -import { activeSearchSpaceIdAtom } from "../seach-spaces/seach-space-queries.atom"; -import { queryClient } from "@/lib/query-client/client"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; import { toast } from "sonner"; -import { Chat } from "@/app/dashboard/[search_space_id]/chats/chats-client"; +import type { Chat } from "@/app/dashboard/[search_space_id]/chats/chats-client"; +import { deleteChat } from "@/lib/apis/chats.api"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { queryClient } from "@/lib/query-client/client"; +import { activeSearchSpaceIdAtom } from "../seach-spaces/seach-space-queries.atom"; export const deleteChatMutationAtom = atomWithMutation((get) => { const searchSpaceId = get(activeSearchSpaceIdAtom); diff --git a/surfsense_web/atoms/chats/chat-queries.atom.ts b/surfsense_web/atoms/chats/chat-querie.atoms.ts similarity index 100% rename from surfsense_web/atoms/chats/chat-queries.atom.ts rename to surfsense_web/atoms/chats/chat-querie.atoms.ts index 2463b65ef..3603bf9bb 100644 --- a/surfsense_web/atoms/chats/chat-queries.atom.ts +++ b/surfsense_web/atoms/chats/chat-querie.atoms.ts @@ -2,10 +2,10 @@ import { atom } from "jotai"; import { atomWithQuery } from "jotai-tanstack-query"; import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; +import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; import { fetchChatDetails, fetchChatsBySearchSpace } from "@/lib/apis/chats.api"; import { getPodcastByChatId } from "@/lib/apis/podcasts.api"; import { cacheKeys } from "@/lib/query-client/cache-keys"; -import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; type ActiveChatState = { chatId: string | null; diff --git a/surfsense_web/atoms/chats/chat-uis.atom.ts b/surfsense_web/atoms/chats/chat-uis.atom.ts deleted file mode 100644 index 3b7e6794b..000000000 --- a/surfsense_web/atoms/chats/chat-uis.atom.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { atom } from "jotai"; - -type ChatUIState = { - isChatPannelOpen: boolean; -}; - -export const chatUIAtom = atom({ - isChatPannelOpen: false, -}); diff --git a/surfsense_web/atoms/chats/ui.atoms.ts b/surfsense_web/atoms/chats/ui.atoms.ts new file mode 100644 index 000000000..4d2b64186 --- /dev/null +++ b/surfsense_web/atoms/chats/ui.atoms.ts @@ -0,0 +1,9 @@ +import { atom } from "jotai"; + +type ActiveChathatUIState = { + isChatPannelOpen: boolean; +}; + +export const activeChathatUIAtom = atom({ + isChatPannelOpen: false, +}); diff --git a/surfsense_web/components/announcement-banner.tsx b/surfsense_web/components/announcement-banner.tsx index cbc48b444..537aa6da7 100644 --- a/surfsense_web/components/announcement-banner.tsx +++ b/surfsense_web/components/announcement-banner.tsx @@ -2,8 +2,8 @@ import { useAtom } from "jotai"; import { ExternalLink, Info, X } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { announcementDismissedAtom } from "@/atoms/announcement.atom"; +import { Button } from "@/components/ui/button"; export function AnnouncementBanner() { const [isDismissed, setIsDismissed] = useAtom(announcementDismissedAtom); diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx index b2dc67ba8..3edd00400 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx @@ -2,10 +2,10 @@ import { useAtom, useAtomValue } from "jotai"; import { LoaderIcon, PanelRight, TriangleAlert } from "lucide-react"; import { toast } from "sonner"; +import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms"; +import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms"; import { generatePodcast } from "@/lib/apis/podcasts.api"; import { cn } from "@/lib/utils"; -import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/chat-queries.atom"; -import { chatUIAtom } from "@/atoms/chats/chat-uis.atom"; import { ChatPanelView } from "./ChatPanelView"; export interface GeneratePodcastRequest { @@ -24,7 +24,7 @@ export function ChatPanelContainer() { } = useAtomValue(activeChatAtom); const activeChatIdState = useAtomValue(activeChatIdAtom); const authToken = localStorage.getItem("surfsense_bearer_token"); - const { isChatPannelOpen } = useAtomValue(chatUIAtom); + const { isChatPannelOpen } = useAtomValue(activeChathatUIAtom); const handleGeneratePodcast = async (request: GeneratePodcastRequest) => { try { diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx index 5b490b16c..d0e7c47e8 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx @@ -4,9 +4,9 @@ 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-querie.atoms"; +import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms"; import { cn } from "@/lib/utils"; -import { activeChatAtom } from "@/atoms/chats/chat-queries.atom"; -import { chatUIAtom } from "@/atoms/chats/chat-uis.atom"; import { getPodcastStalenessMessage, isPodcastStale } from "../PodcastUtils"; import type { GeneratePodcastRequest } from "./ChatPanelContainer"; import { ConfigModal } from "./ConfigModal"; @@ -17,7 +17,7 @@ interface ChatPanelViewProps { } export function ChatPanelView(props: ChatPanelViewProps) { - const [chatUIState, setChatUIState] = useAtom(chatUIAtom); + const [chatUIState, setChatUIState] = useAtom(activeChathatUIAtom); const { data: activeChatState } = useAtomValue(activeChatAtom); const { isChatPannelOpen } = chatUIState; @@ -40,6 +40,7 @@ export function ChatPanelView(props: ChatPanelViewProps) { }); }, [chatDetails, generatePodcast]); + // biome-ignore-start lint/a11y/useSemanticElements: using div for custom layout — will convert later return (
@@ -202,4 +203,5 @@ export function ChatPanelView(props: ChatPanelViewProps) { ) : null}
); + // biome-ignore-end lint/a11y/useSemanticElements : using div for custom layout — will convert later } diff --git a/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx b/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx index c7ac58e9b..09a49a2b1 100644 --- a/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx +++ b/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx @@ -3,8 +3,8 @@ import { useAtomValue } from "jotai"; import { Pencil } from "lucide-react"; import { useCallback, useContext, useState } from "react"; +import { activeChatAtom } from "@/atoms/chats/chat-querie.atoms"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { activeChatAtom } from "@/atoms/chats/chat-queries.atom"; import type { GeneratePodcastRequest } from "./ChatPanelContainer"; interface ConfigModalProps { diff --git a/surfsense_web/contracts/types/auth.types.ts b/surfsense_web/contracts/types/auth.types.ts index 80feb52f7..059f18df8 100644 --- a/surfsense_web/contracts/types/auth.types.ts +++ b/surfsense_web/contracts/types/auth.types.ts @@ -1,29 +1,27 @@ import { z } from "zod"; export const loginRequest = z.object({ - email: z.string().email(), - password: z.string().min(3), - grant_type: z.string().optional(), + email: z.string().email("Invalid email address"), + password: z.string().min(3, "Password must be at least 3 characters"), + grant_type: z.string().optional(), }); export const loginResponse = z.object({ - access_token: z.string(), - token_type: z.string(), + access_token: z.string(), + token_type: z.string(), }); export const registerRequest = loginRequest.omit({ grant_type: true }).extend({ - is_active: z.boolean().optional(), - is_superuser: z.boolean().optional(), - is_verified: z.boolean().optional(), + is_active: z.boolean().optional(), + is_superuser: z.boolean().optional(), + is_verified: z.boolean().optional(), }); -export const registerResponse = registerRequest - .omit({ password: true }) - .extend({ - id: z.number(), - pages_limit: z.number(), - pages_used: z.number(), - }); +export const registerResponse = registerRequest.omit({ password: true }).extend({ + id: z.string(), + pages_limit: z.number(), + pages_used: z.number(), +}); export type LoginRequest = z.infer; export type LoginResponse = z.infer; diff --git a/surfsense_web/contracts/types/chat.types.ts b/surfsense_web/contracts/types/chat.types.ts index 7a8767940..cff4a0ae2 100644 --- a/surfsense_web/contracts/types/chat.types.ts +++ b/surfsense_web/contracts/types/chat.types.ts @@ -1,5 +1,5 @@ +import type { Message } from "@ai-sdk/react"; import { z } from "zod"; -import { type Message } from "@ai-sdk/react"; import { paginationQueryParams } from "."; export const chatTypeEnum = z.enum(["QNA"]); diff --git a/surfsense_web/lib/apis/auth-api.service.ts b/surfsense_web/lib/apis/auth-api.service.ts index a9eea9be7..0689487ba 100644 --- a/surfsense_web/lib/apis/auth-api.service.ts +++ b/surfsense_web/lib/apis/auth-api.service.ts @@ -1,11 +1,12 @@ import { + type LoginRequest, loginRequest, - LoginRequest, loginResponse, + type RegisterRequest, registerRequest, - RegisterRequest, registerResponse, } from "@/contracts/types/auth.types"; +import { ValidationError } from "../error"; import { baseApiService } from "./base-api.service"; export class AuthApiService { @@ -14,7 +15,11 @@ export class AuthApiService { const parsedRequest = loginRequest.safeParse(request); if (!parsedRequest.success) { - throw new Error(`Invalid request: ${parsedRequest.error.message}`); + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`, undefined, "VALLIDATION_ERROR"); } return baseApiService.post(`/auth/jwt/login`, parsedRequest.data, loginResponse, { @@ -27,9 +32,15 @@ export class AuthApiService { const parsedRequest = registerRequest.safeParse(request); if (!parsedRequest.success) { - throw new Error(`Invalid request: ${parsedRequest.error.message}`); + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); } return baseApiService.post(`/auth/register`, parsedRequest.data, registerResponse); }; } + +export const authApiService = new AuthApiService(); diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index 777da0094..0cd410618 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -1,5 +1,11 @@ -import z from "zod"; -import { AppError, AuthenticationError, AuthorizationError, ValidationError } from "../error"; +import type z from "zod"; +import { + AppError, + AuthenticationError, + AuthorizationError, + NotFoundError, + ValidationError, +} from "../error"; export type RequestOptions = { method: "GET" | "POST" | "PUT" | "DELETE"; @@ -14,6 +20,8 @@ export class BaseApiService { bearerToken: string; baseUrl: string; + noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register"]; + constructor(bearerToken: string, baseUrl: string) { this.bearerToken = bearerToken; this.baseUrl = baseUrl; @@ -29,84 +37,123 @@ export class BaseApiService { responseSchema?: z.ZodSchema, options?: RequestOptions ): Promise { - const defaultOptions: RequestOptions = { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.bearerToken}`, - }, - method: "GET", - }; + try { + const defaultOptions: RequestOptions = { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.bearerToken || ""}`, + }, + method: "GET", + }; - const mergedOptions: RequestOptions = { - ...defaultOptions, - ...(options ?? {}), - headers: { - ...defaultOptions.headers, - ...(options?.headers ?? {}), - }, - }; + const mergedOptions: RequestOptions = { + ...defaultOptions, + ...(options ?? {}), + headers: { + ...defaultOptions.headers, + ...(options?.headers ?? {}), + }, + }; - let requestBody; + // biome-ignore lint/suspicious: Unknown + let requestBody; - // Serialize body - if (body) { - if (mergedOptions.headers?.["Content-Type"].toLocaleLowerCase() === "application/json") { - requestBody = JSON.stringify(body); + // Serialize body + if (body) { + if (mergedOptions.headers?.["Content-Type"].toLocaleLowerCase() === "application/json") { + requestBody = JSON.stringify(body); + } + + if ( + mergedOptions.headers?.["Content-Type"].toLocaleLowerCase() === + "application/x-www-form-urlencoded" + ) { + requestBody = new URLSearchParams(body); + } + + mergedOptions.body = requestBody; } - if ( - mergedOptions.headers?.["Content-Type"].toLocaleLowerCase() === - "application/x-www-form-urlencoded" - ) { - requestBody = new URLSearchParams(body); + if (!this.baseUrl) { + throw new AppError("Base URL is not set."); } - mergedOptions.body = requestBody; - } - - if (!this.baseUrl) { - throw new AppError("Base URL is not set."); - } - - if (!this.bearerToken) { - throw new AuthenticationError("You are not authenticated. Please login again."); - } - - const fullUrl = new URL(url, this.baseUrl).toString(); - - const response = await fetch(fullUrl, mergedOptions); - - if (!response.ok) { - if (response.status === 401) { + if (!this.bearerToken && !this.noAuthEndpoints.includes(url)) { throw new AuthenticationError("You are not authenticated. Please login again."); } - if (response.status === 403) { - throw new AuthorizationError("You don't have permission to access this resource."); + const fullUrl = new URL(url, this.baseUrl).toString(); + + const response = await fetch(fullUrl, mergedOptions); + + if (!response.ok) { + // biome-ignore lint/suspicious: Unknown + let data; + + try { + data = await response.json(); + } catch (error) { + console.error("Failed to parse response as JSON:", error); + + throw new AppError("Something went wrong", response.status, response.statusText); + } + + // for fastapi errors response + if ("detail" in data) { + throw new AppError(data.detail, response.status, response.statusText); + } + + switch (response.status) { + case 401: + throw new AuthenticationError( + "You are not authenticated. Please login again.", + response.status, + response.statusText + ); + case 403: + throw new AuthorizationError( + "You don't have permission to access this resource.", + response.status, + response.statusText + ); + case 404: + throw new NotFoundError("Resource not found", response.status, response.statusText); + // Add more cases as needed + default: + throw new AppError("Something went wrong", response.status, response.statusText); + } } - throw new AppError(`API Error: ${response.statusText}`); - } + // biome-ignore lint/suspicious: Unknown + let data; - let data; + try { + data = await response.json(); + } catch (error) { + console.error("Failed to parse response as JSON:", error); - try { - data = await response.json(); - } catch (error) { - throw new AppError(`Failed to parse response as JSON: ${error}`); - } + throw new AppError("Something went wrong", response.status, response.statusText); + } + + if (!responseSchema) { + return data; + } + + const parsedData = responseSchema.safeParse(data); + + if (!parsedData.success) { + /** The request was successful, but the response data does not match the expected schema. + * This is a client side error, and should be fixed by updating the responseSchema to keep things typed. + * This error should not be shown to the user , it is for dev only. + */ + console.error("Invalid API response schema:", parsedData.error); + } - if (!responseSchema) { return data; + } catch (error) { + console.error("Request failed:", error); + throw error; } - - const parsedData = responseSchema.safeParse(data); - - if (!parsedData.success) { - throw new ValidationError(`Invalid response: ${parsedData.error.message}`); - } - - return parsedData.data; } async get( diff --git a/surfsense_web/lib/apis/chats-api.service.api.ts b/surfsense_web/lib/apis/chats-api.service.api.ts index 4019d35ea..ae7aa9837 100644 --- a/surfsense_web/lib/apis/chats-api.service.api.ts +++ b/surfsense_web/lib/apis/chats-api.service.api.ts @@ -1,21 +1,21 @@ -import { ResearchMode } from "@/components/chat/types"; import { Message } from "@ai-sdk/react"; +import { z } from "zod"; +import { ResearchMode } from "@/components/chat/types"; import { + type CreateChatRequest, chatDetails, chatSummary, createChatRequest, - CreateChatRequest, + type DeleteChatRequest, deleteChatRequest, - DeleteChatRequest, - getChatDetailsRequest, - GetChatDetailsRequest, - getChatsBySearchSpaceRequest, - GetChatsBySearchSpaceRequest, deleteChatResponse, - UpdateChatRequest, + type GetChatDetailsRequest, + type GetChatsBySearchSpaceRequest, + getChatDetailsRequest, + getChatsBySearchSpaceRequest, + type UpdateChatRequest, updateChatRequest, } from "@/contracts/types/chat.types"; -import { z } from "zod"; import { baseApiService } from "./base-api.service"; export class ChatApiService { diff --git a/surfsense_web/lib/apis/chats.api.ts b/surfsense_web/lib/apis/chats.api.ts index c5e90ecd5..fc98585d3 100644 --- a/surfsense_web/lib/apis/chats.api.ts +++ b/surfsense_web/lib/apis/chats.api.ts @@ -1,6 +1,6 @@ +import type { Message } from "@ai-sdk/react"; import type { Chat, ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; -import { ResearchMode } from "@/components/chat/types"; -import { Message } from "@ai-sdk/react"; +import type { ResearchMode } from "@/components/chat/types"; export const fetchChatDetails = async ( chatId: string, diff --git a/surfsense_web/lib/apis/documents.api.ts b/surfsense_web/lib/apis/documents.api.ts index 7ef306a4c..02975d2fd 100644 --- a/surfsense_web/lib/apis/documents.api.ts +++ b/surfsense_web/lib/apis/documents.api.ts @@ -1,5 +1,5 @@ -import { DocumentWithChunks } from "@/hooks/use-document-by-chunk"; -import { DocumentTypeCount } from "@/hooks/use-document-types"; +import type { DocumentWithChunks } from "@/hooks/use-document-by-chunk"; +import type { DocumentTypeCount } from "@/hooks/use-document-types"; import { normalizeListResponse } from "../pagination"; export const uploadDocument = async (formData: FormData, authToken: string) => { diff --git a/surfsense_web/lib/apis/llm-configs.api.ts b/surfsense_web/lib/apis/llm-configs.api.ts index 2f9608d2f..53860b4dd 100644 --- a/surfsense_web/lib/apis/llm-configs.api.ts +++ b/surfsense_web/lib/apis/llm-configs.api.ts @@ -1,4 +1,4 @@ -import { CreateLLMConfig, LLMConfig, UpdateLLMConfig } from "@/hooks/use-llm-configs"; +import type { CreateLLMConfig, LLMConfig, UpdateLLMConfig } from "@/hooks/use-llm-configs"; export const fetchLLMConfigs = async (searchSpaceId: number, authToken: string) => { const response = await fetch( diff --git a/surfsense_web/lib/apis/search-source-connectors.api.ts b/surfsense_web/lib/apis/search-source-connectors.api.ts index c84d0be20..98ac31284 100644 --- a/surfsense_web/lib/apis/search-source-connectors.api.ts +++ b/surfsense_web/lib/apis/search-source-connectors.api.ts @@ -1,4 +1,4 @@ -import { Connector, CreateConnectorRequest } from "@/hooks/use-connectors"; +import type { Connector, CreateConnectorRequest } from "@/hooks/use-connectors"; export const createConnector = async ( data: CreateConnectorRequest, diff --git a/surfsense_web/lib/error.ts b/surfsense_web/lib/error.ts index 4e8ab0e9d..c8d8283c8 100644 --- a/surfsense_web/lib/error.ts +++ b/surfsense_web/lib/error.ts @@ -1,30 +1,40 @@ export class AppError extends Error { - constructor(message: string) { + status?: number; + statusText?: string; + constructor(message: string, status?: number, statusText?: string) { super(message); - this.name = this.constructor.name; + this.name = this.constructor.name; // User friendly + this.status = status; + this.statusText = statusText; // Dev friendly } } export class NetworkError extends AppError { - constructor(message: string) { - super(message); + constructor(message: string, status?: number, statusText?: string) { + super(message, status, statusText); } } export class ValidationError extends AppError { - constructor(message: string) { - super(message); + constructor(message: string, status?: number, statusText?: string) { + super(message, status, statusText); } } export class AuthenticationError extends AppError { - constructor(message: string) { - super(message); + constructor(message: string, status?: number, statusText?: string) { + super(message, status, statusText); } } export class AuthorizationError extends AppError { - constructor(message: string) { - super(message); + constructor(message: string, status?: number, statusText?: string) { + super(message, status, statusText); + } +} + +export class NotFoundError extends AppError { + constructor(message: string, status?: number, statusText?: string) { + super(message, status, statusText); } } diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 6b9b2df04..cb3a26f62 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -3,4 +3,7 @@ export const cacheKeys = { chats: (searchSpaceId: string) => ["active-search-space", "chats", searchSpaceId] as const, activeChat: (chatId: string) => ["active-search-space", "active-chat", chatId] as const, }, + auth: { + user: ["auth", "user"] as const, + }, }; diff --git a/surfsense_web/lib/utils.ts b/surfsense_web/lib/utils.ts index e8b097d15..18e665cfb 100644 --- a/surfsense_web/lib/utils.ts +++ b/surfsense_web/lib/utils.ts @@ -1,4 +1,4 @@ -import { Message } from "@ai-sdk/react"; +import type { Message } from "@ai-sdk/react"; import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; From b35a5aa5897d8ff189a976f38bdf2530799785c1 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Sat, 15 Nov 2025 02:57:41 +0200 Subject: [PATCH 21/26] update login form --- .../app/(home)/login/LocalLoginForm.tsx | 84 +++++++++---------- surfsense_web/contracts/types/auth.types.ts | 5 +- surfsense_web/lib/apis/auth-api.service.ts | 19 ++++- surfsense_web/lib/apis/base-api.service.ts | 39 ++------- 4 files changed, 67 insertions(+), 80 deletions(-) diff --git a/surfsense_web/app/(home)/login/LocalLoginForm.tsx b/surfsense_web/app/(home)/login/LocalLoginForm.tsx index cd5f43b48..0157c9faf 100644 --- a/surfsense_web/app/(home)/login/LocalLoginForm.tsx +++ b/surfsense_web/app/(home)/login/LocalLoginForm.tsx @@ -1,4 +1,5 @@ "use client"; +import { useAtom } from "jotai"; import { Eye, EyeOff } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; @@ -6,7 +7,9 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; 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"; export function LocalLoginForm() { const t = useTranslations("auth"); @@ -14,11 +17,16 @@ export function LocalLoginForm() { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); - const [error, setError] = useState(null); - const [errorTitle, setErrorTitle] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<{ + title: string | null; + message: string | null; + }>({ + title: null, + message: null, + }); const [authType, setAuthType] = useState(null); const router = useRouter(); + const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom); useEffect(() => { // Get the auth type from environment variables @@ -27,36 +35,17 @@ export function LocalLoginForm() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - setIsLoading(true); - setError(null); // Clear any previous errors - setErrorTitle(null); + setError({ title: null, message: null }); // Clear any previous errors // Show loading toast const loadingToast = toast.loading(tCommon("loading")); try { - // Create form data for the API request - const formData = new URLSearchParams(); - formData.append("username", username); - formData.append("password", password); - formData.append("grant_type", "password"); - - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/login`, - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: formData.toString(), - } - ); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.detail || `HTTP ${response.status}`); - } + const data = await login({ + username, + password, + grant_type: "password", + }); // Success toast toast.success(t("login_success"), { @@ -70,6 +59,16 @@ export function LocalLoginForm() { router.push(`/auth/callback?token=${data.access_token}`); }, 500); } catch (err) { + if (err instanceof ValidationError) { + setError({ title: err.name, message: err.message }); + toast.error(err.name, { + id: loadingToast, + description: err.message, + duration: 6000, + }); + return; + } + // Use auth-errors utility to get proper error details let errorCode = "UNKNOWN_ERROR"; @@ -83,8 +82,10 @@ export function LocalLoginForm() { const errorDetails = getAuthErrorDetails(errorCode); // Set persistent error display - setErrorTitle(errorDetails.title); - setError(errorDetails.description); + setError({ + title: errorDetails.title, + message: errorDetails.description, + }); // Show error toast with conditional retry action const toastOptions: any = { @@ -102,8 +103,6 @@ export function LocalLoginForm() { } toast.error(errorDetails.title, toastOptions); - } finally { - setIsLoading(false); } }; @@ -112,7 +111,7 @@ export function LocalLoginForm() {
{/* Error Display */} - {error && errorTitle && ( + {error && error.title && (
-

{errorTitle}

-

{error}

+

{error.title}

+

{error.message}

@@ -209,11 +207,11 @@ export function LocalLoginForm() { value={password} onChange={(e) => setPassword(e.target.value)} className={`mt-1 block w-full rounded-md border pr-10 px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${ - error + error.title ? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700" }`} - disabled={isLoading} + disabled={isLoggingIn} /> diff --git a/surfsense_web/contracts/types/auth.types.ts b/surfsense_web/contracts/types/auth.types.ts index 059f18df8..62c128886 100644 --- a/surfsense_web/contracts/types/auth.types.ts +++ b/surfsense_web/contracts/types/auth.types.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const loginRequest = z.object({ - email: z.string().email("Invalid email address"), + username: z.string(), password: z.string().min(3, "Password must be at least 3 characters"), grant_type: z.string().optional(), }); @@ -11,7 +11,8 @@ export const loginResponse = z.object({ token_type: z.string(), }); -export const registerRequest = loginRequest.omit({ grant_type: true }).extend({ +export const registerRequest = loginRequest.omit({ grant_type: true, username: true }).extend({ + email: z.string().email("Invalid email address"), is_active: z.boolean().optional(), is_superuser: z.boolean().optional(), is_verified: z.boolean().optional(), diff --git a/surfsense_web/lib/apis/auth-api.service.ts b/surfsense_web/lib/apis/auth-api.service.ts index 0689487ba..547b9e5e0 100644 --- a/surfsense_web/lib/apis/auth-api.service.ts +++ b/surfsense_web/lib/apis/auth-api.service.ts @@ -19,11 +19,20 @@ export class AuthApiService { // Format a user frendly error message const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); - throw new ValidationError(`Invalid request: ${errorMessage}`, undefined, "VALLIDATION_ERROR"); + throw new ValidationError(`Invalid request: ${errorMessage}`); } - return baseApiService.post(`/auth/jwt/login`, parsedRequest.data, loginResponse, { - contentType: "application/x-www-form-urlencoded", + // Create form data for the API request + const formData = new URLSearchParams(); + formData.append("username", request.username); + formData.append("password", request.password); + formData.append("grant_type", "password"); + + return baseApiService.post(`/auth/jwt/login`, loginResponse, { + body: formData.toString(), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, }); }; @@ -39,7 +48,9 @@ export class AuthApiService { throw new ValidationError(`Invalid request: ${errorMessage}`); } - return baseApiService.post(`/auth/register`, parsedRequest.data, registerResponse); + return baseApiService.post(`/auth/register`, registerResponse, { + body: parsedRequest.data, + }); }; } diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index 0cd410618..ea7b1cb7d 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -20,7 +20,7 @@ export class BaseApiService { bearerToken: string; baseUrl: string; - noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register"]; + noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"]; // Add more endpoints as needed constructor(bearerToken: string, baseUrl: string) { this.bearerToken = bearerToken; @@ -33,7 +33,6 @@ export class BaseApiService { async request( url: string, - body?: any, responseSchema?: z.ZodSchema, options?: RequestOptions ): Promise { @@ -55,25 +54,6 @@ export class BaseApiService { }, }; - // biome-ignore lint/suspicious: Unknown - let requestBody; - - // Serialize body - if (body) { - if (mergedOptions.headers?.["Content-Type"].toLocaleLowerCase() === "application/json") { - requestBody = JSON.stringify(body); - } - - if ( - mergedOptions.headers?.["Content-Type"].toLocaleLowerCase() === - "application/x-www-form-urlencoded" - ) { - requestBody = new URLSearchParams(body); - } - - mergedOptions.body = requestBody; - } - if (!this.baseUrl) { throw new AppError("Base URL is not set."); } @@ -161,7 +141,7 @@ export class BaseApiService { responseSchema?: z.ZodSchema, options?: Omit ) { - return this.request(url, undefined, responseSchema, { + return this.request(url, responseSchema, { ...options, method: "GET", }); @@ -169,37 +149,34 @@ export class BaseApiService { async post( url: string, - body?: any, responseSchema?: z.ZodSchema, options?: Omit ) { - return this.request(url, body, responseSchema, { - ...options, + return this.request(url, responseSchema, { method: "POST", + ...options, }); } async put( url: string, - body?: any, responseSchema?: z.ZodSchema, options?: Omit ) { - return this.request(url, body, responseSchema, { - ...options, + return this.request(url, responseSchema, { method: "PUT", + ...options, }); } async delete( url: string, - body?: any, responseSchema?: z.ZodSchema, options?: Omit ) { - return this.request(url, body, responseSchema, { - ...options, + return this.request(url, responseSchema, { method: "DELETE", + ...options, }); } } From 776660f5e36a77d8d1a52f6e971597389eb24d8a Mon Sep 17 00:00:00 2001 From: thierryverse Date: Sat, 15 Nov 2025 03:28:33 +0200 Subject: [PATCH 22/26] update chat api service --- .../[search_space_id]/chats/chats-client.tsx | 4 +- .../atoms/chats/chat-mutation.atoms.ts | 10 +- .../atoms/chats/chat-querie.atoms.ts | 12 +- .../lib/apis/chats-api.service.api.ts | 103 -------------- surfsense_web/lib/apis/chats-api.service.ts | 130 ++++++++++++++++++ 5 files changed, 137 insertions(+), 122 deletions(-) delete mode 100644 surfsense_web/lib/apis/chats-api.service.api.ts create mode 100644 surfsense_web/lib/apis/chats-api.service.ts diff --git a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx index 1cfec056a..822b749e4 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx @@ -55,14 +55,14 @@ import { export interface Chat { created_at: string; id: number; - type: "DOCUMENT" | "CHAT"; + type: "QNA"; title: string; search_space_id: number; state_version: number; } export interface ChatDetails { - type: "DOCUMENT" | "CHAT"; + type: "QNA"; title: string; initial_connectors: string[]; messages: any[]; diff --git a/surfsense_web/atoms/chats/chat-mutation.atoms.ts b/surfsense_web/atoms/chats/chat-mutation.atoms.ts index a6dd1c9dc..fee69f9f7 100644 --- a/surfsense_web/atoms/chats/chat-mutation.atoms.ts +++ b/surfsense_web/atoms/chats/chat-mutation.atoms.ts @@ -2,6 +2,7 @@ import { atomWithMutation } from "jotai-tanstack-query"; import { toast } from "sonner"; import type { Chat } from "@/app/dashboard/[search_space_id]/chats/chats-client"; import { deleteChat } from "@/lib/apis/chats.api"; +import { chatApiService } from "@/lib/apis/chats-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { queryClient } from "@/lib/query-client/client"; import { activeSearchSpaceIdAtom } from "../seach-spaces/seach-space-queries.atom"; @@ -14,14 +15,7 @@ export const deleteChatMutationAtom = atomWithMutation((get) => { mutationKey: cacheKeys.activeSearchSpace.chats(searchSpaceId ?? ""), enabled: !!searchSpaceId && !!authToken, mutationFn: async (chatId: number) => { - if (!authToken) { - throw new Error("No authentication token found"); - } - if (!searchSpaceId) { - throw new Error("No search space id found"); - } - - return deleteChat(chatId, authToken); + return chatApiService.deleteChat({ id: chatId }); }, onSuccess: (_, chatId) => { diff --git a/surfsense_web/atoms/chats/chat-querie.atoms.ts b/surfsense_web/atoms/chats/chat-querie.atoms.ts index 3603bf9bb..9ff913432 100644 --- a/surfsense_web/atoms/chats/chat-querie.atoms.ts +++ b/surfsense_web/atoms/chats/chat-querie.atoms.ts @@ -4,6 +4,7 @@ import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats- import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; import { fetchChatDetails, fetchChatsBySearchSpace } from "@/lib/apis/chats.api"; +import { chatApiService } from "@/lib/apis/chats-api.service"; import { getPodcastByChatId } from "@/lib/apis/podcasts.api"; import { cacheKeys } from "@/lib/query-client/cache-keys"; @@ -32,7 +33,7 @@ export const activeChatAtom = atomWithQuery((get) => { const [podcast, chatDetails] = await Promise.all([ getPodcastByChatId(activeChatId, authToken), - fetchChatDetails(activeChatId, authToken), + chatApiService.getChatDetails({ id: Number(activeChatId) }), ]); return { chatId: activeChatId, chatDetails, podcast }; @@ -48,14 +49,7 @@ export const activeSearchSpaceChatsAtom = atomWithQuery((get) => { queryKey: cacheKeys.activeSearchSpace.chats(searchSpaceId ?? ""), enabled: !!searchSpaceId && !!authToken, queryFn: async () => { - if (!authToken) { - throw new Error("No authentication token found"); - } - if (!searchSpaceId) { - throw new Error("No search space id found"); - } - - return fetchChatsBySearchSpace(searchSpaceId, authToken); + return chatApiService.getChatsBySearchSpace({ search_space_id: Number(searchSpaceId) }); }, }; }); diff --git a/surfsense_web/lib/apis/chats-api.service.api.ts b/surfsense_web/lib/apis/chats-api.service.api.ts deleted file mode 100644 index ae7aa9837..000000000 --- a/surfsense_web/lib/apis/chats-api.service.api.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Message } from "@ai-sdk/react"; -import { z } from "zod"; -import { ResearchMode } from "@/components/chat/types"; -import { - type CreateChatRequest, - chatDetails, - chatSummary, - createChatRequest, - type DeleteChatRequest, - deleteChatRequest, - deleteChatResponse, - type GetChatDetailsRequest, - type GetChatsBySearchSpaceRequest, - getChatDetailsRequest, - getChatsBySearchSpaceRequest, - type UpdateChatRequest, - updateChatRequest, -} from "@/contracts/types/chat.types"; -import { baseApiService } from "./base-api.service"; - -export class ChatApiService { - fetchChatDetails = async (request: GetChatDetailsRequest) => { - // Validate the request - const parsedRequest = getChatDetailsRequest.safeParse(request); - - if (!parsedRequest.success) { - throw new Error(`Invalid request: ${parsedRequest.error.message}`); - } - - return baseApiService.get(`/api/v1/chats/${request.id}`, chatDetails); - }; - - fetchChatsBySearchSpace = async (request: GetChatsBySearchSpaceRequest) => { - // Validate the request - const parsedRequest = getChatsBySearchSpaceRequest.safeParse(request); - - if (!parsedRequest.success) { - throw new Error(`Invalid request: ${parsedRequest.error.message}`); - } - - return baseApiService.get( - `/api/v1/chats?search_space_id=${request.search_space_id}`, - z.array(chatSummary) - ); - }; - - deleteChat = async (request: DeleteChatRequest) => { - // Validate the request - const parsedRequest = deleteChatRequest.safeParse(request); - - if (!parsedRequest.success) { - throw new Error(`Invalid request: ${parsedRequest.error.message}`); - } - - return baseApiService.delete(`/api/v1/chats/${request.id}`, undefined, deleteChatResponse); - }; - - createChat = async (request: CreateChatRequest) => { - // Validate the request - const parsedRequest = createChatRequest.safeParse(request); - - if (!parsedRequest.success) { - throw new Error(`Invalid request: ${parsedRequest.error.message}`); - } - - const { type, title, initial_connectors, messages, search_space_id } = parsedRequest.data; - - return baseApiService.post( - `/api/v1/chats`, - { - type, - title, - initial_connectors, - messages, - search_space_id, - }, - chatSummary - ); - }; - - updateChat = async (request: UpdateChatRequest) => { - // Validate the request - const parsedRequest = updateChatRequest.safeParse(request); - - if (!parsedRequest.success) { - throw new Error(`Invalid request: ${parsedRequest.error.message}`); - } - - const { type, title, initial_connectors, messages, search_space_id, id } = parsedRequest.data; - - return baseApiService.put( - `/api/v1/chats/${id}`, - { - type, - title, - initial_connectors, - messages, - search_space_id, - }, - chatSummary - ); - }; -} diff --git a/surfsense_web/lib/apis/chats-api.service.ts b/surfsense_web/lib/apis/chats-api.service.ts new file mode 100644 index 000000000..69cfab831 --- /dev/null +++ b/surfsense_web/lib/apis/chats-api.service.ts @@ -0,0 +1,130 @@ +import { z } from "zod"; +import { + type CreateChatRequest, + chatDetails, + chatSummary, + createChatRequest, + type DeleteChatRequest, + deleteChatRequest, + deleteChatResponse, + type GetChatDetailsRequest, + type GetChatsBySearchSpaceRequest, + getChatDetailsRequest, + getChatsBySearchSpaceRequest, + type UpdateChatRequest, + updateChatRequest, +} from "@/contracts/types/chat.types"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +export class ChatApiService { + getChatDetails = async (request: GetChatDetailsRequest) => { + // Validate the request + const parsedRequest = getChatDetailsRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.get(`/api/v1/chats/${request.id}`, chatDetails); + }; + + getChatsBySearchSpace = async (request: GetChatsBySearchSpaceRequest) => { + // Validate the request + const parsedRequest = getChatsBySearchSpaceRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.get( + `/api/v1/chats?search_space_id=${request.search_space_id}`, + z.array(chatSummary) + ); + }; + + deleteChat = async (request: DeleteChatRequest) => { + // Validate the request + const parsedRequest = deleteChatRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.delete(`/api/v1/chats/${request.id}`, deleteChatResponse); + }; + + createChat = async (request: CreateChatRequest) => { + // Validate the request + const parsedRequest = createChatRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + const { type, title, initial_connectors, messages, search_space_id } = parsedRequest.data; + + return baseApiService.post( + `/api/v1/chats`, + + chatSummary, + { + body: { + type, + title, + initial_connectors, + messages, + search_space_id, + }, + } + ); + }; + + updateChat = async (request: UpdateChatRequest) => { + // Validate the request + const parsedRequest = updateChatRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + const { type, title, initial_connectors, messages, search_space_id, id } = parsedRequest.data; + + return baseApiService.put( + `/api/v1/chats/${id}`, + + chatSummary, + { + body: { + type, + title, + initial_connectors, + messages, + search_space_id, + }, + } + ); + }; +} + +export const chatApiService = new ChatApiService(); From 62df67b93b2d3843276351bda11bcd9f986659c2 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Sat, 15 Nov 2025 03:42:42 +0200 Subject: [PATCH 23/26] clean up --- .../atoms/chats/chat-mutation.atoms.ts | 1 - .../atoms/chats/chat-querie.atoms.ts | 1 - .../components/dashboard-breadcrumb.tsx | 25 +-- surfsense_web/lib/apis/chats.api.ts | 157 ------------------ 4 files changed, 5 insertions(+), 179 deletions(-) delete mode 100644 surfsense_web/lib/apis/chats.api.ts diff --git a/surfsense_web/atoms/chats/chat-mutation.atoms.ts b/surfsense_web/atoms/chats/chat-mutation.atoms.ts index fee69f9f7..da0795afa 100644 --- a/surfsense_web/atoms/chats/chat-mutation.atoms.ts +++ b/surfsense_web/atoms/chats/chat-mutation.atoms.ts @@ -1,7 +1,6 @@ import { atomWithMutation } from "jotai-tanstack-query"; import { toast } from "sonner"; import type { Chat } from "@/app/dashboard/[search_space_id]/chats/chats-client"; -import { deleteChat } from "@/lib/apis/chats.api"; import { chatApiService } from "@/lib/apis/chats-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { queryClient } from "@/lib/query-client/client"; diff --git a/surfsense_web/atoms/chats/chat-querie.atoms.ts b/surfsense_web/atoms/chats/chat-querie.atoms.ts index 9ff913432..8ea668eb4 100644 --- a/surfsense_web/atoms/chats/chat-querie.atoms.ts +++ b/surfsense_web/atoms/chats/chat-querie.atoms.ts @@ -3,7 +3,6 @@ import { atomWithQuery } from "jotai-tanstack-query"; import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; -import { fetchChatDetails, fetchChatsBySearchSpace } from "@/lib/apis/chats.api"; import { chatApiService } from "@/lib/apis/chats-api.service"; import { getPodcastByChatId } from "@/lib/apis/podcasts.api"; import { cacheKeys } from "@/lib/query-client/cache-keys"; diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx index 2b032f8fd..65e885ead 100644 --- a/surfsense_web/components/dashboard-breadcrumb.tsx +++ b/surfsense_web/components/dashboard-breadcrumb.tsx @@ -1,9 +1,10 @@ "use client"; +import { useAtomValue } from "jotai"; import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; -import React, { useEffect, useState } from "react"; -import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; +import React, { useEffect } from "react"; +import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms"; import { Breadcrumb, BreadcrumbItem, @@ -13,7 +14,6 @@ import { BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { useSearchSpace } from "@/hooks/use-search-space"; -import { fetchChatDetails } from "@/lib/apis/chats.api"; interface BreadcrumbItemInterface { label: string; @@ -23,13 +23,10 @@ interface BreadcrumbItemInterface { export function DashboardBreadcrumb() { const t = useTranslations("breadcrumb"); const pathname = usePathname(); - const [chatDetails, setChatDetails] = useState(null); - + 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; - const chatId = - segments[0] === "dashboard" && segments[2] === "researcher" && segments[3] ? segments[3] : null; // Fetch search space details if we have an ID const { searchSpace } = useSearchSpace({ @@ -37,18 +34,6 @@ export function DashboardBreadcrumb() { autoFetch: !!searchSpaceId, }); - // Fetch chat details if we have a chat ID - useEffect(() => { - if (chatId) { - const token = localStorage.getItem("surfsense_bearer_token"); - if (token) { - fetchChatDetails(chatId, token).then(setChatDetails); - } - } else { - setChatDetails(null); - } - }, [chatId]); - // Parse the pathname to create breadcrumb items const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => { const segments = path.split("/").filter(Boolean); @@ -125,7 +110,7 @@ export function DashboardBreadcrumb() { // Handle researcher sub-sections (chat IDs) if (section === "researcher") { // Use the actual chat title if available, otherwise fall back to the ID - const chatLabel = chatDetails?.title || subSection; + const chatLabel = activeChatState?.chatDetails?.title || subSection; breadcrumbs.push({ label: t("researcher"), href: `/dashboard/${segments[1]}/researcher`, diff --git a/surfsense_web/lib/apis/chats.api.ts b/surfsense_web/lib/apis/chats.api.ts deleted file mode 100644 index fc98585d3..000000000 --- a/surfsense_web/lib/apis/chats.api.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { Message } from "@ai-sdk/react"; -import type { Chat, ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; -import type { ResearchMode } from "@/components/chat/types"; - -export const fetchChatDetails = async ( - chatId: string, - authToken: string -): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number(chatId)}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - } - ); - - if (!response.ok) { - throw new Error(`Failed to fetch chat details: ${response.statusText}`); - } - - return await response.json(); -}; - -export const fetchChatsBySearchSpace = async ( - searchSpaceId: string, - authToken: string -): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats?search_space_id=${searchSpaceId}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - } - ); - if (!response.ok) { - throw new Error(`Failed to fetch chats: ${response.statusText}`); - } - - return await response.json(); -}; - -export const deleteChat = async (chatId: number, authToken: string) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${chatId}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${authToken}`, - }, - } - ); - - if (!response.ok) { - throw new Error(`Failed to delete chat: ${response.statusText}`); - } - - return await response.json(); -}; - -export const createChat = async ( - initialMessage: string, - researchMode: ResearchMode, - selectedConnectors: string[], - authToken: string, - searchSpaceId: number -): Promise => { - const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - body: JSON.stringify({ - type: researchMode, - title: "Untitled Chat", - initial_connectors: selectedConnectors, - messages: [ - { - role: "user", - content: initialMessage, - }, - ], - search_space_id: searchSpaceId, - }), - }); - - if (!response.ok) { - throw new Error(`Failed to create chat: ${response.statusText}`); - } - - return await response.json(); -}; - -export const updateChat = async ( - chatId: string, - messages: Message[], - researchMode: ResearchMode, - selectedConnectors: string[], - authToken: string, - searchSpaceId: number -) => { - const userMessages = messages.filter((msg) => msg.role === "user"); - if (userMessages.length === 0) return; - - const title = userMessages[0].content; - - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${Number(chatId)}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - body: JSON.stringify({ - type: researchMode, - title: title, - initial_connectors: selectedConnectors, - messages: messages, - search_space_id: searchSpaceId, - }), - } - ); - - if (!response.ok) { - throw new Error(`Failed to update chat: ${response.statusText}`); - } -}; - -export const fetchChats = async ( - searchSpaceId: string, - limit: number, - skip: number, - authToken: string -) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats?limit=${limit}&skip=${skip}&search_space_id=${searchSpaceId}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "GET", - } - ); - - if (!response.ok) { - throw new Error(`Failed to fetch chats: ${response.status}`); - } - - return await response.json(); -}; From 981e3a74e79c79392a90b5d23a3a25b84048eca3 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Sat, 15 Nov 2025 03:46:22 +0200 Subject: [PATCH 24/26] clean up --- surfsense_web/lib/apis/documents.api.ts | 260 ------------------ surfsense_web/lib/apis/llm-configs.api.ts | 90 ------ surfsense_web/lib/apis/podcasts.api.ts | 74 ----- .../lib/apis/search-source-connectors.api.ts | 107 ------- surfsense_web/lib/apis/search-spaces.api.ts | 98 ------- 5 files changed, 629 deletions(-) delete mode 100644 surfsense_web/lib/apis/documents.api.ts delete mode 100644 surfsense_web/lib/apis/llm-configs.api.ts delete mode 100644 surfsense_web/lib/apis/podcasts.api.ts delete mode 100644 surfsense_web/lib/apis/search-source-connectors.api.ts delete mode 100644 surfsense_web/lib/apis/search-spaces.api.ts diff --git a/surfsense_web/lib/apis/documents.api.ts b/surfsense_web/lib/apis/documents.api.ts deleted file mode 100644 index 02975d2fd..000000000 --- a/surfsense_web/lib/apis/documents.api.ts +++ /dev/null @@ -1,260 +0,0 @@ -import type { DocumentWithChunks } from "@/hooks/use-document-by-chunk"; -import type { DocumentTypeCount } from "@/hooks/use-document-types"; -import { normalizeListResponse } from "../pagination"; - -export const uploadDocument = async (formData: FormData, authToken: string) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/fileupload`, - { - method: "POST", - headers: { - Authorization: `Bearer ${authToken}`, - }, - body: formData, - } - ); - - if (!response.ok) { - throw new Error("Failed to upload document"); - } - - return await response.json(); -}; - -export const createDocument = async (request: { - documentType: string; - content: any; - searchSpaceId: number; - authToken: string; -}) => { - const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${request.authToken}`, - }, - body: JSON.stringify(request), - }); - - if (!response.ok) { - throw new Error("Failed to process document"); - } - - return await response.json(); -}; - -export const fetchDocumentByChunk = async (chunkId: number, authToken: string) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/by-chunk/${chunkId}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - "Content-Type": "application/json", - }, - method: "GET", - } - ); - - if (!response.ok) { - const errorText = await response.text(); - let errorMessage = "Failed to fetch document"; - - try { - const errorData = JSON.parse(errorText); - errorMessage = errorData.detail || errorMessage; - } catch { - // If parsing fails, use default message - } - - if (response.status === 404) { - errorMessage = "Chunk not found or you don't have access to it"; - } - throw new Error(errorMessage); - } - - const data: DocumentWithChunks = await response.json(); - - return data; -}; - -export const fetchDocumentTypes = async (authToken: string) => { - if (!authToken) { - throw new Error("No authentication token found"); - } - - // Build URL with optional search_space_id query parameter - const url = new URL( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/type-counts` - ); - - const response = await fetch(url.toString(), { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch document types: ${response.statusText}`); - } - - const data = await response.json(); - - // Convert the object to an array of DocumentTypeCount - const typeCounts: DocumentTypeCount[] = Object.entries(data).map(([type, count]) => ({ - type, - count: count as number, - })); - - return typeCounts; -}; - -export const fetchDocuments = async ( - searchSpaceId: number, - authToken: string, - fetchPage?: number, - fetchPageSize?: number, - fetchDocumentTypes?: string[] -) => { - // Build query params - const params = new URLSearchParams({ - search_space_id: searchSpaceId.toString(), - }); - - // // Use passed parameters or fall back to state/options - // const effectivePage = fetchPage !== undefined ? fetchPage : page; - // const effectivePageSize = - // fetchPageSize !== undefined ? fetchPageSize : pageSize; - // const effectiveDocumentTypes = - // fetchDocumentTypes !== undefined ? fetchDocumentTypes : documentTypes; - - // if (effectivePage !== undefined) { - // params.append("page", effectivePage.toString()); - // } - // if (effectivePageSize !== undefined) { - // params.append("page_size", effectivePageSize.toString()); - // } - // if (effectiveDocumentTypes && effectiveDocumentTypes.length > 0) { - // params.append("document_types", effectiveDocumentTypes.join(",")); - // } - - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents?${params.toString()}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "GET", - } - ); - - if (!response.ok) { - throw new Error("Failed to fetch documents"); - } - - const data = await response.json(); - return normalizeListResponse(data); -}; - -export const searchDocuments = async ( - searchSpaceId: number, - authToken: string, - searchQuery: string, - fetchPage?: number, - fetchPageSize?: number, - fetchDocumentTypes?: string[] -) => { - // if (!searchQuery.trim()) { - // // If search is empty, fetch all documents - // // return fetchDocuments(fetchPage, fetchPageSize, fetchDocumentTypes); - // } - - // Build query params - const params = new URLSearchParams({ - search_space_id: searchSpaceId.toString(), - title: searchQuery, - }); - - // // Use passed parameters or fall back to state/options - // const effectivePage = fetchPage !== undefined ? fetchPage : page; - // const effectivePageSize = fetchPageSize !== undefined ? fetchPageSize : pageSize; - // const effectiveDocumentTypes = - // fetchDocumentTypes !== undefined ? fetchDocumentTypes : documentTypes; - - // if (effectivePage !== undefined) { - // params.append("page", effectivePage.toString()); - // } - // if (effectivePageSize !== undefined) { - // params.append("page_size", effectivePageSize.toString()); - // } - // if (effectiveDocumentTypes && effectiveDocumentTypes.length > 0) { - // params.append("document_types", effectiveDocumentTypes.join(",")); - // } - - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/search?${params.toString()}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "GET", - } - ); - - if (!response.ok) { - throw new Error("Failed to search documents"); - } - - const data = await response.json(); - const normalized = normalizeListResponse(data); - return normalized; -}; - -export const deleteDocument = async (documentId: number, authToken: string) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "DELETE", - } - ); - - if (!response.ok) { - throw new Error("Failed to delete document"); - } - - return await response.json(); -}; - -export const getDocumentTypeCounts = async (searchSpaceId: number, authToken: string) => { - try { - const params = new URLSearchParams({ - search_space_id: searchSpaceId.toString(), - }); - - const response = await fetch( - `${ - process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL - }/api/v1/documents/type-counts?${params.toString()}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "GET", - } - ); - - if (!response.ok) { - throw new Error("Failed to fetch document type counts"); - } - - const counts = await response.json(); - return counts as Record; - } catch (err: any) { - console.error("Error fetching document type counts:", err); - return {}; - } -}; diff --git a/surfsense_web/lib/apis/llm-configs.api.ts b/surfsense_web/lib/apis/llm-configs.api.ts deleted file mode 100644 index 53860b4dd..000000000 --- a/surfsense_web/lib/apis/llm-configs.api.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { CreateLLMConfig, LLMConfig, UpdateLLMConfig } from "@/hooks/use-llm-configs"; - -export const fetchLLMConfigs = async (searchSpaceId: number, authToken: string) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs?search_space_id=${searchSpaceId}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "GET", - } - ); - - if (!response.ok) { - throw new Error("Failed to fetch LLM configurations"); - } - - return await response.json(); -}; - -export const createLLMConfig = async ( - config: CreateLLMConfig, - authToken: string -): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - body: JSON.stringify(config), - } - ); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to create LLM configuration"); - } - - const newConfig = await response.json(); - - return newConfig; -}; - -export const deleteLLMConfig = async (id: number, authToken: string): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs/${id}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${authToken}`, - }, - } - ); - - if (!response.ok) { - throw new Error("Failed to delete LLM configuration"); - } - - return await response.json(); -}; - -export const updateLLMConfig = async ( - id: number, - config: UpdateLLMConfig, - authToken: string -): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs/${id}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - body: JSON.stringify(config), - } - ); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to update LLM configuration"); - } - - const updatedConfig = await response.json(); - - return updatedConfig; -}; diff --git a/surfsense_web/lib/apis/podcasts.api.ts b/surfsense_web/lib/apis/podcasts.api.ts deleted file mode 100644 index beaa475ca..000000000 --- a/surfsense_web/lib/apis/podcasts.api.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; -import type { GeneratePodcastRequest } from "@/components/chat/ChatPanel/ChatPanelContainer"; - -export const getPodcastByChatId = async (chatId: string, authToken: string) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/by-chat/${Number(chatId)}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "GET", - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to fetch podcast"); - } - - return (await response.json()) as PodcastItem | null; -}; - -export const generatePodcast = async (request: GeneratePodcastRequest, authToken: string) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/generate/`, - { - method: "POST", - headers: { - Authorization: `Bearer ${authToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(request), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to generate podcast"); - } - - return await response.json(); -}; - -export const loadPodcast = async (podcast: PodcastItem, authToken: string) => { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 30000); - - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - signal: controller.signal, - } - ); - - if (!response.ok) { - throw new Error(`Failed to fetch audio stream: ${response.statusText}`); - } - - const blob = await response.blob(); - const objectUrl = URL.createObjectURL(blob); - return objectUrl; - } catch (error) { - if (error instanceof DOMException && error.name === "AbortError") { - throw new Error("Request timed out. Please try again."); - } - throw error; - } finally { - clearTimeout(timeoutId); - } -}; diff --git a/surfsense_web/lib/apis/search-source-connectors.api.ts b/surfsense_web/lib/apis/search-source-connectors.api.ts deleted file mode 100644 index 98ac31284..000000000 --- a/surfsense_web/lib/apis/search-source-connectors.api.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { Connector, CreateConnectorRequest } from "@/hooks/use-connectors"; - -export const createConnector = async ( - data: CreateConnectorRequest, - authToken: string -): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - body: JSON.stringify(data), - } - ); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to create connector"); - } - - return response.json(); -}; - -export const getConnectors = async ( - skip = 0, - limit = 100, - authToken: string -): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors?skip=${skip}&limit=${limit}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - } - ); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to fetch connectors"); - } - - return response.json(); -}; - -export const getConnector = async (connectorId: number, authToken: string): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - } - ); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to fetch connector"); - } - - return response.json(); -}; - -export const updateConnector = async ( - connectorId: number, - data: CreateConnectorRequest, - authToken: string -): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - body: JSON.stringify(data), - } - ); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to update connector"); - } - - return response.json(); -}; - -export const deleteConnector = async (connectorId: number, authToken: string): Promise => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${authToken}`, - }, - } - ); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to delete connector"); - } -}; diff --git a/surfsense_web/lib/apis/search-spaces.api.ts b/surfsense_web/lib/apis/search-spaces.api.ts deleted file mode 100644 index d63273f9d..000000000 --- a/surfsense_web/lib/apis/search-spaces.api.ts +++ /dev/null @@ -1,98 +0,0 @@ -export const fetchSearchSpaces = async () => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, - { - headers: { - Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`, - }, - method: "GET", - } - ); - - if (!response.ok) { - throw new Error("Not authenticated"); - } - - return await response.json(); -}; - -export const deleteSearchSpace = async (id: number) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${id}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`, - }, - } - ); - - if (!response.ok) { - throw new Error("Failed to delete search space"); - } - - return await response.json(); -}; - -export const createSearchSpace = async (data: { name: string; description: string }) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`, - }, - body: JSON.stringify(data), - } - ); - - if (!response.ok) { - throw new Error("Failed to create search space"); - } - - return await response.json(); -}; - -export const fetchSearchSpace = async (searchSpaceId: string) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`, - { - headers: { - Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`, - }, - method: "GET", - } - ); - - if (response.status === 401) { - // Clear token and redirect to home - localStorage.removeItem("surfsense_bearer_token"); - window.location.href = "/"; - throw new Error("Unauthorized: Redirecting to login page"); - } - - if (!response.ok) { - throw new Error(`Failed to fetch search space: ${response.status}`); - } - - return await response.json(); -}; - -export const fetchSearchSpacePreferences = async (searchSpaceId: number, authToken: string) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/llm-preferences`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "GET", - } - ); - - if (!response.ok) { - throw new Error("Failed to fetch LLM preferences"); - } - - return await response.json(); -}; From a174c9ca576878d2ada0b34ba01818e57f4a0e8f Mon Sep 17 00:00:00 2001 From: thierryverse Date: Sat, 15 Nov 2025 04:12:38 +0200 Subject: [PATCH 25/26] removed unecessary side effects --- .../[search_space_id]/chats/chats-client.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx index 822b749e4..64661620d 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx @@ -125,18 +125,6 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) } }, [searchParams]); - useEffect(() => { - if (fetchError) { - console.error("Error fetching chats:", fetchError); - } - }, [fetchError]); - - useEffect(() => { - if (deleteError) { - console.error("Error deleting chat:", deleteError); - } - }, [deleteError]); - // Filter and sort chats based on search query, type, and sort order useEffect(() => { let result = [...(chats || [])]; From 6d70105f873c8d4229a1d4f988503f0c6bdfc587 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Mon, 17 Nov 2025 07:52:02 +0200 Subject: [PATCH 26/26] fix build error --- surfsense_web/lib/apis/podcasts.api.ts | 74 ++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 surfsense_web/lib/apis/podcasts.api.ts diff --git a/surfsense_web/lib/apis/podcasts.api.ts b/surfsense_web/lib/apis/podcasts.api.ts new file mode 100644 index 000000000..beaa475ca --- /dev/null +++ b/surfsense_web/lib/apis/podcasts.api.ts @@ -0,0 +1,74 @@ +import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; +import type { GeneratePodcastRequest } from "@/components/chat/ChatPanel/ChatPanelContainer"; + +export const getPodcastByChatId = async (chatId: string, authToken: string) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/by-chat/${Number(chatId)}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: "GET", + } + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || "Failed to fetch podcast"); + } + + return (await response.json()) as PodcastItem | null; +}; + +export const generatePodcast = async (request: GeneratePodcastRequest, authToken: string) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/generate/`, + { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + } + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || "Failed to generate podcast"); + } + + return await response.json(); +}; + +export const loadPodcast = async (podcast: PodcastItem, authToken: string) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + signal: controller.signal, + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch audio stream: ${response.statusText}`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + return objectUrl; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error("Request timed out. Please try again."); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +};