mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-08 20:25:19 +02:00
Merge pull request #489 from MODSetter/dev
[Feat] Add jotai & tanstack query
This commit is contained in:
commit
1eb70e2734
30 changed files with 853 additions and 344 deletions
|
|
@ -1,4 +1,5 @@
|
|||
"use client";
|
||||
import { useAtom } from "jotai";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
|
|
@ -6,7 +7,9 @@ import { useRouter } from "next/navigation";
|
|||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||
import { ValidationError } from "@/lib/error";
|
||||
|
||||
export function LocalLoginForm() {
|
||||
const t = useTranslations("auth");
|
||||
|
|
@ -14,11 +17,16 @@ export function LocalLoginForm() {
|
|||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorTitle, setErrorTitle] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<{
|
||||
title: string | null;
|
||||
message: string | null;
|
||||
}>({
|
||||
title: null,
|
||||
message: null,
|
||||
});
|
||||
const [authType, setAuthType] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom);
|
||||
|
||||
useEffect(() => {
|
||||
// Get the auth type from environment variables
|
||||
|
|
@ -27,36 +35,17 @@ export function LocalLoginForm() {
|
|||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null); // Clear any previous errors
|
||||
setErrorTitle(null);
|
||||
setError({ title: null, message: null }); // Clear any previous errors
|
||||
|
||||
// Show loading toast
|
||||
const loadingToast = toast.loading(tCommon("loading"));
|
||||
|
||||
try {
|
||||
// Create form data for the API request
|
||||
const formData = new URLSearchParams();
|
||||
formData.append("username", username);
|
||||
formData.append("password", password);
|
||||
formData.append("grant_type", "password");
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/login`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: formData.toString(),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
const data = await login({
|
||||
username,
|
||||
password,
|
||||
grant_type: "password",
|
||||
});
|
||||
|
||||
// Success toast
|
||||
toast.success(t("login_success"), {
|
||||
|
|
@ -70,6 +59,16 @@ export function LocalLoginForm() {
|
|||
router.push(`/auth/callback?token=${data.access_token}`);
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
if (err instanceof ValidationError) {
|
||||
setError({ title: err.name, message: err.message });
|
||||
toast.error(err.name, {
|
||||
id: loadingToast,
|
||||
description: err.message,
|
||||
duration: 6000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Use auth-errors utility to get proper error details
|
||||
let errorCode = "UNKNOWN_ERROR";
|
||||
|
||||
|
|
@ -83,8 +82,10 @@ export function LocalLoginForm() {
|
|||
const errorDetails = getAuthErrorDetails(errorCode);
|
||||
|
||||
// Set persistent error display
|
||||
setErrorTitle(errorDetails.title);
|
||||
setError(errorDetails.description);
|
||||
setError({
|
||||
title: errorDetails.title,
|
||||
message: errorDetails.description,
|
||||
});
|
||||
|
||||
// Show error toast with conditional retry action
|
||||
const toastOptions: any = {
|
||||
|
|
@ -102,8 +103,6 @@ export function LocalLoginForm() {
|
|||
}
|
||||
|
||||
toast.error(errorDetails.title, toastOptions);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -112,7 +111,7 @@ export function LocalLoginForm() {
|
|||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Error Display */}
|
||||
<AnimatePresence>
|
||||
{error && errorTitle && (
|
||||
{error && error.title && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
|
|
@ -139,13 +138,12 @@ export function LocalLoginForm() {
|
|||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold mb-1">{errorTitle}</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
<p className="text-sm font-semibold mb-1">{error.title}</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error.message}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setErrorTitle(null);
|
||||
setError({ title: null, message: null });
|
||||
}}
|
||||
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
|
||||
aria-label="Dismiss error"
|
||||
|
|
@ -186,11 +184,11 @@ export function LocalLoginForm() {
|
|||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
|
||||
error
|
||||
error.title
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -209,11 +207,11 @@ export function LocalLoginForm() {
|
|||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={`mt-1 block w-full rounded-md border pr-10 px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
|
||||
error
|
||||
error.title
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -228,10 +226,10 @@ export function LocalLoginForm() {
|
|||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
disabled={isLoggingIn}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isLoading ? tCommon("loading") : t("sign_in")}
|
||||
{isLoggingIn ? tCommon("loading") : t("sign_in")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||
import { AppError, ValidationError } from "@/lib/error";
|
||||
import { AmbientBackground } from "../login/AmbientBackground";
|
||||
|
||||
export default function RegisterPage() {
|
||||
|
|
@ -16,10 +19,15 @@ export default function RegisterPage() {
|
|||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorTitle, setErrorTitle] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<{
|
||||
title: string | null;
|
||||
message: string | null;
|
||||
}>({
|
||||
title: null,
|
||||
message: null,
|
||||
});
|
||||
const router = useRouter();
|
||||
const [{ mutateAsync: register, isPending: isRegistering }] = useAtom(registerMutationAtom);
|
||||
|
||||
// Check authentication type and redirect if not LOCAL
|
||||
useEffect(() => {
|
||||
|
|
@ -34,8 +42,7 @@ export default function RegisterPage() {
|
|||
|
||||
// Form validation
|
||||
if (password !== confirmPassword) {
|
||||
setError(t("passwords_no_match"));
|
||||
setErrorTitle(t("password_mismatch"));
|
||||
setError({ title: t("password_mismatch"), message: t("passwords_no_match_desc") });
|
||||
toast.error(t("password_mismatch"), {
|
||||
description: t("passwords_no_match_desc"),
|
||||
duration: 4000,
|
||||
|
|
@ -43,48 +50,20 @@ export default function RegisterPage() {
|
|||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null); // Clear any previous errors
|
||||
setErrorTitle(null);
|
||||
setError({ title: null, message: null }); // Clear any previous errors
|
||||
|
||||
// Show loading toast
|
||||
const loadingToast = toast.loading(t("creating_account"));
|
||||
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
is_verified: false,
|
||||
}),
|
||||
await register({
|
||||
email,
|
||||
password,
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
is_verified: false,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok && response.status === 403) {
|
||||
const friendlyMessage =
|
||||
"Registrations are currently closed. If you need access, contact your administrator.";
|
||||
setErrorTitle("Registration is disabled");
|
||||
setError(friendlyMessage);
|
||||
toast.error("Registration is disabled", {
|
||||
id: loadingToast,
|
||||
description: friendlyMessage,
|
||||
duration: 6000,
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// Success toast
|
||||
toast.success(t("register_success"), {
|
||||
id: loadingToast,
|
||||
|
|
@ -97,6 +76,34 @@ export default function RegisterPage() {
|
|||
router.push("/login?registered=true");
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) {
|
||||
switch (err.status) {
|
||||
case 403: {
|
||||
const friendlyMessage =
|
||||
"Registrations are currently closed. If you need access, contact your administrator.";
|
||||
setError({ title: "Registration is disabled", message: friendlyMessage });
|
||||
toast.error("Registration is disabled", {
|
||||
id: loadingToast,
|
||||
description: friendlyMessage,
|
||||
duration: 6000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (err instanceof ValidationError) {
|
||||
setError({ title: err.name, message: err.message });
|
||||
toast.error(err.name, {
|
||||
id: loadingToast,
|
||||
description: err.message,
|
||||
duration: 6000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Use auth-errors utility to get proper error details
|
||||
let errorCode = "UNKNOWN_ERROR";
|
||||
|
||||
|
|
@ -110,8 +117,7 @@ export default function RegisterPage() {
|
|||
const errorDetails = getAuthErrorDetails(errorCode);
|
||||
|
||||
// Set persistent error display
|
||||
setErrorTitle(errorDetails.title);
|
||||
setError(errorDetails.description);
|
||||
setError({ title: errorDetails.title, message: errorDetails.description });
|
||||
|
||||
// Show error toast with conditional retry action
|
||||
const toastOptions: any = {
|
||||
|
|
@ -129,8 +135,6 @@ export default function RegisterPage() {
|
|||
}
|
||||
|
||||
toast.error(errorDetails.title, toastOptions);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -147,7 +151,7 @@ export default function RegisterPage() {
|
|||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Enhanced Error Display */}
|
||||
<AnimatePresence>
|
||||
{error && errorTitle && (
|
||||
{error && error.title && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
|
|
@ -174,13 +178,12 @@ export default function RegisterPage() {
|
|||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold mb-1">{errorTitle}</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
<p className="text-sm font-semibold mb-1">{error.title}</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error.message}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setErrorTitle(null);
|
||||
setError({ title: null, message: null });
|
||||
}}
|
||||
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
|
||||
aria-label="Dismiss error"
|
||||
|
|
@ -221,11 +224,11 @@ export default function RegisterPage() {
|
|||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
|
||||
error
|
||||
error.title
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -243,11 +246,11 @@ export default function RegisterPage() {
|
|||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
|
||||
error
|
||||
error.title
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -265,20 +268,20 @@ export default function RegisterPage() {
|
|||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
|
||||
error
|
||||
error.title
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
disabled={isRegistering}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isLoading ? t("creating_account_btn") : t("register")}
|
||||
{isRegistering ? t("creating_account_btn") : t("register")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import {
|
||||
Calendar,
|
||||
ExternalLink,
|
||||
|
|
@ -13,7 +14,8 @@ import {
|
|||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms";
|
||||
import { activeSearchSpaceChatsAtom } from "@/atoms/chats/chat-querie.atoms";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -49,19 +51,18 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface Chat {
|
||||
created_at: string;
|
||||
id: number;
|
||||
type: "DOCUMENT" | "CHAT";
|
||||
type: "QNA";
|
||||
title: string;
|
||||
search_space_id: number;
|
||||
state_version: number;
|
||||
}
|
||||
|
||||
export interface ChatDetails {
|
||||
type: "DOCUMENT" | "CHAT";
|
||||
type: "QNA";
|
||||
title: string;
|
||||
initial_connectors: string[];
|
||||
messages: any[];
|
||||
|
|
@ -91,18 +92,24 @@ const MotionCard = motion(Card);
|
|||
|
||||
export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) {
|
||||
const router = useRouter();
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const [filteredChats, setFilteredChats] = useState<Chat[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [selectedType, setSelectedType] = useState<string>("all");
|
||||
const [sortOrder, setSortOrder] = useState<string>("newest");
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [chatToDelete, setChatToDelete] = useState<{ id: number; title: string } | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [chatToDelete, setChatToDelete] = useState<{
|
||||
id: number;
|
||||
title: string;
|
||||
} | null>(null);
|
||||
const {
|
||||
isFetching: isFetchingChats,
|
||||
data: chats,
|
||||
error: fetchError,
|
||||
} = useAtomValue(activeSearchSpaceChatsAtom);
|
||||
const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] =
|
||||
useAtom(deleteChatMutationAtom);
|
||||
|
||||
const chatsPerPage = 9;
|
||||
const searchParams = useSearchParams();
|
||||
|
|
@ -118,58 +125,9 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
|||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Fetch chats from API
|
||||
useEffect(() => {
|
||||
const fetchChats = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Get token from localStorage
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
|
||||
if (!token) {
|
||||
setError("Authentication token not found. Please log in again.");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch all chats for this search space
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats?search_space_id=${searchSpaceId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(`Failed to fetch chats: ${response.status} ${errorData?.error || ""}`);
|
||||
}
|
||||
|
||||
const data: Chat[] = await response.json();
|
||||
setChats(data);
|
||||
setFilteredChats(data);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error("Error fetching chats:", error);
|
||||
setError(error instanceof Error ? error.message : "Unknown error occurred");
|
||||
setChats([]);
|
||||
setFilteredChats([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchChats();
|
||||
}, [searchSpaceId]);
|
||||
|
||||
// Filter and sort chats based on search query, type, and sort order
|
||||
useEffect(() => {
|
||||
let result = [...chats];
|
||||
let result = [...(chats || [])];
|
||||
|
||||
// Filter by search term
|
||||
if (searchQuery) {
|
||||
|
|
@ -203,49 +161,19 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
|||
const handleDeleteChat = async () => {
|
||||
if (!chatToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
if (!token) {
|
||||
setIsDeleting(false);
|
||||
return;
|
||||
}
|
||||
await deleteChat(chatToDelete.id);
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${chatToDelete.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete chat: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Close dialog and refresh chats
|
||||
setDeleteDialogOpen(false);
|
||||
setChatToDelete(null);
|
||||
|
||||
// Update local state by removing the deleted chat
|
||||
setChats((prevChats) => prevChats.filter((chat) => chat.id !== chatToDelete.id));
|
||||
} catch (error) {
|
||||
console.error("Error deleting chat:", error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
setChatToDelete(null);
|
||||
};
|
||||
|
||||
// Calculate pagination
|
||||
const indexOfLastChat = currentPage * chatsPerPage;
|
||||
const indexOfFirstChat = indexOfLastChat - chatsPerPage;
|
||||
const indexOfLastChat = currentPage * chatsPerPage; // Index of last chat in the current page
|
||||
const indexOfFirstChat = indexOfLastChat - chatsPerPage; // Index of first chat in the current page
|
||||
const currentChats = filteredChats.slice(indexOfFirstChat, indexOfLastChat);
|
||||
|
||||
// Get unique chat types for filter dropdown
|
||||
const chatTypes = ["all", ...Array.from(new Set(chats.map((chat) => chat.type)))];
|
||||
const chatTypes = chats ? ["all", ...Array.from(new Set(chats.map((chat) => chat.type)))] : [];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
|
@ -307,7 +235,7 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
|||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
{isLoading && (
|
||||
{isFetchingChats && (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
|
|
@ -316,14 +244,14 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
|||
</div>
|
||||
)}
|
||||
|
||||
{error && !isLoading && (
|
||||
{fetchError && !isFetchingChats && (
|
||||
<div className="border border-destructive/50 text-destructive p-4 rounded-md">
|
||||
<h3 className="font-medium">Error loading chats</h3>
|
||||
<p className="text-sm">{error}</p>
|
||||
<p className="text-sm">{fetchError.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && filteredChats.length === 0 && (
|
||||
{!isFetchingChats && !fetchError && filteredChats.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-40 gap-2 text-center">
|
||||
<MessageCircleMore className="h-8 w-8 text-muted-foreground" />
|
||||
<h3 className="font-medium">No chats found</h3>
|
||||
|
|
@ -336,7 +264,7 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
|||
)}
|
||||
|
||||
{/* Chat Grid */}
|
||||
{!isLoading && !error && filteredChats.length > 0 && (
|
||||
{!isFetchingChats && !fetchError && filteredChats.length > 0 && (
|
||||
<AnimatePresence mode="wait">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{currentChats.map((chat, index) => (
|
||||
|
|
@ -422,7 +350,7 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
|||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{!isLoading && !error && totalPages > 1 && (
|
||||
{!isFetchingChats && !fetchError && totalPages > 1 && (
|
||||
<Pagination className="mt-8">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
|
|
@ -504,17 +432,17 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
|||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
disabled={isDeleting}
|
||||
disabled={isDeletingChat}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteChat}
|
||||
disabled={isDeleting}
|
||||
disabled={isDeletingChat}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDeleting ? (
|
||||
{isDeletingChat ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Deleting...
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { Loader2, PanelRight } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms";
|
||||
import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom";
|
||||
import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContainer";
|
||||
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
|
|
@ -17,8 +20,6 @@ import { Separator } from "@/components/ui/separator";
|
|||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { activeChatIdAtom } from "@/stores/chat/active-chat.atom";
|
||||
import { chatUIAtom } from "@/stores/chat/chat-ui.atom";
|
||||
|
||||
export function DashboardClientLayout({
|
||||
children,
|
||||
|
|
@ -35,9 +36,11 @@ export function DashboardClientLayout({
|
|||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchSpaceIdNum = Number(searchSpaceId);
|
||||
|
||||
const [chatUIState, setChatUIState] = useAtom(chatUIAtom);
|
||||
const { search_space_id, chat_id } = useParams();
|
||||
const [chatUIState, setChatUIState] = useAtom(activeChathatUIAtom);
|
||||
const activeChatId = useAtomValue(activeChatIdAtom);
|
||||
const setActiveSearchSpaceIdState = useSetAtom(activeSearchSpaceIdAtom);
|
||||
const setActiveChatIdState = useSetAtom(activeChatIdAtom);
|
||||
const [showIndicator, setShowIndicator] = useState(false);
|
||||
|
||||
const { isChatPannelOpen } = chatUIState;
|
||||
|
|
@ -119,6 +122,29 @@ export function DashboardClientLayout({
|
|||
hasCheckedOnboarding,
|
||||
]);
|
||||
|
||||
// Synchronize active search space and chat IDs with URL
|
||||
useEffect(() => {
|
||||
const activeSeacrhSpaceId =
|
||||
typeof search_space_id === "string"
|
||||
? search_space_id
|
||||
: Array.isArray(search_space_id) && search_space_id.length > 0
|
||||
? search_space_id[0]
|
||||
: "";
|
||||
if (!activeSeacrhSpaceId) return;
|
||||
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
|
||||
}, [search_space_id]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeChatId =
|
||||
typeof chat_id === "string"
|
||||
? chat_id
|
||||
: Array.isArray(chat_id) && chat_id.length > 0
|
||||
? chat_id[0]
|
||||
: "";
|
||||
if (!activeChatId) return;
|
||||
setActiveChatIdState(activeChatId);
|
||||
}, [chat_id, search_space_id]);
|
||||
|
||||
// Show loading screen while checking onboarding status (only on first load)
|
||||
if (!hasCheckedOnboarding && loading && !isOnboardingPage) {
|
||||
return (
|
||||
|
|
@ -170,7 +196,7 @@ export function DashboardClientLayout({
|
|||
<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">
|
||||
<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" />
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@ const DashboardPage = () => {
|
|||
{searchSpaces &&
|
||||
searchSpaces.length > 0 &&
|
||||
searchSpaces.map((space) => (
|
||||
<motion.div key={space.id} variants={itemVariants} className="aspect-[4/3]">
|
||||
<motion.div key={space.id} variants={itemVariants} className="aspect-4/3">
|
||||
<Tilt
|
||||
rotationFactor={6}
|
||||
isRevese
|
||||
|
|
|
|||
22
surfsense_web/atoms/auth/auth-mutation.atoms.ts
Normal file
22
surfsense_web/atoms/auth/auth-mutation.atoms.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { atomWithMutation } from "jotai-tanstack-query";
|
||||
import type { LoginRequest, RegisterRequest } from "@/contracts/types/auth.types";
|
||||
import { authApiService } from "@/lib/apis/auth-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
export const registerMutationAtom = atomWithMutation(() => {
|
||||
return {
|
||||
mutationKey: cacheKeys.auth.user,
|
||||
mutationFn: async (request: RegisterRequest) => {
|
||||
return authApiService.register(request);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const loginMutationAtom = atomWithMutation(() => {
|
||||
return {
|
||||
mutationKey: cacheKeys.auth.user,
|
||||
mutationFn: async (request: LoginRequest) => {
|
||||
return authApiService.login(request);
|
||||
},
|
||||
};
|
||||
});
|
||||
30
surfsense_web/atoms/chats/chat-mutation.atoms.ts
Normal file
30
surfsense_web/atoms/chats/chat-mutation.atoms.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { atomWithMutation } from "jotai-tanstack-query";
|
||||
import { toast } from "sonner";
|
||||
import type { Chat } from "@/app/dashboard/[search_space_id]/chats/chats-client";
|
||||
import { chatApiService } from "@/lib/apis/chats-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
import { activeSearchSpaceIdAtom } from "../seach-spaces/seach-space-queries.atom";
|
||||
|
||||
export const deleteChatMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
const authToken = localStorage.getItem("surfsense_bearer_token");
|
||||
|
||||
return {
|
||||
mutationKey: cacheKeys.activeSearchSpace.chats(searchSpaceId ?? ""),
|
||||
enabled: !!searchSpaceId && !!authToken,
|
||||
mutationFn: async (chatId: number) => {
|
||||
return chatApiService.deleteChat({ id: chatId });
|
||||
},
|
||||
|
||||
onSuccess: (_, chatId) => {
|
||||
toast.success("Chat deleted successfully");
|
||||
queryClient.setQueryData(
|
||||
cacheKeys.activeSearchSpace.chats(searchSpaceId!),
|
||||
(oldData: Chat[]) => {
|
||||
return oldData.filter((chat) => chat.id !== chatId);
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -2,8 +2,10 @@ import { atom } from "jotai";
|
|||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client";
|
||||
import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client";
|
||||
import { fetchChatDetails } from "@/lib/apis/chat-apis";
|
||||
import { getPodcastByChatId } from "@/lib/apis/podcast-apis";
|
||||
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;
|
||||
|
|
@ -18,7 +20,7 @@ export const activeChatAtom = atomWithQuery<ActiveChatState>((get) => {
|
|||
const authToken = localStorage.getItem("surfsense_bearer_token");
|
||||
|
||||
return {
|
||||
queryKey: ["activeChat", activeChatId],
|
||||
queryKey: cacheKeys.activeSearchSpace.activeChat(activeChatId ?? ""),
|
||||
enabled: !!activeChatId && !!authToken,
|
||||
queryFn: async () => {
|
||||
if (!authToken) {
|
||||
|
|
@ -30,10 +32,23 @@ export const activeChatAtom = atomWithQuery<ActiveChatState>((get) => {
|
|||
|
||||
const [podcast, chatDetails] = await Promise.all([
|
||||
getPodcastByChatId(activeChatId, authToken),
|
||||
fetchChatDetails(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) });
|
||||
},
|
||||
};
|
||||
});
|
||||
9
surfsense_web/atoms/chats/ui.atoms.ts
Normal file
9
surfsense_web/atoms/chats/ui.atoms.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { atom } from "jotai";
|
||||
|
||||
type ActiveChathatUIState = {
|
||||
isChatPannelOpen: boolean;
|
||||
};
|
||||
|
||||
export const activeChathatUIAtom = atom<ActiveChathatUIState>({
|
||||
isChatPannelOpen: false,
|
||||
});
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
import { useAtom } from "jotai";
|
||||
import { ExternalLink, Info, X } from "lucide-react";
|
||||
import { announcementDismissedAtom } from "@/atoms/announcement.atom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { announcementDismissedAtom } from "@/stores/announcement.atom";
|
||||
|
||||
export function AnnouncementBanner() {
|
||||
const [isDismissed, setIsDismissed] = useAtom(announcementDismissedAtom);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { type ChatHandler, ChatSection as LlamaIndexChatSection } from "@llamaindex/chat-ui";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { ChatInputUI } from "@/components/chat/ChatInputGroup";
|
||||
import { ChatMessagesUI } from "@/components/chat/ChatMessages";
|
||||
import type { Document } from "@/hooks/use-documents";
|
||||
import { activeChatIdAtom } from "@/stores/chat/active-chat.atom";
|
||||
import { ChatPanelContainer } from "./ChatPanel/ChatPanelContainer";
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
handler: ChatHandler;
|
||||
|
|
@ -34,13 +30,6 @@ export default function ChatInterface({
|
|||
onTopKChange,
|
||||
}: ChatInterfaceProps) {
|
||||
const { chat_id, search_space_id } = useParams();
|
||||
const setActiveChatIdState = useSetAtom(activeChatIdAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const id = typeof chat_id === "string" ? chat_id : chat_id ? chat_id[0] : "";
|
||||
if (!id) return;
|
||||
setActiveChatIdState(id);
|
||||
}, [chat_id, search_space_id]);
|
||||
|
||||
return (
|
||||
<LlamaIndexChatSection handler={handler} className="flex h-full max-w-7xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { LoaderIcon, PanelRight, TriangleAlert } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { generatePodcast } from "@/lib/apis/podcast-apis";
|
||||
import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms";
|
||||
import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms";
|
||||
import { generatePodcast } from "@/lib/apis/podcasts.api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { activeChatAtom, activeChatIdAtom } from "@/stores/chat/active-chat.atom";
|
||||
import { chatUIAtom } from "@/stores/chat/chat-ui.atom";
|
||||
import { ChatPanelView } from "./ChatPanelView";
|
||||
|
||||
export interface GeneratePodcastRequest {
|
||||
|
|
@ -24,7 +24,7 @@ export function ChatPanelContainer() {
|
|||
} = useAtomValue(activeChatAtom);
|
||||
const activeChatIdState = useAtomValue(activeChatIdAtom);
|
||||
const authToken = localStorage.getItem("surfsense_bearer_token");
|
||||
const { isChatPannelOpen } = useAtomValue(chatUIAtom);
|
||||
const { isChatPannelOpen } = useAtomValue(activeChathatUIAtom);
|
||||
|
||||
const handleGeneratePodcast = async (request: GeneratePodcastRequest) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import { useAtom, useAtomValue } from "jotai";
|
|||
import { AlertCircle, Play, RefreshCw, Sparkles } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useCallback } from "react";
|
||||
import { activeChatAtom } from "@/atoms/chats/chat-querie.atoms";
|
||||
import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { activeChatAtom } from "@/stores/chat/active-chat.atom";
|
||||
import { chatUIAtom } from "@/stores/chat/chat-ui.atom";
|
||||
import { getPodcastStalenessMessage, isPodcastStale } from "../PodcastUtils";
|
||||
import type { GeneratePodcastRequest } from "./ChatPanelContainer";
|
||||
import { ConfigModal } from "./ConfigModal";
|
||||
|
|
@ -17,7 +17,7 @@ interface ChatPanelViewProps {
|
|||
}
|
||||
|
||||
export function ChatPanelView(props: ChatPanelViewProps) {
|
||||
const [chatUIState, setChatUIState] = useAtom(chatUIAtom);
|
||||
const [chatUIState, setChatUIState] = useAtom(activeChathatUIAtom);
|
||||
const { data: activeChatState } = useAtomValue(activeChatAtom);
|
||||
|
||||
const { isChatPannelOpen } = chatUIState;
|
||||
|
|
@ -40,6 +40,7 @@ export function ChatPanelView(props: ChatPanelViewProps) {
|
|||
});
|
||||
}, [chatDetails, generatePodcast]);
|
||||
|
||||
// biome-ignore-start lint/a11y/useSemanticElements: using div for custom layout — will convert later
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className={cn("w-full p-4", !isChatPannelOpen && "flex items-center justify-center")}>
|
||||
|
|
@ -202,4 +203,5 @@ export function ChatPanelView(props: ChatPanelViewProps) {
|
|||
) : null}
|
||||
</div>
|
||||
);
|
||||
// biome-ignore-end lint/a11y/useSemanticElements : using div for custom layout — will convert later
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
import { useAtomValue } from "jotai";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useCallback, useContext, useState } from "react";
|
||||
import { activeChatAtom } from "@/atoms/chats/chat-querie.atoms";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { activeChatAtom } from "@/stores/chat/active-chat.atom";
|
||||
import type { GeneratePodcastRequest } from "./ChatPanelContainer";
|
||||
|
||||
interface ConfigModalProps {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client";
|
||||
import React, { useEffect } from "react";
|
||||
import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
|
|
@ -13,7 +14,6 @@ import {
|
|||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { useSearchSpace } from "@/hooks/use-search-space";
|
||||
import { fetchChatDetails } from "@/lib/apis/chat-apis";
|
||||
|
||||
interface BreadcrumbItemInterface {
|
||||
label: string;
|
||||
|
|
@ -23,13 +23,10 @@ interface BreadcrumbItemInterface {
|
|||
export function DashboardBreadcrumb() {
|
||||
const t = useTranslations("breadcrumb");
|
||||
const pathname = usePathname();
|
||||
const [chatDetails, setChatDetails] = useState<ChatDetails | null>(null);
|
||||
|
||||
const { data: activeChatState } = useAtomValue(activeChatAtom);
|
||||
// Extract search space ID and chat ID from pathname
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const searchSpaceId = segments[0] === "dashboard" && segments[1] ? segments[1] : null;
|
||||
const chatId =
|
||||
segments[0] === "dashboard" && segments[2] === "researcher" && segments[3] ? segments[3] : null;
|
||||
|
||||
// Fetch search space details if we have an ID
|
||||
const { searchSpace } = useSearchSpace({
|
||||
|
|
@ -37,18 +34,6 @@ export function DashboardBreadcrumb() {
|
|||
autoFetch: !!searchSpaceId,
|
||||
});
|
||||
|
||||
// Fetch chat details if we have a chat ID
|
||||
useEffect(() => {
|
||||
if (chatId) {
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
if (token) {
|
||||
fetchChatDetails(chatId, token).then(setChatDetails);
|
||||
}
|
||||
} else {
|
||||
setChatDetails(null);
|
||||
}
|
||||
}, [chatId]);
|
||||
|
||||
// Parse the pathname to create breadcrumb items
|
||||
const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
|
|
@ -125,7 +110,7 @@ export function DashboardBreadcrumb() {
|
|||
// Handle researcher sub-sections (chat IDs)
|
||||
if (section === "researcher") {
|
||||
// Use the actual chat title if available, otherwise fall back to the ID
|
||||
const chatLabel = chatDetails?.title || subSection;
|
||||
const chatLabel = activeChatState?.chatDetails?.title || subSection;
|
||||
breadcrumbs.push({
|
||||
label: t("researcher"),
|
||||
href: `/dashboard/${segments[1]}/researcher`,
|
||||
|
|
|
|||
30
surfsense_web/contracts/types/auth.types.ts
Normal file
30
surfsense_web/contracts/types/auth.types.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const loginRequest = z.object({
|
||||
username: z.string(),
|
||||
password: z.string().min(3, "Password must be at least 3 characters"),
|
||||
grant_type: z.string().optional(),
|
||||
});
|
||||
|
||||
export const loginResponse = z.object({
|
||||
access_token: z.string(),
|
||||
token_type: z.string(),
|
||||
});
|
||||
|
||||
export const registerRequest = loginRequest.omit({ grant_type: true, username: true }).extend({
|
||||
email: z.string().email("Invalid email address"),
|
||||
is_active: z.boolean().optional(),
|
||||
is_superuser: z.boolean().optional(),
|
||||
is_verified: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const registerResponse = registerRequest.omit({ password: true }).extend({
|
||||
id: z.string(),
|
||||
pages_limit: z.number(),
|
||||
pages_used: z.number(),
|
||||
});
|
||||
|
||||
export type LoginRequest = z.infer<typeof loginRequest>;
|
||||
export type LoginResponse = z.infer<typeof loginResponse>;
|
||||
export type RegisterRequest = z.infer<typeof registerRequest>;
|
||||
export type RegisterResponse = z.infer<typeof registerResponse>;
|
||||
50
surfsense_web/contracts/types/chat.types.ts
Normal file
50
surfsense_web/contracts/types/chat.types.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { Message } from "@ai-sdk/react";
|
||||
import { z } from "zod";
|
||||
import { paginationQueryParams } from ".";
|
||||
|
||||
export const chatTypeEnum = z.enum(["QNA"]);
|
||||
|
||||
export const chatSummary = z.object({
|
||||
created_at: z.string(),
|
||||
id: z.number(),
|
||||
type: chatTypeEnum,
|
||||
title: z.string(),
|
||||
search_space_id: z.number(),
|
||||
state_version: z.number(),
|
||||
});
|
||||
|
||||
export const chatDetails = chatSummary.extend({
|
||||
initial_connectors: z.array(z.string()),
|
||||
messages: z.array(z.any()),
|
||||
});
|
||||
|
||||
export const getChatDetailsRequest = chatSummary.pick({ id: true });
|
||||
|
||||
export const getChatsBySearchSpaceRequest = chatSummary
|
||||
.pick({
|
||||
search_space_id: true,
|
||||
})
|
||||
.merge(paginationQueryParams);
|
||||
|
||||
export const deleteChatResponse = z.object({
|
||||
message: z.literal("Chat deleted successfully"),
|
||||
});
|
||||
|
||||
export const deleteChatRequest = chatSummary.pick({ id: true });
|
||||
|
||||
export const createChatRequest = chatDetails.omit({
|
||||
created_at: true,
|
||||
id: true,
|
||||
state_version: true,
|
||||
});
|
||||
|
||||
export const updateChatRequest = chatDetails.omit({ created_at: true, state_version: true });
|
||||
|
||||
export type ChatSummary = z.infer<typeof chatSummary>;
|
||||
export type ChatDetails = z.infer<typeof chatDetails> & { messages: Message[] };
|
||||
export type GetChatDetailsRequest = z.infer<typeof getChatDetailsRequest>;
|
||||
export type GetChatsBySearchSpaceRequest = z.infer<typeof getChatsBySearchSpaceRequest>;
|
||||
export type DeleteChatResponse = z.infer<typeof deleteChatResponse>;
|
||||
export type DeleteChatRequest = z.infer<typeof deleteChatRequest>;
|
||||
export type CreateChatRequest = z.infer<typeof createChatRequest>;
|
||||
export type UpdateChatRequest = z.infer<typeof updateChatRequest>;
|
||||
8
surfsense_web/contracts/types/index.ts
Normal file
8
surfsense_web/contracts/types/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const paginationQueryParams = z.object({
|
||||
limit: z.number().optional(),
|
||||
skip: z.number().optional(),
|
||||
});
|
||||
|
||||
export type PaginationQueryParams = z.infer<typeof paginationQueryParams>;
|
||||
57
surfsense_web/lib/apis/auth-api.service.ts
Normal file
57
surfsense_web/lib/apis/auth-api.service.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import {
|
||||
type LoginRequest,
|
||||
loginRequest,
|
||||
loginResponse,
|
||||
type RegisterRequest,
|
||||
registerRequest,
|
||||
registerResponse,
|
||||
} from "@/contracts/types/auth.types";
|
||||
import { ValidationError } from "../error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
export class AuthApiService {
|
||||
login = async (request: LoginRequest) => {
|
||||
// Validate the request
|
||||
const parsedRequest = loginRequest.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}`);
|
||||
}
|
||||
|
||||
// Create form data for the API request
|
||||
const formData = new URLSearchParams();
|
||||
formData.append("username", request.username);
|
||||
formData.append("password", request.password);
|
||||
formData.append("grant_type", "password");
|
||||
|
||||
return baseApiService.post(`/auth/jwt/login`, loginResponse, {
|
||||
body: formData.toString(),
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
register = async (request: RegisterRequest) => {
|
||||
// Validate the request
|
||||
const parsedRequest = registerRequest.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(`/auth/register`, registerResponse, {
|
||||
body: parsedRequest.data,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const authApiService = new AuthApiService();
|
||||
187
surfsense_web/lib/apis/base-api.service.ts
Normal file
187
surfsense_web/lib/apis/base-api.service.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import type z from "zod";
|
||||
import {
|
||||
AppError,
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from "../error";
|
||||
|
||||
export type RequestOptions = {
|
||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||
headers?: Record<string, string>;
|
||||
contentType?: "application/json" | "application/x-www-form-urlencoded";
|
||||
signal?: AbortSignal;
|
||||
body?: any;
|
||||
// Add more options as needed
|
||||
};
|
||||
|
||||
export class BaseApiService {
|
||||
bearerToken: string;
|
||||
baseUrl: string;
|
||||
|
||||
noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"]; // Add more endpoints as needed
|
||||
|
||||
constructor(bearerToken: string, baseUrl: string) {
|
||||
this.bearerToken = bearerToken;
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
setBearerToken(bearerToken: string) {
|
||||
this.bearerToken = bearerToken;
|
||||
}
|
||||
|
||||
async request<T>(
|
||||
url: string,
|
||||
responseSchema?: z.ZodSchema<T>,
|
||||
options?: RequestOptions
|
||||
): Promise<T> {
|
||||
try {
|
||||
const defaultOptions: RequestOptions = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.bearerToken || ""}`,
|
||||
},
|
||||
method: "GET",
|
||||
};
|
||||
|
||||
const mergedOptions: RequestOptions = {
|
||||
...defaultOptions,
|
||||
...(options ?? {}),
|
||||
headers: {
|
||||
...defaultOptions.headers,
|
||||
...(options?.headers ?? {}),
|
||||
},
|
||||
};
|
||||
|
||||
if (!this.baseUrl) {
|
||||
throw new AppError("Base URL is not set.");
|
||||
}
|
||||
|
||||
if (!this.bearerToken && !this.noAuthEndpoints.includes(url)) {
|
||||
throw new AuthenticationError("You are not authenticated. Please login again.");
|
||||
}
|
||||
|
||||
const fullUrl = new URL(url, this.baseUrl).toString();
|
||||
|
||||
const response = await fetch(fullUrl, mergedOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
// biome-ignore lint/suspicious: Unknown
|
||||
let data;
|
||||
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to parse response as JSON:", error);
|
||||
|
||||
throw new AppError("Something went wrong", response.status, response.statusText);
|
||||
}
|
||||
|
||||
// for fastapi errors response
|
||||
if ("detail" in data) {
|
||||
throw new AppError(data.detail, response.status, response.statusText);
|
||||
}
|
||||
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
throw new AuthenticationError(
|
||||
"You are not authenticated. Please login again.",
|
||||
response.status,
|
||||
response.statusText
|
||||
);
|
||||
case 403:
|
||||
throw new AuthorizationError(
|
||||
"You don't have permission to access this resource.",
|
||||
response.status,
|
||||
response.statusText
|
||||
);
|
||||
case 404:
|
||||
throw new NotFoundError("Resource not found", response.status, response.statusText);
|
||||
// Add more cases as needed
|
||||
default:
|
||||
throw new AppError("Something went wrong", response.status, response.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious: Unknown
|
||||
let data;
|
||||
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to parse response as JSON:", error);
|
||||
|
||||
throw new AppError("Something went wrong", response.status, response.statusText);
|
||||
}
|
||||
|
||||
if (!responseSchema) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const parsedData = responseSchema.safeParse(data);
|
||||
|
||||
if (!parsedData.success) {
|
||||
/** The request was successful, but the response data does not match the expected schema.
|
||||
* This is a client side error, and should be fixed by updating the responseSchema to keep things typed.
|
||||
* This error should not be shown to the user , it is for dev only.
|
||||
*/
|
||||
console.error("Invalid API response schema:", parsedData.error);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Request failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async get<T>(
|
||||
url: string,
|
||||
responseSchema?: z.ZodSchema<T>,
|
||||
options?: Omit<RequestOptions, "method">
|
||||
) {
|
||||
return this.request(url, responseSchema, {
|
||||
...options,
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
async post<T>(
|
||||
url: string,
|
||||
responseSchema?: z.ZodSchema<T>,
|
||||
options?: Omit<RequestOptions, "method">
|
||||
) {
|
||||
return this.request(url, responseSchema, {
|
||||
method: "POST",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async put<T>(
|
||||
url: string,
|
||||
responseSchema?: z.ZodSchema<T>,
|
||||
options?: Omit<RequestOptions, "method">
|
||||
) {
|
||||
return this.request(url, responseSchema, {
|
||||
method: "PUT",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async delete<T>(
|
||||
url: string,
|
||||
responseSchema?: z.ZodSchema<T>,
|
||||
options?: Omit<RequestOptions, "method">
|
||||
) {
|
||||
return this.request(url, responseSchema, {
|
||||
method: "DELETE",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const baseApiService = new BaseApiService(
|
||||
typeof window !== "undefined" ? localStorage.getItem("surfsense_bearer_token") || "" : "",
|
||||
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || ""
|
||||
);
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client";
|
||||
|
||||
export const fetchChatDetails = async (
|
||||
chatId: string,
|
||||
authToken: string
|
||||
): Promise<ChatDetails | 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 ${authToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
130
surfsense_web/lib/apis/chats-api.service.ts
Normal file
130
surfsense_web/lib/apis/chats-api.service.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
type CreateChatRequest,
|
||||
chatDetails,
|
||||
chatSummary,
|
||||
createChatRequest,
|
||||
type DeleteChatRequest,
|
||||
deleteChatRequest,
|
||||
deleteChatResponse,
|
||||
type GetChatDetailsRequest,
|
||||
type GetChatsBySearchSpaceRequest,
|
||||
getChatDetailsRequest,
|
||||
getChatsBySearchSpaceRequest,
|
||||
type UpdateChatRequest,
|
||||
updateChatRequest,
|
||||
} from "@/contracts/types/chat.types";
|
||||
import { ValidationError } from "../error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
export class ChatApiService {
|
||||
getChatDetails = async (request: GetChatDetailsRequest) => {
|
||||
// Validate the request
|
||||
const parsedRequest = getChatDetailsRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
|
||||
// Format a user frendly error message
|
||||
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.get(`/api/v1/chats/${request.id}`, chatDetails);
|
||||
};
|
||||
|
||||
getChatsBySearchSpace = async (request: GetChatsBySearchSpaceRequest) => {
|
||||
// Validate the request
|
||||
const parsedRequest = getChatsBySearchSpaceRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
|
||||
// Format a user frendly error message
|
||||
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.get(
|
||||
`/api/v1/chats?search_space_id=${request.search_space_id}`,
|
||||
z.array(chatSummary)
|
||||
);
|
||||
};
|
||||
|
||||
deleteChat = async (request: DeleteChatRequest) => {
|
||||
// Validate the request
|
||||
const parsedRequest = deleteChatRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
|
||||
// Format a user frendly error message
|
||||
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.delete(`/api/v1/chats/${request.id}`, deleteChatResponse);
|
||||
};
|
||||
|
||||
createChat = async (request: CreateChatRequest) => {
|
||||
// Validate the request
|
||||
const parsedRequest = createChatRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
|
||||
// Format a user frendly error message
|
||||
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const { type, title, initial_connectors, messages, search_space_id } = parsedRequest.data;
|
||||
|
||||
return baseApiService.post(
|
||||
`/api/v1/chats`,
|
||||
|
||||
chatSummary,
|
||||
{
|
||||
body: {
|
||||
type,
|
||||
title,
|
||||
initial_connectors,
|
||||
messages,
|
||||
search_space_id,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
updateChat = async (request: UpdateChatRequest) => {
|
||||
// Validate the request
|
||||
const parsedRequest = updateChatRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
|
||||
// Format a user frendly error message
|
||||
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const { type, title, initial_connectors, messages, search_space_id, id } = parsedRequest.data;
|
||||
|
||||
return baseApiService.put(
|
||||
`/api/v1/chats/${id}`,
|
||||
|
||||
chatSummary,
|
||||
{
|
||||
body: {
|
||||
type,
|
||||
title,
|
||||
initial_connectors,
|
||||
messages,
|
||||
search_space_id,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const chatApiService = new ChatApiService();
|
||||
|
|
@ -1,50 +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) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/by-chat/${Number(chatId)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to fetch podcast");
|
||||
}
|
||||
|
||||
return (await response.json()) as PodcastItem | null;
|
||||
} catch (err: any) {
|
||||
console.error("Error fetching podcast:", err);
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const generatePodcast = async (request: GeneratePodcastRequest, authToken: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/generate/`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to generate podcast");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error generating podcast:", error);
|
||||
}
|
||||
};
|
||||
74
surfsense_web/lib/apis/podcasts.api.ts
Normal file
74
surfsense_web/lib/apis/podcasts.api.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client";
|
||||
import type { GeneratePodcastRequest } from "@/components/chat/ChatPanel/ChatPanelContainer";
|
||||
|
||||
export const getPodcastByChatId = async (chatId: string, authToken: string) => {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/by-chat/${Number(chatId)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to fetch podcast");
|
||||
}
|
||||
|
||||
return (await response.json()) as PodcastItem | null;
|
||||
};
|
||||
|
||||
export const generatePodcast = async (request: GeneratePodcastRequest, authToken: string) => {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/generate/`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to generate podcast");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const loadPodcast = async (podcast: PodcastItem, authToken: string) => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch audio stream: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
return objectUrl;
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
throw new Error("Request timed out. Please try again.");
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
40
surfsense_web/lib/error.ts
Normal file
40
surfsense_web/lib/error.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
export class AppError extends Error {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
constructor(message: string, status?: number, statusText?: string) {
|
||||
super(message);
|
||||
this.name = this.constructor.name; // User friendly
|
||||
this.status = status;
|
||||
this.statusText = statusText; // Dev friendly
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends AppError {
|
||||
constructor(message: string, status?: number, statusText?: string) {
|
||||
super(message, status, statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message: string, status?: number, statusText?: string) {
|
||||
super(message, status, statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthenticationError extends AppError {
|
||||
constructor(message: string, status?: number, statusText?: string) {
|
||||
super(message, status, statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthorizationError extends AppError {
|
||||
constructor(message: string, status?: number, statusText?: string) {
|
||||
super(message, status, statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(message: string, status?: number, statusText?: string) {
|
||||
super(message, status, statusText);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,9 @@
|
|||
export const cacheKeys = {
|
||||
activeChat: (chatId: string) => ["activeChat", chatId],
|
||||
activeSearchSpace: {
|
||||
chats: (searchSpaceId: string) => ["active-search-space", "chats", searchSpaceId] as const,
|
||||
activeChat: (chatId: string) => ["active-search-space", "active-chat", chatId] as const,
|
||||
},
|
||||
auth: {
|
||||
user: ["auth", "user"] as const,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import type { Message } from "@ai-sdk/react";
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function getChatTitleFromMessages(messages: Message[]) {
|
||||
const userMessages = messages.filter((msg) => msg.role === "user");
|
||||
if (userMessages.length === 0) return "Untitled Chat";
|
||||
return userMessages[0].content;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
import { atom } from "jotai";
|
||||
|
||||
type ChatUIState = {
|
||||
isChatPannelOpen: boolean;
|
||||
};
|
||||
|
||||
export const chatUIAtom = atom<ChatUIState>({
|
||||
isChatPannelOpen: false,
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue