mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-11 00:32:38 +02:00
Merge pull request #493 from thierryverse/feat/add-jotai-tanstack
[Feat] Add jotai & tanstack for podcast and chats
This commit is contained in:
commit
5b99785b68
29 changed files with 700 additions and 739 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
.flashrank_cache*
|
.flashrank_cache*
|
||||||
podcasts/
|
./surfsense_backend/podcasts/
|
||||||
.env
|
.env
|
||||||
node_modules/
|
node_modules/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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([]);
|
||||||
if (!token) {
|
|
||||||
setError("Authentication token not found. Please log in again.");
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all podcasts for this search space
|
if (!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([]);
|
setFilteredPodcasts([]);
|
||||||
} finally {
|
return;
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
fetchPodcasts();
|
setFilteredPodcasts(podcasts);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 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...
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}, [token, isNewChat, chatIdParam]);
|
|
||||||
|
// 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, 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>
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
47
surfsense_web/atoms/chats/chat-query.atoms.ts
Normal file
47
surfsense_web/atoms/chats/chat-query.atoms.ts
Normal 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
|
|
||||||
50
surfsense_web/atoms/podcasts/podcast-mutation.atoms.ts
Normal file
50
surfsense_web/atoms/podcasts/podcast-mutation.atoms.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
17
surfsense_web/atoms/podcasts/podcast-query.atoms.ts
Normal file
17
surfsense_web/atoms/podcasts/podcast-query.atoms.ts
Normal 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
7
surfsense_web/atoms/podcasts/ui.atoms.ts
Normal file
7
surfsense_web/atoms/podcasts/ui.atoms.ts
Normal 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,
|
||||||
|
});
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,13 +74,13 @@ 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
|
||||||
|
? chats.map((chat) => ({
|
||||||
name: chat.title || `Chat ${chat.id}`,
|
name: chat.title || `Chat ${chat.id}`,
|
||||||
url: `/dashboard/${chat.search_space_id}/researcher/${chat.id}`,
|
url: `/dashboard/${chat.search_space_id}/researcher/${chat.id}`,
|
||||||
icon: "MessageCircleMore",
|
icon: "MessageCircleMore",
|
||||||
|
|
@ -94,7 +96,8 @@ export function AppSidebarProvider({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}));
|
}))
|
||||||
|
: [];
|
||||||
}, [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")}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
51
surfsense_web/contracts/types/podcast.types.ts
Normal file
51
surfsense_web/contracts/types/podcast.types.ts
Normal 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>;
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,19 +153,36 @@ export class BaseApiService {
|
||||||
|
|
||||||
// biome-ignore lint/suspicious: Unknown
|
// biome-ignore lint/suspicious: Unknown
|
||||||
let data;
|
let data;
|
||||||
|
const responseType = mergedOptions.responseType;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
switch (responseType) {
|
||||||
|
case ResponseType.JSON:
|
||||||
data = await response.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate response
|
||||||
|
if (responseType === ResponseType.JSON) {
|
||||||
if (!responseSchema) {
|
if (!responseSchema) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedData = responseSchema.safeParse(data);
|
const parsedData = responseSchema.safeParse(data);
|
||||||
|
|
||||||
if (!parsedData.success) {
|
if (!parsedData.success) {
|
||||||
|
|
@ -126,12 +190,15 @@ export class BaseApiService {
|
||||||
* This is a client side error, and should be fixed by updating the responseSchema to keep things typed.
|
* 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.
|
* This error should not be shown to the user , it is for dev only.
|
||||||
*/
|
*/
|
||||||
console.error("Invalid API response schema:", parsedData.error);
|
console.error(`Invalid API response schema - ${url} :`, JSON.stringify(parsedData.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
121
surfsense_web/lib/apis/podcasts-api.service.ts
Normal file
121
surfsense_web/lib/apis/podcasts-api.service.ts
Normal 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();
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue