Merge remote-tracking branch 'upstream/main' into feat/bookstack-connector

This commit is contained in:
Differ 2025-12-06 09:15:02 +08:00
commit e238fab638
110 changed files with 10076 additions and 1671 deletions

View file

@ -18,6 +18,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Separator } from "@/components/ui/separator";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { useLLMPreferences } from "@/hooks/use-llm-configs";
import { useUserAccess } from "@/hooks/use-rbac";
import { cn } from "@/lib/utils";
export function DashboardClientLayout({
@ -60,11 +61,15 @@ export function DashboardClientLayout({
}, [activeChatId, isChatPannelOpen]);
const { loading, error, isOnboardingComplete } = useLLMPreferences(searchSpaceIdNum);
const { access, loading: accessLoading } = useUserAccess(searchSpaceIdNum);
const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false);
// Skip onboarding check if we're already on the onboarding page
const isOnboardingPage = pathname?.includes("/onboard");
// Only owners should see onboarding - invited members use existing config
const isOwner = access?.is_owner ?? false;
// Translate navigation items
const tNavMenu = useTranslations("nav_menu");
const translatedNavMain = useMemo(() => {
@ -102,11 +107,13 @@ export function DashboardClientLayout({
return;
}
// Only check once after preferences have loaded
if (!loading && !hasCheckedOnboarding) {
// Wait for both preferences and access data to load
if (!loading && !accessLoading && !hasCheckedOnboarding) {
const onboardingComplete = isOnboardingComplete();
if (!onboardingComplete) {
// Only redirect to onboarding if user is the owner and onboarding is not complete
// Invited members (non-owners) should skip onboarding and use existing config
if (!onboardingComplete && isOwner) {
router.push(`/dashboard/${searchSpaceId}/onboard`);
}
@ -114,8 +121,10 @@ export function DashboardClientLayout({
}
}, [
loading,
accessLoading,
isOnboardingComplete,
isOnboardingPage,
isOwner,
router,
searchSpaceId,
hasCheckedOnboarding,
@ -145,7 +154,7 @@ export function DashboardClientLayout({
}, [chat_id, search_space_id]);
// Show loading screen while checking onboarding status (only on first load)
if (!hasCheckedOnboarding && loading && !isOnboardingPage) {
if (!hasCheckedOnboarding && (loading || accessLoading) && !isOnboardingPage) {
return (
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">

View file

@ -22,6 +22,7 @@ import {
type SearchSourceConnector,
useSearchSourceConnectors,
} from "@/hooks/use-search-source-connectors";
import { authenticatedFetch } from "@/lib/auth-utils";
export default function AirtableConnectorPage() {
const router = useRouter();
@ -46,14 +47,9 @@ export default function AirtableConnectorPage() {
const handleConnectAirtable = async () => {
setIsConnecting(true);
try {
const response = await fetch(
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/airtable/connector/add/?space_id=${searchSpaceId}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
},
}
{ method: "GET" }
);
if (!response.ok) {

View file

@ -40,6 +40,7 @@ import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
// Assuming useSearchSourceConnectors hook exists and works similarly
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
import { authenticatedFetch, redirectToLogin } from "@/lib/auth-utils";
// Define the form schema with Zod for GitHub PAT entry step
const githubPatFormSchema = z.object({
@ -101,19 +102,11 @@ export default function GithubConnectorPage() {
setConnectorName(values.name); // Store the name
setValidatedPat(values.github_pat); // Store the PAT temporarily
try {
const token = localStorage.getItem("surfsense_bearer_token");
if (!token) {
throw new Error("No authentication token found");
}
const response = await fetch(
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ github_pat: values.github_pat }),
}
);

View file

@ -24,6 +24,7 @@ import {
type SearchSourceConnector,
useSearchSourceConnectors,
} from "@/hooks/use-search-source-connectors";
import { authenticatedFetch } from "@/lib/auth-utils";
export default function GoogleCalendarConnectorPage() {
const router = useRouter();
@ -51,14 +52,9 @@ export default function GoogleCalendarConnectorPage() {
try {
setIsConnecting(true);
// Call backend to initiate authorization flow
const response = await fetch(
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/google/calendar/connector/add/?space_id=${searchSpaceId}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
},
}
{ method: "GET" }
);
if (!response.ok) {

View file

@ -24,6 +24,7 @@ import {
type SearchSourceConnector,
useSearchSourceConnectors,
} from "@/hooks/use-search-source-connectors";
import { authenticatedFetch } from "@/lib/auth-utils";
export default function GoogleGmailConnectorPage() {
const router = useRouter();
@ -50,14 +51,9 @@ export default function GoogleGmailConnectorPage() {
try {
setIsConnecting(true);
// Call backend to initiate authorization flow
const response = await fetch(
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/google/gmail/connector/add/?space_id=${searchSpaceId}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
},
}
{ method: "GET" }
);
if (!response.ok) {

View file

@ -309,6 +309,7 @@ export function DocumentsTableShell({
refreshDocuments={async () => {
await onRefresh();
}}
searchSpaceId={searchSpaceId as string}
/>
</TableCell>
</motion.tr>
@ -340,6 +341,7 @@ export function DocumentsTableShell({
refreshDocuments={async () => {
await onRefresh();
}}
searchSpaceId={searchSpaceId as string}
/>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">

View file

@ -1,6 +1,8 @@
"use client";
import { MoreHorizontal } from "lucide-react";
import { FileText, Pencil, Trash2 } from "lucide-react";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
@ -12,29 +14,26 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { Document } from "./types";
export function RowActions({
document,
deleteDocument,
refreshDocuments,
searchSpaceId,
}: {
document: Document;
deleteDocument: (id: number) => Promise<boolean>;
refreshDocuments: () => Promise<void>;
searchSpaceId: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isMetadataOpen, setIsMetadataOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const router = useRouter();
const handleDelete = async () => {
setIsDeleting(true);
@ -48,62 +47,114 @@ export function RowActions({
toast.error("Failed to delete document");
} finally {
setIsDeleting(false);
setIsOpen(false);
setIsDeleteOpen(false);
}
};
const handleEdit = () => {
router.push(`/dashboard/${searchSpaceId}/editor/${document.id}`);
};
return (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<JsonMetadataViewer
title={document.title}
metadata={document.document_metadata}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
View Metadata
</DropdownMenuItem>
}
/>
<DropdownMenuSeparator />
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialogTrigger asChild>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
>
Delete
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center justify-end gap-1">
{/* Edit Button */}
<Tooltip>
<TooltipTrigger asChild>
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted/80"
onClick={handleEdit}
>
<Pencil className="h-4 w-4" />
<span className="sr-only">Edit Document</span>
</Button>
</motion.div>
</TooltipTrigger>
<TooltipContent side="top">
<p>Edit Document</p>
</TooltipContent>
</Tooltip>
{/* View Metadata Button */}
<Tooltip>
<TooltipTrigger asChild>
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted/80"
onClick={() => setIsMetadataOpen(true)}
>
<FileText className="h-4 w-4" />
<span className="sr-only">View Metadata</span>
</Button>
</motion.div>
</TooltipTrigger>
<TooltipContent side="top">
<p>View Metadata</p>
</TooltipContent>
</Tooltip>
<JsonMetadataViewer
title={document.title}
metadata={document.document_metadata}
open={isMetadataOpen}
onOpenChange={setIsMetadataOpen}
/>
{/* Delete Button */}
<Tooltip>
<TooltipTrigger asChild>
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setIsDeleteOpen(true)}
disabled={isDeleting}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
</motion.div>
</TooltipTrigger>
<TooltipContent side="top">
<p>Delete</p>
</TooltipContent>
</Tooltip>
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -0,0 +1,256 @@
"use client";
import { AlertCircle, FileText, Loader2, Save, X } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
interface EditorContent {
document_id: number;
title: string;
blocknote_document: any;
last_edited_at: string | null;
}
export default function EditorPage() {
const params = useParams();
const router = useRouter();
const documentId = params.documentId as string;
const [document, setDocument] = useState<EditorContent | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editorContent, setEditorContent] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// Fetch document content - DIRECT CALL TO FASTAPI
useEffect(() => {
async function fetchDocument() {
const token = getBearerToken();
if (!token) {
console.error("No auth token found");
// Redirect to login with current path saved
redirectToLogin();
return;
}
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/editor-content`,
{ method: "GET" }
);
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to fetch document" }));
throw new Error(errorData.detail || "Failed to fetch document");
}
const data = await response.json();
// Check if blocknote_document exists
if (!data.blocknote_document) {
setError(
"This document does not have BlockNote content. Please re-upload the document to enable editing."
);
setLoading(false);
return;
}
setDocument(data);
setEditorContent(data.blocknote_document);
setError(null);
} catch (error) {
console.error("Error fetching document:", error);
setError(
error instanceof Error ? error.message : "Failed to fetch document. Please try again."
);
} finally {
setLoading(false);
}
}
if (documentId) {
fetchDocument();
}
}, [documentId, params.search_space_id]);
// Track changes to mark as unsaved
useEffect(() => {
if (editorContent && document) {
setHasUnsavedChanges(true);
}
}, [editorContent, document]);
// TODO: Maybe add Auto-save every 30 seconds - DIRECT CALL TO FASTAPI
// Save and exit - DIRECT CALL TO FASTAPI
const handleSave = async () => {
const token = getBearerToken();
if (!token) {
toast.error("Please login to save");
redirectToLogin();
return;
}
if (!editorContent) {
toast.error("No content to save");
return;
}
setSaving(true);
try {
// Save blocknote_document and trigger reindexing in background
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blocknote_document: editorContent }),
}
);
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to save document" }));
throw new Error(errorData.detail || "Failed to save document");
}
setHasUnsavedChanges(false);
toast.success("Document saved! Reindexing in background...");
// Small delay before redirect to show success message
setTimeout(() => {
router.push(`/dashboard/${params.search_space_id}/documents`);
}, 500);
} catch (error) {
console.error("Error saving document:", error);
toast.error(
error instanceof Error ? error.message : "Failed to save document. Please try again."
);
} finally {
setSaving(false);
}
};
const handleCancel = () => {
if (hasUnsavedChanges) {
if (confirm("You have unsaved changes. Are you sure you want to leave?")) {
router.back();
}
} else {
router.back();
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px] p-6">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-12 w-12 text-primary animate-spin mb-4" />
<p className="text-muted-foreground">Loading editor...</p>
</CardContent>
</Card>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-[400px] p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-md"
>
<Card className="border-destructive/50">
<CardHeader>
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
<CardTitle className="text-destructive">Error</CardTitle>
</div>
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => router.back()} variant="outline" className="w-full">
<X className="mr-2 h-4 w-4" />
Go Back
</Button>
</CardContent>
</Card>
</motion.div>
</div>
);
}
if (!document) {
return (
<div className="flex items-center justify-center min-h-[400px] p-6">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground">Document not found</p>
</CardContent>
</Card>
</div>
);
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col h-full w-full"
>
{/* Toolbar */}
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-4 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-6">
<div className="flex items-center gap-3 flex-1 min-w-0">
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="flex flex-col min-w-0">
<h1 className="text-lg font-semibold truncate">{document.title}</h1>
{hasUnsavedChanges && <p className="text-xs text-muted-foreground">Unsaved changes</p>}
</div>
</div>
<Separator orientation="vertical" className="h-6" />
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel} disabled={saving} className="gap-2">
<X className="h-4 w-4" />
Cancel
</Button>
<Button onClick={handleSave} disabled={saving} className="gap-2">
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4" />
Save & Exit
</>
)}
</Button>
</div>
</div>
{/* Editor Container */}
<div className="flex-1 overflow-hidden relative">
<div className="h-full w-full overflow-auto p-6">
<div className="max-w-4xl mx-auto">
<BlockNoteEditor initialContent={editorContent} onChange={setEditorContent} />
</div>
</div>
</div>
</motion.div>
);
}

View file

@ -52,6 +52,12 @@ export default function DashboardLayout({
},
],
},
{
title: "Team",
url: `/dashboard/${search_space_id}/team`,
icon: "Users",
items: [],
},
{
title: "Settings",
url: `/dashboard/${search_space_id}/settings`,

View file

@ -1126,7 +1126,7 @@ function LogRowActions({ row, t }: { row: Row<Log>; t: (key: string) => string }
setIsDeleting(true);
try {
await deleteLog(log.id);
toast.success(t("log_deleted_success"));
// toast.success(t("log_deleted_success"));
await refreshLogs();
} catch (error) {
console.error("Error deleting log:", error);

View file

@ -1,20 +1,19 @@
"use client";
import { ArrowLeft, ArrowRight, Bot, CheckCircle, MessageSquare, Sparkles } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { FileText, MessageSquare, UserPlus, Users } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { Logo } from "@/components/Logo";
import { CompletionStep } from "@/components/onboard/completion-step";
import { SetupLLMStep } from "@/components/onboard/setup-llm-step";
import { SetupPromptStep } from "@/components/onboard/setup-prompt-step";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { OnboardActionCard } from "@/components/onboard/onboard-action-card";
import { OnboardAdvancedSettings } from "@/components/onboard/onboard-advanced-settings";
import { OnboardHeader } from "@/components/onboard/onboard-header";
import { OnboardLLMSetup } from "@/components/onboard/onboard-llm-setup";
import { OnboardLoading } from "@/components/onboard/onboard-loading";
import { OnboardStats } from "@/components/onboard/onboard-stats";
import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
const TOTAL_STEPS = 3;
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
const OnboardPage = () => {
const t = useTranslations("onboard");
@ -28,10 +27,17 @@ const OnboardPage = () => {
preferences,
loading: preferencesLoading,
isOnboardingComplete,
updatePreferences,
refreshPreferences,
} = useLLMPreferences(searchSpaceId);
const [currentStep, setCurrentStep] = useState(1);
const [hasUserProgressed, setHasUserProgressed] = useState(false);
const [isAutoConfiguring, setIsAutoConfiguring] = useState(false);
const [autoConfigComplete, setAutoConfigComplete] = useState(false);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const [showPromptSettings, setShowPromptSettings] = useState(false);
// Track if we've already attempted auto-configuration
const hasAttemptedAutoConfig = useRef(false);
// Track if onboarding was complete on initial mount
const wasCompleteOnMount = useRef<boolean | null>(null);
@ -39,12 +45,13 @@ const OnboardPage = () => {
// Check if user is authenticated
useEffect(() => {
const token = localStorage.getItem("surfsense_bearer_token");
const token = getBearerToken();
if (!token) {
router.push("/login");
// Save current path and redirect to login
redirectToLogin();
return;
}
}, [router]);
}, []);
// Capture onboarding state on first load
useEffect(() => {
@ -59,231 +66,215 @@ const OnboardPage = () => {
}
}, [preferencesLoading, configsLoading, globalConfigsLoading, isOnboardingComplete]);
// Track if user has progressed beyond step 1
// Redirect to dashboard if onboarding was already complete
useEffect(() => {
if (currentStep > 1) {
setHasUserProgressed(true);
}
}, [currentStep]);
// Redirect to dashboard if onboarding was already complete on mount (not during this session)
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 (
wasCompleteOnMount.current === true &&
!hasUserProgressed &&
!preferencesLoading &&
!configsLoading &&
!globalConfigsLoading
) {
// Small delay to ensure the check is stable on initial load
const timer = setTimeout(() => {
router.push(`/dashboard/${searchSpaceId}`);
}, 300);
return () => clearTimeout(timer);
}
}, [
hasUserProgressed,
preferencesLoading,
configsLoading,
globalConfigsLoading,
router,
searchSpaceId,
]);
}, [preferencesLoading, configsLoading, globalConfigsLoading, router, searchSpaceId]);
const progress = (currentStep / TOTAL_STEPS) * 100;
const stepTitles = [t("setup_llm_configuration"), "Configure AI Responses", t("setup_complete")];
const stepDescriptions = [
t("configure_providers_and_assign_roles"),
"Customize how the AI responds to your queries (Optional)",
t("all_set"),
];
// User can proceed to step 2 if all roles are assigned
const canProceedToStep2 =
!preferencesLoading &&
preferences.long_context_llm_id &&
preferences.fast_llm_id &&
preferences.strategic_llm_id;
// User can always proceed from step 2 to step 3 (prompt config is optional)
const canProceedToStep3 = true;
const handleNext = () => {
if (currentStep < TOTAL_STEPS) {
setCurrentStep(currentStep + 1);
// Auto-configure LLM roles if global configs are available
const autoConfigureLLMs = useCallback(async () => {
if (hasAttemptedAutoConfig.current) return;
if (globalConfigs.length === 0) return;
if (isOnboardingComplete()) {
setAutoConfigComplete(true);
return;
}
};
const handlePrevious = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
hasAttemptedAutoConfig.current = true;
setIsAutoConfiguring(true);
try {
const allConfigs = [...globalConfigs, ...llmConfigs];
if (allConfigs.length === 0) {
setIsAutoConfiguring(false);
return;
}
// Use first available config for all roles
const defaultConfigId = allConfigs[0].id;
const newPreferences = {
long_context_llm_id: defaultConfigId,
fast_llm_id: defaultConfigId,
strategic_llm_id: defaultConfigId,
};
const success = await updatePreferences(newPreferences);
if (success) {
await refreshPreferences();
setAutoConfigComplete(true);
toast.success("AI models configured automatically!", {
description: "You can customize these in advanced settings.",
});
}
} catch (error) {
console.error("Auto-configuration failed:", error);
} finally {
setIsAutoConfiguring(false);
}
};
}, [globalConfigs, llmConfigs, isOnboardingComplete, updatePreferences, refreshPreferences]);
if (configsLoading || preferencesLoading || globalConfigsLoading) {
// Trigger auto-configuration once data is loaded
useEffect(() => {
if (!configsLoading && !globalConfigsLoading && !preferencesLoading) {
autoConfigureLLMs();
}
}, [configsLoading, globalConfigsLoading, preferencesLoading, autoConfigureLLMs]);
const allConfigs = [...globalConfigs, ...llmConfigs];
const isReady = autoConfigComplete || isOnboardingComplete();
// Loading state
if (configsLoading || preferencesLoading || globalConfigsLoading || isAutoConfiguring) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardContent className="flex flex-col items-center justify-center py-12">
<Bot className="h-12 w-12 text-primary animate-pulse mb-4" />
<p className="text-sm text-muted-foreground">{t("loading_config")}</p>
</CardContent>
</Card>
</div>
<OnboardLoading
title={isAutoConfiguring ? "Setting up your AI assistant..." : t("loading_config")}
subtitle={
isAutoConfiguring
? "Auto-configuring optimal settings for you"
: "Please wait while we load your configuration"
}
/>
);
}
// No configs available - show LLM setup
if (allConfigs.length === 0) {
return (
<OnboardLLMSetup
searchSpaceId={searchSpaceId}
title={t("welcome_title")}
configTitle={t("setup_llm_configuration")}
configDescription={t("configure_providers_and_assign_roles")}
onConfigCreated={refreshConfigs}
onConfigDeleted={refreshConfigs}
onPreferencesUpdated={refreshPreferences}
/>
);
}
// Main onboarding view
return (
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/20 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="w-full max-w-4xl"
>
{/* Header */}
<div className="text-center mb-8">
<div className="flex items-center justify-center mb-4">
<Logo className="w-12 h-12 mr-3 rounded-full" />
<h1 className="text-3xl font-bold">{t("welcome_title")}</h1>
</div>
<p className="text-muted-foreground text-lg">{t("welcome_subtitle")}</p>
</div>
<div className="min-h-screen bg-background">
<div className="flex items-center justify-center min-h-screen p-4 md:p-8">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6 }}
className="w-full max-w-5xl"
>
{/* Header */}
<OnboardHeader
title={t("welcome_title")}
subtitle={
isReady ? "You're all set! Choose what you'd like to do next." : t("welcome_subtitle")
}
isReady={isReady}
/>
{/* Progress */}
<Card className="mb-8 bg-background/60 backdrop-blur-sm">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="text-sm font-medium">
{t("step_of", { current: currentStep, total: TOTAL_STEPS })}
</div>
<div className="text-sm text-muted-foreground">
{t("percent_complete", { percent: Math.round(progress) })}
</div>
</div>
<Progress value={progress} className="mb-4" />
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: TOTAL_STEPS }, (_, i) => {
const stepNum = i + 1;
const isCompleted = stepNum < currentStep;
const isCurrent = stepNum === currentStep;
{/* Quick Stats */}
<OnboardStats
globalConfigsCount={globalConfigs.length}
userConfigsCount={llmConfigs.length}
/>
return (
<div key={stepNum} className="flex items-center space-x-2">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
isCompleted
? "bg-primary text-primary-foreground"
: isCurrent
? "bg-primary/20 text-primary border-2 border-primary"
: "bg-muted text-muted-foreground"
}`}
>
{isCompleted ? <CheckCircle className="w-4 h-4" /> : stepNum}
</div>
<div className="flex-1 min-w-0">
<p
className={`text-sm font-medium truncate ${
isCurrent ? "text-foreground" : "text-muted-foreground"
}`}
>
{stepTitles[i]}
</p>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Action Cards */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10"
>
<OnboardActionCard
title="Manage Team"
description="Invite team members and collaborate on your search space"
icon={Users}
features={[
"Invite team members",
"Assign roles & permissions",
"Collaborate together",
]}
buttonText="Manage Team"
onClick={() => router.push(`/dashboard/${searchSpaceId}/team`)}
colorScheme="emerald"
delay={0.7}
/>
{/* Step Content */}
<Card className="min-h-[500px] bg-background/60 backdrop-blur-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl flex items-center justify-center gap-2">
{currentStep === 1 && <Sparkles className="w-6 h-6" />}
{currentStep === 2 && <MessageSquare className="w-6 h-6" />}
{currentStep === 3 && <CheckCircle className="w-6 h-6" />}
{stepTitles[currentStep - 1]}
</CardTitle>
<CardDescription className="text-base">
{stepDescriptions[currentStep - 1]}
</CardDescription>
</CardHeader>
<CardContent>
<AnimatePresence mode="wait">
<motion.div
key={currentStep}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
{currentStep === 1 && (
<SetupLLMStep
searchSpaceId={searchSpaceId}
onConfigCreated={refreshConfigs}
onConfigDeleted={refreshConfigs}
onPreferencesUpdated={refreshPreferences}
/>
)}
{currentStep === 2 && (
<SetupPromptStep searchSpaceId={searchSpaceId} onComplete={handleNext} />
)}
{currentStep === 3 && <CompletionStep searchSpaceId={searchSpaceId} />}
</motion.div>
</AnimatePresence>
</CardContent>
</Card>
<OnboardActionCard
title="Add Sources"
description="Connect your data sources to start building your knowledge base"
icon={FileText}
features={[
"Connect documents and files",
"Import from various sources",
"Build your knowledge base",
]}
buttonText="Add Sources"
onClick={() => router.push(`/dashboard/${searchSpaceId}/sources/add`)}
colorScheme="blue"
delay={0.8}
/>
{/* Navigation */}
<div className="flex justify-between mt-8">
{currentStep === 1 ? (
<>
<div />
<Button
onClick={handleNext}
disabled={!canProceedToStep2}
className="flex items-center gap-2"
<OnboardActionCard
title="Start Chatting"
description="Jump right into the AI researcher and start asking questions"
icon={MessageSquare}
features={[
"AI-powered conversations",
"Research and explore topics",
"Get instant insights",
]}
buttonText="Start Chatting"
onClick={() => router.push(`/dashboard/${searchSpaceId}/researcher`)}
colorScheme="violet"
delay={0.9}
/>
</motion.div>
{/* Advanced Settings */}
<OnboardAdvancedSettings
searchSpaceId={searchSpaceId}
showLLMSettings={showAdvancedSettings}
setShowLLMSettings={setShowAdvancedSettings}
showPromptSettings={showPromptSettings}
setShowPromptSettings={setShowPromptSettings}
onConfigCreated={refreshConfigs}
onConfigDeleted={refreshConfigs}
onPreferencesUpdated={refreshPreferences}
/>
{/* Footer */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.1 }}
className="text-center mt-10 text-muted-foreground text-sm"
>
<p>
You can always adjust these settings later in{" "}
<button
type="button"
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings`)}
className="text-primary hover:underline underline-offset-2 transition-colors"
>
{t("next")}
<ArrowRight className="w-4 h-4" />
</Button>
</>
) : currentStep === 2 ? (
<>
<Button
variant="outline"
onClick={handlePrevious}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
{t("previous")}
</Button>
{/* Next button is handled by SetupPromptStep component */}
<div />
</>
) : (
<>
<Button
variant="outline"
onClick={handlePrevious}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
{t("previous")}
</Button>
<div />
</>
)}
</div>
</motion.div>
Settings
</button>
</p>
</motion.div>
</motion.div>
</div>
</div>
);
};

View file

@ -17,13 +17,14 @@ export default function ResearcherPage() {
const { search_space_id } = useParams();
const router = useRouter();
const hasSetInitialConnectors = useRef(false);
const hasInitiatedResponse = useRef<string | null>(null);
const activeChatId = useAtomValue(activeChatIdAtom);
const { data: activeChatState, isFetching: isChatLoading } = useAtomValue(activeChatAtom);
const { mutateAsync: createChat } = useAtomValue(createChatMutationAtom);
const { mutateAsync: updateChat } = useAtomValue(updateChatMutationAtom);
const isNewChat = !activeChatId;
// Reset the flag when chat ID changes
// Reset the flag when chat ID changes (but not hasInitiatedResponse - we need to remember if we already initiated)
useEffect(() => {
hasSetInitialConnectors.current = false;
}, [activeChatId]);
@ -167,10 +168,14 @@ export default function ResearcherPage() {
if (chatData.messages && Array.isArray(chatData.messages)) {
if (chatData.messages.length === 1 && chatData.messages[0].role === "user") {
// Single user message - append to trigger LLM response
handler.append({
role: "user",
content: chatData.messages[0].content,
});
// Only if we haven't already initiated for this chat and handler doesn't have messages yet
if (hasInitiatedResponse.current !== activeChatId && handler.messages.length === 0) {
hasInitiatedResponse.current = activeChatId;
handler.append({
role: "user",
content: chatData.messages[0].content,
});
}
} else if (chatData.messages.length > 1) {
// Multiple messages - set them all
handler.setMessages(chatData.messages);

File diff suppressed because it is too large Load diff

View file

@ -1,28 +1,28 @@
"use client";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { AnnouncementBanner } from "@/components/announcement-banner";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
interface DashboardLayoutProps {
children: React.ReactNode;
}
export default function DashboardLayout({ children }: DashboardLayoutProps) {
const router = useRouter();
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
useEffect(() => {
// Check if user is authenticated
const token = localStorage.getItem("surfsense_bearer_token");
const token = getBearerToken();
if (!token) {
router.push("/login");
// Save current path and redirect to login
redirectToLogin();
return;
}
setIsCheckingAuth(false);
}, [router]);
}, []);
// Show loading screen while checking authentication
if (isCheckingAuth) {

View file

@ -1,6 +1,6 @@
"use client";
import { AlertCircle, Loader2, Plus, Search, Trash2 } from "lucide-react";
import { AlertCircle, Loader2, Plus, Search, Trash2, UserCheck, Users } from "lucide-react";
import { motion, type Variants } from "motion/react";
import Image from "next/image";
import Link from "next/link";
@ -22,6 +22,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@ -35,6 +36,7 @@ import { Spotlight } from "@/components/ui/spotlight";
import { Tilt } from "@/components/ui/tilt";
import { useUser } from "@/hooks";
import { useSearchSpaces } from "@/hooks/use-search-spaces";
import { authenticatedFetch } from "@/lib/auth-utils";
/**
* Formats a date string into a readable format
@ -172,14 +174,9 @@ const DashboardPage = () => {
const handleDeleteSearchSpace = async (id: number) => {
// Send DELETE request to the API
try {
const response = await fetch(
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${id}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
},
}
{ method: "DELETE" }
);
if (!response.ok) {
@ -308,16 +305,30 @@ const DashboardPage = () => {
>
<div className="flex flex-1 flex-col justify-between p-1">
<div>
<h3 className="font-medium text-lg">{space.name}</h3>
<div className="flex items-center gap-2">
<h3 className="font-medium text-lg">{space.name}</h3>
{!space.is_owner && (
<Badge variant="secondary" className="text-xs font-normal">
{t("shared")}
</Badge>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{space.description}
</p>
</div>
<div className="mt-4 text-xs text-muted-foreground">
{/* <span>{space.title}</span> */}
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
<span>
{t("created")} {formatDate(space.created_at)}
</span>
<div className="flex items-center gap-1">
{space.is_owner ? (
<UserCheck className="h-3.5 w-3.5" />
) : (
<Users className="h-3.5 w-3.5" />
)}
<span>{space.member_count}</span>
</div>
</div>
</div>
</Link>

View file

@ -4,19 +4,21 @@ import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { SearchSpaceForm } from "@/components/search-space-form";
import { authenticatedFetch } from "@/lib/auth-utils";
export default function SearchSpacesPage() {
const router = useRouter();
const handleCreateSearchSpace = async (data: { name: string; description: string }) => {
const handleCreateSearchSpace = async (data: { name: string; description?: string }) => {
try {
const response = await fetch(
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
},
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: data.name,
description: data.description || "",
}),
}
);
@ -31,7 +33,8 @@ export default function SearchSpacesPage() {
description: `"${data.name}" has been created.`,
});
router.push(`/dashboard`);
// Redirect to the newly created search space's onboarding
router.push(`/dashboard/${result.id}/onboard`);
return result;
} catch (error) {