Merge pull request #493 from thierryverse/feat/add-jotai-tanstack

[Feat] Add jotai & tanstack for podcast and chats
This commit is contained in:
Rohan Verma 2025-11-20 00:19:01 -08:00 committed by GitHub
commit 5b99785b68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 700 additions and 739 deletions

2
.gitignore vendored
View file

@ -1,5 +1,5 @@
.flashrank_cache* .flashrank_cache*
podcasts/ ./surfsense_backend/podcasts/
.env .env
node_modules/ node_modules/
.ruff_cache/ .ruff_cache/

View file

@ -15,7 +15,7 @@ import { AnimatePresence, motion, type Variants } from "motion/react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms"; import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms";
import { activeSearchSpaceChatsAtom } from "@/atoms/chats/chat-querie.atoms"; import { chatsAtom } from "@/atoms/chats/chat-query.atoms";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; 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";
@ -103,11 +103,7 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
id: number; id: number;
title: string; title: string;
} | null>(null); } | null>(null);
const { const { isFetching: isFetchingChats, data: chats, error: fetchError } = useAtomValue(chatsAtom);
isFetching: isFetchingChats,
data: chats,
error: fetchError,
} = useAtomValue(activeSearchSpaceChatsAtom);
const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] = const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] =
useAtom(deleteChatMutationAtom); useAtom(deleteChatMutationAtom);
@ -161,7 +157,7 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
const handleDeleteChat = async () => { const handleDeleteChat = async () => {
if (!chatToDelete) return; if (!chatToDelete) return;
await deleteChat(chatToDelete.id); await deleteChat({ id: chatToDelete.id });
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setChatToDelete(null); setChatToDelete(null);

View file

@ -7,8 +7,7 @@ import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type React from "react"; import type React from "react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms"; import { activeChathatUIAtom, activeChatIdAtom } from "@/atoms/chats/ui.atoms";
import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom";
import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContainer"; import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContainer";
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";

View file

@ -1,12 +1,13 @@
"use client"; "use client";
import { format } from "date-fns"; import { format } from "date-fns";
import { useAtom, useAtomValue } from "jotai";
import { import {
Calendar, Calendar,
MoreHorizontal, MoreHorizontal,
Pause, Pause,
Play, Play,
Podcast, Podcast as PodcastIcon,
Search, Search,
SkipBack, SkipBack,
SkipForward, SkipForward,
@ -19,6 +20,8 @@ import { AnimatePresence, motion, type Variants } from "motion/react";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { deletePodcastMutationAtom } from "@/atoms/podcasts/podcast-mutation.atoms";
import { podcastsAtom } from "@/atoms/podcasts/podcast-query.atoms";
// UI Components // UI Components
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@ -46,16 +49,8 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import type { Podcast } from "@/contracts/types/podcast.types";
export interface PodcastItem { import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
id: number;
title: string;
created_at: string;
file_location: string;
podcast_transcript: any[];
search_space_id: number;
chat_state_version: number | null;
}
interface PodcastsPageClientProps { interface PodcastsPageClientProps {
searchSpaceId: string; searchSpaceId: string;
@ -85,10 +80,7 @@ const podcastCardVariants: Variants = {
const MotionCard = motion(Card); const MotionCard = motion(Card);
export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClientProps) { export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClientProps) {
const [podcasts, setPodcasts] = useState<PodcastItem[]>([]); const [filteredPodcasts, setFilteredPodcasts] = useState<Podcast[]>([]);
const [filteredPodcasts, setFilteredPodcasts] = useState<PodcastItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [sortOrder, setSortOrder] = useState<string>("newest"); const [sortOrder, setSortOrder] = useState<string>("newest");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@ -96,10 +88,9 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
id: number; id: number;
title: string; title: string;
} | null>(null); } | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Audio player state // Audio player state
const [currentPodcast, setCurrentPodcast] = useState<PodcastItem | null>(null); const [currentPodcast, setCurrentPodcast] = useState<Podcast | null>(null);
const [audioSrc, setAudioSrc] = useState<string | undefined>(undefined); const [audioSrc, setAudioSrc] = useState<string | undefined>(undefined);
const [isAudioLoading, setIsAudioLoading] = useState(false); const [isAudioLoading, setIsAudioLoading] = useState(false);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
@ -109,64 +100,39 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const currentObjectUrlRef = useRef<string | null>(null); const currentObjectUrlRef = useRef<string | null>(null);
const [{ isPending: isDeletingPodcast, mutateAsync: deletePodcast, error: deleteError }] =
useAtom(deletePodcastMutationAtom);
const {
data: podcasts,
isLoading: isFetchingPodcasts,
error: fetchError,
} = useAtomValue(podcastsAtom);
// Add podcast image URL constant // Add podcast image URL constant
const PODCAST_IMAGE_URL = const PODCAST_IMAGE_URL =
"https://static.vecteezy.com/system/resources/thumbnails/002/157/611/small_2x/illustrations-concept-design-podcast-channel-free-vector.jpg"; "https://static.vecteezy.com/system/resources/thumbnails/002/157/611/small_2x/illustrations-concept-design-podcast-channel-free-vector.jpg";
// Fetch podcasts from API
useEffect(() => { useEffect(() => {
const fetchPodcasts = async () => { if (isFetchingPodcasts) return;
try {
setIsLoading(true);
// Get token from localStorage if (fetchError) {
const token = localStorage.getItem("surfsense_bearer_token"); console.error("Error fetching podcasts:", fetchError);
setFilteredPodcasts([]);
return;
}
if (!token) { if (!podcasts) {
setError("Authentication token not found. Please log in again."); setFilteredPodcasts([]);
setIsLoading(false); return;
return; }
}
// Fetch all podcasts for this search space setFilteredPodcasts(podcasts);
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts`,
{
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 podcasts: ${response.status} ${errorData?.detail || ""}`
);
}
const data: PodcastItem[] = await response.json();
setPodcasts(data);
setFilteredPodcasts(data);
setError(null);
} catch (error) {
console.error("Error fetching podcasts:", error);
setError(error instanceof Error ? error.message : "Unknown error occurred");
setPodcasts([]);
setFilteredPodcasts([]);
} finally {
setIsLoading(false);
}
};
fetchPodcasts();
}, []); }, []);
// Filter and sort podcasts based on search query and sort order // Filter and sort podcasts based on search query and sort order
useEffect(() => { useEffect(() => {
if (!podcasts) return;
let result = [...podcasts]; let result = [...podcasts];
// Filter by search term // Filter by search term
@ -305,7 +271,7 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
}; };
// Play podcast - Fetch blob and set object URL // Play podcast - Fetch blob and set object URL
const playPodcast = async (podcast: PodcastItem) => { const playPodcast = async (podcast: Podcast) => {
// If the same podcast is selected, just toggle play/pause // If the same podcast is selected, just toggle play/pause
if (currentPodcast && currentPodcast.id === podcast.id) { if (currentPodcast && currentPodcast.id === podcast.id) {
togglePlayPause(); togglePlayPause();
@ -326,11 +292,6 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
setIsPlaying(false); setIsPlaying(false);
setIsAudioLoading(true); setIsAudioLoading(true);
const token = localStorage.getItem("surfsense_bearer_token");
if (!token) {
throw new Error("Authentication token not found.");
}
// Revoke previous object URL if exists (only after we've started the new request) // Revoke previous object URL if exists (only after we've started the new request)
if (currentObjectUrlRef.current) { if (currentObjectUrlRef.current) {
URL.revokeObjectURL(currentObjectUrlRef.current); URL.revokeObjectURL(currentObjectUrlRef.current);
@ -342,22 +303,11 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
try { try {
const response = await fetch( const response = await podcastsApiService.loadPodcast({
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`, request: { id: podcast.id },
{ controller,
headers: { });
Authorization: `Bearer ${token}`, const objectUrl = URL.createObjectURL(response);
},
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);
currentObjectUrlRef.current = objectUrl; currentObjectUrlRef.current = objectUrl;
// Set audio source // Set audio source
@ -388,38 +338,13 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
const handleDeletePodcast = async () => { const handleDeletePodcast = async () => {
if (!podcastToDelete) return; if (!podcastToDelete) return;
setIsDeleting(true);
try { try {
const token = localStorage.getItem("surfsense_bearer_token"); await deletePodcast({ id: podcastToDelete.id });
if (!token) {
setIsDeleting(false);
return;
}
const response = await fetch( // Close dialog
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastToDelete.id}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
if (!response.ok) {
throw new Error(`Failed to delete podcast: ${response.statusText}`);
}
// Close dialog and refresh podcasts
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setPodcastToDelete(null); setPodcastToDelete(null);
// Update local state by removing the deleted podcast
setPodcasts((prevPodcasts) =>
prevPodcasts.filter((podcast) => podcast.id !== podcastToDelete.id)
);
// If the current playing podcast is deleted, stop playback // If the current playing podcast is deleted, stop playback
if (currentPodcast && currentPodcast.id === podcastToDelete.id) { if (currentPodcast && currentPodcast.id === podcastToDelete.id) {
if (audioRef.current) { if (audioRef.current) {
@ -428,13 +353,9 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
setCurrentPodcast(null); setCurrentPodcast(null);
setIsPlaying(false); setIsPlaying(false);
} }
toast.success("Podcast deleted successfully");
} catch (error) { } catch (error) {
console.error("Error deleting podcast:", error); console.error("Error deleting podcast:", error);
toast.error(error instanceof Error ? error.message : "Failed to delete podcast"); toast.error(error instanceof Error ? error.message : "Failed to delete podcast");
} finally {
setIsDeleting(false);
} }
}; };
@ -483,7 +404,7 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
</div> </div>
{/* Status Messages */} {/* Status Messages */}
{isLoading && ( {isFetchingPodcasts && (
<div className="flex items-center justify-center h-40"> <div className="flex items-center justify-center h-40">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div> <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
@ -492,16 +413,16 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
</div> </div>
)} )}
{error && !isLoading && ( {fetchError && !isFetchingPodcasts && (
<div className="border border-destructive/50 text-destructive p-4 rounded-md"> <div className="border border-destructive/50 text-destructive p-4 rounded-md">
<h3 className="font-medium">Error loading podcasts</h3> <h3 className="font-medium">Error loading podcasts</h3>
<p className="text-sm">{error}</p> <p className="text-sm">{fetchError.message ?? "Failed to load podcasts"}</p>
</div> </div>
)} )}
{!isLoading && !error && filteredPodcasts.length === 0 && ( {!isFetchingPodcasts && !fetchError && filteredPodcasts.length === 0 && (
<div className="flex flex-col items-center justify-center h-40 gap-2 text-center"> <div className="flex flex-col items-center justify-center h-40 gap-2 text-center">
<Podcast className="h-8 w-8 text-muted-foreground" /> <PodcastIcon className="h-8 w-8 text-muted-foreground" />
<h3 className="font-medium">No podcasts found</h3> <h3 className="font-medium">No podcasts found</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{searchQuery {searchQuery
@ -512,7 +433,7 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
)} )}
{/* Podcast Grid */} {/* Podcast Grid */}
{!isLoading && !error && filteredPodcasts.length > 0 && ( {!isFetchingPodcasts && !fetchError && filteredPodcasts.length > 0 && (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div <motion.div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
@ -829,7 +750,7 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
duration: 2, duration: 2,
}} }}
> >
<Podcast className="h-6 w-6 text-primary" /> <PodcastIcon className="h-6 w-6 text-primary" />
</motion.div> </motion.div>
</div> </div>
@ -957,17 +878,17 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient
<Button <Button
variant="outline" variant="outline"
onClick={() => setDeleteDialogOpen(false)} onClick={() => setDeleteDialogOpen(false)}
disabled={isDeleting} disabled={isDeletingPodcast}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={handleDeletePodcast} onClick={handleDeletePodcast}
disabled={isDeleting} disabled={isDeletingPodcast}
className="gap-2" className="gap-2"
> >
{isDeleting ? ( {isDeletingPodcast ? (
<> <>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Deleting... Deleting...

View file

@ -1,31 +1,35 @@
"use client"; "use client";
import { type CreateMessage, type Message, useChat } from "@ai-sdk/react"; import { type CreateMessage, type Message, useChat } from "@ai-sdk/react";
import { useAtomValue } from "jotai";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { createChatMutationAtom, updateChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms";
import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
import { activeChatIdAtom } from "@/atoms/chats/ui.atoms";
import ChatInterface from "@/components/chat/ChatInterface"; import ChatInterface from "@/components/chat/ChatInterface";
import { useChatAPI, useChatState } from "@/hooks/use-chat"; import { useChatState } from "@/hooks/use-chat";
import { useDocumentTypes } from "@/hooks/use-document-types"; import { useDocumentTypes } from "@/hooks/use-document-types";
import type { Document } from "@/hooks/use-documents"; import type { Document } from "@/hooks/use-documents";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
export default function ResearcherPage() { export default function ResearcherPage() {
const { search_space_id, chat_id } = useParams(); const { search_space_id } = useParams();
const router = useRouter(); const router = useRouter();
const hasSetInitialConnectors = useRef(false); const hasSetInitialConnectors = useRef(false);
const activeChatId = useAtomValue(activeChatIdAtom);
const chatIdParam = Array.isArray(chat_id) ? chat_id[0] : chat_id; const { data: activeChatState, isFetching: isChatLoading } = useAtomValue(activeChatAtom);
const isNewChat = !chatIdParam; const { mutateAsync: createChat } = useAtomValue(createChatMutationAtom);
const { mutateAsync: updateChat } = useAtomValue(updateChatMutationAtom);
const isNewChat = !activeChatId;
// Reset the flag when chat ID changes // Reset the flag when chat ID changes
useEffect(() => { useEffect(() => {
hasSetInitialConnectors.current = false; hasSetInitialConnectors.current = false;
}, [chatIdParam]); }, [activeChatId]);
const { const {
token, token,
isLoading,
setIsLoading,
searchMode, searchMode,
setSearchMode, setSearchMode,
researchMode, researchMode,
@ -37,12 +41,7 @@ export default function ResearcherPage() {
setTopK, setTopK,
} = useChatState({ } = useChatState({
search_space_id: search_space_id as string, search_space_id: search_space_id as string,
chat_id: chatIdParam, chat_id: activeChatId ?? undefined,
});
const { fetchChatDetails, updateChat, createChat } = useChatAPI({
token,
search_space_id: search_space_id as string,
}); });
// Fetch all available sources (document types + live search connectors) // Fetch all available sources (document types + live search connectors)
@ -126,32 +125,64 @@ export default function ResearcherPage() {
message: Message | CreateMessage, message: Message | CreateMessage,
chatRequestOptions?: { data?: any } chatRequestOptions?: { data?: any }
) => { ) => {
const newChatId = await createChat(message.content, researchMode, selectedConnectors); const newChat = await createChat({
if (newChatId) { type: researchMode,
title: "Untitled Chat",
initial_connectors: selectedConnectors,
messages: [
{
role: "user",
content: message.content,
},
],
search_space_id: Number(search_space_id),
});
if (newChat) {
// Store chat state before navigation // Store chat state before navigation
storeChatState(search_space_id as string, newChatId, { storeChatState(search_space_id as string, String(newChat.id), {
selectedDocuments, selectedDocuments,
selectedConnectors, selectedConnectors,
searchMode, searchMode,
researchMode, researchMode,
topK, topK,
}); });
router.replace(`/dashboard/${search_space_id}/researcher/${newChatId}`); router.replace(`/dashboard/${search_space_id}/researcher/${newChat.id}`);
} }
return newChatId; return String(newChat.id);
}; };
useEffect(() => { useEffect(() => {
if (token && !isNewChat && chatIdParam) { if (token && !isNewChat && activeChatId) {
setIsLoading(true); const chatData = activeChatState?.chatDetails;
loadChatData(chatIdParam); if (!chatData) return;
// Update configuration from chat data
// researchMode is always "QNA", no need to set from chat data
if (chatData.initial_connectors && Array.isArray(chatData.initial_connectors)) {
setSelectedConnectors(chatData.initial_connectors);
}
// Load existing messages
if (chatData.messages && Array.isArray(chatData.messages)) {
if (chatData.messages.length === 1 && chatData.messages[0].role === "user") {
// Single user message - append to trigger LLM response
handler.append({
role: "user",
content: chatData.messages[0].content,
});
} else if (chatData.messages.length > 1) {
// Multiple messages - set them all
handler.setMessages(chatData.messages);
}
}
} }
}, [token, isNewChat, chatIdParam]); }, [token, isNewChat, activeChatId, isChatLoading]);
// Restore chat state from localStorage on page load // Restore chat state from localStorage on page load
useEffect(() => { useEffect(() => {
if (chatIdParam && search_space_id) { if (activeChatId && search_space_id) {
const restoredState = restoreChatState(search_space_id as string, chatIdParam); const restoredState = restoreChatState(search_space_id as string, activeChatId);
if (restoredState) { if (restoredState) {
setSelectedDocuments(restoredState.selectedDocuments); setSelectedDocuments(restoredState.selectedDocuments);
setSelectedConnectors(restoredState.selectedConnectors); setSelectedConnectors(restoredState.selectedConnectors);
@ -161,7 +192,8 @@ export default function ResearcherPage() {
} }
} }
}, [ }, [
chatIdParam, activeChatId,
isChatLoading,
search_space_id, search_space_id,
setSelectedDocuments, setSelectedDocuments,
setSelectedConnectors, setSelectedConnectors,
@ -196,50 +228,31 @@ export default function ResearcherPage() {
setSelectedConnectors, setSelectedConnectors,
]); ]);
const loadChatData = async (chatId: string) => {
try {
const chatData = await fetchChatDetails(chatId);
if (!chatData) return;
// Update configuration from chat data
// researchMode is always "QNA", no need to set from chat data
if (chatData.initial_connectors && Array.isArray(chatData.initial_connectors)) {
setSelectedConnectors(chatData.initial_connectors);
}
// Load existing messages
if (chatData.messages && Array.isArray(chatData.messages)) {
if (chatData.messages.length === 1 && chatData.messages[0].role === "user") {
// Single user message - append to trigger LLM response
handler.append({
role: "user",
content: chatData.messages[0].content,
});
} else if (chatData.messages.length > 1) {
// Multiple messages - set them all
handler.setMessages(chatData.messages);
}
}
} finally {
setIsLoading(false);
}
};
// Auto-update chat when messages change (only for existing chats) // Auto-update chat when messages change (only for existing chats)
useEffect(() => { useEffect(() => {
if ( if (
!isNewChat && !isNewChat &&
chatIdParam && activeChatId &&
handler.status === "ready" && handler.status === "ready" &&
handler.messages.length > 0 && handler.messages.length > 0 &&
handler.messages[handler.messages.length - 1]?.role === "assistant" handler.messages[handler.messages.length - 1]?.role === "assistant"
) { ) {
updateChat(chatIdParam, handler.messages, researchMode, selectedConnectors); const userMessages = handler.messages.filter((msg) => msg.role === "user");
} if (userMessages.length === 0) return;
}, [handler.messages, handler.status, chatIdParam, isNewChat]); const title = userMessages[0].content;
if (isLoading) { updateChat({
type: researchMode,
title: title,
initial_connectors: selectedConnectors,
messages: handler.messages,
search_space_id: Number(search_space_id),
id: Number(activeChatId),
});
}
}, [handler.messages, handler.status, activeChatId, isNewChat, isChatLoading]);
if (isChatLoading) {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<div>Loading...</div> <div>Loading...</div>

View file

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

View file

@ -1,54 +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 { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom";
import { chatApiService } from "@/lib/apis/chats-api.service";
import { getPodcastByChatId } from "@/lib/apis/podcasts.api";
import { cacheKeys } from "@/lib/query-client/cache-keys";
type ActiveChatState = {
chatId: string | null;
chatDetails: ChatDetails | null;
podcast: PodcastItem | null;
};
export const activeChatIdAtom = atom<string | null>(null);
export const activeChatAtom = atomWithQuery<ActiveChatState>((get) => {
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");
}
const [podcast, chatDetails] = await Promise.all([
getPodcastByChatId(activeChatId, authToken),
chatApiService.getChatDetails({ id: Number(activeChatId) }),
]);
return { chatId: activeChatId, chatDetails, podcast };
},
};
});
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 () => {
return chatApiService.getChatsBySearchSpace({ search_space_id: Number(searchSpaceId) });
},
};
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,21 +1,14 @@
"use client"; "use client";
import { useAtom, useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { LoaderIcon, PanelRight, TriangleAlert } from "lucide-react"; import { LoaderIcon, TriangleAlert } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms"; import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms"; import { activeChathatUIAtom, activeChatIdAtom } from "@/atoms/chats/ui.atoms";
import { generatePodcast } from "@/lib/apis/podcasts.api"; import { generatePodcastMutationAtom } from "@/atoms/podcasts/podcast-mutation.atoms";
import type { GeneratePodcastRequest } from "@/contracts/types/podcast.types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChatPanelView } from "./ChatPanelView"; import { ChatPanelView } from "./ChatPanelView";
export interface GeneratePodcastRequest {
type: "CHAT" | "DOCUMENT";
ids: number[];
search_space_id: number;
podcast_title?: string;
user_prompt?: string;
}
export function ChatPanelContainer() { export function ChatPanelContainer() {
const { const {
data: activeChatState, data: activeChatState,
@ -23,19 +16,18 @@ export function ChatPanelContainer() {
error: chatError, error: chatError,
} = useAtomValue(activeChatAtom); } = useAtomValue(activeChatAtom);
const activeChatIdState = useAtomValue(activeChatIdAtom); const activeChatIdState = useAtomValue(activeChatIdAtom);
const authToken = localStorage.getItem("surfsense_bearer_token");
const { isChatPannelOpen } = useAtomValue(activeChathatUIAtom); const { isChatPannelOpen } = useAtomValue(activeChathatUIAtom);
const { mutateAsync: generatePodcast, error: generatePodcastError } = useAtomValue(
generatePodcastMutationAtom
);
const handleGeneratePodcast = async (request: GeneratePodcastRequest) => { const handleGeneratePodcast = async (request: GeneratePodcastRequest) => {
try { try {
if (!authToken) { generatePodcast(request);
throw new Error("Authentication error. Please log in again.");
}
await generatePodcast(request, authToken);
toast.success(`Podcast generation started!`); toast.success(`Podcast generation started!`);
} catch (error) { } catch (error) {
toast.error("Error generating podcast. Please log in again."); toast.error("Error generating podcast. Please try again later.");
console.error("Error generating podcast:", error); console.error("Error generating podcast:", JSON.stringify(generatePodcastError));
} }
}; };
@ -53,7 +45,7 @@ export function ChatPanelContainer() {
<LoaderIcon strokeWidth={1.5} className="h-5 w-5 animate-spin" /> <LoaderIcon strokeWidth={1.5} className="h-5 w-5 animate-spin" />
</div> </div>
) : chatError ? ( ) : chatError ? (
<div title="Failed to load chat" className="flex items-center justify-center h-full"> <div title="Failed to load chat" className="flex items-center justify-center h-full">
<TriangleAlert strokeWidth={1.5} className="h-5 w-5 text-red-600" /> <TriangleAlert strokeWidth={1.5} className="h-5 w-5 text-red-600" />
</div> </div>
) : null} ) : null}

View file

@ -4,7 +4,7 @@ import { useAtom, useAtomValue } from "jotai";
import { AlertCircle, Play, RefreshCw, Sparkles } from "lucide-react"; import { AlertCircle, Play, RefreshCw, Sparkles } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useCallback } from "react"; import { useCallback } from "react";
import { activeChatAtom } from "@/atoms/chats/chat-querie.atoms"; import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms"; import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getPodcastStalenessMessage, isPodcastStale } from "../PodcastUtils"; import { getPodcastStalenessMessage, isPodcastStale } from "../PodcastUtils";

View file

@ -3,7 +3,7 @@
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Pencil } from "lucide-react"; import { Pencil } from "lucide-react";
import { useCallback, useContext, useState } from "react"; import { useCallback, useContext, useState } from "react";
import { activeChatAtom } from "@/atoms/chats/chat-querie.atoms"; import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import type { GeneratePodcastRequest } from "./ChatPanelContainer"; import type { GeneratePodcastRequest } from "./ChatPanelContainer";

View file

@ -1,16 +1,17 @@
"use client"; "use client";
import { Pause, Play, Podcast, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react"; import { Pause, Play, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import type { Podcast } from "@/contracts/types/podcast.types";
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
import { PodcastPlayerCompactSkeleton } from "./PodcastPlayerCompactSkeleton"; import { PodcastPlayerCompactSkeleton } from "./PodcastPlayerCompactSkeleton";
interface PodcastPlayerProps { interface PodcastPlayerProps {
podcast: PodcastItem | null; podcast: Podcast | null;
isLoading?: boolean; isLoading?: boolean;
onClose?: () => void; onClose?: () => void;
compact?: boolean; compact?: boolean;
@ -56,11 +57,6 @@ export function PodcastPlayer({
const loadPodcast = async () => { const loadPodcast = async () => {
setIsFetching(true); setIsFetching(true);
try { try {
const token = localStorage.getItem("surfsense_bearer_token");
if (!token) {
throw new Error("Authentication token not found.");
}
// Revoke previous object URL if exists // Revoke previous object URL if exists
if (currentObjectUrlRef.current) { if (currentObjectUrlRef.current) {
URL.revokeObjectURL(currentObjectUrlRef.current); URL.revokeObjectURL(currentObjectUrlRef.current);
@ -71,22 +67,12 @@ export function PodcastPlayer({
const timeoutId = setTimeout(() => controller.abort(), 30000); const timeoutId = setTimeout(() => controller.abort(), 30000);
try { try {
const response = await fetch( const response = await podcastsApiService.loadPodcast({
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`, request: { id: podcast.id },
{ controller,
headers: { });
Authorization: `Bearer ${token}`,
},
signal: controller.signal,
}
);
if (!response.ok) { const objectUrl = URL.createObjectURL(response);
throw new Error(`Failed to fetch audio stream: ${response.statusText}`);
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
currentObjectUrlRef.current = objectUrl; currentObjectUrlRef.current = objectUrl;
setAudioSrc(objectUrl); setAudioSrc(objectUrl);
} catch (error) { } catch (error) {

View file

@ -4,7 +4,7 @@ import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms"; import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,

View file

@ -1,8 +1,12 @@
"use client"; "use client";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms";
import { chatsAtom } from "@/atoms/chats/chat-query.atoms";
import { globalChatsQueryParamsAtom } from "@/atoms/chats/ui.atoms";
import { AppSidebar } from "@/components/sidebar/app-sidebar"; import { AppSidebar } from "@/components/sidebar/app-sidebar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -13,7 +17,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useChats, useSearchSpace, useUser } from "@/hooks"; import { useSearchSpace, useUser } from "@/hooks";
interface AppSidebarProviderProps { interface AppSidebarProviderProps {
searchSpaceId: string; searchSpaceId: string;
@ -41,15 +45,14 @@ export function AppSidebarProvider({
}: AppSidebarProviderProps) { }: AppSidebarProviderProps) {
const t = useTranslations("dashboard"); const t = useTranslations("dashboard");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const setChatsQueryParams = useSetAtom(globalChatsQueryParamsAtom);
const { data: chats, error: chatError, isLoading: isLoadingChats } = useAtomValue(chatsAtom);
const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] =
useAtom(deleteChatMutationAtom);
// Use the new hooks useEffect(() => {
const { setChatsQueryParams((prev) => ({ ...prev, search_space_id: searchSpaceId, skip: 0, limit: 5 }));
chats, }, [searchSpaceId]);
loading: isLoadingChats,
error: chatError,
fetchChats: fetchRecentChats,
deleteChat,
} = useChats({ searchSpaceId, limit: 5, skip: 0 });
const { const {
searchSpace, searchSpace,
@ -62,7 +65,6 @@ export function AppSidebarProvider({
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isClient, setIsClient] = useState(false); const [isClient, setIsClient] = useState(false);
// Set isClient to true when component mounts on the client // Set isClient to true when component mounts on the client
@ -72,29 +74,30 @@ export function AppSidebarProvider({
// Retry function // Retry function
const retryFetch = useCallback(() => { const retryFetch = useCallback(() => {
fetchRecentChats();
fetchSearchSpace(); fetchSearchSpace();
}, [fetchRecentChats, fetchSearchSpace]); }, [fetchSearchSpace]);
// Transform API response to the format expected by AppSidebar // Transform API response to the format expected by AppSidebar
const recentChats = useMemo(() => { const recentChats = useMemo(() => {
return chats.map((chat) => ({ return chats
name: chat.title || `Chat ${chat.id}`, ? chats.map((chat) => ({
url: `/dashboard/${chat.search_space_id}/researcher/${chat.id}`, name: chat.title || `Chat ${chat.id}`,
icon: "MessageCircleMore", url: `/dashboard/${chat.search_space_id}/researcher/${chat.id}`,
id: chat.id, icon: "MessageCircleMore",
search_space_id: chat.search_space_id, id: chat.id,
actions: [ search_space_id: chat.search_space_id,
{ actions: [
name: "Delete", {
icon: "Trash2", name: "Delete",
onClick: () => { icon: "Trash2",
setChatToDelete({ id: chat.id, name: chat.title || `Chat ${chat.id}` }); onClick: () => {
setShowDeleteDialog(true); setChatToDelete({ id: chat.id, name: chat.title || `Chat ${chat.id}` });
}, setShowDeleteDialog(true);
}, },
], },
})); ],
}))
: [];
}, [chats]); }, [chats]);
// Handle delete chat with better error handling // Handle delete chat with better error handling
@ -102,13 +105,11 @@ export function AppSidebarProvider({
if (!chatToDelete) return; if (!chatToDelete) return;
try { try {
setIsDeleting(true); await deleteChat({ id: chatToDelete.id });
await deleteChat(chatToDelete.id);
} catch (error) { } catch (error) {
console.error("Error deleting chat:", error); console.error("Error deleting chat:", error);
// You could show a toast notification here // You could show a toast notification here
} finally { } finally {
setIsDeleting(false);
setShowDeleteDialog(false); setShowDeleteDialog(false);
setChatToDelete(null); setChatToDelete(null);
} }
@ -226,17 +227,17 @@ export function AppSidebarProvider({
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowDeleteDialog(false)} onClick={() => setShowDeleteDialog(false)}
disabled={isDeleting} disabled={isDeletingChat}
> >
{tCommon("cancel")} {tCommon("cancel")}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={handleDeleteChat} onClick={handleDeleteChat}
disabled={isDeleting} disabled={isDeletingChat}
className="gap-2" className="gap-2"
> >
{isDeleting ? ( {isDeletingChat ? (
<> <>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t("deleting")} {t("deleting")}

View file

@ -20,11 +20,13 @@ export const chatDetails = chatSummary.extend({
export const getChatDetailsRequest = chatSummary.pick({ id: true }); export const getChatDetailsRequest = chatSummary.pick({ id: true });
export const getChatsBySearchSpaceRequest = chatSummary export const getChatsRequest = z.object({
.pick({ queryParams: paginationQueryParams
search_space_id: true, .extend({
}) search_space_id: z.number().or(z.string()).optional(),
.merge(paginationQueryParams); })
.nullish(),
});
export const deleteChatResponse = z.object({ export const deleteChatResponse = z.object({
message: z.literal("Chat deleted successfully"), message: z.literal("Chat deleted successfully"),
@ -38,12 +40,15 @@ export const createChatRequest = chatDetails.omit({
state_version: 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<typeof chatSummary>; export type ChatSummary = z.infer<typeof chatSummary>;
export type ChatDetails = z.infer<typeof chatDetails> & { messages: Message[] }; export type ChatDetails = z.infer<typeof chatDetails> & { messages: Message[] };
export type GetChatDetailsRequest = z.infer<typeof getChatDetailsRequest>; export type GetChatDetailsRequest = z.infer<typeof getChatDetailsRequest>;
export type GetChatsBySearchSpaceRequest = z.infer<typeof getChatsBySearchSpaceRequest>; export type GetChatsRequest = z.infer<typeof getChatsRequest>;
export type DeleteChatResponse = z.infer<typeof deleteChatResponse>; export type DeleteChatResponse = z.infer<typeof deleteChatResponse>;
export type DeleteChatRequest = z.infer<typeof deleteChatRequest>; export type DeleteChatRequest = z.infer<typeof deleteChatRequest>;
export type CreateChatRequest = z.infer<typeof createChatRequest>; export type CreateChatRequest = z.infer<typeof createChatRequest>;

View file

@ -0,0 +1,51 @@
import { z } from "zod";
import { paginationQueryParams } from ".";
export const podcast = z.object({
id: z.number(),
title: z.string(),
created_at: z.string(),
file_location: z.string(),
podcast_transcript: z.array(z.any()),
search_space_id: z.number(),
chat_state_version: z.number().nullable(),
});
export const generatePodcastRequest = z.object({
type: z.enum(["CHAT", "DOCUMENT"]),
ids: z.array(z.number()),
search_space_id: z.number(),
podcast_title: z.string().optional(),
user_prompt: z.string().optional(),
});
export const getPodcastByChatIdRequest = z.object({
chat_id: z.number(),
});
export const getPodcastByChaIdResponse = podcast.nullish();
export const deletePodcastRequest = z.object({
id: z.number(),
});
export const deletePodcastResponse = z.object({
message: z.literal("Podcast deleted successfully"),
});
export const loadPodcastRequest = z.object({
id: z.number(),
});
export const getPodcastsRequest = z.object({
queryParams: paginationQueryParams.nullish(),
});
export type GeneratePodcastRequest = z.infer<typeof generatePodcastRequest>;
export type GetPodcastByChatIdRequest = z.infer<typeof getPodcastByChatIdRequest>;
export type GetPodcastByChatIdResponse = z.infer<typeof getPodcastByChaIdResponse>;
export type DeletePodcastRequest = z.infer<typeof deletePodcastRequest>;
export type DeletePodcastResponse = z.infer<typeof deletePodcastResponse>;
export type LoadPodcastRequest = z.infer<typeof loadPodcastRequest>;
export type Podcast = z.infer<typeof podcast>;
export type GetPodcastsRequest = z.infer<typeof getPodcastsRequest>;

View file

@ -1,4 +1,3 @@
export * from "./use-chats";
export * from "./use-document-by-chunk"; export * from "./use-document-by-chunk";
export * from "./use-logs"; export * from "./use-logs";
export * from "./use-search-source-connectors"; export * from "./use-search-source-connectors";

View file

@ -45,137 +45,3 @@ export function useChatState({ chat_id }: UseChatStateProps) {
setTopK, setTopK,
}; };
} }
interface UseChatAPIProps {
token: string | null;
search_space_id: string;
}
export function useChatAPI({ token, search_space_id }: UseChatAPIProps) {
const fetchChatDetails = useCallback(
async (chatId: string): Promise<ChatDetails | null> => {
if (!token) return null;
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 ${token}`,
},
}
);
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;
}
},
[token]
);
const createChat = useCallback(
async (
initialMessage: string,
researchMode: ResearchMode,
selectedConnectors: string[]
): Promise<string | null> => {
if (!token) {
console.error("Authentication token not found");
return null;
}
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
type: researchMode,
title: "Untitled Chat",
initial_connectors: selectedConnectors,
messages: [
{
role: "user",
content: initialMessage,
},
],
search_space_id: Number(search_space_id),
}),
}
);
if (!response.ok) {
throw new Error(`Failed to create chat: ${response.statusText}`);
}
const data = await response.json();
return data.id;
} catch (err) {
console.error("Error creating chat:", err);
return null;
}
},
[token, search_space_id]
);
const updateChat = useCallback(
async (
chatId: string,
messages: Message[],
researchMode: ResearchMode,
selectedConnectors: string[]
) => {
if (!token) return;
try {
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 ${token}`,
},
body: JSON.stringify({
type: researchMode,
title: title,
initial_connectors: selectedConnectors,
messages: messages,
search_space_id: Number(search_space_id),
}),
}
);
if (!response.ok) {
throw new Error(`Failed to update chat: ${response.statusText}`);
}
} catch (err) {
console.error("Error updating chat:", err);
}
},
[token, search_space_id]
);
return {
fetchChatDetails,
createChat,
updateChat,
};
}

View file

@ -1,124 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
interface Chat {
created_at: string;
id: number;
type: string;
title: string;
messages: string[];
search_space_id: number;
}
interface UseChatsOptions {
searchSpaceId: string | number;
limit?: number;
skip?: number;
autoFetch?: boolean;
}
export function useChats({
searchSpaceId,
limit = 5,
skip = 0,
autoFetch = true,
}: UseChatsOptions) {
const [chats, setChats] = useState<Chat[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchChats = useCallback(async () => {
try {
// Only run on client-side
if (typeof window === "undefined") return;
setLoading(true);
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 ${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 chats: ${response.status}`);
}
const data = await response.json();
// Sort chats by created_at in descending order (newest first)
const sortedChats = data.sort(
(a: Chat, b: Chat) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
setChats(sortedChats);
setError(null);
} catch (err: any) {
setError(err.message || "Failed to fetch chats");
console.error("Error fetching chats:", err);
setChats([]);
} finally {
setLoading(false);
}
}, [searchSpaceId, limit, skip]);
const deleteChat = useCallback(async (chatId: number) => {
try {
// Only run on client-side
if (typeof window === "undefined") return;
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${chatId}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
},
method: "DELETE",
}
);
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 delete chat: ${response.status}`);
}
// Update local state to remove the deleted chat
setChats((prev) => prev.filter((chat) => chat.id !== chatId));
} catch (err: any) {
console.error("Error deleting chat:", err);
throw err;
}
}, []);
useEffect(() => {
if (autoFetch) {
fetchChats();
// Set up a refresh interval (every 5 minutes)
const intervalId = setInterval(fetchChats, 5 * 60 * 1000);
// Clean up interval on component unmount
return () => clearInterval(intervalId);
}
}, [autoFetch, fetchChats]);
return { chats, loading, error, fetchChats, deleteChat };
}

View file

@ -9,7 +9,7 @@ import {
import { ValidationError } from "../error"; import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service"; import { baseApiService } from "./base-api.service";
export class AuthApiService { class AuthApiService {
login = async (request: LoginRequest) => { login = async (request: LoginRequest) => {
// Validate the request // Validate the request
const parsedRequest = loginRequest.safeParse(request); const parsedRequest = loginRequest.safeParse(request);
@ -49,7 +49,7 @@ export class AuthApiService {
} }
return baseApiService.post(`/auth/register`, registerResponse, { return baseApiService.post(`/auth/register`, registerResponse, {
body: JSON.stringify(parsedRequest.data), body: parsedRequest.data,
}); });
}; };
} }

View file

@ -1,11 +1,14 @@
import { th } from "date-fns/locale";
import type z from "zod"; import type z from "zod";
import { import { AppError, AuthenticationError, AuthorizationError, NotFoundError } from "../error";
AppError,
AuthenticationError, enum ResponseType {
AuthorizationError, JSON = "json",
NotFoundError, TEXT = "text",
ValidationError, BLOB = "blob",
} from "../error"; ARRAY_BUFFER = "arrayBuffer",
// Add more response types as needed
}
export type RequestOptions = { export type RequestOptions = {
method: "GET" | "POST" | "PUT" | "DELETE"; method: "GET" | "POST" | "PUT" | "DELETE";
@ -13,10 +16,11 @@ export type RequestOptions = {
contentType?: "application/json" | "application/x-www-form-urlencoded"; contentType?: "application/json" | "application/x-www-form-urlencoded";
signal?: AbortSignal; signal?: AbortSignal;
body?: any; body?: any;
responseType?: ResponseType;
// Add more options as needed // Add more options as needed
}; };
export class BaseApiService { class BaseApiService {
bearerToken: string; bearerToken: string;
baseUrl: string; baseUrl: string;
@ -31,18 +35,34 @@ export class BaseApiService {
this.bearerToken = bearerToken; this.bearerToken = bearerToken;
} }
async request<T>( async request<T, R extends ResponseType = ResponseType.JSON>(
url: string, url: string,
responseSchema?: z.ZodSchema<T>, responseSchema?: z.ZodSchema<T>,
options?: RequestOptions options?: RequestOptions & { responseType?: R }
): Promise<T> { ): Promise<
R extends ResponseType.JSON
? T
: R extends ResponseType.TEXT
? string
: R extends ResponseType.BLOB
? Blob
: R extends ResponseType.ARRAY_BUFFER
? ArrayBuffer
: unknown
> {
try { try {
/**
* ----------
* REQUEST
* ----------
*/
const defaultOptions: RequestOptions = { const defaultOptions: RequestOptions = {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${this.bearerToken || ""}`, Authorization: `Bearer ${this.bearerToken || ""}`,
}, },
method: "GET", method: "GET",
responseType: ResponseType.JSON,
}; };
const mergedOptions: RequestOptions = { const mergedOptions: RequestOptions = {
@ -54,18 +74,46 @@ export class BaseApiService {
}, },
}; };
// Validate the base URL
if (!this.baseUrl) { if (!this.baseUrl) {
throw new AppError("Base URL is not set."); throw new AppError("Base URL is not set.");
} }
// Validate the bearer token
if (!this.bearerToken && !this.noAuthEndpoints.includes(url)) { if (!this.bearerToken && !this.noAuthEndpoints.includes(url)) {
throw new AuthenticationError("You are not authenticated. Please login again."); throw new AuthenticationError("You are not authenticated. Please login again.");
} }
// Construct the full URL
const fullUrl = new URL(url, this.baseUrl).toString(); const fullUrl = new URL(url, this.baseUrl).toString();
const response = await fetch(fullUrl, mergedOptions); // Prepare fetch options
const fetchOptions: RequestInit = {
method: mergedOptions.method,
headers: mergedOptions.headers,
signal: mergedOptions.signal,
};
// Automatically stringify body if Content-Type is application/json and body is an object
if (mergedOptions.body !== undefined) {
const contentType = mergedOptions.headers?.["Content-Type"];
if (contentType === "application/json" && typeof mergedOptions.body === "object") {
fetchOptions.body = JSON.stringify(mergedOptions.body);
} else {
// Pass body as-is for other content types (e.g., form data, already stringified)
fetchOptions.body = mergedOptions.body;
}
}
const response = await fetch(fullUrl, fetchOptions);
/**
* ----------
* RESPONSE
* ----------
*/
// Handle errors
if (!response.ok) { if (!response.ok) {
// biome-ignore lint/suspicious: Unknown // biome-ignore lint/suspicious: Unknown
let data; let data;
@ -73,13 +121,12 @@ export class BaseApiService {
try { try {
data = await response.json(); data = await response.json();
} catch (error) { } catch (error) {
console.error("Failed to parse response as JSON:", error); console.error("Failed to parse response as JSON: ", JSON.stringify(error));
throw new AppError("Failed to parse response", response.status, response.statusText);
throw new AppError("Something went wrong", response.status, response.statusText);
} }
// for fastapi errors response // For fastapi errors response
if ("detail" in data) { if (typeof data === "object" && "detail" in data) {
throw new AppError(data.detail, response.status, response.statusText); throw new AppError(data.detail, response.status, response.statusText);
} }
@ -106,32 +153,52 @@ export class BaseApiService {
// biome-ignore lint/suspicious: Unknown // biome-ignore lint/suspicious: Unknown
let data; let data;
const responseType = mergedOptions.responseType;
try { try {
data = await response.json(); switch (responseType) {
case ResponseType.JSON:
data = await response.json();
break;
case ResponseType.TEXT:
data = await response.text();
break;
case ResponseType.BLOB:
data = await response.blob();
break;
case ResponseType.ARRAY_BUFFER:
data = await response.arrayBuffer();
break;
// Add more cases as needed
default:
data = await response.json();
}
} catch (error) { } catch (error) {
console.error("Failed to parse response as JSON:", error); console.error("Failed to parse response as JSON:", error);
throw new AppError("Failed to parse response", response.status, response.statusText);
throw new AppError("Something went wrong", response.status, response.statusText);
} }
if (!responseSchema) { // Validate response
if (responseType === ResponseType.JSON) {
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 - ${url} :`, JSON.stringify(parsedData.error));
}
return data; 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);
}
return data; return data;
} catch (error) { } catch (error) {
console.error("Request failed:", error); console.error("Request failed:", JSON.stringify(error));
throw error; throw error;
} }
} }
@ -139,44 +206,56 @@ export class BaseApiService {
async get<T>( async get<T>(
url: string, url: string,
responseSchema?: z.ZodSchema<T>, responseSchema?: z.ZodSchema<T>,
options?: Omit<RequestOptions, "method"> options?: Omit<RequestOptions, "method" | "responseType">
) { ) {
return this.request(url, responseSchema, { return this.request(url, responseSchema, {
...options, ...options,
method: "GET", method: "GET",
responseType: ResponseType.JSON,
}); });
} }
async post<T>( async post<T>(
url: string, url: string,
responseSchema?: z.ZodSchema<T>, responseSchema?: z.ZodSchema<T>,
options?: Omit<RequestOptions, "method"> options?: Omit<RequestOptions, "method" | "responseType">
) { ) {
return this.request(url, responseSchema, { return this.request(url, responseSchema, {
method: "POST", method: "POST",
...options, ...options,
responseType: ResponseType.JSON,
}); });
} }
async put<T>( async put<T>(
url: string, url: string,
responseSchema?: z.ZodSchema<T>, responseSchema?: z.ZodSchema<T>,
options?: Omit<RequestOptions, "method"> options?: Omit<RequestOptions, "method" | "responseType">
) { ) {
return this.request(url, responseSchema, { return this.request(url, responseSchema, {
method: "PUT", method: "PUT",
...options, ...options,
responseType: ResponseType.JSON,
}); });
} }
async delete<T>( async delete<T>(
url: string, url: string,
responseSchema?: z.ZodSchema<T>, responseSchema?: z.ZodSchema<T>,
options?: Omit<RequestOptions, "method"> options?: Omit<RequestOptions, "method" | "responseType">
) { ) {
return this.request(url, responseSchema, { return this.request(url, responseSchema, {
method: "DELETE", method: "DELETE",
...options, ...options,
responseType: ResponseType.JSON,
});
}
async getBlob(url: string, options?: Omit<RequestOptions, "method" | "responseType">) {
return this.request(url, undefined, {
...options,
method: "GET",
responseType: ResponseType.BLOB,
}); });
} }
} }

View file

@ -8,16 +8,16 @@ import {
deleteChatRequest, deleteChatRequest,
deleteChatResponse, deleteChatResponse,
type GetChatDetailsRequest, type GetChatDetailsRequest,
type GetChatsBySearchSpaceRequest, type GetChatsRequest,
getChatDetailsRequest, getChatDetailsRequest,
getChatsBySearchSpaceRequest, getChatsRequest,
type UpdateChatRequest, type UpdateChatRequest,
updateChatRequest, updateChatRequest,
} from "@/contracts/types/chat.types"; } from "@/contracts/types/chat.types";
import { ValidationError } from "../error"; import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service"; import { baseApiService } from "./base-api.service";
export class ChatApiService { class ChatApiService {
getChatDetails = async (request: GetChatDetailsRequest) => { getChatDetails = async (request: GetChatDetailsRequest) => {
// Validate the request // Validate the request
const parsedRequest = getChatDetailsRequest.safeParse(request); const parsedRequest = getChatDetailsRequest.safeParse(request);
@ -33,9 +33,9 @@ export class ChatApiService {
return baseApiService.get(`/api/v1/chats/${request.id}`, chatDetails); return baseApiService.get(`/api/v1/chats/${request.id}`, chatDetails);
}; };
getChatsBySearchSpace = async (request: GetChatsBySearchSpaceRequest) => { getChats = async (request: GetChatsRequest) => {
// Validate the request // Validate the request
const parsedRequest = getChatsBySearchSpaceRequest.safeParse(request); const parsedRequest = getChatsRequest.safeParse(request);
if (!parsedRequest.success) { if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error); console.error("Invalid request:", parsedRequest.error);
@ -45,10 +45,18 @@ export class ChatApiService {
throw new ValidationError(`Invalid request: ${errorMessage}`); throw new ValidationError(`Invalid request: ${errorMessage}`);
} }
return baseApiService.get( // Transform queries params to be string values
`/api/v1/chats?search_space_id=${request.search_space_id}`, const transformedQueryParams = parsedRequest.data.queryParams
z.array(chatSummary) ? Object.fromEntries(
); Object.entries(parsedRequest.data.queryParams).map(([k, v]) => [k, String(v)])
)
: undefined;
const queryParams = transformedQueryParams
? new URLSearchParams(transformedQueryParams).toString()
: undefined;
return baseApiService.get(`/api/v1/chats?${queryParams}`, z.array(chatSummary));
}; };
deleteChat = async (request: DeleteChatRequest) => { deleteChat = async (request: DeleteChatRequest) => {
@ -78,20 +86,12 @@ export class ChatApiService {
throw new ValidationError(`Invalid request: ${errorMessage}`); throw new ValidationError(`Invalid request: ${errorMessage}`);
} }
const { type, title, initial_connectors, messages, search_space_id } = parsedRequest.data;
return baseApiService.post( return baseApiService.post(
`/api/v1/chats`, `/api/v1/chats`,
chatSummary, chatSummary,
{ {
body: { body: parsedRequest.data,
type,
title,
initial_connectors,
messages,
search_space_id,
},
} }
); );
}; };
@ -127,4 +127,4 @@ export class ChatApiService {
}; };
} }
export const chatApiService = new ChatApiService(); export const chatsApiService = new ChatApiService();

View file

@ -0,0 +1,121 @@
import z from "zod";
import {
type DeletePodcastRequest,
deletePodcastRequest,
deletePodcastResponse,
type GeneratePodcastRequest,
type GetPodcastByChatIdRequest,
type GetPodcastsRequest,
generatePodcastRequest,
getPodcastByChaIdResponse,
getPodcastByChatIdRequest,
getPodcastsRequest,
type LoadPodcastRequest,
loadPodcastRequest,
podcast,
} from "@/contracts/types/podcast.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
class PodcastsApiService {
getPodcasts = async (request: GetPodcastsRequest) => {
// Validate the request
const parsedRequest = getPodcastsRequest.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}`);
}
// Transform queries params to be string values
const transformedQueryParams = parsedRequest.data.queryParams
? Object.fromEntries(
Object.entries(parsedRequest.data.queryParams).map(([k, v]) => [k, String(v)])
)
: undefined;
const queryParams = transformedQueryParams
? new URLSearchParams(transformedQueryParams).toString()
: undefined;
return baseApiService.get(`/api/v1/podcasts?${queryParams}`, z.array(podcast));
};
getPodcastByChatId = async (request: GetPodcastByChatIdRequest) => {
// Validate the request
const parsedRequest = getPodcastByChatIdRequest.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/podcasts/by-chat/${request.chat_id}`,
getPodcastByChaIdResponse
);
};
generatePodcast = async (request: GeneratePodcastRequest) => {
// Validate the request
const parsedRequest = generatePodcastRequest.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.post(`/api/v1/podcasts/generate`, undefined, {
body: parsedRequest.data,
});
};
loadPodcast = async ({
request,
controller,
}: {
request: LoadPodcastRequest;
controller?: AbortController;
}) => {
// Validate the request
const parsedRequest = loadPodcastRequest.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 await baseApiService.getBlob(`/api/v1/podcasts/${request.id}/stream`, {
signal: controller?.signal,
});
};
deletePodcast = async (request: DeletePodcastRequest) => {
// Validate the request
const parsedRequest = deletePodcastRequest.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/podcasts/${request.id}`, deletePodcastResponse);
};
}
export const podcastsApiService = new PodcastsApiService();

View file

@ -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);
}
};

View file

@ -1,7 +1,15 @@
import type { GetChatsRequest } from "@/contracts/types/chat.types";
import type { GetPodcastsRequest } from "@/contracts/types/podcast.types";
export const cacheKeys = { export const cacheKeys = {
activeSearchSpace: { chats: {
chats: (searchSpaceId: string) => ["active-search-space", "chats", searchSpaceId] as const, activeChat: (chatId: string) => ["active-chat", chatId] as const,
activeChat: (chatId: string) => ["active-search-space", "active-chat", chatId] as const, globalQueryParams: (queries: GetChatsRequest["queryParams"]) =>
["chats", ...(queries ? Object.values(queries) : [])] as const,
},
podcasts: {
globalQueryParams: (queries: GetPodcastsRequest["queryParams"]) =>
["podcasts", ...(queries ? Object.values(queries) : [])] as const,
}, },
auth: { auth: {
user: ["auth", "user"] as const, user: ["auth", "user"] as const,