+
diff --git a/surfsense_web/components/prompt-kit/loader.tsx b/surfsense_web/components/prompt-kit/loader.tsx
new file mode 100644
index 000000000..435a6a136
--- /dev/null
+++ b/surfsense_web/components/prompt-kit/loader.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+
+export interface LoaderProps {
+ variant?: "text-shimmer";
+ size?: "sm" | "md" | "lg";
+ text?: string;
+ className?: string;
+}
+
+const textSizes = {
+ sm: "text-xs",
+ md: "text-sm",
+ lg: "text-base",
+} as const;
+
+/**
+ * TextShimmerLoader - A text loader with a shimmer gradient animation
+ * Used for in-progress states in write_todos and chain-of-thought
+ */
+export function TextShimmerLoader({
+ text = "Thinking",
+ className,
+ size = "md",
+}: {
+ text?: string;
+ className?: string;
+ size?: "sm" | "md" | "lg";
+}) {
+ return (
+ <>
+
+
+ {text}
+
+ >
+ );
+}
+
+/**
+ * Loader component - currently only supports text-shimmer variant
+ * Can be extended with more variants if needed in the future
+ */
+export function Loader({ variant = "text-shimmer", size = "md", text, className }: LoaderProps) {
+ switch (variant) {
+ case "text-shimmer":
+ default:
+ return
;
+ }
+}
diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx
index 5e7f08c4d..f5146c427 100644
--- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx
+++ b/surfsense_web/components/sidebar/AppSidebarProvider.tsx
@@ -3,7 +3,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { Trash2 } from "lucide-react";
-import { useRouter } from "next/navigation";
+import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useState } from "react";
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
@@ -50,7 +50,13 @@ export function AppSidebarProvider({
const t = useTranslations("dashboard");
const tCommon = useTranslations("common");
const router = useRouter();
+ const params = useParams();
const queryClient = useQueryClient();
+
+ // Get current chat ID from URL params
+ const currentChatId = params?.chat_id
+ ? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
+ : null;
const [isDeletingThread, setIsDeletingThread] = useState(false);
// Editor state for handling unsaved changes
@@ -61,7 +67,6 @@ export function AppSidebarProvider({
const {
data: threadsData,
error: threadError,
- isLoading: isLoadingThreads,
refetch: refetchThreads,
} = useQuery({
queryKey: ["threads", searchSpaceId],
@@ -73,7 +78,6 @@ export function AppSidebarProvider({
data: searchSpace,
isLoading: isLoadingSearchSpace,
error: searchSpaceError,
- refetch: fetchSearchSpace,
} = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
@@ -83,12 +87,7 @@ export function AppSidebarProvider({
const { data: user } = useAtomValue(currentUserAtom);
// Fetch notes
- const {
- data: notesData,
- error: notesError,
- isLoading: isLoadingNotes,
- refetch: refetchNotes,
- } = useQuery({
+ const { data: notesData, refetch: refetchNotes } = useQuery({
queryKey: ["notes", searchSpaceId],
queryFn: () =>
notesApiService.getNotes({
@@ -108,11 +107,6 @@ export function AppSidebarProvider({
} | null>(null);
const [isDeletingNote, setIsDeletingNote] = useState(false);
- // Retry function
- const retryFetch = useCallback(() => {
- fetchSearchSpace();
- }, [fetchSearchSpace]);
-
// Transform threads to the format expected by AppSidebar
const recentChats = useMemo(() => {
if (!threadsData?.threads) return [];
@@ -149,6 +143,10 @@ export function AppSidebarProvider({
await deleteThread(threadToDelete.id);
// Invalidate threads query to refresh the list
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
+ // Only navigate to new-chat if the deleted chat is currently open
+ if (currentChatId === threadToDelete.id) {
+ router.push(`/dashboard/${searchSpaceId}/new-chat`);
+ }
} catch (error) {
console.error("Error deleting thread:", error);
} finally {
@@ -156,7 +154,7 @@ export function AppSidebarProvider({
setShowDeleteDialog(false);
setThreadToDelete(null);
}
- }, [threadToDelete, queryClient, searchSpaceId]);
+ }, [threadToDelete, queryClient, searchSpaceId, router, currentChatId]);
// Handle delete note with confirmation
const handleDeleteNote = useCallback(async () => {
diff --git a/surfsense_web/components/sidebar/all-chats-sidebar.tsx b/surfsense_web/components/sidebar/all-chats-sidebar.tsx
index 9076715a3..ef55142fa 100644
--- a/surfsense_web/components/sidebar/all-chats-sidebar.tsx
+++ b/surfsense_web/components/sidebar/all-chats-sidebar.tsx
@@ -13,7 +13,7 @@ import {
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
-import { useRouter } from "next/navigation";
+import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
@@ -47,7 +47,15 @@ interface AllChatsSidebarProps {
export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsSidebarProps) {
const t = useTranslations("sidebar");
const router = useRouter();
+ const params = useParams();
const queryClient = useQueryClient();
+
+ // Get the current chat ID from URL to check if user is deleting the currently open chat
+ const currentChatId = Array.isArray(params.chat_id)
+ ? Number(params.chat_id[0])
+ : params.chat_id
+ ? Number(params.chat_id)
+ : null;
const [deletingThreadId, setDeletingThreadId] = useState
(null);
const [archivingThreadId, setArchivingThreadId] = useState(null);
const [searchQuery, setSearchQuery] = useState("");
@@ -126,6 +134,15 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
+
+ // If the deleted chat is currently open, close sidebar first then redirect
+ if (currentChatId === threadId) {
+ onOpenChange(false);
+ // Wait for sidebar close animation to complete before navigating
+ setTimeout(() => {
+ router.push(`/dashboard/${searchSpaceId}/new-chat`);
+ }, 250);
+ }
} catch (error) {
console.error("Error deleting thread:", error);
toast.error(t("error_deleting_chat") || "Failed to delete chat");
@@ -133,7 +150,7 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS
setDeletingThreadId(null);
}
},
- [queryClient, searchSpaceId, t]
+ [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange]
);
// Handle thread archive/unarchive
@@ -293,6 +310,7 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS
const isDeleting = deletingThreadId === thread.id;
const isArchiving = archivingThreadId === thread.id;
const isBusy = isDeleting || isArchiving;
+ const isActive = currentChatId === thread.id;
return (
diff --git a/surfsense_web/components/sidebar/all-notes-sidebar.tsx b/surfsense_web/components/sidebar/all-notes-sidebar.tsx
index d66a01780..ff9f07175 100644
--- a/surfsense_web/components/sidebar/all-notes-sidebar.tsx
+++ b/surfsense_web/components/sidebar/all-notes-sidebar.tsx
@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
-import { useRouter } from "next/navigation";
+import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
@@ -37,7 +37,11 @@ export function AllNotesSidebar({
}: AllNotesSidebarProps) {
const t = useTranslations("sidebar");
const router = useRouter();
+ const params = useParams();
const queryClient = useQueryClient();
+
+ // Get the current note ID from URL to highlight the open note
+ const currentNoteId = params.note_id ? Number(params.note_id) : null;
const [deletingNoteId, setDeletingNoteId] = useState
(null);
const [searchQuery, setSearchQuery] = useState("");
const [mounted, setMounted] = useState(false);
@@ -208,7 +212,7 @@ export function AllNotesSidebar({
aria-label={t("all_notes") || "All Notes"}
>
{/* Header */}
-
+
{t("all_notes") || "All Notes"}
{notes.map((note) => {
const isDeleting = deletingNoteId === note.id;
+ const isActive = currentNoteId === note.id;
return (
@@ -370,7 +376,7 @@ export function AllNotesSidebar({
{/* Footer with Add Note button */}
{onAddNote && notes.length > 0 && (
-
+
{
onAddNote();
diff --git a/surfsense_web/components/sidebar/nav-chats.tsx b/surfsense_web/components/sidebar/nav-chats.tsx
index 1165d6057..3bb7167da 100644
--- a/surfsense_web/components/sidebar/nav-chats.tsx
+++ b/surfsense_web/components/sidebar/nav-chats.tsx
@@ -10,7 +10,7 @@ import {
RefreshCw,
Trash2,
} from "lucide-react";
-import { useRouter } from "next/navigation";
+import { usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -71,6 +71,7 @@ export function NavChats({
}: NavChatsProps) {
const t = useTranslations("sidebar");
const router = useRouter();
+ const pathname = usePathname();
const isMobile = useIsMobile();
const [isDeleting, setIsDeleting] = useState(null);
const [isOpen, setIsOpen] = useState(defaultOpen);
@@ -142,6 +143,7 @@ export function NavChats({
{chats.map((chat) => {
const isDeletingChat = isDeleting === chat.id;
+ const isActive = pathname === chat.url;
return (
@@ -151,6 +153,7 @@ export function NavChats({
disabled={isDeletingChat}
className={cn(
"pr-8", // Make room for the action button
+ isActive && "bg-sidebar-accent text-sidebar-accent-foreground",
isDeletingChat && "opacity-50"
)}
>
diff --git a/surfsense_web/components/sidebar/nav-main.tsx b/surfsense_web/components/sidebar/nav-main.tsx
index 606ab2680..420b1e172 100644
--- a/surfsense_web/components/sidebar/nav-main.tsx
+++ b/surfsense_web/components/sidebar/nav-main.tsx
@@ -1,6 +1,7 @@
"use client";
import { ChevronRight, type LucideIcon } from "lucide-react";
+import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useState } from "react";
@@ -35,6 +36,7 @@ interface NavMainProps {
export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) {
const t = useTranslations("nav_menu");
+ const pathname = usePathname();
// Translation function that handles both exact matches and fallback to original
const translateTitle = (title: string): string => {
@@ -55,6 +57,35 @@ export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) {
return key ? t(key) : title;
};
+ // Check if an item is active based on pathname
+ const isItemActive = useCallback(
+ (item: NavItem): boolean => {
+ if (!pathname) return false;
+
+ // For items without sub-items, check if pathname matches or starts with the URL
+ if (!item.items?.length) {
+ // Chat item: active ONLY when on new-chat page without a specific chat ID
+ // (i.e., exactly /dashboard/{id}/new-chat, not /dashboard/{id}/new-chat/123)
+ if (item.url.includes("/new-chat")) {
+ // Match exactly the new-chat base URL (ends with /new-chat)
+ return pathname.endsWith("/new-chat");
+ }
+ // Logs item: active when on logs page
+ if (item.url.includes("/logs")) {
+ return pathname.includes("/logs");
+ }
+ // Check exact match or prefix match
+ return pathname === item.url || pathname.startsWith(`${item.url}/`);
+ }
+
+ // For items with sub-items (like Sources), check if any sub-item URL matches
+ return item.items.some(
+ (subItem) => pathname === subItem.url || pathname.startsWith(subItem.url)
+ );
+ },
+ [pathname]
+ );
+
// Memoize items to prevent unnecessary re-renders
const memoizedItems = useMemo(() => items, [items]);
@@ -88,14 +119,15 @@ export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) {
{memoizedItems.map((item, index) => {
const translatedTitle = translateTitle(item.title);
const hasSub = !!item.items?.length;
- const isItemOpen = expandedItems[item.title] ?? item.isActive ?? false;
+ const isActive = isItemActive(item);
+ const isItemOpen = expandedItems[item.title] ?? isActive ?? false;
return (
handleOpenChange(item.title, open) : undefined}
- defaultOpen={!hasSub ? item.isActive : undefined}
+ defaultOpen={!hasSub ? isActive : undefined}
>
{hasSub ? (
@@ -105,7 +137,7 @@ export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) {
@@ -147,7 +179,7 @@ export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) {
diff --git a/surfsense_web/components/sidebar/nav-notes.tsx b/surfsense_web/components/sidebar/nav-notes.tsx
index f634c2b72..3b03edb9c 100644
--- a/surfsense_web/components/sidebar/nav-notes.tsx
+++ b/surfsense_web/components/sidebar/nav-notes.tsx
@@ -10,9 +10,9 @@ import {
Plus,
Trash2,
} from "lucide-react";
-import { useRouter } from "next/navigation";
+import { usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
@@ -29,6 +29,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
+import { useLogsSummary } from "@/hooks/use-logs";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { AllNotesSidebar } from "./all-notes-sidebar";
@@ -72,11 +73,27 @@ export function NavNotes({
}: NavNotesProps) {
const t = useTranslations("sidebar");
const router = useRouter();
+ const pathname = usePathname();
const isMobile = useIsMobile();
const [isDeleting, setIsDeleting] = useState(null);
const [isOpen, setIsOpen] = useState(defaultOpen);
const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false);
+ // Poll for active reindexing tasks to show inline loading indicators
+ const { summary } = useLogsSummary(searchSpaceId ? Number(searchSpaceId) : 0, 24, {
+ refetchInterval: 2000,
+ });
+
+ // Create a Set of document IDs that are currently being reindexed
+ const reindexingDocumentIds = useMemo(() => {
+ if (!summary?.active_tasks) return new Set();
+ return new Set(
+ summary.active_tasks
+ .filter((task) => task.document_id != null)
+ .map((task) => task.document_id as number)
+ );
+ }, [summary?.active_tasks]);
+
// Auto-collapse on smaller screens when Sources is expanded
useEffect(() => {
if (isSourcesExpanded && isMobile) {
@@ -157,6 +174,8 @@ export function NavNotes({
{notes.length > 0 ? (
notes.map((note) => {
const isDeletingNote = isDeleting === note.id;
+ const isActive = pathname === note.url;
+ const isReindexing = note.id ? reindexingDocumentIds.has(note.id) : false;
return (
@@ -166,10 +185,15 @@ export function NavNotes({
disabled={isDeletingNote}
className={cn(
"pr-8", // Make room for the action button
+ isActive && "bg-sidebar-accent text-sidebar-accent-foreground",
isDeletingNote && "opacity-50"
)}
>
-
+ {isReindexing ? (
+
+ ) : (
+
+ )}
{note.name}
diff --git a/surfsense_web/components/sidebar/nav-secondary.tsx b/surfsense_web/components/sidebar/nav-secondary.tsx
index d4a56e4b1..23aeabc38 100644
--- a/surfsense_web/components/sidebar/nav-secondary.tsx
+++ b/surfsense_web/components/sidebar/nav-secondary.tsx
@@ -36,12 +36,21 @@ export function NavSecondary({
{memoizedItems.map((item, index) => (
-
-
-
- {item.title}
-
-
+ {item.url === "#" ? (
+ // Non-interactive display item (e.g., search space name)
+
+
+ {item.title}
+
+ ) : (
+ // Interactive link item
+
+
+
+ {item.title}
+
+
+ )}
))}
diff --git a/surfsense_web/components/tool-ui/display-image.tsx b/surfsense_web/components/tool-ui/display-image.tsx
index 333cd496c..660e95bca 100644
--- a/surfsense_web/components/tool-ui/display-image.tsx
+++ b/surfsense_web/components/tool-ui/display-image.tsx
@@ -2,6 +2,7 @@
import { makeAssistantToolUI } from "@assistant-ui/react";
import { AlertCircleIcon, ImageIcon } from "lucide-react";
+import { z } from "zod";
import {
Image,
ImageErrorBoundary,
@@ -9,27 +10,41 @@ import {
parseSerializableImage,
} from "@/components/tool-ui/image";
-/**
- * Type definitions for the display_image tool
- */
-interface DisplayImageArgs {
- src: string;
- alt?: string;
- title?: string;
- description?: string;
-}
+// ============================================================================
+// Zod Schemas
+// ============================================================================
-interface DisplayImageResult {
- id: string;
- assetId: string;
- src: string;
- alt?: string; // Made optional - parseSerializableImage provides fallback
- title?: string;
- description?: string;
- domain?: string;
- ratio?: string;
- error?: string;
-}
+/**
+ * Schema for display_image tool arguments
+ */
+const DisplayImageArgsSchema = z.object({
+ src: z.string(),
+ alt: z.string().nullish(),
+ title: z.string().nullish(),
+ description: z.string().nullish(),
+});
+
+/**
+ * Schema for display_image tool result
+ */
+const DisplayImageResultSchema = z.object({
+ id: z.string(),
+ assetId: z.string(),
+ src: z.string(),
+ alt: z.string().nullish(),
+ title: z.string().nullish(),
+ description: z.string().nullish(),
+ domain: z.string().nullish(),
+ ratio: z.string().nullish(),
+ error: z.string().nullish(),
+});
+
+// ============================================================================
+// Types
+// ============================================================================
+
+type DisplayImageArgs = z.infer;
+type DisplayImageResult = z.infer;
/**
* Error state component shown when image display fails
@@ -142,4 +157,9 @@ export const DisplayImageToolUI = makeAssistantToolUI;
+type LinkPreviewResult = z.infer;
/**
* Error state component shown when link preview fails
@@ -150,20 +165,35 @@ export const LinkPreviewToolUI = makeAssistantToolUI;
+type MultiLinkPreviewResult = z.infer;
export const MultiLinkPreviewToolUI = makeAssistantToolUI<
MultiLinkPreviewArgs,
@@ -217,4 +247,13 @@ export const MultiLinkPreviewToolUI = makeAssistantToolUI<
},
});
-export type { LinkPreviewArgs, LinkPreviewResult, MultiLinkPreviewArgs, MultiLinkPreviewResult };
+export {
+ LinkPreviewArgsSchema,
+ LinkPreviewResultSchema,
+ MultiLinkPreviewArgsSchema,
+ MultiLinkPreviewResultSchema,
+ type LinkPreviewArgs,
+ type LinkPreviewResult,
+ type MultiLinkPreviewArgs,
+ type MultiLinkPreviewResult,
+};
diff --git a/surfsense_web/components/tool-ui/plan/index.tsx b/surfsense_web/components/tool-ui/plan/index.tsx
new file mode 100644
index 000000000..8467b0af9
--- /dev/null
+++ b/surfsense_web/components/tool-ui/plan/index.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { Component, type ReactNode } from "react";
+import { Card, CardContent } from "@/components/ui/card";
+
+export * from "./plan";
+export * from "./schema";
+
+// ============================================================================
+// Error Boundary
+// ============================================================================
+
+interface PlanErrorBoundaryProps {
+ children: ReactNode;
+ fallback?: ReactNode;
+}
+
+interface PlanErrorBoundaryState {
+ hasError: boolean;
+ error?: Error;
+}
+
+export class PlanErrorBoundary extends Component {
+ constructor(props: PlanErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(error: Error): PlanErrorBoundaryState {
+ return { hasError: true, error };
+ }
+
+ render() {
+ if (this.state.hasError) {
+ if (this.props.fallback) {
+ return this.props.fallback;
+ }
+
+ return (
+
+
+
+ Failed to render plan
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
diff --git a/surfsense_web/components/tool-ui/plan/plan.tsx b/surfsense_web/components/tool-ui/plan/plan.tsx
new file mode 100644
index 000000000..37a94fb9f
--- /dev/null
+++ b/surfsense_web/components/tool-ui/plan/plan.tsx
@@ -0,0 +1,229 @@
+"use client";
+
+import { CheckCircle2, Circle, CircleDashed, ListTodo, PartyPopper, XCircle } from "lucide-react";
+import type { FC } from "react";
+import { useMemo, useState } from "react";
+import { TextShimmerLoader } from "@/components/prompt-kit/loader";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import { Progress } from "@/components/ui/progress";
+import { cn } from "@/lib/utils";
+import type { Action, ActionsConfig } from "../shared/schema";
+import type { TodoStatus } from "./schema";
+
+// ============================================================================
+// Status Icon Component
+// ============================================================================
+
+interface StatusIconProps {
+ status: TodoStatus;
+ className?: string;
+ /** When false, in_progress items show as static (no spinner) */
+ isStreaming?: boolean;
+}
+
+const StatusIcon: FC = ({ status, className, isStreaming = true }) => {
+ const baseClass = cn("size-4 shrink-0", className);
+
+ switch (status) {
+ case "completed":
+ return ;
+ case "in_progress":
+ // Only animate the spinner if we're actively streaming
+ // When streaming is stopped, show as a static dashed circle
+ return (
+
+ );
+ case "cancelled":
+ return ;
+ case "pending":
+ default:
+ return ;
+ }
+};
+
+// ============================================================================
+// Todo Item Component
+// ============================================================================
+
+interface TodoItemProps {
+ todo: { id: string; content: string; status: TodoStatus };
+ /** When false, in_progress items show as static (no spinner/pulse) */
+ isStreaming?: boolean;
+}
+
+const TodoItem: FC = ({ todo, isStreaming = true }) => {
+ const isStrikethrough = todo.status === "completed" || todo.status === "cancelled";
+ // Only show shimmer animation if streaming and in progress
+ const isShimmer = todo.status === "in_progress" && isStreaming;
+
+ // Render the content with optional shimmer effect
+ const renderContent = () => {
+ if (isShimmer) {
+ return ;
+ }
+ return (
+
+ {todo.content}
+
+ );
+ };
+
+ return (
+
+
+ {renderContent()}
+
+ );
+};
+
+// ============================================================================
+// Plan Component
+// ============================================================================
+
+export interface PlanProps {
+ id: string;
+ title: string;
+ todos: Array<{ id: string; content: string; status: TodoStatus }>;
+ maxVisibleTodos?: number;
+ showProgress?: boolean;
+ /** When false, in_progress items show as static (no spinner/pulse animations) */
+ isStreaming?: boolean;
+ responseActions?: Action[] | ActionsConfig;
+ className?: string;
+ onResponseAction?: (actionId: string) => void;
+ onBeforeResponseAction?: (actionId: string) => boolean;
+}
+
+export const Plan: FC = ({
+ id,
+ title,
+ todos,
+ maxVisibleTodos = 4,
+ showProgress = true,
+ isStreaming = true,
+ responseActions,
+ className,
+ onResponseAction,
+ onBeforeResponseAction,
+}) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ // Calculate progress
+ const progress = useMemo(() => {
+ const completed = todos.filter((t) => t.status === "completed").length;
+ const total = todos.filter((t) => t.status !== "cancelled").length;
+ return { completed, total, percentage: total > 0 ? (completed / total) * 100 : 0 };
+ }, [todos]);
+
+ const isAllComplete = progress.completed === progress.total && progress.total > 0;
+
+ // Split todos for collapsible display
+ const visibleTodos = todos.slice(0, maxVisibleTodos);
+ const hiddenTodos = todos.slice(maxVisibleTodos);
+ const hasHiddenTodos = hiddenTodos.length > 0;
+
+ // Handle action click
+ const handleAction = (actionId: string) => {
+ if (onBeforeResponseAction && !onBeforeResponseAction(actionId)) {
+ return;
+ }
+ onResponseAction?.(actionId);
+ };
+
+ // Normalize actions to array
+ const actionArray: Action[] = useMemo(() => {
+ if (!responseActions) return [];
+ if (Array.isArray(responseActions)) return responseActions;
+ return [
+ responseActions.confirm && { ...responseActions.confirm, id: "confirm" },
+ responseActions.cancel && { ...responseActions.cancel, id: "cancel" },
+ ].filter(Boolean) as Action[];
+ }, [responseActions]);
+
+ const TodoList: FC<{ items: typeof todos }> = ({ items }) => {
+ return (
+
+ {items.map((todo) => (
+
+ ))}
+
+ );
+ };
+
+ return (
+
+
+
+
+
+ {title}
+
+ {isAllComplete && (
+
+ )}
+
+
+ {showProgress && (
+
+
+
+ {progress.completed} of {progress.total} complete
+
+ {Math.round(progress.percentage)}%
+
+
+
+ )}
+
+
+
+
+
+ {hasHiddenTodos && (
+
+
+
+ {isExpanded
+ ? "Show less"
+ : `Show ${hiddenTodos.length} more ${hiddenTodos.length === 1 ? "task" : "tasks"}`}
+
+
+
+
+
+
+ )}
+
+ {actionArray.length > 0 && (
+
+ {actionArray.map((action) => (
+ handleAction(action.id)}
+ >
+ {action.label}
+
+ ))}
+
+ )}
+
+
+ );
+};
diff --git a/surfsense_web/components/tool-ui/plan/schema.ts b/surfsense_web/components/tool-ui/plan/schema.ts
new file mode 100644
index 000000000..a8263cf71
--- /dev/null
+++ b/surfsense_web/components/tool-ui/plan/schema.ts
@@ -0,0 +1,91 @@
+import { z } from "zod";
+
+/**
+ * Todo item status
+ */
+export const TodoStatusSchema = z.enum(["pending", "in_progress", "completed", "cancelled"]);
+export type TodoStatus = z.infer;
+
+/**
+ * Single todo item in a plan
+ * Matches deepagents TodoListMiddleware output: { content, status }
+ * id is auto-generated if not provided
+ */
+export const PlanTodoSchema = z.object({
+ id: z.string().optional(),
+ content: z.string(),
+ status: TodoStatusSchema,
+});
+
+export type PlanTodo = z.infer;
+
+/**
+ * Serializable plan schema for tool results
+ * Matches deepagents TodoListMiddleware output format
+ * id/title are auto-generated if not provided
+ */
+export const SerializablePlanSchema = z.object({
+ id: z.string().optional(),
+ title: z.string().optional(),
+ todos: z.array(PlanTodoSchema).min(1),
+ maxVisibleTodos: z.number().optional(),
+ showProgress: z.boolean().optional(),
+});
+
+export type SerializablePlan = z.infer;
+
+/**
+ * Normalized plan with required fields (after auto-generation)
+ */
+export interface NormalizedPlan {
+ id: string;
+ title: string;
+ todos: Array<{ id: string; content: string; status: TodoStatus }>;
+ maxVisibleTodos?: number;
+ showProgress?: boolean;
+}
+
+/**
+ * Parse and normalize a plan from tool result
+ * Auto-generates id/title if not provided (for deepagents compatibility)
+ */
+export function parseSerializablePlan(data: unknown): NormalizedPlan {
+ const result = SerializablePlanSchema.safeParse(data);
+
+ if (!result.success) {
+ console.warn("Invalid plan data:", result.error.issues);
+
+ // Try to extract basic info for fallback
+ const obj = (data && typeof data === "object" ? data : {}) as Record;
+
+ return {
+ id: typeof obj.id === "string" ? obj.id : `plan-${Date.now()}`,
+ title: typeof obj.title === "string" ? obj.title : "Plan",
+ todos: Array.isArray(obj.todos)
+ ? obj.todos.map((t: unknown, i: number) => {
+ const todo = t as Record;
+ return {
+ id: typeof todo?.id === "string" ? todo.id : `todo-${i}`,
+ content: typeof todo?.content === "string" ? todo.content : "Task",
+ status: TodoStatusSchema.safeParse(todo?.status).success
+ ? (todo.status as TodoStatus)
+ : ("pending" as const),
+ };
+ })
+ : [{ id: "1", content: "No tasks", status: "pending" as const }],
+ };
+ }
+
+ // Normalize: add id/title if missing
+ return {
+ id: result.data.id || `plan-${Date.now()}`,
+ title: result.data.title || "Plan",
+ todos: result.data.todos.map((t, i) => ({
+ id: t.id || `todo-${i}`,
+ content: t.content,
+ status: t.status,
+ })),
+ maxVisibleTodos: result.data.maxVisibleTodos,
+ showProgress: result.data.showProgress,
+ };
+}
diff --git a/surfsense_web/components/tool-ui/scrape-webpage.tsx b/surfsense_web/components/tool-ui/scrape-webpage.tsx
index 025328235..29e7094db 100644
--- a/surfsense_web/components/tool-ui/scrape-webpage.tsx
+++ b/surfsense_web/components/tool-ui/scrape-webpage.tsx
@@ -2,6 +2,7 @@
import { makeAssistantToolUI } from "@assistant-ui/react";
import { AlertCircleIcon, FileTextIcon } from "lucide-react";
+import { z } from "zod";
import {
Article,
ArticleErrorBoundary,
@@ -9,30 +10,44 @@ import {
parseSerializableArticle,
} from "@/components/tool-ui/article";
-/**
- * Type definitions for the scrape_webpage tool
- */
-interface ScrapeWebpageArgs {
- url: string;
- max_length?: number;
-}
+// ============================================================================
+// Zod Schemas
+// ============================================================================
-interface ScrapeWebpageResult {
- id: string;
- assetId: string;
- kind: "article";
- href: string;
- title: string;
- description?: string;
- content?: string;
- domain?: string;
- author?: string;
- date?: string;
- word_count?: number;
- was_truncated?: boolean;
- crawler_type?: string;
- error?: string;
-}
+/**
+ * Schema for scrape_webpage tool arguments
+ */
+const ScrapeWebpageArgsSchema = z.object({
+ url: z.string(),
+ max_length: z.number().nullish(),
+});
+
+/**
+ * Schema for scrape_webpage tool result
+ */
+const ScrapeWebpageResultSchema = z.object({
+ id: z.string(),
+ assetId: z.string(),
+ kind: z.literal("article"),
+ href: z.string(),
+ title: z.string(),
+ description: z.string().nullish(),
+ content: z.string().nullish(),
+ domain: z.string().nullish(),
+ author: z.string().nullish(),
+ date: z.string().nullish(),
+ word_count: z.number().nullish(),
+ was_truncated: z.boolean().nullish(),
+ crawler_type: z.string().nullish(),
+ error: z.string().nullish(),
+});
+
+// ============================================================================
+// Types
+// ============================================================================
+
+type ScrapeWebpageArgs = z.infer;
+type ScrapeWebpageResult = z.infer;
/**
* Error state component shown when webpage scraping fails
@@ -154,4 +169,9 @@ export const ScrapeWebpageToolUI = makeAssistantToolUI void;
+ disabled?: boolean;
+}
+
+export const ActionButtons: FC = ({ actions, onAction, disabled }) => {
+ if (!actions) return null;
+
+ // Normalize actions to array format
+ const actionArray: Action[] = Array.isArray(actions)
+ ? actions
+ : ([
+ actions.confirm && { ...actions.confirm, id: "confirm" },
+ actions.cancel && { ...actions.cancel, id: "cancel" },
+ ].filter(Boolean) as Action[]);
+
+ if (actionArray.length === 0) return null;
+
+ return (
+
+ {actionArray.map((action) => (
+ onAction?.(action.id)}
+ >
+ {action.label}
+
+ ))}
+
+ );
+};
diff --git a/surfsense_web/components/tool-ui/shared/index.ts b/surfsense_web/components/tool-ui/shared/index.ts
new file mode 100644
index 000000000..23f5a27dd
--- /dev/null
+++ b/surfsense_web/components/tool-ui/shared/index.ts
@@ -0,0 +1,2 @@
+export * from "./action-buttons";
+export * from "./schema";
diff --git a/surfsense_web/components/tool-ui/shared/schema.ts b/surfsense_web/components/tool-ui/shared/schema.ts
new file mode 100644
index 000000000..8076a8e45
--- /dev/null
+++ b/surfsense_web/components/tool-ui/shared/schema.ts
@@ -0,0 +1,23 @@
+import { z } from "zod";
+
+/**
+ * Shared action schema for tool UI components
+ */
+export const ActionSchema = z.object({
+ id: z.string(),
+ label: z.string(),
+ variant: z.enum(["default", "secondary", "destructive", "outline", "ghost", "link"]).optional(),
+ disabled: z.boolean().optional(),
+});
+
+export type Action = z.infer;
+
+/**
+ * Actions configuration schema
+ */
+export const ActionsConfigSchema = z.object({
+ confirm: ActionSchema.optional(),
+ cancel: ActionSchema.optional(),
+});
+
+export type ActionsConfig = z.infer;
diff --git a/surfsense_web/components/tool-ui/write-todos.tsx b/surfsense_web/components/tool-ui/write-todos.tsx
new file mode 100644
index 000000000..a5da31e9e
--- /dev/null
+++ b/surfsense_web/components/tool-ui/write-todos.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+import { makeAssistantToolUI, useAssistantState } from "@assistant-ui/react";
+import { useAtomValue, useSetAtom } from "jotai";
+import { Loader2 } from "lucide-react";
+import { useEffect, useMemo } from "react";
+import { z } from "zod";
+import {
+ getCanonicalPlanTitle,
+ planStatesAtom,
+ registerPlanOwner,
+ updatePlanStateAtom,
+} from "@/atoms/chat/plan-state.atom";
+import { Plan, PlanErrorBoundary, parseSerializablePlan, TodoStatusSchema } from "./plan";
+
+// ============================================================================
+// Zod Schemas - Matching deepagents TodoListMiddleware output
+// ============================================================================
+
+/**
+ * Schema for a single todo item (matches deepagents output)
+ */
+const TodoItemSchema = z.object({
+ content: z.string(),
+ status: TodoStatusSchema,
+});
+
+/**
+ * Schema for write_todos tool args/result (matches deepagents output)
+ * deepagents provides: { todos: [{ content, status }] }
+ */
+const WriteTodosSchema = z.object({
+ todos: z.array(TodoItemSchema).nullish(),
+});
+
+// ============================================================================
+// Types
+// ============================================================================
+
+type WriteTodosData = z.infer;
+
+/**
+ * Loading state component
+ */
+function WriteTodosLoading() {
+ return (
+
+
+
+ Creating plan...
+
+
+ );
+}
+
+/**
+ * WriteTodos Tool UI Component
+ *
+ * Displays the agent's planning/todo list with a beautiful UI.
+ * Uses deepagents TodoListMiddleware output directly: { todos: [{ content, status }] }
+ *
+ * FIXED POSITION: When multiple write_todos calls happen in a conversation,
+ * only the FIRST component renders. Subsequent updates just update the
+ * shared state, and the first component reads from it.
+ */
+export const WriteTodosToolUI = makeAssistantToolUI({
+ toolName: "write_todos",
+ render: function WriteTodosUI({ args, result, status, toolCallId }) {
+ const updatePlanState = useSetAtom(updatePlanStateAtom);
+ const planStates = useAtomValue(planStatesAtom);
+
+ // Check if the THREAD is running
+ const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
+
+ // Use result if available, otherwise args (for streaming)
+ const data = result || args;
+ const hasTodos = data?.todos && data.todos.length > 0;
+
+ // Fixed title for all plans in conversation
+ const planTitle = "Plan";
+
+ // SYNCHRONOUS ownership check
+ const isOwner = useMemo(() => {
+ return registerPlanOwner(planTitle, toolCallId);
+ }, [planTitle, toolCallId]);
+
+ // Get canonical title
+ const canonicalTitle = useMemo(() => getCanonicalPlanTitle(planTitle), [planTitle]);
+
+ // Register/update the plan state
+ useEffect(() => {
+ if (hasTodos) {
+ const normalizedPlan = parseSerializablePlan({ todos: data.todos });
+ updatePlanState({
+ id: normalizedPlan.id,
+ title: canonicalTitle,
+ todos: normalizedPlan.todos,
+ toolCallId,
+ });
+ }
+ }, [data, hasTodos, canonicalTitle, updatePlanState, toolCallId]);
+
+ // Get the current plan state
+ const currentPlanState = planStates.get(canonicalTitle);
+
+ // If we're NOT the owner, render nothing
+ if (!isOwner) {
+ return null;
+ }
+
+ // Loading state
+ if (status.type === "running" || status.type === "requires-action") {
+ if (hasTodos) {
+ const plan = parseSerializablePlan({ todos: data.todos });
+ return (
+
+ );
+ }
+ return ;
+ }
+
+ // Incomplete/cancelled state
+ if (status.type === "incomplete") {
+ if (currentPlanState || hasTodos) {
+ const plan = currentPlanState || parseSerializablePlan({ todos: data?.todos || [] });
+ return (
+
+ );
+ }
+ return null;
+ }
+
+ // Success - render the plan
+ const planToRender =
+ currentPlanState || (hasTodos ? parseSerializablePlan({ todos: data.todos }) : null);
+ if (!planToRender) {
+ return ;
+ }
+
+ return (
+
+ );
+ },
+});
+
+export { WriteTodosSchema, type WriteTodosData };
diff --git a/surfsense_web/components/ui/sidebar.tsx b/surfsense_web/components/ui/sidebar.tsx
index caafa6b6e..46280e1e3 100644
--- a/surfsense_web/components/ui/sidebar.tsx
+++ b/surfsense_web/components/ui/sidebar.tsx
@@ -449,7 +449,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
const sidebarMenuButtonVariants = cva(
- "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
+ "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
diff --git a/surfsense_web/contracts/types/connector.types.ts b/surfsense_web/contracts/types/connector.types.ts
new file mode 100644
index 000000000..4e09ba067
--- /dev/null
+++ b/surfsense_web/contracts/types/connector.types.ts
@@ -0,0 +1,159 @@
+import { z } from "zod";
+import { paginationQueryParams } from ".";
+
+export const searchSourceConnectorTypeEnum = z.enum([
+ "SERPER_API",
+ "TAVILY_API",
+ "SEARXNG_API",
+ "LINKUP_API",
+ "BAIDU_SEARCH_API",
+ "SLACK_CONNECTOR",
+ "NOTION_CONNECTOR",
+ "GITHUB_CONNECTOR",
+ "LINEAR_CONNECTOR",
+ "DISCORD_CONNECTOR",
+ "JIRA_CONNECTOR",
+ "CONFLUENCE_CONNECTOR",
+ "CLICKUP_CONNECTOR",
+ "GOOGLE_CALENDAR_CONNECTOR",
+ "GOOGLE_GMAIL_CONNECTOR",
+ "AIRTABLE_CONNECTOR",
+ "LUMA_CONNECTOR",
+ "ELASTICSEARCH_CONNECTOR",
+ "WEBCRAWLER_CONNECTOR",
+ "BOOKSTACK_CONNECTOR",
+]);
+
+export const searchSourceConnector = z.object({
+ id: z.number(),
+ name: z.string(),
+ connector_type: searchSourceConnectorTypeEnum,
+ is_indexable: z.boolean(),
+ last_indexed_at: z.string().nullable(),
+ config: z.record(z.string(), z.any()),
+ periodic_indexing_enabled: z.boolean(),
+ indexing_frequency_minutes: z.number().nullable(),
+ next_scheduled_at: z.string().nullable(),
+ search_space_id: z.number(),
+ user_id: z.string(),
+ created_at: z.string(),
+});
+
+/**
+ * Get connectors
+ */
+export const getConnectorsRequest = z.object({
+ queryParams: paginationQueryParams
+ .pick({ skip: true, limit: true })
+ .extend({
+ search_space_id: z.number().or(z.string()).nullish(),
+ })
+ .nullish(),
+});
+
+export const getConnectorsResponse = z.array(searchSourceConnector);
+
+/**
+ * Get connector
+ */
+export const getConnectorRequest = searchSourceConnector.pick({ id: true });
+
+export const getConnectorResponse = searchSourceConnector;
+
+/**
+ * Create connector
+ */
+export const createConnectorRequest = z.object({
+ data: searchSourceConnector.pick({
+ name: true,
+ connector_type: true,
+ is_indexable: true,
+ last_indexed_at: true,
+ config: true,
+ periodic_indexing_enabled: true,
+ indexing_frequency_minutes: true,
+ next_scheduled_at: true,
+ }),
+ queryParams: z.object({
+ search_space_id: z.number().or(z.string()),
+ }),
+});
+
+export const createConnectorResponse = searchSourceConnector;
+
+/**
+ * Update connector
+ */
+export const updateConnectorRequest = z.object({
+ id: z.number(),
+ data: searchSourceConnector
+ .pick({
+ name: true,
+ connector_type: true,
+ is_indexable: true,
+ last_indexed_at: true,
+ config: true,
+ periodic_indexing_enabled: true,
+ indexing_frequency_minutes: true,
+ next_scheduled_at: true,
+ })
+ .partial(),
+});
+
+export const updateConnectorResponse = searchSourceConnector;
+
+/**
+ * Delete connector
+ */
+export const deleteConnectorRequest = searchSourceConnector.pick({ id: true });
+
+export const deleteConnectorResponse = z.object({
+ message: z.literal("Search source connector deleted successfully"),
+});
+
+/**
+ * Index connector
+ */
+export const indexConnectorRequest = z.object({
+ connector_id: z.number(),
+ queryParams: z.object({
+ search_space_id: z.number().or(z.string()),
+ start_date: z.string().optional(),
+ end_date: z.string().optional(),
+ }),
+});
+
+export const indexConnectorResponse = z.object({
+ message: z.string(),
+ connector_id: z.number(),
+ search_space_id: z.number(),
+ indexing_from: z.string(),
+ indexing_to: z.string(),
+});
+
+/**
+ * List GitHub repositories
+ */
+export const listGitHubRepositoriesRequest = z.object({
+ github_pat: z.string(),
+});
+
+export const listGitHubRepositoriesResponse = z.array(z.record(z.string(), z.any()));
+
+// Inferred types
+export type SearchSourceConnectorType = z.infer;
+export type SearchSourceConnector = z.infer;
+export type GetConnectorsRequest = z.infer;
+export type GetConnectorsResponse = z.infer;
+export type GetConnectorRequest = z.infer;
+export type GetConnectorResponse = z.infer;
+export type CreateConnectorRequest = z.infer;
+export type CreateConnectorResponse = z.infer;
+export type UpdateConnectorRequest = z.infer;
+export type UpdateConnectorResponse = z.infer;
+export type DeleteConnectorRequest = z.infer;
+export type DeleteConnectorResponse = z.infer;
+export type IndexConnectorRequest = z.infer;
+export type IndexConnectorResponse = z.infer;
+export type ListGitHubRepositoriesRequest = z.infer;
+export type ListGitHubRepositoriesResponse = z.infer;
diff --git a/surfsense_web/contracts/types/log.types.ts b/surfsense_web/contracts/types/log.types.ts
new file mode 100644
index 000000000..ac81d2d0d
--- /dev/null
+++ b/surfsense_web/contracts/types/log.types.ts
@@ -0,0 +1,134 @@
+import { z } from "zod";
+import { paginationQueryParams } from ".";
+
+/**
+ * ENUMS
+ */
+export const logLevelEnum = z.enum(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]);
+
+export const logStatusEnum = z.enum(["IN_PROGRESS", "SUCCESS", "FAILED"]);
+
+/**
+ * Base log schema
+ */
+export const log = z.object({
+ id: z.number(),
+ level: logLevelEnum,
+ status: logStatusEnum,
+ message: z.string(),
+ source: z.string().nullable().optional(),
+ log_metadata: z.record(z.string(), z.any()).nullable().optional(),
+ created_at: z.string(),
+ search_space_id: z.number(),
+});
+
+export const logBase = log.omit({ id: true, created_at: true });
+
+/**
+ * Create log
+ */
+export const createLogRequest = logBase.extend({ search_space_id: z.number() });
+export const createLogResponse = log;
+
+/**
+ * Update log
+ */
+export const updateLogRequest = logBase.partial();
+export const updateLogResponse = log;
+
+/**
+ * Delete log
+ */
+export const deleteLogRequest = z.object({ id: z.number() });
+export const deleteLogResponse = z.object({
+ message: z.string().default("Log deleted successfully"),
+});
+
+/**
+ * Get logs (list)
+ */
+export const logFilters = z.object({
+ search_space_id: z.number().optional(),
+ level: logLevelEnum.optional(),
+ status: logStatusEnum.optional(),
+ source: z.string().optional(),
+ start_date: z.string().optional(),
+ end_date: z.string().optional(),
+});
+
+export const getLogsRequest = z.object({
+ queryParams: paginationQueryParams
+ .extend({
+ search_space_id: z.number().optional(),
+ level: logLevelEnum.optional(),
+ status: logStatusEnum.optional(),
+ source: z.string().optional(),
+ start_date: z.string().optional(),
+ end_date: z.string().optional(),
+ })
+ .nullish(),
+});
+export const getLogsResponse = z.array(log);
+
+/**
+ * Get single log
+ */
+export const getLogRequest = z.object({ id: z.number() });
+export const getLogResponse = log;
+
+/**
+ * Log summary (used for summary dashboard)
+ */
+export const logActiveTask = z.object({
+ id: z.number(),
+ task_name: z.string(),
+ message: z.string(),
+ started_at: z.string(),
+ source: z.string().nullable().optional(),
+ document_id: z.number().nullable().optional(),
+});
+export const logFailure = z.object({
+ id: z.number(),
+ task_name: z.string(),
+ message: z.string(),
+ failed_at: z.string(),
+ source: z.string().nullable().optional(),
+ error_details: z.string().nullable().optional(),
+});
+export const logSummary = z.object({
+ total_logs: z.number(),
+ time_window_hours: z.number(),
+ by_status: z.record(z.string(), z.number()),
+ by_level: z.record(z.string(), z.number()),
+ by_source: z.record(z.string(), z.number()),
+ active_tasks: z.array(logActiveTask),
+ recent_failures: z.array(logFailure),
+});
+export const getLogSummaryRequest = z.object({
+ search_space_id: z.number(),
+ hours: z.number().optional(),
+});
+export const getLogSummaryResponse = logSummary;
+
+/**
+ * Typescript types
+ */
+export type Log = z.infer;
+export type LogLevelEnum = z.infer;
+export type LogStatusEnum = z.infer;
+export type LogFilters = z.infer;
+export type CreateLogRequest = z.infer;
+export type CreateLogResponse = z.infer;
+export type UpdateLogRequest = z.infer;
+export type UpdateLogResponse = z.infer;
+export type DeleteLogRequest = z.infer;
+export type DeleteLogResponse = z.infer;
+export type GetLogsRequest = z.infer;
+export type GetLogsResponse = z.infer;
+export type GetLogRequest = z.infer;
+export type GetLogResponse = z.infer;
+export type LogSummary = z.infer;
+export type LogFailure = z.infer;
+export type LogActiveTask = z.infer;
+export type GetLogSummaryRequest = z.infer;
+export type GetLogSummaryResponse = z.infer;
diff --git a/surfsense_web/hooks/use-connector-edit-page.ts b/surfsense_web/hooks/use-connector-edit-page.ts
index 80a7b4add..05f5abcc2 100644
--- a/surfsense_web/hooks/use-connector-edit-page.ts
+++ b/surfsense_web/hooks/use-connector-edit-page.ts
@@ -1,8 +1,11 @@
import { zodResolver } from "@hookform/resolvers/zod";
+import { useAtomValue } from "jotai";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
+import { updateConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
+import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import {
type EditConnectorFormValues,
type EditMode,
@@ -11,10 +14,8 @@ import {
type GithubRepo,
githubPatSchema,
} from "@/components/editConnector/types";
-import {
- type SearchSourceConnector,
- useSearchSourceConnectors,
-} from "@/hooks/use-search-source-connectors";
+import type { EnumConnectorName } from "@/contracts/enums/connector";
+import type { SearchSourceConnector } from "@/hooks/use-search-source-connectors";
import { authenticatedFetch } from "@/lib/auth-utils";
const normalizeListInput = (value: unknown): string[] => {
@@ -51,11 +52,8 @@ const normalizeBoolean = (value: unknown): boolean | null => {
export function useConnectorEditPage(connectorId: number, searchSpaceId: string) {
const router = useRouter();
- const {
- connectors,
- updateConnector,
- isLoading: connectorsLoading,
- } = useSearchSourceConnectors(false, parseInt(searchSpaceId));
+ const { data: connectors = [], isLoading: connectorsLoading } = useAtomValue(connectorsAtom);
+ const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom);
// State managed by the hook
const [connector, setConnector] = useState(null);
@@ -532,7 +530,13 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
}
try {
- await updateConnector(connectorId, updatePayload);
+ await updateConnector({
+ id: connectorId,
+ data: {
+ ...updatePayload,
+ connector_type: connector.connector_type as EnumConnectorName,
+ },
+ });
toast.success("Connector updated!");
const newlySavedConfig = updatePayload.config || originalConfig;
setOriginalConfig(newlySavedConfig);
diff --git a/surfsense_web/hooks/use-connectors.ts b/surfsense_web/hooks/use-connectors.ts
deleted file mode 100644
index 211a5e815..000000000
--- a/surfsense_web/hooks/use-connectors.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import { authenticatedFetch } from "@/lib/auth-utils";
-
-// Types for connector API
-export interface ConnectorConfig {
- [key: string]: string;
-}
-
-export interface Connector {
- id: number;
- name: string;
- connector_type: string;
- config: ConnectorConfig;
- created_at: string;
- user_id: string;
-}
-
-export interface CreateConnectorRequest {
- name: string;
- connector_type: string;
- config: ConnectorConfig;
-}
-
-// Get connector type display name
-export const getConnectorTypeDisplay = (type: string): string => {
- const typeMap: Record = {
- TAVILY_API: "Tavily API",
- SEARXNG_API: "SearxNG",
- };
- return typeMap[type] || type;
-};
-
-// API service for connectors
-export const ConnectorService = {
- // Create a new connector
- async createConnector(data: CreateConnectorRequest): Promise {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(data),
- }
- );
-
- if (!response.ok) {
- const errorData = await response.json();
- throw new Error(errorData.detail || "Failed to create connector");
- }
-
- return response.json();
- },
-
- // Get all connectors
- async getConnectors(skip = 0, limit = 100): Promise {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors?skip=${skip}&limit=${limit}`,
- { method: "GET" }
- );
-
- if (!response.ok) {
- const errorData = await response.json();
- throw new Error(errorData.detail || "Failed to fetch connectors");
- }
-
- return response.json();
- },
-
- // Get a specific connector
- async getConnector(connectorId: number): Promise {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
- { method: "GET" }
- );
-
- if (!response.ok) {
- const errorData = await response.json();
- throw new Error(errorData.detail || "Failed to fetch connector");
- }
-
- return response.json();
- },
-
- // Update a connector
- async updateConnector(connectorId: number, data: CreateConnectorRequest): Promise {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
- {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(data),
- }
- );
-
- if (!response.ok) {
- const errorData = await response.json();
- throw new Error(errorData.detail || "Failed to update connector");
- }
-
- return response.json();
- },
-
- // Delete a connector
- async deleteConnector(connectorId: number): Promise {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
- { method: "DELETE" }
- );
-
- if (!response.ok) {
- const errorData = await response.json();
- throw new Error(errorData.detail || "Failed to delete connector");
- }
- },
-};
diff --git a/surfsense_web/hooks/use-logs.ts b/surfsense_web/hooks/use-logs.ts
index cfd161de0..41b2660e5 100644
--- a/surfsense_web/hooks/use-logs.ts
+++ b/surfsense_web/hooks/use-logs.ts
@@ -1,7 +1,8 @@
"use client";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { toast } from "sonner";
-import { authenticatedFetch } from "@/lib/auth-utils";
+import { useQuery } from "@tanstack/react-query";
+import { useCallback, useMemo } from "react";
+import { logsApiService } from "@/lib/apis/logs-api.service";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
export type LogLevel = "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL";
export type LogStatus = "IN_PROGRESS" | "SUCCESS" | "FAILED";
@@ -38,6 +39,7 @@ export interface LogSummary {
message: string;
started_at: string;
source?: string;
+ document_id?: number;
}>;
recent_failures: Array<{
id: number;
@@ -50,267 +52,96 @@ export interface LogSummary {
}
export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) {
- const [logs, setLogs] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
// Memoize filters to prevent infinite re-renders
const memoizedFilters = useMemo(() => filters, [JSON.stringify(filters)]);
const buildQueryParams = useCallback(
(customFilters: LogFilters = {}) => {
- const params = new URLSearchParams();
+ const params: Record = {};
const allFilters = { ...memoizedFilters, ...customFilters };
if (allFilters.search_space_id) {
- params.append("search_space_id", allFilters.search_space_id.toString());
+ params["search_space_id"] = allFilters.search_space_id.toString();
}
if (allFilters.level) {
- params.append("level", allFilters.level);
+ params["level"] = allFilters.level;
}
if (allFilters.status) {
- params.append("status", allFilters.status);
+ params["status"] = allFilters.status;
}
if (allFilters.source) {
- params.append("source", allFilters.source);
+ params["source"] = allFilters.source;
}
if (allFilters.start_date) {
- params.append("start_date", allFilters.start_date);
+ params["start_date"] = allFilters.start_date;
}
if (allFilters.end_date) {
- params.append("end_date", allFilters.end_date);
+ params["end_date"] = allFilters.end_date;
}
- return params.toString();
+ return params;
},
[memoizedFilters]
);
- const fetchLogs = useCallback(
- async (customFilters: LogFilters = {}, options: { skip?: number; limit?: number } = {}) => {
- try {
- setLoading(true);
-
- const params = new URLSearchParams(buildQueryParams(customFilters));
- if (options.skip !== undefined) params.append("skip", options.skip.toString());
- if (options.limit !== undefined) params.append("limit", options.limit.toString());
-
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs?${params}`,
- { method: "GET" }
- );
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(errorData.detail || "Failed to fetch logs");
- }
-
- const data = await response.json();
- setLogs(data);
- setError(null);
- return data;
- } catch (err: any) {
- setError(err.message || "Failed to fetch logs");
- console.error("Error fetching logs:", err);
- throw err;
- } finally {
- setLoading(false);
- }
- },
- [buildQueryParams]
- );
-
- // Initial fetch
- useEffect(() => {
- const initialFilters = searchSpaceId
- ? { ...memoizedFilters, search_space_id: searchSpaceId }
- : memoizedFilters;
- fetchLogs(initialFilters);
- }, [searchSpaceId, fetchLogs, memoizedFilters]);
-
- // Function to refresh the logs list
- const refreshLogs = useCallback(
- async (customFilters: LogFilters = {}) => {
- const finalFilters = searchSpaceId
- ? { ...customFilters, search_space_id: searchSpaceId }
- : customFilters;
- return await fetchLogs(finalFilters);
- },
- [searchSpaceId, fetchLogs]
- );
-
- // Function to create a new log
- // Use silent: true to suppress toast notifications (for internal/background operations)
- const createLog = useCallback(
- async (logData: Omit, options?: { silent?: boolean }) => {
- const { silent = false } = options || {};
- try {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`,
- {
- headers: { "Content-Type": "application/json" },
- method: "POST",
- body: JSON.stringify(logData),
- }
- );
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(errorData.detail || "Failed to create log");
- }
-
- const newLog = await response.json();
- setLogs((prevLogs) => [newLog, ...prevLogs]);
- // Only show toast if not silent
- if (!silent) {
- toast.success("Log created successfully");
- }
- return newLog;
- } catch (err: any) {
- // Only show error toast if not silent
- if (!silent) {
- toast.error(err.message || "Failed to create log");
- }
- console.error("Error creating log:", err);
- throw err;
- }
- },
- []
- );
-
- // Function to update a log
- const updateLog = useCallback(
- async (
- logId: number,
- updateData: Partial>
- ) => {
- try {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs/${logId}`,
- {
- headers: { "Content-Type": "application/json" },
- method: "PUT",
- body: JSON.stringify(updateData),
- }
- );
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(errorData.detail || "Failed to update log");
- }
-
- const updatedLog = await response.json();
- setLogs((prevLogs) => prevLogs.map((log) => (log.id === logId ? updatedLog : log)));
- toast.success("Log updated successfully");
- return updatedLog;
- } catch (err: any) {
- toast.error(err.message || "Failed to update log");
- console.error("Error updating log:", err);
- throw err;
- }
- },
- []
- );
-
- // Function to delete a log
- const deleteLog = useCallback(async (logId: number) => {
- try {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs/${logId}`,
- { method: "DELETE" }
- );
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(errorData.detail || "Failed to delete log");
- }
-
- setLogs((prevLogs) => prevLogs.filter((log) => log.id !== logId));
- toast.success("Log deleted successfully");
- return true;
- } catch (err: any) {
- toast.error(err.message || "Failed to delete log");
- console.error("Error deleting log:", err);
- return false;
- }
- }, []);
-
- // Function to get a single log
- const getLog = useCallback(async (logId: number) => {
- try {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs/${logId}`,
- { method: "GET" }
- );
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(errorData.detail || "Failed to fetch log");
- }
-
- return await response.json();
- } catch (err: any) {
- toast.error(err.message || "Failed to fetch log");
- console.error("Error fetching log:", err);
- throw err;
- }
- }, []);
+ const {
+ data: logs,
+ isLoading: loading,
+ error,
+ refetch,
+ } = useQuery({
+ queryKey: cacheKeys.logs.withQueryParams({
+ search_space_id: searchSpaceId,
+ skip: 0,
+ limit: 5,
+ ...buildQueryParams(filters ?? {}),
+ }),
+ queryFn: () =>
+ logsApiService.getLogs({
+ queryParams: {
+ search_space_id: searchSpaceId,
+ skip: 0,
+ limit: 5,
+ ...buildQueryParams(filters ?? {}),
+ },
+ }),
+ enabled: !!searchSpaceId,
+ staleTime: 3 * 60 * 1000,
+ });
return {
- logs,
+ logs: logs ?? [],
loading,
error,
- refreshLogs,
- createLog,
- updateLog,
- deleteLog,
- getLog,
- fetchLogs,
+ refreshLogs: refetch,
};
}
-// Separate hook for log summary
-export function useLogsSummary(searchSpaceId: number, hours: number = 24) {
- const [summary, setSummary] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
+// Separate hook for log summary with optional polling support for document processing indicator UI
+export function useLogsSummary(
+ searchSpaceId: number,
+ hours: number = 24,
+ options: { refetchInterval?: number } = {}
+) {
+ const {
+ data: summary,
+ isLoading: loading,
+ error,
+ refetch,
+ } = useQuery({
+ queryKey: cacheKeys.logs.summary(searchSpaceId),
+ queryFn: () =>
+ logsApiService.getLogSummary({
+ search_space_id: searchSpaceId,
+ hours: hours,
+ }),
+ enabled: !!searchSpaceId,
+ staleTime: 3 * 60 * 1000,
+ // Enable refetch interval for document processing indicator polling
+ refetchInterval:
+ options.refetchInterval && options.refetchInterval > 0 ? options.refetchInterval : undefined,
+ });
- const fetchSummary = useCallback(async () => {
- if (!searchSpaceId) return;
-
- try {
- setLoading(true);
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs/search-space/${searchSpaceId}/summary?hours=${hours}`,
- { method: "GET" }
- );
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(errorData.detail || "Failed to fetch logs summary");
- }
-
- const data = await response.json();
- setSummary(data);
- setError(null);
- return data;
- } catch (err: any) {
- setError(err.message || "Failed to fetch logs summary");
- console.error("Error fetching logs summary:", err);
- throw err;
- } finally {
- setLoading(false);
- }
- }, [searchSpaceId, hours]);
-
- useEffect(() => {
- fetchSummary();
- }, [fetchSummary]);
-
- const refreshSummary = useCallback(() => {
- return fetchSummary();
- }, [fetchSummary]);
-
- return { summary, loading, error, refreshSummary };
+ return { summary, loading, error, refreshSummary: refetch };
}
diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts
index 0f3c17d4e..ff71fe14c 100644
--- a/surfsense_web/lib/apis/base-api.service.ts
+++ b/surfsense_web/lib/apis/base-api.service.ts
@@ -21,18 +21,23 @@ export type RequestOptions = {
};
class BaseApiService {
- bearerToken: string;
baseUrl: string;
noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"]; // Add more endpoints as needed
- constructor(bearerToken: string, baseUrl: string) {
- this.bearerToken = bearerToken;
+ // Use a getter to always read fresh token from localStorage
+ // This ensures the token is always up-to-date after login/logout
+ get bearerToken(): string {
+ return typeof window !== "undefined" ? getBearerToken() || "" : "";
+ }
+
+ constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
- setBearerToken(bearerToken: string) {
- this.bearerToken = bearerToken;
+ // Keep for backward compatibility, but token is now always read from localStorage
+ setBearerToken(_bearerToken: string) {
+ // No-op: token is now always read fresh from localStorage via the getter
}
async request(
@@ -293,7 +298,4 @@ class BaseApiService {
}
}
-export const baseApiService = new BaseApiService(
- typeof window !== "undefined" ? getBearerToken() || "" : "",
- process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || ""
-);
+export const baseApiService = new BaseApiService(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "");
diff --git a/surfsense_web/lib/apis/connectors-api.service.ts b/surfsense_web/lib/apis/connectors-api.service.ts
new file mode 100644
index 000000000..eeee5e6a1
--- /dev/null
+++ b/surfsense_web/lib/apis/connectors-api.service.ts
@@ -0,0 +1,200 @@
+import {
+ type CreateConnectorRequest,
+ createConnectorRequest,
+ createConnectorResponse,
+ type DeleteConnectorRequest,
+ deleteConnectorRequest,
+ deleteConnectorResponse,
+ type GetConnectorRequest,
+ type GetConnectorsRequest,
+ getConnectorRequest,
+ getConnectorResponse,
+ getConnectorsRequest,
+ getConnectorsResponse,
+ type IndexConnectorRequest,
+ indexConnectorRequest,
+ indexConnectorResponse,
+ type ListGitHubRepositoriesRequest,
+ listGitHubRepositoriesRequest,
+ listGitHubRepositoriesResponse,
+ type UpdateConnectorRequest,
+ updateConnectorRequest,
+ updateConnectorResponse,
+} from "@/contracts/types/connector.types";
+import { ValidationError } from "../error";
+import { baseApiService } from "./base-api.service";
+
+class ConnectorsApiService {
+ /**
+ * Get all connectors for a search space
+ */
+ getConnectors = async (request: GetConnectorsRequest) => {
+ const parsedRequest = getConnectorsRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+
+ const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ // Transform query params to be string values
+ const transformedQueryParams = parsedRequest.data.queryParams
+ ? Object.fromEntries(
+ Object.entries(parsedRequest.data.queryParams).map(([k, v]) => {
+ return [k, String(v)];
+ })
+ )
+ : undefined;
+
+ const queryParams = transformedQueryParams
+ ? new URLSearchParams(transformedQueryParams).toString()
+ : "";
+
+ return baseApiService.get(
+ `/api/v1/search-source-connectors?${queryParams}`,
+ getConnectorsResponse
+ );
+ };
+
+ /**
+ * Get a single connector by ID
+ */
+ getConnector = async (request: GetConnectorRequest) => {
+ const parsedRequest = getConnectorRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+
+ const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ return baseApiService.get(
+ `/api/v1/search-source-connectors/${request.id}`,
+ getConnectorResponse
+ );
+ };
+
+ /**
+ * Create a new connector
+ */
+ createConnector = async (request: CreateConnectorRequest) => {
+ const parsedRequest = createConnectorRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+
+ const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ const { data, queryParams } = parsedRequest.data;
+
+ // Transform query params to be string values
+ const transformedQueryParams = Object.fromEntries(
+ Object.entries(queryParams).map(([k, v]) => {
+ return [k, String(v)];
+ })
+ );
+
+ const queryString = new URLSearchParams(transformedQueryParams).toString();
+
+ return baseApiService.post(
+ `/api/v1/search-source-connectors?${queryString}`,
+ createConnectorResponse,
+ {
+ body: data,
+ }
+ );
+ };
+
+ /**
+ * Update an existing connector
+ */
+ updateConnector = async (request: UpdateConnectorRequest) => {
+ const parsedRequest = updateConnectorRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+
+ const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ const { id, data } = parsedRequest.data;
+
+ return baseApiService.put(`/api/v1/search-source-connectors/${id}`, updateConnectorResponse, {
+ body: data,
+ });
+ };
+
+ /**
+ * Delete a connector
+ */
+ deleteConnector = async (request: DeleteConnectorRequest) => {
+ const parsedRequest = deleteConnectorRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+
+ const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ return baseApiService.delete(
+ `/api/v1/search-source-connectors/${request.id}`,
+ deleteConnectorResponse
+ );
+ };
+
+ /**
+ * Index connector content
+ */
+ indexConnector = async (request: IndexConnectorRequest) => {
+ const parsedRequest = indexConnectorRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+
+ const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ const { connector_id, queryParams } = parsedRequest.data;
+
+ // Transform query params to be string values
+ const transformedQueryParams = Object.fromEntries(
+ Object.entries(queryParams).map(([k, v]) => {
+ return [k, String(v)];
+ })
+ );
+
+ const queryString = new URLSearchParams(transformedQueryParams).toString();
+
+ return baseApiService.post(
+ `/api/v1/search-source-connectors/${connector_id}/index?${queryString}`,
+ indexConnectorResponse
+ );
+ };
+
+ /**
+ * List GitHub repositories using a Personal Access Token
+ */
+ listGitHubRepositories = async (request: ListGitHubRepositoriesRequest) => {
+ const parsedRequest = listGitHubRepositoriesRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+
+ const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ return baseApiService.post(`/api/v1/github/repositories`, listGitHubRepositoriesResponse, {
+ body: parsedRequest.data,
+ });
+ };
+}
+
+export const connectorsApiService = new ConnectorsApiService();
diff --git a/surfsense_web/lib/apis/logs-api.service.ts b/surfsense_web/lib/apis/logs-api.service.ts
new file mode 100644
index 000000000..115f50497
--- /dev/null
+++ b/surfsense_web/lib/apis/logs-api.service.ts
@@ -0,0 +1,128 @@
+import {
+ type CreateLogRequest,
+ createLogRequest,
+ createLogResponse,
+ type DeleteLogRequest,
+ deleteLogRequest,
+ deleteLogResponse,
+ type GetLogRequest,
+ type GetLogSummaryRequest,
+ type GetLogsRequest,
+ getLogRequest,
+ getLogResponse,
+ getLogSummaryRequest,
+ getLogSummaryResponse,
+ getLogsRequest,
+ getLogsResponse,
+ type Log,
+ log,
+ type UpdateLogRequest,
+ updateLogRequest,
+ updateLogResponse,
+} from "@/contracts/types/log.types";
+import { ValidationError } from "../error";
+import { baseApiService } from "./base-api.service";
+
+class LogsApiService {
+ /**
+ * Get a list of logs with optional filtering and pagination
+ */
+ getLogs = async (request: GetLogsRequest) => {
+ const parsedRequest = getLogsRequest.safeParse(request);
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+ const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+ // Transform query params to be string values
+ const transformedQueryParams = parsedRequest.data.queryParams
+ ? Object.fromEntries(
+ Object.entries(parsedRequest.data.queryParams).map(([k, v]) => {
+ // Handle array values (document_type)
+ if (Array.isArray(v)) {
+ return [k, v.join(",")];
+ }
+ return [k, String(v)];
+ })
+ )
+ : undefined;
+
+ const queryParams = transformedQueryParams
+ ? new URLSearchParams(transformedQueryParams).toString()
+ : "";
+ return baseApiService.get(`/api/v1/logs?${queryParams}`, getLogsResponse);
+ };
+
+ /**
+ * Get a single log by ID
+ */
+ getLog = async (request: GetLogRequest) => {
+ const parsedRequest = getLogRequest.safeParse(request);
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+ const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+ return baseApiService.get(`/api/v1/logs/${request.id}`, getLogResponse);
+ };
+
+ /**
+ * Create a log entry
+ */
+ createLog = async (request: CreateLogRequest) => {
+ const parsedRequest = createLogRequest.safeParse(request);
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+ const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+ return baseApiService.post(`/api/v1/logs`, createLogResponse, {
+ body: parsedRequest.data,
+ });
+ };
+
+ /**
+ * Update a log entry
+ */
+ updateLog = async (logId: number, request: UpdateLogRequest) => {
+ const parsedRequest = updateLogRequest.safeParse(request);
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+ const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+ return baseApiService.put(`/api/v1/logs/${logId}`, updateLogResponse, {
+ body: parsedRequest.data,
+ });
+ };
+
+ /**
+ * Delete a log entry
+ */
+ deleteLog = async (request: DeleteLogRequest) => {
+ const parsedRequest = deleteLogRequest.safeParse(request);
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+ const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+ return baseApiService.delete(`/api/v1/logs/${parsedRequest.data.id}`, deleteLogResponse);
+ };
+
+ /**
+ * Get summary for logs by search space
+ */
+ getLogSummary = async (request: GetLogSummaryRequest) => {
+ const parsedRequest = getLogSummaryRequest.safeParse(request);
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+ const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+ const { search_space_id, hours } = parsedRequest.data;
+ const url = `/api/v1/logs/search-space/${search_space_id}/summary${hours ? `?hours=${hours}` : ""}`;
+ return baseApiService.get(url, getLogSummaryResponse);
+ };
+}
+
+export const logsApiService = new LogsApiService();
diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts
index 8e0f1431e..7722ec01e 100644
--- a/surfsense_web/lib/query-client/cache-keys.ts
+++ b/surfsense_web/lib/query-client/cache-keys.ts
@@ -1,4 +1,6 @@
+import type { GetConnectorsRequest } from "@/contracts/types/connector.types";
import type { GetDocumentsRequest } from "@/contracts/types/document.types";
+import type { GetLogsRequest } from "@/contracts/types/log.types";
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
export const cacheKeys = {
@@ -18,6 +20,13 @@ export const cacheKeys = {
typeCounts: (searchSpaceId?: string) => ["documents", "type-counts", searchSpaceId] as const,
byChunk: (chunkId: string) => ["documents", "by-chunk", chunkId] as const,
},
+ logs: {
+ list: (searchSpaceId?: number | string) => ["logs", "list", searchSpaceId] as const,
+ detail: (logId: number | string) => ["logs", "detail", logId] as const,
+ summary: (searchSpaceId?: number | string) => ["logs", "summary", searchSpaceId] as const,
+ withQueryParams: (queries: GetLogsRequest["queryParams"]) =>
+ ["logs", "with-query-params", ...(queries ? Object.values(queries) : [])] as const,
+ },
newLLMConfigs: {
all: (searchSpaceId: number) => ["new-llm-configs", searchSpaceId] as const,
byId: (configId: number) => ["new-llm-configs", "detail", configId] as const,
@@ -52,4 +61,11 @@ export const cacheKeys = {
all: (searchSpaceId: string) => ["invites", searchSpaceId] as const,
info: (inviteCode: string) => ["invites", "info", inviteCode] as const,
},
+ connectors: {
+ all: (searchSpaceId: string) => ["connectors", searchSpaceId] as const,
+ withQueryParams: (queries: GetConnectorsRequest["queryParams"]) =>
+ ["connectors", ...(queries ? Object.values(queries) : [])] as const,
+ byId: (connectorId: string) => ["connector", connectorId] as const,
+ index: () => ["connector", "index"] as const,
+ },
};
diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json
index eac362b9c..167a87dbc 100644
--- a/surfsense_web/messages/en.json
+++ b/surfsense_web/messages/en.json
@@ -267,7 +267,11 @@
"content_summary": "Content Summary",
"view_full": "View Full Content",
"filter_placeholder": "Filter by title...",
- "rows_per_page": "Rows per page"
+ "rows_per_page": "Rows per page",
+ "refresh": "Refresh",
+ "refresh_success": "Documents refreshed",
+ "processing_documents": "Processing documents...",
+ "active_tasks_count": "{count} active task(s)"
},
"add_connector": {
"title": "Connect Your Tools",
diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json
index b943a3c2c..3701a220d 100644
--- a/surfsense_web/messages/zh.json
+++ b/surfsense_web/messages/zh.json
@@ -267,7 +267,11 @@
"content_summary": "内容摘要",
"view_full": "查看完整内容",
"filter_placeholder": "按标题筛选...",
- "rows_per_page": "每页行数"
+ "rows_per_page": "每页行数",
+ "refresh": "刷新",
+ "refresh_success": "文档已刷新",
+ "processing_documents": "正在处理文档...",
+ "active_tasks_count": "{count} 个正在进行的工作项"
},
"add_connector": {
"title": "连接您的工具",