mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-22 23:31:12 +02:00
Merge Claude audit automation branch into nightly
Integrate audit automation improvements and security enhancements from Claude AI Assistant. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <code@anthropic.com>
This commit is contained in:
commit
82f8bc2f14
43 changed files with 2967 additions and 1231 deletions
|
|
@ -32,10 +32,10 @@ export function GoogleLoginButton() {
|
||||||
<div className="relative w-full overflow-hidden">
|
<div className="relative w-full overflow-hidden">
|
||||||
<AmbientBackground />
|
<AmbientBackground />
|
||||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||||
<Logo className="rounded-md" />
|
<Logo className="rounded-full my-8" />
|
||||||
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
|
{/* <h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
|
||||||
{t("welcome_back")}
|
Login
|
||||||
</h1>
|
</h1> */}
|
||||||
{/*
|
{/*
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -5 }}
|
initial={{ opacity: 0, y: -5 }}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
@ -6,7 +7,9 @@ import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||||
|
import { ValidationError } from "@/lib/error";
|
||||||
|
|
||||||
export function LocalLoginForm() {
|
export function LocalLoginForm() {
|
||||||
const t = useTranslations("auth");
|
const t = useTranslations("auth");
|
||||||
|
|
@ -14,11 +17,16 @@ export function LocalLoginForm() {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<{
|
||||||
const [errorTitle, setErrorTitle] = useState<string | null>(null);
|
title: string | null;
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
message: string | null;
|
||||||
|
}>({
|
||||||
|
title: null,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
const [authType, setAuthType] = useState<string | null>(null);
|
const [authType, setAuthType] = useState<string | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Get the auth type from environment variables
|
// Get the auth type from environment variables
|
||||||
|
|
@ -27,36 +35,17 @@ export function LocalLoginForm() {
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setError({ title: null, message: null }); // Clear any previous errors
|
||||||
setError(null); // Clear any previous errors
|
|
||||||
setErrorTitle(null);
|
|
||||||
|
|
||||||
// Show loading toast
|
// Show loading toast
|
||||||
const loadingToast = toast.loading(tCommon("loading"));
|
const loadingToast = toast.loading(tCommon("loading"));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create form data for the API request
|
const data = await login({
|
||||||
const formData = new URLSearchParams();
|
username,
|
||||||
formData.append("username", username);
|
password,
|
||||||
formData.append("password", password);
|
grant_type: "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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success toast
|
// Success toast
|
||||||
toast.success(t("login_success"), {
|
toast.success(t("login_success"), {
|
||||||
|
|
@ -70,6 +59,16 @@ export function LocalLoginForm() {
|
||||||
router.push(`/auth/callback?token=${data.access_token}`);
|
router.push(`/auth/callback?token=${data.access_token}`);
|
||||||
}, 500);
|
}, 500);
|
||||||
} catch (err) {
|
} 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
|
// Use auth-errors utility to get proper error details
|
||||||
let errorCode = "UNKNOWN_ERROR";
|
let errorCode = "UNKNOWN_ERROR";
|
||||||
|
|
||||||
|
|
@ -83,8 +82,10 @@ export function LocalLoginForm() {
|
||||||
const errorDetails = getAuthErrorDetails(errorCode);
|
const errorDetails = getAuthErrorDetails(errorCode);
|
||||||
|
|
||||||
// Set persistent error display
|
// Set persistent error display
|
||||||
setErrorTitle(errorDetails.title);
|
setError({
|
||||||
setError(errorDetails.description);
|
title: errorDetails.title,
|
||||||
|
message: errorDetails.description,
|
||||||
|
});
|
||||||
|
|
||||||
// Show error toast with conditional retry action
|
// Show error toast with conditional retry action
|
||||||
const toastOptions: any = {
|
const toastOptions: any = {
|
||||||
|
|
@ -102,8 +103,6 @@ export function LocalLoginForm() {
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(errorDetails.title, toastOptions);
|
toast.error(errorDetails.title, toastOptions);
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -112,7 +111,7 @@ export function LocalLoginForm() {
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{error && errorTitle && (
|
{error && error.title && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
|
@ -139,13 +138,12 @@ export function LocalLoginForm() {
|
||||||
<line x1="9" y1="9" x2="15" y2="15" />
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-semibold mb-1">{errorTitle}</p>
|
<p className="text-sm font-semibold mb-1">{error.title}</p>
|
||||||
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
<p className="text-sm text-red-700 dark:text-red-300">{error.message}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setError(null);
|
setError({ title: null, message: null });
|
||||||
setErrorTitle(null);
|
|
||||||
}}
|
}}
|
||||||
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
|
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"
|
aria-label="Dismiss error"
|
||||||
|
|
@ -186,11 +184,11 @@ export function LocalLoginForm() {
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
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 ${
|
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-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"
|
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||||
}`}
|
}`}
|
||||||
disabled={isLoading}
|
disabled={isLoggingIn}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -209,11 +207,11 @@ export function LocalLoginForm() {
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
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 ${
|
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-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"
|
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||||
}`}
|
}`}
|
||||||
disabled={isLoading}
|
disabled={isLoggingIn}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -228,10 +226,10 @@ export function LocalLoginForm() {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||||
|
import { AppError, ValidationError } from "@/lib/error";
|
||||||
import { AmbientBackground } from "../login/AmbientBackground";
|
import { AmbientBackground } from "../login/AmbientBackground";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
|
|
@ -16,10 +19,15 @@ export default function RegisterPage() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<{
|
||||||
const [errorTitle, setErrorTitle] = useState<string | null>(null);
|
title: string | null;
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
message: string | null;
|
||||||
|
}>({
|
||||||
|
title: null,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [{ mutateAsync: register, isPending: isRegistering }] = useAtom(registerMutationAtom);
|
||||||
|
|
||||||
// Check authentication type and redirect if not LOCAL
|
// Check authentication type and redirect if not LOCAL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -34,8 +42,7 @@ export default function RegisterPage() {
|
||||||
|
|
||||||
// Form validation
|
// Form validation
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
setError(t("passwords_no_match"));
|
setError({ title: t("password_mismatch"), message: t("passwords_no_match_desc") });
|
||||||
setErrorTitle(t("password_mismatch"));
|
|
||||||
toast.error(t("password_mismatch"), {
|
toast.error(t("password_mismatch"), {
|
||||||
description: t("passwords_no_match_desc"),
|
description: t("passwords_no_match_desc"),
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
|
|
@ -43,48 +50,20 @@ export default function RegisterPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setError({ title: null, message: null }); // Clear any previous errors
|
||||||
setError(null); // Clear any previous errors
|
|
||||||
setErrorTitle(null);
|
|
||||||
|
|
||||||
// Show loading toast
|
// Show loading toast
|
||||||
const loadingToast = toast.loading(t("creating_account"));
|
const loadingToast = toast.loading(t("creating_account"));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, {
|
await register({
|
||||||
method: "POST",
|
email,
|
||||||
headers: {
|
password,
|
||||||
"Content-Type": "application/json",
|
is_active: true,
|
||||||
},
|
is_superuser: false,
|
||||||
body: JSON.stringify({
|
is_verified: false,
|
||||||
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
|
// Success toast
|
||||||
toast.success(t("register_success"), {
|
toast.success(t("register_success"), {
|
||||||
id: loadingToast,
|
id: loadingToast,
|
||||||
|
|
@ -97,6 +76,34 @@ export default function RegisterPage() {
|
||||||
router.push("/login?registered=true");
|
router.push("/login?registered=true");
|
||||||
}, 500);
|
}, 500);
|
||||||
} catch (err) {
|
} 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
|
// Use auth-errors utility to get proper error details
|
||||||
let errorCode = "UNKNOWN_ERROR";
|
let errorCode = "UNKNOWN_ERROR";
|
||||||
|
|
||||||
|
|
@ -110,8 +117,7 @@ export default function RegisterPage() {
|
||||||
const errorDetails = getAuthErrorDetails(errorCode);
|
const errorDetails = getAuthErrorDetails(errorCode);
|
||||||
|
|
||||||
// Set persistent error display
|
// Set persistent error display
|
||||||
setErrorTitle(errorDetails.title);
|
setError({ title: errorDetails.title, message: errorDetails.description });
|
||||||
setError(errorDetails.description);
|
|
||||||
|
|
||||||
// Show error toast with conditional retry action
|
// Show error toast with conditional retry action
|
||||||
const toastOptions: any = {
|
const toastOptions: any = {
|
||||||
|
|
@ -129,8 +135,6 @@ export default function RegisterPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(errorDetails.title, toastOptions);
|
toast.error(errorDetails.title, toastOptions);
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -147,7 +151,7 @@ export default function RegisterPage() {
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Enhanced Error Display */}
|
{/* Enhanced Error Display */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{error && errorTitle && (
|
{error && error.title && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
|
@ -174,13 +178,12 @@ export default function RegisterPage() {
|
||||||
<line x1="9" y1="9" x2="15" y2="15" />
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-semibold mb-1">{errorTitle}</p>
|
<p className="text-sm font-semibold mb-1">{error.title}</p>
|
||||||
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
<p className="text-sm text-red-700 dark:text-red-300">{error.message}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setError(null);
|
setError({ title: null, message: null });
|
||||||
setErrorTitle(null);
|
|
||||||
}}
|
}}
|
||||||
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
|
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"
|
aria-label="Dismiss error"
|
||||||
|
|
@ -221,11 +224,11 @@ export default function RegisterPage() {
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
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 ${
|
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-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"
|
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||||
}`}
|
}`}
|
||||||
disabled={isLoading}
|
disabled={isRegistering}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -243,11 +246,11 @@ export default function RegisterPage() {
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
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 ${
|
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-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"
|
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||||
}`}
|
}`}
|
||||||
disabled={isLoading}
|
disabled={isRegistering}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -265,20 +268,20 @@ export default function RegisterPage() {
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
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 ${
|
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-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"
|
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||||
}`}
|
}`}
|
||||||
disabled={isLoading}
|
disabled={isRegistering}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
|
@ -13,7 +14,8 @@ import {
|
||||||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
import { AnimatePresence, motion, type Variants } from "motion/react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { 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 { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
@ -49,19 +51,18 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export interface Chat {
|
export interface Chat {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
id: number;
|
id: number;
|
||||||
type: "DOCUMENT" | "CHAT";
|
type: "QNA";
|
||||||
title: string;
|
title: string;
|
||||||
search_space_id: number;
|
search_space_id: number;
|
||||||
state_version: number;
|
state_version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatDetails {
|
export interface ChatDetails {
|
||||||
type: "DOCUMENT" | "CHAT";
|
type: "QNA";
|
||||||
title: string;
|
title: string;
|
||||||
initial_connectors: string[];
|
initial_connectors: string[];
|
||||||
messages: any[];
|
messages: any[];
|
||||||
|
|
@ -91,18 +92,24 @@ const MotionCard = motion(Card);
|
||||||
|
|
||||||
export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) {
|
export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [chats, setChats] = useState<Chat[]>([]);
|
|
||||||
const [filteredChats, setFilteredChats] = useState<Chat[]>([]);
|
const [filteredChats, setFilteredChats] = useState<Chat[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [selectedType, setSelectedType] = useState<string>("all");
|
const [selectedType, setSelectedType] = useState<string>("all");
|
||||||
const [sortOrder, setSortOrder] = useState<string>("newest");
|
const [sortOrder, setSortOrder] = useState<string>("newest");
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [chatToDelete, setChatToDelete] = useState<{ id: number; title: string } | null>(null);
|
const [chatToDelete, setChatToDelete] = useState<{
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
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 chatsPerPage = 9;
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
@ -118,58 +125,9 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [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
|
// Filter and sort chats based on search query, type, and sort order
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let result = [...chats];
|
let result = [...(chats || [])];
|
||||||
|
|
||||||
// Filter by search term
|
// Filter by search term
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
|
|
@ -203,49 +161,19 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
||||||
const handleDeleteChat = async () => {
|
const handleDeleteChat = async () => {
|
||||||
if (!chatToDelete) return;
|
if (!chatToDelete) return;
|
||||||
|
|
||||||
setIsDeleting(true);
|
await deleteChat(chatToDelete.id);
|
||||||
try {
|
|
||||||
const token = localStorage.getItem("surfsense_bearer_token");
|
|
||||||
if (!token) {
|
|
||||||
setIsDeleting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(
|
setDeleteDialogOpen(false);
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${chatToDelete.id}`,
|
setChatToDelete(null);
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate pagination
|
// Calculate pagination
|
||||||
const indexOfLastChat = currentPage * chatsPerPage;
|
const indexOfLastChat = currentPage * chatsPerPage; // Index of last chat in the current page
|
||||||
const indexOfFirstChat = indexOfLastChat - chatsPerPage;
|
const indexOfFirstChat = indexOfLastChat - chatsPerPage; // Index of first chat in the current page
|
||||||
const currentChats = filteredChats.slice(indexOfFirstChat, indexOfLastChat);
|
const currentChats = filteredChats.slice(indexOfFirstChat, indexOfLastChat);
|
||||||
|
|
||||||
// Get unique chat types for filter dropdown
|
// 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 (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -307,7 +235,7 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Messages */}
|
{/* Status Messages */}
|
||||||
{isLoading && (
|
{isFetchingChats && (
|
||||||
<div className="flex items-center justify-center h-40">
|
<div className="flex items-center justify-center h-40">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||||
|
|
@ -316,14 +244,14 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && !isLoading && (
|
{fetchError && !isFetchingChats && (
|
||||||
<div className="border border-destructive/50 text-destructive p-4 rounded-md">
|
<div className="border border-destructive/50 text-destructive p-4 rounded-md">
|
||||||
<h3 className="font-medium">Error loading chats</h3>
|
<h3 className="font-medium">Error loading chats</h3>
|
||||||
<p className="text-sm">{error}</p>
|
<p className="text-sm">{fetchError.message}</p>
|
||||||
</div>
|
</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">
|
<div className="flex flex-col items-center justify-center h-40 gap-2 text-center">
|
||||||
<MessageCircleMore className="h-8 w-8 text-muted-foreground" />
|
<MessageCircleMore className="h-8 w-8 text-muted-foreground" />
|
||||||
<h3 className="font-medium">No chats found</h3>
|
<h3 className="font-medium">No chats found</h3>
|
||||||
|
|
@ -336,7 +264,7 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chat Grid */}
|
{/* Chat Grid */}
|
||||||
{!isLoading && !error && filteredChats.length > 0 && (
|
{!isFetchingChats && !fetchError && filteredChats.length > 0 && (
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{currentChats.map((chat, index) => (
|
{currentChats.map((chat, index) => (
|
||||||
|
|
@ -422,7 +350,7 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{!isLoading && !error && totalPages > 1 && (
|
{!isFetchingChats && !fetchError && totalPages > 1 && (
|
||||||
<Pagination className="mt-8">
|
<Pagination className="mt-8">
|
||||||
<PaginationContent>
|
<PaginationContent>
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
|
|
@ -504,17 +432,17 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setDeleteDialogOpen(false)}
|
onClick={() => setDeleteDialogOpen(false)}
|
||||||
disabled={isDeleting}
|
disabled={isDeletingChat}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleDeleteChat}
|
onClick={handleDeleteChat}
|
||||||
disabled={isDeleting}
|
disabled={isDeletingChat}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
{isDeleting ? (
|
{isDeletingChat ? (
|
||||||
<>
|
<>
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
Deleting...
|
Deleting...
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import { Loader2, PanelRight } from "lucide-react";
|
import { Loader2, PanelRight } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/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 { useTranslations } from "next-intl";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms";
|
||||||
|
import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms";
|
||||||
|
import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom";
|
||||||
import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContainer";
|
import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContainer";
|
||||||
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
||||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||||
|
|
@ -17,8 +20,6 @@ import { Separator } from "@/components/ui/separator";
|
||||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { useLLMPreferences } from "@/hooks/use-llm-configs";
|
import { useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { activeChatIdAtom } from "@/stores/chat/active-chat.atom";
|
|
||||||
import { chatUIAtom } from "@/stores/chat/chat-ui.atom";
|
|
||||||
|
|
||||||
export function DashboardClientLayout({
|
export function DashboardClientLayout({
|
||||||
children,
|
children,
|
||||||
|
|
@ -35,9 +36,11 @@ export function DashboardClientLayout({
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchSpaceIdNum = Number(searchSpaceId);
|
const searchSpaceIdNum = Number(searchSpaceId);
|
||||||
|
const { search_space_id, chat_id } = useParams();
|
||||||
const [chatUIState, setChatUIState] = useAtom(chatUIAtom);
|
const [chatUIState, setChatUIState] = useAtom(activeChathatUIAtom);
|
||||||
const activeChatId = useAtomValue(activeChatIdAtom);
|
const activeChatId = useAtomValue(activeChatIdAtom);
|
||||||
|
const setActiveSearchSpaceIdState = useSetAtom(activeSearchSpaceIdAtom);
|
||||||
|
const setActiveChatIdState = useSetAtom(activeChatIdAtom);
|
||||||
const [showIndicator, setShowIndicator] = useState(false);
|
const [showIndicator, setShowIndicator] = useState(false);
|
||||||
|
|
||||||
const { isChatPannelOpen } = chatUIState;
|
const { isChatPannelOpen } = chatUIState;
|
||||||
|
|
@ -119,6 +122,29 @@ export function DashboardClientLayout({
|
||||||
hasCheckedOnboarding,
|
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)
|
// Show loading screen while checking onboarding status (only on first load)
|
||||||
if (!hasCheckedOnboarding && loading && !isOnboardingPage) {
|
if (!hasCheckedOnboarding && loading && !isOnboardingPage) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -170,7 +196,7 @@ export function DashboardClientLayout({
|
||||||
<SidebarInset className="h-full ">
|
<SidebarInset className="h-full ">
|
||||||
<main className="flex h-full">
|
<main className="flex h-full">
|
||||||
<div className="flex grow flex-col h-full border-r">
|
<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 justify-between w-full gap-2 px-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronDown, ChevronUp, FileX } from "lucide-react";
|
import { ChevronDown, ChevronUp, FileX, Plus } from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { DocumentViewer } from "@/components/document-viewer";
|
import { DocumentViewer } from "@/components/document-viewer";
|
||||||
|
|
@ -68,6 +69,10 @@ export function DocumentsTableShell({
|
||||||
onSortChange: (key: SortKey) => void;
|
onSortChange: (key: SortKey) => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("documents");
|
const t = useTranslations("documents");
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const searchSpaceId = params.search_space_id;
|
||||||
|
|
||||||
const sorted = React.useMemo(
|
const sorted = React.useMemo(
|
||||||
() => sortDocuments(documents, sortKey, sortDesc),
|
() => sortDocuments(documents, sortKey, sortDesc),
|
||||||
[documents, sortKey, sortDesc]
|
[documents, sortKey, sortDesc]
|
||||||
|
|
@ -117,10 +122,29 @@ export function DocumentsTableShell({
|
||||||
</div>
|
</div>
|
||||||
) : sorted.length === 0 ? (
|
) : sorted.length === 0 ? (
|
||||||
<div className="flex h-[400px] w-full items-center justify-center">
|
<div className="flex h-[400px] w-full items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<motion.div
|
||||||
<FileX className="h-8 w-8 text-muted-foreground" />
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<p className="text-sm text-muted-foreground">{t("no_documents")}</p>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
</div>
|
transition={{ duration: 0.4 }}
|
||||||
|
className="flex flex-col items-center gap-4 max-w-md px-4 text-center"
|
||||||
|
>
|
||||||
|
<div className="rounded-full bg-muted p-4">
|
||||||
|
<FileX className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">{t("no_documents")}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Get started by adding your first data source.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/sources/add`)}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Sources
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export default function DashboardLayout({
|
||||||
|
|
||||||
const customNavMain = [
|
const customNavMain = [
|
||||||
{
|
{
|
||||||
title: "Researcher",
|
title: "Chat",
|
||||||
url: `/dashboard/${search_space_id}/researcher`,
|
url: `/dashboard/${search_space_id}/researcher`,
|
||||||
icon: "SquareTerminal",
|
icon: "SquareTerminal",
|
||||||
items: [],
|
items: [],
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,16 @@ import { ArrowLeft, ArrowRight, Bot, CheckCircle, Sparkles } from "lucide-react"
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { AddProviderStep } from "@/components/onboard/add-provider-step";
|
|
||||||
import { AssignRolesStep } from "@/components/onboard/assign-roles-step";
|
|
||||||
import { CompletionStep } from "@/components/onboard/completion-step";
|
import { CompletionStep } from "@/components/onboard/completion-step";
|
||||||
|
import { SetupLLMStep } from "@/components/onboard/setup-llm-step";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
|
import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||||
|
|
||||||
const TOTAL_STEPS = 3;
|
const TOTAL_STEPS = 2;
|
||||||
|
|
||||||
const OnboardPage = () => {
|
const OnboardPage = () => {
|
||||||
const t = useTranslations("onboard");
|
const t = useTranslations("onboard");
|
||||||
|
|
@ -33,6 +32,10 @@ const OnboardPage = () => {
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [hasUserProgressed, setHasUserProgressed] = useState(false);
|
const [hasUserProgressed, setHasUserProgressed] = useState(false);
|
||||||
|
|
||||||
|
// Track if onboarding was complete on initial mount
|
||||||
|
const wasCompleteOnMount = useRef<boolean | null>(null);
|
||||||
|
const hasCheckedInitialState = useRef(false);
|
||||||
|
|
||||||
// Check if user is authenticated
|
// Check if user is authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem("surfsense_bearer_token");
|
const token = localStorage.getItem("surfsense_bearer_token");
|
||||||
|
|
@ -42,6 +45,19 @@ const OnboardPage = () => {
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
// Capture onboarding state on first load
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!hasCheckedInitialState.current &&
|
||||||
|
!preferencesLoading &&
|
||||||
|
!configsLoading &&
|
||||||
|
!globalConfigsLoading
|
||||||
|
) {
|
||||||
|
wasCompleteOnMount.current = isOnboardingComplete();
|
||||||
|
hasCheckedInitialState.current = true;
|
||||||
|
}
|
||||||
|
}, [preferencesLoading, configsLoading, globalConfigsLoading, isOnboardingComplete]);
|
||||||
|
|
||||||
// Track if user has progressed beyond step 1
|
// Track if user has progressed beyond step 1
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentStep > 1) {
|
if (currentStep > 1) {
|
||||||
|
|
@ -49,47 +65,42 @@ const OnboardPage = () => {
|
||||||
}
|
}
|
||||||
}, [currentStep]);
|
}, [currentStep]);
|
||||||
|
|
||||||
// Redirect to dashboard if onboarding is already complete and user hasn't progressed (fresh page load)
|
// Redirect to dashboard if onboarding was already complete on mount (not during this session)
|
||||||
// But only check once to avoid redirect loops
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Only redirect if:
|
||||||
|
// 1. Onboarding was complete when page loaded
|
||||||
|
// 2. User hasn't progressed past step 1
|
||||||
|
// 3. All data is loaded
|
||||||
if (
|
if (
|
||||||
|
wasCompleteOnMount.current === true &&
|
||||||
|
!hasUserProgressed &&
|
||||||
!preferencesLoading &&
|
!preferencesLoading &&
|
||||||
!configsLoading &&
|
!configsLoading &&
|
||||||
!globalConfigsLoading &&
|
!globalConfigsLoading
|
||||||
isOnboardingComplete() &&
|
|
||||||
!hasUserProgressed
|
|
||||||
) {
|
) {
|
||||||
// Small delay to ensure the check is stable
|
// Small delay to ensure the check is stable on initial load
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
router.push(`/dashboard/${searchSpaceId}`);
|
router.push(`/dashboard/${searchSpaceId}`);
|
||||||
}, 100);
|
}, 300);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
hasUserProgressed,
|
||||||
preferencesLoading,
|
preferencesLoading,
|
||||||
configsLoading,
|
configsLoading,
|
||||||
globalConfigsLoading,
|
globalConfigsLoading,
|
||||||
isOnboardingComplete,
|
|
||||||
hasUserProgressed,
|
|
||||||
router,
|
router,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const progress = (currentStep / TOTAL_STEPS) * 100;
|
const progress = (currentStep / TOTAL_STEPS) * 100;
|
||||||
|
|
||||||
const stepTitles = [t("add_llm_provider"), t("assign_llm_roles"), t("setup_complete")];
|
const stepTitles = [t("setup_llm_configuration"), t("setup_complete")];
|
||||||
|
|
||||||
const stepDescriptions = [
|
const stepDescriptions = [t("configure_providers_and_assign_roles"), t("all_set")];
|
||||||
t("configure_first_provider"),
|
|
||||||
t("assign_specific_roles"),
|
|
||||||
t("all_set"),
|
|
||||||
];
|
|
||||||
|
|
||||||
// User can proceed to step 2 if they have either custom configs OR global configs available
|
// User can proceed to step 2 if all roles are assigned
|
||||||
const canProceedToStep2 =
|
const canProceedToStep2 =
|
||||||
!configsLoading && !globalConfigsLoading && (llmConfigs.length > 0 || globalConfigs.length > 0);
|
|
||||||
|
|
||||||
const canProceedToStep3 =
|
|
||||||
!preferencesLoading &&
|
!preferencesLoading &&
|
||||||
preferences.long_context_llm_id &&
|
preferences.long_context_llm_id &&
|
||||||
preferences.fast_llm_id &&
|
preferences.fast_llm_id &&
|
||||||
|
|
@ -107,10 +118,6 @@ const OnboardPage = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleComplete = () => {
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (configsLoading || preferencesLoading || globalConfigsLoading) {
|
if (configsLoading || preferencesLoading || globalConfigsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
|
|
@ -192,9 +199,8 @@ const OnboardPage = () => {
|
||||||
<Card className="min-h-[500px] bg-background/60 backdrop-blur-sm">
|
<Card className="min-h-[500px] bg-background/60 backdrop-blur-sm">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<CardTitle className="text-2xl flex items-center justify-center gap-2">
|
<CardTitle className="text-2xl flex items-center justify-center gap-2">
|
||||||
{currentStep === 1 && <Bot className="w-6 h-6" />}
|
{currentStep === 1 && <Sparkles className="w-6 h-6" />}
|
||||||
{currentStep === 2 && <Sparkles className="w-6 h-6" />}
|
{currentStep === 2 && <CheckCircle className="w-6 h-6" />}
|
||||||
{currentStep === 3 && <CheckCircle className="w-6 h-6" />}
|
|
||||||
{stepTitles[currentStep - 1]}
|
{stepTitles[currentStep - 1]}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-base">
|
<CardDescription className="text-base">
|
||||||
|
|
@ -211,19 +217,14 @@ const OnboardPage = () => {
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
>
|
>
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<AddProviderStep
|
<SetupLLMStep
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
onConfigCreated={refreshConfigs}
|
onConfigCreated={refreshConfigs}
|
||||||
onConfigDeleted={refreshConfigs}
|
onConfigDeleted={refreshConfigs}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{currentStep === 2 && (
|
|
||||||
<AssignRolesStep
|
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
onPreferencesUpdated={refreshPreferences}
|
onPreferencesUpdated={refreshPreferences}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentStep === 3 && <CompletionStep searchSpaceId={searchSpaceId} />}
|
{currentStep === 2 && <CompletionStep searchSpaceId={searchSpaceId} />}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -231,38 +232,24 @@ const OnboardPage = () => {
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<div className="flex justify-between mt-8">
|
<div className="flex justify-between mt-8">
|
||||||
<Button
|
{currentStep === 1 ? (
|
||||||
variant="outline"
|
<>
|
||||||
onClick={handlePrevious}
|
<div />
|
||||||
disabled={currentStep === 1}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
{t("previous")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{currentStep < TOTAL_STEPS && (
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={
|
disabled={!canProceedToStep2}
|
||||||
(currentStep === 1 && !canProceedToStep2) ||
|
|
||||||
(currentStep === 2 && !canProceedToStep3)
|
|
||||||
}
|
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{t("next")}
|
{t("next")}
|
||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</>
|
||||||
|
) : (
|
||||||
{currentStep === TOTAL_STEPS && (
|
<Button variant="outline" onClick={handlePrevious} className="flex items-center gap-2">
|
||||||
<Button onClick={handleComplete} className="flex items-center gap-2">
|
<ArrowLeft className="w-4 h-4" />
|
||||||
{t("complete_setup")}
|
{t("previous")}
|
||||||
<CheckCircle className="w-4 h-4" />
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,7 @@ const DashboardPage = () => {
|
||||||
{searchSpaces &&
|
{searchSpaces &&
|
||||||
searchSpaces.length > 0 &&
|
searchSpaces.length > 0 &&
|
||||||
searchSpaces.map((space) => (
|
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
|
<Tilt
|
||||||
rotationFactor={6}
|
rotationFactor={6}
|
||||||
isRevese
|
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 { atomWithQuery } from "jotai-tanstack-query";
|
||||||
import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client";
|
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 type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client";
|
||||||
import { fetchChatDetails } from "@/lib/apis/chat-apis";
|
import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom";
|
||||||
import { getPodcastByChatId } from "@/lib/apis/podcast-apis";
|
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 = {
|
type ActiveChatState = {
|
||||||
chatId: string | null;
|
chatId: string | null;
|
||||||
|
|
@ -18,7 +20,7 @@ export const activeChatAtom = atomWithQuery<ActiveChatState>((get) => {
|
||||||
const authToken = localStorage.getItem("surfsense_bearer_token");
|
const authToken = localStorage.getItem("surfsense_bearer_token");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
queryKey: ["activeChat", activeChatId],
|
queryKey: cacheKeys.activeSearchSpace.activeChat(activeChatId ?? ""),
|
||||||
enabled: !!activeChatId && !!authToken,
|
enabled: !!activeChatId && !!authToken,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
|
|
@ -30,10 +32,23 @@ export const activeChatAtom = atomWithQuery<ActiveChatState>((get) => {
|
||||||
|
|
||||||
const [podcast, chatDetails] = await Promise.all([
|
const [podcast, chatDetails] = await Promise.all([
|
||||||
getPodcastByChatId(activeChatId, authToken),
|
getPodcastByChatId(activeChatId, authToken),
|
||||||
fetchChatDetails(activeChatId, authToken),
|
chatApiService.getChatDetails({ id: Number(activeChatId) }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { chatId: activeChatId, chatDetails, podcast };
|
return { chatId: activeChatId, chatDetails, podcast };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const activeSearchSpaceChatsAtom = atomWithQuery((get) => {
|
||||||
|
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||||
|
const authToken = localStorage.getItem("surfsense_bearer_token");
|
||||||
|
|
||||||
|
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 { useAtom } from "jotai";
|
||||||
import { ExternalLink, Info, X } from "lucide-react";
|
import { ExternalLink, Info, X } from "lucide-react";
|
||||||
|
import { announcementDismissedAtom } from "@/atoms/announcement.atom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { announcementDismissedAtom } from "@/stores/announcement.atom";
|
|
||||||
|
|
||||||
export function AnnouncementBanner() {
|
export function AnnouncementBanner() {
|
||||||
const [isDismissed, setIsDismissed] = useAtom(announcementDismissedAtom);
|
const [isDismissed, setIsDismissed] = useAtom(announcementDismissedAtom);
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { type ChatHandler, ChatSection as LlamaIndexChatSection } from "@llamaindex/chat-ui";
|
import { type ChatHandler, ChatSection as LlamaIndexChatSection } from "@llamaindex/chat-ui";
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { ChatInputUI } from "@/components/chat/ChatInputGroup";
|
import { ChatInputUI } from "@/components/chat/ChatInputGroup";
|
||||||
import { ChatMessagesUI } from "@/components/chat/ChatMessages";
|
import { ChatMessagesUI } from "@/components/chat/ChatMessages";
|
||||||
import type { Document } from "@/hooks/use-documents";
|
import type { Document } from "@/hooks/use-documents";
|
||||||
import { activeChatIdAtom } from "@/stores/chat/active-chat.atom";
|
|
||||||
import { ChatPanelContainer } from "./ChatPanel/ChatPanelContainer";
|
|
||||||
|
|
||||||
interface ChatInterfaceProps {
|
interface ChatInterfaceProps {
|
||||||
handler: ChatHandler;
|
handler: ChatHandler;
|
||||||
|
|
@ -34,13 +30,6 @@ export default function ChatInterface({
|
||||||
onTopKChange,
|
onTopKChange,
|
||||||
}: ChatInterfaceProps) {
|
}: ChatInterfaceProps) {
|
||||||
const { chat_id, search_space_id } = useParams();
|
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 (
|
return (
|
||||||
<LlamaIndexChatSection handler={handler} className="flex h-full max-w-7xl mx-auto">
|
<LlamaIndexChatSection handler={handler} className="flex h-full max-w-7xl mx-auto">
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { LoaderIcon, PanelRight, TriangleAlert } from "lucide-react";
|
import { LoaderIcon, PanelRight, TriangleAlert } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
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 { 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";
|
import { ChatPanelView } from "./ChatPanelView";
|
||||||
|
|
||||||
export interface GeneratePodcastRequest {
|
export interface GeneratePodcastRequest {
|
||||||
|
|
@ -24,7 +24,7 @@ export function ChatPanelContainer() {
|
||||||
} = useAtomValue(activeChatAtom);
|
} = useAtomValue(activeChatAtom);
|
||||||
const activeChatIdState = useAtomValue(activeChatIdAtom);
|
const activeChatIdState = useAtomValue(activeChatIdAtom);
|
||||||
const authToken = localStorage.getItem("surfsense_bearer_token");
|
const authToken = localStorage.getItem("surfsense_bearer_token");
|
||||||
const { isChatPannelOpen } = useAtomValue(chatUIAtom);
|
const { isChatPannelOpen } = useAtomValue(activeChathatUIAtom);
|
||||||
|
|
||||||
const handleGeneratePodcast = async (request: GeneratePodcastRequest) => {
|
const handleGeneratePodcast = async (request: GeneratePodcastRequest) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ import { useAtom, useAtomValue } from "jotai";
|
||||||
import { AlertCircle, Play, RefreshCw, Sparkles } from "lucide-react";
|
import { AlertCircle, Play, RefreshCw, Sparkles } from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { activeChatAtom } from "@/atoms/chats/chat-querie.atoms";
|
||||||
|
import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms";
|
||||||
import { cn } from "@/lib/utils";
|
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 { getPodcastStalenessMessage, isPodcastStale } from "../PodcastUtils";
|
||||||
import type { GeneratePodcastRequest } from "./ChatPanelContainer";
|
import type { GeneratePodcastRequest } from "./ChatPanelContainer";
|
||||||
import { ConfigModal } from "./ConfigModal";
|
import { ConfigModal } from "./ConfigModal";
|
||||||
|
|
@ -17,7 +17,7 @@ interface ChatPanelViewProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPanelView(props: ChatPanelViewProps) {
|
export function ChatPanelView(props: ChatPanelViewProps) {
|
||||||
const [chatUIState, setChatUIState] = useAtom(chatUIAtom);
|
const [chatUIState, setChatUIState] = useAtom(activeChathatUIAtom);
|
||||||
const { data: activeChatState } = useAtomValue(activeChatAtom);
|
const { data: activeChatState } = useAtomValue(activeChatAtom);
|
||||||
|
|
||||||
const { isChatPannelOpen } = chatUIState;
|
const { isChatPannelOpen } = chatUIState;
|
||||||
|
|
@ -40,6 +40,7 @@ export function ChatPanelView(props: ChatPanelViewProps) {
|
||||||
});
|
});
|
||||||
}, [chatDetails, generatePodcast]);
|
}, [chatDetails, generatePodcast]);
|
||||||
|
|
||||||
|
// biome-ignore-start lint/a11y/useSemanticElements: using div for custom layout — will convert later
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className={cn("w-full p-4", !isChatPannelOpen && "flex items-center justify-center")}>
|
<div className={cn("w-full p-4", !isChatPannelOpen && "flex items-center justify-center")}>
|
||||||
|
|
@ -78,17 +79,11 @@ export function ChatPanelView(props: ChatPanelViewProps) {
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
|
className="relative"
|
||||||
>
|
>
|
||||||
<div
|
<button
|
||||||
role="button"
|
type="button"
|
||||||
tabIndex={0}
|
|
||||||
onClick={handleGeneratePost}
|
onClick={handleGeneratePost}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleGeneratePost();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-full rounded-2xl p-4 transition-all duration-300 cursor-pointer group overflow-hidden",
|
"relative w-full rounded-2xl p-4 transition-all duration-300 cursor-pointer group overflow-hidden",
|
||||||
"border-2",
|
"border-2",
|
||||||
|
|
@ -151,9 +146,12 @@ export function ChatPanelView(props: ChatPanelViewProps) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ConfigModal generatePodcast={generatePodcast} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
{/* ConfigModal positioned absolutely to avoid nesting buttons */}
|
||||||
|
<div className="absolute top-4 right-4 z-20">
|
||||||
|
<ConfigModal generatePodcast={generatePodcast} />
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -205,4 +203,5 @@ export function ChatPanelView(props: ChatPanelViewProps) {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
// biome-ignore-end lint/a11y/useSemanticElements : using div for custom layout — will convert later
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
import { useCallback, useContext, useState } from "react";
|
import { useCallback, useContext, useState } from "react";
|
||||||
|
import { activeChatAtom } from "@/atoms/chats/chat-querie.atoms";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { activeChatAtom } from "@/stores/chat/active-chat.atom";
|
|
||||||
import type { GeneratePodcastRequest } from "./ChatPanelContainer";
|
import type { GeneratePodcastRequest } from "./ChatPanelContainer";
|
||||||
|
|
||||||
interface ConfigModalProps {
|
interface ConfigModalProps {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ import {
|
||||||
type SortingState,
|
type SortingState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { ArrowUpDown, Calendar, FileText, Filter, Search } from "lucide-react";
|
import { ArrowUpDown, Calendar, FileText, Filter, Plus, Search } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
@ -177,6 +178,7 @@ export function DocumentsDataTable({
|
||||||
onDone,
|
onDone,
|
||||||
initialSelectedDocuments = [],
|
initialSelectedDocuments = [],
|
||||||
}: DocumentsDataTableProps) {
|
}: DocumentsDataTableProps) {
|
||||||
|
const router = useRouter();
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const debouncedSearch = useDebounced(search, 300);
|
const debouncedSearch = useDebounced(search, 300);
|
||||||
|
|
@ -527,11 +529,26 @@ export function DocumentsDataTable({
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell colSpan={columns.length} className="h-64">
|
||||||
colSpan={columns.length}
|
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||||
className="h-32 text-center text-muted-foreground text-sm"
|
<div className="rounded-full bg-muted p-3">
|
||||||
>
|
<FileText className="h-6 w-6 text-muted-foreground" />
|
||||||
No documents found.
|
</div>
|
||||||
|
<div className="space-y-2 text-center max-w-sm">
|
||||||
|
<h3 className="font-semibold">No documents found</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Get started by adding your first data source to build your knowledge
|
||||||
|
base.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/sources/add`)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Sources
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect } from "react";
|
||||||
import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client";
|
import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
|
|
@ -13,7 +14,6 @@ import {
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from "@/components/ui/breadcrumb";
|
} from "@/components/ui/breadcrumb";
|
||||||
import { useSearchSpace } from "@/hooks/use-search-space";
|
import { useSearchSpace } from "@/hooks/use-search-space";
|
||||||
import { fetchChatDetails } from "@/lib/apis/chat-apis";
|
|
||||||
|
|
||||||
interface BreadcrumbItemInterface {
|
interface BreadcrumbItemInterface {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -23,13 +23,10 @@ interface BreadcrumbItemInterface {
|
||||||
export function DashboardBreadcrumb() {
|
export function DashboardBreadcrumb() {
|
||||||
const t = useTranslations("breadcrumb");
|
const t = useTranslations("breadcrumb");
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [chatDetails, setChatDetails] = useState<ChatDetails | null>(null);
|
const { data: activeChatState } = useAtomValue(activeChatAtom);
|
||||||
|
|
||||||
// Extract search space ID and chat ID from pathname
|
// Extract search space ID and chat ID from pathname
|
||||||
const segments = pathname.split("/").filter(Boolean);
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
const searchSpaceId = segments[0] === "dashboard" && segments[1] ? segments[1] : null;
|
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
|
// Fetch search space details if we have an ID
|
||||||
const { searchSpace } = useSearchSpace({
|
const { searchSpace } = useSearchSpace({
|
||||||
|
|
@ -37,18 +34,6 @@ export function DashboardBreadcrumb() {
|
||||||
autoFetch: !!searchSpaceId,
|
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
|
// Parse the pathname to create breadcrumb items
|
||||||
const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => {
|
const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => {
|
||||||
const segments = path.split("/").filter(Boolean);
|
const segments = path.split("/").filter(Boolean);
|
||||||
|
|
@ -125,7 +110,7 @@ export function DashboardBreadcrumb() {
|
||||||
// Handle researcher sub-sections (chat IDs)
|
// Handle researcher sub-sections (chat IDs)
|
||||||
if (section === "researcher") {
|
if (section === "researcher") {
|
||||||
// Use the actual chat title if available, otherwise fall back to the ID
|
// 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({
|
breadcrumbs.push({
|
||||||
label: t("researcher"),
|
label: t("researcher"),
|
||||||
href: `/dashboard/${segments[1]}/researcher`,
|
href: `/dashboard/${segments[1]}/researcher`,
|
||||||
|
|
|
||||||
|
|
@ -1,407 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { AlertCircle, Bot, Check, CheckCircle, ChevronsUpDown, Plus, Trash2 } from "lucide-react";
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from "@/components/ui/command";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { LANGUAGES } from "@/contracts/enums/languages";
|
|
||||||
import { getModelsByProvider } from "@/contracts/enums/llm-models";
|
|
||||||
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
|
|
||||||
import { type CreateLLMConfig, useGlobalLLMConfigs, useLLMConfigs } from "@/hooks/use-llm-configs";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
import InferenceParamsEditor from "../inference-params-editor";
|
|
||||||
|
|
||||||
interface AddProviderStepProps {
|
|
||||||
searchSpaceId: number;
|
|
||||||
onConfigCreated?: () => void;
|
|
||||||
onConfigDeleted?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AddProviderStep({
|
|
||||||
searchSpaceId,
|
|
||||||
onConfigCreated,
|
|
||||||
onConfigDeleted,
|
|
||||||
}: AddProviderStepProps) {
|
|
||||||
const t = useTranslations("onboard");
|
|
||||||
const { llmConfigs, createLLMConfig, deleteLLMConfig } = useLLMConfigs(searchSpaceId);
|
|
||||||
const { globalConfigs } = useGlobalLLMConfigs();
|
|
||||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
|
||||||
const [formData, setFormData] = useState<CreateLLMConfig>({
|
|
||||||
name: "",
|
|
||||||
provider: "",
|
|
||||||
custom_provider: "",
|
|
||||||
model_name: "",
|
|
||||||
api_key: "",
|
|
||||||
api_base: "",
|
|
||||||
language: "English",
|
|
||||||
litellm_params: {},
|
|
||||||
search_space_id: searchSpaceId,
|
|
||||||
});
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleInputChange = (field: keyof CreateLLMConfig, value: string) => {
|
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!formData.name || !formData.provider || !formData.model_name || !formData.api_key) {
|
|
||||||
toast.error("Please fill in all required fields");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
const result = await createLLMConfig(formData);
|
|
||||||
setIsSubmitting(false);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
setFormData({
|
|
||||||
name: "",
|
|
||||||
provider: "",
|
|
||||||
custom_provider: "",
|
|
||||||
model_name: "",
|
|
||||||
api_key: "",
|
|
||||||
api_base: "",
|
|
||||||
language: "English",
|
|
||||||
litellm_params: {},
|
|
||||||
search_space_id: searchSpaceId,
|
|
||||||
});
|
|
||||||
setIsAddingNew(false);
|
|
||||||
// Notify parent component that a config was created
|
|
||||||
onConfigCreated?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedProvider = LLM_PROVIDERS.find((p) => p.value === formData.provider);
|
|
||||||
const availableModels = formData.provider ? getModelsByProvider(formData.provider) : [];
|
|
||||||
|
|
||||||
const handleParamsChange = (newParams: Record<string, number | string>) => {
|
|
||||||
setFormData((prev) => ({ ...prev, litellm_params: newParams }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reset model when provider changes
|
|
||||||
const handleProviderChange = (value: string) => {
|
|
||||||
handleInputChange("provider", value);
|
|
||||||
setFormData((prev) => ({ ...prev, model_name: "" }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Info Alert */}
|
|
||||||
<Alert>
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{t("add_provider_instruction")}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{/* Global Configs Notice */}
|
|
||||||
{globalConfigs.length > 0 && (
|
|
||||||
<Alert className="bg-blue-50 border-blue-200 dark:bg-blue-950 dark:border-blue-800">
|
|
||||||
<CheckCircle className="h-4 w-4 text-blue-600" />
|
|
||||||
<AlertDescription className="text-blue-800 dark:text-blue-200">
|
|
||||||
<strong>{globalConfigs.length} global configuration(s) available!</strong>
|
|
||||||
<br />
|
|
||||||
You can skip adding your own LLM provider and use our pre-configured models in the next
|
|
||||||
step. Or continue here to add your own custom configurations.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Existing Configurations */}
|
|
||||||
{llmConfigs.length > 0 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold">{t("your_llm_configs")}</h3>
|
|
||||||
<div className="grid gap-4">
|
|
||||||
{llmConfigs.map((config) => (
|
|
||||||
<motion.div
|
|
||||||
key={config.id}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
>
|
|
||||||
<Card className="border-l-4 border-l-primary">
|
|
||||||
<CardContent className="pt-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Bot className="w-4 h-4" />
|
|
||||||
<h4 className="font-medium">{config.name}</h4>
|
|
||||||
<Badge variant="secondary">{config.provider}</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("model")}: {config.model_name}
|
|
||||||
{config.language && ` • ${t("language")}: ${config.language}`}
|
|
||||||
{config.api_base && ` • ${t("base")}: ${config.api_base}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={async () => {
|
|
||||||
const success = await deleteLLMConfig(config.id);
|
|
||||||
if (success) {
|
|
||||||
onConfigDeleted?.();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-destructive hover:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add New Provider */}
|
|
||||||
{!isAddingNew ? (
|
|
||||||
<Card className="border-dashed border-2 hover:border-primary/50 transition-colors">
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<Plus className="w-12 h-12 text-muted-foreground mb-4" />
|
|
||||||
<h3 className="text-lg font-semibold mb-2">{t("add_provider_title")}</h3>
|
|
||||||
<p className="text-muted-foreground text-center mb-4">{t("add_provider_subtitle")}</p>
|
|
||||||
<Button onClick={() => setIsAddingNew(true)}>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
{t("add_provider_button")}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t("add_new_llm_provider")}</CardTitle>
|
|
||||||
<CardDescription>{t("configure_new_provider")}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">{t("config_name_required")}</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
placeholder={t("config_name_placeholder")}
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => handleInputChange("name", e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="provider">{t("provider_required")}</Label>
|
|
||||||
<Select value={formData.provider} onValueChange={handleProviderChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={t("provider_placeholder")} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="max-h-[300px]">
|
|
||||||
{LLM_PROVIDERS.map((provider) => (
|
|
||||||
<SelectItem key={provider.value} value={provider.value}>
|
|
||||||
{provider.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* language */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="language">{t("language_optional")}</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.language || "English"}
|
|
||||||
onValueChange={(value) => handleInputChange("language", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={t("language_placeholder")} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{LANGUAGES.map((language) => (
|
|
||||||
<SelectItem key={language.value} value={language.value}>
|
|
||||||
{language.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formData.provider === "CUSTOM" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="custom_provider">{t("custom_provider_name")}</Label>
|
|
||||||
<Input
|
|
||||||
id="custom_provider"
|
|
||||||
placeholder={t("custom_provider_placeholder")}
|
|
||||||
value={formData.custom_provider}
|
|
||||||
onChange={(e) => handleInputChange("custom_provider", e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="model_name">{t("model_name_required")}</Label>
|
|
||||||
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
aria-expanded={modelComboboxOpen}
|
|
||||||
className="w-full justify-between font-normal"
|
|
||||||
>
|
|
||||||
<span className={cn(!formData.model_name && "text-muted-foreground")}>
|
|
||||||
{formData.model_name || t("model_name_placeholder")}
|
|
||||||
</span>
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-full p-0" align="start" side="bottom">
|
|
||||||
<Command shouldFilter={false}>
|
|
||||||
<CommandInput
|
|
||||||
placeholder={
|
|
||||||
selectedProvider?.example ||
|
|
||||||
t("model_name_placeholder") ||
|
|
||||||
"Type model name..."
|
|
||||||
}
|
|
||||||
value={formData.model_name}
|
|
||||||
onValueChange={(value) => handleInputChange("model_name", value)}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>
|
|
||||||
<div className="py-2 text-center text-sm text-muted-foreground">
|
|
||||||
{formData.model_name
|
|
||||||
? `Using custom model: "${formData.model_name}"`
|
|
||||||
: "Type your model name above"}
|
|
||||||
</div>
|
|
||||||
</CommandEmpty>
|
|
||||||
{availableModels.length > 0 && (
|
|
||||||
<CommandGroup heading="Suggested Models">
|
|
||||||
{availableModels
|
|
||||||
.filter(
|
|
||||||
(model) =>
|
|
||||||
!formData.model_name ||
|
|
||||||
model.value
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(formData.model_name.toLowerCase()) ||
|
|
||||||
model.label
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(formData.model_name.toLowerCase())
|
|
||||||
)
|
|
||||||
.map((model) => (
|
|
||||||
<CommandItem
|
|
||||||
key={model.value}
|
|
||||||
value={model.value}
|
|
||||||
onSelect={(currentValue) => {
|
|
||||||
handleInputChange("model_name", currentValue);
|
|
||||||
setModelComboboxOpen(false);
|
|
||||||
}}
|
|
||||||
className="flex flex-col items-start py-3"
|
|
||||||
>
|
|
||||||
<div className="flex w-full items-center">
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4 shrink-0",
|
|
||||||
formData.model_name === model.value
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium">{model.label}</div>
|
|
||||||
{model.contextWindow && (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Context: {model.contextWindow}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
)}
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{availableModels.length > 0
|
|
||||||
? `Type freely or select from ${availableModels.length} model suggestions`
|
|
||||||
: selectedProvider?.example
|
|
||||||
? `${t("examples")}: ${selectedProvider.example}`
|
|
||||||
: "Type your model name freely"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="api_key">{t("api_key_required")}</Label>
|
|
||||||
<Input
|
|
||||||
id="api_key"
|
|
||||||
type="password"
|
|
||||||
placeholder={t("api_key_placeholder")}
|
|
||||||
value={formData.api_key}
|
|
||||||
onChange={(e) => handleInputChange("api_key", e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="api_base">{t("api_base_optional")}</Label>
|
|
||||||
<Input
|
|
||||||
id="api_base"
|
|
||||||
placeholder={t("api_base_placeholder")}
|
|
||||||
value={formData.api_base}
|
|
||||||
onChange={(e) => handleInputChange("api_base", e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Optional Inference Parameters */}
|
|
||||||
<div className="pt-4">
|
|
||||||
<InferenceParamsEditor
|
|
||||||
params={formData.litellm_params || {}}
|
|
||||||
setParams={handleParamsChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-4">
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? t("adding") : t("add_provider")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsAddingNew(false)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,282 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { AlertCircle, Bot, Brain, CheckCircle, Zap } from "lucide-react";
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
|
|
||||||
|
|
||||||
interface AssignRolesStepProps {
|
|
||||||
searchSpaceId: number;
|
|
||||||
onPreferencesUpdated?: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignRolesStepProps) {
|
|
||||||
const t = useTranslations("onboard");
|
|
||||||
const { llmConfigs } = useLLMConfigs(searchSpaceId);
|
|
||||||
const { globalConfigs } = useGlobalLLMConfigs();
|
|
||||||
const { preferences, updatePreferences } = useLLMPreferences(searchSpaceId);
|
|
||||||
|
|
||||||
// Combine global and user-specific configs
|
|
||||||
const allConfigs = [...globalConfigs, ...llmConfigs];
|
|
||||||
|
|
||||||
const ROLE_DESCRIPTIONS = {
|
|
||||||
long_context: {
|
|
||||||
icon: Brain,
|
|
||||||
title: t("long_context_llm_title"),
|
|
||||||
description: t("long_context_llm_desc"),
|
|
||||||
color: "bg-blue-100 text-blue-800 border-blue-200",
|
|
||||||
examples: t("long_context_llm_examples"),
|
|
||||||
},
|
|
||||||
fast: {
|
|
||||||
icon: Zap,
|
|
||||||
title: t("fast_llm_title"),
|
|
||||||
description: t("fast_llm_desc"),
|
|
||||||
color: "bg-green-100 text-green-800 border-green-200",
|
|
||||||
examples: t("fast_llm_examples"),
|
|
||||||
},
|
|
||||||
strategic: {
|
|
||||||
icon: Bot,
|
|
||||||
title: t("strategic_llm_title"),
|
|
||||||
description: t("strategic_llm_desc"),
|
|
||||||
color: "bg-purple-100 text-purple-800 border-purple-200",
|
|
||||||
examples: t("strategic_llm_examples"),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const [assignments, setAssignments] = useState({
|
|
||||||
long_context_llm_id: preferences.long_context_llm_id || "",
|
|
||||||
fast_llm_id: preferences.fast_llm_id || "",
|
|
||||||
strategic_llm_id: preferences.strategic_llm_id || "",
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setAssignments({
|
|
||||||
long_context_llm_id: preferences.long_context_llm_id || "",
|
|
||||||
fast_llm_id: preferences.fast_llm_id || "",
|
|
||||||
strategic_llm_id: preferences.strategic_llm_id || "",
|
|
||||||
});
|
|
||||||
}, [preferences]);
|
|
||||||
|
|
||||||
const handleRoleAssignment = async (role: string, configId: string) => {
|
|
||||||
const newAssignments = {
|
|
||||||
...assignments,
|
|
||||||
[role]: configId === "" ? "" : parseInt(configId),
|
|
||||||
};
|
|
||||||
|
|
||||||
setAssignments(newAssignments);
|
|
||||||
|
|
||||||
// Auto-save if this assignment completes all roles
|
|
||||||
const hasAllAssignments =
|
|
||||||
newAssignments.long_context_llm_id &&
|
|
||||||
newAssignments.fast_llm_id &&
|
|
||||||
newAssignments.strategic_llm_id;
|
|
||||||
|
|
||||||
if (hasAllAssignments) {
|
|
||||||
const numericAssignments = {
|
|
||||||
long_context_llm_id:
|
|
||||||
typeof newAssignments.long_context_llm_id === "string"
|
|
||||||
? parseInt(newAssignments.long_context_llm_id)
|
|
||||||
: newAssignments.long_context_llm_id,
|
|
||||||
fast_llm_id:
|
|
||||||
typeof newAssignments.fast_llm_id === "string"
|
|
||||||
? parseInt(newAssignments.fast_llm_id)
|
|
||||||
: newAssignments.fast_llm_id,
|
|
||||||
strategic_llm_id:
|
|
||||||
typeof newAssignments.strategic_llm_id === "string"
|
|
||||||
? parseInt(newAssignments.strategic_llm_id)
|
|
||||||
: newAssignments.strategic_llm_id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const success = await updatePreferences(numericAssignments);
|
|
||||||
|
|
||||||
// Refresh parent preferences state
|
|
||||||
if (success && onPreferencesUpdated) {
|
|
||||||
await onPreferencesUpdated();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isAssignmentComplete =
|
|
||||||
assignments.long_context_llm_id && assignments.fast_llm_id && assignments.strategic_llm_id;
|
|
||||||
|
|
||||||
if (allConfigs.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
|
||||||
<AlertCircle className="w-16 h-16 text-muted-foreground mb-4" />
|
|
||||||
<h3 className="text-lg font-semibold mb-2">{t("no_llm_configs_found")}</h3>
|
|
||||||
<p className="text-muted-foreground text-center">{t("add_provider_before_roles")}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Info Alert */}
|
|
||||||
<Alert>
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{t("assign_roles_instruction")}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{/* Role Assignment Cards */}
|
|
||||||
<div className="grid gap-6">
|
|
||||||
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
|
|
||||||
const IconComponent = role.icon;
|
|
||||||
const currentAssignment = assignments[`${key}_llm_id` as keyof typeof assignments];
|
|
||||||
const assignedConfig = allConfigs.find((config) => config.id === currentAssignment);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={key}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: Object.keys(ROLE_DESCRIPTIONS).indexOf(key) * 0.1 }}
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
className={`border-l-4 ${currentAssignment ? "border-l-primary" : "border-l-muted"}`}
|
|
||||||
>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`p-2 rounded-lg ${role.color}`}>
|
|
||||||
<IconComponent className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-lg">{role.title}</CardTitle>
|
|
||||||
<CardDescription className="mt-1">{role.description}</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{currentAssignment && <CheckCircle className="w-5 h-5 text-green-500" />}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
<strong>{t("use_cases")}:</strong> {role.examples}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium">{t("assign_llm_config")}:</Label>
|
|
||||||
<Select
|
|
||||||
value={currentAssignment?.toString() || ""}
|
|
||||||
onValueChange={(value) => handleRoleAssignment(`${key}_llm_id`, value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={t("select_llm_config")} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{globalConfigs.length > 0 && (
|
|
||||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
|
||||||
{t("global_configs") || "Global Configurations"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{globalConfigs
|
|
||||||
.filter((config) => config.id && config.id.toString().trim() !== "")
|
|
||||||
.map((config) => (
|
|
||||||
<SelectItem key={config.id} value={config.id.toString()}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
🌐 Global
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{config.provider}
|
|
||||||
</Badge>
|
|
||||||
<span>{config.name}</span>
|
|
||||||
<span className="text-muted-foreground">({config.model_name})</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
{llmConfigs.length > 0 && globalConfigs.length > 0 && (
|
|
||||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground border-t mt-1">
|
|
||||||
{t("your_configs") || "Your Configurations"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{llmConfigs
|
|
||||||
.filter((config) => config.id && config.id.toString().trim() !== "")
|
|
||||||
.map((config) => (
|
|
||||||
<SelectItem key={config.id} value={config.id.toString()}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{config.provider}
|
|
||||||
</Badge>
|
|
||||||
<span>{config.name}</span>
|
|
||||||
<span className="text-muted-foreground">({config.model_name})</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{assignedConfig && (
|
|
||||||
<div className="mt-3 p-3 bg-muted/50 rounded-lg">
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<Bot className="w-4 h-4" />
|
|
||||||
<span className="font-medium">{t("assigned")}:</span>
|
|
||||||
{assignedConfig.is_global && (
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
🌐 Global
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<Badge variant="secondary">{assignedConfig.provider}</Badge>
|
|
||||||
<span>{assignedConfig.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
|
||||||
{t("model")}: {assignedConfig.model_name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Indicator */}
|
|
||||||
{isAssignmentComplete && (
|
|
||||||
<div className="flex justify-center pt-4">
|
|
||||||
<div className="flex items-center gap-2 px-4 py-2 bg-green-50 text-green-700 rounded-lg border border-green-200">
|
|
||||||
<CheckCircle className="w-4 h-4" />
|
|
||||||
<span className="text-sm font-medium">{t("all_roles_assigned_saved")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Progress Indicator */}
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<span>{t("progress")}:</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{Object.keys(ROLE_DESCRIPTIONS).map((key, _index) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className={`w-2 h-2 rounded-full ${
|
|
||||||
assignments[`${key}_llm_id` as keyof typeof assignments]
|
|
||||||
? "bg-primary"
|
|
||||||
: "bg-muted"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<span>
|
|
||||||
{t("roles_assigned", {
|
|
||||||
assigned: Object.values(assignments).filter(Boolean).length,
|
|
||||||
total: Object.keys(ROLE_DESCRIPTIONS).length,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +1,28 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowRight, Bot, Brain, CheckCircle, Sparkles, Zap } from "lucide-react";
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Bot,
|
||||||
|
Brain,
|
||||||
|
CheckCircle,
|
||||||
|
FileText,
|
||||||
|
MessageSquare,
|
||||||
|
Sparkles,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
|
import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||||
|
|
||||||
const ROLE_ICONS = {
|
|
||||||
long_context: Brain,
|
|
||||||
fast: Zap,
|
|
||||||
strategic: Bot,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CompletionStepProps {
|
interface CompletionStepProps {
|
||||||
searchSpaceId: number;
|
searchSpaceId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CompletionStep({ searchSpaceId }: CompletionStepProps) {
|
export function CompletionStep({ searchSpaceId }: CompletionStepProps) {
|
||||||
|
const router = useRouter();
|
||||||
const { llmConfigs } = useLLMConfigs(searchSpaceId);
|
const { llmConfigs } = useLLMConfigs(searchSpaceId);
|
||||||
const { globalConfigs } = useGlobalLLMConfigs();
|
const { globalConfigs } = useGlobalLLMConfigs();
|
||||||
const { preferences } = useLLMPreferences(searchSpaceId);
|
const { preferences } = useLLMPreferences(searchSpaceId);
|
||||||
|
|
@ -32,111 +38,123 @@ export function CompletionStep({ searchSpaceId }: CompletionStepProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Success Message */}
|
{/* Next Steps - What would you like to do? */}
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
className="text-center"
|
|
||||||
>
|
|
||||||
<div className="w-20 h-20 mx-auto mb-6 bg-green-100 rounded-full flex items-center justify-center">
|
|
||||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold mb-2">Setup Complete!</h2>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Configuration Summary */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.2 }}
|
|
||||||
>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Sparkles className="w-5 h-5" />
|
|
||||||
Your LLM Configuration
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Here's a summary of your setup</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{Object.entries(assignedConfigs).map(([role, config]) => {
|
|
||||||
if (!config) return null;
|
|
||||||
|
|
||||||
const IconComponent = ROLE_ICONS[role as keyof typeof ROLE_ICONS];
|
|
||||||
const roleDisplayNames = {
|
|
||||||
long_context: "Long Context LLM",
|
|
||||||
fast: "Fast LLM",
|
|
||||||
strategic: "Strategic LLM",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={role}
|
|
||||||
initial={{ opacity: 0, x: -10 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: 0.3 + Object.keys(assignedConfigs).indexOf(role) * 0.1 }}
|
|
||||||
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 bg-background rounded-md">
|
|
||||||
<IconComponent className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">
|
|
||||||
{roleDisplayNames[role as keyof typeof roleDisplayNames]}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{config.name}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{config.is_global && (
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
🌐 Global
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<Badge variant="outline">{config.provider}</Badge>
|
|
||||||
<span className="text-sm text-muted-foreground">{config.model_name}</span>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Next Steps */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.6 }}
|
transition={{ delay: 0.6 }}
|
||||||
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<Card className="border-primary/20 bg-primary/5">
|
<div className="text-center">
|
||||||
<CardContent className="pt-6">
|
<h3 className="text-xl font-semibold mb-2">What would you like to do next?</h3>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<p className="text-muted-foreground">Choose an option to continue</p>
|
||||||
<div className="p-2 bg-primary rounded-md">
|
</div>
|
||||||
<ArrowRight className="w-4 h-4 text-primary-foreground" />
|
|
||||||
</div>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<h3 className="text-lg font-semibold">Ready to Get Started?</h3>
|
{/* Add Sources Card */}
|
||||||
</div>
|
<motion.div
|
||||||
<p className="text-muted-foreground mb-4">
|
initial={{ opacity: 0, x: -20 }}
|
||||||
Click "Complete Setup" to enter your dashboard and start exploring!
|
animate={{ opacity: 1, x: 0 }}
|
||||||
</p>
|
transition={{ delay: 0.7 }}
|
||||||
<div className="flex flex-wrap gap-2 text-sm">
|
>
|
||||||
<Badge variant="secondary">
|
<Card className="h-full border-2 hover:border-primary/50 transition-all hover:shadow-lg cursor-pointer group">
|
||||||
✓ {allConfigs.length} LLM provider{allConfigs.length > 1 ? "s" : ""} available
|
<CardHeader>
|
||||||
</Badge>
|
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-950 rounded-lg flex items-center justify-center mb-3 group-hover:scale-110 transition-transform">
|
||||||
{globalConfigs.length > 0 && (
|
<FileText className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||||
<Badge variant="secondary">✓ {globalConfigs.length} Global config(s)</Badge>
|
</div>
|
||||||
)}
|
<CardTitle className="text-lg">Add Sources</CardTitle>
|
||||||
{llmConfigs.length > 0 && (
|
<CardDescription>
|
||||||
<Badge variant="secondary">✓ {llmConfigs.length} Custom config(s)</Badge>
|
Connect your data sources to start building your knowledge base
|
||||||
)}
|
</CardDescription>
|
||||||
<Badge variant="secondary">✓ All roles assigned</Badge>
|
</CardHeader>
|
||||||
<Badge variant="secondary">✓ Ready to use</Badge>
|
<CardContent className="space-y-4">
|
||||||
</div>
|
<div className="space-y-2 text-sm text-muted-foreground">
|
||||||
</CardContent>
|
<div className="flex items-center gap-2">
|
||||||
</Card>
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||||
|
<span>Connect documents and files</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||||
|
<span>Import from various sources</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||||
|
<span>Build your knowledge base</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full group-hover:bg-primary/90"
|
||||||
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/sources/add`)}
|
||||||
|
>
|
||||||
|
Add Sources
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Start Chatting Card */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.8 }}
|
||||||
|
>
|
||||||
|
<Card className="h-full border-2 hover:border-primary/50 transition-all hover:shadow-lg cursor-pointer group">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-950 rounded-lg flex items-center justify-center mb-3 group-hover:scale-110 transition-transform">
|
||||||
|
<MessageSquare className="w-6 h-6 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-lg">Start Chatting</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Jump right into the AI researcher and start asking questions
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||||
|
<span>AI-powered conversations</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||||
|
<span>Research and explore topics</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||||
|
<span>Get instant insights</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full group-hover:bg-primary/90"
|
||||||
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/researcher`)}
|
||||||
|
>
|
||||||
|
Start Chatting
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.9 }}
|
||||||
|
className="flex flex-wrap justify-center gap-2 pt-4"
|
||||||
|
>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
✓ {allConfigs.length} LLM provider{allConfigs.length > 1 ? "s" : ""} available
|
||||||
|
</Badge>
|
||||||
|
{globalConfigs.length > 0 && (
|
||||||
|
<Badge variant="secondary">✓ {globalConfigs.length} Global config(s)</Badge>
|
||||||
|
)}
|
||||||
|
{llmConfigs.length > 0 && (
|
||||||
|
<Badge variant="secondary">✓ {llmConfigs.length} Custom config(s)</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="secondary">✓ All roles assigned</Badge>
|
||||||
|
<Badge variant="secondary">✓ Ready to use</Badge>
|
||||||
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
752
surfsense_web/components/onboard/setup-llm-step.tsx
Normal file
752
surfsense_web/components/onboard/setup-llm-step.tsx
Normal file
|
|
@ -0,0 +1,752 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Bot,
|
||||||
|
Brain,
|
||||||
|
Check,
|
||||||
|
CheckCircle,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronsUpDown,
|
||||||
|
ChevronUp,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { LANGUAGES } from "@/contracts/enums/languages";
|
||||||
|
import { getModelsByProvider } from "@/contracts/enums/llm-models";
|
||||||
|
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
|
||||||
|
import {
|
||||||
|
type CreateLLMConfig,
|
||||||
|
useGlobalLLMConfigs,
|
||||||
|
useLLMConfigs,
|
||||||
|
useLLMPreferences,
|
||||||
|
} from "@/hooks/use-llm-configs";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import InferenceParamsEditor from "../inference-params-editor";
|
||||||
|
|
||||||
|
interface SetupLLMStepProps {
|
||||||
|
searchSpaceId: number;
|
||||||
|
onConfigCreated?: () => void;
|
||||||
|
onConfigDeleted?: () => void;
|
||||||
|
onPreferencesUpdated?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_DESCRIPTIONS = {
|
||||||
|
long_context: {
|
||||||
|
icon: Brain,
|
||||||
|
key: "long_context_llm_id" as const,
|
||||||
|
titleKey: "long_context_llm_title",
|
||||||
|
descKey: "long_context_llm_desc",
|
||||||
|
examplesKey: "long_context_llm_examples",
|
||||||
|
color:
|
||||||
|
"bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-950 dark:text-blue-200 dark:border-blue-800",
|
||||||
|
},
|
||||||
|
fast: {
|
||||||
|
icon: Zap,
|
||||||
|
key: "fast_llm_id" as const,
|
||||||
|
titleKey: "fast_llm_title",
|
||||||
|
descKey: "fast_llm_desc",
|
||||||
|
examplesKey: "fast_llm_examples",
|
||||||
|
color:
|
||||||
|
"bg-green-100 text-green-800 border-green-200 dark:bg-green-950 dark:text-green-200 dark:border-green-800",
|
||||||
|
},
|
||||||
|
strategic: {
|
||||||
|
icon: Bot,
|
||||||
|
key: "strategic_llm_id" as const,
|
||||||
|
titleKey: "strategic_llm_title",
|
||||||
|
descKey: "strategic_llm_desc",
|
||||||
|
examplesKey: "strategic_llm_examples",
|
||||||
|
color:
|
||||||
|
"bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-950 dark:text-purple-200 dark:border-purple-800",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SetupLLMStep({
|
||||||
|
searchSpaceId,
|
||||||
|
onConfigCreated,
|
||||||
|
onConfigDeleted,
|
||||||
|
onPreferencesUpdated,
|
||||||
|
}: SetupLLMStepProps) {
|
||||||
|
const t = useTranslations("onboard");
|
||||||
|
const { llmConfigs, createLLMConfig, deleteLLMConfig } = useLLMConfigs(searchSpaceId);
|
||||||
|
const { globalConfigs } = useGlobalLLMConfigs();
|
||||||
|
const { preferences, updatePreferences } = useLLMPreferences(searchSpaceId);
|
||||||
|
|
||||||
|
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<CreateLLMConfig>({
|
||||||
|
name: "",
|
||||||
|
provider: "",
|
||||||
|
custom_provider: "",
|
||||||
|
model_name: "",
|
||||||
|
api_key: "",
|
||||||
|
api_base: "",
|
||||||
|
language: "English",
|
||||||
|
litellm_params: {},
|
||||||
|
search_space_id: searchSpaceId,
|
||||||
|
});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
||||||
|
const [showProviderForm, setShowProviderForm] = useState(false);
|
||||||
|
|
||||||
|
// Role assignments state
|
||||||
|
const [assignments, setAssignments] = useState({
|
||||||
|
long_context_llm_id: preferences.long_context_llm_id || "",
|
||||||
|
fast_llm_id: preferences.fast_llm_id || "",
|
||||||
|
strategic_llm_id: preferences.strategic_llm_id || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine global and user-specific configs
|
||||||
|
const allConfigs = [...globalConfigs, ...llmConfigs];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAssignments({
|
||||||
|
long_context_llm_id: preferences.long_context_llm_id || "",
|
||||||
|
fast_llm_id: preferences.fast_llm_id || "",
|
||||||
|
strategic_llm_id: preferences.strategic_llm_id || "",
|
||||||
|
});
|
||||||
|
}, [preferences]);
|
||||||
|
|
||||||
|
const handleInputChange = (field: keyof CreateLLMConfig, value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!formData.name || !formData.provider || !formData.model_name || !formData.api_key) {
|
||||||
|
toast.error("Please fill in all required fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const result = await createLLMConfig(formData);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
setFormData({
|
||||||
|
name: "",
|
||||||
|
provider: "",
|
||||||
|
custom_provider: "",
|
||||||
|
model_name: "",
|
||||||
|
api_key: "",
|
||||||
|
api_base: "",
|
||||||
|
language: "English",
|
||||||
|
litellm_params: {},
|
||||||
|
search_space_id: searchSpaceId,
|
||||||
|
});
|
||||||
|
setIsAddingNew(false);
|
||||||
|
onConfigCreated?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoleAssignment = async (role: string, configId: string) => {
|
||||||
|
const newAssignments = {
|
||||||
|
...assignments,
|
||||||
|
[role]: configId === "" ? "" : parseInt(configId),
|
||||||
|
};
|
||||||
|
|
||||||
|
setAssignments(newAssignments);
|
||||||
|
|
||||||
|
// Auto-save if this assignment completes all roles
|
||||||
|
const hasAllAssignments =
|
||||||
|
newAssignments.long_context_llm_id &&
|
||||||
|
newAssignments.fast_llm_id &&
|
||||||
|
newAssignments.strategic_llm_id;
|
||||||
|
|
||||||
|
if (hasAllAssignments) {
|
||||||
|
const numericAssignments = {
|
||||||
|
long_context_llm_id:
|
||||||
|
typeof newAssignments.long_context_llm_id === "string"
|
||||||
|
? parseInt(newAssignments.long_context_llm_id)
|
||||||
|
: newAssignments.long_context_llm_id,
|
||||||
|
fast_llm_id:
|
||||||
|
typeof newAssignments.fast_llm_id === "string"
|
||||||
|
? parseInt(newAssignments.fast_llm_id)
|
||||||
|
: newAssignments.fast_llm_id,
|
||||||
|
strategic_llm_id:
|
||||||
|
typeof newAssignments.strategic_llm_id === "string"
|
||||||
|
? parseInt(newAssignments.strategic_llm_id)
|
||||||
|
: newAssignments.strategic_llm_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const success = await updatePreferences(numericAssignments);
|
||||||
|
|
||||||
|
if (success && onPreferencesUpdated) {
|
||||||
|
await onPreferencesUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedProvider = LLM_PROVIDERS.find((p) => p.value === formData.provider);
|
||||||
|
const availableModels = formData.provider ? getModelsByProvider(formData.provider) : [];
|
||||||
|
|
||||||
|
const handleParamsChange = (newParams: Record<string, number | string>) => {
|
||||||
|
setFormData((prev) => ({ ...prev, litellm_params: newParams }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProviderChange = (value: string) => {
|
||||||
|
handleInputChange("provider", value);
|
||||||
|
setFormData((prev) => ({ ...prev, model_name: "" }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAssignmentComplete =
|
||||||
|
assignments.long_context_llm_id && assignments.fast_llm_id && assignments.strategic_llm_id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Global Configs Notice - Prominent at top */}
|
||||||
|
{globalConfigs.length > 0 && (
|
||||||
|
<Alert className="bg-blue-50 border-blue-200 dark:bg-blue-950 dark:border-blue-800">
|
||||||
|
<CheckCircle className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
<AlertDescription className="text-blue-800 dark:text-blue-200">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-semibold text-base">
|
||||||
|
{globalConfigs.length} global configuration(s) available!
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
You can skip adding your own LLM provider and use our pre-configured models in the
|
||||||
|
role assignment section below.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Or expand "Add LLM Provider" to add your own custom configurations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section 1: Add LLM Providers */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||||
|
<Bot className="w-5 h-5" />
|
||||||
|
{t("add_llm_provider")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{t("configure_first_provider")}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowProviderForm(!showProviderForm)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{showProviderForm ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
Collapse
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
Expand
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showProviderForm && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
{/* Info Alert */}
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{t("add_provider_instruction")}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* Existing Configurations */}
|
||||||
|
{llmConfigs.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-semibold text-muted-foreground">
|
||||||
|
{t("your_llm_configs")}
|
||||||
|
</h4>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{llmConfigs.map((config) => (
|
||||||
|
<motion.div
|
||||||
|
key={config.id}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
>
|
||||||
|
<Card className="border-l-4 border-l-primary">
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Bot className="w-4 h-4" />
|
||||||
|
<h4 className="font-medium">{config.name}</h4>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{config.provider}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("model")}: {config.model_name}
|
||||||
|
{config.language && ` • ${t("language")}: ${config.language}`}
|
||||||
|
{config.api_base && ` • ${t("base")}: ${config.api_base}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
const success = await deleteLLMConfig(config.id);
|
||||||
|
if (success) {
|
||||||
|
onConfigDeleted?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add New Provider */}
|
||||||
|
{!isAddingNew ? (
|
||||||
|
<Card className="border-dashed border-2 hover:border-primary/50 transition-colors">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-8">
|
||||||
|
<Plus className="w-8 h-8 text-muted-foreground mb-3" />
|
||||||
|
<h4 className="font-semibold mb-1">{t("add_provider_title")}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground text-center mb-3">
|
||||||
|
{t("add_provider_subtitle")}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setIsAddingNew(true)} size="sm">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
{t("add_provider_button")}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">{t("add_new_llm_provider")}</CardTitle>
|
||||||
|
<CardDescription>{t("configure_new_provider")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">{t("config_name_required")}</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder={t("config_name_placeholder")}
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => handleInputChange("name", e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="provider">{t("provider_required")}</Label>
|
||||||
|
<Select value={formData.provider} onValueChange={handleProviderChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t("provider_placeholder")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-[300px]">
|
||||||
|
{LLM_PROVIDERS.map((provider) => (
|
||||||
|
<SelectItem key={provider.value} value={provider.value}>
|
||||||
|
{provider.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="language">{t("language_optional")}</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.language || "English"}
|
||||||
|
onValueChange={(value) => handleInputChange("language", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t("language_placeholder")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LANGUAGES.map((language) => (
|
||||||
|
<SelectItem key={language.value} value={language.value}>
|
||||||
|
{language.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.provider === "CUSTOM" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="custom_provider">{t("custom_provider_name")}</Label>
|
||||||
|
<Input
|
||||||
|
id="custom_provider"
|
||||||
|
placeholder={t("custom_provider_placeholder")}
|
||||||
|
value={formData.custom_provider}
|
||||||
|
onChange={(e) => handleInputChange("custom_provider", e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="model_name">{t("model_name_required")}</Label>
|
||||||
|
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
aria-expanded={modelComboboxOpen}
|
||||||
|
className="w-full justify-between font-normal"
|
||||||
|
>
|
||||||
|
<span className={cn(!formData.model_name && "text-muted-foreground")}>
|
||||||
|
{formData.model_name || t("model_name_placeholder")}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0" align="start" side="bottom">
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={
|
||||||
|
selectedProvider?.example ||
|
||||||
|
t("model_name_placeholder") ||
|
||||||
|
"Type model name..."
|
||||||
|
}
|
||||||
|
value={formData.model_name}
|
||||||
|
onValueChange={(value) => handleInputChange("model_name", value)}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
<div className="py-2 text-center text-sm text-muted-foreground">
|
||||||
|
{formData.model_name
|
||||||
|
? `Using custom model: "${formData.model_name}"`
|
||||||
|
: "Type your model name above"}
|
||||||
|
</div>
|
||||||
|
</CommandEmpty>
|
||||||
|
{availableModels.length > 0 && (
|
||||||
|
<CommandGroup heading="Suggested Models">
|
||||||
|
{availableModels
|
||||||
|
.filter(
|
||||||
|
(model) =>
|
||||||
|
!formData.model_name ||
|
||||||
|
model.value
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(formData.model_name.toLowerCase()) ||
|
||||||
|
model.label
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(formData.model_name.toLowerCase())
|
||||||
|
)
|
||||||
|
.map((model) => (
|
||||||
|
<CommandItem
|
||||||
|
key={model.value}
|
||||||
|
value={model.value}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
handleInputChange("model_name", currentValue);
|
||||||
|
setModelComboboxOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex flex-col items-start py-3"
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center">
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4 shrink-0",
|
||||||
|
formData.model_name === model.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{model.label}</div>
|
||||||
|
{model.contextWindow && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Context: {model.contextWindow}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{availableModels.length > 0
|
||||||
|
? `Type freely or select from ${availableModels.length} model suggestions`
|
||||||
|
: selectedProvider?.example
|
||||||
|
? `${t("examples")}: ${selectedProvider.example}`
|
||||||
|
: "Type your model name freely"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="api_key">{t("api_key_required")}</Label>
|
||||||
|
<Input
|
||||||
|
id="api_key"
|
||||||
|
type="password"
|
||||||
|
placeholder={t("api_key_placeholder")}
|
||||||
|
value={formData.api_key}
|
||||||
|
onChange={(e) => handleInputChange("api_key", e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="api_base">{t("api_base_optional")}</Label>
|
||||||
|
<Input
|
||||||
|
id="api_base"
|
||||||
|
placeholder={t("api_base_placeholder")}
|
||||||
|
value={formData.api_base}
|
||||||
|
onChange={(e) => handleInputChange("api_base", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<InferenceParamsEditor
|
||||||
|
params={formData.litellm_params || {}}
|
||||||
|
setParams={handleParamsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button type="submit" disabled={isSubmitting} size="sm">
|
||||||
|
{isSubmitting ? t("adding") : t("add_provider")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsAddingNew(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-8" />
|
||||||
|
|
||||||
|
{/* Section 2: Assign Roles */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||||
|
<Brain className="w-5 h-5" />
|
||||||
|
{t("assign_llm_roles")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{t("assign_specific_roles")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allConfigs.length === 0 ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{t("add_provider_before_roles")}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{t("assign_roles_instruction")}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{Object.entries(ROLE_DESCRIPTIONS).map(([roleKey, role]) => {
|
||||||
|
const IconComponent = role.icon;
|
||||||
|
const currentAssignment = assignments[role.key];
|
||||||
|
const assignedConfig = allConfigs.find((config) => config.id === currentAssignment);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={roleKey}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: Object.keys(ROLE_DESCRIPTIONS).indexOf(roleKey) * 0.1 }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className={`border-l-4 ${currentAssignment ? "border-l-primary" : "border-l-muted"}`}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${role.color}`}>
|
||||||
|
<IconComponent className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{t(role.titleKey)}</CardTitle>
|
||||||
|
<CardDescription className="mt-1 text-xs">
|
||||||
|
{t(role.descKey)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{currentAssignment && <CheckCircle className="w-5 h-5 text-green-500" />}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<strong>{t("use_cases")}:</strong> {t(role.examplesKey)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">{t("assign_llm_config")}:</Label>
|
||||||
|
<Select
|
||||||
|
value={currentAssignment?.toString() || ""}
|
||||||
|
onValueChange={(value) => handleRoleAssignment(role.key, value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t("select_llm_config")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{globalConfigs.length > 0 && (
|
||||||
|
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||||
|
{t("global_configs") || "Global Configurations"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{globalConfigs
|
||||||
|
.filter((config) => config.id && config.id.toString().trim() !== "")
|
||||||
|
.map((config) => (
|
||||||
|
<SelectItem key={config.id} value={config.id.toString()}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
🌐 Global
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{config.provider}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm">{config.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({config.model_name})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{llmConfigs.length > 0 && globalConfigs.length > 0 && (
|
||||||
|
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground border-t mt-1">
|
||||||
|
{t("your_configs") || "Your Configurations"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{llmConfigs
|
||||||
|
.filter((config) => config.id && config.id.toString().trim() !== "")
|
||||||
|
.map((config) => (
|
||||||
|
<SelectItem key={config.id} value={config.id.toString()}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{config.provider}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm">{config.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({config.model_name})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{assignedConfig && (
|
||||||
|
<div className="mt-2 p-3 bg-muted/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Bot className="w-4 h-4" />
|
||||||
|
<span className="font-medium">{t("assigned")}:</span>
|
||||||
|
{assignedConfig.is_global && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
🌐 Global
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{assignedConfig.provider}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm">{assignedConfig.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("model")}: {assignedConfig.model_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Indicators */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 pt-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>{t("progress")}:</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{Object.keys(ROLE_DESCRIPTIONS).map((key) => {
|
||||||
|
const roleKey = ROLE_DESCRIPTIONS[key as keyof typeof ROLE_DESCRIPTIONS].key;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={`w-2 h-2 rounded-full ${
|
||||||
|
assignments[roleKey] ? "bg-primary" : "bg-muted"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
{t("roles_assigned", {
|
||||||
|
assigned: Object.values(assignments).filter(Boolean).length,
|
||||||
|
total: Object.keys(ROLE_DESCRIPTIONS).length,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAssignmentComplete && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-200 rounded-lg border border-green-200 dark:border-green-800">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">{t("all_roles_assigned_saved")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -64,7 +64,7 @@ const defaultData = {
|
||||||
},
|
},
|
||||||
navMain: [
|
navMain: [
|
||||||
{
|
{
|
||||||
title: "Researcher",
|
title: "Chat",
|
||||||
url: "#",
|
url: "#",
|
||||||
icon: "SquareTerminal",
|
icon: "SquareTerminal",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
|
|
||||||
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 = {
|
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 { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,8 @@
|
||||||
"manage_connectors": "Manage Connectors",
|
"manage_connectors": "Manage Connectors",
|
||||||
"podcasts": "Podcasts",
|
"podcasts": "Podcasts",
|
||||||
"logs": "Logs",
|
"logs": "Logs",
|
||||||
"all_search_spaces": "All Search Spaces"
|
"all_search_spaces": "All Search Spaces",
|
||||||
|
"chat": "Chat"
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"title": "SurfSense Pricing",
|
"title": "SurfSense Pricing",
|
||||||
|
|
@ -527,6 +528,8 @@
|
||||||
"percent_complete": "{percent}% Complete",
|
"percent_complete": "{percent}% Complete",
|
||||||
"add_llm_provider": "Add LLM Provider",
|
"add_llm_provider": "Add LLM Provider",
|
||||||
"assign_llm_roles": "Assign LLM Roles",
|
"assign_llm_roles": "Assign LLM Roles",
|
||||||
|
"setup_llm_configuration": "Setup LLM Configuration",
|
||||||
|
"configure_providers_and_assign_roles": "Add your LLM providers and assign them to specific roles",
|
||||||
"setup_complete": "Setup Complete",
|
"setup_complete": "Setup Complete",
|
||||||
"configure_first_provider": "Configure your first model provider",
|
"configure_first_provider": "Configure your first model provider",
|
||||||
"assign_specific_roles": "Assign specific roles to your LLM configurations",
|
"assign_specific_roles": "Assign specific roles to your LLM configurations",
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,8 @@
|
||||||
"manage_connectors": "管理连接器",
|
"manage_connectors": "管理连接器",
|
||||||
"podcasts": "播客",
|
"podcasts": "播客",
|
||||||
"logs": "日志",
|
"logs": "日志",
|
||||||
"all_search_spaces": "所有搜索空间"
|
"all_search_spaces": "所有搜索空间",
|
||||||
|
"chat": "聊天"
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"title": "SurfSense 定价",
|
"title": "SurfSense 定价",
|
||||||
|
|
@ -527,6 +528,8 @@
|
||||||
"percent_complete": "已完成 {percent}%",
|
"percent_complete": "已完成 {percent}%",
|
||||||
"add_llm_provider": "添加 LLM 提供商",
|
"add_llm_provider": "添加 LLM 提供商",
|
||||||
"assign_llm_roles": "分配 LLM 角色",
|
"assign_llm_roles": "分配 LLM 角色",
|
||||||
|
"setup_llm_configuration": "设置 LLM 配置",
|
||||||
|
"configure_providers_and_assign_roles": "添加您的 LLM 提供商并为其分配特定角色",
|
||||||
"setup_complete": "设置完成",
|
"setup_complete": "设置完成",
|
||||||
"configure_first_provider": "配置您的第一个模型提供商",
|
"configure_first_provider": "配置您的第一个模型提供商",
|
||||||
"assign_specific_roles": "为您的 LLM 配置分配特定角色",
|
"assign_specific_roles": "为您的 LLM 配置分配特定角色",
|
||||||
|
|
|
||||||
|
|
@ -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