Merge remote-tracking branch 'upstream/dev' into implement-surfsense-docs-mentions

This commit is contained in:
CREDO23 2026-01-14 12:59:56 +02:00
commit eb1ebdccba
33 changed files with 857 additions and 180 deletions

View file

@ -56,6 +56,7 @@ router = APIRouter()
async def check_thread_access( async def check_thread_access(
session: AsyncSession,
thread: NewChatThread, thread: NewChatThread,
user: User, user: User,
require_ownership: bool = False, require_ownership: bool = False,
@ -65,14 +66,16 @@ async def check_thread_access(
Access is granted if: Access is granted if:
- User is the creator of the thread - User is the creator of the thread
- Thread visibility is SEARCH_SPACE (and user has permission to read chats) - Thread visibility is SEARCH_SPACE (any member can access)
- Thread is a legacy thread (created_by_id is NULL) - visible to all - Thread is a legacy thread (created_by_id is NULL) - only if user is search space owner
Args: Args:
session: Database session
thread: The thread to check access for thread: The thread to check access for
user: The user requesting access user: The user requesting access
require_ownership: If True, only the creator can access (for edit/delete operations) require_ownership: If True, only the creator can access (for edit/delete operations)
Legacy threads (NULL creator) are treated as accessible by all For SEARCH_SPACE threads, any member with permission can access
Legacy threads (NULL creator) are accessible by search space owner
Returns: Returns:
True if access is granted True if access is granted
@ -83,10 +86,31 @@ async def check_thread_access(
is_owner = thread.created_by_id == user.id is_owner = thread.created_by_id == user.id
is_legacy = thread.created_by_id is None is_legacy = thread.created_by_id is None
# Legacy threads are accessible to all users in the search space # Shared threads (SEARCH_SPACE) are accessible by any member
if is_legacy: # This check comes first so shared threads are always accessible
if thread.visibility == ChatVisibility.SEARCH_SPACE:
# For ownership-required operations on shared threads, any member can proceed
# (permission check is done at route level)
return True return True
# For legacy threads (created before visibility feature),
# only the search space owner can access
if is_legacy:
search_space_query = select(SearchSpace).filter(
SearchSpace.id == thread.search_space_id
)
search_space_result = await session.execute(search_space_query)
search_space = search_space_result.scalar_one_or_none()
is_search_space_owner = search_space and search_space.user_id == user.id
if is_search_space_owner:
return True
# Legacy threads are not accessible to non-owners
raise HTTPException(
status_code=403,
detail="You don't have access to this chat",
)
# If ownership is required, only the creator can access # If ownership is required, only the creator can access
if require_ownership: if require_ownership:
if not is_owner: if not is_owner:
@ -96,13 +120,10 @@ async def check_thread_access(
) )
return True return True
# For read access: owner or shared threads # For read access: owner can access their own private threads
if is_owner: if is_owner:
return True return True
if thread.visibility == ChatVisibility.SEARCH_SPACE:
return True
# Private thread and user is not the owner # Private thread and user is not the owner
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,
@ -129,7 +150,7 @@ async def list_threads(
A user can see threads that are: A user can see threads that are:
- Created by them (regardless of visibility) - Created by them (regardless of visibility)
- Shared with the search space (visibility = SEARCH_SPACE) - Shared with the search space (visibility = SEARCH_SPACE)
- Legacy threads with no creator (created_by_id is NULL) - Legacy threads with no creator (created_by_id is NULL) - only if user is search space owner
Args: Args:
search_space_id: The search space to list threads for search_space_id: The search space to list threads for
@ -146,19 +167,32 @@ async def list_threads(
"You don't have permission to read chats in this search space", "You don't have permission to read chats in this search space",
) )
# Get threads that are either: # Check if user is the search space owner (for legacy thread visibility)
search_space_query = select(SearchSpace).filter(
SearchSpace.id == search_space_id
)
search_space_result = await session.execute(search_space_query)
search_space = search_space_result.scalar_one_or_none()
is_search_space_owner = search_space and search_space.user_id == user.id
# Build filter conditions:
# 1. Created by the current user (any visibility) # 1. Created by the current user (any visibility)
# 2. Shared with the search space (visibility = SEARCH_SPACE) # 2. Shared with the search space (visibility = SEARCH_SPACE)
# 3. Legacy threads with no creator (created_by_id is NULL) - visible to all # 3. Legacy threads (created_by_id is NULL) - only visible to search space owner
filter_conditions = [
NewChatThread.created_by_id == user.id,
NewChatThread.visibility == ChatVisibility.SEARCH_SPACE,
]
# Only include legacy threads for the search space owner
if is_search_space_owner:
filter_conditions.append(NewChatThread.created_by_id.is_(None))
query = ( query = (
select(NewChatThread) select(NewChatThread)
.filter( .filter(
NewChatThread.search_space_id == search_space_id, NewChatThread.search_space_id == search_space_id,
or_( or_(*filter_conditions),
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()) .order_by(NewChatThread.updated_at.desc())
) )
@ -171,9 +205,9 @@ async def list_threads(
archived_threads = [] archived_threads = []
for thread in all_threads: for thread in all_threads:
# Legacy threads (no creator) are treated as own threads for display purposes # Legacy threads (no creator) are treated as own threads for owner
is_own_thread = ( is_own_thread = thread.created_by_id == user.id or (
thread.created_by_id == user.id or thread.created_by_id is None thread.created_by_id is None and is_search_space_owner
) )
item = ThreadListItem( item = ThreadListItem(
id=thread.id, id=thread.id,
@ -222,7 +256,7 @@ async def search_threads(
A user can search threads that are: A user can search threads that are:
- Created by them (regardless of visibility) - Created by them (regardless of visibility)
- Shared with the search space (visibility = SEARCH_SPACE) - Shared with the search space (visibility = SEARCH_SPACE)
- Legacy threads with no creator (created_by_id is NULL) - Legacy threads with no creator (created_by_id is NULL) - only if user is search space owner
Args: Args:
search_space_id: The search space to search in search_space_id: The search space to search in
@ -239,17 +273,31 @@ async def search_threads(
"You don't have permission to read chats in this search space", "You don't have permission to read chats in this search space",
) )
# Check if user is the search space owner (for legacy thread visibility)
search_space_query = select(SearchSpace).filter(
SearchSpace.id == search_space_id
)
search_space_result = await session.execute(search_space_query)
search_space = search_space_result.scalar_one_or_none()
is_search_space_owner = search_space and search_space.user_id == user.id
# Build filter conditions
filter_conditions = [
NewChatThread.created_by_id == user.id,
NewChatThread.visibility == ChatVisibility.SEARCH_SPACE,
]
# Only include legacy threads for the search space owner
if is_search_space_owner:
filter_conditions.append(NewChatThread.created_by_id.is_(None))
# Search accessible threads by title (case-insensitive) # Search accessible threads by title (case-insensitive)
query = ( query = (
select(NewChatThread) select(NewChatThread)
.filter( .filter(
NewChatThread.search_space_id == search_space_id, NewChatThread.search_space_id == search_space_id,
NewChatThread.title.ilike(f"%{title}%"), NewChatThread.title.ilike(f"%{title}%"),
or_( or_(*filter_conditions),
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()) .order_by(NewChatThread.updated_at.desc())
) )
@ -264,9 +312,10 @@ async def search_threads(
archived=thread.archived, archived=thread.archived,
visibility=thread.visibility, visibility=thread.visibility,
created_by_id=thread.created_by_id, created_by_id=thread.created_by_id,
# Legacy threads (no creator) are treated as own threads # Legacy threads (no creator) are treated as own threads for owner
is_own_thread=( is_own_thread=(
thread.created_by_id == user.id or thread.created_by_id is None thread.created_by_id == user.id
or (thread.created_by_id is None and is_search_space_owner)
), ),
created_at=thread.created_at, created_at=thread.created_at,
updated_at=thread.updated_at, updated_at=thread.updated_at,
@ -383,7 +432,7 @@ async def get_thread_messages(
) )
# Check thread-level access based on visibility # Check thread-level access based on visibility
await check_thread_access(thread, user) await check_thread_access(session, thread, user)
# Return messages in the format expected by assistant-ui # Return messages in the format expected by assistant-ui
messages = [ messages = [
@ -447,7 +496,7 @@ async def get_thread_full(
) )
# Check thread-level access based on visibility # Check thread-level access based on visibility
await check_thread_access(thread, user) await check_thread_access(session, thread, user)
return thread return thread
@ -500,7 +549,7 @@ async def update_thread(
# For PRIVATE threads, only the creator can update # For PRIVATE threads, only the creator can update
# For SEARCH_SPACE threads, any member with permission can update # For SEARCH_SPACE threads, any member with permission can update
if db_thread.visibility == ChatVisibility.PRIVATE: if db_thread.visibility == ChatVisibility.PRIVATE:
await check_thread_access(db_thread, user, require_ownership=True) await check_thread_access(session, db_thread, user, require_ownership=True)
# Update fields # Update fields
update_data = thread_update.model_dump(exclude_unset=True) update_data = thread_update.model_dump(exclude_unset=True)
@ -568,7 +617,7 @@ async def delete_thread(
# For PRIVATE threads, only the creator can delete # For PRIVATE threads, only the creator can delete
# For SEARCH_SPACE threads, any member with permission can delete # For SEARCH_SPACE threads, any member with permission can delete
if db_thread.visibility == ChatVisibility.PRIVATE: if db_thread.visibility == ChatVisibility.PRIVATE:
await check_thread_access(db_thread, user, require_ownership=True) await check_thread_access(session, db_thread, user, require_ownership=True)
await session.delete(db_thread) await session.delete(db_thread)
await session.commit() await session.commit()
@ -628,7 +677,7 @@ async def update_thread_visibility(
) )
# Only the creator can change visibility # Only the creator can change visibility
await check_thread_access(db_thread, user, require_ownership=True) await check_thread_access(session, db_thread, user, require_ownership=True)
# Update visibility # Update visibility
db_thread.visibility = visibility_update.visibility db_thread.visibility = visibility_update.visibility
@ -714,7 +763,7 @@ async def append_message(
) )
# Check thread-level access based on visibility # Check thread-level access based on visibility
await check_thread_access(thread, user) await check_thread_access(session, thread, user)
# Convert string role to enum # Convert string role to enum
role_str = ( role_str = (
@ -825,7 +874,7 @@ async def list_messages(
) )
# Check thread-level access based on visibility # Check thread-level access based on visibility
await check_thread_access(thread, user) await check_thread_access(session, thread, user)
# Get messages # Get messages
query = ( query = (
@ -894,7 +943,7 @@ async def handle_new_chat(
) )
# Check thread-level access based on visibility # Check thread-level access based on visibility
await check_thread_access(thread, user) await check_thread_access(session, thread, user)
# Get search space to check LLM config preferences # Get search space to check LLM config preferences
search_space_result = await session.execute( search_space_result = await session.execute(

View file

@ -556,6 +556,54 @@ async def update_member_role(
) from e ) from e
# NOTE: /members/me must be defined BEFORE /members/{membership_id}
# because FastAPI matches routes in order, and "me" would otherwise
# be interpreted as a membership_id (causing a 422 validation error)
@router.delete("/searchspaces/{search_space_id}/members/me")
async def leave_search_space(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Leave a search space (remove own membership).
Owners cannot leave their search space.
"""
try:
result = await session.execute(
select(SearchSpaceMembership).filter(
SearchSpaceMembership.user_id == user.id,
SearchSpaceMembership.search_space_id == search_space_id,
)
)
db_membership = result.scalars().first()
if not db_membership:
raise HTTPException(
status_code=404,
detail="You are not a member of this search space",
)
if db_membership.is_owner:
raise HTTPException(
status_code=400,
detail="Owners cannot leave their search space. Transfer ownership first or delete the search space.",
)
await session.delete(db_membership)
await session.commit()
return {"message": "Successfully left the search space"}
except HTTPException:
raise
except Exception as e:
await session.rollback()
logger.error(f"Failed to leave search space: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to leave search space: {e!s}"
) from e
@router.delete("/searchspaces/{search_space_id}/members/{membership_id}") @router.delete("/searchspaces/{search_space_id}/members/{membership_id}")
async def remove_member( async def remove_member(
search_space_id: int, search_space_id: int,
@ -608,51 +656,6 @@ async def remove_member(
) from e ) from e
@router.delete("/searchspaces/{search_space_id}/members/me")
async def leave_search_space(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Leave a search space (remove own membership).
Owners cannot leave their search space.
"""
try:
result = await session.execute(
select(SearchSpaceMembership).filter(
SearchSpaceMembership.user_id == user.id,
SearchSpaceMembership.search_space_id == search_space_id,
)
)
db_membership = result.scalars().first()
if not db_membership:
raise HTTPException(
status_code=404,
detail="You are not a member of this search space",
)
if db_membership.is_owner:
raise HTTPException(
status_code=400,
detail="Owners cannot leave their search space. Transfer ownership first or delete the search space.",
)
await session.delete(db_membership)
await session.commit()
return {"message": "Successfully left the search space"}
except HTTPException:
raise
except Exception as e:
await session.rollback()
logger.error(f"Failed to leave search space: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to leave search space: {e!s}"
) from e
# ============ Invite Endpoints ============ # ============ Invite Endpoints ============

View file

@ -77,29 +77,31 @@ def format_mentioned_surfsense_docs_as_context(
return "" return ""
import json import json
context_parts = ["<mentioned_surfsense_docs>"] context_parts = ["<mentioned_surfsense_docs>"]
context_parts.append( context_parts.append(
"The user has explicitly mentioned the following SurfSense documentation pages. " "The user has explicitly mentioned the following SurfSense documentation pages. "
"These are official documentation about how to use SurfSense and should be used to answer questions about the application. " "These are official documentation about how to use SurfSense and should be used to answer questions about the application. "
"Use [citation:CHUNK_ID] format for citations (e.g., [citation:doc-123])." "Use [citation:CHUNK_ID] format for citations (e.g., [citation:doc-123])."
) )
for doc in documents: for doc in documents:
metadata_json = json.dumps({"source": doc.source}, ensure_ascii=False) metadata_json = json.dumps({"source": doc.source}, ensure_ascii=False)
context_parts.append("<document>") context_parts.append("<document>")
context_parts.append("<document_metadata>") context_parts.append("<document_metadata>")
context_parts.append(f" <document_id>doc-{doc.id}</document_id>") context_parts.append(f" <document_id>doc-{doc.id}</document_id>")
context_parts.append(" <document_type>SURFSENSE_DOCS</document_type>") context_parts.append(" <document_type>SURFSENSE_DOCS</document_type>")
context_parts.append(f" <title><![CDATA[{doc.title}]]></title>") context_parts.append(f" <title><![CDATA[{doc.title}]]></title>")
context_parts.append(f" <url><![CDATA[{doc.source}]]></url>") context_parts.append(f" <url><![CDATA[{doc.source}]]></url>")
context_parts.append(f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>") context_parts.append(
f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>"
)
context_parts.append("</document_metadata>") context_parts.append("</document_metadata>")
context_parts.append("") context_parts.append("")
context_parts.append("<document_content>") context_parts.append("<document_content>")
if hasattr(doc, 'chunks') and doc.chunks: if hasattr(doc, "chunks") and doc.chunks:
for chunk in doc.chunks: for chunk in doc.chunks:
context_parts.append( context_parts.append(
f" <chunk id='doc-{chunk.id}'><![CDATA[{chunk.content}]]></chunk>" f" <chunk id='doc-{chunk.id}'><![CDATA[{chunk.content}]]></chunk>"
@ -108,11 +110,11 @@ def format_mentioned_surfsense_docs_as_context(
context_parts.append( context_parts.append(
f" <chunk id='doc-0'><![CDATA[{doc.content}]]></chunk>" f" <chunk id='doc-0'><![CDATA[{doc.content}]]></chunk>"
) )
context_parts.append("</document_content>") context_parts.append("</document_content>")
context_parts.append("</document>") context_parts.append("</document>")
context_parts.append("") context_parts.append("")
context_parts.append("</mentioned_surfsense_docs>") context_parts.append("</mentioned_surfsense_docs>")
return "\n".join(context_parts) return "\n".join(context_parts)
@ -263,6 +265,7 @@ async def stream_new_chat(
mentioned_surfsense_docs: list[SurfsenseDocsDocument] = [] mentioned_surfsense_docs: list[SurfsenseDocsDocument] = []
if mentioned_surfsense_doc_ids: if mentioned_surfsense_doc_ids:
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
result = await session.execute( result = await session.execute(
select(SurfsenseDocsDocument) select(SurfsenseDocsDocument)
.options(selectinload(SurfsenseDocsDocument.chunks)) .options(selectinload(SurfsenseDocsDocument.chunks))

View file

@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { AUTH_TYPE } from "@/lib/env-config";
import { ValidationError } from "@/lib/error"; import { ValidationError } from "@/lib/error";
import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events"; import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events";
@ -30,8 +31,8 @@ export function LocalLoginForm() {
const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom); const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom);
useEffect(() => { useEffect(() => {
// Get the auth type from environment variables // Get the auth type from centralized config
setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE"); setAuthType(AUTH_TYPE);
}, []); }, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {

View file

@ -8,6 +8,7 @@ import { Suspense, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors"; import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
import { AUTH_TYPE } from "@/lib/env-config";
import { AmbientBackground } from "./AmbientBackground"; import { AmbientBackground } from "./AmbientBackground";
import { GoogleLoginButton } from "./GoogleLoginButton"; import { GoogleLoginButton } from "./GoogleLoginButton";
import { LocalLoginForm } from "./LocalLoginForm"; import { LocalLoginForm } from "./LocalLoginForm";
@ -82,8 +83,8 @@ function LoginContent() {
}); });
} }
// Get the auth type from environment variables // Get the auth type from centralized config
setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE"); setAuthType(AUTH_TYPE);
setIsLoading(false); setIsLoading(false);
}, [searchParams, t, tCommon]); }, [searchParams, t, tCommon]);

View file

@ -10,6 +10,7 @@ import { toast } from "sonner";
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { AUTH_TYPE } from "@/lib/env-config";
import { AppError, ValidationError } from "@/lib/error"; import { AppError, ValidationError } from "@/lib/error";
import { import {
trackRegistrationAttempt, trackRegistrationAttempt,
@ -36,8 +37,7 @@ export default function RegisterPage() {
// Check authentication type and redirect if not LOCAL // Check authentication type and redirect if not LOCAL
useEffect(() => { useEffect(() => {
const authType = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE"; if (AUTH_TYPE !== "LOCAL") {
if (authType !== "LOCAL") {
router.push("/login"); router.push("/login");
} }
}, [router]); }, [router]);

View file

@ -2,7 +2,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { RefreshCw, SquarePlus } from "lucide-react"; import { RefreshCw, SquarePlus, Upload } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@ -10,6 +10,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
import { toast } from "sonner"; import { toast } from "sonner";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useLogsSummary } from "@/hooks/use-logs"; import { useLogsSummary } from "@/hooks/use-logs";
@ -36,6 +37,7 @@ export default function DocumentsTable() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const searchSpaceId = Number(params.search_space_id); const searchSpaceId = Number(params.search_space_id);
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
const handleNewNote = useCallback(() => { const handleNewNote = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/editor/new`); router.push(`/dashboard/${searchSpaceId}/editor/new`);
@ -65,8 +67,7 @@ export default function DocumentsTable() {
); );
// Check if only SURFSENSE_DOCS is selected (skip regular docs query) // Check if only SURFSENSE_DOCS is selected (skip regular docs query)
const onlySurfsenseDocsSelected = const onlySurfsenseDocsSelected = activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS";
activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS";
// Build query parameters for fetching documents (excluding SURFSENSE_DOCS type) // Build query parameters for fetching documents (excluding SURFSENSE_DOCS type)
const queryParams = useMemo( const queryParams = useMemo(
@ -130,9 +131,11 @@ export default function DocumentsTable() {
queryKey: ["surfsense-docs", debouncedSearch, pageIndex, pageSize], queryKey: ["surfsense-docs", debouncedSearch, pageIndex, pageSize],
queryFn: () => queryFn: () =>
documentsApiService.getSurfsenseDocs({ documentsApiService.getSurfsenseDocs({
page: pageIndex, queryParams: {
page_size: pageSize, page: pageIndex,
title: debouncedSearch.trim() || undefined, page_size: pageSize,
title: debouncedSearch.trim() || undefined,
},
}), }),
staleTime: 3 * 60 * 1000, // 3 minutes staleTime: 3 * 60 * 1000, // 3 minutes
enabled: showSurfsenseDocs, enabled: showSurfsenseDocs,
@ -256,7 +259,16 @@ export default function DocumentsTable() {
} finally { } finally {
setIsRefreshing(false); setIsRefreshing(false);
} }
}, [debouncedSearch, refetchSearch, refetchDocuments, refetchSurfsenseDocs, showSurfsenseDocs, onlySurfsenseDocsSelected, t, isRefreshing]); }, [
debouncedSearch,
refetchSearch,
refetchDocuments,
refetchSurfsenseDocs,
showSurfsenseDocs,
onlySurfsenseDocsSelected,
t,
isRefreshing,
]);
// Set up smart polling for active tasks - only polls when tasks are in progress // Set up smart polling for active tasks - only polls when tasks are in progress
const { summary } = useLogsSummary(searchSpaceId, 24, { const { summary } = useLogsSummary(searchSpaceId, 24, {
@ -355,7 +367,11 @@ export default function DocumentsTable() {
<p className="text-xs md:text-sm text-muted-foreground">{t("subtitle")}</p> <p className="text-xs md:text-sm text-muted-foreground">{t("subtitle")}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button onClick={handleNewNote} variant="default" size="sm"> <Button onClick={openUploadDialog} variant="default" size="sm">
<Upload className="w-4 h-4 mr-2" />
{t("upload_documents")}
</Button>
<Button onClick={handleNewNote} variant="outline" size="sm">
<SquarePlus className="w-4 h-4 mr-2" /> <SquarePlus className="w-4 h-4 mr-2" />
{t("create_shared_note")} {t("create_shared_note")}
</Button> </Button>

View file

@ -425,6 +425,8 @@ export default function NewChatPage() {
const newThread = await createThread(searchSpaceId, "New Chat"); const newThread = await createThread(searchSpaceId, "New Chat");
currentThreadId = newThread.id; currentThreadId = newThread.id;
setThreadId(currentThreadId); setThreadId(currentThreadId);
// Set currentThread so ChatHeader can show share button immediately
setCurrentThread(newThread);
// Track chat creation // Track chat creation
trackChatCreated(searchSpaceId, currentThreadId); trackChatCreated(searchSpaceId, currentThreadId);
@ -459,7 +461,9 @@ export default function NewChatPage() {
// Track message sent // Track message sent
trackChatMessageSent(searchSpaceId, currentThreadId, { trackChatMessageSent(searchSpaceId, currentThreadId, {
hasAttachments: messageAttachments.length > 0, hasAttachments: messageAttachments.length > 0,
hasMentionedDocuments: mentionedDocumentIds.surfsense_doc_ids.length > 0 || mentionedDocumentIds.document_ids.length > 0, hasMentionedDocuments:
mentionedDocumentIds.surfsense_doc_ids.length > 0 ||
mentionedDocumentIds.document_ids.length > 0,
messageLength: userQuery.length, messageLength: userQuery.length,
}); });
@ -683,7 +687,9 @@ export default function NewChatPage() {
messages: messageHistory, messages: messageHistory,
attachments: attachments.length > 0 ? attachments : undefined, attachments: attachments.length > 0 ? attachments : undefined,
mentioned_document_ids: hasDocumentIds ? mentionedDocumentIds.document_ids : undefined, mentioned_document_ids: hasDocumentIds ? mentionedDocumentIds.document_ids : undefined,
mentioned_surfsense_doc_ids: hasSurfsenseDocIds ? mentionedDocumentIds.surfsense_doc_ids : undefined, mentioned_surfsense_doc_ids: hasSurfsenseDocIds
? mentionedDocumentIds.surfsense_doc_ids
: undefined,
}), }),
signal: controller.signal, signal: controller.signal,
}); });

View file

@ -170,7 +170,9 @@ function SettingsSidebar({
> >
{t(item.labelKey)} {t(item.labelKey)}
</p> </p>
<p className="text-xs text-muted-foreground/70 truncate">{t(item.descriptionKey)}</p> <p className="text-xs text-muted-foreground/70 truncate">
{t(item.descriptionKey)}
</p>
</div> </div>
<ChevronRight <ChevronRight
className={cn( className={cn(
@ -184,7 +186,6 @@ function SettingsSidebar({
); );
})} })}
</nav> </nav>
</aside> </aside>
</> </>
); );

View file

@ -160,7 +160,6 @@ function UserSettingsSidebar({
); );
})} })}
</nav> </nav>
</aside> </aside>
</> </>
); );

View file

@ -19,7 +19,7 @@ export const mentionedDocumentIdsAtom = atom<{
* Atom to store the full document objects mentioned in the current chat composer. * Atom to store the full document objects mentioned in the current chat composer.
* This persists across component remounts. * This persists across component remounts.
*/ */
export const mentionedDocumentsAtom = atom<(Pick<Document, "id" | "title" | "document_type">)[]>([]); export const mentionedDocumentsAtom = atom<Pick<Document, "id" | "title" | "document_type">[]>([]);
/** /**
* Simplified document info for display purposes * Simplified document info for display purposes

View file

@ -54,8 +54,12 @@ export const Composer: FC = () => {
// Sync mentioned document IDs to atom for use in chat request // Sync mentioned document IDs to atom for use in chat request
useEffect(() => { useEffect(() => {
setMentionedDocumentIds({ setMentionedDocumentIds({
surfsense_doc_ids: mentionedDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id), surfsense_doc_ids: mentionedDocuments
document_ids: mentionedDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), .filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: mentionedDocuments
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
}); });
}, [mentionedDocuments, setMentionedDocumentIds]); }, [mentionedDocuments, setMentionedDocumentIds]);
@ -138,12 +142,14 @@ export const Composer: FC = () => {
const handleDocumentRemove = useCallback( const handleDocumentRemove = useCallback(
(docId: number, docType?: string) => { (docId: number, docType?: string) => {
setMentionedDocuments((prev) => { setMentionedDocuments((prev) => {
const updated = prev.filter( const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType));
(doc) => !(doc.id === docId && doc.document_type === docType)
);
setMentionedDocumentIds({ setMentionedDocumentIds({
surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id), surfsense_doc_ids: updated
document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), .filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
}); });
return updated; return updated;
}); });
@ -153,9 +159,7 @@ export const Composer: FC = () => {
const handleDocumentsMention = useCallback( const handleDocumentsMention = useCallback(
(documents: Pick<Document, "id" | "title" | "document_type">[]) => { (documents: Pick<Document, "id" | "title" | "document_type">[]) => {
const existingKeys = new Set( const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)
);
const newDocs = documents.filter( const newDocs = documents.filter(
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`) (doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
); );
@ -171,8 +175,12 @@ export const Composer: FC = () => {
); );
const updated = [...prev, ...uniqueNewDocs]; const updated = [...prev, ...uniqueNewDocs];
setMentionedDocumentIds({ setMentionedDocumentIds({
surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id), surfsense_doc_ids: updated
document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), .filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
}); });
return updated; return updated;
}); });

View file

@ -86,7 +86,6 @@ const DocumentUploadPopupContent: FC<{
}> = ({ isOpen, onOpenChange }) => { }> = ({ isOpen, onOpenChange }) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const router = useRouter(); const router = useRouter();
const [isAccordionExpanded, setIsAccordionExpanded] = useState(false);
if (!searchSpaceId) return null; if (!searchSpaceId) return null;
@ -118,19 +117,16 @@ const DocumentUploadPopupContent: FC<{
{/* Scrollable Content */} {/* Scrollable Content */}
<div className="flex-1 min-h-0 relative overflow-hidden"> <div className="flex-1 min-h-0 relative overflow-hidden">
<div className={`h-full ${isAccordionExpanded ? "overflow-y-auto" : ""}`}> <div className="h-full overflow-y-auto">
<div className="px-6 sm:px-12 pb-5 sm:pb-16"> <div className="px-6 sm:px-12 pb-5 sm:pb-16">
<DocumentUploadTab <DocumentUploadTab
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
onSuccess={handleSuccess} onSuccess={handleSuccess}
onAccordionStateChange={setIsAccordionExpanded}
/> />
</div> </div>
</div> </div>
{/* Bottom fade shadow - only show when scrolling */} {/* Bottom fade shadow */}
{isAccordionExpanded && ( <div className="absolute bottom-0 left-0 right-0 h-2 sm:h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
<div className="absolute bottom-0 left-0 right-0 h-2 sm:h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View file

@ -100,7 +100,9 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
// Sync initial documents // Sync initial documents
useEffect(() => { useEffect(() => {
if (initialDocuments.length > 0) { if (initialDocuments.length > 0) {
setMentionedDocs(new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))); setMentionedDocs(
new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))
);
} }
}, [initialDocuments]); }, [initialDocuments]);

View file

@ -17,7 +17,7 @@ import { cn } from "@/lib/utils";
// Citation pattern: [citation:CHUNK_ID] or [citation:doc-CHUNK_ID] // Citation pattern: [citation:CHUNK_ID] or [citation:doc-CHUNK_ID]
// Also matches Chinese brackets 【】 and handles zero-width spaces that LLM sometimes inserts // Also matches Chinese brackets 【】 and handles zero-width spaces that LLM sometimes inserts
const CITATION_REGEX = /[\[【]\u200B?citation:(doc-)?(\d+)\u200B?[\]】]/g; const CITATION_REGEX = /[[【]\u200B?citation:(doc-)?(\d+)\u200B?[\]】]/g;
// Track chunk IDs to citation numbers mapping for consistent numbering // Track chunk IDs to citation numbers mapping for consistent numbering
// This map is reset when a new message starts rendering // This map is reset when a new message starts rendering

View file

@ -230,8 +230,12 @@ const Composer: FC = () => {
// Sync mentioned document IDs to atom for use in chat request // Sync mentioned document IDs to atom for use in chat request
useEffect(() => { useEffect(() => {
setMentionedDocumentIds({ setMentionedDocumentIds({
surfsense_doc_ids: mentionedDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id), surfsense_doc_ids: mentionedDocuments
document_ids: mentionedDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), .filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: mentionedDocuments
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
}); });
}, [mentionedDocuments, setMentionedDocumentIds]); }, [mentionedDocuments, setMentionedDocumentIds]);
@ -314,12 +318,14 @@ const Composer: FC = () => {
const handleDocumentRemove = useCallback( const handleDocumentRemove = useCallback(
(docId: number, docType?: string) => { (docId: number, docType?: string) => {
setMentionedDocuments((prev) => { setMentionedDocuments((prev) => {
const updated = prev.filter( const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType));
(doc) => !(doc.id === docId && doc.document_type === docType)
);
setMentionedDocumentIds({ setMentionedDocumentIds({
surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id), surfsense_doc_ids: updated
document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), .filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
}); });
return updated; return updated;
}); });
@ -329,9 +335,7 @@ const Composer: FC = () => {
const handleDocumentsMention = useCallback( const handleDocumentsMention = useCallback(
(documents: Pick<Document, "id" | "title" | "document_type">[]) => { (documents: Pick<Document, "id" | "title" | "document_type">[]) => {
const existingKeys = new Set( const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)
);
const newDocs = documents.filter( const newDocs = documents.filter(
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`) (doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
); );
@ -347,8 +351,12 @@ const Composer: FC = () => {
); );
const updated = [...prev, ...uniqueNewDocs]; const updated = [...prev, ...uniqueNewDocs];
setMentionedDocumentIds({ setMentionedDocumentIds({
surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id), surfsense_doc_ids: updated
document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id), .filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
}); });
return updated; return updated;
}); });

View file

@ -4,6 +4,7 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import Balancer from "react-wrap-balancer"; import Balancer from "react-wrap-balancer";
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
import { trackLoginAttempt } from "@/lib/posthog/events"; import { trackLoginAttempt } from "@/lib/posthog/events";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -134,11 +135,11 @@ export function HeroSection() {
} }
function GetStartedButton() { function GetStartedButton() {
const isGoogleAuth = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE === "GOOGLE"; const isGoogleAuth = AUTH_TYPE === "GOOGLE";
const handleGoogleLogin = () => { const handleGoogleLogin = () => {
trackLoginAttempt("google"); trackLoginAttempt("google");
window.location.href = `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize-redirect`; window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
}; };
if (isGoogleAuth) { if (isGoogleAuth) {

View file

@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Logs, SquareLibrary, Trash2 } from "lucide-react"; import { LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation"; import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
@ -86,6 +86,14 @@ export function LayoutDataProvider({
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
const [isDeletingChat, setIsDeletingChat] = useState(false); const [isDeletingChat, setIsDeletingChat] = useState(false);
// Delete/Leave search space dialog state
const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false);
const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false);
const [searchSpaceToDelete, setSearchSpaceToDelete] = useState<SearchSpace | null>(null);
const [searchSpaceToLeave, setSearchSpaceToLeave] = useState<SearchSpace | null>(null);
const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false);
const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false);
const searchSpaces: SearchSpace[] = useMemo(() => { const searchSpaces: SearchSpace[] = useMemo(() => {
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return []; if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
return searchSpacesData.map((space) => ({ return searchSpacesData.map((space) => ({
@ -169,27 +177,75 @@ export function LayoutDataProvider({
}, [router]); }, [router]);
const handleSearchSpaceSettings = useCallback( const handleSearchSpaceSettings = useCallback(
(id: number) => { (space: SearchSpace) => {
router.push(`/dashboard/${id}/settings`); router.push(`/dashboard/${space.id}/settings`);
}, },
[router] [router]
); );
const handleDeleteSearchSpace = useCallback( const handleSearchSpaceDeleteClick = useCallback((space: SearchSpace) => {
async (id: number) => { // If user is owner, show delete dialog; otherwise show leave dialog
await deleteSearchSpace({ id }); if (space.isOwner) {
setSearchSpaceToDelete(space);
setShowDeleteSearchSpaceDialog(true);
} else {
setSearchSpaceToLeave(space);
setShowLeaveSearchSpaceDialog(true);
}
}, []);
const confirmDeleteSearchSpace = useCallback(async () => {
if (!searchSpaceToDelete) return;
setIsDeletingSearchSpace(true);
try {
await deleteSearchSpace({ id: searchSpaceToDelete.id });
refetchSearchSpaces(); refetchSearchSpaces();
if (Number(searchSpaceId) === id && searchSpaces.length > 1) { if (Number(searchSpaceId) === searchSpaceToDelete.id && searchSpaces.length > 1) {
const remaining = searchSpaces.filter((s) => s.id !== id); const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToDelete.id);
if (remaining.length > 0) { if (remaining.length > 0) {
router.push(`/dashboard/${remaining[0].id}/new-chat`); router.push(`/dashboard/${remaining[0].id}/new-chat`);
} }
} else if (searchSpaces.length === 1) { } else if (searchSpaces.length === 1) {
router.push("/dashboard"); router.push("/dashboard");
} }
}, } catch (error) {
[deleteSearchSpace, refetchSearchSpaces, searchSpaceId, searchSpaces, router] console.error("Error deleting search space:", error);
); } finally {
setIsDeletingSearchSpace(false);
setShowDeleteSearchSpaceDialog(false);
setSearchSpaceToDelete(null);
}
}, [
searchSpaceToDelete,
deleteSearchSpace,
refetchSearchSpaces,
searchSpaceId,
searchSpaces,
router,
]);
const confirmLeaveSearchSpace = useCallback(async () => {
if (!searchSpaceToLeave) return;
setIsLeavingSearchSpace(true);
try {
await searchSpacesApiService.leaveSearchSpace(searchSpaceToLeave.id);
refetchSearchSpaces();
if (Number(searchSpaceId) === searchSpaceToLeave.id && searchSpaces.length > 1) {
const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToLeave.id);
if (remaining.length > 0) {
router.push(`/dashboard/${remaining[0].id}/new-chat`);
}
} else if (searchSpaces.length === 1) {
router.push("/dashboard");
}
} catch (error) {
console.error("Error leaving search space:", error);
} finally {
setIsLeavingSearchSpace(false);
setShowLeaveSearchSpaceDialog(false);
setSearchSpaceToLeave(null);
}
}, [searchSpaceToLeave, refetchSearchSpaces, searchSpaceId, searchSpaces, router]);
const handleNavItemClick = useCallback( const handleNavItemClick = useCallback(
(item: NavItem) => { (item: NavItem) => {
@ -284,6 +340,8 @@ export function LayoutDataProvider({
searchSpaces={searchSpaces} searchSpaces={searchSpaces}
activeSearchSpaceId={Number(searchSpaceId)} activeSearchSpaceId={Number(searchSpaceId)}
onSearchSpaceSelect={handleSearchSpaceSelect} onSearchSpaceSelect={handleSearchSpaceSelect}
onSearchSpaceDelete={handleSearchSpaceDeleteClick}
onSearchSpaceSettings={handleSearchSpaceSettings}
onAddSearchSpace={handleAddSearchSpace} onAddSearchSpace={handleAddSearchSpace}
searchSpace={activeSearchSpace} searchSpace={activeSearchSpace}
navItems={navItems} navItems={navItems}
@ -297,9 +355,9 @@ export function LayoutDataProvider({
onViewAllSharedChats={handleViewAllSharedChats} onViewAllSharedChats={handleViewAllSharedChats}
onViewAllPrivateChats={handleViewAllPrivateChats} onViewAllPrivateChats={handleViewAllPrivateChats}
user={{ email: user?.email || "", name: user?.email?.split("@")[0] }} user={{ email: user?.email || "", name: user?.email?.split("@")[0] }}
onSettings={handleSettings} onSettings={handleSettings}
onManageMembers={handleManageMembers} onManageMembers={handleManageMembers}
onUserSettings={handleUserSettings} onUserSettings={handleUserSettings}
onLogout={handleLogout} onLogout={handleLogout}
pageUsage={pageUsage} pageUsage={pageUsage}
breadcrumb={breadcrumb} breadcrumb={breadcrumb}
@ -354,6 +412,90 @@ export function LayoutDataProvider({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Delete Search Space Dialog */}
<Dialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
<span>{t("delete_search_space")}</span>
</DialogTitle>
<DialogDescription>
{t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setShowDeleteSearchSpaceDialog(false)}
disabled={isDeletingSearchSpace}
>
{tCommon("cancel")}
</Button>
<Button
variant="destructive"
onClick={confirmDeleteSearchSpace}
disabled={isDeletingSearchSpace}
className="gap-2"
>
{isDeletingSearchSpace ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t("deleting")}
</>
) : (
<>
<Trash2 className="h-4 w-4" />
{tCommon("delete")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Leave Search Space Dialog */}
<Dialog open={showLeaveSearchSpaceDialog} onOpenChange={setShowLeaveSearchSpaceDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LogOut className="h-5 w-5 text-destructive" />
<span>{t("leave_title")}</span>
</DialogTitle>
<DialogDescription>
{t("leave_confirm", { name: searchSpaceToLeave?.name || "" })}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setShowLeaveSearchSpaceDialog(false)}
disabled={isLeavingSearchSpace}
>
{tCommon("cancel")}
</Button>
<Button
variant="destructive"
onClick={confirmLeaveSearchSpace}
disabled={isLeavingSearchSpace}
className="gap-2"
>
{isLeavingSearchSpace ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t("leaving")}
</>
) : (
<>
<LogOut className="h-4 w-4" />
{t("leave")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* All Shared Chats Sidebar */} {/* All Shared Chats Sidebar */}
<AllSharedChatsSidebar <AllSharedChatsSidebar
open={isAllSharedChatsSidebarOpen} open={isAllSharedChatsSidebarOpen}

View file

@ -12,6 +12,8 @@ interface IconRailProps {
searchSpaces: SearchSpace[]; searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null; activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void; onSearchSpaceSelect: (id: number) => void;
onSearchSpaceDelete?: (searchSpace: SearchSpace) => void;
onSearchSpaceSettings?: (searchSpace: SearchSpace) => void;
onAddSearchSpace: () => void; onAddSearchSpace: () => void;
className?: string; className?: string;
} }
@ -20,6 +22,8 @@ export function IconRail({
searchSpaces, searchSpaces,
activeSearchSpaceId, activeSearchSpaceId,
onSearchSpaceSelect, onSearchSpaceSelect,
onSearchSpaceDelete,
onSearchSpaceSettings,
onAddSearchSpace, onAddSearchSpace,
className, className,
}: IconRailProps) { }: IconRailProps) {
@ -32,7 +36,13 @@ export function IconRail({
key={searchSpace.id} key={searchSpace.id}
name={searchSpace.name} name={searchSpace.name}
isActive={searchSpace.id === activeSearchSpaceId} isActive={searchSpace.id === activeSearchSpaceId}
isShared={searchSpace.memberCount > 1}
isOwner={searchSpace.isOwner}
onClick={() => onSearchSpaceSelect(searchSpace.id)} onClick={() => onSearchSpaceSelect(searchSpace.id)}
onDelete={onSearchSpaceDelete ? () => onSearchSpaceDelete(searchSpace) : undefined}
onSettings={
onSearchSpaceSettings ? () => onSearchSpaceSettings(searchSpace) : undefined
}
size="md" size="md"
/> />
))} ))}

View file

@ -1,12 +1,25 @@
"use client"; "use client";
import { Settings, Trash2, Users } from "lucide-react";
import { useTranslations } from "next-intl";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface SearchSpaceAvatarProps { interface SearchSpaceAvatarProps {
name: string; name: string;
isActive?: boolean; isActive?: boolean;
isShared?: boolean;
isOwner?: boolean;
onClick?: () => void; onClick?: () => void;
onDelete?: () => void;
onSettings?: () => void;
size?: "sm" | "md"; size?: "sm" | "md";
} }
@ -45,32 +58,103 @@ function getInitials(name: string): string {
export function SearchSpaceAvatar({ export function SearchSpaceAvatar({
name, name,
isActive, isActive,
isShared,
isOwner = true,
onClick, onClick,
onDelete,
onSettings,
size = "md", size = "md",
}: SearchSpaceAvatarProps) { }: SearchSpaceAvatarProps) {
const t = useTranslations("searchSpace");
const tCommon = useTranslations("common");
const bgColor = stringToColor(name); const bgColor = stringToColor(name);
const initials = getInitials(name); const initials = getInitials(name);
const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm"; const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm";
const tooltipContent = (
<div className="flex flex-col">
<span>{name}</span>
{isShared && (
<span className="text-xs text-muted-foreground">
{isOwner ? tCommon("owner") : tCommon("shared")}
</span>
)}
</div>
);
const avatarButton = (
<button
type="button"
onClick={onClick}
className={cn(
"relative flex items-center justify-center rounded-lg font-semibold text-white transition-all",
"hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
sizeClasses,
isActive && "ring-2 ring-primary ring-offset-1 ring-offset-background"
)}
style={{ backgroundColor: bgColor }}
>
{initials}
{/* Shared indicator badge */}
{isShared && (
<span
className={cn(
"absolute -top-1 -right-1 flex items-center justify-center rounded-full bg-blue-500 text-white shadow-sm",
size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4"
)}
title={tCommon("shared")}
>
<Users className={cn(size === "sm" ? "h-2 w-2" : "h-2.5 w-2.5")} />
</span>
)}
</button>
);
// If delete or settings handlers are provided, wrap with context menu
if (onDelete || onSettings) {
return (
<ContextMenu>
<Tooltip>
<TooltipTrigger asChild>
<ContextMenuTrigger asChild>
<div className="inline-block">{avatarButton}</div>
</ContextMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{tooltipContent}
</TooltipContent>
</Tooltip>
<ContextMenuContent className="w-48">
{onSettings && (
<ContextMenuItem onClick={onSettings}>
<Settings className="mr-2 h-4 w-4" />
{tCommon("settings")}
</ContextMenuItem>
)}
{onSettings && onDelete && <ContextMenuSeparator />}
{onDelete && isOwner && (
<ContextMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />
{tCommon("delete")}
</ContextMenuItem>
)}
{onDelete && !isOwner && (
<ContextMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />
{t("leave")}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
}
// No context menu needed
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>{avatarButton}</TooltipTrigger>
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center justify-center rounded-lg font-semibold text-white transition-all",
"hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
sizeClasses,
isActive && "ring-2 ring-primary ring-offset-1 ring-offset-background"
)}
style={{ backgroundColor: bgColor }}
>
{initials}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}> <TooltipContent side="right" sideOffset={8}>
{name} {tooltipContent}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );

View file

@ -14,6 +14,8 @@ interface LayoutShellProps {
searchSpaces: SearchSpace[]; searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null; activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void; onSearchSpaceSelect: (id: number) => void;
onSearchSpaceDelete?: (searchSpace: SearchSpace) => void;
onSearchSpaceSettings?: (searchSpace: SearchSpace) => void;
onAddSearchSpace: () => void; onAddSearchSpace: () => void;
searchSpace: SearchSpace | null; searchSpace: SearchSpace | null;
navItems: NavItem[]; navItems: NavItem[];
@ -46,6 +48,8 @@ export function LayoutShell({
searchSpaces, searchSpaces,
activeSearchSpaceId, activeSearchSpaceId,
onSearchSpaceSelect, onSearchSpaceSelect,
onSearchSpaceDelete,
onSearchSpaceSettings,
onAddSearchSpace, onAddSearchSpace,
searchSpace, searchSpace,
navItems, navItems,
@ -96,6 +100,8 @@ export function LayoutShell({
searchSpaces={searchSpaces} searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId} activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={onSearchSpaceSelect} onSearchSpaceSelect={onSearchSpaceSelect}
onSearchSpaceDelete={onSearchSpaceDelete}
onSearchSpaceSettings={onSearchSpaceSettings}
onAddSearchSpace={onAddSearchSpace} onAddSearchSpace={onAddSearchSpace}
searchSpace={searchSpace} searchSpace={searchSpace}
navItems={navItems} navItems={navItems}
@ -133,6 +139,8 @@ export function LayoutShell({
searchSpaces={searchSpaces} searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId} activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={onSearchSpaceSelect} onSearchSpaceSelect={onSearchSpaceSelect}
onSearchSpaceDelete={onSearchSpaceDelete}
onSearchSpaceSettings={onSearchSpaceSettings}
onAddSearchSpace={onAddSearchSpace} onAddSearchSpace={onAddSearchSpace}
/> />
</div> </div>

View file

@ -13,6 +13,8 @@ interface MobileSidebarProps {
searchSpaces: SearchSpace[]; searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null; activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void; onSearchSpaceSelect: (id: number) => void;
onSearchSpaceDelete?: (searchSpace: SearchSpace) => void;
onSearchSpaceSettings?: (searchSpace: SearchSpace) => void;
onAddSearchSpace: () => void; onAddSearchSpace: () => void;
searchSpace: SearchSpace | null; searchSpace: SearchSpace | null;
navItems: NavItem[]; navItems: NavItem[];
@ -48,6 +50,8 @@ export function MobileSidebar({
searchSpaces, searchSpaces,
activeSearchSpaceId, activeSearchSpaceId,
onSearchSpaceSelect, onSearchSpaceSelect,
onSearchSpaceDelete,
onSearchSpaceSettings,
onAddSearchSpace, onAddSearchSpace,
searchSpace, searchSpace,
navItems, navItems,
@ -94,7 +98,13 @@ export function MobileSidebar({
<SearchSpaceAvatar <SearchSpaceAvatar
name={space.name} name={space.name}
isActive={space.id === activeSearchSpaceId} isActive={space.id === activeSearchSpaceId}
isShared={space.memberCount > 1}
isOwner={space.isOwner}
onClick={() => handleSearchSpaceSelect(space.id)} onClick={() => handleSearchSpaceSelect(space.id)}
onDelete={onSearchSpaceDelete ? () => onSearchSpaceDelete(space) : undefined}
onSettings={
onSearchSpaceSettings ? () => onSearchSpaceSettings(space) : undefined
}
size="md" size="md"
/> />
</div> </div>

View file

@ -10,6 +10,14 @@ interface MarkdownViewerProps {
export function MarkdownViewer({ content, className }: MarkdownViewerProps) { export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
const components: StreamdownProps["components"] = { const components: StreamdownProps["components"] = {
// Define custom components for markdown elements // Define custom components for markdown elements
callout: ({ children, ...props }) => (
<div
className="my-4 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950"
{...props}
>
{children}
</div>
),
p: ({ children, ...props }) => ( p: ({ children, ...props }) => (
<p className="my-2" {...props}> <p className="my-2" {...props}>
{children} {children}

View file

@ -57,7 +57,9 @@ export const DocumentMentionPicker = forwardRef<
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
// State for pagination // State for pagination
const [accumulatedDocuments, setAccumulatedDocuments] = useState<Pick<Document, "id" | "title" | "document_type">[]>([]); const [accumulatedDocuments, setAccumulatedDocuments] = useState<
Pick<Document, "id" | "title" | "document_type">[]
>([]);
const [currentPage, setCurrentPage] = useState(0); const [currentPage, setCurrentPage] = useState(0);
const [hasMore, setHasMore] = useState(false); const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
@ -128,7 +130,7 @@ export const DocumentMentionPicker = forwardRef<
useEffect(() => { useEffect(() => {
if (currentPage === 0) { if (currentPage === 0) {
const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = []; const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = [];
// Add SurfSense docs first (they appear at top) // Add SurfSense docs first (they appear at top)
if (surfsenseDocs?.items) { if (surfsenseDocs?.items) {
for (const doc of surfsenseDocs.items) { for (const doc of surfsenseDocs.items) {
@ -139,7 +141,7 @@ export const DocumentMentionPicker = forwardRef<
}); });
} }
} }
// Add regular documents // Add regular documents
if (debouncedSearch.trim()) { if (debouncedSearch.trim()) {
if (searchedDocuments?.items) { if (searchedDocuments?.items) {
@ -152,7 +154,7 @@ export const DocumentMentionPicker = forwardRef<
setHasMore(documents.has_more); setHasMore(documents.has_more);
} }
} }
setAccumulatedDocuments(combinedDocs); setAccumulatedDocuments(combinedDocs);
} }
}, [documents, searchedDocuments, surfsenseDocs, debouncedSearch, currentPage]); }, [documents, searchedDocuments, surfsenseDocs, debouncedSearch, currentPage]);
@ -209,7 +211,9 @@ export const DocumentMentionPicker = forwardRef<
const actualDocuments = accumulatedDocuments; const actualDocuments = accumulatedDocuments;
const actualLoading = const actualLoading =
((debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) || isSurfsenseDocsLoading) && currentPage === 0; ((debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) ||
isSurfsenseDocsLoading) &&
currentPage === 0;
// Track already selected documents using unique key (document_type:id) to avoid ID collisions // Track already selected documents using unique key (document_type:id) to avoid ID collisions
const selectedKeys = useMemo( const selectedKeys = useMemo(

View file

@ -0,0 +1,225 @@
"use client";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />;
}
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />;
}
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />;
}
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />;
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive",
className
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View file

@ -64,6 +64,13 @@ export const deleteSearchSpaceResponse = z.object({
message: z.literal("Search space deleted successfully"), message: z.literal("Search space deleted successfully"),
}); });
/**
* Leave search space (for non-owners)
*/
export const leaveSearchSpaceResponse = z.object({
message: z.literal("Successfully left the search space"),
});
// Inferred types // Inferred types
export type SearchSpace = z.infer<typeof searchSpace>; export type SearchSpace = z.infer<typeof searchSpace>;
export type GetSearchSpacesRequest = z.infer<typeof getSearchSpacesRequest>; export type GetSearchSpacesRequest = z.infer<typeof getSearchSpacesRequest>;

View file

@ -19,6 +19,7 @@ import {
getDocumentTypeCountsRequest, getDocumentTypeCountsRequest,
getDocumentTypeCountsResponse, getDocumentTypeCountsResponse,
getSurfsenseDocsByChunkResponse, getSurfsenseDocsByChunkResponse,
getSurfsenseDocsRequest,
getSurfsenseDocsResponse, getSurfsenseDocsResponse,
type SearchDocumentsRequest, type SearchDocumentsRequest,
searchDocumentsRequest, searchDocumentsRequest,
@ -29,7 +30,6 @@ import {
updateDocumentResponse, updateDocumentResponse,
uploadDocumentRequest, uploadDocumentRequest,
uploadDocumentResponse, uploadDocumentResponse,
getSurfsenseDocsRequest,
} from "@/contracts/types/document.types"; } from "@/contracts/types/document.types";
import { ValidationError } from "../error"; import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service"; import { baseApiService } from "./base-api.service";

View file

@ -11,6 +11,7 @@ import {
getSearchSpaceResponse, getSearchSpaceResponse,
getSearchSpacesRequest, getSearchSpacesRequest,
getSearchSpacesResponse, getSearchSpacesResponse,
leaveSearchSpaceResponse,
type UpdateSearchSpaceRequest, type UpdateSearchSpaceRequest,
updateSearchSpaceRequest, updateSearchSpaceRequest,
updateSearchSpaceResponse, updateSearchSpaceResponse,
@ -115,6 +116,17 @@ class SearchSpacesApiService {
return baseApiService.delete(`/api/v1/searchspaces/${request.id}`, deleteSearchSpaceResponse); return baseApiService.delete(`/api/v1/searchspaces/${request.id}`, deleteSearchSpaceResponse);
}; };
/**
* Leave a search space (remove own membership)
* This is used by non-owners to leave a shared search space
*/
leaveSearchSpace = async (searchSpaceId: number) => {
return baseApiService.delete(
`/api/v1/searchspaces/${searchSpaceId}/members/me`,
leaveSearchSpaceResponse
);
};
} }
export const searchSpacesApiService = new SearchSpacesApiService(); export const searchSpacesApiService = new SearchSpacesApiService();

View file

@ -0,0 +1,28 @@
/**
* Environment configuration for the frontend.
*
* This file centralizes access to NEXT_PUBLIC_* environment variables.
* For Docker deployments, these placeholders are replaced at container startup
* via sed in the entrypoint script.
*
* IMPORTANT: Do not use template literals or complex expressions with these values
* as it may prevent the sed replacement from working correctly.
*/
// Auth type: "LOCAL" for email/password, "GOOGLE" for OAuth
// Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__
export const AUTH_TYPE = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE";
// Backend API URL
// Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_URL__
export const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
// ETL Service: "DOCLING" or "UNSTRUCTURED"
// Placeholder: __NEXT_PUBLIC_ETL_SERVICE__
export const ETL_SERVICE = process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING";
// Helper to check if local auth is enabled
export const isLocalAuth = () => AUTH_TYPE === "LOCAL";
// Helper to check if Google auth is enabled
export const isGoogleAuth = () => AUTH_TYPE === "GOOGLE";

View file

@ -97,6 +97,10 @@
"create_new_search_space": "Create new search space", "create_new_search_space": "Create new search space",
"delete_title": "Delete Search Space", "delete_title": "Delete Search Space",
"delete_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone and will permanently remove all data.", "delete_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone and will permanently remove all data.",
"leave": "Leave",
"leave_title": "Leave Search Space",
"leave_confirm": "Are you sure you want to leave \"{name}\"? You will lose access to all documents and chats in this search space.",
"leaving": "Leaving...",
"welcome_title": "Welcome to SurfSense", "welcome_title": "Welcome to SurfSense",
"welcome_description": "Create your first search space to start organizing your knowledge, connecting sources, and chatting with AI.", "welcome_description": "Create your first search space to start organizing your knowledge, connecting sources, and chatting with AI.",
"create_first_button": "Create your first search space" "create_first_button": "Create your first search space"
@ -159,6 +163,10 @@
"go_home": "Go Home", "go_home": "Go Home",
"delete_search_space": "Delete Search Space", "delete_search_space": "Delete Search Space",
"delete_space_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone. All documents and chats in this search space will be permanently deleted.", "delete_space_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone. All documents and chats in this search space will be permanently deleted.",
"leave": "Leave",
"leave_title": "Leave Search Space",
"leave_confirm": "Are you sure you want to leave \"{name}\"? You will lose access to all documents and chats in this search space.",
"leaving": "Leaving...",
"no_spaces_found": "No search spaces found", "no_spaces_found": "No search spaces found",
"create_first_space": "Create your first search space to get started", "create_first_space": "Create your first search space to get started",
"created": "Created" "created": "Created"
@ -311,6 +319,7 @@
"rows_per_page": "Rows per page", "rows_per_page": "Rows per page",
"refresh": "Refresh", "refresh": "Refresh",
"refresh_success": "Documents refreshed", "refresh_success": "Documents refreshed",
"upload_documents": "Upload Documents",
"create_shared_note": "Create Shared Note", "create_shared_note": "Create Shared Note",
"processing_documents": "Processing documents...", "processing_documents": "Processing documents...",
"active_tasks_count": "{count} active task(s)" "active_tasks_count": "{count} active task(s)"

View file

@ -159,6 +159,10 @@
"go_home": "返回首页", "go_home": "返回首页",
"delete_search_space": "删除搜索空间", "delete_search_space": "删除搜索空间",
"delete_space_confirm": "您确定要删除\"{name}\"吗?此操作无法撤销。此搜索空间中的所有文档、对话和播客将被永久删除。", "delete_space_confirm": "您确定要删除\"{name}\"吗?此操作无法撤销。此搜索空间中的所有文档、对话和播客将被永久删除。",
"leave": "退出",
"leave_title": "退出搜索空间",
"leave_confirm": "您确定要退出\"{name}\"吗?您将无法访问此搜索空间中的所有文档和对话。",
"leaving": "退出中...",
"no_spaces_found": "未找到搜索空间", "no_spaces_found": "未找到搜索空间",
"create_first_space": "创建您的第一个搜索空间以开始使用", "create_first_space": "创建您的第一个搜索空间以开始使用",
"created": "创建于" "created": "创建于"
@ -311,6 +315,7 @@
"rows_per_page": "每页行数", "rows_per_page": "每页行数",
"refresh": "刷新", "refresh": "刷新",
"refresh_success": "文档已刷新", "refresh_success": "文档已刷新",
"upload_documents": "上传文档",
"create_shared_note": "创建共享笔记", "create_shared_note": "创建共享笔记",
"processing_documents": "正在处理文档...", "processing_documents": "正在处理文档...",
"active_tasks_count": "{count} 个正在进行的工作项" "active_tasks_count": "{count} 个正在进行的工作项"

View file

@ -37,6 +37,7 @@
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",

View file

@ -56,6 +56,9 @@ importers:
'@radix-ui/react-collapsible': '@radix-ui/react-collapsible':
specifier: ^1.1.11 specifier: ^1.1.11
version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-context-menu':
specifier: ^2.2.16
version: 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-dialog': '@radix-ui/react-dialog':
specifier: ^1.1.15 specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@ -1628,6 +1631,19 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-context-menu@2.2.16':
resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-context@1.0.0': '@radix-ui/react-context@1.0.0':
resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==} resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==}
peerDependencies: peerDependencies:
@ -7422,6 +7438,20 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.7 '@types/react': 19.2.7
'@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-context@1.0.0(react@19.2.3)': '@radix-ui/react-context@1.0.0(react@19.2.3)':
dependencies: dependencies:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4