From fbeffd58fe9c1f9468085d14187f105cb0f2f604 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 13 Jan 2026 01:07:08 -0800 Subject: [PATCH 1/7] refactor: simplify MobileSidebar component by removing unused props --- .../layout/ui/sidebar/MobileSidebar.tsx | 54 +++++++------------ 1 file changed, 19 insertions(+), 35 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index 0efec49c9..a57fd045c 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -111,29 +111,24 @@ export function MobileSidebar({ - {/* Sidebar Content */} -
- { - onNewChat(); - onOpenChange(false); - }} - onChatSelect={handleChatSelect} - onChatDelete={onChatDelete} - onViewAllChats={onViewAllChats} - notes={notes} - activeNoteId={activeNoteId} - onNoteSelect={handleNoteSelect} - onNoteDelete={onNoteDelete} - onAddNote={onAddNote} - onViewAllNotes={onViewAllNotes} + {/* Sidebar Content */} +
+ { + onNewChat(); + onOpenChange(false); + }} + onChatSelect={handleChatSelect} + onChatDelete={onChatDelete} + onViewAllSharedChats={onViewAllSharedChats} + onViewAllPrivateChats={onViewAllPrivateChats} user={user} onSettings={onSettings} onManageMembers={onManageMembers} @@ -142,18 +137,7 @@ export function MobileSidebar({ pageUsage={pageUsage} className="w-full border-none" /> - onViewAllSharedChats={onViewAllSharedChats} - onViewAllPrivateChats={onViewAllPrivateChats} - user={user} - onSettings={onSettings} - onManageMembers={onManageMembers} - onSeeAllSearchSpaces={onSeeAllSearchSpaces} - onUserSettings={onUserSettings} - onLogout={onLogout} - pageUsage={pageUsage} - className="w-full border-none" - /> -
+
); From 25b911830656a7c9b02edb036668091d48a1ac95 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 13 Jan 2026 01:45:58 -0800 Subject: [PATCH 2/7] feat: implement search space deletion and fixed rback issues with shared chats --- .../app/routes/new_chat_routes.py | 121 +++++++--- .../app/tasks/chat/stream_new_chat.py | 19 +- .../documents/(manage)/page.tsx | 22 +- .../new-chat/[[...chat_id]]/page.tsx | 8 +- .../[search_space_id]/settings/page.tsx | 5 +- .../app/dashboard/user/settings/page.tsx | 1 - .../atoms/chat/mentioned-documents.atom.ts | 2 +- .../components/assistant-ui/composer.tsx | 32 ++- .../assistant-ui/inline-mention-editor.tsx | 4 +- .../components/assistant-ui/markdown-text.tsx | 2 +- .../components/assistant-ui/thread.tsx | 32 ++- .../layout/providers/LayoutDataProvider.tsx | 94 +++++++- .../layout/ui/icon-rail/IconRail.tsx | 10 + .../layout/ui/icon-rail/SearchSpaceAvatar.tsx | 116 +++++++-- .../layout/ui/shell/LayoutShell.tsx | 8 + .../layout/ui/sidebar/MobileSidebar.tsx | 64 ++--- .../new-chat/document-mention-picker.tsx | 14 +- surfsense_web/components/ui/context-menu.tsx | 225 ++++++++++++++++++ .../lib/apis/documents-api.service.ts | 2 +- surfsense_web/messages/en.json | 3 + surfsense_web/package.json | 1 + surfsense_web/pnpm-lock.yaml | 30 +++ 22 files changed, 671 insertions(+), 144 deletions(-) create mode 100644 surfsense_web/components/ui/context-menu.tsx diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index ff9a8675b..fb5808307 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -56,6 +56,7 @@ router = APIRouter() async def check_thread_access( + session: AsyncSession, thread: NewChatThread, user: User, require_ownership: bool = False, @@ -65,14 +66,16 @@ async def check_thread_access( Access is granted if: - User is the creator of the thread - - Thread visibility is SEARCH_SPACE (and user has permission to read chats) - - Thread is a legacy thread (created_by_id is NULL) - visible to all + - Thread visibility is SEARCH_SPACE (any member can access) + - Thread is a legacy thread (created_by_id is NULL) - only if user is search space owner Args: + session: Database session thread: The thread to check access for user: The user requesting access require_ownership: If True, only the creator can access (for edit/delete operations) - Legacy threads (NULL creator) are treated as accessible by all + For SEARCH_SPACE threads, any member with permission can access + Legacy threads (NULL creator) are accessible by search space owner Returns: True if access is granted @@ -83,10 +86,31 @@ async def check_thread_access( is_owner = thread.created_by_id == user.id is_legacy = thread.created_by_id is None - # Legacy threads are accessible to all users in the search space - if is_legacy: + # Shared threads (SEARCH_SPACE) are accessible by any member + # This check comes first so shared threads are always accessible + if thread.visibility == ChatVisibility.SEARCH_SPACE: + # For ownership-required operations on shared threads, any member can proceed + # (permission check is done at route level) return True + # For legacy threads (created before visibility feature), + # only the search space owner can access + if is_legacy: + search_space_query = select(SearchSpace).filter( + SearchSpace.id == thread.search_space_id + ) + search_space_result = await session.execute(search_space_query) + search_space = search_space_result.scalar_one_or_none() + is_search_space_owner = search_space and search_space.user_id == user.id + + if is_search_space_owner: + return True + # Legacy threads are not accessible to non-owners + raise HTTPException( + status_code=403, + detail="You don't have access to this chat", + ) + # If ownership is required, only the creator can access if require_ownership: if not is_owner: @@ -96,13 +120,10 @@ async def check_thread_access( ) return True - # For read access: owner or shared threads + # For read access: owner can access their own private threads if is_owner: return True - if thread.visibility == ChatVisibility.SEARCH_SPACE: - return True - # Private thread and user is not the owner raise HTTPException( status_code=403, @@ -129,7 +150,7 @@ async def list_threads( A user can see threads that are: - Created by them (regardless of visibility) - Shared with the search space (visibility = SEARCH_SPACE) - - Legacy threads with no creator (created_by_id is NULL) + - Legacy threads with no creator (created_by_id is NULL) - only if user is search space owner Args: search_space_id: The search space to list threads for @@ -146,19 +167,32 @@ async def list_threads( "You don't have permission to read chats in this search space", ) - # Get threads that are either: + # Check if user is the search space owner (for legacy thread visibility) + search_space_query = select(SearchSpace).filter( + SearchSpace.id == search_space_id + ) + search_space_result = await session.execute(search_space_query) + search_space = search_space_result.scalar_one_or_none() + is_search_space_owner = search_space and search_space.user_id == user.id + + # Build filter conditions: # 1. Created by the current user (any visibility) # 2. Shared with the search space (visibility = SEARCH_SPACE) - # 3. Legacy threads with no creator (created_by_id is NULL) - visible to all + # 3. Legacy threads (created_by_id is NULL) - only visible to search space owner + filter_conditions = [ + NewChatThread.created_by_id == user.id, + NewChatThread.visibility == ChatVisibility.SEARCH_SPACE, + ] + + # Only include legacy threads for the search space owner + if is_search_space_owner: + filter_conditions.append(NewChatThread.created_by_id.is_(None)) + query = ( select(NewChatThread) .filter( NewChatThread.search_space_id == search_space_id, - or_( - NewChatThread.created_by_id == user.id, - NewChatThread.visibility == ChatVisibility.SEARCH_SPACE, - NewChatThread.created_by_id.is_(None), # Legacy threads - ), + or_(*filter_conditions), ) .order_by(NewChatThread.updated_at.desc()) ) @@ -171,9 +205,9 @@ async def list_threads( archived_threads = [] for thread in all_threads: - # Legacy threads (no creator) are treated as own threads for display purposes - is_own_thread = ( - thread.created_by_id == user.id or thread.created_by_id is None + # Legacy threads (no creator) are treated as own threads for owner + is_own_thread = thread.created_by_id == user.id or ( + thread.created_by_id is None and is_search_space_owner ) item = ThreadListItem( id=thread.id, @@ -222,7 +256,7 @@ async def search_threads( A user can search threads that are: - Created by them (regardless of visibility) - Shared with the search space (visibility = SEARCH_SPACE) - - Legacy threads with no creator (created_by_id is NULL) + - Legacy threads with no creator (created_by_id is NULL) - only if user is search space owner Args: search_space_id: The search space to search in @@ -239,17 +273,31 @@ async def search_threads( "You don't have permission to read chats in this search space", ) + # Check if user is the search space owner (for legacy thread visibility) + search_space_query = select(SearchSpace).filter( + SearchSpace.id == search_space_id + ) + search_space_result = await session.execute(search_space_query) + search_space = search_space_result.scalar_one_or_none() + is_search_space_owner = search_space and search_space.user_id == user.id + + # Build filter conditions + filter_conditions = [ + NewChatThread.created_by_id == user.id, + NewChatThread.visibility == ChatVisibility.SEARCH_SPACE, + ] + + # Only include legacy threads for the search space owner + if is_search_space_owner: + filter_conditions.append(NewChatThread.created_by_id.is_(None)) + # Search accessible threads by title (case-insensitive) query = ( select(NewChatThread) .filter( NewChatThread.search_space_id == search_space_id, NewChatThread.title.ilike(f"%{title}%"), - or_( - NewChatThread.created_by_id == user.id, - NewChatThread.visibility == ChatVisibility.SEARCH_SPACE, - NewChatThread.created_by_id.is_(None), # Legacy threads - ), + or_(*filter_conditions), ) .order_by(NewChatThread.updated_at.desc()) ) @@ -264,9 +312,10 @@ async def search_threads( archived=thread.archived, visibility=thread.visibility, created_by_id=thread.created_by_id, - # Legacy threads (no creator) are treated as own threads + # Legacy threads (no creator) are treated as own threads for owner is_own_thread=( - thread.created_by_id == user.id or thread.created_by_id is None + thread.created_by_id == user.id + or (thread.created_by_id is None and is_search_space_owner) ), created_at=thread.created_at, updated_at=thread.updated_at, @@ -383,7 +432,7 @@ async def get_thread_messages( ) # Check thread-level access based on visibility - await check_thread_access(thread, user) + await check_thread_access(session, thread, user) # Return messages in the format expected by assistant-ui messages = [ @@ -447,7 +496,7 @@ async def get_thread_full( ) # Check thread-level access based on visibility - await check_thread_access(thread, user) + await check_thread_access(session, thread, user) return thread @@ -500,7 +549,7 @@ async def update_thread( # For PRIVATE threads, only the creator can update # For SEARCH_SPACE threads, any member with permission can update if db_thread.visibility == ChatVisibility.PRIVATE: - await check_thread_access(db_thread, user, require_ownership=True) + await check_thread_access(session, db_thread, user, require_ownership=True) # Update fields update_data = thread_update.model_dump(exclude_unset=True) @@ -568,7 +617,7 @@ async def delete_thread( # For PRIVATE threads, only the creator can delete # For SEARCH_SPACE threads, any member with permission can delete if db_thread.visibility == ChatVisibility.PRIVATE: - await check_thread_access(db_thread, user, require_ownership=True) + await check_thread_access(session, db_thread, user, require_ownership=True) await session.delete(db_thread) await session.commit() @@ -628,7 +677,7 @@ async def update_thread_visibility( ) # Only the creator can change visibility - await check_thread_access(db_thread, user, require_ownership=True) + await check_thread_access(session, db_thread, user, require_ownership=True) # Update visibility db_thread.visibility = visibility_update.visibility @@ -714,7 +763,7 @@ async def append_message( ) # Check thread-level access based on visibility - await check_thread_access(thread, user) + await check_thread_access(session, thread, user) # Convert string role to enum role_str = ( @@ -825,7 +874,7 @@ async def list_messages( ) # Check thread-level access based on visibility - await check_thread_access(thread, user) + await check_thread_access(session, thread, user) # Get messages query = ( @@ -894,7 +943,7 @@ async def handle_new_chat( ) # Check thread-level access based on visibility - await check_thread_access(thread, user) + await check_thread_access(session, thread, user) # Get search space to check LLM config preferences search_space_result = await session.execute( diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 99b6e8703..a74f134dc 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -77,29 +77,31 @@ def format_mentioned_surfsense_docs_as_context( return "" import json - + context_parts = [""] context_parts.append( "The user has explicitly mentioned the following SurfSense documentation pages. " "These are official documentation about how to use SurfSense and should be used to answer questions about the application. " "Use [citation:CHUNK_ID] format for citations (e.g., [citation:doc-123])." ) - + for doc in documents: metadata_json = json.dumps({"source": doc.source}, ensure_ascii=False) - + context_parts.append("") context_parts.append("") context_parts.append(f" doc-{doc.id}") context_parts.append(" SURFSENSE_DOCS") context_parts.append(f" <![CDATA[{doc.title}]]>") context_parts.append(f" ") - context_parts.append(f" ") + context_parts.append( + f" " + ) context_parts.append("") context_parts.append("") context_parts.append("") - - if hasattr(doc, 'chunks') and doc.chunks: + + if hasattr(doc, "chunks") and doc.chunks: for chunk in doc.chunks: context_parts.append( f" " @@ -108,11 +110,11 @@ def format_mentioned_surfsense_docs_as_context( context_parts.append( f" " ) - + context_parts.append("") context_parts.append("") context_parts.append("") - + context_parts.append("") return "\n".join(context_parts) @@ -263,6 +265,7 @@ async def stream_new_chat( mentioned_surfsense_docs: list[SurfsenseDocsDocument] = [] if mentioned_surfsense_doc_ids: from sqlalchemy.orm import selectinload + result = await session.execute( select(SurfsenseDocsDocument) .options(selectinload(SurfsenseDocsDocument.chunks)) diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx index 742be6ff4..7d266cafe 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx @@ -65,8 +65,7 @@ export default function DocumentsTable() { ); // Check if only SURFSENSE_DOCS is selected (skip regular docs query) - const onlySurfsenseDocsSelected = - activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS"; + const onlySurfsenseDocsSelected = activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS"; // Build query parameters for fetching documents (excluding SURFSENSE_DOCS type) const queryParams = useMemo( @@ -130,9 +129,11 @@ export default function DocumentsTable() { queryKey: ["surfsense-docs", debouncedSearch, pageIndex, pageSize], queryFn: () => documentsApiService.getSurfsenseDocs({ - page: pageIndex, - page_size: pageSize, - title: debouncedSearch.trim() || undefined, + queryParams: { + page: pageIndex, + page_size: pageSize, + title: debouncedSearch.trim() || undefined, + }, }), staleTime: 3 * 60 * 1000, // 3 minutes enabled: showSurfsenseDocs, @@ -256,7 +257,16 @@ export default function DocumentsTable() { } finally { setIsRefreshing(false); } - }, [debouncedSearch, refetchSearch, refetchDocuments, refetchSurfsenseDocs, showSurfsenseDocs, onlySurfsenseDocsSelected, t, isRefreshing]); + }, [ + debouncedSearch, + refetchSearch, + refetchDocuments, + refetchSurfsenseDocs, + showSurfsenseDocs, + onlySurfsenseDocsSelected, + t, + isRefreshing, + ]); // Set up smart polling for active tasks - only polls when tasks are in progress const { summary } = useLogsSummary(searchSpaceId, 24, { diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 693aa3061..fbb92ae2c 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -459,7 +459,9 @@ export default function NewChatPage() { // Track message sent trackChatMessageSent(searchSpaceId, currentThreadId, { hasAttachments: messageAttachments.length > 0, - hasMentionedDocuments: mentionedDocumentIds.surfsense_doc_ids.length > 0 || mentionedDocumentIds.document_ids.length > 0, + hasMentionedDocuments: + mentionedDocumentIds.surfsense_doc_ids.length > 0 || + mentionedDocumentIds.document_ids.length > 0, messageLength: userQuery.length, }); @@ -683,7 +685,9 @@ export default function NewChatPage() { messages: messageHistory, attachments: attachments.length > 0 ? attachments : undefined, mentioned_document_ids: hasDocumentIds ? mentionedDocumentIds.document_ids : undefined, - mentioned_surfsense_doc_ids: hasSurfsenseDocIds ? mentionedDocumentIds.surfsense_doc_ids : undefined, + mentioned_surfsense_doc_ids: hasSurfsenseDocIds + ? mentionedDocumentIds.surfsense_doc_ids + : undefined, }), signal: controller.signal, }); diff --git a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx index 26cb882e2..fb2f49317 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx @@ -170,7 +170,9 @@ function SettingsSidebar({ > {t(item.labelKey)}

-

{t(item.descriptionKey)}

+

+ {t(item.descriptionKey)} +

- ); diff --git a/surfsense_web/app/dashboard/user/settings/page.tsx b/surfsense_web/app/dashboard/user/settings/page.tsx index d09428a52..bf88e65e5 100644 --- a/surfsense_web/app/dashboard/user/settings/page.tsx +++ b/surfsense_web/app/dashboard/user/settings/page.tsx @@ -160,7 +160,6 @@ function UserSettingsSidebar({ ); })} - ); diff --git a/surfsense_web/atoms/chat/mentioned-documents.atom.ts b/surfsense_web/atoms/chat/mentioned-documents.atom.ts index 17ae38616..aba2736e5 100644 --- a/surfsense_web/atoms/chat/mentioned-documents.atom.ts +++ b/surfsense_web/atoms/chat/mentioned-documents.atom.ts @@ -19,7 +19,7 @@ export const mentionedDocumentIdsAtom = atom<{ * Atom to store the full document objects mentioned in the current chat composer. * This persists across component remounts. */ -export const mentionedDocumentsAtom = atom<(Pick)[]>([]); +export const mentionedDocumentsAtom = atom[]>([]); /** * Simplified document info for display purposes diff --git a/surfsense_web/components/assistant-ui/composer.tsx b/surfsense_web/components/assistant-ui/composer.tsx index 417f7c70f..0e8c5bca5 100644 --- a/surfsense_web/components/assistant-ui/composer.tsx +++ b/surfsense_web/components/assistant-ui/composer.tsx @@ -54,8 +54,12 @@ export const Composer: FC = () => { // Sync mentioned document IDs to atom for use in chat request useEffect(() => { setMentionedDocumentIds({ - surfsense_doc_ids: mentionedDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id), - document_ids: mentionedDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), + surfsense_doc_ids: mentionedDocuments + .filter((doc) => doc.document_type === "SURFSENSE_DOCS") + .map((doc) => doc.id), + document_ids: mentionedDocuments + .filter((doc) => doc.document_type !== "SURFSENSE_DOCS") + .map((doc) => doc.id), }); }, [mentionedDocuments, setMentionedDocumentIds]); @@ -138,12 +142,14 @@ export const Composer: FC = () => { const handleDocumentRemove = useCallback( (docId: number, docType?: string) => { setMentionedDocuments((prev) => { - const updated = prev.filter( - (doc) => !(doc.id === docId && doc.document_type === docType) - ); + const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType)); setMentionedDocumentIds({ - surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id), - document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), + surfsense_doc_ids: updated + .filter((doc) => doc.document_type === "SURFSENSE_DOCS") + .map((doc) => doc.id), + document_ids: updated + .filter((doc) => doc.document_type !== "SURFSENSE_DOCS") + .map((doc) => doc.id), }); return updated; }); @@ -153,9 +159,7 @@ export const Composer: FC = () => { const handleDocumentsMention = useCallback( (documents: Pick[]) => { - const existingKeys = new Set( - mentionedDocuments.map((d) => `${d.document_type}:${d.id}`) - ); + const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)); const newDocs = documents.filter( (doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`) ); @@ -171,8 +175,12 @@ export const Composer: FC = () => { ); const updated = [...prev, ...uniqueNewDocs]; setMentionedDocumentIds({ - surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id), - document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), + surfsense_doc_ids: updated + .filter((doc) => doc.document_type === "SURFSENSE_DOCS") + .map((doc) => doc.id), + document_ids: updated + .filter((doc) => doc.document_type !== "SURFSENSE_DOCS") + .map((doc) => doc.id), }); return updated; }); diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 4fa847a95..f35019216 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -100,7 +100,9 @@ export const InlineMentionEditor = forwardRef { if (initialDocuments.length > 0) { - setMentionedDocs(new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))); + setMentionedDocs( + new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d])) + ); } }, [initialDocuments]); diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx index 880832f1d..5700fbc2e 100644 --- a/surfsense_web/components/assistant-ui/markdown-text.tsx +++ b/surfsense_web/components/assistant-ui/markdown-text.tsx @@ -17,7 +17,7 @@ import { cn } from "@/lib/utils"; // Citation pattern: [citation:CHUNK_ID] or [citation:doc-CHUNK_ID] // Also matches Chinese brackets 【】 and handles zero-width spaces that LLM sometimes inserts -const CITATION_REGEX = /[\[【]\u200B?citation:(doc-)?(\d+)\u200B?[\]】]/g; +const CITATION_REGEX = /[[【]\u200B?citation:(doc-)?(\d+)\u200B?[\]】]/g; // Track chunk IDs to citation numbers mapping for consistent numbering // This map is reset when a new message starts rendering diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 92b8ad786..bf46e3d97 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -230,8 +230,12 @@ const Composer: FC = () => { // Sync mentioned document IDs to atom for use in chat request useEffect(() => { setMentionedDocumentIds({ - surfsense_doc_ids: mentionedDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id), - document_ids: mentionedDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), + surfsense_doc_ids: mentionedDocuments + .filter((doc) => doc.document_type === "SURFSENSE_DOCS") + .map((doc) => doc.id), + document_ids: mentionedDocuments + .filter((doc) => doc.document_type !== "SURFSENSE_DOCS") + .map((doc) => doc.id), }); }, [mentionedDocuments, setMentionedDocumentIds]); @@ -314,12 +318,14 @@ const Composer: FC = () => { const handleDocumentRemove = useCallback( (docId: number, docType?: string) => { setMentionedDocuments((prev) => { - const updated = prev.filter( - (doc) => !(doc.id === docId && doc.document_type === docType) - ); + const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType)); setMentionedDocumentIds({ - surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id), - document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), + surfsense_doc_ids: updated + .filter((doc) => doc.document_type === "SURFSENSE_DOCS") + .map((doc) => doc.id), + document_ids: updated + .filter((doc) => doc.document_type !== "SURFSENSE_DOCS") + .map((doc) => doc.id), }); return updated; }); @@ -329,9 +335,7 @@ const Composer: FC = () => { const handleDocumentsMention = useCallback( (documents: Pick[]) => { - const existingKeys = new Set( - mentionedDocuments.map((d) => `${d.document_type}:${d.id}`) - ); + const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)); const newDocs = documents.filter( (doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`) ); @@ -347,8 +351,12 @@ const Composer: FC = () => { ); const updated = [...prev, ...uniqueNewDocs]; setMentionedDocumentIds({ - surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id), - document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), + surfsense_doc_ids: updated + .filter((doc) => doc.document_type === "SURFSENSE_DOCS") + .map((doc) => doc.id), + document_ids: updated + .filter((doc) => doc.document_type !== "SURFSENSE_DOCS") + .map((doc) => doc.id), }); return updated; }); diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index ce86deca5..dbc9c5f6a 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -86,6 +86,11 @@ export function LayoutDataProvider({ const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); const [isDeletingChat, setIsDeletingChat] = useState(false); + // Delete search space dialog state + const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false); + const [searchSpaceToDelete, setSearchSpaceToDelete] = useState(null); + const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false); + const searchSpaces: SearchSpace[] = useMemo(() => { if (!searchSpacesData || !Array.isArray(searchSpacesData)) return []; return searchSpacesData.map((space) => ({ @@ -169,27 +174,46 @@ export function LayoutDataProvider({ }, [router]); const handleSearchSpaceSettings = useCallback( - (id: number) => { - router.push(`/dashboard/${id}/settings`); + (space: SearchSpace) => { + router.push(`/dashboard/${space.id}/settings`); }, [router] ); - const handleDeleteSearchSpace = useCallback( - async (id: number) => { - await deleteSearchSpace({ id }); + const handleSearchSpaceDeleteClick = useCallback((space: SearchSpace) => { + setSearchSpaceToDelete(space); + setShowDeleteSearchSpaceDialog(true); + }, []); + + const confirmDeleteSearchSpace = useCallback(async () => { + if (!searchSpaceToDelete) return; + setIsDeletingSearchSpace(true); + try { + await deleteSearchSpace({ id: searchSpaceToDelete.id }); refetchSearchSpaces(); - if (Number(searchSpaceId) === id && searchSpaces.length > 1) { - const remaining = searchSpaces.filter((s) => s.id !== id); + if (Number(searchSpaceId) === searchSpaceToDelete.id && searchSpaces.length > 1) { + const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToDelete.id); if (remaining.length > 0) { router.push(`/dashboard/${remaining[0].id}/new-chat`); } } else if (searchSpaces.length === 1) { router.push("/dashboard"); } - }, - [deleteSearchSpace, refetchSearchSpaces, searchSpaceId, searchSpaces, router] - ); + } catch (error) { + console.error("Error deleting search space:", error); + } finally { + setIsDeletingSearchSpace(false); + setShowDeleteSearchSpaceDialog(false); + setSearchSpaceToDelete(null); + } + }, [ + searchSpaceToDelete, + deleteSearchSpace, + refetchSearchSpaces, + searchSpaceId, + searchSpaces, + router, + ]); const handleNavItemClick = useCallback( (item: NavItem) => { @@ -284,6 +308,8 @@ export function LayoutDataProvider({ searchSpaces={searchSpaces} activeSearchSpaceId={Number(searchSpaceId)} onSearchSpaceSelect={handleSearchSpaceSelect} + onSearchSpaceDelete={handleSearchSpaceDeleteClick} + onSearchSpaceSettings={handleSearchSpaceSettings} onAddSearchSpace={handleAddSearchSpace} searchSpace={activeSearchSpace} navItems={navItems} @@ -297,9 +323,9 @@ export function LayoutDataProvider({ onViewAllSharedChats={handleViewAllSharedChats} onViewAllPrivateChats={handleViewAllPrivateChats} user={{ email: user?.email || "", name: user?.email?.split("@")[0] }} - onSettings={handleSettings} - onManageMembers={handleManageMembers} - onUserSettings={handleUserSettings} + onSettings={handleSettings} + onManageMembers={handleManageMembers} + onUserSettings={handleUserSettings} onLogout={handleLogout} pageUsage={pageUsage} breadcrumb={breadcrumb} @@ -354,6 +380,48 @@ export function LayoutDataProvider({ + {/* Delete Search Space Dialog */} + + + + + + {t("delete_search_space")} + + + {t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })} + + + + + + + + + {/* All Shared Chats Sidebar */} void; + onSearchSpaceDelete?: (searchSpace: SearchSpace) => void; + onSearchSpaceSettings?: (searchSpace: SearchSpace) => void; onAddSearchSpace: () => void; className?: string; } @@ -20,6 +22,8 @@ export function IconRail({ searchSpaces, activeSearchSpaceId, onSearchSpaceSelect, + onSearchSpaceDelete, + onSearchSpaceSettings, onAddSearchSpace, className, }: IconRailProps) { @@ -32,7 +36,13 @@ export function IconRail({ key={searchSpace.id} name={searchSpace.name} isActive={searchSpace.id === activeSearchSpaceId} + isShared={searchSpace.memberCount > 1} + isOwner={searchSpace.isOwner} onClick={() => onSearchSpaceSelect(searchSpace.id)} + onDelete={onSearchSpaceDelete ? () => onSearchSpaceDelete(searchSpace) : undefined} + onSettings={ + onSearchSpaceSettings ? () => onSearchSpaceSettings(searchSpace) : undefined + } size="md" /> ))} diff --git a/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx b/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx index 77f4de899..1786c9c5e 100644 --- a/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx +++ b/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx @@ -1,12 +1,25 @@ "use client"; +import { Settings, Trash2, Users } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; interface SearchSpaceAvatarProps { name: string; isActive?: boolean; + isShared?: boolean; + isOwner?: boolean; onClick?: () => void; + onDelete?: () => void; + onSettings?: () => void; size?: "sm" | "md"; } @@ -45,32 +58,103 @@ function getInitials(name: string): string { export function SearchSpaceAvatar({ name, isActive, + isShared, + isOwner = true, onClick, + onDelete, + onSettings, size = "md", }: SearchSpaceAvatarProps) { + const t = useTranslations("searchSpace"); + const tCommon = useTranslations("common"); const bgColor = stringToColor(name); const initials = getInitials(name); const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm"; + const tooltipContent = ( +
+ {name} + {isShared && ( + + {isOwner ? tCommon("owner") : tCommon("shared")} + + )} +
+ ); + + const avatarButton = ( + + ); + + // If delete or settings handlers are provided, wrap with context menu + if (onDelete || onSettings) { + return ( + + + + +
{avatarButton}
+
+
+ + {tooltipContent} + +
+ + {onSettings && ( + + + {tCommon("settings")} + + )} + {onSettings && onDelete && } + {onDelete && isOwner && ( + + + {tCommon("delete")} + + )} + {onDelete && !isOwner && ( + + + {t("leave")} + + )} + +
+ ); + } + + // No context menu needed return ( - - - + {avatarButton} - {name} + {tooltipContent} ); diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 251a67765..ed3a09099 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -14,6 +14,8 @@ interface LayoutShellProps { searchSpaces: SearchSpace[]; activeSearchSpaceId: number | null; onSearchSpaceSelect: (id: number) => void; + onSearchSpaceDelete?: (searchSpace: SearchSpace) => void; + onSearchSpaceSettings?: (searchSpace: SearchSpace) => void; onAddSearchSpace: () => void; searchSpace: SearchSpace | null; navItems: NavItem[]; @@ -46,6 +48,8 @@ export function LayoutShell({ searchSpaces, activeSearchSpaceId, onSearchSpaceSelect, + onSearchSpaceDelete, + onSearchSpaceSettings, onAddSearchSpace, searchSpace, navItems, @@ -96,6 +100,8 @@ export function LayoutShell({ searchSpaces={searchSpaces} activeSearchSpaceId={activeSearchSpaceId} onSearchSpaceSelect={onSearchSpaceSelect} + onSearchSpaceDelete={onSearchSpaceDelete} + onSearchSpaceSettings={onSearchSpaceSettings} onAddSearchSpace={onAddSearchSpace} searchSpace={searchSpace} navItems={navItems} @@ -133,6 +139,8 @@ export function LayoutShell({ searchSpaces={searchSpaces} activeSearchSpaceId={activeSearchSpaceId} onSearchSpaceSelect={onSearchSpaceSelect} + onSearchSpaceDelete={onSearchSpaceDelete} + onSearchSpaceSettings={onSearchSpaceSettings} onAddSearchSpace={onAddSearchSpace} /> diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index a57fd045c..57fba60c9 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -13,6 +13,8 @@ interface MobileSidebarProps { searchSpaces: SearchSpace[]; activeSearchSpaceId: number | null; onSearchSpaceSelect: (id: number) => void; + onSearchSpaceDelete?: (searchSpace: SearchSpace) => void; + onSearchSpaceSettings?: (searchSpace: SearchSpace) => void; onAddSearchSpace: () => void; searchSpace: SearchSpace | null; navItems: NavItem[]; @@ -48,6 +50,8 @@ export function MobileSidebar({ searchSpaces, activeSearchSpaceId, onSearchSpaceSelect, + onSearchSpaceDelete, + onSearchSpaceSettings, onAddSearchSpace, searchSpace, navItems, @@ -94,7 +98,13 @@ export function MobileSidebar({ 1} + isOwner={space.isOwner} onClick={() => handleSearchSpaceSelect(space.id)} + onDelete={onSearchSpaceDelete ? () => onSearchSpaceDelete(space) : undefined} + onSettings={ + onSearchSpaceSettings ? () => onSearchSpaceSettings(space) : undefined + } size="md" /> @@ -111,33 +121,33 @@ export function MobileSidebar({ - {/* Sidebar Content */} -
- { - onNewChat(); - onOpenChange(false); - }} - onChatSelect={handleChatSelect} - onChatDelete={onChatDelete} - onViewAllSharedChats={onViewAllSharedChats} - onViewAllPrivateChats={onViewAllPrivateChats} - user={user} - onSettings={onSettings} - onManageMembers={onManageMembers} - onUserSettings={onUserSettings} - onLogout={onLogout} - pageUsage={pageUsage} - className="w-full border-none" - /> -
+ {/* Sidebar Content */} +
+ { + onNewChat(); + onOpenChange(false); + }} + onChatSelect={handleChatSelect} + onChatDelete={onChatDelete} + onViewAllSharedChats={onViewAllSharedChats} + onViewAllPrivateChats={onViewAllPrivateChats} + user={user} + onSettings={onSettings} + onManageMembers={onManageMembers} + onUserSettings={onUserSettings} + onLogout={onLogout} + pageUsage={pageUsage} + className="w-full border-none" + /> +
); diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index 8b84f3218..e89885b1d 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -57,7 +57,9 @@ export const DocumentMentionPicker = forwardRef< const scrollContainerRef = useRef(null); // State for pagination - const [accumulatedDocuments, setAccumulatedDocuments] = useState[]>([]); + const [accumulatedDocuments, setAccumulatedDocuments] = useState< + Pick[] + >([]); const [currentPage, setCurrentPage] = useState(0); const [hasMore, setHasMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); @@ -128,7 +130,7 @@ export const DocumentMentionPicker = forwardRef< useEffect(() => { if (currentPage === 0) { const combinedDocs: Pick[] = []; - + // Add SurfSense docs first (they appear at top) if (surfsenseDocs?.items) { for (const doc of surfsenseDocs.items) { @@ -139,7 +141,7 @@ export const DocumentMentionPicker = forwardRef< }); } } - + // Add regular documents if (debouncedSearch.trim()) { if (searchedDocuments?.items) { @@ -152,7 +154,7 @@ export const DocumentMentionPicker = forwardRef< setHasMore(documents.has_more); } } - + setAccumulatedDocuments(combinedDocs); } }, [documents, searchedDocuments, surfsenseDocs, debouncedSearch, currentPage]); @@ -209,7 +211,9 @@ export const DocumentMentionPicker = forwardRef< const actualDocuments = accumulatedDocuments; const actualLoading = - ((debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) || isSurfsenseDocsLoading) && currentPage === 0; + ((debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) || + isSurfsenseDocsLoading) && + currentPage === 0; // Track already selected documents using unique key (document_type:id) to avoid ID collisions const selectedKeys = useMemo( diff --git a/surfsense_web/components/ui/context-menu.tsx b/surfsense_web/components/ui/context-menu.tsx new file mode 100644 index 000000000..7c76f7fd0 --- /dev/null +++ b/surfsense_web/components/ui/context-menu.tsx @@ -0,0 +1,225 @@ +"use client"; + +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function ContextMenu({ ...props }: React.ComponentProps) { + return ; +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function ContextMenuGroup({ ...props }: React.ComponentProps) { + return ; +} + +function ContextMenuPortal({ ...props }: React.ComponentProps) { + return ; +} + +function ContextMenuSub({ ...props }: React.ComponentProps) { + return ; +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +}; diff --git a/surfsense_web/lib/apis/documents-api.service.ts b/surfsense_web/lib/apis/documents-api.service.ts index e1047c386..bea399f98 100644 --- a/surfsense_web/lib/apis/documents-api.service.ts +++ b/surfsense_web/lib/apis/documents-api.service.ts @@ -19,6 +19,7 @@ import { getDocumentTypeCountsRequest, getDocumentTypeCountsResponse, getSurfsenseDocsByChunkResponse, + getSurfsenseDocsRequest, getSurfsenseDocsResponse, type SearchDocumentsRequest, searchDocumentsRequest, @@ -29,7 +30,6 @@ import { updateDocumentResponse, uploadDocumentRequest, uploadDocumentResponse, - getSurfsenseDocsRequest, } from "@/contracts/types/document.types"; import { ValidationError } from "../error"; import { baseApiService } from "./base-api.service"; diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 9b59d5043..d55f70bf7 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -97,6 +97,9 @@ "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.", + "leave": "Leave", + "leave_title": "Leave Search Space", + "leave_confirm": "Are you sure you want to leave \"{name}\"? You will lose access to all documents and chats in this search space.", "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" diff --git a/surfsense_web/package.json b/surfsense_web/package.json index 3c98c47e0..c3ee214eb 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -37,6 +37,7 @@ "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.11", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml index a184b6cd0..c85ddbf13 100644 --- a/surfsense_web/pnpm-lock.yaml +++ b/surfsense_web/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@radix-ui/react-collapsible': specifier: ^1.1.11 version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-context-menu': + specifier: ^2.2.16 + version: 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1628,6 +1631,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-context@1.0.0': resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==} peerDependencies: @@ -7422,6 +7438,20 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-context@1.0.0(react@19.2.3)': dependencies: '@babel/runtime': 7.28.4 From 6622a8c582afb8dedc8ae3684ccaeb0eb2921233 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 13 Jan 2026 01:48:43 -0800 Subject: [PATCH 3/7] feat: add custom callout component to MarkdownViewer for enhanced styling --- surfsense_web/components/markdown-viewer.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/surfsense_web/components/markdown-viewer.tsx b/surfsense_web/components/markdown-viewer.tsx index 93e3f26e1..91f97830a 100644 --- a/surfsense_web/components/markdown-viewer.tsx +++ b/surfsense_web/components/markdown-viewer.tsx @@ -10,6 +10,14 @@ interface MarkdownViewerProps { export function MarkdownViewer({ content, className }: MarkdownViewerProps) { const components: StreamdownProps["components"] = { // Define custom components for markdown elements + callout: ({ children, ...props }) => ( +
+ {children} +
+ ), p: ({ children, ...props }) => (

{children} From d140f6393e82d9eb51d3d32f058e84ef17359f1f Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 13 Jan 2026 02:06:35 -0800 Subject: [PATCH 4/7] feat: implement leave search space functionality for non-owners and update related UI components --- surfsense_backend/app/routes/rbac_routes.py | 93 ++++++++++--------- .../layout/providers/LayoutDataProvider.tsx | 82 +++++++++++++++- .../contracts/types/search-space.types.ts | 7 ++ .../lib/apis/search-spaces-api.service.ts | 12 +++ surfsense_web/messages/en.json | 5 + surfsense_web/messages/zh.json | 4 + 6 files changed, 154 insertions(+), 49 deletions(-) diff --git a/surfsense_backend/app/routes/rbac_routes.py b/surfsense_backend/app/routes/rbac_routes.py index c5392f284..e90970b29 100644 --- a/surfsense_backend/app/routes/rbac_routes.py +++ b/surfsense_backend/app/routes/rbac_routes.py @@ -556,6 +556,54 @@ async def update_member_role( ) from e +# NOTE: /members/me must be defined BEFORE /members/{membership_id} +# because FastAPI matches routes in order, and "me" would otherwise +# be interpreted as a membership_id (causing a 422 validation error) +@router.delete("/searchspaces/{search_space_id}/members/me") +async def leave_search_space( + search_space_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Leave a search space (remove own membership). + Owners cannot leave their search space. + """ + try: + result = await session.execute( + select(SearchSpaceMembership).filter( + SearchSpaceMembership.user_id == user.id, + SearchSpaceMembership.search_space_id == search_space_id, + ) + ) + db_membership = result.scalars().first() + + if not db_membership: + raise HTTPException( + status_code=404, + detail="You are not a member of this search space", + ) + + if db_membership.is_owner: + raise HTTPException( + status_code=400, + detail="Owners cannot leave their search space. Transfer ownership first or delete the search space.", + ) + + await session.delete(db_membership) + await session.commit() + return {"message": "Successfully left the search space"} + + except HTTPException: + raise + except Exception as e: + await session.rollback() + logger.error(f"Failed to leave search space: {e!s}", exc_info=True) + raise HTTPException( + status_code=500, detail=f"Failed to leave search space: {e!s}" + ) from e + + @router.delete("/searchspaces/{search_space_id}/members/{membership_id}") async def remove_member( search_space_id: int, @@ -608,51 +656,6 @@ async def remove_member( ) from e -@router.delete("/searchspaces/{search_space_id}/members/me") -async def leave_search_space( - search_space_id: int, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - """ - Leave a search space (remove own membership). - Owners cannot leave their search space. - """ - try: - result = await session.execute( - select(SearchSpaceMembership).filter( - SearchSpaceMembership.user_id == user.id, - SearchSpaceMembership.search_space_id == search_space_id, - ) - ) - db_membership = result.scalars().first() - - if not db_membership: - raise HTTPException( - status_code=404, - detail="You are not a member of this search space", - ) - - if db_membership.is_owner: - raise HTTPException( - status_code=400, - detail="Owners cannot leave their search space. Transfer ownership first or delete the search space.", - ) - - await session.delete(db_membership) - await session.commit() - return {"message": "Successfully left the search space"} - - except HTTPException: - raise - except Exception as e: - await session.rollback() - logger.error(f"Failed to leave search space: {e!s}", exc_info=True) - raise HTTPException( - status_code=500, detail=f"Failed to leave search space: {e!s}" - ) from e - - # ============ Invite Endpoints ============ diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index dbc9c5f6a..3d4e5630d 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; -import { Logs, SquareLibrary, Trash2 } from "lucide-react"; +import { LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; @@ -86,10 +86,13 @@ export function LayoutDataProvider({ const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); const [isDeletingChat, setIsDeletingChat] = useState(false); - // Delete search space dialog state + // Delete/Leave search space dialog state const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false); + const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false); const [searchSpaceToDelete, setSearchSpaceToDelete] = useState(null); + const [searchSpaceToLeave, setSearchSpaceToLeave] = useState(null); const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false); + const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false); const searchSpaces: SearchSpace[] = useMemo(() => { if (!searchSpacesData || !Array.isArray(searchSpacesData)) return []; @@ -181,8 +184,14 @@ export function LayoutDataProvider({ ); const handleSearchSpaceDeleteClick = useCallback((space: SearchSpace) => { - setSearchSpaceToDelete(space); - setShowDeleteSearchSpaceDialog(true); + // If user is owner, show delete dialog; otherwise show leave dialog + if (space.isOwner) { + setSearchSpaceToDelete(space); + setShowDeleteSearchSpaceDialog(true); + } else { + setSearchSpaceToLeave(space); + setShowLeaveSearchSpaceDialog(true); + } }, []); const confirmDeleteSearchSpace = useCallback(async () => { @@ -215,6 +224,29 @@ export function LayoutDataProvider({ router, ]); + const confirmLeaveSearchSpace = useCallback(async () => { + if (!searchSpaceToLeave) return; + setIsLeavingSearchSpace(true); + try { + await searchSpacesApiService.leaveSearchSpace(searchSpaceToLeave.id); + refetchSearchSpaces(); + if (Number(searchSpaceId) === searchSpaceToLeave.id && searchSpaces.length > 1) { + const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToLeave.id); + if (remaining.length > 0) { + router.push(`/dashboard/${remaining[0].id}/new-chat`); + } + } else if (searchSpaces.length === 1) { + router.push("/dashboard"); + } + } catch (error) { + console.error("Error leaving search space:", error); + } finally { + setIsLeavingSearchSpace(false); + setShowLeaveSearchSpaceDialog(false); + setSearchSpaceToLeave(null); + } + }, [searchSpaceToLeave, refetchSearchSpaces, searchSpaceId, searchSpaces, router]); + const handleNavItemClick = useCallback( (item: NavItem) => { router.push(item.url); @@ -422,6 +454,48 @@ export function LayoutDataProvider({ + {/* Leave Search Space Dialog */} +

+ + + + + {t("leave_title")} + + + {t("leave_confirm", { name: searchSpaceToLeave?.name || "" })} + + + + + + + + + {/* All Shared Chats Sidebar */} ; export type GetSearchSpacesRequest = z.infer; diff --git a/surfsense_web/lib/apis/search-spaces-api.service.ts b/surfsense_web/lib/apis/search-spaces-api.service.ts index ff60c513b..3e2006e46 100644 --- a/surfsense_web/lib/apis/search-spaces-api.service.ts +++ b/surfsense_web/lib/apis/search-spaces-api.service.ts @@ -11,6 +11,7 @@ import { getSearchSpaceResponse, getSearchSpacesRequest, getSearchSpacesResponse, + leaveSearchSpaceResponse, type UpdateSearchSpaceRequest, updateSearchSpaceRequest, updateSearchSpaceResponse, @@ -115,6 +116,17 @@ class SearchSpacesApiService { return baseApiService.delete(`/api/v1/searchspaces/${request.id}`, deleteSearchSpaceResponse); }; + + /** + * Leave a search space (remove own membership) + * This is used by non-owners to leave a shared search space + */ + leaveSearchSpace = async (searchSpaceId: number) => { + return baseApiService.delete( + `/api/v1/searchspaces/${searchSpaceId}/members/me`, + leaveSearchSpaceResponse + ); + }; } export const searchSpacesApiService = new SearchSpacesApiService(); diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index d55f70bf7..c0c579070 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -100,6 +100,7 @@ "leave": "Leave", "leave_title": "Leave Search Space", "leave_confirm": "Are you sure you want to leave \"{name}\"? You will lose access to all documents and chats in this search space.", + "leaving": "Leaving...", "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" @@ -162,6 +163,10 @@ "go_home": "Go Home", "delete_search_space": "Delete Search Space", "delete_space_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone. All documents and chats in this search space will be permanently deleted.", + "leave": "Leave", + "leave_title": "Leave Search Space", + "leave_confirm": "Are you sure you want to leave \"{name}\"? You will lose access to all documents and chats in this search space.", + "leaving": "Leaving...", "no_spaces_found": "No search spaces found", "create_first_space": "Create your first search space to get started", "created": "Created" diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index d0e6e50d7..f01ccda4b 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -159,6 +159,10 @@ "go_home": "返回首页", "delete_search_space": "删除搜索空间", "delete_space_confirm": "您确定要删除\"{name}\"吗?此操作无法撤销。此搜索空间中的所有文档、对话和播客将被永久删除。", + "leave": "退出", + "leave_title": "退出搜索空间", + "leave_confirm": "您确定要退出\"{name}\"吗?您将无法访问此搜索空间中的所有文档和对话。", + "leaving": "退出中...", "no_spaces_found": "未找到搜索空间", "create_first_space": "创建您的第一个搜索空间以开始使用", "created": "创建于" From 8646fecc8b965a5607a23a25a5569940964461a8 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 13 Jan 2026 02:15:46 -0800 Subject: [PATCH 5/7] feat: add document upload functionality and update UI components for document management --- .../[search_space_id]/documents/(manage)/page.tsx | 10 ++++++++-- .../components/assistant-ui/document-upload-popup.tsx | 10 +++------- surfsense_web/messages/en.json | 1 + surfsense_web/messages/zh.json | 1 + 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx index 7d266cafe..54fd490a1 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; -import { RefreshCw, SquarePlus } from "lucide-react"; +import { RefreshCw, SquarePlus, Upload } from "lucide-react"; import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -10,6 +10,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react" import { toast } from "sonner"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; +import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; import { Button } from "@/components/ui/button"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { useLogsSummary } from "@/hooks/use-logs"; @@ -36,6 +37,7 @@ export default function DocumentsTable() { const params = useParams(); const router = useRouter(); const searchSpaceId = Number(params.search_space_id); + const { openDialog: openUploadDialog } = useDocumentUploadDialog(); const handleNewNote = useCallback(() => { router.push(`/dashboard/${searchSpaceId}/editor/new`); @@ -365,7 +367,11 @@ export default function DocumentsTable() {

{t("subtitle")}

- + diff --git a/surfsense_web/components/assistant-ui/document-upload-popup.tsx b/surfsense_web/components/assistant-ui/document-upload-popup.tsx index 6ac1ec979..453c6abde 100644 --- a/surfsense_web/components/assistant-ui/document-upload-popup.tsx +++ b/surfsense_web/components/assistant-ui/document-upload-popup.tsx @@ -86,7 +86,6 @@ const DocumentUploadPopupContent: FC<{ }> = ({ isOpen, onOpenChange }) => { const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const router = useRouter(); - const [isAccordionExpanded, setIsAccordionExpanded] = useState(false); if (!searchSpaceId) return null; @@ -118,19 +117,16 @@ const DocumentUploadPopupContent: FC<{ {/* Scrollable Content */}
-
+
- {/* Bottom fade shadow - only show when scrolling */} - {isAccordionExpanded && ( -
- )} + {/* Bottom fade shadow */} +
diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index c0c579070..ae7f98843 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -319,6 +319,7 @@ "rows_per_page": "Rows per page", "refresh": "Refresh", "refresh_success": "Documents refreshed", + "upload_documents": "Upload Documents", "create_shared_note": "Create Shared Note", "processing_documents": "Processing documents...", "active_tasks_count": "{count} active task(s)" diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index f01ccda4b..1404c176f 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -315,6 +315,7 @@ "rows_per_page": "每页行数", "refresh": "刷新", "refresh_success": "文档已刷新", + "upload_documents": "上传文档", "create_shared_note": "创建共享笔记", "processing_documents": "正在处理文档...", "active_tasks_count": "{count} 个正在进行的工作项" From fd3f2660f659ffd6252202b62422ece925f2d076 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 13 Jan 2026 19:35:07 -0800 Subject: [PATCH 6/7] fix: update NewChatPage to set current thread for immediate share button visibility --- .../[search_space_id]/new-chat/[[...chat_id]]/page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index fbb92ae2c..fbe4a9d95 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -425,6 +425,8 @@ export default function NewChatPage() { const newThread = await createThread(searchSpaceId, "New Chat"); currentThreadId = newThread.id; setThreadId(currentThreadId); + // Set currentThread so ChatHeader can show share button immediately + setCurrentThread(newThread); // Track chat creation trackChatCreated(searchSpaceId, currentThreadId); From 6d20c54afb2be37b790ac10d11409e455c103769 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 13 Jan 2026 20:47:21 -0800 Subject: [PATCH 7/7] feat(try): docker registration fix --- .../app/(home)/login/LocalLoginForm.tsx | 5 ++-- surfsense_web/app/(home)/login/page.tsx | 5 ++-- surfsense_web/app/(home)/register/page.tsx | 4 +-- .../components/homepage/hero-section.tsx | 5 ++-- surfsense_web/lib/env-config.ts | 28 +++++++++++++++++++ 5 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 surfsense_web/lib/env-config.ts diff --git a/surfsense_web/app/(home)/login/LocalLoginForm.tsx b/surfsense_web/app/(home)/login/LocalLoginForm.tsx index d632d09ed..62d2a2a66 100644 --- a/surfsense_web/app/(home)/login/LocalLoginForm.tsx +++ b/surfsense_web/app/(home)/login/LocalLoginForm.tsx @@ -9,6 +9,7 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; +import { AUTH_TYPE } from "@/lib/env-config"; import { ValidationError } from "@/lib/error"; import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events"; @@ -30,8 +31,8 @@ export function LocalLoginForm() { const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom); useEffect(() => { - // Get the auth type from environment variables - setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE"); + // Get the auth type from centralized config + setAuthType(AUTH_TYPE); }, []); const handleSubmit = async (e: React.FormEvent) => { diff --git a/surfsense_web/app/(home)/login/page.tsx b/surfsense_web/app/(home)/login/page.tsx index e2577563f..7aade8427 100644 --- a/surfsense_web/app/(home)/login/page.tsx +++ b/surfsense_web/app/(home)/login/page.tsx @@ -8,6 +8,7 @@ import { Suspense, useEffect, useState } from "react"; import { toast } from "sonner"; import { Logo } from "@/components/Logo"; import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors"; +import { AUTH_TYPE } from "@/lib/env-config"; import { AmbientBackground } from "./AmbientBackground"; import { GoogleLoginButton } from "./GoogleLoginButton"; import { LocalLoginForm } from "./LocalLoginForm"; @@ -82,8 +83,8 @@ function LoginContent() { }); } - // Get the auth type from environment variables - setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE"); + // Get the auth type from centralized config + setAuthType(AUTH_TYPE); setIsLoading(false); }, [searchParams, t, tCommon]); diff --git a/surfsense_web/app/(home)/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx index 724f7ee58..243ad4c60 100644 --- a/surfsense_web/app/(home)/register/page.tsx +++ b/surfsense_web/app/(home)/register/page.tsx @@ -10,6 +10,7 @@ import { toast } from "sonner"; import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { Logo } from "@/components/Logo"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; +import { AUTH_TYPE } from "@/lib/env-config"; import { AppError, ValidationError } from "@/lib/error"; import { trackRegistrationAttempt, @@ -36,8 +37,7 @@ export default function RegisterPage() { // Check authentication type and redirect if not LOCAL useEffect(() => { - const authType = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE"; - if (authType !== "LOCAL") { + if (AUTH_TYPE !== "LOCAL") { router.push("/login"); } }, [router]); diff --git a/surfsense_web/components/homepage/hero-section.tsx b/surfsense_web/components/homepage/hero-section.tsx index a9cfdeba2..7ccdd850c 100644 --- a/surfsense_web/components/homepage/hero-section.tsx +++ b/surfsense_web/components/homepage/hero-section.tsx @@ -4,6 +4,7 @@ import Image from "next/image"; import Link from "next/link"; import React, { useEffect, useRef, useState } from "react"; import Balancer from "react-wrap-balancer"; +import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config"; import { trackLoginAttempt } from "@/lib/posthog/events"; import { cn } from "@/lib/utils"; @@ -134,11 +135,11 @@ export function HeroSection() { } function GetStartedButton() { - const isGoogleAuth = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE === "GOOGLE"; + const isGoogleAuth = AUTH_TYPE === "GOOGLE"; const handleGoogleLogin = () => { trackLoginAttempt("google"); - window.location.href = `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize-redirect`; + window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`; }; if (isGoogleAuth) { diff --git a/surfsense_web/lib/env-config.ts b/surfsense_web/lib/env-config.ts new file mode 100644 index 000000000..5e35b160c --- /dev/null +++ b/surfsense_web/lib/env-config.ts @@ -0,0 +1,28 @@ +/** + * Environment configuration for the frontend. + * + * This file centralizes access to NEXT_PUBLIC_* environment variables. + * For Docker deployments, these placeholders are replaced at container startup + * via sed in the entrypoint script. + * + * IMPORTANT: Do not use template literals or complex expressions with these values + * as it may prevent the sed replacement from working correctly. + */ + +// Auth type: "LOCAL" for email/password, "GOOGLE" for OAuth +// Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__ +export const AUTH_TYPE = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE"; + +// Backend API URL +// Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_URL__ +export const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; + +// ETL Service: "DOCLING" or "UNSTRUCTURED" +// Placeholder: __NEXT_PUBLIC_ETL_SERVICE__ +export const ETL_SERVICE = process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING"; + +// Helper to check if local auth is enabled +export const isLocalAuth = () => AUTH_TYPE === "LOCAL"; + +// Helper to check if Google auth is enabled +export const isGoogleAuth = () => AUTH_TYPE === "GOOGLE";