mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-15 18:25:18 +02:00
commit
f3f661f33e
58 changed files with 2784 additions and 1575 deletions
|
|
@ -17,7 +17,7 @@
|
||||||
# SurfSense
|
# SurfSense
|
||||||
Connect any LLM to your internal knowledge sources and chat with it in real time alongside your team. OSS alternative to NotebookLM, Perplexity, and Glean.
|
Connect any LLM to your internal knowledge sources and chat with it in real time alongside your team. OSS alternative to NotebookLM, Perplexity, and Glean.
|
||||||
|
|
||||||
SurfSense is a highly customizable AI research agent, connected to external sources such as Search Engines (SearxNG, Tavily, LinkUp), Google Drive, Slack, Linear, Jira, ClickUp, Confluence, BookStack, Gmail, Notion, YouTube, GitHub, Discord, Airtable, Google Calendar, Luma, Circleback, Elasticsearch and more to come.
|
SurfSense is a highly customizable AI research agent, connected to external sources such as Search Engines (SearxNG, Tavily, LinkUp), Google Drive, Slack, Microsoft Teams, Linear, Jira, ClickUp, Confluence, BookStack, Gmail, Notion, YouTube, GitHub, Discord, Airtable, Google Calendar, Luma, Circleback, Elasticsearch and more to come.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
|
@ -97,6 +97,7 @@ Contributors can easily add new tools via the registry pattern:
|
||||||
- SearxNG (self-hosted instances)
|
- SearxNG (self-hosted instances)
|
||||||
- Google Drive
|
- Google Drive
|
||||||
- Slack
|
- Slack
|
||||||
|
- Microsoft Teams
|
||||||
- Linear
|
- Linear
|
||||||
- Jira
|
- Jira
|
||||||
- ClickUp
|
- ClickUp
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
|
|
||||||
将任何 LLM 连接到您的内部知识源,并与团队成员实时聊天。NotebookLM、Perplexity 和 Glean 的开源替代方案。
|
将任何 LLM 连接到您的内部知识源,并与团队成员实时聊天。NotebookLM、Perplexity 和 Glean 的开源替代方案。
|
||||||
|
|
||||||
SurfSense 是一个高度可定制的 AI 研究助手,可以连接外部数据源,如搜索引擎(SearxNG、Tavily、LinkUp)、Google Drive、Slack、Linear、Jira、ClickUp、Confluence、BookStack、Gmail、Notion、YouTube、GitHub、Discord、Airtable、Google Calendar、Luma、Circleback、Elasticsearch 等,未来还会支持更多。
|
SurfSense 是一个高度可定制的 AI 研究助手,可以连接外部数据源,如搜索引擎(SearxNG、Tavily、LinkUp)、Google Drive、Slack、Microsoft Teams、Linear、Jira、ClickUp、Confluence、BookStack、Gmail、Notion、YouTube、GitHub、Discord、Airtable、Google Calendar、Luma、Circleback、Elasticsearch 等,未来还会支持更多。
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
|
@ -105,6 +105,7 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
|
||||||
- SearxNG(自托管实例)
|
- SearxNG(自托管实例)
|
||||||
- Google Drive
|
- Google Drive
|
||||||
- Slack
|
- Slack
|
||||||
|
- Microsoft Teams
|
||||||
- Linear
|
- Linear
|
||||||
- Jira
|
- Jira
|
||||||
- ClickUp
|
- ClickUp
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
"""Add chat visibility and created_by_id columns to new_chat_threads
|
||||||
|
|
||||||
|
This migration adds:
|
||||||
|
- ChatVisibility enum (PRIVATE, SEARCH_SPACE)
|
||||||
|
- visibility column to new_chat_threads table (default: PRIVATE)
|
||||||
|
- created_by_id column to track who created the chat thread
|
||||||
|
|
||||||
|
Revision ID: 61
|
||||||
|
Revises: 60
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "61"
|
||||||
|
down_revision: str | None = "60"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add visibility and created_by_id columns to new_chat_threads."""
|
||||||
|
|
||||||
|
# Create the ChatVisibility enum type
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'chatvisibility') THEN
|
||||||
|
CREATE TYPE chatvisibility AS ENUM ('PRIVATE', 'SEARCH_SPACE');
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add visibility column with default value PRIVATE
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'new_chat_threads' AND column_name = 'visibility'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE new_chat_threads
|
||||||
|
ADD COLUMN visibility chatvisibility NOT NULL DEFAULT 'PRIVATE';
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create index on visibility column for efficient filtering
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_new_chat_threads_visibility
|
||||||
|
ON new_chat_threads(visibility);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add created_by_id column (nullable to handle existing records)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'new_chat_threads' AND column_name = 'created_by_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE new_chat_threads
|
||||||
|
ADD COLUMN created_by_id UUID REFERENCES "user"(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create index on created_by_id column for efficient filtering
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_new_chat_threads_created_by_id
|
||||||
|
ON new_chat_threads(created_by_id);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Remove visibility and created_by_id columns from new_chat_threads."""
|
||||||
|
|
||||||
|
# Drop indexes
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_created_by_id")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_visibility")
|
||||||
|
|
||||||
|
# Drop columns
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE new_chat_threads
|
||||||
|
DROP COLUMN IF EXISTS created_by_id;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE new_chat_threads
|
||||||
|
DROP COLUMN IF EXISTS visibility;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop enum type (only if not used elsewhere)
|
||||||
|
op.execute("DROP TYPE IF EXISTS chatvisibility")
|
||||||
|
|
@ -326,6 +326,20 @@ class NewChatMessageRole(str, Enum):
|
||||||
SYSTEM = "system"
|
SYSTEM = "system"
|
||||||
|
|
||||||
|
|
||||||
|
class ChatVisibility(str, Enum):
|
||||||
|
"""
|
||||||
|
Visibility/sharing level for chat threads.
|
||||||
|
|
||||||
|
PRIVATE: Only the creator can see/access the chat (default)
|
||||||
|
SEARCH_SPACE: All members of the search space can see/access the chat
|
||||||
|
PUBLIC: (Future) Anyone with the link can access the chat
|
||||||
|
"""
|
||||||
|
|
||||||
|
PRIVATE = "PRIVATE"
|
||||||
|
SEARCH_SPACE = "SEARCH_SPACE"
|
||||||
|
# PUBLIC = "PUBLIC" # Reserved for future implementation
|
||||||
|
|
||||||
|
|
||||||
class NewChatThread(BaseModel, TimestampMixin):
|
class NewChatThread(BaseModel, TimestampMixin):
|
||||||
"""
|
"""
|
||||||
Thread model for the new chat feature using assistant-ui.
|
Thread model for the new chat feature using assistant-ui.
|
||||||
|
|
@ -345,13 +359,31 @@ class NewChatThread(BaseModel, TimestampMixin):
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Visibility/sharing control
|
||||||
|
visibility = Column(
|
||||||
|
SQLAlchemyEnum(ChatVisibility),
|
||||||
|
nullable=False,
|
||||||
|
default=ChatVisibility.PRIVATE,
|
||||||
|
server_default="PRIVATE",
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Foreign keys
|
# Foreign keys
|
||||||
search_space_id = Column(
|
search_space_id = Column(
|
||||||
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
|
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Track who created this chat thread (for visibility filtering)
|
||||||
|
created_by_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("user.id", ondelete="SET NULL"),
|
||||||
|
nullable=True, # Nullable for existing records before migration
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
search_space = relationship("SearchSpace", back_populates="new_chat_threads")
|
search_space = relationship("SearchSpace", back_populates="new_chat_threads")
|
||||||
|
created_by = relationship("User", back_populates="new_chat_threads")
|
||||||
messages = relationship(
|
messages = relationship(
|
||||||
"NewChatMessage",
|
"NewChatMessage",
|
||||||
back_populates="thread",
|
back_populates="thread",
|
||||||
|
|
@ -826,6 +858,13 @@ if config.AUTH_TYPE == "GOOGLE":
|
||||||
passive_deletes=True,
|
passive_deletes=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Chat threads created by this user
|
||||||
|
new_chat_threads = relationship(
|
||||||
|
"NewChatThread",
|
||||||
|
back_populates="created_by",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Page usage tracking for ETL services
|
# Page usage tracking for ETL services
|
||||||
pages_limit = Column(
|
pages_limit = Column(
|
||||||
Integer,
|
Integer,
|
||||||
|
|
@ -852,6 +891,13 @@ else:
|
||||||
passive_deletes=True,
|
passive_deletes=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Chat threads created by this user
|
||||||
|
new_chat_threads = relationship(
|
||||||
|
"NewChatThread",
|
||||||
|
back_populates="created_by",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Page usage tracking for ETL services
|
# Page usage tracking for ETL services
|
||||||
pages_limit = Column(
|
pages_limit = Column(
|
||||||
Integer,
|
Integer,
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,14 @@ from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.db import (
|
from app.db import (
|
||||||
|
ChatVisibility,
|
||||||
NewChatMessage,
|
NewChatMessage,
|
||||||
NewChatMessageRole,
|
NewChatMessageRole,
|
||||||
NewChatThread,
|
NewChatThread,
|
||||||
|
|
@ -40,6 +42,7 @@ from app.schemas.new_chat import (
|
||||||
NewChatThreadCreate,
|
NewChatThreadCreate,
|
||||||
NewChatThreadRead,
|
NewChatThreadRead,
|
||||||
NewChatThreadUpdate,
|
NewChatThreadUpdate,
|
||||||
|
NewChatThreadVisibilityUpdate,
|
||||||
NewChatThreadWithMessages,
|
NewChatThreadWithMessages,
|
||||||
ThreadHistoryLoadResponse,
|
ThreadHistoryLoadResponse,
|
||||||
ThreadListItem,
|
ThreadListItem,
|
||||||
|
|
@ -52,6 +55,82 @@ from app.utils.rbac import check_permission
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def check_thread_access(
|
||||||
|
session: AsyncSession,
|
||||||
|
thread: NewChatThread,
|
||||||
|
user: User,
|
||||||
|
require_ownership: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a user has access to a thread based on visibility rules.
|
||||||
|
|
||||||
|
Access is granted if:
|
||||||
|
- User is the creator of the thread
|
||||||
|
- Thread visibility is SEARCH_SPACE (any member can access)
|
||||||
|
- Thread is a legacy thread (created_by_id is NULL) - only if user is search space owner
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
thread: The thread to check access for
|
||||||
|
user: The user requesting access
|
||||||
|
require_ownership: If True, only the creator can access (for edit/delete operations)
|
||||||
|
For SEARCH_SPACE threads, any member with permission can access
|
||||||
|
Legacy threads (NULL creator) are accessible by search space owner
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if access is granted
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If access is denied
|
||||||
|
"""
|
||||||
|
is_owner = thread.created_by_id == user.id
|
||||||
|
is_legacy = thread.created_by_id is None
|
||||||
|
|
||||||
|
# Shared threads (SEARCH_SPACE) are accessible by any member
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 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 require_ownership:
|
||||||
|
if not is_owner:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Only the creator of this chat can perform this action",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# For read access: owner can access their own private threads
|
||||||
|
if is_owner:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Private thread and user is not the owner
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="You don't have access to this private chat",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Thread Endpoints
|
# Thread Endpoints
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -65,9 +144,14 @@ async def list_threads(
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
List all threads for the current user in a search space.
|
List all accessible threads for the current user in a search space.
|
||||||
Returns threads and archived_threads for ThreadListPrimitive.
|
Returns threads and archived_threads for ThreadListPrimitive.
|
||||||
|
|
||||||
|
A user can see threads that are:
|
||||||
|
- Created by them (regardless of visibility)
|
||||||
|
- Shared with the search space (visibility = SEARCH_SPACE)
|
||||||
|
- Legacy threads with no creator (created_by_id is NULL) - 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
|
||||||
limit: Optional limit on number of threads to return (applies to active threads only)
|
limit: Optional limit on number of threads to return (applies to active threads only)
|
||||||
|
|
@ -83,10 +167,33 @@ 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 all threads 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:
|
||||||
|
# 1. Created by the current user (any visibility)
|
||||||
|
# 2. Shared with the search space (visibility = SEARCH_SPACE)
|
||||||
|
# 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(NewChatThread.search_space_id == search_space_id)
|
.filter(
|
||||||
|
NewChatThread.search_space_id == search_space_id,
|
||||||
|
or_(*filter_conditions),
|
||||||
|
)
|
||||||
.order_by(NewChatThread.updated_at.desc())
|
.order_by(NewChatThread.updated_at.desc())
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -98,10 +205,17 @@ 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 owner
|
||||||
|
is_own_thread = thread.created_by_id == user.id or (
|
||||||
|
thread.created_by_id is None and is_search_space_owner
|
||||||
|
)
|
||||||
item = ThreadListItem(
|
item = ThreadListItem(
|
||||||
id=thread.id,
|
id=thread.id,
|
||||||
title=thread.title,
|
title=thread.title,
|
||||||
archived=thread.archived,
|
archived=thread.archived,
|
||||||
|
visibility=thread.visibility,
|
||||||
|
created_by_id=thread.created_by_id,
|
||||||
|
is_own_thread=is_own_thread,
|
||||||
created_at=thread.created_at,
|
created_at=thread.created_at,
|
||||||
updated_at=thread.updated_at,
|
updated_at=thread.updated_at,
|
||||||
)
|
)
|
||||||
|
|
@ -137,7 +251,12 @@ async def search_threads(
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Search threads by title in a search space.
|
Search accessible threads by title in a search space.
|
||||||
|
|
||||||
|
A user can search threads that are:
|
||||||
|
- Created by them (regardless of visibility)
|
||||||
|
- Shared with the search space (visibility = SEARCH_SPACE)
|
||||||
|
- Legacy threads with no creator (created_by_id is NULL) - 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
|
||||||
|
|
@ -154,12 +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",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Search threads by title (case-insensitive)
|
# 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)
|
||||||
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_(*filter_conditions),
|
||||||
)
|
)
|
||||||
.order_by(NewChatThread.updated_at.desc())
|
.order_by(NewChatThread.updated_at.desc())
|
||||||
)
|
)
|
||||||
|
|
@ -172,6 +310,13 @@ async def search_threads(
|
||||||
id=thread.id,
|
id=thread.id,
|
||||||
title=thread.title,
|
title=thread.title,
|
||||||
archived=thread.archived,
|
archived=thread.archived,
|
||||||
|
visibility=thread.visibility,
|
||||||
|
created_by_id=thread.created_by_id,
|
||||||
|
# Legacy threads (no creator) are treated as own threads for owner
|
||||||
|
is_own_thread=(
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
|
|
@ -200,6 +345,9 @@ async def create_thread(
|
||||||
"""
|
"""
|
||||||
Create a new chat thread.
|
Create a new chat thread.
|
||||||
|
|
||||||
|
The thread is created with the specified visibility (defaults to PRIVATE).
|
||||||
|
The current user is recorded as the creator of the thread.
|
||||||
|
|
||||||
Requires CHATS_CREATE permission.
|
Requires CHATS_CREATE permission.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -215,7 +363,9 @@ async def create_thread(
|
||||||
db_thread = NewChatThread(
|
db_thread = NewChatThread(
|
||||||
title=thread.title,
|
title=thread.title,
|
||||||
archived=thread.archived,
|
archived=thread.archived,
|
||||||
|
visibility=thread.visibility,
|
||||||
search_space_id=thread.search_space_id,
|
search_space_id=thread.search_space_id,
|
||||||
|
created_by_id=user.id,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
session.add(db_thread)
|
session.add(db_thread)
|
||||||
|
|
@ -254,6 +404,10 @@ async def get_thread_messages(
|
||||||
Get a thread with all its messages.
|
Get a thread with all its messages.
|
||||||
This is used by ThreadHistoryAdapter.load() to restore conversation.
|
This is used by ThreadHistoryAdapter.load() to restore conversation.
|
||||||
|
|
||||||
|
Access is granted if:
|
||||||
|
- User is the creator of the thread
|
||||||
|
- Thread visibility is SEARCH_SPACE
|
||||||
|
|
||||||
Requires CHATS_READ permission.
|
Requires CHATS_READ permission.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -268,7 +422,7 @@ async def get_thread_messages(
|
||||||
if not thread:
|
if not thread:
|
||||||
raise HTTPException(status_code=404, detail="Thread not found")
|
raise HTTPException(status_code=404, detail="Thread not found")
|
||||||
|
|
||||||
# Check permission and ownership
|
# Check permission to read chats in this search space
|
||||||
await check_permission(
|
await check_permission(
|
||||||
session,
|
session,
|
||||||
user,
|
user,
|
||||||
|
|
@ -277,6 +431,9 @@ async def get_thread_messages(
|
||||||
"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 thread-level access based on visibility
|
||||||
|
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 = [
|
||||||
NewChatMessageRead(
|
NewChatMessageRead(
|
||||||
|
|
@ -313,6 +470,10 @@ async def get_thread_full(
|
||||||
"""
|
"""
|
||||||
Get full thread details with all messages.
|
Get full thread details with all messages.
|
||||||
|
|
||||||
|
Access is granted if:
|
||||||
|
- User is the creator of the thread
|
||||||
|
- Thread visibility is SEARCH_SPACE
|
||||||
|
|
||||||
Requires CHATS_READ permission.
|
Requires CHATS_READ permission.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -334,6 +495,9 @@ async def get_thread_full(
|
||||||
"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 thread-level access based on visibility
|
||||||
|
await check_thread_access(session, thread, user)
|
||||||
|
|
||||||
return thread
|
return thread
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -360,6 +524,9 @@ async def update_thread(
|
||||||
Update a thread (title, archived status).
|
Update a thread (title, archived status).
|
||||||
Used for renaming and archiving threads.
|
Used for renaming and archiving threads.
|
||||||
|
|
||||||
|
- PRIVATE threads: Only the creator can update
|
||||||
|
- SEARCH_SPACE threads: Any member with CHATS_UPDATE permission can update
|
||||||
|
|
||||||
Requires CHATS_UPDATE permission.
|
Requires CHATS_UPDATE permission.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -379,6 +546,11 @@ async def update_thread(
|
||||||
"You don't have permission to update chats in this search space",
|
"You don't have permission to update chats in this search space",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# For PRIVATE threads, only the creator can update
|
||||||
|
# For SEARCH_SPACE threads, any member with permission can update
|
||||||
|
if db_thread.visibility == ChatVisibility.PRIVATE:
|
||||||
|
await check_thread_access(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)
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
|
|
@ -420,6 +592,9 @@ async def delete_thread(
|
||||||
"""
|
"""
|
||||||
Delete a thread and all its messages.
|
Delete a thread and all its messages.
|
||||||
|
|
||||||
|
- PRIVATE threads: Only the creator can delete
|
||||||
|
- SEARCH_SPACE threads: Any member with CHATS_DELETE permission can delete
|
||||||
|
|
||||||
Requires CHATS_DELETE permission.
|
Requires CHATS_DELETE permission.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -439,6 +614,11 @@ async def delete_thread(
|
||||||
"You don't have permission to delete chats in this search space",
|
"You don't have permission to delete chats in this search space",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# For PRIVATE threads, only the creator can delete
|
||||||
|
# For SEARCH_SPACE threads, any member with permission can delete
|
||||||
|
if db_thread.visibility == ChatVisibility.PRIVATE:
|
||||||
|
await check_thread_access(session, db_thread, user, require_ownership=True)
|
||||||
|
|
||||||
await session.delete(db_thread)
|
await session.delete(db_thread)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"message": "Thread deleted successfully"}
|
return {"message": "Thread deleted successfully"}
|
||||||
|
|
@ -463,6 +643,71 @@ async def delete_thread(
|
||||||
) from None
|
) from None
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/threads/{thread_id}/visibility", response_model=NewChatThreadRead)
|
||||||
|
async def update_thread_visibility(
|
||||||
|
thread_id: int,
|
||||||
|
visibility_update: NewChatThreadVisibilityUpdate,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update the visibility/sharing settings of a thread.
|
||||||
|
|
||||||
|
Only the creator of the thread can change its visibility.
|
||||||
|
- PRIVATE: Only the creator can access the thread (default)
|
||||||
|
- SEARCH_SPACE: All members of the search space can access the thread
|
||||||
|
|
||||||
|
Requires CHATS_UPDATE permission.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
select(NewChatThread).filter(NewChatThread.id == thread_id)
|
||||||
|
)
|
||||||
|
db_thread = result.scalars().first()
|
||||||
|
|
||||||
|
if not db_thread:
|
||||||
|
raise HTTPException(status_code=404, detail="Thread not found")
|
||||||
|
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
db_thread.search_space_id,
|
||||||
|
Permission.CHATS_UPDATE.value,
|
||||||
|
"You don't have permission to update chats in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only the creator can change visibility
|
||||||
|
await check_thread_access(session, db_thread, user, require_ownership=True)
|
||||||
|
|
||||||
|
# Update visibility
|
||||||
|
db_thread.visibility = visibility_update.visibility
|
||||||
|
db_thread.updated_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(db_thread)
|
||||||
|
return db_thread
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except IntegrityError:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Database constraint violation. Please check your input data.",
|
||||||
|
) from None
|
||||||
|
except OperationalError:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503, detail="Database operation failed. Please try again later."
|
||||||
|
) from None
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"An unexpected error occurred while updating thread visibility: {e!s}",
|
||||||
|
) from None
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Message Endpoints
|
# Message Endpoints
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -479,6 +724,10 @@ async def append_message(
|
||||||
Append a message to a thread.
|
Append a message to a thread.
|
||||||
This is used by ThreadHistoryAdapter.append() to persist messages.
|
This is used by ThreadHistoryAdapter.append() to persist messages.
|
||||||
|
|
||||||
|
Access is granted if:
|
||||||
|
- User is the creator of the thread
|
||||||
|
- Thread visibility is SEARCH_SPACE
|
||||||
|
|
||||||
Requires CHATS_UPDATE permission.
|
Requires CHATS_UPDATE permission.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -513,6 +762,9 @@ async def append_message(
|
||||||
"You don't have permission to update chats in this search space",
|
"You don't have permission to update chats in this search space",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check thread-level access based on visibility
|
||||||
|
await check_thread_access(session, thread, user)
|
||||||
|
|
||||||
# Convert string role to enum
|
# Convert string role to enum
|
||||||
role_str = (
|
role_str = (
|
||||||
message.role.lower() if isinstance(message.role, str) else message.role
|
message.role.lower() if isinstance(message.role, str) else message.role
|
||||||
|
|
@ -597,6 +849,10 @@ async def list_messages(
|
||||||
"""
|
"""
|
||||||
List messages in a thread with pagination.
|
List messages in a thread with pagination.
|
||||||
|
|
||||||
|
Access is granted if:
|
||||||
|
- User is the creator of the thread
|
||||||
|
- Thread visibility is SEARCH_SPACE
|
||||||
|
|
||||||
Requires CHATS_READ permission.
|
Requires CHATS_READ permission.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -617,6 +873,9 @@ async def list_messages(
|
||||||
"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 thread-level access based on visibility
|
||||||
|
await check_thread_access(session, thread, user)
|
||||||
|
|
||||||
# Get messages
|
# Get messages
|
||||||
query = (
|
query = (
|
||||||
select(NewChatMessage)
|
select(NewChatMessage)
|
||||||
|
|
@ -659,6 +918,10 @@ async def handle_new_chat(
|
||||||
This endpoint handles the new chat functionality with streaming responses
|
This endpoint handles the new chat functionality with streaming responses
|
||||||
using Server-Sent Events (SSE) format compatible with Vercel AI SDK.
|
using Server-Sent Events (SSE) format compatible with Vercel AI SDK.
|
||||||
|
|
||||||
|
Access is granted if:
|
||||||
|
- User is the creator of the thread
|
||||||
|
- Thread visibility is SEARCH_SPACE
|
||||||
|
|
||||||
Requires CHATS_CREATE permission.
|
Requires CHATS_CREATE permission.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -679,6 +942,9 @@ async def handle_new_chat(
|
||||||
"You don't have permission to chat in this search space",
|
"You don't have permission to chat in this search space",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check thread-level access based on visibility
|
||||||
|
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(
|
||||||
select(SearchSpace).filter(SearchSpace.id == request.search_space_id)
|
select(SearchSpace).filter(SearchSpace.id == request.search_space_id)
|
||||||
|
|
@ -706,6 +972,7 @@ async def handle_new_chat(
|
||||||
llm_config_id=llm_config_id,
|
llm_config_id=llm_config_id,
|
||||||
attachments=request.attachments,
|
attachments=request.attachments,
|
||||||
mentioned_document_ids=request.mentioned_document_ids,
|
mentioned_document_ids=request.mentioned_document_ids,
|
||||||
|
mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids,
|
||||||
),
|
),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
headers={
|
headers={
|
||||||
|
|
|
||||||
|
|
@ -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 ============
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ on a [citation:doc-XXX] link.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
|
@ -17,8 +17,10 @@ from app.db import (
|
||||||
User,
|
User,
|
||||||
get_async_session,
|
get_async_session,
|
||||||
)
|
)
|
||||||
|
from app.schemas import PaginatedResponse
|
||||||
from app.schemas.surfsense_docs import (
|
from app.schemas.surfsense_docs import (
|
||||||
SurfsenseDocsChunkRead,
|
SurfsenseDocsChunkRead,
|
||||||
|
SurfsenseDocsDocumentRead,
|
||||||
SurfsenseDocsDocumentWithChunksRead,
|
SurfsenseDocsDocumentWithChunksRead,
|
||||||
)
|
)
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
|
|
@ -87,3 +89,81 @@ async def get_surfsense_doc_by_chunk_id(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"Failed to retrieve Surfsense documentation: {e!s}",
|
detail=f"Failed to retrieve Surfsense documentation: {e!s}",
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/surfsense-docs",
|
||||||
|
response_model=PaginatedResponse[SurfsenseDocsDocumentRead],
|
||||||
|
)
|
||||||
|
async def list_surfsense_docs(
|
||||||
|
page: int = 0,
|
||||||
|
page_size: int = 50,
|
||||||
|
title: str | None = None,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List all Surfsense documentation documents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: Zero-based page index.
|
||||||
|
page_size: Number of items per page (default: 50).
|
||||||
|
title: Optional title filter (case-insensitive substring match).
|
||||||
|
session: Database session (injected).
|
||||||
|
user: Current authenticated user (injected).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PaginatedResponse[SurfsenseDocsDocumentRead]: Paginated list of Surfsense docs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Base query
|
||||||
|
query = select(SurfsenseDocsDocument)
|
||||||
|
count_query = select(func.count()).select_from(SurfsenseDocsDocument)
|
||||||
|
|
||||||
|
# Filter by title if provided
|
||||||
|
if title and title.strip():
|
||||||
|
query = query.filter(SurfsenseDocsDocument.title.ilike(f"%{title}%"))
|
||||||
|
count_query = count_query.filter(
|
||||||
|
SurfsenseDocsDocument.title.ilike(f"%{title}%")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
total_result = await session.execute(count_query)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# Calculate offset
|
||||||
|
offset = page * page_size
|
||||||
|
|
||||||
|
# Get paginated results
|
||||||
|
result = await session.execute(
|
||||||
|
query.order_by(SurfsenseDocsDocument.title).offset(offset).limit(page_size)
|
||||||
|
)
|
||||||
|
docs = result.scalars().all()
|
||||||
|
|
||||||
|
# Convert to response format
|
||||||
|
items = [
|
||||||
|
SurfsenseDocsDocumentRead(
|
||||||
|
id=doc.id,
|
||||||
|
title=doc.title,
|
||||||
|
source=doc.source,
|
||||||
|
content=doc.content,
|
||||||
|
created_at=doc.created_at,
|
||||||
|
updated_at=doc.updated_at,
|
||||||
|
)
|
||||||
|
for doc in docs
|
||||||
|
]
|
||||||
|
|
||||||
|
has_more = (offset + len(items)) < total
|
||||||
|
|
||||||
|
return PaginatedResponse(
|
||||||
|
items=items,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
has_more=has_more,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to list Surfsense documentation: {e!s}",
|
||||||
|
) from e
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,11 @@ These schemas follow the assistant-ui ThreadHistoryAdapter pattern:
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from app.db import NewChatMessageRole
|
from app.db import ChatVisibility, NewChatMessageRole
|
||||||
|
|
||||||
from .base import IDModel, TimestampModel
|
from .base import IDModel, TimestampModel
|
||||||
|
|
||||||
|
|
@ -66,6 +67,8 @@ class NewChatThreadCreate(NewChatThreadBase):
|
||||||
"""Schema for creating a new thread."""
|
"""Schema for creating a new thread."""
|
||||||
|
|
||||||
search_space_id: int
|
search_space_id: int
|
||||||
|
# Visibility defaults to PRIVATE, but can be set on creation
|
||||||
|
visibility: ChatVisibility = ChatVisibility.PRIVATE
|
||||||
|
|
||||||
|
|
||||||
class NewChatThreadUpdate(BaseModel):
|
class NewChatThreadUpdate(BaseModel):
|
||||||
|
|
@ -75,12 +78,20 @@ class NewChatThreadUpdate(BaseModel):
|
||||||
archived: bool | None = None
|
archived: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NewChatThreadVisibilityUpdate(BaseModel):
|
||||||
|
"""Schema for updating thread visibility/sharing settings."""
|
||||||
|
|
||||||
|
visibility: ChatVisibility
|
||||||
|
|
||||||
|
|
||||||
class NewChatThreadRead(NewChatThreadBase, IDModel):
|
class NewChatThreadRead(NewChatThreadBase, IDModel):
|
||||||
"""
|
"""
|
||||||
Schema for reading a thread (matches assistant-ui ThreadRecord).
|
Schema for reading a thread (matches assistant-ui ThreadRecord).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
search_space_id: int
|
search_space_id: int
|
||||||
|
visibility: ChatVisibility
|
||||||
|
created_by_id: UUID | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
@ -116,6 +127,9 @@ class ThreadListItem(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
archived: bool
|
archived: bool
|
||||||
|
visibility: ChatVisibility
|
||||||
|
created_by_id: UUID | None = None
|
||||||
|
is_own_thread: bool = False # True if the current user created this thread
|
||||||
created_at: datetime = Field(alias="createdAt")
|
created_at: datetime = Field(alias="createdAt")
|
||||||
updated_at: datetime = Field(alias="updatedAt")
|
updated_at: datetime = Field(alias="updatedAt")
|
||||||
|
|
||||||
|
|
@ -163,3 +177,6 @@ class NewChatRequest(BaseModel):
|
||||||
mentioned_document_ids: list[int] | None = (
|
mentioned_document_ids: list[int] | None = (
|
||||||
None # Optional document IDs mentioned with @ in the chat
|
None # Optional document IDs mentioned with @ in the chat
|
||||||
)
|
)
|
||||||
|
mentioned_surfsense_doc_ids: list[int] | None = (
|
||||||
|
None # Optional SurfSense documentation IDs mentioned with @ in the chat
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
Schemas for Surfsense documentation.
|
Schemas for Surfsense documentation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -14,6 +16,19 @@ class SurfsenseDocsChunkRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SurfsenseDocsDocumentRead(BaseModel):
|
||||||
|
"""Schema for a Surfsense docs document (without chunks)."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
source: str
|
||||||
|
content: str
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
class SurfsenseDocsDocumentWithChunksRead(BaseModel):
|
class SurfsenseDocsDocumentWithChunksRead(BaseModel):
|
||||||
"""Schema for a Surfsense docs document with its chunks."""
|
"""Schema for a Surfsense docs document with its chunks."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ from app.agents.new_chat.llm_config import (
|
||||||
load_agent_config,
|
load_agent_config,
|
||||||
load_llm_config_from_yaml,
|
load_llm_config_from_yaml,
|
||||||
)
|
)
|
||||||
from app.db import Document
|
from app.db import Document, SurfsenseDocsDocument
|
||||||
from app.schemas.new_chat import ChatAttachment
|
from app.schemas.new_chat import ChatAttachment
|
||||||
from app.services.connector_service import ConnectorService
|
from app.services.connector_service import ConnectorService
|
||||||
from app.services.new_streaming_service import VercelStreamingService
|
from app.services.new_streaming_service import VercelStreamingService
|
||||||
|
|
@ -69,6 +69,57 @@ def format_mentioned_documents_as_context(documents: list[Document]) -> str:
|
||||||
return "\n".join(context_parts)
|
return "\n".join(context_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def format_mentioned_surfsense_docs_as_context(
|
||||||
|
documents: list[SurfsenseDocsDocument],
|
||||||
|
) -> str:
|
||||||
|
"""Format mentioned SurfSense documentation as context for the agent."""
|
||||||
|
if not documents:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
context_parts = ["<mentioned_surfsense_docs>"]
|
||||||
|
context_parts.append(
|
||||||
|
"The user has explicitly mentioned the following SurfSense documentation pages. "
|
||||||
|
"These are official documentation about how to use SurfSense and should be used to answer questions about the application. "
|
||||||
|
"Use [citation:CHUNK_ID] format for citations (e.g., [citation:doc-123])."
|
||||||
|
)
|
||||||
|
|
||||||
|
for doc in documents:
|
||||||
|
metadata_json = json.dumps({"source": doc.source}, ensure_ascii=False)
|
||||||
|
|
||||||
|
context_parts.append("<document>")
|
||||||
|
context_parts.append("<document_metadata>")
|
||||||
|
context_parts.append(f" <document_id>doc-{doc.id}</document_id>")
|
||||||
|
context_parts.append(" <document_type>SURFSENSE_DOCS</document_type>")
|
||||||
|
context_parts.append(f" <title><![CDATA[{doc.title}]]></title>")
|
||||||
|
context_parts.append(f" <url><![CDATA[{doc.source}]]></url>")
|
||||||
|
context_parts.append(
|
||||||
|
f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>"
|
||||||
|
)
|
||||||
|
context_parts.append("</document_metadata>")
|
||||||
|
context_parts.append("")
|
||||||
|
context_parts.append("<document_content>")
|
||||||
|
|
||||||
|
if hasattr(doc, "chunks") and doc.chunks:
|
||||||
|
for chunk in doc.chunks:
|
||||||
|
context_parts.append(
|
||||||
|
f" <chunk id='doc-{chunk.id}'><![CDATA[{chunk.content}]]></chunk>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
context_parts.append(
|
||||||
|
f" <chunk id='doc-0'><![CDATA[{doc.content}]]></chunk>"
|
||||||
|
)
|
||||||
|
|
||||||
|
context_parts.append("</document_content>")
|
||||||
|
context_parts.append("</document>")
|
||||||
|
context_parts.append("")
|
||||||
|
|
||||||
|
context_parts.append("</mentioned_surfsense_docs>")
|
||||||
|
|
||||||
|
return "\n".join(context_parts)
|
||||||
|
|
||||||
|
|
||||||
def extract_todos_from_deepagents(command_output) -> dict:
|
def extract_todos_from_deepagents(command_output) -> dict:
|
||||||
"""
|
"""
|
||||||
Extract todos from deepagents' TodoListMiddleware Command output.
|
Extract todos from deepagents' TodoListMiddleware Command output.
|
||||||
|
|
@ -101,6 +152,7 @@ async def stream_new_chat(
|
||||||
llm_config_id: int = -1,
|
llm_config_id: int = -1,
|
||||||
attachments: list[ChatAttachment] | None = None,
|
attachments: list[ChatAttachment] | None = None,
|
||||||
mentioned_document_ids: list[int] | None = None,
|
mentioned_document_ids: list[int] | None = None,
|
||||||
|
mentioned_surfsense_doc_ids: list[int] | None = None,
|
||||||
) -> AsyncGenerator[str, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""
|
"""
|
||||||
Stream chat responses from the new SurfSense deep agent.
|
Stream chat responses from the new SurfSense deep agent.
|
||||||
|
|
@ -118,6 +170,7 @@ async def stream_new_chat(
|
||||||
messages: Optional chat history from frontend (list of ChatMessage)
|
messages: Optional chat history from frontend (list of ChatMessage)
|
||||||
attachments: Optional attachments with extracted content
|
attachments: Optional attachments with extracted content
|
||||||
mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat
|
mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat
|
||||||
|
mentioned_surfsense_doc_ids: Optional list of SurfSense doc IDs mentioned with @ in the chat
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
str: SSE formatted response strings
|
str: SSE formatted response strings
|
||||||
|
|
@ -208,7 +261,21 @@ async def stream_new_chat(
|
||||||
)
|
)
|
||||||
mentioned_documents = list(result.scalars().all())
|
mentioned_documents = list(result.scalars().all())
|
||||||
|
|
||||||
# Format the user query with context (attachments + mentioned documents)
|
# Fetch mentioned SurfSense docs if any
|
||||||
|
mentioned_surfsense_docs: list[SurfsenseDocsDocument] = []
|
||||||
|
if mentioned_surfsense_doc_ids:
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(SurfsenseDocsDocument)
|
||||||
|
.options(selectinload(SurfsenseDocsDocument.chunks))
|
||||||
|
.filter(
|
||||||
|
SurfsenseDocsDocument.id.in_(mentioned_surfsense_doc_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mentioned_surfsense_docs = list(result.scalars().all())
|
||||||
|
|
||||||
|
# Format the user query with context (attachments + mentioned documents + surfsense docs)
|
||||||
final_query = user_query
|
final_query = user_query
|
||||||
context_parts = []
|
context_parts = []
|
||||||
|
|
||||||
|
|
@ -220,6 +287,11 @@ async def stream_new_chat(
|
||||||
format_mentioned_documents_as_context(mentioned_documents)
|
format_mentioned_documents_as_context(mentioned_documents)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if mentioned_surfsense_docs:
|
||||||
|
context_parts.append(
|
||||||
|
format_mentioned_surfsense_docs_as_context(mentioned_surfsense_docs)
|
||||||
|
)
|
||||||
|
|
||||||
if context_parts:
|
if context_parts:
|
||||||
context = "\n\n".join(context_parts)
|
context = "\n\n".join(context_parts)
|
||||||
final_query = f"{context}\n\n<user_query>{user_query}</user_query>"
|
final_query = f"{context}\n\n<user_query>{user_query}</user_query>"
|
||||||
|
|
@ -296,13 +368,13 @@ async def stream_new_chat(
|
||||||
last_active_step_id = analyze_step_id
|
last_active_step_id = analyze_step_id
|
||||||
|
|
||||||
# Determine step title and action verb based on context
|
# Determine step title and action verb based on context
|
||||||
if attachments and mentioned_documents:
|
if attachments and (mentioned_documents or mentioned_surfsense_docs):
|
||||||
last_active_step_title = "Analyzing your content"
|
last_active_step_title = "Analyzing your content"
|
||||||
action_verb = "Reading"
|
action_verb = "Reading"
|
||||||
elif attachments:
|
elif attachments:
|
||||||
last_active_step_title = "Reading your content"
|
last_active_step_title = "Reading your content"
|
||||||
action_verb = "Reading"
|
action_verb = "Reading"
|
||||||
elif mentioned_documents:
|
elif mentioned_documents or mentioned_surfsense_docs:
|
||||||
last_active_step_title = "Analyzing referenced content"
|
last_active_step_title = "Analyzing referenced content"
|
||||||
action_verb = "Analyzing"
|
action_verb = "Analyzing"
|
||||||
else:
|
else:
|
||||||
|
|
@ -342,6 +414,19 @@ async def stream_new_chat(
|
||||||
else:
|
else:
|
||||||
processing_parts.append(f"[{len(doc_names)} documents]")
|
processing_parts.append(f"[{len(doc_names)} documents]")
|
||||||
|
|
||||||
|
# Add mentioned SurfSense docs inline
|
||||||
|
if mentioned_surfsense_docs:
|
||||||
|
doc_names = []
|
||||||
|
for doc in mentioned_surfsense_docs:
|
||||||
|
title = doc.title
|
||||||
|
if len(title) > 30:
|
||||||
|
title = title[:27] + "..."
|
||||||
|
doc_names.append(title)
|
||||||
|
if len(doc_names) == 1:
|
||||||
|
processing_parts.append(f"[📖 {doc_names[0]}]")
|
||||||
|
else:
|
||||||
|
processing_parts.append(f"[📖 {len(doc_names)} docs]")
|
||||||
|
|
||||||
last_active_step_items = [f"{action_verb}: {' '.join(processing_parts)}"]
|
last_active_step_items = [f"{action_verb}: {' '.join(processing_parts)}"]
|
||||||
|
|
||||||
yield streaming_service.format_thinking_step(
|
yield streaming_service.format_thinking_step(
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export function DocumentsFilters({
|
||||||
columnVisibility,
|
columnVisibility,
|
||||||
onToggleColumn,
|
onToggleColumn,
|
||||||
}: {
|
}: {
|
||||||
typeCounts: Record<DocumentTypeEnum, number>;
|
typeCounts: Partial<Record<DocumentTypeEnum, number>>;
|
||||||
selectedIds: Set<number>;
|
selectedIds: Set<number>;
|
||||||
onSearch: (v: string) => void;
|
onSearch: (v: string) => void;
|
||||||
searchValue: string;
|
searchValue: string;
|
||||||
|
|
|
||||||
|
|
@ -79,17 +79,25 @@ export function DocumentsTableShell({
|
||||||
[documents, sortKey, sortDesc]
|
[documents, sortKey, sortDesc]
|
||||||
);
|
);
|
||||||
|
|
||||||
const allSelectedOnPage = sorted.length > 0 && sorted.every((d) => selectedIds.has(d.id));
|
// Filter out SURFSENSE_DOCS for selection purposes
|
||||||
const someSelectedOnPage = sorted.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage;
|
const selectableDocs = React.useMemo(
|
||||||
|
() => sorted.filter((d) => d.document_type !== "SURFSENSE_DOCS"),
|
||||||
|
[sorted]
|
||||||
|
);
|
||||||
|
|
||||||
|
const allSelectedOnPage =
|
||||||
|
selectableDocs.length > 0 && selectableDocs.every((d) => selectedIds.has(d.id));
|
||||||
|
const someSelectedOnPage =
|
||||||
|
selectableDocs.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage;
|
||||||
|
|
||||||
const toggleAll = (checked: boolean) => {
|
const toggleAll = (checked: boolean) => {
|
||||||
const next = new Set(selectedIds);
|
const next = new Set(selectedIds);
|
||||||
if (checked)
|
if (checked)
|
||||||
sorted.forEach((d) => {
|
selectableDocs.forEach((d) => {
|
||||||
next.add(d.id);
|
next.add(d.id);
|
||||||
});
|
});
|
||||||
else
|
else
|
||||||
sorted.forEach((d) => {
|
selectableDocs.forEach((d) => {
|
||||||
next.delete(d.id);
|
next.delete(d.id);
|
||||||
});
|
});
|
||||||
setSelectedIds(next);
|
setSelectedIds(next);
|
||||||
|
|
@ -230,9 +238,10 @@ export function DocumentsTableShell({
|
||||||
const icon = getDocumentTypeIcon(doc.document_type);
|
const icon = getDocumentTypeIcon(doc.document_type);
|
||||||
const title = doc.title;
|
const title = doc.title;
|
||||||
const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title;
|
const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title;
|
||||||
|
const isSurfsenseDoc = doc.document_type === "SURFSENSE_DOCS";
|
||||||
return (
|
return (
|
||||||
<motion.tr
|
<motion.tr
|
||||||
key={doc.id}
|
key={`${doc.document_type}-${doc.id}`}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{
|
animate={{
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
|
|
@ -249,8 +258,9 @@ export function DocumentsTableShell({
|
||||||
>
|
>
|
||||||
<TableCell className="px-4 py-3">
|
<TableCell className="px-4 py-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedIds.has(doc.id)}
|
checked={selectedIds.has(doc.id) && !isSurfsenseDoc}
|
||||||
onCheckedChange={(v) => toggleOne(doc.id, !!v)}
|
onCheckedChange={(v) => !isSurfsenseDoc && toggleOne(doc.id, !!v)}
|
||||||
|
disabled={isSurfsenseDoc}
|
||||||
aria-label="Select row"
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ import type { Document } from "./types";
|
||||||
// Only FILE and NOTE document types can be edited
|
// Only FILE and NOTE document types can be edited
|
||||||
const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const;
|
const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const;
|
||||||
|
|
||||||
|
// SURFSENSE_DOCS are system-managed and cannot be deleted
|
||||||
|
const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const;
|
||||||
|
|
||||||
export function RowActions({
|
export function RowActions({
|
||||||
document,
|
document,
|
||||||
deleteDocument,
|
deleteDocument,
|
||||||
|
|
@ -48,6 +51,10 @@ export function RowActions({
|
||||||
document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
|
document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isDeletable = !NON_DELETABLE_DOCUMENT_TYPES.includes(
|
||||||
|
document.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -120,6 +127,7 @@ export function RowActions({
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
{isDeletable && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -143,6 +151,7 @@ export function RowActions({
|
||||||
<p>Delete</p>
|
<p>Delete</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Actions Dropdown */}
|
{/* Mobile Actions Dropdown */}
|
||||||
|
|
@ -165,6 +174,7 @@ export function RowActions({
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
<span>Metadata</span>
|
<span>Metadata</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{isDeletable && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => setIsDeleteOpen(true)}
|
onClick={() => setIsDeleteOpen(true)}
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
|
|
@ -172,6 +182,7 @@ export function RowActions({
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
<span>Delete</span>
|
<span>Delete</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,15 @@
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { RefreshCw } from "lucide-react";
|
import { RefreshCw, SquarePlus, Upload } from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
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";
|
||||||
|
|
@ -19,7 +20,7 @@ import { DocumentsFilters } from "./components/DocumentsFilters";
|
||||||
import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell";
|
import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell";
|
||||||
import { PaginationControls } from "./components/PaginationControls";
|
import { PaginationControls } from "./components/PaginationControls";
|
||||||
import { ProcessingIndicator } from "./components/ProcessingIndicator";
|
import { ProcessingIndicator } from "./components/ProcessingIndicator";
|
||||||
import type { ColumnVisibility } from "./components/types";
|
import type { ColumnVisibility, Document } from "./components/types";
|
||||||
|
|
||||||
function useDebounced<T>(value: T, delay = 250) {
|
function useDebounced<T>(value: T, delay = 250) {
|
||||||
const [debounced, setDebounced] = useState(value);
|
const [debounced, setDebounced] = useState(value);
|
||||||
|
|
@ -34,7 +35,13 @@ export default function DocumentsTable() {
|
||||||
const t = useTranslations("documents");
|
const t = useTranslations("documents");
|
||||||
const id = useId();
|
const id = useId();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
const searchSpaceId = Number(params.search_space_id);
|
const searchSpaceId = Number(params.search_space_id);
|
||||||
|
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
||||||
|
|
||||||
|
const handleNewNote = useCallback(() => {
|
||||||
|
router.push(`/dashboard/${searchSpaceId}/editor/new`);
|
||||||
|
}, [router, searchSpaceId]);
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const debouncedSearch = useDebounced(search, 250);
|
const debouncedSearch = useDebounced(search, 250);
|
||||||
|
|
@ -50,33 +57,42 @@ export default function DocumentsTable() {
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("title");
|
const [sortKey, setSortKey] = useState<SortKey>("title");
|
||||||
const [sortDesc, setSortDesc] = useState(false);
|
const [sortDesc, setSortDesc] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
const { data: typeCounts } = useAtomValue(documentTypeCountsAtom);
|
const { data: rawTypeCounts } = useAtomValue(documentTypeCountsAtom);
|
||||||
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
||||||
|
|
||||||
// Build query parameters for fetching documents
|
// Filter out SURFSENSE_DOCS from active types for regular documents API
|
||||||
|
const regularDocumentTypes = useMemo(
|
||||||
|
() => activeTypes.filter((t) => t !== "SURFSENSE_DOCS"),
|
||||||
|
[activeTypes]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if only SURFSENSE_DOCS is selected (skip regular docs query)
|
||||||
|
const onlySurfsenseDocsSelected = activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS";
|
||||||
|
|
||||||
|
// Build query parameters for fetching documents (excluding SURFSENSE_DOCS type)
|
||||||
const queryParams = useMemo(
|
const queryParams = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
page: pageIndex,
|
page: pageIndex,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
...(activeTypes.length > 0 && { document_types: activeTypes }),
|
...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }),
|
||||||
}),
|
}),
|
||||||
[searchSpaceId, pageIndex, pageSize, activeTypes]
|
[searchSpaceId, pageIndex, pageSize, regularDocumentTypes]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build search query parameters
|
// Build search query parameters (excluding SURFSENSE_DOCS type)
|
||||||
const searchQueryParams = useMemo(
|
const searchQueryParams = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
page: pageIndex,
|
page: pageIndex,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
title: debouncedSearch.trim(),
|
title: debouncedSearch.trim(),
|
||||||
...(activeTypes.length > 0 && { document_types: activeTypes }),
|
...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }),
|
||||||
}),
|
}),
|
||||||
[searchSpaceId, pageIndex, pageSize, activeTypes, debouncedSearch]
|
[searchSpaceId, pageIndex, pageSize, regularDocumentTypes, debouncedSearch]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use query for fetching documents
|
// Use query for fetching documents (disabled when only SURFSENSE_DOCS is selected)
|
||||||
const {
|
const {
|
||||||
data: documentsResponse,
|
data: documentsResponse,
|
||||||
isLoading: isDocumentsLoading,
|
isLoading: isDocumentsLoading,
|
||||||
|
|
@ -86,10 +102,10 @@ export default function DocumentsTable() {
|
||||||
queryKey: cacheKeys.documents.globalQueryParams(queryParams),
|
queryKey: cacheKeys.documents.globalQueryParams(queryParams),
|
||||||
queryFn: () => documentsApiService.getDocuments({ queryParams }),
|
queryFn: () => documentsApiService.getDocuments({ queryParams }),
|
||||||
staleTime: 3 * 60 * 1000, // 3 minutes
|
staleTime: 3 * 60 * 1000, // 3 minutes
|
||||||
enabled: !!searchSpaceId && !debouncedSearch.trim(),
|
enabled: !!searchSpaceId && !debouncedSearch.trim() && !onlySurfsenseDocsSelected,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use query for searching documents
|
// Use query for searching documents (disabled when only SURFSENSE_DOCS is selected)
|
||||||
const {
|
const {
|
||||||
data: searchResponse,
|
data: searchResponse,
|
||||||
isLoading: isSearchLoading,
|
isLoading: isSearchLoading,
|
||||||
|
|
@ -99,16 +115,111 @@ export default function DocumentsTable() {
|
||||||
queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams),
|
queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams),
|
||||||
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
|
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
|
||||||
staleTime: 3 * 60 * 1000, // 3 minutes
|
staleTime: 3 * 60 * 1000, // 3 minutes
|
||||||
enabled: !!searchSpaceId && !!debouncedSearch.trim(),
|
enabled: !!searchSpaceId && !!debouncedSearch.trim() && !onlySurfsenseDocsSelected,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Determine if we should show SurfSense docs (when no type filter or SURFSENSE_DOCS is selected)
|
||||||
|
const showSurfsenseDocs =
|
||||||
|
activeTypes.length === 0 || activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum);
|
||||||
|
|
||||||
|
// Use query for fetching SurfSense docs
|
||||||
|
const {
|
||||||
|
data: surfsenseDocsResponse,
|
||||||
|
isLoading: isSurfsenseDocsLoading,
|
||||||
|
refetch: refetchSurfsenseDocs,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["surfsense-docs", debouncedSearch, pageIndex, pageSize],
|
||||||
|
queryFn: () =>
|
||||||
|
documentsApiService.getSurfsenseDocs({
|
||||||
|
queryParams: {
|
||||||
|
page: pageIndex,
|
||||||
|
page_size: pageSize,
|
||||||
|
title: debouncedSearch.trim() || undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
staleTime: 3 * 60 * 1000, // 3 minutes
|
||||||
|
enabled: showSurfsenseDocs,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform SurfSense docs to match the Document type
|
||||||
|
const surfsenseDocsAsDocuments: Document[] = useMemo(() => {
|
||||||
|
if (!surfsenseDocsResponse?.items) return [];
|
||||||
|
return surfsenseDocsResponse.items.map((doc) => ({
|
||||||
|
id: doc.id,
|
||||||
|
title: doc.title,
|
||||||
|
document_type: "SURFSENSE_DOCS",
|
||||||
|
document_metadata: { source: doc.source },
|
||||||
|
content: doc.content,
|
||||||
|
created_at: doc.created_at || doc.updated_at || new Date().toISOString(),
|
||||||
|
search_space_id: -1, // Special value for global docs
|
||||||
|
}));
|
||||||
|
}, [surfsenseDocsResponse]);
|
||||||
|
|
||||||
|
// Merge type counts with SURFSENSE_DOCS count
|
||||||
|
const typeCounts = useMemo(() => {
|
||||||
|
const counts = { ...(rawTypeCounts || {}) };
|
||||||
|
if (surfsenseDocsResponse?.total) {
|
||||||
|
counts.SURFSENSE_DOCS = surfsenseDocsResponse.total;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [rawTypeCounts, surfsenseDocsResponse?.total]);
|
||||||
|
|
||||||
// Extract documents and total based on search state
|
// Extract documents and total based on search state
|
||||||
const documents = debouncedSearch.trim()
|
const regularDocuments = debouncedSearch.trim()
|
||||||
? searchResponse?.items || []
|
? searchResponse?.items || []
|
||||||
: documentsResponse?.items || [];
|
: documentsResponse?.items || [];
|
||||||
const total = debouncedSearch.trim() ? searchResponse?.total || 0 : documentsResponse?.total || 0;
|
const regularTotal = debouncedSearch.trim()
|
||||||
const loading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading;
|
? searchResponse?.total || 0
|
||||||
const error = debouncedSearch.trim() ? searchError : documentsError;
|
: documentsResponse?.total || 0;
|
||||||
|
|
||||||
|
// Merge regular documents with SurfSense docs
|
||||||
|
const documents = useMemo(() => {
|
||||||
|
// If filtering by type and not including SURFSENSE_DOCS, only show regular docs
|
||||||
|
if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) {
|
||||||
|
return regularDocuments;
|
||||||
|
}
|
||||||
|
// If filtering only by SURFSENSE_DOCS, only show surfsense docs
|
||||||
|
if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") {
|
||||||
|
return surfsenseDocsAsDocuments;
|
||||||
|
}
|
||||||
|
// Otherwise, merge both (surfsense docs first)
|
||||||
|
return [...surfsenseDocsAsDocuments, ...regularDocuments];
|
||||||
|
}, [regularDocuments, surfsenseDocsAsDocuments, activeTypes]);
|
||||||
|
|
||||||
|
const total = useMemo(() => {
|
||||||
|
if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) {
|
||||||
|
return regularTotal;
|
||||||
|
}
|
||||||
|
if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") {
|
||||||
|
return surfsenseDocsResponse?.total || 0;
|
||||||
|
}
|
||||||
|
return regularTotal + (surfsenseDocsResponse?.total || 0);
|
||||||
|
}, [regularTotal, surfsenseDocsResponse?.total, activeTypes]);
|
||||||
|
|
||||||
|
const loading = useMemo(() => {
|
||||||
|
// If only SURFSENSE_DOCS selected, only check surfsense loading
|
||||||
|
if (onlySurfsenseDocsSelected) {
|
||||||
|
return isSurfsenseDocsLoading;
|
||||||
|
}
|
||||||
|
// Otherwise check both regular docs and surfsense docs loading
|
||||||
|
const regularLoading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading;
|
||||||
|
return regularLoading || (showSurfsenseDocs && isSurfsenseDocsLoading);
|
||||||
|
}, [
|
||||||
|
onlySurfsenseDocsSelected,
|
||||||
|
isSurfsenseDocsLoading,
|
||||||
|
debouncedSearch,
|
||||||
|
isSearchLoading,
|
||||||
|
isDocumentsLoading,
|
||||||
|
showSurfsenseDocs,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const error = useMemo(() => {
|
||||||
|
// If only SURFSENSE_DOCS selected, no regular docs errors
|
||||||
|
if (onlySurfsenseDocsSelected) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return debouncedSearch.trim() ? searchError : documentsError;
|
||||||
|
}, [onlySurfsenseDocsSelected, debouncedSearch, searchError, documentsError]);
|
||||||
|
|
||||||
// Display server-filtered results directly
|
// Display server-filtered results directly
|
||||||
const displayDocs = documents || [];
|
const displayDocs = documents || [];
|
||||||
|
|
@ -131,16 +242,33 @@ export default function DocumentsTable() {
|
||||||
if (isRefreshing) return;
|
if (isRefreshing) return;
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
try {
|
try {
|
||||||
|
const refetchPromises: Promise<unknown>[] = [];
|
||||||
|
// Only refetch regular documents if not in "only surfsense docs" mode
|
||||||
|
if (!onlySurfsenseDocsSelected) {
|
||||||
if (debouncedSearch.trim()) {
|
if (debouncedSearch.trim()) {
|
||||||
await refetchSearch();
|
refetchPromises.push(refetchSearch());
|
||||||
} else {
|
} else {
|
||||||
await refetchDocuments();
|
refetchPromises.push(refetchDocuments());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (showSurfsenseDocs) {
|
||||||
|
refetchPromises.push(refetchSurfsenseDocs());
|
||||||
|
}
|
||||||
|
await Promise.all(refetchPromises);
|
||||||
toast.success(t("refresh_success") || "Documents refreshed");
|
toast.success(t("refresh_success") || "Documents refreshed");
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
}
|
}
|
||||||
}, [debouncedSearch, refetchSearch, refetchDocuments, t, isRefreshing]);
|
}, [
|
||||||
|
debouncedSearch,
|
||||||
|
refetchSearch,
|
||||||
|
refetchDocuments,
|
||||||
|
refetchSurfsenseDocs,
|
||||||
|
showSurfsenseDocs,
|
||||||
|
onlySurfsenseDocsSelected,
|
||||||
|
t,
|
||||||
|
isRefreshing,
|
||||||
|
]);
|
||||||
|
|
||||||
// Set up smart polling for active tasks - only polls when tasks are in progress
|
// Set up smart polling for active tasks - only polls when tasks are in progress
|
||||||
const { summary } = useLogsSummary(searchSpaceId, 24, {
|
const { summary } = useLogsSummary(searchSpaceId, 24, {
|
||||||
|
|
@ -238,10 +366,20 @@ export default function DocumentsTable() {
|
||||||
<h2 className="text-xl md:text-2xl font-bold tracking-tight">{t("title")}</h2>
|
<h2 className="text-xl md:text-2xl font-bold tracking-tight">{t("title")}</h2>
|
||||||
<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">
|
||||||
|
<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" />
|
||||||
|
{t("create_shared_note")}
|
||||||
|
</Button>
|
||||||
<Button onClick={refreshCurrentView} variant="outline" size="sm" disabled={isRefreshing}>
|
<Button onClick={refreshCurrentView} variant="outline" size="sm" disabled={isRefreshing}>
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||||
{t("refresh")}
|
{t("refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ProcessingIndicator documentProcessorTasksCount={documentProcessorTasksCount} />
|
<ProcessingIndicator documentProcessorTasksCount={documentProcessorTasksCount} />
|
||||||
|
|
|
||||||
|
|
@ -267,21 +267,8 @@ export default function EditorPage() {
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
toast.success("Note created successfully! Reindexing in background...");
|
toast.success("Note created successfully! Reindexing in background...");
|
||||||
|
|
||||||
// Invalidate notes query to refresh the sidebar
|
// Redirect to documents page after successful save
|
||||||
queryClient.invalidateQueries({
|
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||||
queryKey: ["notes", String(searchSpaceId)],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update URL to reflect the new document ID without navigation
|
|
||||||
window.history.replaceState({}, "", `/dashboard/${searchSpaceId}/editor/${note.id}`);
|
|
||||||
// Update document state to reflect the new ID
|
|
||||||
setDocument({
|
|
||||||
document_id: note.id,
|
|
||||||
title: title,
|
|
||||||
document_type: "NOTE",
|
|
||||||
blocknote_document: editorContent,
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Existing document - save normally
|
// Existing document - save normally
|
||||||
if (!editorContent) {
|
if (!editorContent) {
|
||||||
|
|
@ -310,12 +297,8 @@ export default function EditorPage() {
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
toast.success("Document saved! Reindexing in background...");
|
toast.success("Document saved! Reindexing in background...");
|
||||||
|
|
||||||
// Invalidate notes query when updating notes to refresh the sidebar
|
// Redirect to documents page after successful save
|
||||||
if (isNote) {
|
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["notes", String(searchSpaceId)],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving document:", error);
|
console.error("Error saving document:", error);
|
||||||
|
|
@ -336,7 +319,7 @@ export default function EditorPage() {
|
||||||
if (hasUnsavedChanges) {
|
if (hasUnsavedChanges) {
|
||||||
setShowUnsavedDialog(true);
|
setShowUnsavedDialog(true);
|
||||||
} else {
|
} else {
|
||||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -346,12 +329,12 @@ export default function EditorPage() {
|
||||||
setGlobalHasUnsavedChanges(false);
|
setGlobalHasUnsavedChanges(false);
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
|
|
||||||
// If there's a pending navigation (from sidebar), use that; otherwise go back to chat
|
// If there's a pending navigation (from sidebar), use that; otherwise go back to documents
|
||||||
if (pendingNavigation) {
|
if (pendingNavigation) {
|
||||||
router.push(pendingNavigation);
|
router.push(pendingNavigation);
|
||||||
setPendingNavigation(null);
|
setPendingNavigation(null);
|
||||||
} else {
|
} else {
|
||||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -392,7 +375,7 @@ export default function EditorPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/new-chat`)}
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/documents`)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,12 @@ import {
|
||||||
} from "@/lib/chat/podcast-state";
|
} from "@/lib/chat/podcast-state";
|
||||||
import {
|
import {
|
||||||
appendMessage,
|
appendMessage,
|
||||||
|
type ChatVisibility,
|
||||||
createThread,
|
createThread,
|
||||||
|
getThreadFull,
|
||||||
getThreadMessages,
|
getThreadMessages,
|
||||||
type MessageRecord,
|
type MessageRecord,
|
||||||
|
type ThreadRecord,
|
||||||
} from "@/lib/chat/thread-persistence";
|
} from "@/lib/chat/thread-persistence";
|
||||||
import {
|
import {
|
||||||
trackChatCreated,
|
trackChatCreated,
|
||||||
|
|
@ -217,6 +220,7 @@ export default function NewChatPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isInitializing, setIsInitializing] = useState(true);
|
const [isInitializing, setIsInitializing] = useState(true);
|
||||||
const [threadId, setThreadId] = useState<number | null>(null);
|
const [threadId, setThreadId] = useState<number | null>(null);
|
||||||
|
const [currentThread, setCurrentThread] = useState<ThreadRecord | null>(null);
|
||||||
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
|
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
// Store thinking steps per message ID - kept separate from content to avoid
|
// Store thinking steps per message ID - kept separate from content to avoid
|
||||||
|
|
@ -264,19 +268,31 @@ export default function NewChatPage() {
|
||||||
// Reset all state when switching between chats to prevent stale data
|
// Reset all state when switching between chats to prevent stale data
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setThreadId(null);
|
setThreadId(null);
|
||||||
|
setCurrentThread(null);
|
||||||
setMessageThinkingSteps(new Map());
|
setMessageThinkingSteps(new Map());
|
||||||
setMentionedDocumentIds([]);
|
setMentionedDocumentIds({
|
||||||
|
surfsense_doc_ids: [],
|
||||||
|
document_ids: [],
|
||||||
|
});
|
||||||
setMentionedDocuments([]);
|
setMentionedDocuments([]);
|
||||||
setMessageDocumentsMap({});
|
setMessageDocumentsMap({});
|
||||||
clearPlanOwnerRegistry(); // Reset plan ownership for new chat
|
clearPlanOwnerRegistry(); // Reset plan ownership for new chat
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (urlChatId > 0) {
|
if (urlChatId > 0) {
|
||||||
// Thread exists - load messages
|
// Thread exists - load thread data and messages
|
||||||
setThreadId(urlChatId);
|
setThreadId(urlChatId);
|
||||||
const response = await getThreadMessages(urlChatId);
|
|
||||||
if (response.messages && response.messages.length > 0) {
|
// Load thread data (for visibility info) and messages in parallel
|
||||||
const loadedMessages = response.messages.map(convertToThreadMessage);
|
const [threadData, messagesResponse] = await Promise.all([
|
||||||
|
getThreadFull(urlChatId),
|
||||||
|
getThreadMessages(urlChatId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setCurrentThread(threadData);
|
||||||
|
|
||||||
|
if (messagesResponse.messages && messagesResponse.messages.length > 0) {
|
||||||
|
const loadedMessages = messagesResponse.messages.map(convertToThreadMessage);
|
||||||
setMessages(loadedMessages);
|
setMessages(loadedMessages);
|
||||||
|
|
||||||
// Extract and restore thinking steps from persisted messages
|
// Extract and restore thinking steps from persisted messages
|
||||||
|
|
@ -284,7 +300,7 @@ export default function NewChatPage() {
|
||||||
// Extract and restore mentioned documents from persisted messages
|
// Extract and restore mentioned documents from persisted messages
|
||||||
const restoredDocsMap: Record<string, MentionedDocumentInfo[]> = {};
|
const restoredDocsMap: Record<string, MentionedDocumentInfo[]> = {};
|
||||||
|
|
||||||
for (const msg of response.messages) {
|
for (const msg of messagesResponse.messages) {
|
||||||
if (msg.role === "assistant") {
|
if (msg.role === "assistant") {
|
||||||
const steps = extractThinkingSteps(msg.content);
|
const steps = extractThinkingSteps(msg.content);
|
||||||
if (steps.length > 0) {
|
if (steps.length > 0) {
|
||||||
|
|
@ -320,6 +336,7 @@ export default function NewChatPage() {
|
||||||
// Keep threadId as null - don't use Date.now() as it creates an invalid ID
|
// Keep threadId as null - don't use Date.now() as it creates an invalid ID
|
||||||
// that will cause 404 errors on subsequent API calls
|
// that will cause 404 errors on subsequent API calls
|
||||||
setThreadId(null);
|
setThreadId(null);
|
||||||
|
setCurrentThread(null);
|
||||||
toast.error("Failed to load chat. Please try again.");
|
toast.error("Failed to load chat. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsInitializing(false);
|
setIsInitializing(false);
|
||||||
|
|
@ -346,6 +363,19 @@ export default function NewChatPage() {
|
||||||
setIsRunning(false);
|
setIsRunning(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Handle visibility change from ChatShareButton
|
||||||
|
const handleVisibilityChange = useCallback(
|
||||||
|
(newVisibility: ChatVisibility) => {
|
||||||
|
setCurrentThread((prev) => (prev ? { ...prev, visibility: newVisibility } : null));
|
||||||
|
// Refetch all thread queries so sidebar reflects the change immediately
|
||||||
|
// Use predicate to match any query that starts with "threads"
|
||||||
|
queryClient.refetchQueries({
|
||||||
|
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[queryClient]
|
||||||
|
);
|
||||||
|
|
||||||
// Handle new message from user
|
// Handle new message from user
|
||||||
const onNew = useCallback(
|
const onNew = useCallback(
|
||||||
async (message: AppendMessage) => {
|
async (message: AppendMessage) => {
|
||||||
|
|
@ -429,7 +459,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.length > 0,
|
hasMentionedDocuments:
|
||||||
|
mentionedDocumentIds.surfsense_doc_ids.length > 0 ||
|
||||||
|
mentionedDocumentIds.document_ids.length > 0,
|
||||||
messageLength: userQuery.length,
|
messageLength: userQuery.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -627,12 +659,16 @@ export default function NewChatPage() {
|
||||||
// Extract attachment content to send with the request
|
// Extract attachment content to send with the request
|
||||||
const attachments = extractAttachmentContent(messageAttachments);
|
const attachments = extractAttachmentContent(messageAttachments);
|
||||||
|
|
||||||
// Get mentioned document IDs for context
|
// Get mentioned document IDs for context (separate fields for backend)
|
||||||
const documentIds = mentionedDocumentIds.length > 0 ? [...mentionedDocumentIds] : undefined;
|
const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0;
|
||||||
|
const hasSurfsenseDocIds = mentionedDocumentIds.surfsense_doc_ids.length > 0;
|
||||||
|
|
||||||
// Clear mentioned documents after capturing them
|
// Clear mentioned documents after capturing them
|
||||||
if (mentionedDocumentIds.length > 0) {
|
if (hasDocumentIds || hasSurfsenseDocIds) {
|
||||||
setMentionedDocumentIds([]);
|
setMentionedDocumentIds({
|
||||||
|
surfsense_doc_ids: [],
|
||||||
|
document_ids: [],
|
||||||
|
});
|
||||||
setMentionedDocuments([]);
|
setMentionedDocuments([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -648,7 +684,10 @@ export default function NewChatPage() {
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
messages: messageHistory,
|
messages: messageHistory,
|
||||||
attachments: attachments.length > 0 ? attachments : undefined,
|
attachments: attachments.length > 0 ? attachments : undefined,
|
||||||
mentioned_document_ids: documentIds,
|
mentioned_document_ids: hasDocumentIds ? mentionedDocumentIds.document_ids : undefined,
|
||||||
|
mentioned_surfsense_doc_ids: hasSurfsenseDocIds
|
||||||
|
? mentionedDocumentIds.surfsense_doc_ids
|
||||||
|
: undefined,
|
||||||
}),
|
}),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
@ -916,7 +955,13 @@ export default function NewChatPage() {
|
||||||
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden">
|
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden">
|
||||||
<Thread
|
<Thread
|
||||||
messageThinkingSteps={messageThinkingSteps}
|
messageThinkingSteps={messageThinkingSteps}
|
||||||
header={<ChatHeader searchSpaceId={searchSpaceId} />}
|
header={
|
||||||
|
<ChatHeader
|
||||||
|
searchSpaceId={searchSpaceId}
|
||||||
|
thread={currentThread}
|
||||||
|
onThreadVisibilityChange={handleVisibilityChange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AssistantRuntimeProvider>
|
</AssistantRuntimeProvider>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
|
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
|
||||||
import { ModelConfigManager } from "@/components/settings/model-config-manager";
|
import { ModelConfigManager } from "@/components/settings/model-config-manager";
|
||||||
|
|
@ -23,28 +24,28 @@ import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface SettingsNavItem {
|
interface SettingsNavItem {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
labelKey: string;
|
||||||
description: string;
|
descriptionKey: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingsNavItems: SettingsNavItem[] = [
|
const settingsNavItems: SettingsNavItem[] = [
|
||||||
{
|
{
|
||||||
id: "models",
|
id: "models",
|
||||||
label: "Agent Configs",
|
labelKey: "nav_agent_configs",
|
||||||
description: "LLM models with prompts & citations",
|
descriptionKey: "nav_agent_configs_desc",
|
||||||
icon: Bot,
|
icon: Bot,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "roles",
|
id: "roles",
|
||||||
label: "Role Assignments",
|
labelKey: "nav_role_assignments",
|
||||||
description: "Assign configs to agent roles",
|
descriptionKey: "nav_role_assignments_desc",
|
||||||
icon: Brain,
|
icon: Brain,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "prompts",
|
id: "prompts",
|
||||||
label: "System Instructions",
|
labelKey: "nav_system_instructions",
|
||||||
description: "SearchSpace-wide AI instructions",
|
descriptionKey: "nav_system_instructions_desc",
|
||||||
icon: MessageSquare,
|
icon: MessageSquare,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -62,6 +63,8 @@ function SettingsSidebar({
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("searchSpaceSettings");
|
||||||
|
|
||||||
const handleNavClick = (sectionId: string) => {
|
const handleNavClick = (sectionId: string) => {
|
||||||
onSectionChange(sectionId);
|
onSectionChange(sectionId);
|
||||||
onClose(); // Close sidebar on mobile after selection
|
onClose(); // Close sidebar on mobile after selection
|
||||||
|
|
@ -94,23 +97,29 @@ function SettingsSidebar({
|
||||||
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header with back button */}
|
{/* Header with title */}
|
||||||
<div className="p-4 flex items-center justify-between">
|
<div className="p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={onBackToApp}
|
onClick={onBackToApp}
|
||||||
className="flex-1 justify-start gap-3 h-11 px-3 hover:bg-muted group"
|
className="justify-start gap-3 h-11 px-3 hover:bg-muted group"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 group-hover:bg-primary/20 transition-colors">
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 group-hover:bg-primary/20 transition-colors">
|
||||||
<ArrowLeft className="h-4 w-4 text-primary" />
|
<ArrowLeft className="h-4 w-4 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium">Back to app</span>
|
<span className="font-medium">{t("back_to_app")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
{/* Mobile close button */}
|
{/* Mobile close button */}
|
||||||
<Button variant="ghost" size="icon" onClick={onClose} className="md:hidden h-9 w-9">
|
<Button variant="ghost" size="icon" onClick={onClose} className="md:hidden h-9 w-9">
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Settings Title */}
|
||||||
|
<div className="px-3">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Navigation Items */}
|
{/* Navigation Items */}
|
||||||
<nav className="flex-1 px-3 py-2 space-y-1 overflow-y-auto">
|
<nav className="flex-1 px-3 py-2 space-y-1 overflow-y-auto">
|
||||||
|
|
@ -159,9 +168,11 @@ function SettingsSidebar({
|
||||||
isActive ? "text-foreground" : "text-muted-foreground"
|
isActive ? "text-foreground" : "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{t(item.labelKey)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 truncate">
|
||||||
|
{t(item.descriptionKey)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground/70 truncate">{item.description}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -175,11 +186,6 @@ function SettingsSidebar({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="p-4">
|
|
||||||
<p className="text-xs text-muted-foreground text-center">Search Space Settings</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -194,6 +200,7 @@ function SettingsContent({
|
||||||
searchSpaceId: number;
|
searchSpaceId: number;
|
||||||
onMenuClick: () => void;
|
onMenuClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("searchSpaceSettings");
|
||||||
const activeItem = settingsNavItems.find((item) => item.id === activeSection);
|
const activeItem = settingsNavItems.find((item) => item.id === activeSection);
|
||||||
const Icon = activeItem?.icon || Settings;
|
const Icon = activeItem?.icon || Settings;
|
||||||
|
|
||||||
|
|
@ -236,7 +243,7 @@ function SettingsContent({
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-lg md:text-2xl font-bold tracking-tight truncate">
|
<h1 className="text-lg md:text-2xl font-bold tracking-tight truncate">
|
||||||
{activeItem?.label}
|
{activeItem ? t(activeItem.labelKey) : ""}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -75,11 +75,13 @@ function UserSettingsSidebar({
|
||||||
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between p-4">
|
{/* Header with title */}
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={onBackToApp}
|
onClick={onBackToApp}
|
||||||
className="group h-11 flex-1 justify-start gap-3 px-3 hover:bg-muted"
|
className="group h-11 justify-start gap-3 px-3 hover:bg-muted"
|
||||||
>
|
>
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 transition-colors group-hover:bg-primary/20">
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 transition-colors group-hover:bg-primary/20">
|
||||||
<ArrowLeft className="h-4 w-4 text-primary" />
|
<ArrowLeft className="h-4 w-4 text-primary" />
|
||||||
|
|
@ -90,6 +92,11 @@ function UserSettingsSidebar({
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Settings Title */}
|
||||||
|
<div className="px-3">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-2">
|
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-2">
|
||||||
{navItems.map((item, index) => {
|
{navItems.map((item, index) => {
|
||||||
|
|
@ -153,10 +160,6 @@ function UserSettingsSidebar({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4">
|
|
||||||
<p className="text-center text-xs text-muted-foreground">{t("footer")}</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,25 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import type { Document } from "@/contracts/types/document.types";
|
import type { Document, SurfsenseDocsDocument } from "@/contracts/types/document.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atom to store the IDs of documents mentioned in the current chat composer.
|
* Atom to store the IDs of documents mentioned in the current chat composer.
|
||||||
* This is used to pass document context to the backend when sending a message.
|
* This is used to pass document context to the backend when sending a message.
|
||||||
*/
|
*/
|
||||||
export const mentionedDocumentIdsAtom = atom<number[]>([]);
|
export const mentionedDocumentIdsAtom = atom<{
|
||||||
|
surfsense_doc_ids: number[];
|
||||||
|
document_ids: number[];
|
||||||
|
}>({
|
||||||
|
surfsense_doc_ids: [],
|
||||||
|
document_ids: [],
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atom to store the full document objects mentioned in the current chat composer.
|
* 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<Document[]>([]);
|
export const mentionedDocumentsAtom = atom<Pick<Document, "id" | "title" | "document_type">[]>([]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simplified document info for display purposes
|
* Simplified document info for display purposes
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,14 @@ 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(mentionedDocuments.map((doc) => doc.id));
|
setMentionedDocumentIds({
|
||||||
|
surfsense_doc_ids: mentionedDocuments
|
||||||
|
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
||||||
|
.map((doc) => doc.id),
|
||||||
|
document_ids: mentionedDocuments
|
||||||
|
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
||||||
|
.map((doc) => doc.id),
|
||||||
|
});
|
||||||
}, [mentionedDocuments, setMentionedDocumentIds]);
|
}, [mentionedDocuments, setMentionedDocumentIds]);
|
||||||
|
|
||||||
// Handle text change from inline editor - sync with assistant-ui composer
|
// Handle text change from inline editor - sync with assistant-ui composer
|
||||||
|
|
@ -119,7 +126,10 @@ export const Composer: FC = () => {
|
||||||
// Clear the editor after sending
|
// Clear the editor after sending
|
||||||
editorRef.current?.clear();
|
editorRef.current?.clear();
|
||||||
setMentionedDocuments([]);
|
setMentionedDocuments([]);
|
||||||
setMentionedDocumentIds([]);
|
setMentionedDocumentIds({
|
||||||
|
surfsense_doc_ids: [],
|
||||||
|
document_ids: [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
showDocumentPopover,
|
showDocumentPopover,
|
||||||
|
|
@ -129,41 +139,52 @@ export const Composer: FC = () => {
|
||||||
setMentionedDocumentIds,
|
setMentionedDocumentIds,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Handle document removal from inline editor
|
|
||||||
const handleDocumentRemove = useCallback(
|
const handleDocumentRemove = useCallback(
|
||||||
(docId: number) => {
|
(docId: number, docType?: string) => {
|
||||||
setMentionedDocuments((prev) => {
|
setMentionedDocuments((prev) => {
|
||||||
const updated = prev.filter((doc) => doc.id !== docId);
|
const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType));
|
||||||
// Immediately sync document IDs to avoid race conditions
|
setMentionedDocumentIds({
|
||||||
setMentionedDocumentIds(updated.map((doc) => doc.id));
|
surfsense_doc_ids: updated
|
||||||
|
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
||||||
|
.map((doc) => doc.id),
|
||||||
|
document_ids: updated
|
||||||
|
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
||||||
|
.map((doc) => doc.id),
|
||||||
|
});
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setMentionedDocuments, setMentionedDocumentIds]
|
[setMentionedDocuments, setMentionedDocumentIds]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle document selection from picker
|
|
||||||
const handleDocumentsMention = useCallback(
|
const handleDocumentsMention = useCallback(
|
||||||
(documents: Document[]) => {
|
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
|
||||||
// Insert chips into the inline editor for each new document
|
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
|
||||||
const existingIds = new Set(mentionedDocuments.map((d) => d.id));
|
const newDocs = documents.filter(
|
||||||
const newDocs = documents.filter((doc) => !existingIds.has(doc.id));
|
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
|
||||||
|
);
|
||||||
|
|
||||||
for (const doc of newDocs) {
|
for (const doc of newDocs) {
|
||||||
editorRef.current?.insertDocumentChip(doc);
|
editorRef.current?.insertDocumentChip(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update mentioned documents state
|
|
||||||
setMentionedDocuments((prev) => {
|
setMentionedDocuments((prev) => {
|
||||||
const existingIdSet = new Set(prev.map((d) => d.id));
|
const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`));
|
||||||
const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id));
|
const uniqueNewDocs = documents.filter(
|
||||||
|
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
|
||||||
|
);
|
||||||
const updated = [...prev, ...uniqueNewDocs];
|
const updated = [...prev, ...uniqueNewDocs];
|
||||||
// Immediately sync document IDs to avoid race conditions
|
setMentionedDocumentIds({
|
||||||
setMentionedDocumentIds(updated.map((doc) => doc.id));
|
surfsense_doc_ids: updated
|
||||||
|
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
||||||
|
.map((doc) => doc.id),
|
||||||
|
document_ids: updated
|
||||||
|
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
||||||
|
.map((doc) => doc.id),
|
||||||
|
});
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset mention query but keep popover open for more selections
|
|
||||||
setMentionQuery("");
|
setMentionQuery("");
|
||||||
},
|
},
|
||||||
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
|
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export interface InlineMentionEditorRef {
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
getText: () => string;
|
getText: () => string;
|
||||||
getMentionedDocuments: () => MentionedDocument[];
|
getMentionedDocuments: () => MentionedDocument[];
|
||||||
insertDocumentChip: (doc: Document) => void;
|
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InlineMentionEditorProps {
|
interface InlineMentionEditorProps {
|
||||||
|
|
@ -34,7 +34,7 @@ interface InlineMentionEditorProps {
|
||||||
onMentionClose?: () => void;
|
onMentionClose?: () => void;
|
||||||
onSubmit?: () => void;
|
onSubmit?: () => void;
|
||||||
onChange?: (text: string, docs: MentionedDocument[]) => void;
|
onChange?: (text: string, docs: MentionedDocument[]) => void;
|
||||||
onDocumentRemove?: (docId: number) => void;
|
onDocumentRemove?: (docId: number, docType?: string) => void;
|
||||||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -44,6 +44,7 @@ interface InlineMentionEditorProps {
|
||||||
// Unique data attribute to identify chip elements
|
// Unique data attribute to identify chip elements
|
||||||
const CHIP_DATA_ATTR = "data-mention-chip";
|
const CHIP_DATA_ATTR = "data-mention-chip";
|
||||||
const CHIP_ID_ATTR = "data-mention-id";
|
const CHIP_ID_ATTR = "data-mention-id";
|
||||||
|
const CHIP_DOCTYPE_ATTR = "data-mention-doctype";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type guard to check if a node is a chip element
|
* Type guard to check if a node is a chip element
|
||||||
|
|
@ -66,6 +67,13 @@ function getChipId(element: Element): number | null {
|
||||||
return Number.isNaN(id) ? null : id;
|
return Number.isNaN(id) ? null : id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chip document type from element attribute
|
||||||
|
*/
|
||||||
|
function getChipDocType(element: Element): string {
|
||||||
|
return element.getAttribute(CHIP_DOCTYPE_ATTR) ?? "UNKNOWN";
|
||||||
|
}
|
||||||
|
|
||||||
export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMentionEditorProps>(
|
export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMentionEditorProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
|
|
@ -84,15 +92,17 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
) => {
|
) => {
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
const [isEmpty, setIsEmpty] = useState(true);
|
const [isEmpty, setIsEmpty] = useState(true);
|
||||||
const [mentionedDocs, setMentionedDocs] = useState<Map<number, MentionedDocument>>(
|
const [mentionedDocs, setMentionedDocs] = useState<Map<string, MentionedDocument>>(
|
||||||
() => new Map(initialDocuments.map((d) => [d.id, d]))
|
() => new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))
|
||||||
);
|
);
|
||||||
const isComposingRef = useRef(false);
|
const isComposingRef = useRef(false);
|
||||||
|
|
||||||
// Sync initial documents
|
// Sync initial documents
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialDocuments.length > 0) {
|
if (initialDocuments.length > 0) {
|
||||||
setMentionedDocs(new Map(initialDocuments.map((d) => [d.id, d])));
|
setMentionedDocs(
|
||||||
|
new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [initialDocuments]);
|
}, [initialDocuments]);
|
||||||
|
|
||||||
|
|
@ -153,6 +163,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
const chip = document.createElement("span");
|
const chip = document.createElement("span");
|
||||||
chip.setAttribute(CHIP_DATA_ATTR, "true");
|
chip.setAttribute(CHIP_DATA_ATTR, "true");
|
||||||
chip.setAttribute(CHIP_ID_ATTR, String(doc.id));
|
chip.setAttribute(CHIP_ID_ATTR, String(doc.id));
|
||||||
|
chip.setAttribute(CHIP_DOCTYPE_ATTR, doc.document_type ?? "UNKNOWN");
|
||||||
chip.contentEditable = "false";
|
chip.contentEditable = "false";
|
||||||
chip.className =
|
chip.className =
|
||||||
"inline-flex items-center gap-0.5 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary border border-primary/10 select-none";
|
"inline-flex items-center gap-0.5 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary border border-primary/10 select-none";
|
||||||
|
|
@ -175,13 +186,14 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
chip.remove();
|
chip.remove();
|
||||||
|
const docKey = `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
|
||||||
setMentionedDocs((prev) => {
|
setMentionedDocs((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.delete(doc.id);
|
next.delete(docKey);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
// Notify parent that a document was removed
|
// Notify parent that a document was removed
|
||||||
onDocumentRemove?.(doc.id);
|
onDocumentRemove?.(doc.id, doc.document_type);
|
||||||
focusAtEnd();
|
focusAtEnd();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -195,7 +207,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
|
|
||||||
// Insert a document chip at the current cursor position
|
// Insert a document chip at the current cursor position
|
||||||
const insertDocumentChip = useCallback(
|
const insertDocumentChip = useCallback(
|
||||||
(doc: Document) => {
|
(doc: Pick<Document, "id" | "title" | "document_type">) => {
|
||||||
if (!editorRef.current) return;
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
// Validate required fields for type safety
|
// Validate required fields for type safety
|
||||||
|
|
@ -210,8 +222,9 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
document_type: doc.document_type,
|
document_type: doc.document_type,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add to mentioned docs map
|
// Add to mentioned docs map using unique key
|
||||||
setMentionedDocs((prev) => new Map(prev).set(doc.id, mentionDoc));
|
const docKey = `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
|
||||||
|
setMentionedDocs((prev) => new Map(prev).set(docKey, mentionDoc));
|
||||||
|
|
||||||
// Find and remove the @query text
|
// Find and remove the @query text
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
|
|
@ -413,15 +426,17 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
if (isChipElement(prevSibling)) {
|
if (isChipElement(prevSibling)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const chipId = getChipId(prevSibling);
|
const chipId = getChipId(prevSibling);
|
||||||
|
const chipDocType = getChipDocType(prevSibling);
|
||||||
if (chipId !== null) {
|
if (chipId !== null) {
|
||||||
prevSibling.remove();
|
prevSibling.remove();
|
||||||
|
const chipKey = `${chipDocType}:${chipId}`;
|
||||||
setMentionedDocs((prev) => {
|
setMentionedDocs((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.delete(chipId);
|
next.delete(chipKey);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
// Notify parent that a document was removed
|
// Notify parent that a document was removed
|
||||||
onDocumentRemove?.(chipId);
|
onDocumentRemove?.(chipId, chipDocType);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -448,15 +463,17 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
if (isChipElement(prevChild)) {
|
if (isChipElement(prevChild)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const chipId = getChipId(prevChild);
|
const chipId = getChipId(prevChild);
|
||||||
|
const chipDocType = getChipDocType(prevChild);
|
||||||
if (chipId !== null) {
|
if (chipId !== null) {
|
||||||
prevChild.remove();
|
prevChild.remove();
|
||||||
|
const chipKey = `${chipDocType}:${chipId}`;
|
||||||
setMentionedDocs((prev) => {
|
setMentionedDocs((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.delete(chipId);
|
next.delete(chipKey);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
// Notify parent that a document was removed
|
// Notify parent that a document was removed
|
||||||
onDocumentRemove?.(chipId);
|
onDocumentRemove?.(chipId, chipDocType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button
|
||||||
import { cn } from "@/lib/utils";
|
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]
|
||||||
const CITATION_REGEX = /\[citation:(doc-)?(\d+)\]/g;
|
// Also matches Chinese brackets 【】 and handles zero-width spaces that LLM sometimes inserts
|
||||||
|
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
|
||||||
|
|
@ -90,10 +91,6 @@ function parseTextWithCitations(text: string): ReactNode[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
const MarkdownTextImpl = () => {
|
const MarkdownTextImpl = () => {
|
||||||
// Reset citation counter at the start of each render
|
|
||||||
// This ensures consistent numbering as the message streams in
|
|
||||||
resetCitationCounter();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MarkdownTextPrimitive
|
<MarkdownTextPrimitive
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,14 @@ 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(mentionedDocuments.map((doc) => doc.id));
|
setMentionedDocumentIds({
|
||||||
|
surfsense_doc_ids: mentionedDocuments
|
||||||
|
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
||||||
|
.map((doc) => doc.id),
|
||||||
|
document_ids: mentionedDocuments
|
||||||
|
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
||||||
|
.map((doc) => doc.id),
|
||||||
|
});
|
||||||
}, [mentionedDocuments, setMentionedDocumentIds]);
|
}, [mentionedDocuments, setMentionedDocumentIds]);
|
||||||
|
|
||||||
// Handle text change from inline editor - sync with assistant-ui composer
|
// Handle text change from inline editor - sync with assistant-ui composer
|
||||||
|
|
@ -295,7 +302,10 @@ const Composer: FC = () => {
|
||||||
// Clear the editor after sending
|
// Clear the editor after sending
|
||||||
editorRef.current?.clear();
|
editorRef.current?.clear();
|
||||||
setMentionedDocuments([]);
|
setMentionedDocuments([]);
|
||||||
setMentionedDocumentIds([]);
|
setMentionedDocumentIds({
|
||||||
|
surfsense_doc_ids: [],
|
||||||
|
document_ids: [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
showDocumentPopover,
|
showDocumentPopover,
|
||||||
|
|
@ -305,41 +315,52 @@ const Composer: FC = () => {
|
||||||
setMentionedDocumentIds,
|
setMentionedDocumentIds,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Handle document removal from inline editor
|
|
||||||
const handleDocumentRemove = useCallback(
|
const handleDocumentRemove = useCallback(
|
||||||
(docId: number) => {
|
(docId: number, docType?: string) => {
|
||||||
setMentionedDocuments((prev) => {
|
setMentionedDocuments((prev) => {
|
||||||
const updated = prev.filter((doc) => doc.id !== docId);
|
const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType));
|
||||||
// Immediately sync document IDs to avoid race conditions
|
setMentionedDocumentIds({
|
||||||
setMentionedDocumentIds(updated.map((doc) => doc.id));
|
surfsense_doc_ids: updated
|
||||||
|
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
||||||
|
.map((doc) => doc.id),
|
||||||
|
document_ids: updated
|
||||||
|
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
||||||
|
.map((doc) => doc.id),
|
||||||
|
});
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setMentionedDocuments, setMentionedDocumentIds]
|
[setMentionedDocuments, setMentionedDocumentIds]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle document selection from picker
|
|
||||||
const handleDocumentsMention = useCallback(
|
const handleDocumentsMention = useCallback(
|
||||||
(documents: Document[]) => {
|
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
|
||||||
// Insert chips into the inline editor for each new document
|
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
|
||||||
const existingIds = new Set(mentionedDocuments.map((d) => d.id));
|
const newDocs = documents.filter(
|
||||||
const newDocs = documents.filter((doc) => !existingIds.has(doc.id));
|
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
|
||||||
|
);
|
||||||
|
|
||||||
for (const doc of newDocs) {
|
for (const doc of newDocs) {
|
||||||
editorRef.current?.insertDocumentChip(doc);
|
editorRef.current?.insertDocumentChip(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update mentioned documents state
|
|
||||||
setMentionedDocuments((prev) => {
|
setMentionedDocuments((prev) => {
|
||||||
const existingIdSet = new Set(prev.map((d) => d.id));
|
const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`));
|
||||||
const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id));
|
const uniqueNewDocs = documents.filter(
|
||||||
|
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
|
||||||
|
);
|
||||||
const updated = [...prev, ...uniqueNewDocs];
|
const updated = [...prev, ...uniqueNewDocs];
|
||||||
// Immediately sync document IDs to avoid race conditions
|
setMentionedDocumentIds({
|
||||||
setMentionedDocumentIds(updated.map((doc) => doc.id));
|
surfsense_doc_ids: updated
|
||||||
|
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
||||||
|
.map((doc) => doc.id),
|
||||||
|
document_ids: updated
|
||||||
|
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
||||||
|
.map((doc) => doc.id),
|
||||||
|
});
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset mention query but keep popover open for more selections
|
|
||||||
setMentionQuery("");
|
setMentionQuery("");
|
||||||
},
|
},
|
||||||
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
|
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
|
||||||
|
|
@ -640,7 +661,7 @@ const UserMessage: FC = () => {
|
||||||
{/* Mentioned documents as chips */}
|
{/* Mentioned documents as chips */}
|
||||||
{mentionedDocs?.map((doc) => (
|
{mentionedDocs?.map((doc) => (
|
||||||
<span
|
<span
|
||||||
key={doc.id}
|
key={`${doc.document_type}:${doc.id}`}
|
||||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20"
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20"
|
||||||
title={doc.title}
|
title={doc.title}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export const UserMessage: FC = () => {
|
||||||
{/* Mentioned documents as chips */}
|
{/* Mentioned documents as chips */}
|
||||||
{mentionedDocs?.map((doc) => (
|
{mentionedDocs?.map((doc) => (
|
||||||
<span
|
<span
|
||||||
key={doc.id}
|
key={`${doc.document_type}:${doc.id}`}
|
||||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20"
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20"
|
||||||
title={doc.title}
|
title={doc.title}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,12 @@ export type {
|
||||||
ChatItem,
|
ChatItem,
|
||||||
IconRailProps,
|
IconRailProps,
|
||||||
NavItem,
|
NavItem,
|
||||||
NoteItem,
|
|
||||||
PageUsage,
|
PageUsage,
|
||||||
SearchSpace,
|
SearchSpace,
|
||||||
SidebarSectionProps,
|
SidebarSectionProps,
|
||||||
User,
|
User,
|
||||||
} from "./types/layout.types";
|
} from "./types/layout.types";
|
||||||
export {
|
export {
|
||||||
AllSearchSpacesSheet,
|
|
||||||
ChatListItem,
|
ChatListItem,
|
||||||
CreateSearchSpaceDialog,
|
CreateSearchSpaceDialog,
|
||||||
Header,
|
Header,
|
||||||
|
|
@ -21,7 +19,6 @@ export {
|
||||||
MobileSidebarTrigger,
|
MobileSidebarTrigger,
|
||||||
NavIcon,
|
NavIcon,
|
||||||
NavSection,
|
NavSection,
|
||||||
NoteListItem,
|
|
||||||
PageUsageDisplay,
|
PageUsageDisplay,
|
||||||
SearchSpaceAvatar,
|
SearchSpaceAvatar,
|
||||||
Sidebar,
|
Sidebar,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtomValue, useSetAtom } 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";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
|
|
||||||
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||||
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
|
|
@ -20,18 +19,15 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useLogsSummary } from "@/hooks/use-logs";
|
|
||||||
import { notesApiService } from "@/lib/apis/notes-api.service";
|
|
||||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
|
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
|
||||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import type { ChatItem, NavItem, NoteItem, SearchSpace } from "../types/layout.types";
|
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
|
||||||
import { CreateSearchSpaceDialog } from "../ui/dialogs";
|
import { CreateSearchSpaceDialog } from "../ui/dialogs";
|
||||||
import { AllSearchSpacesSheet } from "../ui/sheets";
|
|
||||||
import { LayoutShell } from "../ui/shell";
|
import { LayoutShell } from "../ui/shell";
|
||||||
import { AllChatsSidebar } from "../ui/sidebar/AllChatsSidebar";
|
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
|
||||||
import { AllNotesSidebar } from "../ui/sidebar/AllNotesSidebar";
|
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
|
||||||
|
|
||||||
interface LayoutDataProviderProps {
|
interface LayoutDataProviderProps {
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
|
|
@ -58,16 +54,11 @@ export function LayoutDataProvider({
|
||||||
const { data: user } = useAtomValue(currentUserAtom);
|
const { data: user } = useAtomValue(currentUserAtom);
|
||||||
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
|
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
|
||||||
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
|
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
|
||||||
const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom);
|
|
||||||
const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom);
|
|
||||||
|
|
||||||
// Current IDs from URL
|
// Current IDs from URL
|
||||||
const currentChatId = params?.chat_id
|
const currentChatId = params?.chat_id
|
||||||
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
||||||
: null;
|
: null;
|
||||||
const currentNoteId = params?.note_id
|
|
||||||
? Number(Array.isArray(params.note_id) ? params.note_id[0] : params.note_id)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Fetch current search space
|
// Fetch current search space
|
||||||
const { data: searchSpace } = useQuery({
|
const { data: searchSpace } = useQuery({
|
||||||
|
|
@ -77,45 +68,17 @@ export function LayoutDataProvider({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch threads
|
// Fetch threads
|
||||||
const { data: threadsData, refetch: refetchThreads } = useQuery({
|
const { data: threadsData } = useQuery({
|
||||||
queryKey: ["threads", searchSpaceId, { limit: 4 }],
|
queryKey: ["threads", searchSpaceId, { limit: 4 }],
|
||||||
queryFn: () => fetchThreads(Number(searchSpaceId), 4),
|
queryFn: () => fetchThreads(Number(searchSpaceId), 4),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!searchSpaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch notes
|
// Separate sidebar states for shared and private chats
|
||||||
const { data: notesData, refetch: refetchNotes } = useQuery({
|
const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false);
|
||||||
queryKey: ["notes", searchSpaceId],
|
const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false);
|
||||||
queryFn: () =>
|
|
||||||
notesApiService.getNotes({
|
|
||||||
search_space_id: Number(searchSpaceId),
|
|
||||||
page_size: 4,
|
|
||||||
}),
|
|
||||||
enabled: !!searchSpaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Poll for active reindexing tasks to show inline loading indicators
|
// Search space dialog state
|
||||||
const { summary } = useLogsSummary(searchSpaceId ? Number(searchSpaceId) : 0, 24, {
|
|
||||||
enablePolling: true,
|
|
||||||
refetchInterval: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a Set of document IDs that are currently being reindexed
|
|
||||||
const reindexingDocumentIds = useMemo(() => {
|
|
||||||
if (!summary?.active_tasks) return new Set<number>();
|
|
||||||
return new Set(
|
|
||||||
summary.active_tasks
|
|
||||||
.filter((task) => task.document_id != null)
|
|
||||||
.map((task) => task.document_id as number)
|
|
||||||
);
|
|
||||||
}, [summary?.active_tasks]);
|
|
||||||
|
|
||||||
// All chats/notes sidebars state
|
|
||||||
const [isAllChatsSidebarOpen, setIsAllChatsSidebarOpen] = useState(false);
|
|
||||||
const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false);
|
|
||||||
|
|
||||||
// Search space sheet and dialog state
|
|
||||||
const [isAllSearchSpacesSheetOpen, setIsAllSearchSpacesSheetOpen] = useState(false);
|
|
||||||
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
||||||
|
|
||||||
// Delete dialogs state
|
// Delete dialogs state
|
||||||
|
|
@ -123,13 +86,13 @@ 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);
|
||||||
|
|
||||||
const [showDeleteNoteDialog, setShowDeleteNoteDialog] = useState(false);
|
// Delete/Leave search space dialog state
|
||||||
const [noteToDelete, setNoteToDelete] = useState<{
|
const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false);
|
||||||
id: number;
|
const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false);
|
||||||
name: string;
|
const [searchSpaceToDelete, setSearchSpaceToDelete] = useState<SearchSpace | null>(null);
|
||||||
search_space_id: number;
|
const [searchSpaceToLeave, setSearchSpaceToLeave] = useState<SearchSpace | null>(null);
|
||||||
} | null>(null);
|
const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false);
|
||||||
const [isDeletingNote, setIsDeletingNote] = 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 [];
|
||||||
|
|
@ -149,35 +112,34 @@ export function LayoutDataProvider({
|
||||||
return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null;
|
return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null;
|
||||||
}, [searchSpaceId, searchSpaces]);
|
}, [searchSpaceId, searchSpaces]);
|
||||||
|
|
||||||
// Transform chats
|
// Transform and split chats into private and shared based on visibility
|
||||||
const chats: ChatItem[] = useMemo(() => {
|
const { myChats, sharedChats } = useMemo(() => {
|
||||||
if (!threadsData?.threads) return [];
|
if (!threadsData?.threads) return { myChats: [], sharedChats: [] };
|
||||||
return threadsData.threads.map((thread) => ({
|
|
||||||
|
const privateChats: ChatItem[] = [];
|
||||||
|
const sharedChatsList: ChatItem[] = [];
|
||||||
|
|
||||||
|
for (const thread of threadsData.threads) {
|
||||||
|
const chatItem: ChatItem = {
|
||||||
id: thread.id,
|
id: thread.id,
|
||||||
name: thread.title || `Chat ${thread.id}`,
|
name: thread.title || `Chat ${thread.id}`,
|
||||||
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
|
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
|
||||||
}));
|
visibility: thread.visibility,
|
||||||
}, [threadsData, searchSpaceId]);
|
isOwnThread: thread.is_own_thread,
|
||||||
|
};
|
||||||
|
|
||||||
// Transform notes
|
// Split based on visibility, not ownership:
|
||||||
const notes: NoteItem[] = useMemo(() => {
|
// - PRIVATE chats go to "Private Chats" section
|
||||||
if (!notesData?.items) return [];
|
// - SEARCH_SPACE chats go to "Shared Chats" section
|
||||||
const sortedNotes = [...notesData.items].sort((a, b) => {
|
if (thread.visibility === "SEARCH_SPACE") {
|
||||||
const dateA = a.updated_at
|
sharedChatsList.push(chatItem);
|
||||||
? new Date(a.updated_at).getTime()
|
} else {
|
||||||
: new Date(a.created_at).getTime();
|
privateChats.push(chatItem);
|
||||||
const dateB = b.updated_at
|
}
|
||||||
? new Date(b.updated_at).getTime()
|
}
|
||||||
: new Date(b.created_at).getTime();
|
|
||||||
return dateB - dateA;
|
return { myChats: privateChats, sharedChats: sharedChatsList };
|
||||||
});
|
}, [threadsData, searchSpaceId]);
|
||||||
return sortedNotes.slice(0, 4).map((note) => ({
|
|
||||||
id: note.id,
|
|
||||||
name: note.title,
|
|
||||||
url: `/dashboard/${note.search_space_id}/editor/${note.id}`,
|
|
||||||
isReindexing: reindexingDocumentIds.has(note.id),
|
|
||||||
}));
|
|
||||||
}, [notesData, reindexingDocumentIds]);
|
|
||||||
|
|
||||||
// Navigation items
|
// Navigation items
|
||||||
const navItems: NavItem[] = useMemo(
|
const navItems: NavItem[] = useMemo(
|
||||||
|
|
@ -210,36 +172,80 @@ export function LayoutDataProvider({
|
||||||
setIsCreateSearchSpaceDialogOpen(true);
|
setIsCreateSearchSpaceDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSeeAllSearchSpaces = useCallback(() => {
|
|
||||||
setIsAllSearchSpacesSheetOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleUserSettings = useCallback(() => {
|
const handleUserSettings = useCallback(() => {
|
||||||
router.push("/dashboard/user/settings");
|
router.push("/dashboard/user/settings");
|
||||||
}, [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) => {
|
||||||
|
|
@ -264,34 +270,6 @@ export function LayoutDataProvider({
|
||||||
setShowDeleteChatDialog(true);
|
setShowDeleteChatDialog(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleNoteSelect = useCallback(
|
|
||||||
(note: NoteItem) => {
|
|
||||||
if (hasUnsavedEditorChanges) {
|
|
||||||
setPendingNavigation(note.url);
|
|
||||||
} else {
|
|
||||||
router.push(note.url);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[router, hasUnsavedEditorChanges, setPendingNavigation]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleNoteDelete = useCallback(
|
|
||||||
(note: NoteItem) => {
|
|
||||||
setNoteToDelete({ id: note.id, name: note.name, search_space_id: Number(searchSpaceId) });
|
|
||||||
setShowDeleteNoteDialog(true);
|
|
||||||
},
|
|
||||||
[searchSpaceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddNote = useCallback(() => {
|
|
||||||
const newNoteUrl = `/dashboard/${searchSpaceId}/editor/new`;
|
|
||||||
if (hasUnsavedEditorChanges) {
|
|
||||||
setPendingNavigation(newNoteUrl);
|
|
||||||
} else {
|
|
||||||
router.push(newNoteUrl);
|
|
||||||
}
|
|
||||||
}, [router, searchSpaceId, hasUnsavedEditorChanges, setPendingNavigation]);
|
|
||||||
|
|
||||||
const handleSettings = useCallback(() => {
|
const handleSettings = useCallback(() => {
|
||||||
router.push(`/dashboard/${searchSpaceId}/settings`);
|
router.push(`/dashboard/${searchSpaceId}/settings`);
|
||||||
}, [router, searchSpaceId]);
|
}, [router, searchSpaceId]);
|
||||||
|
|
@ -318,12 +296,12 @@ export function LayoutDataProvider({
|
||||||
setTheme(theme === "dark" ? "light" : "dark");
|
setTheme(theme === "dark" ? "light" : "dark");
|
||||||
}, [theme, setTheme]);
|
}, [theme, setTheme]);
|
||||||
|
|
||||||
const handleViewAllChats = useCallback(() => {
|
const handleViewAllSharedChats = useCallback(() => {
|
||||||
setIsAllChatsSidebarOpen(true);
|
setIsAllSharedChatsSidebarOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleViewAllNotes = useCallback(() => {
|
const handleViewAllPrivateChats = useCallback(() => {
|
||||||
setIsAllNotesSidebarOpen(true);
|
setIsAllPrivateChatsSidebarOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Delete handlers
|
// Delete handlers
|
||||||
|
|
@ -345,24 +323,6 @@ export function LayoutDataProvider({
|
||||||
}
|
}
|
||||||
}, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]);
|
}, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]);
|
||||||
|
|
||||||
const confirmDeleteNote = useCallback(async () => {
|
|
||||||
if (!noteToDelete) return;
|
|
||||||
setIsDeletingNote(true);
|
|
||||||
try {
|
|
||||||
await notesApiService.deleteNote({
|
|
||||||
search_space_id: noteToDelete.search_space_id,
|
|
||||||
note_id: noteToDelete.id,
|
|
||||||
});
|
|
||||||
refetchNotes();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting note:", error);
|
|
||||||
} finally {
|
|
||||||
setIsDeletingNote(false);
|
|
||||||
setShowDeleteNoteDialog(false);
|
|
||||||
setNoteToDelete(null);
|
|
||||||
}
|
|
||||||
}, [noteToDelete, refetchNotes]);
|
|
||||||
|
|
||||||
// Page usage
|
// Page usage
|
||||||
const pageUsage = user
|
const pageUsage = user
|
||||||
? {
|
? {
|
||||||
|
|
@ -380,26 +340,23 @@ 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}
|
||||||
onNavItemClick={handleNavItemClick}
|
onNavItemClick={handleNavItemClick}
|
||||||
chats={chats}
|
chats={myChats}
|
||||||
|
sharedChats={sharedChats}
|
||||||
activeChatId={currentChatId}
|
activeChatId={currentChatId}
|
||||||
onNewChat={handleNewChat}
|
onNewChat={handleNewChat}
|
||||||
onChatSelect={handleChatSelect}
|
onChatSelect={handleChatSelect}
|
||||||
onChatDelete={handleChatDelete}
|
onChatDelete={handleChatDelete}
|
||||||
onViewAllChats={handleViewAllChats}
|
onViewAllSharedChats={handleViewAllSharedChats}
|
||||||
notes={notes}
|
onViewAllPrivateChats={handleViewAllPrivateChats}
|
||||||
activeNoteId={currentNoteId}
|
|
||||||
onNoteSelect={handleNoteSelect}
|
|
||||||
onNoteDelete={handleNoteDelete}
|
|
||||||
onAddNote={handleAddNote}
|
|
||||||
onViewAllNotes={handleViewAllNotes}
|
|
||||||
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}
|
||||||
onSeeAllSearchSpaces={handleSeeAllSearchSpaces}
|
|
||||||
onUserSettings={handleUserSettings}
|
onUserSettings={handleUserSettings}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
pageUsage={pageUsage}
|
pageUsage={pageUsage}
|
||||||
|
|
@ -455,69 +412,33 @@ export function LayoutDataProvider({
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* All Chats Sidebar */}
|
{/* Delete Search Space Dialog */}
|
||||||
<AllChatsSidebar
|
<Dialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
|
||||||
open={isAllChatsSidebarOpen}
|
|
||||||
onOpenChange={setIsAllChatsSidebarOpen}
|
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* All Notes Sidebar */}
|
|
||||||
<AllNotesSidebar
|
|
||||||
open={isAllNotesSidebarOpen}
|
|
||||||
onOpenChange={setIsAllNotesSidebarOpen}
|
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
onAddNote={handleAddNote}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* All Search Spaces Sheet */}
|
|
||||||
<AllSearchSpacesSheet
|
|
||||||
open={isAllSearchSpacesSheetOpen}
|
|
||||||
onOpenChange={setIsAllSearchSpacesSheetOpen}
|
|
||||||
searchSpaces={searchSpaces}
|
|
||||||
onSearchSpaceSelect={handleSearchSpaceSelect}
|
|
||||||
onCreateNew={() => {
|
|
||||||
setIsAllSearchSpacesSheetOpen(false);
|
|
||||||
setIsCreateSearchSpaceDialogOpen(true);
|
|
||||||
}}
|
|
||||||
onSettings={handleSearchSpaceSettings}
|
|
||||||
onDelete={handleDeleteSearchSpace}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Create Search Space Dialog */}
|
|
||||||
<CreateSearchSpaceDialog
|
|
||||||
open={isCreateSearchSpaceDialogOpen}
|
|
||||||
onOpenChange={setIsCreateSearchSpaceDialogOpen}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Delete Note Dialog */}
|
|
||||||
<Dialog open={showDeleteNoteDialog} onOpenChange={setShowDeleteNoteDialog}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Trash2 className="h-5 w-5 text-destructive" />
|
<Trash2 className="h-5 w-5 text-destructive" />
|
||||||
<span>{t("delete_note")}</span>
|
<span>{t("delete_search_space")}</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t("delete_note_confirm")} <span className="font-medium">{noteToDelete?.name}</span>?{" "}
|
{t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })}
|
||||||
{t("action_cannot_undone")}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowDeleteNoteDialog(false)}
|
onClick={() => setShowDeleteSearchSpaceDialog(false)}
|
||||||
disabled={isDeletingNote}
|
disabled={isDeletingSearchSpace}
|
||||||
>
|
>
|
||||||
{tCommon("cancel")}
|
{tCommon("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={confirmDeleteNote}
|
onClick={confirmDeleteSearchSpace}
|
||||||
disabled={isDeletingNote}
|
disabled={isDeletingSearchSpace}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
{isDeletingNote ? (
|
{isDeletingSearchSpace ? (
|
||||||
<>
|
<>
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
{t("deleting")}
|
{t("deleting")}
|
||||||
|
|
@ -532,6 +453,68 @@ export function LayoutDataProvider({
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 */}
|
||||||
|
<AllSharedChatsSidebar
|
||||||
|
open={isAllSharedChatsSidebarOpen}
|
||||||
|
onOpenChange={setIsAllSharedChatsSidebarOpen}
|
||||||
|
searchSpaceId={searchSpaceId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* All Private Chats Sidebar */}
|
||||||
|
<AllPrivateChatsSidebar
|
||||||
|
open={isAllPrivateChatsSidebarOpen}
|
||||||
|
onOpenChange={setIsAllPrivateChatsSidebarOpen}
|
||||||
|
searchSpaceId={searchSpaceId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Create Search Space Dialog */}
|
||||||
|
<CreateSearchSpaceDialog
|
||||||
|
open={isCreateSearchSpaceDialogOpen}
|
||||||
|
onOpenChange={setIsCreateSearchSpaceDialogOpen}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,8 @@ export interface ChatItem {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
visibility?: "PRIVATE" | "SEARCH_SPACE";
|
||||||
|
isOwnThread?: boolean;
|
||||||
export interface NoteItem {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
isReindexing?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageUsage {
|
export interface PageUsage {
|
||||||
|
|
@ -72,17 +66,8 @@ export interface ChatsSectionProps {
|
||||||
activeChatId?: number | null;
|
activeChatId?: number | null;
|
||||||
onChatSelect: (chat: ChatItem) => void;
|
onChatSelect: (chat: ChatItem) => void;
|
||||||
onChatDelete?: (chat: ChatItem) => void;
|
onChatDelete?: (chat: ChatItem) => void;
|
||||||
onViewAllChats?: () => void;
|
onViewAllSharedChats?: () => void;
|
||||||
searchSpaceId?: string;
|
onViewAllPrivateChats?: () => void;
|
||||||
}
|
|
||||||
|
|
||||||
export interface NotesSectionProps {
|
|
||||||
notes: NoteItem[];
|
|
||||||
activeNoteId?: number | null;
|
|
||||||
onNoteSelect: (note: NoteItem) => void;
|
|
||||||
onNoteDelete?: (note: NoteItem) => void;
|
|
||||||
onAddNote?: () => void;
|
|
||||||
onViewAllNotes?: () => void;
|
|
||||||
searchSpaceId?: string;
|
searchSpaceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,22 +92,17 @@ export interface SidebarProps {
|
||||||
searchSpaceId?: string;
|
searchSpaceId?: string;
|
||||||
navItems: NavItem[];
|
navItems: NavItem[];
|
||||||
chats: ChatItem[];
|
chats: ChatItem[];
|
||||||
|
sharedChats?: ChatItem[];
|
||||||
activeChatId?: number | null;
|
activeChatId?: number | null;
|
||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
onChatSelect: (chat: ChatItem) => void;
|
onChatSelect: (chat: ChatItem) => void;
|
||||||
onChatDelete?: (chat: ChatItem) => void;
|
onChatDelete?: (chat: ChatItem) => void;
|
||||||
onViewAllChats?: () => void;
|
onViewAllSharedChats?: () => void;
|
||||||
notes: NoteItem[];
|
onViewAllPrivateChats?: () => void;
|
||||||
activeNoteId?: number | null;
|
|
||||||
onNoteSelect: (note: NoteItem) => void;
|
|
||||||
onNoteDelete?: (note: NoteItem) => void;
|
|
||||||
onAddNote?: () => void;
|
|
||||||
onViewAllNotes?: () => void;
|
|
||||||
user: User;
|
user: User;
|
||||||
theme?: string;
|
theme?: string;
|
||||||
onSettings?: () => void;
|
onSettings?: () => void;
|
||||||
onManageMembers?: () => void;
|
onManageMembers?: () => void;
|
||||||
onSeeAllSearchSpaces?: () => void;
|
|
||||||
onToggleTheme?: () => void;
|
onToggleTheme?: () => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
pageUsage?: PageUsage;
|
pageUsage?: PageUsage;
|
||||||
|
|
|
||||||
|
|
@ -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,21 +58,36 @@ 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";
|
||||||
|
|
||||||
return (
|
const tooltipContent = (
|
||||||
<Tooltip>
|
<div className="flex flex-col">
|
||||||
<TooltipTrigger asChild>
|
<span>{name}</span>
|
||||||
|
{isShared && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{isOwner ? tCommon("owner") : tCommon("shared")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const avatarButton = (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center rounded-lg font-semibold text-white transition-all",
|
"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",
|
"hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
sizeClasses,
|
sizeClasses,
|
||||||
isActive && "ring-2 ring-primary ring-offset-1 ring-offset-background"
|
isActive && "ring-2 ring-primary ring-offset-1 ring-offset-background"
|
||||||
|
|
@ -67,10 +95,66 @@ export function SearchSpaceAvatar({
|
||||||
style={{ backgroundColor: bgColor }}
|
style={{ backgroundColor: bgColor }}
|
||||||
>
|
>
|
||||||
{initials}
|
{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>
|
</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>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" sideOffset={8}>
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
{name}
|
{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 (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{avatarButton}</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
|
{tooltipContent}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
export { CreateSearchSpaceDialog } from "./dialogs";
|
export { CreateSearchSpaceDialog } from "./dialogs";
|
||||||
export { Header } from "./header";
|
export { Header } from "./header";
|
||||||
export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail";
|
export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail";
|
||||||
export { AllSearchSpacesSheet } from "./sheets";
|
|
||||||
export { LayoutShell } from "./shell";
|
export { LayoutShell } from "./shell";
|
||||||
export {
|
export {
|
||||||
ChatListItem,
|
ChatListItem,
|
||||||
MobileSidebar,
|
MobileSidebar,
|
||||||
MobileSidebarTrigger,
|
MobileSidebarTrigger,
|
||||||
NavSection,
|
NavSection,
|
||||||
NoteListItem,
|
|
||||||
PageUsageDisplay,
|
PageUsageDisplay,
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarCollapseButton,
|
SidebarCollapseButton,
|
||||||
|
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Calendar,
|
|
||||||
MoreHorizontal,
|
|
||||||
Search,
|
|
||||||
Settings,
|
|
||||||
Share2,
|
|
||||||
Trash2,
|
|
||||||
UserCheck,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from "@/components/ui/sheet";
|
|
||||||
import type { SearchSpace } from "../../types/layout.types";
|
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
|
||||||
return new Date(dateString).toLocaleDateString("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AllSearchSpacesSheetProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
searchSpaces: SearchSpace[];
|
|
||||||
onSearchSpaceSelect: (id: number) => void;
|
|
||||||
onCreateNew?: () => void;
|
|
||||||
onSettings?: (id: number) => void;
|
|
||||||
onDelete?: (id: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AllSearchSpacesSheet({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
searchSpaces,
|
|
||||||
onSearchSpaceSelect,
|
|
||||||
onCreateNew,
|
|
||||||
onSettings,
|
|
||||||
onDelete,
|
|
||||||
}: AllSearchSpacesSheetProps) {
|
|
||||||
const t = useTranslations("searchSpace");
|
|
||||||
const tCommon = useTranslations("common");
|
|
||||||
|
|
||||||
const [spaceToDelete, setSpaceToDelete] = useState<SearchSpace | null>(null);
|
|
||||||
|
|
||||||
const handleSelect = (id: number) => {
|
|
||||||
onSearchSpaceSelect(id);
|
|
||||||
onOpenChange(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSettings = (e: React.MouseEvent, space: SearchSpace) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpenChange(false);
|
|
||||||
onSettings?.(space.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteClick = (e: React.MouseEvent, space: SearchSpace) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setSpaceToDelete(space);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmDelete = () => {
|
|
||||||
if (spaceToDelete) {
|
|
||||||
onDelete?.(spaceToDelete.id);
|
|
||||||
setSpaceToDelete(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
||||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
|
||||||
<SheetHeader>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Search className="h-5 w-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<SheetTitle>{t("all_search_spaces")}</SheetTitle>
|
|
||||||
<SheetDescription>
|
|
||||||
{t("search_spaces_count", { count: searchSpaces.length })}
|
|
||||||
</SheetDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 pb-4">
|
|
||||||
{searchSpaces.length === 0 ? (
|
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-12 text-center">
|
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
|
||||||
<Search className="h-8 w-8 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<p className="font-medium">{t("no_search_spaces")}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{t("create_first_search_space")}</p>
|
|
||||||
</div>
|
|
||||||
{onCreateNew && (
|
|
||||||
<Button onClick={onCreateNew} className="mt-2">
|
|
||||||
{t("create_button")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
searchSpaces.map((space) => (
|
|
||||||
<button
|
|
||||||
key={space.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSelect(space.id)}
|
|
||||||
className="flex w-full flex-col gap-2 rounded-lg border p-4 text-left transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex flex-1 flex-col gap-1">
|
|
||||||
<span className="font-medium leading-tight">{space.name}</span>
|
|
||||||
{space.description && (
|
|
||||||
<span className="text-sm text-muted-foreground line-clamp-2">
|
|
||||||
{space.description}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
{space.memberCount > 1 && (
|
|
||||||
<Badge variant="outline" className="shrink-0">
|
|
||||||
<Share2 className="mr-1 h-3 w-3" />
|
|
||||||
{tCommon("shared")}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{space.isOwner && (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 shrink-0"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={(e) => handleSettings(e, space)}>
|
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
|
||||||
{tCommon("settings")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => handleDeleteClick(e, space)}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
{tCommon("delete")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
{space.isOwner ? (
|
|
||||||
<UserCheck className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<Users className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
{t("members_count", { count: space.memberCount })}
|
|
||||||
</span>
|
|
||||||
{space.createdAt && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Calendar className="h-3.5 w-3.5" />
|
|
||||||
{formatDate(space.createdAt)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{searchSpaces.length > 0 && onCreateNew && (
|
|
||||||
<div className="border-t p-4">
|
|
||||||
<Button onClick={onCreateNew} variant="outline" className="w-full">
|
|
||||||
{t("create_new_search_space")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
|
|
||||||
<AlertDialog open={!!spaceToDelete} onOpenChange={(open) => !open && setSpaceToDelete(null)}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{t("delete_title")}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{t("delete_confirm", { name: spaceToDelete?.name ?? "" })}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={confirmDelete}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
{tCommon("delete")}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { AllSearchSpacesSheet } from "./AllSearchSpacesSheet";
|
|
||||||
|
|
@ -5,14 +5,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useSidebarState } from "../../hooks";
|
import { useSidebarState } from "../../hooks";
|
||||||
import type {
|
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||||
ChatItem,
|
|
||||||
NavItem,
|
|
||||||
NoteItem,
|
|
||||||
PageUsage,
|
|
||||||
SearchSpace,
|
|
||||||
User,
|
|
||||||
} from "../../types/layout.types";
|
|
||||||
import { Header } from "../header";
|
import { Header } from "../header";
|
||||||
import { IconRail } from "../icon-rail";
|
import { IconRail } from "../icon-rail";
|
||||||
import { MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar";
|
import { MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar";
|
||||||
|
|
@ -21,26 +14,23 @@ 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[];
|
||||||
onNavItemClick?: (item: NavItem) => void;
|
onNavItemClick?: (item: NavItem) => void;
|
||||||
chats: ChatItem[];
|
chats: ChatItem[];
|
||||||
|
sharedChats?: ChatItem[];
|
||||||
activeChatId?: number | null;
|
activeChatId?: number | null;
|
||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
onChatSelect: (chat: ChatItem) => void;
|
onChatSelect: (chat: ChatItem) => void;
|
||||||
onChatDelete?: (chat: ChatItem) => void;
|
onChatDelete?: (chat: ChatItem) => void;
|
||||||
onViewAllChats?: () => void;
|
onViewAllSharedChats?: () => void;
|
||||||
notes: NoteItem[];
|
onViewAllPrivateChats?: () => void;
|
||||||
activeNoteId?: number | null;
|
|
||||||
onNoteSelect: (note: NoteItem) => void;
|
|
||||||
onNoteDelete?: (note: NoteItem) => void;
|
|
||||||
onAddNote?: () => void;
|
|
||||||
onViewAllNotes?: () => void;
|
|
||||||
user: User;
|
user: User;
|
||||||
onSettings?: () => void;
|
onSettings?: () => void;
|
||||||
onManageMembers?: () => void;
|
onManageMembers?: () => void;
|
||||||
onSeeAllSearchSpaces?: () => void;
|
|
||||||
onUserSettings?: () => void;
|
onUserSettings?: () => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
pageUsage?: PageUsage;
|
pageUsage?: PageUsage;
|
||||||
|
|
@ -58,26 +48,23 @@ export function LayoutShell({
|
||||||
searchSpaces,
|
searchSpaces,
|
||||||
activeSearchSpaceId,
|
activeSearchSpaceId,
|
||||||
onSearchSpaceSelect,
|
onSearchSpaceSelect,
|
||||||
|
onSearchSpaceDelete,
|
||||||
|
onSearchSpaceSettings,
|
||||||
onAddSearchSpace,
|
onAddSearchSpace,
|
||||||
searchSpace,
|
searchSpace,
|
||||||
navItems,
|
navItems,
|
||||||
onNavItemClick,
|
onNavItemClick,
|
||||||
chats,
|
chats,
|
||||||
|
sharedChats,
|
||||||
activeChatId,
|
activeChatId,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onChatSelect,
|
onChatSelect,
|
||||||
onChatDelete,
|
onChatDelete,
|
||||||
onViewAllChats,
|
onViewAllSharedChats,
|
||||||
notes,
|
onViewAllPrivateChats,
|
||||||
activeNoteId,
|
|
||||||
onNoteSelect,
|
|
||||||
onNoteDelete,
|
|
||||||
onAddNote,
|
|
||||||
onViewAllNotes,
|
|
||||||
user,
|
user,
|
||||||
onSettings,
|
onSettings,
|
||||||
onManageMembers,
|
onManageMembers,
|
||||||
onSeeAllSearchSpaces,
|
|
||||||
onUserSettings,
|
onUserSettings,
|
||||||
onLogout,
|
onLogout,
|
||||||
pageUsage,
|
pageUsage,
|
||||||
|
|
@ -113,26 +100,23 @@ 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}
|
||||||
onNavItemClick={onNavItemClick}
|
onNavItemClick={onNavItemClick}
|
||||||
chats={chats}
|
chats={chats}
|
||||||
|
sharedChats={sharedChats}
|
||||||
activeChatId={activeChatId}
|
activeChatId={activeChatId}
|
||||||
onNewChat={onNewChat}
|
onNewChat={onNewChat}
|
||||||
onChatSelect={onChatSelect}
|
onChatSelect={onChatSelect}
|
||||||
onChatDelete={onChatDelete}
|
onChatDelete={onChatDelete}
|
||||||
onViewAllChats={onViewAllChats}
|
onViewAllSharedChats={onViewAllSharedChats}
|
||||||
notes={notes}
|
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||||
activeNoteId={activeNoteId}
|
|
||||||
onNoteSelect={onNoteSelect}
|
|
||||||
onNoteDelete={onNoteDelete}
|
|
||||||
onAddNote={onAddNote}
|
|
||||||
onViewAllNotes={onViewAllNotes}
|
|
||||||
user={user}
|
user={user}
|
||||||
onSettings={onSettings}
|
onSettings={onSettings}
|
||||||
onManageMembers={onManageMembers}
|
onManageMembers={onManageMembers}
|
||||||
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
|
|
||||||
onUserSettings={onUserSettings}
|
onUserSettings={onUserSettings}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
pageUsage={pageUsage}
|
pageUsage={pageUsage}
|
||||||
|
|
@ -155,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>
|
||||||
|
|
@ -167,21 +153,16 @@ export function LayoutShell({
|
||||||
navItems={navItems}
|
navItems={navItems}
|
||||||
onNavItemClick={onNavItemClick}
|
onNavItemClick={onNavItemClick}
|
||||||
chats={chats}
|
chats={chats}
|
||||||
|
sharedChats={sharedChats}
|
||||||
activeChatId={activeChatId}
|
activeChatId={activeChatId}
|
||||||
onNewChat={onNewChat}
|
onNewChat={onNewChat}
|
||||||
onChatSelect={onChatSelect}
|
onChatSelect={onChatSelect}
|
||||||
onChatDelete={onChatDelete}
|
onChatDelete={onChatDelete}
|
||||||
onViewAllChats={onViewAllChats}
|
onViewAllSharedChats={onViewAllSharedChats}
|
||||||
notes={notes}
|
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||||
activeNoteId={activeNoteId}
|
|
||||||
onNoteSelect={onNoteSelect}
|
|
||||||
onNoteDelete={onNoteDelete}
|
|
||||||
onAddNote={onAddNote}
|
|
||||||
onViewAllNotes={onViewAllNotes}
|
|
||||||
user={user}
|
user={user}
|
||||||
onSettings={onSettings}
|
onSettings={onSettings}
|
||||||
onManageMembers={onManageMembers}
|
onManageMembers={onManageMembers}
|
||||||
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
|
|
||||||
onUserSettings={onUserSettings}
|
onUserSettings={onUserSettings}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
pageUsage={pageUsage}
|
pageUsage={pageUsage}
|
||||||
|
|
|
||||||
|
|
@ -1,407 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react";
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
|
||||||
import { notesApiService } from "@/lib/apis/notes-api.service";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface AllNotesSidebarProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
searchSpaceId: string;
|
|
||||||
onAddNote?: () => void;
|
|
||||||
onCloseMobileSidebar?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AllNotesSidebar({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
searchSpaceId,
|
|
||||||
onAddNote,
|
|
||||||
onCloseMobileSidebar,
|
|
||||||
}: AllNotesSidebarProps) {
|
|
||||||
const t = useTranslations("sidebar");
|
|
||||||
const router = useRouter();
|
|
||||||
const params = useParams();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// Get the current note ID from URL to highlight the open note
|
|
||||||
const currentNoteId = params.note_id ? Number(params.note_id) : null;
|
|
||||||
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
|
|
||||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
|
||||||
|
|
||||||
// Handle mounting for portal
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle escape key
|
|
||||||
useEffect(() => {
|
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape" && open) {
|
|
||||||
onOpenChange(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleEscape);
|
|
||||||
return () => document.removeEventListener("keydown", handleEscape);
|
|
||||||
}, [open, onOpenChange]);
|
|
||||||
|
|
||||||
// Lock body scroll when open
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
} else {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
// Fetch all notes (when no search query)
|
|
||||||
const {
|
|
||||||
data: notesData,
|
|
||||||
error: notesError,
|
|
||||||
isLoading: isLoadingNotes,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["all-notes", searchSpaceId],
|
|
||||||
queryFn: () =>
|
|
||||||
notesApiService.getNotes({
|
|
||||||
search_space_id: Number(searchSpaceId),
|
|
||||||
page_size: 1000,
|
|
||||||
}),
|
|
||||||
enabled: !!searchSpaceId && open && !debouncedSearchQuery,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Search notes (when there's a search query)
|
|
||||||
const {
|
|
||||||
data: searchData,
|
|
||||||
error: searchError,
|
|
||||||
isLoading: isSearching,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["search-notes", searchSpaceId, debouncedSearchQuery],
|
|
||||||
queryFn: () =>
|
|
||||||
documentsApiService.searchDocuments({
|
|
||||||
queryParams: {
|
|
||||||
search_space_id: Number(searchSpaceId),
|
|
||||||
document_types: ["NOTE"],
|
|
||||||
title: debouncedSearchQuery,
|
|
||||||
page_size: 100,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
enabled: !!searchSpaceId && open && !!debouncedSearchQuery,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle note navigation
|
|
||||||
const handleNoteClick = useCallback(
|
|
||||||
(noteId: number, noteSearchSpaceId: number) => {
|
|
||||||
router.push(`/dashboard/${noteSearchSpaceId}/editor/${noteId}`);
|
|
||||||
onOpenChange(false);
|
|
||||||
// Also close the main sidebar on mobile
|
|
||||||
onCloseMobileSidebar?.();
|
|
||||||
},
|
|
||||||
[router, onOpenChange, onCloseMobileSidebar]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle note deletion
|
|
||||||
const handleDeleteNote = useCallback(
|
|
||||||
async (noteId: number, noteSearchSpaceId: number) => {
|
|
||||||
setDeletingNoteId(noteId);
|
|
||||||
try {
|
|
||||||
await notesApiService.deleteNote({
|
|
||||||
search_space_id: noteSearchSpaceId,
|
|
||||||
note_id: noteId,
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["all-notes", searchSpaceId] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["notes", searchSpaceId] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search-notes", searchSpaceId] });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting note:", error);
|
|
||||||
} finally {
|
|
||||||
setDeletingNoteId(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[queryClient, searchSpaceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clear search
|
|
||||||
const handleClearSearch = useCallback(() => {
|
|
||||||
setSearchQuery("");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Determine which data to show
|
|
||||||
const isSearchMode = !!debouncedSearchQuery;
|
|
||||||
const isLoading = isSearchMode ? isSearching : isLoadingNotes;
|
|
||||||
const error = isSearchMode ? searchError : notesError;
|
|
||||||
|
|
||||||
// Transform and sort notes data - handle both regular notes and search results
|
|
||||||
const notes = useMemo(() => {
|
|
||||||
let notesList: {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
search_space_id: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at?: string | null;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
if (isSearchMode && searchData?.items) {
|
|
||||||
notesList = searchData.items.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
title: doc.title,
|
|
||||||
search_space_id: doc.search_space_id,
|
|
||||||
created_at: doc.created_at,
|
|
||||||
updated_at: doc.updated_at,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
notesList = notesData?.items ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort notes by updated_at (most recent first), fallback to created_at
|
|
||||||
return [...notesList].sort((a, b) => {
|
|
||||||
const dateA = a.updated_at
|
|
||||||
? new Date(a.updated_at).getTime()
|
|
||||||
: new Date(a.created_at).getTime();
|
|
||||||
const dateB = b.updated_at
|
|
||||||
? new Date(b.updated_at).getTime()
|
|
||||||
: new Date(b.created_at).getTime();
|
|
||||||
return dateB - dateA; // Descending order (most recent first)
|
|
||||||
});
|
|
||||||
}, [isSearchMode, searchData, notesData]);
|
|
||||||
|
|
||||||
if (!mounted) return null;
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<AnimatePresence>
|
|
||||||
{open && (
|
|
||||||
<>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="fixed inset-0 z-[70] bg-black/50"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Panel */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ x: "-100%" }}
|
|
||||||
animate={{ x: 0 }}
|
|
||||||
exit={{ x: "-100%" }}
|
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
|
||||||
className="fixed inset-y-0 left-0 z-[70] w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={t("all_notes") || "All Notes"}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex-shrink-0 p-4 pb-3 space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-lg font-semibold">{t("all_notes") || "All Notes"}</h2>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-full"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Input */}
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder={t("search_notes") || "Search notes..."}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-9 pr-8 h-9"
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
|
|
||||||
onClick={handleClearSearch}
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scrollable Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="text-center py-8 text-sm text-destructive">
|
|
||||||
{t("error_loading_notes") || "Error loading notes"}
|
|
||||||
</div>
|
|
||||||
) : notes.length > 0 ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{notes.map((note) => {
|
|
||||||
const isDeleting = deletingNoteId === note.id;
|
|
||||||
const isActive = currentNoteId === note.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={note.id}
|
|
||||||
className={cn(
|
|
||||||
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
|
||||||
"hover:bg-accent hover:text-accent-foreground",
|
|
||||||
"transition-colors cursor-pointer",
|
|
||||||
isActive && "bg-accent text-accent-foreground",
|
|
||||||
isDeleting && "opacity-50 pointer-events-none"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Main clickable area for navigation */}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleNoteClick(note.id, note.search_space_id)}
|
|
||||||
disabled={isDeleting}
|
|
||||||
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
|
||||||
>
|
|
||||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="truncate">{note.title}</span>
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" align="start">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p>
|
|
||||||
{t("created") || "Created"}:{" "}
|
|
||||||
{format(new Date(note.created_at), "MMM d, yyyy 'at' h:mm a")}
|
|
||||||
</p>
|
|
||||||
{note.updated_at && (
|
|
||||||
<p>
|
|
||||||
{t("updated") || "Updated"}:{" "}
|
|
||||||
{format(new Date(note.updated_at), "MMM d, yyyy 'at' h:mm a")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Actions dropdown - separate from main click area */}
|
|
||||||
<DropdownMenu
|
|
||||||
open={openDropdownId === note.id}
|
|
||||||
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? note.id : null)}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={cn(
|
|
||||||
"h-6 w-6 shrink-0",
|
|
||||||
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
|
||||||
"transition-opacity"
|
|
||||||
)}
|
|
||||||
disabled={isDeleting}
|
|
||||||
>
|
|
||||||
{isDeleting ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
<span className="sr-only">{t("more_options") || "More options"}</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-40 z-[80]">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleDeleteNote(note.id, note.search_space_id)}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
<span>{t("delete") || "Delete"}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : isSearchMode ? (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<Search className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("no_results_found") || "No notes found"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
|
||||||
{t("try_different_search") || "Try a different search term"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<FileText className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
{t("no_notes") || "No notes yet"}
|
|
||||||
</p>
|
|
||||||
{onAddNote && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
onAddNote();
|
|
||||||
onOpenChange(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{t("create_new_note") || "Create a note"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer with Add Note button */}
|
|
||||||
{onAddNote && notes.length > 0 && (
|
|
||||||
<div className="flex-shrink-0 p-3">
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
onAddNote();
|
|
||||||
onOpenChange(false);
|
|
||||||
}}
|
|
||||||
className="w-full"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{t("create_new_note") || "Create a new note"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { format } from "date-fns";
|
||||||
import {
|
import {
|
||||||
ArchiveIcon,
|
ArchiveIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Lock,
|
||||||
MessageCircleMore,
|
MessageCircleMore,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
RotateCcwIcon,
|
RotateCcwIcon,
|
||||||
|
|
@ -15,7 +16,7 @@ import {
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, 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";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -38,25 +39,24 @@ import {
|
||||||
} from "@/lib/chat/thread-persistence";
|
} from "@/lib/chat/thread-persistence";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface AllChatsSidebarProps {
|
interface AllPrivateChatsSidebarProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
onCloseMobileSidebar?: () => void;
|
onCloseMobileSidebar?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AllChatsSidebar({
|
export function AllPrivateChatsSidebar({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
onCloseMobileSidebar,
|
onCloseMobileSidebar,
|
||||||
}: AllChatsSidebarProps) {
|
}: AllPrivateChatsSidebarProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Get the current chat ID from URL to check if user is deleting the currently open chat
|
|
||||||
const currentChatId = Array.isArray(params.chat_id)
|
const currentChatId = Array.isArray(params.chat_id)
|
||||||
? Number(params.chat_id[0])
|
? Number(params.chat_id[0])
|
||||||
: params.chat_id
|
: params.chat_id
|
||||||
|
|
@ -72,12 +72,10 @@ export function AllChatsSidebar({
|
||||||
|
|
||||||
const isSearchMode = !!debouncedSearchQuery.trim();
|
const isSearchMode = !!debouncedSearchQuery.trim();
|
||||||
|
|
||||||
// Handle mounting for portal
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle escape key
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && open) {
|
if (e.key === "Escape" && open) {
|
||||||
|
|
@ -88,7 +86,6 @@ export function AllChatsSidebar({
|
||||||
return () => document.removeEventListener("keydown", handleEscape);
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
}, [open, onOpenChange]);
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
// Lock body scroll when open
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
|
|
@ -100,7 +97,6 @@ export function AllChatsSidebar({
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
// Fetch all threads (when not searching)
|
|
||||||
const {
|
const {
|
||||||
data: threadsData,
|
data: threadsData,
|
||||||
error: threadsError,
|
error: threadsError,
|
||||||
|
|
@ -111,7 +107,6 @@ export function AllChatsSidebar({
|
||||||
enabled: !!searchSpaceId && open && !isSearchMode,
|
enabled: !!searchSpaceId && open && !isSearchMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search threads (when searching)
|
|
||||||
const {
|
const {
|
||||||
data: searchData,
|
data: searchData,
|
||||||
error: searchError,
|
error: searchError,
|
||||||
|
|
@ -122,18 +117,41 @@ export function AllChatsSidebar({
|
||||||
enabled: !!searchSpaceId && open && isSearchMode,
|
enabled: !!searchSpaceId && open && isSearchMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle thread navigation
|
// Filter to only private chats (PRIVATE visibility or no visibility set)
|
||||||
|
const { activeChats, archivedChats } = useMemo(() => {
|
||||||
|
if (isSearchMode) {
|
||||||
|
const privateSearchResults = (searchData ?? []).filter(
|
||||||
|
(thread) => thread.visibility !== "SEARCH_SPACE"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
activeChats: privateSearchResults.filter((t) => !t.archived),
|
||||||
|
archivedChats: privateSearchResults.filter((t) => t.archived),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!threadsData) return { activeChats: [], archivedChats: [] };
|
||||||
|
|
||||||
|
const activePrivate = threadsData.threads.filter(
|
||||||
|
(thread) => thread.visibility !== "SEARCH_SPACE"
|
||||||
|
);
|
||||||
|
const archivedPrivate = threadsData.archived_threads.filter(
|
||||||
|
(thread) => thread.visibility !== "SEARCH_SPACE"
|
||||||
|
);
|
||||||
|
|
||||||
|
return { activeChats: activePrivate, archivedChats: archivedPrivate };
|
||||||
|
}, [threadsData, searchData, isSearchMode]);
|
||||||
|
|
||||||
|
const threads = showArchived ? archivedChats : activeChats;
|
||||||
|
|
||||||
const handleThreadClick = useCallback(
|
const handleThreadClick = useCallback(
|
||||||
(threadId: number) => {
|
(threadId: number) => {
|
||||||
router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`);
|
router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
// Also close the main sidebar on mobile
|
|
||||||
onCloseMobileSidebar?.();
|
onCloseMobileSidebar?.();
|
||||||
},
|
},
|
||||||
[router, onOpenChange, searchSpaceId, onCloseMobileSidebar]
|
[router, onOpenChange, searchSpaceId, onCloseMobileSidebar]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle thread deletion
|
|
||||||
const handleDeleteThread = useCallback(
|
const handleDeleteThread = useCallback(
|
||||||
async (threadId: number) => {
|
async (threadId: number) => {
|
||||||
setDeletingThreadId(threadId);
|
setDeletingThreadId(threadId);
|
||||||
|
|
@ -144,10 +162,8 @@ export function AllChatsSidebar({
|
||||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||||
|
|
||||||
// If the deleted chat is currently open, close sidebar first then redirect
|
|
||||||
if (currentChatId === threadId) {
|
if (currentChatId === threadId) {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
// Wait for sidebar close animation to complete before navigating
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||||
}, 250);
|
}, 250);
|
||||||
|
|
@ -162,7 +178,6 @@ export function AllChatsSidebar({
|
||||||
[queryClient, searchSpaceId, t, currentChatId, router, onOpenChange]
|
[queryClient, searchSpaceId, t, currentChatId, router, onOpenChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle thread archive/unarchive
|
|
||||||
const handleToggleArchive = useCallback(
|
const handleToggleArchive = useCallback(
|
||||||
async (threadId: number, currentlyArchived: boolean) => {
|
async (threadId: number, currentlyArchived: boolean) => {
|
||||||
setArchivingThreadId(threadId);
|
setArchivingThreadId(threadId);
|
||||||
|
|
@ -186,25 +201,15 @@ export function AllChatsSidebar({
|
||||||
[queryClient, searchSpaceId, t]
|
[queryClient, searchSpaceId, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clear search
|
|
||||||
const handleClearSearch = useCallback(() => {
|
const handleClearSearch = useCallback(() => {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Determine which data source to use
|
|
||||||
let threads: ThreadListItem[] = [];
|
|
||||||
if (isSearchMode) {
|
|
||||||
threads = searchData ?? [];
|
|
||||||
} else if (threadsData) {
|
|
||||||
threads = showArchived ? threadsData.archived_threads : threadsData.threads;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads;
|
const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads;
|
||||||
const error = isSearchMode ? searchError : threadsError;
|
const error = isSearchMode ? searchError : threadsError;
|
||||||
|
|
||||||
// Get counts for tabs
|
const activeCount = activeChats.length;
|
||||||
const activeCount = threadsData?.threads.length ?? 0;
|
const archivedCount = archivedChats.length;
|
||||||
const archivedCount = threadsData?.archived_threads.length ?? 0;
|
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
|
@ -212,32 +217,32 @@ export function AllChatsSidebar({
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="fixed inset-0 z-[70] bg-black/50"
|
className="fixed inset-0 z-70 bg-black/50"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Panel */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ x: "-100%" }}
|
initial={{ x: "-100%" }}
|
||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: "-100%" }}
|
exit={{ x: "-100%" }}
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||||
className="fixed inset-y-0 left-0 z-[70] w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
className="fixed inset-y-0 left-0 z-70 w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={t("all_chats") || "All Chats"}
|
aria-label={t("chats") || "Private Chats"}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||||
<div className="flex-shrink-0 p-4 pb-2 space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">{t("all_chats") || "All Chats"}</h2>
|
<div className="flex items-center gap-2">
|
||||||
|
<Lock className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -249,7 +254,6 @@ export function AllChatsSidebar({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Input */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -273,9 +277,8 @@ export function AllChatsSidebar({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab toggle for active/archived (only show when not searching) */}
|
|
||||||
{!isSearchMode && (
|
{!isSearchMode && (
|
||||||
<div className="flex-shrink-0 flex border-b mx-4">
|
<div className="shrink-0 flex border-b mx-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowArchived(false)}
|
onClick={() => setShowArchived(false)}
|
||||||
|
|
@ -303,7 +306,6 @@ export function AllChatsSidebar({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scrollable Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
|
|
@ -332,7 +334,6 @@ export function AllChatsSidebar({
|
||||||
isBusy && "opacity-50 pointer-events-none"
|
isBusy && "opacity-50 pointer-events-none"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Main clickable area for navigation */}
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -353,7 +354,6 @@ export function AllChatsSidebar({
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* Actions dropdown */}
|
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
open={openDropdownId === thread.id}
|
open={openDropdownId === thread.id}
|
||||||
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
|
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
|
||||||
|
|
@ -377,7 +377,7 @@ export function AllChatsSidebar({
|
||||||
<span className="sr-only">{t("more_options") || "More options"}</span>
|
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-40 z-[80]">
|
<DropdownMenuContent align="end" className="w-40 z-80">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleToggleArchive(thread.id, thread.archived)}
|
onClick={() => handleToggleArchive(thread.id, thread.archived)}
|
||||||
disabled={isArchiving}
|
disabled={isArchiving}
|
||||||
|
|
@ -420,11 +420,11 @@ export function AllChatsSidebar({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<MessageCircleMore className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
<Lock className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{showArchived
|
{showArchived
|
||||||
? t("no_archived_chats") || "No archived chats"
|
? t("no_archived_chats") || "No archived chats"
|
||||||
: t("no_chats") || "No chats yet"}
|
: t("no_chats") || "No private chats"}
|
||||||
</p>
|
</p>
|
||||||
{!showArchived && (
|
{!showArchived && (
|
||||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
|
|
@ -0,0 +1,443 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import {
|
||||||
|
ArchiveIcon,
|
||||||
|
Loader2,
|
||||||
|
MessageCircleMore,
|
||||||
|
MoreHorizontal,
|
||||||
|
RotateCcwIcon,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
|
import {
|
||||||
|
deleteThread,
|
||||||
|
fetchThreads,
|
||||||
|
searchThreads,
|
||||||
|
type ThreadListItem,
|
||||||
|
updateThread,
|
||||||
|
} from "@/lib/chat/thread-persistence";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface AllSharedChatsSidebarProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
searchSpaceId: string;
|
||||||
|
onCloseMobileSidebar?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AllSharedChatsSidebar({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
searchSpaceId,
|
||||||
|
onCloseMobileSidebar,
|
||||||
|
}: AllSharedChatsSidebarProps) {
|
||||||
|
const t = useTranslations("sidebar");
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const currentChatId = Array.isArray(params.chat_id)
|
||||||
|
? Number(params.chat_id[0])
|
||||||
|
: params.chat_id
|
||||||
|
? Number(params.chat_id)
|
||||||
|
: null;
|
||||||
|
const [deletingThreadId, setDeletingThreadId] = useState<number | null>(null);
|
||||||
|
const [archivingThreadId, setArchivingThreadId] = useState<number | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
|
||||||
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
||||||
|
|
||||||
|
const isSearchMode = !!debouncedSearchQuery.trim();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && open) {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: threadsData,
|
||||||
|
error: threadsError,
|
||||||
|
isLoading: isLoadingThreads,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["all-threads", searchSpaceId],
|
||||||
|
queryFn: () => fetchThreads(Number(searchSpaceId)),
|
||||||
|
enabled: !!searchSpaceId && open && !isSearchMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: searchData,
|
||||||
|
error: searchError,
|
||||||
|
isLoading: isLoadingSearch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["search-threads", searchSpaceId, debouncedSearchQuery],
|
||||||
|
queryFn: () => searchThreads(Number(searchSpaceId), debouncedSearchQuery.trim()),
|
||||||
|
enabled: !!searchSpaceId && open && isSearchMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter to only shared chats (SEARCH_SPACE visibility)
|
||||||
|
const { activeChats, archivedChats } = useMemo(() => {
|
||||||
|
if (isSearchMode) {
|
||||||
|
const sharedSearchResults = (searchData ?? []).filter(
|
||||||
|
(thread) => thread.visibility === "SEARCH_SPACE"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
activeChats: sharedSearchResults.filter((t) => !t.archived),
|
||||||
|
archivedChats: sharedSearchResults.filter((t) => t.archived),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!threadsData) return { activeChats: [], archivedChats: [] };
|
||||||
|
|
||||||
|
const activeShared = threadsData.threads.filter(
|
||||||
|
(thread) => thread.visibility === "SEARCH_SPACE"
|
||||||
|
);
|
||||||
|
const archivedShared = threadsData.archived_threads.filter(
|
||||||
|
(thread) => thread.visibility === "SEARCH_SPACE"
|
||||||
|
);
|
||||||
|
|
||||||
|
return { activeChats: activeShared, archivedChats: archivedShared };
|
||||||
|
}, [threadsData, searchData, isSearchMode]);
|
||||||
|
|
||||||
|
const threads = showArchived ? archivedChats : activeChats;
|
||||||
|
|
||||||
|
const handleThreadClick = useCallback(
|
||||||
|
(threadId: number) => {
|
||||||
|
router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`);
|
||||||
|
onOpenChange(false);
|
||||||
|
onCloseMobileSidebar?.();
|
||||||
|
},
|
||||||
|
[router, onOpenChange, searchSpaceId, onCloseMobileSidebar]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteThread = useCallback(
|
||||||
|
async (threadId: number) => {
|
||||||
|
setDeletingThreadId(threadId);
|
||||||
|
try {
|
||||||
|
await deleteThread(threadId);
|
||||||
|
toast.success(t("chat_deleted") || "Chat deleted successfully");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||||
|
|
||||||
|
if (currentChatId === threadId) {
|
||||||
|
onOpenChange(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting thread:", error);
|
||||||
|
toast.error(t("error_deleting_chat") || "Failed to delete chat");
|
||||||
|
} finally {
|
||||||
|
setDeletingThreadId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[queryClient, searchSpaceId, t, currentChatId, router, onOpenChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleArchive = useCallback(
|
||||||
|
async (threadId: number, currentlyArchived: boolean) => {
|
||||||
|
setArchivingThreadId(threadId);
|
||||||
|
try {
|
||||||
|
await updateThread(threadId, { archived: !currentlyArchived });
|
||||||
|
toast.success(
|
||||||
|
currentlyArchived
|
||||||
|
? t("chat_unarchived") || "Chat restored"
|
||||||
|
: t("chat_archived") || "Chat archived"
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error archiving thread:", error);
|
||||||
|
toast.error(t("error_archiving_chat") || "Failed to archive chat");
|
||||||
|
} finally {
|
||||||
|
setArchivingThreadId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[queryClient, searchSpaceId, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClearSearch = useCallback(() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads;
|
||||||
|
const error = isSearchMode ? searchError : threadsError;
|
||||||
|
|
||||||
|
const activeCount = activeChats.length;
|
||||||
|
const archivedCount = archivedChats.length;
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed inset-0 z-70 bg-black/50"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "-100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "-100%" }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||||
|
className="fixed inset-y-0 left-0 z-70 w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("shared_chats") || "Shared Chats"}
|
||||||
|
>
|
||||||
|
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("search_chats") || "Search chats..."}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9 pr-8 h-9"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isSearchMode && (
|
||||||
|
<div className="shrink-0 flex border-b mx-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowArchived(false)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
||||||
|
!showArchived
|
||||||
|
? "border-b-2 border-primary text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Active ({activeCount})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowArchived(true)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
||||||
|
showArchived
|
||||||
|
? "border-b-2 border-primary text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Archived ({archivedCount})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-8 text-sm text-destructive">
|
||||||
|
{t("error_loading_chats") || "Error loading chats"}
|
||||||
|
</div>
|
||||||
|
) : threads.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{threads.map((thread) => {
|
||||||
|
const isDeleting = deletingThreadId === thread.id;
|
||||||
|
const isArchiving = archivingThreadId === thread.id;
|
||||||
|
const isBusy = isDeleting || isArchiving;
|
||||||
|
const isActive = currentChatId === thread.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={thread.id}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
"transition-colors cursor-pointer",
|
||||||
|
isActive && "bg-accent text-accent-foreground",
|
||||||
|
isBusy && "opacity-50 pointer-events-none"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleThreadClick(thread.id)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||||
|
>
|
||||||
|
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{thread.title || "New Chat"}</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="start">
|
||||||
|
<p>
|
||||||
|
{t("updated") || "Updated"}:{" "}
|
||||||
|
{format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<DropdownMenu
|
||||||
|
open={openDropdownId === thread.id}
|
||||||
|
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-6 shrink-0",
|
||||||
|
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
||||||
|
"transition-opacity"
|
||||||
|
)}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40 z-80">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleToggleArchive(thread.id, thread.archived)}
|
||||||
|
disabled={isArchiving}
|
||||||
|
>
|
||||||
|
{thread.archived ? (
|
||||||
|
<>
|
||||||
|
<RotateCcwIcon className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t("unarchive") || "Restore"}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ArchiveIcon className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t("archive") || "Archive"}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDeleteThread(thread.id)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t("delete") || "Delete"}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : isSearchMode ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Search className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("no_chats_found") || "No chats found"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
|
{t("try_different_search") || "Try a different search term"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Users className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{showArchived
|
||||||
|
? t("no_archived_chats") || "No archived chats"
|
||||||
|
: t("no_shared_chats") || "No shared chats"}
|
||||||
|
</p>
|
||||||
|
{!showArchived && (
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
|
Share a chat to collaborate with your team
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Menu } from "lucide-react";
|
import { Menu, Plus } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
import type {
|
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||||
ChatItem,
|
import { SearchSpaceAvatar } from "../icon-rail/SearchSpaceAvatar";
|
||||||
NavItem,
|
|
||||||
NoteItem,
|
|
||||||
PageUsage,
|
|
||||||
SearchSpace,
|
|
||||||
User,
|
|
||||||
} from "../../types/layout.types";
|
|
||||||
import { IconRail } from "../icon-rail";
|
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
|
|
||||||
interface MobileSidebarProps {
|
interface MobileSidebarProps {
|
||||||
|
|
@ -21,26 +13,23 @@ 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[];
|
||||||
onNavItemClick?: (item: NavItem) => void;
|
onNavItemClick?: (item: NavItem) => void;
|
||||||
chats: ChatItem[];
|
chats: ChatItem[];
|
||||||
|
sharedChats?: ChatItem[];
|
||||||
activeChatId?: number | null;
|
activeChatId?: number | null;
|
||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
onChatSelect: (chat: ChatItem) => void;
|
onChatSelect: (chat: ChatItem) => void;
|
||||||
onChatDelete?: (chat: ChatItem) => void;
|
onChatDelete?: (chat: ChatItem) => void;
|
||||||
onViewAllChats?: () => void;
|
onViewAllSharedChats?: () => void;
|
||||||
notes: NoteItem[];
|
onViewAllPrivateChats?: () => void;
|
||||||
activeNoteId?: number | null;
|
|
||||||
onNoteSelect: (note: NoteItem) => void;
|
|
||||||
onNoteDelete?: (note: NoteItem) => void;
|
|
||||||
onAddNote?: () => void;
|
|
||||||
onViewAllNotes?: () => void;
|
|
||||||
user: User;
|
user: User;
|
||||||
onSettings?: () => void;
|
onSettings?: () => void;
|
||||||
onManageMembers?: () => void;
|
onManageMembers?: () => void;
|
||||||
onSeeAllSearchSpaces?: () => void;
|
|
||||||
onUserSettings?: () => void;
|
onUserSettings?: () => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
pageUsage?: PageUsage;
|
pageUsage?: PageUsage;
|
||||||
|
|
@ -61,26 +50,23 @@ export function MobileSidebar({
|
||||||
searchSpaces,
|
searchSpaces,
|
||||||
activeSearchSpaceId,
|
activeSearchSpaceId,
|
||||||
onSearchSpaceSelect,
|
onSearchSpaceSelect,
|
||||||
|
onSearchSpaceDelete,
|
||||||
|
onSearchSpaceSettings,
|
||||||
onAddSearchSpace,
|
onAddSearchSpace,
|
||||||
searchSpace,
|
searchSpace,
|
||||||
navItems,
|
navItems,
|
||||||
onNavItemClick,
|
onNavItemClick,
|
||||||
chats,
|
chats,
|
||||||
|
sharedChats,
|
||||||
activeChatId,
|
activeChatId,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onChatSelect,
|
onChatSelect,
|
||||||
onChatDelete,
|
onChatDelete,
|
||||||
onViewAllChats,
|
onViewAllSharedChats,
|
||||||
notes,
|
onViewAllPrivateChats,
|
||||||
activeNoteId,
|
|
||||||
onNoteSelect,
|
|
||||||
onNoteDelete,
|
|
||||||
onAddNote,
|
|
||||||
onViewAllNotes,
|
|
||||||
user,
|
user,
|
||||||
onSettings,
|
onSettings,
|
||||||
onManageMembers,
|
onManageMembers,
|
||||||
onSeeAllSearchSpaces,
|
|
||||||
onUserSettings,
|
onUserSettings,
|
||||||
onLogout,
|
onLogout,
|
||||||
pageUsage,
|
pageUsage,
|
||||||
|
|
@ -99,27 +85,43 @@ export function MobileSidebar({
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNoteSelect = (note: NoteItem) => {
|
|
||||||
onNoteSelect(note);
|
|
||||||
onOpenChange(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<SheetContent side="left" className="w-[320px] p-0 flex">
|
<SheetContent side="left" className="w-[300px] p-0 flex flex-col">
|
||||||
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
||||||
|
|
||||||
<div className="shrink-0 border-r bg-muted/40">
|
{/* Horizontal Search Spaces Rail */}
|
||||||
<ScrollArea className="h-full">
|
<div className="shrink-0 border-b bg-muted/40 px-2 py-2 overflow-hidden">
|
||||||
<IconRail
|
<div className="flex items-center gap-2 px-1 py-1 overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/20">
|
||||||
searchSpaces={searchSpaces}
|
{searchSpaces.map((space) => (
|
||||||
activeSearchSpaceId={activeSearchSpaceId}
|
<div key={space.id} className="shrink-0">
|
||||||
onSearchSpaceSelect={handleSearchSpaceSelect}
|
<SearchSpaceAvatar
|
||||||
onAddSearchSpace={onAddSearchSpace}
|
name={space.name}
|
||||||
|
isActive={space.id === activeSearchSpaceId}
|
||||||
|
isShared={space.memberCount > 1}
|
||||||
|
isOwner={space.isOwner}
|
||||||
|
onClick={() => handleSearchSpaceSelect(space.id)}
|
||||||
|
onDelete={onSearchSpaceDelete ? () => onSearchSpaceDelete(space) : undefined}
|
||||||
|
onSettings={
|
||||||
|
onSearchSpaceSettings ? () => onSearchSpaceSettings(space) : undefined
|
||||||
|
}
|
||||||
|
size="md"
|
||||||
/>
|
/>
|
||||||
</ScrollArea>
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onAddSearchSpace}
|
||||||
|
className="h-10 w-10 shrink-0 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<span className="sr-only">Add search space</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Content */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
searchSpace={searchSpace}
|
searchSpace={searchSpace}
|
||||||
|
|
@ -127,6 +129,7 @@ export function MobileSidebar({
|
||||||
navItems={navItems}
|
navItems={navItems}
|
||||||
onNavItemClick={handleNavItemClick}
|
onNavItemClick={handleNavItemClick}
|
||||||
chats={chats}
|
chats={chats}
|
||||||
|
sharedChats={sharedChats}
|
||||||
activeChatId={activeChatId}
|
activeChatId={activeChatId}
|
||||||
onNewChat={() => {
|
onNewChat={() => {
|
||||||
onNewChat();
|
onNewChat();
|
||||||
|
|
@ -134,17 +137,11 @@ export function MobileSidebar({
|
||||||
}}
|
}}
|
||||||
onChatSelect={handleChatSelect}
|
onChatSelect={handleChatSelect}
|
||||||
onChatDelete={onChatDelete}
|
onChatDelete={onChatDelete}
|
||||||
onViewAllChats={onViewAllChats}
|
onViewAllSharedChats={onViewAllSharedChats}
|
||||||
notes={notes}
|
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||||
activeNoteId={activeNoteId}
|
|
||||||
onNoteSelect={handleNoteSelect}
|
|
||||||
onNoteDelete={onNoteDelete}
|
|
||||||
onAddNote={onAddNote}
|
|
||||||
onViewAllNotes={onViewAllNotes}
|
|
||||||
user={user}
|
user={user}
|
||||||
onSettings={onSettings}
|
onSettings={onSettings}
|
||||||
onManageMembers={onManageMembers}
|
onManageMembers={onManageMembers}
|
||||||
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
|
|
||||||
onUserSettings={onUserSettings}
|
onUserSettings={onUserSettings}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
pageUsage={pageUsage}
|
pageUsage={pageUsage}
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { FileText, Loader2, MoreHorizontal } from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface NoteListItemProps {
|
|
||||||
name: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
isReindexing?: boolean;
|
|
||||||
onClick?: () => void;
|
|
||||||
onDelete?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NoteListItem({
|
|
||||||
name,
|
|
||||||
isActive,
|
|
||||||
isReindexing,
|
|
||||||
onClick,
|
|
||||||
onDelete,
|
|
||||||
}: NoteListItemProps) {
|
|
||||||
const t = useTranslations("sidebar");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="group/item relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
|
|
||||||
"[&>span:last-child]:truncate",
|
|
||||||
"hover:bg-accent hover:text-accent-foreground",
|
|
||||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
||||||
isActive && "bg-accent text-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isReindexing ? (
|
|
||||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
|
|
||||||
) : (
|
|
||||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<span>{name}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Actions dropdown */}
|
|
||||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/item:opacity-100 transition-opacity">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
|
||||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
||||||
<span className="sr-only">{t("more_options")}</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" side="right">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDelete?.();
|
|
||||||
}}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
{t("delete")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FileText, FolderOpen, MessageSquare, PenSquare, Plus } from "lucide-react";
|
import { FolderOpen, MessageSquare, PenSquare } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
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";
|
||||||
import type {
|
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||||
ChatItem,
|
|
||||||
NavItem,
|
|
||||||
NoteItem,
|
|
||||||
PageUsage,
|
|
||||||
SearchSpace,
|
|
||||||
User,
|
|
||||||
} from "../../types/layout.types";
|
|
||||||
import { ChatListItem } from "./ChatListItem";
|
import { ChatListItem } from "./ChatListItem";
|
||||||
import { NavSection } from "./NavSection";
|
import { NavSection } from "./NavSection";
|
||||||
import { NoteListItem } from "./NoteListItem";
|
|
||||||
import { PageUsageDisplay } from "./PageUsageDisplay";
|
import { PageUsageDisplay } from "./PageUsageDisplay";
|
||||||
import { SidebarCollapseButton } from "./SidebarCollapseButton";
|
import { SidebarCollapseButton } from "./SidebarCollapseButton";
|
||||||
import { SidebarHeader } from "./SidebarHeader";
|
import { SidebarHeader } from "./SidebarHeader";
|
||||||
|
|
@ -30,21 +22,16 @@ interface SidebarProps {
|
||||||
navItems: NavItem[];
|
navItems: NavItem[];
|
||||||
onNavItemClick?: (item: NavItem) => void;
|
onNavItemClick?: (item: NavItem) => void;
|
||||||
chats: ChatItem[];
|
chats: ChatItem[];
|
||||||
|
sharedChats?: ChatItem[];
|
||||||
activeChatId?: number | null;
|
activeChatId?: number | null;
|
||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
onChatSelect: (chat: ChatItem) => void;
|
onChatSelect: (chat: ChatItem) => void;
|
||||||
onChatDelete?: (chat: ChatItem) => void;
|
onChatDelete?: (chat: ChatItem) => void;
|
||||||
onViewAllChats?: () => void;
|
onViewAllSharedChats?: () => void;
|
||||||
notes: NoteItem[];
|
onViewAllPrivateChats?: () => void;
|
||||||
activeNoteId?: number | null;
|
|
||||||
onNoteSelect: (note: NoteItem) => void;
|
|
||||||
onNoteDelete?: (note: NoteItem) => void;
|
|
||||||
onAddNote?: () => void;
|
|
||||||
onViewAllNotes?: () => void;
|
|
||||||
user: User;
|
user: User;
|
||||||
onSettings?: () => void;
|
onSettings?: () => void;
|
||||||
onManageMembers?: () => void;
|
onManageMembers?: () => void;
|
||||||
onSeeAllSearchSpaces?: () => void;
|
|
||||||
onUserSettings?: () => void;
|
onUserSettings?: () => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
pageUsage?: PageUsage;
|
pageUsage?: PageUsage;
|
||||||
|
|
@ -58,21 +45,16 @@ export function Sidebar({
|
||||||
navItems,
|
navItems,
|
||||||
onNavItemClick,
|
onNavItemClick,
|
||||||
chats,
|
chats,
|
||||||
|
sharedChats = [],
|
||||||
activeChatId,
|
activeChatId,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onChatSelect,
|
onChatSelect,
|
||||||
onChatDelete,
|
onChatDelete,
|
||||||
onViewAllChats,
|
onViewAllSharedChats,
|
||||||
notes,
|
onViewAllPrivateChats,
|
||||||
activeNoteId,
|
|
||||||
onNoteSelect,
|
|
||||||
onNoteDelete,
|
|
||||||
onAddNote,
|
|
||||||
onViewAllNotes,
|
|
||||||
user,
|
user,
|
||||||
onSettings,
|
onSettings,
|
||||||
onManageMembers,
|
onManageMembers,
|
||||||
onSeeAllSearchSpaces,
|
|
||||||
onUserSettings,
|
onUserSettings,
|
||||||
onLogout,
|
onLogout,
|
||||||
pageUsage,
|
pageUsage,
|
||||||
|
|
@ -103,7 +85,6 @@ export function Sidebar({
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onSettings={onSettings}
|
onSettings={onSettings}
|
||||||
onManageMembers={onManageMembers}
|
onManageMembers={onManageMembers}
|
||||||
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
|
|
||||||
/>
|
/>
|
||||||
<div className="">
|
<div className="">
|
||||||
<SidebarCollapseButton
|
<SidebarCollapseButton
|
||||||
|
|
@ -143,7 +124,7 @@ export function Sidebar({
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<div className="flex flex-col items-center gap-2 py-2 w-[60px]">
|
<div className="flex flex-col items-center gap-2 py-2 w-[60px]">
|
||||||
{chats.length > 0 && (
|
{(chats.length > 0 || sharedChats.length > 0) && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -153,52 +134,78 @@ export function Sidebar({
|
||||||
onClick={() => onToggleCollapse?.()}
|
onClick={() => onToggleCollapse?.()}
|
||||||
>
|
>
|
||||||
<MessageSquare className="h-4 w-4" />
|
<MessageSquare className="h-4 w-4" />
|
||||||
<span className="sr-only">{t("recent_chats")}</span>
|
<span className="sr-only">{t("chats")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
{t("recent_chats")} ({chats.length})
|
{t("chats")} ({chats.length + sharedChats.length})
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{notes.length > 0 && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-10 w-10"
|
|
||||||
onClick={() => onToggleCollapse?.()}
|
|
||||||
>
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
<span className="sr-only">{t("notes")}</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">
|
|
||||||
{t("notes")} ({notes.length})
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1 py-2 w-[240px]">
|
<div className="flex flex-col gap-1 py-2 w-[240px]">
|
||||||
|
{/* Shared Chats Section */}
|
||||||
<SidebarSection
|
<SidebarSection
|
||||||
title={t("recent_chats")}
|
title={t("shared_chats")}
|
||||||
defaultOpen={true}
|
defaultOpen={true}
|
||||||
action={
|
action={
|
||||||
onViewAllChats && chats.length > 0 ? (
|
onViewAllSharedChats ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
onClick={onViewAllChats}
|
onClick={onViewAllSharedChats}
|
||||||
>
|
>
|
||||||
<FolderOpen className="h-3.5 w-3.5" />
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">{t("view_all_chats")}</TooltipContent>
|
<TooltipContent side="top">
|
||||||
|
{t("view_all_shared_chats") || "View all shared chats"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sharedChats.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{sharedChats.map((chat) => (
|
||||||
|
<ChatListItem
|
||||||
|
key={chat.id}
|
||||||
|
name={chat.name}
|
||||||
|
isActive={chat.id === activeChatId}
|
||||||
|
onClick={() => onChatSelect(chat)}
|
||||||
|
onDelete={() => onChatDelete?.(chat)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_shared_chats")}</p>
|
||||||
|
)}
|
||||||
|
</SidebarSection>
|
||||||
|
|
||||||
|
{/* Private Chats Section */}
|
||||||
|
<SidebarSection
|
||||||
|
title={t("chats")}
|
||||||
|
defaultOpen={true}
|
||||||
|
action={
|
||||||
|
onViewAllPrivateChats ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5"
|
||||||
|
onClick={onViewAllPrivateChats}
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{t("view_all_private_chats") || "View all private chats"}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|
@ -216,67 +223,7 @@ export function Sidebar({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_recent_chats")}</p>
|
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_chats")}</p>
|
||||||
)}
|
|
||||||
</SidebarSection>
|
|
||||||
|
|
||||||
<SidebarSection
|
|
||||||
title={t("notes")}
|
|
||||||
defaultOpen={true}
|
|
||||||
action={
|
|
||||||
onViewAllNotes && notes.length > 0 ? (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-5 w-5"
|
|
||||||
onClick={onViewAllNotes}
|
|
||||||
>
|
|
||||||
<FolderOpen className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">{t("view_all_notes")}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
persistentAction={
|
|
||||||
onAddNote && notes.length > 0 ? (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onAddNote}>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">{t("add_note")}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{notes.length > 0 ? (
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
{notes.map((note) => (
|
|
||||||
<NoteListItem
|
|
||||||
key={note.id}
|
|
||||||
name={note.name}
|
|
||||||
isActive={note.id === activeNoteId}
|
|
||||||
isReindexing={note.isReindexing}
|
|
||||||
onClick={() => onNoteSelect(note)}
|
|
||||||
onDelete={() => onNoteDelete?.(note)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : onAddNote ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onAddNote}
|
|
||||||
className="flex items-center gap-2 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
{t("create_new_note")}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_notes")}</p>
|
|
||||||
)}
|
)}
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronsUpDown, LayoutGrid, Settings, Users } from "lucide-react";
|
import { ChevronsUpDown, Settings, Users } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -18,7 +18,6 @@ interface SidebarHeaderProps {
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
onSettings?: () => void;
|
onSettings?: () => void;
|
||||||
onManageMembers?: () => void;
|
onManageMembers?: () => void;
|
||||||
onSeeAllSearchSpaces?: () => void;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,7 +26,6 @@ export function SidebarHeader({
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
onSettings,
|
onSettings,
|
||||||
onManageMembers,
|
onManageMembers,
|
||||||
onSeeAllSearchSpaces,
|
|
||||||
className,
|
className,
|
||||||
}: SidebarHeaderProps) {
|
}: SidebarHeaderProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
|
|
@ -59,11 +57,6 @@ export function SidebarHeader({
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
{t("search_space_settings")}
|
{t("search_space_settings")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={onSeeAllSearchSpaces}>
|
|
||||||
<LayoutGrid className="mr-2 h-4 w-4" />
|
|
||||||
{t("see_all_search_spaces")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
export { AllChatsSidebar } from "./AllChatsSidebar";
|
export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
|
||||||
export { AllNotesSidebar } from "./AllNotesSidebar";
|
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
|
||||||
export { ChatListItem } from "./ChatListItem";
|
export { ChatListItem } from "./ChatListItem";
|
||||||
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||||
export { NavSection } from "./NavSection";
|
export { NavSection } from "./NavSection";
|
||||||
export { NoteListItem } from "./NoteListItem";
|
|
||||||
export { PageUsageDisplay } from "./PageUsageDisplay";
|
export { PageUsageDisplay } from "./PageUsageDisplay";
|
||||||
export { Sidebar } from "./Sidebar";
|
export { Sidebar } from "./Sidebar";
|
||||||
export { SidebarCollapseButton } from "./SidebarCollapseButton";
|
export { SidebarCollapseButton } from "./SidebarCollapseButton";
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,18 @@ import type {
|
||||||
GlobalNewLLMConfig,
|
GlobalNewLLMConfig,
|
||||||
NewLLMConfigPublic,
|
NewLLMConfigPublic,
|
||||||
} from "@/contracts/types/new-llm-config.types";
|
} from "@/contracts/types/new-llm-config.types";
|
||||||
|
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||||
|
import { ChatShareButton } from "./chat-share-button";
|
||||||
import { ModelConfigSidebar } from "./model-config-sidebar";
|
import { ModelConfigSidebar } from "./model-config-sidebar";
|
||||||
import { ModelSelector } from "./model-selector";
|
import { ModelSelector } from "./model-selector";
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
searchSpaceId: number;
|
searchSpaceId: number;
|
||||||
|
thread?: ThreadRecord | null;
|
||||||
|
onThreadVisibilityChange?: (visibility: ChatVisibility) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
export function ChatHeader({ searchSpaceId, thread, onThreadVisibilityChange }: ChatHeaderProps) {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [selectedConfig, setSelectedConfig] = useState<
|
const [selectedConfig, setSelectedConfig] = useState<
|
||||||
NewLLMConfigPublic | GlobalNewLLMConfig | null
|
NewLLMConfigPublic | GlobalNewLLMConfig | null
|
||||||
|
|
@ -46,8 +50,9 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex items-center gap-2">
|
||||||
<ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} />
|
<ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} />
|
||||||
|
<ChatShareButton thread={thread ?? null} onVisibilityChange={onThreadVisibilityChange} />
|
||||||
<ModelConfigSidebar
|
<ModelConfigSidebar
|
||||||
open={sidebarOpen}
|
open={sidebarOpen}
|
||||||
onOpenChange={handleSidebarClose}
|
onOpenChange={handleSidebarClose}
|
||||||
|
|
@ -56,6 +61,6 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
mode={sidebarMode}
|
mode={sidebarMode}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
203
surfsense_web/components/new-chat/chat-share-button.tsx
Normal file
203
surfsense_web/components/new-chat/chat-share-button.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Globe, Loader2, Lock, Share2, Users } from "lucide-react";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
type ChatVisibility,
|
||||||
|
type ThreadRecord,
|
||||||
|
updateThreadVisibility,
|
||||||
|
} from "@/lib/chat/thread-persistence";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ChatShareButtonProps {
|
||||||
|
thread: ThreadRecord | null;
|
||||||
|
onVisibilityChange?: (visibility: ChatVisibility) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibilityOptions: {
|
||||||
|
value: ChatVisibility;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: typeof Lock;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
value: "PRIVATE",
|
||||||
|
label: "Private",
|
||||||
|
description: "Only you can access this chat",
|
||||||
|
icon: Lock,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "SEARCH_SPACE",
|
||||||
|
label: "Search Space",
|
||||||
|
description: "All members of this search space can access",
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
|
||||||
|
const currentVisibility = thread?.visibility ?? "PRIVATE";
|
||||||
|
const isOwnThread = thread?.created_by_id !== null; // If we have the thread, we can modify it
|
||||||
|
|
||||||
|
const handleVisibilityChange = useCallback(
|
||||||
|
async (newVisibility: ChatVisibility) => {
|
||||||
|
if (!thread || newVisibility === currentVisibility) {
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
await updateThreadVisibility(thread.id, newVisibility);
|
||||||
|
|
||||||
|
// Refetch all thread queries to update sidebar immediately
|
||||||
|
await queryClient.refetchQueries({
|
||||||
|
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads",
|
||||||
|
});
|
||||||
|
|
||||||
|
onVisibilityChange?.(newVisibility);
|
||||||
|
toast.success(
|
||||||
|
newVisibility === "SEARCH_SPACE" ? "Chat shared with search space" : "Chat is now private"
|
||||||
|
);
|
||||||
|
setOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update visibility:", error);
|
||||||
|
toast.error("Failed to update sharing settings");
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[thread, currentVisibility, onVisibilityChange, queryClient]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Don't show if no thread (new chat that hasn't been created yet)
|
||||||
|
if (!thread) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CurrentIcon = currentVisibility === "PRIVATE" ? Lock : Users;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-7 md:h-9 gap-1.5 md:gap-2 px-2 md:px-3 rounded-lg md:rounded-xl",
|
||||||
|
"border border-border/80 bg-background/50 backdrop-blur-sm",
|
||||||
|
"hover:bg-muted/80 hover:border-border/30 transition-all duration-200",
|
||||||
|
"text-xs md:text-sm font-medium text-foreground",
|
||||||
|
"focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CurrentIcon className="size-3.5 md:size-4 text-muted-foreground" />
|
||||||
|
<span className="hidden md:inline">
|
||||||
|
{currentVisibility === "PRIVATE" ? "Private" : "Shared"}
|
||||||
|
</span>
|
||||||
|
<Share2 className="size-3 md:size-3.5 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[280px] md:w-[320px] p-0 rounded-lg md:rounded-xl shadow-lg border-border/60"
|
||||||
|
align="end"
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<div className="p-3 md:p-4 border-b border-border/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Share2 className="size-4 md:size-5 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold">Share Chat</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Control who can access this conversation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-1.5 space-y-1">
|
||||||
|
{/* Updating overlay */}
|
||||||
|
{isUpdating && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
<span>Updating...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibilityOptions.map((option) => {
|
||||||
|
const isSelected = currentVisibility === option.value;
|
||||||
|
const Icon = option.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => handleVisibilityChange(option.value)}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-start gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
||||||
|
"hover:bg-accent/50 cursor-pointer",
|
||||||
|
"focus:outline-none focus:ring-2 focus:ring-primary/20",
|
||||||
|
isSelected && "bg-accent/80 ring-1 ring-primary/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 p-1.5 rounded-md shrink-0",
|
||||||
|
isSelected ? "bg-primary/10" : "bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"size-3.5",
|
||||||
|
isSelected ? "text-primary" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={cn("text-sm font-medium", isSelected && "text-primary")}>
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
{isSelected && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
|
||||||
|
Current
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
||||||
|
{option.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info footer */}
|
||||||
|
<div className="p-3 bg-muted/30 border-t border-border/30 rounded-b-xl">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Globe className="size-3.5 text-muted-foreground mt-0.5 shrink-0" />
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||||
|
{currentVisibility === "PRIVATE"
|
||||||
|
? "This chat is private. Only you can view and interact with it."
|
||||||
|
: "This chat is shared. All search space members can view, continue the conversation, and delete it."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -25,9 +25,9 @@ export interface DocumentMentionPickerRef {
|
||||||
|
|
||||||
interface DocumentMentionPickerProps {
|
interface DocumentMentionPickerProps {
|
||||||
searchSpaceId: number;
|
searchSpaceId: number;
|
||||||
onSelectionChange: (documents: Document[]) => void;
|
onSelectionChange: (documents: Pick<Document, "id" | "title" | "document_type">[]) => void;
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
initialSelectedDocuments?: Document[];
|
initialSelectedDocuments?: Pick<Document, "id" | "title" | "document_type">[];
|
||||||
externalSearch?: string;
|
externalSearch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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<Document[]>([]);
|
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);
|
||||||
|
|
@ -90,6 +92,17 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
};
|
};
|
||||||
}, [debouncedSearch, searchSpaceId]);
|
}, [debouncedSearch, searchSpaceId]);
|
||||||
|
|
||||||
|
const surfsenseDocsQueryParams = useMemo(() => {
|
||||||
|
const params: { page: number; page_size: number; title?: string } = {
|
||||||
|
page: 0,
|
||||||
|
page_size: PAGE_SIZE,
|
||||||
|
};
|
||||||
|
if (debouncedSearch.trim()) {
|
||||||
|
params.title = debouncedSearch;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
// Use query for fetching first page of documents
|
// Use query for fetching first page of documents
|
||||||
const { data: documents, isLoading: isDocumentsLoading } = useQuery({
|
const { data: documents, isLoading: isDocumentsLoading } = useQuery({
|
||||||
queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams),
|
queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams),
|
||||||
|
|
@ -106,22 +119,45 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
enabled: !!searchSpaceId && !!debouncedSearch.trim() && currentPage === 0,
|
enabled: !!searchSpaceId && !!debouncedSearch.trim() && currentPage === 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update accumulated documents when first page loads
|
// Use query for fetching first page of SurfSense docs
|
||||||
|
const { data: surfsenseDocs, isLoading: isSurfsenseDocsLoading } = useQuery({
|
||||||
|
queryKey: ["surfsense-docs-mention", debouncedSearch],
|
||||||
|
queryFn: () => documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }),
|
||||||
|
staleTime: 3 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update accumulated documents when first page loads - combine both sources
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentPage === 0) {
|
if (currentPage === 0) {
|
||||||
|
const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = [];
|
||||||
|
|
||||||
|
// Add SurfSense docs first (they appear at top)
|
||||||
|
if (surfsenseDocs?.items) {
|
||||||
|
for (const doc of surfsenseDocs.items) {
|
||||||
|
combinedDocs.push({
|
||||||
|
id: doc.id,
|
||||||
|
title: doc.title,
|
||||||
|
document_type: "SURFSENSE_DOCS",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add regular documents
|
||||||
if (debouncedSearch.trim()) {
|
if (debouncedSearch.trim()) {
|
||||||
if (searchedDocuments) {
|
if (searchedDocuments?.items) {
|
||||||
setAccumulatedDocuments(searchedDocuments.items);
|
combinedDocs.push(...searchedDocuments.items);
|
||||||
setHasMore(searchedDocuments.has_more);
|
setHasMore(searchedDocuments.has_more);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (documents) {
|
if (documents?.items) {
|
||||||
setAccumulatedDocuments(documents.items);
|
combinedDocs.push(...documents.items);
|
||||||
setHasMore(documents.has_more);
|
setHasMore(documents.has_more);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAccumulatedDocuments(combinedDocs);
|
||||||
}
|
}
|
||||||
}, [documents, searchedDocuments, debouncedSearch, currentPage]);
|
}, [documents, searchedDocuments, surfsenseDocs, debouncedSearch, currentPage]);
|
||||||
|
|
||||||
// Function to load next page
|
// Function to load next page
|
||||||
const loadNextPage = useCallback(async () => {
|
const loadNextPage = useCallback(async () => {
|
||||||
|
|
@ -175,22 +211,24 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
|
|
||||||
const actualDocuments = accumulatedDocuments;
|
const actualDocuments = accumulatedDocuments;
|
||||||
const actualLoading =
|
const actualLoading =
|
||||||
(debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) && currentPage === 0;
|
((debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) ||
|
||||||
|
isSurfsenseDocsLoading) &&
|
||||||
|
currentPage === 0;
|
||||||
|
|
||||||
// Track already selected document IDs
|
// Track already selected documents using unique key (document_type:id) to avoid ID collisions
|
||||||
const selectedIds = useMemo(
|
const selectedKeys = useMemo(
|
||||||
() => new Set(initialSelectedDocuments.map((d) => d.id)),
|
() => new Set(initialSelectedDocuments.map((d) => `${d.document_type}:${d.id}`)),
|
||||||
[initialSelectedDocuments]
|
[initialSelectedDocuments]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out already selected documents for navigation
|
// Filter out already selected documents for navigation
|
||||||
const selectableDocuments = useMemo(
|
const selectableDocuments = useMemo(
|
||||||
() => actualDocuments.filter((doc) => !selectedIds.has(doc.id)),
|
() => actualDocuments.filter((doc) => !selectedKeys.has(`${doc.document_type}:${doc.id}`)),
|
||||||
[actualDocuments, selectedIds]
|
[actualDocuments, selectedKeys]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectDocument = useCallback(
|
const handleSelectDocument = useCallback(
|
||||||
(doc: Document) => {
|
(doc: Pick<Document, "id" | "title" | "document_type">) => {
|
||||||
onSelectionChange([...initialSelectedDocuments, doc]);
|
onSelectionChange([...initialSelectedDocuments, doc]);
|
||||||
onDone();
|
onDone();
|
||||||
},
|
},
|
||||||
|
|
@ -287,13 +325,16 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
) : (
|
) : (
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{actualDocuments.map((doc) => {
|
{actualDocuments.map((doc) => {
|
||||||
const isAlreadySelected = selectedIds.has(doc.id);
|
const docKey = `${doc.document_type}:${doc.id}`;
|
||||||
const selectableIndex = selectableDocuments.findIndex((d) => d.id === doc.id);
|
const isAlreadySelected = selectedKeys.has(docKey);
|
||||||
|
const selectableIndex = selectableDocuments.findIndex(
|
||||||
|
(d) => d.document_type === doc.document_type && d.id === doc.id
|
||||||
|
);
|
||||||
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
|
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={doc.id}
|
key={docKey}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (el && selectableIndex >= 0) {
|
if (el && selectableIndex >= 0) {
|
||||||
itemRefs.current.set(selectableIndex, el);
|
itemRefs.current.set(selectableIndex, el);
|
||||||
|
|
|
||||||
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,
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { IconLinkPlus, IconUsersGroup } from "@tabler/icons-react";
|
import { IconLinkPlus, IconUsersGroup } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
|
BookOpen,
|
||||||
File,
|
File,
|
||||||
FileText,
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
|
|
@ -86,6 +87,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
|
||||||
return <FileText {...iconProps} />;
|
return <FileText {...iconProps} />;
|
||||||
case "EXTENSION":
|
case "EXTENSION":
|
||||||
return <Webhook {...iconProps} />;
|
return <Webhook {...iconProps} />;
|
||||||
|
case "SURFSENSE_DOCS":
|
||||||
|
return <BookOpen {...iconProps} />;
|
||||||
case "DEEP":
|
case "DEEP":
|
||||||
return <Sparkles {...iconProps} />;
|
return <Sparkles {...iconProps} />;
|
||||||
case "DEEPER":
|
case "DEEPER":
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export const documentTypeEnum = z.enum([
|
||||||
"LINEAR_CONNECTOR",
|
"LINEAR_CONNECTOR",
|
||||||
"NOTE",
|
"NOTE",
|
||||||
"CIRCLEBACK",
|
"CIRCLEBACK",
|
||||||
|
"SURFSENSE_DOCS",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const document = z.object({
|
export const document = z.object({
|
||||||
|
|
@ -183,6 +184,23 @@ export const getSurfsenseDocsByChunkRequest = z.object({
|
||||||
|
|
||||||
export const getSurfsenseDocsByChunkResponse = surfsenseDocsDocumentWithChunks;
|
export const getSurfsenseDocsByChunkResponse = surfsenseDocsDocumentWithChunks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Surfsense docs
|
||||||
|
*/
|
||||||
|
export const getSurfsenseDocsRequest = z.object({
|
||||||
|
queryParams: paginationQueryParams.extend({
|
||||||
|
title: z.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getSurfsenseDocsResponse = z.object({
|
||||||
|
items: z.array(surfsenseDocsDocument),
|
||||||
|
total: z.number(),
|
||||||
|
page: z.number(),
|
||||||
|
page_size: z.number(),
|
||||||
|
has_more: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update document
|
* Update document
|
||||||
*/
|
*/
|
||||||
|
|
@ -227,3 +245,5 @@ export type SurfsenseDocsDocument = z.infer<typeof surfsenseDocsDocument>;
|
||||||
export type SurfsenseDocsDocumentWithChunks = z.infer<typeof surfsenseDocsDocumentWithChunks>;
|
export type SurfsenseDocsDocumentWithChunks = z.infer<typeof surfsenseDocsDocumentWithChunks>;
|
||||||
export type GetSurfsenseDocsByChunkRequest = z.infer<typeof getSurfsenseDocsByChunkRequest>;
|
export type GetSurfsenseDocsByChunkRequest = z.infer<typeof getSurfsenseDocsByChunkRequest>;
|
||||||
export type GetSurfsenseDocsByChunkResponse = z.infer<typeof getSurfsenseDocsByChunkResponse>;
|
export type GetSurfsenseDocsByChunkResponse = z.infer<typeof getSurfsenseDocsByChunkResponse>;
|
||||||
|
export type GetSurfsenseDocsRequest = z.infer<typeof getSurfsenseDocsRequest>;
|
||||||
|
export type GetSurfsenseDocsResponse = z.infer<typeof getSurfsenseDocsResponse>;
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ enum ResponseType {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RequestOptions = {
|
export type RequestOptions = {
|
||||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
contentType?: "application/json" | "application/x-www-form-urlencoded";
|
contentType?: "application/json" | "application/x-www-form-urlencoded";
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
|
@ -273,6 +273,21 @@ class BaseApiService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async patch<T>(
|
||||||
|
url: string,
|
||||||
|
responseSchema?: ZodType<T>,
|
||||||
|
options?: Omit<RequestOptions, "method" | "responseType">
|
||||||
|
) {
|
||||||
|
return this.request(url, responseSchema, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
responseType: ResponseType.JSON,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getBlob(url: string, options?: Omit<RequestOptions, "method" | "responseType">) {
|
async getBlob(url: string, options?: Omit<RequestOptions, "method" | "responseType">) {
|
||||||
return this.request(url, undefined, {
|
return this.request(url, undefined, {
|
||||||
...options,
|
...options,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
type GetDocumentRequest,
|
type GetDocumentRequest,
|
||||||
type GetDocumentsRequest,
|
type GetDocumentsRequest,
|
||||||
type GetDocumentTypeCountsRequest,
|
type GetDocumentTypeCountsRequest,
|
||||||
|
type GetSurfsenseDocsRequest,
|
||||||
getDocumentByChunkRequest,
|
getDocumentByChunkRequest,
|
||||||
getDocumentByChunkResponse,
|
getDocumentByChunkResponse,
|
||||||
getDocumentRequest,
|
getDocumentRequest,
|
||||||
|
|
@ -18,6 +19,8 @@ import {
|
||||||
getDocumentTypeCountsRequest,
|
getDocumentTypeCountsRequest,
|
||||||
getDocumentTypeCountsResponse,
|
getDocumentTypeCountsResponse,
|
||||||
getSurfsenseDocsByChunkResponse,
|
getSurfsenseDocsByChunkResponse,
|
||||||
|
getSurfsenseDocsRequest,
|
||||||
|
getSurfsenseDocsResponse,
|
||||||
type SearchDocumentsRequest,
|
type SearchDocumentsRequest,
|
||||||
searchDocumentsRequest,
|
searchDocumentsRequest,
|
||||||
searchDocumentsResponse,
|
searchDocumentsResponse,
|
||||||
|
|
@ -221,6 +224,35 @@ class DocumentsApiService {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all Surfsense documentation documents
|
||||||
|
*/
|
||||||
|
getSurfsenseDocs = async (request: GetSurfsenseDocsRequest) => {
|
||||||
|
const parsedRequest = getSurfsenseDocsRequest.safeParse(request);
|
||||||
|
|
||||||
|
if (!parsedRequest.success) {
|
||||||
|
console.error("Invalid request:", parsedRequest.error);
|
||||||
|
|
||||||
|
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||||
|
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform query params to be string values
|
||||||
|
const transformedQueryParams = parsedRequest.data.queryParams
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(parsedRequest.data.queryParams).map(([k, v]) => [k, String(v)])
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const queryParams = transformedQueryParams
|
||||||
|
? new URLSearchParams(transformedQueryParams).toString()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const url = `/api/v1/surfsense-docs?${queryParams}`;
|
||||||
|
|
||||||
|
return baseApiService.get(url, getSurfsenseDocsResponse);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a document
|
* Update a document
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,17 @@ import { baseApiService } from "@/lib/apis/base-api.service";
|
||||||
// Types matching backend schemas
|
// Types matching backend schemas
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat visibility levels - matches backend ChatVisibility enum
|
||||||
|
*/
|
||||||
|
export type ChatVisibility = "PRIVATE" | "SEARCH_SPACE";
|
||||||
|
|
||||||
export interface ThreadRecord {
|
export interface ThreadRecord {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
visibility: ChatVisibility;
|
||||||
|
created_by_id: string | null;
|
||||||
search_space_id: number;
|
search_space_id: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|
@ -35,6 +42,9 @@ export interface ThreadListItem {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
visibility: ChatVisibility;
|
||||||
|
created_by_id: string | null;
|
||||||
|
is_own_thread: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
@ -127,6 +137,25 @@ export async function deleteThread(threadId: number): Promise<void> {
|
||||||
await baseApiService.delete(`/api/v1/threads/${threadId}`);
|
await baseApiService.delete(`/api/v1/threads/${threadId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update thread visibility (share/unshare)
|
||||||
|
*/
|
||||||
|
export async function updateThreadVisibility(
|
||||||
|
threadId: number,
|
||||||
|
visibility: ChatVisibility
|
||||||
|
): Promise<ThreadRecord> {
|
||||||
|
return baseApiService.patch<ThreadRecord>(`/api/v1/threads/${threadId}/visibility`, undefined, {
|
||||||
|
body: { visibility },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full thread details including visibility
|
||||||
|
*/
|
||||||
|
export async function getThreadFull(threadId: number): Promise<ThreadRecord> {
|
||||||
|
return baseApiService.get<ThreadRecord>(`/api/v1/threads/${threadId}/full`);
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Thread List Manager (for thread list sidebar)
|
// Thread List Manager (for thread list sidebar)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -105,7 +109,6 @@
|
||||||
"title": "User Settings",
|
"title": "User Settings",
|
||||||
"description": "Manage your account settings and API access",
|
"description": "Manage your account settings and API access",
|
||||||
"back_to_app": "Back to app",
|
"back_to_app": "Back to app",
|
||||||
"footer": "User Settings",
|
|
||||||
"api_key_nav_label": "API Key",
|
"api_key_nav_label": "API Key",
|
||||||
"api_key_nav_description": "Manage your API access token",
|
"api_key_nav_description": "Manage your API access token",
|
||||||
"api_key_title": "API Key",
|
"api_key_title": "API Key",
|
||||||
|
|
@ -160,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"
|
||||||
|
|
@ -312,6 +319,8 @@
|
||||||
"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",
|
||||||
"processing_documents": "Processing documents...",
|
"processing_documents": "Processing documents...",
|
||||||
"active_tasks_count": "{count} active task(s)"
|
"active_tasks_count": "{count} active task(s)"
|
||||||
},
|
},
|
||||||
|
|
@ -628,37 +637,23 @@
|
||||||
"manage": "Manage"
|
"manage": "Manage"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"recent_chats": "Recent Chats",
|
"chats": "Private Chats",
|
||||||
|
"shared_chats": "Shared Chats",
|
||||||
"search_chats": "Search chats...",
|
"search_chats": "Search chats...",
|
||||||
"no_chats_found": "No chats found",
|
"no_chats_found": "No chats found",
|
||||||
"no_recent_chats": "No recent chats",
|
"no_shared_chats": "No shared chats",
|
||||||
"view_all_chats": "View all chats",
|
"view_all_shared_chats": "View all shared chats",
|
||||||
"all_chats": "All Chats",
|
"view_all_private_chats": "View all private chats",
|
||||||
"all_chats_description": "Browse and manage all your chats",
|
|
||||||
"no_chats": "No chats yet",
|
"no_chats": "No chats yet",
|
||||||
"start_new_chat_hint": "Start a new chat",
|
"start_new_chat_hint": "Start a new chat",
|
||||||
"error_loading_chats": "Error loading chats",
|
"error_loading_chats": "Error loading chats",
|
||||||
"chat_deleted": "Chat deleted successfully",
|
"chat_deleted": "Chat deleted successfully",
|
||||||
"error_deleting_chat": "Failed to delete chat",
|
"error_deleting_chat": "Failed to delete chat",
|
||||||
"search_space": "Search Space",
|
|
||||||
"notes": "Notes",
|
|
||||||
"all_notes": "All Notes",
|
|
||||||
"all_notes_description": "Browse and manage all your notes",
|
|
||||||
"search_notes": "Search notes...",
|
|
||||||
"no_results_found": "No notes found",
|
|
||||||
"try_different_search": "Try a different search term",
|
|
||||||
"no_notes": "No notes yet",
|
|
||||||
"create_new_note": "Create a new note",
|
|
||||||
"error_loading_notes": "Error loading notes",
|
|
||||||
"loading": "Loading...",
|
|
||||||
"deleting": "Deleting...",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"created": "Created",
|
"try_different_search": "Try a different search term",
|
||||||
"updated": "Updated",
|
"updated": "Updated",
|
||||||
"more_options": "More options",
|
"more_options": "More options",
|
||||||
"clear_search": "Clear search",
|
"clear_search": "Clear search",
|
||||||
"view_all_notes": "View all notes",
|
|
||||||
"add_note": "Add note",
|
|
||||||
"archive": "Archive",
|
"archive": "Archive",
|
||||||
"unarchive": "Restore",
|
"unarchive": "Restore",
|
||||||
"chat_archived": "Chat archived",
|
"chat_archived": "Chat archived",
|
||||||
|
|
@ -684,6 +679,16 @@
|
||||||
"server_error": "Server error",
|
"server_error": "Server error",
|
||||||
"network_error": "Network error"
|
"network_error": "Network error"
|
||||||
},
|
},
|
||||||
|
"searchSpaceSettings": {
|
||||||
|
"title": "Search Space Settings",
|
||||||
|
"back_to_app": "Back to app",
|
||||||
|
"nav_agent_configs": "Agent Configs",
|
||||||
|
"nav_agent_configs_desc": "LLM models with prompts & citations",
|
||||||
|
"nav_role_assignments": "Role Assignments",
|
||||||
|
"nav_role_assignments_desc": "Assign configs to agent roles",
|
||||||
|
"nav_system_instructions": "System Instructions",
|
||||||
|
"nav_system_instructions_desc": "SearchSpace-wide AI instructions"
|
||||||
|
},
|
||||||
"homepage": {
|
"homepage": {
|
||||||
"hero_title_part1": "The AI Workspace",
|
"hero_title_part1": "The AI Workspace",
|
||||||
"hero_title_part2": "Built for Teams",
|
"hero_title_part2": "Built for Teams",
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,6 @@
|
||||||
"title": "用户设置",
|
"title": "用户设置",
|
||||||
"description": "管理您的账户设置和API访问",
|
"description": "管理您的账户设置和API访问",
|
||||||
"back_to_app": "返回应用",
|
"back_to_app": "返回应用",
|
||||||
"footer": "用户设置",
|
|
||||||
"api_key_nav_label": "API密钥",
|
"api_key_nav_label": "API密钥",
|
||||||
"api_key_nav_description": "管理您的API访问令牌",
|
"api_key_nav_description": "管理您的API访问令牌",
|
||||||
"api_key_title": "API密钥",
|
"api_key_title": "API密钥",
|
||||||
|
|
@ -160,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": "创建于"
|
||||||
|
|
@ -312,6 +315,8 @@
|
||||||
"rows_per_page": "每页行数",
|
"rows_per_page": "每页行数",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"refresh_success": "文档已刷新",
|
"refresh_success": "文档已刷新",
|
||||||
|
"upload_documents": "上传文档",
|
||||||
|
"create_shared_note": "创建共享笔记",
|
||||||
"processing_documents": "正在处理文档...",
|
"processing_documents": "正在处理文档...",
|
||||||
"active_tasks_count": "{count} 个正在进行的工作项"
|
"active_tasks_count": "{count} 个正在进行的工作项"
|
||||||
},
|
},
|
||||||
|
|
@ -628,37 +633,29 @@
|
||||||
"manage": "管理"
|
"manage": "管理"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"recent_chats": "最近对话",
|
"chats": "私人对话",
|
||||||
|
"shared_chats": "共享对话",
|
||||||
"search_chats": "搜索对话...",
|
"search_chats": "搜索对话...",
|
||||||
"no_chats_found": "未找到对话",
|
"no_chats_found": "未找到对话",
|
||||||
"no_recent_chats": "暂无最近对话",
|
"no_shared_chats": "暂无共享对话",
|
||||||
"view_all_chats": "查看所有对话",
|
"view_all_shared_chats": "查看所有共享对话",
|
||||||
"all_chats": "所有对话",
|
"view_all_private_chats": "查看所有私人对话",
|
||||||
"all_chats_description": "浏览和管理您的所有对话",
|
|
||||||
"no_chats": "暂无对话",
|
"no_chats": "暂无对话",
|
||||||
"start_new_chat_hint": "开始新对话",
|
"start_new_chat_hint": "开始新对话",
|
||||||
"error_loading_chats": "加载对话时出错",
|
"error_loading_chats": "加载对话时出错",
|
||||||
"chat_deleted": "对话删除成功",
|
"chat_deleted": "对话删除成功",
|
||||||
"error_deleting_chat": "删除对话失败",
|
"error_deleting_chat": "删除对话失败",
|
||||||
"search_space": "搜索空间",
|
|
||||||
"notes": "笔记",
|
|
||||||
"all_notes": "所有笔记",
|
|
||||||
"all_notes_description": "浏览和管理您的所有笔记",
|
|
||||||
"search_notes": "搜索笔记...",
|
|
||||||
"no_results_found": "未找到笔记",
|
|
||||||
"try_different_search": "尝试其他搜索词",
|
|
||||||
"no_notes": "暂无笔记",
|
|
||||||
"create_new_note": "创建新笔记",
|
|
||||||
"error_loading_notes": "加载笔记时出错",
|
|
||||||
"loading": "加载中...",
|
|
||||||
"deleting": "删除中...",
|
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"created": "创建时间",
|
"try_different_search": "尝试其他搜索词",
|
||||||
"updated": "更新时间",
|
"updated": "更新时间",
|
||||||
"more_options": "更多选项",
|
"more_options": "更多选项",
|
||||||
"clear_search": "清除搜索",
|
"clear_search": "清除搜索",
|
||||||
"view_all_notes": "查看所有笔记",
|
"archive": "归档",
|
||||||
"add_note": "添加笔记",
|
"unarchive": "恢复",
|
||||||
|
"chat_archived": "对话已归档",
|
||||||
|
"chat_unarchived": "对话已恢复",
|
||||||
|
"no_archived_chats": "暂无已归档对话",
|
||||||
|
"error_archiving_chat": "归档对话失败",
|
||||||
"new_chat": "新对话",
|
"new_chat": "新对话",
|
||||||
"select_search_space": "选择搜索空间",
|
"select_search_space": "选择搜索空间",
|
||||||
"manage_members": "管理成员",
|
"manage_members": "管理成员",
|
||||||
|
|
@ -678,6 +675,16 @@
|
||||||
"server_error": "服务器错误",
|
"server_error": "服务器错误",
|
||||||
"network_error": "网络错误"
|
"network_error": "网络错误"
|
||||||
},
|
},
|
||||||
|
"searchSpaceSettings": {
|
||||||
|
"title": "搜索空间设置",
|
||||||
|
"back_to_app": "返回应用",
|
||||||
|
"nav_agent_configs": "代理配置",
|
||||||
|
"nav_agent_configs_desc": "LLM 模型配置提示词和引用",
|
||||||
|
"nav_role_assignments": "角色分配",
|
||||||
|
"nav_role_assignments_desc": "为代理角色分配配置",
|
||||||
|
"nav_system_instructions": "系统指令",
|
||||||
|
"nav_system_instructions_desc": "搜索空间级别的 AI 指令"
|
||||||
|
},
|
||||||
"homepage": {
|
"homepage": {
|
||||||
"hero_title_part1": "AI 工作空间",
|
"hero_title_part1": "AI 工作空间",
|
||||||
"hero_title_part2": "为团队而生",
|
"hero_title_part2": "为团队而生",
|
||||||
|
|
|
||||||
|
|
@ -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