diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index e9b0c569a..ff9a8675b 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -923,6 +923,7 @@ async def handle_new_chat( llm_config_id=llm_config_id, attachments=request.attachments, mentioned_document_ids=request.mentioned_document_ids, + mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids, ), media_type="text/event-stream", headers={ diff --git a/surfsense_backend/app/routes/surfsense_docs_routes.py b/surfsense_backend/app/routes/surfsense_docs_routes.py index a2de65568..e1713e8a3 100644 --- a/surfsense_backend/app/routes/surfsense_docs_routes.py +++ b/surfsense_backend/app/routes/surfsense_docs_routes.py @@ -7,7 +7,7 @@ on a [citation:doc-XXX] link. """ from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -17,8 +17,10 @@ from app.db import ( User, get_async_session, ) +from app.schemas import PaginatedResponse from app.schemas.surfsense_docs import ( SurfsenseDocsChunkRead, + SurfsenseDocsDocumentRead, SurfsenseDocsDocumentWithChunksRead, ) from app.users import current_active_user @@ -87,3 +89,81 @@ async def get_surfsense_doc_by_chunk_id( status_code=500, detail=f"Failed to retrieve Surfsense documentation: {e!s}", ) from e + + +@router.get( + "/surfsense-docs", + response_model=PaginatedResponse[SurfsenseDocsDocumentRead], +) +async def list_surfsense_docs( + page: int = 0, + page_size: int = 50, + title: str | None = None, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + List all Surfsense documentation documents. + + Args: + page: Zero-based page index. + page_size: Number of items per page (default: 50). + title: Optional title filter (case-insensitive substring match). + session: Database session (injected). + user: Current authenticated user (injected). + + Returns: + PaginatedResponse[SurfsenseDocsDocumentRead]: Paginated list of Surfsense docs. + """ + try: + # Base query + query = select(SurfsenseDocsDocument) + count_query = select(func.count()).select_from(SurfsenseDocsDocument) + + # Filter by title if provided + if title and title.strip(): + query = query.filter(SurfsenseDocsDocument.title.ilike(f"%{title}%")) + count_query = count_query.filter( + SurfsenseDocsDocument.title.ilike(f"%{title}%") + ) + + # Get total count + total_result = await session.execute(count_query) + total = total_result.scalar() or 0 + + # Calculate offset + offset = page * page_size + + # Get paginated results + result = await session.execute( + query.order_by(SurfsenseDocsDocument.title).offset(offset).limit(page_size) + ) + docs = result.scalars().all() + + # Convert to response format + items = [ + SurfsenseDocsDocumentRead( + id=doc.id, + title=doc.title, + source=doc.source, + content=doc.content, + created_at=doc.created_at, + updated_at=doc.updated_at, + ) + for doc in docs + ] + + has_more = (offset + len(items)) < total + + return PaginatedResponse( + items=items, + total=total, + page=page, + page_size=page_size, + has_more=has_more, + ) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to list Surfsense documentation: {e!s}", + ) from e diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index 79b443529..e6dbcd920 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -177,3 +177,6 @@ class NewChatRequest(BaseModel): mentioned_document_ids: list[int] | None = ( None # Optional document IDs mentioned with @ in the chat ) + mentioned_surfsense_doc_ids: list[int] | None = ( + None # Optional SurfSense documentation IDs mentioned with @ in the chat + ) diff --git a/surfsense_backend/app/schemas/surfsense_docs.py b/surfsense_backend/app/schemas/surfsense_docs.py index c6029320f..ce32c0ef8 100644 --- a/surfsense_backend/app/schemas/surfsense_docs.py +++ b/surfsense_backend/app/schemas/surfsense_docs.py @@ -2,6 +2,8 @@ Schemas for Surfsense documentation. """ +from datetime import datetime + from pydantic import BaseModel, ConfigDict @@ -14,6 +16,19 @@ class SurfsenseDocsChunkRead(BaseModel): model_config = ConfigDict(from_attributes=True) +class SurfsenseDocsDocumentRead(BaseModel): + """Schema for a Surfsense docs document (without chunks).""" + + id: int + title: str + source: str + content: str + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = ConfigDict(from_attributes=True) + + class SurfsenseDocsDocumentWithChunksRead(BaseModel): """Schema for a Surfsense docs document with its chunks.""" diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 3b87c33f1..99b6e8703 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -25,7 +25,7 @@ from app.agents.new_chat.llm_config import ( load_agent_config, load_llm_config_from_yaml, ) -from app.db import Document +from app.db import Document, SurfsenseDocsDocument from app.schemas.new_chat import ChatAttachment from app.services.connector_service import ConnectorService from app.services.new_streaming_service import VercelStreamingService @@ -69,6 +69,55 @@ def format_mentioned_documents_as_context(documents: list[Document]) -> str: return "\n".join(context_parts) +def format_mentioned_surfsense_docs_as_context( + documents: list[SurfsenseDocsDocument], +) -> str: + """Format mentioned SurfSense documentation as context for the agent.""" + if not documents: + 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("") + context_parts.append("") + context_parts.append("") + + if hasattr(doc, 'chunks') and doc.chunks: + for chunk in doc.chunks: + context_parts.append( + f" " + ) + else: + context_parts.append( + f" " + ) + + context_parts.append("") + context_parts.append("") + context_parts.append("") + + context_parts.append("") + + return "\n".join(context_parts) + + def extract_todos_from_deepagents(command_output) -> dict: """ Extract todos from deepagents' TodoListMiddleware Command output. @@ -101,6 +150,7 @@ async def stream_new_chat( llm_config_id: int = -1, attachments: list[ChatAttachment] | None = None, mentioned_document_ids: list[int] | None = None, + mentioned_surfsense_doc_ids: list[int] | None = None, ) -> AsyncGenerator[str, None]: """ Stream chat responses from the new SurfSense deep agent. @@ -118,6 +168,7 @@ async def stream_new_chat( messages: Optional chat history from frontend (list of ChatMessage) attachments: Optional attachments with extracted content mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat + mentioned_surfsense_doc_ids: Optional list of SurfSense doc IDs mentioned with @ in the chat Yields: str: SSE formatted response strings @@ -208,7 +259,20 @@ async def stream_new_chat( ) mentioned_documents = list(result.scalars().all()) - # Format the user query with context (attachments + mentioned documents) + # Fetch mentioned SurfSense docs if any + 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)) + .filter( + SurfsenseDocsDocument.id.in_(mentioned_surfsense_doc_ids), + ) + ) + mentioned_surfsense_docs = list(result.scalars().all()) + + # Format the user query with context (attachments + mentioned documents + surfsense docs) final_query = user_query context_parts = [] @@ -220,6 +284,11 @@ async def stream_new_chat( format_mentioned_documents_as_context(mentioned_documents) ) + if mentioned_surfsense_docs: + context_parts.append( + format_mentioned_surfsense_docs_as_context(mentioned_surfsense_docs) + ) + if context_parts: context = "\n\n".join(context_parts) final_query = f"{context}\n\n{user_query}" @@ -296,13 +365,13 @@ async def stream_new_chat( last_active_step_id = analyze_step_id # Determine step title and action verb based on context - if attachments and mentioned_documents: + if attachments and (mentioned_documents or mentioned_surfsense_docs): last_active_step_title = "Analyzing your content" action_verb = "Reading" elif attachments: last_active_step_title = "Reading your content" action_verb = "Reading" - elif mentioned_documents: + elif mentioned_documents or mentioned_surfsense_docs: last_active_step_title = "Analyzing referenced content" action_verb = "Analyzing" else: @@ -342,6 +411,19 @@ async def stream_new_chat( else: processing_parts.append(f"[{len(doc_names)} documents]") + # Add mentioned SurfSense docs inline + if mentioned_surfsense_docs: + doc_names = [] + for doc in mentioned_surfsense_docs: + title = doc.title + if len(title) > 30: + title = title[:27] + "..." + doc_names.append(title) + if len(doc_names) == 1: + processing_parts.append(f"[📖 {doc_names[0]}]") + else: + processing_parts.append(f"[📖 {len(doc_names)} docs]") + last_active_step_items = [f"{action_verb}: {' '.join(processing_parts)}"] yield streaming_service.format_thinking_step( diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx index 4adb5414c..67413d6f0 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx @@ -47,7 +47,7 @@ export function DocumentsFilters({ columnVisibility, onToggleColumn, }: { - typeCounts: Record; + typeCounts: Partial>; selectedIds: Set; onSearch: (v: string) => void; searchValue: string; diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx index 94c0626e6..566e103ac 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -79,17 +79,25 @@ export function DocumentsTableShell({ [documents, sortKey, sortDesc] ); - const allSelectedOnPage = sorted.length > 0 && sorted.every((d) => selectedIds.has(d.id)); - const someSelectedOnPage = sorted.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage; + // Filter out SURFSENSE_DOCS for selection purposes + const selectableDocs = React.useMemo( + () => sorted.filter((d) => d.document_type !== "SURFSENSE_DOCS"), + [sorted] + ); + + const allSelectedOnPage = + selectableDocs.length > 0 && selectableDocs.every((d) => selectedIds.has(d.id)); + const someSelectedOnPage = + selectableDocs.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage; const toggleAll = (checked: boolean) => { const next = new Set(selectedIds); if (checked) - sorted.forEach((d) => { + selectableDocs.forEach((d) => { next.add(d.id); }); else - sorted.forEach((d) => { + selectableDocs.forEach((d) => { next.delete(d.id); }); setSelectedIds(next); @@ -230,9 +238,10 @@ export function DocumentsTableShell({ const icon = getDocumentTypeIcon(doc.document_type); const title = doc.title; const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title; + const isSurfsenseDoc = doc.document_type === "SURFSENSE_DOCS"; return ( toggleOne(doc.id, !!v)} + checked={selectedIds.has(doc.id) && !isSurfsenseDoc} + onCheckedChange={(v) => !isSurfsenseDoc && toggleOne(doc.id, !!v)} + disabled={isSurfsenseDoc} aria-label="Select row" /> diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx index 2fe9ab3e8..d277a84ee 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx @@ -28,6 +28,9 @@ import type { Document } from "./types"; // Only FILE and NOTE document types can be edited const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const; +// SURFSENSE_DOCS are system-managed and cannot be deleted +const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const; + export function RowActions({ document, deleteDocument, @@ -48,6 +51,10 @@ export function RowActions({ document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number] ); + const isDeletable = !NON_DELETABLE_DOCUMENT_TYPES.includes( + document.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number] + ); + const handleDelete = async () => { setIsDeleting(true); try { @@ -120,29 +127,31 @@ export function RowActions({ - - - - - - - -

Delete

-
-
+ + + + +

Delete

+
+ + )} {/* Mobile Actions Dropdown */} @@ -165,13 +174,15 @@ export function RowActions({ Metadata - setIsDeleteOpen(true)} - className="text-destructive focus:text-destructive" - > - - Delete - + {isDeletable && ( + setIsDeleteOpen(true)} + className="text-destructive focus:text-destructive" + > + + Delete + + )} 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 c0cf9a858..742be6ff4 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 @@ -19,7 +19,7 @@ import { DocumentsFilters } from "./components/DocumentsFilters"; import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell"; import { PaginationControls } from "./components/PaginationControls"; import { ProcessingIndicator } from "./components/ProcessingIndicator"; -import type { ColumnVisibility } from "./components/types"; +import type { ColumnVisibility, Document } from "./components/types"; function useDebounced(value: T, delay = 250) { const [debounced, setDebounced] = useState(value); @@ -55,33 +55,43 @@ export default function DocumentsTable() { const [sortKey, setSortKey] = useState("title"); const [sortDesc, setSortDesc] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); - const { data: typeCounts } = useAtomValue(documentTypeCountsAtom); + const { data: rawTypeCounts } = useAtomValue(documentTypeCountsAtom); const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); - // Build query parameters for fetching documents + // Filter out SURFSENSE_DOCS from active types for regular documents API + const regularDocumentTypes = useMemo( + () => activeTypes.filter((t) => t !== "SURFSENSE_DOCS"), + [activeTypes] + ); + + // Check if only SURFSENSE_DOCS is selected (skip regular docs query) + const onlySurfsenseDocsSelected = + activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS"; + + // Build query parameters for fetching documents (excluding SURFSENSE_DOCS type) const queryParams = useMemo( () => ({ search_space_id: searchSpaceId, page: pageIndex, page_size: pageSize, - ...(activeTypes.length > 0 && { document_types: activeTypes }), + ...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }), }), - [searchSpaceId, pageIndex, pageSize, activeTypes] + [searchSpaceId, pageIndex, pageSize, regularDocumentTypes] ); - // Build search query parameters + // Build search query parameters (excluding SURFSENSE_DOCS type) const searchQueryParams = useMemo( () => ({ search_space_id: searchSpaceId, page: pageIndex, page_size: pageSize, title: debouncedSearch.trim(), - ...(activeTypes.length > 0 && { document_types: activeTypes }), + ...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }), }), - [searchSpaceId, pageIndex, pageSize, activeTypes, debouncedSearch] + [searchSpaceId, pageIndex, pageSize, regularDocumentTypes, debouncedSearch] ); - // Use query for fetching documents + // Use query for fetching documents (disabled when only SURFSENSE_DOCS is selected) const { data: documentsResponse, isLoading: isDocumentsLoading, @@ -91,10 +101,10 @@ export default function DocumentsTable() { queryKey: cacheKeys.documents.globalQueryParams(queryParams), queryFn: () => documentsApiService.getDocuments({ queryParams }), staleTime: 3 * 60 * 1000, // 3 minutes - enabled: !!searchSpaceId && !debouncedSearch.trim(), + enabled: !!searchSpaceId && !debouncedSearch.trim() && !onlySurfsenseDocsSelected, }); - // Use query for searching documents + // Use query for searching documents (disabled when only SURFSENSE_DOCS is selected) const { data: searchResponse, isLoading: isSearchLoading, @@ -104,16 +114,109 @@ export default function DocumentsTable() { queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams), queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }), staleTime: 3 * 60 * 1000, // 3 minutes - enabled: !!searchSpaceId && !!debouncedSearch.trim(), + enabled: !!searchSpaceId && !!debouncedSearch.trim() && !onlySurfsenseDocsSelected, }); + // Determine if we should show SurfSense docs (when no type filter or SURFSENSE_DOCS is selected) + const showSurfsenseDocs = + activeTypes.length === 0 || activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum); + + // Use query for fetching SurfSense docs + const { + data: surfsenseDocsResponse, + isLoading: isSurfsenseDocsLoading, + refetch: refetchSurfsenseDocs, + } = useQuery({ + queryKey: ["surfsense-docs", debouncedSearch, pageIndex, pageSize], + queryFn: () => + documentsApiService.getSurfsenseDocs({ + page: pageIndex, + page_size: pageSize, + title: debouncedSearch.trim() || undefined, + }), + staleTime: 3 * 60 * 1000, // 3 minutes + enabled: showSurfsenseDocs, + }); + + // Transform SurfSense docs to match the Document type + const surfsenseDocsAsDocuments: Document[] = useMemo(() => { + if (!surfsenseDocsResponse?.items) return []; + return surfsenseDocsResponse.items.map((doc) => ({ + id: doc.id, + title: doc.title, + document_type: "SURFSENSE_DOCS", + document_metadata: { source: doc.source }, + content: doc.content, + created_at: doc.created_at || doc.updated_at || new Date().toISOString(), + search_space_id: -1, // Special value for global docs + })); + }, [surfsenseDocsResponse]); + + // Merge type counts with SURFSENSE_DOCS count + const typeCounts = useMemo(() => { + const counts = { ...(rawTypeCounts || {}) }; + if (surfsenseDocsResponse?.total) { + counts.SURFSENSE_DOCS = surfsenseDocsResponse.total; + } + return counts; + }, [rawTypeCounts, surfsenseDocsResponse?.total]); + // Extract documents and total based on search state - const documents = debouncedSearch.trim() + const regularDocuments = debouncedSearch.trim() ? searchResponse?.items || [] : documentsResponse?.items || []; - const total = debouncedSearch.trim() ? searchResponse?.total || 0 : documentsResponse?.total || 0; - const loading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading; - const error = debouncedSearch.trim() ? searchError : documentsError; + const regularTotal = debouncedSearch.trim() + ? searchResponse?.total || 0 + : documentsResponse?.total || 0; + + // Merge regular documents with SurfSense docs + const documents = useMemo(() => { + // If filtering by type and not including SURFSENSE_DOCS, only show regular docs + if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) { + return regularDocuments; + } + // If filtering only by SURFSENSE_DOCS, only show surfsense docs + if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") { + return surfsenseDocsAsDocuments; + } + // Otherwise, merge both (surfsense docs first) + return [...surfsenseDocsAsDocuments, ...regularDocuments]; + }, [regularDocuments, surfsenseDocsAsDocuments, activeTypes]); + + const total = useMemo(() => { + if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) { + return regularTotal; + } + if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") { + return surfsenseDocsResponse?.total || 0; + } + return regularTotal + (surfsenseDocsResponse?.total || 0); + }, [regularTotal, surfsenseDocsResponse?.total, activeTypes]); + + const loading = useMemo(() => { + // If only SURFSENSE_DOCS selected, only check surfsense loading + if (onlySurfsenseDocsSelected) { + return isSurfsenseDocsLoading; + } + // Otherwise check both regular docs and surfsense docs loading + const regularLoading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading; + return regularLoading || (showSurfsenseDocs && isSurfsenseDocsLoading); + }, [ + onlySurfsenseDocsSelected, + isSurfsenseDocsLoading, + debouncedSearch, + isSearchLoading, + isDocumentsLoading, + showSurfsenseDocs, + ]); + + const error = useMemo(() => { + // If only SURFSENSE_DOCS selected, no regular docs errors + if (onlySurfsenseDocsSelected) { + return null; + } + return debouncedSearch.trim() ? searchError : documentsError; + }, [onlySurfsenseDocsSelected, debouncedSearch, searchError, documentsError]); // Display server-filtered results directly const displayDocs = documents || []; @@ -136,16 +239,24 @@ export default function DocumentsTable() { if (isRefreshing) return; setIsRefreshing(true); try { - if (debouncedSearch.trim()) { - await refetchSearch(); - } else { - await refetchDocuments(); + const refetchPromises: Promise[] = []; + // Only refetch regular documents if not in "only surfsense docs" mode + if (!onlySurfsenseDocsSelected) { + if (debouncedSearch.trim()) { + refetchPromises.push(refetchSearch()); + } else { + refetchPromises.push(refetchDocuments()); + } } + if (showSurfsenseDocs) { + refetchPromises.push(refetchSurfsenseDocs()); + } + await Promise.all(refetchPromises); toast.success(t("refresh_success") || "Documents refreshed"); } finally { setIsRefreshing(false); } - }, [debouncedSearch, refetchSearch, refetchDocuments, 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 d6012a2fe..693aa3061 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 @@ -270,7 +270,10 @@ export default function NewChatPage() { setThreadId(null); setCurrentThread(null); setMessageThinkingSteps(new Map()); - setMentionedDocumentIds([]); + setMentionedDocumentIds({ + surfsense_doc_ids: [], + document_ids: [], + }); setMentionedDocuments([]); setMessageDocumentsMap({}); clearPlanOwnerRegistry(); // Reset plan ownership for new chat @@ -456,7 +459,7 @@ export default function NewChatPage() { // Track message sent trackChatMessageSent(searchSpaceId, currentThreadId, { hasAttachments: messageAttachments.length > 0, - hasMentionedDocuments: mentionedDocumentIds.length > 0, + hasMentionedDocuments: mentionedDocumentIds.surfsense_doc_ids.length > 0 || mentionedDocumentIds.document_ids.length > 0, messageLength: userQuery.length, }); @@ -654,12 +657,16 @@ export default function NewChatPage() { // Extract attachment content to send with the request const attachments = extractAttachmentContent(messageAttachments); - // Get mentioned document IDs for context - const documentIds = mentionedDocumentIds.length > 0 ? [...mentionedDocumentIds] : undefined; + // Get mentioned document IDs for context (separate fields for backend) + const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0; + const hasSurfsenseDocIds = mentionedDocumentIds.surfsense_doc_ids.length > 0; // Clear mentioned documents after capturing them - if (mentionedDocumentIds.length > 0) { - setMentionedDocumentIds([]); + if (hasDocumentIds || hasSurfsenseDocIds) { + setMentionedDocumentIds({ + surfsense_doc_ids: [], + document_ids: [], + }); setMentionedDocuments([]); } @@ -675,7 +682,8 @@ export default function NewChatPage() { search_space_id: searchSpaceId, messages: messageHistory, attachments: attachments.length > 0 ? attachments : undefined, - mentioned_document_ids: documentIds, + mentioned_document_ids: hasDocumentIds ? mentionedDocumentIds.document_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 fde676dff..26cb882e2 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx @@ -13,6 +13,7 @@ import { } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; import { LLMRoleManager } from "@/components/settings/llm-role-manager"; import { ModelConfigManager } from "@/components/settings/model-config-manager"; @@ -23,28 +24,28 @@ import { cn } from "@/lib/utils"; interface SettingsNavItem { id: string; - label: string; - description: string; + labelKey: string; + descriptionKey: string; icon: LucideIcon; } const settingsNavItems: SettingsNavItem[] = [ { id: "models", - label: "Agent Configs", - description: "LLM models with prompts & citations", + labelKey: "nav_agent_configs", + descriptionKey: "nav_agent_configs_desc", icon: Bot, }, { id: "roles", - label: "Role Assignments", - description: "Assign configs to agent roles", + labelKey: "nav_role_assignments", + descriptionKey: "nav_role_assignments_desc", icon: Brain, }, { id: "prompts", - label: "System Instructions", - description: "SearchSpace-wide AI instructions", + labelKey: "nav_system_instructions", + descriptionKey: "nav_system_instructions_desc", icon: MessageSquare, }, ]; @@ -62,6 +63,8 @@ function SettingsSidebar({ isOpen: boolean; onClose: () => void; }) { + const t = useTranslations("searchSpaceSettings"); + const handleNavClick = (sectionId: string) => { onSectionChange(sectionId); onClose(); // Close sidebar on mobile after selection @@ -94,22 +97,28 @@ function SettingsSidebar({ isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0" )} > - {/* Header with back button */} -
- - {/* Mobile close button */} - + {/* Header with title */} +
+
+ + {/* Mobile close button */} + +
+ {/* Settings Title */} +
+

{t("title")}

+
{/* Navigation Items */} @@ -159,9 +168,9 @@ function SettingsSidebar({ isActive ? "text-foreground" : "text-muted-foreground" )} > - {item.label} + {t(item.labelKey)}

-

{item.description}

+

{t(item.descriptionKey)}

- {/* Footer */} -
-

Search Space Settings

-
); @@ -194,6 +199,7 @@ function SettingsContent({ searchSpaceId: number; onMenuClick: () => void; }) { + const t = useTranslations("searchSpaceSettings"); const activeItem = settingsNavItems.find((item) => item.id === activeSection); const Icon = activeItem?.icon || Settings; @@ -236,7 +242,7 @@ function SettingsContent({

- {activeItem?.label} + {activeItem ? t(activeItem.labelKey) : ""}

diff --git a/surfsense_web/app/dashboard/user/settings/page.tsx b/surfsense_web/app/dashboard/user/settings/page.tsx index ca89f9ba3..d09428a52 100644 --- a/surfsense_web/app/dashboard/user/settings/page.tsx +++ b/surfsense_web/app/dashboard/user/settings/page.tsx @@ -75,20 +75,27 @@ function UserSettingsSidebar({ isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0" )} > -
- - + {/* Header with title */} +
+
+ + +
+ {/* Settings Title */} +
+

{t("title")}

+
-
-

{t("footer")}

-
); diff --git a/surfsense_web/atoms/chat/mentioned-documents.atom.ts b/surfsense_web/atoms/chat/mentioned-documents.atom.ts index 79ea27d12..17ae38616 100644 --- a/surfsense_web/atoms/chat/mentioned-documents.atom.ts +++ b/surfsense_web/atoms/chat/mentioned-documents.atom.ts @@ -1,19 +1,25 @@ "use client"; import { atom } from "jotai"; -import type { Document } from "@/contracts/types/document.types"; +import type { Document, SurfsenseDocsDocument } from "@/contracts/types/document.types"; /** * Atom to store the IDs of documents mentioned in the current chat composer. * This is used to pass document context to the backend when sending a message. */ -export const mentionedDocumentIdsAtom = atom([]); +export const mentionedDocumentIdsAtom = atom<{ + surfsense_doc_ids: number[]; + document_ids: number[]; +}>({ + surfsense_doc_ids: [], + document_ids: [], +}); /** * Atom to store the full document objects mentioned in the current chat composer. * This persists across component remounts. */ -export const mentionedDocumentsAtom = atom([]); +export const mentionedDocumentsAtom = atom<(Pick)[]>([]); /** * 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 8f8ee5e0b..417f7c70f 100644 --- a/surfsense_web/components/assistant-ui/composer.tsx +++ b/surfsense_web/components/assistant-ui/composer.tsx @@ -53,7 +53,10 @@ export const Composer: FC = () => { // Sync mentioned document IDs to atom for use in chat request useEffect(() => { - setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id)); + 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), + }); }, [mentionedDocuments, setMentionedDocumentIds]); // Handle text change from inline editor - sync with assistant-ui composer @@ -119,7 +122,10 @@ export const Composer: FC = () => { // Clear the editor after sending editorRef.current?.clear(); setMentionedDocuments([]); - setMentionedDocumentIds([]); + setMentionedDocumentIds({ + surfsense_doc_ids: [], + document_ids: [], + }); } }, [ showDocumentPopover, @@ -129,41 +135,48 @@ export const Composer: FC = () => { setMentionedDocumentIds, ]); - // Handle document removal from inline editor const handleDocumentRemove = useCallback( - (docId: number) => { + (docId: number, docType?: string) => { setMentionedDocuments((prev) => { - const updated = prev.filter((doc) => doc.id !== docId); - // Immediately sync document IDs to avoid race conditions - setMentionedDocumentIds(updated.map((doc) => doc.id)); + 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), + }); return updated; }); }, [setMentionedDocuments, setMentionedDocumentIds] ); - // Handle document selection from picker const handleDocumentsMention = useCallback( - (documents: Document[]) => { - // Insert chips into the inline editor for each new document - const existingIds = new Set(mentionedDocuments.map((d) => d.id)); - const newDocs = documents.filter((doc) => !existingIds.has(doc.id)); + (documents: Pick[]) => { + const existingKeys = new Set( + mentionedDocuments.map((d) => `${d.document_type}:${d.id}`) + ); + const newDocs = documents.filter( + (doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`) + ); for (const doc of newDocs) { editorRef.current?.insertDocumentChip(doc); } - // Update mentioned documents state setMentionedDocuments((prev) => { - const existingIdSet = new Set(prev.map((d) => d.id)); - const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id)); + const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`)); + const uniqueNewDocs = documents.filter( + (doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`) + ); const updated = [...prev, ...uniqueNewDocs]; - // Immediately sync document IDs to avoid race conditions - setMentionedDocumentIds(updated.map((doc) => doc.id)); + 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), + }); return updated; }); - // Reset mention query but keep popover open for more selections setMentionQuery(""); }, [mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds] diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 12a8f895f..4fa847a95 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -25,7 +25,7 @@ export interface InlineMentionEditorRef { clear: () => void; getText: () => string; getMentionedDocuments: () => MentionedDocument[]; - insertDocumentChip: (doc: Document) => void; + insertDocumentChip: (doc: Pick) => void; } interface InlineMentionEditorProps { @@ -34,7 +34,7 @@ interface InlineMentionEditorProps { onMentionClose?: () => void; onSubmit?: () => void; onChange?: (text: string, docs: MentionedDocument[]) => void; - onDocumentRemove?: (docId: number) => void; + onDocumentRemove?: (docId: number, docType?: string) => void; onKeyDown?: (e: React.KeyboardEvent) => void; disabled?: boolean; className?: string; @@ -44,6 +44,7 @@ interface InlineMentionEditorProps { // Unique data attribute to identify chip elements const CHIP_DATA_ATTR = "data-mention-chip"; const CHIP_ID_ATTR = "data-mention-id"; +const CHIP_DOCTYPE_ATTR = "data-mention-doctype"; /** * Type guard to check if a node is a chip element @@ -66,6 +67,13 @@ function getChipId(element: Element): number | null { return Number.isNaN(id) ? null : id; } +/** + * Get chip document type from element attribute + */ +function getChipDocType(element: Element): string { + return element.getAttribute(CHIP_DOCTYPE_ATTR) ?? "UNKNOWN"; +} + export const InlineMentionEditor = forwardRef( ( { @@ -84,15 +92,15 @@ export const InlineMentionEditor = forwardRef { const editorRef = useRef(null); const [isEmpty, setIsEmpty] = useState(true); - const [mentionedDocs, setMentionedDocs] = useState>( - () => new Map(initialDocuments.map((d) => [d.id, d])) + const [mentionedDocs, setMentionedDocs] = useState>( + () => new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d])) ); const isComposingRef = useRef(false); // Sync initial documents useEffect(() => { if (initialDocuments.length > 0) { - setMentionedDocs(new Map(initialDocuments.map((d) => [d.id, d]))); + setMentionedDocs(new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))); } }, [initialDocuments]); @@ -153,6 +161,7 @@ export const InlineMentionEditor = forwardRef { const next = new Map(prev); - next.delete(doc.id); + next.delete(docKey); return next; }); // Notify parent that a document was removed - onDocumentRemove?.(doc.id); + onDocumentRemove?.(doc.id, doc.document_type); focusAtEnd(); }; @@ -195,7 +205,7 @@ export const InlineMentionEditor = forwardRef { + (doc: Pick) => { if (!editorRef.current) return; // Validate required fields for type safety @@ -210,8 +220,9 @@ export const InlineMentionEditor = forwardRef new Map(prev).set(doc.id, mentionDoc)); + // Add to mentioned docs map using unique key + const docKey = `${doc.document_type ?? "UNKNOWN"}:${doc.id}`; + setMentionedDocs((prev) => new Map(prev).set(docKey, mentionDoc)); // Find and remove the @query text const selection = window.getSelection(); @@ -413,15 +424,17 @@ export const InlineMentionEditor = forwardRef { const next = new Map(prev); - next.delete(chipId); + next.delete(chipKey); return next; }); // Notify parent that a document was removed - onDocumentRemove?.(chipId); + onDocumentRemove?.(chipId, chipDocType); } return; } @@ -448,15 +461,17 @@ export const InlineMentionEditor = forwardRef { const next = new Map(prev); - next.delete(chipId); + next.delete(chipKey); return next; }); // Notify parent that a document was removed - onDocumentRemove?.(chipId); + onDocumentRemove?.(chipId, chipDocType); } } } diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx index 5bc905645..880832f1d 100644 --- a/surfsense_web/components/assistant-ui/markdown-text.tsx +++ b/surfsense_web/components/assistant-ui/markdown-text.tsx @@ -16,7 +16,8 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button import { cn } from "@/lib/utils"; // Citation pattern: [citation:CHUNK_ID] or [citation:doc-CHUNK_ID] -const CITATION_REGEX = /\[citation:(doc-)?(\d+)\]/g; +// Also matches Chinese brackets 【】 and handles zero-width spaces that LLM sometimes inserts +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 @@ -90,10 +91,6 @@ function parseTextWithCitations(text: string): ReactNode[] { } const MarkdownTextImpl = () => { - // Reset citation counter at the start of each render - // This ensures consistent numbering as the message streams in - resetCitationCounter(); - return ( { // Sync mentioned document IDs to atom for use in chat request useEffect(() => { - setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id)); + 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), + }); }, [mentionedDocuments, setMentionedDocumentIds]); // Handle text change from inline editor - sync with assistant-ui composer @@ -295,7 +298,10 @@ const Composer: FC = () => { // Clear the editor after sending editorRef.current?.clear(); setMentionedDocuments([]); - setMentionedDocumentIds([]); + setMentionedDocumentIds({ + surfsense_doc_ids: [], + document_ids: [], + }); } }, [ showDocumentPopover, @@ -305,41 +311,48 @@ const Composer: FC = () => { setMentionedDocumentIds, ]); - // Handle document removal from inline editor const handleDocumentRemove = useCallback( - (docId: number) => { + (docId: number, docType?: string) => { setMentionedDocuments((prev) => { - const updated = prev.filter((doc) => doc.id !== docId); - // Immediately sync document IDs to avoid race conditions - setMentionedDocumentIds(updated.map((doc) => doc.id)); + 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), + }); return updated; }); }, [setMentionedDocuments, setMentionedDocumentIds] ); - // Handle document selection from picker const handleDocumentsMention = useCallback( - (documents: Document[]) => { - // Insert chips into the inline editor for each new document - const existingIds = new Set(mentionedDocuments.map((d) => d.id)); - const newDocs = documents.filter((doc) => !existingIds.has(doc.id)); + (documents: Pick[]) => { + const existingKeys = new Set( + mentionedDocuments.map((d) => `${d.document_type}:${d.id}`) + ); + const newDocs = documents.filter( + (doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`) + ); for (const doc of newDocs) { editorRef.current?.insertDocumentChip(doc); } - // Update mentioned documents state setMentionedDocuments((prev) => { - const existingIdSet = new Set(prev.map((d) => d.id)); - const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id)); + const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`)); + const uniqueNewDocs = documents.filter( + (doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`) + ); const updated = [...prev, ...uniqueNewDocs]; - // Immediately sync document IDs to avoid race conditions - setMentionedDocumentIds(updated.map((doc) => doc.id)); + 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), + }); return updated; }); - // Reset mention query but keep popover open for more selections setMentionQuery(""); }, [mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds] @@ -640,7 +653,7 @@ const UserMessage: FC = () => { {/* Mentioned documents as chips */} {mentionedDocs?.map((doc) => ( diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index dcf626461..745542304 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -29,7 +29,7 @@ export const UserMessage: FC = () => { {/* Mentioned documents as chips */} {mentionedDocs?.map((doc) => ( diff --git a/surfsense_web/components/layout/index.ts b/surfsense_web/components/layout/index.ts index 57b675088..67f161d1a 100644 --- a/surfsense_web/components/layout/index.ts +++ b/surfsense_web/components/layout/index.ts @@ -10,7 +10,6 @@ export type { User, } from "./types/layout.types"; export { - AllSearchSpacesSheet, ChatListItem, CreateSearchSpaceDialog, Header, diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 7b4159b13..ce86deca5 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -25,7 +25,6 @@ import { resetUser, trackLogout } from "@/lib/posthog/events"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types"; import { CreateSearchSpaceDialog } from "../ui/dialogs"; -import { AllSearchSpacesSheet } from "../ui/sheets"; import { LayoutShell } from "../ui/shell"; import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar"; import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar"; @@ -79,8 +78,7 @@ export function LayoutDataProvider({ const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false); const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false); - // Search space sheet and dialog state - const [isAllSearchSpacesSheetOpen, setIsAllSearchSpacesSheetOpen] = useState(false); + // Search space dialog state const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); // Delete dialogs state @@ -166,10 +164,6 @@ export function LayoutDataProvider({ setIsCreateSearchSpaceDialogOpen(true); }, []); - const handleSeeAllSearchSpaces = useCallback(() => { - setIsAllSearchSpacesSheetOpen(true); - }, []); - const handleUserSettings = useCallback(() => { router.push("/dashboard/user/settings"); }, [router]); @@ -303,10 +297,9 @@ export function LayoutDataProvider({ onViewAllSharedChats={handleViewAllSharedChats} onViewAllPrivateChats={handleViewAllPrivateChats} user={{ email: user?.email || "", name: user?.email?.split("@")[0] }} - onSettings={handleSettings} - onManageMembers={handleManageMembers} - onSeeAllSearchSpaces={handleSeeAllSearchSpaces} - onUserSettings={handleUserSettings} + onSettings={handleSettings} + onManageMembers={handleManageMembers} + onUserSettings={handleUserSettings} onLogout={handleLogout} pageUsage={pageUsage} breadcrumb={breadcrumb} @@ -375,20 +368,6 @@ export function LayoutDataProvider({ searchSpaceId={searchSpaceId} /> - {/* All Search Spaces Sheet */} - { - setIsAllSearchSpacesSheetOpen(false); - setIsCreateSearchSpaceDialogOpen(true); - }} - onSettings={handleSearchSpaceSettings} - onDelete={handleDeleteSearchSpace} - /> - {/* Create Search Space Dialog */} void; onManageMembers?: () => void; - onSeeAllSearchSpaces?: () => void; onToggleTheme?: () => void; onLogout?: () => void; pageUsage?: PageUsage; diff --git a/surfsense_web/components/layout/ui/index.ts b/surfsense_web/components/layout/ui/index.ts index 875e3b746..00b862082 100644 --- a/surfsense_web/components/layout/ui/index.ts +++ b/surfsense_web/components/layout/ui/index.ts @@ -1,7 +1,6 @@ export { CreateSearchSpaceDialog } from "./dialogs"; export { Header } from "./header"; export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail"; -export { AllSearchSpacesSheet } from "./sheets"; export { LayoutShell } from "./shell"; export { ChatListItem, diff --git a/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx b/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx deleted file mode 100644 index 401de41c3..000000000 --- a/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx +++ /dev/null @@ -1,241 +0,0 @@ -"use client"; - -import { - Calendar, - MoreHorizontal, - Search, - Settings, - Share2, - Trash2, - UserCheck, - Users, -} from "lucide-react"; -import { useTranslations } from "next-intl"; -import { useState } from "react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; -import type { SearchSpace } from "../../types/layout.types"; - -function formatDate(dateString: string): string { - return new Date(dateString).toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); -} - -interface AllSearchSpacesSheetProps { - open: boolean; - onOpenChange: (open: boolean) => void; - searchSpaces: SearchSpace[]; - onSearchSpaceSelect: (id: number) => void; - onCreateNew?: () => void; - onSettings?: (id: number) => void; - onDelete?: (id: number) => void; -} - -export function AllSearchSpacesSheet({ - open, - onOpenChange, - searchSpaces, - onSearchSpaceSelect, - onCreateNew, - onSettings, - onDelete, -}: AllSearchSpacesSheetProps) { - const t = useTranslations("searchSpace"); - const tCommon = useTranslations("common"); - - const [spaceToDelete, setSpaceToDelete] = useState(null); - - const handleSelect = (id: number) => { - onSearchSpaceSelect(id); - onOpenChange(false); - }; - - const handleSettings = (e: React.MouseEvent, space: SearchSpace) => { - e.stopPropagation(); - onOpenChange(false); - onSettings?.(space.id); - }; - - const handleDeleteClick = (e: React.MouseEvent, space: SearchSpace) => { - e.stopPropagation(); - setSpaceToDelete(space); - }; - - const confirmDelete = () => { - if (spaceToDelete) { - onDelete?.(spaceToDelete.id); - setSpaceToDelete(null); - } - }; - - return ( - <> - - - -
-
- -
-
- {t("all_search_spaces")} - - {t("search_spaces_count", { count: searchSpaces.length })} - -
-
-
- -
- {searchSpaces.length === 0 ? ( -
-
- -
-
-

{t("no_search_spaces")}

-

{t("create_first_search_space")}

-
- {onCreateNew && ( - - )} -
- ) : ( - searchSpaces.map((space) => ( - - - - handleSettings(e, space)}> - - {tCommon("settings")} - - - handleDeleteClick(e, space)} - className="text-destructive focus:text-destructive" - > - - {tCommon("delete")} - - - - )} -
-
- -
- - {space.isOwner ? ( - - ) : ( - - )} - {t("members_count", { count: space.memberCount })} - - {space.createdAt && ( - - - {formatDate(space.createdAt)} - - )} -
- - )) - )} - - - {searchSpaces.length > 0 && onCreateNew && ( -
- -
- )} - - - - !open && setSpaceToDelete(null)}> - - - {t("delete_title")} - - {t("delete_confirm", { name: spaceToDelete?.name ?? "" })} - - - - {tCommon("cancel")} - - {tCommon("delete")} - - - - - - ); -} diff --git a/surfsense_web/components/layout/ui/sheets/index.ts b/surfsense_web/components/layout/ui/sheets/index.ts deleted file mode 100644 index d3db749bb..000000000 --- a/surfsense_web/components/layout/ui/sheets/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AllSearchSpacesSheet } from "./AllSearchSpacesSheet"; diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 26ee43541..251a67765 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -29,7 +29,6 @@ interface LayoutShellProps { user: User; onSettings?: () => void; onManageMembers?: () => void; - onSeeAllSearchSpaces?: () => void; onUserSettings?: () => void; onLogout?: () => void; pageUsage?: PageUsage; @@ -62,7 +61,6 @@ export function LayoutShell({ user, onSettings, onManageMembers, - onSeeAllSearchSpaces, onUserSettings, onLogout, pageUsage, @@ -113,7 +111,6 @@ export function LayoutShell({ user={user} onSettings={onSettings} onManageMembers={onManageMembers} - onSeeAllSearchSpaces={onSeeAllSearchSpaces} onUserSettings={onUserSettings} onLogout={onLogout} pageUsage={pageUsage} @@ -158,7 +155,6 @@ export function LayoutShell({ user={user} onSettings={onSettings} onManageMembers={onManageMembers} - onSeeAllSearchSpaces={onSeeAllSearchSpaces} onUserSettings={onUserSettings} onLogout={onLogout} pageUsage={pageUsage} diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index 988798ae7..0efec49c9 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -28,7 +28,6 @@ interface MobileSidebarProps { user: User; onSettings?: () => void; onManageMembers?: () => void; - onSeeAllSearchSpaces?: () => void; onUserSettings?: () => void; onLogout?: () => void; pageUsage?: PageUsage; @@ -64,7 +63,6 @@ export function MobileSidebar({ user, onSettings, onManageMembers, - onSeeAllSearchSpaces, onUserSettings, onLogout, pageUsage, @@ -129,6 +127,21 @@ export function MobileSidebar({ }} onChatSelect={handleChatSelect} onChatDelete={onChatDelete} + onViewAllChats={onViewAllChats} + notes={notes} + activeNoteId={activeNoteId} + onNoteSelect={handleNoteSelect} + onNoteDelete={onNoteDelete} + onAddNote={onAddNote} + onViewAllNotes={onViewAllNotes} + user={user} + onSettings={onSettings} + onManageMembers={onManageMembers} + onUserSettings={onUserSettings} + onLogout={onLogout} + pageUsage={pageUsage} + className="w-full border-none" + /> onViewAllSharedChats={onViewAllSharedChats} onViewAllPrivateChats={onViewAllPrivateChats} user={user} diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 80d0a2a98..a23bec11a 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -32,7 +32,6 @@ interface SidebarProps { user: User; onSettings?: () => void; onManageMembers?: () => void; - onSeeAllSearchSpaces?: () => void; onUserSettings?: () => void; onLogout?: () => void; pageUsage?: PageUsage; @@ -56,7 +55,6 @@ export function Sidebar({ user, onSettings, onManageMembers, - onSeeAllSearchSpaces, onUserSettings, onLogout, pageUsage, @@ -87,7 +85,6 @@ export function Sidebar({ isCollapsed={isCollapsed} onSettings={onSettings} onManageMembers={onManageMembers} - onSeeAllSearchSpaces={onSeeAllSearchSpaces} />
void; onManageMembers?: () => void; - onSeeAllSearchSpaces?: () => void; className?: string; } @@ -27,7 +26,6 @@ export function SidebarHeader({ isCollapsed, onSettings, onManageMembers, - onSeeAllSearchSpaces, className, }: SidebarHeaderProps) { const t = useTranslations("sidebar"); @@ -59,11 +57,6 @@ export function SidebarHeader({ {t("search_space_settings")} - - - - {t("see_all_search_spaces")} -
diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index 7a9e7aaa5..8b84f3218 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -25,9 +25,9 @@ export interface DocumentMentionPickerRef { interface DocumentMentionPickerProps { searchSpaceId: number; - onSelectionChange: (documents: Document[]) => void; + onSelectionChange: (documents: Pick[]) => void; onDone: () => void; - initialSelectedDocuments?: Document[]; + initialSelectedDocuments?: Pick[]; externalSearch?: string; } @@ -57,7 +57,7 @@ export const DocumentMentionPicker = forwardRef< const scrollContainerRef = useRef(null); // State for pagination - const [accumulatedDocuments, setAccumulatedDocuments] = useState([]); + const [accumulatedDocuments, setAccumulatedDocuments] = useState[]>([]); const [currentPage, setCurrentPage] = useState(0); const [hasMore, setHasMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); @@ -90,6 +90,17 @@ export const DocumentMentionPicker = forwardRef< }; }, [debouncedSearch, searchSpaceId]); + const surfsenseDocsQueryParams = useMemo(() => { + const params: { page: number; page_size: number; title?: string } = { + page: 0, + page_size: PAGE_SIZE, + }; + if (debouncedSearch.trim()) { + params.title = debouncedSearch; + } + return params; + }, [debouncedSearch]); + // Use query for fetching first page of documents const { data: documents, isLoading: isDocumentsLoading } = useQuery({ queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams), @@ -106,22 +117,45 @@ export const DocumentMentionPicker = forwardRef< enabled: !!searchSpaceId && !!debouncedSearch.trim() && currentPage === 0, }); - // Update accumulated documents when first page loads + // Use query for fetching first page of SurfSense docs + const { data: surfsenseDocs, isLoading: isSurfsenseDocsLoading } = useQuery({ + queryKey: ["surfsense-docs-mention", debouncedSearch], + queryFn: () => documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }), + staleTime: 3 * 60 * 1000, + }); + + // Update accumulated documents when first page loads - combine both sources useEffect(() => { if (currentPage === 0) { + const combinedDocs: Pick[] = []; + + // Add SurfSense docs first (they appear at top) + if (surfsenseDocs?.items) { + for (const doc of surfsenseDocs.items) { + combinedDocs.push({ + id: doc.id, + title: doc.title, + document_type: "SURFSENSE_DOCS", + }); + } + } + + // Add regular documents if (debouncedSearch.trim()) { - if (searchedDocuments) { - setAccumulatedDocuments(searchedDocuments.items); + if (searchedDocuments?.items) { + combinedDocs.push(...searchedDocuments.items); setHasMore(searchedDocuments.has_more); } } else { - if (documents) { - setAccumulatedDocuments(documents.items); + if (documents?.items) { + combinedDocs.push(...documents.items); setHasMore(documents.has_more); } } + + setAccumulatedDocuments(combinedDocs); } - }, [documents, searchedDocuments, debouncedSearch, currentPage]); + }, [documents, searchedDocuments, surfsenseDocs, debouncedSearch, currentPage]); // Function to load next page const loadNextPage = useCallback(async () => { @@ -175,22 +209,22 @@ export const DocumentMentionPicker = forwardRef< const actualDocuments = accumulatedDocuments; const actualLoading = - (debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) && currentPage === 0; + ((debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) || isSurfsenseDocsLoading) && currentPage === 0; - // Track already selected document IDs - const selectedIds = useMemo( - () => new Set(initialSelectedDocuments.map((d) => d.id)), + // Track already selected documents using unique key (document_type:id) to avoid ID collisions + const selectedKeys = useMemo( + () => new Set(initialSelectedDocuments.map((d) => `${d.document_type}:${d.id}`)), [initialSelectedDocuments] ); // Filter out already selected documents for navigation const selectableDocuments = useMemo( - () => actualDocuments.filter((doc) => !selectedIds.has(doc.id)), - [actualDocuments, selectedIds] + () => actualDocuments.filter((doc) => !selectedKeys.has(`${doc.document_type}:${doc.id}`)), + [actualDocuments, selectedKeys] ); const handleSelectDocument = useCallback( - (doc: Document) => { + (doc: Pick) => { onSelectionChange([...initialSelectedDocuments, doc]); onDone(); }, @@ -287,13 +321,16 @@ export const DocumentMentionPicker = forwardRef< ) : (
{actualDocuments.map((doc) => { - const isAlreadySelected = selectedIds.has(doc.id); - const selectableIndex = selectableDocuments.findIndex((d) => d.id === doc.id); + const docKey = `${doc.document_type}:${doc.id}`; + const isAlreadySelected = selectedKeys.has(docKey); + const selectableIndex = selectableDocuments.findIndex( + (d) => d.document_type === doc.document_type && d.id === doc.id + ); const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex; return (