From 4ace7d09a0a3ea0958f65c55bfc7e4ae414bf62f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 13 Jan 2026 00:49:27 +0200 Subject: [PATCH 01/10] Remove all search spaces sheet and dropdown option --- surfsense_web/components/layout/index.ts | 1 - .../layout/providers/LayoutDataProvider.tsx | 29 +-- .../components/layout/types/layout.types.ts | 1 - surfsense_web/components/layout/ui/index.ts | 1 - .../layout/ui/sheets/AllSearchSpacesSheet.tsx | 241 ------------------ .../components/layout/ui/sheets/index.ts | 1 - .../layout/ui/shell/LayoutShell.tsx | 4 - .../layout/ui/sidebar/MobileSidebar.tsx | 19 +- .../components/layout/ui/sidebar/Sidebar.tsx | 3 - .../layout/ui/sidebar/SidebarHeader.tsx | 9 +- 10 files changed, 13 insertions(+), 296 deletions(-) delete mode 100644 surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx delete mode 100644 surfsense_web/components/layout/ui/sheets/index.ts diff --git a/surfsense_web/components/layout/index.ts b/surfsense_web/components/layout/index.ts index 18f8cc9d3..7a57bf73f 100644 --- a/surfsense_web/components/layout/index.ts +++ b/surfsense_web/components/layout/index.ts @@ -11,7 +11,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 70bc96f58..c22af5962 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -28,7 +28,6 @@ import { resetUser, trackLogout } from "@/lib/posthog/events"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import type { ChatItem, NavItem, NoteItem, SearchSpace } from "../types/layout.types"; import { CreateSearchSpaceDialog } from "../ui/dialogs"; -import { AllSearchSpacesSheet } from "../ui/sheets"; import { LayoutShell } from "../ui/shell"; import { AllChatsSidebar } from "../ui/sidebar/AllChatsSidebar"; import { AllNotesSidebar } from "../ui/sidebar/AllNotesSidebar"; @@ -114,8 +113,7 @@ export function LayoutDataProvider({ const [isAllChatsSidebarOpen, setIsAllChatsSidebarOpen] = useState(false); const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = 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 @@ -210,10 +208,6 @@ export function LayoutDataProvider({ setIsCreateSearchSpaceDialogOpen(true); }, []); - const handleSeeAllSearchSpaces = useCallback(() => { - setIsAllSearchSpacesSheetOpen(true); - }, []); - const handleUserSettings = useCallback(() => { router.push("/dashboard/user/settings"); }, [router]); @@ -397,10 +391,9 @@ export function LayoutDataProvider({ onAddNote={handleAddNote} onViewAllNotes={handleViewAllNotes} 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} @@ -470,20 +463,6 @@ export function LayoutDataProvider({ onAddNote={handleAddNote} /> - {/* 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 bd3d54838..1c3ddb2ca 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 1bb0a015a..d3fdafa5c 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -40,7 +40,6 @@ interface LayoutShellProps { user: User; onSettings?: () => void; onManageMembers?: () => void; - onSeeAllSearchSpaces?: () => void; onUserSettings?: () => void; onLogout?: () => void; pageUsage?: PageUsage; @@ -77,7 +76,6 @@ export function LayoutShell({ user, onSettings, onManageMembers, - onSeeAllSearchSpaces, onUserSettings, onLogout, pageUsage, @@ -132,7 +130,6 @@ export function LayoutShell({ user={user} onSettings={onSettings} onManageMembers={onManageMembers} - onSeeAllSearchSpaces={onSeeAllSearchSpaces} onUserSettings={onUserSettings} onLogout={onLogout} pageUsage={pageUsage} @@ -181,7 +178,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 c1874bfd1..cae81f613 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -40,7 +40,6 @@ interface MobileSidebarProps { user: User; onSettings?: () => void; onManageMembers?: () => void; - onSeeAllSearchSpaces?: () => void; onUserSettings?: () => void; onLogout?: () => void; pageUsage?: PageUsage; @@ -80,7 +79,6 @@ export function MobileSidebar({ user, onSettings, onManageMembers, - onSeeAllSearchSpaces, onUserSettings, onLogout, pageUsage, @@ -141,15 +139,14 @@ export function MobileSidebar({ onNoteDelete={onNoteDelete} onAddNote={onAddNote} onViewAllNotes={onViewAllNotes} - user={user} - onSettings={onSettings} - onManageMembers={onManageMembers} - onSeeAllSearchSpaces={onSeeAllSearchSpaces} - onUserSettings={onUserSettings} - onLogout={onLogout} - pageUsage={pageUsage} - className="w-full border-none" - /> + user={user} + onSettings={onSettings} + onManageMembers={onManageMembers} + onUserSettings={onUserSettings} + onLogout={onLogout} + pageUsage={pageUsage} + className="w-full border-none" + /> diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 0fdec2a03..5bc1e62b9 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -44,7 +44,6 @@ interface SidebarProps { user: User; onSettings?: () => void; onManageMembers?: () => void; - onSeeAllSearchSpaces?: () => void; onUserSettings?: () => void; onLogout?: () => void; pageUsage?: PageUsage; @@ -72,7 +71,6 @@ export function Sidebar({ user, onSettings, onManageMembers, - onSeeAllSearchSpaces, onUserSettings, onLogout, pageUsage, @@ -103,7 +101,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")} -
From 738e23b51ab20eb2b82887409e03b8c23514d2d1 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 13 Jan 2026 01:15:33 +0200 Subject: [PATCH 02/10] Add SurfSense docs to documents table --- .../app/routes/surfsense_docs_routes.py | 82 +++++++++- .../app/schemas/surfsense_docs.py | 15 ++ .../(manage)/components/DocumentsFilters.tsx | 2 +- .../components/DocumentsTableShell.tsx | 24 ++- .../(manage)/components/RowActions.tsx | 69 ++++---- .../documents/(manage)/page.tsx | 153 +++++++++++++++--- .../contracts/enums/connectorIcons.tsx | 3 + .../contracts/types/document.types.ts | 23 +++ .../lib/apis/documents-api.service.ts | 26 +++ 9 files changed, 338 insertions(+), 59 deletions(-) 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/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_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 019f5796a..368f0f654 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); @@ -50,33 +50,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, @@ -86,10 +96,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, @@ -99,16 +109,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 || []; @@ -131,16 +234,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/contracts/enums/connectorIcons.tsx b/surfsense_web/contracts/enums/connectorIcons.tsx index befe132f9..e2d667a16 100644 --- a/surfsense_web/contracts/enums/connectorIcons.tsx +++ b/surfsense_web/contracts/enums/connectorIcons.tsx @@ -1,5 +1,6 @@ import { IconLinkPlus, IconUsersGroup } from "@tabler/icons-react"; import { + BookOpen, File, FileText, Globe, @@ -86,6 +87,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas return ; case "EXTENSION": return ; + case "SURFSENSE_DOCS": + return ; case "DEEP": return ; case "DEEPER": diff --git a/surfsense_web/contracts/types/document.types.ts b/surfsense_web/contracts/types/document.types.ts index 757c6aeb4..c167d4cbf 100644 --- a/surfsense_web/contracts/types/document.types.ts +++ b/surfsense_web/contracts/types/document.types.ts @@ -22,6 +22,7 @@ export const documentTypeEnum = z.enum([ "LINEAR_CONNECTOR", "NOTE", "CIRCLEBACK", + "SURFSENSE_DOCS", ]); export const document = z.object({ @@ -183,6 +184,26 @@ export const getSurfsenseDocsByChunkRequest = z.object({ export const getSurfsenseDocsByChunkResponse = surfsenseDocsDocumentWithChunks; +/** + * List Surfsense docs + */ +export const getSurfsenseDocsRequest = z.object({ + page: z.number().optional(), + page_size: z.number().optional(), + title: z.string().optional(), +}); + +export const getSurfsenseDocsResponse = z.object({ + items: z.array(surfsenseDocsDocument.extend({ + created_at: z.string().nullable().optional(), + updated_at: z.string().nullable().optional(), + })), + total: z.number(), + page: z.number(), + page_size: z.number(), + has_more: z.boolean(), +}); + /** * Update document */ @@ -227,3 +248,5 @@ export type SurfsenseDocsDocument = z.infer; export type SurfsenseDocsDocumentWithChunks = z.infer; export type GetSurfsenseDocsByChunkRequest = z.infer; export type GetSurfsenseDocsByChunkResponse = z.infer; +export type GetSurfsenseDocsRequest = z.infer; +export type GetSurfsenseDocsResponse = z.infer; diff --git a/surfsense_web/lib/apis/documents-api.service.ts b/surfsense_web/lib/apis/documents-api.service.ts index 2e7d18e44..91ab154d6 100644 --- a/surfsense_web/lib/apis/documents-api.service.ts +++ b/surfsense_web/lib/apis/documents-api.service.ts @@ -9,6 +9,7 @@ import { type GetDocumentRequest, type GetDocumentsRequest, type GetDocumentTypeCountsRequest, + type GetSurfsenseDocsRequest, getDocumentByChunkRequest, getDocumentByChunkResponse, getDocumentRequest, @@ -18,6 +19,7 @@ import { getDocumentTypeCountsRequest, getDocumentTypeCountsResponse, getSurfsenseDocsByChunkResponse, + getSurfsenseDocsResponse, type SearchDocumentsRequest, searchDocumentsRequest, searchDocumentsResponse, @@ -221,6 +223,30 @@ class DocumentsApiService { ); }; + /** + * List all Surfsense documentation documents + */ + getSurfsenseDocs = async (request: GetSurfsenseDocsRequest = {}) => { + const queryParams = new URLSearchParams(); + + if (request.page !== undefined) { + queryParams.set("page", String(request.page)); + } + if (request.page_size !== undefined) { + queryParams.set("page_size", String(request.page_size)); + } + if (request.title) { + queryParams.set("title", request.title); + } + + const queryString = queryParams.toString(); + const url = queryString + ? `/api/v1/surfsense-docs?${queryString}` + : "/api/v1/surfsense-docs"; + + return baseApiService.get(url, getSurfsenseDocsResponse); + }; + /** * Update a document */ From ce62bef32204950710c0a39dc93152ebc72776ad Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 13 Jan 2026 02:35:00 +0200 Subject: [PATCH 03/10] i18n settings pages with clear titles --- .../[search_space_id]/settings/page.tsx | 68 ++++++++++--------- .../app/dashboard/user/settings/page.tsx | 38 ++++++----- surfsense_web/messages/en.json | 11 ++- surfsense_web/messages/zh.json | 11 ++- 4 files changed, 78 insertions(+), 50 deletions(-) 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/messages/en.json b/surfsense_web/messages/en.json index 57f03a0fb..5ee25489d 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -105,7 +105,6 @@ "title": "User Settings", "description": "Manage your account settings and API access", "back_to_app": "Back to app", - "footer": "User Settings", "api_key_nav_label": "API Key", "api_key_nav_description": "Manage your API access token", "api_key_title": "API Key", @@ -684,6 +683,16 @@ "server_error": "Server error", "network_error": "Network error" }, + "searchSpaceSettings": { + "title": "Search Space Settings", + "back_to_app": "Back to app", + "nav_agent_configs": "Agent Configs", + "nav_agent_configs_desc": "LLM models with prompts & citations", + "nav_role_assignments": "Role Assignments", + "nav_role_assignments_desc": "Assign configs to agent roles", + "nav_system_instructions": "System Instructions", + "nav_system_instructions_desc": "SearchSpace-wide AI instructions" + }, "homepage": { "hero_title_part1": "The AI Workspace", "hero_title_part2": "Built for Teams", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 89cb7813a..8c1f53699 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -105,7 +105,6 @@ "title": "用户设置", "description": "管理您的账户设置和API访问", "back_to_app": "返回应用", - "footer": "用户设置", "api_key_nav_label": "API密钥", "api_key_nav_description": "管理您的API访问令牌", "api_key_title": "API密钥", @@ -678,6 +677,16 @@ "server_error": "服务器错误", "network_error": "网络错误" }, + "searchSpaceSettings": { + "title": "搜索空间设置", + "back_to_app": "返回应用", + "nav_agent_configs": "代理配置", + "nav_agent_configs_desc": "LLM 模型配置提示词和引用", + "nav_role_assignments": "角色分配", + "nav_role_assignments_desc": "为代理角色分配配置", + "nav_system_instructions": "系统指令", + "nav_system_instructions_desc": "搜索空间级别的 AI 指令" + }, "homepage": { "hero_title_part1": "AI 工作空间", "hero_title_part2": "为团队而生", From 7e3e784b4567b963ae777bdad21c927181247697 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 13 Jan 2026 02:42:59 +0200 Subject: [PATCH 04/10] add mentioned_surfsense_doc_ids to NewChatRequest schema --- surfsense_backend/app/schemas/new_chat.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index 78498cf04..62c61da6b 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -163,3 +163,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 + ) From 5ecdfae8a9cb0feac92e6a472ad9bb7328e68cda Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 13 Jan 2026 02:50:56 +0200 Subject: [PATCH 05/10] add surfsense docs context formatting in stream_new_chat --- .../app/routes/new_chat_routes.py | 1 + .../app/tasks/chat/stream_new_chat.py | 61 +++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 476ff2935..da0d239d2 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -706,6 +706,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/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 3b87c33f1..25cec6959 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,29 @@ 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 "" + + 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." + ) + for i, doc in enumerate(documents, 1): + context_parts.append( + f"" + ) + context_parts.append(f"") + 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 +124,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 +142,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 +233,17 @@ 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: + result = await session.execute( + select(SurfsenseDocsDocument).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 +255,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 +336,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 +382,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( From 1b5f29afccc499ae990c49cb39a002c926c7babd Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 13 Jan 2026 02:52:56 +0200 Subject: [PATCH 06/10] add mentionedSurfsenseDoc atoms --- .../chat/mentioned-surfsense-docs.atom.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 surfsense_web/atoms/chat/mentioned-surfsense-docs.atom.ts diff --git a/surfsense_web/atoms/chat/mentioned-surfsense-docs.atom.ts b/surfsense_web/atoms/chat/mentioned-surfsense-docs.atom.ts new file mode 100644 index 000000000..aa4a84e05 --- /dev/null +++ b/surfsense_web/atoms/chat/mentioned-surfsense-docs.atom.ts @@ -0,0 +1,32 @@ +"use client"; + +import { atom } from "jotai"; +import type { SurfsenseDocsDocument } from "@/contracts/types/document.types"; + +/** + * Atom to store the IDs of SurfSense docs mentioned in the current chat composer. + * This is used to pass documentation context to the backend when sending a message. + */ +export const mentionedSurfsenseDocIdsAtom = atom([]); + +/** + * Atom to store the full SurfSense doc objects mentioned in the current chat composer. + * This persists across component remounts. + */ +export const mentionedSurfsenseDocsAtom = atom([]); + +/** + * Simplified SurfSense doc info for display purposes + */ +export interface MentionedSurfsenseDocInfo { + id: number; + title: string; + source: string; +} + +/** + * Atom to store mentioned SurfSense docs per message ID. + * This allows displaying which docs were mentioned with each user message. + */ +export const messageSurfsenseDocsMapAtom = atom>({}); + From cd3677b5fab78aad105ab22135c7c1721f05b0df Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 13 Jan 2026 06:14:58 +0200 Subject: [PATCH 07/10] feat: add SurfSense docs mention support in chat --- .../app/tasks/chat/stream_new_chat.py | 42 +++++++++-- .../new-chat/[[...chat_id]]/page.tsx | 22 ++++-- .../atoms/chat/mentioned-documents.atom.ts | 12 +++- .../chat/mentioned-surfsense-docs.atom.ts | 32 --------- .../components/assistant-ui/composer.tsx | 49 ++++++++----- .../assistant-ui/inline-mention-editor.tsx | 43 +++++++---- .../components/assistant-ui/thread.tsx | 51 ++++++++----- .../components/assistant-ui/user-message.tsx | 2 +- .../new-chat/document-mention-picker.tsx | 71 ++++++++++++++----- .../contracts/types/document.types.ts | 9 +-- .../lib/apis/documents-api.service.ts | 34 +++++---- 11 files changed, 226 insertions(+), 141 deletions(-) delete mode 100644 surfsense_web/atoms/chat/mentioned-surfsense-docs.atom.ts diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 25cec6959..45899c2ef 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -76,17 +76,42 @@ def format_mentioned_surfsense_docs_as_context( 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." ) - for i, doc in enumerate(documents, 1): - context_parts.append( - f"" - ) - context_parts.append(f"") - context_parts.append("") + + 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) @@ -236,8 +261,11 @@ async def stream_new_chat( # 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).filter( + select(SurfsenseDocsDocument) + .options(selectinload(SurfsenseDocsDocument.chunks)) + .filter( SurfsenseDocsDocument.id.in_(mentioned_surfsense_doc_ids), ) ) 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 b1abd647f..489e17b20 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 @@ -265,7 +265,10 @@ export default function NewChatPage() { setMessages([]); setThreadId(null); setMessageThinkingSteps(new Map()); - setMentionedDocumentIds([]); + setMentionedDocumentIds({ + surfsense_doc_ids: [], + document_ids: [], + }); setMentionedDocuments([]); setMessageDocumentsMap({}); clearPlanOwnerRegistry(); // Reset plan ownership for new chat @@ -429,7 +432,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, }); @@ -627,12 +630,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([]); } @@ -648,7 +655,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/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/atoms/chat/mentioned-surfsense-docs.atom.ts b/surfsense_web/atoms/chat/mentioned-surfsense-docs.atom.ts deleted file mode 100644 index aa4a84e05..000000000 --- a/surfsense_web/atoms/chat/mentioned-surfsense-docs.atom.ts +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; - -import { atom } from "jotai"; -import type { SurfsenseDocsDocument } from "@/contracts/types/document.types"; - -/** - * Atom to store the IDs of SurfSense docs mentioned in the current chat composer. - * This is used to pass documentation context to the backend when sending a message. - */ -export const mentionedSurfsenseDocIdsAtom = atom([]); - -/** - * Atom to store the full SurfSense doc objects mentioned in the current chat composer. - * This persists across component remounts. - */ -export const mentionedSurfsenseDocsAtom = atom([]); - -/** - * Simplified SurfSense doc info for display purposes - */ -export interface MentionedSurfsenseDocInfo { - id: number; - title: string; - source: string; -} - -/** - * Atom to store mentioned SurfSense docs per message ID. - * This allows displaying which docs were mentioned with each user message. - */ -export const messageSurfsenseDocsMapAtom = atom>({}); - 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/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 6dc13fddf..92b8ad786 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -229,7 +229,10 @@ 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 @@ -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/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index 7a9e7aaa5..90515d52d 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,13 @@ export const DocumentMentionPicker = forwardRef< }; }, [debouncedSearch, searchSpaceId]); + const surfsenseDocsQueryParams = useMemo(() => { + return { + page: 0, + page_size: PAGE_SIZE, + }; + }, []); + // Use query for fetching first page of documents const { data: documents, isLoading: isDocumentsLoading } = useQuery({ queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams), @@ -106,22 +113,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", surfsenseDocsQueryParams], + 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 +205,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 +317,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 (