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

@ -0,0 +1,68 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useMemo, useRef } from "react";
import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";
import { BlockNoteView } from "@blocknote/mantine";
import { useCreateBlockNote } from "@blocknote/react";
interface BlockNoteEditorProps {
initialContent?: any;
onChange?: (content: any) => void;
}
export default function BlockNoteEditor({ initialContent, onChange }: BlockNoteEditorProps) {
const { resolvedTheme } = useTheme();
// Track the initial content to prevent re-initialization
const initialContentRef = useRef<any>(null);
const isInitializedRef = useRef(false);
// Creates a new editor instance - only use initialContent on first render
const editor = useCreateBlockNote({
initialContent: initialContentRef.current === null ? initialContent || undefined : undefined,
});
// Store initial content on first render only
useEffect(() => {
if (initialContent && initialContentRef.current === null) {
initialContentRef.current = initialContent;
isInitializedRef.current = true;
}
}, [initialContent]);
// Call onChange when document changes (but don't update from props)
useEffect(() => {
if (!onChange || !editor || !isInitializedRef.current) return;
const handleChange = () => {
onChange(editor.document);
};
// Subscribe to document changes
const unsubscribe = editor.onChange(handleChange);
return () => {
unsubscribe();
};
}, [editor, onChange]);
// Determine theme for BlockNote with custom dark mode background
const blockNoteTheme = useMemo(() => {
if (resolvedTheme === "dark") {
// Custom dark theme - only override editor background, let BlockNote handle the rest
return {
colors: {
editor: {
background: "#0A0A0A", // Custom dark background
},
},
};
}
return "light" as const;
}, [resolvedTheme]);
// Renders the editor instance
return <BlockNoteView editor={editor} theme={blockNoteTheme} />;
}

View file

@ -0,0 +1,6 @@
"use client";
import dynamic from "next/dynamic";
// Dynamically import BlockNote editor with SSR disabled
export const BlockNoteEditor = dynamic(() => import("./BlockNoteEditor"), { ssr: false });

View file

@ -2,22 +2,25 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils";
interface TokenHandlerProps {
redirectPath?: string; // Path to redirect after storing token
redirectPath?: string; // Default path to redirect after storing token (if no saved path)
tokenParamName?: string; // Name of the URL parameter containing the token
storageKey?: string; // Key to use when storing in localStorage
storageKey?: string; // Key to use when storing in localStorage (kept for backwards compatibility)
}
/**
* Client component that extracts a token from URL parameters and stores it in localStorage
* After storing the token, it redirects the user back to the page they were on before
* being redirected to login (if available), or to the default redirectPath.
*
* @param redirectPath - Path to redirect after storing token (default: '/')
* @param redirectPath - Default path to redirect after storing token (default: '/dashboard')
* @param tokenParamName - Name of the URL parameter containing the token (default: 'token')
* @param storageKey - Key to use when storing in localStorage (default: 'auth_token')
* @param storageKey - Key to use when storing in localStorage (default: 'surfsense_bearer_token')
*/
const TokenHandler = ({
redirectPath = "/",
redirectPath = "/dashboard",
tokenParamName = "token",
storageKey = "surfsense_bearer_token",
}: TokenHandlerProps) => {
@ -33,14 +36,22 @@ const TokenHandler = ({
if (token) {
try {
// Store token in localStorage
// Store token in localStorage using both methods for compatibility
localStorage.setItem(storageKey, token);
// console.log(`Token stored in localStorage with key: ${storageKey}`);
setBearerToken(token);
// Redirect to specified path
router.push(redirectPath);
// Check if there's a saved redirect path from before the auth flow
const savedRedirectPath = getAndClearRedirectPath();
// Use the saved path if available, otherwise use the default redirectPath
const finalRedirectPath = savedRedirectPath || redirectPath;
// Redirect to the appropriate path
router.push(finalRedirectPath);
} catch (error) {
console.error("Error storing token in localStorage:", error);
// Even if there's an error, try to redirect to the default path
router.push(redirectPath);
}
}
}, [searchParams, tokenParamName, storageKey, redirectPath, router]);

View file

@ -1,8 +1,8 @@
"use client";
import { ChatInput } from "@llamaindex/chat-ui";
import { Brain, Check, FolderOpen, Minus, Plus, Zap } from "lucide-react";
import { useParams } from "next/navigation";
import { Brain, Check, FolderOpen, Minus, Plus, PlusCircle, Zap } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import React, { Suspense, useCallback, useState } from "react";
import { DocumentsDataTable } from "@/components/chat/DocumentsDataTable";
import { Badge } from "@/components/ui/badge";
@ -115,6 +115,7 @@ const ConnectorSelector = React.memo(
selectedConnectors?: string[];
}) => {
const { search_space_id } = useParams();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
// Fetch immediately (not lazy) so the button can show the correct count
@ -247,9 +248,19 @@ const ConnectorSelector = React.memo(
<Brain className="h-8 w-8 text-muted-foreground" />
</div>
<h4 className="text-sm font-medium mb-1">No sources found</h4>
<p className="text-xs text-muted-foreground max-w-xs">
<p className="text-xs text-muted-foreground max-w-xs mb-4">
Add documents or configure search connectors for this search space
</p>
<Button
onClick={() => {
setIsOpen(false);
router.push(`/dashboard/${search_space_id}/sources/add`);
}}
className="gap-2"
>
<PlusCircle className="h-4 w-4" />
Add Sources
</Button>
</div>
) : (
<>

View file

@ -3,7 +3,7 @@
import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
import {
Breadcrumb,
@ -14,6 +14,7 @@ import {
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { useSearchSpace } from "@/hooks/use-search-space";
import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils";
interface BreadcrumbItemInterface {
label: string;
@ -34,6 +35,36 @@ export function DashboardBreadcrumb() {
autoFetch: !!searchSpaceId,
});
// State to store document title for editor breadcrumb
const [documentTitle, setDocumentTitle] = useState<string | null>(null);
// Fetch document title when on editor page
useEffect(() => {
if (segments[2] === "editor" && segments[3] && searchSpaceId) {
const documentId = segments[3];
const token = getBearerToken();
if (token) {
authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`,
{ method: "GET" }
)
.then((res) => res.json())
.then((data) => {
if (data.title) {
setDocumentTitle(data.title);
}
})
.catch(() => {
// If fetch fails, just use the document ID
setDocumentTitle(null);
});
}
} else {
setDocumentTitle(null);
}
}, [segments, searchSpaceId]);
// Parse the pathname to create breadcrumb items
const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => {
const segments = path.split("/").filter(Boolean);
@ -66,6 +97,7 @@ export function DashboardBreadcrumb() {
logs: t("logs"),
chats: t("chats"),
settings: t("settings"),
editor: t("editor"),
};
sectionLabel = sectionLabels[section] || sectionLabel;
@ -73,7 +105,21 @@ export function DashboardBreadcrumb() {
// Handle sub-sections
if (segments[3]) {
const subSection = segments[3];
let subSectionLabel = subSection.charAt(0).toUpperCase() + subSection.slice(1);
// Handle editor sub-sections (document ID)
if (section === "editor") {
const documentLabel = documentTitle || subSection;
breadcrumbs.push({
label: t("documents"),
href: `/dashboard/${segments[1]}/documents`,
});
breadcrumbs.push({
label: sectionLabel,
href: `/dashboard/${segments[1]}/documents`,
});
breadcrumbs.push({ label: documentLabel });
return breadcrumbs;
}
// Handle sources sub-sections
if (section === "sources") {
@ -81,7 +127,7 @@ export function DashboardBreadcrumb() {
add: "Add Sources",
};
const sourceLabel = sourceLabels[subSection] || subSectionLabel;
const sourceLabel = sourceLabels[subSection] || subSection;
breadcrumbs.push({
label: "Sources",
href: `/dashboard/${segments[1]}/sources`,
@ -98,7 +144,7 @@ export function DashboardBreadcrumb() {
webpage: t("add_webpages"),
};
const documentLabel = documentLabels[subSection] || subSectionLabel;
const documentLabel = documentLabels[subSection] || subSection;
breadcrumbs.push({
label: t("documents"),
href: `/dashboard/${segments[1]}/documents`,
@ -160,7 +206,7 @@ export function DashboardBreadcrumb() {
manage: t("manage_connectors"),
};
const connectorLabel = connectorLabels[subSection] || subSectionLabel;
const connectorLabel = connectorLabels[subSection] || subSection;
breadcrumbs.push({
label: t("connectors"),
href: `/dashboard/${segments[1]}/connectors`,
@ -170,6 +216,7 @@ export function DashboardBreadcrumb() {
}
// Handle other sub-sections
let subSectionLabel = subSection.charAt(0).toUpperCase() + subSection.slice(1);
const subSectionLabels: Record<string, string> = {
upload: t("upload_documents"),
youtube: t("add_youtube"),

View file

@ -15,9 +15,17 @@ interface JsonMetadataViewerProps {
title: string;
metadata: any;
trigger?: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function JsonMetadataViewer({ title, metadata, trigger }: JsonMetadataViewerProps) {
export function JsonMetadataViewer({
title,
metadata,
trigger,
open,
onOpenChange,
}: JsonMetadataViewerProps) {
// Ensure metadata is a valid object
const jsonData = React.useMemo(() => {
if (!metadata) return {};
@ -35,6 +43,23 @@ export function JsonMetadataViewer({ title, metadata, trigger }: JsonMetadataVie
}
}, [metadata]);
// Controlled mode: when open and onOpenChange are provided
if (open !== undefined && onOpenChange !== undefined) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title} - Metadata</DialogTitle>
</DialogHeader>
<div className="mt-4 p-4 bg-muted/30 rounded-md">
<JsonView data={jsonData} style={defaultStyles} />
</div>
</DialogContent>
</Dialog>
);
}
// Uncontrolled mode: when using trigger
return (
<Dialog>
<DialogTrigger asChild>

View file

@ -1,161 +0,0 @@
"use client";
import {
ArrowRight,
Bot,
Brain,
CheckCircle,
FileText,
MessageSquare,
Sparkles,
Zap,
} from "lucide-react";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
interface CompletionStepProps {
searchSpaceId: number;
}
export function CompletionStep({ searchSpaceId }: CompletionStepProps) {
const router = useRouter();
const { llmConfigs } = useLLMConfigs(searchSpaceId);
const { globalConfigs } = useGlobalLLMConfigs();
const { preferences } = useLLMPreferences(searchSpaceId);
// Combine global and user-specific configs
const allConfigs = [...globalConfigs, ...llmConfigs];
const assignedConfigs = {
long_context: allConfigs.find((c) => c.id === preferences.long_context_llm_id),
fast: allConfigs.find((c) => c.id === preferences.fast_llm_id),
strategic: allConfigs.find((c) => c.id === preferences.strategic_llm_id),
};
return (
<div className="space-y-8">
{/* Next Steps - What would you like to do? */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="space-y-4"
>
<div className="text-center">
<h3 className="text-xl font-semibold mb-2">What would you like to do next?</h3>
<p className="text-muted-foreground">Choose an option to continue</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Add Sources Card */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.7 }}
>
<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-blue-100 dark:bg-blue-950 rounded-lg flex items-center justify-center mb-3 group-hover:scale-110 transition-transform">
<FileText className="w-6 h-6 text-blue-600 dark:text-blue-400" />
</div>
<CardTitle className="text-lg">Add Sources</CardTitle>
<CardDescription>
Connect your data sources to start building your knowledge base
</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>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>
</div>
);
}

View file

@ -0,0 +1,8 @@
export { OnboardActionCard } from "./onboard-action-card";
export { OnboardAdvancedSettings } from "./onboard-advanced-settings";
export { OnboardHeader } from "./onboard-header";
export { OnboardLLMSetup } from "./onboard-llm-setup";
export { OnboardLoading } from "./onboard-loading";
export { OnboardStats } from "./onboard-stats";
export { SetupLLMStep } from "./setup-llm-step";
export { SetupPromptStep } from "./setup-prompt-step";

View file

@ -0,0 +1,114 @@
"use client";
import { ArrowRight, CheckCircle, type LucideIcon } from "lucide-react";
import { motion } from "motion/react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface OnboardActionCardProps {
title: string;
description: string;
icon: LucideIcon;
features: string[];
buttonText: string;
onClick: () => void;
colorScheme: "emerald" | "blue" | "violet";
delay?: number;
}
const colorSchemes = {
emerald: {
iconBg: "bg-emerald-500/10 dark:bg-emerald-500/20",
iconRing: "ring-emerald-500/20 dark:ring-emerald-500/30",
iconColor: "text-emerald-600 dark:text-emerald-400",
checkColor: "text-emerald-500",
buttonBg: "bg-emerald-600 hover:bg-emerald-500",
hoverBorder: "hover:border-emerald-500/50",
},
blue: {
iconBg: "bg-blue-500/10 dark:bg-blue-500/20",
iconRing: "ring-blue-500/20 dark:ring-blue-500/30",
iconColor: "text-blue-600 dark:text-blue-400",
checkColor: "text-blue-500",
buttonBg: "bg-blue-600 hover:bg-blue-500",
hoverBorder: "hover:border-blue-500/50",
},
violet: {
iconBg: "bg-violet-500/10 dark:bg-violet-500/20",
iconRing: "ring-violet-500/20 dark:ring-violet-500/30",
iconColor: "text-violet-600 dark:text-violet-400",
checkColor: "text-violet-500",
buttonBg: "bg-violet-600 hover:bg-violet-500",
hoverBorder: "hover:border-violet-500/50",
},
};
export function OnboardActionCard({
title,
description,
icon: Icon,
features,
buttonText,
onClick,
colorScheme,
delay = 0,
}: OnboardActionCardProps) {
const colors = colorSchemes[colorScheme];
return (
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay, type: "spring", stiffness: 200 }}
whileHover={{ y: -6, transition: { duration: 0.2 } }}
>
<Card
className={cn(
"h-full cursor-pointer group relative overflow-hidden transition-all duration-300",
"border bg-card hover:shadow-lg",
colors.hoverBorder
)}
onClick={onClick}
>
<CardHeader className="relative pb-4">
<motion.div
className={cn(
"w-14 h-14 rounded-2xl flex items-center justify-center mb-4 ring-1 transition-all duration-300",
colors.iconBg,
colors.iconRing,
"group-hover:scale-110"
)}
whileHover={{ rotate: [0, -5, 5, 0] }}
transition={{ duration: 0.5 }}
>
<Icon className={cn("w-7 h-7", colors.iconColor)} />
</motion.div>
<CardTitle className="text-xl">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="relative space-y-4">
<div className="space-y-2.5 text-sm text-muted-foreground">
{features.map((feature, index) => (
<div key={index} className="flex items-center gap-2.5">
<CheckCircle className={cn("w-4 h-4", colors.checkColor)} />
<span>{feature}</span>
</div>
))}
</div>
<Button
className={cn(
"w-full text-white border-0 transition-all duration-300",
colors.buttonBg
)}
>
{buttonText}
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
</CardContent>
</Card>
</motion.div>
);
}

View file

@ -0,0 +1,144 @@
"use client";
import { ChevronDown, MessageSquare, Settings2 } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { SetupLLMStep } from "@/components/onboard/setup-llm-step";
import { SetupPromptStep } from "@/components/onboard/setup-prompt-step";
import { Card, CardContent } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
interface OnboardAdvancedSettingsProps {
searchSpaceId: number;
showLLMSettings: boolean;
setShowLLMSettings: (show: boolean) => void;
showPromptSettings: boolean;
setShowPromptSettings: (show: boolean) => void;
onConfigCreated: () => void;
onConfigDeleted: () => void;
onPreferencesUpdated: () => Promise<void>;
}
export function OnboardAdvancedSettings({
searchSpaceId,
showLLMSettings,
setShowLLMSettings,
showPromptSettings,
setShowPromptSettings,
onConfigCreated,
onConfigDeleted,
onPreferencesUpdated,
}: OnboardAdvancedSettingsProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1 }}
className="space-y-4"
>
{/* LLM Configuration */}
<Collapsible open={showLLMSettings} onOpenChange={setShowLLMSettings}>
<CollapsibleTrigger asChild>
<Card className="hover:bg-muted/50 transition-colors cursor-pointer">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-fuchsia-500/10 dark:bg-fuchsia-500/20 border border-fuchsia-500/20">
<Settings2 className="w-5 h-5 text-fuchsia-600 dark:text-fuchsia-400" />
</div>
<div>
<h3 className="font-semibold">LLM Configuration</h3>
<p className="text-sm text-muted-foreground">
Customize AI models and role assignments
</p>
</div>
</div>
<motion.div
animate={{ rotate: showLLMSettings ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown className="w-5 h-5 text-muted-foreground" />
</motion.div>
</div>
</CardContent>
</Card>
</CollapsibleTrigger>
<CollapsibleContent>
<AnimatePresence>
{showLLMSettings && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<Card className="mt-2">
<CardContent className="pt-6">
<SetupLLMStep
searchSpaceId={searchSpaceId}
onConfigCreated={onConfigCreated}
onConfigDeleted={onConfigDeleted}
onPreferencesUpdated={onPreferencesUpdated}
/>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</CollapsibleContent>
</Collapsible>
{/* Prompt Configuration */}
<Collapsible open={showPromptSettings} onOpenChange={setShowPromptSettings}>
<CollapsibleTrigger asChild>
<Card className="hover:bg-muted/50 transition-colors cursor-pointer">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-cyan-500/10 dark:bg-cyan-500/20 border border-cyan-500/20">
<MessageSquare className="w-5 h-5 text-cyan-600 dark:text-cyan-400" />
</div>
<div>
<h3 className="font-semibold">AI Response Settings</h3>
<p className="text-sm text-muted-foreground">
Configure citations and custom instructions (Optional)
</p>
</div>
</div>
<motion.div
animate={{ rotate: showPromptSettings ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown className="w-5 h-5 text-muted-foreground" />
</motion.div>
</div>
</CardContent>
</Card>
</CollapsibleTrigger>
<CollapsibleContent>
<AnimatePresence>
{showPromptSettings && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<Card className="mt-2">
<CardContent className="pt-6">
<SetupPromptStep
searchSpaceId={searchSpaceId}
onComplete={() => setShowPromptSettings(false)}
/>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</CollapsibleContent>
</Collapsible>
</motion.div>
);
}

View file

@ -0,0 +1,56 @@
"use client";
import { CheckCircle } from "lucide-react";
import { motion } from "motion/react";
import { Logo } from "@/components/Logo";
import { Badge } from "@/components/ui/badge";
interface OnboardHeaderProps {
title: string;
subtitle: string;
isReady?: boolean;
}
export function OnboardHeader({ title, subtitle, isReady }: OnboardHeaderProps) {
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="text-center mb-10"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200, delay: 0.2 }}
className="inline-flex items-center justify-center mb-6"
>
<Logo className="w-20 h-20 rounded-2xl shadow-lg" />
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="space-y-2"
>
<h1 className="text-4xl md:text-5xl font-bold text-foreground">{title}</h1>
<p className="text-muted-foreground text-lg md:text-xl max-w-2xl mx-auto">{subtitle}</p>
</motion.div>
{isReady && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.4, type: "spring" }}
className="mt-4"
>
<Badge className="px-4 py-2 text-sm bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400">
<CheckCircle className="w-4 h-4 mr-2" />
AI Configuration Complete
</Badge>
</motion.div>
)}
</motion.div>
);
}

View file

@ -0,0 +1,93 @@
"use client";
import { Bot } from "lucide-react";
import { motion } from "motion/react";
import { Logo } from "@/components/Logo";
import { SetupLLMStep } from "@/components/onboard/setup-llm-step";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
interface OnboardLLMSetupProps {
searchSpaceId: number;
title: string;
configTitle: string;
configDescription: string;
onConfigCreated: () => void;
onConfigDeleted: () => void;
onPreferencesUpdated: () => Promise<void>;
}
export function OnboardLLMSetup({
searchSpaceId,
title,
configTitle,
configDescription,
onConfigCreated,
onConfigDeleted,
onPreferencesUpdated,
}: OnboardLLMSetupProps) {
return (
<div className="min-h-screen bg-background 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">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200, delay: 0.1 }}
className="inline-flex items-center justify-center mb-6"
>
<Logo className="w-16 h-16 rounded-2xl shadow-lg" />
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="text-4xl font-bold text-foreground mb-3"
>
{title}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="text-muted-foreground text-lg"
>
Configure your AI model to get started
</motion.p>
</div>
{/* LLM Setup Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<Card className="shadow-lg">
<CardHeader className="text-center border-b pb-6">
<div className="flex items-center justify-center gap-3 mb-2">
<div className="p-2 rounded-xl bg-primary/10 border border-primary/20">
<Bot className="w-6 h-6 text-primary" />
</div>
<CardTitle className="text-2xl">{configTitle}</CardTitle>
</div>
<CardDescription>{configDescription}</CardDescription>
</CardHeader>
<CardContent className="pt-6">
<SetupLLMStep
searchSpaceId={searchSpaceId}
onConfigCreated={onConfigCreated}
onConfigDeleted={onConfigDeleted}
onPreferencesUpdated={onPreferencesUpdated}
/>
</CardContent>
</Card>
</motion.div>
</motion.div>
</div>
);
}

View file

@ -0,0 +1,47 @@
"use client";
import { Wand2 } from "lucide-react";
import { motion } from "motion/react";
interface OnboardLoadingProps {
title: string;
subtitle: string;
}
export function OnboardLoading({ title, subtitle }: OnboardLoadingProps) {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
className="text-center"
>
<div className="relative mb-8 flex justify-center">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
>
<Wand2 className="w-16 h-16 text-primary" />
</motion.div>
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">{title}</h2>
<p className="text-muted-foreground">{subtitle}</p>
<div className="mt-6 flex justify-center gap-1.5">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
className="w-2 h-2 rounded-full bg-primary"
animate={{ scale: [1, 1.5, 1], opacity: [0.5, 1, 0.5] }}
transition={{
duration: 1,
repeat: Infinity,
delay: i * 0.2,
}}
/>
))}
</div>
</motion.div>
</div>
);
}

View file

@ -0,0 +1,38 @@
"use client";
import { Bot, Brain, Sparkles } from "lucide-react";
import { motion } from "motion/react";
import { Badge } from "@/components/ui/badge";
interface OnboardStatsProps {
globalConfigsCount: number;
userConfigsCount: number;
}
export function OnboardStats({ globalConfigsCount, userConfigsCount }: OnboardStatsProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="flex flex-wrap justify-center gap-3 mb-10"
>
{globalConfigsCount > 0 && (
<Badge variant="secondary" className="px-3 py-1.5">
<Sparkles className="w-3 h-3 mr-1.5 text-violet-500" />
{globalConfigsCount} Global Model{globalConfigsCount > 1 ? "s" : ""}
</Badge>
)}
{userConfigsCount > 0 && (
<Badge variant="secondary" className="px-3 py-1.5">
<Bot className="w-3 h-3 mr-1.5 text-blue-500" />
{userConfigsCount} Custom Config{userConfigsCount > 1 ? "s" : ""}
</Badge>
)}
<Badge variant="secondary" className="px-3 py-1.5">
<Brain className="w-3 h-3 mr-1.5 text-fuchsia-500" />
All Roles Assigned
</Badge>
</motion.div>
);
}

View file

@ -13,6 +13,7 @@ import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { type CommunityPrompt, useCommunityPrompts } from "@/hooks/use-community-prompts";
import { authenticatedFetch } from "@/lib/auth-utils";
interface SetupPromptStepProps {
searchSpaceId: number;
@ -74,14 +75,11 @@ export function SetupPromptStep({ searchSpaceId, onComplete }: SetupPromptStepPr
// Only send update if there's something to update
if (Object.keys(payload).length > 0) {
const response = await fetch(
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}
);

View file

@ -16,7 +16,8 @@ const demoPlans = [
"Podcasts support with local TTS providers.",
"Connects with 15+ external sources.",
"Cross-Browser Extension for dynamic webpages including authenticated content",
"Upcoming: Mergeable MindMaps",
"Role-based access permissions",
"Collaboration and multiplayer features",
"Upcoming: Note Management",
],
description: "Open source version with powerful features",
@ -32,9 +33,10 @@ const demoPlans = [
features: [
"Everything in Community",
"Priority Support",
"Role-based access permissions",
"Collaboration and multiplayer features",
"Advanced security features",
"Audit logs and compliance",
"SSO, OIDC & SAML",
"SLA guarantee",
],
description: "For large organizations with specific needs",
buttonText: "Contact Sales",

View file

@ -36,19 +36,19 @@ import { cn } from "@/lib/utils";
// Define the form schema with Zod
const searchSpaceFormSchema = z.object({
name: z.string().min(3, "Name is required"),
description: z.string().min(10, "Description is required"),
name: z.string().min(3, "Name must be at least 3 characters"),
description: z.string().optional(),
});
// Define the type for the form values
type SearchSpaceFormValues = z.infer<typeof searchSpaceFormSchema>;
interface SearchSpaceFormProps {
onSubmit?: (data: { name: string; description: string }) => void;
onSubmit?: (data: { name: string; description?: string }) => void;
onDelete?: () => void;
className?: string;
isEditing?: boolean;
initialData?: { name: string; description: string };
initialData?: { name: string; description?: string };
}
export function SearchSpaceForm({
@ -229,7 +229,9 @@ export function SearchSpaceForm({
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormLabel>
Description <span className="text-muted-foreground font-normal">(optional)</span>
</FormLabel>
<FormControl>
<Input placeholder="Enter search space description" {...field} />
</FormControl>

View file

@ -413,19 +413,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
<strong>Use cases:</strong> {role.examples}
</div>
<div className="flex flex-wrap gap-1">
{role.characteristics.map((char, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{char}
</Badge>
))}
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Assign LLM Configuration:</Label>
<Select

View file

@ -25,6 +25,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { type CommunityPrompt, useCommunityPrompts } from "@/hooks/use-community-prompts";
import { useSearchSpace } from "@/hooks/use-search-space";
import { authenticatedFetch } from "@/lib/auth-utils";
interface PromptConfigManagerProps {
searchSpaceId: number;
@ -78,14 +79,11 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
// Only send request if we have something to update
if (Object.keys(payload).length > 0) {
const response = await fetch(
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}
);

View file

@ -17,6 +17,7 @@ import {
SquareTerminal,
Trash2,
Undo2,
Users,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
@ -54,6 +55,7 @@ export const iconMap: Record<string, LucideIcon> = {
Trash2,
Podcast,
FileText,
Users,
};
const defaultData = {

View file

@ -43,6 +43,7 @@ export function NavMain({ items }: { items: NavItem[] }) {
Podcasts: "podcasts",
Logs: "logs",
Platform: "platform",
Team: "team",
};
const key = titleMap[title];

View file

@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import { getAuthHeaders } from "@/lib/auth-utils";
import { GridPattern } from "./GridPattern";
interface DocumentUploadTabProps {
@ -168,9 +169,7 @@ export function DocumentUploadTab({ searchSpaceId }: DocumentUploadTabProps) {
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/fileupload`,
{
method: "POST",
headers: {
Authorization: `Bearer ${window.localStorage.getItem("surfsense_bearer_token")}`,
},
headers: getAuthHeaders(),
body: formData,
}
);

View file

@ -19,6 +19,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { authenticatedFetch } from "@/lib/auth-utils";
const youtubeRegex =
/^(https:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})$/;
@ -66,14 +67,11 @@ export function YouTubeTab({ searchSpaceId }: YouTubeTabProps) {
const videoUrls = videoTags.map((tag) => tag.text);
const response = await fetch(
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
document_type: "YOUTUBE_VIDEO",
content: videoUrls,