From 7ed6e9c75f1c6ef9c9fc997c53bea8dc0e7dcb69 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:56:42 +0530 Subject: [PATCH 01/24] fix: update message handling in NewChatPage to conditionally set author_id based on chat visibility --- .../[search_space_id]/new-chat/[[...chat_id]]/page.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 9809c9b2e..1f9074793 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -218,13 +218,15 @@ export default function NewChatPage() { return; } + const isSharedChat = currentThread?.visibility === "SEARCH_SPACE"; + setMessages((prev) => { if (syncedMessages.length < prev.length) { return prev; } return syncedMessages.map((msg) => { - const member = msg.author_id + const member = isSharedChat && msg.author_id ? membersData?.find((m) => m.user_id === msg.author_id) : null; @@ -239,7 +241,7 @@ export default function NewChatPage() { thread_id: msg.thread_id, role: msg.role.toLowerCase() as "user" | "assistant" | "system", content: msg.content, - author_id: msg.author_id, + author_id: isSharedChat ? msg.author_id : null, created_at: msg.created_at, author_display_name: member?.user_display_name ?? existingAuthor?.displayName ?? null, author_avatar_url: member?.user_avatar_url ?? existingAuthor?.avatarUrl ?? null, @@ -247,7 +249,7 @@ export default function NewChatPage() { }); }); }, - [isRunning, membersData] + [isRunning, membersData, currentThread?.visibility] ); useMessagesSync(threadId, handleSyncedMessagesUpdate); From 75fd39c249ba8eba832e342ea8da9f7cc48c9a29 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:11:00 +0530 Subject: [PATCH 02/24] refactor: simplify author metadata handling in NewChatPage and UserMessage components --- .../new-chat/[[...chat_id]]/page.tsx | 30 ++++++++----------- .../components/assistant-ui/user-message.tsx | 5 +++- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 1f9074793..5b35c0284 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -218,15 +218,13 @@ export default function NewChatPage() { return; } - const isSharedChat = currentThread?.visibility === "SEARCH_SPACE"; - setMessages((prev) => { if (syncedMessages.length < prev.length) { return prev; } return syncedMessages.map((msg) => { - const member = isSharedChat && msg.author_id + const member = msg.author_id ? membersData?.find((m) => m.user_id === msg.author_id) : null; @@ -241,7 +239,7 @@ export default function NewChatPage() { thread_id: msg.thread_id, role: msg.role.toLowerCase() as "user" | "assistant" | "system", content: msg.content, - author_id: isSharedChat ? msg.author_id : null, + author_id: msg.author_id, created_at: msg.created_at, author_display_name: member?.user_display_name ?? existingAuthor?.displayName ?? null, author_avatar_url: member?.user_avatar_url ?? existingAuthor?.avatarUrl ?? null, @@ -249,7 +247,7 @@ export default function NewChatPage() { }); }); }, - [isRunning, membersData, currentThread?.visibility] + [isRunning, membersData] ); useMessagesSync(threadId, handleSyncedMessagesUpdate); @@ -485,18 +483,17 @@ export default function NewChatPage() { // Add user message to state const userMsgId = `msg-user-${Date.now()}`; - // Include author metadata for shared chats - const authorMetadata = - currentThread?.visibility === "SEARCH_SPACE" && currentUser - ? { - custom: { - author: { - displayName: currentUser.display_name ?? null, - avatarUrl: currentUser.avatar_url ?? null, - }, + // Always include author metadata so the UI layer can decide visibility + const authorMetadata = currentUser + ? { + custom: { + author: { + displayName: currentUser.display_name ?? null, + avatarUrl: currentUser.avatar_url ?? null, }, - } - : undefined; + }, + } + : undefined; const userMessage: ThreadMessageLike = { id: userMsgId, @@ -884,7 +881,6 @@ export default function NewChatPage() { setMessageDocumentsMap, setAgentCreatedDocuments, queryClient, - currentThread, currentUser, disabledTools, updateChatTabTitle, diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index 74461e760..34945c472 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -3,6 +3,7 @@ import { useAtomValue } from "jotai"; import { CheckIcon, CopyIcon, FileText, Pen } from "lucide-react"; import Image from "next/image"; import { type FC, useState } from "react"; +import { currentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; @@ -51,6 +52,8 @@ export const UserMessage: FC = () => { const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined; const metadata = useAuiState(({ message }) => message?.metadata); const author = metadata?.custom?.author as AuthorMetadata | undefined; + const isSharedChat = useAtomValue(currentThreadAtom).visibility === "SEARCH_SPACE"; + const showAvatar = isSharedChat && !!author; return ( { - {author && ( + {showAvatar && (
From fec5c005eb1c59b078c12397ed4c1f2ccc1a16c1 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:26:31 +0530 Subject: [PATCH 03/24] refactor: enhance button loading states in various components for improved user experience --- .../components/DocumentsTableShell.tsx | 10 +++-- .../components/PromptsContent.tsx | 5 ++- .../assistant-ui/connector-popup.tsx | 45 +------------------ .../components/connector-card.tsx | 27 ++++++----- .../layout/providers/LayoutDataProvider.tsx | 41 +++++++---------- .../layout/ui/sidebar/DocumentsSidebar.tsx | 5 ++- 6 files changed, 42 insertions(+), 91 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx index 68d971fc4..918032acd 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -986,9 +986,10 @@ export function DocumentsTableShell({ handleDeleteFromMenu(); }} disabled={isDeleting} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90" > - {isDeleting ? : "Delete"} + Delete + {isDeleting && } @@ -1104,9 +1105,10 @@ export function DocumentsTableShell({ handleBulkDelete(); }} disabled={isBulkDeleting} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90" > - {isBulkDeleting ? : "Delete"} + Delete + {isBulkDeleting && } diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx index 38ccafa94..104dc111f 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx @@ -165,8 +165,9 @@ export function PromptsContent() { - diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index f1cf5ee4d..27868f5da 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -1,7 +1,7 @@ "use client"; import { useAtomValue, useSetAtom } from "jotai"; -import { AlertTriangle, Cable, Settings } from "lucide-react"; +import { AlertTriangle, Settings } from "lucide-react"; import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom"; @@ -12,17 +12,14 @@ import { import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; -import { Spinner } from "@/components/ui/spinner"; import { Tabs, TabsContent } from "@/components/ui/tabs"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { useConnectorsSync } from "@/hooks/use-connectors-sync"; import { PICKER_CLOSE_EVENT, PICKER_OPEN_EVENT } from "@/hooks/use-google-picker"; import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts"; -import { cn } from "@/lib/utils"; import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header"; import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view"; import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view"; @@ -47,7 +44,7 @@ interface ConnectorIndicatorProps { } export const ConnectorIndicator = forwardRef( - ({ showTrigger = true }, ref) => { + (_props, ref) => { const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom); useAtomValue(currentUserAtom); @@ -74,8 +71,6 @@ export const ConnectorIndicator = forwardRef count > 0) @@ -205,40 +198,6 @@ export const ConnectorIndicator = forwardRef - {showTrigger && ( - handleOpenChange(true)} - > - {isLoading ? ( - - ) : ( - <> - - {activeConnectorsCount > 0 && ( - - {activeConnectorsCount > 99 ? "99+" : activeConnectorsCount} - - )} - - )} - - )} {isOpen && createPortal( diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx index 4119b74cd..d24057b1c 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx @@ -143,7 +143,7 @@ export const ConnectorCard: FC = ({ size="sm" variant={isConnected ? "secondary" : "default"} className={cn( - "h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium", + "relative h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium items-center justify-center", isConnected && "bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80", !isConnected && "shadow-xs" @@ -151,19 +151,18 @@ export const ConnectorCard: FC = ({ onClick={isConnected ? onManage : onConnect} disabled={isConnecting || !isEnabled} > - {isConnecting ? ( - - ) : !isEnabled ? ( - "Unavailable" - ) : isConnected ? ( - "Manage" - ) : id === "youtube-crawler" ? ( - "Add" - ) : connectorType ? ( - "Connect" - ) : ( - "Add" - )} + + {!isEnabled + ? "Unavailable" + : isConnected + ? "Manage" + : id === "youtube-crawler" + ? "Add" + : connectorType + ? "Connect" + : "Add"} + + {isConnecting && } ); diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index ef9ed1402..57a0f89cf 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -795,9 +795,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid confirmDeleteChat(); }} disabled={isDeletingChat} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2" + className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90 items-center justify-center" > - {isDeletingChat ? : tCommon("delete")} + {tCommon("delete")} + {isDeletingChat && } @@ -835,15 +836,11 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid @@ -869,15 +866,11 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid confirmDeleteSearchSpace(); }} disabled={isDeletingSearchSpace} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2" + className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90" > - {isDeletingSearchSpace ? ( - <> - - {t("deleting")} - - ) : ( - tCommon("delete") + {tCommon("delete")} + {isDeletingSearchSpace && ( + )} @@ -903,15 +896,11 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid confirmLeaveSearchSpace(); }} disabled={isLeavingSearchSpace} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2" + className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90" > - {isLeavingSearchSpace ? ( - <> - - {t("leaving")} - - ) : ( - t("leave") + {t("leave")} + {isLeavingSearchSpace && ( + )} diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index da28c17e0..ede469039 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -807,9 +807,10 @@ export function DocumentsSidebar({ handleBulkDeleteSelected(); }} disabled={isBulkDeleting} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90" > - {isBulkDeleting ? : "Delete"} + Delete + {isBulkDeleting && } From db263735947f1bf84585ff0d55118fc7c5f42c15 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:33:15 +0530 Subject: [PATCH 04/24] refactor: update styling for button and command components in ImageModelManager for improved UI consistency --- surfsense_web/components/settings/image-model-manager.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/settings/image-model-manager.tsx b/surfsense_web/components/settings/image-model-manager.tsx index 877fa991c..74de7c28c 100644 --- a/surfsense_web/components/settings/image-model-manager.tsx +++ b/surfsense_web/components/settings/image-model-manager.tsx @@ -610,14 +610,14 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) { - + Date: Sun, 29 Mar 2026 17:02:20 +0530 Subject: [PATCH 05/24] refactor: move ImageConfigDialog to shared components and update imports in chat-header and image-model-manager for better organization --- .../image-gen-config-mutation.atoms.ts | 12 +- .../components/new-chat/chat-header.tsx | 2 +- .../new-chat/image-config-dialog.tsx | 558 ------------------ .../settings/image-model-manager.tsx | 371 +----------- .../components/shared/image-config-dialog.tsx | 500 ++++++++++++++++ 5 files changed, 527 insertions(+), 916 deletions(-) delete mode 100644 surfsense_web/components/new-chat/image-config-dialog.tsx create mode 100644 surfsense_web/components/shared/image-config-dialog.tsx diff --git a/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts b/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts index dbaf441d0..dd8bcd324 100644 --- a/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts +++ b/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts @@ -24,13 +24,13 @@ export const createImageGenConfigMutationAtom = atomWithMutation((get) => { return imageGenConfigApiService.createConfig(request); }, onSuccess: () => { - toast.success("Image model configuration created"); + toast.success("Image model created"); queryClient.invalidateQueries({ queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)), }); }, onError: (error: Error) => { - toast.error(error.message || "Failed to create image model configuration"); + toast.error(error.message || "Failed to create image model"); }, }; }); @@ -48,7 +48,7 @@ export const updateImageGenConfigMutationAtom = atomWithMutation((get) => { return imageGenConfigApiService.updateConfig(request); }, onSuccess: (_: UpdateImageGenConfigResponse, request: UpdateImageGenConfigRequest) => { - toast.success("Image model configuration updated"); + toast.success("Image model updated"); queryClient.invalidateQueries({ queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)), }); @@ -57,7 +57,7 @@ export const updateImageGenConfigMutationAtom = atomWithMutation((get) => { }); }, onError: (error: Error) => { - toast.error(error.message || "Failed to update image model configuration"); + toast.error(error.message || "Failed to update image model"); }, }; }); @@ -75,7 +75,7 @@ export const deleteImageGenConfigMutationAtom = atomWithMutation((get) => { return imageGenConfigApiService.deleteConfig(id); }, onSuccess: (_, id: number) => { - toast.success("Image model configuration deleted"); + toast.success("Image model deleted"); queryClient.setQueryData( cacheKeys.imageGenConfigs.all(Number(searchSpaceId)), (oldData: GetImageGenConfigsResponse | undefined) => { @@ -85,7 +85,7 @@ export const deleteImageGenConfigMutationAtom = atomWithMutation((get) => { ); }, onError: (error: Error) => { - toast.error(error.message || "Failed to delete image model configuration"); + toast.error(error.message || "Failed to delete image model"); }, }; }); diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx index 45a07d5a1..06801372d 100644 --- a/surfsense_web/components/new-chat/chat-header.tsx +++ b/surfsense_web/components/new-chat/chat-header.tsx @@ -7,7 +7,7 @@ import type { ImageGenerationConfig, NewLLMConfigPublic, } from "@/contracts/types/new-llm-config.types"; -import { ImageConfigDialog } from "./image-config-dialog"; +import { ImageConfigDialog } from "@/components/shared/image-config-dialog"; import { ModelConfigDialog } from "./model-config-dialog"; import { ModelSelector } from "./model-selector"; diff --git a/surfsense_web/components/new-chat/image-config-dialog.tsx b/surfsense_web/components/new-chat/image-config-dialog.tsx deleted file mode 100644 index 12263bdb1..000000000 --- a/surfsense_web/components/new-chat/image-config-dialog.tsx +++ /dev/null @@ -1,558 +0,0 @@ -"use client"; - -import { useAtomValue } from "jotai"; -import { AlertCircle, Check, ChevronsUpDown, X } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { toast } from "sonner"; -import { - createImageGenConfigMutationAtom, - updateImageGenConfigMutationAtom, -} from "@/atoms/image-gen-config/image-gen-config-mutation.atoms"; -import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -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 { Spinner } from "@/components/ui/spinner"; -import { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDERS } from "@/contracts/enums/image-gen-providers"; -import type { - GlobalImageGenConfig, - ImageGenerationConfig, - ImageGenProvider, -} from "@/contracts/types/new-llm-config.types"; -import { cn } from "@/lib/utils"; - -interface ImageConfigDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - config: ImageGenerationConfig | GlobalImageGenConfig | null; - isGlobal: boolean; - searchSpaceId: number; - mode: "create" | "edit" | "view"; -} - -const INITIAL_FORM = { - name: "", - description: "", - provider: "", - model_name: "", - api_key: "", - api_base: "", - api_version: "", -}; - -export function ImageConfigDialog({ - open, - onOpenChange, - config, - isGlobal, - searchSpaceId, - mode, -}: ImageConfigDialogProps) { - const [isSubmitting, setIsSubmitting] = useState(false); - const [mounted, setMounted] = useState(false); - const [formData, setFormData] = useState(INITIAL_FORM); - const [modelComboboxOpen, setModelComboboxOpen] = useState(false); - const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top"); - const scrollRef = useRef(null); - - useEffect(() => { - setMounted(true); - }, []); - - useEffect(() => { - if (open) { - if (mode === "edit" && config && !isGlobal) { - setFormData({ - name: config.name || "", - description: config.description || "", - provider: config.provider || "", - model_name: config.model_name || "", - api_key: (config as ImageGenerationConfig).api_key || "", - api_base: config.api_base || "", - api_version: config.api_version || "", - }); - } else if (mode === "create") { - setFormData(INITIAL_FORM); - } - setScrollPos("top"); - } - }, [open, mode, config, isGlobal]); - - const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom); - const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom); - const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); - - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape" && open) onOpenChange(false); - }; - window.addEventListener("keydown", handleEscape); - return () => window.removeEventListener("keydown", handleEscape); - }, [open, onOpenChange]); - - const handleScroll = useCallback((e: React.UIEvent) => { - const el = e.currentTarget; - const atTop = el.scrollTop <= 2; - const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; - setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); - }, []); - - const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode; - - const suggestedModels = useMemo(() => { - if (!formData.provider) return []; - return IMAGE_GEN_MODELS.filter((m) => m.provider === formData.provider); - }, [formData.provider]); - - const getTitle = () => { - if (mode === "create") return "Add Image Model"; - if (isAutoMode) return "Auto Mode (Fastest)"; - if (isGlobal) return "View Global Image Model"; - return "Edit Image Model"; - }; - - const getSubtitle = () => { - if (mode === "create") return "Set up a new image generation provider"; - if (isAutoMode) return "Automatically routes requests across providers"; - if (isGlobal) return "Read-only global configuration"; - return "Update your image model settings"; - }; - - const handleSubmit = useCallback(async () => { - setIsSubmitting(true); - try { - if (mode === "create") { - const result = await createConfig({ - name: formData.name, - provider: formData.provider as ImageGenProvider, - model_name: formData.model_name, - api_key: formData.api_key, - api_base: formData.api_base || undefined, - api_version: formData.api_version || undefined, - description: formData.description || undefined, - search_space_id: searchSpaceId, - }); - if (result?.id) { - await updatePreferences({ - search_space_id: searchSpaceId, - data: { image_generation_config_id: result.id }, - }); - } - toast.success("Image model created and assigned!"); - onOpenChange(false); - } else if (!isGlobal && config) { - await updateConfig({ - id: config.id, - data: { - name: formData.name, - description: formData.description || undefined, - provider: formData.provider as ImageGenProvider, - model_name: formData.model_name, - api_key: formData.api_key, - api_base: formData.api_base || undefined, - api_version: formData.api_version || undefined, - }, - }); - toast.success("Image model updated!"); - onOpenChange(false); - } - } catch (error) { - console.error("Failed to save image config:", error); - toast.error("Failed to save image model"); - } finally { - setIsSubmitting(false); - } - }, [ - mode, - isGlobal, - config, - formData, - searchSpaceId, - createConfig, - updateConfig, - updatePreferences, - onOpenChange, - ]); - - const handleUseGlobalConfig = useCallback(async () => { - if (!config || !isGlobal) return; - setIsSubmitting(true); - try { - await updatePreferences({ - search_space_id: searchSpaceId, - data: { image_generation_config_id: config.id }, - }); - toast.success(`Now using ${config.name}`); - onOpenChange(false); - } catch (error) { - console.error("Failed to set image model:", error); - toast.error("Failed to set image model"); - } finally { - setIsSubmitting(false); - } - }, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]); - - const isFormValid = formData.name && formData.provider && formData.model_name && formData.api_key; - const selectedProvider = IMAGE_GEN_PROVIDERS.find((p) => p.value === formData.provider); - - if (!mounted) return null; - - const dialogContent = ( - - {open && ( - <> - onOpenChange(false)} - /> - - -
e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === "Escape") onOpenChange(false); - }} - > - {/* Header */} -
-
-
-

{getTitle()}

- {isAutoMode && ( - - Recommended - - )} - {isGlobal && !isAutoMode && mode !== "create" && ( - - Global - - )} -
-

{getSubtitle()}

- {config && !isAutoMode && mode !== "create" && ( -

- {config.model_name} -

- )} -
- -
- - {/* Scrollable content */} -
- {isAutoMode && ( - - - Auto mode distributes image generation requests across all configured - providers for optimal performance and rate limit protection. - - - )} - - {isGlobal && !isAutoMode && config && ( - <> - - - - Global configurations are read-only. To customize, create a new model. - - -
-
-
-
- Name -
-

{config.name}

-
- {config.description && ( -
-
- Description -
-

{config.description}

-
- )} -
- -
-
-
- Provider -
-

{config.provider}

-
-
-
- Model -
-

{config.model_name}

-
-
-
- - )} - - {(mode === "create" || (mode === "edit" && !isGlobal)) && ( -
-
- - setFormData((p) => ({ ...p, name: e.target.value }))} - /> -
- -
- - - setFormData((p) => ({ ...p, description: e.target.value })) - } - /> -
- - - -
- - -
- -
- - {suggestedModels.length > 0 ? ( - - - - - - - - setFormData((p) => ({ ...p, model_name: val })) - } - /> - - - - Type a custom model name - - - - {suggestedModels.map((m) => ( - { - setFormData((p) => ({ ...p, model_name: m.value })); - setModelComboboxOpen(false); - }} - > - - {m.value} - - {m.label} - - - ))} - - - - - - ) : ( - - setFormData((p) => ({ ...p, model_name: e.target.value })) - } - /> - )} -
- -
- - setFormData((p) => ({ ...p, api_key: e.target.value }))} - /> -
- -
- - setFormData((p) => ({ ...p, api_base: e.target.value }))} - /> -
- - {formData.provider === "AZURE_OPENAI" && ( -
- - - setFormData((p) => ({ ...p, api_version: e.target.value })) - } - /> -
- )} -
- )} -
- - {/* Fixed footer */} -
- - {mode === "create" || (mode === "edit" && !isGlobal) ? ( - - ) : isAutoMode ? ( - - ) : isGlobal && config ? ( - - ) : null} -
-
-
- - )} -
- ); - - return typeof document !== "undefined" ? createPortal(dialogContent, document.body) : null; -} diff --git a/surfsense_web/components/settings/image-model-manager.tsx b/surfsense_web/components/settings/image-model-manager.tsx index 74de7c28c..7b0db5596 100644 --- a/surfsense_web/components/settings/image-model-manager.tsx +++ b/surfsense_web/components/settings/image-model-manager.tsx @@ -3,29 +3,22 @@ import { useAtomValue } from "jotai"; import { AlertCircle, - Check, - ChevronsUpDown, Edit3, Info, - Key, Plus, RefreshCw, Trash2, Wand2, } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; -import { toast } from "sonner"; +import { useMemo, useState } from "react"; import { - createImageGenConfigMutationAtom, deleteImageGenConfigMutationAtom, - updateImageGenConfigMutationAtom, } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms"; import { globalImageGenConfigsAtom, imageGenConfigsAtom, } from "@/atoms/image-gen-config/image-gen-config-query.atoms"; import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; -import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { AlertDialog, @@ -40,43 +33,14 @@ import { import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -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 { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { - getImageGenModelsByProvider, - IMAGE_GEN_PROVIDERS, -} from "@/contracts/enums/image-gen-providers"; import type { ImageGenerationConfig } from "@/contracts/types/new-llm-config.types"; import { useMediaQuery } from "@/hooks/use-media-query"; import { getProviderIcon } from "@/lib/provider-icons"; import { cn } from "@/lib/utils"; +import { ImageConfigDialog } from "@/components/shared/image-config-dialog"; interface ImageModelManagerProps { searchSpaceId: number; @@ -92,23 +56,12 @@ function getInitials(name: string): string { export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) { const isDesktop = useMediaQuery("(min-width: 768px)"); - // Image gen config atoms - const { - mutateAsync: createConfig, - isPending: isCreating, - error: createError, - } = useAtomValue(createImageGenConfigMutationAtom); - const { - mutateAsync: updateConfig, - isPending: isUpdating, - error: updateError, - } = useAtomValue(updateImageGenConfigMutationAtom); + const { mutateAsync: deleteConfig, isPending: isDeleting, error: deleteError, } = useAtomValue(deleteImageGenConfigMutationAtom); - const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); const { data: userConfigs, @@ -119,7 +72,6 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) { const { data: globalConfigs = [], isFetching: globalLoading } = useAtomValue(globalImageGenConfigsAtom); - // Members for user resolution const { data: members } = useAtomValue(membersAtom); const memberMap = useMemo(() => { const map = new Map(); @@ -135,7 +87,6 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) { return map; }, [members]); - // Permissions const { data: access } = useAtomValue(myAccessAtom); const canCreate = useMemo(() => { if (!access) return false; @@ -147,92 +98,25 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) { if (access.is_owner) return true; return access.permissions?.includes("image_generations:delete") ?? false; }, [access]); - // Backend uses image_generations:create for update as well const canUpdate = canCreate; const isReadOnly = !canCreate && !canDelete; - // Local state const [isDialogOpen, setIsDialogOpen] = useState(false); const [editingConfig, setEditingConfig] = useState(null); const [configToDelete, setConfigToDelete] = useState(null); - const isSubmitting = isCreating || isUpdating; const isLoading = configsLoading || globalLoading; - const errors = [createError, updateError, deleteError, fetchError].filter(Boolean) as Error[]; - - // Form state for create/edit dialog - const [formData, setFormData] = useState({ - name: "", - description: "", - provider: "", - custom_provider: "", - model_name: "", - api_key: "", - api_base: "", - api_version: "", - }); - const [modelComboboxOpen, setModelComboboxOpen] = useState(false); + const errors = [deleteError, fetchError].filter(Boolean) as Error[]; - const resetForm = () => { - setFormData({ - name: "", - description: "", - provider: "", - custom_provider: "", - model_name: "", - api_key: "", - api_base: "", - api_version: "", - }); + const openEditDialog = (config: ImageGenerationConfig) => { + setEditingConfig(config); + setIsDialogOpen(true); }; - const handleFormSubmit = useCallback(async () => { - if (!formData.name || !formData.provider || !formData.model_name || !formData.api_key) { - toast.error("Please fill in all required fields"); - return; - } - try { - if (editingConfig) { - await updateConfig({ - id: editingConfig.id, - data: { - name: formData.name, - description: formData.description || undefined, - provider: formData.provider as any, - custom_provider: formData.custom_provider || undefined, - model_name: formData.model_name, - api_key: formData.api_key, - api_base: formData.api_base || undefined, - api_version: formData.api_version || undefined, - }, - }); - } else { - const result = await createConfig({ - name: formData.name, - description: formData.description || undefined, - provider: formData.provider as any, - custom_provider: formData.custom_provider || undefined, - model_name: formData.model_name, - api_key: formData.api_key, - api_base: formData.api_base || undefined, - api_version: formData.api_version || undefined, - search_space_id: searchSpaceId, - }); - // Auto-assign newly created config - if (result?.id) { - await updatePreferences({ - search_space_id: searchSpaceId, - data: { image_generation_config_id: result.id }, - }); - } - } - setIsDialogOpen(false); - setEditingConfig(null); - resetForm(); - } catch { - // Error handled by mutation - } - }, [editingConfig, formData, searchSpaceId, createConfig, updateConfig, updatePreferences]); + const openNewDialog = () => { + setEditingConfig(null); + setIsDialogOpen(true); + }; const handleDelete = async () => { if (!configToDelete) return; @@ -244,30 +128,6 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) { } }; - const openEditDialog = (config: ImageGenerationConfig) => { - setEditingConfig(config); - setFormData({ - name: config.name, - description: config.description || "", - provider: config.provider, - custom_provider: config.custom_provider || "", - model_name: config.model_name, - api_key: config.api_key, - api_base: config.api_base || "", - api_version: config.api_version || "", - }); - setIsDialogOpen(true); - }; - - const openNewDialog = () => { - setEditingConfig(null); - resetForm(); - setIsDialogOpen(true); - }; - - const selectedProvider = IMAGE_GEN_PROVIDERS.find((p) => p.value === formData.provider); - const suggestedModels = getImageGenModelsByProvider(formData.provider); - return (
{/* Header */} @@ -348,31 +208,26 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) { {/* Loading Skeleton */} {isLoading && (
- {/* Your Image Models Section Skeleton */}
- {/* Cards Grid Skeleton */}
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => ( - {/* Header */}
- {/* Provider + Model */}
- {/* Footer */}
@@ -529,204 +384,18 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
)} - {/* Create/Edit Dialog */} - { - if (!open) { - setIsDialogOpen(false); - setEditingConfig(null); - resetForm(); - } + setIsDialogOpen(open); + if (!open) setEditingConfig(null); }} - > - e.preventDefault()} - > - - {editingConfig ? "Edit Image Model" : "Add Image Model"} - - {editingConfig - ? "Update your image generation model" - : "Configure a new image generation model (DALL-E 3, GPT Image 1, etc.)"} - - - -
- {/* Name */} -
- - setFormData((p) => ({ ...p, name: e.target.value }))} - /> -
- - {/* Description */} -
- - setFormData((p) => ({ ...p, description: e.target.value }))} - /> -
- - - - {/* Provider */} -
- - -
- - {/* Model Name */} -
- - {suggestedModels.length > 0 ? ( - - - - - - - setFormData((p) => ({ ...p, model_name: val }))} - /> - - - - Type a custom model name - - - - {suggestedModels.map((m) => ( - { - setFormData((p) => ({ ...p, model_name: m.value })); - setModelComboboxOpen(false); - }} - > - - {m.value} - {m.label} - - ))} - - - - - - ) : ( - setFormData((p) => ({ ...p, model_name: e.target.value }))} - /> - )} -
- - {/* API Key */} -
- - setFormData((p) => ({ ...p, api_key: e.target.value }))} - /> -
- - {/* API Base (optional) */} -
- - setFormData((p) => ({ ...p, api_base: e.target.value }))} - /> -
- - {/* API Version (Azure) */} - {formData.provider === "AZURE_OPENAI" && ( -
- - setFormData((p) => ({ ...p, api_version: e.target.value }))} - /> -
- )} - - {/* Actions */} -
- - -
-
-
-
+ config={editingConfig} + isGlobal={false} + searchSpaceId={searchSpaceId} + mode={editingConfig ? "edit" : "create"} + /> {/* Delete Confirmation */} void; + config: ImageGenerationConfig | GlobalImageGenConfig | null; + isGlobal: boolean; + searchSpaceId: number; + mode: "create" | "edit" | "view"; +} + +const INITIAL_FORM = { + name: "", + description: "", + provider: "", + model_name: "", + api_key: "", + api_base: "", + api_version: "", +}; + +export function ImageConfigDialog({ + open, + onOpenChange, + config, + isGlobal, + searchSpaceId, + mode, +}: ImageConfigDialogProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [formData, setFormData] = useState(INITIAL_FORM); + const [modelComboboxOpen, setModelComboboxOpen] = useState(false); + const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top"); + const scrollRef = useRef(null); + + useEffect(() => { + if (open) { + if (mode === "edit" && config && !isGlobal) { + setFormData({ + name: config.name || "", + description: config.description || "", + provider: config.provider || "", + model_name: config.model_name || "", + api_key: (config as ImageGenerationConfig).api_key || "", + api_base: config.api_base || "", + api_version: config.api_version || "", + }); + } else if (mode === "create") { + setFormData(INITIAL_FORM); + } + setScrollPos("top"); + } + }, [open, mode, config, isGlobal]); + + const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom); + const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom); + const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); + + const handleScroll = useCallback((e: React.UIEvent) => { + const el = e.currentTarget; + const atTop = el.scrollTop <= 2; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; + setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); + }, []); + + const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode; + + const suggestedModels = useMemo(() => { + if (!formData.provider) return []; + return IMAGE_GEN_MODELS.filter((m) => m.provider === formData.provider); + }, [formData.provider]); + + const getTitle = () => { + if (mode === "create") return "Add Image Model"; + if (isAutoMode) return "Auto Mode (Fastest)"; + if (isGlobal) return "View Global Image Model"; + return "Edit Image Model"; + }; + + const getSubtitle = () => { + if (mode === "create") return "Set up a new image generation provider"; + if (isAutoMode) return "Automatically routes requests across providers"; + if (isGlobal) return "Read-only global configuration"; + return "Update your image model settings"; + }; + + const handleSubmit = useCallback(async () => { + setIsSubmitting(true); + try { + if (mode === "create") { + const result = await createConfig({ + name: formData.name, + provider: formData.provider as ImageGenProvider, + model_name: formData.model_name, + api_key: formData.api_key, + api_base: formData.api_base || undefined, + api_version: formData.api_version || undefined, + description: formData.description || undefined, + search_space_id: searchSpaceId, + }); + if (result?.id) { + await updatePreferences({ + search_space_id: searchSpaceId, + data: { image_generation_config_id: result.id }, + }); + } + onOpenChange(false); + } else if (!isGlobal && config) { + await updateConfig({ + id: config.id, + data: { + name: formData.name, + description: formData.description || undefined, + provider: formData.provider as ImageGenProvider, + model_name: formData.model_name, + api_key: formData.api_key, + api_base: formData.api_base || undefined, + api_version: formData.api_version || undefined, + }, + }); + onOpenChange(false); + } + } catch (error) { + console.error("Failed to save image config:", error); + toast.error("Failed to save image model"); + } finally { + setIsSubmitting(false); + } + }, [ + mode, + isGlobal, + config, + formData, + searchSpaceId, + createConfig, + updateConfig, + updatePreferences, + onOpenChange, + ]); + + const handleUseGlobalConfig = useCallback(async () => { + if (!config || !isGlobal) return; + setIsSubmitting(true); + try { + await updatePreferences({ + search_space_id: searchSpaceId, + data: { image_generation_config_id: config.id }, + }); + toast.success(`Now using ${config.name}`); + onOpenChange(false); + } catch (error) { + console.error("Failed to set image model:", error); + toast.error("Failed to set image model"); + } finally { + setIsSubmitting(false); + } + }, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]); + + const isFormValid = formData.name && formData.provider && formData.model_name && formData.api_key; + const selectedProvider = IMAGE_GEN_PROVIDERS.find((p) => p.value === formData.provider); + + return ( + + e.preventDefault()} + > + {getTitle()} + + {/* Header */} +
+
+
+

{getTitle()}

+ {isAutoMode && ( + + Recommended + + )} + {isGlobal && !isAutoMode && mode !== "create" && ( + + Global + + )} +
+

{getSubtitle()}

+ {config && !isAutoMode && mode !== "create" && ( +

+ {config.model_name} +

+ )} +
+
+ + {/* Scrollable content */} +
+ {isAutoMode && ( + + + Auto mode distributes image generation requests across all configured + providers for optimal performance and rate limit protection. + + + )} + + {isGlobal && !isAutoMode && config && ( + <> + + + + Global configurations are read-only. To customize, create a new model. + + +
+
+
+
+ Name +
+

{config.name}

+
+ {config.description && ( +
+
+ Description +
+

{config.description}

+
+ )} +
+ +
+
+
+ Provider +
+

{config.provider}

+
+
+
+ Model +
+

{config.model_name}

+
+
+
+ + )} + + {(mode === "create" || (mode === "edit" && !isGlobal)) && ( +
+
+ + setFormData((p) => ({ ...p, name: e.target.value }))} + /> +
+ +
+ + + setFormData((p) => ({ ...p, description: e.target.value })) + } + /> +
+ + + +
+ + +
+ +
+ + {suggestedModels.length > 0 ? ( + + + + + + + + setFormData((p) => ({ ...p, model_name: val })) + } + /> + + + + Type a custom model name + + + + {suggestedModels.map((m) => ( + { + setFormData((p) => ({ ...p, model_name: m.value })); + setModelComboboxOpen(false); + }} + > + + {m.value} + + {m.label} + + + ))} + + + + + + ) : ( + + setFormData((p) => ({ ...p, model_name: e.target.value })) + } + /> + )} +
+ +
+ + setFormData((p) => ({ ...p, api_key: e.target.value }))} + /> +
+ +
+ + setFormData((p) => ({ ...p, api_base: e.target.value }))} + /> +
+ + {formData.provider === "AZURE_OPENAI" && ( +
+ + + setFormData((p) => ({ ...p, api_version: e.target.value })) + } + /> +
+ )} +
+ )} +
+ + {/* Fixed footer */} +
+ + {mode === "create" || (mode === "edit" && !isGlobal) ? ( + + ) : isAutoMode ? ( + + ) : isGlobal && config ? ( + + ) : null} +
+
+
+ ); +} From f4adfb54fcd2964172c57ecdbdabc4d76db0c98d Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:16:40 +0530 Subject: [PATCH 06/24] refactor: update global image model and configuration messages for clarity and consistency across components --- .../settings/image-model-manager.tsx | 6 +----- .../settings/model-config-manager.tsx | 20 +++++++------------ surfsense_web/messages/en.json | 1 - surfsense_web/messages/es.json | 1 - surfsense_web/messages/hi.json | 1 - surfsense_web/messages/pt.json | 1 - surfsense_web/messages/zh.json | 1 - 7 files changed, 8 insertions(+), 23 deletions(-) diff --git a/surfsense_web/components/settings/image-model-manager.tsx b/surfsense_web/components/settings/image-model-manager.tsx index 7b0db5596..1c8394c9c 100644 --- a/surfsense_web/components/settings/image-model-manager.tsx +++ b/surfsense_web/components/settings/image-model-manager.tsx @@ -196,11 +196,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) { - - {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length} global - image model(s) - {" "} - available from your administrator. +

{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length} global image {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length === 1 ? "model" : "models"} available from your administrator. Use the model selector to view and select them.

)} diff --git a/surfsense_web/components/settings/model-config-manager.tsx b/surfsense_web/components/settings/model-config-manager.tsx index 80bfd8e31..64bd7455f 100644 --- a/surfsense_web/components/settings/model-config-manager.tsx +++ b/surfsense_web/components/settings/model-config-manager.tsx @@ -196,7 +196,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) { onClick={openNewDialog} className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200" > - Add Configuration + Add LLM Model )}
@@ -243,18 +243,12 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) { {/* Global Configs Info */} {globalConfigs.length > 0 && ( -
- - - - {globalConfigs.length} global configuration(s){" "} - available from your administrator. These are pre-configured and ready to use.{" "} - - Global configs: {globalConfigs.map((g) => g.name).join(", ")} - - - -
+ + + +

{globalConfigs.length} global {globalConfigs.length === 1 ? "model" : "models"} available from your administrator. Use the model selector to view and select them.

+
+
)} {/* Loading Skeleton */} diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 22097b212..53f80ea5f 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -640,7 +640,6 @@ "active": "Active", "your_configs": "Your Configurations", "manage_configs": "Manage and configure your LLM providers", - "add_config": "Add Configuration", "no_configs": "No Configurations Yet", "no_configs_desc": "Add your own LLM provider configurations.", "add_first_config": "Add First Configuration", diff --git a/surfsense_web/messages/es.json b/surfsense_web/messages/es.json index 83a00e721..36e627295 100644 --- a/surfsense_web/messages/es.json +++ b/surfsense_web/messages/es.json @@ -640,7 +640,6 @@ "active": "Activo", "your_configs": "Tus configuraciones", "manage_configs": "Administra y configura tus proveedores de LLM", - "add_config": "Agregar configuración", "no_configs": "Aún no hay configuraciones", "no_configs_desc": "Agrega tus propias configuraciones de proveedor de LLM.", "add_first_config": "Agregar primera configuración", diff --git a/surfsense_web/messages/hi.json b/surfsense_web/messages/hi.json index ae3a77c2e..fd51acdc2 100644 --- a/surfsense_web/messages/hi.json +++ b/surfsense_web/messages/hi.json @@ -640,7 +640,6 @@ "active": "सक्रिय", "your_configs": "आपकी कॉन्फ़िगरेशन", "manage_configs": "अपने LLM प्रदाता प्रबंधित और कॉन्फ़िगर करें", - "add_config": "कॉन्फ़िगरेशन जोड़ें", "no_configs": "अभी तक कोई कॉन्फ़िगरेशन नहीं", "no_configs_desc": "अपनी LLM प्रदाता कॉन्फ़िगरेशन जोड़ें।", "add_first_config": "पहली कॉन्फ़िगरेशन जोड़ें", diff --git a/surfsense_web/messages/pt.json b/surfsense_web/messages/pt.json index f622c0e51..e26499f90 100644 --- a/surfsense_web/messages/pt.json +++ b/surfsense_web/messages/pt.json @@ -640,7 +640,6 @@ "active": "Ativo", "your_configs": "Suas configurações", "manage_configs": "Gerencie e configure seus provedores de LLM", - "add_config": "Adicionar configuração", "no_configs": "Nenhuma configuração ainda", "no_configs_desc": "Adicione suas próprias configurações de provedor de LLM.", "add_first_config": "Adicionar primeira configuração", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 2a1688b63..819432410 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -624,7 +624,6 @@ "active": "活跃", "your_configs": "您的配置", "manage_configs": "管理和配置您的 LLM 提供商", - "add_config": "添加配置", "no_configs": "暂无配置", "no_configs_desc": "添加您自己的 LLM 提供商配置。", "add_first_config": "添加首个配置", From d88236d43bc424c3b993f4984f797fd3288e4ae3 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:32:23 +0530 Subject: [PATCH 07/24] refactor: replace ModelConfigDialog with a shared component and update related imports for better organization and clarity --- .../new-llm-config-mutation.atoms.ts | 6 +- .../components/new-chat/chat-header.tsx | 2 +- .../new-chat/model-config-dialog.tsx | 489 ------------------ .../settings/model-config-manager.tsx | 106 +--- .../components/shared/model-config-dialog.tsx | 424 +++++++++++++++ 5 files changed, 441 insertions(+), 586 deletions(-) delete mode 100644 surfsense_web/components/new-chat/model-config-dialog.tsx create mode 100644 surfsense_web/components/shared/model-config-dialog.tsx diff --git a/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts b/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts index 8f81b7475..b3b9b2bab 100644 --- a/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts +++ b/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts @@ -26,7 +26,7 @@ export const createNewLLMConfigMutationAtom = atomWithMutation((get) => { return newLLMConfigApiService.createConfig(request); }, onSuccess: () => { - toast.success("Configuration created successfully"); + toast.success("LLM model created"); queryClient.invalidateQueries({ queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)), }); @@ -50,7 +50,7 @@ export const updateNewLLMConfigMutationAtom = atomWithMutation((get) => { return newLLMConfigApiService.updateConfig(request); }, onSuccess: (_: UpdateNewLLMConfigResponse, request: UpdateNewLLMConfigRequest) => { - toast.success("Configuration updated successfully"); + toast.success("LLM model updated"); queryClient.invalidateQueries({ queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)), }); @@ -77,7 +77,7 @@ export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => { return newLLMConfigApiService.deleteConfig(request); }, onSuccess: (_, request: DeleteNewLLMConfigRequest) => { - toast.success("Configuration deleted successfully"); + toast.success("LLM model deleted"); queryClient.setQueryData( cacheKeys.newLLMConfigs.all(Number(searchSpaceId)), (oldData: GetNewLLMConfigsResponse | undefined) => { diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx index 06801372d..5b7d3500c 100644 --- a/surfsense_web/components/new-chat/chat-header.tsx +++ b/surfsense_web/components/new-chat/chat-header.tsx @@ -8,7 +8,7 @@ import type { NewLLMConfigPublic, } from "@/contracts/types/new-llm-config.types"; import { ImageConfigDialog } from "@/components/shared/image-config-dialog"; -import { ModelConfigDialog } from "./model-config-dialog"; +import { ModelConfigDialog } from "@/components/shared/model-config-dialog"; import { ModelSelector } from "./model-selector"; interface ChatHeaderProps { diff --git a/surfsense_web/components/new-chat/model-config-dialog.tsx b/surfsense_web/components/new-chat/model-config-dialog.tsx deleted file mode 100644 index 06ec3b9b5..000000000 --- a/surfsense_web/components/new-chat/model-config-dialog.tsx +++ /dev/null @@ -1,489 +0,0 @@ -"use client"; - -import { useAtomValue } from "jotai"; -import { AlertCircle, X, Zap } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { toast } from "sonner"; -import { - createNewLLMConfigMutationAtom, - updateLLMPreferencesMutationAtom, - updateNewLLMConfigMutationAtom, -} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms"; -import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Spinner } from "@/components/ui/spinner"; -import type { - GlobalNewLLMConfig, - LiteLLMProvider, - NewLLMConfigPublic, -} from "@/contracts/types/new-llm-config.types"; -import { cn } from "@/lib/utils"; - -interface ModelConfigDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - config: NewLLMConfigPublic | GlobalNewLLMConfig | null; - isGlobal: boolean; - searchSpaceId: number; - mode: "create" | "edit" | "view"; -} - -export function ModelConfigDialog({ - open, - onOpenChange, - config, - isGlobal, - searchSpaceId, - mode, -}: ModelConfigDialogProps) { - const [isSubmitting, setIsSubmitting] = useState(false); - const [mounted, setMounted] = useState(false); - const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top"); - const scrollRef = useRef(null); - - useEffect(() => { - setMounted(true); - }, []); - - const handleScroll = useCallback((e: React.UIEvent) => { - const el = e.currentTarget; - const atTop = el.scrollTop <= 2; - const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; - setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); - }, []); - - const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom); - const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom); - const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); - - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape" && open) { - onOpenChange(false); - } - }; - window.addEventListener("keydown", handleEscape); - return () => window.removeEventListener("keydown", handleEscape); - }, [open, onOpenChange]); - - const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode; - - const getTitle = () => { - if (mode === "create") return "Add New Configuration"; - if (isAutoMode) return "Auto Mode (Fastest)"; - if (isGlobal) return "View Global Configuration"; - return "Edit Configuration"; - }; - - const getSubtitle = () => { - if (mode === "create") return "Set up a new LLM provider for this search space"; - if (isAutoMode) return "Automatically routes requests across providers"; - if (isGlobal) return "Read-only global configuration"; - return "Update your configuration settings"; - }; - - const handleSubmit = useCallback( - async (data: LLMConfigFormData) => { - setIsSubmitting(true); - try { - if (mode === "create") { - const result = await createConfig({ - ...data, - search_space_id: searchSpaceId, - }); - - if (result?.id) { - await updatePreferences({ - search_space_id: searchSpaceId, - data: { - agent_llm_id: result.id, - }, - }); - } - - toast.success("Configuration created and assigned!"); - onOpenChange(false); - } else if (!isGlobal && config) { - await updateConfig({ - id: config.id, - data: { - name: data.name, - description: data.description, - provider: data.provider, - custom_provider: data.custom_provider, - model_name: data.model_name, - api_key: data.api_key, - api_base: data.api_base, - litellm_params: data.litellm_params, - system_instructions: data.system_instructions, - use_default_system_instructions: data.use_default_system_instructions, - citations_enabled: data.citations_enabled, - }, - }); - toast.success("Configuration updated!"); - onOpenChange(false); - } - } catch (error) { - console.error("Failed to save configuration:", error); - toast.error("Failed to save configuration"); - } finally { - setIsSubmitting(false); - } - }, - [ - mode, - isGlobal, - config, - searchSpaceId, - createConfig, - updateConfig, - updatePreferences, - onOpenChange, - ] - ); - - const handleUseGlobalConfig = useCallback(async () => { - if (!config || !isGlobal) return; - setIsSubmitting(true); - try { - await updatePreferences({ - search_space_id: searchSpaceId, - data: { - agent_llm_id: config.id, - }, - }); - toast.success(`Now using ${config.name}`); - onOpenChange(false); - } catch (error) { - console.error("Failed to set model:", error); - toast.error("Failed to set model"); - } finally { - setIsSubmitting(false); - } - }, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]); - - if (!mounted) return null; - - const dialogContent = ( - - {open && ( - <> - {/* Backdrop */} - onOpenChange(false)} - /> - - {/* Dialog */} - -
e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === "Escape") onOpenChange(false); - }} - > - {/* Header */} -
-
-
-

{getTitle()}

- {isAutoMode && ( - - Recommended - - )} - {isGlobal && !isAutoMode && mode !== "create" && ( - - Global - - )} - {!isGlobal && mode !== "create" && !isAutoMode && ( - - Custom - - )} -
-

{getSubtitle()}

- {config && !isAutoMode && mode !== "create" && ( -

- {config.model_name} -

- )} -
- -
- - {/* Scrollable content */} -
- {isAutoMode && ( - - - Auto mode automatically distributes requests across all available LLM - providers to optimize performance and avoid rate limits. - - - )} - - {isGlobal && !isAutoMode && mode !== "create" && ( - - - - Global configurations are read-only. To customize settings, create a new - configuration based on this template. - - - )} - - {mode === "create" ? ( - - ) : isAutoMode && config ? ( -
-
-
-
- How It Works -
-

{config.description}

-
- -
- -
-
- Key Benefits -
-
-
- -
-

- Automatic (Fastest) -

-

- Distributes requests across all configured LLM providers -

-
-
-
- -
-

- Rate Limit Protection -

-

- Automatically handles rate limits with cooldowns and retries -

-
-
-
- -
-

- Automatic Failover -

-

- Falls back to other providers if one becomes unavailable -

-
-
-
-
-
-
- ) : isGlobal && config ? ( -
-
-
-
-
- Configuration Name -
-

{config.name}

-
- {config.description && ( -
-
- Description -
-

{config.description}

-
- )} -
- -
- -
-
-
- Provider -
-

{config.provider}

-
-
-
- Model -
-

{config.model_name}

-
-
- -
- -
-
-
- Citations -
- - {config.citations_enabled ? "Enabled" : "Disabled"} - -
-
- - {config.system_instructions && ( - <> -
-
-
- System Instructions -
-
-

- {config.system_instructions} -

-
-
- - )} -
-
- ) : config ? ( - - ) : null} -
- - {/* Fixed footer */} -
- - {mode === "create" || (!isGlobal && !isAutoMode && config) ? ( - - ) : isAutoMode ? ( - - ) : isGlobal && config ? ( - - ) : null} -
-
- - - )} - - ); - - return typeof document !== "undefined" ? createPortal(dialogContent, document.body) : null; -} diff --git a/surfsense_web/components/settings/model-config-manager.tsx b/surfsense_web/components/settings/model-config-manager.tsx index 64bd7455f..a20086492 100644 --- a/surfsense_web/components/settings/model-config-manager.tsx +++ b/surfsense_web/components/settings/model-config-manager.tsx @@ -12,18 +12,16 @@ import { Trash2, Wand2, } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; import { - createNewLLMConfigMutationAtom, deleteNewLLMConfigMutationAtom, - updateNewLLMConfigMutationAtom, } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms"; import { globalNewLLMConfigsAtom, newLLMConfigsAtom, } from "@/atoms/new-llm-config/new-llm-config-query.atoms"; -import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form"; +import { ModelConfigDialog } from "@/components/shared/model-config-dialog"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { AlertDialog, @@ -39,13 +37,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; @@ -69,12 +60,6 @@ function getInitials(name: string): string { export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) { const isDesktop = useMediaQuery("(min-width: 768px)"); // Mutations - const { mutateAsync: createConfig, isPending: isCreating } = useAtomValue( - createNewLLMConfigMutationAtom - ); - const { mutateAsync: updateConfig, isPending: isUpdating } = useAtomValue( - updateNewLLMConfigMutationAtom - ); const { mutateAsync: deleteConfig, isPending: isDeleting } = useAtomValue( deleteNewLLMConfigMutationAtom ); @@ -128,29 +113,6 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) { const [editingConfig, setEditingConfig] = useState(null); const [configToDelete, setConfigToDelete] = useState(null); - const isSubmitting = isCreating || isUpdating; - - const handleFormSubmit = useCallback( - async (formData: LLMConfigFormData) => { - try { - if (editingConfig) { - const { search_space_id, ...updateData } = formData; - await updateConfig({ - id: editingConfig.id, - data: updateData, - }); - } else { - await createConfig(formData); - } - setIsDialogOpen(false); - setEditingConfig(null); - } catch { - // Error is displayed inside the dialog by the form - } - }, - [editingConfig, createConfig, updateConfig] - ); - const handleDelete = async () => { if (!configToDelete) return; try { @@ -171,11 +133,6 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) { setIsDialogOpen(true); }; - const closeDialog = () => { - setIsDialogOpen(false); - setEditingConfig(null); - }; - return (
{/* Header actions */} @@ -457,54 +414,17 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) { )} {/* Add/Edit Configuration Dialog */} - !open && closeDialog()}> - e.preventDefault()} - > - - - {editingConfig ? "Edit Configuration" : "Create New Configuration"} - - - {editingConfig - ? "Update your AI model and prompt configuration" - : "Set up a new AI model with custom prompts and citation settings"} - - - - - - + { + setIsDialogOpen(open); + if (!open) setEditingConfig(null); + }} + config={editingConfig} + isGlobal={false} + searchSpaceId={searchSpaceId} + mode={editingConfig ? "edit" : "create"} + /> {/* Delete Confirmation Dialog */} void; + config: NewLLMConfigPublic | GlobalNewLLMConfig | null; + isGlobal: boolean; + searchSpaceId: number; + mode: "create" | "edit" | "view"; +} + +export function ModelConfigDialog({ + open, + onOpenChange, + config, + isGlobal, + searchSpaceId, + mode, +}: ModelConfigDialogProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top"); + const scrollRef = useRef(null); + + const handleScroll = useCallback((e: React.UIEvent) => { + const el = e.currentTarget; + const atTop = el.scrollTop <= 2; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; + setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); + }, []); + + const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom); + const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom); + const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); + + const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode; + + const getTitle = () => { + if (mode === "create") return "Add New Configuration"; + if (isAutoMode) return "Auto Mode (Fastest)"; + if (isGlobal) return "View Global Configuration"; + return "Edit Configuration"; + }; + + const getSubtitle = () => { + if (mode === "create") return "Set up a new LLM provider for this search space"; + if (isAutoMode) return "Automatically routes requests across providers"; + if (isGlobal) return "Read-only global configuration"; + return "Update your configuration settings"; + }; + + const handleSubmit = useCallback( + async (data: LLMConfigFormData) => { + setIsSubmitting(true); + try { + if (mode === "create") { + const result = await createConfig({ + ...data, + search_space_id: searchSpaceId, + }); + + if (result?.id) { + await updatePreferences({ + search_space_id: searchSpaceId, + data: { + agent_llm_id: result.id, + }, + }); + } + + onOpenChange(false); + } else if (!isGlobal && config) { + await updateConfig({ + id: config.id, + data: { + name: data.name, + description: data.description, + provider: data.provider, + custom_provider: data.custom_provider, + model_name: data.model_name, + api_key: data.api_key, + api_base: data.api_base, + litellm_params: data.litellm_params, + system_instructions: data.system_instructions, + use_default_system_instructions: data.use_default_system_instructions, + citations_enabled: data.citations_enabled, + }, + }); + onOpenChange(false); + } + } catch (error) { + console.error("Failed to save configuration:", error); + } finally { + setIsSubmitting(false); + } + }, + [ + mode, + isGlobal, + config, + searchSpaceId, + createConfig, + updateConfig, + updatePreferences, + onOpenChange, + ] + ); + + const handleUseGlobalConfig = useCallback(async () => { + if (!config || !isGlobal) return; + setIsSubmitting(true); + try { + await updatePreferences({ + search_space_id: searchSpaceId, + data: { + agent_llm_id: config.id, + }, + }); + toast.success(`Now using ${config.name}`); + onOpenChange(false); + } catch (error) { + console.error("Failed to set model:", error); + } finally { + setIsSubmitting(false); + } + }, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]); + + return ( + + e.preventDefault()} + > + {getTitle()} + + {/* Header */} +
+
+
+

{getTitle()}

+ {isAutoMode && ( + + Recommended + + )} + {isGlobal && !isAutoMode && mode !== "create" && ( + + Global + + )} + {!isGlobal && mode !== "create" && !isAutoMode && ( + + Custom + + )} +
+

{getSubtitle()}

+ {config && !isAutoMode && mode !== "create" && ( +

+ {config.model_name} +

+ )} +
+
+ + {/* Scrollable content */} +
+ {isAutoMode && ( + + + Auto mode automatically distributes requests across all available LLM + providers to optimize performance and avoid rate limits. + + + )} + + {isGlobal && !isAutoMode && mode !== "create" && ( + + + + Global configurations are read-only. To customize settings, create a new + configuration based on this template. + + + )} + + {mode === "create" ? ( + + ) : isAutoMode && config ? ( +
+
+
+
+ How It Works +
+

{config.description}

+
+ +
+ +
+
+ Key Benefits +
+
+
+ +
+

+ Automatic (Fastest) +

+

+ Distributes requests across all configured LLM providers +

+
+
+
+ +
+

+ Rate Limit Protection +

+

+ Automatically handles rate limits with cooldowns and retries +

+
+
+
+ +
+

+ Automatic Failover +

+

+ Falls back to other providers if one becomes unavailable +

+
+
+
+
+
+
+ ) : isGlobal && config ? ( +
+
+
+
+
+ Configuration Name +
+

{config.name}

+
+ {config.description && ( +
+
+ Description +
+

{config.description}

+
+ )} +
+ +
+ +
+
+
+ Provider +
+

{config.provider}

+
+
+
+ Model +
+

{config.model_name}

+
+
+ +
+ +
+
+
+ Citations +
+ + {config.citations_enabled ? "Enabled" : "Disabled"} + +
+
+ + {config.system_instructions && ( + <> +
+
+
+ System Instructions +
+
+

+ {config.system_instructions} +

+
+
+ + )} +
+
+ ) : config ? ( + + ) : null} +
+ + {/* Fixed footer */} +
+ + {mode === "create" || (!isGlobal && !isAutoMode && config) ? ( + + ) : isAutoMode ? ( + + ) : isGlobal && config ? ( + + ) : null} +
+ +
+ ); +} From 32ff5f085c17b736839a3d4e936a17fe73901a1a Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:25:45 +0530 Subject: [PATCH 08/24] refactor: simplify onboarding page logic by temporarily disabling auto-configuration and redirect features for UI testing --- .../[search_space_id]/client-layout.tsx | 4 + .../[search_space_id]/onboard/page.tsx | 173 ++++++------------ 2 files changed, 57 insertions(+), 120 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index 25e4e990b..1715e525f 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -183,6 +183,10 @@ export function DashboardClientLayout({ ); } + if (isOnboardingPage) { + return <>{children}; + } + return ( 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 b188d7c8f..1d3ff3cfd 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx @@ -1,7 +1,6 @@ "use client"; -import { useAtomValue, useSetAtom } from "jotai"; -import { motion } from "motion/react"; +import { useAtomValue } from "jotai"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; @@ -13,10 +12,9 @@ import { globalNewLLMConfigsAtom, llmPreferencesAtom, } from "@/atoms/new-llm-config/new-llm-config-query.atoms"; -import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; import { Logo } from "@/components/Logo"; import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { getBearerToken, redirectToLogin } from "@/lib/auth-utils"; @@ -24,8 +22,6 @@ export default function OnboardPage() { const router = useRouter(); const params = useParams(); const searchSpaceId = Number(params.search_space_id); - const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom); - // Queries const { data: globalConfigs = [], @@ -62,14 +58,12 @@ export default function OnboardPage() { preferences.document_summary_llm_id !== null && preferences.document_summary_llm_id !== undefined; - // If onboarding is already complete, redirect immediately useEffect(() => { if (!preferencesLoading && isOnboardingComplete) { router.push(`/dashboard/${searchSpaceId}/new-chat`); } }, [preferencesLoading, isOnboardingComplete, router, searchSpaceId]); - // Auto-configure if global configs are available useEffect(() => { const autoConfigureWithGlobal = async () => { if (hasAttemptedAutoConfig.current) return; @@ -77,7 +71,6 @@ export default function OnboardPage() { if (!globalConfigsLoaded) return; if (isOnboardingComplete) return; - // Only auto-configure if we have global configs if (globalConfigs.length > 0) { hasAttemptedAutoConfig.current = true; setIsAutoConfiguring(true); @@ -97,7 +90,6 @@ export default function OnboardPage() { description: `Using ${firstGlobalConfig.name}. You can customize this later in Settings.`, }); - // Redirect to new-chat router.push(`/dashboard/${searchSpaceId}/new-chat`); } catch (error) { console.error("Auto-configuration failed:", error); @@ -119,13 +111,10 @@ export default function OnboardPage() { router, ]); - // Handle form submission const handleSubmit = async (formData: LLMConfigFormData) => { try { - // Create the config const newConfig = await createConfig(formData); - // Auto-assign to all roles await updatePreferences({ search_space_id: searchSpaceId, data: { @@ -138,7 +127,6 @@ export default function OnboardPage() { description: "Redirecting to chat...", }); - // Redirect to new-chat router.push(`/dashboard/${searchSpaceId}/new-chat`); } catch (error) { console.error("Failed to create config:", error); @@ -150,124 +138,69 @@ export default function OnboardPage() { const isSubmitting = isCreating || isUpdatingPreferences; - // Loading state if (globalConfigsLoading || preferencesLoading || isAutoConfiguring) { return ( -
- -
-
-
- -
-
-
-

- {isAutoConfiguring ? "Setting up your AI..." : "Loading..."} -

-

- {isAutoConfiguring - ? "Auto-configuring with available settings" - : "Please wait while we check your configuration"} -

-
-
- {[0, 1, 2].map((i) => ( - - ))} -
- +
+
+ +

+ {isAutoConfiguring ? "Setting up your AI..." : "Loading..."} +

+
); } - // If global configs exist but auto-config failed, show simple message if (globalConfigs.length > 0 && !isAutoConfiguring) { - return null; // Will redirect via useEffect + return null; } - // No global configs - show the config form return ( -
-
- - {/* Header */} -
- - - - -
-

Configure Your AI

-

- Add your LLM provider to get started with SurfSense -

-
+
+
+ {/* Header */} +
+ +
+

Configure Your AI

+

+ Add your LLM provider to get started with SurfSense +

- - {/* Config Form */} - - - - LLM Configuration - - - - - - - - {/* Footer note */} - + + {/* Form card */} +
+ +
+ + {/* Footer */} +
+ - - + Start Using SurfSense + {isSubmitting && } + +

+ You can add more configurations later +

+
); From ba926bbcc97516a3dce73eb48a3d9d04ab5f7f34 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:46:01 +0530 Subject: [PATCH 09/24] refactor: integrate global loading effect into onboarding page and streamline LLMConfigForm usage for improved user experience --- .../[search_space_id]/onboard/page.tsx | 33 ++-- .../components/new-chat/model-selector.tsx | 2 +- .../components/shared/llm-config-form.tsx | 154 +++++------------- .../components/shared/model-config-dialog.tsx | 22 +-- 4 files changed, 67 insertions(+), 144 deletions(-) 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 1d3ff3cfd..5e3f1cf7b 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx @@ -16,6 +16,7 @@ import { Logo } from "@/components/Logo"; import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; +import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; import { getBearerToken, redirectToLogin } from "@/lib/auth-utils"; export default function OnboardPage() { @@ -138,17 +139,11 @@ export default function OnboardPage() { const isSubmitting = isCreating || isUpdatingPreferences; - if (globalConfigsLoading || preferencesLoading || isAutoConfiguring) { - return ( -
-
- -

- {isAutoConfiguring ? "Setting up your AI..." : "Loading..."} -

-
-
- ); + const isLoading = globalConfigsLoading || preferencesLoading || isAutoConfiguring; + useGlobalLoadingEffect(isLoading); + + if (isLoading) { + return null; } if (globalConfigs.length > 0 && !isAutoConfiguring) { @@ -171,15 +166,13 @@ export default function OnboardPage() { {/* Form card */}
- - Add New Configuration + Add LLM Model
diff --git a/surfsense_web/components/shared/llm-config-form.tsx b/surfsense_web/components/shared/llm-config-form.tsx index 38c67cfa6..9fb8b9208 100644 --- a/surfsense_web/components/shared/llm-config-form.tsx +++ b/surfsense_web/components/shared/llm-config-form.tsx @@ -3,9 +3,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useAtomValue } from "jotai"; import { Check, ChevronDown, ChevronsUpDown } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; import { useEffect, useMemo, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useForm, type Resolver } from "react-hook-form"; import { z } from "zod"; import { defaultSystemInstructionsAtom, @@ -41,7 +40,6 @@ import { SelectValue, } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; -import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers"; @@ -73,28 +71,18 @@ interface LLMConfigFormProps { initialData?: Partial; searchSpaceId: number; onSubmit: (data: LLMConfigFormData) => Promise; - onCancel?: () => void; - isSubmitting?: boolean; mode?: "create" | "edit"; - submitLabel?: string; showAdvanced?: boolean; - compact?: boolean; formId?: string; - hideActions?: boolean; } export function LLMConfigForm({ initialData, searchSpaceId, onSubmit, - onCancel, - isSubmitting = false, mode = "create", - submitLabel, showAdvanced = true, - compact = false, formId, - hideActions = false, }: LLMConfigFormProps) { const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue( defaultSystemInstructionsAtom @@ -105,8 +93,7 @@ export function LLMConfigForm({ const [systemInstructionsOpen, setSystemInstructionsOpen] = useState(false); const form = useForm({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - resolver: zodResolver(formSchema) as any, + resolver: zodResolver(formSchema) as Resolver, defaultValues: { name: initialData?.name ?? "", description: initialData?.description ?? "", @@ -232,34 +219,26 @@ export function LLMConfigForm({ )} /> - {/* Custom Provider (conditional) */} - - {watchProvider === "CUSTOM" && ( - - ( - - Custom Provider Name - - - - - - )} - /> - + {/* Custom Provider (conditional) */} + {watchProvider === "CUSTOM" && ( + ( + + Custom Provider Name + + + + + )} - + /> + )} {/* Model Name with Combobox */}
- {/* Ollama Quick Actions */} - - {watchProvider === "OLLAMA" && ( - - - - - )} - + {/* Ollama Quick Actions */} + {watchProvider === "OLLAMA" && ( +
+ + +
+ )}
{/* Advanced Parameters */} @@ -554,44 +526,6 @@ export function LLMConfigForm({ /> - - {!hideActions && ( -
- {onCancel && ( - - )} - -
- )} ); diff --git a/surfsense_web/components/shared/model-config-dialog.tsx b/surfsense_web/components/shared/model-config-dialog.tsx index f4ea9ba2f..4dff5f173 100644 --- a/surfsense_web/components/shared/model-config-dialog.tsx +++ b/surfsense_web/components/shared/model-config-dialog.tsx @@ -213,14 +213,12 @@ export function ModelConfigDialog({ )} {mode === "create" ? ( - + ) : isAutoMode && config ? (
@@ -362,11 +360,9 @@ export function ModelConfigDialog({ citations_enabled: config.citations_enabled, search_space_id: searchSpaceId, }} - onSubmit={handleSubmit} - isSubmitting={isSubmitting} - mode="edit" - formId="model-config-form" - hideActions + onSubmit={handleSubmit} + mode="edit" + formId="model-config-form" /> ) : null}
From a5f41cfd8e866b55fa8105cc6cc95af607a685db Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:04:09 +0530 Subject: [PATCH 10/24] refactor: update LLM configuration terminology and enhance user feedback messages for improved clarity --- docs/chinese-llm-setup.md | 2 +- .../new-llm-config-mutation.atoms.ts | 22 +++---- .../settings/model-config-manager.tsx | 30 +++++----- .../components/shared/model-config-dialog.tsx | 58 +++++++++---------- 4 files changed, 53 insertions(+), 59 deletions(-) diff --git a/docs/chinese-llm-setup.md b/docs/chinese-llm-setup.md index 37042aa2f..1fb0ce2a1 100644 --- a/docs/chinese-llm-setup.md +++ b/docs/chinese-llm-setup.md @@ -24,7 +24,7 @@ SurfSense 现已支持以下国产 LLM: 1. 登录 SurfSense Dashboard 2. 进入 **Settings** → **API Keys** (或 **LLM Configurations**) -3. 点击 **Add New Configuration** +3. 点击 **Add LLM Model** 4. 从 **Provider** 下拉菜单中选择你的国产 LLM 提供商 5. 填写必填字段(见下方各提供商详细配置) 6. 点击 **Save** diff --git a/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts b/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts index b3b9b2bab..00156b844 100644 --- a/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts +++ b/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts @@ -2,7 +2,9 @@ import { atomWithMutation } from "jotai-tanstack-query"; import { toast } from "sonner"; import type { CreateNewLLMConfigRequest, + CreateNewLLMConfigResponse, DeleteNewLLMConfigRequest, + DeleteNewLLMConfigResponse, GetNewLLMConfigsResponse, UpdateLLMPreferencesRequest, UpdateNewLLMConfigRequest, @@ -25,14 +27,14 @@ export const createNewLLMConfigMutationAtom = atomWithMutation((get) => { mutationFn: async (request: CreateNewLLMConfigRequest) => { return newLLMConfigApiService.createConfig(request); }, - onSuccess: () => { - toast.success("LLM model created"); + onSuccess: (_: CreateNewLLMConfigResponse, request: CreateNewLLMConfigRequest) => { + toast.success(`${request.name} created`); queryClient.invalidateQueries({ queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)), }); }, onError: (error: Error) => { - toast.error(error.message || "Failed to create configuration"); + toast.error(error.message || "Failed to create LLM model"); }, }; }); @@ -50,7 +52,7 @@ export const updateNewLLMConfigMutationAtom = atomWithMutation((get) => { return newLLMConfigApiService.updateConfig(request); }, onSuccess: (_: UpdateNewLLMConfigResponse, request: UpdateNewLLMConfigRequest) => { - toast.success("LLM model updated"); + toast.success(`${request.data.name ?? "Configuration"} updated`); queryClient.invalidateQueries({ queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)), }); @@ -59,7 +61,7 @@ export const updateNewLLMConfigMutationAtom = atomWithMutation((get) => { }); }, onError: (error: Error) => { - toast.error(error.message || "Failed to update configuration"); + toast.error(error.message || "Failed to update"); }, }; }); @@ -73,11 +75,11 @@ export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => { return { mutationKey: ["new-llm-configs", "delete"], enabled: !!searchSpaceId, - mutationFn: async (request: DeleteNewLLMConfigRequest) => { - return newLLMConfigApiService.deleteConfig(request); + mutationFn: async (request: DeleteNewLLMConfigRequest & { name: string }) => { + return newLLMConfigApiService.deleteConfig({ id: request.id }); }, - onSuccess: (_, request: DeleteNewLLMConfigRequest) => { - toast.success("LLM model deleted"); + onSuccess: (_: DeleteNewLLMConfigResponse, request: DeleteNewLLMConfigRequest & { name: string }) => { + toast.success(`${request.name} deleted`); queryClient.setQueryData( cacheKeys.newLLMConfigs.all(Number(searchSpaceId)), (oldData: GetNewLLMConfigsResponse | undefined) => { @@ -87,7 +89,7 @@ export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => { ); }, onError: (error: Error) => { - toast.error(error.message || "Failed to delete configuration"); + toast.error(error.message || "Failed to delete"); }, }; }); diff --git a/surfsense_web/components/settings/model-config-manager.tsx b/surfsense_web/components/settings/model-config-manager.tsx index a20086492..409aa4f3c 100644 --- a/surfsense_web/components/settings/model-config-manager.tsx +++ b/surfsense_web/components/settings/model-config-manager.tsx @@ -116,7 +116,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) { const handleDelete = async () => { if (!configToDelete) return; try { - await deleteConfig({ id: configToDelete.id }); + await deleteConfig({ id: configToDelete.id, name: configToDelete.name }); setConfigToDelete(null); } catch { // Error handled by mutation state @@ -431,12 +431,11 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) { open={!!configToDelete} onOpenChange={(open) => !open && setConfigToDelete(null)} > - + - - - Delete Configuration - + + Delete LLM Model + Are you sure you want to delete{" "} {configToDelete?.name}? This @@ -450,17 +449,14 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) { disabled={isDeleting} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - {isDeleting ? ( - <> - - Deleting - - ) : ( - <> - - Delete - - )} + {isDeleting ? ( + <> + + Deleting + + ) : ( + "Delete" + )} diff --git a/surfsense_web/components/shared/model-config-dialog.tsx b/surfsense_web/components/shared/model-config-dialog.tsx index 4dff5f173..f786ee2c1 100644 --- a/surfsense_web/components/shared/model-config-dialog.tsx +++ b/surfsense_web/components/shared/model-config-dialog.tsx @@ -379,39 +379,35 @@ export function ModelConfigDialog({ Cancel {mode === "create" || (!isGlobal && !isAutoMode && config) ? ( - + ) : isAutoMode ? ( - + ) : isGlobal && config ? ( - + ) : null}
From 4a05229476eb2c5daae09f72701dbccc1bcf8704 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:14:46 +0530 Subject: [PATCH 11/24] refactor: enhance image generation configuration handling and user feedback messages for improved clarity and consistency --- .../image-gen-config-mutation.atoms.ts | 18 +++--- .../settings/image-model-manager.tsx | 38 +++++-------- .../components/shared/image-config-dialog.tsx | 56 +++++++++---------- 3 files changed, 50 insertions(+), 62 deletions(-) diff --git a/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts b/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts index dd8bcd324..362c3a690 100644 --- a/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts +++ b/surfsense_web/atoms/image-gen-config/image-gen-config-mutation.atoms.ts @@ -2,6 +2,8 @@ import { atomWithMutation } from "jotai-tanstack-query"; import { toast } from "sonner"; import type { CreateImageGenConfigRequest, + CreateImageGenConfigResponse, + DeleteImageGenConfigResponse, GetImageGenConfigsResponse, UpdateImageGenConfigRequest, UpdateImageGenConfigResponse, @@ -23,8 +25,8 @@ export const createImageGenConfigMutationAtom = atomWithMutation((get) => { mutationFn: async (request: CreateImageGenConfigRequest) => { return imageGenConfigApiService.createConfig(request); }, - onSuccess: () => { - toast.success("Image model created"); + onSuccess: (_: CreateImageGenConfigResponse, request: CreateImageGenConfigRequest) => { + toast.success(`${request.name} created`); queryClient.invalidateQueries({ queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)), }); @@ -48,7 +50,7 @@ export const updateImageGenConfigMutationAtom = atomWithMutation((get) => { return imageGenConfigApiService.updateConfig(request); }, onSuccess: (_: UpdateImageGenConfigResponse, request: UpdateImageGenConfigRequest) => { - toast.success("Image model updated"); + toast.success(`${request.data.name ?? "Configuration"} updated`); queryClient.invalidateQueries({ queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)), }); @@ -71,16 +73,16 @@ export const deleteImageGenConfigMutationAtom = atomWithMutation((get) => { return { mutationKey: ["image-gen-configs", "delete"], enabled: !!searchSpaceId, - mutationFn: async (id: number) => { - return imageGenConfigApiService.deleteConfig(id); + mutationFn: async (request: { id: number; name: string }) => { + return imageGenConfigApiService.deleteConfig(request.id); }, - onSuccess: (_, id: number) => { - toast.success("Image model deleted"); + onSuccess: (_: DeleteImageGenConfigResponse, request: { id: number; name: string }) => { + toast.success(`${request.name} deleted`); queryClient.setQueryData( cacheKeys.imageGenConfigs.all(Number(searchSpaceId)), (oldData: GetImageGenConfigsResponse | undefined) => { if (!oldData) return oldData; - return oldData.filter((config) => config.id !== id); + return oldData.filter((config) => config.id !== request.id); } ); }, diff --git a/surfsense_web/components/settings/image-model-manager.tsx b/surfsense_web/components/settings/image-model-manager.tsx index 1c8394c9c..b5a98dcbe 100644 --- a/surfsense_web/components/settings/image-model-manager.tsx +++ b/surfsense_web/components/settings/image-model-manager.tsx @@ -121,7 +121,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) { const handleDelete = async () => { if (!configToDelete) return; try { - await deleteConfig(configToDelete.id); + await deleteConfig({ id: configToDelete.id, name: configToDelete.name }); setConfigToDelete(null); } catch { // Error handled by mutation @@ -398,12 +398,11 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) { open={!!configToDelete} onOpenChange={(open) => !open && setConfigToDelete(null)} > - - - - - Delete Image Model - + + + + Delete Image Model + Are you sure you want to delete{" "} {configToDelete?.name}? @@ -411,23 +410,14 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) { Cancel - - {isDeleting ? ( - <> - - Deleting - - ) : ( - <> - - Delete - - )} - + + Delete + {isDeleting && } + diff --git a/surfsense_web/components/shared/image-config-dialog.tsx b/surfsense_web/components/shared/image-config-dialog.tsx index 4a9c3862a..a364b6a64 100644 --- a/surfsense_web/components/shared/image-config-dialog.tsx +++ b/surfsense_web/components/shared/image-config-dialog.tsx @@ -460,38 +460,34 @@ export function ImageConfigDialog({ Cancel {mode === "create" || (mode === "edit" && !isGlobal) ? ( - + ) : isAutoMode ? ( - + ) : isGlobal && config ? ( - + ) : null}
From b5cc45e819acc8469fc0ce2a1f07fe3a6a023303 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:22:56 +0530 Subject: [PATCH 12/24] refactor: streamline image and model configuration dialogs by removing auto mode references for improved clarity and consistency --- .../components/shared/image-config-dialog.tsx | 35 +------ .../components/shared/model-config-dialog.tsx | 97 ++----------------- 2 files changed, 12 insertions(+), 120 deletions(-) diff --git a/surfsense_web/components/shared/image-config-dialog.tsx b/surfsense_web/components/shared/image-config-dialog.tsx index a364b6a64..fe8a73df6 100644 --- a/surfsense_web/components/shared/image-config-dialog.tsx +++ b/surfsense_web/components/shared/image-config-dialog.tsx @@ -104,8 +104,6 @@ export function ImageConfigDialog({ setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); }, []); - const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode; - const suggestedModels = useMemo(() => { if (!formData.provider) return []; return IMAGE_GEN_MODELS.filter((m) => m.provider === formData.provider); @@ -113,14 +111,12 @@ export function ImageConfigDialog({ const getTitle = () => { if (mode === "create") return "Add Image Model"; - if (isAutoMode) return "Auto Mode (Fastest)"; if (isGlobal) return "View Global Image Model"; return "Edit Image Model"; }; const getSubtitle = () => { if (mode === "create") return "Set up a new image generation provider"; - if (isAutoMode) return "Automatically routes requests across providers"; if (isGlobal) return "Read-only global configuration"; return "Update your image model settings"; }; @@ -213,19 +209,14 @@ export function ImageConfigDialog({

{getTitle()}

- {isAutoMode && ( - - Recommended - - )} - {isGlobal && !isAutoMode && mode !== "create" && ( + {isGlobal && mode !== "create" && ( Global )}

{getSubtitle()}

- {config && !isAutoMode && mode !== "create" && ( + {config && mode !== "create" && (

{config.model_name}

@@ -243,16 +234,7 @@ export function ImageConfigDialog({ WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`, }} > - {isAutoMode && ( - - - Auto mode distributes image generation requests across all configured - providers for optimal performance and rate limit protection. - - - )} - - {isGlobal && !isAutoMode && config && ( + {isGlobal && config && ( <> @@ -470,16 +452,7 @@ export function ImageConfigDialog({ {isSubmitting && } - ) : isAutoMode ? ( - - ) : isGlobal && config ? ( + ) : isGlobal && config ? ( - {mode === "create" || (!isGlobal && !isAutoMode && config) ? ( + {mode === "create" || (!isGlobal && config) ? ( - ) : isAutoMode ? ( - - ) : isGlobal && config ? ( + ) : isGlobal && config ? ( - - Expand panel - - )}
); diff --git a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx index 90cdde127..b92430607 100644 --- a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx +++ b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx @@ -55,7 +55,7 @@ export function RightPanelExpandButton() { if (!collapsed || !hasContent) return null; return ( -
+
); })} + {onNewChat && ( +
+ +
+ )}
- - {onNewChat && ( - - )}
); } From b54aa517a8446c081402919c63b6125bffaf88a2 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:34:41 +0530 Subject: [PATCH 14/24] refactor: implement chat tab removal functionality and enhance tab title update logic for improved user experience --- surfsense_web/atoms/tabs/tabs.atom.ts | 43 +++++++++++++++++++ .../layout/providers/LayoutDataProvider.tsx | 8 +++- .../ui/sidebar/AllPrivateChatsSidebar.tsx | 6 ++- .../ui/sidebar/AllSharedChatsSidebar.tsx | 6 ++- 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/surfsense_web/atoms/tabs/tabs.atom.ts b/surfsense_web/atoms/tabs/tabs.atom.ts index 7ba115a95..bb747f032 100644 --- a/surfsense_web/atoms/tabs/tabs.atom.ts +++ b/surfsense_web/atoms/tabs/tabs.atom.ts @@ -128,6 +128,21 @@ export const updateChatTabTitleAtom = atom( (get, set, { chatId, title }: { chatId: number; title: string }) => { const state = get(tabsStateAtom); const tabId = makeChatTabId(chatId); + const hasExactTab = state.tabs.some((t) => t.id === tabId); + + // During lazy thread creation, title updates can arrive before "chat-new" + // is swapped to chat-{id}. In that case, promote the active "chat-new" tab. + if (!hasExactTab && state.activeTabId === "chat-new") { + set(tabsStateAtom, { + ...state, + activeTabId: tabId, + tabs: state.tabs.map((t) => + t.id === "chat-new" ? { ...t, id: tabId, chatId, title } : t + ), + }); + return; + } + set(tabsStateAtom, { ...state, tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, title } : t)), @@ -213,6 +228,34 @@ export const closeTabAtom = atom(null, (get, set, tabId: string) => { return remaining.find((t) => t.id === newActiveId) ?? null; }); +/** Remove a chat tab by chat ID (used when a chat is deleted). */ +export const removeChatTabAtom = atom(null, (get, set, chatId: number) => { + const state = get(tabsStateAtom); + const tabId = makeChatTabId(chatId); + const idx = state.tabs.findIndex((t) => t.id === tabId); + if (idx === -1) return null; + + const remaining = state.tabs.filter((t) => t.id !== tabId); + + // Always keep at least one tab available. + if (remaining.length === 0) { + set(tabsStateAtom, { + tabs: [INITIAL_CHAT_TAB], + activeTabId: "chat-new", + }); + return INITIAL_CHAT_TAB; + } + + let newActiveId = state.activeTabId; + if (state.activeTabId === tabId) { + const newIdx = Math.min(idx, remaining.length - 1); + newActiveId = remaining[newIdx].id; + } + + set(tabsStateAtom, { tabs: remaining, activeTabId: newActiveId }); + return remaining.find((t) => t.id === newActiveId) ?? null; +}); + /** Reset tabs when switching search spaces. */ export const resetTabsAtom = atom(null, (_get, set) => { set(tabsStateAtom, { ...initialState }); diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 57a0f89cf..f611c1861 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -20,7 +20,7 @@ import { teamDialogAtom, userSettingsDialogAtom, } from "@/atoms/settings/settings-dialog.atoms"; -import { resetTabsAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; +import { removeChatTabAtom, resetTabsAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { MorePagesDialog } from "@/components/settings/more-pages-dialog"; import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog"; @@ -103,6 +103,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const resetCurrentThread = useSetAtom(resetCurrentThreadAtom); const syncChatTab = useSetAtom(syncChatTabAtom); const resetTabs = useSetAtom(resetTabsAtom); + const removeChatTab = useSetAtom(removeChatTabAtom); // State for handling new chat navigation when router is out of sync const [pendingNewChat, setPendingNewChat] = useState(false); @@ -325,7 +326,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const thread = threadsData?.threads?.find((t) => t.id === chatId); syncChatTab({ chatId, - title: thread?.title || (chatId ? `Chat ${chatId}` : "New Chat"), + // Avoid overwriting live SSE-updated tab titles with fallback values. + title: chatId ? (thread?.title ?? undefined) : "New Chat", chatUrl, }); }, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]); @@ -637,6 +639,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid setIsDeletingChat(true); try { await deleteThread(chatToDelete.id); + removeChatTab(chatToDelete.id); queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); if (currentChatId === chatToDelete.id) { resetCurrentThread(); @@ -664,6 +667,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid currentThreadState.id, params?.chat_id, router, + removeChatTab, ]); // Rename handler diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx index b00f701c4..4a359859a 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx @@ -1,6 +1,7 @@ "use client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSetAtom } from "jotai"; import { format } from "date-fns"; import { ArchiveIcon, @@ -41,6 +42,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useLongPress } from "@/hooks/use-long-press"; import { useIsMobile } from "@/hooks/use-mobile"; +import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom"; import { deleteThread, fetchThreads, @@ -70,6 +72,7 @@ export function AllPrivateChatsSidebarContent({ const params = useParams(); const queryClient = useQueryClient(); const isMobile = useIsMobile(); + const removeChatTab = useSetAtom(removeChatTabAtom); const currentChatId = Array.isArray(params.chat_id) ? Number(params.chat_id[0]) @@ -158,6 +161,7 @@ export function AllPrivateChatsSidebarContent({ setDeletingThreadId(threadId); try { await deleteThread(threadId); + removeChatTab(threadId); toast.success(t("chat_deleted") || "Chat deleted successfully"); queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); @@ -176,7 +180,7 @@ export function AllPrivateChatsSidebarContent({ setDeletingThreadId(null); } }, - [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange] + [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange, removeChatTab] ); const handleToggleArchive = useCallback( diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx index d8289d1e9..6e7828116 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx @@ -1,6 +1,7 @@ "use client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSetAtom } from "jotai"; import { format } from "date-fns"; import { ArchiveIcon, @@ -41,6 +42,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useLongPress } from "@/hooks/use-long-press"; import { useIsMobile } from "@/hooks/use-mobile"; +import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom"; import { deleteThread, fetchThreads, @@ -70,6 +72,7 @@ export function AllSharedChatsSidebarContent({ const params = useParams(); const queryClient = useQueryClient(); const isMobile = useIsMobile(); + const removeChatTab = useSetAtom(removeChatTabAtom); const currentChatId = Array.isArray(params.chat_id) ? Number(params.chat_id[0]) @@ -158,6 +161,7 @@ export function AllSharedChatsSidebarContent({ setDeletingThreadId(threadId); try { await deleteThread(threadId); + removeChatTab(threadId); toast.success(t("chat_deleted") || "Chat deleted successfully"); queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); @@ -176,7 +180,7 @@ export function AllSharedChatsSidebarContent({ setDeletingThreadId(null); } }, - [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange] + [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange, removeChatTab] ); const handleToggleArchive = useCallback( From 69b8eef5ce222cc58c2b11b9c3f10fc39be9d662 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:12:55 +0530 Subject: [PATCH 15/24] refactor: enhance chat tab management by implementing fallback navigation and preventing race conditions during deletion for improved user experience --- .../new-chat/[[...chat_id]]/page.tsx | 16 +++- surfsense_web/atoms/tabs/tabs.atom.ts | 11 +++ .../layout/providers/LayoutDataProvider.tsx | 16 ++-- .../ui/sidebar/AllPrivateChatsSidebar.tsx | 6 +- .../ui/sidebar/AllSharedChatsSidebar.tsx | 6 +- .../layout/ui/sidebar/DocumentsSidebar.tsx | 87 +++---------------- .../components/layout/ui/tabs/TabBar.tsx | 16 ++-- 7 files changed, 65 insertions(+), 93 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 5b35c0284..8fdd9eecb 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -33,7 +33,7 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { type AgentCreatedDocument, agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms"; import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { membersAtom } from "@/atoms/members/members-query.atoms"; -import { updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom"; +import { removeChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps"; import { Thread } from "@/components/assistant-ui/thread"; @@ -70,6 +70,7 @@ import { getThreadMessages, type ThreadRecord, } from "@/lib/chat/thread-persistence"; +import { NotFoundError } from "@/lib/error"; import { trackChatCreated, trackChatError, @@ -194,6 +195,7 @@ export default function NewChatPage() { const closeReportPanel = useSetAtom(closeReportPanelAtom); const closeEditorPanel = useSetAtom(closeEditorPanelAtom); const updateChatTabTitle = useSetAtom(updateChatTabTitleAtom); + const removeChatTab = useSetAtom(removeChatTabAtom); const setAgentCreatedDocuments = useSetAtom(agentCreatedDocumentsAtom); // Get current user for author info in shared chats @@ -323,6 +325,14 @@ export default function NewChatPage() { // This improves UX (instant load) and avoids orphan threads } catch (error) { console.error("[NewChatPage] Failed to initialize thread:", error); + if (urlChatId > 0 && error instanceof NotFoundError) { + removeChatTab(urlChatId); + if (typeof window !== "undefined") { + window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`); + } + toast.error("This chat was deleted."); + return; + } // Keep threadId as null - don't use Date.now() as it creates an invalid ID // that will cause 404 errors on subsequent API calls setThreadId(null); @@ -338,12 +348,14 @@ export default function NewChatPage() { setSidebarDocuments, closeReportPanel, closeEditorPanel, + removeChatTab, + searchSpaceId, ]); // Initialize on mount, and re-init when switching search spaces (even if urlChatId is the same) useEffect(() => { initializeThread(); - }, [initializeThread, searchSpaceId]); + }, [initializeThread]); // Prefetch document titles for @ mention picker // Runs when user lands on page so data is ready when they type @ diff --git a/surfsense_web/atoms/tabs/tabs.atom.ts b/surfsense_web/atoms/tabs/tabs.atom.ts index bb747f032..2d462e4d5 100644 --- a/surfsense_web/atoms/tabs/tabs.atom.ts +++ b/surfsense_web/atoms/tabs/tabs.atom.ts @@ -33,6 +33,9 @@ const initialState: TabsState = { activeTabId: "chat-new", }; +// Prevent race conditions where route-sync recreates a just-deleted chat tab. +const deletedChatIdsAtom = atom>(new Set()); + const sessionStorageAdapter = createJSONStorage( () => (typeof window !== "undefined" ? sessionStorage : undefined) as Storage ); @@ -71,6 +74,10 @@ export const syncChatTabAtom = atom( set, { chatId, title, chatUrl }: { chatId: number | null; title?: string; chatUrl?: string } ) => { + if (chatId && get(deletedChatIdsAtom).has(chatId)) { + return; + } + const state = get(tabsStateAtom); const tabId = makeChatTabId(chatId); const existing = state.tabs.find((t) => t.id === tabId); @@ -235,6 +242,9 @@ export const removeChatTabAtom = atom(null, (get, set, chatId: number) => { const idx = state.tabs.findIndex((t) => t.id === tabId); if (idx === -1) return null; + const deletedChatIds = get(deletedChatIdsAtom); + set(deletedChatIdsAtom, new Set([...deletedChatIds, chatId])); + const remaining = state.tabs.filter((t) => t.id !== tabId); // Always keep at least one tab available. @@ -259,4 +269,5 @@ export const removeChatTabAtom = atom(null, (get, set, chatId: number) => { /** Reset tabs when switching search spaces. */ export const resetTabsAtom = atom(null, (_get, set) => { set(tabsStateAtom, { ...initialState }); + set(deletedChatIdsAtom, new Set()); }); diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index f611c1861..3db53285b 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -639,16 +639,20 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid setIsDeletingChat(true); try { await deleteThread(chatToDelete.id); - removeChatTab(chatToDelete.id); + const fallbackTab = removeChatTab(chatToDelete.id); queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); if (currentChatId === chatToDelete.id) { resetCurrentThread(); - const isOutOfSync = currentThreadState.id !== null && !params?.chat_id; - if (isOutOfSync) { - window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`); - setChatResetKey((k) => k + 1); + if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) { + router.push(fallbackTab.chatUrl); } else { - router.push(`/dashboard/${searchSpaceId}/new-chat`); + const isOutOfSync = currentThreadState.id !== null && !params?.chat_id; + if (isOutOfSync) { + window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`); + setChatResetKey((k) => k + 1); + } else { + router.push(`/dashboard/${searchSpaceId}/new-chat`); + } } } } catch (error) { diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx index 4a359859a..01e397309 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx @@ -161,7 +161,7 @@ export function AllPrivateChatsSidebarContent({ setDeletingThreadId(threadId); try { await deleteThread(threadId); - removeChatTab(threadId); + const fallbackTab = removeChatTab(threadId); toast.success(t("chat_deleted") || "Chat deleted successfully"); queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); @@ -170,6 +170,10 @@ export function AllPrivateChatsSidebarContent({ if (currentChatId === threadId) { onOpenChange(false); setTimeout(() => { + if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) { + router.push(fallbackTab.chatUrl); + return; + } router.push(`/dashboard/${searchSpaceId}/new-chat`); }, 250); } diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx index 6e7828116..b13bf2ba3 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx @@ -161,7 +161,7 @@ export function AllSharedChatsSidebarContent({ setDeletingThreadId(threadId); try { await deleteThread(threadId); - removeChatTab(threadId); + const fallbackTab = removeChatTab(threadId); toast.success(t("chat_deleted") || "Chat deleted successfully"); queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); @@ -170,6 +170,10 @@ export function AllSharedChatsSidebarContent({ if (currentChatId === threadId) { onOpenChange(false); setTimeout(() => { + if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) { + router.push(fallbackTab.chatUrl); + return; + } router.push(`/dashboard/${searchSpaceId}/new-chat`); }, 250); } diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 09f603f0f..2d3cbbe53 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -7,16 +7,15 @@ import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; -import { MarkdownViewer } from "@/components/markdown-viewer"; import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters"; import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom"; import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; +import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms"; import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom"; import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms"; -import { openDocumentTabAtom } from "@/atoms/tabs/tabs.atom"; import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog"; import type { DocumentNodeDoc } from "@/components/documents/DocumentNode"; import type { FolderDisplay } from "@/components/documents/FolderNode"; @@ -35,22 +34,13 @@ import { } from "@/components/ui/alert-dialog"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; -import { - Drawer, - DrawerContent, - DrawerHandle, - DrawerHeader, - DrawerTitle, -} from "@/components/ui/drawer"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useMediaQuery } from "@/hooks/use-media-query"; -import { useIsMobile } from "@/hooks/use-mobile"; import { foldersApiService } from "@/lib/apis/folders-api.service"; -import { documentsApiService } from "@/lib/apis/documents-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; import { queries } from "@/zero/queries/index"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; @@ -95,12 +85,10 @@ export function DocumentsSidebar({ const searchSpaceId = Number(params.search_space_id); const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom); const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom); - const openDocumentTab = useSetAtom(openDocumentTabAtom); + const openEditorPanel = useSetAtom(openEditorPanelAtom); const { data: connectors } = useAtomValue(connectorsAtom); const connectorCount = connectors?.length ?? 0; - const isMobileLayout = useIsMobile(); - const [search, setSearch] = useState(""); const debouncedSearch = useDebouncedValue(search, 250); const [activeTypes, setActiveTypes] = useState([]); @@ -374,31 +362,6 @@ export function DocumentsSidebar({ [] ); - // Document popup viewer state (for tree view "Open" and mobile preview) - const [viewingDoc, setViewingDoc] = useState(null); - const [viewingContent, setViewingContent] = useState(""); - const [viewingLoading, setViewingLoading] = useState(false); - - const handleViewDocumentPopup = useCallback(async (doc: DocumentNodeDoc) => { - setViewingDoc(doc); - setViewingLoading(true); - try { - const fullDoc = await documentsApiService.getDocument({ id: doc.id }); - setViewingContent(fullDoc.content); - } catch (err) { - console.error("[DocumentsSidebar] Failed to fetch document content:", err); - setViewingContent("Failed to load document content."); - } finally { - setViewingLoading(false); - } - }, []); - - const handleCloseViewer = useCallback(() => { - setViewingDoc(null); - setViewingContent(""); - setViewingLoading(false); - }, []); - const handleToggleChatMention = useCallback( (doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => { if (isMentioned) { @@ -716,24 +679,18 @@ export function DocumentsSidebar({ onCreateFolder={handleCreateFolder} searchQuery={debouncedSearch.trim() || undefined} onPreviewDocument={(doc) => { - if (isMobileLayout) { - handleViewDocumentPopup(doc); - } else { - openDocumentTab({ - documentId: doc.id, - searchSpaceId, - title: doc.title, - }); - } + openEditorPanel({ + documentId: doc.id, + searchSpaceId, + title: doc.title, + }); }} onEditDocument={(doc) => { - if (!isMobileLayout) { - openDocumentTab({ - documentId: doc.id, - searchSpaceId, - title: doc.title, - }); - } + openEditorPanel({ + documentId: doc.id, + searchSpaceId, + title: doc.title, + }); }} onDeleteDocument={(doc) => handleDeleteDocument(doc.id)} onMoveDocument={handleMoveDocument} @@ -761,26 +718,6 @@ export function DocumentsSidebar({ onConfirm={handleCreateFolderConfirm} /> - !open && handleCloseViewer()}> - - - - - {viewingDoc?.title} - - -
- {viewingLoading ? ( -
- -
- ) : ( - - )} -
-
-
- !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)} diff --git a/surfsense_web/components/layout/ui/tabs/TabBar.tsx b/surfsense_web/components/layout/ui/tabs/TabBar.tsx index c163d3e3f..dc346b6cb 100644 --- a/surfsense_web/components/layout/ui/tabs/TabBar.tsx +++ b/surfsense_web/components/layout/ui/tabs/TabBar.tsx @@ -1,7 +1,7 @@ "use client"; import { useAtomValue, useSetAtom } from "jotai"; -import { FileText, MessageSquare, Plus, X } from "lucide-react"; +import { Plus, X } from "lucide-react"; import { useCallback, useEffect, useRef } from "react"; import { activeTabIdAtom, @@ -65,7 +65,6 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) { > {tabs.map((tab) => { const isActive = tab.id === activeTabId; - const Icon = tab.type === "document" ? FileText : MessageSquare; return (
diff --git a/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx b/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx index 7cec16bfa..ac279cd4d 100644 --- a/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx +++ b/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx @@ -41,6 +41,8 @@ interface DocumentTabContentProps { title?: string; } +const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]); + export function DocumentTabContent({ documentId, searchSpaceId, title }: DocumentTabContentProps) { const [doc, setDoc] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -171,6 +173,8 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen ); } + const isEditable = EDITABLE_DOCUMENT_TYPES.has(doc.document_type ?? ""); + if (isEditing) { return (
@@ -218,7 +222,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen

{doc.title || title || "Untitled"}

- {doc.document_type === "NOTE" && ( + {isEditable && ( + + e.preventDefault()} + > + {popoverContent} + + + ); + } + + // Default variant + return ( + + + + + e.preventDefault()} + > + {popoverContent} + + + ); +} + +interface OverflowItemProps { + citation: SerializableCitation; + onClick: () => void; +} + +function OverflowItem({ citation, onClick }: OverflowItemProps) { + const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe; + + return ( + + ); +} + +interface StackedCitationsProps { + id: string; + citations: SerializableCitation[]; + className?: string; + onNavigate?: (href: string, citation: SerializableCitation) => void; +} + +function StackedCitations({ + id, + citations, + className, + onNavigate, +}: StackedCitationsProps) { + const { + open, + setOpen, + containerRef, + handleMouseEnter, + handleMouseLeave, + handleBlur, + } = useHoverPopover(); + const maxIcons = 4; + const visibleCitations = citations.slice(0, maxIcons); + const remainingCount = Math.max(0, citations.length - maxIcons); + + const handleClick = (citation: SerializableCitation) => { + const href = resolveSafeNavigationHref(citation.href); + if (!href) return; + if (onNavigate) { + onNavigate(href, citation); + } else { + openSafeNavigationHref(href); + } + }; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: blur boundary for popover focus management +
+ + + + + setOpen(false)} + > +
+ {citations.map((citation) => ( + handleClick(citation)} + /> + ))} +
+
+
+
+ ); +} diff --git a/surfsense_web/components/tool-ui/citation/citation.tsx b/surfsense_web/components/tool-ui/citation/citation.tsx new file mode 100644 index 000000000..dcecb7fa3 --- /dev/null +++ b/surfsense_web/components/tool-ui/citation/citation.tsx @@ -0,0 +1,261 @@ +"use client"; + +import * as React from "react"; +import type { LucideIcon } from "lucide-react"; +import { + FileText, + Globe, + Code2, + Newspaper, + Database, + File, + ExternalLink, +} from "lucide-react"; +import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter"; + +import { openSafeNavigationHref, sanitizeHref } from "../shared/media"; +import type { + SerializableCitation, + CitationType, + CitationVariant, +} from "./schema"; + +const FALLBACK_LOCALE = "en-US"; + +const TYPE_ICONS: Record = { + webpage: Globe, + document: FileText, + article: Newspaper, + api: Database, + code: Code2, + other: File, +}; + +function extractDomain(url: string): string | undefined { + try { + const urlObj = new URL(url); + return urlObj.hostname.replace(/^www\./, ""); + } catch { + return undefined; + } +} + +function formatDate(isoString: string, locale: string): string { + try { + const date = new Date(isoString); + return date.toLocaleDateString(locale, { + year: "numeric", + month: "short", + }); + } catch { + return isoString; + } +} + +function useHoverPopover(delay = 100) { + const [open, setOpen] = React.useState(false); + const timeoutRef = React.useRef | null>(null); + + const handleMouseEnter = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(true), delay); + }, [delay]); + + const handleMouseLeave = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(false), delay); + }, [delay]); + + React.useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + return { open, setOpen, handleMouseEnter, handleMouseLeave }; +} + +export interface CitationProps extends SerializableCitation { + variant?: CitationVariant; + className?: string; + onNavigate?: (href: string, citation: SerializableCitation) => void; +} + +export function Citation(props: CitationProps) { + const { variant = "default", className, onNavigate, ...serializable } = props; + + const { + id, + href: rawHref, + title, + snippet, + domain: providedDomain, + favicon, + author, + publishedAt, + type = "webpage", + locale: providedLocale, + } = serializable; + + const locale = providedLocale ?? FALLBACK_LOCALE; + const sanitizedHref = sanitizeHref(rawHref); + const domain = providedDomain ?? extractDomain(rawHref); + + const citationData: SerializableCitation = { + ...serializable, + href: sanitizedHref ?? rawHref, + domain, + locale, + }; + + const TypeIcon = TYPE_ICONS[type] ?? Globe; + + const handleClick = () => { + if (!sanitizedHref) return; + if (onNavigate) { + onNavigate(sanitizedHref, citationData); + } else { + openSafeNavigationHref(sanitizedHref); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (sanitizedHref && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + handleClick(); + } + }; + + const iconElement = favicon ? ( + // biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config + + ) : ( +
diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx index b6f008887..d9ca9efb3 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx @@ -308,7 +308,8 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) { {invitesLoading ? ( ) : ( - canInvite && activeInvites.length > 0 && ( + canInvite && + activeInvites.length > 0 && ( ) )} diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx index 104dc111f..c2d2c01de 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx @@ -3,11 +3,11 @@ import { PenLine, Plus, Sparkles, Trash2 } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; -import type { PromptRead } from "@/contracts/types/prompts.types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/spinner"; +import type { PromptRead } from "@/contracts/types/prompts.types"; import { promptsApiService } from "@/lib/apis/prompts-api.service"; interface PromptFormData { @@ -99,7 +99,9 @@ export function PromptsContent() {

- Create prompt templates triggered with / in the chat composer. + Create prompt templates triggered with{" "} + / in the + chat composer.

{!showForm && (
@@ -153,7 +159,9 @@ export function PromptsContent() { - setFormData((p) => ({ ...p, description: e.target.value })) - } + onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))} />
@@ -337,17 +333,12 @@ export function ImageConfigDialog({ - + - setFormData((p) => ({ ...p, model_name: val })) - } + onValueChange={(val) => setFormData((p) => ({ ...p, model_name: val }))} /> @@ -368,9 +359,7 @@ export function ImageConfigDialog({ {m.value} @@ -388,9 +377,7 @@ export function ImageConfigDialog({ - setFormData((p) => ({ ...p, model_name: e.target.value })) - } + onChange={(e) => setFormData((p) => ({ ...p, model_name: e.target.value }))} /> )}
@@ -420,9 +407,7 @@ export function ImageConfigDialog({ - setFormData((p) => ({ ...p, api_version: e.target.value })) - } + onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))} />
)} @@ -442,25 +427,25 @@ export function ImageConfigDialog({ Cancel {mode === "create" || (mode === "edit" && !isGlobal) ? ( - - ) : isGlobal && config ? ( - + + ) : isGlobal && config ? ( + ) : null}
diff --git a/surfsense_web/components/shared/llm-config-form.tsx b/surfsense_web/components/shared/llm-config-form.tsx index 9fb8b9208..732bf971e 100644 --- a/surfsense_web/components/shared/llm-config-form.tsx +++ b/surfsense_web/components/shared/llm-config-form.tsx @@ -4,7 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useAtomValue } from "jotai"; import { Check, ChevronDown, ChevronsUpDown } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; -import { useForm, type Resolver } from "react-hook-form"; +import { type Resolver, useForm } from "react-hook-form"; import { z } from "zod"; import { defaultSystemInstructionsAtom, @@ -219,26 +219,22 @@ export function LLMConfigForm({ )} /> - {/* Custom Provider (conditional) */} - {watchProvider === "CUSTOM" && ( - ( - - Custom Provider Name - - - - - - )} - /> - )} + {/* Custom Provider (conditional) */} + {watchProvider === "CUSTOM" && ( + ( + + Custom Provider Name + + + + + + )} + /> + )} {/* Model Name with Combobox */}
- {/* Ollama Quick Actions */} - {watchProvider === "OLLAMA" && ( -
- - -
- )} + {/* Ollama Quick Actions */} + {watchProvider === "OLLAMA" && ( +
+ + +
+ )}
{/* Advanced Parameters */} diff --git a/surfsense_web/components/shared/model-config-dialog.tsx b/surfsense_web/components/shared/model-config-dialog.tsx index d5405574b..84ba821fc 100644 --- a/surfsense_web/components/shared/model-config-dialog.tsx +++ b/surfsense_web/components/shared/model-config-dialog.tsx @@ -167,9 +167,7 @@ export function ModelConfigDialog({

{getSubtitle()}

{config && mode !== "create" && ( -

- {config.model_name} -

+

{config.model_name}

)}
@@ -184,7 +182,7 @@ export function ModelConfigDialog({ WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`, }} > - {isGlobal && mode !== "create" && ( + {isGlobal && mode !== "create" && ( @@ -195,13 +193,13 @@ export function ModelConfigDialog({ )} {mode === "create" ? ( - - ) : isGlobal && config ? ( + + ) : isGlobal && config ? (
@@ -288,9 +286,9 @@ export function ModelConfigDialog({ citations_enabled: config.citations_enabled, search_space_id: searchSpaceId, }} - onSubmit={handleSubmit} - mode="edit" - formId="model-config-form" + onSubmit={handleSubmit} + mode="edit" + formId="model-config-form" /> ) : null}
@@ -307,26 +305,26 @@ export function ModelConfigDialog({ Cancel {mode === "create" || (!isGlobal && config) ? ( - - ) : isGlobal && config ? ( - + + ) : isGlobal && config ? ( + ) : null}
diff --git a/surfsense_web/components/tool-ui/citation/_adapter.tsx b/surfsense_web/components/tool-ui/citation/_adapter.tsx index 06ee62f6f..ba8ea5080 100644 --- a/surfsense_web/components/tool-ui/citation/_adapter.tsx +++ b/surfsense_web/components/tool-ui/citation/_adapter.tsx @@ -1,8 +1,8 @@ "use client"; -export { cn } from "@/lib/utils"; export { - Popover, - PopoverContent, - PopoverTrigger, + Popover, + PopoverContent, + PopoverTrigger, } from "@/components/ui/popover"; +export { cn } from "@/lib/utils"; diff --git a/surfsense_web/components/tool-ui/citation/citation-list.tsx b/surfsense_web/components/tool-ui/citation/citation-list.tsx index 34b995aae..3151917b6 100644 --- a/surfsense_web/components/tool-ui/citation/citation-list.tsx +++ b/surfsense_web/components/tool-ui/citation/citation-list.tsx @@ -1,463 +1,395 @@ "use client"; -import * as React from "react"; import type { LucideIcon } from "lucide-react"; -import { - FileText, - Globe, - Code2, - Newspaper, - Database, - File, - ExternalLink, -} from "lucide-react"; +import { Code2, Database, ExternalLink, File, FileText, Globe, Newspaper } from "lucide-react"; +import * as React from "react"; +import { openSafeNavigationHref, resolveSafeNavigationHref } from "../shared/media"; import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter"; import { Citation } from "./citation"; -import type { - SerializableCitation, - CitationType, - CitationVariant, -} from "./schema"; -import { - openSafeNavigationHref, - resolveSafeNavigationHref, -} from "../shared/media"; +import type { CitationType, CitationVariant, SerializableCitation } from "./schema"; const TYPE_ICONS: Record = { - webpage: Globe, - document: FileText, - article: Newspaper, - api: Database, - code: Code2, - other: File, + webpage: Globe, + document: FileText, + article: Newspaper, + api: Database, + code: Code2, + other: File, }; function useHoverPopover(delay = 100) { - const [open, setOpen] = React.useState(false); - const timeoutRef = React.useRef | null>(null); - const containerRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const timeoutRef = React.useRef | null>(null); + const containerRef = React.useRef(null); - const handleMouseEnter = React.useCallback(() => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout(() => setOpen(true), delay); - }, [delay]); + const handleMouseEnter = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(true), delay); + }, [delay]); - const handleMouseLeave = React.useCallback(() => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout(() => setOpen(false), delay); - }, [delay]); + const handleMouseLeave = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(false), delay); + }, [delay]); - const handleFocus = React.useCallback(() => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - setOpen(true); - }, []); + const handleFocus = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + setOpen(true); + }, []); - const handleBlur = React.useCallback( - (e: React.FocusEvent) => { - const relatedTarget = e.relatedTarget as HTMLElement | null; - if (containerRef.current?.contains(relatedTarget)) { - return; - } - if (relatedTarget?.closest("[data-radix-popper-content-wrapper]")) { - return; - } - if (timeoutRef.current) clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout(() => setOpen(false), delay); - }, - [delay], - ); + const handleBlur = React.useCallback( + (e: React.FocusEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement | null; + if (containerRef.current?.contains(relatedTarget)) { + return; + } + if (relatedTarget?.closest("[data-radix-popper-content-wrapper]")) { + return; + } + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(false), delay); + }, + [delay] + ); - React.useEffect(() => { - return () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - }; - }, []); + React.useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); - return { - open, - setOpen, - containerRef, - handleMouseEnter, - handleMouseLeave, - handleFocus, - handleBlur, - }; + return { + open, + setOpen, + containerRef, + handleMouseEnter, + handleMouseLeave, + handleFocus, + handleBlur, + }; } export interface CitationListProps { - id: string; - citations: SerializableCitation[]; - variant?: CitationVariant; - maxVisible?: number; - className?: string; - onNavigate?: (href: string, citation: SerializableCitation) => void; + id: string; + citations: SerializableCitation[]; + variant?: CitationVariant; + maxVisible?: number; + className?: string; + onNavigate?: (href: string, citation: SerializableCitation) => void; } export function CitationList(props: CitationListProps) { - const { - id, - citations, - variant = "default", - maxVisible, - className, - onNavigate, - } = props; + const { id, citations, variant = "default", maxVisible, className, onNavigate } = props; - const shouldTruncate = - maxVisible !== undefined && citations.length > maxVisible; - const visibleCitations = shouldTruncate - ? citations.slice(0, maxVisible) - : citations; - const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : []; - const overflowCount = overflowCitations.length; + const shouldTruncate = maxVisible !== undefined && citations.length > maxVisible; + const visibleCitations = shouldTruncate ? citations.slice(0, maxVisible) : citations; + const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : []; + const overflowCount = overflowCitations.length; - const wrapperClass = - variant === "inline" - ? "flex flex-wrap items-center gap-1.5" - : "flex flex-col gap-2"; + const wrapperClass = + variant === "inline" ? "flex flex-wrap items-center gap-1.5" : "flex flex-col gap-2"; - // Stacked variant: overlapping favicons with popover - if (variant === "stacked") { - return ( - - ); - } + // Stacked variant: overlapping favicons with popover + if (variant === "stacked") { + return ( + + ); + } - if (variant === "default") { - return ( -
- {visibleCitations.map((citation) => ( - - ))} - {shouldTruncate && ( - - )} -
- ); - } + if (variant === "default") { + return ( +
+ {visibleCitations.map((citation) => ( + + ))} + {shouldTruncate && ( + + )} +
+ ); + } - return ( -
- {visibleCitations.map((citation) => ( - - ))} - {shouldTruncate && ( - - )} -
- ); + return ( +
+ {visibleCitations.map((citation) => ( + + ))} + {shouldTruncate && ( + + )} +
+ ); } interface OverflowIndicatorProps { - citations: SerializableCitation[]; - count: number; - variant: CitationVariant; - onNavigate?: (href: string, citation: SerializableCitation) => void; + citations: SerializableCitation[]; + count: number; + variant: CitationVariant; + onNavigate?: (href: string, citation: SerializableCitation) => void; } -function OverflowIndicator({ - citations, - count, - variant, - onNavigate, -}: OverflowIndicatorProps) { - const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover(); +function OverflowIndicator({ citations, count, variant, onNavigate }: OverflowIndicatorProps) { + const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover(); - const handleClick = (citation: SerializableCitation) => { - const href = resolveSafeNavigationHref(citation.href); - if (!href) return; - if (onNavigate) { - onNavigate(href, citation); - } else { - openSafeNavigationHref(href); - } - }; + const handleClick = (citation: SerializableCitation) => { + const href = resolveSafeNavigationHref(citation.href); + if (!href) return; + if (onNavigate) { + onNavigate(href, citation); + } else { + openSafeNavigationHref(href); + } + }; - const popoverContent = ( -
- {citations.map((citation) => ( - handleClick(citation)} - /> - ))} -
- ); + const popoverContent = ( +
+ {citations.map((citation) => ( + handleClick(citation)} /> + ))} +
+ ); - if (variant === "inline") { - return ( - - - - - e.preventDefault()} - > - {popoverContent} - - - ); - } + if (variant === "inline") { + return ( + + + + + e.preventDefault()} + > + {popoverContent} + + + ); + } - // Default variant - return ( - - - - - e.preventDefault()} - > - {popoverContent} - - - ); + // Default variant + return ( + + + + + e.preventDefault()} + > + {popoverContent} + + + ); } interface OverflowItemProps { - citation: SerializableCitation; - onClick: () => void; + citation: SerializableCitation; + onClick: () => void; } function OverflowItem({ citation, onClick }: OverflowItemProps) { - const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe; + const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe; - return ( - - ); + return ( + + ); } interface StackedCitationsProps { - id: string; - citations: SerializableCitation[]; - className?: string; - onNavigate?: (href: string, citation: SerializableCitation) => void; + id: string; + citations: SerializableCitation[]; + className?: string; + onNavigate?: (href: string, citation: SerializableCitation) => void; } -function StackedCitations({ - id, - citations, - className, - onNavigate, -}: StackedCitationsProps) { - const { - open, - setOpen, - containerRef, - handleMouseEnter, - handleMouseLeave, - handleBlur, - } = useHoverPopover(); - const maxIcons = 4; - const visibleCitations = citations.slice(0, maxIcons); - const remainingCount = Math.max(0, citations.length - maxIcons); +function StackedCitations({ id, citations, className, onNavigate }: StackedCitationsProps) { + const { open, setOpen, containerRef, handleMouseEnter, handleMouseLeave, handleBlur } = + useHoverPopover(); + const maxIcons = 4; + const visibleCitations = citations.slice(0, maxIcons); + const remainingCount = Math.max(0, citations.length - maxIcons); - const handleClick = (citation: SerializableCitation) => { - const href = resolveSafeNavigationHref(citation.href); - if (!href) return; - if (onNavigate) { - onNavigate(href, citation); - } else { - openSafeNavigationHref(href); - } - }; + const handleClick = (citation: SerializableCitation) => { + const href = resolveSafeNavigationHref(citation.href); + if (!href) return; + if (onNavigate) { + onNavigate(href, citation); + } else { + openSafeNavigationHref(href); + } + }; - return ( - // biome-ignore lint/a11y/noStaticElementInteractions: blur boundary for popover focus management -
- - - - - setOpen(false)} - > -
- {citations.map((citation) => ( - handleClick(citation)} - /> - ))} -
-
-
-
- ); + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: blur boundary for popover focus management +
+ + + + + setOpen(false)} + > +
+ {citations.map((citation) => ( + handleClick(citation)} + /> + ))} +
+
+
+
+ ); } diff --git a/surfsense_web/components/tool-ui/citation/citation.tsx b/surfsense_web/components/tool-ui/citation/citation.tsx index dcecb7fa3..523169f49 100644 --- a/surfsense_web/components/tool-ui/citation/citation.tsx +++ b/surfsense_web/components/tool-ui/citation/citation.tsx @@ -1,261 +1,248 @@ "use client"; -import * as React from "react"; import type { LucideIcon } from "lucide-react"; -import { - FileText, - Globe, - Code2, - Newspaper, - Database, - File, - ExternalLink, -} from "lucide-react"; -import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter"; - +import { Code2, Database, ExternalLink, File, FileText, Globe, Newspaper } from "lucide-react"; +import * as React from "react"; import { openSafeNavigationHref, sanitizeHref } from "../shared/media"; -import type { - SerializableCitation, - CitationType, - CitationVariant, -} from "./schema"; +import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter"; +import type { CitationType, CitationVariant, SerializableCitation } from "./schema"; const FALLBACK_LOCALE = "en-US"; const TYPE_ICONS: Record = { - webpage: Globe, - document: FileText, - article: Newspaper, - api: Database, - code: Code2, - other: File, + webpage: Globe, + document: FileText, + article: Newspaper, + api: Database, + code: Code2, + other: File, }; function extractDomain(url: string): string | undefined { - try { - const urlObj = new URL(url); - return urlObj.hostname.replace(/^www\./, ""); - } catch { - return undefined; - } + try { + const urlObj = new URL(url); + return urlObj.hostname.replace(/^www\./, ""); + } catch { + return undefined; + } } function formatDate(isoString: string, locale: string): string { - try { - const date = new Date(isoString); - return date.toLocaleDateString(locale, { - year: "numeric", - month: "short", - }); - } catch { - return isoString; - } + try { + const date = new Date(isoString); + return date.toLocaleDateString(locale, { + year: "numeric", + month: "short", + }); + } catch { + return isoString; + } } function useHoverPopover(delay = 100) { - const [open, setOpen] = React.useState(false); - const timeoutRef = React.useRef | null>(null); - - const handleMouseEnter = React.useCallback(() => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout(() => setOpen(true), delay); - }, [delay]); - - const handleMouseLeave = React.useCallback(() => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout(() => setOpen(false), delay); - }, [delay]); - - React.useEffect(() => { - return () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - }; - }, []); - - return { open, setOpen, handleMouseEnter, handleMouseLeave }; + const [open, setOpen] = React.useState(false); + const timeoutRef = React.useRef | null>(null); + + const handleMouseEnter = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(true), delay); + }, [delay]); + + const handleMouseLeave = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(false), delay); + }, [delay]); + + React.useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + return { open, setOpen, handleMouseEnter, handleMouseLeave }; } export interface CitationProps extends SerializableCitation { - variant?: CitationVariant; - className?: string; - onNavigate?: (href: string, citation: SerializableCitation) => void; + variant?: CitationVariant; + className?: string; + onNavigate?: (href: string, citation: SerializableCitation) => void; } export function Citation(props: CitationProps) { - const { variant = "default", className, onNavigate, ...serializable } = props; - - const { - id, - href: rawHref, - title, - snippet, - domain: providedDomain, - favicon, - author, - publishedAt, - type = "webpage", - locale: providedLocale, - } = serializable; - - const locale = providedLocale ?? FALLBACK_LOCALE; - const sanitizedHref = sanitizeHref(rawHref); - const domain = providedDomain ?? extractDomain(rawHref); - - const citationData: SerializableCitation = { - ...serializable, - href: sanitizedHref ?? rawHref, - domain, - locale, - }; - - const TypeIcon = TYPE_ICONS[type] ?? Globe; - - const handleClick = () => { - if (!sanitizedHref) return; - if (onNavigate) { - onNavigate(sanitizedHref, citationData); - } else { - openSafeNavigationHref(sanitizedHref); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (sanitizedHref && (e.key === "Enter" || e.key === " ")) { - e.preventDefault(); - handleClick(); - } - }; - - const iconElement = favicon ? ( - // biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config - - ) : ( -