diff --git a/README.md b/README.md index 4f2ce4332..77c34334d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ # SurfSense Connect any LLM to your internal knowledge sources and chat with it in real time alongside your team. OSS alternative to NotebookLM, Perplexity, and Glean. -SurfSense is a highly customizable AI research agent, connected to external sources such as Search Engines (SearxNG, Tavily, LinkUp), Google Drive, Slack, Linear, Jira, ClickUp, Confluence, BookStack, Gmail, Notion, YouTube, GitHub, Discord, Airtable, Google Calendar, Luma, Circleback, Elasticsearch and more to come. +SurfSense is a highly customizable AI research agent, connected to external sources such as Search Engines (SearxNG, Tavily, LinkUp), Google Drive, Slack, Microsoft Teams, Linear, Jira, ClickUp, Confluence, BookStack, Gmail, Notion, YouTube, GitHub, Discord, Airtable, Google Calendar, Luma, Circleback, Elasticsearch and more to come.
MODSetter%2FSurfSense | Trendshift @@ -97,6 +97,7 @@ Contributors can easily add new tools via the registry pattern: - SearxNG (self-hosted instances) - Google Drive - Slack +- Microsoft Teams - Linear - Jira - ClickUp diff --git a/README.zh-CN.md b/README.zh-CN.md index fe6ec8e30..5eb369287 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -18,7 +18,7 @@ 将任何 LLM 连接到您的内部知识源,并与团队成员实时聊天。NotebookLM、Perplexity 和 Glean 的开源替代方案。 -SurfSense 是一个高度可定制的 AI 研究助手,可以连接外部数据源,如搜索引擎(SearxNG、Tavily、LinkUp)、Google Drive、Slack、Linear、Jira、ClickUp、Confluence、BookStack、Gmail、Notion、YouTube、GitHub、Discord、Airtable、Google Calendar、Luma、Circleback、Elasticsearch 等,未来还会支持更多。 +SurfSense 是一个高度可定制的 AI 研究助手,可以连接外部数据源,如搜索引擎(SearxNG、Tavily、LinkUp)、Google Drive、Slack、Microsoft Teams、Linear、Jira、ClickUp、Confluence、BookStack、Gmail、Notion、YouTube、GitHub、Discord、Airtable、Google Calendar、Luma、Circleback、Elasticsearch 等,未来还会支持更多。
MODSetter%2FSurfSense | Trendshift @@ -105,6 +105,7 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7 - SearxNG(自托管实例) - Google Drive - Slack +- Microsoft Teams - Linear - Jira - ClickUp diff --git a/surfsense_backend/alembic/versions/61_add_chat_visibility_and_created_by.py b/surfsense_backend/alembic/versions/61_add_chat_visibility_and_created_by.py new file mode 100644 index 000000000..8ebb99426 --- /dev/null +++ b/surfsense_backend/alembic/versions/61_add_chat_visibility_and_created_by.py @@ -0,0 +1,109 @@ +"""Add chat visibility and created_by_id columns to new_chat_threads + +This migration adds: +- ChatVisibility enum (PRIVATE, SEARCH_SPACE) +- visibility column to new_chat_threads table (default: PRIVATE) +- created_by_id column to track who created the chat thread + +Revision ID: 61 +Revises: 60 +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "61" +down_revision: str | None = "60" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add visibility and created_by_id columns to new_chat_threads.""" + + # Create the ChatVisibility enum type + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'chatvisibility') THEN + CREATE TYPE chatvisibility AS ENUM ('PRIVATE', 'SEARCH_SPACE'); + END IF; + END$$; + """ + ) + + # Add visibility column with default value PRIVATE + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'new_chat_threads' AND column_name = 'visibility' + ) THEN + ALTER TABLE new_chat_threads + ADD COLUMN visibility chatvisibility NOT NULL DEFAULT 'PRIVATE'; + END IF; + END$$; + """ + ) + + # Create index on visibility column for efficient filtering + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_new_chat_threads_visibility + ON new_chat_threads(visibility); + """ + ) + + # Add created_by_id column (nullable to handle existing records) + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'new_chat_threads' AND column_name = 'created_by_id' + ) THEN + ALTER TABLE new_chat_threads + ADD COLUMN created_by_id UUID REFERENCES "user"(id) ON DELETE SET NULL; + END IF; + END$$; + """ + ) + + # Create index on created_by_id column for efficient filtering + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_new_chat_threads_created_by_id + ON new_chat_threads(created_by_id); + """ + ) + + +def downgrade() -> None: + """Remove visibility and created_by_id columns from new_chat_threads.""" + + # Drop indexes + op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_created_by_id") + op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_visibility") + + # Drop columns + op.execute( + """ + ALTER TABLE new_chat_threads + DROP COLUMN IF EXISTS created_by_id; + """ + ) + op.execute( + """ + ALTER TABLE new_chat_threads + DROP COLUMN IF EXISTS visibility; + """ + ) + + # Drop enum type (only if not used elsewhere) + op.execute("DROP TYPE IF EXISTS chatvisibility") diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index a0b174bf6..73727a9ef 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -326,6 +326,20 @@ class NewChatMessageRole(str, Enum): SYSTEM = "system" +class ChatVisibility(str, Enum): + """ + Visibility/sharing level for chat threads. + + PRIVATE: Only the creator can see/access the chat (default) + SEARCH_SPACE: All members of the search space can see/access the chat + PUBLIC: (Future) Anyone with the link can access the chat + """ + + PRIVATE = "PRIVATE" + SEARCH_SPACE = "SEARCH_SPACE" + # PUBLIC = "PUBLIC" # Reserved for future implementation + + class NewChatThread(BaseModel, TimestampMixin): """ Thread model for the new chat feature using assistant-ui. @@ -345,13 +359,31 @@ class NewChatThread(BaseModel, TimestampMixin): index=True, ) + # Visibility/sharing control + visibility = Column( + SQLAlchemyEnum(ChatVisibility), + nullable=False, + default=ChatVisibility.PRIVATE, + server_default="PRIVATE", + index=True, + ) + # Foreign keys search_space_id = Column( Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False ) + # Track who created this chat thread (for visibility filtering) + created_by_id = Column( + UUID(as_uuid=True), + ForeignKey("user.id", ondelete="SET NULL"), + nullable=True, # Nullable for existing records before migration + index=True, + ) + # Relationships search_space = relationship("SearchSpace", back_populates="new_chat_threads") + created_by = relationship("User", back_populates="new_chat_threads") messages = relationship( "NewChatMessage", back_populates="thread", @@ -826,6 +858,13 @@ if config.AUTH_TYPE == "GOOGLE": passive_deletes=True, ) + # Chat threads created by this user + new_chat_threads = relationship( + "NewChatThread", + back_populates="created_by", + passive_deletes=True, + ) + # Page usage tracking for ETL services pages_limit = Column( Integer, @@ -852,6 +891,13 @@ else: passive_deletes=True, ) + # Chat threads created by this user + new_chat_threads = relationship( + "NewChatThread", + back_populates="created_by", + passive_deletes=True, + ) + # Page usage tracking for ETL services pages_limit = Column( Integer, diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index da0d239d2..ff9a8675b 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -19,12 +19,14 @@ from datetime import UTC, datetime from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile from fastapi.responses import StreamingResponse +from sqlalchemy import or_ from sqlalchemy.exc import IntegrityError, OperationalError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import selectinload from app.db import ( + ChatVisibility, NewChatMessage, NewChatMessageRole, NewChatThread, @@ -40,6 +42,7 @@ from app.schemas.new_chat import ( NewChatThreadCreate, NewChatThreadRead, NewChatThreadUpdate, + NewChatThreadVisibilityUpdate, NewChatThreadWithMessages, ThreadHistoryLoadResponse, ThreadListItem, @@ -52,6 +55,61 @@ from app.utils.rbac import check_permission router = APIRouter() +async def check_thread_access( + thread: NewChatThread, + user: User, + require_ownership: bool = False, +) -> bool: + """ + Check if a user has access to a thread based on visibility rules. + + Access is granted if: + - User is the creator of the thread + - Thread visibility is SEARCH_SPACE (and user has permission to read chats) + - Thread is a legacy thread (created_by_id is NULL) - visible to all + + Args: + thread: The thread to check access for + user: The user requesting access + require_ownership: If True, only the creator can access (for edit/delete operations) + Legacy threads (NULL creator) are treated as accessible by all + + Returns: + True if access is granted + + Raises: + HTTPException: If access is denied + """ + is_owner = thread.created_by_id == user.id + is_legacy = thread.created_by_id is None + + # Legacy threads are accessible to all users in the search space + if is_legacy: + return True + + # If ownership is required, only the creator can access + if require_ownership: + if not is_owner: + raise HTTPException( + status_code=403, + detail="Only the creator of this chat can perform this action", + ) + return True + + # For read access: owner or shared threads + if is_owner: + return True + + if thread.visibility == ChatVisibility.SEARCH_SPACE: + return True + + # Private thread and user is not the owner + raise HTTPException( + status_code=403, + detail="You don't have access to this private chat", + ) + + # ============================================================================= # Thread Endpoints # ============================================================================= @@ -65,9 +123,14 @@ async def list_threads( user: User = Depends(current_active_user), ): """ - List all threads for the current user in a search space. + List all accessible threads for the current user in a search space. Returns threads and archived_threads for ThreadListPrimitive. + A user can see threads that are: + - Created by them (regardless of visibility) + - Shared with the search space (visibility = SEARCH_SPACE) + - Legacy threads with no creator (created_by_id is NULL) + Args: search_space_id: The search space to list threads for limit: Optional limit on number of threads to return (applies to active threads only) @@ -83,10 +146,20 @@ async def list_threads( "You don't have permission to read chats in this search space", ) - # Get all threads in this search space + # Get threads that are either: + # 1. Created by the current user (any visibility) + # 2. Shared with the search space (visibility = SEARCH_SPACE) + # 3. Legacy threads with no creator (created_by_id is NULL) - visible to all query = ( select(NewChatThread) - .filter(NewChatThread.search_space_id == search_space_id) + .filter( + NewChatThread.search_space_id == search_space_id, + or_( + NewChatThread.created_by_id == user.id, + NewChatThread.visibility == ChatVisibility.SEARCH_SPACE, + NewChatThread.created_by_id.is_(None), # Legacy threads + ), + ) .order_by(NewChatThread.updated_at.desc()) ) @@ -98,10 +171,17 @@ async def list_threads( archived_threads = [] for thread in all_threads: + # Legacy threads (no creator) are treated as own threads for display purposes + is_own_thread = ( + thread.created_by_id == user.id or thread.created_by_id is None + ) item = ThreadListItem( id=thread.id, title=thread.title, archived=thread.archived, + visibility=thread.visibility, + created_by_id=thread.created_by_id, + is_own_thread=is_own_thread, created_at=thread.created_at, updated_at=thread.updated_at, ) @@ -137,7 +217,12 @@ async def search_threads( user: User = Depends(current_active_user), ): """ - Search threads by title in a search space. + Search accessible threads by title in a search space. + + A user can search threads that are: + - Created by them (regardless of visibility) + - Shared with the search space (visibility = SEARCH_SPACE) + - Legacy threads with no creator (created_by_id is NULL) Args: search_space_id: The search space to search in @@ -154,12 +239,17 @@ async def search_threads( "You don't have permission to read chats in this search space", ) - # Search threads by title (case-insensitive) + # Search accessible threads by title (case-insensitive) query = ( select(NewChatThread) .filter( NewChatThread.search_space_id == search_space_id, NewChatThread.title.ilike(f"%{title}%"), + or_( + NewChatThread.created_by_id == user.id, + NewChatThread.visibility == ChatVisibility.SEARCH_SPACE, + NewChatThread.created_by_id.is_(None), # Legacy threads + ), ) .order_by(NewChatThread.updated_at.desc()) ) @@ -172,6 +262,12 @@ async def search_threads( id=thread.id, title=thread.title, archived=thread.archived, + visibility=thread.visibility, + created_by_id=thread.created_by_id, + # Legacy threads (no creator) are treated as own threads + is_own_thread=( + thread.created_by_id == user.id or thread.created_by_id is None + ), created_at=thread.created_at, updated_at=thread.updated_at, ) @@ -200,6 +296,9 @@ async def create_thread( """ Create a new chat thread. + The thread is created with the specified visibility (defaults to PRIVATE). + The current user is recorded as the creator of the thread. + Requires CHATS_CREATE permission. """ try: @@ -215,7 +314,9 @@ async def create_thread( db_thread = NewChatThread( title=thread.title, archived=thread.archived, + visibility=thread.visibility, search_space_id=thread.search_space_id, + created_by_id=user.id, updated_at=now, ) session.add(db_thread) @@ -254,6 +355,10 @@ async def get_thread_messages( Get a thread with all its messages. This is used by ThreadHistoryAdapter.load() to restore conversation. + Access is granted if: + - User is the creator of the thread + - Thread visibility is SEARCH_SPACE + Requires CHATS_READ permission. """ try: @@ -268,7 +373,7 @@ async def get_thread_messages( if not thread: raise HTTPException(status_code=404, detail="Thread not found") - # Check permission and ownership + # Check permission to read chats in this search space await check_permission( session, user, @@ -277,6 +382,9 @@ async def get_thread_messages( "You don't have permission to read chats in this search space", ) + # Check thread-level access based on visibility + await check_thread_access(thread, user) + # Return messages in the format expected by assistant-ui messages = [ NewChatMessageRead( @@ -313,6 +421,10 @@ async def get_thread_full( """ Get full thread details with all messages. + Access is granted if: + - User is the creator of the thread + - Thread visibility is SEARCH_SPACE + Requires CHATS_READ permission. """ try: @@ -334,6 +446,9 @@ async def get_thread_full( "You don't have permission to read chats in this search space", ) + # Check thread-level access based on visibility + await check_thread_access(thread, user) + return thread except HTTPException: @@ -360,6 +475,9 @@ async def update_thread( Update a thread (title, archived status). Used for renaming and archiving threads. + - PRIVATE threads: Only the creator can update + - SEARCH_SPACE threads: Any member with CHATS_UPDATE permission can update + Requires CHATS_UPDATE permission. """ try: @@ -379,6 +497,11 @@ async def update_thread( "You don't have permission to update chats in this search space", ) + # For PRIVATE threads, only the creator can update + # For SEARCH_SPACE threads, any member with permission can update + if db_thread.visibility == ChatVisibility.PRIVATE: + await check_thread_access(db_thread, user, require_ownership=True) + # Update fields update_data = thread_update.model_dump(exclude_unset=True) for key, value in update_data.items(): @@ -420,6 +543,9 @@ async def delete_thread( """ Delete a thread and all its messages. + - PRIVATE threads: Only the creator can delete + - SEARCH_SPACE threads: Any member with CHATS_DELETE permission can delete + Requires CHATS_DELETE permission. """ try: @@ -439,6 +565,11 @@ async def delete_thread( "You don't have permission to delete chats in this search space", ) + # For PRIVATE threads, only the creator can delete + # For SEARCH_SPACE threads, any member with permission can delete + if db_thread.visibility == ChatVisibility.PRIVATE: + await check_thread_access(db_thread, user, require_ownership=True) + await session.delete(db_thread) await session.commit() return {"message": "Thread deleted successfully"} @@ -463,6 +594,71 @@ async def delete_thread( ) from None +@router.patch("/threads/{thread_id}/visibility", response_model=NewChatThreadRead) +async def update_thread_visibility( + thread_id: int, + visibility_update: NewChatThreadVisibilityUpdate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Update the visibility/sharing settings of a thread. + + Only the creator of the thread can change its visibility. + - PRIVATE: Only the creator can access the thread (default) + - SEARCH_SPACE: All members of the search space can access the thread + + Requires CHATS_UPDATE permission. + """ + try: + result = await session.execute( + select(NewChatThread).filter(NewChatThread.id == thread_id) + ) + db_thread = result.scalars().first() + + if not db_thread: + raise HTTPException(status_code=404, detail="Thread not found") + + await check_permission( + session, + user, + db_thread.search_space_id, + Permission.CHATS_UPDATE.value, + "You don't have permission to update chats in this search space", + ) + + # Only the creator can change visibility + await check_thread_access(db_thread, user, require_ownership=True) + + # Update visibility + db_thread.visibility = visibility_update.visibility + db_thread.updated_at = datetime.now(UTC) + + await session.commit() + await session.refresh(db_thread) + return db_thread + + except HTTPException: + raise + except IntegrityError: + await session.rollback() + raise HTTPException( + status_code=400, + detail="Database constraint violation. Please check your input data.", + ) from None + except OperationalError: + await session.rollback() + raise HTTPException( + status_code=503, detail="Database operation failed. Please try again later." + ) from None + except Exception as e: + await session.rollback() + raise HTTPException( + status_code=500, + detail=f"An unexpected error occurred while updating thread visibility: {e!s}", + ) from None + + # ============================================================================= # Message Endpoints # ============================================================================= @@ -479,6 +675,10 @@ async def append_message( Append a message to a thread. This is used by ThreadHistoryAdapter.append() to persist messages. + Access is granted if: + - User is the creator of the thread + - Thread visibility is SEARCH_SPACE + Requires CHATS_UPDATE permission. """ try: @@ -513,6 +713,9 @@ async def append_message( "You don't have permission to update chats in this search space", ) + # Check thread-level access based on visibility + await check_thread_access(thread, user) + # Convert string role to enum role_str = ( message.role.lower() if isinstance(message.role, str) else message.role @@ -597,6 +800,10 @@ async def list_messages( """ List messages in a thread with pagination. + Access is granted if: + - User is the creator of the thread + - Thread visibility is SEARCH_SPACE + Requires CHATS_READ permission. """ try: @@ -617,6 +824,9 @@ async def list_messages( "You don't have permission to read chats in this search space", ) + # Check thread-level access based on visibility + await check_thread_access(thread, user) + # Get messages query = ( select(NewChatMessage) @@ -659,6 +869,10 @@ async def handle_new_chat( This endpoint handles the new chat functionality with streaming responses using Server-Sent Events (SSE) format compatible with Vercel AI SDK. + Access is granted if: + - User is the creator of the thread + - Thread visibility is SEARCH_SPACE + Requires CHATS_CREATE permission. """ try: @@ -679,6 +893,9 @@ async def handle_new_chat( "You don't have permission to chat in this search space", ) + # Check thread-level access based on visibility + await check_thread_access(thread, user) + # Get search space to check LLM config preferences search_space_result = await session.execute( select(SearchSpace).filter(SearchSpace.id == request.search_space_id) diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index 62c61da6b..e6dbcd920 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -8,10 +8,11 @@ These schemas follow the assistant-ui ThreadHistoryAdapter pattern: from datetime import datetime from typing import Any +from uuid import UUID from pydantic import BaseModel, ConfigDict, Field -from app.db import NewChatMessageRole +from app.db import ChatVisibility, NewChatMessageRole from .base import IDModel, TimestampModel @@ -66,6 +67,8 @@ class NewChatThreadCreate(NewChatThreadBase): """Schema for creating a new thread.""" search_space_id: int + # Visibility defaults to PRIVATE, but can be set on creation + visibility: ChatVisibility = ChatVisibility.PRIVATE class NewChatThreadUpdate(BaseModel): @@ -75,12 +78,20 @@ class NewChatThreadUpdate(BaseModel): archived: bool | None = None +class NewChatThreadVisibilityUpdate(BaseModel): + """Schema for updating thread visibility/sharing settings.""" + + visibility: ChatVisibility + + class NewChatThreadRead(NewChatThreadBase, IDModel): """ Schema for reading a thread (matches assistant-ui ThreadRecord). """ search_space_id: int + visibility: ChatVisibility + created_by_id: UUID | None = None created_at: datetime updated_at: datetime @@ -116,6 +127,9 @@ class ThreadListItem(BaseModel): id: int title: str archived: bool + visibility: ChatVisibility + created_by_id: UUID | None = None + is_own_thread: bool = False # True if the current user created this thread created_at: datetime = Field(alias="createdAt") updated_at: datetime = Field(alias="updatedAt") 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 368f0f654..742be6ff4 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx @@ -2,9 +2,9 @@ import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; -import { RefreshCw } from "lucide-react"; +import { RefreshCw, SquarePlus } from "lucide-react"; import { motion } from "motion/react"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; @@ -34,8 +34,13 @@ export default function DocumentsTable() { const t = useTranslations("documents"); const id = useId(); const params = useParams(); + const router = useRouter(); const searchSpaceId = Number(params.search_space_id); + const handleNewNote = useCallback(() => { + router.push(`/dashboard/${searchSpaceId}/editor/new`); + }, [router, searchSpaceId]); + const [search, setSearch] = useState(""); const debouncedSearch = useDebounced(search, 250); const [activeTypes, setActiveTypes] = useState([]); @@ -349,10 +354,16 @@ export default function DocumentsTable() {

{t("title")}

{t("subtitle")}

- +
+ + +
diff --git a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx index 239fdc5c1..99ccefcd9 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx @@ -267,21 +267,8 @@ export default function EditorPage() { setHasUnsavedChanges(false); toast.success("Note created successfully! Reindexing in background..."); - // Invalidate notes query to refresh the sidebar - queryClient.invalidateQueries({ - queryKey: ["notes", String(searchSpaceId)], - }); - - // Update URL to reflect the new document ID without navigation - window.history.replaceState({}, "", `/dashboard/${searchSpaceId}/editor/${note.id}`); - // Update document state to reflect the new ID - setDocument({ - document_id: note.id, - title: title, - document_type: "NOTE", - blocknote_document: editorContent, - updated_at: new Date().toISOString(), - }); + // Redirect to documents page after successful save + router.push(`/dashboard/${searchSpaceId}/documents`); } else { // Existing document - save normally if (!editorContent) { @@ -310,12 +297,8 @@ export default function EditorPage() { setHasUnsavedChanges(false); toast.success("Document saved! Reindexing in background..."); - // Invalidate notes query when updating notes to refresh the sidebar - if (isNote) { - queryClient.invalidateQueries({ - queryKey: ["notes", String(searchSpaceId)], - }); - } + // Redirect to documents page after successful save + router.push(`/dashboard/${searchSpaceId}/documents`); } } catch (error) { console.error("Error saving document:", error); @@ -336,7 +319,7 @@ export default function EditorPage() { if (hasUnsavedChanges) { setShowUnsavedDialog(true); } else { - router.push(`/dashboard/${searchSpaceId}/new-chat`); + router.push(`/dashboard/${searchSpaceId}/documents`); } }; @@ -346,12 +329,12 @@ export default function EditorPage() { setGlobalHasUnsavedChanges(false); setHasUnsavedChanges(false); - // If there's a pending navigation (from sidebar), use that; otherwise go back to chat + // If there's a pending navigation (from sidebar), use that; otherwise go back to documents if (pendingNavigation) { router.push(pendingNavigation); setPendingNavigation(null); } else { - router.push(`/dashboard/${searchSpaceId}/new-chat`); + router.push(`/dashboard/${searchSpaceId}/documents`); } }; @@ -392,7 +375,7 @@ export default function EditorPage() { - - - - ); } diff --git a/surfsense_web/components/layout/types/layout.types.ts b/surfsense_web/components/layout/types/layout.types.ts index a8fa609f9..73ac98fa5 100644 --- a/surfsense_web/components/layout/types/layout.types.ts +++ b/surfsense_web/components/layout/types/layout.types.ts @@ -27,14 +27,8 @@ export interface ChatItem { name: string; url: string; isActive?: boolean; -} - -export interface NoteItem { - id: number; - name: string; - url: string; - isActive?: boolean; - isReindexing?: boolean; + visibility?: "PRIVATE" | "SEARCH_SPACE"; + isOwnThread?: boolean; } export interface PageUsage { @@ -72,17 +66,8 @@ export interface ChatsSectionProps { activeChatId?: number | null; onChatSelect: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; - onViewAllChats?: () => void; - searchSpaceId?: string; -} - -export interface NotesSectionProps { - notes: NoteItem[]; - activeNoteId?: number | null; - onNoteSelect: (note: NoteItem) => void; - onNoteDelete?: (note: NoteItem) => void; - onAddNote?: () => void; - onViewAllNotes?: () => void; + onViewAllSharedChats?: () => void; + onViewAllPrivateChats?: () => void; searchSpaceId?: string; } @@ -107,17 +92,13 @@ export interface SidebarProps { searchSpaceId?: string; navItems: NavItem[]; chats: ChatItem[]; + sharedChats?: ChatItem[]; activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; - onViewAllChats?: () => void; - notes: NoteItem[]; - activeNoteId?: number | null; - onNoteSelect: (note: NoteItem) => void; - onNoteDelete?: (note: NoteItem) => void; - onAddNote?: () => void; - onViewAllNotes?: () => void; + onViewAllSharedChats?: () => void; + onViewAllPrivateChats?: () => void; user: User; theme?: string; onSettings?: () => void; diff --git a/surfsense_web/components/layout/ui/index.ts b/surfsense_web/components/layout/ui/index.ts index 1c3ddb2ca..00b862082 100644 --- a/surfsense_web/components/layout/ui/index.ts +++ b/surfsense_web/components/layout/ui/index.ts @@ -7,7 +7,6 @@ export { MobileSidebar, MobileSidebarTrigger, NavSection, - NoteListItem, PageUsageDisplay, Sidebar, SidebarCollapseButton, diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index d3fdafa5c..251a67765 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -5,14 +5,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import { useSidebarState } from "../../hooks"; -import type { - ChatItem, - NavItem, - NoteItem, - PageUsage, - SearchSpace, - User, -} from "../../types/layout.types"; +import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; import { Header } from "../header"; import { IconRail } from "../icon-rail"; import { MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar"; @@ -26,17 +19,13 @@ interface LayoutShellProps { navItems: NavItem[]; onNavItemClick?: (item: NavItem) => void; chats: ChatItem[]; + sharedChats?: ChatItem[]; activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; - onViewAllChats?: () => void; - notes: NoteItem[]; - activeNoteId?: number | null; - onNoteSelect: (note: NoteItem) => void; - onNoteDelete?: (note: NoteItem) => void; - onAddNote?: () => void; - onViewAllNotes?: () => void; + onViewAllSharedChats?: () => void; + onViewAllPrivateChats?: () => void; user: User; onSettings?: () => void; onManageMembers?: () => void; @@ -62,17 +51,13 @@ export function LayoutShell({ navItems, onNavItemClick, chats, + sharedChats, activeChatId, onNewChat, onChatSelect, onChatDelete, - onViewAllChats, - notes, - activeNoteId, - onNoteSelect, - onNoteDelete, - onAddNote, - onViewAllNotes, + onViewAllSharedChats, + onViewAllPrivateChats, user, onSettings, onManageMembers, @@ -116,17 +101,13 @@ export function LayoutShell({ navItems={navItems} onNavItemClick={onNavItemClick} chats={chats} + sharedChats={sharedChats} activeChatId={activeChatId} onNewChat={onNewChat} onChatSelect={onChatSelect} onChatDelete={onChatDelete} - onViewAllChats={onViewAllChats} - notes={notes} - activeNoteId={activeNoteId} - onNoteSelect={onNoteSelect} - onNoteDelete={onNoteDelete} - onAddNote={onAddNote} - onViewAllNotes={onViewAllNotes} + onViewAllSharedChats={onViewAllSharedChats} + onViewAllPrivateChats={onViewAllPrivateChats} user={user} onSettings={onSettings} onManageMembers={onManageMembers} @@ -164,17 +145,13 @@ export function LayoutShell({ navItems={navItems} onNavItemClick={onNavItemClick} chats={chats} + sharedChats={sharedChats} activeChatId={activeChatId} onNewChat={onNewChat} onChatSelect={onChatSelect} onChatDelete={onChatDelete} - onViewAllChats={onViewAllChats} - notes={notes} - activeNoteId={activeNoteId} - onNoteSelect={onNoteSelect} - onNoteDelete={onNoteDelete} - onAddNote={onAddNote} - onViewAllNotes={onViewAllNotes} + onViewAllSharedChats={onViewAllSharedChats} + onViewAllPrivateChats={onViewAllPrivateChats} user={user} onSettings={onSettings} onManageMembers={onManageMembers} diff --git a/surfsense_web/components/layout/ui/sidebar/AllNotesSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllNotesSidebar.tsx deleted file mode 100644 index 67d1b4ba6..000000000 --- a/surfsense_web/components/layout/ui/sidebar/AllNotesSidebar.tsx +++ /dev/null @@ -1,407 +0,0 @@ -"use client"; - -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { format } from "date-fns"; -import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useParams, useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { createPortal } from "react-dom"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -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"; - -interface AllNotesSidebarProps { - open: boolean; - onOpenChange: (open: boolean) => void; - searchSpaceId: string; - onAddNote?: () => void; - onCloseMobileSidebar?: () => void; -} - -export function AllNotesSidebar({ - open, - onOpenChange, - searchSpaceId, - onAddNote, - onCloseMobileSidebar, -}: AllNotesSidebarProps) { - const t = useTranslations("sidebar"); - const router = useRouter(); - const params = useParams(); - const queryClient = useQueryClient(); - - // Get the current note ID from URL to highlight the open note - const currentNoteId = params.note_id ? Number(params.note_id) : null; - const [deletingNoteId, setDeletingNoteId] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - const [mounted, setMounted] = useState(false); - const [openDropdownId, setOpenDropdownId] = useState(null); - const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); - - // Handle mounting for portal - useEffect(() => { - setMounted(true); - }, []); - - // Handle escape key - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape" && open) { - onOpenChange(false); - } - }; - document.addEventListener("keydown", handleEscape); - return () => document.removeEventListener("keydown", handleEscape); - }, [open, onOpenChange]); - - // Lock body scroll when open - useEffect(() => { - if (open) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = ""; - } - return () => { - document.body.style.overflow = ""; - }; - }, [open]); - - // Fetch all notes (when no search query) - const { - data: notesData, - error: notesError, - isLoading: isLoadingNotes, - } = useQuery({ - queryKey: ["all-notes", searchSpaceId], - queryFn: () => - notesApiService.getNotes({ - search_space_id: Number(searchSpaceId), - page_size: 1000, - }), - enabled: !!searchSpaceId && open && !debouncedSearchQuery, - }); - - // 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); - // Also close the main sidebar on mobile - onCloseMobileSidebar?.(); - }, - [router, onOpenChange, onCloseMobileSidebar] - ); - - // Handle note deletion - const handleDeleteNote = useCallback( - async (noteId: number, noteSearchSpaceId: number) => { - setDeletingNoteId(noteId); - try { - await notesApiService.deleteNote({ - search_space_id: noteSearchSpaceId, - note_id: noteId, - }); - 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 { - setDeletingNoteId(null); - } - }, - [queryClient, searchSpaceId] - ); - - // Clear search - const handleClearSearch = useCallback(() => { - setSearchQuery(""); - }, []); - - // Determine which data to show - const isSearchMode = !!debouncedSearchQuery; - const isLoading = isSearchMode ? isSearching : isLoadingNotes; - const error = isSearchMode ? searchError : notesError; - - // Transform and sort notes data - handle both regular notes and search results - const notes = useMemo(() => { - let notesList: { - id: number; - title: string; - search_space_id: number; - created_at: string; - updated_at?: string | null; - }[]; - - if (isSearchMode && searchData?.items) { - notesList = searchData.items.map((doc) => ({ - id: doc.id, - title: doc.title, - search_space_id: doc.search_space_id, - created_at: doc.created_at, - updated_at: doc.updated_at, - })); - } else { - notesList = notesData?.items ?? []; - } - - // Sort notes by updated_at (most recent first), fallback to created_at - return [...notesList].sort((a, b) => { - const dateA = a.updated_at - ? new Date(a.updated_at).getTime() - : new Date(a.created_at).getTime(); - const dateB = b.updated_at - ? new Date(b.updated_at).getTime() - : new Date(b.created_at).getTime(); - return dateB - dateA; // Descending order (most recent first) - }); - }, [isSearchMode, searchData, notesData]); - - if (!mounted) return null; - - return createPortal( - - {open && ( - <> - {/* Backdrop */} - onOpenChange(false)} - aria-hidden="true" - /> - - {/* Panel */} - - {/* Header */} -
-
-

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

- -
- - {/* Search Input */} -
- - setSearchQuery(e.target.value)} - className="pl-9 pr-8 h-9" - /> - {searchQuery && ( - - )} -
-
- - {/* Scrollable Content */} -
- {isLoading ? ( -
- -
- ) : error ? ( -
- {t("error_loading_notes") || "Error loading notes"} -
- ) : notes.length > 0 ? ( -
- {notes.map((note) => { - const isDeleting = deletingNoteId === note.id; - const isActive = currentNoteId === note.id; - - return ( -
- {/* Main clickable area for navigation */} - - - - - -
-

- {t("created") || "Created"}:{" "} - {format(new Date(note.created_at), "MMM d, yyyy 'at' h:mm a")} -

- {note.updated_at && ( -

- {t("updated") || "Updated"}:{" "} - {format(new Date(note.updated_at), "MMM d, yyyy 'at' h:mm a")} -

- )} -
-
-
- - {/* Actions dropdown - separate from main click area */} - setOpenDropdownId(isOpen ? note.id : null)} - > - - - - - handleDeleteNote(note.id, note.search_space_id)} - className="text-destructive focus:text-destructive" - > - - {t("delete") || "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 && notes.length > 0 && ( -
- -
- )} -
- - )} -
, - document.body - ); -} diff --git a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx similarity index 86% rename from surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx rename to surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx index 02459f2b9..117f8acef 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx @@ -5,6 +5,7 @@ import { format } from "date-fns"; import { ArchiveIcon, Loader2, + Lock, MessageCircleMore, MoreHorizontal, RotateCcwIcon, @@ -15,7 +16,7 @@ import { import { AnimatePresence, motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -38,25 +39,24 @@ import { } from "@/lib/chat/thread-persistence"; import { cn } from "@/lib/utils"; -interface AllChatsSidebarProps { +interface AllPrivateChatsSidebarProps { open: boolean; onOpenChange: (open: boolean) => void; searchSpaceId: string; onCloseMobileSidebar?: () => void; } -export function AllChatsSidebar({ +export function AllPrivateChatsSidebar({ open, onOpenChange, searchSpaceId, onCloseMobileSidebar, -}: AllChatsSidebarProps) { +}: AllPrivateChatsSidebarProps) { const t = useTranslations("sidebar"); const router = useRouter(); const params = useParams(); const queryClient = useQueryClient(); - // Get the current chat ID from URL to check if user is deleting the currently open chat const currentChatId = Array.isArray(params.chat_id) ? Number(params.chat_id[0]) : params.chat_id @@ -72,12 +72,10 @@ export function AllChatsSidebar({ const isSearchMode = !!debouncedSearchQuery.trim(); - // Handle mounting for portal useEffect(() => { setMounted(true); }, []); - // Handle escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape" && open) { @@ -88,7 +86,6 @@ export function AllChatsSidebar({ return () => document.removeEventListener("keydown", handleEscape); }, [open, onOpenChange]); - // Lock body scroll when open useEffect(() => { if (open) { document.body.style.overflow = "hidden"; @@ -100,7 +97,6 @@ export function AllChatsSidebar({ }; }, [open]); - // Fetch all threads (when not searching) const { data: threadsData, error: threadsError, @@ -111,7 +107,6 @@ export function AllChatsSidebar({ enabled: !!searchSpaceId && open && !isSearchMode, }); - // Search threads (when searching) const { data: searchData, error: searchError, @@ -122,18 +117,41 @@ export function AllChatsSidebar({ enabled: !!searchSpaceId && open && isSearchMode, }); - // Handle thread navigation + // Filter to only private chats (PRIVATE visibility or no visibility set) + const { activeChats, archivedChats } = useMemo(() => { + if (isSearchMode) { + const privateSearchResults = (searchData ?? []).filter( + (thread) => thread.visibility !== "SEARCH_SPACE" + ); + return { + activeChats: privateSearchResults.filter((t) => !t.archived), + archivedChats: privateSearchResults.filter((t) => t.archived), + }; + } + + if (!threadsData) return { activeChats: [], archivedChats: [] }; + + const activePrivate = threadsData.threads.filter( + (thread) => thread.visibility !== "SEARCH_SPACE" + ); + const archivedPrivate = threadsData.archived_threads.filter( + (thread) => thread.visibility !== "SEARCH_SPACE" + ); + + return { activeChats: activePrivate, archivedChats: archivedPrivate }; + }, [threadsData, searchData, isSearchMode]); + + const threads = showArchived ? archivedChats : activeChats; + const handleThreadClick = useCallback( (threadId: number) => { router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`); onOpenChange(false); - // Also close the main sidebar on mobile onCloseMobileSidebar?.(); }, [router, onOpenChange, searchSpaceId, onCloseMobileSidebar] ); - // Handle thread deletion const handleDeleteThread = useCallback( async (threadId: number) => { setDeletingThreadId(threadId); @@ -144,10 +162,8 @@ export function AllChatsSidebar({ queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); - // If the deleted chat is currently open, close sidebar first then redirect if (currentChatId === threadId) { onOpenChange(false); - // Wait for sidebar close animation to complete before navigating setTimeout(() => { router.push(`/dashboard/${searchSpaceId}/new-chat`); }, 250); @@ -162,7 +178,6 @@ export function AllChatsSidebar({ [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange] ); - // Handle thread archive/unarchive const handleToggleArchive = useCallback( async (threadId: number, currentlyArchived: boolean) => { setArchivingThreadId(threadId); @@ -186,25 +201,15 @@ export function AllChatsSidebar({ [queryClient, searchSpaceId, t] ); - // Clear search const handleClearSearch = useCallback(() => { setSearchQuery(""); }, []); - // Determine which data source to use - let threads: ThreadListItem[] = []; - if (isSearchMode) { - threads = searchData ?? []; - } else if (threadsData) { - threads = showArchived ? threadsData.archived_threads : threadsData.threads; - } - const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads; const error = isSearchMode ? searchError : threadsError; - // Get counts for tabs - const activeCount = threadsData?.threads.length ?? 0; - const archivedCount = threadsData?.archived_threads.length ?? 0; + const activeCount = activeChats.length; + const archivedCount = archivedChats.length; if (!mounted) return null; @@ -212,32 +217,32 @@ export function AllChatsSidebar({ {open && ( <> - {/* Backdrop */} onOpenChange(false)} aria-hidden="true" /> - {/* Panel */} - {/* Header */} -
+
-

{t("all_chats") || "All Chats"}

+
+ +

{t("chats") || "Private Chats"}

+
- {/* Search Input */}
- {/* Tab toggle for active/archived (only show when not searching) */} {!isSearchMode && ( -
+
)} - {/* Scrollable Content */}
{isLoading ? (
@@ -332,7 +334,6 @@ export function AllChatsSidebar({ isBusy && "opacity-50 pointer-events-none" )} > - {/* Main clickable area for navigation */} - + handleToggleArchive(thread.id, thread.archived)} disabled={isArchiving} @@ -420,11 +420,11 @@ export function AllChatsSidebar({
) : (
- +

{showArchived ? t("no_archived_chats") || "No archived chats" - : t("no_chats") || "No chats yet"} + : t("no_chats") || "No private chats"}

{!showArchived && (

diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx new file mode 100644 index 000000000..d2e7bf1d1 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx @@ -0,0 +1,443 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { + ArchiveIcon, + Loader2, + MessageCircleMore, + MoreHorizontal, + RotateCcwIcon, + Search, + Trash2, + Users, + X, +} from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useParams, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { + deleteThread, + fetchThreads, + searchThreads, + type ThreadListItem, + updateThread, +} from "@/lib/chat/thread-persistence"; +import { cn } from "@/lib/utils"; + +interface AllSharedChatsSidebarProps { + open: boolean; + onOpenChange: (open: boolean) => void; + searchSpaceId: string; + onCloseMobileSidebar?: () => void; +} + +export function AllSharedChatsSidebar({ + open, + onOpenChange, + searchSpaceId, + onCloseMobileSidebar, +}: AllSharedChatsSidebarProps) { + const t = useTranslations("sidebar"); + const router = useRouter(); + const params = useParams(); + const queryClient = useQueryClient(); + + const currentChatId = Array.isArray(params.chat_id) + ? Number(params.chat_id[0]) + : params.chat_id + ? Number(params.chat_id) + : null; + const [deletingThreadId, setDeletingThreadId] = useState(null); + const [archivingThreadId, setArchivingThreadId] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [showArchived, setShowArchived] = useState(false); + const [mounted, setMounted] = useState(false); + const [openDropdownId, setOpenDropdownId] = useState(null); + const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); + + const isSearchMode = !!debouncedSearchQuery.trim(); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && open) { + onOpenChange(false); + } + }; + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [open, onOpenChange]); + + useEffect(() => { + if (open) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [open]); + + const { + data: threadsData, + error: threadsError, + isLoading: isLoadingThreads, + } = useQuery({ + queryKey: ["all-threads", searchSpaceId], + queryFn: () => fetchThreads(Number(searchSpaceId)), + enabled: !!searchSpaceId && open && !isSearchMode, + }); + + const { + data: searchData, + error: searchError, + isLoading: isLoadingSearch, + } = useQuery({ + queryKey: ["search-threads", searchSpaceId, debouncedSearchQuery], + queryFn: () => searchThreads(Number(searchSpaceId), debouncedSearchQuery.trim()), + enabled: !!searchSpaceId && open && isSearchMode, + }); + + // Filter to only shared chats (SEARCH_SPACE visibility) + const { activeChats, archivedChats } = useMemo(() => { + if (isSearchMode) { + const sharedSearchResults = (searchData ?? []).filter( + (thread) => thread.visibility === "SEARCH_SPACE" + ); + return { + activeChats: sharedSearchResults.filter((t) => !t.archived), + archivedChats: sharedSearchResults.filter((t) => t.archived), + }; + } + + if (!threadsData) return { activeChats: [], archivedChats: [] }; + + const activeShared = threadsData.threads.filter( + (thread) => thread.visibility === "SEARCH_SPACE" + ); + const archivedShared = threadsData.archived_threads.filter( + (thread) => thread.visibility === "SEARCH_SPACE" + ); + + return { activeChats: activeShared, archivedChats: archivedShared }; + }, [threadsData, searchData, isSearchMode]); + + const threads = showArchived ? archivedChats : activeChats; + + const handleThreadClick = useCallback( + (threadId: number) => { + router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`); + onOpenChange(false); + onCloseMobileSidebar?.(); + }, + [router, onOpenChange, searchSpaceId, onCloseMobileSidebar] + ); + + const handleDeleteThread = useCallback( + async (threadId: number) => { + setDeletingThreadId(threadId); + try { + await deleteThread(threadId); + toast.success(t("chat_deleted") || "Chat deleted successfully"); + queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); + + if (currentChatId === threadId) { + onOpenChange(false); + setTimeout(() => { + router.push(`/dashboard/${searchSpaceId}/new-chat`); + }, 250); + } + } catch (error) { + console.error("Error deleting thread:", error); + toast.error(t("error_deleting_chat") || "Failed to delete chat"); + } finally { + setDeletingThreadId(null); + } + }, + [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange] + ); + + const handleToggleArchive = useCallback( + async (threadId: number, currentlyArchived: boolean) => { + setArchivingThreadId(threadId); + try { + await updateThread(threadId, { archived: !currentlyArchived }); + toast.success( + currentlyArchived + ? t("chat_unarchived") || "Chat restored" + : t("chat_archived") || "Chat archived" + ); + queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); + } catch (error) { + console.error("Error archiving thread:", error); + toast.error(t("error_archiving_chat") || "Failed to archive chat"); + } finally { + setArchivingThreadId(null); + } + }, + [queryClient, searchSpaceId, t] + ); + + const handleClearSearch = useCallback(() => { + setSearchQuery(""); + }, []); + + const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads; + const error = isSearchMode ? searchError : threadsError; + + const activeCount = activeChats.length; + const archivedCount = archivedChats.length; + + if (!mounted) return null; + + return createPortal( + + {open && ( + <> + onOpenChange(false)} + aria-hidden="true" + /> + + +

+
+
+ +

{t("shared_chats") || "Shared Chats"}

+
+ +
+ +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-8 h-9" + /> + {searchQuery && ( + + )} +
+
+ + {!isSearchMode && ( +
+ + +
+ )} + +
+ {isLoading ? ( +
+ +
+ ) : error ? ( +
+ {t("error_loading_chats") || "Error loading chats"} +
+ ) : threads.length > 0 ? ( +
+ {threads.map((thread) => { + const isDeleting = deletingThreadId === thread.id; + const isArchiving = archivingThreadId === thread.id; + const isBusy = isDeleting || isArchiving; + const isActive = currentChatId === thread.id; + + return ( +
+ + + + + +

+ {t("updated") || "Updated"}:{" "} + {format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")} +

+
+
+ + setOpenDropdownId(isOpen ? thread.id : null)} + > + + + + + handleToggleArchive(thread.id, thread.archived)} + disabled={isArchiving} + > + {thread.archived ? ( + <> + + {t("unarchive") || "Restore"} + + ) : ( + <> + + {t("archive") || "Archive"} + + )} + + + handleDeleteThread(thread.id)} + className="text-destructive focus:text-destructive" + > + + {t("delete") || "Delete"} + + + +
+ ); + })} +
+ ) : isSearchMode ? ( +
+ +

+ {t("no_chats_found") || "No chats found"} +

+

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

+
+ ) : ( +
+ +

+ {showArchived + ? t("no_archived_chats") || "No archived chats" + : t("no_shared_chats") || "No shared chats"} +

+ {!showArchived && ( +

+ Share a chat to collaborate with your team +

+ )} +
+ )} +
+ + + )} + , + document.body + ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index cae81f613..0efec49c9 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -1,18 +1,10 @@ "use client"; -import { Menu } from "lucide-react"; +import { Menu, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; -import type { - ChatItem, - NavItem, - NoteItem, - PageUsage, - SearchSpace, - User, -} from "../../types/layout.types"; -import { IconRail } from "../icon-rail"; +import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; +import { SearchSpaceAvatar } from "../icon-rail/SearchSpaceAvatar"; import { Sidebar } from "./Sidebar"; interface MobileSidebarProps { @@ -26,17 +18,13 @@ interface MobileSidebarProps { navItems: NavItem[]; onNavItemClick?: (item: NavItem) => void; chats: ChatItem[]; + sharedChats?: ChatItem[]; activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; - onViewAllChats?: () => void; - notes: NoteItem[]; - activeNoteId?: number | null; - onNoteSelect: (note: NoteItem) => void; - onNoteDelete?: (note: NoteItem) => void; - onAddNote?: () => void; - onViewAllNotes?: () => void; + onViewAllSharedChats?: () => void; + onViewAllPrivateChats?: () => void; user: User; onSettings?: () => void; onManageMembers?: () => void; @@ -65,17 +53,13 @@ export function MobileSidebar({ navItems, onNavItemClick, chats, + sharedChats, activeChatId, onNewChat, onChatSelect, onChatDelete, - onViewAllChats, - notes, - activeNoteId, - onNoteSelect, - onNoteDelete, - onAddNote, - onViewAllNotes, + onViewAllSharedChats, + onViewAllPrivateChats, user, onSettings, onManageMembers, @@ -97,27 +81,37 @@ export function MobileSidebar({ onOpenChange(false); }; - const handleNoteSelect = (note: NoteItem) => { - onNoteSelect(note); - onOpenChange(false); - }; - return ( - + Navigation -
- - - + {/* Horizontal Search Spaces Rail */} +
+
+ {searchSpaces.map((space) => ( +
+ handleSearchSpaceSelect(space.id)} + size="md" + /> +
+ ))} + +
+ {/* Sidebar Content */}
{ onNewChat(); @@ -147,6 +142,17 @@ export function MobileSidebar({ pageUsage={pageUsage} className="w-full border-none" /> + onViewAllSharedChats={onViewAllSharedChats} + onViewAllPrivateChats={onViewAllPrivateChats} + user={user} + onSettings={onSettings} + onManageMembers={onManageMembers} + onSeeAllSearchSpaces={onSeeAllSearchSpaces} + onUserSettings={onUserSettings} + onLogout={onLogout} + pageUsage={pageUsage} + className="w-full border-none" + />
diff --git a/surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx b/surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx deleted file mode 100644 index 0491ebcca..000000000 --- a/surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"use client"; - -import { FileText, Loader2, MoreHorizontal } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { cn } from "@/lib/utils"; - -interface NoteListItemProps { - name: string; - isActive?: boolean; - isReindexing?: boolean; - onClick?: () => void; - onDelete?: () => void; -} - -export function NoteListItem({ - name, - isActive, - isReindexing, - onClick, - onDelete, -}: NoteListItemProps) { - const t = useTranslations("sidebar"); - - return ( -
- - - {/* Actions dropdown */} -
- - - - - - { - e.stopPropagation(); - onDelete?.(); - }} - className="text-destructive focus:text-destructive" - > - {t("delete")} - - - -
-
- ); -} diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 5bc1e62b9..a23bec11a 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -1,22 +1,14 @@ "use client"; -import { FileText, FolderOpen, MessageSquare, PenSquare, Plus } from "lucide-react"; +import { FolderOpen, MessageSquare, PenSquare } from "lucide-react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import type { - ChatItem, - NavItem, - NoteItem, - PageUsage, - SearchSpace, - User, -} from "../../types/layout.types"; +import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; import { ChatListItem } from "./ChatListItem"; import { NavSection } from "./NavSection"; -import { NoteListItem } from "./NoteListItem"; import { PageUsageDisplay } from "./PageUsageDisplay"; import { SidebarCollapseButton } from "./SidebarCollapseButton"; import { SidebarHeader } from "./SidebarHeader"; @@ -30,17 +22,13 @@ interface SidebarProps { navItems: NavItem[]; onNavItemClick?: (item: NavItem) => void; chats: ChatItem[]; + sharedChats?: ChatItem[]; activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; - onViewAllChats?: () => void; - notes: NoteItem[]; - activeNoteId?: number | null; - onNoteSelect: (note: NoteItem) => void; - onNoteDelete?: (note: NoteItem) => void; - onAddNote?: () => void; - onViewAllNotes?: () => void; + onViewAllSharedChats?: () => void; + onViewAllPrivateChats?: () => void; user: User; onSettings?: () => void; onManageMembers?: () => void; @@ -57,17 +45,13 @@ export function Sidebar({ navItems, onNavItemClick, chats, + sharedChats = [], activeChatId, onNewChat, onChatSelect, onChatDelete, - onViewAllChats, - notes, - activeNoteId, - onNoteSelect, - onNoteDelete, - onAddNote, - onViewAllNotes, + onViewAllSharedChats, + onViewAllPrivateChats, user, onSettings, onManageMembers, @@ -140,7 +124,7 @@ export function Sidebar({ {isCollapsed ? (
- {chats.length > 0 && ( + {(chats.length > 0 || sharedChats.length > 0) && ( - {t("recent_chats")} ({chats.length}) - - - )} - {notes.length > 0 && ( - - - - - - {t("notes")} ({notes.length}) + {t("chats")} ({chats.length + sharedChats.length}) )}
) : (
+ {/* Shared Chats Section */} 0 ? ( + onViewAllSharedChats ? ( - {t("view_all_chats")} + + {t("view_all_shared_chats") || "View all shared chats"} + + + ) : undefined + } + > + {sharedChats.length > 0 ? ( +
+ {sharedChats.map((chat) => ( + onChatSelect(chat)} + onDelete={() => onChatDelete?.(chat)} + /> + ))} +
+ ) : ( +

{t("no_shared_chats")}

+ )} +
+ + {/* Private Chats Section */} + + + + + + {t("view_all_private_chats") || "View all private chats"} + ) : undefined } @@ -213,67 +223,7 @@ export function Sidebar({ ))}
) : ( -

{t("no_recent_chats")}

- )} - - - 0 ? ( - - - - - {t("view_all_notes")} - - ) : undefined - } - persistentAction={ - onAddNote && notes.length > 0 ? ( - - - - - {t("add_note")} - - ) : undefined - } - > - {notes.length > 0 ? ( -
- {notes.map((note) => ( - onNoteSelect(note)} - onDelete={() => onNoteDelete?.(note)} - /> - ))} -
- ) : onAddNote ? ( - - ) : ( -

{t("no_notes")}

+

{t("no_chats")}

)}
diff --git a/surfsense_web/components/layout/ui/sidebar/index.ts b/surfsense_web/components/layout/ui/sidebar/index.ts index d98b45ca5..282e4740b 100644 --- a/surfsense_web/components/layout/ui/sidebar/index.ts +++ b/surfsense_web/components/layout/ui/sidebar/index.ts @@ -1,9 +1,8 @@ -export { AllChatsSidebar } from "./AllChatsSidebar"; -export { AllNotesSidebar } from "./AllNotesSidebar"; +export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar"; +export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar"; export { ChatListItem } from "./ChatListItem"; export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar"; export { NavSection } from "./NavSection"; -export { NoteListItem } from "./NoteListItem"; export { PageUsageDisplay } from "./PageUsageDisplay"; export { Sidebar } from "./Sidebar"; export { SidebarCollapseButton } from "./SidebarCollapseButton"; diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx index 34b2cc814..fc01b6dd6 100644 --- a/surfsense_web/components/new-chat/chat-header.tsx +++ b/surfsense_web/components/new-chat/chat-header.tsx @@ -5,14 +5,18 @@ import type { GlobalNewLLMConfig, NewLLMConfigPublic, } from "@/contracts/types/new-llm-config.types"; +import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence"; +import { ChatShareButton } from "./chat-share-button"; import { ModelConfigSidebar } from "./model-config-sidebar"; import { ModelSelector } from "./model-selector"; interface ChatHeaderProps { searchSpaceId: number; + thread?: ThreadRecord | null; + onThreadVisibilityChange?: (visibility: ChatVisibility) => void; } -export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { +export function ChatHeader({ searchSpaceId, thread, onThreadVisibilityChange }: ChatHeaderProps) { const [sidebarOpen, setSidebarOpen] = useState(false); const [selectedConfig, setSelectedConfig] = useState< NewLLMConfigPublic | GlobalNewLLMConfig | null @@ -46,8 +50,9 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { }, []); return ( - <> +
+ - +
); } diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx new file mode 100644 index 000000000..28a149f95 --- /dev/null +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { Globe, Loader2, Lock, Share2, Users } from "lucide-react"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + type ChatVisibility, + type ThreadRecord, + updateThreadVisibility, +} from "@/lib/chat/thread-persistence"; +import { cn } from "@/lib/utils"; + +interface ChatShareButtonProps { + thread: ThreadRecord | null; + onVisibilityChange?: (visibility: ChatVisibility) => void; + className?: string; +} + +const visibilityOptions: { + value: ChatVisibility; + label: string; + description: string; + icon: typeof Lock; +}[] = [ + { + value: "PRIVATE", + label: "Private", + description: "Only you can access this chat", + icon: Lock, + }, + { + value: "SEARCH_SPACE", + label: "Search Space", + description: "All members of this search space can access", + icon: Users, + }, +]; + +export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + + const currentVisibility = thread?.visibility ?? "PRIVATE"; + const isOwnThread = thread?.created_by_id !== null; // If we have the thread, we can modify it + + const handleVisibilityChange = useCallback( + async (newVisibility: ChatVisibility) => { + if (!thread || newVisibility === currentVisibility) { + setOpen(false); + return; + } + + setIsUpdating(true); + try { + await updateThreadVisibility(thread.id, newVisibility); + + // Refetch all thread queries to update sidebar immediately + await queryClient.refetchQueries({ + predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads", + }); + + onVisibilityChange?.(newVisibility); + toast.success( + newVisibility === "SEARCH_SPACE" ? "Chat shared with search space" : "Chat is now private" + ); + setOpen(false); + } catch (error) { + console.error("Failed to update visibility:", error); + toast.error("Failed to update sharing settings"); + } finally { + setIsUpdating(false); + } + }, + [thread, currentVisibility, onVisibilityChange, queryClient] + ); + + // Don't show if no thread (new chat that hasn't been created yet) + if (!thread) { + return null; + } + + const CurrentIcon = currentVisibility === "PRIVATE" ? Lock : Users; + + return ( + + + + + + +
+
+ +
+

Share Chat

+

+ Control who can access this conversation +

+
+
+
+ +
+ {/* Updating overlay */} + {isUpdating && ( +
+
+ + Updating... +
+
+ )} + + {visibilityOptions.map((option) => { + const isSelected = currentVisibility === option.value; + const Icon = option.icon; + + return ( + + ); + })} +
+ + {/* Info footer */} +
+
+ +

+ {currentVisibility === "PRIVATE" + ? "This chat is private. Only you can view and interact with it." + : "This chat is shared. All search space members can view, continue the conversation, and delete it."} +

+
+
+
+
+ ); +} diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index 5849003e2..dcff4768b 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -11,7 +11,7 @@ enum ResponseType { } export type RequestOptions = { - method: "GET" | "POST" | "PUT" | "DELETE"; + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; headers?: Record; contentType?: "application/json" | "application/x-www-form-urlencoded"; signal?: AbortSignal; @@ -273,6 +273,21 @@ class BaseApiService { }); } + async patch( + url: string, + responseSchema?: ZodType, + options?: Omit + ) { + return this.request(url, responseSchema, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + ...options, + responseType: ResponseType.JSON, + }); + } + async getBlob(url: string, options?: Omit) { return this.request(url, undefined, { ...options, diff --git a/surfsense_web/lib/chat/thread-persistence.ts b/surfsense_web/lib/chat/thread-persistence.ts index b84c5907b..5c65ad47e 100644 --- a/surfsense_web/lib/chat/thread-persistence.ts +++ b/surfsense_web/lib/chat/thread-persistence.ts @@ -9,10 +9,17 @@ import { baseApiService } from "@/lib/apis/base-api.service"; // Types matching backend schemas // ============================================================================= +/** + * Chat visibility levels - matches backend ChatVisibility enum + */ +export type ChatVisibility = "PRIVATE" | "SEARCH_SPACE"; + export interface ThreadRecord { id: number; title: string; archived: boolean; + visibility: ChatVisibility; + created_by_id: string | null; search_space_id: number; created_at: string; updated_at: string; @@ -35,6 +42,9 @@ export interface ThreadListItem { id: number; title: string; archived: boolean; + visibility: ChatVisibility; + created_by_id: string | null; + is_own_thread: boolean; createdAt: string; updatedAt: string; } @@ -127,6 +137,25 @@ export async function deleteThread(threadId: number): Promise { await baseApiService.delete(`/api/v1/threads/${threadId}`); } +/** + * Update thread visibility (share/unshare) + */ +export async function updateThreadVisibility( + threadId: number, + visibility: ChatVisibility +): Promise { + return baseApiService.patch(`/api/v1/threads/${threadId}/visibility`, undefined, { + body: { visibility }, + }); +} + +/** + * Get full thread details including visibility + */ +export async function getThreadFull(threadId: number): Promise { + return baseApiService.get(`/api/v1/threads/${threadId}/full`); +} + // ============================================================================= // Thread List Manager (for thread list sidebar) // ============================================================================= diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 5ee25489d..9b59d5043 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -311,6 +311,7 @@ "rows_per_page": "Rows per page", "refresh": "Refresh", "refresh_success": "Documents refreshed", + "create_shared_note": "Create Shared Note", "processing_documents": "Processing documents...", "active_tasks_count": "{count} active task(s)" }, @@ -627,37 +628,23 @@ "manage": "Manage" }, "sidebar": { - "recent_chats": "Recent Chats", + "chats": "Private Chats", + "shared_chats": "Shared Chats", "search_chats": "Search chats...", "no_chats_found": "No chats found", - "no_recent_chats": "No recent chats", - "view_all_chats": "View all chats", - "all_chats": "All Chats", - "all_chats_description": "Browse and manage all your chats", + "no_shared_chats": "No shared chats", + "view_all_shared_chats": "View all shared chats", + "view_all_private_chats": "View all private chats", "no_chats": "No chats yet", "start_new_chat_hint": "Start a new chat", "error_loading_chats": "Error loading chats", "chat_deleted": "Chat deleted successfully", "error_deleting_chat": "Failed to delete chat", - "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", - "loading": "Loading...", - "deleting": "Deleting...", "delete": "Delete", - "created": "Created", + "try_different_search": "Try a different search term", "updated": "Updated", "more_options": "More options", "clear_search": "Clear search", - "view_all_notes": "View all notes", - "add_note": "Add note", "archive": "Archive", "unarchive": "Restore", "chat_archived": "Chat archived", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 8c1f53699..d0e6e50d7 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -311,6 +311,7 @@ "rows_per_page": "每页行数", "refresh": "刷新", "refresh_success": "文档已刷新", + "create_shared_note": "创建共享笔记", "processing_documents": "正在处理文档...", "active_tasks_count": "{count} 个正在进行的工作项" }, @@ -627,37 +628,29 @@ "manage": "管理" }, "sidebar": { - "recent_chats": "最近对话", + "chats": "私人对话", + "shared_chats": "共享对话", "search_chats": "搜索对话...", "no_chats_found": "未找到对话", - "no_recent_chats": "暂无最近对话", - "view_all_chats": "查看所有对话", - "all_chats": "所有对话", - "all_chats_description": "浏览和管理您的所有对话", + "no_shared_chats": "暂无共享对话", + "view_all_shared_chats": "查看所有共享对话", + "view_all_private_chats": "查看所有私人对话", "no_chats": "暂无对话", "start_new_chat_hint": "开始新对话", "error_loading_chats": "加载对话时出错", "chat_deleted": "对话删除成功", "error_deleting_chat": "删除对话失败", - "search_space": "搜索空间", - "notes": "笔记", - "all_notes": "所有笔记", - "all_notes_description": "浏览和管理您的所有笔记", - "search_notes": "搜索笔记...", - "no_results_found": "未找到笔记", - "try_different_search": "尝试其他搜索词", - "no_notes": "暂无笔记", - "create_new_note": "创建新笔记", - "error_loading_notes": "加载笔记时出错", - "loading": "加载中...", - "deleting": "删除中...", "delete": "删除", - "created": "创建时间", + "try_different_search": "尝试其他搜索词", "updated": "更新时间", "more_options": "更多选项", "clear_search": "清除搜索", - "view_all_notes": "查看所有笔记", - "add_note": "添加笔记", + "archive": "归档", + "unarchive": "恢复", + "chat_archived": "对话已归档", + "chat_unarchived": "对话已恢复", + "no_archived_chats": "暂无已归档对话", + "error_archiving_chat": "归档对话失败", "new_chat": "新对话", "select_search_space": "选择搜索空间", "manage_members": "管理成员",