diff --git a/surfsense_backend/app/agents/researcher/nodes.py b/surfsense_backend/app/agents/researcher/nodes.py index 491840589..35857c004 100644 --- a/surfsense_backend/app/agents/researcher/nodes.py +++ b/surfsense_backend/app/agents/researcher/nodes.py @@ -492,6 +492,7 @@ async def fetch_documents_by_ids( "CLICKUP_CONNECTOR": "ClickUp (Selected)", "AIRTABLE_CONNECTOR": "Airtable (Selected)", "LUMA_CONNECTOR": "Luma Events (Selected)", + "NOTE": "Notes (Selected)", } source_object = { @@ -1162,6 +1163,33 @@ async def fetch_relevant_documents( } ) + elif connector == "NOTE": + ( + source_object, + notes_chunks, + ) = await connector_service.search_notes( + user_query=reformulated_query, + search_space_id=search_space_id, + top_k=top_k, + start_date=start_date, + end_date=end_date, + ) + + # Add to sources and raw documents + if source_object: + all_sources.append(source_object) + all_raw_documents.extend(notes_chunks) + + # Stream found document count + if streaming_service and writer: + writer( + { + "yield_value": streaming_service.format_terminal_info_delta( + f"📝 Found {len(notes_chunks)} Notes related to your query" + ) + } + ) + except Exception as e: logging.error("Error in search_airtable: %s", traceback.format_exc()) error_message = f"Error searching connector {connector}: {e!s}" diff --git a/surfsense_backend/app/agents/researcher/utils.py b/surfsense_backend/app/agents/researcher/utils.py index 3666d8b8a..ea3c99547 100644 --- a/surfsense_backend/app/agents/researcher/utils.py +++ b/surfsense_backend/app/agents/researcher/utils.py @@ -34,6 +34,7 @@ def get_connector_emoji(connector_name: str) -> str: "LUMA_CONNECTOR": "✨", "ELASTICSEARCH_CONNECTOR": "⚡", "WEBCRAWLER_CONNECTOR": "🌐", + "NOTE": "📝", } return connector_emojis.get(connector_name, "🔎") @@ -59,6 +60,7 @@ def get_connector_friendly_name(connector_name: str) -> str: "LUMA_CONNECTOR": "Luma", "ELASTICSEARCH_CONNECTOR": "Elasticsearch", "WEBCRAWLER_CONNECTOR": "Web Pages", + "NOTE": "Notes", } return connector_friendly_names.get(connector_name, connector_name) diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py index a959db5e6..d06217fc4 100644 --- a/surfsense_backend/app/routes/documents_routes.py +++ b/surfsense_backend/app/routes/documents_routes.py @@ -266,12 +266,27 @@ async def read_documents( document_type=doc.document_type, document_metadata=doc.document_metadata, content=doc.content, + content_hash=doc.content_hash, + unique_identifier_hash=doc.unique_identifier_hash, created_at=doc.created_at, + updated_at=doc.updated_at, search_space_id=doc.search_space_id, ) ) - return PaginatedResponse(items=api_documents, total=total) + # Calculate pagination info + actual_page = ( + page if page is not None else (offset // page_size if page_size > 0 else 0) + ) + has_more = (offset + len(api_documents)) < total if page_size > 0 else False + + return PaginatedResponse( + items=api_documents, + total=total, + page=actual_page, + page_size=page_size, + has_more=has_more, + ) except HTTPException: raise except Exception as e: @@ -385,12 +400,27 @@ async def search_documents( document_type=doc.document_type, document_metadata=doc.document_metadata, content=doc.content, + content_hash=doc.content_hash, + unique_identifier_hash=doc.unique_identifier_hash, created_at=doc.created_at, + updated_at=doc.updated_at, search_space_id=doc.search_space_id, ) ) - return PaginatedResponse(items=api_documents, total=total) + # Calculate pagination info + actual_page = ( + page if page is not None else (offset // page_size if page_size > 0 else 0) + ) + has_more = (offset + len(api_documents)) < total if page_size > 0 else False + + return PaginatedResponse( + items=api_documents, + total=total, + page=actual_page, + page_size=page_size, + has_more=has_more, + ) except HTTPException: raise except Exception as e: @@ -510,7 +540,10 @@ async def get_document_by_chunk_id( document_type=document.document_type, document_metadata=document.document_metadata, content=document.content, + content_hash=document.content_hash, + unique_identifier_hash=document.unique_identifier_hash, created_at=document.created_at, + updated_at=document.updated_at, search_space_id=document.search_space_id, chunks=sorted_chunks, ) @@ -559,7 +592,10 @@ async def read_document( document_type=document.document_type, document_metadata=document.document_metadata, content=document.content, + content_hash=document.content_hash, + unique_identifier_hash=document.unique_identifier_hash, created_at=document.created_at, + updated_at=document.updated_at, search_space_id=document.search_space_id, ) except HTTPException: @@ -614,7 +650,10 @@ async def update_document( document_type=db_document.document_type, document_metadata=db_document.document_metadata, content=db_document.content, + content_hash=db_document.content_hash, + unique_identifier_hash=db_document.unique_identifier_hash, created_at=db_document.created_at, + updated_at=db_document.updated_at, search_space_id=db_document.search_space_id, ) except HTTPException: diff --git a/surfsense_backend/app/routes/editor_routes.py b/surfsense_backend/app/routes/editor_routes.py index 1baf52e85..a0e7b59c1 100644 --- a/surfsense_backend/app/routes/editor_routes.py +++ b/surfsense_backend/app/routes/editor_routes.py @@ -172,7 +172,7 @@ async def save_document( blocknote_document = data.get("blocknote_document") if not blocknote_document: raise HTTPException(status_code=400, detail="blocknote_document is required") - + # Add type validation if not isinstance(blocknote_document, list): raise HTTPException(status_code=400, detail="blocknote_document must be a list") diff --git a/surfsense_backend/app/routes/notes_routes.py b/surfsense_backend/app/routes/notes_routes.py index 99a12e803..5bb0a88a9 100644 --- a/surfsense_backend/app/routes/notes_routes.py +++ b/surfsense_backend/app/routes/notes_routes.py @@ -5,7 +5,7 @@ Notes routes for creating and managing BlockNote documents. from datetime import UTC, datetime from typing import Any -from fastapi import APIRouter, Body, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -64,7 +64,6 @@ async def create_note( content_hash = hashlib.sha256(request.title.encode()).hexdigest() # Create document with NOTE type - from app.config import config document = Document( search_space_id=search_space_id, diff --git a/surfsense_backend/app/schemas/documents.py b/surfsense_backend/app/schemas/documents.py index fc83d24be..e1e8b9248 100644 --- a/surfsense_backend/app/schemas/documents.py +++ b/surfsense_backend/app/schemas/documents.py @@ -46,7 +46,10 @@ class DocumentRead(BaseModel): document_type: DocumentType document_metadata: dict content: str # Changed to string to match frontend + content_hash: str + unique_identifier_hash: str | None created_at: datetime + updated_at: datetime | None search_space_id: int model_config = ConfigDict(from_attributes=True) @@ -61,3 +64,6 @@ class DocumentWithChunksRead(DocumentRead): class PaginatedResponse[T](BaseModel): items: list[T] total: int + page: int + page_size: int + has_more: bool diff --git a/surfsense_backend/app/services/connector_service.py b/surfsense_backend/app/services/connector_service.py index cac1b7f47..61bc08b40 100644 --- a/surfsense_backend/app/services/connector_service.py +++ b/surfsense_backend/app/services/connector_service.py @@ -2360,6 +2360,75 @@ class ConnectorService: return result_object, elasticsearch_docs + async def search_notes( + self, + user_query: str, + search_space_id: int, + top_k: int = 20, + start_date: datetime | None = None, + end_date: datetime | None = None, + ) -> tuple: + """ + Search for Notes and return both the source information and langchain documents. + + Uses combined chunk-level and document-level hybrid search with RRF fusion. + + Args: + user_query: The user's query + search_space_id: The search space ID to search in + top_k: Maximum number of results to return + start_date: Optional start date for filtering documents by updated_at + end_date: Optional end date for filtering documents by updated_at + + Returns: + tuple: (sources_info, langchain_documents) + """ + notes_docs = await self._combined_rrf_search( + query_text=user_query, + search_space_id=search_space_id, + document_type="NOTE", + top_k=top_k, + start_date=start_date, + end_date=end_date, + ) + + # Early return if no results + if not notes_docs: + return { + "id": 51, + "name": "Notes", + "type": "NOTE", + "sources": [], + }, [] + + def _title_fn(doc_info: dict[str, Any], metadata: dict[str, Any]) -> str: + return doc_info.get("title", "Untitled Note") + + def _url_fn(_doc_info: dict[str, Any], _metadata: dict[str, Any]) -> str: + return "" # Notes don't have URLs + + def _description_fn( + chunk: dict[str, Any], _doc_info: dict[str, Any], _metadata: dict[str, Any] + ) -> str: + return self._chunk_preview(chunk.get("content", ""), limit=200) + + sources_list = self._build_chunk_sources_from_documents( + notes_docs, + title_fn=_title_fn, + url_fn=_url_fn, + description_fn=_description_fn, + ) + + # Create result object + result_object = { + "id": 51, + "name": "Notes", + "type": "NOTE", + "sources": sources_list, + } + + return result_object, notes_docs + async def search_bookstack( self, user_query: str, diff --git a/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py b/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py index 8ab5309f2..b9d4c3b95 100644 --- a/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py +++ b/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py @@ -3,6 +3,7 @@ import logging from sqlalchemy import delete, select +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.orm import selectinload from sqlalchemy.pool import NullPool @@ -11,6 +12,7 @@ from app.celery_app import celery_app from app.config import config from app.db import Document from app.services.llm_service import get_user_long_context_llm +from app.services.task_logging_service import TaskLoggingService from app.utils.blocknote_converter import convert_blocknote_to_markdown from app.utils.document_converters import ( create_document_chunks, @@ -53,21 +55,42 @@ def reindex_document_task(self, document_id: int, user_id: str): async def _reindex_document(document_id: int, user_id: str): """Async function to reindex a document.""" async with get_celery_session_maker()() as session: + # First, get the document to get search_space_id for logging + result = await session.execute( + select(Document) + .options(selectinload(Document.chunks)) + .where(Document.id == document_id) + ) + document = result.scalars().first() + + if not document: + logger.error(f"Document {document_id} not found") + return + + # Initialize task logger + task_logger = TaskLoggingService(session, document.search_space_id) + + # Log task start + log_entry = await task_logger.log_task_start( + task_name="document_reindex", + source="editor", + message=f"Starting reindex for document: {document.title}", + metadata={ + "document_id": document_id, + "document_type": document.document_type.value, + "title": document.title, + "user_id": user_id, + }, + ) + try: - # Get document - result = await session.execute( - select(Document) - .options(selectinload(Document.chunks)) # Eagerly load chunks - .where(Document.id == document_id) - ) - document = result.scalars().first() - - if not document: - logger.error(f"Document {document_id} not found") - return - if not document.blocknote_document: - logger.warning(f"Document {document_id} has no BlockNote content") + await task_logger.log_task_failure( + log_entry, + f"Document {document_id} has no BlockNote content to reindex", + "No BlockNote content", + {"error_type": "NoBlockNoteContent"}, + ) return logger.info(f"Reindexing document {document_id} ({document.title})") @@ -78,7 +101,12 @@ async def _reindex_document(document_id: int, user_id: str): ) if not markdown_content: - logger.error(f"Failed to convert document {document_id} to markdown") + await task_logger.log_task_failure( + log_entry, + f"Failed to convert document {document_id} to markdown", + "Markdown conversion failed", + {"error_type": "ConversionError"}, + ) return # 2. Delete old chunks explicitly @@ -118,9 +146,39 @@ async def _reindex_document(document_id: int, user_id: str): await session.commit() + # Log success + await task_logger.log_task_success( + log_entry, + f"Successfully reindexed document: {document.title}", + { + "chunks_created": len(new_chunks), + "document_id": document_id, + }, + ) + logger.info(f"Successfully reindexed document {document_id}") + except SQLAlchemyError as db_error: + await session.rollback() + await task_logger.log_task_failure( + log_entry, + f"Database error during reindex for document {document_id}", + str(db_error), + {"error_type": "SQLAlchemyError"}, + ) + logger.error( + f"Database error reindexing document {document_id}: {db_error}", + exc_info=True, + ) + raise + except Exception as e: await session.rollback() + await task_logger.log_task_failure( + log_entry, + f"Failed to reindex document: {document.title}", + str(e), + {"error_type": type(e).__name__}, + ) logger.error(f"Error reindexing document {document_id}: {e}", exc_info=True) raise diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index 028c0efdc..82197921a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -14,7 +14,6 @@ import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContain import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider"; -import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; @@ -224,7 +223,6 @@ export function DashboardClientLayout({
- {/* Only show artifacts toggle on researcher page */} {isResearcherPage && ( (null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -116,25 +113,6 @@ export default function EditorPage() { .json() .catch(() => ({ detail: "Failed to fetch document" })); const errorMessage = errorData.detail || "Failed to fetch document"; - - // Log fetch error - try { - await createLog({ - level: "ERROR", - status: "FAILED", - message: `Failed to fetch document: ${errorMessage}`, - source: "editor", - search_space_id: searchSpaceId, - log_metadata: { - document_id: documentId, - error_type: "fetch_error", - http_status: response.status, - }, - }); - } catch (err) { - console.error("Failed to create log:", err); - } - throw new Error(errorMessage); } @@ -142,48 +120,13 @@ export default function EditorPage() { // Check if blocknote_document exists if (!data.blocknote_document) { - const errorMsg = "This document does not have BlockNote content. Please re-upload the document to enable editing."; - - // Log missing BlockNote content - try { - await createLog({ - level: "WARNING", - status: "FAILED", - message: `Document ${documentId} does not have BlockNote content`, - source: "editor", - search_space_id: searchSpaceId, - log_metadata: { - document_id: documentId, - error_type: "missing_blocknote_content", - }, - }); - } catch (err) { - console.error("Failed to create log:", err); - } - + const errorMsg = + "This document does not have BlockNote content. Please re-upload the document to enable editing."; setError(errorMsg); setLoading(false); return; } - // Log successful fetch - try { - await createLog({ - level: "INFO", - status: "SUCCESS", - message: `Document ${documentId} loaded successfully`, - source: "editor", - search_space_id: searchSpaceId, - log_metadata: { - document_id: documentId, - document_type: data.document_type, - title: data.title, - }, - }); - } catch (err) { - console.error("Failed to create log:", err); - } - setDocument(data); setEditorContent(data.blocknote_document); setError(null); @@ -191,24 +134,6 @@ export default function EditorPage() { console.error("Error fetching document:", error); const errorMessage = error instanceof Error ? error.message : "Failed to fetch document. Please try again."; - - // Log general fetch error - try { - await createLog({ - level: "ERROR", - status: "FAILED", - message: `Error fetching document: ${errorMessage}`, - source: "editor", - search_space_id: searchSpaceId, - log_metadata: { - document_id: documentId, - error_type: "fetch_exception", - }, - }); - } catch (err) { - console.error("Failed to create log:", err); - } - setError(errorMessage); } finally { setLoading(false); @@ -218,7 +143,7 @@ export default function EditorPage() { if (documentId) { fetchDocument(); } - }, [documentId, params.search_space_id, isNewNote, searchSpaceId, createLog]); + }, [documentId, params.search_space_id, isNewNote]); // Track changes to mark as unsaved useEffect(() => { @@ -280,49 +205,10 @@ export default function EditorPage() { const errorData = await response .json() .catch(() => ({ detail: "Failed to save document" })); - - // Log save error - try { - await createLog({ - level: "ERROR", - status: "FAILED", - message: `Failed to save new note: ${errorData.detail || "Unknown error"}`, - source: "editor", - search_space_id: searchSpaceId, - log_metadata: { - document_id: note.id, - is_new_note: true, - action: "save", - http_status: response.status, - }, - }); - } catch (err) { - console.error("Failed to create log:", err); - } - throw new Error(errorData.detail || "Failed to save document"); } } - // Log successful note creation - try { - await createLog({ - level: "INFO", - status: "SUCCESS", - message: `Note created successfully: ${title}`, - source: "editor", - search_space_id: searchSpaceId, - log_metadata: { - document_id: note.id, - is_new_note: true, - action: "save", - title: title, - }, - }); - } catch (err) { - console.error("Failed to create log:", err); - } - setHasUnsavedChanges(false); toast.success("Note created successfully! Reindexing in background..."); @@ -363,46 +249,9 @@ export default function EditorPage() { const errorData = await response .json() .catch(() => ({ detail: "Failed to save document" })); - - // Log save error - try { - await createLog({ - level: "ERROR", - status: "FAILED", - message: `Failed to save document ${documentId}: ${errorData.detail || "Unknown error"}`, - source: "editor", - search_space_id: searchSpaceId, - log_metadata: { - document_id: documentId, - action: "save", - http_status: response.status, - }, - }); - } catch (err) { - console.error("Failed to create log:", err); - } - throw new Error(errorData.detail || "Failed to save document"); } - // Log successful save - try { - await createLog({ - level: "INFO", - status: "SUCCESS", - message: `Document ${documentId} saved successfully`, - source: "editor", - search_space_id: searchSpaceId, - log_metadata: { - document_id: documentId, - action: "save", - title: document?.title, - }, - }); - } catch (err) { - console.error("Failed to create log:", err); - } - setHasUnsavedChanges(false); toast.success("Document saved! Reindexing in background..."); @@ -421,26 +270,6 @@ export default function EditorPage() { : isNewNote ? "Failed to create note. Please try again." : "Failed to save document. Please try again."; - - // Log save error - try { - await createLog({ - level: "ERROR", - status: "FAILED", - message: `Error saving document: ${errorMessage}`, - source: "editor", - search_space_id: searchSpaceId, - log_metadata: { - document_id: isNewNote ? null : documentId, - is_new_note: isNewNote, - action: "save", - error_type: "save_exception", - }, - }); - } catch (err) { - console.error("Failed to create log:", err); - } - setError(errorMessage); toast.error(errorMessage); } finally { @@ -557,7 +386,7 @@ export default function EditorPage() {
{/* Editor Container */} -
+
{error && ( => { + async (roleData: CreateRoleRequest["data"]): Promise => { const request: CreateRoleRequest = { search_space_id: searchSpaceId, data: roleData, @@ -1219,7 +1228,7 @@ function CreateRoleDialog({ onCreateRole, }: { groupedPermissions: Record; - onCreateRole: (data: CreateRoleRequest['data']) => Promise; + onCreateRole: (data: CreateRoleRequest["data"]) => Promise; }) { const [open, setOpen] = useState(false); const [creating, setCreating] = useState(false); diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx index ed39593e3..dbf5b7155 100644 --- a/surfsense_web/app/dashboard/page.tsx +++ b/surfsense_web/app/dashboard/page.tsx @@ -8,6 +8,9 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; +import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; +import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { Logo } from "@/components/Logo"; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; import { UserDropdown } from "@/components/UserDropdown"; @@ -35,10 +38,7 @@ import { } from "@/components/ui/card"; import { Spotlight } from "@/components/ui/spotlight"; import { Tilt } from "@/components/ui/tilt"; -import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; -import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { authenticatedFetch } from "@/lib/auth-utils"; -import { currentUserAtom } from "@/atoms/user/user-query.atoms"; /** * Formats a date string into a readable format diff --git a/surfsense_web/app/dashboard/searchspaces/page.tsx b/surfsense_web/app/dashboard/searchspaces/page.tsx index 76e17c0ce..ae3956a54 100644 --- a/surfsense_web/app/dashboard/searchspaces/page.tsx +++ b/surfsense_web/app/dashboard/searchspaces/page.tsx @@ -3,8 +3,8 @@ import { useAtomValue } from "jotai"; import { motion } from "motion/react"; import { useRouter } from "next/navigation"; -import { SearchSpaceForm } from "@/components/search-space-form"; import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; +import { SearchSpaceForm } from "@/components/search-space-form"; export default function SearchSpacesPage() { const router = useRouter(); diff --git a/surfsense_web/atoms/roles/roles-mutation.atoms.ts b/surfsense_web/atoms/roles/roles-mutation.atoms.ts index 47ece8b68..ddbc68ca2 100644 --- a/surfsense_web/atoms/roles/roles-mutation.atoms.ts +++ b/surfsense_web/atoms/roles/roles-mutation.atoms.ts @@ -3,10 +3,10 @@ import { toast } from "sonner"; import type { CreateRoleRequest, CreateRoleResponse, - UpdateRoleRequest, - UpdateRoleResponse, DeleteRoleRequest, DeleteRoleResponse, + UpdateRoleRequest, + UpdateRoleResponse, } from "@/contracts/types/roles.types"; import { rolesApiService } from "@/lib/apis/roles-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; @@ -40,7 +40,10 @@ export const updateRoleMutationAtom = atomWithMutation(() => { queryKey: cacheKeys.roles.all(request.search_space_id.toString()), }); queryClient.invalidateQueries({ - queryKey: cacheKeys.roles.byId(request.search_space_id.toString(), request.role_id.toString()), + queryKey: cacheKeys.roles.byId( + request.search_space_id.toString(), + request.role_id.toString() + ), }); }, onError: () => { diff --git a/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts b/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts index ea1415869..62f23507b 100644 --- a/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts +++ b/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts @@ -2,13 +2,13 @@ import { atomWithMutation } from "jotai-tanstack-query"; import { toast } from "sonner"; import type { CreateSearchSpaceRequest, - UpdateSearchSpaceRequest, DeleteSearchSpaceRequest, + UpdateSearchSpaceRequest, } from "@/contracts/types/search-space.types"; -import { activeSearchSpaceIdAtom } from "./search-space-query.atoms"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { queryClient } from "@/lib/query-client/client"; +import { activeSearchSpaceIdAtom } from "./search-space-query.atoms"; export const createSearchSpaceMutationAtom = atomWithMutation(() => { return { diff --git a/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts b/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts index 1f03e25a2..4aa024e93 100644 --- a/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts +++ b/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts @@ -1,5 +1,5 @@ -import { atomWithQuery } from "jotai-tanstack-query"; import { atom } from "jotai"; +import { atomWithQuery } from "jotai-tanstack-query"; import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx index c6b264879..abf77da5e 100644 --- a/surfsense_web/components/dashboard-breadcrumb.tsx +++ b/surfsense_web/components/dashboard-breadcrumb.tsx @@ -1,5 +1,6 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -13,10 +14,9 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; -import { useQuery } from "@tanstack/react-query"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; interface BreadcrumbItemInterface { label: string; @@ -44,7 +44,7 @@ export function DashboardBreadcrumb() { useEffect(() => { if (segments[2] === "editor" && segments[3] && searchSpaceId) { const documentId = segments[3]; - + // Skip fetch for "new" notes if (documentId === "new") { setDocumentTitle(null); @@ -124,7 +124,7 @@ export function DashboardBreadcrumb() { } else { documentLabel = documentTitle || subSection; } - + breadcrumbs.push({ label: t("documents"), href: `/dashboard/${segments[1]}/documents`, diff --git a/surfsense_web/components/onboard/setup-prompt-step.tsx b/surfsense_web/components/onboard/setup-prompt-step.tsx index 5e3683031..b53e49700 100644 --- a/surfsense_web/components/onboard/setup-prompt-step.tsx +++ b/surfsense_web/components/onboard/setup-prompt-step.tsx @@ -1,8 +1,10 @@ "use client"; +import { useAtomValue } from "jotai"; import { ChevronDown, ChevronUp, ExternalLink, Info, Sparkles, User } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -12,9 +14,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; -import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { authenticatedFetch } from "@/lib/auth-utils"; -import { useAtomValue } from "jotai"; interface SetupPromptStepProps { searchSpaceId: number; diff --git a/surfsense_web/components/settings/prompt-config-manager.tsx b/surfsense_web/components/settings/prompt-config-manager.tsx index dae842305..82456919c 100644 --- a/surfsense_web/components/settings/prompt-config-manager.tsx +++ b/surfsense_web/components/settings/prompt-config-manager.tsx @@ -1,5 +1,7 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; import { ChevronDown, ChevronUp, @@ -12,6 +14,7 @@ import { } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -23,19 +26,20 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; -import { useQuery } from "@tanstack/react-query"; -import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; -import { useAtomValue } from "jotai"; import { authenticatedFetch } from "@/lib/auth-utils"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; interface PromptConfigManagerProps { searchSpaceId: number; } export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) { - const { data: searchSpace, isLoading: loading, refetch: fetchSearchSpace } = useQuery({ + const { + data: searchSpace, + isLoading: loading, + refetch: fetchSearchSpace, + } = useQuery({ queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()), queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }), enabled: !!searchSpaceId, diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx index ea717e125..ca05b0e3f 100644 --- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx +++ b/surfsense_web/components/sidebar/AppSidebarProvider.tsx @@ -1,5 +1,6 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; @@ -8,6 +9,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms"; import { chatsAtom } from "@/atoms/chats/chat-query.atoms"; import { globalChatsQueryParamsAtom } from "@/atoms/chats/ui.atoms"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { AppSidebar } from "@/components/sidebar/app-sidebar"; import { Button } from "@/components/ui/button"; import { @@ -18,11 +20,9 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { useQuery } from "@tanstack/react-query"; import { notesApiService } from "@/lib/apis/notes-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; -import { currentUserAtom } from "@/atoms/user/user-query.atoms"; interface AppSidebarProviderProps { searchSpaceId: string; diff --git a/surfsense_web/components/sidebar/all-notes-sidebar.tsx b/surfsense_web/components/sidebar/all-notes-sidebar.tsx index f2065edef..11c4f80ec 100644 --- a/surfsense_web/components/sidebar/all-notes-sidebar.tsx +++ b/surfsense_web/components/sidebar/all-notes-sidebar.tsx @@ -1,60 +1,36 @@ "use client"; -import { FileText, type LucideIcon, MoreHorizontal, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useMemo, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { notesApiService } from "@/lib/apis/notes-api.service"; +import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { - SidebarGroup, - SidebarGroupContent, - SidebarMenu, - SidebarMenuAction, - SidebarMenuButton, - SidebarMenuItem, -} from "@/components/ui/sidebar"; -import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { documentsApiService } from "@/lib/apis/documents-api.service"; +import { notesApiService } from "@/lib/apis/notes-api.service"; import { cn } from "@/lib/utils"; -import { useEffect, useRef } from "react"; -import { createPortal } from "react-dom"; - -// Map of icon names to their components -const actionIconMap: Record = { - FileText, - Trash2, - MoreHorizontal, - RefreshCw, -}; - -interface NoteAction { - name: string; - icon: string; - onClick: () => void; -} - -interface NoteItem { - name: string; - url: string; - icon: LucideIcon; - id?: number; - search_space_id?: number; - actions?: NoteAction[]; -} interface AllNotesSidebarProps { open: boolean; onOpenChange: (open: boolean) => void; searchSpaceId: string; onAddNote?: () => void; - hoverTimeoutRef?: React.MutableRefObject; } export function AllNotesSidebar({ @@ -62,315 +38,242 @@ export function AllNotesSidebar({ onOpenChange, searchSpaceId, onAddNote, - hoverTimeoutRef, }: AllNotesSidebarProps) { const t = useTranslations("sidebar"); const router = useRouter(); - const [isDeleting, setIsDeleting] = useState(null); - const sidebarRef = useRef(null); - const [sidebarLeft, setSidebarLeft] = useState(0); // Position from left edge of viewport + const queryClient = useQueryClient(); + const [deletingNoteId, setDeletingNoteId] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); - // Calculate the sidebar's right edge position - useEffect(() => { - if (typeof window === "undefined") return; - - const updatePosition = () => { - // Find the actual sidebar element (the fixed positioned one) - const sidebarElement = document.querySelector( - '[data-slot="sidebar"][data-sidebar="sidebar"]' - ) as HTMLElement; - - if (sidebarElement) { - const rect = sidebarElement.getBoundingClientRect(); - // Set the left position to be the right edge of the sidebar - setSidebarLeft(rect.right); - } else { - // Fallback: try to find any sidebar element - const fallbackSidebar = document.querySelector('[data-slot="sidebar"]') as HTMLElement; - if (fallbackSidebar) { - const rect = fallbackSidebar.getBoundingClientRect(); - setSidebarLeft(rect.right); - } else { - // Final fallback: use CSS variable - const sidebarWidth = getComputedStyle(document.documentElement) - .getPropertyValue("--sidebar-width") - .trim(); - if (sidebarWidth) { - const remValue = parseFloat(sidebarWidth); - setSidebarLeft(remValue * 16); // Convert rem to px - } else { - setSidebarLeft(256); // Default 16rem - } - } - } - }; - - updatePosition(); - // Update on window resize and scroll - window.addEventListener("resize", updatePosition); - window.addEventListener("scroll", updatePosition, true); - - // Use MutationObserver to watch for sidebar state changes - const observer = new MutationObserver(updatePosition); - const sidebarWrapper = document.querySelector('[data-slot="sidebar-wrapper"]'); - if (sidebarWrapper) { - observer.observe(sidebarWrapper, { - attributes: true, - attributeFilter: ["data-state", "class"], - childList: true, - subtree: true, - }); - } - - // Also observe the sidebar element directly if it exists - const sidebarElement = document.querySelector('[data-slot="sidebar"]'); - if (sidebarElement) { - observer.observe(sidebarElement, { - attributes: true, - attributeFilter: ["data-state", "class"], - childList: false, - subtree: false, - }); - } - - return () => { - window.removeEventListener("resize", updatePosition); - window.removeEventListener("scroll", updatePosition, true); - observer.disconnect(); - }; - }, []); - - // Handle Escape key to close sidebar - useEffect(() => { - if (!open) return; - - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onOpenChange(false); - } - }; - - window.addEventListener("keydown", handleEscape); - return () => window.removeEventListener("keydown", handleEscape); - }, [open, onOpenChange]); - - // Fetch all notes + // Fetch all notes (when no search query) const { data: notesData, error: notesError, isLoading: isLoadingNotes, - refetch: refetchNotes, } = useQuery({ queryKey: ["all-notes", searchSpaceId], queryFn: () => notesApiService.getNotes({ search_space_id: Number(searchSpaceId), - page_size: 1000, // Get all notes + page_size: 1000, }), - enabled: !!searchSpaceId && open, // Only fetch when sidebar is open + enabled: !!searchSpaceId && open && !debouncedSearchQuery, }); - // Handle note deletion with loading state + // Search notes (when there's a search query) + const { + data: searchData, + error: searchError, + isLoading: isSearching, + } = useQuery({ + queryKey: ["search-notes", searchSpaceId, debouncedSearchQuery], + queryFn: () => + documentsApiService.searchDocuments({ + queryParams: { + search_space_id: Number(searchSpaceId), + document_types: ["NOTE"], + title: debouncedSearchQuery, + page_size: 100, + }, + }), + enabled: !!searchSpaceId && open && !!debouncedSearchQuery, + }); + + // Handle note navigation + const handleNoteClick = useCallback( + (noteId: number, noteSearchSpaceId: number) => { + router.push(`/dashboard/${noteSearchSpaceId}/editor/${noteId}`); + onOpenChange(false); + }, + [router, onOpenChange] + ); + + // Handle note deletion const handleDeleteNote = useCallback( - async (noteId: number, deleteAction: () => void) => { - setIsDeleting(noteId); + async (noteId: number, noteSearchSpaceId: number) => { + setDeletingNoteId(noteId); try { - await deleteAction(); - refetchNotes(); + await notesApiService.deleteNote({ + search_space_id: noteSearchSpaceId, + note_id: noteId, + }); + // Invalidate queries to refresh the list + queryClient.invalidateQueries({ queryKey: ["all-notes", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["notes", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["search-notes", searchSpaceId] }); + } catch (error) { + console.error("Error deleting note:", error); } finally { - setIsDeleting(null); + setDeletingNoteId(null); } }, - [refetchNotes] + [queryClient, searchSpaceId] ); - // Transform notes to the format expected by the component - const allNotes = useMemo(() => { - return notesData?.items - ? notesData.items.map((note) => ({ - name: note.title, - url: `/dashboard/${note.search_space_id}/editor/${note.id}`, - icon: FileText as LucideIcon, - id: note.id, - search_space_id: note.search_space_id, - actions: [ - { - name: "Delete", - icon: "Trash2", - onClick: async () => { - try { - await notesApiService.deleteNote({ - search_space_id: note.search_space_id, - note_id: note.id, - }); - } catch (error) { - console.error("Error deleting note:", error); - } - }, - }, - ], - })) - : []; - }, [notesData]); + // Clear search + const handleClearSearch = useCallback(() => { + setSearchQuery(""); + }, []); - // Enhanced note item component - const NoteItemComponent = useCallback( - ({ note }: { note: NoteItem }) => { - const isDeletingNote = isDeleting === note.id; + // Determine which data to show + const isSearchMode = !!debouncedSearchQuery; + const isLoading = isSearchMode ? isSearching : isLoadingNotes; + const error = isSearchMode ? searchError : notesError; - return ( - - { - router.push(note.url); - onOpenChange(false); // Close sidebar when navigating - }} - disabled={isDeletingNote} - className={cn("group/item relative", isDeletingNote && "opacity-50")} - > - - {note.name} - + // Transform notes data - handle both regular notes and search results + const notes = useMemo(() => { + if (isSearchMode && searchData?.items) { + return searchData.items.map((doc) => ({ + id: doc.id, + title: doc.title, + search_space_id: doc.search_space_id, + })); + } + return notesData?.items ?? []; + }, [isSearchMode, searchData, notesData]); - {note.actions && note.actions.length > 0 && ( - - - - - More - - - - {note.actions.map((action, actionIndex) => { - const ActionIcon = actionIconMap[action.icon] || FileText; - const isDeleteAction = action.name.toLowerCase().includes("delete"); + return ( + + + + {t("all_notes") || "All Notes"} + + {t("all_notes_description") || "Browse and manage all your notes"} + - return ( - { - if (isDeleteAction) { - handleDeleteNote(note.id || 0, action.onClick); - } else { - action.onClick(); - } - }} - disabled={isDeletingNote} - className={isDeleteAction ? "text-destructive" : ""} - > - - {isDeletingNote && isDeleteAction ? "Deleting..." : action.name} - - ); - })} - - - )} - - ); - }, - [isDeleting, router, onOpenChange, handleDeleteNote] - ); + {/* Search Input */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-8 h-9" + /> + {searchQuery && ( + + )} +
+ - const sidebarContent = ( -
{ - // Clear any pending close timeout when hovering over sidebar - if (hoverTimeoutRef?.current) { - clearTimeout(hoverTimeoutRef.current); - hoverTimeoutRef.current = null; - } - }} - onMouseLeave={() => { - // Close sidebar when mouse leaves - if (hoverTimeoutRef) { - hoverTimeoutRef.current = setTimeout(() => { - onOpenChange(false); - }, 200); - } else { - onOpenChange(false); - } - }} - > -
- {/* Header */} -
-

{t("all_notes") || "All Notes"}

-
- - {/* Content */}
- - - {isLoadingNotes ? ( - - - - {t("loading") || "Loading..."} - - - - ) : notesError ? ( - - - - {t("error_loading_notes") || "Error loading notes"} - - - - ) : allNotes.length > 0 ? ( - - {allNotes.map((note) => ( - - ))} - - ) : ( - - {onAddNote ? ( - { - onAddNote(); - onOpenChange(false); - }} - className="text-muted-foreground hover:text-sidebar-foreground text-xs" + {isLoading ? ( +
+ +
+ ) : error ? ( +
+ {t("error_loading_notes") || "Error loading notes"} +
+ ) : notes.length > 0 ? ( +
+ {notes.map((note) => { + const isDeleting = deletingNoteId === note.id; + + return ( +
+ {/* Main clickable area for navigation */} + + + {/* Actions dropdown - separate from main click area */} + + + + + + handleDeleteNote(note.id, note.search_space_id)} + className="text-destructive focus:text-destructive" + > + + Delete + + + +
+ ); + })} +
+ ) : isSearchMode ? ( +
+ +

+ {t("no_results_found") || "No notes found"} +

+

+ {t("try_different_search") || "Try a different search term"} +

+
+ ) : ( +
+ +

+ {t("no_notes") || "No notes yet"} +

+ {onAddNote && ( + )} - - +
+ )}
{/* Footer with Add Note button */} - {onAddNote && ( -
+ {onAddNote && notes.length > 0 && ( +
)} -
-
+ + ); - - // Render sidebar via portal to avoid stacking context issues - if (typeof window === "undefined") { - return null; - } - - return createPortal(sidebarContent, document.body); } diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx index f1d690c7f..48e7c35b8 100644 --- a/surfsense_web/components/sidebar/app-sidebar.tsx +++ b/surfsense_web/components/sidebar/app-sidebar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useAtomValue } from "jotai"; import { AlertCircle, BookOpen, @@ -27,7 +28,7 @@ import { import { useRouter } from "next/navigation"; import { useTheme } from "next-themes"; import { memo, useEffect, useMemo, useState } from "react"; - +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { DropdownMenu, DropdownMenuContent, @@ -37,8 +38,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { useAtomValue } from "jotai"; -import { currentUserAtom } from "@/atoms/user/user-query.atoms"; /** * Generates a consistent color based on a string (email) @@ -454,7 +453,11 @@ export const AppSidebar = memo(function AppSidebar({ )}
- +
diff --git a/surfsense_web/components/sidebar/nav-notes.tsx b/surfsense_web/components/sidebar/nav-notes.tsx index 60e5d9c12..b14ecea77 100644 --- a/surfsense_web/components/sidebar/nav-notes.tsx +++ b/surfsense_web/components/sidebar/nav-notes.tsx @@ -2,19 +2,18 @@ import { ChevronRight, - ExternalLink, - Eye, FileText, + FolderOpen, + Loader2, type LucideIcon, MoreHorizontal, Plus, - RefreshCw, - Share, Trash2, } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useCallback, useState, useRef } from "react"; +import { useCallback, useState } from "react"; +import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { DropdownMenu, @@ -27,23 +26,12 @@ import { SidebarGroupContent, SidebarGroupLabel, SidebarMenu, - SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, - useSidebar, } from "@/components/ui/sidebar"; +import { cn } from "@/lib/utils"; import { AllNotesSidebar } from "./all-notes-sidebar"; -// Map of icon names to their components -const actionIconMap: Record = { - ExternalLink, - FileText, - Share, - Trash2, - MoreHorizontal, - RefreshCw, -}; - interface NoteAction { name: string; icon: string; @@ -66,14 +54,19 @@ interface NavNotesProps { searchSpaceId?: string; } +// Map of icon names to their components +const actionIconMap: Record = { + FileText, + Trash2, + MoreHorizontal, +}; + export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }: NavNotesProps) { const t = useTranslations("sidebar"); - const { isMobile } = useSidebar(); const router = useRouter(); const [isDeleting, setIsDeleting] = useState(null); const [isOpen, setIsOpen] = useState(defaultOpen); const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false); - const hoverTimeoutRef = useRef(null); // Handle note deletion with loading state const handleDeleteNote = useCallback(async (noteId: number, deleteAction: () => void) => { @@ -85,132 +78,148 @@ export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId } } }, []); - // Enhanced note item component - const NoteItemComponent = useCallback( - ({ note }: { note: NoteItem }) => { - const isDeletingNote = isDeleting === note.id; - - return ( - - router.push(note.url)} - disabled={isDeletingNote} - className={`group/item relative ${isDeletingNote ? "opacity-50" : ""}`} - > - - {note.name} - - - {note.actions && note.actions.length > 0 && ( - - - - - More - - - - {note.actions.map((action, actionIndex) => { - const ActionIcon = actionIconMap[action.icon] || FileText; - const isDeleteAction = action.name.toLowerCase().includes("delete"); - - return ( - { - if (isDeleteAction) { - handleDeleteNote(note.id || 0, action.onClick); - } else { - action.onClick(); - } - }} - disabled={isDeletingNote} - className={isDeleteAction ? "text-destructive" : ""} - > - - {isDeletingNote && isDeleteAction ? "Deleting..." : action.name} - - ); - })} - - - )} - - ); + // Handle note navigation + const handleNoteClick = useCallback( + (url: string) => { + router.push(url); }, - [isDeleting, router, isMobile, handleDeleteNote] + [router] ); return ( - + -
+
- + {t("notes") || "Notes"} -
+ + {/* Action buttons - always visible on hover */} +
{searchSpaceId && notes.length > 0 && ( - + + )} {onAddNote && ( - + + )}
+ - {/* Note Items */} {notes.length > 0 ? ( - notes.map((note) => ) + notes.map((note) => { + const isDeletingNote = isDeleting === note.id; + + return ( + + {/* Main navigation button */} + handleNoteClick(note.url)} + disabled={isDeletingNote} + className={cn( + "pr-8", // Make room for the action button + isDeletingNote && "opacity-50" + )} + > + + {note.name} + + + {/* Actions dropdown - positioned absolutely */} + {note.actions && note.actions.length > 0 && ( +
+ + + + + + {note.actions.map((action, actionIndex) => { + const ActionIcon = actionIconMap[action.icon] || FileText; + const isDeleteAction = action.name.toLowerCase().includes("delete"); + + return ( + { + if (isDeleteAction) { + handleDeleteNote(note.id || 0, action.onClick); + } else { + action.onClick(); + } + }} + disabled={isDeletingNote} + className={ + isDeleteAction + ? "text-destructive focus:text-destructive" + : "" + } + > + + + {isDeletingNote && isDeleteAction + ? "Deleting..." + : action.name} + + + ); + })} + + +
+ )} +
+ ); + }) ) : ( - /* Empty state with create button */ {onAddNote ? (
+ + {/* All Notes Sheet */} {searchSpaceId && ( )} diff --git a/surfsense_web/components/sidebar/page-usage-display.tsx b/surfsense_web/components/sidebar/page-usage-display.tsx index 74e0e4671..51e67d2f0 100644 --- a/surfsense_web/components/sidebar/page-usage-display.tsx +++ b/surfsense_web/components/sidebar/page-usage-display.tsx @@ -60,4 +60,4 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp ); -} \ No newline at end of file +} diff --git a/surfsense_web/contracts/enums/connectorIcons.tsx b/surfsense_web/contracts/enums/connectorIcons.tsx index 18521edb7..a12d26197 100644 --- a/surfsense_web/contracts/enums/connectorIcons.tsx +++ b/surfsense_web/contracts/enums/connectorIcons.tsx @@ -17,7 +17,17 @@ import { IconTicket, IconWorldWww, } from "@tabler/icons-react"; -import { File, FileText, Globe, Link, Microscope, Search, Sparkles, Telescope, Webhook } from "lucide-react"; +import { + File, + FileText, + Globe, + Link, + Microscope, + Search, + Sparkles, + Telescope, + Webhook, +} from "lucide-react"; import { EnumConnectorName } from "./connector"; export const getConnectorIcon = (connectorType: EnumConnectorName | string, className?: string) => { diff --git a/surfsense_web/contracts/types/document.types.ts b/surfsense_web/contracts/types/document.types.ts index abffc68a5..3ce5388dd 100644 --- a/surfsense_web/contracts/types/document.types.ts +++ b/surfsense_web/contracts/types/document.types.ts @@ -19,6 +19,7 @@ export const documentTypeEnum = z.enum([ "LUMA_CONNECTOR", "ELASTICSEARCH_CONNECTOR", "LINEAR_CONNECTOR", + "NOTE", ]); export const document = z.object({ @@ -27,7 +28,10 @@ export const document = z.object({ document_type: documentTypeEnum, document_metadata: z.record(z.string(), z.any()), content: z.string(), + content_hash: z.string(), + unique_identifier_hash: z.string().nullable(), created_at: z.string(), + updated_at: z.string().nullable(), search_space_id: z.number(), }); @@ -68,6 +72,9 @@ export const getDocumentsRequest = z.object({ export const getDocumentsResponse = z.object({ items: z.array(document), total: z.number(), + page: z.number(), + page_size: z.number(), + has_more: z.boolean(), }); /** @@ -118,6 +125,9 @@ export const searchDocumentsRequest = z.object({ export const searchDocumentsResponse = z.object({ items: z.array(document), total: z.number(), + page: z.number(), + page_size: z.number(), + has_more: z.boolean(), }); /** diff --git a/surfsense_web/contracts/types/roles.types.ts b/surfsense_web/contracts/types/roles.types.ts index 31ad0e970..9008a859a 100644 --- a/surfsense_web/contracts/types/roles.types.ts +++ b/surfsense_web/contracts/types/roles.types.ts @@ -51,12 +51,14 @@ export const getRoleByIdResponse = role; export const updateRoleRequest = z.object({ search_space_id: z.number(), role_id: z.number(), - data: role.pick({ - name: true, - description: true, - permissions: true, - is_default: true, - }).partial(), + data: role + .pick({ + name: true, + description: true, + permissions: true, + is_default: true, + }) + .partial(), }); export const updateRoleResponse = role; diff --git a/surfsense_web/contracts/types/search-space.types.ts b/surfsense_web/contracts/types/search-space.types.ts index c0096b41c..b591fafbb 100644 --- a/surfsense_web/contracts/types/search-space.types.ts +++ b/surfsense_web/contracts/types/search-space.types.ts @@ -2,26 +2,26 @@ import { z } from "zod"; import { paginationQueryParams } from "."; export const searchSpace = z.object({ - id: z.number(), - name: z.string(), - description: z.string().nullable(), - created_at: z.string(), - user_id: z.string(), - citations_enabled: z.boolean(), - qna_custom_instructions: z.string().nullable(), - member_count: z.number(), - is_owner: z.boolean(), + id: z.number(), + name: z.string(), + description: z.string().nullable(), + created_at: z.string(), + user_id: z.string(), + citations_enabled: z.boolean(), + qna_custom_instructions: z.string().nullable(), + member_count: z.number(), + is_owner: z.boolean(), }); /** * Get search spaces */ export const getSearchSpacesRequest = z.object({ - queryParams: paginationQueryParams - .extend({ - owned_only: z.boolean().optional(), - }) - .nullish(), + queryParams: paginationQueryParams + .extend({ + owned_only: z.boolean().optional(), + }) + .nullish(), }); export const getSearchSpacesResponse = z.array(searchSpace); @@ -29,12 +29,10 @@ export const getSearchSpacesResponse = z.array(searchSpace); /** * Create search space */ -export const createSearchSpaceRequest = searchSpace - .pick({ name: true, description: true }) - .extend({ - citations_enabled: z.boolean().default(true).optional(), - qna_custom_instructions: z.string().nullable().optional(), - }); +export const createSearchSpaceRequest = searchSpace.pick({ name: true, description: true }).extend({ + citations_enabled: z.boolean().default(true).optional(), + qna_custom_instructions: z.string().nullable().optional(), +}); export const createSearchSpaceResponse = searchSpace.omit({ member_count: true, is_owner: true }); @@ -42,13 +40,13 @@ export const createSearchSpaceResponse = searchSpace.omit({ member_count: true, * Get community prompts */ export const getCommunityPromptsResponse = z.array( - z.object({ - key: z.string(), - value: z.string(), - author: z.string(), - link: z.string(), - category: z.string(), - }) + z.object({ + key: z.string(), + value: z.string(), + author: z.string(), + link: z.string(), + category: z.string(), + }) ); /** @@ -62,10 +60,10 @@ export const getSearchSpaceResponse = searchSpace.omit({ member_count: true, is_ * Update search space */ export const updateSearchSpaceRequest = z.object({ - id: z.number(), - data: searchSpace - .pick({ name: true, description: true, citations_enabled: true, qna_custom_instructions: true }) - .partial(), + id: z.number(), + data: searchSpace + .pick({ name: true, description: true, citations_enabled: true, qna_custom_instructions: true }) + .partial(), }); export const updateSearchSpaceResponse = searchSpace.omit({ member_count: true, is_owner: true }); @@ -76,7 +74,7 @@ export const updateSearchSpaceResponse = searchSpace.omit({ member_count: true, export const deleteSearchSpaceRequest = searchSpace.pick({ id: true }); export const deleteSearchSpaceResponse = z.object({ - message: z.literal("Search space deleted successfully"), + message: z.literal("Search space deleted successfully"), }); // Inferred types diff --git a/surfsense_web/hooks/index.ts b/surfsense_web/hooks/index.ts index d2a4ff6bf..db454c161 100644 --- a/surfsense_web/hooks/index.ts +++ b/surfsense_web/hooks/index.ts @@ -1,3 +1,4 @@ +export * from "./use-debounced-value"; export * from "./use-logs"; export * from "./use-rbac"; export * from "./use-search-source-connectors"; diff --git a/surfsense_web/hooks/use-debounced-value.ts b/surfsense_web/hooks/use-debounced-value.ts new file mode 100644 index 000000000..6b4ba2adb --- /dev/null +++ b/surfsense_web/hooks/use-debounced-value.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react"; + +/** + * Hook that returns a debounced value that only updates after the specified delay + * @param value - The value to debounce + * @param delay - The delay in milliseconds (default: 300ms) + * @returns The debounced value + */ +export function useDebouncedValue(value: T, delay: number = 300): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/surfsense_web/hooks/use-logs.ts b/surfsense_web/hooks/use-logs.ts index 6ce025e89..cfd161de0 100644 --- a/surfsense_web/hooks/use-logs.ts +++ b/surfsense_web/hooks/use-logs.ts @@ -141,32 +141,43 @@ export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) { ); // Function to create a new log - const createLog = useCallback(async (logData: Omit) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`, - { - headers: { "Content-Type": "application/json" }, - method: "POST", - body: JSON.stringify(logData), + // Use silent: true to suppress toast notifications (for internal/background operations) + const createLog = useCallback( + async (logData: Omit, options?: { silent?: boolean }) => { + const { silent = false } = options || {}; + try { + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`, + { + headers: { "Content-Type": "application/json" }, + method: "POST", + body: JSON.stringify(logData), + } + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || "Failed to create log"); } - ); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to create log"); + const newLog = await response.json(); + setLogs((prevLogs) => [newLog, ...prevLogs]); + // Only show toast if not silent + if (!silent) { + toast.success("Log created successfully"); + } + return newLog; + } catch (err: any) { + // Only show error toast if not silent + if (!silent) { + toast.error(err.message || "Failed to create log"); + } + console.error("Error creating log:", err); + throw err; } - - const newLog = await response.json(); - setLogs((prevLogs) => [newLog, ...prevLogs]); - toast.success("Log created successfully"); - return newLog; - } catch (err: any) { - toast.error(err.message || "Failed to create log"); - console.error("Error creating log:", err); - throw err; - } - }, []); + }, + [] + ); // Function to update a log const updateLog = useCallback( diff --git a/surfsense_web/lib/apis/notes-api.service.ts b/surfsense_web/lib/apis/notes-api.service.ts index 38a48e0d7..5e8ab8a96 100644 --- a/surfsense_web/lib/apis/notes-api.service.ts +++ b/surfsense_web/lib/apis/notes-api.service.ts @@ -145,4 +145,3 @@ class NotesApiService { } export const notesApiService = new NotesApiService(); - diff --git a/surfsense_web/lib/apis/roles-api.service.ts b/surfsense_web/lib/apis/roles-api.service.ts index 92083293a..1a574ee4b 100644 --- a/surfsense_web/lib/apis/roles-api.service.ts +++ b/surfsense_web/lib/apis/roles-api.service.ts @@ -6,9 +6,9 @@ import { deleteRoleRequest, deleteRoleResponse, type GetRoleByIdRequest, + type GetRolesRequest, getRoleByIdRequest, getRoleByIdResponse, - type GetRolesRequest, getRolesRequest, getRolesResponse, type UpdateRoleRequest, @@ -34,7 +34,7 @@ class RolesApiService { createRoleResponse, { body: parsedRequest.data.data, - }, + } ); }; @@ -50,7 +50,7 @@ class RolesApiService { return baseApiService.get( `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles`, - getRolesResponse, + getRolesResponse ); }; @@ -66,7 +66,7 @@ class RolesApiService { return baseApiService.get( `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, - getRoleByIdResponse, + getRoleByIdResponse ); }; @@ -85,7 +85,7 @@ class RolesApiService { updateRoleResponse, { body: parsedRequest.data.data, - }, + } ); }; @@ -101,7 +101,7 @@ class RolesApiService { return baseApiService.delete( `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, - deleteRoleResponse, + deleteRoleResponse ); }; } diff --git a/surfsense_web/lib/apis/search-spaces-api.service.ts b/surfsense_web/lib/apis/search-spaces-api.service.ts index 73b57ee3c..23433faee 100644 --- a/surfsense_web/lib/apis/search-spaces-api.service.ts +++ b/surfsense_web/lib/apis/search-spaces-api.service.ts @@ -1,18 +1,18 @@ import { type CreateSearchSpaceRequest, - type DeleteSearchSpaceRequest, - type GetSearchSpaceRequest, - type GetSearchSpacesRequest, - type UpdateSearchSpaceRequest, createSearchSpaceRequest, createSearchSpaceResponse, + type DeleteSearchSpaceRequest, deleteSearchSpaceRequest, deleteSearchSpaceResponse, + type GetSearchSpaceRequest, + type GetSearchSpacesRequest, getCommunityPromptsResponse, getSearchSpaceRequest, getSearchSpaceResponse, getSearchSpacesRequest, getSearchSpacesResponse, + type UpdateSearchSpaceRequest, updateSearchSpaceRequest, updateSearchSpaceResponse, } from "@/contracts/types/search-space.types"; @@ -71,7 +71,10 @@ class SearchSpacesApiService { * Get community-curated prompts for search space system instructions */ getCommunityPrompts = async () => { - return baseApiService.get(`/api/v1/searchspaces/prompts/community`, getCommunityPromptsResponse); + return baseApiService.get( + `/api/v1/searchspaces/prompts/community`, + getCommunityPromptsResponse + ); }; /** diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 6ac7c6a6e..db7af6636 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -2,8 +2,8 @@ import type { GetChatsRequest } from "@/contracts/types/chat.types"; import type { GetDocumentsRequest } from "@/contracts/types/document.types"; import type { GetLLMConfigsRequest } from "@/contracts/types/llm-config.types"; import type { GetPodcastsRequest } from "@/contracts/types/podcast.types"; -import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types"; import type { GetRolesRequest } from "@/contracts/types/roles.types"; +import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types"; export const cacheKeys = { chats: { diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 376e76a07..e2cf89b5f 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -645,6 +645,10 @@ "search_space": "Search Space", "notes": "Notes", "all_notes": "All Notes", + "all_notes_description": "Browse and manage all your notes", + "search_notes": "Search notes...", + "no_results_found": "No notes found", + "try_different_search": "Try a different search term", "no_notes": "No notes yet", "create_new_note": "Create a new note", "error_loading_notes": "Error loading notes", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index e9a50930c..38546bb87 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -645,6 +645,10 @@ "search_space": "搜索空间", "notes": "笔记", "all_notes": "所有笔记", + "all_notes_description": "浏览和管理您的所有笔记", + "search_notes": "搜索笔记...", + "no_results_found": "未找到笔记", + "try_different_search": "尝试其他搜索词", "no_notes": "暂无笔记", "create_new_note": "创建新笔记", "error_loading_notes": "加载笔记时出错",