mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-09 07:42:39 +02:00
Merge branch 'dev' of https://github.com/MODSetter/SurfSense into dev
This commit is contained in:
commit
20a13df7e7
45 changed files with 1554 additions and 641 deletions
|
|
@ -178,9 +178,26 @@ async def create_documents_file_upload(
|
|||
session, unique_identifier_hash
|
||||
)
|
||||
if existing:
|
||||
# Clean up temp file for duplicates
|
||||
os.unlink(temp_path)
|
||||
skipped_duplicates += 1
|
||||
if DocumentStatus.is_state(existing.status, DocumentStatus.READY):
|
||||
# True duplicate — content already indexed, skip
|
||||
os.unlink(temp_path)
|
||||
skipped_duplicates += 1
|
||||
continue
|
||||
|
||||
# Existing document is stuck (failed/pending/processing)
|
||||
# Reset it to pending and re-dispatch for processing
|
||||
existing.status = DocumentStatus.pending()
|
||||
existing.content = "Processing..."
|
||||
existing.document_metadata = {
|
||||
**(existing.document_metadata or {}),
|
||||
"file_size": file_size,
|
||||
"upload_time": datetime.now().isoformat(),
|
||||
}
|
||||
existing.updated_at = get_current_timestamp()
|
||||
created_documents.append(existing)
|
||||
files_to_process.append(
|
||||
(existing, temp_path, file.filename or "unknown")
|
||||
)
|
||||
continue
|
||||
|
||||
# Create pending document (visible immediately in UI via ElectricSQL)
|
||||
|
|
|
|||
|
|
@ -144,6 +144,9 @@ async def list_notifications(
|
|||
before_date: str | None = Query(
|
||||
None, description="Get notifications before this ISO date (for pagination)"
|
||||
),
|
||||
search: str | None = Query(
|
||||
None, description="Search notifications by title or message (case-insensitive)"
|
||||
),
|
||||
limit: int = Query(50, ge=1, le=100, description="Number of items to return"),
|
||||
offset: int = Query(0, ge=0, description="Number of items to skip"),
|
||||
user: User = Depends(current_active_user),
|
||||
|
|
@ -191,6 +194,15 @@ async def list_notifications(
|
|||
detail="Invalid date format. Use ISO format (e.g., 2024-01-15T00:00:00Z)",
|
||||
) from None
|
||||
|
||||
# Filter by search query (case-insensitive title/message search)
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
search_filter = Notification.title.ilike(
|
||||
search_term
|
||||
) | Notification.message.ilike(search_term)
|
||||
query = query.where(search_filter)
|
||||
count_query = count_query.where(search_filter)
|
||||
|
||||
# Get total count
|
||||
total_result = await session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""Celery tasks for document processing."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
|
@ -17,6 +19,79 @@ from app.tasks.document_processors import (
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ===== Redis heartbeat for document processing tasks =====
|
||||
# Same mechanism as connector indexing heartbeats (search_source_connectors_routes.py).
|
||||
# A background coroutine refreshes a Redis key every 60s with a 2-min TTL.
|
||||
# If the Celery worker crashes, the coroutine dies, the key expires, and the
|
||||
# stale_notification_cleanup_task detects the missing key and marks the
|
||||
# notification + document as failed.
|
||||
_doc_heartbeat_redis = None
|
||||
HEARTBEAT_TTL_SECONDS = 120 # 2 minutes — same as connector indexing
|
||||
HEARTBEAT_REFRESH_INTERVAL = 60 # Refresh every 60 seconds
|
||||
|
||||
|
||||
def _get_doc_heartbeat_redis():
|
||||
"""Get Redis client for document processing heartbeat."""
|
||||
import redis
|
||||
|
||||
global _doc_heartbeat_redis
|
||||
if _doc_heartbeat_redis is None:
|
||||
redis_url = os.getenv(
|
||||
"REDIS_APP_URL",
|
||||
os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0"),
|
||||
)
|
||||
_doc_heartbeat_redis = redis.from_url(redis_url, decode_responses=True)
|
||||
return _doc_heartbeat_redis
|
||||
|
||||
|
||||
def _get_heartbeat_key(notification_id: int) -> str:
|
||||
"""Generate Redis key for document processing heartbeat.
|
||||
|
||||
Uses same key pattern as connector indexing: indexing:heartbeat:{notification_id}
|
||||
"""
|
||||
return f"indexing:heartbeat:{notification_id}"
|
||||
|
||||
|
||||
def _start_heartbeat(notification_id: int) -> None:
|
||||
"""Set initial Redis heartbeat key for a document processing task."""
|
||||
try:
|
||||
key = _get_heartbeat_key(notification_id)
|
||||
_get_doc_heartbeat_redis().setex(key, HEARTBEAT_TTL_SECONDS, "started")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to set initial heartbeat for notification {notification_id}: {e}"
|
||||
)
|
||||
|
||||
|
||||
def _stop_heartbeat(notification_id: int) -> None:
|
||||
"""Delete Redis heartbeat key when task completes (success or failure)."""
|
||||
try:
|
||||
key = _get_heartbeat_key(notification_id)
|
||||
_get_doc_heartbeat_redis().delete(key)
|
||||
except Exception:
|
||||
pass # Key will expire on its own
|
||||
|
||||
|
||||
async def _run_heartbeat_loop(notification_id: int):
|
||||
"""Background coroutine that refreshes Redis heartbeat every 60 seconds.
|
||||
|
||||
This keeps the heartbeat alive while the task is running.
|
||||
When the task finishes, this coroutine is cancelled via heartbeat_task.cancel().
|
||||
When the worker crashes, this coroutine dies with it and the key expires.
|
||||
"""
|
||||
key = _get_heartbeat_key(notification_id)
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(HEARTBEAT_REFRESH_INTERVAL)
|
||||
try:
|
||||
_get_doc_heartbeat_redis().setex(key, HEARTBEAT_TTL_SECONDS, "alive")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to refresh heartbeat for notification {notification_id}: {e}"
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
pass # Normal cancellation when task completes
|
||||
|
||||
|
||||
def get_celery_session_maker():
|
||||
"""
|
||||
|
|
@ -44,8 +119,6 @@ def process_extension_document_task(
|
|||
search_space_id: ID of the search space
|
||||
user_id: ID of the user
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# Create a new event loop for this task
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
|
@ -196,8 +269,6 @@ def process_youtube_video_task(self, url: str, search_space_id: int, user_id: st
|
|||
search_space_id: ID of the search space
|
||||
user_id: ID of the user
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
|
|
@ -226,6 +297,10 @@ async def _process_youtube_video(url: str, search_space_id: int, user_id: str):
|
|||
)
|
||||
)
|
||||
|
||||
# Start Redis heartbeat for stale task detection
|
||||
_start_heartbeat(notification.id)
|
||||
heartbeat_task = asyncio.create_task(_run_heartbeat_loop(notification.id))
|
||||
|
||||
log_entry = await task_logger.log_task_start(
|
||||
task_name="process_youtube_video",
|
||||
source="document_processor",
|
||||
|
|
@ -243,7 +318,7 @@ async def _process_youtube_video(url: str, search_space_id: int, user_id: str):
|
|||
)
|
||||
|
||||
result = await add_youtube_video_document(
|
||||
session, url, search_space_id, user_id
|
||||
session, url, search_space_id, user_id, notification=notification
|
||||
)
|
||||
|
||||
if result:
|
||||
|
|
@ -307,6 +382,10 @@ async def _process_youtube_video(url: str, search_space_id: int, user_id: str):
|
|||
|
||||
logger.error(f"Error processing YouTube video: {e!s}")
|
||||
raise
|
||||
finally:
|
||||
# Stop heartbeat — key deleted on success, expires on crash
|
||||
heartbeat_task.cancel()
|
||||
_stop_heartbeat(notification.id)
|
||||
|
||||
|
||||
@celery_app.task(name="process_file_upload", bind=True)
|
||||
|
|
@ -322,8 +401,6 @@ def process_file_upload_task(
|
|||
search_space_id: ID of the search space
|
||||
user_id: ID of the user
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import traceback
|
||||
|
||||
logger.info(
|
||||
|
|
@ -336,7 +413,7 @@ def process_file_upload_task(
|
|||
if not os.path.exists(file_path):
|
||||
logger.error(
|
||||
f"[process_file_upload] File does not exist: {file_path}. "
|
||||
"The temp file may have been cleaned up before the task ran."
|
||||
"File may have been removed before syncing could start."
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -370,8 +447,6 @@ async def _process_file_upload(
|
|||
file_path: str, filename: str, search_space_id: int, user_id: str
|
||||
):
|
||||
"""Process file upload with new session."""
|
||||
import os
|
||||
|
||||
from app.tasks.document_processors.file_processors import process_file_in_background
|
||||
|
||||
logger.info(f"[_process_file_upload] Starting async processing for: {filename}")
|
||||
|
|
@ -404,6 +479,10 @@ async def _process_file_upload(
|
|||
f"[_process_file_upload] Notification created with ID: {notification.id if notification else 'None'}"
|
||||
)
|
||||
|
||||
# Start Redis heartbeat for stale task detection
|
||||
_start_heartbeat(notification.id)
|
||||
heartbeat_task = asyncio.create_task(_run_heartbeat_loop(notification.id))
|
||||
|
||||
log_entry = await task_logger.log_task_start(
|
||||
task_name="process_file_upload",
|
||||
source="document_processor",
|
||||
|
|
@ -535,6 +614,10 @@ async def _process_file_upload(
|
|||
)
|
||||
logger.error(error_message)
|
||||
raise
|
||||
finally:
|
||||
# Stop heartbeat — key deleted on success, expires on crash
|
||||
heartbeat_task.cancel()
|
||||
_stop_heartbeat(notification.id)
|
||||
|
||||
|
||||
@celery_app.task(name="process_file_upload_with_document", bind=True)
|
||||
|
|
@ -560,8 +643,6 @@ def process_file_upload_with_document_task(
|
|||
search_space_id: ID of the search space
|
||||
user_id: ID of the user
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import traceback
|
||||
|
||||
logger.info(
|
||||
|
|
@ -573,7 +654,7 @@ def process_file_upload_with_document_task(
|
|||
if not os.path.exists(temp_path):
|
||||
logger.error(
|
||||
f"[process_file_upload_with_document] File does not exist: {temp_path}. "
|
||||
"The temp file may have been cleaned up before the task ran."
|
||||
"File may have been removed before syncing could start."
|
||||
)
|
||||
# Mark document as failed since file is missing
|
||||
loop = asyncio.new_event_loop()
|
||||
|
|
@ -582,7 +663,7 @@ def process_file_upload_with_document_task(
|
|||
loop.run_until_complete(
|
||||
_mark_document_failed(
|
||||
document_id,
|
||||
"File not found - temp file may have been cleaned up",
|
||||
"File not found. Please re-upload the file.",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
|
|
@ -640,8 +721,6 @@ async def _process_file_with_document(
|
|||
- Processes the file (parsing, embedding, chunking)
|
||||
- Updates document to 'ready' on success or 'failed' on error
|
||||
"""
|
||||
import os
|
||||
|
||||
from app.db import Document, DocumentStatus
|
||||
from app.tasks.document_processors.base import get_current_timestamp
|
||||
from app.tasks.document_processors.file_processors import (
|
||||
|
|
@ -689,6 +768,19 @@ async def _process_file_with_document(
|
|||
)
|
||||
)
|
||||
|
||||
# Store document_id in notification metadata so cleanup task can find the document
|
||||
if notification and notification.notification_metadata is not None:
|
||||
notification.notification_metadata["document_id"] = document_id
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
flag_modified(notification, "notification_metadata")
|
||||
await session.commit()
|
||||
await session.refresh(notification)
|
||||
|
||||
# Start Redis heartbeat for stale task detection
|
||||
_start_heartbeat(notification.id)
|
||||
heartbeat_task = asyncio.create_task(_run_heartbeat_loop(notification.id))
|
||||
|
||||
log_entry = await task_logger.log_task_start(
|
||||
task_name="process_file_upload_with_document",
|
||||
source="document_processor",
|
||||
|
|
@ -822,6 +914,10 @@ async def _process_file_with_document(
|
|||
raise
|
||||
|
||||
finally:
|
||||
# Stop heartbeat — key deleted on success, expires on crash
|
||||
heartbeat_task.cancel()
|
||||
_stop_heartbeat(notification.id)
|
||||
|
||||
# Clean up temp file
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
|
|
@ -856,8 +952,6 @@ def process_circleback_meeting_task(
|
|||
search_space_id: ID of the search space
|
||||
connector_id: ID of the Circleback connector (for deletion support)
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
|
|
@ -897,6 +991,7 @@ async def _process_circleback_meeting(
|
|||
|
||||
# Create notification if user_id is available
|
||||
notification = None
|
||||
heartbeat_task = None
|
||||
if user_id:
|
||||
notification = (
|
||||
await NotificationService.document_processing.notify_processing_started(
|
||||
|
|
@ -908,6 +1003,10 @@ async def _process_circleback_meeting(
|
|||
)
|
||||
)
|
||||
|
||||
# Start Redis heartbeat for stale task detection
|
||||
_start_heartbeat(notification.id)
|
||||
heartbeat_task = asyncio.create_task(_run_heartbeat_loop(notification.id))
|
||||
|
||||
log_entry = await task_logger.log_task_start(
|
||||
task_name="process_circleback_meeting",
|
||||
source="circleback_webhook",
|
||||
|
|
@ -1000,3 +1099,9 @@ async def _process_circleback_meeting(
|
|||
|
||||
logger.error(f"Error processing Circleback meeting: {e!s}")
|
||||
raise
|
||||
finally:
|
||||
# Stop heartbeat — key deleted on success, expires on crash
|
||||
if heartbeat_task:
|
||||
heartbeat_task.cancel()
|
||||
if notification:
|
||||
_stop_heartbeat(notification.id)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,25 @@
|
|||
"""Celery task to detect and mark stale connector indexing notifications as failed.
|
||||
"""Celery task to detect and mark stale notifications as failed.
|
||||
|
||||
This task runs periodically (every 5 minutes by default) to find notifications
|
||||
that are stuck in "in_progress" status but don't have an active Redis heartbeat key.
|
||||
These are marked as "failed" to prevent the frontend from showing a perpetual "syncing" state.
|
||||
These are marked as "failed" to prevent the frontend from showing a perpetual
|
||||
"syncing" or "processing" state.
|
||||
|
||||
Additionally, it cleans up documents stuck in pending/processing state that belong
|
||||
to connectors with stale notifications.
|
||||
It handles two notification types:
|
||||
1. **connector_indexing** — connector sync tasks (Google Calendar, etc.)
|
||||
2. **document_processing** — manual file uploads, YouTube videos, etc.
|
||||
|
||||
Additionally, it cleans up documents stuck in pending/processing state:
|
||||
- For connectors: by connector_id
|
||||
- For non-connector documents (FILE uploads, YouTube): by document_id from notification metadata
|
||||
|
||||
Detection mechanism:
|
||||
- Active indexing tasks set a Redis key with TTL (2 minutes) as a heartbeat
|
||||
- If the task crashes, the Redis key expires automatically
|
||||
- Active tasks set a Redis key with TTL (2 minutes) as a heartbeat
|
||||
- A background coroutine refreshes the key every 60 seconds
|
||||
- If the task/worker crashes, the Redis key expires automatically
|
||||
- This cleanup task checks for in-progress notifications without a Redis heartbeat key
|
||||
- Such notifications are marked as failed with O(1) batch UPDATE
|
||||
- Documents with pending/processing status for those connectors are also marked as failed
|
||||
- Associated documents are also marked as failed
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
|
|
@ -36,8 +43,9 @@ logger = logging.getLogger(__name__)
|
|||
# Redis client for checking heartbeats
|
||||
_redis_client: redis.Redis | None = None
|
||||
|
||||
# Error message shown to users when sync is interrupted
|
||||
# Error messages shown to users when tasks are interrupted
|
||||
STALE_SYNC_ERROR_MESSAGE = "Sync was interrupted unexpectedly. Please retry."
|
||||
STALE_PROCESSING_ERROR_MESSAGE = "Syncing was interrupted unexpectedly. Please retry."
|
||||
|
||||
|
||||
def get_redis_client() -> redis.Redis:
|
||||
|
|
@ -70,14 +78,13 @@ def get_celery_session_maker():
|
|||
@celery_app.task(name="cleanup_stale_indexing_notifications")
|
||||
def cleanup_stale_indexing_notifications_task():
|
||||
"""
|
||||
Check for stale connector indexing notifications and mark them as failed.
|
||||
Check for stale notifications and mark them as failed.
|
||||
|
||||
This task finds notifications that:
|
||||
- Have type = 'connector_indexing'
|
||||
- Have metadata.status = 'in_progress'
|
||||
- Do NOT have a corresponding Redis heartbeat key (meaning task crashed)
|
||||
Handles two notification types:
|
||||
1. connector_indexing — connector sync tasks
|
||||
2. document_processing — manual file uploads, YouTube videos, etc.
|
||||
|
||||
And marks them as failed with O(1) batch UPDATE.
|
||||
Detection: Redis heartbeat key with 2-min TTL. Missing key = stale task.
|
||||
Also marks associated pending/processing documents as failed.
|
||||
"""
|
||||
import asyncio
|
||||
|
|
@ -87,6 +94,7 @@ def cleanup_stale_indexing_notifications_task():
|
|||
|
||||
try:
|
||||
loop.run_until_complete(_cleanup_stale_notifications())
|
||||
loop.run_until_complete(_cleanup_stale_document_processing_notifications())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
|
@ -269,3 +277,186 @@ async def _cleanup_stuck_documents(session, connector_ids: list[int]):
|
|||
exc_info=True,
|
||||
)
|
||||
# Don't raise - let the notification cleanup continue even if document cleanup fails
|
||||
|
||||
|
||||
# ===== Document Processing Cleanup (FILE uploads, YouTube, etc.) =====
|
||||
|
||||
|
||||
async def _cleanup_stale_document_processing_notifications():
|
||||
"""Find and mark stale document processing notifications as failed.
|
||||
|
||||
Same Redis heartbeat mechanism as connector indexing cleanup, but for
|
||||
document_processing type notifications (FILE uploads, YouTube videos, etc.).
|
||||
|
||||
For each stale notification that contains a document_id in its metadata,
|
||||
the associated document is also marked as failed.
|
||||
"""
|
||||
async with get_celery_session_maker()() as session:
|
||||
try:
|
||||
# Find all in-progress document processing notifications
|
||||
result = await session.execute(
|
||||
select(
|
||||
Notification.id,
|
||||
Notification.notification_metadata,
|
||||
).where(
|
||||
and_(
|
||||
Notification.type == "document_processing",
|
||||
Notification.notification_metadata["status"].astext
|
||||
== "in_progress",
|
||||
)
|
||||
)
|
||||
)
|
||||
in_progress_rows = result.fetchall()
|
||||
|
||||
if not in_progress_rows:
|
||||
logger.debug("No in-progress document processing notifications found")
|
||||
return
|
||||
|
||||
# Check which ones are missing heartbeat keys in Redis
|
||||
redis_client = get_redis_client()
|
||||
stale_notification_ids = []
|
||||
stale_document_ids = []
|
||||
|
||||
for row in in_progress_rows:
|
||||
notification_id = row[0]
|
||||
metadata = row[1] # Full metadata dict
|
||||
heartbeat_key = _get_heartbeat_key(notification_id)
|
||||
if not redis_client.exists(heartbeat_key):
|
||||
stale_notification_ids.append(notification_id)
|
||||
# Extract document_id from metadata for document cleanup
|
||||
if metadata and isinstance(metadata, dict):
|
||||
doc_id = metadata.get("document_id")
|
||||
if doc_id is not None:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
stale_document_ids.append(int(doc_id))
|
||||
|
||||
if not stale_notification_ids:
|
||||
logger.debug(
|
||||
f"All {len(in_progress_rows)} in-progress document processing "
|
||||
"notifications have active Redis heartbeats"
|
||||
)
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
f"Found {len(stale_notification_ids)} stale document processing "
|
||||
f"notifications (no Redis heartbeat): {stale_notification_ids}"
|
||||
)
|
||||
|
||||
# O(1) Batch UPDATE: Mark stale notifications as failed
|
||||
update_data = {
|
||||
"status": "failed",
|
||||
"completed_at": datetime.now(UTC).isoformat(),
|
||||
"error_message": STALE_PROCESSING_ERROR_MESSAGE,
|
||||
"processing_stage": "failed",
|
||||
}
|
||||
|
||||
await session.execute(
|
||||
text("""
|
||||
UPDATE notifications
|
||||
SET metadata = metadata || CAST(:update_json AS jsonb),
|
||||
title = 'Failed: ' || COALESCE(metadata->>'document_name', 'Document'),
|
||||
message = :display_message
|
||||
WHERE id = ANY(:ids)
|
||||
"""),
|
||||
{
|
||||
"update_json": json.dumps(update_data),
|
||||
"display_message": STALE_PROCESSING_ERROR_MESSAGE,
|
||||
"ids": stale_notification_ids,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Successfully marked {len(stale_notification_ids)} stale document "
|
||||
"processing notifications as failed"
|
||||
)
|
||||
|
||||
# Clean up stuck documents by document_id from notification metadata
|
||||
if stale_document_ids:
|
||||
await _cleanup_stuck_non_connector_documents(
|
||||
session, stale_document_ids
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error cleaning up stale document processing notifications: {e!s}",
|
||||
exc_info=True,
|
||||
)
|
||||
await session.rollback()
|
||||
|
||||
|
||||
async def _cleanup_stuck_non_connector_documents(session, document_ids: list[int]):
|
||||
"""
|
||||
Mark specific non-connector documents stuck in pending/processing as failed.
|
||||
|
||||
These are documents (FILE uploads, YouTube, etc.) identified from stale
|
||||
notification metadata. Only documents that are still in pending/processing
|
||||
state are updated — already-completed documents are left untouched.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
document_ids: List of document IDs to check and potentially mark as failed
|
||||
"""
|
||||
if not document_ids:
|
||||
return
|
||||
|
||||
try:
|
||||
# Find which of these documents are actually stuck
|
||||
count_result = await session.execute(
|
||||
select(Document.id).where(
|
||||
and_(
|
||||
Document.id.in_(document_ids),
|
||||
or_(
|
||||
Document.status["state"].astext == DocumentStatus.PENDING,
|
||||
Document.status["state"].astext == DocumentStatus.PROCESSING,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
stuck_doc_ids = [row[0] for row in count_result.fetchall()]
|
||||
|
||||
if not stuck_doc_ids:
|
||||
logger.debug(
|
||||
f"No stuck non-connector documents found for IDs: {document_ids}"
|
||||
)
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
f"Found {len(stuck_doc_ids)} stuck non-connector documents "
|
||||
f"(pending/processing): {stuck_doc_ids}"
|
||||
)
|
||||
|
||||
failed_status = DocumentStatus.failed(STALE_PROCESSING_ERROR_MESSAGE)
|
||||
|
||||
await session.execute(
|
||||
text("""
|
||||
UPDATE documents
|
||||
SET status = CAST(:failed_status AS jsonb),
|
||||
updated_at = :now
|
||||
WHERE id = ANY(:doc_ids)
|
||||
AND (
|
||||
status->>'state' = :pending_state
|
||||
OR status->>'state' = :processing_state
|
||||
)
|
||||
"""),
|
||||
{
|
||||
"failed_status": json.dumps(failed_status),
|
||||
"now": datetime.now(UTC),
|
||||
"doc_ids": stuck_doc_ids,
|
||||
"pending_state": DocumentStatus.PENDING,
|
||||
"processing_state": DocumentStatus.PROCESSING,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Successfully marked {len(stuck_doc_ids)} stuck non-connector "
|
||||
"documents as failed"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error cleaning up stuck non-connector documents {document_ids}: {e!s}",
|
||||
exc_info=True,
|
||||
)
|
||||
# Don't raise — let the rest of the cleanup continue
|
||||
|
|
|
|||
|
|
@ -61,7 +61,11 @@ def get_youtube_video_id(url: str) -> str | None:
|
|||
|
||||
|
||||
async def add_youtube_video_document(
|
||||
session: AsyncSession, url: str, search_space_id: int, user_id: str
|
||||
session: AsyncSession,
|
||||
url: str,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
notification=None,
|
||||
) -> Document:
|
||||
"""
|
||||
Process a YouTube video URL, extract transcripts, and store as a document.
|
||||
|
|
@ -75,6 +79,9 @@ async def add_youtube_video_document(
|
|||
url: YouTube video URL (supports standard, shortened, and embed formats)
|
||||
search_space_id: ID of the search space to add the document to
|
||||
user_id: ID of the user
|
||||
notification: Optional notification object — if provided, the document_id
|
||||
is stored in its metadata right after document creation so the stale
|
||||
cleanup task can identify stuck documents.
|
||||
|
||||
Returns:
|
||||
Document: The created document object
|
||||
|
|
@ -182,6 +189,15 @@ async def add_youtube_video_document(
|
|||
await session.commit() # Document visible in UI now with pending status!
|
||||
is_new_document = True
|
||||
|
||||
# Store document_id in notification metadata so stale cleanup task
|
||||
# can identify this document if the worker crashes.
|
||||
if notification and notification.notification_metadata is not None:
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
notification.notification_metadata["document_id"] = document.id
|
||||
flag_modified(notification, "notification_metadata")
|
||||
await session.commit()
|
||||
|
||||
logging.info(f"Created pending document for YouTube video {video_id}")
|
||||
|
||||
# =======================================================================
|
||||
|
|
|
|||
|
|
@ -35,9 +35,9 @@ export function DocumentTypeChip({ type, className }: { type: string; className?
|
|||
|
||||
const chip = (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded bg-muted/40 px-2 py-1 text-xs text-muted-foreground max-w-full overflow-hidden ${className ?? ""}`}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full bg-accent/80 px-2.5 py-1 text-xs font-medium text-accent-foreground/70 shadow-sm max-w-full overflow-hidden ${className ?? ""}`}
|
||||
>
|
||||
<span className="opacity-80 flex-shrink-0">{icon}</span>
|
||||
<span className="flex-shrink-0">{icon}</span>
|
||||
<span ref={textRef} className="truncate min-w-0">
|
||||
{fullLabel}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ export function DocumentsFilters({
|
|||
<div className="relative">
|
||||
<Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search types..."
|
||||
placeholder="Search types"
|
||||
value={typeSearchQuery}
|
||||
onChange={(e) => setTypeSearchQuery(e.target.value)}
|
||||
className="h-6 pl-6 text-sm bg-transparent border-0 focus-visible:ring-0"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
AlertCircle,
|
||||
BadgeInfo,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
|
|
@ -372,7 +373,7 @@ export function DocumentsTableShell({
|
|||
</TableHead>
|
||||
)}
|
||||
{columnVisibility.status && (
|
||||
<TableHead className="w-20 text-center">
|
||||
<TableHead className="w-14 text-center">
|
||||
<Skeleton className="h-3 w-12 mx-auto" />
|
||||
</TableHead>
|
||||
)}
|
||||
|
|
@ -414,7 +415,7 @@ export function DocumentsTableShell({
|
|||
</TableCell>
|
||||
)}
|
||||
{columnVisibility.status && (
|
||||
<TableCell className="w-20 py-2.5 text-center">
|
||||
<TableCell className="w-14 py-2.5 text-center">
|
||||
<Skeleton className="h-5 w-5 mx-auto rounded-full" />
|
||||
</TableCell>
|
||||
)}
|
||||
|
|
@ -466,7 +467,7 @@ export function DocumentsTableShell({
|
|||
className="flex flex-col items-center gap-4 max-w-md px-4 text-center"
|
||||
>
|
||||
<div className="rounded-full bg-muted/50 p-4">
|
||||
<FileX className="h-8 w-8 text-muted-foreground/60" />
|
||||
<FileX className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="text-lg font-semibold">{t("no_documents")}</h3>
|
||||
|
|
@ -544,8 +545,11 @@ export function DocumentsTableShell({
|
|||
</TableHead>
|
||||
)}
|
||||
{columnVisibility.status && (
|
||||
<TableHead className="w-20 text-center">
|
||||
<span className="text-sm font-medium text-muted-foreground/70">Status</span>
|
||||
<TableHead className="w-14">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<BadgeInfo size={14} className="opacity-60 text-muted-foreground" />
|
||||
Status
|
||||
</span>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="w-10">
|
||||
|
|
@ -644,7 +648,7 @@ export function DocumentsTableShell({
|
|||
</TableCell>
|
||||
)}
|
||||
{columnVisibility.status && (
|
||||
<TableCell className="w-20 py-2.5 text-center">
|
||||
<TableCell className="w-14 py-2.5 text-center">
|
||||
<StatusIndicator status={doc.status} />
|
||||
</TableCell>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
|
|
@ -205,7 +206,10 @@ export function RowActions({
|
|||
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogTitle>Delete document?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete this document from your search space.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
|
|
|
|||
|
|
@ -140,6 +140,9 @@ export default function DocumentsTable() {
|
|||
}
|
||||
});
|
||||
setPageIndex(0);
|
||||
// Clear selections when filter changes — selected IDs from the previous
|
||||
// filter view are no longer visible and would cause misleading bulk actions
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const onBulkDelete = async () => {
|
||||
|
|
|
|||
|
|
@ -844,7 +844,12 @@ export default function NewChatPage() {
|
|||
});
|
||||
// Invalidate thread detail for breadcrumb update
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["threads", String(searchSpaceId), "detail", String(titleData.threadId)],
|
||||
queryKey: [
|
||||
"threads",
|
||||
String(searchSpaceId),
|
||||
"detail",
|
||||
String(titleData.threadId),
|
||||
],
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
|
@ -1403,7 +1408,7 @@ export default function NewChatPage() {
|
|||
// Show loading state only when loading an existing thread
|
||||
if (isInitializing) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-64px)] flex-col bg-background px-4">
|
||||
<div className="flex h-[calc(100dvh-64px)] flex-col bg-background px-4">
|
||||
<div className="mx-auto w-full max-w-[44rem] flex flex-1 flex-col gap-6 py-8">
|
||||
{/* User message */}
|
||||
<div className="flex justify-end">
|
||||
|
|
@ -1444,7 +1449,7 @@ export default function NewChatPage() {
|
|||
// For new chats (urlChatId === 0), threadId being null is expected (lazy creation)
|
||||
if (!threadId && urlChatId > 0) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-64px)] flex-col items-center justify-center gap-4">
|
||||
<div className="flex h-[calc(100dvh-64px)] flex-col items-center justify-center gap-4">
|
||||
<div className="text-destructive">Failed to load chat</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1469,7 +1474,7 @@ export default function NewChatPage() {
|
|||
<SaveMemoryToolUI />
|
||||
<RecallMemoryToolUI />
|
||||
{/* <WriteTodosToolUI /> Disabled for now */}
|
||||
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden">
|
||||
<div className="flex flex-col h-[calc(100dvh-64px)] overflow-hidden">
|
||||
<Thread
|
||||
messageThinkingSteps={messageThinkingSteps}
|
||||
header={<ChatHeader searchSpaceId={searchSpaceId} />}
|
||||
|
|
|
|||
|
|
@ -290,11 +290,11 @@ function SettingsContent({
|
|||
<GeneralSettingsManager searchSpaceId={searchSpaceId} />
|
||||
)}
|
||||
{activeSection === "models" && <ModelConfigManager searchSpaceId={searchSpaceId} />}
|
||||
{activeSection === "roles" && <LLMRoleManager searchSpaceId={searchSpaceId} />}
|
||||
{activeSection === "image-models" && (
|
||||
<ImageModelManager searchSpaceId={searchSpaceId} />
|
||||
)}
|
||||
{activeSection === "prompts" && <PromptConfigManager searchSpaceId={searchSpaceId} />}
|
||||
{activeSection === "roles" && <LLMRoleManager searchSpaceId={searchSpaceId} />}
|
||||
{activeSection === "image-models" && (
|
||||
<ImageModelManager searchSpaceId={searchSpaceId} />
|
||||
)}
|
||||
{activeSection === "prompts" && <PromptConfigManager searchSpaceId={searchSpaceId} />}
|
||||
{activeSection === "public-links" && (
|
||||
<PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
import { RootProvider } from "fumadocs-ui/provider/next";
|
||||
import { Roboto } from "next/font/google";
|
||||
|
|
@ -19,6 +19,20 @@ const roboto = Roboto({
|
|||
variable: "--font-roboto",
|
||||
});
|
||||
|
||||
/**
|
||||
* Viewport configuration for mobile keyboard handling.
|
||||
* - interactiveWidget: 'resizes-content' tells mobile browsers (especially Chrome Android)
|
||||
* to resize the CSS layout viewport when the virtual keyboard opens, so sticky elements
|
||||
* (like the chat input bar) stay visible above the keyboard.
|
||||
* - viewportFit: 'cover' enables env(safe-area-inset-*) for notched/home-indicator devices.
|
||||
*/
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
interactiveWidget: "resizes-content",
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SurfSense – Customizable AI Research & Knowledge Management Assistant",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ import Image from "next/image";
|
|||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Logo = ({ className, disableLink = false }: { className?: string; disableLink?: boolean }) => {
|
||||
export const Logo = ({
|
||||
className,
|
||||
disableLink = false,
|
||||
}: {
|
||||
className?: string;
|
||||
disableLink?: boolean;
|
||||
}) => {
|
||||
const image = (
|
||||
<Image
|
||||
src="/icon-128.svg"
|
||||
|
|
|
|||
|
|
@ -120,7 +120,10 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
|
|||
}}
|
||||
/>
|
||||
|
||||
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6">
|
||||
<ThreadPrimitive.ViewportFooter
|
||||
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6"
|
||||
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
|
||||
>
|
||||
<ThreadScrollToBottom />
|
||||
<AssistantIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
|
||||
|
|
|
|||
|
|
@ -100,10 +100,10 @@ export const UserMessage: FC = () => {
|
|||
|
||||
const UserActionBar: FC = () => {
|
||||
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||
|
||||
|
||||
// Get current message ID
|
||||
const currentMessageId = useAssistantState(({ message }) => message?.id);
|
||||
|
||||
|
||||
// Find the last user message ID in the thread (computed once, memoized by selector)
|
||||
const lastUserMessageId = useAssistantState(({ thread }) => {
|
||||
const messages = thread.messages;
|
||||
|
|
@ -117,7 +117,7 @@ const UserActionBar: FC = () => {
|
|||
|
||||
// Simple comparison - no iteration needed per message
|
||||
const isLastUserMessage = currentMessageId === lastUserMessageId;
|
||||
|
||||
|
||||
// Show edit button only on the last user message and when thread is not running
|
||||
const canEdit = isLastUserMessage && !isThreadRunning;
|
||||
|
||||
|
|
|
|||
|
|
@ -526,7 +526,9 @@ export function LayoutDataProvider({
|
|||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||
// Invalidate thread detail for breadcrumb update
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId, "detail", String(chatToRename.id)] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["threads", searchSpaceId, "detail", String(chatToRename.id)],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error renaming thread:", error);
|
||||
toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Settings, Trash2, Users } from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
ContextMenu,
|
||||
|
|
@ -9,6 +10,13 @@ import {
|
|||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -21,6 +29,7 @@ interface SearchSpaceAvatarProps {
|
|||
onDelete?: () => void;
|
||||
onSettings?: () => void;
|
||||
size?: "sm" | "md";
|
||||
disableTooltip?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -64,6 +73,7 @@ export function SearchSpaceAvatar({
|
|||
onDelete,
|
||||
onSettings,
|
||||
size = "md",
|
||||
disableTooltip = false,
|
||||
}: SearchSpaceAvatarProps) {
|
||||
const t = useTranslations("searchSpace");
|
||||
const tCommon = useTranslations("common");
|
||||
|
|
@ -71,6 +81,35 @@ export function SearchSpaceAvatar({
|
|||
const initials = getInitials(name);
|
||||
const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm";
|
||||
|
||||
// Long-press state for mobile
|
||||
const [longPressMenuOpen, setLongPressMenuOpen] = useState(false);
|
||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const touchMoved = useRef(false);
|
||||
|
||||
const handleTouchStart = useCallback(() => {
|
||||
touchMoved.current = false;
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
if (!touchMoved.current) {
|
||||
setLongPressMenuOpen(true);
|
||||
}
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback(() => {
|
||||
touchMoved.current = true;
|
||||
if (longPressTimer.current) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (longPressTimer.current) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const tooltipContent = (
|
||||
<div className="flex flex-col">
|
||||
<span>{name}</span>
|
||||
|
|
@ -110,8 +149,53 @@ export function SearchSpaceAvatar({
|
|||
</button>
|
||||
);
|
||||
|
||||
const menuItems = (
|
||||
<>
|
||||
{onSettings && (
|
||||
<DropdownMenuItem onClick={onSettings}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{tCommon("settings")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onSettings && onDelete && <DropdownMenuSeparator />}
|
||||
{onDelete && isOwner && (
|
||||
<DropdownMenuItem variant="destructive" onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{tCommon("delete")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDelete && !isOwner && (
|
||||
<DropdownMenuItem variant="destructive" onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("leave")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// If delete or settings handlers are provided, wrap with context menu
|
||||
if (onDelete || onSettings) {
|
||||
// Mobile: use long-press triggered DropdownMenu
|
||||
if (disableTooltip) {
|
||||
return (
|
||||
<DropdownMenu open={longPressMenuOpen} onOpenChange={setLongPressMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className="inline-block"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchEnd}
|
||||
>
|
||||
{avatarButton}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48">{menuItems}</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: use right-click ContextMenu + Tooltip
|
||||
return (
|
||||
<ContextMenu>
|
||||
<Tooltip>
|
||||
|
|
@ -150,6 +234,10 @@ export function SearchSpaceAvatar({
|
|||
}
|
||||
|
||||
// No context menu needed
|
||||
if (disableTooltip) {
|
||||
return avatarButton;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{avatarButton}</TooltipTrigger>
|
||||
|
|
|
|||
|
|
@ -27,10 +27,12 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import {
|
||||
deleteThread,
|
||||
fetchThreads,
|
||||
|
|
@ -56,6 +58,7 @@ export function AllPrivateChatsSidebar({
|
|||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const currentChatId = Array.isArray(params.chat_id)
|
||||
? Number(params.chat_id[0])
|
||||
|
|
@ -303,8 +306,16 @@ export function AllPrivateChatsSidebar({
|
|||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
<div className="space-y-1">
|
||||
{[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
|
||||
<div
|
||||
key={`skeleton-${i}`}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5"
|
||||
>
|
||||
<Skeleton className="h-4 w-4 shrink-0 rounded" />
|
||||
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-sm text-destructive">
|
||||
|
|
@ -329,25 +340,37 @@ export function AllPrivateChatsSidebar({
|
|||
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>
|
||||
{isMobile ? (
|
||||
<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>
|
||||
) : (
|
||||
<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}
|
||||
|
|
|
|||
|
|
@ -27,10 +27,12 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import {
|
||||
deleteThread,
|
||||
fetchThreads,
|
||||
|
|
@ -56,6 +58,7 @@ export function AllSharedChatsSidebar({
|
|||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const currentChatId = Array.isArray(params.chat_id)
|
||||
? Number(params.chat_id[0])
|
||||
|
|
@ -303,8 +306,16 @@ export function AllSharedChatsSidebar({
|
|||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
<div className="space-y-1">
|
||||
{[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
|
||||
<div
|
||||
key={`skeleton-${i}`}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5"
|
||||
>
|
||||
<Skeleton className="h-4 w-4 shrink-0 rounded" />
|
||||
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-sm text-destructive">
|
||||
|
|
@ -329,25 +340,37 @@ export function AllSharedChatsSidebar({
|
|||
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>
|
||||
{isMobile ? (
|
||||
<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>
|
||||
) : (
|
||||
<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}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { ArchiveIcon, MessageSquare, MoreHorizontal, PencilIcon, RotateCcwIcon, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
PencilIcon,
|
||||
RotateCcwIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -59,26 +66,26 @@ export function ChatListItem({
|
|||
<span className="sr-only">{t("more_options")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="right">
|
||||
{onRename && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRename();
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="mr-2 h-4 w-4" />
|
||||
<span>{t("rename") || "Rename"}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onArchive && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onArchive();
|
||||
}}
|
||||
>
|
||||
{archived ? (
|
||||
<DropdownMenuContent align="end" side="right">
|
||||
{onRename && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRename();
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="mr-2 h-4 w-4" />
|
||||
<span>{t("rename") || "Rename"}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onArchive && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onArchive();
|
||||
}}
|
||||
>
|
||||
{archived ? (
|
||||
<>
|
||||
<RotateCcwIcon className="mr-2 h-4 w-4" />
|
||||
<span>{t("unarchive") || "Restore"}</span>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
AlertCircle,
|
||||
|
|
@ -19,7 +20,7 @@ import {
|
|||
X,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||
|
|
@ -41,6 +42,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -52,7 +54,10 @@ import {
|
|||
isPageLimitExceededMetadata,
|
||||
} from "@/contracts/types/inbox.types";
|
||||
import type { InboxItem } from "@/hooks/use-inbox";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSidebarContextSafe } from "../../hooks";
|
||||
|
||||
|
|
@ -179,7 +184,9 @@ export function InboxSidebar({
|
|||
}: InboxSidebarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
|
||||
|
||||
// Comments collapsed state (desktop only, when docked)
|
||||
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
|
||||
|
|
@ -187,12 +194,22 @@ export function InboxSidebar({
|
|||
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(searchQuery, 300);
|
||||
const isSearchMode = !!debouncedSearch.trim();
|
||||
const [activeTab, setActiveTab] = useState<InboxTab>("comments");
|
||||
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
||||
const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
// Dropdown state for filter menu (desktop only)
|
||||
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
|
||||
// Scroll shadow state for connector list
|
||||
const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}, []);
|
||||
// Drawer state for filter menu (mobile only)
|
||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||
|
|
@ -200,6 +217,24 @@ export function InboxSidebar({
|
|||
// Prefetch trigger ref - placed on item near the end
|
||||
const prefetchTriggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Server-side search query (enabled only when user is typing a search)
|
||||
// Determines which notification types to search based on active tab
|
||||
const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined;
|
||||
const { data: searchResponse, isLoading: isSearchLoading } = useQuery({
|
||||
queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab),
|
||||
queryFn: () =>
|
||||
notificationsApiService.getNotifications({
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId ?? undefined,
|
||||
type: searchTypeFilter,
|
||||
search: debouncedSearch.trim(),
|
||||
limit: 50,
|
||||
},
|
||||
}),
|
||||
staleTime: 30 * 1000, // 30 seconds (search results don't need to be super fresh)
|
||||
enabled: isSearchMode && open,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
|
@ -234,17 +269,11 @@ export function InboxSidebar({
|
|||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Both tabs now derive items from status (all types), so use status for pagination
|
||||
const { loading, loadingMore = false, hasMore = false, loadMore } = status;
|
||||
// Each tab uses its own data source for independent pagination
|
||||
// Comments tab: uses mentions data source (fetches only mention/reply types from server)
|
||||
const commentsItems = mentions.items;
|
||||
|
||||
// Comments tab: mentions and comment replies
|
||||
const commentsItems = useMemo(
|
||||
() =>
|
||||
status.items.filter((item) => item.type === "new_mention" || item.type === "comment_reply"),
|
||||
[status.items]
|
||||
);
|
||||
|
||||
// Status tab: connector indexing, document processing, page limit exceeded, connector deletion
|
||||
// Status tab: filters status data source (fetches all types) to status-specific types
|
||||
const statusItems = useMemo(
|
||||
() =>
|
||||
status.items.filter(
|
||||
|
|
@ -257,6 +286,14 @@ export function InboxSidebar({
|
|||
[status.items]
|
||||
);
|
||||
|
||||
// Pagination switches based on active tab
|
||||
const loading = activeTab === "comments" ? mentions.loading : status.loading;
|
||||
const loadingMore =
|
||||
activeTab === "comments" ? (mentions.loadingMore ?? false) : (status.loadingMore ?? false);
|
||||
const hasMore =
|
||||
activeTab === "comments" ? (mentions.hasMore ?? false) : (status.hasMore ?? false);
|
||||
const loadMore = activeTab === "comments" ? mentions.loadMore : status.loadMore;
|
||||
|
||||
// Get unique connector types from status items for filtering
|
||||
const uniqueConnectorTypes = useMemo(() => {
|
||||
const connectorTypes = new Set<string>();
|
||||
|
|
@ -279,9 +316,23 @@ export function InboxSidebar({
|
|||
// Get items for current tab
|
||||
const displayItems = activeTab === "comments" ? commentsItems : statusItems;
|
||||
|
||||
// Filter items based on filter type, connector filter, and search query
|
||||
// Filter items based on filter type, connector filter, and search mode
|
||||
// When searching: use server-side API results (searches ALL notifications)
|
||||
// When not searching: use Electric real-time items (fast, local)
|
||||
const filteredItems = useMemo(() => {
|
||||
let items = displayItems;
|
||||
// In search mode, use API results
|
||||
let items: InboxItem[] = isSearchMode ? (searchResponse?.items ?? []) : displayItems;
|
||||
|
||||
// For status tab search results, filter to status-specific types
|
||||
if (isSearchMode && activeTab === "status") {
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.type === "connector_indexing" ||
|
||||
item.type === "document_processing" ||
|
||||
item.type === "page_limit_exceeded" ||
|
||||
item.type === "connector_deletion"
|
||||
);
|
||||
}
|
||||
|
||||
// Apply read/unread filter
|
||||
if (activeFilter === "unread") {
|
||||
|
|
@ -302,22 +353,14 @@ export function InboxSidebar({
|
|||
});
|
||||
}
|
||||
|
||||
// Apply search query
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(query) || item.message.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [displayItems, activeFilter, activeTab, selectedConnector, searchQuery]);
|
||||
}, [displayItems, searchResponse, isSearchMode, activeFilter, activeTab, selectedConnector]);
|
||||
|
||||
// Intersection Observer for infinite scroll with prefetching
|
||||
// Only active when not searching (search results are client-side filtered)
|
||||
// Re-runs when active tab changes so each tab gets its own pagination
|
||||
// Disabled during server-side search (search results are not paginated via infinite scroll)
|
||||
useEffect(() => {
|
||||
if (!loadMore || !hasMore || loadingMore || !open || searchQuery.trim()) return;
|
||||
if (!loadMore || !hasMore || loadingMore || !open || isSearchMode) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
|
|
@ -338,17 +381,11 @@ export function InboxSidebar({
|
|||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, hasMore, loadingMore, open, searchQuery]);
|
||||
}, [loadMore, hasMore, loadingMore, open, isSearchMode, activeTab]);
|
||||
|
||||
// Unread counts derived from filtered items
|
||||
const unreadCommentsCount = useMemo(
|
||||
() => commentsItems.filter((item) => !item.read).length,
|
||||
[commentsItems]
|
||||
);
|
||||
const unreadStatusCount = useMemo(
|
||||
() => statusItems.filter((item) => !item.read).length,
|
||||
[statusItems]
|
||||
);
|
||||
// Unread counts from server-side accurate totals (passed via props)
|
||||
const unreadCommentsCount = mentions.unreadCount;
|
||||
const unreadStatusCount = status.unreadCount;
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
async (item: InboxItem) => {
|
||||
|
|
@ -539,26 +576,33 @@ export function InboxSidebar({
|
|||
<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">
|
||||
{/* Back button - mobile only */}
|
||||
{isMobile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("close") || "Close"}</span>
|
||||
</Button>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Mobile: Button that opens bottom drawer */}
|
||||
{isMobile ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => setFilterDrawerOpen(true)}
|
||||
>
|
||||
<ListFilter className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("filter") || "Filter"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => setFilterDrawerOpen(true)}
|
||||
>
|
||||
<ListFilter className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("filter") || "Filter"}</span>
|
||||
</Button>
|
||||
<Drawer
|
||||
open={filterDrawerOpen}
|
||||
onOpenChange={setFilterDrawerOpen}
|
||||
|
|
@ -725,66 +769,71 @@ export function InboxSidebar({
|
|||
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
|
||||
{t("connectors") || "Connectors"}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setSelectedConnector(null)}
|
||||
className="flex items-center justify-between"
|
||||
<div
|
||||
className="relative max-h-[30vh] overflow-y-auto -mb-1"
|
||||
onScroll={handleConnectorScroll}
|
||||
style={{
|
||||
maskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span>{t("all_connectors") || "All connectors"}</span>
|
||||
</span>
|
||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
{uniqueConnectorTypes.map((connector) => (
|
||||
<DropdownMenuItem
|
||||
key={connector.type}
|
||||
onClick={() => setSelectedConnector(connector.type)}
|
||||
onClick={() => setSelectedConnector(null)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||
<span>{connector.displayName}</span>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span>{t("all_connectors") || "All connectors"}</span>
|
||||
</span>
|
||||
{selectedConnector === connector.type && <Check className="h-4 w-4" />}
|
||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{uniqueConnectorTypes.map((connector) => (
|
||||
<DropdownMenuItem
|
||||
key={connector.type}
|
||||
onClick={() => setSelectedConnector(connector.type)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||
<span>{connector.displayName}</span>
|
||||
</span>
|
||||
{selectedConnector === connector.type && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={totalUnreadCount === 0}
|
||||
>
|
||||
<CheckCheck className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">
|
||||
{t("mark_all_read") || "Mark all as read"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Close button - mobile only */}
|
||||
{isMobile && (
|
||||
{isMobile ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={totalUnreadCount === 0}
|
||||
>
|
||||
<CheckCheck className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={totalUnreadCount === 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("close") || "Close"}</span>
|
||||
<CheckCheck className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">{t("close") || "Close"}</TooltipContent>
|
||||
<TooltipContent className="z-80">
|
||||
{t("mark_all_read") || "Mark all as read"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Dock/Undock button - desktop only */}
|
||||
|
|
@ -881,17 +930,48 @@ export function InboxSidebar({
|
|||
</Tabs>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
{(isSearchMode ? isSearchLoading : loading) ? (
|
||||
<div className="space-y-2">
|
||||
{activeTab === "comments"
|
||||
? /* Comments skeleton: avatar + two-line text + time */
|
||||
[85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
|
||||
<div
|
||||
key={`skeleton-comment-${i}`}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
|
||||
>
|
||||
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<Skeleton className="h-3 rounded" style={{ width: `${titleWidth}%` }} />
|
||||
<Skeleton className="h-2.5 w-[70%] rounded" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-6 shrink-0 rounded" />
|
||||
</div>
|
||||
))
|
||||
: /* Status skeleton: status icon circle + two-line text + time */
|
||||
[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
|
||||
<div
|
||||
key={`skeleton-status-${i}`}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
|
||||
>
|
||||
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<Skeleton className="h-3 rounded" style={{ width: `${titleWidth}%` }} />
|
||||
<Skeleton className="h-2.5 w-[60%] rounded" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Skeleton className="h-3 w-6 rounded" />
|
||||
<Skeleton className="h-2 w-2 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredItems.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{filteredItems.map((item, index) => {
|
||||
const isMarkingAsRead = markingAsReadId === item.id;
|
||||
// Place prefetch trigger on 5th item from end (only if not searching)
|
||||
// Place prefetch trigger on 5th item from end (only when not searching)
|
||||
const isPrefetchTrigger =
|
||||
!searchQuery && hasMore && index === filteredItems.length - 5;
|
||||
!isSearchMode && hasMore && index === filteredItems.length - 5;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -904,37 +984,61 @@ export function InboxSidebar({
|
|||
isMarkingAsRead && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={isMarkingAsRead}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
|
||||
>
|
||||
<div className="shrink-0">{getStatusIcon(item)}</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-medium line-clamp-2",
|
||||
!item.read && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
|
||||
{convertRenderedToDisplay(item.message)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-[250px]">
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{convertRenderedToDisplay(item.message)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{isMobile ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={isMarkingAsRead}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
|
||||
>
|
||||
<div className="shrink-0">{getStatusIcon(item)}</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-medium line-clamp-2",
|
||||
!item.read && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
|
||||
{convertRenderedToDisplay(item.message)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={isMarkingAsRead}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
|
||||
>
|
||||
<div className="shrink-0">{getStatusIcon(item)}</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-medium line-clamp-2",
|
||||
!item.read && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
|
||||
{convertRenderedToDisplay(item.message)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-[250px]">
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{convertRenderedToDisplay(item.message)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Time and unread dot - fixed width to prevent content shift */}
|
||||
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
|
||||
|
|
@ -947,11 +1051,43 @@ export function InboxSidebar({
|
|||
);
|
||||
})}
|
||||
{/* Fallback trigger at the very end if less than 5 items and not searching */}
|
||||
{!searchQuery && filteredItems.length < 5 && hasMore && (
|
||||
{!isSearchMode && filteredItems.length < 5 && hasMore && (
|
||||
<div ref={prefetchTriggerRef} className="h-1" />
|
||||
)}
|
||||
{/* Loading more skeletons at the bottom during infinite scroll */}
|
||||
{loadingMore &&
|
||||
(activeTab === "comments"
|
||||
? [80, 60, 90].map((titleWidth, i) => (
|
||||
<div
|
||||
key={`loading-more-comment-${i}`}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
|
||||
>
|
||||
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<Skeleton className="h-3 rounded" style={{ width: `${titleWidth}%` }} />
|
||||
<Skeleton className="h-2.5 w-[70%] rounded" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-6 shrink-0 rounded" />
|
||||
</div>
|
||||
))
|
||||
: [70, 85, 55].map((titleWidth, i) => (
|
||||
<div
|
||||
key={`loading-more-status-${i}`}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
|
||||
>
|
||||
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<Skeleton className="h-3 rounded" style={{ width: `${titleWidth}%` }} />
|
||||
<Skeleton className="h-2.5 w-[60%] rounded" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Skeleton className="h-3 w-6 rounded" />
|
||||
<Skeleton className="h-2 w-2 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
)))}
|
||||
</div>
|
||||
) : searchQuery ? (
|
||||
) : isSearchMode ? (
|
||||
<div className="text-center py-8">
|
||||
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { Menu, Plus } from "lucide-react";
|
||||
import { PanelRightClose, Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||
import { SearchSpaceAvatar } from "../icon-rail/SearchSpaceAvatar";
|
||||
|
|
@ -43,7 +44,7 @@ interface MobileSidebarProps {
|
|||
export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<Button variant="ghost" size="icon" className="md:hidden h-8 w-8" onClick={onClick}>
|
||||
<Menu className="h-5 w-5" />
|
||||
<PanelRightClose className="h-5 w-5" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
);
|
||||
|
|
@ -97,15 +98,16 @@ export function MobileSidebar({
|
|||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="left" className="w-[300px] p-0 flex flex-col">
|
||||
<SheetContent side="left" className="w-[340px] p-0 flex flex-row gap-0 [&>button]:hidden">
|
||||
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
||||
|
||||
{/* Horizontal Search Spaces Rail */}
|
||||
<div className="shrink-0 border-b bg-muted/40 px-2 py-2 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-1 py-1 overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/20">
|
||||
{searchSpaces.map((space) => (
|
||||
<div key={space.id} className="shrink-0">
|
||||
{/* Vertical Search Spaces Rail - left side */}
|
||||
<div className="flex h-full w-14 shrink-0 flex-col items-center bg-muted/40 border-r">
|
||||
<ScrollArea className="w-full flex-1">
|
||||
<div className="flex flex-col items-center gap-2 px-1.5 py-3">
|
||||
{searchSpaces.map((space) => (
|
||||
<SearchSpaceAvatar
|
||||
key={space.id}
|
||||
name={space.name}
|
||||
isActive={space.id === activeSearchSpaceId}
|
||||
isShared={space.memberCount > 1}
|
||||
|
|
@ -116,26 +118,28 @@ export function MobileSidebar({
|
|||
onSearchSpaceSettings ? () => onSearchSpaceSettings(space) : undefined
|
||||
}
|
||||
size="md"
|
||||
disableTooltip
|
||||
/>
|
||||
</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>
|
||||
))}
|
||||
<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>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{/* Sidebar Content - right side */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<Sidebar
|
||||
searchSpace={searchSpace}
|
||||
isCollapsed={false}
|
||||
onToggleCollapse={() => onOpenChange(false)}
|
||||
navItems={navItems}
|
||||
onNavItemClick={handleNavItemClick}
|
||||
chats={chats}
|
||||
|
|
@ -149,8 +153,22 @@ export function MobileSidebar({
|
|||
onChatRename={onChatRename}
|
||||
onChatDelete={onChatDelete}
|
||||
onChatArchive={onChatArchive}
|
||||
onViewAllSharedChats={onViewAllSharedChats}
|
||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||
onViewAllSharedChats={
|
||||
onViewAllSharedChats
|
||||
? () => {
|
||||
onOpenChange(false);
|
||||
onViewAllSharedChats();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onViewAllPrivateChats={
|
||||
onViewAllPrivateChats
|
||||
? () => {
|
||||
onOpenChange(false);
|
||||
onViewAllPrivateChats();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onManageMembers={onManageMembers}
|
||||
|
|
@ -161,6 +179,7 @@ export function MobileSidebar({
|
|||
setTheme={setTheme}
|
||||
className="w-full border-none"
|
||||
isLoadingChats={isLoadingChats}
|
||||
disableTooltips
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ interface SidebarProps {
|
|||
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||
className?: string;
|
||||
isLoadingChats?: boolean;
|
||||
disableTooltips?: boolean;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
|
|
@ -78,6 +79,7 @@ export function Sidebar({
|
|||
setTheme,
|
||||
className,
|
||||
isLoadingChats = false,
|
||||
disableTooltips = false,
|
||||
}: SidebarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
|
||||
|
|
@ -95,20 +97,22 @@ export function Sidebar({
|
|||
<SidebarCollapseButton
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={onToggleCollapse ?? (() => {})}
|
||||
disableTooltip={disableTooltips}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-14 shrink-0 items-center justify-between px-1 border-b">
|
||||
<div className="flex h-14 shrink-0 items-center gap-0 px-1 border-b">
|
||||
<SidebarHeader
|
||||
searchSpace={searchSpace}
|
||||
isCollapsed={isCollapsed}
|
||||
onSettings={onSettings}
|
||||
onManageMembers={onManageMembers}
|
||||
/>
|
||||
<div className="">
|
||||
<div className="shrink-0">
|
||||
<SidebarCollapseButton
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={onToggleCollapse ?? (() => {})}
|
||||
disableTooltip={disableTooltips}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -138,7 +142,7 @@ export function Sidebar({
|
|||
{isCollapsed ? (
|
||||
<div className="flex-1 w-[60px]" />
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col gap-1 py-2 w-[240px] min-h-0 overflow-hidden">
|
||||
<div className="flex-1 flex flex-col gap-1 py-2 w-full min-h-0 overflow-hidden">
|
||||
{/* Shared Chats Section - takes only space needed, max 50% */}
|
||||
<SidebarSection
|
||||
title={t("shared_chats")}
|
||||
|
|
@ -147,21 +151,32 @@ export function Sidebar({
|
|||
className="shrink-0 max-h-[50%] flex flex-col"
|
||||
action={
|
||||
onViewAllSharedChats ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
|
||||
onClick={onViewAllSharedChats}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{t("view_all_shared_chats") || "View all shared chats"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
disableTooltips ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
|
||||
onClick={onViewAllSharedChats}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
|
||||
onClick={onViewAllSharedChats}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{t("view_all_shared_chats") || "View all shared chats"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
|
|
@ -208,21 +223,32 @@ export function Sidebar({
|
|||
fillHeight={true}
|
||||
action={
|
||||
onViewAllPrivateChats ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
|
||||
onClick={onViewAllPrivateChats}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{t("view_all_private_chats") || "View all private chats"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
disableTooltips ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
|
||||
onClick={onViewAllPrivateChats}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
|
||||
onClick={onViewAllPrivateChats}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{t("view_all_private_chats") || "View all private chats"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -8,21 +8,30 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||
interface SidebarCollapseButtonProps {
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
disableTooltip?: boolean;
|
||||
}
|
||||
|
||||
export function SidebarCollapseButton({ isCollapsed, onToggle }: SidebarCollapseButtonProps) {
|
||||
export function SidebarCollapseButton({
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
disableTooltip = false,
|
||||
}: SidebarCollapseButtonProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
|
||||
const button = (
|
||||
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
|
||||
{isCollapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
||||
<span className="sr-only">{isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (disableTooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
|
||||
{isCollapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
||||
<span className="sr-only">
|
||||
{isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side={isCollapsed ? "right" : "bottom"}>
|
||||
{isCollapsed ? `${t("expand_sidebar")} (⌘B)` : `${t("collapse_sidebar")} (⌘B)`}
|
||||
</TooltipContent>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronsUpDown, Logs, Settings, Users } from "lucide-react";
|
||||
import { ChevronsUpDown, Settings, Users } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -35,14 +35,14 @@ export function SidebarHeader({
|
|||
const searchSpaceId = params.search_space_id as string;
|
||||
|
||||
return (
|
||||
<div className={cn("flex shrink-0 items-center", className)}>
|
||||
<div className={cn("flex min-w-0 flex-1 items-center", className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"flex h-auto items-center justify-between gap-2 overflow-hidden py-1.5 font-semibold",
|
||||
isCollapsed ? "w-10" : "w-50"
|
||||
"flex h-auto w-full items-center justify-between gap-1 overflow-hidden py-1.5 font-semibold",
|
||||
isCollapsed && "w-10"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-base">
|
||||
|
|
@ -56,10 +56,6 @@ export function SidebarHeader({
|
|||
<Users className="mr-2 h-4 w-4" />
|
||||
{t("manage_members")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push(`/dashboard/${searchSpaceId}/logs`)}>
|
||||
<Logs className="mr-2 h-4 w-4" />
|
||||
{t("logs")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onSettings}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { Check, ChevronUp, Languages, Laptop, Loader2, LogOut, Moon, Settings, Sun } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
ChevronUp,
|
||||
Languages,
|
||||
Laptop,
|
||||
Loader2,
|
||||
LogOut,
|
||||
Moon,
|
||||
Settings,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
<TooltipContent>Share settings</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent
|
||||
<PopoverContent
|
||||
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
|
|
@ -243,7 +243,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Globe indicator when public snapshots exist - clicks to settings */}
|
||||
{/* Globe indicator when public snapshots exist - clicks to settings */}
|
||||
{hasPublicSnapshots && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -179,7 +179,17 @@ export function ImageConfigSidebar({
|
|||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [mode, isGlobal, config, formData, searchSpaceId, createConfig, updateConfig, updatePreferences, onOpenChange]);
|
||||
}, [
|
||||
mode,
|
||||
isGlobal,
|
||||
config,
|
||||
formData,
|
||||
searchSpaceId,
|
||||
createConfig,
|
||||
updateConfig,
|
||||
updatePreferences,
|
||||
onOpenChange,
|
||||
]);
|
||||
|
||||
const handleUseGlobalConfig = useCallback(async () => {
|
||||
if (!config || !isGlobal) return;
|
||||
|
|
@ -297,11 +307,16 @@ export function ImageConfigSidebar({
|
|||
<Alert className="mb-6 border-violet-500/30 bg-violet-500/5">
|
||||
<Shuffle className="size-4 text-violet-500" />
|
||||
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
|
||||
Auto mode distributes image generation requests across all configured providers for optimal performance and rate limit protection.
|
||||
Auto mode distributes image generation requests across all configured
|
||||
providers for optimal performance and rate limit protection.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex gap-3 pt-4 border-t border-border/50">
|
||||
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -327,12 +342,16 @@ export function ImageConfigSidebar({
|
|||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Name</div>
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Name
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.name}</p>
|
||||
</div>
|
||||
{config.description && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Description</div>
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Description
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{config.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -340,20 +359,32 @@ export function ImageConfigSidebar({
|
|||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Provider</div>
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Provider
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.provider}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</div>
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Model
|
||||
</div>
|
||||
<p className="text-sm font-medium font-mono">{config.model_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-6 border-t border-border/50 mt-6">
|
||||
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button className="flex-1 gap-2" onClick={handleUseGlobalConfig} disabled={isSubmitting}>
|
||||
<Button
|
||||
className="flex-1 gap-2"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Loading..." : "Use This Model"}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -379,7 +410,9 @@ export function ImageConfigSidebar({
|
|||
<Input
|
||||
placeholder="Optional description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||
onChange={(e) =>
|
||||
setFormData((p) => ({ ...p, description: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -390,7 +423,9 @@ export function ImageConfigSidebar({
|
|||
<Label className="text-sm font-medium">Provider *</Label>
|
||||
<Select
|
||||
value={formData.provider}
|
||||
onValueChange={(val) => setFormData((p) => ({ ...p, provider: val, model_name: "" }))}
|
||||
onValueChange={(val) =>
|
||||
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
|
|
@ -414,7 +449,11 @@ export function ImageConfigSidebar({
|
|||
{suggestedModels.length > 0 ? (
|
||||
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="w-full justify-between font-normal">
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{formData.model_name || "Select or type a model..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
|
|
@ -424,11 +463,15 @@ export function ImageConfigSidebar({
|
|||
<CommandInput
|
||||
placeholder="Search or type model..."
|
||||
value={formData.model_name}
|
||||
onValueChange={(val) => setFormData((p) => ({ ...p, model_name: val }))}
|
||||
onValueChange={(val) =>
|
||||
setFormData((p) => ({ ...p, model_name: val }))
|
||||
}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<span className="text-xs text-muted-foreground">Type a custom model name</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Type a custom model name
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{suggestedModels.map((m) => (
|
||||
|
|
@ -440,9 +483,18 @@ export function ImageConfigSidebar({
|
|||
setModelComboboxOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", formData.model_name === m.value ? "opacity-100" : "opacity-0")} />
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.model_name === m.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="font-mono text-sm">{m.value}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{m.label}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{m.label}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
|
@ -454,7 +506,9 @@ export function ImageConfigSidebar({
|
|||
<Input
|
||||
placeholder="e.g., dall-e-3"
|
||||
value={formData.model_name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, model_name: e.target.value }))}
|
||||
onChange={(e) =>
|
||||
setFormData((p) => ({ ...p, model_name: e.target.value }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -489,14 +543,20 @@ export function ImageConfigSidebar({
|
|||
<Input
|
||||
placeholder="2024-02-15-preview"
|
||||
value={formData.api_version}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))}
|
||||
onChange={(e) =>
|
||||
setFormData((p) => ({ ...p, api_version: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-4 border-t">
|
||||
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -54,8 +54,7 @@ export function ImageModelSelector({ className, onAddNew, onEdit }: ImageModelSe
|
|||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const { data: globalConfigs, isLoading: globalLoading } =
|
||||
useAtomValue(globalImageGenConfigsAtom);
|
||||
const { data: globalConfigs, isLoading: globalLoading } = useAtomValue(globalImageGenConfigsAtom);
|
||||
const { data: userConfigs, isLoading: userLoading } = useAtomValue(imageGenConfigsAtom);
|
||||
const { data: preferences, isLoading: prefsLoading } = useAtomValue(llmPreferencesAtom);
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
|
|
@ -225,59 +224,59 @@ export function ImageModelSelector({ className, onAddNew, onEdit }: ImageModelSe
|
|||
<Globe className="size-3.5" />
|
||||
Global Image Models
|
||||
</div>
|
||||
{filteredGlobal.map((config) => {
|
||||
const isSelected = currentConfig?.id === config.id;
|
||||
const isAuto = "is_auto_mode" in config && config.is_auto_mode;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`g-${config.id}`}
|
||||
value={`g-${config.id}`}
|
||||
onSelect={() => handleSelect(config.id)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80",
|
||||
isAuto && "border border-violet-200 dark:border-violet-800/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
{isAuto ? (
|
||||
<Shuffle className="size-4 text-violet-500" />
|
||||
) : (
|
||||
<ImageIcon className="size-4 text-teal-500" />
|
||||
{filteredGlobal.map((config) => {
|
||||
const isSelected = currentConfig?.id === config.id;
|
||||
const isAuto = "is_auto_mode" in config && config.is_auto_mode;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`g-${config.id}`}
|
||||
value={`g-${config.id}`}
|
||||
onSelect={() => handleSelect(config.id)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80",
|
||||
isAuto && "border border-violet-200 dark:border-violet-800/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
{isAuto ? (
|
||||
<Shuffle className="size-4 text-violet-500" />
|
||||
) : (
|
||||
<ImageIcon className="size-4 text-teal-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{config.name}</span>
|
||||
{isAuto && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-0"
|
||||
>
|
||||
Recommended
|
||||
</Badge>
|
||||
)}
|
||||
{isSelected && <Check className="size-3.5 text-primary shrink-0" />}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{isAuto ? "Auto load balancing" : config.model_name}
|
||||
</span>
|
||||
</div>
|
||||
{onEdit && (
|
||||
<ChevronRight
|
||||
className="size-3.5 text-muted-foreground shrink-0 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
onEdit(config, true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{config.name}</span>
|
||||
{isAuto && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-0"
|
||||
>
|
||||
Recommended
|
||||
</Badge>
|
||||
)}
|
||||
{isSelected && <Check className="size-3.5 text-primary shrink-0" />}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{isAuto ? "Auto load balancing" : config.model_name}
|
||||
</span>
|
||||
</div>
|
||||
{onEdit && (
|
||||
<ChevronRight
|
||||
className="size-3.5 text-muted-foreground shrink-0 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
onEdit(config, true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
|
|
@ -290,51 +289,49 @@ export function ImageModelSelector({ className, onAddNew, onEdit }: ImageModelSe
|
|||
<User className="size-3.5" />
|
||||
Your Image Models
|
||||
</div>
|
||||
{filteredUser.map((config) => {
|
||||
const isSelected = currentConfig?.id === config.id;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`u-${config.id}`}
|
||||
value={`u-${config.id}`}
|
||||
onSelect={() => handleSelect(config.id)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
<ImageIcon className="size-4 text-teal-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{config.name}</span>
|
||||
{isSelected && (
|
||||
<Check className="size-3.5 text-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{config.model_name}
|
||||
</span>
|
||||
</div>
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
onEdit(config, false);
|
||||
}}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
{filteredUser.map((config) => {
|
||||
const isSelected = currentConfig?.id === config.id;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`u-${config.id}`}
|
||||
value={`u-${config.id}`}
|
||||
onSelect={() => handleSelect(config.id)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
<ImageIcon className="size-4 text-teal-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{config.name}</span>
|
||||
{isSelected && <Check className="size-3.5 text-primary shrink-0" />}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{config.model_name}
|
||||
</span>
|
||||
</div>
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
onEdit(config, false);
|
||||
}}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -392,8 +392,8 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Add New Config Button */}
|
||||
<div className="p-2 bg-muted/20">
|
||||
{/* Add New Config Button */}
|
||||
<div className="p-2 bg-muted/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
|||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { fetchThreads } from "@/lib/chat/thread-persistence";
|
||||
|
||||
interface TourStep {
|
||||
|
|
@ -393,6 +394,7 @@ function TourTooltip({
|
|||
}
|
||||
|
||||
export function OnboardingTour() {
|
||||
const isMobile = useIsMobile();
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [targetEl, setTargetEl] = useState<Element | null>(null);
|
||||
|
|
@ -685,8 +687,8 @@ export function OnboardingTour() {
|
|||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isActive, user?.id]);
|
||||
|
||||
// Don't render if not active or not mounted
|
||||
if (!mounted || !isActive) {
|
||||
// Don't render on mobile, or if not active or not mounted
|
||||
if (isMobile || !mounted || !isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -184,7 +184,12 @@ export function Pricing({
|
|||
</div>
|
||||
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{plan.billingText ?? (isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually")}
|
||||
{plan.billingText ??
|
||||
(isNaN(Number(plan.price))
|
||||
? ""
|
||||
: isMonthly
|
||||
? "billed monthly"
|
||||
: "billed annually")}
|
||||
</p>
|
||||
|
||||
<ul className="mt-5 gap-2 flex flex-col">
|
||||
|
|
|
|||
|
|
@ -95,16 +95,29 @@ const item = {
|
|||
|
||||
export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||
// Image gen config atoms
|
||||
const { mutateAsync: createConfig, isPending: isCreating, error: createError } =
|
||||
useAtomValue(createImageGenConfigMutationAtom);
|
||||
const { mutateAsync: updateConfig, isPending: isUpdating, error: updateError } =
|
||||
useAtomValue(updateImageGenConfigMutationAtom);
|
||||
const { mutateAsync: deleteConfig, isPending: isDeleting, error: deleteError } =
|
||||
useAtomValue(deleteImageGenConfigMutationAtom);
|
||||
const {
|
||||
mutateAsync: createConfig,
|
||||
isPending: isCreating,
|
||||
error: createError,
|
||||
} = useAtomValue(createImageGenConfigMutationAtom);
|
||||
const {
|
||||
mutateAsync: updateConfig,
|
||||
isPending: isUpdating,
|
||||
error: updateError,
|
||||
} = useAtomValue(updateImageGenConfigMutationAtom);
|
||||
const {
|
||||
mutateAsync: deleteConfig,
|
||||
isPending: isDeleting,
|
||||
error: deleteError,
|
||||
} = useAtomValue(deleteImageGenConfigMutationAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const { data: userConfigs, isFetching: configsLoading, error: fetchError, refetch: refreshConfigs } =
|
||||
useAtomValue(imageGenConfigsAtom);
|
||||
const {
|
||||
data: userConfigs,
|
||||
isFetching: configsLoading,
|
||||
error: fetchError,
|
||||
refetch: refreshConfigs,
|
||||
} = useAtomValue(imageGenConfigsAtom);
|
||||
const { data: globalConfigs = [], isFetching: globalLoading } =
|
||||
useAtomValue(globalImageGenConfigsAtom);
|
||||
const { data: preferences = {}, isFetching: prefsLoading } = useAtomValue(llmPreferencesAtom);
|
||||
|
|
@ -249,7 +262,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
data: {
|
||||
image_generation_config_id:
|
||||
typeof selectedPrefId === "string"
|
||||
? selectedPrefId ? parseInt(selectedPrefId) : undefined
|
||||
? selectedPrefId
|
||||
? parseInt(selectedPrefId)
|
||||
: undefined
|
||||
: selectedPrefId,
|
||||
},
|
||||
});
|
||||
|
|
@ -289,7 +304,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
{/* Errors */}
|
||||
<AnimatePresence>
|
||||
{errors.map((err) => (
|
||||
<motion.div key={err?.message} initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}>
|
||||
<motion.div
|
||||
key={err?.message}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
>
|
||||
<Alert variant="destructive" className="py-3">
|
||||
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription>
|
||||
|
|
@ -304,7 +324,8 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
<Sparkles className="h-3 w-3 md:h-4 md:w-4 text-teal-600 dark:text-teal-400 shrink-0" />
|
||||
<AlertDescription className="text-teal-800 dark:text-teal-200 text-xs md:text-sm">
|
||||
<span className="font-medium">
|
||||
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length} global image model(s)
|
||||
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length} global
|
||||
image model(s)
|
||||
</span>{" "}
|
||||
available from your administrator.
|
||||
</AlertDescription>
|
||||
|
|
@ -342,18 +363,27 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
</SelectItem>
|
||||
{globalConfigs.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">Global</div>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Global
|
||||
</div>
|
||||
{globalConfigs.map((c) => {
|
||||
const isAuto = "is_auto_mode" in c && c.is_auto_mode;
|
||||
return (
|
||||
<SelectItem key={`g-${c.id}`} value={c.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAuto ? (
|
||||
<Badge variant="outline" className="text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-violet-200">
|
||||
<Shuffle className="size-3 mr-1" />AUTO
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-violet-200"
|
||||
>
|
||||
<Shuffle className="size-3 mr-1" />
|
||||
AUTO
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs bg-teal-50 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 border-teal-200">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-teal-50 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 border-teal-200"
|
||||
>
|
||||
{c.provider}
|
||||
</Badge>
|
||||
)}
|
||||
|
|
@ -366,11 +396,15 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
)}
|
||||
{(userConfigs?.length ?? 0) > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">Your Models</div>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Your Models
|
||||
</div>
|
||||
{userConfigs?.map((c) => (
|
||||
<SelectItem key={`u-${c.id}`} value={c.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">{c.provider}</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{c.provider}
|
||||
</Badge>
|
||||
<span>{c.name}</span>
|
||||
<span className="text-muted-foreground">({c.model_name})</span>
|
||||
</div>
|
||||
|
|
@ -382,10 +416,23 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
</Select>
|
||||
{hasPrefChanges && (
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button size="sm" onClick={handleSavePref} disabled={isSavingPref} className="text-xs h-8">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSavePref}
|
||||
disabled={isSavingPref}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
{isSavingPref ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { setSelectedPrefId(preferences.image_generation_config_id ?? ""); setHasPrefChanges(false); }} className="text-xs h-8">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedPrefId(preferences.image_generation_config_id ?? "");
|
||||
setHasPrefChanges(false);
|
||||
}}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -409,7 +456,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<h3 className="text-lg md:text-xl font-semibold tracking-tight">Your Image Models</h3>
|
||||
<Button onClick={openNewDialog} className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9">
|
||||
<Button
|
||||
onClick={openNewDialog}
|
||||
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
|
||||
>
|
||||
<Plus className="h-3 w-3 md:h-4 md:w-4" />
|
||||
Add Image Model
|
||||
</Button>
|
||||
|
|
@ -435,7 +485,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
<motion.div variants={container} initial="hidden" animate="show" className="grid gap-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{userConfigs?.map((config) => (
|
||||
<motion.div key={config.id} variants={item} layout exit={{ opacity: 0, scale: 0.95 }}>
|
||||
<motion.div
|
||||
key={config.id}
|
||||
variants={item}
|
||||
layout
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
>
|
||||
<Card className="group overflow-hidden hover:shadow-lg transition-all duration-300 border-muted-foreground/10 hover:border-teal-500/30">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex">
|
||||
|
|
@ -448,8 +503,13 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<h4 className="text-sm md:text-base font-semibold truncate">{config.name}</h4>
|
||||
<Badge variant="secondary" className="text-[9px] md:text-[10px] px-1.5 py-0.5 bg-teal-500/10 text-teal-700 dark:text-teal-300 border-teal-500/20">
|
||||
<h4 className="text-sm md:text-base font-semibold truncate">
|
||||
{config.name}
|
||||
</h4>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[9px] md:text-[10px] px-1.5 py-0.5 bg-teal-500/10 text-teal-700 dark:text-teal-300 border-teal-500/20"
|
||||
>
|
||||
{config.provider}
|
||||
</Badge>
|
||||
</div>
|
||||
|
|
@ -457,7 +517,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
{config.model_name}
|
||||
</code>
|
||||
{config.description && (
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground line-clamp-1">{config.description}</p>
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground line-clamp-1">
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-[10px] md:text-xs text-muted-foreground pt-1">
|
||||
<Clock className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
||||
|
|
@ -469,7 +531,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEditDialog(config)} className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEditDialog(config)}
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Edit3 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -479,7 +546,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={() => setConfigToDelete(config)} className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfigToDelete(config)}
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -501,15 +573,30 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
)}
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={(open) => { if (!open) { setIsDialogOpen(false); setEditingConfig(null); resetForm(); } }}>
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setIsDialogOpen(false);
|
||||
setEditingConfig(null);
|
||||
resetForm();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{editingConfig ? <Edit3 className="w-5 h-5 text-teal-600" /> : <Plus className="w-5 h-5 text-teal-600" />}
|
||||
{editingConfig ? (
|
||||
<Edit3 className="w-5 h-5 text-teal-600" />
|
||||
) : (
|
||||
<Plus className="w-5 h-5 text-teal-600" />
|
||||
)}
|
||||
{editingConfig ? "Edit Image Model" : "Add Image Model"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingConfig ? "Update your image generation model" : "Configure a new image generation model (DALL-E 3, GPT Image 1, etc.)"}
|
||||
{editingConfig
|
||||
? "Update your image generation model"
|
||||
: "Configure a new image generation model (DALL-E 3, GPT Image 1, etc.)"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -541,7 +628,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
<Label className="text-sm font-medium">Provider *</Label>
|
||||
<Select
|
||||
value={formData.provider}
|
||||
onValueChange={(val) => setFormData((p) => ({ ...p, provider: val, model_name: "" }))}
|
||||
onValueChange={(val) =>
|
||||
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
|
|
@ -565,7 +654,11 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
{suggestedModels.length > 0 ? (
|
||||
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="w-full justify-between font-normal">
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{formData.model_name || "Select or type a model..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
|
|
@ -579,7 +672,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<span className="text-xs text-muted-foreground">Type a custom model name</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Type a custom model name
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{suggestedModels.map((m) => (
|
||||
|
|
@ -591,7 +686,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
setModelComboboxOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", formData.model_name === m.value ? "opacity-100" : "opacity-0")} />
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.model_name === m.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="font-mono text-sm">{m.value}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{m.label}</span>
|
||||
</CommandItem>
|
||||
|
|
@ -650,14 +750,24 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => { setIsDialogOpen(false); setEditingConfig(null); resetForm(); }}
|
||||
onClick={() => {
|
||||
setIsDialogOpen(false);
|
||||
setEditingConfig(null);
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleFormSubmit}
|
||||
disabled={isSubmitting || !formData.name || !formData.provider || !formData.model_name || !formData.api_key}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!formData.name ||
|
||||
!formData.provider ||
|
||||
!formData.model_name ||
|
||||
!formData.api_key
|
||||
}
|
||||
>
|
||||
{isSubmitting ? <Spinner size="sm" className="mr-2" /> : null}
|
||||
{editingConfig ? "Save Changes" : "Create & Use"}
|
||||
|
|
@ -668,7 +778,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={!!configToDelete} onOpenChange={(open) => !open && setConfigToDelete(null)}>
|
||||
<AlertDialog
|
||||
open={!!configToDelete}
|
||||
onOpenChange={(open) => !open && setConfigToDelete(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
|
|
@ -676,13 +789,28 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
Delete Image Model
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete <span className="font-semibold text-foreground">{configToDelete?.name}</span>?
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-foreground">{configToDelete?.name}</span>?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} disabled={isDeleting} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
{isDeleting ? <><Spinner size="sm" className="mr-2" />Deleting</> : <><Trash2 className="mr-2 h-4 w-4" />Delete</>}
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Deleting
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</>
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -255,15 +255,15 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Role Assignment Cards */}
|
||||
{availableConfigs.length > 0 && (
|
||||
<div className="grid gap-4 md:gap-6">
|
||||
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
|
||||
const IconComponent = role.icon;
|
||||
const currentAssignment = assignments[`${key}_llm_id` as keyof typeof assignments];
|
||||
const assignedConfig = availableConfigs.find(
|
||||
(config) => config.id === currentAssignment
|
||||
);
|
||||
{/* Role Assignment Cards */}
|
||||
{availableConfigs.length > 0 && (
|
||||
<div className="grid gap-4 md:gap-6">
|
||||
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
|
||||
const IconComponent = role.icon;
|
||||
const currentAssignment = assignments[`${key}_llm_id` as keyof typeof assignments];
|
||||
const assignedConfig = availableConfigs.find(
|
||||
(config) => config.id === currentAssignment
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
|
@ -294,100 +294,100 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
|
||||
<div className="space-y-1.5 md:space-y-2">
|
||||
<Label className="text-xs md:text-sm font-medium">
|
||||
Assign LLM Configuration:
|
||||
</Label>
|
||||
<Select
|
||||
value={currentAssignment?.toString() || "unassigned"}
|
||||
onValueChange={(value) => handleRoleAssignment(`${key}_llm_id`, value)}
|
||||
>
|
||||
<SelectTrigger className="h-9 md:h-10 text-xs md:text-sm">
|
||||
<SelectValue placeholder="Select an LLM configuration" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="unassigned">
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
</SelectItem>
|
||||
<div className="space-y-1.5 md:space-y-2">
|
||||
<Label className="text-xs md:text-sm font-medium">
|
||||
Assign LLM Configuration:
|
||||
</Label>
|
||||
<Select
|
||||
value={currentAssignment?.toString() || "unassigned"}
|
||||
onValueChange={(value) => handleRoleAssignment(`${key}_llm_id`, value)}
|
||||
>
|
||||
<SelectTrigger className="h-9 md:h-10 text-xs md:text-sm">
|
||||
<SelectValue placeholder="Select an LLM configuration" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="unassigned">
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
</SelectItem>
|
||||
|
||||
{/* Global Configurations */}
|
||||
{globalConfigs.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Global Configurations
|
||||
</div>
|
||||
{globalConfigs.map((config) => {
|
||||
const isAutoMode =
|
||||
"is_auto_mode" in config && config.is_auto_mode;
|
||||
return (
|
||||
<SelectItem key={config.id} value={config.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAutoMode ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-violet-200 dark:border-violet-700"
|
||||
>
|
||||
<Shuffle className="size-3 mr-1" />
|
||||
AUTO
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.provider}
|
||||
</Badge>
|
||||
)}
|
||||
<span>{config.name}</span>
|
||||
{!isAutoMode && (
|
||||
<span className="text-muted-foreground">
|
||||
({config.model_name})
|
||||
</span>
|
||||
)}
|
||||
{isAutoMode ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
|
||||
>
|
||||
Recommended
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
🌐 Global
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{/* Global Configurations */}
|
||||
{globalConfigs.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Global Configurations
|
||||
</div>
|
||||
{globalConfigs.map((config) => {
|
||||
const isAutoMode =
|
||||
"is_auto_mode" in config && config.is_auto_mode;
|
||||
return (
|
||||
<SelectItem key={config.id} value={config.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAutoMode ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-violet-200 dark:border-violet-700"
|
||||
>
|
||||
<Shuffle className="size-3 mr-1" />
|
||||
AUTO
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.provider}
|
||||
</Badge>
|
||||
)}
|
||||
<span>{config.name}</span>
|
||||
{!isAutoMode && (
|
||||
<span className="text-muted-foreground">
|
||||
({config.model_name})
|
||||
</span>
|
||||
)}
|
||||
{isAutoMode ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
|
||||
>
|
||||
Recommended
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
🌐 Global
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Custom Configurations */}
|
||||
{newLLMConfigs.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Your Configurations
|
||||
</div>
|
||||
{newLLMConfigs
|
||||
.filter(
|
||||
(config) => config.id && config.id.toString().trim() !== ""
|
||||
)
|
||||
.map((config) => (
|
||||
<SelectItem key={config.id} value={config.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.provider}
|
||||
</Badge>
|
||||
<span>{config.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
({config.model_name})
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Custom Configurations */}
|
||||
{newLLMConfigs.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Your Configurations
|
||||
</div>
|
||||
{newLLMConfigs
|
||||
.filter(
|
||||
(config) => config.id && config.id.toString().trim() !== ""
|
||||
)
|
||||
.map((config) => (
|
||||
<SelectItem key={config.id} value={config.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.provider}
|
||||
</Badge>
|
||||
<span>{config.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
({config.model_name})
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{assignedConfig && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -345,9 +345,7 @@ export function Image({
|
|||
variant="secondary"
|
||||
className={cn(
|
||||
"border-0 text-xs backdrop-blur-sm",
|
||||
isGenerated
|
||||
? "bg-primary/80 text-primary-foreground"
|
||||
: "bg-black/60 text-white"
|
||||
isGenerated ? "bg-primary/80 text-primary-foreground" : "bg-black/60 text-white"
|
||||
)}
|
||||
>
|
||||
{isGenerated && <SparklesIcon className="size-3 mr-1" />}
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ export const getNotificationsRequest = z.object({
|
|||
search_space_id: z.number().optional(),
|
||||
type: inboxItemTypeEnum.optional(),
|
||||
before_date: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
limit: z.number().min(1).max(100).optional(),
|
||||
offset: z.number().min(0).optional(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -213,9 +213,7 @@ export const getImageGenConfigsResponse = z.array(imageGenerationConfig);
|
|||
|
||||
export const updateImageGenConfigRequest = z.object({
|
||||
id: z.number(),
|
||||
data: imageGenerationConfig
|
||||
.omit({ id: true, created_at: true, search_space_id: true })
|
||||
.partial(),
|
||||
data: imageGenerationConfig.omit({ id: true, created_at: true, search_space_id: true }).partial(),
|
||||
});
|
||||
|
||||
export const updateImageGenConfigResponse = imageGenerationConfig;
|
||||
|
|
|
|||
|
|
@ -71,8 +71,14 @@ function isValidDocument(doc: DocumentElectric): boolean {
|
|||
* 3. Use syncHandle.isUpToDate to determine if deletions can be trusted
|
||||
* 4. Handles bulk deletions correctly by checking sync state
|
||||
*
|
||||
* Filtering strategy:
|
||||
* - Internal state always stores ALL documents (unfiltered)
|
||||
* - typeFilter is applied client-side when returning documents
|
||||
* - typeCounts always reflect the full dataset so the filter sidebar stays complete
|
||||
* - Changing filters is instant (no API re-fetch or Electric re-sync)
|
||||
*
|
||||
* @param searchSpaceId - The search space ID to filter documents
|
||||
* @param typeFilter - Optional document types to filter by
|
||||
* @param typeFilter - Optional document types to filter by (applied client-side)
|
||||
*/
|
||||
export function useDocuments(
|
||||
searchSpaceId: number | null,
|
||||
|
|
@ -80,7 +86,8 @@ export function useDocuments(
|
|||
) {
|
||||
const electricClient = useElectricClient();
|
||||
|
||||
const [documents, setDocuments] = useState<DocumentDisplay[]>([]);
|
||||
// Internal state: ALL documents (unfiltered)
|
||||
const [allDocuments, setAllDocuments] = useState<DocumentDisplay[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
|
|
@ -94,14 +101,21 @@ export function useDocuments(
|
|||
const syncHandleRef = useRef<SyncHandle | null>(null);
|
||||
const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
|
||||
|
||||
// Real-time type counts
|
||||
// Type counts from ALL documents (unfiltered) — keeps filter sidebar complete
|
||||
const typeCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const doc of documents) {
|
||||
for (const doc of allDocuments) {
|
||||
counts[doc.document_type] = (counts[doc.document_type] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [documents]);
|
||||
}, [allDocuments]);
|
||||
|
||||
// Client-side filtered documents for display
|
||||
const documents = useMemo(() => {
|
||||
if (typeFilter.length === 0) return allDocuments;
|
||||
const filterSet = new Set<string>(typeFilter);
|
||||
return allDocuments.filter((doc) => filterSet.has(doc.document_type));
|
||||
}, [allDocuments, typeFilter]);
|
||||
|
||||
// Populate user cache from API response
|
||||
const populateUserCache = useCallback(
|
||||
|
|
@ -151,7 +165,8 @@ export function useDocuments(
|
|||
[]
|
||||
);
|
||||
|
||||
// EFFECT 1: Load from API (PRIMARY source of truth)
|
||||
// EFFECT 1: Load ALL documents from API (PRIMARY source of truth)
|
||||
// No type filter — always fetches everything so typeCounts stay complete
|
||||
useEffect(() => {
|
||||
if (!searchSpaceId) {
|
||||
setLoading(false);
|
||||
|
|
@ -160,7 +175,6 @@ export function useDocuments(
|
|||
|
||||
// Capture validated value for async closure
|
||||
const spaceId = searchSpaceId;
|
||||
const currentTypeFilter = typeFilter;
|
||||
|
||||
let mounted = true;
|
||||
apiLoadedRef.current = false;
|
||||
|
|
@ -174,8 +188,7 @@ export function useDocuments(
|
|||
queryParams: {
|
||||
search_space_id: spaceId,
|
||||
page: 0,
|
||||
page_size: -1, // Fetch all documents
|
||||
...(currentTypeFilter.length > 0 && { document_types: currentTypeFilter }),
|
||||
page_size: -1, // Fetch all documents (unfiltered)
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -183,7 +196,7 @@ export function useDocuments(
|
|||
|
||||
populateUserCache(response.items);
|
||||
const docs = response.items.map(apiToDisplayDoc);
|
||||
setDocuments(docs);
|
||||
setAllDocuments(docs);
|
||||
apiLoadedRef.current = true;
|
||||
setError(null);
|
||||
console.log("[useDocuments] API loaded", docs.length, "documents");
|
||||
|
|
@ -201,16 +214,16 @@ export function useDocuments(
|
|||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [searchSpaceId, typeFilter, populateUserCache, apiToDisplayDoc]);
|
||||
}, [searchSpaceId, populateUserCache, apiToDisplayDoc]);
|
||||
|
||||
// EFFECT 2: Start Electric sync + live query for real-time updates
|
||||
// No type filter — syncs and queries ALL documents; filtering is client-side
|
||||
useEffect(() => {
|
||||
if (!searchSpaceId || !electricClient) return;
|
||||
|
||||
// Capture validated values for async closure
|
||||
const spaceId = searchSpaceId;
|
||||
const client = electricClient;
|
||||
const currentTypeFilter = typeFilter;
|
||||
|
||||
let mounted = true;
|
||||
|
||||
|
|
@ -228,7 +241,7 @@ export function useDocuments(
|
|||
try {
|
||||
console.log("[useDocuments] Starting Electric sync for real-time updates");
|
||||
|
||||
// Start Electric sync
|
||||
// Start Electric sync (all documents for this search space)
|
||||
const handle = await client.syncShape({
|
||||
table: "documents",
|
||||
where: `search_space_id = ${spaceId}`,
|
||||
|
|
@ -263,7 +276,7 @@ export function useDocuments(
|
|||
|
||||
if (!mounted) return;
|
||||
|
||||
// Set up live query
|
||||
// Set up live query (unfiltered — type filtering is done client-side)
|
||||
const db = client.db as {
|
||||
live?: {
|
||||
query: <T>(
|
||||
|
|
@ -281,21 +294,12 @@ export function useDocuments(
|
|||
return;
|
||||
}
|
||||
|
||||
let query = `SELECT id, document_type, search_space_id, title, created_by_id, created_at, status
|
||||
const query = `SELECT id, document_type, search_space_id, title, created_by_id, created_at, status
|
||||
FROM documents
|
||||
WHERE search_space_id = $1`;
|
||||
WHERE search_space_id = $1
|
||||
ORDER BY created_at DESC`;
|
||||
|
||||
const params: (number | string)[] = [spaceId];
|
||||
|
||||
if (currentTypeFilter.length > 0) {
|
||||
const placeholders = currentTypeFilter.map((_, i) => `$${i + 2}`).join(", ");
|
||||
query += ` AND document_type IN (${placeholders})`;
|
||||
params.push(...currentTypeFilter);
|
||||
}
|
||||
|
||||
query += ` ORDER BY created_at DESC`;
|
||||
|
||||
const liveQuery = await db.live.query<DocumentElectric>(query, params);
|
||||
const liveQuery = await db.live.query<DocumentElectric>(query, [spaceId]);
|
||||
|
||||
if (!mounted) {
|
||||
liveQuery.unsubscribe?.();
|
||||
|
|
@ -333,7 +337,7 @@ export function useDocuments(
|
|||
.then((response) => {
|
||||
populateUserCache(response.items);
|
||||
if (mounted) {
|
||||
setDocuments((prev) =>
|
||||
setAllDocuments((prev) =>
|
||||
prev.map((doc) => ({
|
||||
...doc,
|
||||
created_by_name: doc.created_by_id
|
||||
|
|
@ -347,7 +351,7 @@ export function useDocuments(
|
|||
}
|
||||
|
||||
// Smart update logic based on sync state
|
||||
setDocuments((prev) => {
|
||||
setAllDocuments((prev) => {
|
||||
// Don't process if API hasn't loaded yet
|
||||
if (!apiLoadedRef.current) {
|
||||
console.log("[useDocuments] Waiting for API load, skipping live update");
|
||||
|
|
@ -424,7 +428,7 @@ export function useDocuments(
|
|||
liveQueryRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [searchSpaceId, electricClient, typeFilter, electricToDisplayDoc, populateUserCache]);
|
||||
}, [searchSpaceId, electricClient, electricToDisplayDoc, populateUserCache]);
|
||||
|
||||
// Track previous searchSpaceId to detect actual changes
|
||||
const prevSearchSpaceIdRef = useRef<number | null>(null);
|
||||
|
|
@ -432,7 +436,7 @@ export function useDocuments(
|
|||
// Reset on search space change (not on initial mount)
|
||||
useEffect(() => {
|
||||
if (prevSearchSpaceIdRef.current !== null && prevSearchSpaceIdRef.current !== searchSpaceId) {
|
||||
setDocuments([]);
|
||||
setAllDocuments([]);
|
||||
apiLoadedRef.current = false;
|
||||
userCacheRef.current.clear();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,11 +32,9 @@ class ImageGenConfigApiService {
|
|||
const msg = parsed.error.issues.map((i) => i.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${msg}`);
|
||||
}
|
||||
return baseApiService.post(
|
||||
`/api/v1/image-generation-configs`,
|
||||
createImageGenConfigResponse,
|
||||
{ body: parsed.data }
|
||||
);
|
||||
return baseApiService.post(`/api/v1/image-generation-configs`, createImageGenConfigResponse, {
|
||||
body: parsed.data,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ class NotificationsApiService {
|
|||
if (queryParams.offset !== undefined) {
|
||||
params.append("offset", String(queryParams.offset));
|
||||
}
|
||||
if (queryParams.search) {
|
||||
params.append("search", queryParams.search);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
|
||||
|
|
|
|||
|
|
@ -67,14 +67,10 @@ const pendingSyncs = new Map<string, Promise<SyncHandle>>();
|
|||
// v2: user-specific database architecture
|
||||
// v3: consistent cutoff date for sync+queries, visibility refresh support
|
||||
// v4: heartbeat-based stale notification detection with updated_at tracking
|
||||
// v5: fixed duplicate key errors (root cause: unstable cutoff dates in use-inbox.ts)
|
||||
// - added onMustRefetch handler for server-side refetch scenarios
|
||||
// - fixed getSyncCutoffDate to use stable midnight UTC timestamps
|
||||
// v6: real-time documents table - added title and created_by_id columns for live document display
|
||||
// v7: removed use-documents-electric.ts - consolidated to single documents sync to prevent conflicts
|
||||
// v8: added status column for real-time document processing status (ready/processing/failed)
|
||||
// v9: added pending state for accurate document queue visibility
|
||||
const SYNC_VERSION = 11;
|
||||
// v5: fixed duplicate key errors, stable cutoff dates, onMustRefetch handler,
|
||||
// real-time documents table with title/created_by_id/status columns,
|
||||
// consolidated single documents sync, pending state for document queue visibility
|
||||
const SYNC_VERSION = 5;
|
||||
|
||||
// Database name prefix for identifying SurfSense databases
|
||||
const DB_PREFIX = "surfsense-";
|
||||
|
|
|
|||
|
|
@ -92,4 +92,8 @@ export const cacheKeys = {
|
|||
bySearchSpace: (searchSpaceId: number) =>
|
||||
["public-chat-snapshots", "search-space", searchSpaceId] as const,
|
||||
},
|
||||
notifications: {
|
||||
search: (searchSpaceId: number | null, search: string, tab: string) =>
|
||||
["notifications", "search", searchSpaceId, search, tab] as const,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -376,7 +376,7 @@
|
|||
"upload_documents": {
|
||||
"title": "Upload Documents",
|
||||
"subtitle": "Upload your files to make them searchable and accessible through AI-powered conversations.",
|
||||
"file_size_limit": "Maximum file size: 50MB per file. Supported formats vary based on your ETL service configuration.",
|
||||
"file_size_limit": "Maximum file size: 50MB per file.",
|
||||
"upload_limits": "Upload limit: {maxFiles} files, {maxSizeMB}MB total.",
|
||||
"drop_files": "Drop files here",
|
||||
"drag_drop": "Drag & drop files here",
|
||||
|
|
|
|||
|
|
@ -360,7 +360,7 @@
|
|||
"upload_documents": {
|
||||
"title": "上传文档",
|
||||
"subtitle": "上传您的文件,使其可通过 AI 对话进行搜索和访问。",
|
||||
"file_size_limit": "最大文件大小:每个文件 50MB。支持的格式因您的 ETL 服务配置而异。",
|
||||
"file_size_limit": "最大文件大小:每个文件 50MB。",
|
||||
"upload_limits": "上传限制:最多 {maxFiles} 个文件,总大小不超过 {maxSizeMB}MB。",
|
||||
"drop_files": "放下文件到这里",
|
||||
"drag_drop": "拖放文件到这里",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue