mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-15 18:25:18 +02:00
feat: added attachment support
This commit is contained in:
parent
bb971460fc
commit
c2dcb2045d
62 changed files with 1166 additions and 9012 deletions
|
|
@ -13,7 +13,7 @@ This migration:
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
@ -116,7 +116,7 @@ def upgrade() -> None:
|
||||||
# Only migrate user and assistant messages
|
# Only migrate user and assistant messages
|
||||||
if role_lower not in ("user", "assistant"):
|
if role_lower not in ("user", "assistant"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Convert to uppercase for database enum
|
# Convert to uppercase for database enum
|
||||||
role = role_lower.upper()
|
role = role_lower.upper()
|
||||||
|
|
||||||
|
|
@ -195,8 +195,18 @@ def downgrade() -> None:
|
||||||
sa.Column("initial_connectors", sa.ARRAY(sa.String()), nullable=True),
|
sa.Column("initial_connectors", sa.ARRAY(sa.String()), nullable=True),
|
||||||
sa.Column("messages", sa.JSON(), nullable=False),
|
sa.Column("messages", sa.JSON(), nullable=False),
|
||||||
sa.Column("state_version", sa.BigInteger(), nullable=False, default=1),
|
sa.Column("state_version", sa.BigInteger(), nullable=False, default=1),
|
||||||
sa.Column("search_space_id", sa.Integer(), sa.ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False),
|
sa.Column(
|
||||||
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.func.now()),
|
"search_space_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("searchspaces.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.TIMESTAMP(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Recreate podcasts table
|
# Recreate podcasts table
|
||||||
|
|
@ -206,11 +216,25 @@ def downgrade() -> None:
|
||||||
sa.Column("title", sa.String(), nullable=False, index=True),
|
sa.Column("title", sa.String(), nullable=False, index=True),
|
||||||
sa.Column("podcast_transcript", sa.JSON(), nullable=False, server_default="{}"),
|
sa.Column("podcast_transcript", sa.JSON(), nullable=False, server_default="{}"),
|
||||||
sa.Column("file_location", sa.String(500), nullable=False, server_default=""),
|
sa.Column("file_location", sa.String(500), nullable=False, server_default=""),
|
||||||
sa.Column("chat_id", sa.Integer(), sa.ForeignKey("chats.id", ondelete="CASCADE"), nullable=True),
|
sa.Column(
|
||||||
|
"chat_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("chats.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
sa.Column("chat_state_version", sa.BigInteger(), nullable=True),
|
sa.Column("chat_state_version", sa.BigInteger(), nullable=True),
|
||||||
sa.Column("search_space_id", sa.Integer(), sa.ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False),
|
sa.Column(
|
||||||
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.func.now()),
|
"search_space_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("searchspaces.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.TIMESTAMP(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
print("[Migration 49 Downgrade] Tables recreated (data not restored)")
|
print("[Migration 49 Downgrade] Tables recreated (data not restored)")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ from sqlalchemy import (
|
||||||
ARRAY,
|
ARRAY,
|
||||||
JSON,
|
JSON,
|
||||||
TIMESTAMP,
|
TIMESTAMP,
|
||||||
BigInteger,
|
|
||||||
Boolean,
|
Boolean,
|
||||||
Column,
|
Column,
|
||||||
Enum as SQLAlchemyEnum,
|
Enum as SQLAlchemyEnum,
|
||||||
|
|
@ -423,6 +422,25 @@ class Chunk(BaseModel, TimestampMixin):
|
||||||
document = relationship("Document", back_populates="chunks")
|
document = relationship("Document", back_populates="chunks")
|
||||||
|
|
||||||
|
|
||||||
|
class Podcast(BaseModel, TimestampMixin):
|
||||||
|
"""Podcast model for storing generated podcasts."""
|
||||||
|
|
||||||
|
__tablename__ = "podcasts"
|
||||||
|
|
||||||
|
title = Column(String(500), nullable=False)
|
||||||
|
podcast_transcript = Column(JSONB, nullable=True) # List of transcript entries
|
||||||
|
file_location = Column(Text, nullable=True) # Path to the audio file
|
||||||
|
|
||||||
|
search_space_id = Column(
|
||||||
|
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
search_space = relationship("SearchSpace", back_populates="podcasts")
|
||||||
|
|
||||||
|
# Optional: link to chat thread (null for content-based podcasts from new-chat)
|
||||||
|
chat_id = Column(Integer, nullable=True)
|
||||||
|
chat_state_version = Column(String(100), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class SearchSpace(BaseModel, TimestampMixin):
|
class SearchSpace(BaseModel, TimestampMixin):
|
||||||
__tablename__ = "searchspaces"
|
__tablename__ = "searchspaces"
|
||||||
|
|
||||||
|
|
@ -459,6 +477,12 @@ class SearchSpace(BaseModel, TimestampMixin):
|
||||||
order_by="NewChatThread.updated_at.desc()",
|
order_by="NewChatThread.updated_at.desc()",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
podcasts = relationship(
|
||||||
|
"Podcast",
|
||||||
|
back_populates="search_space",
|
||||||
|
order_by="Podcast.id.desc()",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
logs = relationship(
|
logs = relationship(
|
||||||
"Log",
|
"Log",
|
||||||
back_populates="search_space",
|
back_populates="search_space",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from .logs_routes import router as logs_router
|
||||||
from .luma_add_connector_route import router as luma_add_connector_router
|
from .luma_add_connector_route import router as luma_add_connector_router
|
||||||
from .new_chat_routes import router as new_chat_router
|
from .new_chat_routes import router as new_chat_router
|
||||||
from .notes_routes import router as notes_router
|
from .notes_routes import router as notes_router
|
||||||
|
from .podcasts_routes import router as podcasts_router
|
||||||
from .rbac_routes import router as rbac_router
|
from .rbac_routes import router as rbac_router
|
||||||
from .search_source_connectors_routes import router as search_source_connectors_router
|
from .search_source_connectors_routes import router as search_source_connectors_router
|
||||||
from .search_spaces_routes import router as search_spaces_router
|
from .search_spaces_routes import router as search_spaces_router
|
||||||
|
|
@ -28,6 +29,7 @@ router.include_router(editor_router)
|
||||||
router.include_router(documents_router)
|
router.include_router(documents_router)
|
||||||
router.include_router(notes_router)
|
router.include_router(notes_router)
|
||||||
router.include_router(new_chat_router) # Chat with assistant-ui persistence
|
router.include_router(new_chat_router) # Chat with assistant-ui persistence
|
||||||
|
router.include_router(podcasts_router) # Podcast task status and audio
|
||||||
router.include_router(search_source_connectors_router)
|
router.include_router(search_source_connectors_router)
|
||||||
router.include_router(google_calendar_add_connector_router)
|
router.include_router(google_calendar_add_connector_router)
|
||||||
router.include_router(google_gmail_add_connector_router)
|
router.include_router(google_gmail_add_connector_router)
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,16 @@ These endpoints support the ThreadHistoryAdapter pattern from assistant-ui:
|
||||||
- PUT /threads/{thread_id} - Update thread (rename, archive)
|
- PUT /threads/{thread_id} - Update thread (rename, archive)
|
||||||
- DELETE /threads/{thread_id} - Delete thread
|
- DELETE /threads/{thread_id} - Delete thread
|
||||||
- POST /threads/{thread_id}/messages - Append message
|
- POST /threads/{thread_id}/messages - Append message
|
||||||
|
- POST /attachments/process - Process attachments for chat context
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
@ -650,10 +655,10 @@ async def handle_new_chat(
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Stream chat responses from the deep agent.
|
Stream chat responses from the deep agent.
|
||||||
|
|
||||||
This endpoint handles the new chat functionality with streaming responses
|
This endpoint handles the new chat functionality with streaming responses
|
||||||
using Server-Sent Events (SSE) format compatible with Vercel AI SDK.
|
using Server-Sent Events (SSE) format compatible with Vercel AI SDK.
|
||||||
|
|
||||||
Requires CHATS_CREATE permission.
|
Requires CHATS_CREATE permission.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -695,6 +700,7 @@ async def handle_new_chat(
|
||||||
session=session,
|
session=session,
|
||||||
llm_config_id=llm_config_id,
|
llm_config_id=llm_config_id,
|
||||||
messages=request.messages,
|
messages=request.messages,
|
||||||
|
attachments=request.attachments,
|
||||||
),
|
),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
headers={
|
headers={
|
||||||
|
|
@ -711,3 +717,185 @@ async def handle_new_chat(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"An unexpected error occurred: {e!s}",
|
detail=f"An unexpected error occurred: {e!s}",
|
||||||
) from None
|
) from None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Attachment Processing Endpoint
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/attachments/process")
|
||||||
|
async def process_attachment(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Process an attachment file and extract its content as markdown.
|
||||||
|
|
||||||
|
This endpoint uses the configured ETL service to parse files and return
|
||||||
|
the extracted content that can be used as context in chat messages.
|
||||||
|
|
||||||
|
Supported file types depend on the configured ETL_SERVICE:
|
||||||
|
- Markdown/Text files: .md, .markdown, .txt (always supported)
|
||||||
|
- Audio files: .mp3, .mp4, .mpeg, .mpga, .m4a, .wav, .webm (if STT configured)
|
||||||
|
- Documents: .pdf, .docx, .doc, .pptx, .xlsx (depends on ETL service)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with attachment id, name, type, and extracted content
|
||||||
|
"""
|
||||||
|
from app.config import config as app_config
|
||||||
|
|
||||||
|
if not file.filename:
|
||||||
|
raise HTTPException(status_code=400, detail="No filename provided")
|
||||||
|
|
||||||
|
filename = file.filename
|
||||||
|
attachment_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Save file to a temporary location
|
||||||
|
file_ext = os.path.splitext(filename)[1].lower()
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=file_ext) as temp_file:
|
||||||
|
temp_path = temp_file.name
|
||||||
|
content = await file.read()
|
||||||
|
temp_file.write(content)
|
||||||
|
|
||||||
|
extracted_content = ""
|
||||||
|
|
||||||
|
# Process based on file type
|
||||||
|
if file_ext in (".md", ".markdown", ".txt"):
|
||||||
|
# For text/markdown files, read content directly
|
||||||
|
with open(temp_path, encoding="utf-8") as f:
|
||||||
|
extracted_content = f.read()
|
||||||
|
|
||||||
|
elif file_ext in (".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm"):
|
||||||
|
# Audio files - transcribe if STT service is configured
|
||||||
|
if not app_config.STT_SERVICE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="Audio transcription is not configured. Please set STT_SERVICE.",
|
||||||
|
)
|
||||||
|
|
||||||
|
stt_service_type = (
|
||||||
|
"local" if app_config.STT_SERVICE.startswith("local/") else "external"
|
||||||
|
)
|
||||||
|
|
||||||
|
if stt_service_type == "local":
|
||||||
|
from app.services.stt_service import stt_service
|
||||||
|
|
||||||
|
result = stt_service.transcribe_file(temp_path)
|
||||||
|
extracted_content = result.get("text", "")
|
||||||
|
else:
|
||||||
|
from litellm import atranscription
|
||||||
|
|
||||||
|
with open(temp_path, "rb") as audio_file:
|
||||||
|
transcription_kwargs = {
|
||||||
|
"model": app_config.STT_SERVICE,
|
||||||
|
"file": audio_file,
|
||||||
|
"api_key": app_config.STT_SERVICE_API_KEY,
|
||||||
|
}
|
||||||
|
if app_config.STT_SERVICE_API_BASE:
|
||||||
|
transcription_kwargs["api_base"] = (
|
||||||
|
app_config.STT_SERVICE_API_BASE
|
||||||
|
)
|
||||||
|
|
||||||
|
transcription_response = await atranscription(
|
||||||
|
**transcription_kwargs
|
||||||
|
)
|
||||||
|
extracted_content = transcription_response.get("text", "")
|
||||||
|
|
||||||
|
if extracted_content:
|
||||||
|
extracted_content = (
|
||||||
|
f"# Transcription of {filename}\n\n{extracted_content}"
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Document files - use configured ETL service
|
||||||
|
if app_config.ETL_SERVICE == "UNSTRUCTURED":
|
||||||
|
from langchain_unstructured import UnstructuredLoader
|
||||||
|
|
||||||
|
from app.utils.document_converters import convert_document_to_markdown
|
||||||
|
|
||||||
|
loader = UnstructuredLoader(
|
||||||
|
temp_path,
|
||||||
|
mode="elements",
|
||||||
|
post_processors=[],
|
||||||
|
languages=["eng"],
|
||||||
|
include_orig_elements=False,
|
||||||
|
include_metadata=False,
|
||||||
|
strategy="auto",
|
||||||
|
)
|
||||||
|
docs = await loader.aload()
|
||||||
|
extracted_content = await convert_document_to_markdown(docs)
|
||||||
|
|
||||||
|
elif app_config.ETL_SERVICE == "LLAMACLOUD":
|
||||||
|
from llama_cloud_services import LlamaParse
|
||||||
|
from llama_cloud_services.parse.utils import ResultType
|
||||||
|
|
||||||
|
parser = LlamaParse(
|
||||||
|
api_key=app_config.LLAMA_CLOUD_API_KEY,
|
||||||
|
num_workers=1,
|
||||||
|
verbose=False,
|
||||||
|
language="en",
|
||||||
|
result_type=ResultType.MD,
|
||||||
|
)
|
||||||
|
result = await parser.aparse(temp_path)
|
||||||
|
markdown_documents = await result.aget_markdown_documents(
|
||||||
|
split_by_page=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if markdown_documents:
|
||||||
|
extracted_content = "\n\n".join(
|
||||||
|
doc.text for doc in markdown_documents
|
||||||
|
)
|
||||||
|
|
||||||
|
elif app_config.ETL_SERVICE == "DOCLING":
|
||||||
|
from app.services.docling_service import create_docling_service
|
||||||
|
|
||||||
|
docling_service = create_docling_service()
|
||||||
|
result = await docling_service.process_document(temp_path, filename)
|
||||||
|
extracted_content = result.get("content", "")
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"ETL service not configured or unsupported file type: {file_ext}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up temp file
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
os.unlink(temp_path)
|
||||||
|
|
||||||
|
if not extracted_content:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"Could not extract content from file: {filename}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine attachment type (must be one of: "image", "document", "file")
|
||||||
|
# assistant-ui only supports these three types
|
||||||
|
if file_ext in (".png", ".jpg", ".jpeg", ".gif", ".webp"):
|
||||||
|
attachment_type = "image"
|
||||||
|
else:
|
||||||
|
# All other files (including audio, documents, text) are treated as "document"
|
||||||
|
attachment_type = "document"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": attachment_id,
|
||||||
|
"name": filename,
|
||||||
|
"type": attachment_type,
|
||||||
|
"content": extracted_content,
|
||||||
|
"contentLength": len(extracted_content),
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
# Clean up temp file on error
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
os.unlink(temp_path)
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to process attachment: {e!s}",
|
||||||
|
) from e
|
||||||
|
|
|
||||||
277
surfsense_backend/app/routes/podcasts_routes.py
Normal file
277
surfsense_backend/app/routes/podcasts_routes.py
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
"""
|
||||||
|
Podcast routes for task status polling and audio retrieval.
|
||||||
|
|
||||||
|
These routes support the podcast generation feature in new-chat.
|
||||||
|
Note: The old Chat-based podcast generation has been removed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from celery.result import AsyncResult
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.celery_app import celery_app
|
||||||
|
from app.db import (
|
||||||
|
Permission,
|
||||||
|
Podcast,
|
||||||
|
SearchSpace,
|
||||||
|
SearchSpaceMembership,
|
||||||
|
User,
|
||||||
|
get_async_session,
|
||||||
|
)
|
||||||
|
from app.schemas import PodcastRead
|
||||||
|
from app.users import current_active_user
|
||||||
|
from app.utils.rbac import check_permission
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/podcasts", response_model=list[PodcastRead])
|
||||||
|
async def read_podcasts(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
search_space_id: int | None = None,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List podcasts the user has access to.
|
||||||
|
Requires PODCASTS_READ permission for the search space(s).
|
||||||
|
"""
|
||||||
|
if skip < 0 or limit < 1:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid pagination parameters")
|
||||||
|
try:
|
||||||
|
if search_space_id is not None:
|
||||||
|
# Check permission for specific search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
search_space_id,
|
||||||
|
Permission.PODCASTS_READ.value,
|
||||||
|
"You don't have permission to read podcasts in this search space",
|
||||||
|
)
|
||||||
|
result = await session.execute(
|
||||||
|
select(Podcast)
|
||||||
|
.filter(Podcast.search_space_id == search_space_id)
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Get podcasts from all search spaces user has membership in
|
||||||
|
result = await session.execute(
|
||||||
|
select(Podcast)
|
||||||
|
.join(SearchSpace)
|
||||||
|
.join(SearchSpaceMembership)
|
||||||
|
.filter(SearchSpaceMembership.user_id == user.id)
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except SQLAlchemyError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Database error occurred while fetching podcasts"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/podcasts/{podcast_id}", response_model=PodcastRead)
|
||||||
|
async def read_podcast(
|
||||||
|
podcast_id: int,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get a specific podcast by ID.
|
||||||
|
Requires PODCASTS_READ permission for the search space.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id))
|
||||||
|
podcast = result.scalars().first()
|
||||||
|
|
||||||
|
if not podcast:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Podcast not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check permission for the search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
podcast.search_space_id,
|
||||||
|
Permission.PODCASTS_READ.value,
|
||||||
|
"You don't have permission to read podcasts in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
|
return podcast
|
||||||
|
except HTTPException as he:
|
||||||
|
raise he
|
||||||
|
except SQLAlchemyError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Database error occurred while fetching podcast"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/podcasts/{podcast_id}", response_model=dict)
|
||||||
|
async def delete_podcast(
|
||||||
|
podcast_id: int,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete a podcast.
|
||||||
|
Requires PODCASTS_DELETE permission for the search space.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id))
|
||||||
|
db_podcast = result.scalars().first()
|
||||||
|
|
||||||
|
if not db_podcast:
|
||||||
|
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||||
|
|
||||||
|
# Check permission for the search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
db_podcast.search_space_id,
|
||||||
|
Permission.PODCASTS_DELETE.value,
|
||||||
|
"You don't have permission to delete podcasts in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.delete(db_podcast)
|
||||||
|
await session.commit()
|
||||||
|
return {"message": "Podcast deleted successfully"}
|
||||||
|
except HTTPException as he:
|
||||||
|
raise he
|
||||||
|
except SQLAlchemyError:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Database error occurred while deleting podcast"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/podcasts/{podcast_id}/stream")
|
||||||
|
@router.get("/podcasts/{podcast_id}/audio")
|
||||||
|
async def stream_podcast(
|
||||||
|
podcast_id: int,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Stream a podcast audio file.
|
||||||
|
Requires PODCASTS_READ permission for the search space.
|
||||||
|
|
||||||
|
Note: Both /stream and /audio endpoints are supported for compatibility.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id))
|
||||||
|
podcast = result.scalars().first()
|
||||||
|
|
||||||
|
if not podcast:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Podcast not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check permission for the search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
podcast.search_space_id,
|
||||||
|
Permission.PODCASTS_READ.value,
|
||||||
|
"You don't have permission to access podcasts in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the file path
|
||||||
|
file_path = podcast.file_location
|
||||||
|
|
||||||
|
# Check if the file exists
|
||||||
|
if not file_path or not os.path.isfile(file_path):
|
||||||
|
raise HTTPException(status_code=404, detail="Podcast audio file not found")
|
||||||
|
|
||||||
|
# Define a generator function to stream the file
|
||||||
|
def iterfile():
|
||||||
|
with open(file_path, mode="rb") as file_like:
|
||||||
|
yield from file_like
|
||||||
|
|
||||||
|
# Return a streaming response with appropriate headers
|
||||||
|
return StreamingResponse(
|
||||||
|
iterfile(),
|
||||||
|
media_type="audio/mpeg",
|
||||||
|
headers={
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Disposition": f"inline; filename={Path(file_path).name}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException as he:
|
||||||
|
raise he
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Error streaming podcast: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/podcasts/task/{task_id}/status")
|
||||||
|
async def get_podcast_task_status(
|
||||||
|
task_id: str,
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get the status of a podcast generation task.
|
||||||
|
Used by new-chat frontend to poll for completion.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- status: "processing" | "success" | "error"
|
||||||
|
- podcast_id: (only if status == "success")
|
||||||
|
- title: (only if status == "success")
|
||||||
|
- error: (only if status == "error")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = AsyncResult(task_id, app=celery_app)
|
||||||
|
|
||||||
|
if result.ready():
|
||||||
|
# Task completed
|
||||||
|
if result.successful():
|
||||||
|
task_result = result.result
|
||||||
|
if isinstance(task_result, dict):
|
||||||
|
if task_result.get("status") == "success":
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"podcast_id": task_result.get("podcast_id"),
|
||||||
|
"title": task_result.get("title"),
|
||||||
|
"transcript_entries": task_result.get("transcript_entries"),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": task_result.get("error", "Unknown error"),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "Unexpected task result format",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Task failed
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": str(result.result) if result.result else "Task failed",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Task still processing
|
||||||
|
return {
|
||||||
|
"status": "processing",
|
||||||
|
"state": result.state,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Error checking task status: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
@ -26,6 +26,7 @@ from .new_chat import (
|
||||||
ThreadListItem,
|
ThreadListItem,
|
||||||
ThreadListResponse,
|
ThreadListResponse,
|
||||||
)
|
)
|
||||||
|
from .podcasts import PodcastBase, PodcastCreate, PodcastRead, PodcastUpdate
|
||||||
from .rbac_schemas import (
|
from .rbac_schemas import (
|
||||||
InviteAcceptRequest,
|
InviteAcceptRequest,
|
||||||
InviteAcceptResponse,
|
InviteAcceptResponse,
|
||||||
|
|
@ -61,17 +62,6 @@ from .users import UserCreate, UserRead, UserUpdate
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Chat schemas (assistant-ui integration)
|
# Chat schemas (assistant-ui integration)
|
||||||
"ChatMessage",
|
"ChatMessage",
|
||||||
"NewChatMessageAppend",
|
|
||||||
"NewChatMessageCreate",
|
|
||||||
"NewChatMessageRead",
|
|
||||||
"NewChatRequest",
|
|
||||||
"NewChatThreadCreate",
|
|
||||||
"NewChatThreadRead",
|
|
||||||
"NewChatThreadUpdate",
|
|
||||||
"NewChatThreadWithMessages",
|
|
||||||
"ThreadHistoryLoadResponse",
|
|
||||||
"ThreadListItem",
|
|
||||||
"ThreadListResponse",
|
|
||||||
# Chunk schemas
|
# Chunk schemas
|
||||||
"ChunkBase",
|
"ChunkBase",
|
||||||
"ChunkCreate",
|
"ChunkCreate",
|
||||||
|
|
@ -85,10 +75,15 @@ __all__ = [
|
||||||
"DocumentsCreate",
|
"DocumentsCreate",
|
||||||
"ExtensionDocumentContent",
|
"ExtensionDocumentContent",
|
||||||
"ExtensionDocumentMetadata",
|
"ExtensionDocumentMetadata",
|
||||||
"PaginatedResponse",
|
|
||||||
# Base schemas
|
# Base schemas
|
||||||
"IDModel",
|
"IDModel",
|
||||||
"TimestampModel",
|
# RBAC schemas
|
||||||
|
"InviteAcceptRequest",
|
||||||
|
"InviteAcceptResponse",
|
||||||
|
"InviteCreate",
|
||||||
|
"InviteInfoResponse",
|
||||||
|
"InviteRead",
|
||||||
|
"InviteUpdate",
|
||||||
# LLM Config schemas
|
# LLM Config schemas
|
||||||
"LLMConfigBase",
|
"LLMConfigBase",
|
||||||
"LLMConfigCreate",
|
"LLMConfigCreate",
|
||||||
|
|
@ -100,18 +95,25 @@ __all__ = [
|
||||||
"LogFilter",
|
"LogFilter",
|
||||||
"LogRead",
|
"LogRead",
|
||||||
"LogUpdate",
|
"LogUpdate",
|
||||||
# RBAC schemas
|
|
||||||
"InviteAcceptRequest",
|
|
||||||
"InviteAcceptResponse",
|
|
||||||
"InviteCreate",
|
|
||||||
"InviteInfoResponse",
|
|
||||||
"InviteRead",
|
|
||||||
"InviteUpdate",
|
|
||||||
"MembershipRead",
|
"MembershipRead",
|
||||||
"MembershipReadWithUser",
|
"MembershipReadWithUser",
|
||||||
"MembershipUpdate",
|
"MembershipUpdate",
|
||||||
|
"NewChatMessageAppend",
|
||||||
|
"NewChatMessageCreate",
|
||||||
|
"NewChatMessageRead",
|
||||||
|
"NewChatRequest",
|
||||||
|
"NewChatThreadCreate",
|
||||||
|
"NewChatThreadRead",
|
||||||
|
"NewChatThreadUpdate",
|
||||||
|
"NewChatThreadWithMessages",
|
||||||
|
"PaginatedResponse",
|
||||||
"PermissionInfo",
|
"PermissionInfo",
|
||||||
"PermissionsListResponse",
|
"PermissionsListResponse",
|
||||||
|
# Podcast schemas
|
||||||
|
"PodcastBase",
|
||||||
|
"PodcastCreate",
|
||||||
|
"PodcastRead",
|
||||||
|
"PodcastUpdate",
|
||||||
"RoleCreate",
|
"RoleCreate",
|
||||||
"RoleRead",
|
"RoleRead",
|
||||||
"RoleUpdate",
|
"RoleUpdate",
|
||||||
|
|
@ -126,6 +128,10 @@ __all__ = [
|
||||||
"SearchSpaceRead",
|
"SearchSpaceRead",
|
||||||
"SearchSpaceUpdate",
|
"SearchSpaceUpdate",
|
||||||
"SearchSpaceWithStats",
|
"SearchSpaceWithStats",
|
||||||
|
"ThreadHistoryLoadResponse",
|
||||||
|
"ThreadListItem",
|
||||||
|
"ThreadListResponse",
|
||||||
|
"TimestampModel",
|
||||||
# User schemas
|
# User schemas
|
||||||
"UserCreate",
|
"UserCreate",
|
||||||
"UserRead",
|
"UserRead",
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,15 @@ class ChatMessage(BaseModel):
|
||||||
content: str
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChatAttachment(BaseModel):
|
||||||
|
"""An attachment with its extracted content for chat context."""
|
||||||
|
|
||||||
|
id: str # Unique attachment ID
|
||||||
|
name: str # Original filename
|
||||||
|
type: str # Attachment type: document, image, audio
|
||||||
|
content: str # Extracted markdown content from the file
|
||||||
|
|
||||||
|
|
||||||
class NewChatRequest(BaseModel):
|
class NewChatRequest(BaseModel):
|
||||||
"""Request schema for the deep agent chat endpoint."""
|
"""Request schema for the deep agent chat endpoint."""
|
||||||
|
|
||||||
|
|
@ -148,3 +157,6 @@ class NewChatRequest(BaseModel):
|
||||||
user_query: str
|
user_query: str
|
||||||
search_space_id: int
|
search_space_id: int
|
||||||
messages: list[ChatMessage] | None = None # Optional chat history from frontend
|
messages: list[ChatMessage] | None = None # Optional chat history from frontend
|
||||||
|
attachments: list[ChatAttachment] | None = (
|
||||||
|
None # Optional attachments with extracted content
|
||||||
|
)
|
||||||
|
|
|
||||||
41
surfsense_backend/app/schemas/podcasts.py
Normal file
41
surfsense_backend/app/schemas/podcasts.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""Podcast schemas for API responses."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastBase(BaseModel):
|
||||||
|
"""Base podcast schema."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
podcast_transcript: list[dict[str, Any]] | None = None
|
||||||
|
file_location: str | None = None
|
||||||
|
search_space_id: int
|
||||||
|
chat_id: int | None = None
|
||||||
|
chat_state_version: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastCreate(PodcastBase):
|
||||||
|
"""Schema for creating a podcast."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastUpdate(BaseModel):
|
||||||
|
"""Schema for updating a podcast."""
|
||||||
|
|
||||||
|
title: str | None = None
|
||||||
|
podcast_transcript: list[dict[str, Any]] | None = None
|
||||||
|
file_location: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastRead(PodcastBase):
|
||||||
|
"""Schema for reading a podcast."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
@ -13,7 +13,6 @@ from app.agents.podcaster.state import State as PodcasterState
|
||||||
from app.celery_app import celery_app
|
from app.celery_app import celery_app
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.db import Podcast
|
from app.db import Podcast
|
||||||
from app.tasks.podcast_tasks import generate_chat_podcast
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -40,58 +39,6 @@ def get_celery_session_maker():
|
||||||
return async_sessionmaker(engine, expire_on_commit=False)
|
return async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(name="generate_chat_podcast", bind=True)
|
|
||||||
def generate_chat_podcast_task(
|
|
||||||
self,
|
|
||||||
chat_id: int,
|
|
||||||
search_space_id: int,
|
|
||||||
user_id: int,
|
|
||||||
podcast_title: str | None = None,
|
|
||||||
user_prompt: str | None = None,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Celery task to generate podcast from chat.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
chat_id: ID of the chat to generate podcast from
|
|
||||||
search_space_id: ID of the search space
|
|
||||||
user_id: ID of the user,
|
|
||||||
podcast_title: Title for the podcast
|
|
||||||
user_prompt: Optional prompt from the user to guide the podcast generation
|
|
||||||
"""
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
|
|
||||||
try:
|
|
||||||
loop.run_until_complete(
|
|
||||||
_generate_chat_podcast(
|
|
||||||
chat_id, search_space_id, user_id, podcast_title, user_prompt
|
|
||||||
)
|
|
||||||
)
|
|
||||||
loop.run_until_complete(loop.shutdown_asyncgens())
|
|
||||||
finally:
|
|
||||||
asyncio.set_event_loop(None)
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
|
|
||||||
async def _generate_chat_podcast(
|
|
||||||
chat_id: int,
|
|
||||||
search_space_id: int,
|
|
||||||
user_id: int,
|
|
||||||
podcast_title: str | None = None,
|
|
||||||
user_prompt: str | None = None,
|
|
||||||
):
|
|
||||||
"""Generate chat podcast with new session."""
|
|
||||||
async with get_celery_session_maker()() as session:
|
|
||||||
try:
|
|
||||||
await generate_chat_podcast(
|
|
||||||
session, chat_id, search_space_id, user_id, podcast_title, user_prompt
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error generating podcast from chat: {e!s}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Content-based podcast generation (for new-chat)
|
# Content-based podcast generation (for new-chat)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,28 @@ from app.agents.new_chat.llm_config import (
|
||||||
create_chat_litellm_from_config,
|
create_chat_litellm_from_config,
|
||||||
load_llm_config_from_yaml,
|
load_llm_config_from_yaml,
|
||||||
)
|
)
|
||||||
from app.schemas.new_chat import ChatMessage
|
from app.schemas.new_chat import ChatAttachment, ChatMessage
|
||||||
from app.services.connector_service import ConnectorService
|
from app.services.connector_service import ConnectorService
|
||||||
from app.services.new_streaming_service import VercelStreamingService
|
from app.services.new_streaming_service import VercelStreamingService
|
||||||
|
|
||||||
|
|
||||||
|
def format_attachments_as_context(attachments: list[ChatAttachment]) -> str:
|
||||||
|
"""Format attachments as context for the agent."""
|
||||||
|
if not attachments:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
context_parts = ["<user_attachments>"]
|
||||||
|
for i, attachment in enumerate(attachments, 1):
|
||||||
|
context_parts.append(
|
||||||
|
f"<attachment index='{i}' name='{attachment.name}' type='{attachment.type}'>"
|
||||||
|
)
|
||||||
|
context_parts.append(f"<![CDATA[{attachment.content}]]>")
|
||||||
|
context_parts.append("</attachment>")
|
||||||
|
context_parts.append("</user_attachments>")
|
||||||
|
|
||||||
|
return "\n".join(context_parts)
|
||||||
|
|
||||||
|
|
||||||
async def stream_new_chat(
|
async def stream_new_chat(
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str | UUID,
|
user_id: str | UUID,
|
||||||
|
|
@ -31,6 +48,7 @@ async def stream_new_chat(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
llm_config_id: int = -1,
|
llm_config_id: int = -1,
|
||||||
messages: list[ChatMessage] | None = None,
|
messages: list[ChatMessage] | None = None,
|
||||||
|
attachments: list[ChatAttachment] | None = None,
|
||||||
) -> AsyncGenerator[str, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""
|
"""
|
||||||
Stream chat responses from the new SurfSense deep agent.
|
Stream chat responses from the new SurfSense deep agent.
|
||||||
|
|
@ -96,6 +114,14 @@ async def stream_new_chat(
|
||||||
# Build input with message history from frontend
|
# Build input with message history from frontend
|
||||||
langchain_messages = []
|
langchain_messages = []
|
||||||
|
|
||||||
|
# Format the user query with attachment context if any
|
||||||
|
final_query = user_query
|
||||||
|
if attachments:
|
||||||
|
attachment_context = format_attachments_as_context(attachments)
|
||||||
|
final_query = (
|
||||||
|
f"{attachment_context}\n\n<user_query>{user_query}</user_query>"
|
||||||
|
)
|
||||||
|
|
||||||
# if messages:
|
# if messages:
|
||||||
# # Convert frontend messages to LangChain format
|
# # Convert frontend messages to LangChain format
|
||||||
# for msg in messages:
|
# for msg in messages:
|
||||||
|
|
@ -104,8 +130,8 @@ async def stream_new_chat(
|
||||||
# elif msg.role == "assistant":
|
# elif msg.role == "assistant":
|
||||||
# langchain_messages.append(AIMessage(content=msg.content))
|
# langchain_messages.append(AIMessage(content=msg.content))
|
||||||
# else:
|
# else:
|
||||||
# Fallback: just use the current user query
|
# Fallback: just use the current user query with attachment context
|
||||||
langchain_messages.append(HumanMessage(content=user_query))
|
langchain_messages.append(HumanMessage(content=final_query))
|
||||||
|
|
||||||
input_state = {
|
input_state = {
|
||||||
# Lets not pass this message atm because we are using the checkpointer to manage the conversation history
|
# Lets not pass this message atm because we are using the checkpointer to manage the conversation history
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
from sqlalchemy import select
|
"""
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
Legacy podcast task for old chat system.
|
||||||
|
|
||||||
|
NOTE: The old Chat model has been removed. This module is kept for backwards
|
||||||
|
compatibility but the generate_chat_podcast function will raise an error
|
||||||
|
if called. Use generate_content_podcast_task in celery_tasks/podcast_tasks.py
|
||||||
|
for new-chat podcast generation instead.
|
||||||
|
"""
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.agents.podcaster.graph import graph as podcaster_graph
|
from app.db import Podcast # noqa: F401 - imported for backwards compatibility
|
||||||
from app.agents.podcaster.state import State
|
|
||||||
from app.db import Chat, Podcast
|
|
||||||
from app.services.task_logging_service import TaskLoggingService
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_chat_podcast(
|
async def generate_chat_podcast(
|
||||||
|
|
@ -16,196 +20,13 @@ async def generate_chat_podcast(
|
||||||
podcast_title: str | None = None,
|
podcast_title: str | None = None,
|
||||||
user_prompt: str | None = None,
|
user_prompt: str | None = None,
|
||||||
):
|
):
|
||||||
task_logger = TaskLoggingService(session, search_space_id)
|
"""
|
||||||
|
Legacy function for generating podcasts from old chat system.
|
||||||
|
|
||||||
# Log task start
|
This function is deprecated as the old Chat model has been removed.
|
||||||
log_entry = await task_logger.log_task_start(
|
Use generate_content_podcast_task for new-chat podcast generation.
|
||||||
task_name="generate_chat_podcast",
|
"""
|
||||||
source="podcast_task",
|
raise NotImplementedError(
|
||||||
message=f"Starting podcast generation for chat {chat_id}",
|
"generate_chat_podcast is deprecated. The old Chat model has been removed. "
|
||||||
metadata={
|
"Use generate_content_podcast_task for podcast generation from new-chat."
|
||||||
"chat_id": chat_id,
|
|
||||||
"search_space_id": search_space_id,
|
|
||||||
"podcast_title": podcast_title,
|
|
||||||
"user_id": str(user_id),
|
|
||||||
"user_prompt": user_prompt,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
|
||||||
# Fetch the chat with the specified ID
|
|
||||||
await task_logger.log_task_progress(
|
|
||||||
log_entry, f"Fetching chat {chat_id} from database", {"stage": "fetch_chat"}
|
|
||||||
)
|
|
||||||
|
|
||||||
query = select(Chat).filter(
|
|
||||||
Chat.id == chat_id, Chat.search_space_id == search_space_id
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await session.execute(query)
|
|
||||||
chat = result.scalars().first()
|
|
||||||
|
|
||||||
if not chat:
|
|
||||||
await task_logger.log_task_failure(
|
|
||||||
log_entry,
|
|
||||||
f"Chat with id {chat_id} not found in search space {search_space_id}",
|
|
||||||
"Chat not found",
|
|
||||||
{"error_type": "ChatNotFound"},
|
|
||||||
)
|
|
||||||
raise ValueError(
|
|
||||||
f"Chat with id {chat_id} not found in search space {search_space_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create chat history structure
|
|
||||||
await task_logger.log_task_progress(
|
|
||||||
log_entry,
|
|
||||||
f"Processing chat history for chat {chat_id}",
|
|
||||||
{"stage": "process_chat_history", "message_count": len(chat.messages)},
|
|
||||||
)
|
|
||||||
|
|
||||||
chat_history_str = "<chat_history>"
|
|
||||||
|
|
||||||
processed_messages = 0
|
|
||||||
for message in chat.messages:
|
|
||||||
if message["role"] == "user":
|
|
||||||
chat_history_str += f"<user_message>{message['content']}</user_message>"
|
|
||||||
processed_messages += 1
|
|
||||||
elif message["role"] == "assistant":
|
|
||||||
chat_history_str += (
|
|
||||||
f"<assistant_message>{message['content']}</assistant_message>"
|
|
||||||
)
|
|
||||||
processed_messages += 1
|
|
||||||
|
|
||||||
chat_history_str += "</chat_history>"
|
|
||||||
|
|
||||||
# Pass it to the SurfSense Podcaster
|
|
||||||
await task_logger.log_task_progress(
|
|
||||||
log_entry,
|
|
||||||
f"Initializing podcast generation for chat {chat_id}",
|
|
||||||
{
|
|
||||||
"stage": "initialize_podcast_generation",
|
|
||||||
"processed_messages": processed_messages,
|
|
||||||
"content_length": len(chat_history_str),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
config = {
|
|
||||||
"configurable": {
|
|
||||||
"podcast_title": podcast_title or "SurfSense Podcast",
|
|
||||||
"user_id": str(user_id),
|
|
||||||
"search_space_id": search_space_id,
|
|
||||||
"user_prompt": user_prompt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
# Initialize state with database session and streaming service
|
|
||||||
initial_state = State(source_content=chat_history_str, db_session=session)
|
|
||||||
|
|
||||||
# Run the graph directly
|
|
||||||
await task_logger.log_task_progress(
|
|
||||||
log_entry,
|
|
||||||
f"Running podcast generation graph for chat {chat_id}",
|
|
||||||
{"stage": "run_podcast_graph"},
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await podcaster_graph.ainvoke(initial_state, config=config)
|
|
||||||
|
|
||||||
# Convert podcast transcript entries to serializable format
|
|
||||||
await task_logger.log_task_progress(
|
|
||||||
log_entry,
|
|
||||||
f"Processing podcast transcript for chat {chat_id}",
|
|
||||||
{
|
|
||||||
"stage": "process_transcript",
|
|
||||||
"transcript_entries": len(result["podcast_transcript"]),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
serializable_transcript = []
|
|
||||||
for entry in result["podcast_transcript"]:
|
|
||||||
serializable_transcript.append(
|
|
||||||
{"speaker_id": entry.speaker_id, "dialog": entry.dialog}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a new podcast entry
|
|
||||||
await task_logger.log_task_progress(
|
|
||||||
log_entry,
|
|
||||||
f"Creating podcast database entry for chat {chat_id}",
|
|
||||||
{
|
|
||||||
"stage": "create_podcast_entry",
|
|
||||||
"file_location": result.get("final_podcast_file_path"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# check if podcast already exists for this chat (re-generation)
|
|
||||||
existing_podcast = await session.execute(
|
|
||||||
select(Podcast).filter(Podcast.chat_id == chat_id)
|
|
||||||
)
|
|
||||||
existing_podcast = existing_podcast.scalars().first()
|
|
||||||
|
|
||||||
if existing_podcast:
|
|
||||||
existing_podcast.podcast_transcript = serializable_transcript
|
|
||||||
existing_podcast.file_location = result["final_podcast_file_path"]
|
|
||||||
existing_podcast.chat_state_version = chat.state_version
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(existing_podcast)
|
|
||||||
return existing_podcast
|
|
||||||
else:
|
|
||||||
podcast = Podcast(
|
|
||||||
title=f"{podcast_title}",
|
|
||||||
podcast_transcript=serializable_transcript,
|
|
||||||
file_location=result["final_podcast_file_path"],
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
chat_state_version=chat.state_version,
|
|
||||||
chat_id=chat.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add to session and commit
|
|
||||||
session.add(podcast)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(podcast)
|
|
||||||
|
|
||||||
# Log success
|
|
||||||
await task_logger.log_task_success(
|
|
||||||
log_entry,
|
|
||||||
f"Successfully generated podcast for chat {chat_id}",
|
|
||||||
{
|
|
||||||
"podcast_id": podcast.id,
|
|
||||||
"podcast_title": podcast_title,
|
|
||||||
"transcript_entries": len(serializable_transcript),
|
|
||||||
"file_location": result.get("final_podcast_file_path"),
|
|
||||||
"processed_messages": processed_messages,
|
|
||||||
"content_length": len(chat_history_str),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return podcast
|
|
||||||
|
|
||||||
except ValueError as ve:
|
|
||||||
# ValueError is already logged above for chat not found
|
|
||||||
if "not found" not in str(ve):
|
|
||||||
await task_logger.log_task_failure(
|
|
||||||
log_entry,
|
|
||||||
f"Value error during podcast generation for chat {chat_id}",
|
|
||||||
str(ve),
|
|
||||||
{"error_type": "ValueError"},
|
|
||||||
)
|
|
||||||
raise ve
|
|
||||||
except SQLAlchemyError as db_error:
|
|
||||||
await session.rollback()
|
|
||||||
await task_logger.log_task_failure(
|
|
||||||
log_entry,
|
|
||||||
f"Database error during podcast generation for chat {chat_id}",
|
|
||||||
str(db_error),
|
|
||||||
{"error_type": "SQLAlchemyError"},
|
|
||||||
)
|
|
||||||
raise db_error
|
|
||||||
except Exception as e:
|
|
||||||
await session.rollback()
|
|
||||||
await task_logger.log_task_failure(
|
|
||||||
log_entry,
|
|
||||||
f"Unexpected error during podcast generation for chat {chat_id}",
|
|
||||||
str(e),
|
|
||||||
{"error_type": type(e).__name__},
|
|
||||||
)
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Failed to generate podcast for chat {chat_id}: {e!s}"
|
|
||||||
) from e
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,20 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { Loader2, PanelRight } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { activeChathatUIAtom, activeChatIdAtom } from "@/atoms/chats/ui.atoms";
|
|
||||||
import { llmPreferencesAtom } from "@/atoms/llm-config/llm-config-query.atoms";
|
import { llmPreferencesAtom } from "@/atoms/llm-config/llm-config-query.atoms";
|
||||||
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
|
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContainer";
|
|
||||||
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
||||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||||
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
|
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export function DashboardClientLayout({
|
export function DashboardClientLayout({
|
||||||
children,
|
children,
|
||||||
|
|
@ -34,33 +30,8 @@ export function DashboardClientLayout({
|
||||||
const t = useTranslations("dashboard");
|
const t = useTranslations("dashboard");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchSpaceIdNum = Number(searchSpaceId);
|
const { search_space_id } = useParams();
|
||||||
const { search_space_id, chat_id } = useParams();
|
|
||||||
const [chatUIState, setChatUIState] = useAtom(activeChathatUIAtom);
|
|
||||||
const activeChatId = useAtomValue(activeChatIdAtom);
|
|
||||||
const setActiveSearchSpaceIdState = useSetAtom(activeSearchSpaceIdAtom);
|
const setActiveSearchSpaceIdState = useSetAtom(activeSearchSpaceIdAtom);
|
||||||
const setActiveChatIdState = useSetAtom(activeChatIdAtom);
|
|
||||||
const [showIndicator, setShowIndicator] = useState(false);
|
|
||||||
|
|
||||||
const { isChatPannelOpen } = chatUIState;
|
|
||||||
|
|
||||||
// Check if we're on the researcher page
|
|
||||||
const isResearcherPage = pathname?.includes("/researcher");
|
|
||||||
|
|
||||||
// Check if we're on the new-chat page (uses separate thread persistence)
|
|
||||||
const isNewChatPage = pathname?.includes("/new-chat");
|
|
||||||
|
|
||||||
// Show indicator when chat becomes active and panel is closed
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeChatId && !isChatPannelOpen) {
|
|
||||||
setShowIndicator(true);
|
|
||||||
// Hide indicator after 5 seconds
|
|
||||||
const timer = setTimeout(() => setShowIndicator(false), 5000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
} else {
|
|
||||||
setShowIndicator(false);
|
|
||||||
}
|
|
||||||
}, [activeChatId, isChatPannelOpen]);
|
|
||||||
|
|
||||||
const { data: preferences = {}, isFetching: loading, error } = useAtomValue(llmPreferencesAtom);
|
const { data: preferences = {}, isFetching: loading, error } = useAtomValue(llmPreferencesAtom);
|
||||||
|
|
||||||
|
|
@ -151,24 +122,7 @@ export function DashboardClientLayout({
|
||||||
: "";
|
: "";
|
||||||
if (!activeSeacrhSpaceId) return;
|
if (!activeSeacrhSpaceId) return;
|
||||||
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
|
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
|
||||||
}, [search_space_id]);
|
}, [search_space_id, setActiveSearchSpaceIdState]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Skip setting activeChatIdAtom on new-chat page (uses separate thread persistence)
|
|
||||||
if (isNewChatPage) {
|
|
||||||
setActiveChatIdState(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeChatId =
|
|
||||||
typeof chat_id === "string"
|
|
||||||
? chat_id
|
|
||||||
: Array.isArray(chat_id) && chat_id.length > 0
|
|
||||||
? chat_id[0]
|
|
||||||
: "";
|
|
||||||
if (!activeChatId) return;
|
|
||||||
setActiveChatIdState(activeChatId);
|
|
||||||
}, [chat_id, search_space_id, isNewChatPage]);
|
|
||||||
|
|
||||||
// Show loading screen while checking onboarding status (only on first load)
|
// Show loading screen while checking onboarding status (only on first load)
|
||||||
if (!hasCheckedOnboarding && (loading || accessLoading) && !isOnboardingPage) {
|
if (!hasCheckedOnboarding && (loading || accessLoading) && !isOnboardingPage) {
|
||||||
|
|
@ -221,123 +175,20 @@ export function DashboardClientLayout({
|
||||||
navMain={translatedNavMain}
|
navMain={translatedNavMain}
|
||||||
/>
|
/>
|
||||||
<SidebarInset className="h-full ">
|
<SidebarInset className="h-full ">
|
||||||
<main className="flex h-full">
|
<main className="flex flex-col h-full">
|
||||||
<div className="flex grow flex-col h-full border-r">
|
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 border-b">
|
||||||
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 border-b">
|
<div className="flex items-center justify-between w-full gap-2 px-4">
|
||||||
<div className="flex items-center justify-between w-full gap-2 px-4">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<SidebarTrigger className="-ml-1" />
|
||||||
<SidebarTrigger className="-ml-1" />
|
<Separator orientation="vertical" className="h-6" />
|
||||||
<Separator orientation="vertical" className="h-6" />
|
<DashboardBreadcrumb />
|
||||||
<DashboardBreadcrumb />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<LanguageSwitcher />
|
|
||||||
{/* Only show artifacts toggle on researcher page */}
|
|
||||||
{isResearcherPage && (
|
|
||||||
<motion.div
|
|
||||||
className="relative"
|
|
||||||
animate={
|
|
||||||
showIndicator
|
|
||||||
? {
|
|
||||||
scale: [1, 1.05, 1],
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
transition={{
|
|
||||||
duration: 2,
|
|
||||||
repeat: showIndicator ? Number.POSITIVE_INFINITY : 0,
|
|
||||||
ease: "easeInOut",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<motion.button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setChatUIState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
isChatPannelOpen: !isChatPannelOpen,
|
|
||||||
}));
|
|
||||||
setShowIndicator(false);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 rounded-full p-2 transition-all duration-300 relative",
|
|
||||||
showIndicator
|
|
||||||
? "bg-primary/20 hover:bg-primary/30 shadow-lg shadow-primary/25"
|
|
||||||
: "hover:bg-muted",
|
|
||||||
activeChatId && !showIndicator && "hover:bg-primary/10"
|
|
||||||
)}
|
|
||||||
title="Toggle Artifacts Panel"
|
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
animate={
|
|
||||||
showIndicator
|
|
||||||
? {
|
|
||||||
rotate: [0, -10, 10, -10, 0],
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
transition={{
|
|
||||||
duration: 0.5,
|
|
||||||
repeat: showIndicator ? Number.POSITIVE_INFINITY : 0,
|
|
||||||
repeatDelay: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PanelRight
|
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 transition-colors",
|
|
||||||
showIndicator && "text-primary"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</motion.button>
|
|
||||||
|
|
||||||
{/* Pulsing indicator badge */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{showIndicator && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0 }}
|
|
||||||
className="absolute -right-1 -top-1 pointer-events-none"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
scale: [1, 1.3, 1],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 1.5,
|
|
||||||
repeat: Number.POSITIVE_INFINITY,
|
|
||||||
ease: "easeInOut",
|
|
||||||
}}
|
|
||||||
className="relative"
|
|
||||||
>
|
|
||||||
<div className="h-2.5 w-2.5 rounded-full bg-primary shadow-lg" />
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
scale: [1, 2.5, 1],
|
|
||||||
opacity: [0.6, 0, 0.6],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 1.5,
|
|
||||||
repeat: Number.POSITIVE_INFINITY,
|
|
||||||
ease: "easeInOut",
|
|
||||||
}}
|
|
||||||
className="absolute inset-0 h-2.5 w-2.5 rounded-full bg-primary"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
<div className="flex items-center gap-2">
|
||||||
<div className="grow flex-1 overflow-auto min-h-[calc(100vh-64px)]">{children}</div>
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
{/* Only render chat panel on researcher page */}
|
</div>
|
||||||
{isResearcherPage && <ChatPanelContainer />}
|
</header>
|
||||||
|
<div className="grow flex-1 overflow-auto min-h-[calc(100vh-64px)]">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type AppendMessage,
|
||||||
AssistantRuntimeProvider,
|
AssistantRuntimeProvider,
|
||||||
type ThreadMessageLike,
|
type ThreadMessageLike,
|
||||||
useExternalStoreRuntime,
|
useExternalStoreRuntime,
|
||||||
|
|
@ -11,6 +12,7 @@ import { toast } from "sonner";
|
||||||
import { Thread } from "@/components/assistant-ui/thread";
|
import { Thread } from "@/components/assistant-ui/thread";
|
||||||
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
import { getBearerToken } from "@/lib/auth-utils";
|
||||||
|
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
|
||||||
import {
|
import {
|
||||||
isPodcastGenerating,
|
isPodcastGenerating,
|
||||||
looksLikePodcastRequest,
|
looksLikePodcastRequest,
|
||||||
|
|
@ -59,6 +61,9 @@ export default function NewChatPage() {
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Create the attachment adapter for file processing
|
||||||
|
const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []);
|
||||||
|
|
||||||
// Extract search_space_id from URL params
|
// Extract search_space_id from URL params
|
||||||
const searchSpaceId = useMemo(() => {
|
const searchSpaceId = useMemo(() => {
|
||||||
const id = params.search_space_id;
|
const id = params.search_space_id;
|
||||||
|
|
@ -99,7 +104,10 @@ export default function NewChatPage() {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[NewChatPage] Failed to initialize thread:", error);
|
console.error("[NewChatPage] Failed to initialize thread:", error);
|
||||||
setThreadId(Date.now());
|
// Keep threadId as null - don't use Date.now() as it creates an invalid ID
|
||||||
|
// that will cause 404 errors on subsequent API calls
|
||||||
|
setThreadId(null);
|
||||||
|
toast.error("Failed to initialize chat. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsInitializing(false);
|
setIsInitializing(false);
|
||||||
}
|
}
|
||||||
|
|
@ -121,18 +129,27 @@ export default function NewChatPage() {
|
||||||
|
|
||||||
// Handle new message from user
|
// Handle new message from user
|
||||||
const onNew = useCallback(
|
const onNew = useCallback(
|
||||||
async (message: ThreadMessageLike) => {
|
async (message: AppendMessage) => {
|
||||||
if (!threadId) return;
|
if (!threadId) return;
|
||||||
|
|
||||||
// Extract user query text
|
// Extract user query text from content parts
|
||||||
let userQuery = "";
|
let userQuery = "";
|
||||||
for (const part of message.content) {
|
for (const part of message.content) {
|
||||||
if (typeof part === "object" && part.type === "text" && "text" in part) {
|
if (part.type === "text") {
|
||||||
userQuery += part.text;
|
userQuery += part.text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userQuery.trim()) return;
|
// Extract attachments from message
|
||||||
|
// AppendMessage.attachments contains the processed attachment objects (from adapter.send())
|
||||||
|
const messageAttachments: Array<Record<string, unknown>> = [];
|
||||||
|
if (message.attachments && message.attachments.length > 0) {
|
||||||
|
for (const att of message.attachments) {
|
||||||
|
messageAttachments.push(att as unknown as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userQuery.trim() && messageAttachments.length === 0) return;
|
||||||
|
|
||||||
// Check if podcast is already generating
|
// Check if podcast is already generating
|
||||||
if (isPodcastGenerating() && looksLikePodcastRequest(userQuery)) {
|
if (isPodcastGenerating() && looksLikePodcastRequest(userQuery)) {
|
||||||
|
|
@ -239,6 +256,9 @@ export default function NewChatPage() {
|
||||||
})
|
})
|
||||||
.filter((m) => m.content.length > 0);
|
.filter((m) => m.content.length > 0);
|
||||||
|
|
||||||
|
// Extract attachment content to send with the request
|
||||||
|
const attachments = extractAttachmentContent(messageAttachments);
|
||||||
|
|
||||||
const response = await fetch(`${backendUrl}/api/v1/new_chat`, {
|
const response = await fetch(`${backendUrl}/api/v1/new_chat`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -250,6 +270,7 @@ export default function NewChatPage() {
|
||||||
user_query: userQuery.trim(),
|
user_query: userQuery.trim(),
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
messages: messageHistory,
|
messages: messageHistory,
|
||||||
|
attachments: attachments.length > 0 ? attachments : undefined,
|
||||||
}),
|
}),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
@ -405,13 +426,29 @@ export default function NewChatPage() {
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create external store runtime
|
// Handle editing a message - removes messages after the edited one and sends as new
|
||||||
|
const onEdit = useCallback(
|
||||||
|
async (message: AppendMessage) => {
|
||||||
|
// Find the message being edited by looking at the parentId
|
||||||
|
// The parentId tells us which message's response we're editing
|
||||||
|
// For now, we'll just treat edits like new messages
|
||||||
|
// A more sophisticated implementation would truncate the history
|
||||||
|
await onNew(message);
|
||||||
|
},
|
||||||
|
[onNew]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create external store runtime with attachment support
|
||||||
const runtime = useExternalStoreRuntime({
|
const runtime = useExternalStoreRuntime({
|
||||||
messages,
|
messages,
|
||||||
isRunning,
|
isRunning,
|
||||||
onNew,
|
onNew,
|
||||||
|
onEdit,
|
||||||
convertMessage,
|
convertMessage,
|
||||||
onCancel: cancelRun,
|
onCancel: cancelRun,
|
||||||
|
adapters: {
|
||||||
|
attachments: attachmentAdapter,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
|
|
@ -423,6 +460,25 @@ export default function NewChatPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show error state if thread initialization failed
|
||||||
|
if (!threadId) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-64px)] flex-col items-center justify-center gap-4">
|
||||||
|
<div className="text-destructive">Failed to initialize chat</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsInitializing(true);
|
||||||
|
initializeThread();
|
||||||
|
}}
|
||||||
|
className="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AssistantRuntimeProvider runtime={runtime}>
|
<AssistantRuntimeProvider runtime={runtime}>
|
||||||
<GeneratePodcastToolUI />
|
<GeneratePodcastToolUI />
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { Suspense } from "react";
|
|
||||||
import PodcastsPageClient from "./podcasts-client";
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: {
|
|
||||||
search_space_id: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function PodcastsPage({ params }: PageProps) {
|
|
||||||
const { search_space_id: searchSpaceId } = await Promise.resolve(params);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex items-center justify-center h-[60vh]">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PodcastsPageClient searchSpaceId={searchSpaceId} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,957 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
|
||||||
import {
|
|
||||||
Calendar,
|
|
||||||
MoreHorizontal,
|
|
||||||
Pause,
|
|
||||||
Play,
|
|
||||||
Podcast as PodcastIcon,
|
|
||||||
Search,
|
|
||||||
SkipBack,
|
|
||||||
SkipForward,
|
|
||||||
Trash2,
|
|
||||||
Volume2,
|
|
||||||
VolumeX,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { deletePodcastMutationAtom } from "@/atoms/podcasts/podcast-mutation.atoms";
|
|
||||||
import { podcastsAtom } from "@/atoms/podcasts/podcast-query.atoms";
|
|
||||||
// UI Components
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Slider } from "@/components/ui/slider";
|
|
||||||
import type { Podcast } from "@/contracts/types/podcast.types";
|
|
||||||
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
|
|
||||||
|
|
||||||
interface PodcastsPageClientProps {
|
|
||||||
searchSpaceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageVariants: Variants = {
|
|
||||||
initial: { opacity: 0 },
|
|
||||||
enter: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: { duration: 0.4, ease: "easeInOut", staggerChildren: 0.1 },
|
|
||||||
},
|
|
||||||
exit: { opacity: 0, transition: { duration: 0.3, ease: "easeInOut" } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const podcastCardVariants: Variants = {
|
|
||||||
initial: { scale: 0.95, y: 20, opacity: 0 },
|
|
||||||
animate: {
|
|
||||||
scale: 1,
|
|
||||||
y: 0,
|
|
||||||
opacity: 1,
|
|
||||||
transition: { type: "spring", stiffness: 300, damping: 25 },
|
|
||||||
},
|
|
||||||
exit: { scale: 0.95, y: -20, opacity: 0 },
|
|
||||||
hover: { y: -5, scale: 1.02, transition: { duration: 0.2 } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const MotionCard = motion(Card);
|
|
||||||
|
|
||||||
export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClientProps) {
|
|
||||||
const [filteredPodcasts, setFilteredPodcasts] = useState<Podcast[]>([]);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [sortOrder, setSortOrder] = useState<string>("newest");
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
||||||
const [podcastToDelete, setPodcastToDelete] = useState<{
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Audio player state
|
|
||||||
const [currentPodcast, setCurrentPodcast] = useState<Podcast | null>(null);
|
|
||||||
const [audioSrc, setAudioSrc] = useState<string | undefined>(undefined);
|
|
||||||
const [isAudioLoading, setIsAudioLoading] = useState(false);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
|
||||||
const [duration, setDuration] = useState(0);
|
|
||||||
const [volume, setVolume] = useState(0.7);
|
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
const currentObjectUrlRef = useRef<string | null>(null);
|
|
||||||
const [{ isPending: isDeletingPodcast, mutateAsync: deletePodcast, error: deleteError }] =
|
|
||||||
useAtom(deletePodcastMutationAtom);
|
|
||||||
const {
|
|
||||||
data: podcasts,
|
|
||||||
isLoading: isFetchingPodcasts,
|
|
||||||
error: fetchError,
|
|
||||||
} = useAtomValue(podcastsAtom);
|
|
||||||
|
|
||||||
// Add podcast image URL constant
|
|
||||||
const PODCAST_IMAGE_URL =
|
|
||||||
"https://static.vecteezy.com/system/resources/thumbnails/002/157/611/small_2x/illustrations-concept-design-podcast-channel-free-vector.jpg";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isFetchingPodcasts) return;
|
|
||||||
|
|
||||||
if (fetchError) {
|
|
||||||
console.error("Error fetching podcasts:", fetchError);
|
|
||||||
setFilteredPodcasts([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!podcasts) {
|
|
||||||
setFilteredPodcasts([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFilteredPodcasts(podcasts);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Filter and sort podcasts based on search query and sort order
|
|
||||||
useEffect(() => {
|
|
||||||
if (!podcasts) return;
|
|
||||||
|
|
||||||
let result = [...podcasts];
|
|
||||||
|
|
||||||
// Filter by search term
|
|
||||||
if (searchQuery) {
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
result = result.filter((podcast) => podcast.title.toLowerCase().includes(query));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by search space
|
|
||||||
result = result.filter((podcast) => podcast.search_space_id === parseInt(searchSpaceId));
|
|
||||||
|
|
||||||
// Sort podcasts
|
|
||||||
result.sort((a, b) => {
|
|
||||||
const dateA = new Date(a.created_at).getTime();
|
|
||||||
const dateB = new Date(b.created_at).getTime();
|
|
||||||
|
|
||||||
return sortOrder === "newest" ? dateB - dateA : dateA - dateB;
|
|
||||||
});
|
|
||||||
|
|
||||||
setFilteredPodcasts(result);
|
|
||||||
}, [podcasts, searchQuery, sortOrder, searchSpaceId]);
|
|
||||||
|
|
||||||
// Cleanup object URL on unmount or when currentPodcast changes
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (currentObjectUrlRef.current) {
|
|
||||||
URL.revokeObjectURL(currentObjectUrlRef.current);
|
|
||||||
currentObjectUrlRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Audio player time update handler
|
|
||||||
const handleTimeUpdate = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
setCurrentTime(audioRef.current.currentTime);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Audio player metadata loaded handler
|
|
||||||
const handleMetadataLoaded = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
setDuration(audioRef.current.duration);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Play/pause toggle
|
|
||||||
const togglePlayPause = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
if (isPlaying) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
} else {
|
|
||||||
audioRef.current.play();
|
|
||||||
}
|
|
||||||
setIsPlaying(!isPlaying);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// To close player
|
|
||||||
const closePlayer = () => {
|
|
||||||
if (isPlaying) {
|
|
||||||
audioRef.current?.pause();
|
|
||||||
}
|
|
||||||
setIsPlaying(false);
|
|
||||||
setAudioSrc(undefined);
|
|
||||||
setCurrentTime(0);
|
|
||||||
setCurrentPodcast(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Seek to position
|
|
||||||
const handleSeek = (value: number[]) => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = value[0];
|
|
||||||
setCurrentTime(value[0]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Volume change
|
|
||||||
const handleVolumeChange = (value: number[]) => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
const newVolume = value[0];
|
|
||||||
|
|
||||||
// Set volume
|
|
||||||
audioRef.current.volume = newVolume;
|
|
||||||
setVolume(newVolume);
|
|
||||||
|
|
||||||
// Handle mute state based on volume
|
|
||||||
if (newVolume === 0) {
|
|
||||||
audioRef.current.muted = true;
|
|
||||||
setIsMuted(true);
|
|
||||||
} else {
|
|
||||||
audioRef.current.muted = false;
|
|
||||||
setIsMuted(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle mute
|
|
||||||
const toggleMute = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
const newMutedState = !isMuted;
|
|
||||||
audioRef.current.muted = newMutedState;
|
|
||||||
setIsMuted(newMutedState);
|
|
||||||
|
|
||||||
// If unmuting, restore previous volume if it was 0
|
|
||||||
if (!newMutedState && volume === 0) {
|
|
||||||
const restoredVolume = 0.5;
|
|
||||||
audioRef.current.volume = restoredVolume;
|
|
||||||
setVolume(restoredVolume);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Skip forward 10 seconds
|
|
||||||
const skipForward = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = Math.min(
|
|
||||||
audioRef.current.duration,
|
|
||||||
audioRef.current.currentTime + 10
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Skip backward 10 seconds
|
|
||||||
const skipBackward = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = Math.max(0, audioRef.current.currentTime - 10);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format time in MM:SS
|
|
||||||
const formatTime = (time: number) => {
|
|
||||||
const minutes = Math.floor(time / 60);
|
|
||||||
const seconds = Math.floor(time % 60);
|
|
||||||
return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Play podcast - Fetch blob and set object URL
|
|
||||||
const playPodcast = async (podcast: Podcast) => {
|
|
||||||
// If the same podcast is selected, just toggle play/pause
|
|
||||||
if (currentPodcast && currentPodcast.id === podcast.id) {
|
|
||||||
togglePlayPause();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent multiple simultaneous loading requests
|
|
||||||
if (isAudioLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Reset player state and show loading
|
|
||||||
setCurrentPodcast(podcast);
|
|
||||||
setAudioSrc(undefined);
|
|
||||||
setCurrentTime(0);
|
|
||||||
setDuration(0);
|
|
||||||
setIsPlaying(false);
|
|
||||||
setIsAudioLoading(true);
|
|
||||||
|
|
||||||
// Revoke previous object URL if exists (only after we've started the new request)
|
|
||||||
if (currentObjectUrlRef.current) {
|
|
||||||
URL.revokeObjectURL(currentObjectUrlRef.current);
|
|
||||||
currentObjectUrlRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use AbortController to handle timeout or cancellation
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await podcastsApiService.loadPodcast({
|
|
||||||
request: { id: podcast.id },
|
|
||||||
controller,
|
|
||||||
});
|
|
||||||
const objectUrl = URL.createObjectURL(response);
|
|
||||||
currentObjectUrlRef.current = objectUrl;
|
|
||||||
|
|
||||||
// Set audio source
|
|
||||||
setAudioSrc(objectUrl);
|
|
||||||
|
|
||||||
// Wait for the audio to be ready before playing
|
|
||||||
// We'll handle actual playback in the onLoadedData event instead of here
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof DOMException && error.name === "AbortError") {
|
|
||||||
throw new Error("Request timed out. Please try again.");
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching or playing podcast:", error);
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to load podcast audio.");
|
|
||||||
// Reset state on error
|
|
||||||
setCurrentPodcast(null);
|
|
||||||
setAudioSrc(undefined);
|
|
||||||
} finally {
|
|
||||||
setIsAudioLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to handle podcast deletion
|
|
||||||
const handleDeletePodcast = async () => {
|
|
||||||
if (!podcastToDelete) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deletePodcast({ id: podcastToDelete.id });
|
|
||||||
|
|
||||||
// Close dialog
|
|
||||||
setDeleteDialogOpen(false);
|
|
||||||
setPodcastToDelete(null);
|
|
||||||
|
|
||||||
// If the current playing podcast is deleted, stop playback
|
|
||||||
if (currentPodcast && currentPodcast.id === podcastToDelete.id) {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
}
|
|
||||||
setCurrentPodcast(null);
|
|
||||||
setIsPlaying(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting podcast:", error);
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to delete podcast");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className="container p-6 mx-auto"
|
|
||||||
initial="initial"
|
|
||||||
animate="enter"
|
|
||||||
exit="exit"
|
|
||||||
variants={pageVariants}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col space-y-4 md:space-y-6">
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Podcasts</h1>
|
|
||||||
<p className="text-muted-foreground">Listen to generated podcasts.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter and Search Bar */}
|
|
||||||
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:justify-between md:space-y-0">
|
|
||||||
<div className="flex flex-1 items-center gap-2">
|
|
||||||
<div className="relative w-full md:w-80">
|
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search podcasts..."
|
|
||||||
className="pl-8"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Select value={sortOrder} onValueChange={setSortOrder}>
|
|
||||||
<SelectTrigger className="w-40">
|
|
||||||
<SelectValue placeholder="Sort order" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectItem value="newest">Newest First</SelectItem>
|
|
||||||
<SelectItem value="oldest">Oldest First</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Messages */}
|
|
||||||
{isFetchingPodcasts && (
|
|
||||||
<div className="flex items-center justify-center h-40">
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
|
||||||
<p className="text-sm text-muted-foreground">Loading podcasts...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{fetchError && !isFetchingPodcasts && (
|
|
||||||
<div className="border border-destructive/50 text-destructive p-4 rounded-md">
|
|
||||||
<h3 className="font-medium">Error loading podcasts</h3>
|
|
||||||
<p className="text-sm">{fetchError.message ?? "Failed to load podcasts"}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isFetchingPodcasts && !fetchError && filteredPodcasts.length === 0 && (
|
|
||||||
<div className="flex flex-col items-center justify-center h-40 gap-2 text-center">
|
|
||||||
<PodcastIcon className="h-8 w-8 text-muted-foreground" />
|
|
||||||
<h3 className="font-medium">No podcasts found</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{searchQuery
|
|
||||||
? "Try adjusting your search filters"
|
|
||||||
: "Generate podcasts from your chats to get started"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Podcast Grid */}
|
|
||||||
{!isFetchingPodcasts && !fetchError && filteredPodcasts.length > 0 && (
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.div
|
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
|
||||||
variants={pageVariants}
|
|
||||||
initial="initial"
|
|
||||||
animate="enter"
|
|
||||||
exit="exit"
|
|
||||||
>
|
|
||||||
{filteredPodcasts.map((podcast, index) => (
|
|
||||||
<MotionCard
|
|
||||||
key={podcast.id}
|
|
||||||
variants={podcastCardVariants}
|
|
||||||
initial="initial"
|
|
||||||
animate="animate"
|
|
||||||
exit="exit"
|
|
||||||
whileHover="hover"
|
|
||||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
|
||||||
className={`
|
|
||||||
bg-card/60 dark:bg-card/40 backdrop-blur-lg rounded-xl p-4
|
|
||||||
shadow-md hover:shadow-xl transition-all duration-300
|
|
||||||
border-border overflow-hidden cursor-pointer
|
|
||||||
${currentPodcast?.id === podcast.id ? "ring-2 ring-primary ring-offset-2 ring-offset-background" : ""}
|
|
||||||
`}
|
|
||||||
layout
|
|
||||||
onClick={() => playPodcast(podcast)}
|
|
||||||
>
|
|
||||||
<div className="relative w-full aspect-[16/10] mb-4 rounded-lg overflow-hidden">
|
|
||||||
{/* Podcast image with gradient overlay */}
|
|
||||||
<Image
|
|
||||||
src={PODCAST_IMAGE_URL}
|
|
||||||
alt="Podcast illustration"
|
|
||||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105 brightness-[0.85] contrast-[1.1]"
|
|
||||||
loading="lazy"
|
|
||||||
width={100}
|
|
||||||
height={100}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Better overlay with gradient for improved text legibility */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-black/10 transition-opacity duration-300"></div>
|
|
||||||
|
|
||||||
{/* Loading indicator with improved animation */}
|
|
||||||
{currentPodcast?.id === podcast.id && isAudioLoading && (
|
|
||||||
<motion.div
|
|
||||||
className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-md z-10"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className="flex flex-col items-center gap-3"
|
|
||||||
initial={{ scale: 0.9 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
transition={{ type: "spring", damping: 20 }}
|
|
||||||
>
|
|
||||||
<div className="h-14 w-14 rounded-full border-4 border-primary/30 border-t-primary animate-spin"></div>
|
|
||||||
<p className="text-sm text-foreground font-medium">Loading podcast...</p>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Play button with animations */}
|
|
||||||
{!(currentPodcast?.id === podcast.id && (isPlaying || isAudioLoading)) && (
|
|
||||||
<motion.div
|
|
||||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
|
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
whileTap={{ scale: 0.9 }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="h-16 w-16 rounded-full
|
|
||||||
bg-background/80 hover:bg-background/95 backdrop-blur-md
|
|
||||||
transition-all duration-200 shadow-xl border-0
|
|
||||||
flex items-center justify-center"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
playPodcast(podcast);
|
|
||||||
}}
|
|
||||||
disabled={isAudioLoading}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.8 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
transition={{
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 400,
|
|
||||||
damping: 10,
|
|
||||||
}}
|
|
||||||
className="text-primary w-10 h-10 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Play className="h-8 w-8 ml-1" />
|
|
||||||
</motion.div>
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pause button with animations */}
|
|
||||||
{currentPodcast?.id === podcast.id && isPlaying && !isAudioLoading && (
|
|
||||||
<motion.div
|
|
||||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
|
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
whileTap={{ scale: 0.9 }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="h-16 w-16 rounded-full
|
|
||||||
bg-background/80 hover:bg-background/95 backdrop-blur-md
|
|
||||||
transition-all duration-200 shadow-xl border-0
|
|
||||||
flex items-center justify-center"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
togglePlayPause();
|
|
||||||
}}
|
|
||||||
disabled={isAudioLoading}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.8 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
transition={{
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 400,
|
|
||||||
damping: 10,
|
|
||||||
}}
|
|
||||||
className="text-primary w-10 h-10 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Pause className="h-8 w-8" />
|
|
||||||
</motion.div>
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Now playing indicator */}
|
|
||||||
{currentPodcast?.id === podcast.id && !isAudioLoading && (
|
|
||||||
<div className="absolute top-2 left-2 bg-primary text-primary-foreground text-xs px-2 py-1 rounded-full z-10 flex items-center gap-1.5">
|
|
||||||
<span className="relative flex h-2 w-2">
|
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary-foreground opacity-75"></span>
|
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary-foreground"></span>
|
|
||||||
</span>
|
|
||||||
Now Playing
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-3 px-1">
|
|
||||||
<h3
|
|
||||||
className="text-base font-semibold text-foreground truncate"
|
|
||||||
title={podcast.title}
|
|
||||||
>
|
|
||||||
{podcast.title || "Untitled Podcast"}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1.5">
|
|
||||||
<Calendar className="h-3 w-3" />
|
|
||||||
{format(new Date(podcast.created_at), "MMM d, yyyy")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{currentPodcast?.id === podcast.id && !isAudioLoading && (
|
|
||||||
<motion.div
|
|
||||||
className="mb-3 px-1"
|
|
||||||
initial={{ opacity: 0, y: 5 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.1 }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="h-1.5 bg-muted rounded-full cursor-pointer group relative overflow-hidden"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!audioRef.current || !duration) return;
|
|
||||||
const container = e.currentTarget;
|
|
||||||
const rect = container.getBoundingClientRect();
|
|
||||||
const x = e.clientX - rect.left;
|
|
||||||
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
|
||||||
const newTime = percentage * duration;
|
|
||||||
handleSeek([newTime]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className="h-full bg-primary rounded-full relative"
|
|
||||||
style={{
|
|
||||||
width: `${(currentTime / duration) * 100}%`,
|
|
||||||
}}
|
|
||||||
transition={{ ease: "linear" }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3
|
|
||||||
bg-primary rounded-full shadow-md transform scale-0
|
|
||||||
group-hover:scale-100 transition-transform"
|
|
||||||
whileHover={{ scale: 1.5 }}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</Button>
|
|
||||||
<div className="flex justify-between mt-1.5 text-xs text-muted-foreground">
|
|
||||||
<span>{formatTime(currentTime)}</span>
|
|
||||||
<span>{formatTime(duration)}</span>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentPodcast?.id === podcast.id && !isAudioLoading && (
|
|
||||||
<motion.div
|
|
||||||
className="flex items-center justify-between px-2 mt-1"
|
|
||||||
initial={{ opacity: 0, y: 5 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.2 }}
|
|
||||||
>
|
|
||||||
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
skipBackward();
|
|
||||||
}}
|
|
||||||
className="w-9 h-9 text-muted-foreground hover:text-primary transition-colors"
|
|
||||||
title="Rewind 10 seconds"
|
|
||||||
disabled={!duration}
|
|
||||||
>
|
|
||||||
<SkipBack className="w-5 h-5" />
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
togglePlayPause();
|
|
||||||
}}
|
|
||||||
className="w-10 h-10 text-primary hover:bg-primary/10 rounded-full transition-colors"
|
|
||||||
disabled={!duration}
|
|
||||||
>
|
|
||||||
{isPlaying ? (
|
|
||||||
<Pause className="w-6 h-6" />
|
|
||||||
) : (
|
|
||||||
<Play className="w-6 h-6 ml-0.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
skipForward();
|
|
||||||
}}
|
|
||||||
className="w-9 h-9 text-muted-foreground hover:text-primary transition-colors"
|
|
||||||
title="Forward 10 seconds"
|
|
||||||
disabled={!duration}
|
|
||||||
>
|
|
||||||
<SkipForward className="w-5 h-5" />
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="absolute top-2 right-2 z-20">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 bg-background/50 hover:bg-background/80 rounded-full backdrop-blur-sm"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Open menu</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setPodcastToDelete({
|
|
||||||
id: podcast.id,
|
|
||||||
title: podcast.title,
|
|
||||||
});
|
|
||||||
setDeleteDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
<span>Delete Podcast</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</MotionCard>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Current Podcast Player (Fixed at bottom) */}
|
|
||||||
{currentPodcast && !isAudioLoading && audioSrc && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ y: 100, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
exit={{ y: 100, opacity: 0 }}
|
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
|
||||||
className="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur-sm border-t p-4 shadow-lg z-50"
|
|
||||||
>
|
|
||||||
<div className="container mx-auto">
|
|
||||||
<div className="flex flex-col md:flex-row items-center gap-4">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<motion.div
|
|
||||||
className="w-12 h-12 bg-primary/20 rounded-md flex items-center justify-center"
|
|
||||||
animate={{ scale: isPlaying ? [1, 1.05, 1] : 1 }}
|
|
||||||
transition={{
|
|
||||||
repeat: isPlaying ? Infinity : 0,
|
|
||||||
duration: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PodcastIcon className="h-6 w-6 text-primary" />
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-grow min-w-0">
|
|
||||||
<h4 className="font-medium text-sm line-clamp-1">{currentPodcast.title}</h4>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<div className="flex-grow relative">
|
|
||||||
<Slider
|
|
||||||
value={[currentTime]}
|
|
||||||
min={0}
|
|
||||||
max={duration || 100}
|
|
||||||
step={0.1}
|
|
||||||
onValueChange={handleSeek}
|
|
||||||
className="relative z-10"
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
className="absolute left-0 top-1/2 h-2 bg-primary/25 rounded-full -translate-y-1/2"
|
|
||||||
style={{
|
|
||||||
width: `${(currentTime / (duration || 100)) * 100}%`,
|
|
||||||
}}
|
|
||||||
transition={{ ease: "linear" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-shrink-0 text-xs text-muted-foreground whitespace-nowrap">
|
|
||||||
{formatTime(currentTime)} / {formatTime(duration)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
|
||||||
<Button variant="ghost" size="icon" onClick={skipBackward} className="h-8 w-8">
|
|
||||||
<SkipBack className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="icon"
|
|
||||||
onClick={togglePlayPause}
|
|
||||||
className="h-10 w-10 rounded-full"
|
|
||||||
>
|
|
||||||
{isPlaying ? (
|
|
||||||
<Pause className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<Play className="h-5 w-5 ml-0.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
|
||||||
<Button variant="ghost" size="icon" onClick={skipForward} className="h-8 w-8">
|
|
||||||
<SkipForward className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="hidden md:flex items-center gap-2 ml-4 w-32">
|
|
||||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={toggleMute}
|
|
||||||
className={`h-8 w-8 ${isMuted ? "text-muted-foreground" : "text-primary"}`}
|
|
||||||
>
|
|
||||||
{isMuted ? (
|
|
||||||
<VolumeX className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Volume2 className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="relative w-full">
|
|
||||||
<Slider
|
|
||||||
value={[isMuted ? 0 : volume]}
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.01}
|
|
||||||
onValueChange={handleVolumeChange}
|
|
||||||
className="w-full"
|
|
||||||
disabled={isMuted}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
className={`absolute left-0 bottom-0 h-1 bg-primary/30 rounded-full ${isMuted ? "opacity-50" : ""}`}
|
|
||||||
initial={false}
|
|
||||||
animate={{ width: `${(isMuted ? 0 : volume) * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="icon"
|
|
||||||
onClick={closePlayer}
|
|
||||||
className="h-10 w-10 rounded-full"
|
|
||||||
>
|
|
||||||
<X />
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<Trash2 className="h-5 w-5 text-destructive" />
|
|
||||||
<span>Delete Podcast</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete{" "}
|
|
||||||
<span className="font-medium">{podcastToDelete?.title}</span>? This action cannot be
|
|
||||||
undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeleteDialogOpen(false)}
|
|
||||||
disabled={isDeletingPodcast}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDeletePodcast}
|
|
||||||
disabled={isDeletingPodcast}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
{isDeletingPodcast ? (
|
|
||||||
<>
|
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
||||||
Deleting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Hidden audio element for playback */}
|
|
||||||
<audio
|
|
||||||
ref={audioRef}
|
|
||||||
src={audioSrc}
|
|
||||||
preload="auto"
|
|
||||||
onTimeUpdate={handleTimeUpdate}
|
|
||||||
onLoadedMetadata={handleMetadataLoaded}
|
|
||||||
onLoadedData={() => {
|
|
||||||
// Only auto-play when audio is fully loaded
|
|
||||||
if (audioRef.current && currentPodcast && audioSrc) {
|
|
||||||
// Small delay to ensure browser is ready to play
|
|
||||||
setTimeout(() => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current
|
|
||||||
.play()
|
|
||||||
.then(() => {
|
|
||||||
setIsPlaying(true);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Error playing audio:", error);
|
|
||||||
// Don't show error if it's just the user navigating away
|
|
||||||
if (error.name !== "AbortError") {
|
|
||||||
toast.error("Failed to play audio.");
|
|
||||||
}
|
|
||||||
setIsPlaying(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onEnded={() => setIsPlaying(false)}
|
|
||||||
onError={(e) => {
|
|
||||||
console.error("Audio error:", e);
|
|
||||||
if (audioRef.current?.error) {
|
|
||||||
// Log the specific error code for debugging
|
|
||||||
console.error("Audio error code:", audioRef.current.error.code);
|
|
||||||
|
|
||||||
// Don't show error message for aborted loads
|
|
||||||
if (audioRef.current.error.code !== audioRef.current.error.MEDIA_ERR_ABORTED) {
|
|
||||||
toast.error("Error playing audio. Please try again.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Reset playing state on error
|
|
||||||
setIsPlaying(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<track kind="captions" />
|
|
||||||
</audio>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,291 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { type CreateMessage, type Message, useChat } from "@ai-sdk/react";
|
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
|
||||||
import { createChatMutationAtom, updateChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms";
|
|
||||||
import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
|
|
||||||
import { activeChatIdAtom } from "@/atoms/chats/ui.atoms";
|
|
||||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
|
||||||
import ChatInterface from "@/components/chat/ChatInterface";
|
|
||||||
import type { Document } from "@/contracts/types/document.types";
|
|
||||||
import { useChatState } from "@/hooks/use-chat";
|
|
||||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
|
||||||
|
|
||||||
export default function ResearcherPage() {
|
|
||||||
const { search_space_id } = useParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const hasSetInitialConnectors = useRef(false);
|
|
||||||
const hasInitiatedResponse = useRef<string | null>(null);
|
|
||||||
const activeChatId = useAtomValue(activeChatIdAtom);
|
|
||||||
const { data: activeChatState, isFetching: isChatLoading } = useAtomValue(activeChatAtom);
|
|
||||||
const { mutateAsync: createChat } = useAtomValue(createChatMutationAtom);
|
|
||||||
const { mutateAsync: updateChat } = useAtomValue(updateChatMutationAtom);
|
|
||||||
const isNewChat = !activeChatId;
|
|
||||||
|
|
||||||
// Reset the flag when chat ID changes (but not hasInitiatedResponse - we need to remember if we already initiated)
|
|
||||||
useEffect(() => {
|
|
||||||
hasSetInitialConnectors.current = false;
|
|
||||||
}, [activeChatId]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
token,
|
|
||||||
researchMode,
|
|
||||||
selectedConnectors,
|
|
||||||
setSelectedConnectors,
|
|
||||||
selectedDocuments,
|
|
||||||
setSelectedDocuments,
|
|
||||||
topK,
|
|
||||||
setTopK,
|
|
||||||
} = useChatState({
|
|
||||||
search_space_id: search_space_id as string,
|
|
||||||
chat_id: activeChatId ?? undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch all available sources (document types + live search connectors)
|
|
||||||
// Use the documentTypeCountsAtom for fetching document types
|
|
||||||
const [documentTypeCountsQuery] = useAtom(documentTypeCountsAtom);
|
|
||||||
const { data: documentTypeCountsData } = documentTypeCountsQuery;
|
|
||||||
|
|
||||||
// Transform the response into the expected format
|
|
||||||
const documentTypes = useMemo(() => {
|
|
||||||
if (!documentTypeCountsData) return [];
|
|
||||||
return Object.entries(documentTypeCountsData).map(([type, count]) => ({
|
|
||||||
type,
|
|
||||||
count,
|
|
||||||
}));
|
|
||||||
}, [documentTypeCountsData]);
|
|
||||||
|
|
||||||
const { connectors: searchConnectors } = useSearchSourceConnectors(
|
|
||||||
false,
|
|
||||||
Number(search_space_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter for non-indexable connectors (live search)
|
|
||||||
const liveSearchConnectors = useMemo(
|
|
||||||
() => searchConnectors.filter((connector) => !connector.is_indexable),
|
|
||||||
[searchConnectors]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize document IDs to prevent infinite re-renders
|
|
||||||
const documentIds = useMemo(() => {
|
|
||||||
return selectedDocuments.map((doc) => doc.id);
|
|
||||||
}, [selectedDocuments]);
|
|
||||||
|
|
||||||
// Memoize connector types to prevent infinite re-renders
|
|
||||||
const connectorTypes = useMemo(() => {
|
|
||||||
return selectedConnectors;
|
|
||||||
}, [selectedConnectors]);
|
|
||||||
|
|
||||||
// Unified localStorage management for chat state
|
|
||||||
interface ChatState {
|
|
||||||
selectedDocuments: Document[];
|
|
||||||
selectedConnectors: string[];
|
|
||||||
researchMode: "QNA"; // Always QNA mode
|
|
||||||
topK: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getChatStateStorageKey = (searchSpaceId: string, chatId: string) =>
|
|
||||||
`surfsense_chat_state_${searchSpaceId}_${chatId}`;
|
|
||||||
|
|
||||||
const storeChatState = (searchSpaceId: string, chatId: string, state: ChatState) => {
|
|
||||||
const key = getChatStateStorageKey(searchSpaceId, chatId);
|
|
||||||
localStorage.setItem(key, JSON.stringify(state));
|
|
||||||
};
|
|
||||||
|
|
||||||
const restoreChatState = (searchSpaceId: string, chatId: string): ChatState | null => {
|
|
||||||
const key = getChatStateStorageKey(searchSpaceId, chatId);
|
|
||||||
const stored = localStorage.getItem(key);
|
|
||||||
if (stored) {
|
|
||||||
localStorage.removeItem(key); // Clean up after restoration
|
|
||||||
try {
|
|
||||||
return JSON.parse(stored);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error parsing stored chat state:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handler = useChat({
|
|
||||||
api: `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chat`,
|
|
||||||
streamProtocol: "data",
|
|
||||||
initialMessages: [],
|
|
||||||
headers: {
|
|
||||||
...(token && { Authorization: `Bearer ${token}` }),
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
data: {
|
|
||||||
search_space_id: search_space_id,
|
|
||||||
selected_connectors: connectorTypes,
|
|
||||||
research_mode: researchMode,
|
|
||||||
document_ids_to_add_in_context: documentIds,
|
|
||||||
top_k: topK,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("Chat error:", error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const customHandlerAppend = async (
|
|
||||||
message: Message | CreateMessage,
|
|
||||||
chatRequestOptions?: { data?: any }
|
|
||||||
) => {
|
|
||||||
// Use the first message content as the chat title (truncated to 100 chars)
|
|
||||||
const messageContent = typeof message.content === "string" ? message.content : "";
|
|
||||||
const chatTitle = messageContent.slice(0, 100) || "Untitled Chat";
|
|
||||||
|
|
||||||
const newChat = await createChat({
|
|
||||||
type: researchMode,
|
|
||||||
title: chatTitle,
|
|
||||||
initial_connectors: selectedConnectors,
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: message.content,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
search_space_id: Number(search_space_id),
|
|
||||||
});
|
|
||||||
if (newChat) {
|
|
||||||
// Store chat state before navigation
|
|
||||||
storeChatState(search_space_id as string, String(newChat.id), {
|
|
||||||
selectedDocuments,
|
|
||||||
selectedConnectors,
|
|
||||||
researchMode,
|
|
||||||
topK,
|
|
||||||
});
|
|
||||||
router.replace(`/dashboard/${search_space_id}/researcher/${newChat.id}`);
|
|
||||||
}
|
|
||||||
return String(newChat.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (token && !isNewChat && activeChatId) {
|
|
||||||
const chatData = activeChatState?.chatDetails;
|
|
||||||
if (!chatData) return;
|
|
||||||
|
|
||||||
// Update configuration from chat data
|
|
||||||
// researchMode is always "QNA", no need to set from chat data
|
|
||||||
|
|
||||||
if (chatData.initial_connectors && Array.isArray(chatData.initial_connectors)) {
|
|
||||||
setSelectedConnectors(chatData.initial_connectors);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load existing messages
|
|
||||||
if (chatData.messages && Array.isArray(chatData.messages)) {
|
|
||||||
if (chatData.messages.length === 1 && chatData.messages[0].role === "user") {
|
|
||||||
// Single user message - append to trigger LLM response
|
|
||||||
// Only if we haven't already initiated for this chat and handler doesn't have messages yet
|
|
||||||
if (hasInitiatedResponse.current !== activeChatId && handler.messages.length === 0) {
|
|
||||||
hasInitiatedResponse.current = activeChatId;
|
|
||||||
handler.append({
|
|
||||||
role: "user",
|
|
||||||
content: chatData.messages[0].content,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (chatData.messages.length > 1) {
|
|
||||||
// Multiple messages - set them all
|
|
||||||
handler.setMessages(chatData.messages);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [token, isNewChat, activeChatId, isChatLoading]);
|
|
||||||
|
|
||||||
// Restore chat state from localStorage on page load
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeChatId && search_space_id) {
|
|
||||||
const restoredState = restoreChatState(search_space_id as string, activeChatId);
|
|
||||||
if (restoredState) {
|
|
||||||
setSelectedDocuments(restoredState.selectedDocuments);
|
|
||||||
setSelectedConnectors(restoredState.selectedConnectors);
|
|
||||||
setTopK(restoredState.topK);
|
|
||||||
// researchMode is always "QNA", no need to restore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
activeChatId,
|
|
||||||
isChatLoading,
|
|
||||||
search_space_id,
|
|
||||||
setSelectedDocuments,
|
|
||||||
setSelectedConnectors,
|
|
||||||
setTopK,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Set all sources as default for new chats (only once on initial mount)
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
isNewChat &&
|
|
||||||
!hasSetInitialConnectors.current &&
|
|
||||||
selectedConnectors.length === 0 &&
|
|
||||||
documentTypes.length > 0
|
|
||||||
) {
|
|
||||||
// Combine all document types and live search connectors
|
|
||||||
const allSourceTypes = [
|
|
||||||
...documentTypes.map((dt) => dt.type),
|
|
||||||
...liveSearchConnectors.map((c) => c.connector_type),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (allSourceTypes.length > 0) {
|
|
||||||
setSelectedConnectors(allSourceTypes);
|
|
||||||
hasSetInitialConnectors.current = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isNewChat,
|
|
||||||
documentTypes,
|
|
||||||
liveSearchConnectors,
|
|
||||||
selectedConnectors.length,
|
|
||||||
setSelectedConnectors,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Auto-update chat when messages change (only for existing chats)
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!isNewChat &&
|
|
||||||
activeChatId &&
|
|
||||||
handler.status === "ready" &&
|
|
||||||
handler.messages.length > 0 &&
|
|
||||||
handler.messages[handler.messages.length - 1]?.role === "assistant"
|
|
||||||
) {
|
|
||||||
const userMessages = handler.messages.filter((msg) => msg.role === "user");
|
|
||||||
if (userMessages.length === 0) return;
|
|
||||||
const title = userMessages[0].content;
|
|
||||||
|
|
||||||
updateChat({
|
|
||||||
type: researchMode,
|
|
||||||
title: title,
|
|
||||||
initial_connectors: selectedConnectors,
|
|
||||||
messages: handler.messages,
|
|
||||||
search_space_id: Number(search_space_id),
|
|
||||||
id: Number(activeChatId),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [handler.messages, handler.status, activeChatId, isNewChat, isChatLoading]);
|
|
||||||
|
|
||||||
if (isChatLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div>Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChatInterface
|
|
||||||
handler={{
|
|
||||||
...handler,
|
|
||||||
append: isNewChat ? customHandlerAppend : handler.append,
|
|
||||||
}}
|
|
||||||
onDocumentSelectionChange={setSelectedDocuments}
|
|
||||||
selectedDocuments={selectedDocuments}
|
|
||||||
onConnectorSelectionChange={setSelectedConnectors}
|
|
||||||
selectedConnectors={selectedConnectors}
|
|
||||||
topK={topK}
|
|
||||||
onTopKChange={setTopK}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -244,7 +244,7 @@ const DashboardPage = () => {
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col h-full justify-between overflow-hidden rounded-xl border bg-muted/30 backdrop-blur-sm transition-all hover:border-primary/50">
|
<div className="flex flex-col h-full justify-between overflow-hidden rounded-xl border bg-muted/30 backdrop-blur-sm transition-all hover:border-primary/50">
|
||||||
<div className="relative h-32 w-full overflow-hidden">
|
<div className="relative h-32 w-full overflow-hidden">
|
||||||
<Link href={`/dashboard/${space.id}/researcher`} key={space.id}>
|
<Link href={`/dashboard/${space.id}/new-chat`} key={space.id}>
|
||||||
<Image
|
<Image
|
||||||
src="https://images.unsplash.com/photo-1519389950473-47ba0277781c?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1740&q=80"
|
src="https://images.unsplash.com/photo-1519389950473-47ba0277781c?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1740&q=80"
|
||||||
alt={space.name}
|
alt={space.name}
|
||||||
|
|
@ -289,7 +289,7 @@ const DashboardPage = () => {
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
className="flex flex-1 flex-col p-4 cursor-pointer"
|
className="flex flex-1 flex-col p-4 cursor-pointer"
|
||||||
href={`/dashboard/${space.id}/researcher`}
|
href={`/dashboard/${space.id}/new-chat`}
|
||||||
key={space.id}
|
key={space.id}
|
||||||
>
|
>
|
||||||
<div className="flex flex-1 flex-col justify-between p-1">
|
<div className="flex flex-1 flex-col justify-between p-1">
|
||||||
|
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
import { atomWithMutation } from "jotai-tanstack-query";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import type {
|
|
||||||
ChatSummary,
|
|
||||||
CreateChatRequest,
|
|
||||||
DeleteChatRequest,
|
|
||||||
UpdateChatRequest,
|
|
||||||
} from "@/contracts/types/chat.types";
|
|
||||||
import { chatsApiService } from "@/lib/apis/chats-api.service";
|
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|
||||||
import { queryClient } from "@/lib/query-client/client";
|
|
||||||
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
|
|
||||||
import { globalChatsQueryParamsAtom } from "./ui.atoms";
|
|
||||||
|
|
||||||
export const deleteChatMutationAtom = atomWithMutation((get) => {
|
|
||||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
|
||||||
const authToken = getBearerToken();
|
|
||||||
const chatsQueryParams = get(globalChatsQueryParamsAtom);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mutationKey: cacheKeys.chats.globalQueryParams(chatsQueryParams),
|
|
||||||
enabled: !!searchSpaceId && !!authToken,
|
|
||||||
mutationFn: async (request: DeleteChatRequest) => {
|
|
||||||
return chatsApiService.deleteChat(request);
|
|
||||||
},
|
|
||||||
|
|
||||||
onSuccess: (_, request: DeleteChatRequest) => {
|
|
||||||
toast.success("Chat deleted successfully");
|
|
||||||
// Optimistically update the current query
|
|
||||||
queryClient.setQueryData(
|
|
||||||
cacheKeys.chats.globalQueryParams(chatsQueryParams),
|
|
||||||
(oldData: ChatSummary[]) => {
|
|
||||||
return oldData?.filter((chat) => chat.id !== request.id) ?? [];
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// Invalidate all chat queries to ensure consistency across components
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["chats"],
|
|
||||||
});
|
|
||||||
// Also invalidate the "all-chats" query used by AllChatsSidebar
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["all-chats"],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createChatMutationAtom = atomWithMutation((get) => {
|
|
||||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
|
||||||
const authToken = getBearerToken();
|
|
||||||
const chatsQueryParams = get(globalChatsQueryParamsAtom);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mutationKey: cacheKeys.chats.globalQueryParams(chatsQueryParams),
|
|
||||||
enabled: !!searchSpaceId && !!authToken,
|
|
||||||
mutationFn: async (request: CreateChatRequest) => {
|
|
||||||
return chatsApiService.createChat(request);
|
|
||||||
},
|
|
||||||
|
|
||||||
onSuccess: () => {
|
|
||||||
// Invalidate ALL chat queries to ensure sidebar and other components refresh
|
|
||||||
// Using a partial key match to avoid stale closure issues with specific query params
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["chats"],
|
|
||||||
});
|
|
||||||
// Also invalidate the "all-chats" query used by AllChatsSidebar
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["all-chats"],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateChatMutationAtom = atomWithMutation((get) => {
|
|
||||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
|
||||||
const authToken = getBearerToken();
|
|
||||||
const chatsQueryParams = get(globalChatsQueryParamsAtom);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mutationKey: cacheKeys.chats.globalQueryParams(chatsQueryParams),
|
|
||||||
enabled: !!searchSpaceId && !!authToken,
|
|
||||||
mutationFn: async (request: UpdateChatRequest) => {
|
|
||||||
return chatsApiService.updateChat(request);
|
|
||||||
},
|
|
||||||
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: cacheKeys.chats.globalQueryParams(chatsQueryParams),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import { atomWithQuery } from "jotai-tanstack-query";
|
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
|
||||||
import { chatsApiService } from "@/lib/apis/chats-api.service";
|
|
||||||
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
|
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|
||||||
import { activeChatIdAtom, globalChatsQueryParamsAtom } from "./ui.atoms";
|
|
||||||
|
|
||||||
export const activeChatAtom = atomWithQuery((get) => {
|
|
||||||
const activeChatId = get(activeChatIdAtom);
|
|
||||||
const authToken = getBearerToken();
|
|
||||||
|
|
||||||
return {
|
|
||||||
queryKey: cacheKeys.chats.activeChat(activeChatId ?? ""),
|
|
||||||
enabled: !!activeChatId && !!authToken,
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!authToken) {
|
|
||||||
throw new Error("No authentication token found");
|
|
||||||
}
|
|
||||||
if (!activeChatId) {
|
|
||||||
throw new Error("No active chat id found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const [podcast, chatDetails] = await Promise.all([
|
|
||||||
podcastsApiService.getPodcastByChatId({ chat_id: Number(activeChatId) }),
|
|
||||||
chatsApiService.getChatDetails({ id: Number(activeChatId) }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { chatId: activeChatId, chatDetails, podcast };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const chatsAtom = atomWithQuery((get) => {
|
|
||||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
|
||||||
const authToken = getBearerToken();
|
|
||||||
const queryParams = get(globalChatsQueryParamsAtom);
|
|
||||||
|
|
||||||
return {
|
|
||||||
queryKey: cacheKeys.chats.globalQueryParams(queryParams),
|
|
||||||
enabled: !!searchSpaceId && !!authToken,
|
|
||||||
queryFn: async () => {
|
|
||||||
return chatsApiService.getChats({
|
|
||||||
queryParams: queryParams,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { atom } from "jotai";
|
|
||||||
import type { GetChatsRequest } from "@/contracts/types/chat.types";
|
|
||||||
|
|
||||||
type ActiveChathatUIState = {
|
|
||||||
isChatPannelOpen: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const activeChathatUIAtom = atom<ActiveChathatUIState>({
|
|
||||||
isChatPannelOpen: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const activeChatIdAtom = atom<string | null>(null);
|
|
||||||
|
|
||||||
export const globalChatsQueryParamsAtom = atom<GetChatsRequest["queryParams"]>({
|
|
||||||
limit: 5,
|
|
||||||
skip: 0,
|
|
||||||
});
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { atomWithMutation } from "jotai-tanstack-query";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
|
||||||
import type {
|
|
||||||
DeletePodcastRequest,
|
|
||||||
GeneratePodcastRequest,
|
|
||||||
Podcast,
|
|
||||||
} from "@/contracts/types/podcast.types";
|
|
||||||
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
|
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|
||||||
import { queryClient } from "@/lib/query-client/client";
|
|
||||||
import { globalPodcastsQueryParamsAtom } from "./ui.atoms";
|
|
||||||
|
|
||||||
export const deletePodcastMutationAtom = atomWithMutation((get) => {
|
|
||||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
|
||||||
const authToken = getBearerToken();
|
|
||||||
const podcastsQueryParams = get(globalPodcastsQueryParamsAtom);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mutationKey: cacheKeys.podcasts.globalQueryParams(podcastsQueryParams),
|
|
||||||
enabled: !!searchSpaceId && !!authToken,
|
|
||||||
mutationFn: async (request: DeletePodcastRequest) => {
|
|
||||||
return podcastsApiService.deletePodcast(request);
|
|
||||||
},
|
|
||||||
|
|
||||||
onSuccess: (_, request: DeletePodcastRequest) => {
|
|
||||||
toast.success("Podcast deleted successfully");
|
|
||||||
queryClient.setQueryData(
|
|
||||||
cacheKeys.podcasts.globalQueryParams(podcastsQueryParams),
|
|
||||||
(oldData: Podcast[]) => {
|
|
||||||
return oldData.filter((podcast) => podcast.id !== request.id);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const generatePodcastMutationAtom = atomWithMutation((get) => {
|
|
||||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
|
||||||
const authToken = getBearerToken();
|
|
||||||
const podcastsQueryParams = get(globalPodcastsQueryParamsAtom);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mutationKey: cacheKeys.podcasts.globalQueryParams(podcastsQueryParams),
|
|
||||||
enabled: !!searchSpaceId && !!authToken,
|
|
||||||
mutationFn: async (request: GeneratePodcastRequest) => {
|
|
||||||
return podcastsApiService.generatePodcast(request);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { atomWithQuery } from "jotai-tanstack-query";
|
|
||||||
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
|
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|
||||||
import { globalPodcastsQueryParamsAtom } from "./ui.atoms";
|
|
||||||
|
|
||||||
export const podcastsAtom = atomWithQuery((get) => {
|
|
||||||
const queryParams = get(globalPodcastsQueryParamsAtom);
|
|
||||||
|
|
||||||
return {
|
|
||||||
queryKey: cacheKeys.podcasts.globalQueryParams(queryParams),
|
|
||||||
queryFn: async () => {
|
|
||||||
return podcastsApiService.getPodcasts({
|
|
||||||
queryParams: queryParams,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { atom } from "jotai";
|
|
||||||
import type { GetPodcastsRequest } from "@/contracts/types/podcast.types";
|
|
||||||
|
|
||||||
export const globalPodcastsQueryParamsAtom = atom<GetPodcastsRequest["queryParams"]>({
|
|
||||||
limit: 5,
|
|
||||||
skip: 0,
|
|
||||||
});
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
useAssistantApi,
|
useAssistantApi,
|
||||||
useAssistantState,
|
useAssistantState,
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
import { FileText, PlusIcon, XIcon } from "lucide-react";
|
import { FileText, Loader2, PlusIcon, XIcon } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { type FC, type PropsWithChildren, useEffect, useState } from "react";
|
import { type FC, type PropsWithChildren, useEffect, useState } from "react";
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
|
|
@ -40,11 +40,15 @@ const useFileSrc = (file: File | undefined) => {
|
||||||
const useAttachmentSrc = () => {
|
const useAttachmentSrc = () => {
|
||||||
const { file, src } = useAssistantState(
|
const { file, src } = useAssistantState(
|
||||||
useShallow(({ attachment }): { file?: File; src?: string } => {
|
useShallow(({ attachment }): { file?: File; src?: string } => {
|
||||||
if (attachment.type !== "image") return {};
|
if (!attachment || attachment.type !== "image") return {};
|
||||||
if (attachment.file) return { file: attachment.file };
|
if (attachment.file) return { file: attachment.file };
|
||||||
const src = attachment.content?.filter((c) => c.type === "image")[0]?.image;
|
// Only try to filter if content is an array (standard assistant-ui format)
|
||||||
if (!src) return {};
|
// Our custom ChatAttachment has content as a string, so skip this
|
||||||
return { src };
|
if (Array.isArray(attachment.content)) {
|
||||||
|
const src = attachment.content.filter((c) => c.type === "image")[0]?.image;
|
||||||
|
if (src) return { src };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -98,9 +102,27 @@ const AttachmentPreviewDialog: FC<PropsWithChildren> = ({ children }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const AttachmentThumb: FC = () => {
|
const AttachmentThumb: FC = () => {
|
||||||
const isImage = useAssistantState(({ attachment }) => attachment.type === "image");
|
const isImage = useAssistantState(({ attachment }) => attachment?.type === "image");
|
||||||
|
// Check if actively processing (running AND progress < 100)
|
||||||
|
// When progress is 100, processing is done but waiting for send()
|
||||||
|
const isProcessing = useAssistantState(({ attachment }) => {
|
||||||
|
const status = attachment?.status;
|
||||||
|
if (status?.type !== "running") return false;
|
||||||
|
// If progress is defined and equals 100, processing is complete
|
||||||
|
const progress = (status as { type: "running"; progress?: number }).progress;
|
||||||
|
return progress === undefined || progress < 100;
|
||||||
|
});
|
||||||
const src = useAttachmentSrc();
|
const src = useAttachmentSrc();
|
||||||
|
|
||||||
|
// Show loading spinner only when actively processing (not when done and waiting for send)
|
||||||
|
if (isProcessing) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar className="aui-attachment-tile-avatar h-full w-full rounded-none">
|
<Avatar className="aui-attachment-tile-avatar h-full w-full rounded-none">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
|
|
@ -119,9 +141,17 @@ const AttachmentUI: FC = () => {
|
||||||
const api = useAssistantApi();
|
const api = useAssistantApi();
|
||||||
const isComposer = api.attachment.source === "composer";
|
const isComposer = api.attachment.source === "composer";
|
||||||
|
|
||||||
const isImage = useAssistantState(({ attachment }) => attachment.type === "image");
|
const isImage = useAssistantState(({ attachment }) => attachment?.type === "image");
|
||||||
|
// Check if actively processing (running AND progress < 100)
|
||||||
|
// When progress is 100, processing is done but waiting for send()
|
||||||
|
const isProcessing = useAssistantState(({ attachment }) => {
|
||||||
|
const status = attachment?.status;
|
||||||
|
if (status?.type !== "running") return false;
|
||||||
|
const progress = (status as { type: "running"; progress?: number }).progress;
|
||||||
|
return progress === undefined || progress < 100;
|
||||||
|
});
|
||||||
const typeLabel = useAssistantState(({ attachment }) => {
|
const typeLabel = useAssistantState(({ attachment }) => {
|
||||||
const type = attachment.type;
|
const type = attachment?.type;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "image":
|
case "image":
|
||||||
return "Image";
|
return "Image";
|
||||||
|
|
@ -129,10 +159,8 @@ const AttachmentUI: FC = () => {
|
||||||
return "Document";
|
return "Document";
|
||||||
case "file":
|
case "file":
|
||||||
return "File";
|
return "File";
|
||||||
default: {
|
default:
|
||||||
const _exhaustiveCheck: never = type;
|
return "File"; // Default fallback for unknown types
|
||||||
throw new Error(`Unknown attachment type: ${_exhaustiveCheck}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -149,20 +177,28 @@ const AttachmentUI: FC = () => {
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"aui-attachment-tile size-14 cursor-pointer overflow-hidden rounded-[14px] border bg-muted transition-opacity hover:opacity-75",
|
"aui-attachment-tile size-14 cursor-pointer overflow-hidden rounded-[14px] border bg-muted transition-opacity hover:opacity-75",
|
||||||
isComposer && "aui-attachment-tile-composer border-foreground/20"
|
isComposer && "aui-attachment-tile-composer border-foreground/20",
|
||||||
|
isProcessing && "animate-pulse"
|
||||||
)}
|
)}
|
||||||
role="button"
|
role="button"
|
||||||
id="attachment-tile"
|
id="attachment-tile"
|
||||||
aria-label={`${typeLabel} attachment`}
|
aria-label={isProcessing ? "Processing attachment..." : `${typeLabel} attachment`}
|
||||||
>
|
>
|
||||||
<AttachmentThumb />
|
<AttachmentThumb />
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</AttachmentPreviewDialog>
|
</AttachmentPreviewDialog>
|
||||||
{isComposer && <AttachmentRemove />}
|
{isComposer && !isProcessing && <AttachmentRemove />}
|
||||||
</AttachmentPrimitive.Root>
|
</AttachmentPrimitive.Root>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
<AttachmentPrimitive.Name />
|
{isProcessing ? (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<AttachmentPrimitive.Name />
|
||||||
|
)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
ErrorPrimitive,
|
ErrorPrimitive,
|
||||||
MessagePrimitive,
|
MessagePrimitive,
|
||||||
ThreadPrimitive,
|
ThreadPrimitive,
|
||||||
|
useAssistantState,
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
import {
|
import {
|
||||||
ArrowDownIcon,
|
ArrowDownIcon,
|
||||||
|
|
@ -15,6 +16,7 @@ import {
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
|
Loader2,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
SquareIcon,
|
SquareIcon,
|
||||||
|
|
@ -157,20 +159,43 @@ const Composer: FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const ComposerAction: FC = () => {
|
const ComposerAction: FC = () => {
|
||||||
|
// Check if any attachments are still being processed (running AND progress < 100)
|
||||||
|
// When progress is 100, processing is done but waiting for send()
|
||||||
|
const hasProcessingAttachments = useAssistantState(({ composer }) =>
|
||||||
|
composer.attachments?.some((att) => {
|
||||||
|
const status = att.status;
|
||||||
|
if (status?.type !== "running") return false;
|
||||||
|
const progress = (status as { type: "running"; progress?: number }).progress;
|
||||||
|
return progress === undefined || progress < 100;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||||
<ComposerAddAttachment />
|
<ComposerAddAttachment />
|
||||||
|
|
||||||
|
{/* Show processing indicator when attachments are being processed */}
|
||||||
|
{hasProcessingAttachments && (
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
<span>Processing...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
||||||
<ComposerPrimitive.Send asChild>
|
<ComposerPrimitive.Send asChild disabled={hasProcessingAttachments}>
|
||||||
<TooltipIconButton
|
<TooltipIconButton
|
||||||
tooltip="Send message"
|
tooltip={hasProcessingAttachments ? "Wait for attachments to process" : "Send message"}
|
||||||
side="bottom"
|
side="bottom"
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="default"
|
variant="default"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="aui-composer-send size-8 rounded-full"
|
className={cn(
|
||||||
|
"aui-composer-send size-8 rounded-full",
|
||||||
|
hasProcessingAttachments && "cursor-not-allowed opacity-50"
|
||||||
|
)}
|
||||||
aria-label="Send message"
|
aria-label="Send message"
|
||||||
|
disabled={hasProcessingAttachments}
|
||||||
>
|
>
|
||||||
<ArrowUpIcon className="aui-composer-send-icon size-4" />
|
<ArrowUpIcon className="aui-composer-send-icon size-4" />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
|
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useInView } from "motion/react";
|
|
||||||
import { Manrope } from "next/font/google";
|
|
||||||
import { useEffect, useMemo, useReducer, useRef } from "react";
|
|
||||||
import { RoughNotation, RoughNotationGroup } from "react-rough-notation";
|
|
||||||
import { useSidebar } from "@/components/ui/sidebar";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
// Font configuration - could be moved to a global font config file
|
|
||||||
const manrope = Manrope({
|
|
||||||
subsets: ["latin"],
|
|
||||||
weight: ["400", "700"],
|
|
||||||
display: "swap", // Optimize font loading
|
|
||||||
variable: "--font-manrope",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Constants for timing - makes it easier to adjust and more maintainable
|
|
||||||
const TIMING = {
|
|
||||||
SIDEBAR_TRANSITION: 300, // Wait for sidebar transition + buffer
|
|
||||||
LAYOUT_SETTLE: 100, // Small delay to ensure layout is fully settled
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Animation configuration
|
|
||||||
const ANIMATION_CONFIG = {
|
|
||||||
HIGHLIGHT: {
|
|
||||||
type: "highlight" as const,
|
|
||||||
animationDuration: 2000,
|
|
||||||
iterations: 3,
|
|
||||||
color: "#3b82f680",
|
|
||||||
multiline: true,
|
|
||||||
},
|
|
||||||
UNDERLINE: {
|
|
||||||
type: "underline" as const,
|
|
||||||
animationDuration: 2000,
|
|
||||||
iterations: 3,
|
|
||||||
color: "#10b981",
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// State management with useReducer for better organization
|
|
||||||
interface HighlightState {
|
|
||||||
shouldShowHighlight: boolean;
|
|
||||||
layoutStable: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type HighlightAction =
|
|
||||||
| { type: "SIDEBAR_CHANGED" }
|
|
||||||
| { type: "LAYOUT_STABILIZED" }
|
|
||||||
| { type: "SHOW_HIGHLIGHT" }
|
|
||||||
| { type: "HIDE_HIGHLIGHT" };
|
|
||||||
|
|
||||||
const highlightReducer = (state: HighlightState, action: HighlightAction): HighlightState => {
|
|
||||||
switch (action.type) {
|
|
||||||
case "SIDEBAR_CHANGED":
|
|
||||||
return {
|
|
||||||
shouldShowHighlight: false,
|
|
||||||
layoutStable: false,
|
|
||||||
};
|
|
||||||
case "LAYOUT_STABILIZED":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
layoutStable: true,
|
|
||||||
};
|
|
||||||
case "SHOW_HIGHLIGHT":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
shouldShowHighlight: true,
|
|
||||||
};
|
|
||||||
case "HIDE_HIGHLIGHT":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
shouldShowHighlight: false,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialState: HighlightState = {
|
|
||||||
shouldShowHighlight: false,
|
|
||||||
layoutStable: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AnimatedEmptyState() {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const isInView = useInView(ref);
|
|
||||||
const [{ shouldShowHighlight, layoutStable }, dispatch] = useReducer(
|
|
||||||
highlightReducer,
|
|
||||||
initialState
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize class names to prevent unnecessary recalculations
|
|
||||||
const headingClassName = useMemo(
|
|
||||||
() =>
|
|
||||||
cn(
|
|
||||||
"text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-neutral-900 dark:text-neutral-50 mb-6",
|
|
||||||
manrope.className
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const paragraphClassName = useMemo(
|
|
||||||
() => "text-lg sm:text-xl text-neutral-600 dark:text-neutral-300 mb-8 max-w-2xl mx-auto",
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle sidebar state changes
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch({ type: "SIDEBAR_CHANGED" });
|
|
||||||
|
|
||||||
const stabilizeTimer = setTimeout(() => {
|
|
||||||
dispatch({ type: "LAYOUT_STABILIZED" });
|
|
||||||
}, TIMING.SIDEBAR_TRANSITION);
|
|
||||||
|
|
||||||
return () => clearTimeout(stabilizeTimer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle highlight visibility based on layout stability and viewport visibility
|
|
||||||
useEffect(() => {
|
|
||||||
if (!layoutStable || !isInView) {
|
|
||||||
dispatch({ type: "HIDE_HIGHLIGHT" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const showTimer = setTimeout(() => {
|
|
||||||
dispatch({ type: "SHOW_HIGHLIGHT" });
|
|
||||||
}, TIMING.LAYOUT_SETTLE);
|
|
||||||
|
|
||||||
return () => clearTimeout(showTimer);
|
|
||||||
}, [layoutStable, isInView]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="flex-1 flex items-center justify-center w-full min-h-fit">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 py-10 text-center">
|
|
||||||
<RoughNotationGroup show={shouldShowHighlight}>
|
|
||||||
<h1 className={headingClassName}>
|
|
||||||
<RoughNotation {...ANIMATION_CONFIG.HIGHLIGHT}>
|
|
||||||
<span>SurfSense</span>
|
|
||||||
</RoughNotation>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className={paragraphClassName}>
|
|
||||||
<RoughNotation {...ANIMATION_CONFIG.UNDERLINE}>Let's Start Surfing</RoughNotation>{" "}
|
|
||||||
through your knowledge base.
|
|
||||||
</p>
|
|
||||||
</RoughNotationGroup>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import type React from "react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { SheetTrigger } from "@/components/ui/sheet";
|
|
||||||
import { SourceDetailSheet } from "./SourceDetailSheet";
|
|
||||||
|
|
||||||
export const CitationDisplay: React.FC<{ index: number; node: any }> = ({ index, node }) => {
|
|
||||||
const chunkId = Number(node?.id);
|
|
||||||
const sourceType = node?.metadata?.source_type;
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SourceDetailSheet
|
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={setIsOpen}
|
|
||||||
chunkId={chunkId}
|
|
||||||
sourceType={sourceType}
|
|
||||||
title={node?.metadata?.title || node?.metadata?.group_name || "Source"}
|
|
||||||
description={node?.text}
|
|
||||||
url={node?.url}
|
|
||||||
>
|
|
||||||
<SheetTrigger asChild>
|
|
||||||
<span className="text-[10px] font-bold bg-slate-500 hover:bg-slate-600 text-white rounded-full w-4 h-4 inline-flex items-center justify-center align-super cursor-pointer transition-colors">
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
</SheetTrigger>
|
|
||||||
</SourceDetailSheet>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { getAnnotationData, type Message, useChatUI } from "@llamaindex/chat-ui";
|
|
||||||
import { SuggestedQuestions } from "@llamaindex/chat-ui/widgets";
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from "@/components/ui/accordion";
|
|
||||||
|
|
||||||
export const ChatFurtherQuestions: React.FC<{ message: Message }> = ({ message }) => {
|
|
||||||
const annotations: string[][] = getAnnotationData(message, "FURTHER_QUESTIONS");
|
|
||||||
const { append, requestData } = useChatUI();
|
|
||||||
|
|
||||||
if (annotations.length !== 1 || annotations[0].length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Accordion type="single" collapsible className="w-full border rounded-md bg-card shadow-sm">
|
|
||||||
<AccordionItem value="suggested-questions" className="border-0">
|
|
||||||
<AccordionTrigger className="px-4 py-3 text-sm font-medium text-foreground transition-colors">
|
|
||||||
Further Suggested Questions
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="px-4 pb-4 pt-0">
|
|
||||||
<SuggestedQuestions
|
|
||||||
questions={annotations[0]}
|
|
||||||
append={append}
|
|
||||||
requestData={requestData}
|
|
||||||
/>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,851 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { ChatInput } from "@llamaindex/chat-ui";
|
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
|
||||||
import { Brain, Check, FolderOpen, Minus, Plus, PlusCircle, Zap } from "lucide-react";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
|
||||||
import React, { Suspense, useCallback, useMemo, useState } from "react";
|
|
||||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
|
||||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/llm-config/llm-config-mutation.atoms";
|
|
||||||
import {
|
|
||||||
globalLLMConfigsAtom,
|
|
||||||
llmConfigsAtom,
|
|
||||||
llmPreferencesAtom,
|
|
||||||
} from "@/atoms/llm-config/llm-config-query.atoms";
|
|
||||||
import { DocumentsDataTable } from "@/components/chat/DocumentsDataTable";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
|
||||||
import type { Document } from "@/contracts/types/document.types";
|
|
||||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
|
||||||
|
|
||||||
const DocumentSelector = React.memo(
|
|
||||||
({
|
|
||||||
onSelectionChange,
|
|
||||||
selectedDocuments = [],
|
|
||||||
}: {
|
|
||||||
onSelectionChange?: (documents: Document[]) => void;
|
|
||||||
selectedDocuments?: Document[];
|
|
||||||
}) => {
|
|
||||||
const { search_space_id } = useParams();
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleOpenChange = useCallback((open: boolean) => {
|
|
||||||
setIsOpen(open);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelectionChange = useCallback(
|
|
||||||
(documents: Document[]) => {
|
|
||||||
onSelectionChange?.(documents);
|
|
||||||
},
|
|
||||||
[onSelectionChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDone = useCallback(() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectedCount = React.useMemo(() => selectedDocuments.length, [selectedDocuments.length]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 gap-2 px-3 border-dashed hover:border-solid hover:bg-accent/50 transition-all"
|
|
||||||
>
|
|
||||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="text-xs font-medium">
|
|
||||||
{selectedCount > 0 ? `Selected` : "Documents"}
|
|
||||||
</span>
|
|
||||||
{selectedCount > 0 && (
|
|
||||||
<Badge variant="secondary" className="h-5 px-1.5 text-xs font-medium">
|
|
||||||
{selectedCount}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="max-w-[95vw] md:max-w-5xl h-[90vh] md:h-[85vh] p-0 flex flex-col">
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="px-4 md:px-6 py-4 border-b flex-shrink-0 bg-muted/30">
|
|
||||||
<DialogTitle className="text-lg md:text-xl font-semibold">
|
|
||||||
Select Documents
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="mt-1.5 text-sm">
|
|
||||||
Choose specific documents to include in your research context
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 p-4 md:p-6">
|
|
||||||
<DocumentsDataTable
|
|
||||||
searchSpaceId={Number(search_space_id)}
|
|
||||||
onSelectionChange={handleSelectionChange}
|
|
||||||
onDone={handleDone}
|
|
||||||
initialSelectedDocuments={selectedDocuments}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
DocumentSelector.displayName = "DocumentSelector";
|
|
||||||
|
|
||||||
const ConnectorSelector = React.memo(
|
|
||||||
({
|
|
||||||
onSelectionChange,
|
|
||||||
selectedConnectors = [],
|
|
||||||
}: {
|
|
||||||
onSelectionChange?: (connectorTypes: string[]) => void;
|
|
||||||
selectedConnectors?: string[];
|
|
||||||
}) => {
|
|
||||||
const { search_space_id } = useParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
// Use the documentTypeCountsAtom for fetching document types
|
|
||||||
const [documentTypeCountsQuery] = useAtom(documentTypeCountsAtom);
|
|
||||||
const {
|
|
||||||
data: documentTypeCountsData,
|
|
||||||
isLoading,
|
|
||||||
refetch: fetchDocumentTypes,
|
|
||||||
} = documentTypeCountsQuery;
|
|
||||||
|
|
||||||
// Transform the response into the expected format
|
|
||||||
const documentTypes = useMemo(() => {
|
|
||||||
if (!documentTypeCountsData) return [];
|
|
||||||
return Object.entries(documentTypeCountsData).map(([type, count]) => ({
|
|
||||||
type,
|
|
||||||
count,
|
|
||||||
}));
|
|
||||||
}, [documentTypeCountsData]);
|
|
||||||
|
|
||||||
const isLoaded = !!documentTypeCountsData;
|
|
||||||
|
|
||||||
// Fetch live search connectors immediately (non-indexable)
|
|
||||||
const {
|
|
||||||
connectors: searchConnectors,
|
|
||||||
isLoading: connectorsLoading,
|
|
||||||
isLoaded: connectorsLoaded,
|
|
||||||
fetchConnectors,
|
|
||||||
} = useSearchSourceConnectors(false, Number(search_space_id));
|
|
||||||
|
|
||||||
// Filter for non-indexable connectors (live search)
|
|
||||||
const liveSearchConnectors = React.useMemo(
|
|
||||||
() => searchConnectors.filter((connector) => !connector.is_indexable),
|
|
||||||
[searchConnectors]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleOpenChange = useCallback((open: boolean) => {
|
|
||||||
setIsOpen(open);
|
|
||||||
// Data is already loaded on mount, no need to fetch again
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleConnectorToggle = useCallback(
|
|
||||||
(connectorType: string) => {
|
|
||||||
const isSelected = selectedConnectors.includes(connectorType);
|
|
||||||
const newSelection = isSelected
|
|
||||||
? selectedConnectors.filter((type) => type !== connectorType)
|
|
||||||
: [...selectedConnectors, connectorType];
|
|
||||||
onSelectionChange?.(newSelection);
|
|
||||||
},
|
|
||||||
[selectedConnectors, onSelectionChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelectAll = useCallback(() => {
|
|
||||||
const allTypes = [
|
|
||||||
...documentTypes.map((dt) => dt.type),
|
|
||||||
...liveSearchConnectors.map((c) => c.connector_type),
|
|
||||||
];
|
|
||||||
onSelectionChange?.(allTypes);
|
|
||||||
}, [documentTypes, liveSearchConnectors, onSelectionChange]);
|
|
||||||
|
|
||||||
const handleClearAll = useCallback(() => {
|
|
||||||
onSelectionChange?.([]);
|
|
||||||
}, [onSelectionChange]);
|
|
||||||
|
|
||||||
// Get display name for connector type
|
|
||||||
const getDisplayName = (type: string) => {
|
|
||||||
return type
|
|
||||||
.split("_")
|
|
||||||
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
|
|
||||||
.join(" ");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get selected document types with their counts
|
|
||||||
const selectedDocTypes = documentTypes.filter((dt) => selectedConnectors.includes(dt.type));
|
|
||||||
const selectedLiveConnectors = liveSearchConnectors.filter((c) =>
|
|
||||||
selectedConnectors.includes(c.connector_type)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Total selected count
|
|
||||||
const totalSelectedCount = selectedDocTypes.length + selectedLiveConnectors.length;
|
|
||||||
const totalAvailableCount = documentTypes.length + liveSearchConnectors.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="relative h-9 gap-2 px-3 border-dashed hover:border-solid hover:bg-accent/50 transition-all"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{totalSelectedCount > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center -space-x-2">
|
|
||||||
{selectedDocTypes.slice(0, 2).map((docType) => (
|
|
||||||
<div
|
|
||||||
key={docType.type}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full border-2 border-background bg-muted"
|
|
||||||
>
|
|
||||||
{getConnectorIcon(docType.type, "h-3 w-3")}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{selectedLiveConnectors
|
|
||||||
.slice(0, 3 - selectedDocTypes.slice(0, 2).length)
|
|
||||||
.map((connector) => (
|
|
||||||
<div
|
|
||||||
key={connector.id}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full border-2 border-background bg-muted"
|
|
||||||
>
|
|
||||||
{getConnectorIcon(connector.connector_type, "h-3 w-3")}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-medium">
|
|
||||||
{totalSelectedCount} {totalSelectedCount === 1 ? "source" : "sources"}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Brain className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="text-xs font-medium">Sources</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-2xl max-h-[85vh] flex flex-col">
|
|
||||||
<div className="space-y-4 flex-1 overflow-y-auto pr-2">
|
|
||||||
<div>
|
|
||||||
<DialogTitle className="text-xl">Select Sources</DialogTitle>
|
|
||||||
<DialogDescription className="mt-1.5">
|
|
||||||
Choose indexed document types and live search connectors to include in your search
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading || connectorsLoading ? (
|
|
||||||
<div className="flex justify-center py-8">
|
|
||||||
<div className="animate-spin h-8 w-8 border-3 border-primary border-t-transparent rounded-full" />
|
|
||||||
</div>
|
|
||||||
) : totalAvailableCount === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<div className="rounded-full bg-muted p-4 mb-4">
|
|
||||||
<Brain className="h-8 w-8 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<h4 className="text-sm font-medium mb-1">No sources found</h4>
|
|
||||||
<p className="text-xs text-muted-foreground max-w-xs mb-4">
|
|
||||||
Add documents or configure search connectors for this search space
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
router.push(`/dashboard/${search_space_id}/sources/add`);
|
|
||||||
}}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<PlusCircle className="h-4 w-4" />
|
|
||||||
Add Sources
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Live Search Connectors Section */}
|
|
||||||
{liveSearchConnectors.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2 pb-2">
|
|
||||||
<Zap className="h-4 w-4 text-primary" />
|
|
||||||
<h3 className="text-sm font-semibold">Live Search Connectors</h3>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
Real-time
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{liveSearchConnectors.map((connector) => {
|
|
||||||
const isSelected = selectedConnectors.includes(connector.connector_type);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={connector.id}
|
|
||||||
onClick={() => handleConnectorToggle(connector.connector_type)}
|
|
||||||
type="button"
|
|
||||||
className={`group relative flex items-center gap-3 p-3 rounded-lg border-2 transition-all ${
|
|
||||||
isSelected
|
|
||||||
? "border-primary bg-primary/5 shadow-sm"
|
|
||||||
: "border-border hover:border-primary/50 hover:bg-accent/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`flex h-10 w-10 items-center justify-center rounded-md transition-colors ${
|
|
||||||
isSelected ? "bg-primary/10" : "bg-muted group-hover:bg-primary/5"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{getConnectorIcon(
|
|
||||||
connector.connector_type,
|
|
||||||
`h-5 w-5 ${isSelected ? "text-primary" : "text-muted-foreground group-hover:text-primary"}`
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-left min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="text-sm font-medium truncate">{connector.name}</p>
|
|
||||||
{isSelected && (
|
|
||||||
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-primary">
|
|
||||||
<Check className="h-3 w-3 text-primary-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
|
||||||
{getDisplayName(connector.connector_type)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Document Types Section */}
|
|
||||||
{documentTypes.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2 pb-2">
|
|
||||||
<FolderOpen className="h-4 w-4 text-primary" />
|
|
||||||
<h3 className="text-sm font-semibold">Indexed Document Types</h3>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
Stored
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{documentTypes.map((docType) => {
|
|
||||||
const isSelected = selectedConnectors.includes(docType.type);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={docType.type}
|
|
||||||
onClick={() => handleConnectorToggle(docType.type)}
|
|
||||||
type="button"
|
|
||||||
className={`group relative flex items-center gap-3 p-3 rounded-lg border-2 transition-all ${
|
|
||||||
isSelected
|
|
||||||
? "border-primary bg-primary/5 shadow-sm"
|
|
||||||
: "border-border hover:border-primary/50 hover:bg-accent/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`flex h-10 w-10 items-center justify-center rounded-md transition-colors ${
|
|
||||||
isSelected ? "bg-primary/10" : "bg-muted group-hover:bg-primary/5"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{getConnectorIcon(
|
|
||||||
docType.type,
|
|
||||||
`h-5 w-5 ${isSelected ? "text-primary" : "text-muted-foreground group-hover:text-primary"}`
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-left min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="text-sm font-medium truncate">
|
|
||||||
{getDisplayName(docType.type)}
|
|
||||||
</p>
|
|
||||||
{isSelected && (
|
|
||||||
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-primary">
|
|
||||||
<Check className="h-3 w-3 text-primary-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{docType.count} {docType.count === 1 ? "document" : "documents"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{totalAvailableCount > 0 && (
|
|
||||||
<DialogFooter className="flex flex-row justify-between items-center gap-2 pt-4 border-t">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleClearAll}
|
|
||||||
disabled={selectedConnectors.length === 0}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
Clear All
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSelectAll}
|
|
||||||
disabled={selectedConnectors.length === totalAvailableCount}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
Select All ({totalAvailableCount})
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
ConnectorSelector.displayName = "ConnectorSelector";
|
|
||||||
|
|
||||||
const TopKSelector = React.memo(
|
|
||||||
({ topK = 10, onTopKChange }: { topK?: number; onTopKChange?: (topK: number) => void }) => {
|
|
||||||
const MIN_VALUE = 1;
|
|
||||||
const MAX_VALUE = 100;
|
|
||||||
|
|
||||||
const handleIncrement = React.useCallback(() => {
|
|
||||||
if (topK < MAX_VALUE) {
|
|
||||||
onTopKChange?.(topK + 1);
|
|
||||||
}
|
|
||||||
}, [topK, onTopKChange]);
|
|
||||||
|
|
||||||
const handleDecrement = React.useCallback(() => {
|
|
||||||
if (topK > MIN_VALUE) {
|
|
||||||
onTopKChange?.(topK - 1);
|
|
||||||
}
|
|
||||||
}, [topK, onTopKChange]);
|
|
||||||
|
|
||||||
const handleInputChange = React.useCallback(
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
// Allow empty input for editing
|
|
||||||
if (value === "") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const numValue = parseInt(value, 10);
|
|
||||||
if (!isNaN(numValue) && numValue >= MIN_VALUE && numValue <= MAX_VALUE) {
|
|
||||||
onTopKChange?.(numValue);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onTopKChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleInputBlur = React.useCallback(
|
|
||||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
if (value === "") {
|
|
||||||
// Reset to default if empty
|
|
||||||
onTopKChange?.(10);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const numValue = parseInt(value, 10);
|
|
||||||
if (isNaN(numValue) || numValue < MIN_VALUE) {
|
|
||||||
onTopKChange?.(MIN_VALUE);
|
|
||||||
} else if (numValue > MAX_VALUE) {
|
|
||||||
onTopKChange?.(MAX_VALUE);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onTopKChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip delayDuration={200}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center h-8 border rounded-md bg-background hover:bg-accent/50 transition-colors">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-full w-7 rounded-l-md rounded-r-none hover:bg-accent border-r"
|
|
||||||
onClick={handleDecrement}
|
|
||||||
disabled={topK <= MIN_VALUE}
|
|
||||||
>
|
|
||||||
<Minus className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<div className="flex flex-col items-center justify-center px-2 min-w-[60px]">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={topK}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
onBlur={handleInputBlur}
|
|
||||||
min={MIN_VALUE}
|
|
||||||
max={MAX_VALUE}
|
|
||||||
className="h-5 w-full px-1 text-center text-sm font-semibold border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
||||||
/>
|
|
||||||
<span className="text-[10px] text-muted-foreground leading-none">Results</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-full w-7 rounded-r-md rounded-l-none hover:bg-accent border-l"
|
|
||||||
onClick={handleIncrement}
|
|
||||||
disabled={topK >= MAX_VALUE}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" className="max-w-xs">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-semibold">Results per Source</p>
|
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
||||||
Control how many results to fetch from each data source. Set a higher number to get
|
|
||||||
more information, or a lower number for faster, more focused results.
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground pt-1 border-t">
|
|
||||||
<span>Recommended: 5-20</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>
|
|
||||||
Range: {MIN_VALUE}-{MAX_VALUE}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
TopKSelector.displayName = "TopKSelector";
|
|
||||||
|
|
||||||
const LLMSelector = React.memo(() => {
|
|
||||||
const { search_space_id } = useParams();
|
|
||||||
const searchSpaceId = Number(search_space_id);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: llmConfigs = [],
|
|
||||||
isFetching: llmLoading,
|
|
||||||
isError: error,
|
|
||||||
} = useAtomValue(llmConfigsAtom);
|
|
||||||
const {
|
|
||||||
data: globalConfigs = [],
|
|
||||||
isFetching: globalConfigsLoading,
|
|
||||||
isError: globalConfigsError,
|
|
||||||
} = useAtomValue(globalLLMConfigsAtom);
|
|
||||||
|
|
||||||
// Replace useLLMPreferences with jotai atoms
|
|
||||||
const { data: preferences = {}, isFetching: preferencesLoading } =
|
|
||||||
useAtomValue(llmPreferencesAtom);
|
|
||||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
|
||||||
|
|
||||||
const isLoading = llmLoading || preferencesLoading || globalConfigsLoading;
|
|
||||||
|
|
||||||
// Combine global and custom configs
|
|
||||||
const allConfigs = React.useMemo(() => {
|
|
||||||
return [...globalConfigs.map((config) => ({ ...config, is_global: true })), ...llmConfigs];
|
|
||||||
}, [globalConfigs, llmConfigs]);
|
|
||||||
|
|
||||||
// Memoize the selected config to avoid repeated lookups
|
|
||||||
const selectedConfig = React.useMemo(() => {
|
|
||||||
if (!preferences.fast_llm_id || !allConfigs.length) return null;
|
|
||||||
return allConfigs.find((config) => config.id === preferences.fast_llm_id) || null;
|
|
||||||
}, [preferences.fast_llm_id, allConfigs]);
|
|
||||||
|
|
||||||
// Memoize the display value for the trigger
|
|
||||||
const displayValue = React.useMemo(() => {
|
|
||||||
if (!selectedConfig) return null;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="font-medium text-xs">{selectedConfig.provider}</span>
|
|
||||||
<span className="text-muted-foreground">•</span>
|
|
||||||
<span className="hidden sm:inline text-muted-foreground text-xs truncate max-w-[60px]">
|
|
||||||
{selectedConfig.name}
|
|
||||||
</span>
|
|
||||||
{"is_global" in selectedConfig && selectedConfig.is_global && (
|
|
||||||
<span className="text-xs">🌐</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [selectedConfig]);
|
|
||||||
|
|
||||||
const handleValueChange = React.useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
const llmId = value ? parseInt(value, 10) : undefined;
|
|
||||||
updatePreferences({
|
|
||||||
search_space_id: searchSpaceId,
|
|
||||||
data: { fast_llm_id: llmId },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[updatePreferences, searchSpaceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Loading skeleton
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="h-8 min-w-[100px] sm:min-w-[120px]">
|
|
||||||
<div className="h-8 rounded-md bg-muted animate-pulse flex items-center px-3">
|
|
||||||
<div className="w-3 h-3 rounded bg-muted-foreground/20 mr-2" />
|
|
||||||
<div className="h-3 w-16 rounded bg-muted-foreground/20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error state
|
|
||||||
if (error || globalConfigsError) {
|
|
||||||
return (
|
|
||||||
<div className="h-8 min-w-[100px] sm:min-w-[120px]">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 px-3 text-xs text-destructive border-destructive/50 hover:bg-destructive/10"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<span className="text-xs">Error</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-8 min-w-0">
|
|
||||||
<Select
|
|
||||||
value={preferences.fast_llm_id?.toString() || ""}
|
|
||||||
onValueChange={handleValueChange}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-auto min-w-[100px] sm:min-w-[120px] px-3 text-xs border-border bg-background hover:bg-muted/50 transition-colors duration-200 focus:ring-2 focus:ring-primary/20">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<Zap className="h-3 w-3 text-primary flex-shrink-0" />
|
|
||||||
<SelectValue placeholder="Fast LLM" className="text-xs">
|
|
||||||
{displayValue || <span className="text-muted-foreground">Select LLM</span>}
|
|
||||||
</SelectValue>
|
|
||||||
</div>
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent align="end" className="w-[300px] max-h-[400px]">
|
|
||||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Zap className="h-3 w-3" />
|
|
||||||
Fast LLM Selection
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{allConfigs.length === 0 ? (
|
|
||||||
<div className="px-4 py-6 text-center">
|
|
||||||
<div className="mx-auto w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3">
|
|
||||||
<Brain className="h-5 w-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<h4 className="text-sm font-medium mb-1">No LLM configurations</h4>
|
|
||||||
<p className="text-xs text-muted-foreground mb-3">
|
|
||||||
Configure AI models to get started
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs"
|
|
||||||
onClick={() => window.open("/settings", "_blank")}
|
|
||||||
>
|
|
||||||
Open Settings
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="py-1">
|
|
||||||
{/* Global Configurations */}
|
|
||||||
{globalConfigs.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="px-3 py-1.5 text-xs font-semibold text-muted-foreground">
|
|
||||||
Global Configurations
|
|
||||||
</div>
|
|
||||||
{globalConfigs.map((config) => (
|
|
||||||
<SelectItem
|
|
||||||
key={config.id}
|
|
||||||
value={config.id.toString()}
|
|
||||||
className="px-3 py-2 cursor-pointer hover:bg-accent/50 focus:bg-accent"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between w-full min-w-0">
|
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 flex-shrink-0">
|
|
||||||
<Brain className="h-4 w-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
||||||
<span className="font-medium text-sm truncate">{config.name}</span>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-xs px-1.5 py-0.5 flex-shrink-0"
|
|
||||||
>
|
|
||||||
{config.provider}
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="text-xs px-1.5 py-0.5 flex-shrink-0"
|
|
||||||
>
|
|
||||||
🌐 Global
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground font-mono truncate">
|
|
||||||
{config.model_name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Custom Configurations */}
|
|
||||||
{llmConfigs.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="px-3 py-1.5 text-xs font-semibold text-muted-foreground">
|
|
||||||
Your Configurations
|
|
||||||
</div>
|
|
||||||
{llmConfigs.map((config) => (
|
|
||||||
<SelectItem
|
|
||||||
key={config.id}
|
|
||||||
value={config.id.toString()}
|
|
||||||
className="px-3 py-2 cursor-pointer hover:bg-accent/50 focus:bg-accent"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between w-full min-w-0">
|
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 flex-shrink-0">
|
|
||||||
<Brain className="h-4 w-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="font-medium text-sm truncate">{config.name}</span>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-xs px-1.5 py-0.5 flex-shrink-0"
|
|
||||||
>
|
|
||||||
{config.provider}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground font-mono truncate">
|
|
||||||
{config.model_name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
LLMSelector.displayName = "LLMSelector";
|
|
||||||
|
|
||||||
const CustomChatInputOptions = React.memo(
|
|
||||||
({
|
|
||||||
onDocumentSelectionChange,
|
|
||||||
selectedDocuments,
|
|
||||||
onConnectorSelectionChange,
|
|
||||||
selectedConnectors,
|
|
||||||
topK,
|
|
||||||
onTopKChange,
|
|
||||||
}: {
|
|
||||||
onDocumentSelectionChange?: (documents: Document[]) => void;
|
|
||||||
selectedDocuments?: Document[];
|
|
||||||
onConnectorSelectionChange?: (connectorTypes: string[]) => void;
|
|
||||||
selectedConnectors?: string[];
|
|
||||||
topK?: number;
|
|
||||||
onTopKChange?: (topK: number) => void;
|
|
||||||
}) => {
|
|
||||||
// Memoize the loading fallback to prevent recreation
|
|
||||||
const loadingFallback = React.useMemo(
|
|
||||||
() => <div className="h-9 w-24 animate-pulse bg-muted/50 rounded-md" />,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap gap-2 items-center">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Suspense fallback={loadingFallback}>
|
|
||||||
<DocumentSelector
|
|
||||||
onSelectionChange={onDocumentSelectionChange}
|
|
||||||
selectedDocuments={selectedDocuments}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
<Suspense fallback={loadingFallback}>
|
|
||||||
<ConnectorSelector
|
|
||||||
onSelectionChange={onConnectorSelectionChange}
|
|
||||||
selectedConnectors={selectedConnectors}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
<div className="h-4 w-px bg-border hidden sm:block" />
|
|
||||||
<TopKSelector topK={topK} onTopKChange={onTopKChange} />
|
|
||||||
<div className="h-4 w-px bg-border hidden sm:block" />
|
|
||||||
<LLMSelector />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
CustomChatInputOptions.displayName = "CustomChatInputOptions";
|
|
||||||
|
|
||||||
export const ChatInputUI = React.memo(
|
|
||||||
({
|
|
||||||
onDocumentSelectionChange,
|
|
||||||
selectedDocuments,
|
|
||||||
onConnectorSelectionChange,
|
|
||||||
selectedConnectors,
|
|
||||||
topK,
|
|
||||||
onTopKChange,
|
|
||||||
}: {
|
|
||||||
onDocumentSelectionChange?: (documents: Document[]) => void;
|
|
||||||
selectedDocuments?: Document[];
|
|
||||||
onConnectorSelectionChange?: (connectorTypes: string[]) => void;
|
|
||||||
selectedConnectors?: string[];
|
|
||||||
topK?: number;
|
|
||||||
onTopKChange?: (topK: number) => void;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<ChatInput className="p-2">
|
|
||||||
<ChatInput.Form className="flex gap-2">
|
|
||||||
<ChatInput.Field className="flex-1" />
|
|
||||||
<ChatInput.Submit />
|
|
||||||
</ChatInput.Form>
|
|
||||||
<CustomChatInputOptions
|
|
||||||
onDocumentSelectionChange={onDocumentSelectionChange}
|
|
||||||
selectedDocuments={selectedDocuments}
|
|
||||||
onConnectorSelectionChange={onConnectorSelectionChange}
|
|
||||||
selectedConnectors={selectedConnectors}
|
|
||||||
topK={topK}
|
|
||||||
onTopKChange={onTopKChange}
|
|
||||||
/>
|
|
||||||
</ChatInput>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
ChatInputUI.displayName = "ChatInputUI";
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { type ChatHandler, ChatSection as LlamaIndexChatSection } from "@llamaindex/chat-ui";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { ChatInputUI } from "@/components/chat/ChatInputGroup";
|
|
||||||
import { ChatMessagesUI } from "@/components/chat/ChatMessages";
|
|
||||||
import type { Document } from "@/contracts/types/document.types";
|
|
||||||
|
|
||||||
interface ChatInterfaceProps {
|
|
||||||
handler: ChatHandler;
|
|
||||||
onDocumentSelectionChange?: (documents: Document[]) => void;
|
|
||||||
selectedDocuments?: Document[];
|
|
||||||
onConnectorSelectionChange?: (connectorTypes: string[]) => void;
|
|
||||||
selectedConnectors?: string[];
|
|
||||||
topK?: number;
|
|
||||||
onTopKChange?: (topK: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChatInterface({
|
|
||||||
handler,
|
|
||||||
onDocumentSelectionChange,
|
|
||||||
selectedDocuments = [],
|
|
||||||
onConnectorSelectionChange,
|
|
||||||
selectedConnectors = [],
|
|
||||||
topK = 10,
|
|
||||||
onTopKChange,
|
|
||||||
}: ChatInterfaceProps) {
|
|
||||||
const { chat_id, search_space_id } = useParams();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LlamaIndexChatSection handler={handler} className="flex h-full max-w-7xl mx-auto">
|
|
||||||
<div className="flex grow-1 flex-col">
|
|
||||||
<ChatMessagesUI />
|
|
||||||
<div className="border-1 rounded-4xl p-2">
|
|
||||||
<ChatInputUI
|
|
||||||
onDocumentSelectionChange={onDocumentSelectionChange}
|
|
||||||
selectedDocuments={selectedDocuments}
|
|
||||||
onConnectorSelectionChange={onConnectorSelectionChange}
|
|
||||||
selectedConnectors={selectedConnectors}
|
|
||||||
topK={topK}
|
|
||||||
onTopKChange={onTopKChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</LlamaIndexChatSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ChatMessage as LlamaIndexChatMessage,
|
|
||||||
ChatMessages as LlamaIndexChatMessages,
|
|
||||||
type Message,
|
|
||||||
useChatUI,
|
|
||||||
} from "@llamaindex/chat-ui";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { AnimatedEmptyState } from "@/components/chat/AnimatedEmptyState";
|
|
||||||
import { CitationDisplay } from "@/components/chat/ChatCitation";
|
|
||||||
import { ChatFurtherQuestions } from "@/components/chat/ChatFurtherQuestions";
|
|
||||||
import ChatSourcesDisplay from "@/components/chat/ChatSources";
|
|
||||||
import TerminalDisplay from "@/components/chat/ChatTerminal";
|
|
||||||
import { languageRenderers } from "@/components/chat/CodeBlock";
|
|
||||||
|
|
||||||
export function ChatMessagesUI() {
|
|
||||||
const { messages } = useChatUI();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LlamaIndexChatMessages className="flex-1">
|
|
||||||
<LlamaIndexChatMessages.Empty>
|
|
||||||
<AnimatedEmptyState />
|
|
||||||
</LlamaIndexChatMessages.Empty>
|
|
||||||
<LlamaIndexChatMessages.List className="p-2">
|
|
||||||
{messages.map((message, index) => (
|
|
||||||
<ChatMessageUI
|
|
||||||
key={`Message-${index}`}
|
|
||||||
message={message}
|
|
||||||
isLast={index === messages.length - 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</LlamaIndexChatMessages.List>
|
|
||||||
<LlamaIndexChatMessages.Loading />
|
|
||||||
</LlamaIndexChatMessages>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChatMessageUI({ message, isLast }: { message: Message; isLast: boolean }) {
|
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLast && bottomRef.current) {
|
|
||||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}
|
|
||||||
}, [isLast]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LlamaIndexChatMessage message={message} isLast={isLast} className="flex flex-col ">
|
|
||||||
{message.role === "assistant" ? (
|
|
||||||
<div className="flex-1 flex flex-col space-y-4">
|
|
||||||
<TerminalDisplay message={message} open={isLast} />
|
|
||||||
<ChatSourcesDisplay message={message} />
|
|
||||||
<LlamaIndexChatMessage.Content className="flex-1">
|
|
||||||
<LlamaIndexChatMessage.Content.Markdown
|
|
||||||
citationComponent={CitationDisplay}
|
|
||||||
languageRenderers={languageRenderers}
|
|
||||||
/>
|
|
||||||
</LlamaIndexChatMessage.Content>
|
|
||||||
<div ref={bottomRef} />
|
|
||||||
<div className="flex flex-row justify-end gap-2">
|
|
||||||
{isLast && <ChatFurtherQuestions message={message} />}
|
|
||||||
<LlamaIndexChatMessage.Actions className="flex-1 flex-col" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<LlamaIndexChatMessage.Content className="flex-1">
|
|
||||||
<LlamaIndexChatMessage.Content.Markdown languageRenderers={languageRenderers} />
|
|
||||||
</LlamaIndexChatMessage.Content>
|
|
||||||
)}
|
|
||||||
</LlamaIndexChatMessage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
"use client";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { LoaderIcon, TriangleAlert } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
|
|
||||||
import { activeChathatUIAtom, activeChatIdAtom } from "@/atoms/chats/ui.atoms";
|
|
||||||
import { generatePodcastMutationAtom } from "@/atoms/podcasts/podcast-mutation.atoms";
|
|
||||||
import type { GeneratePodcastRequest } from "@/contracts/types/podcast.types";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { ChatPanelView } from "./ChatPanelView";
|
|
||||||
|
|
||||||
export function ChatPanelContainer() {
|
|
||||||
const {
|
|
||||||
data: activeChatState,
|
|
||||||
isLoading: isChatLoading,
|
|
||||||
error: chatError,
|
|
||||||
} = useAtomValue(activeChatAtom);
|
|
||||||
const activeChatIdState = useAtomValue(activeChatIdAtom);
|
|
||||||
const { isChatPannelOpen } = useAtomValue(activeChathatUIAtom);
|
|
||||||
const { mutateAsync: generatePodcast, error: generatePodcastError } = useAtomValue(
|
|
||||||
generatePodcastMutationAtom
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleGeneratePodcast = async (request: GeneratePodcastRequest) => {
|
|
||||||
try {
|
|
||||||
generatePodcast(request);
|
|
||||||
toast.success(`Podcast generation started!`);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Error generating podcast. Please try again later.");
|
|
||||||
console.error("Error generating podcast:", JSON.stringify(generatePodcastError));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return activeChatIdState ? (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 flex flex-col h-full transition-all",
|
|
||||||
isChatPannelOpen ? "w-96" : "w-0"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isChatLoading || chatError ? (
|
|
||||||
<div className="border-b p-2">
|
|
||||||
{isChatLoading ? (
|
|
||||||
<div title="Loading chat" className="flex items-center justify-center h-full">
|
|
||||||
<LoaderIcon strokeWidth={1.5} className="h-5 w-5 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : chatError ? (
|
|
||||||
<div title="Failed to load chat" className="flex items-center justify-center h-full">
|
|
||||||
<TriangleAlert strokeWidth={1.5} className="h-5 w-5 text-red-600" />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isChatLoading && !chatError && activeChatState?.chatDetails && (
|
|
||||||
<ChatPanelView generatePodcast={handleGeneratePodcast} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
|
||||||
import { AlertCircle, Play, RefreshCw, Sparkles } from "lucide-react";
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
|
|
||||||
import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { getPodcastStalenessMessage, isPodcastStale } from "../PodcastUtils";
|
|
||||||
import type { GeneratePodcastRequest } from "./ChatPanelContainer";
|
|
||||||
import { ConfigModal } from "./ConfigModal";
|
|
||||||
import { PodcastPlayer } from "./PodcastPlayer";
|
|
||||||
|
|
||||||
interface ChatPanelViewProps {
|
|
||||||
generatePodcast: (request: GeneratePodcastRequest) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatPanelView(props: ChatPanelViewProps) {
|
|
||||||
const [chatUIState, setChatUIState] = useAtom(activeChathatUIAtom);
|
|
||||||
const { data: activeChatState } = useAtomValue(activeChatAtom);
|
|
||||||
|
|
||||||
const { isChatPannelOpen } = chatUIState;
|
|
||||||
const podcast = activeChatState?.podcast;
|
|
||||||
const chatDetails = activeChatState?.chatDetails;
|
|
||||||
|
|
||||||
const { generatePodcast } = props;
|
|
||||||
|
|
||||||
// Check if podcast is stale
|
|
||||||
const podcastIsStale =
|
|
||||||
podcast && chatDetails && isPodcastStale(chatDetails.state_version, podcast.chat_state_version);
|
|
||||||
|
|
||||||
const handleGeneratePost = useCallback(async () => {
|
|
||||||
if (!chatDetails) return;
|
|
||||||
await generatePodcast({
|
|
||||||
type: "CHAT",
|
|
||||||
ids: [chatDetails.id],
|
|
||||||
search_space_id: chatDetails.search_space_id,
|
|
||||||
podcast_title: chatDetails.title,
|
|
||||||
});
|
|
||||||
}, [chatDetails, generatePodcast]);
|
|
||||||
|
|
||||||
// biome-ignore-start lint/a11y/useSemanticElements: using div for custom layout — will convert later
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<div className={cn("w-full p-4", !isChatPannelOpen && "flex items-center justify-center")}>
|
|
||||||
{isChatPannelOpen ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Show stale podcast warning if applicable */}
|
|
||||||
{podcastIsStale && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: -10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="rounded-xl p-3 bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/20 border border-amber-200/50 dark:border-amber-800/50 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="flex gap-2 items-start">
|
|
||||||
<motion.div
|
|
||||||
animate={{ rotate: [0, 10, -10, 0] }}
|
|
||||||
transition={{ duration: 0.5, repeat: Infinity, repeatDelay: 3 }}
|
|
||||||
>
|
|
||||||
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
|
||||||
</motion.div>
|
|
||||||
<div className="text-sm text-amber-900 dark:text-amber-100">
|
|
||||||
<p className="font-semibold">Podcast Outdated</p>
|
|
||||||
<p className="text-xs mt-1 opacity-80">
|
|
||||||
{getPodcastStalenessMessage(
|
|
||||||
chatDetails?.state_version || 0,
|
|
||||||
podcast?.chat_state_version
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ scale: 1.02 }}
|
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="relative"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleGeneratePost}
|
|
||||||
className={cn(
|
|
||||||
"relative w-full rounded-2xl p-4 transition-all duration-300 cursor-pointer group overflow-hidden",
|
|
||||||
"border-2",
|
|
||||||
podcastIsStale
|
|
||||||
? "bg-gradient-to-br from-amber-500/10 via-orange-500/10 to-amber-500/10 dark:from-amber-500/20 dark:via-orange-500/20 dark:to-amber-500/20 border-amber-400/50 hover:border-amber-400 hover:shadow-lg hover:shadow-amber-500/20"
|
|
||||||
: "bg-gradient-to-br from-primary/10 via-primary/5 to-primary/10 border-primary/30 hover:border-primary/60 hover:shadow-lg hover:shadow-primary/20"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Background gradient animation */}
|
|
||||||
<motion.div
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500",
|
|
||||||
podcastIsStale
|
|
||||||
? "bg-gradient-to-r from-transparent via-amber-400/10 to-transparent"
|
|
||||||
: "bg-gradient-to-r from-transparent via-primary/10 to-transparent"
|
|
||||||
)}
|
|
||||||
animate={{
|
|
||||||
x: ["-100%", "100%"],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 3,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "linear",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative z-10 space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<motion.div
|
|
||||||
className={cn(
|
|
||||||
"p-2.5 rounded-xl",
|
|
||||||
podcastIsStale
|
|
||||||
? "bg-amber-500/20 dark:bg-amber-500/30"
|
|
||||||
: "bg-primary/20 dark:bg-primary/30"
|
|
||||||
)}
|
|
||||||
animate={{
|
|
||||||
rotate: podcastIsStale ? [0, 360] : 0,
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 2,
|
|
||||||
repeat: podcastIsStale ? Infinity : 0,
|
|
||||||
ease: "linear",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{podcastIsStale ? (
|
|
||||||
<RefreshCw className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
|
||||||
) : (
|
|
||||||
<Sparkles className="h-5 w-5 text-primary" />
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold">
|
|
||||||
{podcastIsStale ? "Regenerate Podcast" : "Generate Podcast"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{podcastIsStale
|
|
||||||
? "Update with latest changes"
|
|
||||||
: "Create podcasts of your chat"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/* ConfigModal positioned absolutely to avoid nesting buttons */}
|
|
||||||
<div className="absolute top-4 right-4 z-20">
|
|
||||||
<ConfigModal generatePodcast={generatePodcast} />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<motion.button
|
|
||||||
title={podcastIsStale ? "Regenerate Podcast" : "Generate Podcast"}
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setChatUIState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
isChatPannelOpen: !isChatPannelOpen,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
whileTap={{ scale: 0.9 }}
|
|
||||||
className={cn(
|
|
||||||
"p-2.5 rounded-full transition-colors shadow-sm",
|
|
||||||
podcastIsStale
|
|
||||||
? "bg-amber-500/20 hover:bg-amber-500/30 text-amber-600 dark:text-amber-400"
|
|
||||||
: "bg-primary/20 hover:bg-primary/30 text-primary"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{podcastIsStale ? <RefreshCw className="h-5 w-5" /> : <Sparkles className="h-5 w-5" />}
|
|
||||||
</motion.button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{podcast ? (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"w-full border-t",
|
|
||||||
!isChatPannelOpen && "flex items-center justify-center p-4"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isChatPannelOpen ? (
|
|
||||||
<PodcastPlayer compact podcast={podcast} />
|
|
||||||
) : podcast ? (
|
|
||||||
<motion.button
|
|
||||||
title="Play Podcast"
|
|
||||||
type="button"
|
|
||||||
onClick={() => setChatUIState((prev) => ({ ...prev, isChatPannelOpen: true }))}
|
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
whileTap={{ scale: 0.9 }}
|
|
||||||
className="p-2.5 rounded-full bg-green-500/20 hover:bg-green-500/30 text-green-600 dark:text-green-400 transition-colors shadow-sm"
|
|
||||||
>
|
|
||||||
<Play className="h-5 w-5" />
|
|
||||||
</motion.button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
// biome-ignore-end lint/a11y/useSemanticElements : using div for custom layout — will convert later
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { Pencil } from "lucide-react";
|
|
||||||
import { useCallback, useContext, useState } from "react";
|
|
||||||
import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
||||||
import type { GeneratePodcastRequest } from "./ChatPanelContainer";
|
|
||||||
|
|
||||||
interface ConfigModalProps {
|
|
||||||
generatePodcast: (request: GeneratePodcastRequest) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfigModal(props: ConfigModalProps) {
|
|
||||||
const { data: activeChatState } = useAtomValue(activeChatAtom);
|
|
||||||
|
|
||||||
const chatDetails = activeChatState?.chatDetails;
|
|
||||||
const podcast = activeChatState?.podcast;
|
|
||||||
|
|
||||||
const { generatePodcast } = props;
|
|
||||||
|
|
||||||
const [userPromt, setUserPrompt] = useState("");
|
|
||||||
|
|
||||||
const handleGeneratePost = useCallback(async () => {
|
|
||||||
if (!chatDetails) return;
|
|
||||||
await generatePodcast({
|
|
||||||
type: "CHAT",
|
|
||||||
ids: [chatDetails.id],
|
|
||||||
search_space_id: chatDetails.search_space_id,
|
|
||||||
podcast_title: podcast?.title || chatDetails.title,
|
|
||||||
user_prompt: userPromt,
|
|
||||||
});
|
|
||||||
}, [chatDetails, userPromt]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger
|
|
||||||
title="Edit the prompt"
|
|
||||||
className="rounded-full p-2 bg-slate-400/30 hover:bg-slate-400/40"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Pencil strokeWidth={1} className="h-4 w-4" />
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent onClick={(e) => e.stopPropagation()} align="end" className="bg-sidebar w-96 ">
|
|
||||||
<form className="flex flex-col gap-3 w-full">
|
|
||||||
<label className="text-sm font-medium" htmlFor="prompt">
|
|
||||||
Special user instructions
|
|
||||||
</label>
|
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
|
||||||
Leave empty to use the default prompt
|
|
||||||
</p>
|
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400 space-y-1">
|
|
||||||
<p>Examples:</p>
|
|
||||||
<ul className="list-disc list-inside space-y-0.5">
|
|
||||||
<li>Make hosts speak in London street language</li>
|
|
||||||
<li>Use real-world analogies and metaphors</li>
|
|
||||||
<li>Add dramatic pauses like a late-night radio show</li>
|
|
||||||
<li>Include 90s pop culture references</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
name="prompt"
|
|
||||||
id="prompt"
|
|
||||||
defaultValue={userPromt}
|
|
||||||
className="w-full rounded-md border border-slate-400/40 p-2"
|
|
||||||
onChange={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setUserPrompt(e.target.value);
|
|
||||||
}}
|
|
||||||
></textarea>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleGeneratePost}
|
|
||||||
className="w-full rounded-md bg-foreground text-white dark:text-black p-2"
|
|
||||||
>
|
|
||||||
Generate Podcast
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,329 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Pause, Play, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Slider } from "@/components/ui/slider";
|
|
||||||
import type { Podcast } from "@/contracts/types/podcast.types";
|
|
||||||
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
|
|
||||||
import { PodcastPlayerCompactSkeleton } from "./PodcastPlayerCompactSkeleton";
|
|
||||||
|
|
||||||
interface PodcastPlayerProps {
|
|
||||||
podcast: Podcast | null;
|
|
||||||
isLoading?: boolean;
|
|
||||||
onClose?: () => void;
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PodcastPlayer({
|
|
||||||
podcast,
|
|
||||||
isLoading = false,
|
|
||||||
onClose,
|
|
||||||
compact = false,
|
|
||||||
}: PodcastPlayerProps) {
|
|
||||||
const [audioSrc, setAudioSrc] = useState<string | undefined>(undefined);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
|
||||||
const [duration, setDuration] = useState(0);
|
|
||||||
const [volume, setVolume] = useState(0.7);
|
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
|
||||||
const [isFetching, setIsFetching] = useState(false);
|
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
const currentObjectUrlRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Cleanup object URL on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (currentObjectUrlRef.current) {
|
|
||||||
URL.revokeObjectURL(currentObjectUrlRef.current);
|
|
||||||
currentObjectUrlRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load podcast audio when podcast changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!podcast) {
|
|
||||||
setAudioSrc(undefined);
|
|
||||||
setCurrentTime(0);
|
|
||||||
setDuration(0);
|
|
||||||
setIsPlaying(false);
|
|
||||||
setIsFetching(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadPodcast = async () => {
|
|
||||||
setIsFetching(true);
|
|
||||||
try {
|
|
||||||
// Revoke previous object URL if exists
|
|
||||||
if (currentObjectUrlRef.current) {
|
|
||||||
URL.revokeObjectURL(currentObjectUrlRef.current);
|
|
||||||
currentObjectUrlRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await podcastsApiService.loadPodcast({
|
|
||||||
request: { id: podcast.id },
|
|
||||||
controller,
|
|
||||||
});
|
|
||||||
|
|
||||||
const objectUrl = URL.createObjectURL(response);
|
|
||||||
currentObjectUrlRef.current = objectUrl;
|
|
||||||
setAudioSrc(objectUrl);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof DOMException && error.name === "AbortError") {
|
|
||||||
throw new Error("Request timed out. Please try again.");
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching podcast:", error);
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to load podcast audio.");
|
|
||||||
setAudioSrc(undefined);
|
|
||||||
} finally {
|
|
||||||
setIsFetching(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadPodcast();
|
|
||||||
}, [podcast]);
|
|
||||||
|
|
||||||
const handleTimeUpdate = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
setCurrentTime(audioRef.current.currentTime);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMetadataLoaded = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
setDuration(audioRef.current.duration);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePlayPause = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
if (isPlaying) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
} else {
|
|
||||||
audioRef.current.play();
|
|
||||||
}
|
|
||||||
setIsPlaying(!isPlaying);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSeek = (value: number[]) => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = value[0];
|
|
||||||
setCurrentTime(value[0]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVolumeChange = (value: number[]) => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
const newVolume = value[0];
|
|
||||||
audioRef.current.volume = newVolume;
|
|
||||||
setVolume(newVolume);
|
|
||||||
|
|
||||||
if (newVolume === 0) {
|
|
||||||
audioRef.current.muted = true;
|
|
||||||
setIsMuted(true);
|
|
||||||
} else {
|
|
||||||
audioRef.current.muted = false;
|
|
||||||
setIsMuted(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleMute = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
const newMutedState = !isMuted;
|
|
||||||
audioRef.current.muted = newMutedState;
|
|
||||||
setIsMuted(newMutedState);
|
|
||||||
|
|
||||||
if (!newMutedState && volume === 0) {
|
|
||||||
const restoredVolume = 0.5;
|
|
||||||
audioRef.current.volume = restoredVolume;
|
|
||||||
setVolume(restoredVolume);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const skipForward = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = Math.min(
|
|
||||||
audioRef.current.duration,
|
|
||||||
audioRef.current.currentTime + 10
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const skipBackward = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = Math.max(0, audioRef.current.currentTime - 10);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (time: number) => {
|
|
||||||
const minutes = Math.floor(time / 60);
|
|
||||||
const seconds = Math.floor(time % 60);
|
|
||||||
return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show skeleton while fetching
|
|
||||||
if (isFetching && compact) {
|
|
||||||
return <PodcastPlayerCompactSkeleton />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!podcast || !audioSrc) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (compact) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-4 p-4">
|
|
||||||
{/* Audio Visualizer */}
|
|
||||||
<motion.div
|
|
||||||
className="relative h-1 bg-gradient-to-r from-primary/20 via-primary/40 to-primary/20 rounded-full overflow-hidden"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
>
|
|
||||||
{isPlaying && (
|
|
||||||
<motion.div
|
|
||||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-primary to-transparent"
|
|
||||||
animate={{
|
|
||||||
x: ["-100%", "100%"],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 2,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "linear",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Progress Bar with Time */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Slider
|
|
||||||
value={[currentTime]}
|
|
||||||
min={0}
|
|
||||||
max={duration || 100}
|
|
||||||
step={0.1}
|
|
||||||
onValueChange={handleSeek}
|
|
||||||
className="w-full cursor-pointer"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
||||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
|
||||||
<span className="font-mono">{formatTime(duration)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
{/* Left: Volume */}
|
|
||||||
<div className="flex items-center gap-2 flex-1">
|
|
||||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
|
||||||
<Button variant="ghost" size="icon" onClick={toggleMute} className="h-8 w-8">
|
|
||||||
{isMuted ? (
|
|
||||||
<VolumeX className="h-4 w-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<Volume2 className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Center: Playback Controls */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={skipBackward}
|
|
||||||
className="h-9 w-9"
|
|
||||||
disabled={!duration}
|
|
||||||
>
|
|
||||||
<SkipBack className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
animate={
|
|
||||||
isPlaying
|
|
||||||
? {
|
|
||||||
boxShadow: [
|
|
||||||
"0 0 0 0 rgba(var(--primary), 0)",
|
|
||||||
"0 0 0 8px rgba(var(--primary), 0.1)",
|
|
||||||
"0 0 0 0 rgba(var(--primary), 0)",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
transition={{ duration: 1.5, repeat: isPlaying ? Infinity : 0 }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="icon"
|
|
||||||
onClick={togglePlayPause}
|
|
||||||
className="h-10 w-10 rounded-full"
|
|
||||||
disabled={!duration}
|
|
||||||
>
|
|
||||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 ml-0.5" />}
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={skipForward}
|
|
||||||
className="h-9 w-9"
|
|
||||||
disabled={!duration}
|
|
||||||
>
|
|
||||||
<SkipForward className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: Placeholder for symmetry */}
|
|
||||||
<div className="flex-1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<audio
|
|
||||||
ref={audioRef}
|
|
||||||
src={audioSrc}
|
|
||||||
preload="auto"
|
|
||||||
onTimeUpdate={handleTimeUpdate}
|
|
||||||
onLoadedMetadata={handleMetadataLoaded}
|
|
||||||
onEnded={() => setIsPlaying(false)}
|
|
||||||
onError={(e) => {
|
|
||||||
console.error("Audio error:", e);
|
|
||||||
if (audioRef.current?.error) {
|
|
||||||
console.error("Audio error code:", audioRef.current.error.code);
|
|
||||||
if (audioRef.current.error.code !== audioRef.current.error.MEDIA_ERR_ABORTED) {
|
|
||||||
toast.error("Error playing audio. Please try again.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setIsPlaying(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<track kind="captions" />
|
|
||||||
</audio>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Podcast } from "lucide-react";
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
|
|
||||||
export function PodcastPlayerCompactSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3 p-3">
|
|
||||||
{/* Header with icon and title */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<motion.div
|
|
||||||
className="w-8 h-8 bg-primary/20 rounded-md flex items-center justify-center flex-shrink-0"
|
|
||||||
animate={{ scale: [1, 1.05, 1] }}
|
|
||||||
transition={{
|
|
||||||
repeat: Infinity,
|
|
||||||
duration: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Podcast className="h-4 w-4 text-primary" />
|
|
||||||
</motion.div>
|
|
||||||
{/* Title skeleton */}
|
|
||||||
<div className="h-4 bg-muted rounded w-32 flex-grow animate-pulse" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bar skeleton */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="h-1 bg-muted rounded flex-grow animate-pulse" />
|
|
||||||
<div className="h-4 bg-muted rounded w-12 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls skeleton */}
|
|
||||||
<div className="flex items-center justify-between gap-1">
|
|
||||||
<div className="h-7 w-7 bg-muted rounded-full animate-pulse" />
|
|
||||||
<div className="h-8 w-8 bg-primary/20 rounded-full animate-pulse" />
|
|
||||||
<div className="h-7 w-7 bg-muted rounded-full animate-pulse" />
|
|
||||||
<div className="h-7 w-7 bg-muted rounded-full animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export { PodcastPlayer } from "./PodcastPlayer";
|
|
||||||
export { PodcastPlayerCompactSkeleton } from "./PodcastPlayerCompactSkeleton";
|
|
||||||
|
|
@ -1,226 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { getAnnotationData, type Message } from "@llamaindex/chat-ui";
|
|
||||||
import { ExternalLink, FileText } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
|
||||||
import { SourceDetailSheet } from "./SourceDetailSheet";
|
|
||||||
|
|
||||||
interface Source {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
url: string;
|
|
||||||
sourceType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SourceGroup {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
sources: Source[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// New interfaces for the updated data format
|
|
||||||
interface NodeMetadata {
|
|
||||||
title: string;
|
|
||||||
source_type: string;
|
|
||||||
group_name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SourceNode {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
url: string;
|
|
||||||
metadata: NodeMetadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSourceIcon(type: string) {
|
|
||||||
// Handle USER_SELECTED_ prefix
|
|
||||||
const normalizedType = type.startsWith("USER_SELECTED_")
|
|
||||||
? type.replace("USER_SELECTED_", "")
|
|
||||||
: type;
|
|
||||||
return getConnectorIcon(normalizedType, "h-4 w-4");
|
|
||||||
}
|
|
||||||
|
|
||||||
function SourceCard({ source }: { source: Source }) {
|
|
||||||
const hasUrl = source.url && source.url.trim() !== "";
|
|
||||||
const chunkId = Number(source.id);
|
|
||||||
const sourceType = source.sourceType;
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
// Clean up the description for better display
|
|
||||||
const cleanDescription = source.description
|
|
||||||
.replace(/## Metadata\n\n/g, "")
|
|
||||||
.replace(/\n+/g, " ")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
const handleUrlClick = (e: React.MouseEvent, url: string) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SourceDetailSheet
|
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={setIsOpen}
|
|
||||||
chunkId={chunkId}
|
|
||||||
sourceType={sourceType}
|
|
||||||
title={source.title}
|
|
||||||
description={source.description}
|
|
||||||
url={source.url}
|
|
||||||
>
|
|
||||||
<SheetTrigger asChild>
|
|
||||||
<Card className="border-muted hover:border-muted-foreground/20 transition-colors cursor-pointer">
|
|
||||||
<CardHeader className="pb-3 pt-3">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<CardTitle className="text-sm font-medium leading-tight line-clamp-2 flex-1">
|
|
||||||
{source.title}
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
<Badge variant="secondary" className="text-[10px] h-5 px-2 font-mono">
|
|
||||||
#{chunkId}
|
|
||||||
</Badge>
|
|
||||||
{hasUrl && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-7 p-0 flex-shrink-0 hover:bg-muted"
|
|
||||||
onClick={(e) => handleUrlClick(e, source.url)}
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0 pb-3">
|
|
||||||
<CardDescription className="text-xs line-clamp-3 leading-relaxed text-muted-foreground">
|
|
||||||
{cleanDescription}
|
|
||||||
</CardDescription>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</SheetTrigger>
|
|
||||||
</SourceDetailSheet>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const annotations = getAnnotationData(message, "sources");
|
|
||||||
|
|
||||||
// Transform the new data format to the expected SourceGroup format
|
|
||||||
const sourceGroups: SourceGroup[] = [];
|
|
||||||
|
|
||||||
if (Array.isArray(annotations) && annotations.length > 0) {
|
|
||||||
// Extract all nodes from the response
|
|
||||||
const allNodes: SourceNode[] = [];
|
|
||||||
|
|
||||||
annotations.forEach((item) => {
|
|
||||||
if (item && typeof item === "object" && "nodes" in item && Array.isArray(item.nodes)) {
|
|
||||||
allNodes.push(...item.nodes);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Group nodes by source_type
|
|
||||||
const groupedByType = allNodes.reduce(
|
|
||||||
(acc, node) => {
|
|
||||||
const sourceType = node.metadata.source_type;
|
|
||||||
if (!acc[sourceType]) {
|
|
||||||
acc[sourceType] = [];
|
|
||||||
}
|
|
||||||
acc[sourceType].push(node);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, SourceNode[]>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert grouped nodes to SourceGroup format
|
|
||||||
Object.entries(groupedByType).forEach(([sourceType, nodes], index) => {
|
|
||||||
if (nodes.length > 0) {
|
|
||||||
const firstNode = nodes[0];
|
|
||||||
sourceGroups.push({
|
|
||||||
id: index + 100, // Generate unique ID
|
|
||||||
name: firstNode.metadata.group_name,
|
|
||||||
type: sourceType,
|
|
||||||
sources: nodes.map((node) => ({
|
|
||||||
id: node.id,
|
|
||||||
title: node.metadata.title,
|
|
||||||
description: node.text,
|
|
||||||
url: node.url || "",
|
|
||||||
sourceType: sourceType,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceGroups.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalSources = sourceGroups.reduce((acc, group) => acc + group.sources.length, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sheet open={open} onOpenChange={setOpen}>
|
|
||||||
<SheetTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" className="w-fit">
|
|
||||||
<FileText className="h-4 w-4 mr-2" />
|
|
||||||
View Sources ({totalSources})
|
|
||||||
</Button>
|
|
||||||
</SheetTrigger>
|
|
||||||
<SheetContent className="w-[400px] sm:w-[540px] md:w-[640px] lg:w-[720px] xl:w-[800px] sm:max-w-[540px] md:max-w-[640px] lg:max-w-[720px] xl:max-w-[800px] flex flex-col p-0 overflow-hidden">
|
|
||||||
<SheetHeader className="px-6 py-4 border-b flex-shrink-0">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<SheetTitle className="text-lg font-semibold">Sources</SheetTitle>
|
|
||||||
<Badge variant="outline" className="font-normal">
|
|
||||||
{totalSources} {totalSources === 1 ? "source" : "sources"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</SheetHeader>
|
|
||||||
<Tabs defaultValue={sourceGroups[0]?.type} className="flex-1 flex flex-col min-h-0">
|
|
||||||
<div className="flex-shrink-0 w-full overflow-x-auto px-6 pt-4 scrollbar-none">
|
|
||||||
<TabsList className="flex w-max min-w-full bg-muted/50">
|
|
||||||
{sourceGroups.map((group) => (
|
|
||||||
<TabsTrigger
|
|
||||||
key={group.type}
|
|
||||||
value={group.type}
|
|
||||||
className="flex items-center gap-2 whitespace-nowrap px-4 data-[state=active]:bg-background data-[state=active]:shadow-sm"
|
|
||||||
>
|
|
||||||
{getSourceIcon(group.type)}
|
|
||||||
<span className="truncate max-w-[120px] md:max-w-[180px] lg:max-w-none">
|
|
||||||
{group.name}
|
|
||||||
</span>
|
|
||||||
<Badge variant="secondary" className="ml-1.5 h-5 text-xs flex-shrink-0">
|
|
||||||
{group.sources.length}
|
|
||||||
</Badge>
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
{sourceGroups.map((group) => (
|
|
||||||
<TabsContent
|
|
||||||
key={group.type}
|
|
||||||
value={group.type}
|
|
||||||
className="flex-1 min-h-0 mt-0 px-6 pb-6 data-[state=active]:flex data-[state=active]:flex-col"
|
|
||||||
>
|
|
||||||
<div className="h-full overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
|
|
||||||
<div className="grid gap-3 pt-4 grid-cols-1 lg:grid-cols-2">
|
|
||||||
{group.sources.map((source) => (
|
|
||||||
<SourceCard key={source.id} source={source} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { getAnnotationData, type Message } from "@llamaindex/chat-ui";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export default function TerminalDisplay({ message, open }: { message: Message; open: boolean }) {
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(!open);
|
|
||||||
|
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (bottomRef.current) {
|
|
||||||
bottomRef.current.scrollTo({
|
|
||||||
top: bottomRef.current.scrollHeight,
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Get the last assistant message that's not being typed
|
|
||||||
if (!message) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TerminalInfo {
|
|
||||||
id: number;
|
|
||||||
text: string;
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const events = getAnnotationData(message, "TERMINAL_INFO") as TerminalInfo[];
|
|
||||||
|
|
||||||
if (events.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden font-mono text-sm shadow-lg">
|
|
||||||
{/* Terminal Header */}
|
|
||||||
<Button
|
|
||||||
className="w-full bg-gray-800 px-4 py-2 flex items-center gap-2 border-b border-gray-700 cursor-pointer hover:bg-gray-750 transition-colors"
|
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
|
||||||
variant="ghost"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-red-500"></div>
|
|
||||||
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
|
|
||||||
<div className="w-3 h-3 rounded-full bg-green-500"></div>
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-400 text-xs ml-2 flex-1">
|
|
||||||
Agent Process Terminal ({events.length} events)
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-400">
|
|
||||||
{isCollapsed ? (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<title>Collapse</title>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M19 9l-7 7-7-7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<title>Expand</title>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M5 15l7-7 7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Terminal Content (animated expand/collapse) */}
|
|
||||||
<div
|
|
||||||
className={`overflow-hidden bg-gray-900 transition-[max-height,opacity] duration-300 ease-in-out ${
|
|
||||||
isCollapsed ? "max-h-0 opacity-0" : "max-h-64 opacity-100"
|
|
||||||
}`}
|
|
||||||
style={{ maxHeight: isCollapsed ? "0px" : "16rem" }}
|
|
||||||
aria-hidden={isCollapsed}
|
|
||||||
>
|
|
||||||
<div ref={bottomRef} className="h-64 overflow-y-auto p-4 space-y-1">
|
|
||||||
{events.map((event, index) => (
|
|
||||||
<div key={`${event.id}-${index}`} className="text-green-400">
|
|
||||||
<span className="text-blue-400">$</span>
|
|
||||||
<span className="text-yellow-400 ml-2">[{event.type || ""}]</span>
|
|
||||||
<span className="text-gray-300 ml-4 mt-1 pl-2 border-l-2 border-gray-600">
|
|
||||||
{event.text || ""}...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{events.length === 0 && (
|
|
||||||
<div className="text-gray-500 italic">No agent events to display...</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
import { ExternalLink } from "lucide-react";
|
|
||||||
import { memo, useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { getConnectorIcon } from "./ConnectorComponents";
|
|
||||||
import type { Source } from "./types";
|
|
||||||
|
|
||||||
type CitationProps = {
|
|
||||||
citationId: number;
|
|
||||||
citationText: string;
|
|
||||||
position: number;
|
|
||||||
source: Source | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Citation component to handle individual citations
|
|
||||||
*/
|
|
||||||
export const Citation = memo(({ citationId, citationText, position, source }: CitationProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const citationKey = `citation-${citationId}-${position}`;
|
|
||||||
|
|
||||||
if (!source) return <>{citationText}</>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span key={citationKey} className="relative inline-flex items-center">
|
|
||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<sup>
|
|
||||||
<span className="inline-flex items-center justify-center text-primary cursor-pointer bg-primary/10 hover:bg-primary/15 w-4 h-4 rounded-full text-[10px] font-medium ml-0.5 transition-colors border border-primary/20 shadow-sm">
|
|
||||||
{citationId}
|
|
||||||
</span>
|
|
||||||
</sup>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
{open && (
|
|
||||||
<DropdownMenuContent align="start" className="w-80 p-0" forceMount>
|
|
||||||
<Card className="border-0 shadow-none">
|
|
||||||
<div className="p-3 flex items-start gap-3">
|
|
||||||
<div className="flex-shrink-0 w-7 h-7 flex items-center justify-center bg-muted rounded-full">
|
|
||||||
{getConnectorIcon(source.connectorType || "")}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<h3 className="font-medium text-sm text-card-foreground">{source.title}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">{source.description}</p>
|
|
||||||
<div className="mt-2 flex items-center text-xs text-muted-foreground">
|
|
||||||
<span className="truncate max-w-[200px]">{source.url}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 rounded-full"
|
|
||||||
onClick={() => window.open(source.url, "_blank", "noopener,noreferrer")}
|
|
||||||
title="Open in new tab"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
)}
|
|
||||||
</DropdownMenu>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Citation.displayName = "Citation";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Function to render text with citations
|
|
||||||
*/
|
|
||||||
export const renderTextWithCitations = (
|
|
||||||
text: string,
|
|
||||||
getCitationSource: (id: number) => Source | null
|
|
||||||
) => {
|
|
||||||
// Regular expression to find citation patterns like [1], [2], etc.
|
|
||||||
const citationRegex = /\[(\d+)\]/g;
|
|
||||||
const parts = [];
|
|
||||||
let lastIndex = 0;
|
|
||||||
let match: RegExpExecArray | null = citationRegex.exec(text);
|
|
||||||
let position = 0;
|
|
||||||
|
|
||||||
while (match !== null) {
|
|
||||||
// Add text before the citation
|
|
||||||
if (match.index > lastIndex) {
|
|
||||||
parts.push(text.substring(lastIndex, match.index));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the citation component
|
|
||||||
const citationId = parseInt(match[1], 10);
|
|
||||||
parts.push(
|
|
||||||
<Citation
|
|
||||||
key={`citation-${citationId}-${position}`}
|
|
||||||
citationId={citationId}
|
|
||||||
citationText={match[0]}
|
|
||||||
position={position}
|
|
||||||
source={getCitationSource(citationId)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
lastIndex = match.index + match[0].length;
|
|
||||||
position++;
|
|
||||||
match = citationRegex.exec(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add any remaining text after the last citation
|
|
||||||
if (lastIndex < text.length) {
|
|
||||||
parts.push(text.substring(lastIndex));
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts;
|
|
||||||
};
|
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Check, Copy } from "lucide-react";
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
|
||||||
import { oneDark, oneLight } from "react-syntax-highlighter/dist/cjs/styles/prism";
|
|
||||||
|
|
||||||
// Constants for styling and configuration
|
|
||||||
const COPY_TIMEOUT = 2000;
|
|
||||||
|
|
||||||
const BASE_CUSTOM_STYLE = {
|
|
||||||
margin: 0,
|
|
||||||
borderRadius: "0.375rem",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
lineHeight: "1.5rem",
|
|
||||||
border: "none",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const LINE_PROPS_STYLE = {
|
|
||||||
wordBreak: "break-all" as const,
|
|
||||||
whiteSpace: "pre-wrap" as const,
|
|
||||||
border: "none",
|
|
||||||
borderBottom: "none",
|
|
||||||
paddingLeft: 0,
|
|
||||||
paddingRight: 0,
|
|
||||||
margin: "0.25rem 0",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const CODE_TAG_PROPS = {
|
|
||||||
className: "font-mono",
|
|
||||||
style: {
|
|
||||||
border: "none",
|
|
||||||
background: "var(--syntax-bg)",
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// TypeScript interfaces
|
|
||||||
interface CodeBlockProps {
|
|
||||||
children: string;
|
|
||||||
language: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type LanguageRenderer = (props: { code: string }) => React.JSX.Element;
|
|
||||||
|
|
||||||
interface SyntaxStyle {
|
|
||||||
[key: string]: React.CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memoized fallback component for SSR/hydration
|
|
||||||
const FallbackCodeBlock = memo(({ children }: { children: string }) => (
|
|
||||||
<div className="bg-muted p-4 rounded-md">
|
|
||||||
<pre className="m-0 p-0 border-0">
|
|
||||||
<code className="text-xs font-mono border-0 leading-6">{children}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
|
|
||||||
FallbackCodeBlock.displayName = "FallbackCodeBlock";
|
|
||||||
|
|
||||||
// Code block component with syntax highlighting and copy functionality
|
|
||||||
export const CodeBlock = memo<CodeBlockProps>(({ children, language }) => {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const { resolvedTheme, theme } = useTheme();
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
// Prevent hydration issues
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Memoize theme detection
|
|
||||||
const isDarkTheme = useMemo(
|
|
||||||
() => mounted && (resolvedTheme === "dark" || theme === "dark"),
|
|
||||||
[mounted, resolvedTheme, theme]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize syntax theme selection
|
|
||||||
const syntaxTheme = useMemo(() => (isDarkTheme ? oneDark : oneLight), [isDarkTheme]);
|
|
||||||
|
|
||||||
// Memoize enhanced style with theme-specific modifications
|
|
||||||
const enhancedStyle = useMemo<SyntaxStyle>(
|
|
||||||
() => ({
|
|
||||||
...syntaxTheme,
|
|
||||||
'pre[class*="language-"]': {
|
|
||||||
...syntaxTheme['pre[class*="language-"]'],
|
|
||||||
margin: 0,
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "0.375rem",
|
|
||||||
background: "var(--syntax-bg)",
|
|
||||||
},
|
|
||||||
'code[class*="language-"]': {
|
|
||||||
...syntaxTheme['code[class*="language-"]'],
|
|
||||||
border: "none",
|
|
||||||
background: "var(--syntax-bg)",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[syntaxTheme]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize custom style with background
|
|
||||||
const customStyle = useMemo(
|
|
||||||
() => ({
|
|
||||||
...BASE_CUSTOM_STYLE,
|
|
||||||
backgroundColor: "var(--syntax-bg)",
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoized copy handler
|
|
||||||
const handleCopy = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(children);
|
|
||||||
setCopied(true);
|
|
||||||
const timeoutId = setTimeout(() => setCopied(false), COPY_TIMEOUT);
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to copy code to clipboard:", error);
|
|
||||||
}
|
|
||||||
}, [children]);
|
|
||||||
|
|
||||||
// Memoized line props with style
|
|
||||||
const lineProps = useMemo(
|
|
||||||
() => ({
|
|
||||||
style: LINE_PROPS_STYLE,
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Early return for non-mounted state
|
|
||||||
if (!mounted) {
|
|
||||||
return <FallbackCodeBlock>{children}</FallbackCodeBlock>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative my-4 group">
|
|
||||||
<div className="absolute right-2 top-2 z-10">
|
|
||||||
<button
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="p-1.5 rounded-md bg-background/80 hover:bg-background border border-border flex items-center justify-center transition-colors"
|
|
||||||
aria-label="Copy code"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<Check size={14} className="text-green-500" />
|
|
||||||
) : (
|
|
||||||
<Copy size={14} className="text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<SyntaxHighlighter
|
|
||||||
language={language || "text"}
|
|
||||||
style={enhancedStyle}
|
|
||||||
customStyle={customStyle}
|
|
||||||
codeTagProps={CODE_TAG_PROPS}
|
|
||||||
showLineNumbers={false}
|
|
||||||
wrapLines={false}
|
|
||||||
lineProps={lineProps}
|
|
||||||
PreTag="div"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SyntaxHighlighter>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
CodeBlock.displayName = "CodeBlock";
|
|
||||||
|
|
||||||
// Optimized language renderer factory with memoization
|
|
||||||
const createLanguageRenderer = (lang: string): LanguageRenderer => {
|
|
||||||
const renderer = ({ code }: { code: string }) => <CodeBlock language={lang}>{code}</CodeBlock>;
|
|
||||||
renderer.displayName = `LanguageRenderer(${lang})`;
|
|
||||||
return renderer;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pre-defined supported languages for better maintainability
|
|
||||||
const SUPPORTED_LANGUAGES = [
|
|
||||||
"javascript",
|
|
||||||
"typescript",
|
|
||||||
"python",
|
|
||||||
"java",
|
|
||||||
"csharp",
|
|
||||||
"cpp",
|
|
||||||
"c",
|
|
||||||
"php",
|
|
||||||
"ruby",
|
|
||||||
"go",
|
|
||||||
"rust",
|
|
||||||
"swift",
|
|
||||||
"kotlin",
|
|
||||||
"scala",
|
|
||||||
"sql",
|
|
||||||
"json",
|
|
||||||
"xml",
|
|
||||||
"yaml",
|
|
||||||
"bash",
|
|
||||||
"shell",
|
|
||||||
"powershell",
|
|
||||||
"dockerfile",
|
|
||||||
"html",
|
|
||||||
"css",
|
|
||||||
"scss",
|
|
||||||
"less",
|
|
||||||
"markdown",
|
|
||||||
"text",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
// Generate language renderers efficiently
|
|
||||||
export const languageRenderers: Record<string, LanguageRenderer> = Object.fromEntries(
|
|
||||||
SUPPORTED_LANGUAGES.map((lang) => [lang, createLanguageRenderer(lang)])
|
|
||||||
);
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
import { ChevronDown, Plus } from "lucide-react";
|
|
||||||
import type React from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
|
||||||
import type { Connector } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Displays a small icon for a connector type
|
|
||||||
*/
|
|
||||||
export const ConnectorIcon = ({ type, index = 0 }: { type: string; index?: number }) => (
|
|
||||||
<div
|
|
||||||
className="w-4 h-4 rounded-full flex items-center justify-center bg-muted border border-background"
|
|
||||||
style={{ zIndex: 10 - index }}
|
|
||||||
>
|
|
||||||
{getConnectorIcon(type)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Displays a count indicator for additional connectors
|
|
||||||
*/
|
|
||||||
export const ConnectorCountBadge = ({ count }: { count: number }) => (
|
|
||||||
<div className="w-4 h-4 rounded-full flex items-center justify-center bg-primary text-primary-foreground text-[8px] font-medium border border-background z-0">
|
|
||||||
+{count}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
type ConnectorButtonProps = {
|
|
||||||
selectedConnectors: string[];
|
|
||||||
onClick: () => void;
|
|
||||||
connectorSources: Connector[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Button that displays selected connectors and opens connector selection dialog
|
|
||||||
*/
|
|
||||||
export const ConnectorButton = ({
|
|
||||||
selectedConnectors,
|
|
||||||
onClick,
|
|
||||||
connectorSources,
|
|
||||||
}: ConnectorButtonProps) => {
|
|
||||||
const totalConnectors = connectorSources.length;
|
|
||||||
const selectedCount = selectedConnectors.length;
|
|
||||||
const progressPercentage = (selectedCount / totalConnectors) * 100;
|
|
||||||
|
|
||||||
// Get the name of a single selected connector
|
|
||||||
const getSingleConnectorName = () => {
|
|
||||||
const connector = connectorSources.find((c) => c.type === selectedConnectors[0]);
|
|
||||||
return connector?.name || "";
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get display text based on selection count
|
|
||||||
const getDisplayText = () => {
|
|
||||||
if (selectedCount === totalConnectors) return "All Connectors";
|
|
||||||
if (selectedCount === 1) return getSingleConnectorName();
|
|
||||||
return `${selectedCount} Connectors`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render the empty state (no connectors selected)
|
|
||||||
const renderEmptyState = () => (
|
|
||||||
<>
|
|
||||||
<Plus className="h-3 w-3 text-muted-foreground" />
|
|
||||||
<span className="text-muted-foreground">Select Connectors</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Render the selected connectors preview
|
|
||||||
const renderSelectedConnectors = () => (
|
|
||||||
<>
|
|
||||||
<div className="flex -space-x-1.5 mr-1">
|
|
||||||
{/* Show up to 3 connector icons */}
|
|
||||||
{selectedConnectors.slice(0, 3).map((type, index) => (
|
|
||||||
<ConnectorIcon key={type} type={type} index={index} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Show count indicator if more than 3 connectors are selected */}
|
|
||||||
{selectedCount > 3 && <ConnectorCountBadge count={selectedCount - 3} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Display text */}
|
|
||||||
<span className="font-medium">{getDisplayText()}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 px-2 text-xs font-medium rounded-md border-border relative overflow-hidden group"
|
|
||||||
onClick={onClick}
|
|
||||||
aria-label={
|
|
||||||
selectedCount === 0 ? "Select Connectors" : `${selectedCount} connectors selected`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Progress indicator */}
|
|
||||||
<div
|
|
||||||
className="absolute bottom-0 left-0 h-1 bg-primary"
|
|
||||||
style={{
|
|
||||||
width: `${progressPercentage}%`,
|
|
||||||
transition: "width 0.3s ease",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 z-10 relative">
|
|
||||||
{selectedCount === 0 ? renderEmptyState() : renderSelectedConnectors()}
|
|
||||||
<ChevronDown className="h-3 w-3 ml-0.5 text-muted-foreground opacity-70" />
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,604 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
type ColumnDef,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
type SortingState,
|
|
||||||
useReactTable,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { ArrowUpDown, Calendar, FileText, Filter, Plus, Search } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
|
||||||
import type { Document, DocumentTypeEnum } from "@/contracts/types/document.types";
|
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|
||||||
|
|
||||||
interface DocumentsDataTableProps {
|
|
||||||
searchSpaceId: number;
|
|
||||||
onSelectionChange: (documents: Document[]) => void;
|
|
||||||
onDone: () => void;
|
|
||||||
initialSelectedDocuments?: Document[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function useDebounced<T>(value: T, delay = 300) {
|
|
||||||
const [debounced, setDebounced] = useState(value);
|
|
||||||
useEffect(() => {
|
|
||||||
const t = setTimeout(() => setDebounced(value), delay);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}, [value, delay]);
|
|
||||||
return debounced;
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: ColumnDef<Document>[] = [
|
|
||||||
{
|
|
||||||
id: "select",
|
|
||||||
header: ({ table }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={
|
|
||||||
table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")
|
|
||||||
}
|
|
||||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
|
||||||
aria-label="Select all"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
|
||||||
aria-label="Select row"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
|
||||||
size: 40,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "title",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
className="h-8 px-1 sm:px-2 font-medium text-left justify-start"
|
|
||||||
>
|
|
||||||
<FileText className="mr-1 sm:mr-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" />
|
|
||||||
<span className="hidden sm:inline">Title</span>
|
|
||||||
<span className="sm:hidden">Doc</span>
|
|
||||||
<ArrowUpDown className="ml-1 sm:ml-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const title = row.getValue("title") as string;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="font-medium max-w-[120px] sm:max-w-[250px] truncate text-xs sm:text-sm"
|
|
||||||
title={title}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "document_type",
|
|
||||||
header: "Type",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const type = row.getValue("document_type") as DocumentType;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2" title={String(type)}>
|
|
||||||
<span className="text-primary">{getConnectorIcon(String(type))}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
size: 80,
|
|
||||||
meta: {
|
|
||||||
className: "hidden sm:table-cell",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "content",
|
|
||||||
header: "Preview",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const content = row.getValue("content") as string;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="text-muted-foreground max-w-[150px] sm:max-w-[350px] truncate text-[10px] sm:text-sm"
|
|
||||||
title={content}
|
|
||||||
>
|
|
||||||
<span className="sm:hidden">{content.substring(0, 30)}...</span>
|
|
||||||
<span className="hidden sm:inline">{content.substring(0, 100)}...</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
enableSorting: false,
|
|
||||||
meta: {
|
|
||||||
className: "hidden md:table-cell",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "created_at",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
className="h-8 px-1 sm:px-2 font-medium"
|
|
||||||
>
|
|
||||||
<Calendar className="mr-1 sm:mr-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" />
|
|
||||||
<span className="hidden sm:inline">Created</span>
|
|
||||||
<span className="sm:hidden">Date</span>
|
|
||||||
<ArrowUpDown className="ml-1 sm:ml-2 h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const date = new Date(row.getValue("created_at"));
|
|
||||||
return (
|
|
||||||
<div className="text-xs sm:text-sm whitespace-nowrap">
|
|
||||||
<span className="hidden sm:inline">
|
|
||||||
{date.toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<span className="sm:hidden">
|
|
||||||
{date.toLocaleDateString("en-US", {
|
|
||||||
month: "numeric",
|
|
||||||
day: "numeric",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
size: 80,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function DocumentsDataTable({
|
|
||||||
searchSpaceId,
|
|
||||||
onSelectionChange,
|
|
||||||
onDone,
|
|
||||||
initialSelectedDocuments = [],
|
|
||||||
}: DocumentsDataTableProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const debouncedSearch = useDebounced(search, 300);
|
|
||||||
const [documentTypeFilter, setDocumentTypeFilter] = useState<DocumentTypeEnum[]>([]);
|
|
||||||
const [pageIndex, setPageIndex] = useState(0);
|
|
||||||
const [pageSize, setPageSize] = useState(10);
|
|
||||||
const { data: typeCounts } = useAtomValue(documentTypeCountsAtom);
|
|
||||||
|
|
||||||
const fetchQueryParams = useMemo(
|
|
||||||
() => ({
|
|
||||||
search_space_id: searchSpaceId,
|
|
||||||
page: pageIndex,
|
|
||||||
page_size: pageSize,
|
|
||||||
...(documentTypeFilter.length > 0 && { document_types: documentTypeFilter }),
|
|
||||||
}),
|
|
||||||
[searchSpaceId, pageIndex, pageSize, documentTypeFilter, debouncedSearch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const searchQueryParams = useMemo(() => {
|
|
||||||
return {
|
|
||||||
search_space_id: searchSpaceId,
|
|
||||||
page: pageIndex,
|
|
||||||
page_size: pageSize,
|
|
||||||
...(documentTypeFilter.length > 0 && { document_types: documentTypeFilter }),
|
|
||||||
title: debouncedSearch,
|
|
||||||
};
|
|
||||||
}, [debouncedSearch, searchSpaceId, pageIndex, pageSize, documentTypeFilter, debouncedSearch]);
|
|
||||||
|
|
||||||
// Use query for fetching documents
|
|
||||||
const { data: documents, isLoading: isDocumentsLoading } = useQuery({
|
|
||||||
queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams),
|
|
||||||
queryFn: () => documentsApiService.getDocuments({ queryParams: fetchQueryParams }),
|
|
||||||
staleTime: 3 * 60 * 1000, // 3 minutes
|
|
||||||
enabled: !!searchSpaceId && !debouncedSearch.trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Seaching
|
|
||||||
const { data: searchedDocuments, isLoading: isSearchedDocumentsLoading } = useQuery({
|
|
||||||
queryKey: cacheKeys.documents.withQueryParams(searchQueryParams),
|
|
||||||
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
|
|
||||||
staleTime: 3 * 60 * 1000, // 3 minutes
|
|
||||||
enabled: !!searchSpaceId && !!debouncedSearch.trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use query data when not searching, otherwise use hook data
|
|
||||||
const actualDocuments = debouncedSearch.trim()
|
|
||||||
? searchedDocuments?.items || []
|
|
||||||
: documents?.items || [];
|
|
||||||
const actualTotal = debouncedSearch.trim()
|
|
||||||
? searchedDocuments?.total || 0
|
|
||||||
: documents?.total || 0;
|
|
||||||
const actualLoading = debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading;
|
|
||||||
|
|
||||||
// Memoize initial row selection to prevent infinite loops
|
|
||||||
const initialRowSelection = useMemo(() => {
|
|
||||||
if (!initialSelectedDocuments.length) return {};
|
|
||||||
|
|
||||||
const selection: Record<string, boolean> = {};
|
|
||||||
initialSelectedDocuments.forEach((selectedDoc) => {
|
|
||||||
selection[selectedDoc.id] = true;
|
|
||||||
});
|
|
||||||
return selection;
|
|
||||||
}, [initialSelectedDocuments]);
|
|
||||||
|
|
||||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>(
|
|
||||||
() => initialRowSelection
|
|
||||||
);
|
|
||||||
|
|
||||||
// Maintain a separate state for actually selected documents (across all pages)
|
|
||||||
const [selectedDocumentsMap, setSelectedDocumentsMap] = useState<Map<number, Document>>(() => {
|
|
||||||
const map = new Map<number, Document>();
|
|
||||||
initialSelectedDocuments.forEach((doc) => map.set(doc.id, doc));
|
|
||||||
return map;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track the last notified selection to avoid redundant parent calls
|
|
||||||
const lastNotifiedSelection = useRef<string>("");
|
|
||||||
|
|
||||||
// Update row selection only when initialSelectedDocuments changes (not rowSelection itself)
|
|
||||||
useEffect(() => {
|
|
||||||
const initialKeys = Object.keys(initialRowSelection);
|
|
||||||
if (initialKeys.length === 0) return;
|
|
||||||
|
|
||||||
const currentKeys = Object.keys(rowSelection);
|
|
||||||
// Quick length check before expensive comparison
|
|
||||||
if (currentKeys.length === initialKeys.length) {
|
|
||||||
// Check if all keys match (order doesn't matter for Sets)
|
|
||||||
const hasAllKeys = initialKeys.every((key) => rowSelection[key]);
|
|
||||||
if (hasAllKeys) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setRowSelection(initialRowSelection);
|
|
||||||
}, [initialRowSelection]); // Remove rowSelection from dependencies to prevent loop
|
|
||||||
|
|
||||||
// Update the selected documents map when row selection changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!actualDocuments || actualDocuments.length === 0) return;
|
|
||||||
|
|
||||||
setSelectedDocumentsMap((prev) => {
|
|
||||||
const newMap = new Map(prev);
|
|
||||||
let hasChanges = false;
|
|
||||||
|
|
||||||
// Process only current page documents
|
|
||||||
for (const doc of actualDocuments) {
|
|
||||||
const docId = doc.id;
|
|
||||||
const isSelected = rowSelection[docId.toString()];
|
|
||||||
const wasInMap = newMap.has(docId);
|
|
||||||
|
|
||||||
if (isSelected && !wasInMap) {
|
|
||||||
newMap.set(docId, doc);
|
|
||||||
hasChanges = true;
|
|
||||||
} else if (!isSelected && wasInMap) {
|
|
||||||
newMap.delete(docId);
|
|
||||||
hasChanges = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return same reference if no changes to avoid unnecessary re-renders
|
|
||||||
return hasChanges ? newMap : prev;
|
|
||||||
});
|
|
||||||
}, [rowSelection, documents]);
|
|
||||||
|
|
||||||
// Memoize selected documents array
|
|
||||||
const selectedDocumentsArray = useMemo(() => {
|
|
||||||
return Array.from(selectedDocumentsMap.values());
|
|
||||||
}, [selectedDocumentsMap]);
|
|
||||||
|
|
||||||
// Notify parent of selection changes only when content actually changes
|
|
||||||
useEffect(() => {
|
|
||||||
// Create a stable string representation for comparison
|
|
||||||
const selectionKey = selectedDocumentsArray
|
|
||||||
.map((d) => d.id)
|
|
||||||
.sort()
|
|
||||||
.join(",");
|
|
||||||
|
|
||||||
// Skip if selection hasn't actually changed
|
|
||||||
if (selectionKey === lastNotifiedSelection.current) return;
|
|
||||||
|
|
||||||
lastNotifiedSelection.current = selectionKey;
|
|
||||||
onSelectionChange(selectedDocumentsArray);
|
|
||||||
}, [selectedDocumentsArray, onSelectionChange]);
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: actualDocuments || [],
|
|
||||||
columns,
|
|
||||||
getRowId: (row) => row.id.toString(),
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
onRowSelectionChange: setRowSelection,
|
|
||||||
manualPagination: true,
|
|
||||||
pageCount: Math.ceil(actualTotal / pageSize),
|
|
||||||
state: { sorting, rowSelection, pagination: { pageIndex, pageSize } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleClearAll = useCallback(() => {
|
|
||||||
setRowSelection({});
|
|
||||||
setSelectedDocumentsMap(new Map());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelectPage = useCallback(() => {
|
|
||||||
const currentPageRows = table.getRowModel().rows;
|
|
||||||
const newSelection = { ...rowSelection };
|
|
||||||
currentPageRows.forEach((row) => {
|
|
||||||
newSelection[row.id] = true;
|
|
||||||
});
|
|
||||||
setRowSelection(newSelection);
|
|
||||||
}, [table, rowSelection]);
|
|
||||||
|
|
||||||
const handleToggleType = useCallback((type: DocumentTypeEnum, checked: boolean) => {
|
|
||||||
setDocumentTypeFilter((prev) => {
|
|
||||||
if (checked) {
|
|
||||||
return [...prev, type];
|
|
||||||
}
|
|
||||||
return prev.filter((t) => t !== type);
|
|
||||||
});
|
|
||||||
setPageIndex(0); // Reset to first page when filter changes
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectedCount = selectedDocumentsMap.size;
|
|
||||||
|
|
||||||
// Get available document types from type counts (memoized)
|
|
||||||
const availableTypes = useMemo(() => {
|
|
||||||
const types = typeCounts ? (Object.keys(typeCounts) as DocumentTypeEnum[]) : [];
|
|
||||||
return types.length > 0 ? types.sort() : [];
|
|
||||||
}, [typeCounts]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full space-y-3 md:space-y-4">
|
|
||||||
{/* Header Controls */}
|
|
||||||
<div className="space-y-3 md:space-y-4 flex-shrink-0">
|
|
||||||
{/* Search and Filter Row */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
|
||||||
<div className="relative flex-1 max-w-full sm:max-w-sm">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search documents..."
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => {
|
|
||||||
setSearch(event.target.value);
|
|
||||||
setPageIndex(0); // Reset to first page on search
|
|
||||||
}}
|
|
||||||
className="pl-10 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" className="w-full sm:w-auto">
|
|
||||||
<Filter className="mr-2 h-4 w-4 opacity-60" />
|
|
||||||
Type
|
|
||||||
{documentTypeFilter.length > 0 && (
|
|
||||||
<span className="ml-2 inline-flex h-5 items-center rounded border border-border bg-background px-1.5 text-[0.625rem] font-medium text-muted-foreground/70">
|
|
||||||
{documentTypeFilter.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-64 p-3" align="start">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="text-xs font-medium text-muted-foreground">Filter by Type</div>
|
|
||||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
|
||||||
{availableTypes.map((type) => (
|
|
||||||
<div key={type} className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id={`type-${type}`}
|
|
||||||
checked={documentTypeFilter.includes(type)}
|
|
||||||
onCheckedChange={(checked) => handleToggleType(type, !!checked)}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor={`type-${type}`}
|
|
||||||
className="flex grow justify-between gap-2 font-normal text-sm cursor-pointer"
|
|
||||||
>
|
|
||||||
<span>{type.replace(/_/g, " ")}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{typeCounts?.[type]}</span>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{documentTypeFilter.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full text-xs"
|
|
||||||
onClick={() => {
|
|
||||||
setDocumentTypeFilter([]);
|
|
||||||
setPageIndex(0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear Filters
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Controls Row */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
|
||||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
|
||||||
{selectedCount} selected {actualLoading && "· Loading..."}
|
|
||||||
</span>
|
|
||||||
<div className="hidden sm:block h-4 w-px bg-border mx-2" />
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleClearAll}
|
|
||||||
disabled={selectedCount === 0}
|
|
||||||
className="text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
Clear All
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSelectPage}
|
|
||||||
className="text-xs sm:text-sm"
|
|
||||||
disabled={actualLoading}
|
|
||||||
>
|
|
||||||
Select Page
|
|
||||||
</Button>
|
|
||||||
<Select
|
|
||||||
value={pageSize.toString()}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setPageSize(Number(v));
|
|
||||||
setPageIndex(0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[100px] h-8 text-xs">
|
|
||||||
<SelectValue>{pageSize} per page</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{[10, 25, 50, 100].map((size) => (
|
|
||||||
<SelectItem key={size} value={size.toString()}>
|
|
||||||
{size} per page
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={onDone}
|
|
||||||
disabled={selectedCount === 0}
|
|
||||||
className="w-full sm:w-auto sm:min-w-[100px]"
|
|
||||||
>
|
|
||||||
Done ({selectedCount})
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table Container */}
|
|
||||||
<div className="border rounded-lg flex-1 min-h-0 overflow-hidden bg-background">
|
|
||||||
<div className="overflow-auto h-full">
|
|
||||||
{actualLoading ? (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full mx-auto" />
|
|
||||||
<p className="text-sm text-muted-foreground">Loading documents...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader className="sticky top-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 z-10">
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<TableRow key={headerGroup.id} className="border-b">
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<TableHead key={header.id} className="h-12 text-xs sm:text-sm">
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{table.getRowModel().rows?.length ? (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
className="hover:bg-muted/30"
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell key={cell.id} className="py-3 text-xs sm:text-sm">
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={columns.length} className="h-64">
|
|
||||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
|
||||||
<div className="rounded-full bg-muted p-3">
|
|
||||||
<FileText className="h-6 w-6 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 text-center max-w-sm">
|
|
||||||
<h3 className="font-semibold">No documents found</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Get started by adding your first data source to build your knowledge
|
|
||||||
base.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/sources/add`)}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Add Sources
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer Pagination */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs sm:text-sm text-muted-foreground border-t pt-3 md:pt-4 flex-shrink-0">
|
|
||||||
<div className="text-center sm:text-left">
|
|
||||||
Showing {pageIndex * pageSize + 1} to {Math.min((pageIndex + 1) * pageSize, actualTotal)}{" "}
|
|
||||||
of {actualTotal} documents
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center sm:justify-end space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
|
|
||||||
disabled={pageIndex === 0 || actualLoading}
|
|
||||||
className="text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center space-x-1 text-xs sm:text-sm">
|
|
||||||
<span>Page</span>
|
|
||||||
<strong>{pageIndex + 1}</strong>
|
|
||||||
<span>of</span>
|
|
||||||
<strong>{Math.ceil(actualTotal / pageSize)}</strong>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPageIndex((p) => p + 1)}
|
|
||||||
disabled={pageIndex >= Math.ceil(actualTotal / pageSize) - 1 || actualLoading}
|
|
||||||
className="text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
/**
|
|
||||||
* Determines if a podcast is stale compared to the current chat state.
|
|
||||||
* A podcast is considered stale if:
|
|
||||||
* - The chat's current state_version is greater than the podcast's chat_state_version
|
|
||||||
*
|
|
||||||
* @param chatVersion - The current state_version of the chat
|
|
||||||
* @param podcastVersion - The chat_state_version stored when the podcast was generated (nullable)
|
|
||||||
* @returns true if the podcast is stale, false otherwise
|
|
||||||
*/
|
|
||||||
export function isPodcastStale(
|
|
||||||
chatVersion: number,
|
|
||||||
podcastVersion: number | null | undefined
|
|
||||||
): boolean {
|
|
||||||
// If podcast has no version, it's stale (generated before this feature)
|
|
||||||
if (!podcastVersion) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// If chat version is greater than podcast version, it's stale : We can change this condition to consider staleness after a huge number of updates
|
|
||||||
return chatVersion > podcastVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a human-readable message about podcast staleness
|
|
||||||
*
|
|
||||||
* @param chatVersion - The current state_version of the chat
|
|
||||||
* @param podcastVersion - The chat_state_version stored when the podcast was generated
|
|
||||||
* @returns A descriptive message about the podcast's staleness status
|
|
||||||
*/
|
|
||||||
export function getPodcastStalenessMessage(
|
|
||||||
chatVersion: number,
|
|
||||||
podcastVersion: number | null | undefined
|
|
||||||
): string {
|
|
||||||
if (!podcastVersion) {
|
|
||||||
return "This podcast was generated before chat updates were tracked. Consider regenerating it.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chatVersion > podcastVersion) {
|
|
||||||
const versionDiff = chatVersion - podcastVersion;
|
|
||||||
return `This podcast is outdated. The chat has been updated ${versionDiff} time${versionDiff > 1 ? "s" : ""} since this podcast was generated.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "This podcast is up to date with the current chat.";
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
import { type RefObject, useEffect } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Function to scroll to the bottom of a container
|
|
||||||
*/
|
|
||||||
export const scrollToBottom = (ref: RefObject<HTMLDivElement>) => {
|
|
||||||
ref.current?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to scroll to bottom when messages change
|
|
||||||
*/
|
|
||||||
export const useScrollToBottom = (ref: RefObject<HTMLDivElement>, dependencies: any[]) => {
|
|
||||||
useEffect(() => {
|
|
||||||
scrollToBottom(ref);
|
|
||||||
}, dependencies);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Function to check scroll position and update indicators
|
|
||||||
*/
|
|
||||||
export const updateScrollIndicators = (
|
|
||||||
tabsListRef: RefObject<HTMLDivElement>,
|
|
||||||
setCanScrollLeft: (value: boolean) => void,
|
|
||||||
setCanScrollRight: (value: boolean) => void
|
|
||||||
) => {
|
|
||||||
if (tabsListRef.current) {
|
|
||||||
const { scrollLeft, scrollWidth, clientWidth } = tabsListRef.current;
|
|
||||||
setCanScrollLeft(scrollLeft > 0);
|
|
||||||
setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 10); // 10px buffer
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to initialize scroll indicators and add resize listener
|
|
||||||
*/
|
|
||||||
export const useScrollIndicators = (
|
|
||||||
tabsListRef: RefObject<HTMLDivElement>,
|
|
||||||
setCanScrollLeft: (value: boolean) => void,
|
|
||||||
setCanScrollRight: (value: boolean) => void
|
|
||||||
) => {
|
|
||||||
const updateIndicators = () =>
|
|
||||||
updateScrollIndicators(tabsListRef, setCanScrollLeft, setCanScrollRight);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
updateIndicators();
|
|
||||||
// Add resize listener to update indicators when window size changes
|
|
||||||
window.addEventListener("resize", updateIndicators);
|
|
||||||
return () => window.removeEventListener("resize", updateIndicators);
|
|
||||||
}, [updateIndicators]);
|
|
||||||
|
|
||||||
return updateIndicators;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Function to scroll tabs list left
|
|
||||||
*/
|
|
||||||
export const scrollTabsLeft = (
|
|
||||||
tabsListRef: RefObject<HTMLDivElement>,
|
|
||||||
updateIndicators: () => void
|
|
||||||
) => {
|
|
||||||
if (tabsListRef.current) {
|
|
||||||
tabsListRef.current.scrollBy({ left: -200, behavior: "smooth" });
|
|
||||||
// Update indicators after scrolling
|
|
||||||
setTimeout(updateIndicators, 300);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Function to scroll tabs list right
|
|
||||||
*/
|
|
||||||
export const scrollTabsRight = (
|
|
||||||
tabsListRef: RefObject<HTMLDivElement>,
|
|
||||||
updateIndicators: () => void
|
|
||||||
) => {
|
|
||||||
if (tabsListRef.current) {
|
|
||||||
tabsListRef.current.scrollBy({ left: 200, behavior: "smooth" });
|
|
||||||
// Update indicators after scrolling
|
|
||||||
setTimeout(updateIndicators, 300);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import type React from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
type SegmentedControlProps<T extends string> = {
|
|
||||||
value: T;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
options: Array<{
|
|
||||||
value: T;
|
|
||||||
label: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A segmented control component for selecting between different options
|
|
||||||
*/
|
|
||||||
function SegmentedControl<T extends string>({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
}: SegmentedControlProps<T>) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-7 rounded-md border border-border overflow-hidden">
|
|
||||||
{options.map((option) => (
|
|
||||||
<Button
|
|
||||||
key={option.value}
|
|
||||||
className={`flex h-full items-center gap-1 px-2 text-xs transition-colors ${
|
|
||||||
value === option.value ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
|
||||||
}`}
|
|
||||||
onClick={() => onChange(option.value)}
|
|
||||||
aria-pressed={value === option.value}
|
|
||||||
>
|
|
||||||
{option.icon}
|
|
||||||
<span>{option.label}</span>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SegmentedControl;
|
|
||||||
|
|
@ -1,254 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { ChevronDown, ChevronUp, ExternalLink, Loader2 } from "lucide-react";
|
|
||||||
import type React from "react";
|
|
||||||
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
||||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from "@/components/ui/sheet";
|
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface SourceDetailSheetProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
chunkId: number;
|
|
||||||
sourceType: string;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
url?: string;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDocumentType = (type: string) => {
|
|
||||||
return type
|
|
||||||
.split("_")
|
|
||||||
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
|
|
||||||
.join(" ");
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SourceDetailSheet({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
chunkId,
|
|
||||||
sourceType,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
url,
|
|
||||||
children,
|
|
||||||
}: SourceDetailSheetProps) {
|
|
||||||
const chunksContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const highlightedChunkRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [summaryOpen, setSummaryOpen] = useState(false);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: document,
|
|
||||||
isLoading: isDocumentByChunkFetching,
|
|
||||||
error: documentByChunkFetchingError,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: cacheKeys.documents.byChunk(chunkId.toString()),
|
|
||||||
queryFn: () => documentsApiService.getDocumentByChunk({ chunk_id: chunkId }),
|
|
||||||
enabled: !!chunkId && open,
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if this is a source type that should render directly from node
|
|
||||||
const isDirectRenderSource =
|
|
||||||
sourceType === "TAVILY_API" ||
|
|
||||||
sourceType === "LINKUP_API" ||
|
|
||||||
sourceType === "SEARXNG_API" ||
|
|
||||||
sourceType === "BAIDU_SEARCH_API";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Scroll to highlighted chunk when document loads
|
|
||||||
if (document) {
|
|
||||||
setTimeout(() => {
|
|
||||||
highlightedChunkRef.current?.scrollIntoView({
|
|
||||||
behavior: "smooth",
|
|
||||||
block: "start",
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}, [document, open]);
|
|
||||||
|
|
||||||
const handleUrlClick = (e: React.MouseEvent, clickUrl: string) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
window.open(clickUrl, "_blank", "noopener,noreferrer");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
||||||
{children}
|
|
||||||
<SheetContent side="right" className="w-full sm:max-w-5xl lg:max-w-7xl">
|
|
||||||
<SheetHeader className="px-6 py-4 border-b">
|
|
||||||
<SheetTitle className="flex items-center gap-3 text-lg">
|
|
||||||
{getConnectorIcon(sourceType)}
|
|
||||||
{document?.title || title}
|
|
||||||
</SheetTitle>
|
|
||||||
<SheetDescription className="text-base mt-2">
|
|
||||||
{document
|
|
||||||
? formatDocumentType(document.document_type)
|
|
||||||
: sourceType && formatDocumentType(sourceType)}
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
{!isDirectRenderSource && isDocumentByChunkFetching && (
|
|
||||||
<div className="flex items-center justify-center h-64 px-6">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isDirectRenderSource && documentByChunkFetchingError && (
|
|
||||||
<div className="flex items-center justify-center h-64 px-6">
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
{documentByChunkFetchingError.message || "Failed to load document"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Direct render for web search providers */}
|
|
||||||
{isDirectRenderSource && (
|
|
||||||
<ScrollArea className="h-[calc(100vh-10rem)]">
|
|
||||||
<div className="px-6 py-4">
|
|
||||||
{/* External Link */}
|
|
||||||
{url && (
|
|
||||||
<div className="mb-8">
|
|
||||||
<Button
|
|
||||||
size="default"
|
|
||||||
variant="outline"
|
|
||||||
onClick={(e) => handleUrlClick(e, url)}
|
|
||||||
className="w-full py-3"
|
|
||||||
>
|
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
|
||||||
Open in Browser
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Source Information */}
|
|
||||||
<div className="mb-8 p-6 bg-muted/50 rounded-lg border">
|
|
||||||
<h3 className="text-base font-semibold mb-4">Source Information</h3>
|
|
||||||
<div className="text-sm text-muted-foreground mb-3 font-medium">
|
|
||||||
{title || "Untitled"}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
|
|
||||||
{description || "No content available"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* API-fetched document content */}
|
|
||||||
{!isDirectRenderSource && document && (
|
|
||||||
<ScrollArea className="h-[calc(100vh-10rem)]">
|
|
||||||
<div className="px-6 py-4">
|
|
||||||
{/* Document Metadata */}
|
|
||||||
{document.document_metadata && Object.keys(document.document_metadata).length > 0 && (
|
|
||||||
<div className="mb-8 p-6 bg-muted/50 rounded-lg border">
|
|
||||||
<h3 className="text-base font-semibold mb-4">Document Information</h3>
|
|
||||||
<dl className="grid grid-cols-1 gap-3 text-sm">
|
|
||||||
{Object.entries(document.document_metadata).map(([key, value]) => (
|
|
||||||
<div key={key} className="flex gap-3">
|
|
||||||
<dt className="font-medium text-muted-foreground capitalize min-w-0 flex-shrink-0">
|
|
||||||
{key.replace(/_/g, " ")}:
|
|
||||||
</dt>
|
|
||||||
<dd className="text-foreground break-words">{String(value)}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* External Link */}
|
|
||||||
{url && (
|
|
||||||
<div className="mb-8">
|
|
||||||
<Button
|
|
||||||
size="default"
|
|
||||||
variant="outline"
|
|
||||||
onClick={(e) => handleUrlClick(e, url)}
|
|
||||||
className="w-full py-3"
|
|
||||||
>
|
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
|
||||||
Open in Browser
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Chunks */}
|
|
||||||
<div className="space-y-6" ref={chunksContainerRef}>
|
|
||||||
<div className="mb-4">
|
|
||||||
{/* Header row: header and button side by side */}
|
|
||||||
<div className="flex flex-row items-center gap-4">
|
|
||||||
<h3 className="text-base font-semibold mb-2 md:mb-0">Document Content</h3>
|
|
||||||
{document.content && (
|
|
||||||
<Collapsible open={summaryOpen} onOpenChange={setSummaryOpen}>
|
|
||||||
<CollapsibleTrigger className="flex items-center gap-2 py-2 px-3 font-medium border rounded-md bg-muted hover:bg-muted/80 transition-colors">
|
|
||||||
<span>Summary</span>
|
|
||||||
{summaryOpen ? (
|
|
||||||
<ChevronUp className="h-4 w-4 transition-transform" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-4 w-4 transition-transform" />
|
|
||||||
)}
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
</Collapsible>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Expanded summary content: always full width, below the row */}
|
|
||||||
{document.content && (
|
|
||||||
<Collapsible open={summaryOpen} onOpenChange={setSummaryOpen}>
|
|
||||||
<CollapsibleContent className="pt-2 w-full">
|
|
||||||
<div className="p-6 bg-muted/50 rounded-lg border">
|
|
||||||
<MarkdownViewer content={document.content} />
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{document.chunks.map((chunk, idx) => (
|
|
||||||
<div
|
|
||||||
key={chunk.id}
|
|
||||||
ref={chunk.id === chunkId ? highlightedChunkRef : null}
|
|
||||||
className={cn(
|
|
||||||
"p-6 rounded-lg border transition-all duration-300",
|
|
||||||
chunk.id === chunkId
|
|
||||||
? "bg-primary/10 border-primary shadow-md ring-1 ring-primary/20"
|
|
||||||
: "bg-background border-border hover:bg-muted/50 hover:border-muted-foreground/20"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
|
||||||
Chunk {idx + 1} of {document.chunks.length}
|
|
||||||
</span>
|
|
||||||
{chunk.id === chunkId && (
|
|
||||||
<span className="text-sm font-medium text-primary bg-primary/10 px-3 py-1 rounded-full">
|
|
||||||
Referenced Chunk
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
|
|
||||||
<MarkdownViewer content={chunk.content} className="max-w-fit" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
import type { Connector, Source } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Function to get sources for the main view
|
|
||||||
*/
|
|
||||||
export const getMainViewSources = (connector: Connector, initialSourcesDisplay: number) => {
|
|
||||||
return connector.sources?.slice(0, initialSourcesDisplay);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Function to get filtered sources for the dialog
|
|
||||||
*/
|
|
||||||
export const getFilteredSources = (connector: Connector, sourceFilter: string) => {
|
|
||||||
if (!sourceFilter.trim()) {
|
|
||||||
return connector.sources;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filter = sourceFilter.toLowerCase().trim();
|
|
||||||
return connector.sources?.filter(
|
|
||||||
(source) =>
|
|
||||||
source.title.toLowerCase().includes(filter) ||
|
|
||||||
source.description.toLowerCase().includes(filter)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Function to get paginated and filtered sources for the dialog
|
|
||||||
*/
|
|
||||||
export const getPaginatedDialogSources = (
|
|
||||||
connector: Connector,
|
|
||||||
sourceFilter: string,
|
|
||||||
expandedSources: boolean,
|
|
||||||
sourcesPage: number,
|
|
||||||
sourcesPerPage: number
|
|
||||||
) => {
|
|
||||||
const filteredSources = getFilteredSources(connector, sourceFilter);
|
|
||||||
|
|
||||||
if (expandedSources) {
|
|
||||||
return filteredSources;
|
|
||||||
}
|
|
||||||
return filteredSources?.slice(0, sourcesPage * sourcesPerPage);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Function to get the count of sources for a connector type
|
|
||||||
*/
|
|
||||||
export const getSourcesCount = (connectorSources: Connector[], connectorType: string) => {
|
|
||||||
const connector = connectorSources.find((c) => c.type === connectorType);
|
|
||||||
return connector?.sources?.length || 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Function to get a citation source by ID
|
|
||||||
*/
|
|
||||||
export const getCitationSource = (
|
|
||||||
citationId: number,
|
|
||||||
connectorSources: Connector[]
|
|
||||||
): Source | null => {
|
|
||||||
for (const connector of connectorSources) {
|
|
||||||
const source = connector.sources?.find((s) => s.id === citationId);
|
|
||||||
if (source) {
|
|
||||||
return {
|
|
||||||
...source,
|
|
||||||
connectorType: connector.type,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
// Export all components and utilities from the chat folder
|
|
||||||
|
|
||||||
export * from "./Citation";
|
|
||||||
export * from "./CodeBlock";
|
|
||||||
export * from "./ConnectorComponents";
|
|
||||||
export * from "./ScrollUtils";
|
|
||||||
export { default as SegmentedControl } from "./SegmentedControl";
|
|
||||||
export * from "./SourceUtils";
|
|
||||||
export * from "./types";
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
/**
|
|
||||||
* Types for chat components
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type Source = {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
url: string;
|
|
||||||
connectorType?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Connector = {
|
|
||||||
id: number;
|
|
||||||
type: string;
|
|
||||||
name: string;
|
|
||||||
sources?: Source[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StatusMessage = {
|
|
||||||
id: number;
|
|
||||||
message: string;
|
|
||||||
type: "info" | "success" | "error" | "warning";
|
|
||||||
timestamp: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChatMessage = {
|
|
||||||
id: string;
|
|
||||||
role: "user" | "assistant";
|
|
||||||
content: string;
|
|
||||||
timestamp?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Define message types to match useChat() structure
|
|
||||||
export type MessageRole = "user" | "assistant" | "system" | "data";
|
|
||||||
|
|
||||||
export interface ToolInvocation {
|
|
||||||
state: "call" | "result";
|
|
||||||
toolCallId: string;
|
|
||||||
toolName: string;
|
|
||||||
args: any;
|
|
||||||
result?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToolInvocationUIPart {
|
|
||||||
type: "tool-invocation";
|
|
||||||
toolInvocation: ToolInvocation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ResearchMode = "QNA";
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
|
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
|
|
@ -26,7 +24,6 @@ interface BreadcrumbItemInterface {
|
||||||
export function DashboardBreadcrumb() {
|
export function DashboardBreadcrumb() {
|
||||||
const t = useTranslations("breadcrumb");
|
const t = useTranslations("breadcrumb");
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { data: activeChatState } = useAtomValue(activeChatAtom);
|
|
||||||
// Extract search space ID and chat ID from pathname
|
// Extract search space ID and chat ID from pathname
|
||||||
const segments = pathname.split("/").filter(Boolean);
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
const searchSpaceId = segments[0] === "dashboard" && segments[1] ? segments[1] : null;
|
const searchSpaceId = segments[0] === "dashboard" && segments[1] ? segments[1] : null;
|
||||||
|
|
@ -98,13 +95,11 @@ export function DashboardBreadcrumb() {
|
||||||
|
|
||||||
// Map section names to more readable labels
|
// Map section names to more readable labels
|
||||||
const sectionLabels: Record<string, string> = {
|
const sectionLabels: Record<string, string> = {
|
||||||
researcher: t("researcher"),
|
"new-chat": t("chat") || "Chat",
|
||||||
documents: t("documents"),
|
documents: t("documents"),
|
||||||
connectors: t("connectors"),
|
connectors: t("connectors"),
|
||||||
sources: "Sources",
|
sources: "Sources",
|
||||||
podcasts: t("podcasts"),
|
|
||||||
logs: t("logs"),
|
logs: t("logs"),
|
||||||
chats: t("chats"),
|
|
||||||
settings: t("settings"),
|
settings: t("settings"),
|
||||||
editor: t("editor"),
|
editor: t("editor"),
|
||||||
};
|
};
|
||||||
|
|
@ -169,15 +164,15 @@ export function DashboardBreadcrumb() {
|
||||||
return breadcrumbs;
|
return breadcrumbs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle researcher sub-sections (chat IDs)
|
// Handle new-chat sub-sections (thread IDs)
|
||||||
if (section === "researcher") {
|
if (section === "new-chat") {
|
||||||
// Use the actual chat title if available, otherwise fall back to the ID
|
|
||||||
const chatLabel = activeChatState?.chatDetails?.title || subSection;
|
|
||||||
breadcrumbs.push({
|
breadcrumbs.push({
|
||||||
label: t("researcher"),
|
label: t("chat") || "Chat",
|
||||||
href: `/dashboard/${segments[1]}/researcher`,
|
href: `/dashboard/${segments[1]}/new-chat`,
|
||||||
});
|
});
|
||||||
breadcrumbs.push({ label: chatLabel });
|
if (subSection) {
|
||||||
|
breadcrumbs.push({ label: `Thread ${subSection}` });
|
||||||
|
}
|
||||||
return breadcrumbs;
|
return breadcrumbs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,8 @@ import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||||
import { AlertCircleIcon, Loader2Icon, MicIcon } from "lucide-react";
|
import { AlertCircleIcon, Loader2Icon, MicIcon } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Audio } from "@/components/tool-ui/audio";
|
import { Audio } from "@/components/tool-ui/audio";
|
||||||
import type { PodcastTranscriptEntry } from "@/contracts/types/podcast.types";
|
|
||||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||||
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
|
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||||
import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/podcast-state";
|
import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/podcast-state";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -113,6 +112,14 @@ function AudioLoadingState({ title }: { title: string }) {
|
||||||
/**
|
/**
|
||||||
* Podcast Player Component - Fetches audio and transcript with authentication
|
* Podcast Player Component - Fetches audio and transcript with authentication
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Transcript entry type for podcast transcripts
|
||||||
|
*/
|
||||||
|
interface PodcastTranscriptEntry {
|
||||||
|
speaker_id: number;
|
||||||
|
dialog: string;
|
||||||
|
}
|
||||||
|
|
||||||
function PodcastPlayer({
|
function PodcastPlayer({
|
||||||
podcastId,
|
podcastId,
|
||||||
title,
|
title,
|
||||||
|
|
@ -156,14 +163,22 @@ function PodcastPlayer({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch audio blob and podcast details in parallel
|
// Fetch audio blob and podcast details in parallel
|
||||||
const [audioBlob, podcastDetails] = await Promise.all([
|
const [audioResponse, podcastDetails] = await Promise.all([
|
||||||
podcastsApiService.loadPodcast({
|
authenticatedFetch(
|
||||||
request: { id: podcastId },
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`,
|
||||||
controller,
|
{ method: "GET", signal: controller.signal }
|
||||||
}),
|
),
|
||||||
podcastsApiService.getPodcastById(podcastId),
|
baseApiService.get<{ podcast_transcript?: PodcastTranscriptEntry[] }>(
|
||||||
|
`/api/v1/podcasts/${podcastId}`
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (!audioResponse.ok) {
|
||||||
|
throw new Error(`Failed to load audio: ${audioResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioBlob = await audioResponse.blob();
|
||||||
|
|
||||||
// Create object URL from blob
|
// Create object URL from blob
|
||||||
const objectUrl = URL.createObjectURL(audioBlob);
|
const objectUrl = URL.createObjectURL(audioBlob);
|
||||||
objectUrlRef.current = objectUrl;
|
objectUrlRef.current = objectUrl;
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import type { Message } from "@ai-sdk/react";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { paginationQueryParams } from ".";
|
|
||||||
|
|
||||||
export const chatTypeEnum = z.enum(["QNA"]);
|
|
||||||
|
|
||||||
export const chatSummary = z.object({
|
|
||||||
created_at: z.string(),
|
|
||||||
id: z.number(),
|
|
||||||
type: chatTypeEnum,
|
|
||||||
title: z.string(),
|
|
||||||
search_space_id: z.number(),
|
|
||||||
state_version: z.number(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const chatDetails = chatSummary.extend({
|
|
||||||
initial_connectors: z.array(z.string()).nullable().optional(),
|
|
||||||
messages: z.array(z.any()),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getChatDetailsRequest = chatSummary.pick({ id: true });
|
|
||||||
|
|
||||||
export const getChatsRequest = z.object({
|
|
||||||
queryParams: paginationQueryParams
|
|
||||||
.extend({
|
|
||||||
search_space_id: z.number().or(z.string()).optional(),
|
|
||||||
})
|
|
||||||
.nullish(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const searchChatsRequest = z.object({
|
|
||||||
queryParams: paginationQueryParams.extend({
|
|
||||||
title: z.string(),
|
|
||||||
search_space_id: z.number().or(z.string()).optional(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const deleteChatResponse = z.object({
|
|
||||||
message: z.literal("Chat deleted successfully"),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const deleteChatRequest = chatSummary.pick({ id: true });
|
|
||||||
|
|
||||||
export const createChatRequest = chatDetails.omit({
|
|
||||||
created_at: true,
|
|
||||||
id: true,
|
|
||||||
state_version: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateChatRequest = chatDetails.omit({
|
|
||||||
created_at: true,
|
|
||||||
state_version: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ChatSummary = z.infer<typeof chatSummary>;
|
|
||||||
export type ChatDetails = z.infer<typeof chatDetails> & { messages: Message[] };
|
|
||||||
export type GetChatDetailsRequest = z.infer<typeof getChatDetailsRequest>;
|
|
||||||
export type GetChatsRequest = z.infer<typeof getChatsRequest>;
|
|
||||||
export type SearchChatsRequest = z.infer<typeof searchChatsRequest>;
|
|
||||||
export type DeleteChatResponse = z.infer<typeof deleteChatResponse>;
|
|
||||||
export type DeleteChatRequest = z.infer<typeof deleteChatRequest>;
|
|
||||||
export type CreateChatRequest = z.infer<typeof createChatRequest>;
|
|
||||||
export type UpdateChatRequest = z.infer<typeof updateChatRequest>;
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
import { paginationQueryParams } from ".";
|
|
||||||
|
|
||||||
export const podcastTranscriptEntry = z.object({
|
|
||||||
speaker_id: z.number(),
|
|
||||||
dialog: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const podcast = z.object({
|
|
||||||
id: z.number(),
|
|
||||||
title: z.string(),
|
|
||||||
created_at: z.string(),
|
|
||||||
file_location: z.string(),
|
|
||||||
podcast_transcript: z.array(podcastTranscriptEntry),
|
|
||||||
search_space_id: z.number(),
|
|
||||||
chat_state_version: z.number().nullable(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const generatePodcastRequest = z.object({
|
|
||||||
type: z.enum(["CHAT", "DOCUMENT"]),
|
|
||||||
ids: z.array(z.number()),
|
|
||||||
search_space_id: z.number(),
|
|
||||||
podcast_title: z.string().optional(),
|
|
||||||
user_prompt: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getPodcastByChatIdRequest = z.object({
|
|
||||||
chat_id: z.number(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getPodcastByChaIdResponse = podcast.nullish();
|
|
||||||
|
|
||||||
export const deletePodcastRequest = z.object({
|
|
||||||
id: z.number(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const deletePodcastResponse = z.object({
|
|
||||||
message: z.literal("Podcast deleted successfully"),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const loadPodcastRequest = z.object({
|
|
||||||
id: z.number(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getPodcastsRequest = z.object({
|
|
||||||
queryParams: paginationQueryParams.nullish(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type PodcastTranscriptEntry = z.infer<typeof podcastTranscriptEntry>;
|
|
||||||
export type GeneratePodcastRequest = z.infer<typeof generatePodcastRequest>;
|
|
||||||
export type GetPodcastByChatIdRequest = z.infer<typeof getPodcastByChatIdRequest>;
|
|
||||||
export type GetPodcastByChatIdResponse = z.infer<typeof getPodcastByChaIdResponse>;
|
|
||||||
export type DeletePodcastRequest = z.infer<typeof deletePodcastRequest>;
|
|
||||||
export type DeletePodcastResponse = z.infer<typeof deletePodcastResponse>;
|
|
||||||
export type LoadPodcastRequest = z.infer<typeof loadPodcastRequest>;
|
|
||||||
export type Podcast = z.infer<typeof podcast>;
|
|
||||||
export type GetPodcastsRequest = z.infer<typeof getPodcastsRequest>;
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
|
||||||
type CreateChatRequest,
|
|
||||||
chatDetails,
|
|
||||||
chatSummary,
|
|
||||||
createChatRequest,
|
|
||||||
type DeleteChatRequest,
|
|
||||||
deleteChatRequest,
|
|
||||||
deleteChatResponse,
|
|
||||||
type GetChatDetailsRequest,
|
|
||||||
type GetChatsRequest,
|
|
||||||
getChatDetailsRequest,
|
|
||||||
getChatsRequest,
|
|
||||||
type SearchChatsRequest,
|
|
||||||
searchChatsRequest,
|
|
||||||
type UpdateChatRequest,
|
|
||||||
updateChatRequest,
|
|
||||||
} from "@/contracts/types/chat.types";
|
|
||||||
import { ValidationError } from "../error";
|
|
||||||
import { baseApiService } from "./base-api.service";
|
|
||||||
|
|
||||||
class ChatApiService {
|
|
||||||
getChatDetails = async (request: GetChatDetailsRequest) => {
|
|
||||||
// Validate the request
|
|
||||||
const parsedRequest = getChatDetailsRequest.safeParse(request);
|
|
||||||
|
|
||||||
if (!parsedRequest.success) {
|
|
||||||
console.error("Invalid request:", parsedRequest.error);
|
|
||||||
|
|
||||||
// Format a user frendly error message
|
|
||||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
|
||||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseApiService.get(`/api/v1/chats/${request.id}`, chatDetails);
|
|
||||||
};
|
|
||||||
|
|
||||||
getChats = async (request: GetChatsRequest) => {
|
|
||||||
// Validate the request
|
|
||||||
const parsedRequest = getChatsRequest.safeParse(request);
|
|
||||||
|
|
||||||
if (!parsedRequest.success) {
|
|
||||||
console.error("Invalid request:", parsedRequest.error);
|
|
||||||
|
|
||||||
// Format a user frendly error message
|
|
||||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
|
||||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transform queries params to be string values
|
|
||||||
const transformedQueryParams = parsedRequest.data.queryParams
|
|
||||||
? Object.fromEntries(
|
|
||||||
Object.entries(parsedRequest.data.queryParams).map(([k, v]) => [k, String(v)])
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const queryParams = transformedQueryParams
|
|
||||||
? new URLSearchParams(transformedQueryParams).toString()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return baseApiService.get(`/api/v1/chats?${queryParams}`, z.array(chatSummary));
|
|
||||||
};
|
|
||||||
|
|
||||||
searchChats = async (request: SearchChatsRequest) => {
|
|
||||||
// Validate the request
|
|
||||||
const parsedRequest = searchChatsRequest.safeParse(request);
|
|
||||||
|
|
||||||
if (!parsedRequest.success) {
|
|
||||||
console.error("Invalid request:", parsedRequest.error);
|
|
||||||
|
|
||||||
// Format a user frendly error message
|
|
||||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
|
||||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transform queries params to be string values
|
|
||||||
const transformedQueryParams = Object.fromEntries(
|
|
||||||
Object.entries(parsedRequest.data.queryParams).map(([k, v]) => [k, String(v)])
|
|
||||||
);
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams(transformedQueryParams).toString();
|
|
||||||
|
|
||||||
return baseApiService.get(`/api/v1/chats/search?${queryParams}`, z.array(chatSummary));
|
|
||||||
};
|
|
||||||
|
|
||||||
deleteChat = async (request: DeleteChatRequest) => {
|
|
||||||
// Validate the request
|
|
||||||
const parsedRequest = deleteChatRequest.safeParse(request);
|
|
||||||
|
|
||||||
if (!parsedRequest.success) {
|
|
||||||
console.error("Invalid request:", parsedRequest.error);
|
|
||||||
|
|
||||||
// Format a user frendly error message
|
|
||||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
|
||||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseApiService.delete(`/api/v1/chats/${request.id}`, deleteChatResponse);
|
|
||||||
};
|
|
||||||
|
|
||||||
createChat = async (request: CreateChatRequest) => {
|
|
||||||
// Validate the request
|
|
||||||
const parsedRequest = createChatRequest.safeParse(request);
|
|
||||||
|
|
||||||
if (!parsedRequest.success) {
|
|
||||||
console.error("Invalid request:", parsedRequest.error);
|
|
||||||
|
|
||||||
// Format a user frendly error message
|
|
||||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
|
||||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseApiService.post(
|
|
||||||
`/api/v1/chats`,
|
|
||||||
|
|
||||||
chatSummary,
|
|
||||||
{
|
|
||||||
body: parsedRequest.data,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
updateChat = async (request: UpdateChatRequest) => {
|
|
||||||
// Validate the request
|
|
||||||
const parsedRequest = updateChatRequest.safeParse(request);
|
|
||||||
|
|
||||||
if (!parsedRequest.success) {
|
|
||||||
console.error("Invalid request:", parsedRequest.error);
|
|
||||||
|
|
||||||
// Format a user frendly error message
|
|
||||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
|
||||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { type, title, initial_connectors, messages, search_space_id, id } = parsedRequest.data;
|
|
||||||
|
|
||||||
return baseApiService.put(
|
|
||||||
`/api/v1/chats/${id}`,
|
|
||||||
|
|
||||||
chatSummary,
|
|
||||||
{
|
|
||||||
body: {
|
|
||||||
type,
|
|
||||||
title,
|
|
||||||
initial_connectors,
|
|
||||||
messages,
|
|
||||||
search_space_id,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const chatsApiService = new ChatApiService();
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
import z from "zod";
|
|
||||||
import {
|
|
||||||
type DeletePodcastRequest,
|
|
||||||
deletePodcastRequest,
|
|
||||||
deletePodcastResponse,
|
|
||||||
type GeneratePodcastRequest,
|
|
||||||
type GetPodcastByChatIdRequest,
|
|
||||||
type GetPodcastsRequest,
|
|
||||||
generatePodcastRequest,
|
|
||||||
getPodcastByChaIdResponse,
|
|
||||||
getPodcastByChatIdRequest,
|
|
||||||
getPodcastsRequest,
|
|
||||||
type LoadPodcastRequest,
|
|
||||||
loadPodcastRequest,
|
|
||||||
podcast,
|
|
||||||
} from "@/contracts/types/podcast.types";
|
|
||||||
import { ValidationError } from "../error";
|
|
||||||
import { baseApiService } from "./base-api.service";
|
|
||||||
|
|
||||||
class PodcastsApiService {
|
|
||||||
getPodcasts = async (request: GetPodcastsRequest) => {
|
|
||||||
// Validate the request
|
|
||||||
const parsedRequest = getPodcastsRequest.safeParse(request);
|
|
||||||
|
|
||||||
if (!parsedRequest.success) {
|
|
||||||
console.error("Invalid request:", parsedRequest.error);
|
|
||||||
|
|
||||||
// Format a user frendly error message
|
|
||||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
|
||||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transform queries params to be string values
|
|
||||||
const transformedQueryParams = parsedRequest.data.queryParams
|
|
||||||
? Object.fromEntries(
|
|
||||||
Object.entries(parsedRequest.data.queryParams).map(([k, v]) => [k, String(v)])
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const queryParams = transformedQueryParams
|
|
||||||
? new URLSearchParams(transformedQueryParams).toString()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return baseApiService.get(`/api/v1/podcasts?${queryParams}`, z.array(podcast));
|
|
||||||
};
|
|
||||||
|
|
||||||
getPodcastByChatId = async (request: GetPodcastByChatIdRequest) => {
|
|
||||||
// Validate the request
|
|
||||||
const parsedRequest = getPodcastByChatIdRequest.safeParse(request);
|
|
||||||
|
|
||||||
if (!parsedRequest.success) {
|
|
||||||
console.error("Invalid request:", parsedRequest.error);
|
|
||||||
|
|
||||||
// Format a user frendly error message
|
|
||||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
|
||||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseApiService.get(
|
|
||||||
`/api/v1/podcasts/by-chat/${request.chat_id}`,
|
|
||||||
getPodcastByChaIdResponse
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a podcast by its ID (includes full transcript)
|
|
||||||
*/
|
|
||||||
getPodcastById = async (podcastId: number) => {
|
|
||||||
return baseApiService.get(`/api/v1/podcasts/${podcastId}`, podcast);
|
|
||||||
};
|
|
||||||
|
|
||||||
generatePodcast = async (request: GeneratePodcastRequest) => {
|
|
||||||
// Validate the request
|
|
||||||
const parsedRequest = generatePodcastRequest.safeParse(request);
|
|
||||||
|
|
||||||
if (!parsedRequest.success) {
|
|
||||||
console.error("Invalid request:", parsedRequest.error);
|
|
||||||
|
|
||||||
// Format a user frendly error message
|
|
||||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
|
||||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseApiService.post(`/api/v1/podcasts/generate`, undefined, {
|
|
||||||
body: parsedRequest.data,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
loadPodcast = async ({
|
|
||||||
request,
|
|
||||||
controller,
|
|
||||||
}: {
|
|
||||||
request: LoadPodcastRequest;
|
|
||||||
controller?: AbortController;
|
|
||||||
}) => {
|
|
||||||
// Validate the request
|
|
||||||
const parsedRequest = loadPodcastRequest.safeParse(request);
|
|
||||||
|
|
||||||
if (!parsedRequest.success) {
|
|
||||||
console.error("Invalid request:", parsedRequest.error);
|
|
||||||
|
|
||||||
// Format a user frendly error message
|
|
||||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
|
||||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await baseApiService.getBlob(`/api/v1/podcasts/${request.id}/stream`, {
|
|
||||||
signal: controller?.signal,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
deletePodcast = async (request: DeletePodcastRequest) => {
|
|
||||||
// Validate the request
|
|
||||||
const parsedRequest = deletePodcastRequest.safeParse(request);
|
|
||||||
|
|
||||||
if (!parsedRequest.success) {
|
|
||||||
console.error("Invalid request:", parsedRequest.error);
|
|
||||||
|
|
||||||
// Format a user frendly error message
|
|
||||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
|
||||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseApiService.delete(`/api/v1/podcasts/${request.id}`, deletePodcastResponse);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const podcastsApiService = new PodcastsApiService();
|
|
||||||
304
surfsense_web/lib/chat/attachment-adapter.ts
Normal file
304
surfsense_web/lib/chat/attachment-adapter.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
/**
|
||||||
|
* Attachment adapter for assistant-ui
|
||||||
|
*
|
||||||
|
* This adapter handles file uploads by:
|
||||||
|
* 1. Uploading the file to the backend /attachments/process endpoint
|
||||||
|
* 2. The backend extracts markdown content using the configured ETL service
|
||||||
|
* 3. The extracted content is stored in the attachment and sent with messages
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AttachmentAdapter, CompleteAttachment, PendingAttachment } from "@assistant-ui/react";
|
||||||
|
import { getBearerToken } from "@/lib/auth-utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported file types for the attachment adapter
|
||||||
|
*
|
||||||
|
* - Text/Markdown: .md, .markdown, .txt
|
||||||
|
* - Audio (if STT configured): .mp3, .mp4, .mpeg, .mpga, .m4a, .wav, .webm
|
||||||
|
* - Documents (depends on ETL service): .pdf, .docx, .doc, .pptx, .xlsx, .html
|
||||||
|
* - Images: .jpg, .jpeg, .png, .gif, .webp
|
||||||
|
*/
|
||||||
|
const ACCEPTED_FILE_TYPES = [
|
||||||
|
// Text/Markdown (always supported)
|
||||||
|
".md",
|
||||||
|
".markdown",
|
||||||
|
".txt",
|
||||||
|
// Audio files
|
||||||
|
".mp3",
|
||||||
|
".mp4",
|
||||||
|
".mpeg",
|
||||||
|
".mpga",
|
||||||
|
".m4a",
|
||||||
|
".wav",
|
||||||
|
".webm",
|
||||||
|
// Document files (depends on ETL service)
|
||||||
|
".pdf",
|
||||||
|
".docx",
|
||||||
|
".doc",
|
||||||
|
".pptx",
|
||||||
|
".xlsx",
|
||||||
|
".html",
|
||||||
|
// Image files
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".png",
|
||||||
|
".gif",
|
||||||
|
".webp",
|
||||||
|
].join(",");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response from the attachment processing endpoint
|
||||||
|
*/
|
||||||
|
interface ProcessAttachmentResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "document" | "image" | "file";
|
||||||
|
content: string;
|
||||||
|
contentLength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended CompleteAttachment with our custom extractedContent field
|
||||||
|
* We store the extracted text in a custom field so we can access it in onNew
|
||||||
|
*/
|
||||||
|
export interface ChatAttachment extends CompleteAttachment {
|
||||||
|
extractedContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a file through the backend ETL service
|
||||||
|
*/
|
||||||
|
async function processAttachment(file: File): Promise<ProcessAttachmentResponse> {
|
||||||
|
const token = getBearerToken();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("Not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const response = await fetch(`${backendUrl}/api/v1/attachments/process`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error("[processAttachment] Error response:", errorText);
|
||||||
|
let errorDetail = "Unknown error";
|
||||||
|
try {
|
||||||
|
const errorJson = JSON.parse(errorText);
|
||||||
|
// FastAPI validation errors return detail as array
|
||||||
|
if (Array.isArray(errorJson.detail)) {
|
||||||
|
errorDetail = errorJson.detail
|
||||||
|
.map((err: { msg?: string; loc?: string[] }) => {
|
||||||
|
const field = err.loc?.join(".") || "unknown";
|
||||||
|
return `${field}: ${err.msg || "validation error"}`;
|
||||||
|
})
|
||||||
|
.join("; ");
|
||||||
|
} else if (typeof errorJson.detail === "string") {
|
||||||
|
errorDetail = errorJson.detail;
|
||||||
|
} else {
|
||||||
|
errorDetail = JSON.stringify(errorJson);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorDetail = errorText || `HTTP ${response.status}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store processed results for the send() method
|
||||||
|
const processedAttachments = new Map<string, ProcessAttachmentResponse>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the attachment adapter for assistant-ui
|
||||||
|
*
|
||||||
|
* This adapter:
|
||||||
|
* 1. Accepts file upload
|
||||||
|
* 2. Processes the file through the backend ETL service
|
||||||
|
* 3. Returns the attachment with extracted markdown content
|
||||||
|
*
|
||||||
|
* The content is stored in the attachment and will be sent with the message.
|
||||||
|
*/
|
||||||
|
export function createAttachmentAdapter(): AttachmentAdapter {
|
||||||
|
return {
|
||||||
|
accept: ACCEPTED_FILE_TYPES,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async generator that yields pending states while processing
|
||||||
|
* and returns a pending attachment when done.
|
||||||
|
*
|
||||||
|
* IMPORTANT: The generator should return status: { type: "running", progress: 100 }
|
||||||
|
* NOT status: { type: "complete" }. The "complete" status is set by send().
|
||||||
|
* Returning "complete" from the generator will prevent send() from being called!
|
||||||
|
*
|
||||||
|
* This pattern allows the UI to show a loading indicator
|
||||||
|
* while the file is being processed by the backend.
|
||||||
|
* The send() method is called to finalize the attachment.
|
||||||
|
*/
|
||||||
|
async *add(input: File | { file: File }): AsyncGenerator<PendingAttachment, void> {
|
||||||
|
// Handle both direct File and { file: File } patterns
|
||||||
|
const file = input instanceof File ? input : input.file;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
console.error("[AttachmentAdapter] No file found in input:", input);
|
||||||
|
throw new Error("No file provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[AttachmentAdapter] Processing file:", file.name);
|
||||||
|
|
||||||
|
// Generate a unique ID for this attachment
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
// Determine attachment type from file
|
||||||
|
const attachmentType = file.type.startsWith("image/") ? "image" : "document";
|
||||||
|
|
||||||
|
// Yield initial pending state with "running" status (0% progress)
|
||||||
|
// This triggers the loading indicator in the UI
|
||||||
|
yield {
|
||||||
|
id,
|
||||||
|
type: attachmentType,
|
||||||
|
name: file.name,
|
||||||
|
file,
|
||||||
|
status: { type: "running", reason: "uploading", progress: 0 },
|
||||||
|
} as PendingAttachment;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Process the file through the backend ETL service
|
||||||
|
const result = await processAttachment(file);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[AttachmentAdapter] File processed:",
|
||||||
|
result.name,
|
||||||
|
"content length:",
|
||||||
|
result.contentLength
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify we have the required fields
|
||||||
|
if (!result.content) {
|
||||||
|
console.error("[AttachmentAdapter] WARNING: No content received from backend!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the processed result for send()
|
||||||
|
processedAttachments.set(id, result);
|
||||||
|
|
||||||
|
// Create the final pending attachment
|
||||||
|
// IMPORTANT: Use "running" status with progress: 100 to indicate processing is done
|
||||||
|
// but attachment is still pending. The "complete" status will be set by send().
|
||||||
|
// Yield the final state to ensure it gets processed by the UI
|
||||||
|
yield {
|
||||||
|
id,
|
||||||
|
type: result.type,
|
||||||
|
name: result.name,
|
||||||
|
file,
|
||||||
|
status: { type: "running", reason: "uploading", progress: 100 },
|
||||||
|
} as PendingAttachment;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AttachmentAdapter] Failed to process attachment:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when user sends the message.
|
||||||
|
* Converts the pending attachment to a complete attachment.
|
||||||
|
*/
|
||||||
|
async send(pendingAttachment: PendingAttachment): Promise<ChatAttachment> {
|
||||||
|
const result = processedAttachments.get(pendingAttachment.id);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// Clean up stored result
|
||||||
|
processedAttachments.delete(pendingAttachment.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: result.id,
|
||||||
|
type: result.type,
|
||||||
|
name: result.name,
|
||||||
|
contentType: "text/markdown",
|
||||||
|
status: { type: "complete" },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: result.content,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
extractedContent: result.content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback if no processed result found
|
||||||
|
console.warn(
|
||||||
|
"[AttachmentAdapter] send() - No processed result found for attachment:",
|
||||||
|
pendingAttachment.id
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: pendingAttachment.id,
|
||||||
|
type: pendingAttachment.type,
|
||||||
|
name: pendingAttachment.name,
|
||||||
|
contentType: "text/plain",
|
||||||
|
status: { type: "complete" },
|
||||||
|
content: [],
|
||||||
|
extractedContent: "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove() {
|
||||||
|
// No server-side cleanup needed since we don't persist attachments
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract attachment content for chat request
|
||||||
|
*
|
||||||
|
* This function extracts the content from attachments to be sent with the chat request.
|
||||||
|
* Only attachments that have been fully processed (have content) will be included.
|
||||||
|
*/
|
||||||
|
export function extractAttachmentContent(
|
||||||
|
attachments: Array<unknown>
|
||||||
|
): Array<{ id: string; name: string; type: string; content: string }> {
|
||||||
|
return attachments
|
||||||
|
.filter((att): att is ChatAttachment => {
|
||||||
|
if (!att || typeof att !== "object") return false;
|
||||||
|
const a = att as Record<string, unknown>;
|
||||||
|
// Check for our custom extractedContent field first
|
||||||
|
if (typeof a.extractedContent === "string" && a.extractedContent.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fallback: check if content array has text content
|
||||||
|
if (Array.isArray(a.content)) {
|
||||||
|
const textContent = (a.content as Array<{ type: string; text?: string }>).find(
|
||||||
|
(c) => c.type === "text" && typeof c.text === "string" && c.text.length > 0
|
||||||
|
);
|
||||||
|
return Boolean(textContent);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.map((att) => {
|
||||||
|
// Get content from extractedContent or from content array
|
||||||
|
let content = "";
|
||||||
|
if (typeof att.extractedContent === "string") {
|
||||||
|
content = att.extractedContent;
|
||||||
|
} else if (Array.isArray(att.content)) {
|
||||||
|
const textContent = (att.content as Array<{ type: string; text?: string }>).find(
|
||||||
|
(c) => c.type === "text"
|
||||||
|
);
|
||||||
|
content = textContent?.text || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: att.id,
|
||||||
|
name: att.name,
|
||||||
|
type: att.type,
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,27 +1,14 @@
|
||||||
import type { GetChatsRequest } from "@/contracts/types/chat.types";
|
|
||||||
import type { GetDocumentsRequest } from "@/contracts/types/document.types";
|
import type { GetDocumentsRequest } from "@/contracts/types/document.types";
|
||||||
import type { GetLLMConfigsRequest } from "@/contracts/types/llm-config.types";
|
import type { GetLLMConfigsRequest } from "@/contracts/types/llm-config.types";
|
||||||
import type { GetMembersRequest } from "@/contracts/types/members.types";
|
|
||||||
import type { GetPodcastsRequest } from "@/contracts/types/podcast.types";
|
|
||||||
import type { GetRolesRequest } from "@/contracts/types/roles.types";
|
|
||||||
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
|
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
|
||||||
|
|
||||||
export const cacheKeys = {
|
export const cacheKeys = {
|
||||||
chats: {
|
// New chat threads (assistant-ui)
|
||||||
activeChat: (chatId: string) => ["active-chat", chatId] as const,
|
threads: {
|
||||||
globalQueryParams: (queries: GetChatsRequest["queryParams"]) =>
|
list: (searchSpaceId: number) => ["threads", searchSpaceId] as const,
|
||||||
[
|
detail: (threadId: number) => ["threads", "detail", threadId] as const,
|
||||||
"chats",
|
search: (searchSpaceId: number, query: string) =>
|
||||||
queries?.search_space_id,
|
["threads", "search", searchSpaceId, query] as const,
|
||||||
queries?.limit,
|
|
||||||
queries?.skip,
|
|
||||||
queries?.page,
|
|
||||||
queries?.page_size,
|
|
||||||
] as const,
|
|
||||||
},
|
|
||||||
podcasts: {
|
|
||||||
globalQueryParams: (queries: GetPodcastsRequest["queryParams"]) =>
|
|
||||||
["podcasts", ...(queries ? Object.values(queries) : [])] as const,
|
|
||||||
},
|
},
|
||||||
documents: {
|
documents: {
|
||||||
globalQueryParams: (queries: GetDocumentsRequest["queryParams"]) =>
|
globalQueryParams: (queries: GetDocumentsRequest["queryParams"]) =>
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@
|
||||||
"documents": "Documents",
|
"documents": "Documents",
|
||||||
"connectors": "Connectors",
|
"connectors": "Connectors",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"researcher": "Researcher",
|
"chat": "Chat",
|
||||||
"api_keys": "API Keys",
|
"api_keys": "API Keys",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"loading_dashboard": "Loading Dashboard",
|
"loading_dashboard": "Loading Dashboard",
|
||||||
|
|
@ -117,7 +117,7 @@
|
||||||
"try_again": "Try Again",
|
"try_again": "Try Again",
|
||||||
"go_home": "Go Home",
|
"go_home": "Go Home",
|
||||||
"delete_search_space": "Delete Search Space",
|
"delete_search_space": "Delete Search Space",
|
||||||
"delete_space_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone. All documents, chats, and podcasts in this search space will be permanently deleted.",
|
"delete_space_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone. All documents and chats in this search space will be permanently deleted.",
|
||||||
"no_spaces_found": "No search spaces found",
|
"no_spaces_found": "No search spaces found",
|
||||||
"create_first_space": "Create your first search space to get started",
|
"create_first_space": "Create your first search space to get started",
|
||||||
"created": "Created"
|
"created": "Created"
|
||||||
|
|
@ -136,7 +136,7 @@
|
||||||
"nav_menu": {
|
"nav_menu": {
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"platform": "Platform",
|
"platform": "Platform",
|
||||||
"researcher": "Researcher",
|
"chat": "Chat",
|
||||||
"manage_llms": "Manage LLMs",
|
"manage_llms": "Manage LLMs",
|
||||||
"sources": "Sources",
|
"sources": "Sources",
|
||||||
"add_sources": "Add Sources",
|
"add_sources": "Add Sources",
|
||||||
|
|
@ -149,10 +149,8 @@
|
||||||
"connectors": "Connectors",
|
"connectors": "Connectors",
|
||||||
"add_connector": "Add Connector",
|
"add_connector": "Add Connector",
|
||||||
"manage_connectors": "Manage Connectors",
|
"manage_connectors": "Manage Connectors",
|
||||||
"podcasts": "Podcasts",
|
|
||||||
"logs": "Logs",
|
"logs": "Logs",
|
||||||
"all_search_spaces": "All Search Spaces",
|
"all_search_spaces": "All Search Spaces",
|
||||||
"chat": "Chat",
|
|
||||||
"team": "Team"
|
"team": "Team"
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
|
|
@ -209,40 +207,6 @@
|
||||||
"try_again_later": "Please try again later.",
|
"try_again_later": "Please try again later.",
|
||||||
"something_wrong": "Something went wrong"
|
"something_wrong": "Something went wrong"
|
||||||
},
|
},
|
||||||
"researcher": {
|
|
||||||
"loading": "Loading...",
|
|
||||||
"select_documents": "Select Documents",
|
|
||||||
"select_documents_desc": "Choose documents to include in your research context",
|
|
||||||
"loading_documents": "Loading documents...",
|
|
||||||
"select_connectors": "Select Connectors",
|
|
||||||
"select_connectors_desc": "Choose which data sources to include in your research",
|
|
||||||
"clear_all": "Clear All",
|
|
||||||
"select_all": "Select All",
|
|
||||||
"scope": "Scope",
|
|
||||||
"documents": "Documents",
|
|
||||||
"docs": "Docs",
|
|
||||||
"chunks": "Chunks",
|
|
||||||
"mode": "Mode",
|
|
||||||
"research_mode": "Research Mode",
|
|
||||||
"mode_qna": "Q&A",
|
|
||||||
"mode_general": "General Report",
|
|
||||||
"mode_general_short": "General",
|
|
||||||
"mode_deep": "Deep Report",
|
|
||||||
"mode_deep_short": "Deep",
|
|
||||||
"mode_deeper": "Deeper Report",
|
|
||||||
"mode_deeper_short": "Deeper",
|
|
||||||
"fast_llm": "Fast LLM",
|
|
||||||
"select_llm": "Select LLM",
|
|
||||||
"fast_llm_selection": "Fast LLM Selection",
|
|
||||||
"no_llm_configs": "No LLM configurations",
|
|
||||||
"configure_llm_to_start": "Configure AI models to get started",
|
|
||||||
"open_settings": "Open Settings",
|
|
||||||
"start_surfing": "Let's Start Surfing",
|
|
||||||
"through_knowledge_base": "through your knowledge base.",
|
|
||||||
"all_connectors": "All Connectors",
|
|
||||||
"connectors_selected": "{count} Connectors",
|
|
||||||
"placeholder": "Ask me anything..."
|
|
||||||
},
|
|
||||||
"connectors": {
|
"connectors": {
|
||||||
"title": "Connectors",
|
"title": "Connectors",
|
||||||
"subtitle": "Manage your connected services and data sources.",
|
"subtitle": "Manage your connected services and data sources.",
|
||||||
|
|
@ -468,27 +432,6 @@
|
||||||
"progress": "Progress",
|
"progress": "Progress",
|
||||||
"roles_assigned_count": "{assigned} of {total} roles assigned"
|
"roles_assigned_count": "{assigned} of {total} roles assigned"
|
||||||
},
|
},
|
||||||
"podcasts": {
|
|
||||||
"title": "Podcasts",
|
|
||||||
"subtitle": "Listen to generated podcasts.",
|
|
||||||
"search_placeholder": "Search podcasts...",
|
|
||||||
"sort_order": "Sort order",
|
|
||||||
"newest_first": "Newest First",
|
|
||||||
"oldest_first": "Oldest First",
|
|
||||||
"loading": "Loading podcasts...",
|
|
||||||
"error_loading": "Error loading podcasts",
|
|
||||||
"no_podcasts": "No podcasts found",
|
|
||||||
"adjust_filters": "Try adjusting your search filters",
|
|
||||||
"generate_hint": "Generate podcasts from your chats to get started",
|
|
||||||
"loading_podcast": "Loading podcast...",
|
|
||||||
"now_playing": "Now Playing",
|
|
||||||
"delete_podcast": "Delete Podcast",
|
|
||||||
"delete_confirm_1": "Are you sure you want to delete",
|
|
||||||
"delete_confirm_2": "This action cannot be undone.",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"delete": "Delete",
|
|
||||||
"deleting": "Deleting..."
|
|
||||||
},
|
|
||||||
"logs": {
|
"logs": {
|
||||||
"title": "Task Logs",
|
"title": "Task Logs",
|
||||||
"subtitle": "Monitor and analyze all task execution logs",
|
"subtitle": "Monitor and analyze all task execution logs",
|
||||||
|
|
@ -622,13 +565,11 @@
|
||||||
"breadcrumb": {
|
"breadcrumb": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"search_space": "Search Space",
|
"search_space": "Search Space",
|
||||||
"researcher": "Researcher",
|
"chat": "Chat",
|
||||||
"documents": "Documents",
|
"documents": "Documents",
|
||||||
"connectors": "Connectors",
|
"connectors": "Connectors",
|
||||||
"podcasts": "Podcasts",
|
|
||||||
"editor": "Editor",
|
"editor": "Editor",
|
||||||
"logs": "Logs",
|
"logs": "Logs",
|
||||||
"chats": "Chats",
|
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"upload_documents": "Upload Documents",
|
"upload_documents": "Upload Documents",
|
||||||
"add_youtube": "Add YouTube Videos",
|
"add_youtube": "Add YouTube Videos",
|
||||||
|
|
@ -647,7 +588,7 @@
|
||||||
"all_chats": "All Chats",
|
"all_chats": "All Chats",
|
||||||
"all_chats_description": "Browse and manage all your chats",
|
"all_chats_description": "Browse and manage all your chats",
|
||||||
"no_chats": "No chats yet",
|
"no_chats": "No chats yet",
|
||||||
"start_new_chat_hint": "Start a new chat from the researcher",
|
"start_new_chat_hint": "Start a new chat",
|
||||||
"error_loading_chats": "Error loading chats",
|
"error_loading_chats": "Error loading chats",
|
||||||
"chat_deleted": "Chat deleted successfully",
|
"chat_deleted": "Chat deleted successfully",
|
||||||
"error_deleting_chat": "Failed to delete chat",
|
"error_deleting_chat": "Failed to delete chat",
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@
|
||||||
"documents": "文档",
|
"documents": "文档",
|
||||||
"connectors": "连接器",
|
"connectors": "连接器",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"researcher": "AI 研究",
|
"chat": "聊天",
|
||||||
"api_keys": "API 密钥",
|
"api_keys": "API 密钥",
|
||||||
"profile": "个人资料",
|
"profile": "个人资料",
|
||||||
"loading_dashboard": "正在加载仪表盘",
|
"loading_dashboard": "正在加载仪表盘",
|
||||||
|
|
@ -136,7 +136,7 @@
|
||||||
"nav_menu": {
|
"nav_menu": {
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"platform": "平台",
|
"platform": "平台",
|
||||||
"researcher": "AI 研究",
|
"chat": "聊天",
|
||||||
"manage_llms": "管理 LLM",
|
"manage_llms": "管理 LLM",
|
||||||
"sources": "数据源",
|
"sources": "数据源",
|
||||||
"add_sources": "添加数据源",
|
"add_sources": "添加数据源",
|
||||||
|
|
@ -149,10 +149,8 @@
|
||||||
"connectors": "连接器",
|
"connectors": "连接器",
|
||||||
"add_connector": "添加连接器",
|
"add_connector": "添加连接器",
|
||||||
"manage_connectors": "管理连接器",
|
"manage_connectors": "管理连接器",
|
||||||
"podcasts": "播客",
|
|
||||||
"logs": "日志",
|
"logs": "日志",
|
||||||
"all_search_spaces": "所有搜索空间",
|
"all_search_spaces": "所有搜索空间",
|
||||||
"chat": "聊天",
|
|
||||||
"team": "团队"
|
"team": "团队"
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
|
|
@ -209,40 +207,6 @@
|
||||||
"try_again_later": "请稍后重试。",
|
"try_again_later": "请稍后重试。",
|
||||||
"something_wrong": "出错了"
|
"something_wrong": "出错了"
|
||||||
},
|
},
|
||||||
"researcher": {
|
|
||||||
"loading": "加载中...",
|
|
||||||
"select_documents": "选择文档",
|
|
||||||
"select_documents_desc": "选择要包含在研究上下文中的文档",
|
|
||||||
"loading_documents": "正在加载文档...",
|
|
||||||
"select_connectors": "选择连接器",
|
|
||||||
"select_connectors_desc": "选择要包含在研究中的数据源",
|
|
||||||
"clear_all": "全部清除",
|
|
||||||
"select_all": "全部选择",
|
|
||||||
"scope": "范围",
|
|
||||||
"documents": "文档",
|
|
||||||
"docs": "文档",
|
|
||||||
"chunks": "块",
|
|
||||||
"mode": "模式",
|
|
||||||
"research_mode": "研究模式",
|
|
||||||
"mode_qna": "问答",
|
|
||||||
"mode_general": "通用报告",
|
|
||||||
"mode_general_short": "通用",
|
|
||||||
"mode_deep": "深度报告",
|
|
||||||
"mode_deep_short": "深度",
|
|
||||||
"mode_deeper": "更深度报告",
|
|
||||||
"mode_deeper_short": "更深",
|
|
||||||
"fast_llm": "快速 LLM",
|
|
||||||
"select_llm": "选择 LLM",
|
|
||||||
"fast_llm_selection": "快速 LLM 选择",
|
|
||||||
"no_llm_configs": "未配置 LLM",
|
|
||||||
"configure_llm_to_start": "配置 AI 模型以开始使用",
|
|
||||||
"open_settings": "打开设置",
|
|
||||||
"start_surfing": "开始探索",
|
|
||||||
"through_knowledge_base": "您的知识库。",
|
|
||||||
"all_connectors": "所有连接器",
|
|
||||||
"connectors_selected": "{count} 个连接器",
|
|
||||||
"placeholder": "问我任何问题..."
|
|
||||||
},
|
|
||||||
"connectors": {
|
"connectors": {
|
||||||
"title": "连接器",
|
"title": "连接器",
|
||||||
"subtitle": "管理您的已连接服务和数据源。",
|
"subtitle": "管理您的已连接服务和数据源。",
|
||||||
|
|
@ -468,27 +432,6 @@
|
||||||
"progress": "进度",
|
"progress": "进度",
|
||||||
"roles_assigned_count": "{assigned} / {total} 个角色已分配"
|
"roles_assigned_count": "{assigned} / {total} 个角色已分配"
|
||||||
},
|
},
|
||||||
"podcasts": {
|
|
||||||
"title": "播客",
|
|
||||||
"subtitle": "收听生成的播客。",
|
|
||||||
"search_placeholder": "搜索播客...",
|
|
||||||
"sort_order": "排序方式",
|
|
||||||
"newest_first": "最新优先",
|
|
||||||
"oldest_first": "最旧优先",
|
|
||||||
"loading": "正在加载播客...",
|
|
||||||
"error_loading": "加载播客时出错",
|
|
||||||
"no_podcasts": "未找到播客",
|
|
||||||
"adjust_filters": "尝试调整搜索条件",
|
|
||||||
"generate_hint": "从您的聊天中生成播客以开始使用",
|
|
||||||
"loading_podcast": "正在加载播客...",
|
|
||||||
"now_playing": "正在播放",
|
|
||||||
"delete_podcast": "删除播客",
|
|
||||||
"delete_confirm_1": "您确定要删除",
|
|
||||||
"delete_confirm_2": "此操作无法撤销。",
|
|
||||||
"cancel": "取消",
|
|
||||||
"delete": "删除",
|
|
||||||
"deleting": "删除中..."
|
|
||||||
},
|
|
||||||
"logs": {
|
"logs": {
|
||||||
"title": "任务日志",
|
"title": "任务日志",
|
||||||
"subtitle": "监控和分析所有任务执行日志",
|
"subtitle": "监控和分析所有任务执行日志",
|
||||||
|
|
@ -622,13 +565,11 @@
|
||||||
"breadcrumb": {
|
"breadcrumb": {
|
||||||
"dashboard": "仪表盘",
|
"dashboard": "仪表盘",
|
||||||
"search_space": "搜索空间",
|
"search_space": "搜索空间",
|
||||||
"researcher": "AI 研究",
|
"chat": "聊天",
|
||||||
"documents": "文档",
|
"documents": "文档",
|
||||||
"connectors": "连接器",
|
"connectors": "连接器",
|
||||||
"podcasts": "播客",
|
|
||||||
"editor": "编辑器",
|
"editor": "编辑器",
|
||||||
"logs": "日志",
|
"logs": "日志",
|
||||||
"chats": "聊天",
|
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"upload_documents": "上传文档",
|
"upload_documents": "上传文档",
|
||||||
"add_youtube": "添加 YouTube 视频",
|
"add_youtube": "添加 YouTube 视频",
|
||||||
|
|
@ -647,7 +588,7 @@
|
||||||
"all_chats": "所有对话",
|
"all_chats": "所有对话",
|
||||||
"all_chats_description": "浏览和管理您的所有对话",
|
"all_chats_description": "浏览和管理您的所有对话",
|
||||||
"no_chats": "暂无对话",
|
"no_chats": "暂无对话",
|
||||||
"start_new_chat_hint": "从研究员开始新对话",
|
"start_new_chat_hint": "开始新对话",
|
||||||
"error_loading_chats": "加载对话时出错",
|
"error_loading_chats": "加载对话时出错",
|
||||||
"chat_deleted": "对话删除成功",
|
"chat_deleted": "对话删除成功",
|
||||||
"error_deleting_chat": "删除对话失败",
|
"error_deleting_chat": "删除对话失败",
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@
|
||||||
"@blocknote/react": "^0.45.0",
|
"@blocknote/react": "^0.45.0",
|
||||||
"@blocknote/server-util": "^0.45.0",
|
"@blocknote/server-util": "^0.45.0",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@llamaindex/chat-ui": "^0.5.17",
|
|
||||||
"@next/third-parties": "^16.1.0",
|
"@next/third-parties": "^16.1.0",
|
||||||
"@number-flow/react": "^0.5.10",
|
"@number-flow/react": "^0.5.10",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
|
|
|
||||||
2631
surfsense_web/pnpm-lock.yaml
generated
2631
surfsense_web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue