diff --git a/surfsense_web/app/(home)/login/GoogleLoginButton.tsx b/surfsense_web/app/(home)/login/GoogleLoginButton.tsx index 3c8aaeb77..52d79d72b 100644 --- a/surfsense_web/app/(home)/login/GoogleLoginButton.tsx +++ b/surfsense_web/app/(home)/login/GoogleLoginButton.tsx @@ -32,10 +32,10 @@ export function GoogleLoginButton() {
- -

- {t("welcome_back")} -

+ + {/*

+ Login +

*/} {/* void; }) { const t = useTranslations("documents"); + const router = useRouter(); + const params = useParams(); + const searchSpaceId = params.search_space_id; + const sorted = React.useMemo( () => sortDocuments(documents, sortKey, sortDesc), [documents, sortKey, sortDesc] @@ -117,10 +122,29 @@ export function DocumentsTableShell({
) : sorted.length === 0 ? (
-
- -

{t("no_documents")}

-
+ +
+ +
+
+

{t("no_documents")}

+

+ Get started by adding your first data source. +

+
+ +
) : ( <> diff --git a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx index c014c6f32..ea5dc41e2 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx @@ -28,7 +28,7 @@ export default function DashboardLayout({ const customNavMain = [ { - title: "Researcher", + title: "Chat", url: `/dashboard/${search_space_id}/researcher`, icon: "SquareTerminal", items: [], diff --git a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx index bfbae3b99..099909515 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx @@ -4,17 +4,16 @@ import { ArrowLeft, ArrowRight, Bot, CheckCircle, Sparkles } from "lucide-react" import { AnimatePresence, motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; 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 { SetupLLMStep } from "@/components/onboard/setup-llm-step"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs"; -const TOTAL_STEPS = 3; +const TOTAL_STEPS = 2; const OnboardPage = () => { const t = useTranslations("onboard"); @@ -33,6 +32,10 @@ const OnboardPage = () => { const [currentStep, setCurrentStep] = useState(1); const [hasUserProgressed, setHasUserProgressed] = useState(false); + // Track if onboarding was complete on initial mount + const wasCompleteOnMount = useRef(null); + const hasCheckedInitialState = useRef(false); + // Check if user is authenticated useEffect(() => { const token = localStorage.getItem("surfsense_bearer_token"); @@ -42,6 +45,19 @@ const OnboardPage = () => { } }, [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 useEffect(() => { if (currentStep > 1) { @@ -49,47 +65,42 @@ const OnboardPage = () => { } }, [currentStep]); - // Redirect to dashboard if onboarding is already complete and user hasn't progressed (fresh page load) - // But only check once to avoid redirect loops + // 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 && - isOnboardingComplete() && - !hasUserProgressed + !globalConfigsLoading ) { - // Small delay to ensure the check is stable + // Small delay to ensure the check is stable on initial load const timer = setTimeout(() => { router.push(`/dashboard/${searchSpaceId}`); - }, 100); + }, 300); return () => clearTimeout(timer); } }, [ + hasUserProgressed, preferencesLoading, configsLoading, globalConfigsLoading, - isOnboardingComplete, - hasUserProgressed, router, searchSpaceId, ]); 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 = [ - t("configure_first_provider"), - t("assign_specific_roles"), - t("all_set"), - ]; + const stepDescriptions = [t("configure_providers_and_assign_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 = - !configsLoading && !globalConfigsLoading && (llmConfigs.length > 0 || globalConfigs.length > 0); - - const canProceedToStep3 = !preferencesLoading && preferences.long_context_llm_id && preferences.fast_llm_id && @@ -107,10 +118,6 @@ const OnboardPage = () => { } }; - const handleComplete = () => { - router.push(`/dashboard/${searchSpaceId}/documents`); - }; - if (configsLoading || preferencesLoading || globalConfigsLoading) { return (
@@ -192,9 +199,8 @@ const OnboardPage = () => { - {currentStep === 1 && } - {currentStep === 2 && } - {currentStep === 3 && } + {currentStep === 1 && } + {currentStep === 2 && } {stepTitles[currentStep - 1]} @@ -211,19 +217,14 @@ const OnboardPage = () => { transition={{ duration: 0.3 }} > {currentStep === 1 && ( - - )} - {currentStep === 2 && ( - )} - {currentStep === 3 && } + {currentStep === 2 && } @@ -231,38 +232,24 @@ const OnboardPage = () => { {/* Navigation */}
- - -
- {currentStep < TOTAL_STEPS && ( + {currentStep === 1 ? ( + <> +
- )} - - {currentStep === TOTAL_STEPS && ( - - )} -
+ + ) : ( + + )}
diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx index 549f50806..40c2650a5 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx @@ -78,17 +78,11 @@ export function ChatPanelView(props: ChatPanelViewProps) { initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} + className="relative" > -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleGeneratePost(); - } - }} className={cn( "relative w-full rounded-2xl p-4 transition-all duration-300 cursor-pointer group overflow-hidden", "border-2", @@ -151,9 +145,12 @@ export function ChatPanelView(props: ChatPanelViewProps) {

-
+ + {/* ConfigModal positioned absolutely to avoid nesting buttons */} +
+
diff --git a/surfsense_web/components/chat/DocumentsDataTable.tsx b/surfsense_web/components/chat/DocumentsDataTable.tsx index ae647f341..331c1b404 100644 --- a/surfsense_web/components/chat/DocumentsDataTable.tsx +++ b/surfsense_web/components/chat/DocumentsDataTable.tsx @@ -7,7 +7,8 @@ import { type SortingState, useReactTable, } 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 { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -177,6 +178,7 @@ export function DocumentsDataTable({ onDone, initialSelectedDocuments = [], }: DocumentsDataTableProps) { + const router = useRouter(); const [sorting, setSorting] = useState([]); const [search, setSearch] = useState(""); const debouncedSearch = useDebounced(search, 300); @@ -527,11 +529,26 @@ export function DocumentsDataTable({ )) ) : ( - - No documents found. + +
+
+ +
+
+

No documents found

+

+ Get started by adding your first data source to build your knowledge + base. +

+
+ +
)} diff --git a/surfsense_web/components/onboard/add-provider-step.tsx b/surfsense_web/components/onboard/add-provider-step.tsx deleted file mode 100644 index e63287a6a..000000000 --- a/surfsense_web/components/onboard/add-provider-step.tsx +++ /dev/null @@ -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({ - 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) => { - setFormData((prev) => ({ ...prev, litellm_params: newParams })); - }; - - // Reset model when provider changes - const handleProviderChange = (value: string) => { - handleInputChange("provider", value); - setFormData((prev) => ({ ...prev, model_name: "" })); - }; - - return ( -
- {/* Info Alert */} - - - {t("add_provider_instruction")} - - - {/* Global Configs Notice */} - {globalConfigs.length > 0 && ( - - - - {globalConfigs.length} global configuration(s) available! -
- 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. -
-
- )} - - {/* Existing Configurations */} - {llmConfigs.length > 0 && ( -
-

{t("your_llm_configs")}

-
- {llmConfigs.map((config) => ( - - - -
-
-
- -

{config.name}

- {config.provider} -
-

- {t("model")}: {config.model_name} - {config.language && ` • ${t("language")}: ${config.language}`} - {config.api_base && ` • ${t("base")}: ${config.api_base}`} -

-
- -
-
-
-
- ))} -
-
- )} - - {/* Add New Provider */} - {!isAddingNew ? ( - - - -

{t("add_provider_title")}

-

{t("add_provider_subtitle")}

- -
-
- ) : ( - - - {t("add_new_llm_provider")} - {t("configure_new_provider")} - - -
-
-
- - handleInputChange("name", e.target.value)} - required - /> -
- -
- - -
- - {/* language */} -
- - -
-
- - {formData.provider === "CUSTOM" && ( -
- - handleInputChange("custom_provider", e.target.value)} - required - /> -
- )} - -
- - - - - - - - handleInputChange("model_name", value)} - /> - - -
- {formData.model_name - ? `Using custom model: "${formData.model_name}"` - : "Type your model name above"} -
-
- {availableModels.length > 0 && ( - - {availableModels - .filter( - (model) => - !formData.model_name || - model.value - .toLowerCase() - .includes(formData.model_name.toLowerCase()) || - model.label - .toLowerCase() - .includes(formData.model_name.toLowerCase()) - ) - .map((model) => ( - { - handleInputChange("model_name", currentValue); - setModelComboboxOpen(false); - }} - className="flex flex-col items-start py-3" - > -
- -
-
{model.label}
- {model.contextWindow && ( -
- Context: {model.contextWindow} -
- )} -
-
-
- ))} -
- )} -
-
-
-
-

- {availableModels.length > 0 - ? `Type freely or select from ${availableModels.length} model suggestions` - : selectedProvider?.example - ? `${t("examples")}: ${selectedProvider.example}` - : "Type your model name freely"} -

-
- -
- - handleInputChange("api_key", e.target.value)} - required - /> -
- -
- - handleInputChange("api_base", e.target.value)} - /> -
- - {/* Optional Inference Parameters */} -
- -
- -
- - -
-
-
-
- )} -
- ); -} diff --git a/surfsense_web/components/onboard/assign-roles-step.tsx b/surfsense_web/components/onboard/assign-roles-step.tsx deleted file mode 100644 index 094b46cf9..000000000 --- a/surfsense_web/components/onboard/assign-roles-step.tsx +++ /dev/null @@ -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; -} - -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 ( -
- -

{t("no_llm_configs_found")}

-

{t("add_provider_before_roles")}

-
- ); - } - - return ( -
- {/* Info Alert */} - - - {t("assign_roles_instruction")} - - - {/* Role Assignment Cards */} -
- {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 ( - - - -
-
-
- -
-
- {role.title} - {role.description} -
-
- {currentAssignment && } -
-
- -
- {t("use_cases")}: {role.examples} -
- -
- - -
- - {assignedConfig && ( -
-
- - {t("assigned")}: - {assignedConfig.is_global && ( - - 🌐 Global - - )} - {assignedConfig.provider} - {assignedConfig.name} -
-
- {t("model")}: {assignedConfig.model_name} -
-
- )} -
-
-
- ); - })} -
- - {/* Status Indicator */} - {isAssignmentComplete && ( -
-
- - {t("all_roles_assigned_saved")} -
-
- )} - - {/* Progress Indicator */} -
-
- {t("progress")}: -
- {Object.keys(ROLE_DESCRIPTIONS).map((key, _index) => ( -
- ))} -
- - {t("roles_assigned", { - assigned: Object.values(assignments).filter(Boolean).length, - total: Object.keys(ROLE_DESCRIPTIONS).length, - })} - -
-
-
- ); -} diff --git a/surfsense_web/components/onboard/completion-step.tsx b/surfsense_web/components/onboard/completion-step.tsx index 83595a956..68aa77568 100644 --- a/surfsense_web/components/onboard/completion-step.tsx +++ b/surfsense_web/components/onboard/completion-step.tsx @@ -1,22 +1,28 @@ "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 { 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"; -const ROLE_ICONS = { - long_context: Brain, - fast: Zap, - strategic: Bot, -}; - interface CompletionStepProps { searchSpaceId: number; } export function CompletionStep({ searchSpaceId }: CompletionStepProps) { + const router = useRouter(); const { llmConfigs } = useLLMConfigs(searchSpaceId); const { globalConfigs } = useGlobalLLMConfigs(); const { preferences } = useLLMPreferences(searchSpaceId); @@ -32,111 +38,123 @@ export function CompletionStep({ searchSpaceId }: CompletionStepProps) { return (
- {/* Success Message */} - -
- -
-

Setup Complete!

-
- - {/* Configuration Summary */} - - - - - - Your LLM Configuration - - Here's a summary of your setup - - - {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 ( - -
-
- -
-
-

- {roleDisplayNames[role as keyof typeof roleDisplayNames]} -

-

{config.name}

-
-
-
- {config.is_global && ( - - 🌐 Global - - )} - {config.provider} - {config.model_name} -
-
- ); - })} -
-
-
- - {/* Next Steps */} + {/* Next Steps - What would you like to do? */} - - -
-
- -
-

Ready to Get Started?

-
-

- Click "Complete Setup" to enter your dashboard and start exploring! -

-
- - ✓ {allConfigs.length} LLM provider{allConfigs.length > 1 ? "s" : ""} available - - {globalConfigs.length > 0 && ( - ✓ {globalConfigs.length} Global config(s) - )} - {llmConfigs.length > 0 && ( - ✓ {llmConfigs.length} Custom config(s) - )} - ✓ All roles assigned - ✓ Ready to use -
-
-
+
+

What would you like to do next?

+

Choose an option to continue

+
+ +
+ {/* Add Sources Card */} + + + +
+ +
+ Add Sources + + Connect your data sources to start building your knowledge base + +
+ +
+
+ + Connect documents and files +
+
+ + Import from various sources +
+
+ + Build your knowledge base +
+
+ +
+
+
+ + {/* Start Chatting Card */} + + + +
+ +
+ Start Chatting + + Jump right into the AI researcher and start asking questions + +
+ +
+
+ + AI-powered conversations +
+
+ + Research and explore topics +
+
+ + Get instant insights +
+
+ +
+
+
+
+ + {/* Quick Stats */} + + + ✓ {allConfigs.length} LLM provider{allConfigs.length > 1 ? "s" : ""} available + + {globalConfigs.length > 0 && ( + ✓ {globalConfigs.length} Global config(s) + )} + {llmConfigs.length > 0 && ( + ✓ {llmConfigs.length} Custom config(s) + )} + ✓ All roles assigned + ✓ Ready to use +
); diff --git a/surfsense_web/components/onboard/setup-llm-step.tsx b/surfsense_web/components/onboard/setup-llm-step.tsx new file mode 100644 index 000000000..d0cf67055 --- /dev/null +++ b/surfsense_web/components/onboard/setup-llm-step.tsx @@ -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; +} + +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({ + 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) => { + 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 ( +
+ {/* Global Configs Notice - Prominent at top */} + {globalConfigs.length > 0 && ( + + + +
+

+ {globalConfigs.length} global configuration(s) available! +

+

+ You can skip adding your own LLM provider and use our pre-configured models in the + role assignment section below. +

+

+ Or expand "Add LLM Provider" to add your own custom configurations. +

+
+
+
+ )} + + {/* Section 1: Add LLM Providers */} +
+
+
+

+ + {t("add_llm_provider")} +

+

{t("configure_first_provider")}

+
+ +
+ + {showProviderForm && ( + + {/* Info Alert */} + + + {t("add_provider_instruction")} + + + {/* Existing Configurations */} + {llmConfigs.length > 0 && ( +
+

+ {t("your_llm_configs")} +

+
+ {llmConfigs.map((config) => ( + + + +
+
+
+ +

{config.name}

+ + {config.provider} + +
+

+ {t("model")}: {config.model_name} + {config.language && ` • ${t("language")}: ${config.language}`} + {config.api_base && ` • ${t("base")}: ${config.api_base}`} +

+
+ +
+
+
+
+ ))} +
+
+ )} + + {/* Add New Provider */} + {!isAddingNew ? ( + + + +

{t("add_provider_title")}

+

+ {t("add_provider_subtitle")} +

+ +
+
+ ) : ( + + + {t("add_new_llm_provider")} + {t("configure_new_provider")} + + +
+
+
+ + handleInputChange("name", e.target.value)} + required + /> +
+ +
+ + +
+ +
+ + +
+
+ + {formData.provider === "CUSTOM" && ( +
+ + handleInputChange("custom_provider", e.target.value)} + required + /> +
+ )} + +
+ + + + + + + + handleInputChange("model_name", value)} + /> + + +
+ {formData.model_name + ? `Using custom model: "${formData.model_name}"` + : "Type your model name above"} +
+
+ {availableModels.length > 0 && ( + + {availableModels + .filter( + (model) => + !formData.model_name || + model.value + .toLowerCase() + .includes(formData.model_name.toLowerCase()) || + model.label + .toLowerCase() + .includes(formData.model_name.toLowerCase()) + ) + .map((model) => ( + { + handleInputChange("model_name", currentValue); + setModelComboboxOpen(false); + }} + className="flex flex-col items-start py-3" + > +
+ +
+
{model.label}
+ {model.contextWindow && ( +
+ Context: {model.contextWindow} +
+ )} +
+
+
+ ))} +
+ )} +
+
+
+
+

+ {availableModels.length > 0 + ? `Type freely or select from ${availableModels.length} model suggestions` + : selectedProvider?.example + ? `${t("examples")}: ${selectedProvider.example}` + : "Type your model name freely"} +

+
+ +
+ + handleInputChange("api_key", e.target.value)} + required + /> +
+ +
+ + handleInputChange("api_base", e.target.value)} + /> +
+ +
+ +
+ +
+ + +
+
+
+
+ )} +
+ )} +
+ + + + {/* Section 2: Assign Roles */} +
+
+

+ + {t("assign_llm_roles")} +

+

{t("assign_specific_roles")}

+
+ + {allConfigs.length === 0 ? ( + + + {t("add_provider_before_roles")} + + ) : ( +
+ + + {t("assign_roles_instruction")} + + +
+ {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 ( + + + +
+
+
+ +
+
+ {t(role.titleKey)} + + {t(role.descKey)} + +
+
+ {currentAssignment && } +
+
+ +
+ {t("use_cases")}: {t(role.examplesKey)} +
+ +
+ + +
+ + {assignedConfig && ( +
+
+ + {t("assigned")}: + {assignedConfig.is_global && ( + + 🌐 Global + + )} + + {assignedConfig.provider} + + {assignedConfig.name} +
+
+ {t("model")}: {assignedConfig.model_name} +
+
+ )} +
+
+
+ ); + })} +
+ + {/* Status Indicators */} +
+
+ {t("progress")}: +
+ {Object.keys(ROLE_DESCRIPTIONS).map((key) => { + const roleKey = ROLE_DESCRIPTIONS[key as keyof typeof ROLE_DESCRIPTIONS].key; + return ( +
+ ); + })} +
+ + {t("roles_assigned", { + assigned: Object.values(assignments).filter(Boolean).length, + total: Object.keys(ROLE_DESCRIPTIONS).length, + })} + +
+ + {isAssignmentComplete && ( +
+ + {t("all_roles_assigned_saved")} +
+ )} +
+
+ )} +
+
+ ); +} diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx index 2fc99d262..c06a166ec 100644 --- a/surfsense_web/components/sidebar/app-sidebar.tsx +++ b/surfsense_web/components/sidebar/app-sidebar.tsx @@ -64,7 +64,7 @@ const defaultData = { }, navMain: [ { - title: "Researcher", + title: "Chat", url: "#", icon: "SquareTerminal", isActive: true, diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 0351ad31f..0b22ea0f5 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -147,7 +147,8 @@ "manage_connectors": "Manage Connectors", "podcasts": "Podcasts", "logs": "Logs", - "all_search_spaces": "All Search Spaces" + "all_search_spaces": "All Search Spaces", + "chat": "Chat" }, "pricing": { "title": "SurfSense Pricing", @@ -527,6 +528,8 @@ "percent_complete": "{percent}% Complete", "add_llm_provider": "Add LLM Provider", "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", "configure_first_provider": "Configure your first model provider", "assign_specific_roles": "Assign specific roles to your LLM configurations", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index c87d37267..d00da8e3a 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -147,7 +147,8 @@ "manage_connectors": "管理连接器", "podcasts": "播客", "logs": "日志", - "all_search_spaces": "所有搜索空间" + "all_search_spaces": "所有搜索空间", + "chat": "聊天" }, "pricing": { "title": "SurfSense 定价", @@ -527,6 +528,8 @@ "percent_complete": "已完成 {percent}%", "add_llm_provider": "添加 LLM 提供商", "assign_llm_roles": "分配 LLM 角色", + "setup_llm_configuration": "设置 LLM 配置", + "configure_providers_and_assign_roles": "添加您的 LLM 提供商并为其分配特定角色", "setup_complete": "设置完成", "configure_first_provider": "配置您的第一个模型提供商", "assign_specific_roles": "为您的 LLM 配置分配特定角色",