mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-09 15:52:40 +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
|
session, unique_identifier_hash
|
||||||
)
|
)
|
||||||
if existing:
|
if existing:
|
||||||
# Clean up temp file for duplicates
|
if DocumentStatus.is_state(existing.status, DocumentStatus.READY):
|
||||||
os.unlink(temp_path)
|
# True duplicate — content already indexed, skip
|
||||||
skipped_duplicates += 1
|
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
|
continue
|
||||||
|
|
||||||
# Create pending document (visible immediately in UI via ElectricSQL)
|
# Create pending document (visible immediately in UI via ElectricSQL)
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,9 @@ async def list_notifications(
|
||||||
before_date: str | None = Query(
|
before_date: str | None = Query(
|
||||||
None, description="Get notifications before this ISO date (for pagination)"
|
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"),
|
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"),
|
offset: int = Query(0, ge=0, description="Number of items to skip"),
|
||||||
user: User = Depends(current_active_user),
|
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)",
|
detail="Invalid date format. Use ISO format (e.g., 2024-01-15T00:00:00Z)",
|
||||||
) from None
|
) 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
|
# Get total count
|
||||||
total_result = await session.execute(count_query)
|
total_result = await session.execute(count_query)
|
||||||
total = total_result.scalar() or 0
|
total = total_result.scalar() or 0
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
"""Celery tasks for document processing."""
|
"""Celery tasks for document processing."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
@ -17,6 +19,79 @@ from app.tasks.document_processors import (
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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():
|
def get_celery_session_maker():
|
||||||
"""
|
"""
|
||||||
|
|
@ -44,8 +119,6 @@ def process_extension_document_task(
|
||||||
search_space_id: ID of the search space
|
search_space_id: ID of the search space
|
||||||
user_id: ID of the user
|
user_id: ID of the user
|
||||||
"""
|
"""
|
||||||
import asyncio
|
|
||||||
|
|
||||||
# Create a new event loop for this task
|
# Create a new event loop for this task
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(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
|
search_space_id: ID of the search space
|
||||||
user_id: ID of the user
|
user_id: ID of the user
|
||||||
"""
|
"""
|
||||||
import asyncio
|
|
||||||
|
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(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(
|
log_entry = await task_logger.log_task_start(
|
||||||
task_name="process_youtube_video",
|
task_name="process_youtube_video",
|
||||||
source="document_processor",
|
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(
|
result = await add_youtube_video_document(
|
||||||
session, url, search_space_id, user_id
|
session, url, search_space_id, user_id, notification=notification
|
||||||
)
|
)
|
||||||
|
|
||||||
if result:
|
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}")
|
logger.error(f"Error processing YouTube video: {e!s}")
|
||||||
raise
|
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)
|
@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
|
search_space_id: ID of the search space
|
||||||
user_id: ID of the user
|
user_id: ID of the user
|
||||||
"""
|
"""
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -336,7 +413,7 @@ def process_file_upload_task(
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[process_file_upload] File does not exist: {file_path}. "
|
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
|
return
|
||||||
|
|
||||||
|
|
@ -370,8 +447,6 @@ async def _process_file_upload(
|
||||||
file_path: str, filename: str, search_space_id: int, user_id: str
|
file_path: str, filename: str, search_space_id: int, user_id: str
|
||||||
):
|
):
|
||||||
"""Process file upload with new session."""
|
"""Process file upload with new session."""
|
||||||
import os
|
|
||||||
|
|
||||||
from app.tasks.document_processors.file_processors import process_file_in_background
|
from app.tasks.document_processors.file_processors import process_file_in_background
|
||||||
|
|
||||||
logger.info(f"[_process_file_upload] Starting async processing for: {filename}")
|
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'}"
|
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(
|
log_entry = await task_logger.log_task_start(
|
||||||
task_name="process_file_upload",
|
task_name="process_file_upload",
|
||||||
source="document_processor",
|
source="document_processor",
|
||||||
|
|
@ -535,6 +614,10 @@ async def _process_file_upload(
|
||||||
)
|
)
|
||||||
logger.error(error_message)
|
logger.error(error_message)
|
||||||
raise
|
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)
|
@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
|
search_space_id: ID of the search space
|
||||||
user_id: ID of the user
|
user_id: ID of the user
|
||||||
"""
|
"""
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -573,7 +654,7 @@ def process_file_upload_with_document_task(
|
||||||
if not os.path.exists(temp_path):
|
if not os.path.exists(temp_path):
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[process_file_upload_with_document] File does not exist: {temp_path}. "
|
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
|
# Mark document as failed since file is missing
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
|
|
@ -582,7 +663,7 @@ def process_file_upload_with_document_task(
|
||||||
loop.run_until_complete(
|
loop.run_until_complete(
|
||||||
_mark_document_failed(
|
_mark_document_failed(
|
||||||
document_id,
|
document_id,
|
||||||
"File not found - temp file may have been cleaned up",
|
"File not found. Please re-upload the file.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -640,8 +721,6 @@ async def _process_file_with_document(
|
||||||
- Processes the file (parsing, embedding, chunking)
|
- Processes the file (parsing, embedding, chunking)
|
||||||
- Updates document to 'ready' on success or 'failed' on error
|
- Updates document to 'ready' on success or 'failed' on error
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
|
|
||||||
from app.db import Document, DocumentStatus
|
from app.db import Document, DocumentStatus
|
||||||
from app.tasks.document_processors.base import get_current_timestamp
|
from app.tasks.document_processors.base import get_current_timestamp
|
||||||
from app.tasks.document_processors.file_processors import (
|
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(
|
log_entry = await task_logger.log_task_start(
|
||||||
task_name="process_file_upload_with_document",
|
task_name="process_file_upload_with_document",
|
||||||
source="document_processor",
|
source="document_processor",
|
||||||
|
|
@ -822,6 +914,10 @@ async def _process_file_with_document(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
# Stop heartbeat — key deleted on success, expires on crash
|
||||||
|
heartbeat_task.cancel()
|
||||||
|
_stop_heartbeat(notification.id)
|
||||||
|
|
||||||
# Clean up temp file
|
# Clean up temp file
|
||||||
if os.path.exists(temp_path):
|
if os.path.exists(temp_path):
|
||||||
try:
|
try:
|
||||||
|
|
@ -856,8 +952,6 @@ def process_circleback_meeting_task(
|
||||||
search_space_id: ID of the search space
|
search_space_id: ID of the search space
|
||||||
connector_id: ID of the Circleback connector (for deletion support)
|
connector_id: ID of the Circleback connector (for deletion support)
|
||||||
"""
|
"""
|
||||||
import asyncio
|
|
||||||
|
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
|
@ -897,6 +991,7 @@ async def _process_circleback_meeting(
|
||||||
|
|
||||||
# Create notification if user_id is available
|
# Create notification if user_id is available
|
||||||
notification = None
|
notification = None
|
||||||
|
heartbeat_task = None
|
||||||
if user_id:
|
if user_id:
|
||||||
notification = (
|
notification = (
|
||||||
await NotificationService.document_processing.notify_processing_started(
|
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(
|
log_entry = await task_logger.log_task_start(
|
||||||
task_name="process_circleback_meeting",
|
task_name="process_circleback_meeting",
|
||||||
source="circleback_webhook",
|
source="circleback_webhook",
|
||||||
|
|
@ -1000,3 +1099,9 @@ async def _process_circleback_meeting(
|
||||||
|
|
||||||
logger.error(f"Error processing Circleback meeting: {e!s}")
|
logger.error(f"Error processing Circleback meeting: {e!s}")
|
||||||
raise
|
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
|
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.
|
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
|
It handles two notification types:
|
||||||
to connectors with stale notifications.
|
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:
|
Detection mechanism:
|
||||||
- Active indexing tasks set a Redis key with TTL (2 minutes) as a heartbeat
|
- Active tasks set a Redis key with TTL (2 minutes) as a heartbeat
|
||||||
- If the task crashes, the Redis key expires automatically
|
- 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
|
- This cleanup task checks for in-progress notifications without a Redis heartbeat key
|
||||||
- Such notifications are marked as failed with O(1) batch UPDATE
|
- 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
|
import contextlib
|
||||||
|
|
@ -36,8 +43,9 @@ logger = logging.getLogger(__name__)
|
||||||
# Redis client for checking heartbeats
|
# Redis client for checking heartbeats
|
||||||
_redis_client: redis.Redis | None = None
|
_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_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:
|
def get_redis_client() -> redis.Redis:
|
||||||
|
|
@ -70,14 +78,13 @@ def get_celery_session_maker():
|
||||||
@celery_app.task(name="cleanup_stale_indexing_notifications")
|
@celery_app.task(name="cleanup_stale_indexing_notifications")
|
||||||
def cleanup_stale_indexing_notifications_task():
|
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:
|
Handles two notification types:
|
||||||
- Have type = 'connector_indexing'
|
1. connector_indexing — connector sync tasks
|
||||||
- Have metadata.status = 'in_progress'
|
2. document_processing — manual file uploads, YouTube videos, etc.
|
||||||
- Do NOT have a corresponding Redis heartbeat key (meaning task crashed)
|
|
||||||
|
|
||||||
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.
|
Also marks associated pending/processing documents as failed.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
@ -87,6 +94,7 @@ def cleanup_stale_indexing_notifications_task():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loop.run_until_complete(_cleanup_stale_notifications())
|
loop.run_until_complete(_cleanup_stale_notifications())
|
||||||
|
loop.run_until_complete(_cleanup_stale_document_processing_notifications())
|
||||||
finally:
|
finally:
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
|
|
@ -269,3 +277,186 @@ async def _cleanup_stuck_documents(session, connector_ids: list[int]):
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
# Don't raise - let the notification cleanup continue even if document cleanup fails
|
# 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(
|
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:
|
) -> Document:
|
||||||
"""
|
"""
|
||||||
Process a YouTube video URL, extract transcripts, and store as a 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)
|
url: YouTube video URL (supports standard, shortened, and embed formats)
|
||||||
search_space_id: ID of the search space to add the document to
|
search_space_id: ID of the search space to add the document to
|
||||||
user_id: ID of the user
|
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:
|
Returns:
|
||||||
Document: The created document object
|
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!
|
await session.commit() # Document visible in UI now with pending status!
|
||||||
is_new_document = True
|
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}")
|
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 = (
|
const chip = (
|
||||||
<span
|
<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">
|
<span ref={textRef} className="truncate min-w-0">
|
||||||
{fullLabel}
|
{fullLabel}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ export function DocumentsFilters({
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search types..."
|
placeholder="Search types"
|
||||||
value={typeSearchQuery}
|
value={typeSearchQuery}
|
||||||
onChange={(e) => setTypeSearchQuery(e.target.value)}
|
onChange={(e) => setTypeSearchQuery(e.target.value)}
|
||||||
className="h-6 pl-6 text-sm bg-transparent border-0 focus-visible:ring-0"
|
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 { formatDistanceToNow } from "date-fns";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
BadgeInfo,
|
||||||
Calendar,
|
Calendar,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|
@ -372,7 +373,7 @@ export function DocumentsTableShell({
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)}
|
)}
|
||||||
{columnVisibility.status && (
|
{columnVisibility.status && (
|
||||||
<TableHead className="w-20 text-center">
|
<TableHead className="w-14 text-center">
|
||||||
<Skeleton className="h-3 w-12 mx-auto" />
|
<Skeleton className="h-3 w-12 mx-auto" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)}
|
)}
|
||||||
|
|
@ -414,7 +415,7 @@ export function DocumentsTableShell({
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
{columnVisibility.status && (
|
{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" />
|
<Skeleton className="h-5 w-5 mx-auto rounded-full" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
|
|
@ -466,7 +467,7 @@ export function DocumentsTableShell({
|
||||||
className="flex flex-col items-center gap-4 max-w-md px-4 text-center"
|
className="flex flex-col items-center gap-4 max-w-md px-4 text-center"
|
||||||
>
|
>
|
||||||
<div className="rounded-full bg-muted/50 p-4">
|
<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>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<h3 className="text-lg font-semibold">{t("no_documents")}</h3>
|
<h3 className="text-lg font-semibold">{t("no_documents")}</h3>
|
||||||
|
|
@ -544,8 +545,11 @@ export function DocumentsTableShell({
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)}
|
)}
|
||||||
{columnVisibility.status && (
|
{columnVisibility.status && (
|
||||||
<TableHead className="w-20 text-center">
|
<TableHead className="w-14">
|
||||||
<span className="text-sm font-medium text-muted-foreground/70">Status</span>
|
<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>
|
||||||
)}
|
)}
|
||||||
<TableHead className="w-10">
|
<TableHead className="w-10">
|
||||||
|
|
@ -644,7 +648,7 @@ export function DocumentsTableShell({
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
{columnVisibility.status && (
|
{columnVisibility.status && (
|
||||||
<TableCell className="w-20 py-2.5 text-center">
|
<TableCell className="w-14 py-2.5 text-center">
|
||||||
<StatusIndicator status={doc.status} />
|
<StatusIndicator status={doc.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
|
|
@ -205,7 +206,10 @@ export function RowActions({
|
||||||
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<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>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,9 @@ export default function DocumentsTable() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setPageIndex(0);
|
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 () => {
|
const onBulkDelete = async () => {
|
||||||
|
|
|
||||||
|
|
@ -844,7 +844,12 @@ export default function NewChatPage() {
|
||||||
});
|
});
|
||||||
// Invalidate thread detail for breadcrumb update
|
// Invalidate thread detail for breadcrumb update
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["threads", String(searchSpaceId), "detail", String(titleData.threadId)],
|
queryKey: [
|
||||||
|
"threads",
|
||||||
|
String(searchSpaceId),
|
||||||
|
"detail",
|
||||||
|
String(titleData.threadId),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -1403,7 +1408,7 @@ export default function NewChatPage() {
|
||||||
// Show loading state only when loading an existing thread
|
// Show loading state only when loading an existing thread
|
||||||
if (isInitializing) {
|
if (isInitializing) {
|
||||||
return (
|
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">
|
<div className="mx-auto w-full max-w-[44rem] flex flex-1 flex-col gap-6 py-8">
|
||||||
{/* User message */}
|
{/* User message */}
|
||||||
<div className="flex justify-end">
|
<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)
|
// For new chats (urlChatId === 0), threadId being null is expected (lazy creation)
|
||||||
if (!threadId && urlChatId > 0) {
|
if (!threadId && urlChatId > 0) {
|
||||||
return (
|
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>
|
<div className="text-destructive">Failed to load chat</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -1469,7 +1474,7 @@ export default function NewChatPage() {
|
||||||
<SaveMemoryToolUI />
|
<SaveMemoryToolUI />
|
||||||
<RecallMemoryToolUI />
|
<RecallMemoryToolUI />
|
||||||
{/* <WriteTodosToolUI /> Disabled for now */}
|
{/* <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
|
<Thread
|
||||||
messageThinkingSteps={messageThinkingSteps}
|
messageThinkingSteps={messageThinkingSteps}
|
||||||
header={<ChatHeader searchSpaceId={searchSpaceId} />}
|
header={<ChatHeader searchSpaceId={searchSpaceId} />}
|
||||||
|
|
|
||||||
|
|
@ -290,11 +290,11 @@ function SettingsContent({
|
||||||
<GeneralSettingsManager searchSpaceId={searchSpaceId} />
|
<GeneralSettingsManager searchSpaceId={searchSpaceId} />
|
||||||
)}
|
)}
|
||||||
{activeSection === "models" && <ModelConfigManager searchSpaceId={searchSpaceId} />}
|
{activeSection === "models" && <ModelConfigManager searchSpaceId={searchSpaceId} />}
|
||||||
{activeSection === "roles" && <LLMRoleManager searchSpaceId={searchSpaceId} />}
|
{activeSection === "roles" && <LLMRoleManager searchSpaceId={searchSpaceId} />}
|
||||||
{activeSection === "image-models" && (
|
{activeSection === "image-models" && (
|
||||||
<ImageModelManager searchSpaceId={searchSpaceId} />
|
<ImageModelManager searchSpaceId={searchSpaceId} />
|
||||||
)}
|
)}
|
||||||
{activeSection === "prompts" && <PromptConfigManager searchSpaceId={searchSpaceId} />}
|
{activeSection === "prompts" && <PromptConfigManager searchSpaceId={searchSpaceId} />}
|
||||||
{activeSection === "public-links" && (
|
{activeSection === "public-links" && (
|
||||||
<PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />
|
<PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { RootProvider } from "fumadocs-ui/provider/next";
|
import { RootProvider } from "fumadocs-ui/provider/next";
|
||||||
import { Roboto } from "next/font/google";
|
import { Roboto } from "next/font/google";
|
||||||
|
|
@ -19,6 +19,20 @@ const roboto = Roboto({
|
||||||
variable: "--font-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 = {
|
export const metadata: Metadata = {
|
||||||
title: "SurfSense – Customizable AI Research & Knowledge Management Assistant",
|
title: "SurfSense – Customizable AI Research & Knowledge Management Assistant",
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,13 @@ import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cn } from "@/lib/utils";
|
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 = (
|
const image = (
|
||||||
<Image
|
<Image
|
||||||
src="/icon-128.svg"
|
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 />
|
<ThreadScrollToBottom />
|
||||||
<AssistantIf condition={({ thread }) => !thread.isEmpty}>
|
<AssistantIf condition={({ thread }) => !thread.isEmpty}>
|
||||||
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
|
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
|
||||||
|
|
|
||||||
|
|
@ -526,7 +526,9 @@ export function LayoutDataProvider({
|
||||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||||
// Invalidate thread detail for breadcrumb update
|
// 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) {
|
} catch (error) {
|
||||||
console.error("Error renaming thread:", error);
|
console.error("Error renaming thread:", error);
|
||||||
toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");
|
toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Settings, Trash2, Users } from "lucide-react";
|
import { Settings, Trash2, Users } from "lucide-react";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
|
|
@ -9,6 +10,13 @@ import {
|
||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu";
|
} 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -21,6 +29,7 @@ interface SearchSpaceAvatarProps {
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onSettings?: () => void;
|
onSettings?: () => void;
|
||||||
size?: "sm" | "md";
|
size?: "sm" | "md";
|
||||||
|
disableTooltip?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -64,6 +73,7 @@ export function SearchSpaceAvatar({
|
||||||
onDelete,
|
onDelete,
|
||||||
onSettings,
|
onSettings,
|
||||||
size = "md",
|
size = "md",
|
||||||
|
disableTooltip = false,
|
||||||
}: SearchSpaceAvatarProps) {
|
}: SearchSpaceAvatarProps) {
|
||||||
const t = useTranslations("searchSpace");
|
const t = useTranslations("searchSpace");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
|
|
@ -71,6 +81,35 @@ export function SearchSpaceAvatar({
|
||||||
const initials = getInitials(name);
|
const initials = getInitials(name);
|
||||||
const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm";
|
const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm";
|
||||||
|
|
||||||
|
// 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 = (
|
const tooltipContent = (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span>{name}</span>
|
<span>{name}</span>
|
||||||
|
|
@ -110,8 +149,53 @@ export function SearchSpaceAvatar({
|
||||||
</button>
|
</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 delete or settings handlers are provided, wrap with context menu
|
||||||
if (onDelete || onSettings) {
|
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 (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
@ -150,6 +234,10 @@ export function SearchSpaceAvatar({
|
||||||
}
|
}
|
||||||
|
|
||||||
// No context menu needed
|
// No context menu needed
|
||||||
|
if (disableTooltip) {
|
||||||
|
return avatarButton;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>{avatarButton}</TooltipTrigger>
|
<TooltipTrigger asChild>{avatarButton}</TooltipTrigger>
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,12 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import {
|
import {
|
||||||
deleteThread,
|
deleteThread,
|
||||||
fetchThreads,
|
fetchThreads,
|
||||||
|
|
@ -56,6 +58,7 @@ export function AllPrivateChatsSidebar({
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const currentChatId = Array.isArray(params.chat_id)
|
const currentChatId = Array.isArray(params.chat_id)
|
||||||
? Number(params.chat_id[0])
|
? Number(params.chat_id[0])
|
||||||
|
|
@ -303,8 +306,16 @@ export function AllPrivateChatsSidebar({
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="space-y-1">
|
||||||
<Spinner size="md" className="text-muted-foreground" />
|
{[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>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="text-center py-8 text-sm text-destructive">
|
<div className="text-center py-8 text-sm text-destructive">
|
||||||
|
|
@ -329,25 +340,37 @@ export function AllPrivateChatsSidebar({
|
||||||
isBusy && "opacity-50 pointer-events-none"
|
isBusy && "opacity-50 pointer-events-none"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
{isMobile ? (
|
||||||
<TooltipTrigger asChild>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => handleThreadClick(thread.id)}
|
||||||
onClick={() => handleThreadClick(thread.id)}
|
disabled={isBusy}
|
||||||
disabled={isBusy}
|
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||||
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" />
|
||||||
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<span className="truncate">{thread.title || "New Chat"}</span>
|
||||||
<span className="truncate">{thread.title || "New Chat"}</span>
|
</button>
|
||||||
</button>
|
) : (
|
||||||
</TooltipTrigger>
|
<Tooltip>
|
||||||
<TooltipContent side="bottom" align="start">
|
<TooltipTrigger asChild>
|
||||||
<p>
|
<button
|
||||||
{t("updated") || "Updated"}:{" "}
|
type="button"
|
||||||
{format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
|
onClick={() => handleThreadClick(thread.id)}
|
||||||
</p>
|
disabled={isBusy}
|
||||||
</TooltipContent>
|
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||||
</Tooltip>
|
>
|
||||||
|
<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
|
<DropdownMenu
|
||||||
open={openDropdownId === thread.id}
|
open={openDropdownId === thread.id}
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,12 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import {
|
import {
|
||||||
deleteThread,
|
deleteThread,
|
||||||
fetchThreads,
|
fetchThreads,
|
||||||
|
|
@ -56,6 +58,7 @@ export function AllSharedChatsSidebar({
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const currentChatId = Array.isArray(params.chat_id)
|
const currentChatId = Array.isArray(params.chat_id)
|
||||||
? Number(params.chat_id[0])
|
? Number(params.chat_id[0])
|
||||||
|
|
@ -303,8 +306,16 @@ export function AllSharedChatsSidebar({
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="space-y-1">
|
||||||
<Spinner size="md" className="text-muted-foreground" />
|
{[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>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="text-center py-8 text-sm text-destructive">
|
<div className="text-center py-8 text-sm text-destructive">
|
||||||
|
|
@ -329,25 +340,37 @@ export function AllSharedChatsSidebar({
|
||||||
isBusy && "opacity-50 pointer-events-none"
|
isBusy && "opacity-50 pointer-events-none"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
{isMobile ? (
|
||||||
<TooltipTrigger asChild>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => handleThreadClick(thread.id)}
|
||||||
onClick={() => handleThreadClick(thread.id)}
|
disabled={isBusy}
|
||||||
disabled={isBusy}
|
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||||
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" />
|
||||||
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<span className="truncate">{thread.title || "New Chat"}</span>
|
||||||
<span className="truncate">{thread.title || "New Chat"}</span>
|
</button>
|
||||||
</button>
|
) : (
|
||||||
</TooltipTrigger>
|
<Tooltip>
|
||||||
<TooltipContent side="bottom" align="start">
|
<TooltipTrigger asChild>
|
||||||
<p>
|
<button
|
||||||
{t("updated") || "Updated"}:{" "}
|
type="button"
|
||||||
{format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
|
onClick={() => handleThreadClick(thread.id)}
|
||||||
</p>
|
disabled={isBusy}
|
||||||
</TooltipContent>
|
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||||
</Tooltip>
|
>
|
||||||
|
<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
|
<DropdownMenu
|
||||||
open={openDropdownId === thread.id}
|
open={openDropdownId === thread.id}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
"use client";
|
"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 { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -59,26 +66,26 @@ export function ChatListItem({
|
||||||
<span className="sr-only">{t("more_options")}</span>
|
<span className="sr-only">{t("more_options")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" side="right">
|
<DropdownMenuContent align="end" side="right">
|
||||||
{onRename && (
|
{onRename && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRename();
|
onRename();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PencilIcon className="mr-2 h-4 w-4" />
|
<PencilIcon className="mr-2 h-4 w-4" />
|
||||||
<span>{t("rename") || "Rename"}</span>
|
<span>{t("rename") || "Rename"}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{onArchive && (
|
{onArchive && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onArchive();
|
onArchive();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{archived ? (
|
{archived ? (
|
||||||
<>
|
<>
|
||||||
<RotateCcwIcon className="mr-2 h-4 w-4" />
|
<RotateCcwIcon className="mr-2 h-4 w-4" />
|
||||||
<span>{t("unarchive") || "Restore"}</span>
|
<span>{t("unarchive") || "Restore"}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
|
@ -19,7 +20,7 @@ import {
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
|
|
@ -41,6 +42,7 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
@ -52,7 +54,10 @@ import {
|
||||||
isPageLimitExceededMetadata,
|
isPageLimitExceededMetadata,
|
||||||
} from "@/contracts/types/inbox.types";
|
} from "@/contracts/types/inbox.types";
|
||||||
import type { InboxItem } from "@/hooks/use-inbox";
|
import type { InboxItem } from "@/hooks/use-inbox";
|
||||||
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
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 { cn } from "@/lib/utils";
|
||||||
import { useSidebarContextSafe } from "../../hooks";
|
import { useSidebarContextSafe } from "../../hooks";
|
||||||
|
|
||||||
|
|
@ -179,7 +184,9 @@ export function InboxSidebar({
|
||||||
}: InboxSidebarProps) {
|
}: InboxSidebarProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
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)
|
// Comments collapsed state (desktop only, when docked)
|
||||||
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
|
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
|
||||||
|
|
@ -187,12 +194,22 @@ export function InboxSidebar({
|
||||||
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const debouncedSearch = useDebouncedValue(searchQuery, 300);
|
||||||
|
const isSearchMode = !!debouncedSearch.trim();
|
||||||
const [activeTab, setActiveTab] = useState<InboxTab>("comments");
|
const [activeTab, setActiveTab] = useState<InboxTab>("comments");
|
||||||
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
||||||
const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
|
const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
// Dropdown state for filter menu (desktop only)
|
// Dropdown state for filter menu (desktop only)
|
||||||
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
|
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)
|
// Drawer state for filter menu (mobile only)
|
||||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||||
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||||
|
|
@ -200,6 +217,24 @@ export function InboxSidebar({
|
||||||
// Prefetch trigger ref - placed on item near the end
|
// Prefetch trigger ref - placed on item near the end
|
||||||
const prefetchTriggerRef = useRef<HTMLDivElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -234,17 +269,11 @@ export function InboxSidebar({
|
||||||
}
|
}
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
// Both tabs now derive items from status (all types), so use status for pagination
|
// Each tab uses its own data source for independent pagination
|
||||||
const { loading, loadingMore = false, hasMore = false, loadMore } = status;
|
// Comments tab: uses mentions data source (fetches only mention/reply types from server)
|
||||||
|
const commentsItems = mentions.items;
|
||||||
|
|
||||||
// Comments tab: mentions and comment replies
|
// Status tab: filters status data source (fetches all types) to status-specific types
|
||||||
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
|
|
||||||
const statusItems = useMemo(
|
const statusItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
status.items.filter(
|
status.items.filter(
|
||||||
|
|
@ -257,6 +286,14 @@ export function InboxSidebar({
|
||||||
[status.items]
|
[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
|
// Get unique connector types from status items for filtering
|
||||||
const uniqueConnectorTypes = useMemo(() => {
|
const uniqueConnectorTypes = useMemo(() => {
|
||||||
const connectorTypes = new Set<string>();
|
const connectorTypes = new Set<string>();
|
||||||
|
|
@ -279,9 +316,23 @@ export function InboxSidebar({
|
||||||
// Get items for current tab
|
// Get items for current tab
|
||||||
const displayItems = activeTab === "comments" ? commentsItems : statusItems;
|
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(() => {
|
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
|
// Apply read/unread filter
|
||||||
if (activeFilter === "unread") {
|
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;
|
return items;
|
||||||
}, [displayItems, activeFilter, activeTab, selectedConnector, searchQuery]);
|
}, [displayItems, searchResponse, isSearchMode, activeFilter, activeTab, selectedConnector]);
|
||||||
|
|
||||||
// Intersection Observer for infinite scroll with prefetching
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!loadMore || !hasMore || loadingMore || !open || searchQuery.trim()) return;
|
if (!loadMore || !hasMore || loadingMore || !open || isSearchMode) return;
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
|
|
@ -338,17 +381,11 @@ export function InboxSidebar({
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [loadMore, hasMore, loadingMore, open, searchQuery]);
|
}, [loadMore, hasMore, loadingMore, open, isSearchMode, activeTab]);
|
||||||
|
|
||||||
// Unread counts derived from filtered items
|
// Unread counts from server-side accurate totals (passed via props)
|
||||||
const unreadCommentsCount = useMemo(
|
const unreadCommentsCount = mentions.unreadCount;
|
||||||
() => commentsItems.filter((item) => !item.read).length,
|
const unreadStatusCount = status.unreadCount;
|
||||||
[commentsItems]
|
|
||||||
);
|
|
||||||
const unreadStatusCount = useMemo(
|
|
||||||
() => statusItems.filter((item) => !item.read).length,
|
|
||||||
[statusItems]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleItemClick = useCallback(
|
const handleItemClick = useCallback(
|
||||||
async (item: InboxItem) => {
|
async (item: InboxItem) => {
|
||||||
|
|
@ -539,26 +576,33 @@ export function InboxSidebar({
|
||||||
<div className="shrink-0 p-4 pb-2 space-y-3">
|
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* Mobile: Button that opens bottom drawer */}
|
{/* Mobile: Button that opens bottom drawer */}
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<>
|
<>
|
||||||
<Tooltip>
|
<Button
|
||||||
<TooltipTrigger asChild>
|
variant="ghost"
|
||||||
<Button
|
size="icon"
|
||||||
variant="ghost"
|
className="h-8 w-8 rounded-full"
|
||||||
size="icon"
|
onClick={() => setFilterDrawerOpen(true)}
|
||||||
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>
|
||||||
<ListFilter className="h-4 w-4 text-muted-foreground" />
|
</Button>
|
||||||
<span className="sr-only">{t("filter") || "Filter"}</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Drawer
|
<Drawer
|
||||||
open={filterDrawerOpen}
|
open={filterDrawerOpen}
|
||||||
onOpenChange={setFilterDrawerOpen}
|
onOpenChange={setFilterDrawerOpen}
|
||||||
|
|
@ -725,66 +769,71 @@ export function InboxSidebar({
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
|
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
|
||||||
{t("connectors") || "Connectors"}
|
{t("connectors") || "Connectors"}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuItem
|
<div
|
||||||
onClick={() => setSelectedConnector(null)}
|
className="relative max-h-[30vh] overflow-y-auto -mb-1"
|
||||||
className="flex items-center justify-between"
|
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
|
<DropdownMenuItem
|
||||||
key={connector.type}
|
onClick={() => setSelectedConnector(null)}
|
||||||
onClick={() => setSelectedConnector(connector.type)}
|
|
||||||
className="flex items-center justify-between"
|
className="flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
<LayoutGrid className="h-4 w-4" />
|
||||||
<span>{connector.displayName}</span>
|
<span>{t("all_connectors") || "All connectors"}</span>
|
||||||
</span>
|
</span>
|
||||||
{selectedConnector === connector.type && <Check className="h-4 w-4" />}
|
{selectedConnector === null && <Check className="h-4 w-4" />}
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
<Tooltip>
|
{isMobile ? (
|
||||||
<TooltipTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
className="h-8 w-8 rounded-full"
|
||||||
className="h-8 w-8 rounded-full"
|
onClick={handleMarkAllAsRead}
|
||||||
onClick={handleMarkAllAsRead}
|
disabled={totalUnreadCount === 0}
|
||||||
disabled={totalUnreadCount === 0}
|
>
|
||||||
>
|
<CheckCheck className="h-4 w-4 text-muted-foreground" />
|
||||||
<CheckCheck className="h-4 w-4 text-muted-foreground" />
|
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
|
||||||
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
|
</Button>
|
||||||
</Button>
|
) : (
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="z-80">
|
|
||||||
{t("mark_all_read") || "Mark all as read"}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
{/* Close button - mobile only */}
|
|
||||||
{isMobile && (
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 rounded-full"
|
className="h-8 w-8 rounded-full"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={handleMarkAllAsRead}
|
||||||
|
disabled={totalUnreadCount === 0}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
<CheckCheck className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="sr-only">{t("close") || "Close"}</span>
|
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="z-80">{t("close") || "Close"}</TooltipContent>
|
<TooltipContent className="z-80">
|
||||||
|
{t("mark_all_read") || "Mark all as read"}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* Dock/Undock button - desktop only */}
|
{/* Dock/Undock button - desktop only */}
|
||||||
|
|
@ -881,17 +930,48 @@ export function InboxSidebar({
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||||
{loading ? (
|
{(isSearchMode ? isSearchLoading : loading) ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="space-y-2">
|
||||||
<Spinner size="md" className="text-muted-foreground" />
|
{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>
|
</div>
|
||||||
) : filteredItems.length > 0 ? (
|
) : filteredItems.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filteredItems.map((item, index) => {
|
{filteredItems.map((item, index) => {
|
||||||
const isMarkingAsRead = markingAsReadId === item.id;
|
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 =
|
const isPrefetchTrigger =
|
||||||
!searchQuery && hasMore && index === filteredItems.length - 5;
|
!isSearchMode && hasMore && index === filteredItems.length - 5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -904,37 +984,61 @@ export function InboxSidebar({
|
||||||
isMarkingAsRead && "opacity-50 pointer-events-none"
|
isMarkingAsRead && "opacity-50 pointer-events-none"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
{isMobile ? (
|
||||||
<TooltipTrigger asChild>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => handleItemClick(item)}
|
||||||
onClick={() => handleItemClick(item)}
|
disabled={isMarkingAsRead}
|
||||||
disabled={isMarkingAsRead}
|
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
|
||||||
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
|
>
|
||||||
>
|
<div className="shrink-0">{getStatusIcon(item)}</div>
|
||||||
<div className="shrink-0">{getStatusIcon(item)}</div>
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
<div className="flex-1 min-w-0 overflow-hidden">
|
<p
|
||||||
<p
|
className={cn(
|
||||||
className={cn(
|
"text-xs font-medium line-clamp-2",
|
||||||
"text-xs font-medium line-clamp-2",
|
!item.read && "font-semibold"
|
||||||
!item.read && "font-semibold"
|
)}
|
||||||
)}
|
>
|
||||||
>
|
{item.title}
|
||||||
{item.title}
|
</p>
|
||||||
</p>
|
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
|
||||||
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
|
{convertRenderedToDisplay(item.message)}
|
||||||
{convertRenderedToDisplay(item.message)}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</button>
|
) : (
|
||||||
</TooltipTrigger>
|
<Tooltip>
|
||||||
<TooltipContent side="bottom" align="start" className="max-w-[250px]">
|
<TooltipTrigger asChild>
|
||||||
<p className="font-medium">{item.title}</p>
|
<button
|
||||||
<p className="text-muted-foreground mt-1">
|
type="button"
|
||||||
{convertRenderedToDisplay(item.message)}
|
onClick={() => handleItemClick(item)}
|
||||||
</p>
|
disabled={isMarkingAsRead}
|
||||||
</TooltipContent>
|
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
|
||||||
</Tooltip>
|
>
|
||||||
|
<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 */}
|
{/* Time and unread dot - fixed width to prevent content shift */}
|
||||||
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
|
<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 */}
|
{/* 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" />
|
<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>
|
</div>
|
||||||
) : searchQuery ? (
|
) : isSearchMode ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Menu, Plus } from "lucide-react";
|
import { PanelRightClose, Plus } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||||
import { SearchSpaceAvatar } from "../icon-rail/SearchSpaceAvatar";
|
import { SearchSpaceAvatar } from "../icon-rail/SearchSpaceAvatar";
|
||||||
|
|
@ -43,7 +44,7 @@ interface MobileSidebarProps {
|
||||||
export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) {
|
export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Button variant="ghost" size="icon" className="md:hidden h-8 w-8" onClick={onClick}>
|
<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>
|
<span className="sr-only">Open menu</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
@ -97,15 +98,16 @@ export function MobileSidebar({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
<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>
|
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
||||||
|
|
||||||
{/* Horizontal Search Spaces Rail */}
|
{/* Vertical Search Spaces Rail - left side */}
|
||||||
<div className="shrink-0 border-b bg-muted/40 px-2 py-2 overflow-hidden">
|
<div className="flex h-full w-14 shrink-0 flex-col items-center bg-muted/40 border-r">
|
||||||
<div className="flex items-center gap-2 px-1 py-1 overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/20">
|
<ScrollArea className="w-full flex-1">
|
||||||
{searchSpaces.map((space) => (
|
<div className="flex flex-col items-center gap-2 px-1.5 py-3">
|
||||||
<div key={space.id} className="shrink-0">
|
{searchSpaces.map((space) => (
|
||||||
<SearchSpaceAvatar
|
<SearchSpaceAvatar
|
||||||
|
key={space.id}
|
||||||
name={space.name}
|
name={space.name}
|
||||||
isActive={space.id === activeSearchSpaceId}
|
isActive={space.id === activeSearchSpaceId}
|
||||||
isShared={space.memberCount > 1}
|
isShared={space.memberCount > 1}
|
||||||
|
|
@ -116,26 +118,28 @@ export function MobileSidebar({
|
||||||
onSearchSpaceSettings ? () => onSearchSpaceSettings(space) : undefined
|
onSearchSpaceSettings ? () => onSearchSpaceSettings(space) : undefined
|
||||||
}
|
}
|
||||||
size="md"
|
size="md"
|
||||||
|
disableTooltip
|
||||||
/>
|
/>
|
||||||
</div>
|
))}
|
||||||
))}
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
onClick={onAddSearchSpace}
|
||||||
onClick={onAddSearchSpace}
|
className="h-10 w-10 shrink-0 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50"
|
||||||
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" />
|
||||||
<Plus className="h-5 w-5 text-muted-foreground" />
|
<span className="sr-only">Add search space</span>
|
||||||
<span className="sr-only">Add search space</span>
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar Content */}
|
{/* Sidebar Content - right side */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden flex flex-col">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
searchSpace={searchSpace}
|
searchSpace={searchSpace}
|
||||||
isCollapsed={false}
|
isCollapsed={false}
|
||||||
|
onToggleCollapse={() => onOpenChange(false)}
|
||||||
navItems={navItems}
|
navItems={navItems}
|
||||||
onNavItemClick={handleNavItemClick}
|
onNavItemClick={handleNavItemClick}
|
||||||
chats={chats}
|
chats={chats}
|
||||||
|
|
@ -149,8 +153,22 @@ export function MobileSidebar({
|
||||||
onChatRename={onChatRename}
|
onChatRename={onChatRename}
|
||||||
onChatDelete={onChatDelete}
|
onChatDelete={onChatDelete}
|
||||||
onChatArchive={onChatArchive}
|
onChatArchive={onChatArchive}
|
||||||
onViewAllSharedChats={onViewAllSharedChats}
|
onViewAllSharedChats={
|
||||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
onViewAllSharedChats
|
||||||
|
? () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
onViewAllSharedChats();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onViewAllPrivateChats={
|
||||||
|
onViewAllPrivateChats
|
||||||
|
? () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
onViewAllPrivateChats();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
user={user}
|
user={user}
|
||||||
onSettings={onSettings}
|
onSettings={onSettings}
|
||||||
onManageMembers={onManageMembers}
|
onManageMembers={onManageMembers}
|
||||||
|
|
@ -161,6 +179,7 @@ export function MobileSidebar({
|
||||||
setTheme={setTheme}
|
setTheme={setTheme}
|
||||||
className="w-full border-none"
|
className="w-full border-none"
|
||||||
isLoadingChats={isLoadingChats}
|
isLoadingChats={isLoadingChats}
|
||||||
|
disableTooltips
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ interface SidebarProps {
|
||||||
setTheme?: (theme: "light" | "dark" | "system") => void;
|
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
isLoadingChats?: boolean;
|
isLoadingChats?: boolean;
|
||||||
|
disableTooltips?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
|
|
@ -78,6 +79,7 @@ export function Sidebar({
|
||||||
setTheme,
|
setTheme,
|
||||||
className,
|
className,
|
||||||
isLoadingChats = false,
|
isLoadingChats = false,
|
||||||
|
disableTooltips = false,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
|
|
||||||
|
|
@ -95,20 +97,22 @@ export function Sidebar({
|
||||||
<SidebarCollapseButton
|
<SidebarCollapseButton
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onToggle={onToggleCollapse ?? (() => {})}
|
onToggle={onToggleCollapse ?? (() => {})}
|
||||||
|
disableTooltip={disableTooltips}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<SidebarHeader
|
||||||
searchSpace={searchSpace}
|
searchSpace={searchSpace}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onSettings={onSettings}
|
onSettings={onSettings}
|
||||||
onManageMembers={onManageMembers}
|
onManageMembers={onManageMembers}
|
||||||
/>
|
/>
|
||||||
<div className="">
|
<div className="shrink-0">
|
||||||
<SidebarCollapseButton
|
<SidebarCollapseButton
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onToggle={onToggleCollapse ?? (() => {})}
|
onToggle={onToggleCollapse ?? (() => {})}
|
||||||
|
disableTooltip={disableTooltips}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -138,7 +142,7 @@ export function Sidebar({
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<div className="flex-1 w-[60px]" />
|
<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% */}
|
{/* Shared Chats Section - takes only space needed, max 50% */}
|
||||||
<SidebarSection
|
<SidebarSection
|
||||||
title={t("shared_chats")}
|
title={t("shared_chats")}
|
||||||
|
|
@ -147,21 +151,32 @@ export function Sidebar({
|
||||||
className="shrink-0 max-h-[50%] flex flex-col"
|
className="shrink-0 max-h-[50%] flex flex-col"
|
||||||
action={
|
action={
|
||||||
onViewAllSharedChats ? (
|
onViewAllSharedChats ? (
|
||||||
<Tooltip>
|
disableTooltips ? (
|
||||||
<TooltipTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
|
||||||
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
|
onClick={onViewAllSharedChats}
|
||||||
onClick={onViewAllSharedChats}
|
>
|
||||||
>
|
<FolderOpen className="h-4 w-4" />
|
||||||
<FolderOpen className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
) : (
|
||||||
</TooltipTrigger>
|
<Tooltip>
|
||||||
<TooltipContent side="top">
|
<TooltipTrigger asChild>
|
||||||
{t("view_all_shared_chats") || "View all shared chats"}
|
<Button
|
||||||
</TooltipContent>
|
variant="ghost"
|
||||||
</Tooltip>
|
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
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -208,21 +223,32 @@ export function Sidebar({
|
||||||
fillHeight={true}
|
fillHeight={true}
|
||||||
action={
|
action={
|
||||||
onViewAllPrivateChats ? (
|
onViewAllPrivateChats ? (
|
||||||
<Tooltip>
|
disableTooltips ? (
|
||||||
<TooltipTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
|
||||||
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
|
onClick={onViewAllPrivateChats}
|
||||||
onClick={onViewAllPrivateChats}
|
>
|
||||||
>
|
<FolderOpen className="h-4 w-4" />
|
||||||
<FolderOpen className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
) : (
|
||||||
</TooltipTrigger>
|
<Tooltip>
|
||||||
<TooltipContent side="top">
|
<TooltipTrigger asChild>
|
||||||
{t("view_all_private_chats") || "View all private chats"}
|
<Button
|
||||||
</TooltipContent>
|
variant="ghost"
|
||||||
</Tooltip>
|
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
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -8,21 +8,30 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||||
interface SidebarCollapseButtonProps {
|
interface SidebarCollapseButtonProps {
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
disableTooltip?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarCollapseButton({ isCollapsed, onToggle }: SidebarCollapseButtonProps) {
|
export function SidebarCollapseButton({
|
||||||
|
isCollapsed,
|
||||||
|
onToggle,
|
||||||
|
disableTooltip = false,
|
||||||
|
}: SidebarCollapseButtonProps) {
|
||||||
const t = useTranslations("sidebar");
|
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 (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
<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>
|
|
||||||
<TooltipContent side={isCollapsed ? "right" : "bottom"}>
|
<TooltipContent side={isCollapsed ? "right" : "bottom"}>
|
||||||
{isCollapsed ? `${t("expand_sidebar")} (⌘B)` : `${t("collapse_sidebar")} (⌘B)`}
|
{isCollapsed ? `${t("expand_sidebar")} (⌘B)` : `${t("collapse_sidebar")} (⌘B)`}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronsUpDown, Logs, Settings, Users } from "lucide-react";
|
import { ChevronsUpDown, Settings, Users } from "lucide-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -35,14 +35,14 @@ export function SidebarHeader({
|
||||||
const searchSpaceId = params.search_space_id as string;
|
const searchSpaceId = params.search_space_id as string;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex shrink-0 items-center", className)}>
|
<div className={cn("flex min-w-0 flex-1 items-center", className)}>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-auto items-center justify-between gap-2 overflow-hidden py-1.5 font-semibold",
|
"flex h-auto w-full items-center justify-between gap-1 overflow-hidden py-1.5 font-semibold",
|
||||||
isCollapsed ? "w-10" : "w-50"
|
isCollapsed && "w-10"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="truncate text-base">
|
<span className="truncate text-base">
|
||||||
|
|
@ -56,10 +56,6 @@ export function SidebarHeader({
|
||||||
<Users className="mr-2 h-4 w-4" />
|
<Users className="mr-2 h-4 w-4" />
|
||||||
{t("manage_members")}
|
{t("manage_members")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => router.push(`/dashboard/${searchSpaceId}/logs`)}>
|
|
||||||
<Logs className="mr-2 h-4 w-4" />
|
|
||||||
{t("logs")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={onSettings}>
|
<DropdownMenuItem onClick={onSettings}>
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
"use client";
|
"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 { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
<TooltipContent>Share settings</TooltipContent>
|
<TooltipContent>Share settings</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60"
|
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60"
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
|
|
@ -243,7 +243,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
{/* Globe indicator when public snapshots exist - clicks to settings */}
|
{/* Globe indicator when public snapshots exist - clicks to settings */}
|
||||||
{hasPublicSnapshots && (
|
{hasPublicSnapshots && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,17 @@ export function ImageConfigSidebar({
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [mode, isGlobal, config, formData, searchSpaceId, createConfig, updateConfig, updatePreferences, onOpenChange]);
|
}, [
|
||||||
|
mode,
|
||||||
|
isGlobal,
|
||||||
|
config,
|
||||||
|
formData,
|
||||||
|
searchSpaceId,
|
||||||
|
createConfig,
|
||||||
|
updateConfig,
|
||||||
|
updatePreferences,
|
||||||
|
onOpenChange,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleUseGlobalConfig = useCallback(async () => {
|
const handleUseGlobalConfig = useCallback(async () => {
|
||||||
if (!config || !isGlobal) return;
|
if (!config || !isGlobal) return;
|
||||||
|
|
@ -297,11 +307,16 @@ export function ImageConfigSidebar({
|
||||||
<Alert className="mb-6 border-violet-500/30 bg-violet-500/5">
|
<Alert className="mb-6 border-violet-500/30 bg-violet-500/5">
|
||||||
<Shuffle className="size-4 text-violet-500" />
|
<Shuffle className="size-4 text-violet-500" />
|
||||||
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
|
<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>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<div className="flex gap-3 pt-4 border-t border-border/50">
|
<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
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -327,12 +342,16 @@ export function ImageConfigSidebar({
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<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>
|
<p className="text-sm font-medium">{config.name}</p>
|
||||||
</div>
|
</div>
|
||||||
{config.description && (
|
{config.description && (
|
||||||
<div className="space-y-1.5">
|
<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>
|
<p className="text-sm text-muted-foreground">{config.description}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -340,20 +359,32 @@ export function ImageConfigSidebar({
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<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>
|
<p className="text-sm font-medium">{config.provider}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<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>
|
<p className="text-sm font-medium font-mono">{config.model_name}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 pt-6 border-t border-border/50 mt-6">
|
<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
|
Close
|
||||||
</Button>
|
</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"}
|
{isSubmitting ? "Loading..." : "Use This Model"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -379,7 +410,9 @@ export function ImageConfigSidebar({
|
||||||
<Input
|
<Input
|
||||||
placeholder="Optional description"
|
placeholder="Optional description"
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
onChange={(e) =>
|
||||||
|
setFormData((p) => ({ ...p, description: e.target.value }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -390,7 +423,9 @@ export function ImageConfigSidebar({
|
||||||
<Label className="text-sm font-medium">Provider *</Label>
|
<Label className="text-sm font-medium">Provider *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.provider}
|
value={formData.provider}
|
||||||
onValueChange={(val) => setFormData((p) => ({ ...p, provider: val, model_name: "" }))}
|
onValueChange={(val) =>
|
||||||
|
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a provider" />
|
<SelectValue placeholder="Select a provider" />
|
||||||
|
|
@ -414,7 +449,11 @@ export function ImageConfigSidebar({
|
||||||
{suggestedModels.length > 0 ? (
|
{suggestedModels.length > 0 ? (
|
||||||
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
|
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
|
||||||
<PopoverTrigger asChild>
|
<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..."}
|
{formData.model_name || "Select or type a model..."}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -424,11 +463,15 @@ export function ImageConfigSidebar({
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search or type model..."
|
placeholder="Search or type model..."
|
||||||
value={formData.model_name}
|
value={formData.model_name}
|
||||||
onValueChange={(val) => setFormData((p) => ({ ...p, model_name: val }))}
|
onValueChange={(val) =>
|
||||||
|
setFormData((p) => ({ ...p, model_name: val }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>
|
<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>
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{suggestedModels.map((m) => (
|
{suggestedModels.map((m) => (
|
||||||
|
|
@ -440,9 +483,18 @@ export function ImageConfigSidebar({
|
||||||
setModelComboboxOpen(false);
|
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="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>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
@ -454,7 +506,9 @@ export function ImageConfigSidebar({
|
||||||
<Input
|
<Input
|
||||||
placeholder="e.g., dall-e-3"
|
placeholder="e.g., dall-e-3"
|
||||||
value={formData.model_name}
|
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>
|
</div>
|
||||||
|
|
@ -489,14 +543,20 @@ export function ImageConfigSidebar({
|
||||||
<Input
|
<Input
|
||||||
placeholder="2024-02-15-preview"
|
placeholder="2024-02-15-preview"
|
||||||
value={formData.api_version}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-3 pt-4 border-t">
|
<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
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,7 @@ export function ImageModelSelector({ className, onAddNew, onEdit }: ImageModelSe
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
const { data: globalConfigs, isLoading: globalLoading } =
|
const { data: globalConfigs, isLoading: globalLoading } = useAtomValue(globalImageGenConfigsAtom);
|
||||||
useAtomValue(globalImageGenConfigsAtom);
|
|
||||||
const { data: userConfigs, isLoading: userLoading } = useAtomValue(imageGenConfigsAtom);
|
const { data: userConfigs, isLoading: userLoading } = useAtomValue(imageGenConfigsAtom);
|
||||||
const { data: preferences, isLoading: prefsLoading } = useAtomValue(llmPreferencesAtom);
|
const { data: preferences, isLoading: prefsLoading } = useAtomValue(llmPreferencesAtom);
|
||||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||||
|
|
@ -225,59 +224,59 @@ export function ImageModelSelector({ className, onAddNew, onEdit }: ImageModelSe
|
||||||
<Globe className="size-3.5" />
|
<Globe className="size-3.5" />
|
||||||
Global Image Models
|
Global Image Models
|
||||||
</div>
|
</div>
|
||||||
{filteredGlobal.map((config) => {
|
{filteredGlobal.map((config) => {
|
||||||
const isSelected = currentConfig?.id === config.id;
|
const isSelected = currentConfig?.id === config.id;
|
||||||
const isAuto = "is_auto_mode" in config && config.is_auto_mode;
|
const isAuto = "is_auto_mode" in config && config.is_auto_mode;
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={`g-${config.id}`}
|
key={`g-${config.id}`}
|
||||||
value={`g-${config.id}`}
|
value={`g-${config.id}`}
|
||||||
onSelect={() => handleSelect(config.id)}
|
onSelect={() => handleSelect(config.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
|
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
|
||||||
isSelected && "bg-accent/80",
|
isSelected && "bg-accent/80",
|
||||||
isAuto && "border border-violet-200 dark:border-violet-800/50"
|
isAuto && "border border-violet-200 dark:border-violet-800/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
{isAuto ? (
|
{isAuto ? (
|
||||||
<Shuffle className="size-4 text-violet-500" />
|
<Shuffle className="size-4 text-violet-500" />
|
||||||
) : (
|
) : (
|
||||||
<ImageIcon className="size-4 text-teal-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>
|
||||||
<div className="min-w-0 flex-1">
|
</CommandItem>
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -290,51 +289,49 @@ export function ImageModelSelector({ className, onAddNew, onEdit }: ImageModelSe
|
||||||
<User className="size-3.5" />
|
<User className="size-3.5" />
|
||||||
Your Image Models
|
Your Image Models
|
||||||
</div>
|
</div>
|
||||||
{filteredUser.map((config) => {
|
{filteredUser.map((config) => {
|
||||||
const isSelected = currentConfig?.id === config.id;
|
const isSelected = currentConfig?.id === config.id;
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={`u-${config.id}`}
|
key={`u-${config.id}`}
|
||||||
value={`u-${config.id}`}
|
value={`u-${config.id}`}
|
||||||
onSelect={() => handleSelect(config.id)}
|
onSelect={() => handleSelect(config.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
|
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
|
||||||
isSelected && "bg-accent/80"
|
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>
|
|
||||||
)}
|
)}
|
||||||
</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>
|
</CommandGroup>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -392,8 +392,8 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add New Config Button */}
|
{/* Add New Config Button */}
|
||||||
<div className="p-2 bg-muted/20">
|
<div className="p-2 bg-muted/20">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { fetchThreads } from "@/lib/chat/thread-persistence";
|
import { fetchThreads } from "@/lib/chat/thread-persistence";
|
||||||
|
|
||||||
interface TourStep {
|
interface TourStep {
|
||||||
|
|
@ -393,6 +394,7 @@ function TourTooltip({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OnboardingTour() {
|
export function OnboardingTour() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [isActive, setIsActive] = useState(false);
|
const [isActive, setIsActive] = useState(false);
|
||||||
const [stepIndex, setStepIndex] = useState(0);
|
const [stepIndex, setStepIndex] = useState(0);
|
||||||
const [targetEl, setTargetEl] = useState<Element | null>(null);
|
const [targetEl, setTargetEl] = useState<Element | null>(null);
|
||||||
|
|
@ -685,8 +687,8 @@ export function OnboardingTour() {
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [isActive, user?.id]);
|
}, [isActive, user?.id]);
|
||||||
|
|
||||||
// Don't render if not active or not mounted
|
// Don't render on mobile, or if not active or not mounted
|
||||||
if (!mounted || !isActive) {
|
if (isMobile || !mounted || !isActive) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,12 @@ export function Pricing({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs leading-5 text-muted-foreground">
|
<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>
|
</p>
|
||||||
|
|
||||||
<ul className="mt-5 gap-2 flex flex-col">
|
<ul className="mt-5 gap-2 flex flex-col">
|
||||||
|
|
|
||||||
|
|
@ -95,16 +95,29 @@ const item = {
|
||||||
|
|
||||||
export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
// Image gen config atoms
|
// Image gen config atoms
|
||||||
const { mutateAsync: createConfig, isPending: isCreating, error: createError } =
|
const {
|
||||||
useAtomValue(createImageGenConfigMutationAtom);
|
mutateAsync: createConfig,
|
||||||
const { mutateAsync: updateConfig, isPending: isUpdating, error: updateError } =
|
isPending: isCreating,
|
||||||
useAtomValue(updateImageGenConfigMutationAtom);
|
error: createError,
|
||||||
const { mutateAsync: deleteConfig, isPending: isDeleting, error: deleteError } =
|
} = useAtomValue(createImageGenConfigMutationAtom);
|
||||||
useAtomValue(deleteImageGenConfigMutationAtom);
|
const {
|
||||||
|
mutateAsync: updateConfig,
|
||||||
|
isPending: isUpdating,
|
||||||
|
error: updateError,
|
||||||
|
} = useAtomValue(updateImageGenConfigMutationAtom);
|
||||||
|
const {
|
||||||
|
mutateAsync: deleteConfig,
|
||||||
|
isPending: isDeleting,
|
||||||
|
error: deleteError,
|
||||||
|
} = useAtomValue(deleteImageGenConfigMutationAtom);
|
||||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||||
|
|
||||||
const { data: userConfigs, isFetching: configsLoading, error: fetchError, refetch: refreshConfigs } =
|
const {
|
||||||
useAtomValue(imageGenConfigsAtom);
|
data: userConfigs,
|
||||||
|
isFetching: configsLoading,
|
||||||
|
error: fetchError,
|
||||||
|
refetch: refreshConfigs,
|
||||||
|
} = useAtomValue(imageGenConfigsAtom);
|
||||||
const { data: globalConfigs = [], isFetching: globalLoading } =
|
const { data: globalConfigs = [], isFetching: globalLoading } =
|
||||||
useAtomValue(globalImageGenConfigsAtom);
|
useAtomValue(globalImageGenConfigsAtom);
|
||||||
const { data: preferences = {}, isFetching: prefsLoading } = useAtomValue(llmPreferencesAtom);
|
const { data: preferences = {}, isFetching: prefsLoading } = useAtomValue(llmPreferencesAtom);
|
||||||
|
|
@ -249,7 +262,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
data: {
|
data: {
|
||||||
image_generation_config_id:
|
image_generation_config_id:
|
||||||
typeof selectedPrefId === "string"
|
typeof selectedPrefId === "string"
|
||||||
? selectedPrefId ? parseInt(selectedPrefId) : undefined
|
? selectedPrefId
|
||||||
|
? parseInt(selectedPrefId)
|
||||||
|
: undefined
|
||||||
: selectedPrefId,
|
: selectedPrefId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -289,7 +304,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
{/* Errors */}
|
{/* Errors */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{errors.map((err) => (
|
{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">
|
<Alert variant="destructive" className="py-3">
|
||||||
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
<AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription>
|
<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" />
|
<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">
|
<AlertDescription className="text-teal-800 dark:text-teal-200 text-xs md:text-sm">
|
||||||
<span className="font-medium">
|
<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>{" "}
|
</span>{" "}
|
||||||
available from your administrator.
|
available from your administrator.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|
@ -342,18 +363,27 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
{globalConfigs.length > 0 && (
|
{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) => {
|
{globalConfigs.map((c) => {
|
||||||
const isAuto = "is_auto_mode" in c && c.is_auto_mode;
|
const isAuto = "is_auto_mode" in c && c.is_auto_mode;
|
||||||
return (
|
return (
|
||||||
<SelectItem key={`g-${c.id}`} value={c.id.toString()}>
|
<SelectItem key={`g-${c.id}`} value={c.id.toString()}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isAuto ? (
|
{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">
|
<Badge
|
||||||
<Shuffle className="size-3 mr-1" />AUTO
|
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>
|
||||||
) : (
|
) : (
|
||||||
<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}
|
{c.provider}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
@ -366,11 +396,15 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
)}
|
)}
|
||||||
{(userConfigs?.length ?? 0) > 0 && (
|
{(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) => (
|
{userConfigs?.map((c) => (
|
||||||
<SelectItem key={`u-${c.id}`} value={c.id.toString()}>
|
<SelectItem key={`u-${c.id}`} value={c.id.toString()}>
|
||||||
<div className="flex items-center gap-2">
|
<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>{c.name}</span>
|
||||||
<span className="text-muted-foreground">({c.model_name})</span>
|
<span className="text-muted-foreground">({c.model_name})</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -382,10 +416,23 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
</Select>
|
</Select>
|
||||||
{hasPrefChanges && (
|
{hasPrefChanges && (
|
||||||
<div className="flex gap-2 pt-1">
|
<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"}
|
{isSavingPref ? "Saving..." : "Save"}
|
||||||
</Button>
|
</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
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -409,7 +456,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
<div className="space-y-4 md:space-y-6">
|
<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">
|
<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>
|
<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" />
|
<Plus className="h-3 w-3 md:h-4 md:w-4" />
|
||||||
Add Image Model
|
Add Image Model
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -435,7 +485,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
<motion.div variants={container} initial="hidden" animate="show" className="grid gap-4">
|
<motion.div variants={container} initial="hidden" animate="show" className="grid gap-4">
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence mode="popLayout">
|
||||||
{userConfigs?.map((config) => (
|
{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">
|
<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">
|
<CardContent className="p-0">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
|
|
@ -448,8 +503,13 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0 space-y-2">
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<h4 className="text-sm md:text-base font-semibold truncate">{config.name}</h4>
|
<h4 className="text-sm md:text-base font-semibold truncate">
|
||||||
<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.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}
|
{config.provider}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -457,7 +517,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
{config.model_name}
|
{config.model_name}
|
||||||
</code>
|
</code>
|
||||||
{config.description && (
|
{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">
|
<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" />
|
<Clock className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
||||||
|
|
@ -469,7 +531,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<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" />
|
<Edit3 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
@ -479,7 +546,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<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" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
@ -501,15 +573,30 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create/Edit Dialog */}
|
{/* 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">
|
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<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"}
|
{editingConfig ? "Edit Image Model" : "Add Image Model"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -541,7 +628,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
<Label className="text-sm font-medium">Provider *</Label>
|
<Label className="text-sm font-medium">Provider *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.provider}
|
value={formData.provider}
|
||||||
onValueChange={(val) => setFormData((p) => ({ ...p, provider: val, model_name: "" }))}
|
onValueChange={(val) =>
|
||||||
|
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a provider" />
|
<SelectValue placeholder="Select a provider" />
|
||||||
|
|
@ -565,7 +654,11 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
{suggestedModels.length > 0 ? (
|
{suggestedModels.length > 0 ? (
|
||||||
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
|
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
|
||||||
<PopoverTrigger asChild>
|
<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..."}
|
{formData.model_name || "Select or type a model..."}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -579,7 +672,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
/>
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>
|
<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>
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{suggestedModels.map((m) => (
|
{suggestedModels.map((m) => (
|
||||||
|
|
@ -591,7 +686,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
setModelComboboxOpen(false);
|
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="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>
|
</CommandItem>
|
||||||
|
|
@ -650,14 +750,24 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={() => { setIsDialogOpen(false); setEditingConfig(null); resetForm(); }}
|
onClick={() => {
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setEditingConfig(null);
|
||||||
|
resetForm();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={handleFormSubmit}
|
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}
|
{isSubmitting ? <Spinner size="sm" className="mr-2" /> : null}
|
||||||
{editingConfig ? "Save Changes" : "Create & Use"}
|
{editingConfig ? "Save Changes" : "Create & Use"}
|
||||||
|
|
@ -668,7 +778,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
{/* Delete Confirmation */}
|
||||||
<AlertDialog open={!!configToDelete} onOpenChange={(open) => !open && setConfigToDelete(null)}>
|
<AlertDialog
|
||||||
|
open={!!configToDelete}
|
||||||
|
onOpenChange={(open) => !open && setConfigToDelete(null)}
|
||||||
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle className="flex items-center gap-2">
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
|
@ -676,13 +789,28 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
Delete Image Model
|
Delete Image Model
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<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>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleDelete} disabled={isDeleting} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
<AlertDialogAction
|
||||||
{isDeleting ? <><Spinner size="sm" className="mr-2" />Deleting</> : <><Trash2 className="mr-2 h-4 w-4" />Delete</>}
|
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>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -255,15 +255,15 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Role Assignment Cards */}
|
{/* Role Assignment Cards */}
|
||||||
{availableConfigs.length > 0 && (
|
{availableConfigs.length > 0 && (
|
||||||
<div className="grid gap-4 md:gap-6">
|
<div className="grid gap-4 md:gap-6">
|
||||||
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
|
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
|
||||||
const IconComponent = role.icon;
|
const IconComponent = role.icon;
|
||||||
const currentAssignment = assignments[`${key}_llm_id` as keyof typeof assignments];
|
const currentAssignment = assignments[`${key}_llm_id` as keyof typeof assignments];
|
||||||
const assignedConfig = availableConfigs.find(
|
const assignedConfig = availableConfigs.find(
|
||||||
(config) => config.id === currentAssignment
|
(config) => config.id === currentAssignment
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -294,100 +294,100 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
|
<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">
|
<div className="space-y-1.5 md:space-y-2">
|
||||||
<Label className="text-xs md:text-sm font-medium">
|
<Label className="text-xs md:text-sm font-medium">
|
||||||
Assign LLM Configuration:
|
Assign LLM Configuration:
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={currentAssignment?.toString() || "unassigned"}
|
value={currentAssignment?.toString() || "unassigned"}
|
||||||
onValueChange={(value) => handleRoleAssignment(`${key}_llm_id`, value)}
|
onValueChange={(value) => handleRoleAssignment(`${key}_llm_id`, value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-9 md:h-10 text-xs md:text-sm">
|
<SelectTrigger className="h-9 md:h-10 text-xs md:text-sm">
|
||||||
<SelectValue placeholder="Select an LLM configuration" />
|
<SelectValue placeholder="Select an LLM configuration" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="unassigned">
|
<SelectItem value="unassigned">
|
||||||
<span className="text-muted-foreground">Unassigned</span>
|
<span className="text-muted-foreground">Unassigned</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
||||||
{/* Global Configurations */}
|
{/* Global Configurations */}
|
||||||
{globalConfigs.length > 0 && (
|
{globalConfigs.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||||
Global Configurations
|
Global Configurations
|
||||||
</div>
|
</div>
|
||||||
{globalConfigs.map((config) => {
|
{globalConfigs.map((config) => {
|
||||||
const isAutoMode =
|
const isAutoMode =
|
||||||
"is_auto_mode" in config && config.is_auto_mode;
|
"is_auto_mode" in config && config.is_auto_mode;
|
||||||
return (
|
return (
|
||||||
<SelectItem key={config.id} value={config.id.toString()}>
|
<SelectItem key={config.id} value={config.id.toString()}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isAutoMode ? (
|
{isAutoMode ? (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
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"
|
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" />
|
<Shuffle className="size-3 mr-1" />
|
||||||
AUTO
|
AUTO
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{config.provider}
|
{config.provider}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<span>{config.name}</span>
|
<span>{config.name}</span>
|
||||||
{!isAutoMode && (
|
{!isAutoMode && (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
({config.model_name})
|
({config.model_name})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isAutoMode ? (
|
{isAutoMode ? (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
|
className="text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
|
||||||
>
|
>
|
||||||
Recommended
|
Recommended
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
🌐 Global
|
🌐 Global
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Custom Configurations */}
|
{/* Custom Configurations */}
|
||||||
{newLLMConfigs.length > 0 && (
|
{newLLMConfigs.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||||
Your Configurations
|
Your Configurations
|
||||||
</div>
|
</div>
|
||||||
{newLLMConfigs
|
{newLLMConfigs
|
||||||
.filter(
|
.filter(
|
||||||
(config) => config.id && config.id.toString().trim() !== ""
|
(config) => config.id && config.id.toString().trim() !== ""
|
||||||
)
|
)
|
||||||
.map((config) => (
|
.map((config) => (
|
||||||
<SelectItem key={config.id} value={config.id.toString()}>
|
<SelectItem key={config.id} value={config.id.toString()}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{config.provider}
|
{config.provider}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span>{config.name}</span>
|
<span>{config.name}</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
({config.model_name})
|
({config.model_name})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{assignedConfig && (
|
{assignedConfig && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -345,9 +345,7 @@ export function Image({
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-0 text-xs backdrop-blur-sm",
|
"border-0 text-xs backdrop-blur-sm",
|
||||||
isGenerated
|
isGenerated ? "bg-primary/80 text-primary-foreground" : "bg-black/60 text-white"
|
||||||
? "bg-primary/80 text-primary-foreground"
|
|
||||||
: "bg-black/60 text-white"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isGenerated && <SparklesIcon className="size-3 mr-1" />}
|
{isGenerated && <SparklesIcon className="size-3 mr-1" />}
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,7 @@ export const getNotificationsRequest = z.object({
|
||||||
search_space_id: z.number().optional(),
|
search_space_id: z.number().optional(),
|
||||||
type: inboxItemTypeEnum.optional(),
|
type: inboxItemTypeEnum.optional(),
|
||||||
before_date: z.string().optional(),
|
before_date: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
limit: z.number().min(1).max(100).optional(),
|
limit: z.number().min(1).max(100).optional(),
|
||||||
offset: z.number().min(0).optional(),
|
offset: z.number().min(0).optional(),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -213,9 +213,7 @@ export const getImageGenConfigsResponse = z.array(imageGenerationConfig);
|
||||||
|
|
||||||
export const updateImageGenConfigRequest = z.object({
|
export const updateImageGenConfigRequest = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
data: imageGenerationConfig
|
data: imageGenerationConfig.omit({ id: true, created_at: true, search_space_id: true }).partial(),
|
||||||
.omit({ id: true, created_at: true, search_space_id: true })
|
|
||||||
.partial(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateImageGenConfigResponse = imageGenerationConfig;
|
export const updateImageGenConfigResponse = imageGenerationConfig;
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,14 @@ function isValidDocument(doc: DocumentElectric): boolean {
|
||||||
* 3. Use syncHandle.isUpToDate to determine if deletions can be trusted
|
* 3. Use syncHandle.isUpToDate to determine if deletions can be trusted
|
||||||
* 4. Handles bulk deletions correctly by checking sync state
|
* 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 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(
|
export function useDocuments(
|
||||||
searchSpaceId: number | null,
|
searchSpaceId: number | null,
|
||||||
|
|
@ -80,7 +86,8 @@ export function useDocuments(
|
||||||
) {
|
) {
|
||||||
const electricClient = useElectricClient();
|
const electricClient = useElectricClient();
|
||||||
|
|
||||||
const [documents, setDocuments] = useState<DocumentDisplay[]>([]);
|
// Internal state: ALL documents (unfiltered)
|
||||||
|
const [allDocuments, setAllDocuments] = useState<DocumentDisplay[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
|
@ -94,14 +101,21 @@ export function useDocuments(
|
||||||
const syncHandleRef = useRef<SyncHandle | null>(null);
|
const syncHandleRef = useRef<SyncHandle | null>(null);
|
||||||
const liveQueryRef = useRef<{ unsubscribe?: () => void } | 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 typeCounts = useMemo(() => {
|
||||||
const counts: Record<string, number> = {};
|
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;
|
counts[doc.document_type] = (counts[doc.document_type] || 0) + 1;
|
||||||
}
|
}
|
||||||
return counts;
|
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
|
// Populate user cache from API response
|
||||||
const populateUserCache = useCallback(
|
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(() => {
|
useEffect(() => {
|
||||||
if (!searchSpaceId) {
|
if (!searchSpaceId) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -160,7 +175,6 @@ export function useDocuments(
|
||||||
|
|
||||||
// Capture validated value for async closure
|
// Capture validated value for async closure
|
||||||
const spaceId = searchSpaceId;
|
const spaceId = searchSpaceId;
|
||||||
const currentTypeFilter = typeFilter;
|
|
||||||
|
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
apiLoadedRef.current = false;
|
apiLoadedRef.current = false;
|
||||||
|
|
@ -174,8 +188,7 @@ export function useDocuments(
|
||||||
queryParams: {
|
queryParams: {
|
||||||
search_space_id: spaceId,
|
search_space_id: spaceId,
|
||||||
page: 0,
|
page: 0,
|
||||||
page_size: -1, // Fetch all documents
|
page_size: -1, // Fetch all documents (unfiltered)
|
||||||
...(currentTypeFilter.length > 0 && { document_types: currentTypeFilter }),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -183,7 +196,7 @@ export function useDocuments(
|
||||||
|
|
||||||
populateUserCache(response.items);
|
populateUserCache(response.items);
|
||||||
const docs = response.items.map(apiToDisplayDoc);
|
const docs = response.items.map(apiToDisplayDoc);
|
||||||
setDocuments(docs);
|
setAllDocuments(docs);
|
||||||
apiLoadedRef.current = true;
|
apiLoadedRef.current = true;
|
||||||
setError(null);
|
setError(null);
|
||||||
console.log("[useDocuments] API loaded", docs.length, "documents");
|
console.log("[useDocuments] API loaded", docs.length, "documents");
|
||||||
|
|
@ -201,16 +214,16 @@ export function useDocuments(
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
};
|
};
|
||||||
}, [searchSpaceId, typeFilter, populateUserCache, apiToDisplayDoc]);
|
}, [searchSpaceId, populateUserCache, apiToDisplayDoc]);
|
||||||
|
|
||||||
// EFFECT 2: Start Electric sync + live query for real-time updates
|
// EFFECT 2: Start Electric sync + live query for real-time updates
|
||||||
|
// No type filter — syncs and queries ALL documents; filtering is client-side
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchSpaceId || !electricClient) return;
|
if (!searchSpaceId || !electricClient) return;
|
||||||
|
|
||||||
// Capture validated values for async closure
|
// Capture validated values for async closure
|
||||||
const spaceId = searchSpaceId;
|
const spaceId = searchSpaceId;
|
||||||
const client = electricClient;
|
const client = electricClient;
|
||||||
const currentTypeFilter = typeFilter;
|
|
||||||
|
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
|
|
@ -228,7 +241,7 @@ export function useDocuments(
|
||||||
try {
|
try {
|
||||||
console.log("[useDocuments] Starting Electric sync for real-time updates");
|
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({
|
const handle = await client.syncShape({
|
||||||
table: "documents",
|
table: "documents",
|
||||||
where: `search_space_id = ${spaceId}`,
|
where: `search_space_id = ${spaceId}`,
|
||||||
|
|
@ -263,7 +276,7 @@ export function useDocuments(
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// Set up live query
|
// Set up live query (unfiltered — type filtering is done client-side)
|
||||||
const db = client.db as {
|
const db = client.db as {
|
||||||
live?: {
|
live?: {
|
||||||
query: <T>(
|
query: <T>(
|
||||||
|
|
@ -281,21 +294,12 @@ export function useDocuments(
|
||||||
return;
|
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
|
FROM documents
|
||||||
WHERE search_space_id = $1`;
|
WHERE search_space_id = $1
|
||||||
|
ORDER BY created_at DESC`;
|
||||||
|
|
||||||
const params: (number | string)[] = [spaceId];
|
const liveQuery = await db.live.query<DocumentElectric>(query, [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);
|
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
liveQuery.unsubscribe?.();
|
liveQuery.unsubscribe?.();
|
||||||
|
|
@ -333,7 +337,7 @@ export function useDocuments(
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
populateUserCache(response.items);
|
populateUserCache(response.items);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setDocuments((prev) =>
|
setAllDocuments((prev) =>
|
||||||
prev.map((doc) => ({
|
prev.map((doc) => ({
|
||||||
...doc,
|
...doc,
|
||||||
created_by_name: doc.created_by_id
|
created_by_name: doc.created_by_id
|
||||||
|
|
@ -347,7 +351,7 @@ export function useDocuments(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Smart update logic based on sync state
|
// Smart update logic based on sync state
|
||||||
setDocuments((prev) => {
|
setAllDocuments((prev) => {
|
||||||
// Don't process if API hasn't loaded yet
|
// Don't process if API hasn't loaded yet
|
||||||
if (!apiLoadedRef.current) {
|
if (!apiLoadedRef.current) {
|
||||||
console.log("[useDocuments] Waiting for API load, skipping live update");
|
console.log("[useDocuments] Waiting for API load, skipping live update");
|
||||||
|
|
@ -424,7 +428,7 @@ export function useDocuments(
|
||||||
liveQueryRef.current = null;
|
liveQueryRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [searchSpaceId, electricClient, typeFilter, electricToDisplayDoc, populateUserCache]);
|
}, [searchSpaceId, electricClient, electricToDisplayDoc, populateUserCache]);
|
||||||
|
|
||||||
// Track previous searchSpaceId to detect actual changes
|
// Track previous searchSpaceId to detect actual changes
|
||||||
const prevSearchSpaceIdRef = useRef<number | null>(null);
|
const prevSearchSpaceIdRef = useRef<number | null>(null);
|
||||||
|
|
@ -432,7 +436,7 @@ export function useDocuments(
|
||||||
// Reset on search space change (not on initial mount)
|
// Reset on search space change (not on initial mount)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevSearchSpaceIdRef.current !== null && prevSearchSpaceIdRef.current !== searchSpaceId) {
|
if (prevSearchSpaceIdRef.current !== null && prevSearchSpaceIdRef.current !== searchSpaceId) {
|
||||||
setDocuments([]);
|
setAllDocuments([]);
|
||||||
apiLoadedRef.current = false;
|
apiLoadedRef.current = false;
|
||||||
userCacheRef.current.clear();
|
userCacheRef.current.clear();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,9 @@ class ImageGenConfigApiService {
|
||||||
const msg = parsed.error.issues.map((i) => i.message).join(", ");
|
const msg = parsed.error.issues.map((i) => i.message).join(", ");
|
||||||
throw new ValidationError(`Invalid request: ${msg}`);
|
throw new ValidationError(`Invalid request: ${msg}`);
|
||||||
}
|
}
|
||||||
return baseApiService.post(
|
return baseApiService.post(`/api/v1/image-generation-configs`, createImageGenConfigResponse, {
|
||||||
`/api/v1/image-generation-configs`,
|
body: parsed.data,
|
||||||
createImageGenConfigResponse,
|
});
|
||||||
{ body: parsed.data }
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,9 @@ class NotificationsApiService {
|
||||||
if (queryParams.offset !== undefined) {
|
if (queryParams.offset !== undefined) {
|
||||||
params.append("offset", String(queryParams.offset));
|
params.append("offset", String(queryParams.offset));
|
||||||
}
|
}
|
||||||
|
if (queryParams.search) {
|
||||||
|
params.append("search", queryParams.search);
|
||||||
|
}
|
||||||
|
|
||||||
const queryString = params.toString();
|
const queryString = params.toString();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,14 +67,10 @@ const pendingSyncs = new Map<string, Promise<SyncHandle>>();
|
||||||
// v2: user-specific database architecture
|
// v2: user-specific database architecture
|
||||||
// v3: consistent cutoff date for sync+queries, visibility refresh support
|
// v3: consistent cutoff date for sync+queries, visibility refresh support
|
||||||
// v4: heartbeat-based stale notification detection with updated_at tracking
|
// v4: heartbeat-based stale notification detection with updated_at tracking
|
||||||
// v5: fixed duplicate key errors (root cause: unstable cutoff dates in use-inbox.ts)
|
// v5: fixed duplicate key errors, stable cutoff dates, onMustRefetch handler,
|
||||||
// - added onMustRefetch handler for server-side refetch scenarios
|
// real-time documents table with title/created_by_id/status columns,
|
||||||
// - fixed getSyncCutoffDate to use stable midnight UTC timestamps
|
// consolidated single documents sync, pending state for document queue visibility
|
||||||
// v6: real-time documents table - added title and created_by_id columns for live document display
|
const SYNC_VERSION = 5;
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Database name prefix for identifying SurfSense databases
|
// Database name prefix for identifying SurfSense databases
|
||||||
const DB_PREFIX = "surfsense-";
|
const DB_PREFIX = "surfsense-";
|
||||||
|
|
|
||||||
|
|
@ -92,4 +92,8 @@ export const cacheKeys = {
|
||||||
bySearchSpace: (searchSpaceId: number) =>
|
bySearchSpace: (searchSpaceId: number) =>
|
||||||
["public-chat-snapshots", "search-space", searchSpaceId] as const,
|
["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": {
|
"upload_documents": {
|
||||||
"title": "Upload Documents",
|
"title": "Upload Documents",
|
||||||
"subtitle": "Upload your files to make them searchable and accessible through AI-powered conversations.",
|
"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.",
|
"upload_limits": "Upload limit: {maxFiles} files, {maxSizeMB}MB total.",
|
||||||
"drop_files": "Drop files here",
|
"drop_files": "Drop files here",
|
||||||
"drag_drop": "Drag & drop files here",
|
"drag_drop": "Drag & drop files here",
|
||||||
|
|
|
||||||
|
|
@ -360,7 +360,7 @@
|
||||||
"upload_documents": {
|
"upload_documents": {
|
||||||
"title": "上传文档",
|
"title": "上传文档",
|
||||||
"subtitle": "上传您的文件,使其可通过 AI 对话进行搜索和访问。",
|
"subtitle": "上传您的文件,使其可通过 AI 对话进行搜索和访问。",
|
||||||
"file_size_limit": "最大文件大小:每个文件 50MB。支持的格式因您的 ETL 服务配置而异。",
|
"file_size_limit": "最大文件大小:每个文件 50MB。",
|
||||||
"upload_limits": "上传限制:最多 {maxFiles} 个文件,总大小不超过 {maxSizeMB}MB。",
|
"upload_limits": "上传限制:最多 {maxFiles} 个文件,总大小不超过 {maxSizeMB}MB。",
|
||||||
"drop_files": "放下文件到这里",
|
"drop_files": "放下文件到这里",
|
||||||
"drag_drop": "拖放文件到这里",
|
"drag_drop": "拖放文件到这里",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue