({
+ queryKey: isDocsChunk
+ ? cacheKeys.documents.byChunk(`doc-${chunkId}`)
+ : cacheKeys.documents.byChunk(chunkId.toString()),
+ queryFn: async () => {
+ if (isDocsChunk) {
+ return documentsApiService.getSurfsenseDocByChunk(chunkId);
+ }
+ return documentsApiService.getDocumentByChunk({ chunk_id: chunkId });
+ },
enabled: !!chunkId && open,
staleTime: 5 * 60 * 1000,
});
@@ -325,7 +340,7 @@ export function SourceDetailPanel({
{documentData?.title || title || "Source Document"}
- {documentData
+ {documentData && "document_type" in documentData
? formatDocumentType(documentData.document_type)
: sourceType && formatDocumentType(sourceType)}
{documentData?.chunks && (
@@ -491,7 +506,8 @@ export function SourceDetailPanel({
{/* Document Metadata */}
- {documentData.document_metadata &&
+ {"document_metadata" in documentData &&
+ documentData.document_metadata &&
Object.keys(documentData.document_metadata).length > 0 && (
fetchThreads(Number(searchSpaceId), 1), // Only need to check if any exist
enabled: !!searchSpaceId,
});
diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx
deleted file mode 100644
index f5146c427..000000000
--- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx
+++ /dev/null
@@ -1,383 +0,0 @@
-"use client";
-
-import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { useAtomValue, useSetAtom } from "jotai";
-import { Trash2 } from "lucide-react";
-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";
-import { currentUserAtom } from "@/atoms/user/user-query.atoms";
-import { AppSidebar } from "@/components/sidebar/app-sidebar";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { notesApiService } from "@/lib/apis/notes-api.service";
-import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
-import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
-import { cacheKeys } from "@/lib/query-client/cache-keys";
-
-interface AppSidebarProviderProps {
- searchSpaceId: string;
- navSecondary: {
- title: string;
- url: string;
- icon: string;
- }[];
- navMain: {
- title: string;
- url: string;
- icon: string;
- isActive?: boolean;
- items?: {
- title: string;
- url: string;
- }[];
- }[];
-}
-
-export function AppSidebarProvider({
- searchSpaceId,
- navSecondary,
- navMain,
-}: AppSidebarProviderProps) {
- 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
- const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom);
- const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom);
-
- // Fetch new chat threads
- const {
- data: threadsData,
- error: threadError,
- refetch: refetchThreads,
- } = useQuery({
- queryKey: ["threads", searchSpaceId],
- queryFn: () => fetchThreads(Number(searchSpaceId), 4),
- enabled: !!searchSpaceId,
- });
-
- const {
- data: searchSpace,
- isLoading: isLoadingSearchSpace,
- error: searchSpaceError,
- } = useQuery({
- queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
- queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
- enabled: !!searchSpaceId,
- });
-
- const { data: user } = useAtomValue(currentUserAtom);
-
- // Fetch notes
- const { data: notesData, refetch: refetchNotes } = useQuery({
- queryKey: ["notes", searchSpaceId],
- queryFn: () =>
- notesApiService.getNotes({
- search_space_id: Number(searchSpaceId),
- page_size: 4, // Get 4 notes for compact sidebar
- }),
- enabled: !!searchSpaceId,
- });
-
- const [showDeleteDialog, setShowDeleteDialog] = useState(false);
- const [threadToDelete, setThreadToDelete] = useState<{ id: number; name: string } | null>(null);
- const [showDeleteNoteDialog, setShowDeleteNoteDialog] = useState(false);
- const [noteToDelete, setNoteToDelete] = useState<{
- id: number;
- name: string;
- search_space_id: number;
- } | null>(null);
- const [isDeletingNote, setIsDeletingNote] = useState(false);
-
- // Transform threads to the format expected by AppSidebar
- const recentChats = useMemo(() => {
- if (!threadsData?.threads) return [];
-
- // Threads are already sorted by updated_at desc from the API
- return threadsData.threads.map((thread) => ({
- name: thread.title || `Chat ${thread.id}`,
- url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
- icon: "MessageCircleMore",
- id: thread.id,
- search_space_id: Number(searchSpaceId),
- actions: [
- {
- name: "Delete",
- icon: "Trash2",
- onClick: () => {
- setThreadToDelete({
- id: thread.id,
- name: thread.title || `Chat ${thread.id}`,
- });
- setShowDeleteDialog(true);
- },
- },
- ],
- }));
- }, [threadsData, searchSpaceId]);
-
- // Handle delete thread
- const handleDeleteThread = useCallback(async () => {
- if (!threadToDelete) return;
-
- setIsDeletingThread(true);
- try {
- 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 {
- setIsDeletingThread(false);
- setShowDeleteDialog(false);
- setThreadToDelete(null);
- }
- }, [threadToDelete, queryClient, searchSpaceId, router, currentChatId]);
-
- // Handle delete note with confirmation
- const handleDeleteNote = useCallback(async () => {
- if (!noteToDelete) return;
-
- setIsDeletingNote(true);
- try {
- await notesApiService.deleteNote({
- search_space_id: noteToDelete.search_space_id,
- note_id: noteToDelete.id,
- });
- refetchNotes();
- } catch (error) {
- console.error("Error deleting note:", error);
- } finally {
- setIsDeletingNote(false);
- setShowDeleteNoteDialog(false);
- setNoteToDelete(null);
- }
- }, [noteToDelete, refetchNotes]);
-
- // Memoized fallback chats
- const fallbackChats = useMemo(() => {
- if (threadError) {
- return [
- {
- name: t("error_loading_chats"),
- url: "#",
- icon: "AlertCircle",
- id: 0,
- search_space_id: Number(searchSpaceId),
- actions: [
- {
- name: tCommon("retry"),
- icon: "RefreshCw",
- onClick: () => refetchThreads(),
- },
- ],
- },
- ];
- }
-
- return [];
- }, [threadError, searchSpaceId, refetchThreads, t, tCommon]);
-
- // Use fallback chats if there's an error or no chats
- const displayChats = recentChats.length > 0 ? recentChats : fallbackChats;
-
- // Transform notes to the format expected by NavNotes
- const recentNotes = useMemo(() => {
- if (!notesData?.items) return [];
-
- // Sort notes by updated_at (most recent first), fallback to created_at if updated_at is null
- const sortedNotes = [...notesData.items].sort((a, b) => {
- const dateA = a.updated_at
- ? new Date(a.updated_at).getTime()
- : new Date(a.created_at).getTime();
- const dateB = b.updated_at
- ? new Date(b.updated_at).getTime()
- : new Date(b.created_at).getTime();
- return dateB - dateA; // Descending order (most recent first)
- });
-
- // Limit to 4 notes for compact sidebar
- return sortedNotes.slice(0, 4).map((note) => ({
- name: note.title,
- url: `/dashboard/${note.search_space_id}/editor/${note.id}`,
- icon: "FileText",
- id: note.id,
- search_space_id: note.search_space_id,
- actions: [
- {
- name: "Delete",
- icon: "Trash2",
- onClick: () => {
- setNoteToDelete({
- id: note.id,
- name: note.title,
- search_space_id: note.search_space_id,
- });
- setShowDeleteNoteDialog(true);
- },
- },
- ],
- }));
- }, [notesData]);
-
- // Handle add note - check for unsaved changes first
- const handleAddNote = useCallback(() => {
- const newNoteUrl = `/dashboard/${searchSpaceId}/editor/new`;
-
- if (hasUnsavedEditorChanges) {
- // Set pending navigation - the editor will show the unsaved changes dialog
- setPendingNavigation(newNoteUrl);
- } else {
- // No unsaved changes, navigate directly
- router.push(newNoteUrl);
- }
- }, [router, searchSpaceId, hasUnsavedEditorChanges, setPendingNavigation]);
-
- // Memoized updated navSecondary
- const updatedNavSecondary = useMemo(() => {
- const updated = [...navSecondary];
- if (updated.length > 0) {
- updated[0] = {
- ...updated[0],
- title:
- searchSpace?.name ||
- (isLoadingSearchSpace
- ? tCommon("loading")
- : searchSpaceError
- ? t("error_loading_space")
- : t("unknown_search_space")),
- };
- }
- return updated;
- }, [navSecondary, searchSpace?.name, isLoadingSearchSpace, searchSpaceError, t, tCommon]);
-
- // Prepare page usage data
- const pageUsage = user
- ? {
- pagesUsed: user.pages_used,
- pagesLimit: user.pages_limit,
- }
- : undefined;
-
- return (
- <>
-
-
- {/* Delete Confirmation Dialog */}
-
-
-
-
-
- {t("delete_chat")}
-
-
- {t("delete_chat_confirm")} {threadToDelete?.name}
- ? {t("action_cannot_undone")}
-
-
-
- setShowDeleteDialog(false)}
- disabled={isDeletingThread}
- >
- {tCommon("cancel")}
-
-
- {isDeletingThread ? (
- <>
-
- {t("deleting")}
- >
- ) : (
- <>
-
- {tCommon("delete")}
- >
- )}
-
-
-
-
-
- {/* Delete Note Confirmation Dialog */}
-
-
-
-
-
- {t("delete_note")}
-
-
- {t("delete_note_confirm")} {noteToDelete?.name} ?{" "}
- {t("action_cannot_undone")}
-
-
-
- setShowDeleteNoteDialog(false)}
- disabled={isDeletingNote}
- >
- {tCommon("cancel")}
-
-
- {isDeletingNote ? (
- <>
-
- {t("deleting")}
- >
- ) : (
- <>
-
- {tCommon("delete")}
- >
- )}
-
-
-
-
- >
- );
-}
diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx
deleted file mode 100644
index 8030cb9d2..000000000
--- a/surfsense_web/components/sidebar/app-sidebar.tsx
+++ /dev/null
@@ -1,473 +0,0 @@
-"use client";
-
-import { useAtomValue } from "jotai";
-import {
- AlertCircle,
- ArrowLeftRight,
- BookOpen,
- Cable,
- ChevronsUpDown,
- Database,
- ExternalLink,
- FileStack,
- FileText,
- Info,
- LogOut,
- Logs,
- type LucideIcon,
- MessageCircle,
- MessageCircleMore,
- MoonIcon,
- Podcast,
- RefreshCw,
- Settings2,
- SquareLibrary,
- SquareTerminal,
- SunIcon,
- Trash2,
- Undo2,
- UserPlus,
- Users,
-} from "lucide-react";
-import { useRouter } from "next/navigation";
-import { useTheme } from "next-themes";
-import { memo, useEffect, useMemo, useState } from "react";
-import { currentUserAtom } from "@/atoms/user/user-query.atoms";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuGroup,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { resetUser, trackLogout } from "@/lib/posthog/events";
-
-/**
- * Generates a consistent color based on a string (email)
- */
-function stringToColor(str: string): string {
- let hash = 0;
- for (let i = 0; i < str.length; i++) {
- hash = str.charCodeAt(i) + ((hash << 5) - hash);
- }
- const colors = [
- "#6366f1", // indigo
- "#8b5cf6", // violet
- "#a855f7", // purple
- "#d946ef", // fuchsia
- "#ec4899", // pink
- "#f43f5e", // rose
- "#ef4444", // red
- "#f97316", // orange
- "#eab308", // yellow
- "#84cc16", // lime
- "#22c55e", // green
- "#14b8a6", // teal
- "#06b6d4", // cyan
- "#0ea5e9", // sky
- "#3b82f6", // blue
- ];
- return colors[Math.abs(hash) % colors.length];
-}
-
-/**
- * Gets initials from an email address
- */
-function getInitials(email: string): string {
- const name = email.split("@")[0];
- const parts = name.split(/[._-]/);
- if (parts.length >= 2) {
- return (parts[0][0] + parts[1][0]).toUpperCase();
- }
- return name.slice(0, 2).toUpperCase();
-}
-
-/**
- * Dynamic avatar component that generates an SVG based on email
- */
-function UserAvatar({ email, size = 32 }: { email: string; size?: number }) {
- const bgColor = stringToColor(email);
- const initials = getInitials(email);
-
- return (
-
-
-
-
- {initials}
-
-
- );
-}
-
-import { NavChats } from "@/components/sidebar/nav-chats";
-import { NavMain } from "@/components/sidebar/nav-main";
-import { NavNotes } from "@/components/sidebar/nav-notes";
-import { NavSecondary } from "@/components/sidebar/nav-secondary";
-import { PageUsageDisplay } from "@/components/sidebar/page-usage-display";
-import {
- Sidebar,
- SidebarContent,
- SidebarFooter,
- SidebarHeader,
- SidebarMenu,
- SidebarMenuButton,
- SidebarMenuItem,
-} from "@/components/ui/sidebar";
-
-// Map of icon names to their components
-export const iconMap: Record = {
- BookOpen,
- Cable,
- Database,
- FileStack,
- Undo2,
- MessageCircleMore,
- Settings2,
- SquareLibrary,
- FileText,
- SquareTerminal,
- AlertCircle,
- Info,
- ExternalLink,
- Trash2,
- Podcast,
- Users,
- RefreshCw,
- MessageCircle,
- Logs,
-};
-
-const defaultData = {
- user: {
- name: "Surf",
- email: "m@example.com",
- avatar: "/icon-128.png",
- },
- navMain: [
- {
- title: "Chat",
- url: "#",
- icon: "SquareTerminal",
- isActive: true,
- items: [],
- },
- {
- title: "Sources",
- url: "#",
- icon: "Database",
- items: [
- {
- title: "Manage Documents",
- url: "#",
- },
- {
- title: "Manage Connectors",
- url: "#",
- },
- ],
- },
- ],
- navSecondary: [
- {
- title: "SEARCH SPACE",
- url: "#",
- icon: "LifeBuoy",
- },
- ],
- RecentChats: [
- {
- name: "Design Engineering",
- url: "#",
- icon: "MessageCircleMore",
- id: 1001,
- },
- {
- name: "Sales & Marketing",
- url: "#",
- icon: "MessageCircleMore",
- id: 1002,
- },
- {
- name: "Travel",
- url: "#",
- icon: "MessageCircleMore",
- id: 1003,
- },
- ],
- RecentNotes: [
- {
- name: "Meeting Notes",
- url: "#",
- icon: "FileText",
- id: 2001,
- },
- {
- name: "Project Ideas",
- url: "#",
- icon: "FileText",
- id: 2002,
- },
- ],
-};
-
-interface AppSidebarProps extends React.ComponentProps {
- searchSpaceId?: string;
- navMain?: {
- title: string;
- url: string;
- icon: string;
- isActive?: boolean;
- items?: {
- title: string;
- url: string;
- }[];
- }[];
- navSecondary?: {
- title: string;
- url: string;
- icon: string;
- }[];
- RecentChats?: {
- name: string;
- url: string;
- icon: string;
- id?: number;
- search_space_id?: number;
- actions?: {
- name: string;
- icon: string;
- onClick: () => void;
- }[];
- }[];
- RecentNotes?: {
- name: string;
- url: string;
- icon: string;
- id?: number;
- search_space_id?: number;
- actions?: {
- name: string;
- icon: string;
- onClick: () => void;
- }[];
- }[];
- user?: {
- name: string;
- email: string;
- avatar: string;
- };
- pageUsage?: {
- pagesUsed: number;
- pagesLimit: number;
- };
- onAddNote?: () => void;
-}
-
-// Memoized AppSidebar component for better performance
-export const AppSidebar = memo(function AppSidebar({
- searchSpaceId,
- navMain = defaultData.navMain,
- navSecondary = defaultData.navSecondary,
- RecentChats = defaultData.RecentChats,
- RecentNotes = defaultData.RecentNotes,
- pageUsage,
- onAddNote,
- ...props
-}: AppSidebarProps) {
- const router = useRouter();
- const { theme, setTheme } = useTheme();
- const { data: user, isPending: isLoadingUser } = useAtomValue(currentUserAtom);
- const [isClient, setIsClient] = useState(false);
-
- useEffect(() => {
- setIsClient(true);
- }, []);
-
- // Process navMain to resolve icon names to components
- const processedNavMain = useMemo(() => {
- return navMain.map((item) => ({
- ...item,
- icon: iconMap[item.icon] || SquareTerminal,
- }));
- }, [navMain]);
-
- // Process navSecondary to resolve icon names to components
- const processedNavSecondary = useMemo(() => {
- return navSecondary.map((item) => ({
- ...item,
- icon: iconMap[item.icon] || Undo2,
- }));
- }, [navSecondary]);
-
- // Process RecentChats to resolve icon names to components
- const processedRecentChats = useMemo(() => {
- return (
- RecentChats?.map((item) => ({
- ...item,
- icon: iconMap[item.icon] || MessageCircleMore,
- })) || []
- );
- }, [RecentChats]);
-
- // Process RecentNotes to resolve icon names to components
- const processedRecentNotes = useMemo(() => {
- return (
- RecentNotes?.map((item) => ({
- ...item,
- icon: iconMap[item.icon] || FileText,
- })) || []
- );
- }, [RecentNotes]);
-
- // Get user display name from email
- const userDisplayName = user?.email ? user.email.split("@")[0] : "User";
- const userEmail = user?.email || (isLoadingUser ? "Loading..." : "Unknown");
-
- const handleLogout = () => {
- try {
- // Track logout event and reset PostHog identity
- trackLogout();
- resetUser();
-
- if (typeof window !== "undefined") {
- localStorage.removeItem("surfsense_bearer_token");
- router.push("/");
- }
- } catch (error) {
- console.error("Error during logout:", error);
- router.push("/");
- }
- };
-
- return (
-
-
-
-
-
-
-
-
- {user?.email ? (
-
- ) : (
-
- )}
-
-
- {userDisplayName}
- {userEmail}
-
-
-
-
-
-
-
-
- {user?.email ? (
-
- ) : (
-
- )}
-
-
- {userDisplayName}
- {userEmail}
-
-
-
-
-
- {searchSpaceId && (
- <>
- router.push(`/dashboard/${searchSpaceId}/settings`)}
- >
-
- Settings
-
- router.push(`/dashboard/${searchSpaceId}/team`)}
- >
-
- Invite members
-
- >
- )}
- router.push("/dashboard")}>
-
- Switch workspace
-
-
-
-
- {isClient && (
- setTheme(theme === "dark" ? "light" : "dark")}>
- {theme === "dark" ? (
-
- ) : (
-
- )}
- {theme === "dark" ? "Light mode" : "Dark mode"}
-
- )}
-
-
-
-
- Logout
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {pageUsage && (
-
- )}
-
-
-
- );
-});
diff --git a/surfsense_web/components/sidebar/nav-chats.tsx b/surfsense_web/components/sidebar/nav-chats.tsx
deleted file mode 100644
index ba0004fc8..000000000
--- a/surfsense_web/components/sidebar/nav-chats.tsx
+++ /dev/null
@@ -1,237 +0,0 @@
-"use client";
-
-import {
- ChevronRight,
- FolderOpen,
- Loader2,
- type LucideIcon,
- MessageCircleMore,
- MoreHorizontal,
- RefreshCw,
- Trash2,
-} from "lucide-react";
-import { usePathname, useRouter } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { useCallback, useState } from "react";
-import { Button } from "@/components/ui/button";
-import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import {
- SidebarGroup,
- SidebarGroupContent,
- SidebarGroupLabel,
- SidebarMenu,
- SidebarMenuButton,
- SidebarMenuItem,
- useSidebar,
-} from "@/components/ui/sidebar";
-import { cn } from "@/lib/utils";
-import { AllChatsSidebar } from "./all-chats-sidebar";
-
-interface ChatAction {
- name: string;
- icon: string;
- onClick: () => void;
-}
-
-interface ChatItem {
- name: string;
- url: string;
- icon: LucideIcon;
- id?: number;
- search_space_id?: number;
- actions?: ChatAction[];
-}
-
-interface NavChatsProps {
- chats: ChatItem[];
- defaultOpen?: boolean;
- searchSpaceId?: string;
-}
-
-// Map of icon names to their components
-const actionIconMap: Record = {
- MessageCircleMore,
- Trash2,
- MoreHorizontal,
- RefreshCw,
-};
-
-export function NavChats({ chats, defaultOpen = true, searchSpaceId }: NavChatsProps) {
- const t = useTranslations("sidebar");
- const router = useRouter();
- const pathname = usePathname();
- const { setOpenMobile } = useSidebar();
- const [isDeleting, setIsDeleting] = useState(null);
- const [isOpen, setIsOpen] = useState(defaultOpen);
- const [isAllChatsSidebarOpen, setIsAllChatsSidebarOpen] = useState(false);
-
- // Handle chat deletion with loading state
- const handleDeleteChat = useCallback(async (chatId: number, deleteAction: () => void) => {
- setIsDeleting(chatId);
- try {
- await deleteAction();
- } finally {
- setIsDeleting(null);
- }
- }, []);
-
- // Handle chat navigation
- const handleChatClick = useCallback(
- (url: string) => {
- router.push(url);
- },
- [router]
- );
-
- return (
-
-
-
-
-
-
- {t("recent_chats") || "Recent Chats"}
-
-
-
- {/* Action buttons - always visible on hover */}
-
- {searchSpaceId && chats.length > 0 && (
- {
- e.stopPropagation();
- setIsAllChatsSidebarOpen(true);
- }}
- aria-label={t("view_all_chats") || "View all chats"}
- >
-
-
- )}
-
-
-
-
- {chats.length > 0 ? (
-
-
- {chats.map((chat) => {
- const isDeletingChat = isDeleting === chat.id;
- const isActive = pathname === chat.url;
-
- return (
-
- {/* Main navigation button */}
- handleChatClick(chat.url)}
- disabled={isDeletingChat}
- className={cn(
- "pr-8", // Make room for the action button
- isActive && "bg-sidebar-accent text-sidebar-accent-foreground",
- isDeletingChat && "opacity-50"
- )}
- >
-
- {chat.name}
-
-
- {/* Actions dropdown - positioned absolutely */}
- {chat.actions && chat.actions.length > 0 && (
-
-
-
-
- {isDeletingChat ? (
-
- ) : (
-
- )}
-
- {t("more_options") || "More options"}
-
-
-
-
- {chat.actions.map((action, actionIndex) => {
- const ActionIcon = actionIconMap[action.icon] || MessageCircleMore;
- const isDeleteAction = action.name.toLowerCase().includes("delete");
-
- return (
- {
- if (isDeleteAction) {
- handleDeleteChat(chat.id || 0, action.onClick);
- } else {
- action.onClick();
- }
- }}
- disabled={isDeletingChat}
- className={
- isDeleteAction
- ? "text-destructive focus:text-destructive"
- : ""
- }
- >
-
-
- {isDeletingChat && isDeleteAction
- ? t("deleting") || "Deleting..."
- : action.name}
-
-
- );
- })}
-
-
-
- )}
-
- );
- })}
-
-
- ) : (
-
-
- {t("no_recent_chats") || "No recent chats"}
-
- )}
-
-
-
- {/* All Chats Sheet */}
- {searchSpaceId && (
- setOpenMobile(false)}
- />
- )}
-
- );
-}
diff --git a/surfsense_web/components/sidebar/nav-main.tsx b/surfsense_web/components/sidebar/nav-main.tsx
deleted file mode 100644
index a0dbe912f..000000000
--- a/surfsense_web/components/sidebar/nav-main.tsx
+++ /dev/null
@@ -1,207 +0,0 @@
-"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";
-
-import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
-import {
- SidebarGroup,
- SidebarGroupLabel,
- SidebarMenu,
- SidebarMenuAction,
- SidebarMenuButton,
- SidebarMenuItem,
- SidebarMenuSub,
- SidebarMenuSubButton,
- SidebarMenuSubItem,
-} from "@/components/ui/sidebar";
-
-interface NavItem {
- title: string;
- url: string;
- icon: LucideIcon;
- isActive?: boolean;
- items?: {
- title: string;
- url: string;
- }[];
-}
-
-interface NavMainProps {
- items: NavItem[];
-}
-
-export function NavMain({ items }: 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 => {
- const titleMap: Record = {
- Researcher: "researcher",
- "Manage LLMs": "manage_llms",
- Sources: "sources",
- "Manage Documents": "manage_documents",
- "Manage Connectors": "manage_connectors",
- Podcasts: "podcasts",
- Logs: "logs",
- Platform: "platform",
- Team: "team",
- };
-
- const key = titleMap[title];
- 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]);
-
- // Track expanded state for items with sub-menus (like Sources)
- const [expandedItems, setExpandedItems] = useState>(() => {
- const initial: Record = {};
- items.forEach((item) => {
- if (item.items?.length) {
- initial[item.title] = item.isActive ?? false;
- }
- });
- return initial;
- });
-
- // Handle collapsible state change
- const handleOpenChange = useCallback((title: string, isOpen: boolean) => {
- setExpandedItems((prev) => ({ ...prev, [title]: isOpen }));
- }, []);
-
- return (
-
- {translateTitle("Platform")}
-
- {memoizedItems.map((item, index) => {
- const translatedTitle = translateTitle(item.title);
- const hasSub = !!item.items?.length;
- const isActive = isItemActive(item);
- const isItemOpen = expandedItems[item.title] ?? isActive ?? false;
- return (
- handleOpenChange(item.title, open) : undefined}
- defaultOpen={!hasSub ? isActive : undefined}
- >
-
- {hasSub ? (
- // When the item has children, make the whole row a collapsible trigger
- <>
-
-
-
-
- {translatedTitle}
-
-
-
-
-
-
-
- Toggle submenu
-
-
-
-
-
- {item.items?.map((subItem, subIndex) => {
- const translatedSubTitle = translateTitle(subItem.title);
- const isDocumentsLink =
- subItem.title === "Manage Documents" ||
- translatedSubTitle.toLowerCase().includes("documents");
- return (
-
-
-
- {translatedSubTitle}
-
-
-
- );
- })}
-
-
- >
- ) : (
- // Leaf item: treat as a normal link
-
-
-
- {translatedTitle}
-
-
- )}
-
-
- );
- })}
-
-
- );
-}
diff --git a/surfsense_web/components/sidebar/nav-notes.tsx b/surfsense_web/components/sidebar/nav-notes.tsx
deleted file mode 100644
index e9f94fe80..000000000
--- a/surfsense_web/components/sidebar/nav-notes.tsx
+++ /dev/null
@@ -1,287 +0,0 @@
-"use client";
-
-import {
- ChevronRight,
- FileText,
- FolderOpen,
- Loader2,
- type LucideIcon,
- MoreHorizontal,
- Plus,
- Trash2,
-} from "lucide-react";
-import { usePathname, useRouter } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { useCallback, useMemo, useState } from "react";
-import { Button } from "@/components/ui/button";
-import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import {
- SidebarGroup,
- SidebarGroupContent,
- SidebarGroupLabel,
- SidebarMenu,
- SidebarMenuButton,
- SidebarMenuItem,
- useSidebar,
-} from "@/components/ui/sidebar";
-import { useLogsSummary } from "@/hooks/use-logs";
-import { cn } from "@/lib/utils";
-import { AllNotesSidebar } from "./all-notes-sidebar";
-
-interface NoteAction {
- name: string;
- icon: string;
- onClick: () => void;
-}
-
-interface NoteItem {
- name: string;
- url: string;
- icon: LucideIcon;
- id?: number;
- search_space_id?: number;
- actions?: NoteAction[];
-}
-
-interface NavNotesProps {
- notes: NoteItem[];
- onAddNote?: () => void;
- defaultOpen?: boolean;
- searchSpaceId?: string;
-}
-
-// Map of icon names to their components
-const actionIconMap: Record = {
- FileText,
- Trash2,
- MoreHorizontal,
-};
-
-export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }: NavNotesProps) {
- const t = useTranslations("sidebar");
- const router = useRouter();
- const pathname = usePathname();
- const { setOpenMobile } = useSidebar();
- 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
- // Smart polling: only polls when there are active tasks, stops when idle
- const { summary } = useLogsSummary(searchSpaceId ? Number(searchSpaceId) : 0, 24, {
- enablePolling: true,
- refetchInterval: 5000, // Poll every 5 seconds when tasks are active
- });
-
- // 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]);
-
- // Handle note deletion with loading state
- const handleDeleteNote = useCallback(async (noteId: number, deleteAction: () => void) => {
- setIsDeleting(noteId);
- try {
- await deleteAction();
- } finally {
- setIsDeleting(null);
- }
- }, []);
-
- // Handle note navigation
- const handleNoteClick = useCallback(
- (url: string) => {
- router.push(url);
- },
- [router]
- );
-
- return (
-
-
-
-
-
-
- {t("notes") || "Notes"}
-
-
-
- {/* Action buttons - always visible on hover */}
-
- {searchSpaceId && notes.length > 0 && (
-
{
- e.stopPropagation();
- setIsAllNotesSidebarOpen(true);
- }}
- aria-label={t("view_all_notes") || "View all notes"}
- >
-
-
- )}
- {onAddNote && (
-
{
- e.stopPropagation();
- onAddNote();
- }}
- aria-label={t("add_note") || "Add note"}
- >
-
-
- )}
-
-
-
-
-
-
- {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 (
-
- {/* Main navigation button */}
- handleNoteClick(note.url)}
- 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}
-
-
- {/* Actions dropdown - positioned absolutely */}
- {note.actions && note.actions.length > 0 && (
-
-
-
-
- {isDeletingNote ? (
-
- ) : (
-
- )}
-
- {t("more_options") || "More options"}
-
-
-
-
- {note.actions.map((action, actionIndex) => {
- const ActionIcon = actionIconMap[action.icon] || FileText;
- const isDeleteAction = action.name.toLowerCase().includes("delete");
-
- return (
- {
- if (isDeleteAction) {
- handleDeleteNote(note.id || 0, action.onClick);
- } else {
- action.onClick();
- }
- }}
- disabled={isDeletingNote}
- className={
- isDeleteAction
- ? "text-destructive focus:text-destructive"
- : ""
- }
- >
-
-
- {isDeletingNote && isDeleteAction
- ? t("deleting") || "Deleting..."
- : action.name}
-
-
- );
- })}
-
-
-
- )}
-
- );
- })
- ) : (
-
- {onAddNote ? (
-
-
- {t("create_new_note") || "Create a new note"}
-
- ) : (
-
-
- {t("no_notes") || "No notes yet"}
-
- )}
-
- )}
-
-
-
-
-
- {/* All Notes Sheet */}
- {searchSpaceId && (
- setOpenMobile(false)}
- />
- )}
-
- );
-}
diff --git a/surfsense_web/components/sidebar/nav-secondary.tsx b/surfsense_web/components/sidebar/nav-secondary.tsx
deleted file mode 100644
index 23aeabc38..000000000
--- a/surfsense_web/components/sidebar/nav-secondary.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-"use client";
-
-import type { LucideIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
-import type * as React from "react";
-import { useMemo } from "react";
-
-import {
- SidebarGroup,
- SidebarGroupLabel,
- SidebarMenu,
- SidebarMenuButton,
- SidebarMenuItem,
-} from "@/components/ui/sidebar";
-
-interface NavSecondaryItem {
- title: string;
- url: string;
- icon: LucideIcon;
-}
-
-export function NavSecondary({
- items,
- ...props
-}: {
- items: NavSecondaryItem[];
-} & React.ComponentPropsWithoutRef) {
- const t = useTranslations("sidebar");
-
- // Memoize items to prevent unnecessary re-renders
- const memoizedItems = useMemo(() => items, [items]);
-
- return (
-
- {t("search_space")}
-
- {memoizedItems.map((item, index) => (
-
- {item.url === "#" ? (
- // Non-interactive display item (e.g., search space name)
-
-
- {item.title}
-
- ) : (
- // Interactive link item
-
-
-
- {item.title}
-
-
- )}
-
- ))}
-
-
- );
-}
diff --git a/surfsense_web/components/sidebar/page-usage-display.tsx b/surfsense_web/components/sidebar/page-usage-display.tsx
deleted file mode 100644
index 6c640c0aa..000000000
--- a/surfsense_web/components/sidebar/page-usage-display.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-"use client";
-
-import { Mail } from "lucide-react";
-import { Progress } from "@/components/ui/progress";
-import {
- SidebarGroup,
- SidebarGroupContent,
- SidebarGroupLabel,
- useSidebar,
-} from "@/components/ui/sidebar";
-
-interface PageUsageDisplayProps {
- pagesUsed: number;
- pagesLimit: number;
-}
-
-export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) {
- const { state } = useSidebar();
- const usagePercentage = (pagesUsed / pagesLimit) * 100;
- const isCollapsed = state === "collapsed";
-
- return (
-
-
- Page Usage
-
-
-
- {isCollapsed ? (
- // Show only a compact progress indicator when collapsed
-
- ) : (
- // Show full details when expanded
- <>
-
-
- {pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages
-
- {usagePercentage.toFixed(0)}%
-
-
-
-
- Contact to increase limits
-
- >
- )}
-
-
-
- );
-}
diff --git a/surfsense_web/content/docs/connectors/meta.json b/surfsense_web/content/docs/connectors/meta.json
index 7a075fbb5..9b416afdd 100644
--- a/surfsense_web/content/docs/connectors/meta.json
+++ b/surfsense_web/content/docs/connectors/meta.json
@@ -9,6 +9,7 @@
"discord",
"jira",
"linear",
+ "microsoft-teams",
"confluence",
"airtable",
"clickup",
diff --git a/surfsense_web/content/docs/connectors/microsoft-teams.mdx b/surfsense_web/content/docs/connectors/microsoft-teams.mdx
new file mode 100644
index 000000000..daa6eb375
--- /dev/null
+++ b/surfsense_web/content/docs/connectors/microsoft-teams.mdx
@@ -0,0 +1,101 @@
+---
+title: Microsoft Teams
+description: Connect your Microsoft Teams to SurfSense
+---
+
+# Microsoft Teams OAuth Integration Setup Guide
+
+This guide walks you through setting up a Microsoft Teams OAuth integration for SurfSense using Azure App Registration.
+
+## Step 1: Access Azure App Registrations
+
+1. Navigate to [portal.azure.com](https://portal.azure.com)
+2. In the search bar, type **"app reg"**
+3. Select **"App registrations"** from the Services results
+
+
+
+## Step 2: Create New Registration
+
+1. On the **App registrations** page, click **"+ New registration"**
+
+
+
+## Step 3: Register the Application
+
+Fill in the application details:
+
+| Field | Value |
+|-------|-------|
+| **Name** | `SurfSense` |
+| **Supported account types** | Select **"Accounts in any organizational directory (Any Microsoft Entra ID tenant - Multitenant) and personal Microsoft accounts"** |
+| **Redirect URI** | Platform: `Web`, URI: `http://localhost:8000/api/v1/auth/teams/connector/callback` |
+
+Click **"Register"**
+
+
+
+## Step 4: Get Application (Client) ID
+
+After registration, you'll be taken to the app's **Overview** page. Here you'll find:
+
+1. Copy the **Application (client) ID** - this is your Client ID
+2. Note the **Directory (tenant) ID** if needed
+
+
+
+## Step 5: Create Client Secret
+
+1. In the left sidebar under **Manage**, click **"Certificates & secrets"**
+2. Select the **"Client secrets"** tab
+3. Click **"+ New client secret"**
+4. Enter a description (e.g., `SurfSense`) and select an expiration period
+5. Click **"Add"**
+
+
+
+6. **Important**: Copy the secret **Value** immediately - it won't be shown again!
+
+
+
+> ⚠️ Never share your client secret publicly or include it in code repositories.
+
+## Step 6: Configure API Permissions
+
+1. In the left sidebar under **Manage**, click **"API permissions"**
+2. Click **"+ Add a permission"**
+3. Select **"Microsoft Graph"**
+4. Select **"Delegated permissions"**
+5. Add the following permissions:
+
+| Permission | Type | Description | Admin Consent |
+|------------|------|-------------|---------------|
+| `Channel.ReadBasic.All` | Delegated | Read the names and descriptions of channels | No |
+| `ChannelMessage.Read.All` | Delegated | Read user channel messages | Yes |
+| `offline_access` | Delegated | Maintain access to data you have given it access to | No |
+| `Team.ReadBasic.All` | Delegated | Read the names and descriptions of teams | No |
+| `User.Read` | Delegated | Sign in and read user profile | No |
+
+6. Click **"Add permissions"**
+
+> ⚠️ The `ChannelMessage.Read.All` permission requires admin consent. An admin will need to click **"Grant admin consent for [Directory]"** for full functionality.
+
+
+
+---
+
+## Running SurfSense with Microsoft Teams Connector
+
+Add the Microsoft Teams environment variables to your Docker run command:
+
+```bash
+docker run -d -p 3000:3000 -p 8000:8000 \
+ -v surfsense-data:/data \
+ # Microsoft Teams Connector
+ -e TEAMS_CLIENT_ID=your_microsoft_client_id \
+ -e TEAMS_CLIENT_SECRET=your_microsoft_client_secret \
+ -e TEAMS_REDIRECT_URI=http://localhost:8000/api/v1/auth/teams/connector/callback \
+ --name surfsense \
+ --restart unless-stopped \
+ ghcr.io/modsetter/surfsense:latest
+```
diff --git a/surfsense_web/contracts/enums/connector.ts b/surfsense_web/contracts/enums/connector.ts
index ae80cf871..fc65585e2 100644
--- a/surfsense_web/contracts/enums/connector.ts
+++ b/surfsense_web/contracts/enums/connector.ts
@@ -4,6 +4,7 @@ export enum EnumConnectorName {
LINKUP_API = "LINKUP_API",
BAIDU_SEARCH_API = "BAIDU_SEARCH_API",
SLACK_CONNECTOR = "SLACK_CONNECTOR",
+ TEAMS_CONNECTOR = "TEAMS_CONNECTOR",
NOTION_CONNECTOR = "NOTION_CONNECTOR",
GITHUB_CONNECTOR = "GITHUB_CONNECTOR",
LINEAR_CONNECTOR = "LINEAR_CONNECTOR",
diff --git a/surfsense_web/contracts/enums/connectorIcons.tsx b/surfsense_web/contracts/enums/connectorIcons.tsx
index 22bc734aa..befe132f9 100644
--- a/surfsense_web/contracts/enums/connectorIcons.tsx
+++ b/surfsense_web/contracts/enums/connectorIcons.tsx
@@ -31,6 +31,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
return ;
case EnumConnectorName.SLACK_CONNECTOR:
return ;
+ case EnumConnectorName.TEAMS_CONNECTOR:
+ return ;
case EnumConnectorName.NOTION_CONNECTOR:
return ;
case EnumConnectorName.DISCORD_CONNECTOR:
diff --git a/surfsense_web/contracts/types/connector.types.ts b/surfsense_web/contracts/types/connector.types.ts
index 5b67297ae..f864ae16f 100644
--- a/surfsense_web/contracts/types/connector.types.ts
+++ b/surfsense_web/contracts/types/connector.types.ts
@@ -8,6 +8,7 @@ export const searchSourceConnectorTypeEnum = z.enum([
"LINKUP_API",
"BAIDU_SEARCH_API",
"SLACK_CONNECTOR",
+ "TEAMS_CONNECTOR",
"NOTION_CONNECTOR",
"GITHUB_CONNECTOR",
"LINEAR_CONNECTOR",
diff --git a/surfsense_web/contracts/types/document.types.ts b/surfsense_web/contracts/types/document.types.ts
index f7eb8f278..757c6aeb4 100644
--- a/surfsense_web/contracts/types/document.types.ts
+++ b/surfsense_web/contracts/types/document.types.ts
@@ -59,6 +59,26 @@ export const documentWithChunks = document.extend({
),
});
+/**
+ * Surfsense documentation schemas
+ * Follows the same pattern as document/documentWithChunks
+ */
+export const surfsenseDocsChunk = z.object({
+ id: z.number(),
+ content: z.string(),
+});
+
+export const surfsenseDocsDocument = z.object({
+ id: z.number(),
+ title: z.string(),
+ source: z.string(),
+ content: z.string(),
+});
+
+export const surfsenseDocsDocumentWithChunks = surfsenseDocsDocument.extend({
+ chunks: z.array(surfsenseDocsChunk),
+});
+
/**
* Get documents
*/
@@ -154,6 +174,15 @@ export const getDocumentByChunkRequest = z.object({
export const getDocumentByChunkResponse = documentWithChunks;
+/**
+ * Get Surfsense docs by chunk
+ */
+export const getSurfsenseDocsByChunkRequest = z.object({
+ chunk_id: z.number(),
+});
+
+export const getSurfsenseDocsByChunkResponse = surfsenseDocsDocumentWithChunks;
+
/**
* Update document
*/
@@ -193,3 +222,8 @@ export type UpdateDocumentResponse = z.infer;
export type DeleteDocumentRequest = z.infer;
export type DeleteDocumentResponse = z.infer;
export type DocumentTypeEnum = z.infer;
+export type SurfsenseDocsChunk = z.infer;
+export type SurfsenseDocsDocument = z.infer;
+export type SurfsenseDocsDocumentWithChunks = z.infer;
+export type GetSurfsenseDocsByChunkRequest = z.infer;
+export type GetSurfsenseDocsByChunkResponse = z.infer;
diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts
index ff71fe14c..5849003e2 100644
--- a/surfsense_web/lib/apis/base-api.service.ts
+++ b/surfsense_web/lib/apis/base-api.service.ts
@@ -129,20 +129,24 @@ class BaseApiService {
throw new AppError("Failed to parse response", response.status, response.statusText);
}
+ // Handle 401 first before other error handling - ensures token is cleared and user redirected
+ if (response.status === 401) {
+ handleUnauthorized();
+ throw new AuthenticationError(
+ typeof data === "object" && "detail" in data
+ ? data.detail
+ : "You are not authenticated. Please login again.",
+ response.status,
+ response.statusText
+ );
+ }
+
// For fastapi errors response
if (typeof data === "object" && "detail" in data) {
throw new AppError(data.detail, response.status, response.statusText);
}
switch (response.status) {
- case 401:
- // Use centralized auth handler for 401 responses
- handleUnauthorized();
- throw new AuthenticationError(
- "You are not authenticated. Please login again.",
- response.status,
- response.statusText
- );
case 403:
throw new AuthorizationError(
"You don't have permission to access this resource.",
diff --git a/surfsense_web/lib/apis/documents-api.service.ts b/surfsense_web/lib/apis/documents-api.service.ts
index cf7a4b778..2e7d18e44 100644
--- a/surfsense_web/lib/apis/documents-api.service.ts
+++ b/surfsense_web/lib/apis/documents-api.service.ts
@@ -17,6 +17,7 @@ import {
getDocumentsResponse,
getDocumentTypeCountsRequest,
getDocumentTypeCountsResponse,
+ getSurfsenseDocsByChunkResponse,
type SearchDocumentsRequest,
searchDocumentsRequest,
searchDocumentsResponse,
@@ -209,6 +210,17 @@ class DocumentsApiService {
);
};
+ /**
+ * Get Surfsense documentation by chunk ID
+ * Used for resolving [citation:doc-XXX] citations
+ */
+ getSurfsenseDocByChunk = async (chunkId: number) => {
+ return baseApiService.get(
+ `/api/v1/surfsense-docs/by-chunk/${chunkId}`,
+ getSurfsenseDocsByChunkResponse
+ );
+ };
+
/**
* Update a document
*/
diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json
index 6c64e62ba..57f03a0fb 100644
--- a/surfsense_web/messages/en.json
+++ b/surfsense_web/messages/en.json
@@ -28,7 +28,10 @@
"info": "Information",
"required": "Required",
"optional": "Optional",
- "retry": "Retry"
+ "retry": "Retry",
+ "owner": "Owner",
+ "shared": "Shared",
+ "settings": "Settings"
},
"auth": {
"login": "Login",
@@ -77,6 +80,45 @@
"creating_account_btn": "Creating account...",
"redirecting_login": "Redirecting to login page..."
},
+ "searchSpace": {
+ "create_title": "Create Search Space",
+ "create_description": "Create a new search space to organize your knowledge",
+ "name_label": "Name",
+ "name_placeholder": "Enter search space name",
+ "description_label": "Description",
+ "description_placeholder": "What is this search space for?",
+ "create_button": "Create",
+ "creating": "Creating...",
+ "all_search_spaces": "All Search Spaces",
+ "search_spaces_count": "{count, plural, =0 {No search spaces} =1 {1 search space} other {# search spaces}}",
+ "no_search_spaces": "No search spaces yet",
+ "create_first_search_space": "Create your first search space to get started",
+ "members_count": "{count, plural, =1 {1 member} other {# members}}",
+ "create_new_search_space": "Create new search space",
+ "delete_title": "Delete Search Space",
+ "delete_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone and will permanently remove all data.",
+ "welcome_title": "Welcome to SurfSense",
+ "welcome_description": "Create your first search space to start organizing your knowledge, connecting sources, and chatting with AI.",
+ "create_first_button": "Create your first search space"
+ },
+ "userSettings": {
+ "title": "User Settings",
+ "description": "Manage your account settings and API access",
+ "back_to_app": "Back to app",
+ "footer": "User Settings",
+ "api_key_nav_label": "API Key",
+ "api_key_nav_description": "Manage your API access token",
+ "api_key_title": "API Key",
+ "api_key_description": "Use this key to authenticate API requests",
+ "api_key_warning_title": "Keep it secret",
+ "api_key_warning_description": "Your API key grants full access to your account. Never share it publicly or commit it to version control.",
+ "your_api_key": "Your API Key",
+ "copied": "Copied!",
+ "copy": "Copy to clipboard",
+ "no_api_key": "No API key found",
+ "usage_title": "How to use",
+ "usage_description": "Include your API key in the Authorization header:"
+ },
"dashboard": {
"title": "Dashboard",
"search_spaces": "Search Spaces",
@@ -622,7 +664,16 @@
"chat_archived": "Chat archived",
"chat_unarchived": "Chat restored",
"no_archived_chats": "No archived chats",
- "error_archiving_chat": "Failed to archive chat"
+ "error_archiving_chat": "Failed to archive chat",
+ "new_chat": "New chat",
+ "select_search_space": "Select Search Space",
+ "manage_members": "Manage members",
+ "search_space_settings": "Search Space settings",
+ "see_all_search_spaces": "See all search spaces",
+ "expand_sidebar": "Expand sidebar",
+ "collapse_sidebar": "Collapse sidebar",
+ "user_settings": "User settings",
+ "logout": "Logout"
},
"errors": {
"something_went_wrong": "Something went wrong",
diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json
index 67069cf55..89cb7813a 100644
--- a/surfsense_web/messages/zh.json
+++ b/surfsense_web/messages/zh.json
@@ -28,7 +28,10 @@
"info": "信息",
"required": "必填",
"optional": "可选",
- "retry": "重试"
+ "retry": "重试",
+ "owner": "所有者",
+ "shared": "共享",
+ "settings": "设置"
},
"auth": {
"login": "登录",
@@ -77,6 +80,45 @@
"creating_account_btn": "创建中...",
"redirecting_login": "正在跳转到登录页面..."
},
+ "searchSpace": {
+ "create_title": "创建搜索空间",
+ "create_description": "创建一个新的搜索空间来组织您的知识",
+ "name_label": "名称",
+ "name_placeholder": "输入搜索空间名称",
+ "description_label": "描述",
+ "description_placeholder": "这个搜索空间是做什么的?",
+ "create_button": "创建",
+ "creating": "创建中...",
+ "all_search_spaces": "所有搜索空间",
+ "search_spaces_count": "{count, plural, =0 {没有搜索空间} other {# 个搜索空间}}",
+ "no_search_spaces": "暂无搜索空间",
+ "create_first_search_space": "创建您的第一个搜索空间以开始使用",
+ "members_count": "{count, plural, other {# 位成员}}",
+ "create_new_search_space": "创建新的搜索空间",
+ "delete_title": "删除搜索空间",
+ "delete_confirm": "您确定要删除「{name}」吗?此操作无法撤销,将永久删除所有数据。",
+ "welcome_title": "欢迎使用 SurfSense",
+ "welcome_description": "创建您的第一个搜索空间,开始组织知识、连接数据源并与AI对话。",
+ "create_first_button": "创建第一个搜索空间"
+ },
+ "userSettings": {
+ "title": "用户设置",
+ "description": "管理您的账户设置和API访问",
+ "back_to_app": "返回应用",
+ "footer": "用户设置",
+ "api_key_nav_label": "API密钥",
+ "api_key_nav_description": "管理您的API访问令牌",
+ "api_key_title": "API密钥",
+ "api_key_description": "使用此密钥验证API请求",
+ "api_key_warning_title": "请保密",
+ "api_key_warning_description": "您的API密钥可以完全访问您的账户。请勿公开分享或提交到版本控制。",
+ "your_api_key": "您的API密钥",
+ "copied": "已复制!",
+ "copy": "复制到剪贴板",
+ "no_api_key": "未找到API密钥",
+ "usage_title": "使用方法",
+ "usage_description": "在Authorization请求头中包含您的API密钥:"
+ },
"dashboard": {
"title": "仪表盘",
"search_spaces": "搜索空间",
@@ -616,7 +658,16 @@
"more_options": "更多选项",
"clear_search": "清除搜索",
"view_all_notes": "查看所有笔记",
- "add_note": "添加笔记"
+ "add_note": "添加笔记",
+ "new_chat": "新对话",
+ "select_search_space": "选择搜索空间",
+ "manage_members": "管理成员",
+ "search_space_settings": "搜索空间设置",
+ "see_all_search_spaces": "查看所有搜索空间",
+ "expand_sidebar": "展开侧边栏",
+ "collapse_sidebar": "收起侧边栏",
+ "user_settings": "用户设置",
+ "logout": "退出登录"
},
"errors": {
"something_went_wrong": "出错了",
diff --git a/surfsense_web/public/docs/connectors/microsoft-teams/azure-api-permissions.png b/surfsense_web/public/docs/connectors/microsoft-teams/azure-api-permissions.png
new file mode 100644
index 000000000..f362a3344
Binary files /dev/null and b/surfsense_web/public/docs/connectors/microsoft-teams/azure-api-permissions.png differ
diff --git a/surfsense_web/public/docs/connectors/microsoft-teams/azure-app-overview.png b/surfsense_web/public/docs/connectors/microsoft-teams/azure-app-overview.png
new file mode 100644
index 000000000..27a4290e7
Binary files /dev/null and b/surfsense_web/public/docs/connectors/microsoft-teams/azure-app-overview.png differ
diff --git a/surfsense_web/public/docs/connectors/microsoft-teams/azure-app-registrations.png b/surfsense_web/public/docs/connectors/microsoft-teams/azure-app-registrations.png
new file mode 100644
index 000000000..f7865fe5e
Binary files /dev/null and b/surfsense_web/public/docs/connectors/microsoft-teams/azure-app-registrations.png differ
diff --git a/surfsense_web/public/docs/connectors/microsoft-teams/azure-certificates-created.png b/surfsense_web/public/docs/connectors/microsoft-teams/azure-certificates-created.png
new file mode 100644
index 000000000..abfc90dde
Binary files /dev/null and b/surfsense_web/public/docs/connectors/microsoft-teams/azure-certificates-created.png differ
diff --git a/surfsense_web/public/docs/connectors/microsoft-teams/azure-certificates-empty.png b/surfsense_web/public/docs/connectors/microsoft-teams/azure-certificates-empty.png
new file mode 100644
index 000000000..603d79155
Binary files /dev/null and b/surfsense_web/public/docs/connectors/microsoft-teams/azure-certificates-empty.png differ
diff --git a/surfsense_web/public/docs/connectors/microsoft-teams/azure-register-app.png b/surfsense_web/public/docs/connectors/microsoft-teams/azure-register-app.png
new file mode 100644
index 000000000..d1a5d1b6e
Binary files /dev/null and b/surfsense_web/public/docs/connectors/microsoft-teams/azure-register-app.png differ
diff --git a/surfsense_web/public/docs/connectors/microsoft-teams/azure-search-app-reg.png b/surfsense_web/public/docs/connectors/microsoft-teams/azure-search-app-reg.png
new file mode 100644
index 000000000..974b9d013
Binary files /dev/null and b/surfsense_web/public/docs/connectors/microsoft-teams/azure-search-app-reg.png differ
diff --git a/surfsense_web/public/icon-128.png b/surfsense_web/public/icon-128.png
deleted file mode 100644
index 5d1464a7a..000000000
Binary files a/surfsense_web/public/icon-128.png and /dev/null differ
diff --git a/surfsense_web/public/icon-128.svg b/surfsense_web/public/icon-128.svg
new file mode 100644
index 000000000..1d73fc752
--- /dev/null
+++ b/surfsense_web/public/icon-128.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+