feat: added attachment support

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2025-12-21 22:26:33 -08:00
parent bb971460fc
commit c2dcb2045d
62 changed files with 1166 additions and 9012 deletions

View file

@ -1,24 +1,20 @@
"use client";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { Loader2, PanelRight } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useAtomValue, useSetAtom } from "jotai";
import { Loader2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { activeChathatUIAtom, activeChatIdAtom } from "@/atoms/chats/ui.atoms";
import { llmPreferencesAtom } from "@/atoms/llm-config/llm-config-query.atoms";
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContainer";
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
export function DashboardClientLayout({
children,
@ -34,33 +30,8 @@ export function DashboardClientLayout({
const t = useTranslations("dashboard");
const router = useRouter();
const pathname = usePathname();
const searchSpaceIdNum = Number(searchSpaceId);
const { search_space_id, chat_id } = useParams();
const [chatUIState, setChatUIState] = useAtom(activeChathatUIAtom);
const activeChatId = useAtomValue(activeChatIdAtom);
const { search_space_id } = useParams();
const setActiveSearchSpaceIdState = useSetAtom(activeSearchSpaceIdAtom);
const setActiveChatIdState = useSetAtom(activeChatIdAtom);
const [showIndicator, setShowIndicator] = useState(false);
const { isChatPannelOpen } = chatUIState;
// Check if we're on the researcher page
const isResearcherPage = pathname?.includes("/researcher");
// Check if we're on the new-chat page (uses separate thread persistence)
const isNewChatPage = pathname?.includes("/new-chat");
// Show indicator when chat becomes active and panel is closed
useEffect(() => {
if (activeChatId && !isChatPannelOpen) {
setShowIndicator(true);
// Hide indicator after 5 seconds
const timer = setTimeout(() => setShowIndicator(false), 5000);
return () => clearTimeout(timer);
} else {
setShowIndicator(false);
}
}, [activeChatId, isChatPannelOpen]);
const { data: preferences = {}, isFetching: loading, error } = useAtomValue(llmPreferencesAtom);
@ -151,24 +122,7 @@ export function DashboardClientLayout({
: "";
if (!activeSeacrhSpaceId) return;
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
}, [search_space_id]);
useEffect(() => {
// Skip setting activeChatIdAtom on new-chat page (uses separate thread persistence)
if (isNewChatPage) {
setActiveChatIdState(null);
return;
}
const activeChatId =
typeof chat_id === "string"
? chat_id
: Array.isArray(chat_id) && chat_id.length > 0
? chat_id[0]
: "";
if (!activeChatId) return;
setActiveChatIdState(activeChatId);
}, [chat_id, search_space_id, isNewChatPage]);
}, [search_space_id, setActiveSearchSpaceIdState]);
// Show loading screen while checking onboarding status (only on first load)
if (!hasCheckedOnboarding && (loading || accessLoading) && !isOnboardingPage) {
@ -221,123 +175,20 @@ export function DashboardClientLayout({
navMain={translatedNavMain}
/>
<SidebarInset className="h-full ">
<main className="flex h-full">
<div className="flex grow flex-col h-full border-r">
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 border-b">
<div className="flex items-center justify-between w-full gap-2 px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="h-6" />
<DashboardBreadcrumb />
</div>
<div className="flex items-center gap-2">
<LanguageSwitcher />
{/* Only show artifacts toggle on researcher page */}
{isResearcherPage && (
<motion.div
className="relative"
animate={
showIndicator
? {
scale: [1, 1.05, 1],
}
: {}
}
transition={{
duration: 2,
repeat: showIndicator ? Number.POSITIVE_INFINITY : 0,
ease: "easeInOut",
}}
>
<motion.button
type="button"
onClick={() => {
setChatUIState((prev) => ({
...prev,
isChatPannelOpen: !isChatPannelOpen,
}));
setShowIndicator(false);
}}
className={cn(
"shrink-0 rounded-full p-2 transition-all duration-300 relative",
showIndicator
? "bg-primary/20 hover:bg-primary/30 shadow-lg shadow-primary/25"
: "hover:bg-muted",
activeChatId && !showIndicator && "hover:bg-primary/10"
)}
title="Toggle Artifacts Panel"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<motion.div
animate={
showIndicator
? {
rotate: [0, -10, 10, -10, 0],
}
: {}
}
transition={{
duration: 0.5,
repeat: showIndicator ? Number.POSITIVE_INFINITY : 0,
repeatDelay: 2,
}}
>
<PanelRight
className={cn(
"h-4 w-4 transition-colors",
showIndicator && "text-primary"
)}
/>
</motion.div>
</motion.button>
{/* Pulsing indicator badge */}
<AnimatePresence>
{showIndicator && (
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
className="absolute -right-1 -top-1 pointer-events-none"
>
<motion.div
animate={{
scale: [1, 1.3, 1],
}}
transition={{
duration: 1.5,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="relative"
>
<div className="h-2.5 w-2.5 rounded-full bg-primary shadow-lg" />
<motion.div
animate={{
scale: [1, 2.5, 1],
opacity: [0.6, 0, 0.6],
}}
transition={{
duration: 1.5,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="absolute inset-0 h-2.5 w-2.5 rounded-full bg-primary"
/>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</div>
<main className="flex flex-col h-full">
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 border-b">
<div className="flex items-center justify-between w-full gap-2 px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="h-6" />
<DashboardBreadcrumb />
</div>
</header>
<div className="grow flex-1 overflow-auto min-h-[calc(100vh-64px)]">{children}</div>
</div>
{/* Only render chat panel on researcher page */}
{isResearcherPage && <ChatPanelContainer />}
<div className="flex items-center gap-2">
<LanguageSwitcher />
</div>
</div>
</header>
<div className="grow flex-1 overflow-auto min-h-[calc(100vh-64px)]">{children}</div>
</main>
</SidebarInset>
</SidebarProvider>

View file

@ -1,6 +1,7 @@
"use client";
import {
type AppendMessage,
AssistantRuntimeProvider,
type ThreadMessageLike,
useExternalStoreRuntime,
@ -11,6 +12,7 @@ import { toast } from "sonner";
import { Thread } from "@/components/assistant-ui/thread";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { getBearerToken } from "@/lib/auth-utils";
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
import {
isPodcastGenerating,
looksLikePodcastRequest,
@ -59,6 +61,9 @@ export default function NewChatPage() {
const [isRunning, setIsRunning] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
// Create the attachment adapter for file processing
const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []);
// Extract search_space_id from URL params
const searchSpaceId = useMemo(() => {
const id = params.search_space_id;
@ -99,7 +104,10 @@ export default function NewChatPage() {
}
} catch (error) {
console.error("[NewChatPage] Failed to initialize thread:", error);
setThreadId(Date.now());
// Keep threadId as null - don't use Date.now() as it creates an invalid ID
// that will cause 404 errors on subsequent API calls
setThreadId(null);
toast.error("Failed to initialize chat. Please try again.");
} finally {
setIsInitializing(false);
}
@ -121,18 +129,27 @@ export default function NewChatPage() {
// Handle new message from user
const onNew = useCallback(
async (message: ThreadMessageLike) => {
async (message: AppendMessage) => {
if (!threadId) return;
// Extract user query text
// Extract user query text from content parts
let userQuery = "";
for (const part of message.content) {
if (typeof part === "object" && part.type === "text" && "text" in part) {
if (part.type === "text") {
userQuery += part.text;
}
}
if (!userQuery.trim()) return;
// Extract attachments from message
// AppendMessage.attachments contains the processed attachment objects (from adapter.send())
const messageAttachments: Array<Record<string, unknown>> = [];
if (message.attachments && message.attachments.length > 0) {
for (const att of message.attachments) {
messageAttachments.push(att as unknown as Record<string, unknown>);
}
}
if (!userQuery.trim() && messageAttachments.length === 0) return;
// Check if podcast is already generating
if (isPodcastGenerating() && looksLikePodcastRequest(userQuery)) {
@ -239,6 +256,9 @@ export default function NewChatPage() {
})
.filter((m) => m.content.length > 0);
// Extract attachment content to send with the request
const attachments = extractAttachmentContent(messageAttachments);
const response = await fetch(`${backendUrl}/api/v1/new_chat`, {
method: "POST",
headers: {
@ -250,6 +270,7 @@ export default function NewChatPage() {
user_query: userQuery.trim(),
search_space_id: searchSpaceId,
messages: messageHistory,
attachments: attachments.length > 0 ? attachments : undefined,
}),
signal: controller.signal,
});
@ -405,13 +426,29 @@ export default function NewChatPage() {
[]
);
// Create external store runtime
// Handle editing a message - removes messages after the edited one and sends as new
const onEdit = useCallback(
async (message: AppendMessage) => {
// Find the message being edited by looking at the parentId
// The parentId tells us which message's response we're editing
// For now, we'll just treat edits like new messages
// A more sophisticated implementation would truncate the history
await onNew(message);
},
[onNew]
);
// Create external store runtime with attachment support
const runtime = useExternalStoreRuntime({
messages,
isRunning,
onNew,
onEdit,
convertMessage,
onCancel: cancelRun,
adapters: {
attachments: attachmentAdapter,
},
});
// Show loading state
@ -423,6 +460,25 @@ export default function NewChatPage() {
);
}
// Show error state if thread initialization failed
if (!threadId) {
return (
<div className="flex h-[calc(100vh-64px)] flex-col items-center justify-center gap-4">
<div className="text-destructive">Failed to initialize chat</div>
<button
type="button"
onClick={() => {
setIsInitializing(true);
initializeThread();
}}
className="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
Try Again
</button>
</div>
);
}
return (
<AssistantRuntimeProvider runtime={runtime}>
<GeneratePodcastToolUI />

View file

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

View file

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

View file

@ -1,291 +0,0 @@
"use client";
import { type CreateMessage, type Message, useChat } from "@ai-sdk/react";
import { useAtom, useAtomValue } from "jotai";
import { useParams, useRouter } from "next/navigation";
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 { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import ChatInterface from "@/components/chat/ChatInterface";
import type { Document } from "@/contracts/types/document.types";
import { useChatState } from "@/hooks/use-chat";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
export default function ResearcherPage() {
const { search_space_id } = useParams();
const router = useRouter();
const hasSetInitialConnectors = useRef(false);
const hasInitiatedResponse = useRef<string | null>(null);
const activeChatId = useAtomValue(activeChatIdAtom);
const { data: activeChatState, isFetching: isChatLoading } = useAtomValue(activeChatAtom);
const { mutateAsync: createChat } = useAtomValue(createChatMutationAtom);
const { mutateAsync: updateChat } = useAtomValue(updateChatMutationAtom);
const isNewChat = !activeChatId;
// Reset the flag when chat ID changes (but not hasInitiatedResponse - we need to remember if we already initiated)
useEffect(() => {
hasSetInitialConnectors.current = false;
}, [activeChatId]);
const {
token,
researchMode,
selectedConnectors,
setSelectedConnectors,
selectedDocuments,
setSelectedDocuments,
topK,
setTopK,
} = useChatState({
search_space_id: search_space_id as string,
chat_id: activeChatId ?? undefined,
});
// Fetch all available sources (document types + live search connectors)
// Use the documentTypeCountsAtom for fetching document types
const [documentTypeCountsQuery] = useAtom(documentTypeCountsAtom);
const { data: documentTypeCountsData } = documentTypeCountsQuery;
// Transform the response into the expected format
const documentTypes = useMemo(() => {
if (!documentTypeCountsData) return [];
return Object.entries(documentTypeCountsData).map(([type, count]) => ({
type,
count,
}));
}, [documentTypeCountsData]);
const { connectors: searchConnectors } = useSearchSourceConnectors(
false,
Number(search_space_id)
);
// Filter for non-indexable connectors (live search)
const liveSearchConnectors = useMemo(
() => searchConnectors.filter((connector) => !connector.is_indexable),
[searchConnectors]
);
// Memoize document IDs to prevent infinite re-renders
const documentIds = useMemo(() => {
return selectedDocuments.map((doc) => doc.id);
}, [selectedDocuments]);
// Memoize connector types to prevent infinite re-renders
const connectorTypes = useMemo(() => {
return selectedConnectors;
}, [selectedConnectors]);
// Unified localStorage management for chat state
interface ChatState {
selectedDocuments: Document[];
selectedConnectors: string[];
researchMode: "QNA"; // Always QNA mode
topK: number;
}
const getChatStateStorageKey = (searchSpaceId: string, chatId: string) =>
`surfsense_chat_state_${searchSpaceId}_${chatId}`;
const storeChatState = (searchSpaceId: string, chatId: string, state: ChatState) => {
const key = getChatStateStorageKey(searchSpaceId, chatId);
localStorage.setItem(key, JSON.stringify(state));
};
const restoreChatState = (searchSpaceId: string, chatId: string): ChatState | null => {
const key = getChatStateStorageKey(searchSpaceId, chatId);
const stored = localStorage.getItem(key);
if (stored) {
localStorage.removeItem(key); // Clean up after restoration
try {
return JSON.parse(stored);
} catch (error) {
console.error("Error parsing stored chat state:", error);
return null;
}
}
return null;
};
const handler = useChat({
api: `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chat`,
streamProtocol: "data",
initialMessages: [],
headers: {
...(token && { Authorization: `Bearer ${token}` }),
},
body: {
data: {
search_space_id: search_space_id,
selected_connectors: connectorTypes,
research_mode: researchMode,
document_ids_to_add_in_context: documentIds,
top_k: topK,
},
},
onError: (error) => {
console.error("Chat error:", error);
},
});
const customHandlerAppend = async (
message: Message | CreateMessage,
chatRequestOptions?: { data?: any }
) => {
// Use the first message content as the chat title (truncated to 100 chars)
const messageContent = typeof message.content === "string" ? message.content : "";
const chatTitle = messageContent.slice(0, 100) || "Untitled Chat";
const newChat = await createChat({
type: researchMode,
title: chatTitle,
initial_connectors: selectedConnectors,
messages: [
{
role: "user",
content: message.content,
},
],
search_space_id: Number(search_space_id),
});
if (newChat) {
// Store chat state before navigation
storeChatState(search_space_id as string, String(newChat.id), {
selectedDocuments,
selectedConnectors,
researchMode,
topK,
});
router.replace(`/dashboard/${search_space_id}/researcher/${newChat.id}`);
}
return String(newChat.id);
};
useEffect(() => {
if (token && !isNewChat && activeChatId) {
const chatData = activeChatState?.chatDetails;
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
// Only if we haven't already initiated for this chat and handler doesn't have messages yet
if (hasInitiatedResponse.current !== activeChatId && handler.messages.length === 0) {
hasInitiatedResponse.current = activeChatId;
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
useEffect(() => {
if (activeChatId && search_space_id) {
const restoredState = restoreChatState(search_space_id as string, activeChatId);
if (restoredState) {
setSelectedDocuments(restoredState.selectedDocuments);
setSelectedConnectors(restoredState.selectedConnectors);
setTopK(restoredState.topK);
// researchMode is always "QNA", no need to restore
}
}
}, [
activeChatId,
isChatLoading,
search_space_id,
setSelectedDocuments,
setSelectedConnectors,
setTopK,
]);
// Set all sources as default for new chats (only once on initial mount)
useEffect(() => {
if (
isNewChat &&
!hasSetInitialConnectors.current &&
selectedConnectors.length === 0 &&
documentTypes.length > 0
) {
// Combine all document types and live search connectors
const allSourceTypes = [
...documentTypes.map((dt) => dt.type),
...liveSearchConnectors.map((c) => c.connector_type),
];
if (allSourceTypes.length > 0) {
setSelectedConnectors(allSourceTypes);
hasSetInitialConnectors.current = true;
}
}
}, [
isNewChat,
documentTypes,
liveSearchConnectors,
selectedConnectors.length,
setSelectedConnectors,
]);
// Auto-update chat when messages change (only for existing chats)
useEffect(() => {
if (
!isNewChat &&
activeChatId &&
handler.status === "ready" &&
handler.messages.length > 0 &&
handler.messages[handler.messages.length - 1]?.role === "assistant"
) {
const userMessages = handler.messages.filter((msg) => msg.role === "user");
if (userMessages.length === 0) return;
const title = userMessages[0].content;
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 (
<div className="flex items-center justify-center h-full">
<div>Loading...</div>
</div>
);
}
return (
<ChatInterface
handler={{
...handler,
append: isNewChat ? customHandlerAppend : handler.append,
}}
onDocumentSelectionChange={setSelectedDocuments}
selectedDocuments={selectedDocuments}
onConnectorSelectionChange={setSelectedConnectors}
selectedConnectors={selectedConnectors}
topK={topK}
onTopKChange={setTopK}
/>
);
}

View file

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