mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-07 06:42:39 +02:00
Merge remote-tracking branch 'upstream/dev' into implement-surfsense-docs-mentions
This commit is contained in:
commit
eb1ebdccba
33 changed files with 857 additions and 180 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 ============
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,12 +94,14 @@ def format_mentioned_surfsense_docs_as_context(
|
||||||
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>"
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,6 @@ function UserSettingsSidebar({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
225
surfsense_web/components/ui/context-menu.tsx
Normal file
225
surfsense_web/components/ui/context-menu.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
28
surfsense_web/lib/env-config.ts
Normal file
28
surfsense_web/lib/env-config.ts
Normal 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";
|
||||||
|
|
@ -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)"
|
||||||
|
|
|
||||||
|
|
@ -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} 个正在进行的工作项"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
30
surfsense_web/pnpm-lock.yaml
generated
30
surfsense_web/pnpm-lock.yaml
generated
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue