Compare commits
5 Commits
0bdeede5a6
...
cafe0bf5f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cafe0bf5f3 | ||
|
|
314a876744 | ||
|
|
c5e0a451c0 | ||
|
|
86e5c955c5 | ||
|
|
90f32b2508 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
from typing import List
|
||||
from pydantic import validator
|
||||
|
||||
from pydantic import RedisDsn
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
@@ -8,20 +8,22 @@ class Settings(BaseSettings):
|
||||
"""
|
||||
Configuración básica de la aplicación
|
||||
"""
|
||||
|
||||
|
||||
# Configuración básica de la aplicación
|
||||
APP_NAME: str = "File Manager API"
|
||||
DEBUG: bool = False
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
|
||||
|
||||
# Configuración de CORS para React frontend
|
||||
ALLOWED_ORIGINS: List[str] = [
|
||||
"http://localhost:3000", # React dev server
|
||||
"http://localhost:5173",
|
||||
"http://frontend:3000", # Docker container name
|
||||
"http://frontend:3000", # Docker container name
|
||||
]
|
||||
|
||||
|
||||
REDIS_OM_URL: RedisDsn
|
||||
|
||||
# Azure Blob Storage configuración
|
||||
AZURE_STORAGE_CONNECTION_STRING: str
|
||||
AZURE_STORAGE_ACCOUNT_NAME: str = ""
|
||||
@@ -52,66 +54,10 @@ class Settings(BaseSettings):
|
||||
# Schemas storage
|
||||
SCHEMAS_DIR: str = "./data/schemas"
|
||||
|
||||
@validator("AZURE_STORAGE_CONNECTION_STRING")
|
||||
def validate_azure_connection_string(cls, v):
|
||||
"""Validar que el connection string de Azure esté presente"""
|
||||
if not v:
|
||||
raise ValueError("AZURE_STORAGE_CONNECTION_STRING es requerido")
|
||||
return v
|
||||
|
||||
@validator("QDRANT_URL")
|
||||
def validate_qdrant_url(cls, v):
|
||||
"""Validar que la URL de Qdrant esté presente"""
|
||||
if not v:
|
||||
raise ValueError("QDRANT_URL es requerido")
|
||||
return v
|
||||
|
||||
@validator("QDRANT_API_KEY")
|
||||
def validate_qdrant_api_key(cls, v):
|
||||
"""Validar que la API key de Qdrant esté presente"""
|
||||
if not v:
|
||||
raise ValueError("QDRANT_API_KEY es requerido")
|
||||
return v
|
||||
|
||||
@validator("AZURE_OPENAI_ENDPOINT")
|
||||
def validate_azure_openai_endpoint(cls, v):
|
||||
"""Validar que el endpoint de Azure OpenAI esté presente"""
|
||||
if not v:
|
||||
raise ValueError("AZURE_OPENAI_ENDPOINT es requerido")
|
||||
return v
|
||||
|
||||
@validator("AZURE_OPENAI_API_KEY")
|
||||
def validate_azure_openai_api_key(cls, v):
|
||||
"""Validar que la API key de Azure OpenAI esté presente"""
|
||||
if not v:
|
||||
raise ValueError("AZURE_OPENAI_API_KEY es requerido")
|
||||
return v
|
||||
|
||||
@validator("GOOGLE_APPLICATION_CREDENTIALS")
|
||||
def validate_google_credentials(cls, v):
|
||||
"""Validar que el path de credenciales de Google esté presente"""
|
||||
if not v:
|
||||
raise ValueError("GOOGLE_APPLICATION_CREDENTIALS es requerido")
|
||||
return v
|
||||
|
||||
@validator("GOOGLE_CLOUD_PROJECT")
|
||||
def validate_google_project(cls, v):
|
||||
"""Validar que el proyecto de Google Cloud esté presente"""
|
||||
if not v:
|
||||
raise ValueError("GOOGLE_CLOUD_PROJECT es requerido")
|
||||
return v
|
||||
|
||||
@validator("LANDINGAI_API_KEY")
|
||||
def validate_landingai_api_key(cls, v):
|
||||
"""Validar que la API key de LandingAI esté presente"""
|
||||
if not v:
|
||||
raise ValueError("LANDINGAI_API_KEY es requerido")
|
||||
return v
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
# Instancia global de configuración
|
||||
settings = Settings()
|
||||
settings = Settings.model_validate({})
|
||||
|
||||
@@ -1,35 +1,48 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
import uvicorn
|
||||
import logging
|
||||
|
||||
# Import routers
|
||||
from .routers.files import router as files_router
|
||||
from .routers.vectors import router as vectors_router
|
||||
from .routers.chunking import router as chunking_router
|
||||
from .routers.schemas import router as schemas_router
|
||||
from .routers.chunking_landingai import router as chunking_landingai_router
|
||||
from .core.config import settings
|
||||
# from routers.ai import router as ai_router # futuro con Azure OpenAI
|
||||
|
||||
# Import config
|
||||
|
||||
from .routers.agent import router as agent_router
|
||||
from .routers.chunking import router as chunking_router
|
||||
from .routers.chunking_landingai import router as chunking_landingai_router
|
||||
from .routers.dataroom import router as dataroom_router
|
||||
from .routers.files import router as files_router
|
||||
from .routers.schemas import router as schemas_router
|
||||
from .routers.vectors import router as vectors_router
|
||||
|
||||
# Configurar logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
level=logging.WARNING, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logging.getLogger("app").setLevel(logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
logger.info("Iniciando File Manager API...")
|
||||
logger.info(
|
||||
f"Conectando a Azure Storage Account: {settings.AZURE_STORAGE_ACCOUNT_NAME}"
|
||||
)
|
||||
logger.info(f"Conectando a Qdrant: {settings.QDRANT_URL}")
|
||||
|
||||
yield
|
||||
|
||||
logger.info("Cerrando File Manager API...")
|
||||
# Cleanup de recursos si es necesario
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="File Manager API",
|
||||
description=" DoRa",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc"
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
# Configurar CORS para React frontend
|
||||
@@ -41,6 +54,7 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# Middleware para logging de requests
|
||||
@app.middleware("http")
|
||||
async def log_requests(request, call_next):
|
||||
@@ -49,19 +63,17 @@ async def log_requests(request, call_next):
|
||||
logger.info(f"Response: {response.status_code}")
|
||||
return response
|
||||
|
||||
|
||||
# Manejador global de excepciones
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request, exc):
|
||||
logger.error(f"HTTP Exception: {exc.status_code} - {exc.detail}")
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"error": True,
|
||||
"message": exc.detail,
|
||||
"status_code": exc.status_code
|
||||
}
|
||||
content={"error": True, "message": exc.detail, "status_code": exc.status_code},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(request, exc):
|
||||
logger.error(f"Unhandled Exception: {str(exc)}")
|
||||
@@ -70,10 +82,11 @@ async def general_exception_handler(request, exc):
|
||||
content={
|
||||
"error": True,
|
||||
"message": "Error interno del servidor",
|
||||
"status_code": 500
|
||||
}
|
||||
"status_code": 500,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
@@ -81,9 +94,10 @@ async def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"message": "File Manager API está funcionando correctamente",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
# Root endpoint
|
||||
@app.get("/")
|
||||
async def root():
|
||||
@@ -92,27 +106,16 @@ async def root():
|
||||
"message": "File Manager API",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"health": "/health"
|
||||
"health": "/health",
|
||||
}
|
||||
|
||||
|
||||
# Incluir routers
|
||||
app.include_router(
|
||||
files_router,
|
||||
prefix="/api/v1/files",
|
||||
tags=["files"]
|
||||
)
|
||||
app.include_router(files_router, prefix="/api/v1/files", tags=["files"])
|
||||
|
||||
app.include_router(
|
||||
vectors_router,
|
||||
prefix="/api/v1",
|
||||
tags=["vectors"]
|
||||
)
|
||||
app.include_router(vectors_router, prefix="/api/v1", tags=["vectors"])
|
||||
|
||||
app.include_router(
|
||||
chunking_router,
|
||||
prefix="/api/v1",
|
||||
tags=["chunking"]
|
||||
)
|
||||
app.include_router(chunking_router, prefix="/api/v1", tags=["chunking"])
|
||||
|
||||
# Schemas router (nuevo)
|
||||
app.include_router(schemas_router)
|
||||
@@ -120,6 +123,10 @@ app.include_router(schemas_router)
|
||||
# Chunking LandingAI router (nuevo)
|
||||
app.include_router(chunking_landingai_router)
|
||||
|
||||
app.include_router(dataroom_router, prefix="/api/v1")
|
||||
|
||||
app.include_router(agent_router)
|
||||
|
||||
# Router para IA
|
||||
# app.include_router(
|
||||
# ai_router,
|
||||
@@ -127,21 +134,6 @@ app.include_router(chunking_landingai_router)
|
||||
# tags=["ai"]
|
||||
# )
|
||||
|
||||
# Evento de startup
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
logger.info("Iniciando File Manager API...")
|
||||
logger.info(f"Conectando a Azure Storage Account: {settings.AZURE_STORAGE_ACCOUNT_NAME}")
|
||||
logger.info(f"Conectando a Qdrant: {settings.QDRANT_URL}")
|
||||
# validaciones de conexión a Azure
|
||||
|
||||
|
||||
# Evento de shutdown
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
logger.info("Cerrando File Manager API...")
|
||||
# Cleanup de recursos si es necesario
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
@@ -149,5 +141,5 @@ if __name__ == "__main__":
|
||||
host=settings.HOST,
|
||||
port=settings.PORT,
|
||||
reload=settings.DEBUG,
|
||||
log_level="info"
|
||||
)
|
||||
log_level="info",
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
10
backend/app/models/dataroom.py
Normal file
10
backend/app/models/dataroom.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from redis_om import HashModel, Migrator
|
||||
|
||||
|
||||
class DataRoom(HashModel):
|
||||
name: str
|
||||
collection: str
|
||||
storage: str
|
||||
|
||||
|
||||
Migrator().run()
|
||||
Binary file not shown.
Binary file not shown.
24
backend/app/routers/agent.py
Normal file
24
backend/app/routers/agent.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from fastapi import APIRouter
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from pydantic_ai.providers.azure import AzureProvider
|
||||
from pydantic_ai.ui.vercel_ai import VercelAIAdapter
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
provider = AzureProvider(
|
||||
azure_endpoint=settings.AZURE_OPENAI_ENDPOINT,
|
||||
api_version=settings.AZURE_OPENAI_API_VERSION,
|
||||
api_key=settings.AZURE_OPENAI_API_KEY,
|
||||
)
|
||||
model = OpenAIChatModel(model_name="gpt-4o", provider=provider)
|
||||
agent = Agent(model=model)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/agent", tags=["Agent"])
|
||||
|
||||
|
||||
@router.post("/chat")
|
||||
async def chat(request: Request) -> Response:
|
||||
return await VercelAIAdapter.dispatch_request(request, agent=agent)
|
||||
150
backend/app/routers/dataroom.py
Normal file
150
backend/app/routers/dataroom.py
Normal file
@@ -0,0 +1,150 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..models.dataroom import DataRoom
|
||||
from ..models.vector_models import CollectionCreateRequest
|
||||
from ..services.vector_service import vector_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DataroomCreate(BaseModel):
|
||||
name: str
|
||||
collection: str = ""
|
||||
storage: str = ""
|
||||
|
||||
|
||||
router = APIRouter(prefix="/dataroom", tags=["Dataroom"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_datarooms():
|
||||
"""
|
||||
Listar todos los temas disponibles
|
||||
"""
|
||||
try:
|
||||
# Get all DataRoom instances
|
||||
datarooms: list[DataRoom] = DataRoom.find().all()
|
||||
logger.info(f"Found {len(datarooms)} datarooms in Redis")
|
||||
|
||||
# Convert to list of dictionaries
|
||||
dataroom_list = [
|
||||
{"name": room.name, "collection": room.collection, "storage": room.storage}
|
||||
for room in datarooms
|
||||
]
|
||||
|
||||
logger.info(f"Returning dataroom list: {dataroom_list}")
|
||||
return {"datarooms": dataroom_list}
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing datarooms: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error listing datarooms: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_dataroom(dataroom: DataroomCreate):
|
||||
"""
|
||||
Crear un nuevo dataroom y su colección vectorial asociada
|
||||
"""
|
||||
try:
|
||||
# Create new DataRoom instance
|
||||
new_dataroom = DataRoom(
|
||||
name=dataroom.name, collection=dataroom.collection, storage=dataroom.storage
|
||||
)
|
||||
|
||||
# Save to Redis
|
||||
new_dataroom.save()
|
||||
|
||||
# Create the vector collection for this dataroom
|
||||
try:
|
||||
# First check if collection already exists
|
||||
collection_exists_response = await vector_service.check_collection_exists(
|
||||
dataroom.name
|
||||
)
|
||||
|
||||
if not collection_exists_response.exists:
|
||||
# Only create if it doesn't exist
|
||||
collection_request = CollectionCreateRequest(
|
||||
collection_name=dataroom.name,
|
||||
vector_size=3072, # Default vector size for embeddings
|
||||
distance="Cosine", # Default distance metric
|
||||
)
|
||||
await vector_service.create_collection(collection_request)
|
||||
logger.info(f"Collection '{dataroom.name}' created successfully")
|
||||
else:
|
||||
logger.info(
|
||||
f"Collection '{dataroom.name}' already exists, skipping creation"
|
||||
)
|
||||
except Exception as e:
|
||||
# Log the error but don't fail the dataroom creation
|
||||
logger.warning(
|
||||
f"Could not create collection for dataroom '{dataroom.name}': {e}"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Dataroom created successfully",
|
||||
"dataroom": {
|
||||
"name": new_dataroom.name,
|
||||
"collection": new_dataroom.collection,
|
||||
"storage": new_dataroom.storage,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error creating dataroom: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{dataroom_name}")
|
||||
async def delete_dataroom(dataroom_name: str):
|
||||
"""
|
||||
Eliminar un dataroom y su colección vectorial asociada
|
||||
"""
|
||||
try:
|
||||
# First check if dataroom exists
|
||||
existing_datarooms = DataRoom.find().all()
|
||||
dataroom_exists = any(room.name == dataroom_name for room in existing_datarooms)
|
||||
|
||||
if not dataroom_exists:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Dataroom '{dataroom_name}' not found"
|
||||
)
|
||||
|
||||
# Delete the vector collection first
|
||||
try:
|
||||
collection_exists = await vector_service.check_collection_exists(
|
||||
dataroom_name
|
||||
)
|
||||
if collection_exists.exists:
|
||||
await vector_service.delete_collection(dataroom_name)
|
||||
logger.info(
|
||||
f"Collection '{dataroom_name}' deleted from vector database"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not delete collection '{dataroom_name}' from vector database: {e}"
|
||||
)
|
||||
# Continue with dataroom deletion even if collection deletion fails
|
||||
|
||||
# Delete the dataroom from Redis
|
||||
for room in existing_datarooms:
|
||||
if room.name == dataroom_name:
|
||||
# Delete using the primary key
|
||||
DataRoom.delete(room.pk)
|
||||
logger.info(f"Dataroom '{dataroom_name}' deleted from Redis")
|
||||
break
|
||||
|
||||
return {
|
||||
"message": "Dataroom deleted successfully",
|
||||
"dataroom_name": dataroom_name,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting dataroom '{dataroom_name}': {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error deleting dataroom: {str(e)}"
|
||||
)
|
||||
@@ -1,18 +1,28 @@
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException, Query, Form
|
||||
from fastapi.responses import StreamingResponse, Response
|
||||
from typing import Optional, List
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import zipfile
|
||||
import io
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from ..models.dataroom import DataRoom
|
||||
from ..models.file_models import (
|
||||
FileUploadRequest, FileUploadResponse, FileInfo, FileListResponse,
|
||||
FileDeleteResponse, FileBatchDeleteRequest,
|
||||
FileConflictResponse, FileBatchDeleteResponse,
|
||||
FileBatchDownloadRequest, TemasListResponse,
|
||||
FileUploadCheckRequest, FileUploadConfirmRequest, ErrorResponse
|
||||
ErrorResponse,
|
||||
FileBatchDeleteRequest,
|
||||
FileBatchDeleteResponse,
|
||||
FileBatchDownloadRequest,
|
||||
FileConflictResponse,
|
||||
FileDeleteResponse,
|
||||
FileInfo,
|
||||
FileListResponse,
|
||||
FileUploadCheckRequest,
|
||||
FileUploadConfirmRequest,
|
||||
FileUploadRequest,
|
||||
FileUploadResponse,
|
||||
TemasListResponse,
|
||||
)
|
||||
from ..services.azure_service import azure_service
|
||||
from ..services.file_service import file_service
|
||||
@@ -31,27 +41,27 @@ async def check_file_before_upload(request: FileUploadCheckRequest):
|
||||
is_valid, error_msg = file_service.validate_filename(request.filename)
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
|
||||
|
||||
# Validar extensión
|
||||
is_valid, error_msg = file_service.validate_file_extension(request.filename)
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
|
||||
|
||||
# Limpiar tema
|
||||
clean_tema = file_service.clean_tema_name(request.tema or "")
|
||||
|
||||
|
||||
# Verificar si existe conflicto
|
||||
has_conflict, suggested_name = await file_service.handle_file_conflict(
|
||||
request.filename, clean_tema
|
||||
)
|
||||
|
||||
|
||||
if has_conflict:
|
||||
return FileConflictResponse(
|
||||
conflict=True,
|
||||
message=f"El archivo '{request.filename}' ya existe en el tema '{clean_tema or 'general'}'",
|
||||
existing_file=request.filename,
|
||||
suggested_name=suggested_name,
|
||||
tema=clean_tema
|
||||
tema=clean_tema,
|
||||
)
|
||||
else:
|
||||
# No hay conflicto, se puede subir directamente
|
||||
@@ -60,14 +70,16 @@ async def check_file_before_upload(request: FileUploadCheckRequest):
|
||||
message="Archivo disponible para subir",
|
||||
existing_file=request.filename,
|
||||
suggested_name=request.filename,
|
||||
tema=clean_tema
|
||||
tema=clean_tema,
|
||||
)
|
||||
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error verificando archivo '{request.filename}': {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error interno del servidor: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload/confirm", response_model=FileUploadResponse)
|
||||
@@ -75,7 +87,7 @@ async def upload_file_with_confirmation(
|
||||
file: UploadFile = File(...),
|
||||
action: str = Form(...),
|
||||
tema: Optional[str] = Form(None),
|
||||
new_filename: Optional[str] = Form(None)
|
||||
new_filename: Optional[str] = Form(None),
|
||||
):
|
||||
"""
|
||||
Subir archivo con confirmación de acción para conflictos
|
||||
@@ -84,61 +96,54 @@ async def upload_file_with_confirmation(
|
||||
# Validar archivo
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="Nombre de archivo requerido")
|
||||
|
||||
|
||||
# Crear request de confirmación para validaciones
|
||||
confirm_request = FileUploadConfirmRequest(
|
||||
filename=file.filename,
|
||||
tema=tema,
|
||||
action=action,
|
||||
new_filename=new_filename
|
||||
filename=file.filename, tema=tema, action=action, new_filename=new_filename
|
||||
)
|
||||
|
||||
|
||||
# Si la acción es cancelar, no hacer nada
|
||||
if confirm_request.action == "cancel":
|
||||
return FileUploadResponse(
|
||||
success=False,
|
||||
message="Subida cancelada por el usuario",
|
||||
file=None
|
||||
success=False, message="Subida cancelada por el usuario", file=None
|
||||
)
|
||||
|
||||
|
||||
# Determinar el nombre final del archivo
|
||||
final_filename = file.filename
|
||||
if confirm_request.action == "rename" and confirm_request.new_filename:
|
||||
final_filename = confirm_request.new_filename
|
||||
|
||||
|
||||
# Validar extensión del archivo final
|
||||
is_valid, error_msg = file_service.validate_file_extension(final_filename)
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
|
||||
|
||||
# Leer contenido del archivo
|
||||
file_content = await file.read()
|
||||
|
||||
|
||||
# Validar tamaño del archivo
|
||||
is_valid, error_msg = file_service.validate_file_size(len(file_content))
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
|
||||
|
||||
# Limpiar tema
|
||||
clean_tema = file_service.clean_tema_name(confirm_request.tema or "")
|
||||
|
||||
|
||||
# Si es sobrescribir, verificar que el archivo original exista
|
||||
if confirm_request.action == "overwrite":
|
||||
exists = await file_service.check_file_exists(file.filename, clean_tema)
|
||||
if not exists:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Archivo '{file.filename}' no existe para sobrescribir"
|
||||
status_code=404,
|
||||
detail=f"Archivo '{file.filename}' no existe para sobrescribir",
|
||||
)
|
||||
|
||||
|
||||
# Subir archivo a Azure
|
||||
file_stream = io.BytesIO(file_content)
|
||||
uploaded_file_info = await azure_service.upload_file(
|
||||
file_data=file_stream,
|
||||
blob_name=final_filename,
|
||||
tema=clean_tema
|
||||
file_data=file_stream, blob_name=final_filename, tema=clean_tema
|
||||
)
|
||||
|
||||
|
||||
# Crear objeto FileInfo
|
||||
file_info = FileInfo(
|
||||
name=uploaded_file_info["name"],
|
||||
@@ -146,75 +151,95 @@ async def upload_file_with_confirmation(
|
||||
tema=uploaded_file_info["tema"],
|
||||
size=uploaded_file_info["size"],
|
||||
last_modified=uploaded_file_info["last_modified"],
|
||||
url=uploaded_file_info["url"]
|
||||
url=uploaded_file_info["url"],
|
||||
)
|
||||
|
||||
|
||||
action_msg = {
|
||||
"overwrite": "sobrescrito",
|
||||
"rename": f"renombrado a '{final_filename}'"
|
||||
"rename": f"renombrado a '{final_filename}'",
|
||||
}
|
||||
|
||||
logger.info(f"Archivo '{file.filename}' {action_msg.get(confirm_request.action, 'subido')} exitosamente")
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Archivo '{file.filename}' {action_msg.get(confirm_request.action, 'subido')} exitosamente"
|
||||
)
|
||||
|
||||
return FileUploadResponse(
|
||||
success=True,
|
||||
message=f"Archivo {action_msg.get(confirm_request.action, 'subido')} exitosamente",
|
||||
file=file_info
|
||||
file=file_info,
|
||||
)
|
||||
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error en subida confirmada: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error interno del servidor: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload", response_model=FileUploadResponse)
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
tema: Optional[str] = Form(None)
|
||||
):
|
||||
async def upload_file(file: UploadFile = File(...), tema: Optional[str] = Form(None)):
|
||||
"""
|
||||
Subir un archivo al almacenamiento
|
||||
"""
|
||||
try:
|
||||
# Validar que el dataroom existe si se proporciona un tema
|
||||
if tema:
|
||||
existing_datarooms = DataRoom.find().all()
|
||||
dataroom_exists = any(room.name == tema for room in existing_datarooms)
|
||||
|
||||
if not dataroom_exists:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"El dataroom '{tema}' no existe. Créalo primero antes de subir archivos.",
|
||||
)
|
||||
|
||||
# Validar archivo
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="Nombre de archivo requerido")
|
||||
|
||||
|
||||
# Validar extensión del archivo
|
||||
file_extension = os.path.splitext(file.filename)[1].lower()
|
||||
allowed_extensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv']
|
||||
|
||||
allowed_extensions = [
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".txt",
|
||||
".csv",
|
||||
]
|
||||
|
||||
if file_extension not in allowed_extensions:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Tipo de archivo no permitido. Extensiones permitidas: {', '.join(allowed_extensions)}"
|
||||
status_code=400,
|
||||
detail=f"Tipo de archivo no permitido. Extensiones permitidas: {', '.join(allowed_extensions)}",
|
||||
)
|
||||
|
||||
|
||||
# Leer contenido del archivo
|
||||
file_content = await file.read()
|
||||
|
||||
|
||||
# Validar tamaño del archivo (100MB máximo)
|
||||
max_size = 100 * 1024 * 1024 # 100MB
|
||||
if len(file_content) > max_size:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Archivo demasiado grande. Tamaño máximo permitido: 100MB"
|
||||
detail=f"Archivo demasiado grande. Tamaño máximo permitido: 100MB",
|
||||
)
|
||||
|
||||
|
||||
# Procesar tema
|
||||
upload_request = FileUploadRequest(tema=tema)
|
||||
processed_tema = upload_request.tema or ""
|
||||
|
||||
|
||||
# Subir archivo a Azure
|
||||
file_stream = io.BytesIO(file_content)
|
||||
uploaded_file_info = await azure_service.upload_file(
|
||||
file_data=file_stream,
|
||||
blob_name=file.filename,
|
||||
tema=processed_tema
|
||||
file_data=file_stream, blob_name=file.filename, tema=processed_tema
|
||||
)
|
||||
|
||||
|
||||
# Crear objeto FileInfo
|
||||
file_info = FileInfo(
|
||||
name=uploaded_file_info["name"],
|
||||
@@ -222,22 +247,24 @@ async def upload_file(
|
||||
tema=uploaded_file_info["tema"],
|
||||
size=uploaded_file_info["size"],
|
||||
last_modified=uploaded_file_info["last_modified"],
|
||||
url=uploaded_file_info["url"]
|
||||
url=uploaded_file_info["url"],
|
||||
)
|
||||
|
||||
logger.info(f"Archivo '{file.filename}' subido exitosamente al tema '{processed_tema}'")
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Archivo '{file.filename}' subido exitosamente al tema '{processed_tema}'"
|
||||
)
|
||||
|
||||
return FileUploadResponse(
|
||||
success=True,
|
||||
message="Archivo subido exitosamente",
|
||||
file=file_info
|
||||
success=True, message="Archivo subido exitosamente", file=file_info
|
||||
)
|
||||
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error subiendo archivo: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error interno del servidor: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=FileListResponse)
|
||||
@@ -248,7 +275,7 @@ async def list_files(tema: Optional[str] = Query(None, description="Filtrar por
|
||||
try:
|
||||
# Obtener archivos de Azure
|
||||
files_data = await azure_service.list_files(tema=tema or "")
|
||||
|
||||
|
||||
# Convertir a objetos FileInfo
|
||||
files_info = []
|
||||
for file_data in files_data:
|
||||
@@ -258,21 +285,22 @@ async def list_files(tema: Optional[str] = Query(None, description="Filtrar por
|
||||
tema=file_data["tema"],
|
||||
size=file_data["size"],
|
||||
last_modified=file_data["last_modified"],
|
||||
content_type=file_data.get("content_type")
|
||||
content_type=file_data.get("content_type"),
|
||||
)
|
||||
files_info.append(file_info)
|
||||
|
||||
logger.info(f"Listados {len(files_info)} archivos" + (f" del tema '{tema}'" if tema else ""))
|
||||
|
||||
return FileListResponse(
|
||||
files=files_info,
|
||||
total=len(files_info),
|
||||
tema=tema
|
||||
|
||||
logger.info(
|
||||
f"Listados {len(files_info)} archivos"
|
||||
+ (f" del tema '{tema}'" if tema else "")
|
||||
)
|
||||
|
||||
|
||||
return FileListResponse(files=files_info, total=len(files_info), tema=tema)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listando archivos: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error interno del servidor: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/temas", response_model=TemasListResponse)
|
||||
@@ -283,31 +311,30 @@ async def list_temas():
|
||||
try:
|
||||
# Obtener todos los archivos
|
||||
files_data = await azure_service.list_files()
|
||||
|
||||
|
||||
# Extraer temas únicos
|
||||
temas = set()
|
||||
for file_data in files_data:
|
||||
if file_data["tema"]:
|
||||
temas.add(file_data["tema"])
|
||||
|
||||
|
||||
temas_list = sorted(list(temas))
|
||||
|
||||
|
||||
logger.info(f"Encontrados {len(temas_list)} temas")
|
||||
|
||||
return TemasListResponse(
|
||||
temas=temas_list,
|
||||
total=len(temas_list)
|
||||
)
|
||||
|
||||
|
||||
return TemasListResponse(temas=temas_list, total=len(temas_list))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listando temas: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error interno del servidor: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{filename}/download")
|
||||
async def download_file(
|
||||
filename: str,
|
||||
tema: Optional[str] = Query(None, description="Tema donde está el archivo")
|
||||
tema: Optional[str] = Query(None, description="Tema donde está el archivo"),
|
||||
):
|
||||
"""
|
||||
Descargar un archivo individual
|
||||
@@ -315,64 +342,71 @@ async def download_file(
|
||||
try:
|
||||
# Descargar archivo de Azure
|
||||
file_content = await azure_service.download_file(
|
||||
blob_name=filename,
|
||||
tema=tema or ""
|
||||
blob_name=filename, tema=tema or ""
|
||||
)
|
||||
|
||||
|
||||
# Obtener información del archivo para content-type
|
||||
file_info = await azure_service.get_file_info(
|
||||
blob_name=filename,
|
||||
tema=tema or ""
|
||||
blob_name=filename, tema=tema or ""
|
||||
)
|
||||
|
||||
|
||||
# Determinar content-type
|
||||
content_type = file_info.get("content_type", "application/octet-stream")
|
||||
|
||||
logger.info(f"Descargando archivo '{filename}'" + (f" del tema '{tema}'" if tema else ""))
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Descargando archivo '{filename}'"
|
||||
+ (f" del tema '{tema}'" if tema else "")
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=file_content,
|
||||
media_type=content_type,
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}"
|
||||
}
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
|
||||
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Archivo '{filename}' no encontrado")
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Archivo '{filename}' no encontrado"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error descargando archivo '{filename}': {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error interno del servidor: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{filename}", response_model=FileDeleteResponse)
|
||||
async def delete_file(
|
||||
filename: str,
|
||||
tema: Optional[str] = Query(None, description="Tema donde está el archivo")
|
||||
tema: Optional[str] = Query(None, description="Tema donde está el archivo"),
|
||||
):
|
||||
"""
|
||||
Eliminar un archivo
|
||||
"""
|
||||
try:
|
||||
# Eliminar archivo de Azure
|
||||
await azure_service.delete_file(
|
||||
blob_name=filename,
|
||||
tema=tema or ""
|
||||
await azure_service.delete_file(blob_name=filename, tema=tema or "")
|
||||
|
||||
logger.info(
|
||||
f"Archivo '{filename}' eliminado exitosamente"
|
||||
+ (f" del tema '{tema}'" if tema else "")
|
||||
)
|
||||
|
||||
logger.info(f"Archivo '{filename}' eliminado exitosamente" + (f" del tema '{tema}'" if tema else ""))
|
||||
|
||||
|
||||
return FileDeleteResponse(
|
||||
success=True,
|
||||
message="Archivo eliminado exitosamente",
|
||||
deleted_file=filename
|
||||
deleted_file=filename,
|
||||
)
|
||||
|
||||
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Archivo '{filename}' no encontrado")
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Archivo '{filename}' no encontrado"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error eliminando archivo '{filename}': {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error interno del servidor: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/delete-batch", response_model=FileBatchDeleteResponse)
|
||||
@@ -383,34 +417,35 @@ async def delete_batch_files(request: FileBatchDeleteRequest):
|
||||
try:
|
||||
deleted_files = []
|
||||
failed_files = []
|
||||
|
||||
|
||||
for filename in request.files:
|
||||
try:
|
||||
await azure_service.delete_file(
|
||||
blob_name=filename,
|
||||
tema=request.tema or ""
|
||||
blob_name=filename, tema=request.tema or ""
|
||||
)
|
||||
deleted_files.append(filename)
|
||||
logger.info(f"Archivo '{filename}' eliminado exitosamente")
|
||||
except Exception as e:
|
||||
failed_files.append(filename)
|
||||
logger.error(f"Error eliminando archivo '{filename}': {e}")
|
||||
|
||||
|
||||
success = len(failed_files) == 0
|
||||
message = f"Eliminados {len(deleted_files)} archivos exitosamente"
|
||||
if failed_files:
|
||||
message += f", {len(failed_files)} archivos fallaron"
|
||||
|
||||
|
||||
return FileBatchDeleteResponse(
|
||||
success=success,
|
||||
message=message,
|
||||
deleted_files=deleted_files,
|
||||
failed_files=failed_files
|
||||
failed_files=failed_files,
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error en eliminación batch: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error interno del servidor: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/download-batch")
|
||||
@@ -421,44 +456,43 @@ async def download_batch_files(request: FileBatchDownloadRequest):
|
||||
try:
|
||||
# Crear ZIP en memoria
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for filename in request.files:
|
||||
try:
|
||||
# Descargar archivo de Azure
|
||||
file_content = await azure_service.download_file(
|
||||
blob_name=filename,
|
||||
tema=request.tema or ""
|
||||
blob_name=filename, tema=request.tema or ""
|
||||
)
|
||||
|
||||
|
||||
# Agregar al ZIP
|
||||
zip_file.writestr(filename, file_content)
|
||||
logger.info(f"Archivo '{filename}' agregado al ZIP")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error agregando '{filename}' al ZIP: {e}")
|
||||
# Continuar con otros archivos
|
||||
continue
|
||||
|
||||
|
||||
zip_buffer.seek(0)
|
||||
|
||||
|
||||
# Generar nombre del ZIP
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
zip_filename = f"{request.zip_name}_{timestamp}.zip"
|
||||
|
||||
|
||||
logger.info(f"ZIP creado exitosamente: {zip_filename}")
|
||||
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(zip_buffer.read()),
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={zip_filename}"
|
||||
}
|
||||
headers={"Content-Disposition": f"attachment; filename={zip_filename}"},
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creando ZIP: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error interno del servidor: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tema/{tema}/download-all")
|
||||
@@ -469,54 +503,58 @@ async def download_tema_completo(tema: str):
|
||||
try:
|
||||
# Obtener todos los archivos del tema
|
||||
files_data = await azure_service.list_files(tema=tema)
|
||||
|
||||
|
||||
if not files_data:
|
||||
raise HTTPException(status_code=404, detail=f"No se encontraron archivos en el tema '{tema}'")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No se encontraron archivos en el tema '{tema}'",
|
||||
)
|
||||
|
||||
# Crear ZIP en memoria
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for file_data in files_data:
|
||||
try:
|
||||
filename = file_data["name"]
|
||||
|
||||
|
||||
# Descargar archivo de Azure
|
||||
file_content = await azure_service.download_file(
|
||||
blob_name=filename,
|
||||
tema=tema
|
||||
blob_name=filename, tema=tema
|
||||
)
|
||||
|
||||
|
||||
# Agregar al ZIP
|
||||
zip_file.writestr(filename, file_content)
|
||||
logger.info(f"Archivo '{filename}' agregado al ZIP del tema '{tema}'")
|
||||
|
||||
logger.info(
|
||||
f"Archivo '{filename}' agregado al ZIP del tema '{tema}'"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error agregando '{filename}' al ZIP: {e}")
|
||||
# Continuar con otros archivos
|
||||
continue
|
||||
|
||||
|
||||
zip_buffer.seek(0)
|
||||
|
||||
|
||||
# Generar nombre del ZIP
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
zip_filename = f"{tema}_{timestamp}.zip"
|
||||
|
||||
|
||||
logger.info(f"ZIP del tema '{tema}' creado exitosamente: {zip_filename}")
|
||||
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(zip_buffer.read()),
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={zip_filename}"
|
||||
}
|
||||
headers={"Content-Disposition": f"attachment; filename={zip_filename}"},
|
||||
)
|
||||
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creando ZIP del tema '{tema}': {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error interno del servidor: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/tema/{tema}/delete-all", response_model=FileBatchDeleteResponse)
|
||||
@@ -527,51 +565,59 @@ async def delete_tema_completo(tema: str):
|
||||
try:
|
||||
# Obtener todos los archivos del tema
|
||||
files_data = await azure_service.list_files(tema=tema)
|
||||
|
||||
|
||||
if not files_data:
|
||||
raise HTTPException(status_code=404, detail=f"No se encontraron archivos en el tema '{tema}'")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No se encontraron archivos en el tema '{tema}'",
|
||||
)
|
||||
|
||||
deleted_files = []
|
||||
failed_files = []
|
||||
|
||||
|
||||
for file_data in files_data:
|
||||
filename = file_data["name"]
|
||||
try:
|
||||
await azure_service.delete_file(
|
||||
blob_name=filename,
|
||||
tema=tema
|
||||
)
|
||||
await azure_service.delete_file(blob_name=filename, tema=tema)
|
||||
deleted_files.append(filename)
|
||||
logger.info(f"Archivo '{filename}' eliminado del tema '{tema}'")
|
||||
except Exception as e:
|
||||
failed_files.append(filename)
|
||||
logger.error(f"Error eliminando archivo '{filename}' del tema '{tema}': {e}")
|
||||
|
||||
logger.error(
|
||||
f"Error eliminando archivo '{filename}' del tema '{tema}': {e}"
|
||||
)
|
||||
|
||||
success = len(failed_files) == 0
|
||||
message = f"Tema '{tema}': eliminados {len(deleted_files)} archivos exitosamente"
|
||||
message = (
|
||||
f"Tema '{tema}': eliminados {len(deleted_files)} archivos exitosamente"
|
||||
)
|
||||
if failed_files:
|
||||
message += f", {len(failed_files)} archivos fallaron"
|
||||
|
||||
logger.info(f"Eliminación completa del tema '{tema}': {len(deleted_files)} exitosos, {len(failed_files)} fallidos")
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Eliminación completa del tema '{tema}': {len(deleted_files)} exitosos, {len(failed_files)} fallidos"
|
||||
)
|
||||
|
||||
return FileBatchDeleteResponse(
|
||||
success=success,
|
||||
message=message,
|
||||
deleted_files=deleted_files,
|
||||
failed_files=failed_files
|
||||
failed_files=failed_files,
|
||||
)
|
||||
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error eliminando tema '{tema}': {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error interno del servidor: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{filename}/info", response_model=FileInfo)
|
||||
async def get_file_info(
|
||||
filename: str,
|
||||
tema: Optional[str] = Query(None, description="Tema donde está el archivo")
|
||||
tema: Optional[str] = Query(None, description="Tema donde está el archivo"),
|
||||
):
|
||||
"""
|
||||
Obtener información detallada de un archivo
|
||||
@@ -579,8 +625,7 @@ async def get_file_info(
|
||||
try:
|
||||
# Obtener información de Azure
|
||||
file_data = await azure_service.get_file_info(
|
||||
blob_name=filename,
|
||||
tema=tema or ""
|
||||
blob_name=filename, tema=tema or ""
|
||||
)
|
||||
|
||||
# Convertir a objeto FileInfo
|
||||
@@ -591,24 +636,30 @@ async def get_file_info(
|
||||
size=file_data["size"],
|
||||
last_modified=file_data["last_modified"],
|
||||
content_type=file_data.get("content_type"),
|
||||
url=file_data.get("url")
|
||||
url=file_data.get("url"),
|
||||
)
|
||||
|
||||
logger.info(f"Información obtenida para archivo '{filename}'")
|
||||
return file_info
|
||||
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Archivo '{filename}' no encontrado")
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Archivo '{filename}' no encontrado"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo info del archivo '{filename}': {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error interno del servidor: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{filename}/preview-url")
|
||||
async def get_file_preview_url(
|
||||
filename: str,
|
||||
tema: Optional[str] = Query(None, description="Tema donde está el archivo"),
|
||||
expiry_hours: int = Query(1, description="Horas de validez de la URL (máximo 24)", ge=1, le=24)
|
||||
expiry_hours: int = Query(
|
||||
1, description="Horas de validez de la URL (máximo 24)", ge=1, le=24
|
||||
),
|
||||
):
|
||||
"""
|
||||
Generar una URL temporal (SAS) para vista previa de archivos
|
||||
@@ -633,23 +684,28 @@ async def get_file_preview_url(
|
||||
try:
|
||||
# Generar SAS URL usando el servicio de Azure
|
||||
sas_url = await azure_service.generate_sas_url(
|
||||
blob_name=filename,
|
||||
tema=tema or "",
|
||||
expiry_hours=expiry_hours
|
||||
blob_name=filename, tema=tema or "", expiry_hours=expiry_hours
|
||||
)
|
||||
|
||||
logger.info(f"SAS URL generada para preview de '{filename}'" + (f" del tema '{tema}'" if tema else ""))
|
||||
logger.info(
|
||||
f"SAS URL generada para preview de '{filename}'"
|
||||
+ (f" del tema '{tema}'" if tema else "")
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"filename": filename,
|
||||
"url": sas_url,
|
||||
"expiry_hours": expiry_hours,
|
||||
"message": f"URL temporal generada (válida por {expiry_hours} hora{'s' if expiry_hours > 1 else ''})"
|
||||
"message": f"URL temporal generada (válida por {expiry_hours} hora{'s' if expiry_hours > 1 else ''})",
|
||||
}
|
||||
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Archivo '{filename}' no encontrado")
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Archivo '{filename}' no encontrado"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error generando preview URL para '{filename}': {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error interno del servidor: {str(e)}"
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,9 +1,17 @@
|
||||
from azure.storage.blob import BlobServiceClient, BlobClient, ContainerClient, generate_blob_sas, BlobSasPermissions
|
||||
from azure.core.exceptions import ResourceNotFoundError, ResourceExistsError
|
||||
from typing import List, Optional, BinaryIO
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import BinaryIO, List, Optional
|
||||
|
||||
from azure.core.exceptions import ResourceExistsError, ResourceNotFoundError
|
||||
from azure.storage.blob import (
|
||||
BlobClient,
|
||||
BlobSasPermissions,
|
||||
BlobServiceClient,
|
||||
ContainerClient,
|
||||
generate_blob_sas,
|
||||
)
|
||||
|
||||
from ..core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -13,7 +21,7 @@ class AzureBlobService:
|
||||
"""
|
||||
Servicio para interactuar con Azure Blob Storage
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
"""Inicializar el cliente de Azure Blob Storage"""
|
||||
try:
|
||||
@@ -21,7 +29,9 @@ class AzureBlobService:
|
||||
settings.AZURE_STORAGE_CONNECTION_STRING
|
||||
)
|
||||
self.container_name = settings.AZURE_CONTAINER_NAME
|
||||
logger.info(f"Cliente de Azure Blob Storage inicializado para container: {self.container_name}")
|
||||
logger.info(
|
||||
f"Cliente de Azure Blob Storage inicializado para container: {self.container_name}"
|
||||
)
|
||||
|
||||
# Configurar CORS automáticamente al inicializar
|
||||
self._configure_cors()
|
||||
@@ -45,7 +55,7 @@ class AzureBlobService:
|
||||
allowed_methods=["GET", "HEAD", "OPTIONS"],
|
||||
allowed_headers=["*"],
|
||||
exposed_headers=["*"],
|
||||
max_age_in_seconds=3600
|
||||
max_age_in_seconds=3600,
|
||||
)
|
||||
|
||||
# Aplicar la configuración CORS
|
||||
@@ -55,15 +65,19 @@ class AzureBlobService:
|
||||
except Exception as e:
|
||||
# No fallar si CORS no se puede configurar (puede que ya esté configurado)
|
||||
logger.warning(f"No se pudo configurar CORS automáticamente: {e}")
|
||||
logger.warning("Asegúrate de configurar CORS manualmente en Azure Portal si es necesario")
|
||||
|
||||
logger.warning(
|
||||
"Asegúrate de configurar CORS manualmente en Azure Portal si es necesario"
|
||||
)
|
||||
|
||||
async def create_container_if_not_exists(self) -> bool:
|
||||
"""
|
||||
Crear el container si no existe
|
||||
Returns: True si se creó, False si ya existía
|
||||
"""
|
||||
try:
|
||||
container_client = self.blob_service_client.get_container_client(self.container_name)
|
||||
container_client = self.blob_service_client.get_container_client(
|
||||
self.container_name
|
||||
)
|
||||
container_client.create_container()
|
||||
logger.info(f"Container '{self.container_name}' creado exitosamente")
|
||||
return True
|
||||
@@ -73,217 +87,249 @@ class AzureBlobService:
|
||||
except Exception as e:
|
||||
logger.error(f"Error creando container: {e}")
|
||||
raise e
|
||||
|
||||
async def upload_file(self, file_data: BinaryIO, blob_name: str, tema: str = "") -> dict:
|
||||
|
||||
async def upload_file(
|
||||
self, file_data: BinaryIO, blob_name: str, tema: str = ""
|
||||
) -> dict:
|
||||
"""
|
||||
Subir un archivo a Azure Blob Storage
|
||||
|
||||
|
||||
Args:
|
||||
file_data: Datos del archivo
|
||||
blob_name: Nombre del archivo en el blob
|
||||
tema: Tema/carpeta donde guardar el archivo
|
||||
|
||||
tema: Tema/carpeta donde guardar el archivo (se normaliza a lowercase)
|
||||
|
||||
Returns:
|
||||
dict: Información del archivo subido
|
||||
"""
|
||||
try:
|
||||
# Construir la ruta completa con tema si se proporciona
|
||||
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
|
||||
|
||||
# Normalizar tema a lowercase para consistencia
|
||||
tema_normalized = tema.lower() if tema else ""
|
||||
|
||||
# Construir la ruta completa con tema normalizado
|
||||
full_blob_name = (
|
||||
f"{tema_normalized}/{blob_name}" if tema_normalized else blob_name
|
||||
)
|
||||
|
||||
# Obtener cliente del blob
|
||||
blob_client = self.blob_service_client.get_blob_client(
|
||||
container=self.container_name,
|
||||
blob=full_blob_name
|
||||
container=self.container_name, blob=full_blob_name
|
||||
)
|
||||
|
||||
|
||||
# Subir el archivo
|
||||
blob_client.upload_blob(file_data, overwrite=True)
|
||||
|
||||
|
||||
# Obtener propiedades del blob
|
||||
blob_properties = blob_client.get_blob_properties()
|
||||
|
||||
|
||||
logger.info(f"Archivo '{full_blob_name}' subido exitosamente")
|
||||
|
||||
|
||||
return {
|
||||
"name": blob_name,
|
||||
"full_path": full_blob_name,
|
||||
"tema": tema,
|
||||
"tema": tema_normalized,
|
||||
"size": blob_properties.size,
|
||||
"last_modified": blob_properties.last_modified,
|
||||
"url": blob_client.url
|
||||
"url": blob_client.url,
|
||||
}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error subiendo archivo '{blob_name}': {e}")
|
||||
raise e
|
||||
|
||||
|
||||
async def download_file(self, blob_name: str, tema: str = "") -> bytes:
|
||||
"""
|
||||
Descargar un archivo de Azure Blob Storage
|
||||
|
||||
|
||||
Args:
|
||||
blob_name: Nombre del archivo
|
||||
tema: Tema/carpeta donde está el archivo
|
||||
|
||||
tema: Tema/carpeta donde está el archivo (búsqueda case-insensitive)
|
||||
|
||||
Returns:
|
||||
bytes: Contenido del archivo
|
||||
"""
|
||||
try:
|
||||
# Construir la ruta completa
|
||||
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
|
||||
|
||||
# Si se proporciona tema, buscar el archivo de manera case-insensitive
|
||||
if tema:
|
||||
full_blob_name = await self._find_blob_case_insensitive(blob_name, tema)
|
||||
else:
|
||||
full_blob_name = blob_name
|
||||
|
||||
# Obtener cliente del blob
|
||||
blob_client = self.blob_service_client.get_blob_client(
|
||||
container=self.container_name,
|
||||
blob=full_blob_name
|
||||
container=self.container_name, blob=full_blob_name
|
||||
)
|
||||
|
||||
|
||||
# Descargar el archivo
|
||||
blob_data = blob_client.download_blob()
|
||||
content = blob_data.readall()
|
||||
|
||||
|
||||
logger.info(f"Archivo '{full_blob_name}' descargado exitosamente")
|
||||
return content
|
||||
|
||||
|
||||
except ResourceNotFoundError:
|
||||
logger.error(f"Archivo '{full_blob_name}' no encontrado")
|
||||
raise FileNotFoundError(f"El archivo '{blob_name}' no existe")
|
||||
except Exception as e:
|
||||
logger.error(f"Error descargando archivo '{blob_name}': {e}")
|
||||
raise e
|
||||
|
||||
|
||||
async def delete_file(self, blob_name: str, tema: str = "") -> bool:
|
||||
"""
|
||||
Eliminar un archivo de Azure Blob Storage
|
||||
|
||||
|
||||
Args:
|
||||
blob_name: Nombre del archivo
|
||||
tema: Tema/carpeta donde está el archivo
|
||||
|
||||
tema: Tema/carpeta donde está el archivo (búsqueda case-insensitive)
|
||||
|
||||
Returns:
|
||||
bool: True si se eliminó exitosamente
|
||||
"""
|
||||
try:
|
||||
# Construir la ruta completa
|
||||
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
|
||||
|
||||
# Si se proporciona tema, buscar el archivo de manera case-insensitive
|
||||
if tema:
|
||||
full_blob_name = await self._find_blob_case_insensitive(blob_name, tema)
|
||||
else:
|
||||
full_blob_name = blob_name
|
||||
|
||||
# Obtener cliente del blob
|
||||
blob_client = self.blob_service_client.get_blob_client(
|
||||
container=self.container_name,
|
||||
blob=full_blob_name
|
||||
container=self.container_name, blob=full_blob_name
|
||||
)
|
||||
|
||||
|
||||
# Eliminar el archivo
|
||||
blob_client.delete_blob()
|
||||
|
||||
|
||||
logger.info(f"Archivo '{full_blob_name}' eliminado exitosamente")
|
||||
return True
|
||||
|
||||
|
||||
except ResourceNotFoundError:
|
||||
logger.error(f"Archivo '{full_blob_name}' no encontrado para eliminar")
|
||||
raise FileNotFoundError(f"El archivo '{blob_name}' no existe")
|
||||
except Exception as e:
|
||||
logger.error(f"Error eliminando archivo '{blob_name}': {e}")
|
||||
raise e
|
||||
|
||||
|
||||
async def list_files(self, tema: str = "") -> List[dict]:
|
||||
"""
|
||||
Listar archivos en el container o en un tema específico
|
||||
|
||||
|
||||
Args:
|
||||
tema: Tema/carpeta específica (opcional)
|
||||
|
||||
tema: Tema/carpeta específica (opcional) - filtrado case-insensitive
|
||||
|
||||
Returns:
|
||||
List[dict]: Lista de archivos con sus propiedades
|
||||
"""
|
||||
try:
|
||||
container_client = self.blob_service_client.get_container_client(self.container_name)
|
||||
|
||||
# Filtrar por tema si se proporciona
|
||||
name_starts_with = f"{tema}/" if tema else None
|
||||
|
||||
blobs = container_client.list_blobs(name_starts_with=name_starts_with)
|
||||
|
||||
container_client = self.blob_service_client.get_container_client(
|
||||
self.container_name
|
||||
)
|
||||
|
||||
# Obtener todos los blobs para hacer filtrado case-insensitive
|
||||
blobs = container_client.list_blobs()
|
||||
|
||||
files = []
|
||||
tema_lower = tema.lower() if tema else ""
|
||||
|
||||
for blob in blobs:
|
||||
# Extraer información del blob
|
||||
blob_tema = os.path.dirname(blob.name) if "/" in blob.name else ""
|
||||
|
||||
# Filtrar por tema de manera case-insensitive si se proporciona
|
||||
if tema and blob_tema.lower() != tema_lower:
|
||||
continue
|
||||
|
||||
blob_info = {
|
||||
"name": os.path.basename(blob.name),
|
||||
"full_path": blob.name,
|
||||
"tema": os.path.dirname(blob.name) if "/" in blob.name else "",
|
||||
"tema": blob_tema,
|
||||
"size": blob.size,
|
||||
"last_modified": blob.last_modified,
|
||||
"content_type": blob.content_settings.content_type if blob.content_settings else None
|
||||
"content_type": blob.content_settings.content_type
|
||||
if blob.content_settings
|
||||
else None,
|
||||
}
|
||||
files.append(blob_info)
|
||||
|
||||
logger.info(f"Listados {len(files)} archivos" + (f" en tema '{tema}'" if tema else ""))
|
||||
|
||||
logger.info(
|
||||
f"Listados {len(files)} archivos"
|
||||
+ (f" en tema '{tema}' (case-insensitive)" if tema else "")
|
||||
)
|
||||
return files
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listando archivos: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
async def get_file_info(self, blob_name: str, tema: str = "") -> dict:
|
||||
"""
|
||||
Obtener información de un archivo específico
|
||||
|
||||
|
||||
Args:
|
||||
blob_name: Nombre del archivo
|
||||
tema: Tema/carpeta donde está el archivo
|
||||
|
||||
tema: Tema/carpeta donde está el archivo (búsqueda case-insensitive)
|
||||
|
||||
Returns:
|
||||
dict: Información del archivo
|
||||
"""
|
||||
try:
|
||||
# Construir la ruta completa
|
||||
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
|
||||
|
||||
# Si se proporciona tema, buscar el archivo de manera case-insensitive
|
||||
if tema:
|
||||
full_blob_name = await self._find_blob_case_insensitive(blob_name, tema)
|
||||
# Extraer el tema real del path encontrado
|
||||
real_tema = (
|
||||
os.path.dirname(full_blob_name) if "/" in full_blob_name else ""
|
||||
)
|
||||
else:
|
||||
full_blob_name = blob_name
|
||||
real_tema = ""
|
||||
|
||||
# Obtener cliente del blob
|
||||
blob_client = self.blob_service_client.get_blob_client(
|
||||
container=self.container_name,
|
||||
blob=full_blob_name
|
||||
container=self.container_name, blob=full_blob_name
|
||||
)
|
||||
|
||||
|
||||
# Obtener propiedades
|
||||
properties = blob_client.get_blob_properties()
|
||||
|
||||
|
||||
return {
|
||||
"name": blob_name,
|
||||
"full_path": full_blob_name,
|
||||
"tema": tema,
|
||||
"tema": real_tema,
|
||||
"size": properties.size,
|
||||
"last_modified": properties.last_modified,
|
||||
"content_type": properties.content_settings.content_type,
|
||||
"url": blob_client.url
|
||||
"url": blob_client.url,
|
||||
}
|
||||
|
||||
|
||||
except ResourceNotFoundError:
|
||||
logger.error(f"Archivo '{full_blob_name}' no encontrado")
|
||||
raise FileNotFoundError(f"El archivo '{blob_name}' no existe")
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo info del archivo '{blob_name}': {e}")
|
||||
raise e
|
||||
|
||||
|
||||
async def get_download_url(self, blob_name: str, tema: str = "") -> str:
|
||||
"""
|
||||
Obtener URL de descarga directa para un archivo
|
||||
|
||||
Args:
|
||||
blob_name: Nombre del archivo
|
||||
tema: Tema/carpeta donde está el archivo
|
||||
tema: Tema/carpeta donde está el archivo (búsqueda case-insensitive)
|
||||
|
||||
Returns:
|
||||
str: URL de descarga
|
||||
"""
|
||||
try:
|
||||
# Construir la ruta completa
|
||||
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
|
||||
# Si se proporciona tema, buscar el archivo de manera case-insensitive
|
||||
if tema:
|
||||
full_blob_name = await self._find_blob_case_insensitive(blob_name, tema)
|
||||
else:
|
||||
full_blob_name = blob_name
|
||||
|
||||
# Obtener cliente del blob
|
||||
blob_client = self.blob_service_client.get_blob_client(
|
||||
container=self.container_name,
|
||||
blob=full_blob_name
|
||||
container=self.container_name, blob=full_blob_name
|
||||
)
|
||||
|
||||
return blob_client.url
|
||||
@@ -292,7 +338,9 @@ class AzureBlobService:
|
||||
logger.error(f"Error obteniendo URL de descarga para '{blob_name}': {e}")
|
||||
raise e
|
||||
|
||||
async def generate_sas_url(self, blob_name: str, tema: str = "", expiry_hours: int = 1) -> str:
|
||||
async def generate_sas_url(
|
||||
self, blob_name: str, tema: str = "", expiry_hours: int = 1
|
||||
) -> str:
|
||||
"""
|
||||
Generar una URL SAS (Shared Access Signature) temporal para acceder a un archivo
|
||||
|
||||
@@ -301,7 +349,7 @@ class AzureBlobService:
|
||||
|
||||
Args:
|
||||
blob_name: Nombre del archivo
|
||||
tema: Tema/carpeta donde está el archivo
|
||||
tema: Tema/carpeta donde está el archivo (búsqueda case-insensitive)
|
||||
expiry_hours: Horas de validez de la URL (por defecto 1 hora)
|
||||
|
||||
Returns:
|
||||
@@ -310,13 +358,15 @@ class AzureBlobService:
|
||||
try:
|
||||
from azure.storage.blob import ContentSettings
|
||||
|
||||
# Construir la ruta completa del blob
|
||||
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
|
||||
# Si se proporciona tema, buscar el archivo de manera case-insensitive
|
||||
if tema:
|
||||
full_blob_name = await self._find_blob_case_insensitive(blob_name, tema)
|
||||
else:
|
||||
full_blob_name = blob_name
|
||||
|
||||
# Obtener cliente del blob
|
||||
blob_client = self.blob_service_client.get_blob_client(
|
||||
container=self.container_name,
|
||||
blob=full_blob_name
|
||||
container=self.container_name, blob=full_blob_name
|
||||
)
|
||||
|
||||
# Verificar que el archivo existe antes de generar el SAS
|
||||
@@ -327,11 +377,13 @@ class AzureBlobService:
|
||||
# Esto hace que el navegador muestre el PDF en lugar de descargarlo
|
||||
try:
|
||||
content_settings = ContentSettings(
|
||||
content_type='application/pdf',
|
||||
content_disposition='inline' # Clave para mostrar en navegador
|
||||
content_type="application/pdf",
|
||||
content_disposition="inline", # Clave para mostrar en navegador
|
||||
)
|
||||
blob_client.set_http_headers(content_settings=content_settings)
|
||||
logger.info(f"Headers configurados para visualización inline de '{full_blob_name}'")
|
||||
logger.info(
|
||||
f"Headers configurados para visualización inline de '{full_blob_name}'"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"No se pudieron configurar headers inline: {e}")
|
||||
|
||||
@@ -342,9 +394,9 @@ class AzureBlobService:
|
||||
# Extraer la account key del connection string para generar el SAS
|
||||
# El SAS necesita la account key para firmar el token
|
||||
account_key = None
|
||||
for part in settings.AZURE_STORAGE_CONNECTION_STRING.split(';'):
|
||||
if part.startswith('AccountKey='):
|
||||
account_key = part.split('=', 1)[1]
|
||||
for part in settings.AZURE_STORAGE_CONNECTION_STRING.split(";"):
|
||||
if part.startswith("AccountKey="):
|
||||
account_key = part.split("=", 1)[1]
|
||||
break
|
||||
|
||||
if not account_key:
|
||||
@@ -358,13 +410,15 @@ class AzureBlobService:
|
||||
account_key=account_key,
|
||||
permission=BlobSasPermissions(read=True), # Solo permisos de lectura
|
||||
expiry=expiry_time,
|
||||
start=start_time
|
||||
start=start_time,
|
||||
)
|
||||
|
||||
# Construir la URL completa con el SAS token
|
||||
sas_url = f"{blob_client.url}?{sas_token}"
|
||||
|
||||
logger.info(f"SAS URL generada para '{full_blob_name}' (válida por {expiry_hours} horas)")
|
||||
logger.info(
|
||||
f"SAS URL generada para '{full_blob_name}' (válida por {expiry_hours} horas)"
|
||||
)
|
||||
return sas_url
|
||||
|
||||
except FileNotFoundError:
|
||||
@@ -374,6 +428,47 @@ class AzureBlobService:
|
||||
logger.error(f"Error generando SAS URL para '{blob_name}': {e}")
|
||||
raise e
|
||||
|
||||
async def _find_blob_case_insensitive(self, blob_name: str, tema: str) -> str:
|
||||
"""
|
||||
Buscar un blob de manera case-insensitive
|
||||
|
||||
Args:
|
||||
blob_name: Nombre del archivo a buscar
|
||||
tema: Tema donde buscar (case-insensitive)
|
||||
|
||||
Returns:
|
||||
str: Ruta completa del blob encontrado
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Si no se encuentra el archivo
|
||||
"""
|
||||
try:
|
||||
container_client = self.blob_service_client.get_container_client(
|
||||
self.container_name
|
||||
)
|
||||
blobs = container_client.list_blobs()
|
||||
|
||||
tema_lower = tema.lower()
|
||||
blob_name_lower = blob_name.lower()
|
||||
|
||||
for blob in blobs:
|
||||
blob_tema = os.path.dirname(blob.name) if "/" in blob.name else ""
|
||||
current_blob_name = os.path.basename(blob.name)
|
||||
|
||||
if (
|
||||
blob_tema.lower() == tema_lower
|
||||
and current_blob_name.lower() == blob_name_lower
|
||||
):
|
||||
return blob.name
|
||||
|
||||
# Si no se encuentra, usar la construcción original para que falle apropiadamente
|
||||
return f"{tema}/{blob_name}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error buscando blob case-insensitive: {e}")
|
||||
# Fallback a construcción original
|
||||
return f"{tema}/{blob_name}"
|
||||
|
||||
|
||||
# Instancia global del servicio
|
||||
azure_service = AzureBlobService()
|
||||
azure_service = AzureBlobService()
|
||||
|
||||
@@ -27,6 +27,8 @@ dependencies = [
|
||||
"langchain-text-splitters>=1.0.0",
|
||||
# LandingAI Document AI
|
||||
"landingai-ade>=0.2.1",
|
||||
"redis-om>=0.3.5",
|
||||
"pydantic-ai-slim[google,openai]>=1.11.1",
|
||||
]
|
||||
[project.scripts]
|
||||
dev = "uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
|
||||
|
||||
290
backend/uv.lock
generated
290
backend/uv.lock
generated
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14'",
|
||||
@@ -74,11 +74,13 @@ dependencies = [
|
||||
{ name = "openai" },
|
||||
{ name = "pdf2image" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pydantic-ai-slim", extra = ["google", "openai"] },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pypdf" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "qdrant-client" },
|
||||
{ name = "redis-om" },
|
||||
{ name = "tiktoken" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "websockets" },
|
||||
@@ -96,11 +98,13 @@ requires-dist = [
|
||||
{ name = "openai", specifier = ">=1.59.6" },
|
||||
{ name = "pdf2image", specifier = ">=1.17.0" },
|
||||
{ name = "pillow", specifier = ">=11.0.0" },
|
||||
{ name = "pydantic-ai-slim", extras = ["google", "openai"], specifier = ">=1.11.1" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.10.1" },
|
||||
{ name = "pypdf", specifier = ">=5.1.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.1.1" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||
{ name = "qdrant-client", specifier = ">=1.15.1" },
|
||||
{ name = "redis-om", specifier = ">=0.3.5" },
|
||||
{ name = "tiktoken", specifier = ">=0.8.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35.0" },
|
||||
{ name = "websockets", specifier = ">=14.1" },
|
||||
@@ -287,6 +291,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "genai-prices"
|
||||
version = "0.0.36"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/39/e2/45c863fb61cf2d70d948e80d63e4f3db213a957976a2a3564e40ebe8f506/genai_prices-0.0.36.tar.gz", hash = "sha256:1092f5b96168967fa880440dd9dcc9287fd73910b284045f0226a38f628ccbc9", size = 46046, upload-time = "2025-11-05T14:04:13.437Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/16/89/14b4be11b74dd29827bc37b648b0540fcf3bd6530cb48031f1ce7da4594c/genai_prices-0.0.36-py3-none-any.whl", hash = "sha256:7ad39e04fbcdb5cfdc3891e68de6ca1064b6660e06e9ba76fa6f161ff12b32e4", size = 48688, upload-time = "2025-11-05T14:04:12.133Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-api-core"
|
||||
version = "2.28.1"
|
||||
@@ -484,6 +501,18 @@ grpc = [
|
||||
{ name = "grpcio", version = "1.76.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "griffe"
|
||||
version = "1.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/d7/6c09dd7ce4c7837e4cdb11dce980cb45ae3cd87677298dc3b781b6bce7d3/griffe-1.14.0.tar.gz", hash = "sha256:9d2a15c1eca966d68e00517de5d69dd1bc5c9f2335ef6c1775362ba5b8651a13", size = 424684, upload-time = "2025-09-05T15:02:29.167Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/b1/9ff6578d789a89812ff21e4e0f80ffae20a65d5dd84e7a17873fe3b365be/griffe-1.14.0-py3-none-any.whl", hash = "sha256:0e9d52832cccf0f7188cfe585ba962d2674b241c01916d780925df34873bceb0", size = 144439, upload-time = "2025-09-05T15:02:27.511Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grpc-google-iam-v1"
|
||||
version = "0.14.3"
|
||||
@@ -632,6 +661,66 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hiredis"
|
||||
version = "3.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/82/d2817ce0653628e0a0cb128533f6af0dd6318a49f3f3a6a7bd1f2f2154af/hiredis-3.3.0.tar.gz", hash = "sha256:105596aad9249634361815c574351f1bd50455dc23b537c2940066c4a9dea685", size = 89048, upload-time = "2025-10-14T16:33:34.263Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/1c/ed28ae5d704f5c7e85b946fa327f30d269e6272c847fef7e91ba5fc86193/hiredis-3.3.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:5b8e1d6a2277ec5b82af5dce11534d3ed5dffeb131fd9b210bc1940643b39b5f", size = 82026, upload-time = "2025-10-14T16:32:12.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/9b/79f30c5c40e248291023b7412bfdef4ad9a8a92d9e9285d65d600817dac7/hiredis-3.3.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c4981de4d335f996822419e8a8b3b87367fcef67dc5fb74d3bff4df9f6f17783", size = 46217, upload-time = "2025-10-14T16:32:13.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/c3/02b9ed430ad9087aadd8afcdf616717452d16271b701fa47edfe257b681e/hiredis-3.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1706480a683e328ae9ba5d704629dee2298e75016aa0207e7067b9c40cecc271", size = 41858, upload-time = "2025-10-14T16:32:13.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/98/b2a42878b82130a535c7aa20bc937ba2d07d72e9af3ad1ad93e837c419b5/hiredis-3.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a95cef9989736ac313639f8f545b76b60b797e44e65834aabbb54e4fad8d6c8", size = 170195, upload-time = "2025-10-14T16:32:14.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/1d/9dcde7a75115d3601b016113d9b90300726fa8e48aacdd11bf01a453c145/hiredis-3.3.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca2802934557ccc28a954414c245ba7ad904718e9712cb67c05152cf6b9dd0a3", size = 181808, upload-time = "2025-10-14T16:32:15.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/a1/60f6bda9b20b4e73c85f7f5f046bc2c154a5194fc94eb6861e1fd97ced52/hiredis-3.3.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fe730716775f61e76d75810a38ee4c349d3af3896450f1525f5a4034cf8f2ed7", size = 180578, upload-time = "2025-10-14T16:32:16.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/01/859d21de65085f323a701824e23ea3330a0ac05f8e184544d7aa5c26128d/hiredis-3.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:749faa69b1ce1f741f5eaf743435ac261a9262e2d2d66089192477e7708a9abc", size = 172508, upload-time = "2025-10-14T16:32:17.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/a8/28fd526e554c80853d0fbf57ef2a3235f00e4ed34ce0e622e05d27d0f788/hiredis-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:95c9427f2ac3f1dd016a3da4e1161fa9d82f221346c8f3fdd6f3f77d4e28946c", size = 166341, upload-time = "2025-10-14T16:32:18.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/91/ded746b7d2914f557fbbf77be55e90d21f34ba758ae10db6591927c642c8/hiredis-3.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c863ee44fe7bff25e41f3a5105c936a63938b76299b802d758f40994ab340071", size = 176765, upload-time = "2025-10-14T16:32:19.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/4c/04aa46ff386532cb5f08ee495c2bf07303e93c0acf2fa13850e031347372/hiredis-3.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2213c7eb8ad5267434891f3241c7776e3bafd92b5933fc57d53d4456247dc542", size = 170312, upload-time = "2025-10-14T16:32:20.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/6e/67f9d481c63f542a9cf4c9f0ea4e5717db0312fb6f37fb1f78f3a66de93c/hiredis-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a172bae3e2837d74530cd60b06b141005075db1b814d966755977c69bd882ce8", size = 167965, upload-time = "2025-10-14T16:32:21.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/df/dde65144d59c3c0d85e43255798f1fa0c48d413e668cfd92b3d9f87924ef/hiredis-3.3.0-cp312-cp312-win32.whl", hash = "sha256:cb91363b9fd6d41c80df9795e12fffbaf5c399819e6ae8120f414dedce6de068", size = 20533, upload-time = "2025-10-14T16:32:22.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/a9/55a4ac9c16fdf32e92e9e22c49f61affe5135e177ca19b014484e28950f7/hiredis-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:04ec150e95eea3de9ff8bac754978aa17b8bf30a86d4ab2689862020945396b0", size = 22379, upload-time = "2025-10-14T16:32:22.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/39/2b789ebadd1548ccb04a2c18fbc123746ad1a7e248b7f3f3cac618ca10a6/hiredis-3.3.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:b7048b4ec0d5dddc8ddd03da603de0c4b43ef2540bf6e4c54f47d23e3480a4fa", size = 82035, upload-time = "2025-10-14T16:32:23.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/74/4066d9c1093be744158ede277f2a0a4e4cd0fefeaa525c79e2876e9e5c72/hiredis-3.3.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:e5f86ce5a779319c15567b79e0be806e8e92c18bb2ea9153e136312fafa4b7d6", size = 46219, upload-time = "2025-10-14T16:32:24.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/3f/f9e0f6d632f399d95b3635703e1558ffaa2de3aea4cfcbc2d7832606ba43/hiredis-3.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fbdb97a942e66016fff034df48a7a184e2b7dc69f14c4acd20772e156f20d04b", size = 41860, upload-time = "2025-10-14T16:32:25.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/c5/b7dde5ec390dabd1cabe7b364a509c66d4e26de783b0b64cf1618f7149fc/hiredis-3.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0fb4bea72fe45ff13e93ddd1352b43ff0749f9866263b5cca759a4c960c776f", size = 170094, upload-time = "2025-10-14T16:32:26.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d6/7f05c08ee74d41613be466935688068e07f7b6c55266784b5ace7b35b766/hiredis-3.3.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85b9baf98050e8f43c2826ab46aaf775090d608217baf7af7882596aef74e7f9", size = 181746, upload-time = "2025-10-14T16:32:27.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/d2/aaf9f8edab06fbf5b766e0cae3996324297c0516a91eb2ca3bd1959a0308/hiredis-3.3.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69079fb0f0ebb61ba63340b9c4bce9388ad016092ca157e5772eb2818209d930", size = 180465, upload-time = "2025-10-14T16:32:29.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/1e/93ded8b9b484519b211fc71746a231af98c98928e3ebebb9086ed20bb1ad/hiredis-3.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c17f77b79031ea4b0967d30255d2ae6e7df0603ee2426ad3274067f406938236", size = 172419, upload-time = "2025-10-14T16:32:30.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/13/02880458e02bbfcedcaabb8f7510f9dda1c89d7c1921b1bb28c22bb38cbf/hiredis-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45d14f745fc177bc05fc24bdf20e2b515e9a068d3d4cce90a0fb78d04c9c9d9a", size = 166400, upload-time = "2025-10-14T16:32:31.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/60/896e03267670570f19f61dc65a2137fcb2b06e83ab0911d58eeec9f3cb88/hiredis-3.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ba063fdf1eff6377a0c409609cbe890389aefddfec109c2d20fcc19cfdafe9da", size = 176845, upload-time = "2025-10-14T16:32:32.12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/90/a1d4bd0cdcf251fda72ac0bd932f547b48ad3420f89bb2ef91bf6a494534/hiredis-3.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1799cc66353ad066bfdd410135c951959da9f16bcb757c845aab2f21fc4ef099", size = 170365, upload-time = "2025-10-14T16:32:33.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/9a/7c98f7bb76bdb4a6a6003cf8209721f083e65d2eed2b514f4a5514bda665/hiredis-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2cbf71a121996ffac82436b6153290815b746afb010cac19b3290a1644381b07", size = 168022, upload-time = "2025-10-14T16:32:34.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/ca/672ee658ffe9525558615d955b554ecd36aa185acd4431ccc9701c655c9b/hiredis-3.3.0-cp313-cp313-win32.whl", hash = "sha256:a7cbbc6026bf03659f0b25e94bbf6e64f6c8c22f7b4bc52fe569d041de274194", size = 20533, upload-time = "2025-10-14T16:32:35.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/93/511fd94f6a7b6d72a4cf9c2b159bf3d780585a9a1dca52715dd463825299/hiredis-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:a8def89dd19d4e2e4482b7412d453dec4a5898954d9a210d7d05f60576cedef6", size = 22387, upload-time = "2025-10-14T16:32:36.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/b3/b948ee76a6b2bc7e45249861646f91f29704f743b52565cf64cee9c4658b/hiredis-3.3.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c135bda87211f7af9e2fd4e046ab433c576cd17b69e639a0f5bb2eed5e0e71a9", size = 82105, upload-time = "2025-10-14T16:32:37.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/9b/4210f4ebfb3ab4ada964b8de08190f54cbac147198fb463cd3c111cc13e0/hiredis-3.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f855c678230aed6fc29b962ce1cc67e5858a785ef3a3fd6b15dece0487a2e60", size = 46237, upload-time = "2025-10-14T16:32:38.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/7a/e38bfd7d04c05036b4ccc6f42b86b1032185cf6ae426e112a97551fece14/hiredis-3.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4059c78a930cbb33c391452ccce75b137d6f89e2eebf6273d75dafc5c2143c03", size = 41894, upload-time = "2025-10-14T16:32:38.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/d3/eae43d9609c5d9a6effef0586ee47e13a0d84b44264b688d97a75cd17ee5/hiredis-3.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:334a3f1d14c253bb092e187736c3384203bd486b244e726319bbb3f7dffa4a20", size = 170486, upload-time = "2025-10-14T16:32:40.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/fd/34d664554880b27741ab2916d66207357563b1639e2648685f4c84cfb755/hiredis-3.3.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd137b147235447b3d067ec952c5b9b95ca54b71837e1b38dbb2ec03b89f24fc", size = 182031, upload-time = "2025-10-14T16:32:41.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/a3/0c69fdde3f4155b9f7acc64ccffde46f312781469260061b3bbaa487fd34/hiredis-3.3.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f88f4f2aceb73329ece86a1cb0794fdbc8e6d614cb5ca2d1023c9b7eb432db8", size = 180542, upload-time = "2025-10-14T16:32:42.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/7a/ad5da4d7bc241e57c5b0c4fe95aa75d1f2116e6e6c51577394d773216e01/hiredis-3.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:550f4d1538822fc75ebf8cf63adc396b23d4958bdbbad424521f2c0e3dfcb169", size = 172353, upload-time = "2025-10-14T16:32:43.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/dc/c46eace64eb047a5b31acd5e4b0dc6d2f0390a4a3f6d507442d9efa570ad/hiredis-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54b14211fbd5930fc696f6fcd1f1f364c660970d61af065a80e48a1fa5464dd6", size = 166435, upload-time = "2025-10-14T16:32:44.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/ac/ad13a714e27883a2e4113c980c94caf46b801b810de5622c40f8d3e8335f/hiredis-3.3.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9e96f63dbc489fc86f69951e9f83dadb9582271f64f6822c47dcffa6fac7e4a", size = 177218, upload-time = "2025-10-14T16:32:45.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/38/268fabd85b225271fe1ba82cb4a484fcc1bf922493ff2c74b400f1a6f339/hiredis-3.3.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:106e99885d46684d62ab3ec1d6b01573cc0e0083ac295b11aaa56870b536c7ec", size = 170477, upload-time = "2025-10-14T16:32:46.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/6b/02bb8af810ea04247334ab7148acff7a61c08a8832830c6703f464be83a9/hiredis-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:087e2ef3206361281b1a658b5b4263572b6ba99465253e827796964208680459", size = 167915, upload-time = "2025-10-14T16:32:47.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/94/901fa817e667b2e69957626395e6dee416e31609dca738f28e6b545ca6c2/hiredis-3.3.0-cp314-cp314-win32.whl", hash = "sha256:80638ebeab1cefda9420e9fedc7920e1ec7b4f0513a6b23d58c9d13c882f8065", size = 21165, upload-time = "2025-10-14T16:32:50.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/7e/4881b9c1d0b4cdaba11bd10e600e97863f977ea9d67c5988f7ec8cd363e5/hiredis-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a68aaf9ba024f4e28cf23df9196ff4e897bd7085872f3a30644dca07fa787816", size = 22996, upload-time = "2025-10-14T16:32:51.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/b6/d7e6c17da032665a954a89c1e6ee3bd12cb51cd78c37527842b03519981d/hiredis-3.3.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f7f80442a32ce51ee5d89aeb5a84ee56189a0e0e875f1a57bbf8d462555ae48f", size = 83034, upload-time = "2025-10-14T16:32:52.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/6c/6751b698060cdd1b2d8427702cff367c9ed7a1705bcf3792eb5b896f149b/hiredis-3.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1a67530da714954ed50579f4fe1ab0ddbac9c43643b1721c2cb226a50dde263", size = 46701, upload-time = "2025-10-14T16:32:53.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/8e/20a5cf2c83c7a7e08c76b9abab113f99f71cd57468a9c7909737ce6e9bf8/hiredis-3.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:616868352e47ab355559adca30f4f3859f9db895b4e7bc71e2323409a2add751", size = 42381, upload-time = "2025-10-14T16:32:54.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/0a/547c29c06e8c9c337d0df3eec39da0cf1aad701daf8a9658dd37f25aca66/hiredis-3.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e799b79f3150083e9702fc37e6243c0bd47a443d6eae3f3077b0b3f510d6a145", size = 180313, upload-time = "2025-10-14T16:32:55.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/8a/488de5469e3d0921a1c425045bf00e983d48b2111a90e47cf5769eaa536c/hiredis-3.3.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ef1dfb0d2c92c3701655e2927e6bbe10c499aba632c7ea57b6392516df3864b", size = 190488, upload-time = "2025-10-14T16:32:56.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/59/8493edc3eb9ae0dbea2b2230c2041a52bc03e390b02ffa3ac0bca2af9aea/hiredis-3.3.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c290da6bc2a57e854c7da9956cd65013483ede935677e84560da3b848f253596", size = 189210, upload-time = "2025-10-14T16:32:57.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/de/8c9a653922057b32fb1e2546ecd43ef44c9aa1a7cf460c87cae507eb2bc7/hiredis-3.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd8c438d9e1728f0085bf9b3c9484d19ec31f41002311464e75b69550c32ffa8", size = 180972, upload-time = "2025-10-14T16:32:58.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/a3/51e6e6afaef2990986d685ca6e254ffbd191f1635a59b2d06c9e5d10c8a2/hiredis-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1bbc6b8a88bbe331e3ebf6685452cebca6dfe6d38a6d4efc5651d7e363ba28bd", size = 175315, upload-time = "2025-10-14T16:32:59.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/54/e436312feb97601f70f8b39263b8da5ac4a5d18305ebdfb08ad7621f6119/hiredis-3.3.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:55d8c18fe9a05496c5c04e6eccc695169d89bf358dff964bcad95696958ec05f", size = 185653, upload-time = "2025-10-14T16:33:00.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/a3/88e66030d066337c6c0f883a912c6d4b2d6d7173490fbbc113a6cbe414ff/hiredis-3.3.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4ddc79afa76b805d364e202a754666cb3c4d9c85153cbfed522871ff55827838", size = 179032, upload-time = "2025-10-14T16:33:01.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/1f/fb7375467e9adaa371cd617c2984fefe44bdce73add4c70b8dd8cab1b33a/hiredis-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e8a4b8540581dcd1b2b25827a54cfd538e0afeaa1a0e3ca87ad7126965981cc", size = 176127, upload-time = "2025-10-14T16:33:02.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/14/0dc2b99209c400f3b8f24067273e9c3cb383d894e155830879108fb19e98/hiredis-3.3.0-cp314-cp314t-win32.whl", hash = "sha256:298593bb08487753b3afe6dc38bac2532e9bac8dcee8d992ef9977d539cc6776", size = 22024, upload-time = "2025-10-14T16:33:03.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/2f/8a0befeed8bbe142d5a6cf3b51e8cbe019c32a64a596b0ebcbc007a8f8f1/hiredis-3.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b442b6ab038a6f3b5109874d2514c4edf389d8d8b553f10f12654548808683bc", size = 23808, upload-time = "2025-10-14T16:33:04.965Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hpack"
|
||||
version = "4.1.0"
|
||||
@@ -714,6 +803,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "importlib-metadata"
|
||||
version = "8.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "zipp" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "isodate"
|
||||
version = "0.7.2"
|
||||
@@ -947,6 +1048,24 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/4c/6c0c338ca7182e4ecb7af61049415e7b3513cc6cea9aa5bf8ca508f53539/langsmith-0.4.41-py3-none-any.whl", hash = "sha256:5cdc554e5f0361bf791fdd5e8dea16d5ba9dfce09b3b8f8bba5e99450c569b27", size = 399279, upload-time = "2025-11-04T22:31:30.268Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logfire-api"
|
||||
version = "4.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/25/6072086af3b3ac5c2c2f2a6cf89488a1b228ffc6ee0fb357ed1e227efd13/logfire_api-4.14.2.tar.gz", hash = "sha256:bbdeccd931069b76ab811261b41bc52d8b78d1c045fc4b4237dbc085e0fb9bcd", size = 57604, upload-time = "2025-10-24T20:14:40.551Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/c7/b06a83df678fca882c24fb498e628e0406bdb95ffdfa7ae43ecc0a714d52/logfire_api-4.14.2-py3-none-any.whl", hash = "sha256:aa4af2ecb007c3e0095e25ba4526fd8c0e2c0be2ceceac71ca651c4ad86dc713", size = 95021, upload-time = "2025-10-24T20:14:36.161Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "more-itertools"
|
||||
version = "10.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.3.2"
|
||||
@@ -1029,6 +1148,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/74/6bfc3adc81f6c2cea4439f2a734c40e3a420703bbcdc539890096a732bbd/openai-2.7.1-py3-none-any.whl", hash = "sha256:2f2530354d94c59c614645a4662b9dab0a5b881c5cd767a8587398feac0c9021", size = 1008780, upload-time = "2025-11-04T06:07:20.818Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
version = "1.38.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "importlib-metadata" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/d8/0f354c375628e048bd0570645b310797299754730079853095bf000fba69/opentelemetry_api-1.38.0.tar.gz", hash = "sha256:f4c193b5e8acb0912b06ac5b16321908dd0843d75049c091487322284a3eea12", size = 65242, upload-time = "2025-10-16T08:35:50.25Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/a2/d86e01c28300bd41bab8f18afd613676e2bd63515417b77636fc1add426f/opentelemetry_api-1.38.0-py3-none-any.whl", hash = "sha256:2891b0197f47124454ab9f0cf58f3be33faca394457ac3e09daba13ff50aa582", size = 65947, upload-time = "2025-10-16T08:35:30.23Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.11.4"
|
||||
@@ -1293,6 +1425,32 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-ai-slim"
|
||||
version = "1.11.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "genai-prices" },
|
||||
{ name = "griffe" },
|
||||
{ name = "httpx" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-graph" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/a5/fbfcdd3c89549dd44417606af0130f1118aea8e43f4d14723e49218901a6/pydantic_ai_slim-1.11.1.tar.gz", hash = "sha256:242fb5c7a0f812d540f68d4e2e6498730ef11644b55ccf3da38bf9767802f742", size = 298765, upload-time = "2025-11-06T00:48:42.815Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/6d/d8ea48afdd8838d6419cdbc08d81753e2e732ff3451e3d83f6b4b56388af/pydantic_ai_slim-1.11.1-py3-none-any.whl", hash = "sha256:00ca8b0a8f677fa9efd077239b66c925423d1dc517dfac7953b62547a66adbf2", size = 397971, upload-time = "2025-11-06T00:48:28.219Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
google = [
|
||||
{ name = "google-genai" },
|
||||
]
|
||||
openai = [
|
||||
{ name = "openai" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.33.2"
|
||||
@@ -1335,6 +1493,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-graph"
|
||||
version = "1.11.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "logfire-api" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/b6/1b37a9517bc71fde33184cc6f3f03795c3669b7be5a143a3012fb112742d/pydantic_graph-1.11.1.tar.gz", hash = "sha256:345d6309ac677ef6cf2f5b225e6762afd9b87cc916b943376a5cb555705a7f2b", size = 57964, upload-time = "2025-11-06T00:48:45.028Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/50/64/934e1f9be64f44515c501bf528cfc2dd672516530d5a7aa7436f72aba5ef/pydantic_graph-1.11.1-py3-none-any.whl", hash = "sha256:4d52d0c925672439e407d64e663a5e7f011f0bb0941c8b6476911044c7478cd6", size = 72002, upload-time = "2025-11-06T00:48:32.411Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.10.1"
|
||||
@@ -1349,6 +1522,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.1.3"
|
||||
@@ -1388,6 +1570,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-ulid"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/8b/0580d8ee0a73a3f3869488856737c429cbaa08b63c3506275f383c4771a8/python-ulid-1.1.0.tar.gz", hash = "sha256:5fb5e4a91db8ca93e8938a613360b3def299b60d41f847279a8c39c9b2e9c65e", size = 19992, upload-time = "2022-03-10T15:11:41.968Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/8e/c30b08ee9b8dc9b4a10e782c2a7fd5de55388201ddebfe0f7ab99dfbb349/python_ulid-1.1.0-py3-none-any.whl", hash = "sha256:88c952f6be133dbede19c907d72d26717d2691ec8421512b573144794d891e24", size = 9360, upload-time = "2022-03-10T15:11:40.405Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "311"
|
||||
@@ -1449,6 +1640,38 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/33/d8df6a2b214ffbe4138db9a1efe3248f67dc3c671f82308bea1582ecbbb7/qdrant_client-1.15.1-py3-none-any.whl", hash = "sha256:2b975099b378382f6ca1cfb43f0d59e541be6e16a5892f282a4b8de7eff5cb63", size = 337331, upload-time = "2025-07-31T19:35:17.539Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "5.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyjwt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6a/cf/128b1b6d7086200c9f387bd4be9b2572a30b90745ef078bd8b235042dc9f/redis-5.3.1.tar.gz", hash = "sha256:ca49577a531ea64039b5a36db3d6cd1a0c7a60c34124d46924a45b956e8cf14c", size = 4626200, upload-time = "2025-07-25T08:06:27.778Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/26/5c5fa0e83c3621db835cfc1f1d789b37e7fa99ed54423b5f519beb931aa7/redis-5.3.1-py3-none-any.whl", hash = "sha256:dc1909bd24669cc31b5f67a039700b16ec30571096c5f1f0d9d2324bff31af97", size = 272833, upload-time = "2025-07-25T08:06:26.317Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis-om"
|
||||
version = "0.3.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "hiredis" },
|
||||
{ name = "more-itertools" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-ulid" },
|
||||
{ name = "redis" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "types-redis" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/32/9bdcb86b88f5b53fd9f80019a62970ded91e4befb65c03fee17bdb2bc9f0/redis_om-0.3.5.tar.gz", hash = "sha256:fd152ccebc9b47604287a347628ef0d2c0051c13d5653f121193e801bb1cc4a7", size = 78939, upload-time = "2025-04-04T12:54:51.465Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/60/2cc6753c2c36a2a5dded8c380c6cad67a26c5878cd7aad56de2eee1d63c8/redis_om-0.3.5-py3-none-any.whl", hash = "sha256:99ab40f696028ce47c5e2eb5118a1ffc1fd193005428df89c8cf77ad35a0177a", size = 86634, upload-time = "2025-04-04T12:54:50.07Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "2025.11.3"
|
||||
@@ -1566,6 +1789,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "80.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shapely"
|
||||
version = "2.1.2"
|
||||
@@ -1716,6 +1948,53 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-cffi"
|
||||
version = "1.17.0.20250915"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "types-setuptools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/98/ea454cea03e5f351323af6a482c65924f3c26c515efd9090dede58f2b4b6/types_cffi-1.17.0.20250915.tar.gz", hash = "sha256:4362e20368f78dabd5c56bca8004752cc890e07a71605d9e0d9e069dbaac8c06", size = 17229, upload-time = "2025-09-15T03:01:25.31Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ec/092f2b74b49ec4855cdb53050deb9699f7105b8fda6fe034c0781b8687f3/types_cffi-1.17.0.20250915-py3-none-any.whl", hash = "sha256:cef4af1116c83359c11bb4269283c50f0688e9fc1d7f0eeb390f3661546da52c", size = 20112, upload-time = "2025-09-15T03:01:24.187Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-pyopenssl"
|
||||
version = "24.1.0.20240722"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "types-cffi" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/93/29/47a346550fd2020dac9a7a6d033ea03fccb92fa47c726056618cc889745e/types-pyOpenSSL-24.1.0.20240722.tar.gz", hash = "sha256:47913b4678a01d879f503a12044468221ed8576263c1540dcb0484ca21b08c39", size = 8458, upload-time = "2024-07-22T02:32:22.558Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/05/c868a850b6fbb79c26f5f299b768ee0adc1f9816d3461dcf4287916f655b/types_pyOpenSSL-24.1.0.20240722-py3-none-any.whl", hash = "sha256:6a7a5d2ec042537934cfb4c9d4deb0e16c4c6250b09358df1f083682fe6fda54", size = 7499, upload-time = "2024-07-22T02:32:21.232Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-redis"
|
||||
version = "4.6.0.20241004"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "types-pyopenssl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3a/95/c054d3ac940e8bac4ca216470c80c26688a0e79e09f520a942bb27da3386/types-redis-4.6.0.20241004.tar.gz", hash = "sha256:5f17d2b3f9091ab75384153bfa276619ffa1cf6a38da60e10d5e6749cc5b902e", size = 49679, upload-time = "2024-10-04T02:43:59.224Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/55/82/7d25dce10aad92d2226b269bce2f85cfd843b4477cd50245d7d40ecf8f89/types_redis-4.6.0.20241004-py3-none-any.whl", hash = "sha256:ef5da68cb827e5f606c8f9c0b49eeee4c2669d6d97122f301d3a55dc6a63f6ed", size = 58737, upload-time = "2024-10-04T02:43:57.968Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-setuptools"
|
||||
version = "80.9.0.20250822"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/bd/1e5f949b7cb740c9f0feaac430e301b8f1c5f11a81e26324299ea671a237/types_setuptools-80.9.0.20250822.tar.gz", hash = "sha256:070ea7716968ec67a84c7f7768d9952ff24d28b65b6594797a464f1b3066f965", size = 41296, upload-time = "2025-08-22T03:02:08.771Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/2d/475bf15c1cdc172e7a0d665b6e373ebfb1e9bf734d3f2f543d668b07a142/types_setuptools-80.9.0.20250822-py3-none-any.whl", hash = "sha256:53bf881cb9d7e46ed12c76ef76c0aaf28cfe6211d3fab12e0b83620b1a8642c3", size = 63179, upload-time = "2025-08-22T03:02:07.643Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
@@ -1971,6 +2250,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zipp"
|
||||
version = "3.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
|
||||
@@ -6,8 +6,6 @@ services:
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- VITE_API_URL=http://localhost:8000
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
@@ -20,11 +18,22 @@ services:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- /app/.venv
|
||||
- ./backend/app:/app/app
|
||||
- ./backend/.secrets:/app/.secrets
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
db:
|
||||
# docker run -p 6379:6379 -p 8001:8001 redis/redis-stack
|
||||
image: redis/redis-stack:latest
|
||||
ports:
|
||||
- 6379:6379
|
||||
- 8001:8001
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
driver: bridge
|
||||
|
||||
4071
frontend/package-lock.json
generated
4071
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,22 +11,40 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@xyflow/react": "^12.9.2",
|
||||
"ai": "^5.0.89",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^0.543.0",
|
||||
"motion": "^12.23.24",
|
||||
"nanoid": "^5.1.6",
|
||||
"pdfjs-dist": "^5.4.296",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-pdf": "^10.2.0",
|
||||
"shiki": "^3.15.0",
|
||||
"streamdown": "^1.4.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tokenlens": "^1.3.1",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
93
frontend/src/components/ChatTab.tsx
Normal file
93
frontend/src/components/ChatTab.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { MessageCircle, Send, Bot, User } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface ChatTabProps {
|
||||
selectedTema: string | null;
|
||||
}
|
||||
|
||||
export function ChatTab({ selectedTema }: ChatTabProps) {
|
||||
if (!selectedTema) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64">
|
||||
<MessageCircle className="w-12 h-12 text-gray-400 mb-4" />
|
||||
<p className="text-gray-500">
|
||||
Selecciona un dataroom para iniciar el chat
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Chat Header */}
|
||||
<div className="border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<MessageCircle className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Chat con {selectedTema}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Haz preguntas sobre los documentos de este dataroom
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
{/* Welcome Message */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-full">
|
||||
<Bot className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1 bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-800">
|
||||
¡Hola! Soy tu asistente de IA para el dataroom <strong>{selectedTema}</strong>.
|
||||
Puedes hacerme preguntas sobre los documentos almacenados aquí.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder for future messages */}
|
||||
<div className="text-center py-8">
|
||||
<MessageCircle className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h4 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Chat Inteligente
|
||||
</h4>
|
||||
<p className="text-gray-500 max-w-md mx-auto">
|
||||
El chat estará disponible próximamente. Podrás hacer preguntas sobre los
|
||||
documentos y obtener respuestas basadas en el contenido del dataroom.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Input Area */}
|
||||
<div className="border-t border-gray-200 p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder={`Pregunta algo sobre ${selectedTema}...`}
|
||||
disabled
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button disabled className="gap-2">
|
||||
<Send className="w-4 h-4" />
|
||||
Enviar
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Esta funcionalidad estará disponible próximamente
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,540 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useFileStore } from '@/stores/fileStore'
|
||||
import { api } from '@/services/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/table'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { FileUpload } from './FileUpload'
|
||||
import { DeleteConfirmDialog } from './DeleteConfirmDialog'
|
||||
import { PDFPreviewModal } from './PDFPreviewModal'
|
||||
import { CollectionVerifier } from './CollectionVerifier'
|
||||
import { ChunkViewerModal } from './ChunkViewerModal'
|
||||
import { ChunkingConfigModalLandingAI, type LandingAIConfig } from './ChunkingConfigModalLandingAI'
|
||||
import {
|
||||
Upload,
|
||||
Download,
|
||||
Trash2,
|
||||
Search,
|
||||
FileText,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
Scissors
|
||||
} from 'lucide-react'
|
||||
|
||||
interface DashboardProps {
|
||||
onProcessingChange?: (isProcessing: boolean) => void
|
||||
}
|
||||
|
||||
export function Dashboard({ onProcessingChange }: DashboardProps = {}) {
|
||||
const {
|
||||
selectedTema,
|
||||
files,
|
||||
setFiles,
|
||||
loading,
|
||||
setLoading,
|
||||
selectedFiles,
|
||||
toggleFileSelection,
|
||||
selectAllFiles,
|
||||
clearSelection
|
||||
} = useFileStore()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [fileToDelete, setFileToDelete] = useState<string | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
|
||||
// Estados para el modal de preview de PDF
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
||||
const [previewFileUrl, setPreviewFileUrl] = useState<string | null>(null)
|
||||
const [previewFileName, setPreviewFileName] = useState('')
|
||||
const [previewFileTema, setPreviewFileTema] = useState<string | undefined>(undefined)
|
||||
const [loadingPreview, setLoadingPreview] = useState(false)
|
||||
|
||||
// Estados para el modal de chunks
|
||||
const [chunkViewerOpen, setChunkViewerOpen] = useState(false)
|
||||
const [chunkFileName, setChunkFileName] = useState('')
|
||||
const [chunkFileTema, setChunkFileTema] = useState('')
|
||||
|
||||
// Estados para chunking
|
||||
const [chunkingConfigOpen, setChunkingConfigOpen] = useState(false)
|
||||
const [chunkingFileName, setChunkingFileName] = useState('')
|
||||
const [chunkingFileTema, setChunkingFileTema] = useState('')
|
||||
const [chunkingCollectionName, setChunkingCollectionName] = useState('')
|
||||
const [processing, setProcessing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles()
|
||||
}, [selectedTema])
|
||||
|
||||
const loadFiles = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await api.getFiles(selectedTema || undefined)
|
||||
setFiles(response.files)
|
||||
} catch (error) {
|
||||
console.error('Error loading files:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
// Eliminar archivo individual
|
||||
const handleDeleteSingle = async (filename: string) => {
|
||||
setFileToDelete(filename)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
// Eliminar archivos seleccionados
|
||||
const handleDeleteMultiple = () => {
|
||||
if (selectedFiles.size === 0) return
|
||||
setFileToDelete(null)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
// Confirmar eliminación
|
||||
const confirmDelete = async () => {
|
||||
if (!fileToDelete && selectedFiles.size === 0) return
|
||||
|
||||
setDeleting(true)
|
||||
try {
|
||||
if (fileToDelete) {
|
||||
// Eliminar archivo individual
|
||||
await api.deleteFile(fileToDelete, selectedTema || undefined)
|
||||
} else {
|
||||
// Eliminar archivos seleccionados
|
||||
const filesToDelete = Array.from(selectedFiles)
|
||||
await api.deleteFiles(filesToDelete, selectedTema || undefined)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
// Recargar archivos
|
||||
await loadFiles()
|
||||
setDeleteDialogOpen(false)
|
||||
setFileToDelete(null)
|
||||
} catch (error) {
|
||||
console.error('Error deleting files:', error)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Descargar archivo individual
|
||||
const handleDownloadSingle = async (filename: string) => {
|
||||
try {
|
||||
setDownloading(true)
|
||||
await api.downloadFile(filename, selectedTema || undefined)
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error)
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Descargar archivos seleccionados
|
||||
const handleDownloadMultiple = async () => {
|
||||
if (selectedFiles.size === 0) return
|
||||
|
||||
try {
|
||||
setDownloading(true)
|
||||
const filesToDownload = Array.from(selectedFiles)
|
||||
const zipName = selectedTema ? `${selectedTema}_archivos` : 'archivos_seleccionados'
|
||||
await api.downloadMultipleFiles(filesToDownload, selectedTema || undefined, zipName)
|
||||
} catch (error) {
|
||||
console.error('Error downloading files:', error)
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Abrir preview de PDF
|
||||
const handlePreviewFile = async (filename: string, tema?: string) => {
|
||||
// Solo permitir preview de archivos PDF
|
||||
if (!filename.toLowerCase().endsWith('.pdf')) {
|
||||
console.log('Solo se pueden previsualizar archivos PDF')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingPreview(true)
|
||||
setPreviewFileName(filename)
|
||||
setPreviewFileTema(tema)
|
||||
|
||||
// Obtener la URL temporal (SAS) para el archivo
|
||||
const url = await api.getPreviewUrl(filename, tema)
|
||||
|
||||
setPreviewFileUrl(url)
|
||||
setPreviewModalOpen(true)
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo URL de preview:', error)
|
||||
alert('Error al cargar la vista previa del archivo')
|
||||
} finally {
|
||||
setLoadingPreview(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Manejar descarga desde el modal de preview
|
||||
const handleDownloadFromPreview = async () => {
|
||||
if (previewFileName) {
|
||||
await handleDownloadSingle(previewFileName)
|
||||
}
|
||||
}
|
||||
|
||||
// Abrir modal de chunks
|
||||
const handleViewChunks = (filename: string, tema: string) => {
|
||||
if (!tema) {
|
||||
alert('No hay tema seleccionado. Por favor selecciona un tema primero.')
|
||||
return
|
||||
}
|
||||
setChunkFileName(filename)
|
||||
setChunkFileTema(tema)
|
||||
setChunkViewerOpen(true)
|
||||
}
|
||||
|
||||
// Handlers para chunking
|
||||
const handleStartChunking = (filename: string, tema: string) => {
|
||||
if (!tema) {
|
||||
alert('No hay tema seleccionado. Por favor selecciona un tema primero.')
|
||||
return
|
||||
}
|
||||
setChunkingFileName(filename)
|
||||
setChunkingFileTema(tema)
|
||||
setChunkingCollectionName(tema) // Usar el tema como nombre de colección
|
||||
setChunkingConfigOpen(true)
|
||||
}
|
||||
|
||||
const handleProcessWithLandingAI = async (config: LandingAIConfig) => {
|
||||
setProcessing(true)
|
||||
onProcessingChange?.(true)
|
||||
setChunkingConfigOpen(false)
|
||||
|
||||
try {
|
||||
const result = await api.processWithLandingAI(config)
|
||||
|
||||
// Mensaje detallado
|
||||
let message = `Completado\n\n`
|
||||
message += `• Modo: ${result.mode === 'quick' ? 'Rápido' : 'Con Extracción'}\n`
|
||||
message += `• Chunks procesados: ${result.total_chunks}\n`
|
||||
message += `• Chunks agregados: ${result.chunks_added}\n`
|
||||
message += `• Colección: ${result.collection_name}\n`
|
||||
message += `• Tiempo: ${result.processing_time_seconds}s\n`
|
||||
|
||||
if (result.schema_used) {
|
||||
message += `• Schema usado: ${result.schema_used}\n`
|
||||
}
|
||||
|
||||
if (result.extracted_data) {
|
||||
message += `\nDatos extraídos disponibles en metadata`
|
||||
}
|
||||
|
||||
alert(message)
|
||||
|
||||
// Recargar archivos
|
||||
loadFiles()
|
||||
} catch (error: any) {
|
||||
console.error('Error processing with LandingAI:', error)
|
||||
alert(`❌ Error: ${error.message}`)
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
onProcessingChange?.(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredFiles = files.filter(file =>
|
||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// Preparar datos para el modal de confirmación
|
||||
const getDeleteDialogProps = () => {
|
||||
if (fileToDelete) {
|
||||
return {
|
||||
title: 'Eliminar archivo',
|
||||
description: `¿Estás seguro de que quieres eliminar "${fileToDelete}"? Esta acción no se puede deshacer.`,
|
||||
fileList: [fileToDelete]
|
||||
}
|
||||
} else {
|
||||
const filesToDelete = Array.from(selectedFiles)
|
||||
return {
|
||||
title: `Eliminar ${filesToDelete.length} archivos`,
|
||||
description: `¿Estás seguro de que quieres eliminar ${filesToDelete.length} archivo${filesToDelete.length !== 1 ? 's' : ''}? Esta acción no se puede deshacer.`,
|
||||
fileList: filesToDelete
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white">
|
||||
{/* Processing Banner */}
|
||||
{processing && (
|
||||
<div className="bg-blue-50 border-b border-blue-200 px-6 py-3">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||
<p className="text-sm font-medium text-blue-900">
|
||||
Procesando archivo con LandingAI... Por favor no navegues ni realices otras acciones.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900">
|
||||
{selectedTema ? `Tema: ${selectedTema}` : 'Todos los archivos'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{filteredFiles.length} archivo{filteredFiles.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setUploadDialogOpen(true)} disabled={processing}>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Subir archivo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Buscar archivos..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
disabled={processing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedFiles.size > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadMultiple}
|
||||
disabled={downloading || processing}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{downloading ? 'Descargando...' : `Descargar (${selectedFiles.size})`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDeleteMultiple}
|
||||
disabled={processing}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Eliminar ({selectedFiles.size})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-gray-500">Cargando archivos...</p>
|
||||
</div>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64">
|
||||
<FileText className="w-12 h-12 text-gray-400 mb-4" />
|
||||
<p className="text-gray-500">
|
||||
{searchTerm ? 'No se encontraron archivos' : 'No hay archivos en este tema'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedFiles.size === filteredFiles.length && filteredFiles.length > 0}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
if (checked) {
|
||||
selectAllFiles()
|
||||
} else {
|
||||
clearSelection()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Nombre</TableHead>
|
||||
<TableHead>Tamaño</TableHead>
|
||||
<TableHead>Fecha</TableHead>
|
||||
<TableHead>Tema</TableHead>
|
||||
<TableHead className="w-32">Acciones</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredFiles.map((file) => {
|
||||
const isPDF = file.name.toLowerCase().endsWith('.pdf')
|
||||
|
||||
return (
|
||||
<TableRow key={file.full_path}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedFiles.has(file.name)}
|
||||
onCheckedChange={() => toggleFileSelection(file.name)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{isPDF ? (
|
||||
<button
|
||||
onClick={() => handlePreviewFile(file.name, file.tema || undefined)}
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline text-left transition-colors"
|
||||
disabled={loadingPreview}
|
||||
>
|
||||
{file.name}
|
||||
</button>
|
||||
) : (
|
||||
<span>{file.name}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{formatFileSize(file.size)}</TableCell>
|
||||
<TableCell>{formatDate(file.last_modified)}</TableCell>
|
||||
<TableCell>
|
||||
<span className="px-2 py-1 bg-gray-100 rounded-md text-sm">
|
||||
{file.tema || 'General'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadSingle(file.name)}
|
||||
disabled={downloading}
|
||||
title="Descargar archivo"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Procesar con chunking"
|
||||
onClick={() => handleStartChunking(file.name, file.tema)}
|
||||
>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Ver chunks"
|
||||
onClick={() => handleViewChunks(file.name, file.tema)}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Generar preguntas"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteSingle(file.name)}
|
||||
title="Eliminar archivo"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Dialog */}
|
||||
<FileUpload
|
||||
open={uploadDialogOpen}
|
||||
onOpenChange={setUploadDialogOpen}
|
||||
onSuccess={handleUploadSuccess}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onConfirm={confirmDelete}
|
||||
loading={deleting}
|
||||
{...getDeleteDialogProps()}
|
||||
/>
|
||||
|
||||
{/* PDF Preview Modal */}
|
||||
<PDFPreviewModal
|
||||
open={previewModalOpen}
|
||||
onOpenChange={setPreviewModalOpen}
|
||||
fileUrl={previewFileUrl}
|
||||
fileName={previewFileName}
|
||||
onDownload={handleDownloadFromPreview}
|
||||
/>
|
||||
|
||||
{/* Collection Verifier - Verifica/crea colección cuando se selecciona un tema */}
|
||||
<CollectionVerifier
|
||||
tema={selectedTema}
|
||||
onVerified={(exists) => {
|
||||
console.log(`Collection ${selectedTema} exists: ${exists}`)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Chunk Viewer Modal */}
|
||||
<ChunkViewerModal
|
||||
isOpen={chunkViewerOpen}
|
||||
onClose={() => setChunkViewerOpen(false)}
|
||||
fileName={chunkFileName}
|
||||
tema={chunkFileTema}
|
||||
/>
|
||||
|
||||
{/* Modal de configuración de chunking con LandingAI */}
|
||||
<ChunkingConfigModalLandingAI
|
||||
isOpen={chunkingConfigOpen}
|
||||
onClose={() => setChunkingConfigOpen(false)}
|
||||
fileName={chunkingFileName}
|
||||
tema={chunkingFileTema}
|
||||
collectionName={chunkingCollectionName}
|
||||
onProcess={handleProcessWithLandingAI}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
98
frontend/src/components/DashboardTab.tsx
Normal file
98
frontend/src/components/DashboardTab.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { FileText, Users, Database, Activity } from "lucide-react";
|
||||
|
||||
interface DashboardTabProps {
|
||||
selectedTema: string | null;
|
||||
}
|
||||
|
||||
export function DashboardTab({ selectedTema }: DashboardTabProps) {
|
||||
if (!selectedTema) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64">
|
||||
<Activity className="w-12 h-12 text-gray-400 mb-4" />
|
||||
<p className="text-gray-500">
|
||||
Selecciona un dataroom para ver las métricas
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Métricas del Dataroom: {selectedTema}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Vista general del estado y actividad del dataroom
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{/* Files Count Card */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Archivos</p>
|
||||
<p className="text-2xl font-bold text-gray-900">--</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storage Usage Card */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 rounded-lg">
|
||||
<Database className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Almacenamiento</p>
|
||||
<p className="text-2xl font-bold text-gray-900">--</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vector Collections Card */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 rounded-lg">
|
||||
<Activity className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Vectores</p>
|
||||
<p className="text-2xl font-bold text-gray-900">--</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity Card */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-orange-100 rounded-lg">
|
||||
<Users className="w-5 h-5 text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Actividad</p>
|
||||
<p className="text-2xl font-bold text-gray-900">--</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coming Soon Message */}
|
||||
<div className="mt-8 bg-gray-50 border border-gray-200 rounded-lg p-6">
|
||||
<div className="text-center">
|
||||
<Activity className="w-8 h-8 text-gray-400 mx-auto mb-3" />
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">
|
||||
Panel de Métricas
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
Este panel se llenará con métricas detalladas y gráficos interactivos próximamente.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
frontend/src/components/DataroomView.tsx
Normal file
195
frontend/src/components/DataroomView.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFileStore } from "@/stores/fileStore";
|
||||
import { api } from "@/services/api";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { FilesTab } from "./FilesTab";
|
||||
import { DashboardTab } from "./DashboardTab";
|
||||
import { ChatTab } from "./ChatTab";
|
||||
import {
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface DataroomViewProps {
|
||||
onProcessingChange?: (isProcessing: boolean) => void;
|
||||
}
|
||||
|
||||
export function DataroomView({ onProcessingChange }: DataroomViewProps = {}) {
|
||||
const { selectedTema, files } = useFileStore();
|
||||
|
||||
// Collection status states
|
||||
const [isCheckingCollection, setIsCheckingCollection] = useState(false);
|
||||
const [collectionExists, setCollectionExists] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
const [collectionError, setCollectionError] = useState<string | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
// Check collection status when tema changes
|
||||
useEffect(() => {
|
||||
checkCollectionStatus();
|
||||
}, [selectedTema]);
|
||||
|
||||
// Load files when tema changes
|
||||
useEffect(() => {
|
||||
loadFiles();
|
||||
}, [selectedTema]);
|
||||
|
||||
const checkCollectionStatus = async () => {
|
||||
if (!selectedTema) {
|
||||
setCollectionExists(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCheckingCollection(true);
|
||||
setCollectionError(null);
|
||||
|
||||
try {
|
||||
const result = await api.checkCollectionExists(selectedTema);
|
||||
setCollectionExists(result.exists);
|
||||
} catch (err) {
|
||||
console.error("Error checking collection:", err);
|
||||
setCollectionError(
|
||||
err instanceof Error ? err.message : "Error al verificar colección",
|
||||
);
|
||||
setCollectionExists(null);
|
||||
} finally {
|
||||
setIsCheckingCollection(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCollection = async () => {
|
||||
if (!selectedTema) return;
|
||||
|
||||
setIsCheckingCollection(true);
|
||||
setCollectionError(null);
|
||||
|
||||
try {
|
||||
const result = await api.createCollection(selectedTema);
|
||||
if (result.success) {
|
||||
setCollectionExists(true);
|
||||
console.log(`Collection "${selectedTema}" created successfully`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error creating collection:", err);
|
||||
setCollectionError(
|
||||
err instanceof Error ? err.message : "Error al crear colección",
|
||||
);
|
||||
} finally {
|
||||
setIsCheckingCollection(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadFiles = async () => {
|
||||
// This will be handled by FilesTab component
|
||||
};
|
||||
|
||||
const handleProcessingChange = (isProcessing: boolean) => {
|
||||
setProcessing(isProcessing);
|
||||
onProcessingChange?.(isProcessing);
|
||||
};
|
||||
|
||||
const totalFiles = files.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white">
|
||||
<div className="border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">
|
||||
{selectedTema
|
||||
? `Dataroom: ${selectedTema}`
|
||||
: "Selecciona un dataroom"}
|
||||
</h2>
|
||||
{/* Collection Status Indicator */}
|
||||
{selectedTema && (
|
||||
<div className="flex items-center gap-2">
|
||||
{isCheckingCollection ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin text-gray-500" />
|
||||
<span className="text-xs text-gray-500">
|
||||
Verificando...
|
||||
</span>
|
||||
</>
|
||||
) : collectionExists === true ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600" />
|
||||
<span className="text-xs text-green-600">
|
||||
Colección disponible
|
||||
</span>
|
||||
</>
|
||||
) : collectionExists === false ? (
|
||||
<>
|
||||
<AlertCircle className="w-4 h-4 text-yellow-600" />
|
||||
<button
|
||||
onClick={handleCreateCollection}
|
||||
className="text-xs text-yellow-600 hover:text-yellow-700 underline"
|
||||
>
|
||||
Crear colección
|
||||
</button>
|
||||
</>
|
||||
) : collectionError ? (
|
||||
<>
|
||||
<AlertCircle className="w-4 h-4 text-red-600" />
|
||||
<span className="text-xs text-red-600">
|
||||
Error de conexión
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
{selectedTema
|
||||
? `${totalFiles} archivo${totalFiles !== 1 ? "s" : ""}`
|
||||
: "Selecciona un dataroom de la barra lateral para ver sus archivos"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="files" className="flex flex-col flex-1">
|
||||
<div className="border-b border-gray-200 px-6 py-2">
|
||||
<TabsList className="flex h-10 w-full items-center gap-2 bg-transparent p-0 justify-start">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="rounded-md px-4 py-2 text-sm font-medium text-gray-600 transition data-[state=active]:bg-gray-900 data-[state=active]:text-white data-[state=active]:shadow"
|
||||
>
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="files"
|
||||
className="rounded-md px-4 py-2 text-sm font-medium text-gray-600 transition data-[state=active]:bg-gray-900 data-[state=active]:text-white data-[state=active]:shadow"
|
||||
>
|
||||
Files
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="chat"
|
||||
className="rounded-md px-4 py-2 text-sm font-medium text-gray-600 transition data-[state=active]:bg-gray-900 data-[state=active]:text-white data-[state=active]:shadow"
|
||||
>
|
||||
Chat
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="overview" className="mt-0 flex-1">
|
||||
<DashboardTab selectedTema={selectedTema} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="files" className="mt-0 flex flex-1 flex-col">
|
||||
<FilesTab
|
||||
selectedTema={selectedTema}
|
||||
processing={processing}
|
||||
onProcessingChange={handleProcessingChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="chat" className="mt-0 flex-1">
|
||||
<ChatTab selectedTema={selectedTema} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
512
frontend/src/components/FilesTab.tsx
Normal file
512
frontend/src/components/FilesTab.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/fileStore";
|
||||
import { api } from "@/services/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { FileUpload } from "./FileUpload";
|
||||
import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
|
||||
import { PDFPreviewModal } from "./PDFPreviewModal";
|
||||
import { ChunkViewerModal } from "./ChunkViewerModal";
|
||||
import {
|
||||
ChunkingConfigModalLandingAI,
|
||||
type LandingAIConfig,
|
||||
} from "./ChunkingConfigModalLandingAI";
|
||||
import {
|
||||
Upload,
|
||||
Download,
|
||||
Trash2,
|
||||
Search,
|
||||
FileText,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
Scissors,
|
||||
} from "lucide-react";
|
||||
|
||||
interface FilesTabProps {
|
||||
selectedTema: string | null;
|
||||
processing: boolean;
|
||||
onProcessingChange?: (isProcessing: boolean) => void;
|
||||
}
|
||||
|
||||
export function FilesTab({
|
||||
selectedTema,
|
||||
processing,
|
||||
onProcessingChange,
|
||||
}: FilesTabProps) {
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
loading,
|
||||
setLoading,
|
||||
selectedFiles,
|
||||
toggleFileSelection,
|
||||
selectAllFiles,
|
||||
clearSelection,
|
||||
} = useFileStore();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [fileToDelete, setFileToDelete] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
// Estados para el modal de preview de PDF
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
||||
const [previewFileUrl, setPreviewFileUrl] = useState<string | null>(null);
|
||||
const [previewFileName, setPreviewFileName] = useState("");
|
||||
const [previewFileTema, setPreviewFileTema] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
|
||||
// Estados para el modal de chunks
|
||||
const [chunkViewerOpen, setChunkViewerOpen] = useState(false);
|
||||
const [chunkFileName, setChunkFileName] = useState("");
|
||||
const [chunkFileTema, setChunkFileTema] = useState("");
|
||||
|
||||
// Estados para chunking
|
||||
const [chunkingConfigOpen, setChunkingConfigOpen] = useState(false);
|
||||
const [chunkingFileName, setChunkingFileName] = useState("");
|
||||
const [chunkingFileTema, setChunkingFileTema] = useState("");
|
||||
const [chunkingCollectionName, setChunkingCollectionName] = useState("");
|
||||
|
||||
const loadFiles = async () => {
|
||||
// Don't load files if no dataroom is selected
|
||||
if (!selectedTema) {
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.getFiles(selectedTema);
|
||||
setFiles(response.files);
|
||||
} catch (error) {
|
||||
console.error("Error loading files:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
loadFiles();
|
||||
};
|
||||
|
||||
const handleDeleteFile = (filename: string) => {
|
||||
setFileToDelete(filename);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
if (selectedFiles.size === 0) return;
|
||||
setFileToDelete(null);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
setDeleting(true);
|
||||
|
||||
if (fileToDelete) {
|
||||
// Eliminar archivo individual
|
||||
await api.deleteFile(fileToDelete, selectedTema || undefined);
|
||||
} else {
|
||||
// Eliminar archivos seleccionados
|
||||
const filesToDelete = Array.from(selectedFiles);
|
||||
await api.deleteFiles(filesToDelete, selectedTema || undefined);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
await loadFiles();
|
||||
setDeleteDialogOpen(false);
|
||||
setFileToDelete(null);
|
||||
} catch (error) {
|
||||
console.error("Error deleting files:", error);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadFile = async (filename: string) => {
|
||||
try {
|
||||
setDownloading(true);
|
||||
await api.downloadFile(filename, selectedTema || undefined);
|
||||
} catch (error) {
|
||||
console.error("Error downloading file:", error);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadSelected = async () => {
|
||||
if (selectedFiles.size === 0) return;
|
||||
|
||||
try {
|
||||
setDownloading(true);
|
||||
const filesToDownload = Array.from(selectedFiles);
|
||||
const zipName = selectedTema
|
||||
? `${selectedTema}_archivos`
|
||||
: "archivos_seleccionados";
|
||||
await api.downloadMultipleFiles(
|
||||
filesToDownload,
|
||||
selectedTema || undefined,
|
||||
zipName,
|
||||
);
|
||||
clearSelection();
|
||||
} catch (error) {
|
||||
console.error("Error downloading files:", error);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewFile = async (filename: string) => {
|
||||
try {
|
||||
setLoadingPreview(true);
|
||||
const url = await api.getPreviewUrl(filename, selectedTema || undefined);
|
||||
setPreviewFileUrl(url);
|
||||
setPreviewFileName(filename);
|
||||
setPreviewFileTema(selectedTema || undefined);
|
||||
setPreviewModalOpen(true);
|
||||
} catch (error) {
|
||||
console.error("Error getting preview URL:", error);
|
||||
} finally {
|
||||
setLoadingPreview(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadFromPreview = () => {
|
||||
if (previewFileName) {
|
||||
handleDownloadFile(previewFileName);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewChunks = (filename: string) => {
|
||||
setChunkFileName(filename);
|
||||
setChunkFileTema(selectedTema || "");
|
||||
setChunkViewerOpen(true);
|
||||
};
|
||||
|
||||
const handleStartChunking = (filename: string) => {
|
||||
setChunkingFileName(filename);
|
||||
setChunkingFileTema(selectedTema || "");
|
||||
setChunkingCollectionName(selectedTema || "");
|
||||
setChunkingConfigOpen(true);
|
||||
};
|
||||
|
||||
const handleChunkingProcess = async (config: LandingAIConfig) => {
|
||||
try {
|
||||
onProcessingChange?.(true);
|
||||
|
||||
const processConfig = {
|
||||
file_name: chunkingFileName,
|
||||
tema: chunkingFileTema,
|
||||
collection_name: chunkingCollectionName,
|
||||
mode: config.mode,
|
||||
schema_id: config.schemaId,
|
||||
include_chunk_types: config.includeChunkTypes,
|
||||
max_tokens_per_chunk: config.maxTokensPerChunk,
|
||||
merge_small_chunks: config.mergeSmallChunks,
|
||||
};
|
||||
|
||||
await api.processWithLandingAI(processConfig);
|
||||
console.log("Procesamiento con LandingAI completado");
|
||||
} catch (error) {
|
||||
console.error("Error en procesamiento con LandingAI:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
onProcessingChange?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filtrar archivos por término de búsqueda
|
||||
const filteredFiles = files.filter((file) =>
|
||||
file.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const totalFiles = filteredFiles.length;
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString("es-ES", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const getDeleteDialogProps = () => {
|
||||
if (fileToDelete) {
|
||||
return {
|
||||
title: "Eliminar archivo",
|
||||
message: `¿Estás seguro de que deseas eliminar el archivo "${fileToDelete}"?`,
|
||||
fileList: [fileToDelete],
|
||||
};
|
||||
} else {
|
||||
const filesToDelete = Array.from(selectedFiles);
|
||||
return {
|
||||
title: "Eliminar archivos seleccionados",
|
||||
message: `¿Estás seguro de que deseas eliminar ${filesToDelete.length} archivo${filesToDelete.length > 1 ? "s" : ""}?`,
|
||||
fileList: filesToDelete,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedTema) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64">
|
||||
<FileText className="w-12 h-12 text-gray-400 mb-4" />
|
||||
<p className="text-gray-500">
|
||||
Selecciona un dataroom para ver sus archivos
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{processing && (
|
||||
<div className="bg-blue-50 border-b border-blue-200 px-6 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||
<span className="text-sm text-blue-800">
|
||||
Procesando archivos con LandingAI...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Buscar archivos..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedFiles.size > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadSelected}
|
||||
disabled={downloading}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Descargar ({selectedFiles.size})
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={deleting}
|
||||
className="gap-2 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Eliminar ({selectedFiles.size})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => setUploadDialogOpen(true)}
|
||||
disabled={loading}
|
||||
className="gap-2"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Subir archivo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-gray-500">Cargando archivos...</p>
|
||||
</div>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64">
|
||||
<FileText className="w-12 h-12 text-gray-400 mb-4" />
|
||||
<p className="text-gray-500">
|
||||
{!selectedTema
|
||||
? "Selecciona un dataroom para ver sus archivos"
|
||||
: searchTerm
|
||||
? "No se encontraron archivos"
|
||||
: "No hay archivos en este dataroom"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedFiles.size === filteredFiles.length &&
|
||||
filteredFiles.length > 0
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
selectAllFiles();
|
||||
} else {
|
||||
clearSelection();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Archivo</TableHead>
|
||||
<TableHead>Tamaño</TableHead>
|
||||
<TableHead>Modificado</TableHead>
|
||||
<TableHead className="text-right">Acciones</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredFiles.map((file) => (
|
||||
<TableRow key={file.name}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedFiles.has(file.name)}
|
||||
onCheckedChange={() => toggleFileSelection(file.name)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-gray-400" />
|
||||
{file.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{formatFileSize(file.size)}</TableCell>
|
||||
<TableCell>{formatDate(file.last_modified)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePreviewFile(file.name)}
|
||||
disabled={loadingPreview}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Vista previa"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewChunks(file.name)}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Ver chunks"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleStartChunking(file.name)}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Procesar con LandingAI"
|
||||
>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadFile(file.name)}
|
||||
disabled={downloading}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Descargar"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteFile(file.name)}
|
||||
disabled={deleting}
|
||||
className="h-8 w-8 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Upload Modal */}
|
||||
<FileUpload
|
||||
open={uploadDialogOpen}
|
||||
onOpenChange={setUploadDialogOpen}
|
||||
onSuccess={handleUploadSuccess}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onConfirm={confirmDelete}
|
||||
loading={deleting}
|
||||
{...getDeleteDialogProps()}
|
||||
/>
|
||||
|
||||
{/* PDF Preview Modal */}
|
||||
<PDFPreviewModal
|
||||
open={previewModalOpen}
|
||||
onOpenChange={setPreviewModalOpen}
|
||||
fileUrl={previewFileUrl}
|
||||
fileName={previewFileName}
|
||||
onDownload={handleDownloadFromPreview}
|
||||
/>
|
||||
|
||||
{/* Chunk Viewer Modal */}
|
||||
<ChunkViewerModal
|
||||
isOpen={chunkViewerOpen}
|
||||
onClose={() => setChunkViewerOpen(false)}
|
||||
fileName={chunkFileName}
|
||||
tema={chunkFileTema}
|
||||
/>
|
||||
|
||||
{/* Modal de configuración de chunking con LandingAI */}
|
||||
<ChunkingConfigModalLandingAI
|
||||
isOpen={chunkingConfigOpen}
|
||||
onClose={() => setChunkingConfigOpen(false)}
|
||||
onProcess={handleChunkingProcess}
|
||||
fileName={chunkingFileName}
|
||||
tema={chunkingFileTema}
|
||||
collectionName={chunkingCollectionName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +1,96 @@
|
||||
import { useState } from 'react'
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Menu } from 'lucide-react'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { Dashboard } from './Dashboard'
|
||||
import { SchemaManagement } from '@/pages/SchemaManagement'
|
||||
import { useState } from "react";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Menu } from "lucide-react";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { DataroomView } from "./DataroomView";
|
||||
import { SchemaManagement } from "@/pages/SchemaManagement";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type View = 'dashboard' | 'schemas'
|
||||
type View = "dataroom" | "schemas";
|
||||
|
||||
export function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [currentView, setCurrentView] = useState<View>('dashboard')
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [currentView, setCurrentView] = useState<View>("dataroom");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const handleNavigateToSchemas = () => {
|
||||
if (isProcessing) {
|
||||
alert('No puedes navegar mientras se está procesando un archivo. Por favor espera a que termine.')
|
||||
return
|
||||
alert(
|
||||
"No puedes navegar mientras se está procesando un archivo. Por favor espera a que termine.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setCurrentView('schemas')
|
||||
setSidebarOpen(false)
|
||||
}
|
||||
setCurrentView("schemas");
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
|
||||
const handleNavigateToDashboard = () => {
|
||||
if (isProcessing) {
|
||||
alert('No puedes navegar mientras se está procesando un archivo. Por favor espera a que termine.')
|
||||
return
|
||||
alert(
|
||||
"No puedes navegar mientras se está procesando un archivo. Por favor espera a que termine.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setCurrentView('dashboard')
|
||||
}
|
||||
setCurrentView("dataroom");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex bg-gray-50">
|
||||
{/* Desktop Sidebar */}
|
||||
<div className="hidden md:flex md:w-64 md:flex-col">
|
||||
<Sidebar onNavigateToSchemas={handleNavigateToSchemas} disabled={isProcessing} />
|
||||
<div
|
||||
className={cn(
|
||||
"hidden md:flex md:flex-col transition-all duration-300",
|
||||
isSidebarCollapsed ? "md:w-20" : "md:w-64",
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
onNavigateToSchemas={handleNavigateToSchemas}
|
||||
disabled={isProcessing}
|
||||
collapsed={isSidebarCollapsed}
|
||||
onToggleCollapse={() => setIsSidebarCollapsed((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile Sidebar */}
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="md:hidden fixed top-4 left-4 z-40" disabled={isProcessing}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden fixed top-4 left-4 z-40"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-64 p-0">
|
||||
<Sidebar onNavigateToSchemas={handleNavigateToSchemas} disabled={isProcessing} />
|
||||
<Sidebar
|
||||
onNavigateToSchemas={handleNavigateToSchemas}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{currentView === 'dashboard' ? (
|
||||
<Dashboard onProcessingChange={setIsProcessing} />
|
||||
{currentView === "dataroom" ? (
|
||||
<DataroomView onProcessingChange={setIsProcessing} />
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<SchemaManagement />
|
||||
<div className="fixed bottom-6 right-6">
|
||||
<Button onClick={handleNavigateToDashboard} disabled={isProcessing}>
|
||||
{isProcessing ? 'Procesando...' : 'Volver al Dashboard'}
|
||||
<Button
|
||||
onClick={handleNavigateToDashboard}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? "Procesando..." : "Volver al Dataroom"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,172 +1,453 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useFileStore } from '@/stores/fileStore'
|
||||
import { api } from '@/services/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FolderIcon, FileText, Trash2, Database } from 'lucide-react'
|
||||
import { useEffect, useState, type ReactElement } from "react";
|
||||
import { useFileStore } from "@/stores/fileStore";
|
||||
import { api } from "@/services/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FolderIcon,
|
||||
FileText,
|
||||
Trash2,
|
||||
Database,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RefreshCcw,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface SidebarProps {
|
||||
onNavigateToSchemas?: () => void
|
||||
disabled?: boolean
|
||||
onNavigateToSchemas?: () => void;
|
||||
disabled?: boolean;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ onNavigateToSchemas, disabled = false }: SidebarProps = {}) {
|
||||
export function Sidebar({
|
||||
onNavigateToSchemas,
|
||||
disabled = false,
|
||||
collapsed = false,
|
||||
onToggleCollapse,
|
||||
}: SidebarProps = {}) {
|
||||
const {
|
||||
temas,
|
||||
selectedTema,
|
||||
setTemas,
|
||||
setSelectedTema,
|
||||
loading,
|
||||
setLoading
|
||||
} = useFileStore()
|
||||
setLoading,
|
||||
} = useFileStore();
|
||||
|
||||
const [deletingTema, setDeletingTema] = useState<string | null>(null)
|
||||
const [deletingTema, setDeletingTema] = useState<string | null>(null);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [newDataroomName, setNewDataroomName] = useState("");
|
||||
const [creatingDataroom, setCreatingDataroom] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const renderWithTooltip = (label: string, element: ReactElement) => {
|
||||
if (!collapsed) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{element}</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreateDialogOpenChange = (open: boolean) => {
|
||||
setCreateDialogOpen(open);
|
||||
if (!open) {
|
||||
setNewDataroomName("");
|
||||
setCreateError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateDataroom = async () => {
|
||||
const trimmed = newDataroomName.trim();
|
||||
if (!trimmed) {
|
||||
setCreateError("El nombre es obligatorio");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatingDataroom(true);
|
||||
setCreateError(null);
|
||||
|
||||
try {
|
||||
const result = await api.createDataroom({ name: trimmed });
|
||||
|
||||
// Refresh the datarooms list (this will load all datarooms including the new one)
|
||||
|
||||
await loadTemas();
|
||||
|
||||
// Select the newly created dataroom
|
||||
setSelectedTema(trimmed);
|
||||
|
||||
// Close dialog and show success
|
||||
handleCreateDialogOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Error creating dataroom:", error);
|
||||
setCreateError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "No se pudo crear el dataroom. Inténtalo nuevamente.",
|
||||
);
|
||||
} finally {
|
||||
setCreatingDataroom(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTemas()
|
||||
}, [])
|
||||
loadTemas();
|
||||
}, []);
|
||||
|
||||
const loadTemas = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await api.getTemas()
|
||||
setTemas(response.temas)
|
||||
setLoading(true);
|
||||
const response = await api.getDatarooms();
|
||||
|
||||
// Extract dataroom names from the response with better error handling
|
||||
let dataroomNames: string[] = [];
|
||||
if (response && response.datarooms && Array.isArray(response.datarooms)) {
|
||||
dataroomNames = response.datarooms
|
||||
.filter((dataroom) => dataroom && dataroom.name)
|
||||
.map((dataroom) => dataroom.name);
|
||||
}
|
||||
|
||||
setTemas(dataroomNames);
|
||||
// Auto-select first dataroom if none is selected and datarooms are available
|
||||
if (!selectedTema && dataroomNames.length > 0) {
|
||||
setSelectedTema(dataroomNames[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading temas:', error)
|
||||
console.error("Error loading datarooms:", error);
|
||||
// Fallback to legacy getTemas if dataroom endpoint fails
|
||||
try {
|
||||
const legacyResponse = await api.getTemas();
|
||||
const legacyTemas = Array.isArray(legacyResponse?.temas)
|
||||
? legacyResponse.temas.filter(Boolean)
|
||||
: [];
|
||||
setTemas(legacyTemas);
|
||||
// Auto-select first legacy tema if none is selected
|
||||
if (!selectedTema && legacyTemas.length > 0) {
|
||||
setSelectedTema(legacyTemas[0]);
|
||||
}
|
||||
} catch (legacyError) {
|
||||
console.error("Error loading legacy temas:", legacyError);
|
||||
// Ensure we always set an array, never undefined or null
|
||||
setTemas([]);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTemaSelect = (tema: string | null) => {
|
||||
setSelectedTema(tema)
|
||||
}
|
||||
setSelectedTema(tema);
|
||||
};
|
||||
|
||||
const handleDeleteTema = async (tema: string, e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation() // Evitar que se seleccione el tema al hacer clic en el icono
|
||||
const handleDeleteTema = async (
|
||||
tema: string,
|
||||
e: React.MouseEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
e.stopPropagation(); // Evitar que se seleccione el tema al hacer clic en el icono
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`¿Estás seguro de que deseas eliminar el tema "${tema}"?\n\n` +
|
||||
`Esto eliminará:\n` +
|
||||
`• Todos los archivos del tema en Azure Blob Storage\n` +
|
||||
`• La colección "${tema}" en Qdrant (si existe)\n\n` +
|
||||
`Esta acción no se puede deshacer.`
|
||||
)
|
||||
`¿Estás seguro de que deseas eliminar el dataroom "${tema}"?\n\n` +
|
||||
`Esto eliminará:\n` +
|
||||
`• El dataroom de la base de datos\n` +
|
||||
`• Todos los archivos del tema en Azure Blob Storage\n` +
|
||||
`• La colección "${tema}" en Qdrant (si existe)\n\n` +
|
||||
`Esta acción no se puede deshacer.`,
|
||||
);
|
||||
|
||||
if (!confirmed) return
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
setDeletingTema(tema)
|
||||
setDeletingTema(tema);
|
||||
|
||||
// 1. Eliminar todos los archivos del tema en Azure Blob Storage
|
||||
await api.deleteTema(tema)
|
||||
|
||||
// 2. Intentar eliminar la colección en Qdrant (si existe)
|
||||
// 1. Delete the dataroom (this will also delete the vector collection)
|
||||
try {
|
||||
const collectionExists = await api.checkCollectionExists(tema)
|
||||
if (collectionExists.exists) {
|
||||
await api.deleteCollection(tema)
|
||||
console.log(`Colección "${tema}" eliminada de Qdrant`)
|
||||
}
|
||||
await api.deleteDataroom(tema);
|
||||
} catch (error) {
|
||||
console.warn(`No se pudo eliminar la colección "${tema}" de Qdrant:`, error)
|
||||
// Continuar aunque falle la eliminación de la colección
|
||||
console.error(`Error deleting dataroom "${tema}":`, error);
|
||||
// If dataroom deletion fails, fall back to legacy deletion
|
||||
|
||||
// Eliminar todos los archivos del tema en Azure Blob Storage
|
||||
await api.deleteTema(tema);
|
||||
|
||||
// Intentar eliminar la colección en Qdrant (si existe)
|
||||
try {
|
||||
const collectionExists = await api.checkCollectionExists(tema);
|
||||
if (collectionExists.exists) {
|
||||
await api.deleteCollection(tema);
|
||||
}
|
||||
} catch (collectionError) {
|
||||
console.warn(
|
||||
`No se pudo eliminar la colección "${tema}" de Qdrant:`,
|
||||
collectionError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Actualizar la lista de temas
|
||||
await loadTemas()
|
||||
// 2. Actualizar la lista de temas
|
||||
await loadTemas();
|
||||
|
||||
// 4. Si el tema eliminado estaba seleccionado, deseleccionar
|
||||
// 3. Si el tema eliminado estaba seleccionado, deseleccionar
|
||||
if (selectedTema === tema) {
|
||||
setSelectedTema(null)
|
||||
setSelectedTema(null);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error eliminando tema "${tema}":`, error)
|
||||
alert(`Error al eliminar el tema: ${error instanceof Error ? error.message : 'Error desconocido'}`)
|
||||
console.error(`Error eliminando dataroom "${tema}":`, error);
|
||||
alert(
|
||||
`Error al eliminar el dataroom: ${error instanceof Error ? error.message : "Error desconocido"}`,
|
||||
);
|
||||
} finally {
|
||||
setDeletingTema(null)
|
||||
setDeletingTema(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border-r border-gray-200 flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h1 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
|
||||
<FileText className="h-6 w-6" />
|
||||
DoRa Luma
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Temas List */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-medium text-gray-500 mb-3">Collections</h2>
|
||||
|
||||
{/* Todos los archivos */}
|
||||
<Button
|
||||
variant={selectedTema === null ? "secondary" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleTemaSelect(null)}
|
||||
disabled={disabled}
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<div className="bg-slate-800 border-r border-slate-700 flex flex-col h-full transition-[width] duration-300">
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-b border-slate-700 flex items-center gap-3",
|
||||
collapsed ? "p-4" : "p-6",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-slate-100 flex-1",
|
||||
collapsed ? "justify-center" : "justify-start",
|
||||
)}
|
||||
>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
Todos los archivos
|
||||
</Button>
|
||||
<FileText className="h-6 w-6" />
|
||||
{!collapsed && <h1 className="text-xl font-semibold">Luma</h1>}
|
||||
</div>
|
||||
{onToggleCollapse && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-slate-400 hover:text-slate-100"
|
||||
onClick={onToggleCollapse}
|
||||
disabled={disabled}
|
||||
aria-label={
|
||||
collapsed ? "Expandir barra lateral" : "Contraer barra lateral"
|
||||
}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lista de temas */}
|
||||
{loading ? (
|
||||
<div className="text-sm text-gray-500 px-3 py-2">Cargando...</div>
|
||||
) : (
|
||||
temas.map((tema) => (
|
||||
<div key={tema} className="relative group">
|
||||
{/* Temas List */}
|
||||
<div className={cn("flex-1 overflow-y-auto p-4", collapsed && "px-2")}>
|
||||
<div className="space-y-1">
|
||||
<div
|
||||
className={cn(
|
||||
"mb-3 flex items-center",
|
||||
collapsed ? "justify-center" : "justify-between",
|
||||
)}
|
||||
>
|
||||
<h2
|
||||
className={cn(
|
||||
"text-sm font-medium text-slate-300",
|
||||
collapsed && "text-xs text-center",
|
||||
)}
|
||||
>
|
||||
{collapsed ? "Rooms" : "Datarooms"}
|
||||
</h2>
|
||||
{renderWithTooltip(
|
||||
"Crear dataroom",
|
||||
<Button
|
||||
variant={selectedTema === tema ? "secondary" : "ghost"}
|
||||
className="w-full justify-start pr-10"
|
||||
onClick={() => handleTemaSelect(tema)}
|
||||
disabled={deletingTema === tema || disabled}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"gap-2 bg-slate-700/50 text-slate-200 hover:bg-slate-600 hover:text-slate-100 border border-slate-600",
|
||||
collapsed
|
||||
? "h-10 w-10 p-0 justify-center rounded-full"
|
||||
: "",
|
||||
)}
|
||||
onClick={() => handleCreateDialogOpenChange(true)}
|
||||
disabled={disabled || creatingDataroom}
|
||||
>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
{tema}
|
||||
</Button>
|
||||
<button
|
||||
onClick={(e) => handleDeleteTema(tema, e)}
|
||||
disabled={deletingTema === tema || disabled}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded hover:bg-red-100 opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
|
||||
title="Eliminar tema y colección"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</button>
|
||||
<Plus className="h-4 w-4" />
|
||||
{!collapsed && <span>Crear dataroom</span>}
|
||||
</Button>,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lista de temas */}
|
||||
{loading ? (
|
||||
<div className="text-sm text-slate-400 px-3 py-2 text-center">
|
||||
{collapsed ? "..." : "Cargando..."}
|
||||
</div>
|
||||
))
|
||||
) : Array.isArray(temas) && temas.length > 0 ? (
|
||||
temas.map((tema) => (
|
||||
<div key={tema} className="relative group">
|
||||
{renderWithTooltip(
|
||||
tema,
|
||||
<Button
|
||||
variant={selectedTema === tema ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start text-slate-300 hover:bg-slate-700 hover:text-slate-100",
|
||||
selectedTema === tema && "bg-slate-700 text-slate-100",
|
||||
collapsed ? "px-0 justify-center" : "pr-10",
|
||||
)}
|
||||
onClick={() => handleTemaSelect(tema)}
|
||||
disabled={deletingTema === tema || disabled}
|
||||
>
|
||||
<FolderIcon
|
||||
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||
/>
|
||||
<span className={cn("truncate", collapsed && "sr-only")}>
|
||||
{tema}
|
||||
</span>
|
||||
</Button>,
|
||||
)}
|
||||
{!collapsed && (
|
||||
<button
|
||||
onClick={(e) => handleDeleteTema(tema, e)}
|
||||
disabled={deletingTema === tema || disabled}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded hover:bg-red-500/20 opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
|
||||
title="Eliminar dataroom y colección"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-slate-400 px-3 py-2 text-center">
|
||||
{Array.isArray(temas) && temas.length === 0
|
||||
? "No hay datarooms"
|
||||
: "Cargando datarooms..."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className={cn(
|
||||
"p-4 border-t border-slate-700 space-y-2",
|
||||
collapsed && "flex flex-col items-center gap-2",
|
||||
)}
|
||||
>
|
||||
{onNavigateToSchemas &&
|
||||
renderWithTooltip(
|
||||
"Gestionar Schemas",
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onNavigateToSchemas}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-start bg-slate-700 text-slate-100 hover:bg-slate-600",
|
||||
collapsed && "px-0 justify-center",
|
||||
)}
|
||||
>
|
||||
<Database className={cn("h-4 w-4", !collapsed && "mr-2")} />
|
||||
<span className={cn(collapsed && "sr-only")}>
|
||||
Gestionar Schemas
|
||||
</span>
|
||||
</Button>,
|
||||
)}
|
||||
{renderWithTooltip(
|
||||
"Actualizar datarooms",
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadTemas}
|
||||
disabled={loading || disabled}
|
||||
className={cn(
|
||||
"w-full justify-start bg-slate-700/50 text-slate-200 hover:bg-slate-600 hover:text-slate-100 border border-slate-600",
|
||||
collapsed && "px-0 justify-center",
|
||||
)}
|
||||
>
|
||||
<RefreshCcw className={cn("mr-2 h-4 w-4", collapsed && "mr-0")} />
|
||||
<span className={cn(collapsed && "sr-only")}>
|
||||
Actualizar datarooms
|
||||
</span>
|
||||
</Button>,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-200 space-y-2">
|
||||
{onNavigateToSchemas && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onNavigateToSchemas}
|
||||
disabled={disabled}
|
||||
className="w-full"
|
||||
>
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
Gestionar Schemas
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadTemas}
|
||||
disabled={loading || disabled}
|
||||
className="w-full"
|
||||
<Dialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={handleCreateDialogOpenChange}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-sm"
|
||||
aria-describedby="create-dataroom-description"
|
||||
>
|
||||
Actualizar temas
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<DialogHeader>
|
||||
<DialogTitle>Crear dataroom</DialogTitle>
|
||||
<DialogDescription id="create-dataroom-description">
|
||||
Define un nombre único para organizar tus archivos.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dataroom-name">Nombre del dataroom</Label>
|
||||
<Input
|
||||
id="dataroom-name"
|
||||
value={newDataroomName}
|
||||
onChange={(e) => {
|
||||
setNewDataroomName(e.target.value);
|
||||
if (createError) {
|
||||
setCreateError(null);
|
||||
}
|
||||
}}
|
||||
placeholder="Ej: normativa, contratos, fiscal..."
|
||||
autoFocus
|
||||
/>
|
||||
{createError && (
|
||||
<p className="text-sm text-red-500">{createError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleCreateDialogOpenChange(false)}
|
||||
disabled={creatingDataroom}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateDataroom}
|
||||
disabled={creatingDataroom || newDataroomName.trim() === ""}
|
||||
>
|
||||
{creatingDataroom ? "Creando..." : "Crear dataroom"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
65
frontend/src/components/ai-elements/actions.tsx
Normal file
65
frontend/src/components/ai-elements/actions.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type ActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const Actions = ({ className, children, ...props }: ActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const Action = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
className,
|
||||
variant = "ghost",
|
||||
size = "sm",
|
||||
...props
|
||||
}: ActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
className={cn(
|
||||
"relative size-9 p-1.5 text-muted-foreground hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
147
frontend/src/components/ai-elements/artifact.tsx
Normal file
147
frontend/src/components/ai-elements/artifact.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type LucideIcon, XIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
|
||||
export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Artifact = ({ className, ...props }: ArtifactProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b bg-muted/50 px-4 py-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactCloseProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ArtifactClose = ({
|
||||
className,
|
||||
children,
|
||||
size = "sm",
|
||||
variant = "ghost",
|
||||
...props
|
||||
}: ArtifactCloseProps) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-8 p-0 text-muted-foreground hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <XIcon className="size-4" />}
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
|
||||
<p
|
||||
className={cn("font-medium text-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const ArtifactDescription = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactDescriptionProps) => (
|
||||
<p className={cn("text-muted-foreground text-sm", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactActions = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ArtifactActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
|
||||
export const ArtifactAction = ({
|
||||
tooltip,
|
||||
label,
|
||||
icon: Icon,
|
||||
children,
|
||||
className,
|
||||
size = "sm",
|
||||
variant = "ghost",
|
||||
...props
|
||||
}: ArtifactActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-8 p-0 text-muted-foreground hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{Icon ? <Icon className="size-4" /> : children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactContentProps) => (
|
||||
<div className={cn("flex-1 overflow-auto p-4", className)} {...props} />
|
||||
);
|
||||
212
frontend/src/components/ai-elements/branch.tsx
Normal file
212
frontend/src/components/ai-elements/branch.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "ai";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type BranchContextType = {
|
||||
currentBranch: number;
|
||||
totalBranches: number;
|
||||
goToPrevious: () => void;
|
||||
goToNext: () => void;
|
||||
branches: ReactElement[];
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
};
|
||||
|
||||
const BranchContext = createContext<BranchContextType | null>(null);
|
||||
|
||||
const useBranch = () => {
|
||||
const context = useContext(BranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Branch components must be used within Branch");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type BranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number;
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const Branch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: BranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
const handleBranchChange = (newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const contextValue: BranchContextType = {
|
||||
currentBranch,
|
||||
totalBranches: branches.length,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
branches,
|
||||
setBranches,
|
||||
};
|
||||
|
||||
return (
|
||||
<BranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</BranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type BranchMessagesProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
|
||||
const { currentBranch, setBranches, branches } = useBranch();
|
||||
const childrenArray = Array.isArray(children) ? children : [children];
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray);
|
||||
}
|
||||
}, [childrenArray, branches, setBranches]);
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||
index === currentBranch ? "block" : "hidden"
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export type BranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const BranchSelector = ({
|
||||
className,
|
||||
from,
|
||||
...props
|
||||
}: BranchSelectorProps) => {
|
||||
const { totalBranches } = useBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 self-end px-10",
|
||||
from === "assistant" ? "justify-start" : "justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type BranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const BranchPrevious = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: BranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
className={cn(
|
||||
"size-7 shrink-0 rounded-full text-muted-foreground transition-colors",
|
||||
"hover:bg-accent hover:text-foreground",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type BranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const BranchNext = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: BranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
className={cn(
|
||||
"size-7 shrink-0 rounded-full text-muted-foreground transition-colors",
|
||||
"hover:bg-accent hover:text-foreground",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type BranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const BranchPage = ({ className, ...props }: BranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useBranch();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium text-muted-foreground text-xs tabular-nums",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
22
frontend/src/components/ai-elements/canvas.tsx
Normal file
22
frontend/src/components/ai-elements/canvas.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react";
|
||||
import type { ReactNode } from "react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
type CanvasProps = ReactFlowProps & {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const Canvas = ({ children, ...props }: CanvasProps) => (
|
||||
<ReactFlow
|
||||
deleteKeyCode={["Backspace", "Delete"]}
|
||||
fitView
|
||||
panOnDrag={false}
|
||||
panOnScroll
|
||||
selectionOnDrag={true}
|
||||
zoomOnDoubleClick={false}
|
||||
{...props}
|
||||
>
|
||||
<Background bgColor="var(--sidebar)" />
|
||||
{children}
|
||||
</ReactFlow>
|
||||
);
|
||||
228
frontend/src/components/ai-elements/chain-of-thought.tsx
Normal file
228
frontend/src/components/ai-elements/chain-of-thought.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
BrainIcon,
|
||||
ChevronDownIcon,
|
||||
DotIcon,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useContext, useMemo } from "react";
|
||||
|
||||
type ChainOfThoughtContextValue = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useChainOfThought = () => {
|
||||
const context = useContext(ChainOfThoughtContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"ChainOfThought components must be used within ChainOfThought"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ChainOfThoughtProps = ComponentProps<"div"> & {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const ChainOfThought = memo(
|
||||
({
|
||||
className,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
|
||||
const chainOfThoughtContext = useMemo(
|
||||
() => ({ isOpen, setIsOpen }),
|
||||
[isOpen, setIsOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
|
||||
<div
|
||||
className={cn("not-prose max-w-prose space-y-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ChainOfThoughtContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtHeaderProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
>;
|
||||
|
||||
export const ChainOfThoughtHeader = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
|
||||
const { isOpen, setIsOpen } = useChainOfThought();
|
||||
|
||||
return (
|
||||
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<BrainIcon className="size-4" />
|
||||
<span className="flex-1 text-left">
|
||||
{children ?? "Chain of Thought"}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
||||
icon?: LucideIcon;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
status?: "complete" | "active" | "pending";
|
||||
};
|
||||
|
||||
export const ChainOfThoughtStep = memo(
|
||||
({
|
||||
className,
|
||||
icon: Icon = DotIcon,
|
||||
label,
|
||||
description,
|
||||
status = "complete",
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtStepProps) => {
|
||||
const statusStyles = {
|
||||
complete: "text-muted-foreground",
|
||||
active: "text-foreground",
|
||||
pending: "text-muted-foreground/50",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 text-sm",
|
||||
statusStyles[status],
|
||||
"fade-in-0 slide-in-from-top-2 animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative mt-0.5">
|
||||
<Icon className="size-4" />
|
||||
<div className="-mx-px absolute top-7 bottom-0 left-1/2 w-px bg-border" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div>{label}</div>
|
||||
{description && (
|
||||
<div className="text-muted-foreground text-xs">{description}</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
|
||||
|
||||
export const ChainOfThoughtSearchResults = memo(
|
||||
({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props} />
|
||||
)
|
||||
);
|
||||
|
||||
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
|
||||
|
||||
export const ChainOfThoughtSearchResult = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
|
||||
<Badge
|
||||
className={cn("gap-1 px-2 py-0.5 font-normal text-xs", className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
)
|
||||
);
|
||||
|
||||
export type ChainOfThoughtContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>;
|
||||
|
||||
export const ChainOfThoughtContent = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtContentProps) => {
|
||||
const { isOpen } = useChainOfThought();
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen}>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-2 space-y-3",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export const ChainOfThoughtImage = memo(
|
||||
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
|
||||
<div className={cn("mt-2 space-y-2", className)} {...props}>
|
||||
<div className="relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg bg-muted p-3">
|
||||
{children}
|
||||
</div>
|
||||
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
ChainOfThought.displayName = "ChainOfThought";
|
||||
ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
|
||||
ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
|
||||
ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
|
||||
ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
|
||||
ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
|
||||
ChainOfThoughtImage.displayName = "ChainOfThoughtImage";
|
||||
179
frontend/src/components/ai-elements/code-block.tsx
Normal file
179
frontend/src/components/ai-elements/code-block.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Element } from "hast";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki";
|
||||
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
};
|
||||
|
||||
type CodeBlockContextType = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
const lineNumberTransformer: ShikiTransformer = {
|
||||
name: "line-numbers",
|
||||
line(node: Element, line: number) {
|
||||
node.children.unshift({
|
||||
type: "element",
|
||||
tagName: "span",
|
||||
properties: {
|
||||
className: [
|
||||
"inline-block",
|
||||
"min-w-10",
|
||||
"mr-4",
|
||||
"text-right",
|
||||
"select-none",
|
||||
"text-muted-foreground",
|
||||
],
|
||||
},
|
||||
children: [{ type: "text", value: String(line) }],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export async function highlightCode(
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
showLineNumbers = false
|
||||
) {
|
||||
const transformers: ShikiTransformer[] = showLineNumbers
|
||||
? [lineNumberTransformer]
|
||||
: [];
|
||||
|
||||
return await Promise.all([
|
||||
codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: "one-light",
|
||||
transformers,
|
||||
}),
|
||||
codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: "one-dark-pro",
|
||||
transformers,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const [html, setHtml] = useState<string>("");
|
||||
const [darkHtml, setDarkHtml] = useState<string>("");
|
||||
const mounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
|
||||
if (!mounted.current) {
|
||||
setHtml(light);
|
||||
setDarkHtml(dark);
|
||||
mounted.current = true;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, [code, language, showLineNumbers]);
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={{ code }}>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="overflow-hidden dark:hidden [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
<div
|
||||
className="hidden overflow-hidden dark:block [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: darkHtml }}
|
||||
/>
|
||||
{children && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-2">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CodeBlockContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
176
frontend/src/components/ai-elements/confirmation.tsx
Normal file
176
frontend/src/components/ai-elements/confirmation.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
} from "react";
|
||||
|
||||
type ToolUIPartApproval =
|
||||
| {
|
||||
id: string;
|
||||
approved?: never;
|
||||
reason?: never;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: true;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: true;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: false;
|
||||
reason?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
type ConfirmationContextValue = {
|
||||
approval: ToolUIPartApproval;
|
||||
state: ToolUIPart["state"];
|
||||
};
|
||||
|
||||
const ConfirmationContext = createContext<ConfirmationContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useConfirmation = () => {
|
||||
const context = useContext(ConfirmationContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Confirmation components must be used within Confirmation");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ConfirmationProps = ComponentProps<typeof Alert> & {
|
||||
approval?: ToolUIPartApproval;
|
||||
state: ToolUIPart["state"];
|
||||
};
|
||||
|
||||
export const Confirmation = ({
|
||||
className,
|
||||
approval,
|
||||
state,
|
||||
...props
|
||||
}: ConfirmationProps) => {
|
||||
if (!approval || state === "input-streaming" || state === "input-available") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationContext.Provider value={{ approval, state }}>
|
||||
<Alert className={cn("flex flex-col gap-2", className)} {...props} />
|
||||
</ConfirmationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type ConfirmationTitleProps = ComponentProps<typeof AlertDescription>;
|
||||
|
||||
export const ConfirmationTitle = ({
|
||||
className,
|
||||
...props
|
||||
}: ConfirmationTitleProps) => (
|
||||
<AlertDescription className={cn("inline", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ConfirmationRequestProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
|
||||
const { state } = useConfirmation();
|
||||
|
||||
// Only show when approval is requested
|
||||
if (state !== "approval-requested") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export type ConfirmationAcceptedProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const ConfirmationAccepted = ({
|
||||
children,
|
||||
}: ConfirmationAcceptedProps) => {
|
||||
const { approval, state } = useConfirmation();
|
||||
|
||||
// Only show when approved and in response states
|
||||
if (
|
||||
!approval?.approved ||
|
||||
(state !== "approval-responded" &&
|
||||
state !== "output-denied" &&
|
||||
state !== "output-available")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export type ConfirmationRejectedProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const ConfirmationRejected = ({
|
||||
children,
|
||||
}: ConfirmationRejectedProps) => {
|
||||
const { approval, state } = useConfirmation();
|
||||
|
||||
// Only show when rejected and in response states
|
||||
if (
|
||||
approval?.approved !== false ||
|
||||
(state !== "approval-responded" &&
|
||||
state !== "output-denied" &&
|
||||
state !== "output-available")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export type ConfirmationActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const ConfirmationActions = ({
|
||||
className,
|
||||
...props
|
||||
}: ConfirmationActionsProps) => {
|
||||
const { state } = useConfirmation();
|
||||
|
||||
// Only show when approval is requested
|
||||
if (state !== "approval-requested") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-end gap-2 self-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type ConfirmationActionProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConfirmationAction = (props: ConfirmationActionProps) => (
|
||||
<Button className="h-8 px-3 text-sm" type="button" {...props} />
|
||||
);
|
||||
28
frontend/src/components/ai-elements/connection.tsx
Normal file
28
frontend/src/components/ai-elements/connection.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { ConnectionLineComponent } from "@xyflow/react";
|
||||
|
||||
const HALF = 0.5;
|
||||
|
||||
export const Connection: ConnectionLineComponent = ({
|
||||
fromX,
|
||||
fromY,
|
||||
toX,
|
||||
toY,
|
||||
}) => (
|
||||
<g>
|
||||
<path
|
||||
className="animated"
|
||||
d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`}
|
||||
fill="none"
|
||||
stroke="var(--color-ring)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<circle
|
||||
cx={toX}
|
||||
cy={toY}
|
||||
fill="#fff"
|
||||
r={3}
|
||||
stroke="var(--color-ring)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
408
frontend/src/components/ai-elements/context.tsx
Normal file
408
frontend/src/components/ai-elements/context.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LanguageModelUsage } from "ai";
|
||||
import { type ComponentProps, createContext, useContext } from "react";
|
||||
import { getUsage } from "tokenlens";
|
||||
|
||||
const PERCENT_MAX = 100;
|
||||
const ICON_RADIUS = 10;
|
||||
const ICON_VIEWBOX = 24;
|
||||
const ICON_CENTER = 12;
|
||||
const ICON_STROKE_WIDTH = 2;
|
||||
|
||||
type ModelId = string;
|
||||
|
||||
type ContextSchema = {
|
||||
usedTokens: number;
|
||||
maxTokens: number;
|
||||
usage?: LanguageModelUsage;
|
||||
modelId?: ModelId;
|
||||
};
|
||||
|
||||
const ContextContext = createContext<ContextSchema | null>(null);
|
||||
|
||||
const useContextValue = () => {
|
||||
const context = useContext(ContextContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Context components must be used within Context");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;
|
||||
|
||||
export const Context = ({
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
usage,
|
||||
modelId,
|
||||
...props
|
||||
}: ContextProps) => (
|
||||
<ContextContext.Provider
|
||||
value={{
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
usage,
|
||||
modelId,
|
||||
}}
|
||||
>
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
</ContextContext.Provider>
|
||||
);
|
||||
|
||||
const ContextIcon = () => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const circumference = 2 * Math.PI * ICON_RADIUS;
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const dashOffset = circumference * (1 - usedPercent);
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-label="Model context usage"
|
||||
height="20"
|
||||
role="img"
|
||||
style={{ color: "currentcolor" }}
|
||||
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
|
||||
width="20"
|
||||
>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill="none"
|
||||
opacity="0.25"
|
||||
r={ICON_RADIUS}
|
||||
stroke="currentColor"
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
/>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill="none"
|
||||
opacity="0.7"
|
||||
r={ICON_RADIUS}
|
||||
stroke="currentColor"
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
style={{ transformOrigin: "center", transform: "rotate(-90deg)" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextTriggerProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const renderedPercent = new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(usedPercent);
|
||||
|
||||
return (
|
||||
<HoverCardTrigger asChild>
|
||||
{children ?? (
|
||||
<Button type="button" variant="ghost" {...props}>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{renderedPercent}
|
||||
</span>
|
||||
<ContextIcon />
|
||||
</Button>
|
||||
)}
|
||||
</HoverCardTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextContentProps = ComponentProps<typeof HoverCardContent>;
|
||||
|
||||
export const ContextContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ContextContentProps) => (
|
||||
<HoverCardContent
|
||||
className={cn("min-w-60 divide-y overflow-hidden p-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ContextContentHeaderProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentHeader = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentHeaderProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const displayPct = new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(usedPercent);
|
||||
const used = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(usedTokens);
|
||||
const total = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(maxTokens);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full space-y-2 p-3", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<p>{displayPct}</p>
|
||||
<p className="font-mono text-muted-foreground">
|
||||
{used} / {total}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Progress className="bg-muted" value={usedPercent * PERCENT_MAX} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextContentBodyProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentBody = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentBodyProps) => (
|
||||
<div className={cn("w-full p-3", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ContextContentFooterProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentFooter = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentFooterProps) => {
|
||||
const { modelId, usage } = useContextValue();
|
||||
const costUSD = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: {
|
||||
input: usage?.inputTokens ?? 0,
|
||||
output: usage?.outputTokens ?? 0,
|
||||
},
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const totalCost = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(costUSD ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<span className="text-muted-foreground">Total cost</span>
|
||||
<span>{totalCost}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextInputUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextInputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextInputUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const inputTokens = usage?.inputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!inputTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: inputTokens, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const inputCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(inputCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Input</span>
|
||||
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextOutputUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextOutputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextOutputUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const outputTokens = usage?.outputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!outputTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const outputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: 0, output: outputTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const outputCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(outputCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Output</span>
|
||||
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextReasoningUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextReasoningUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextReasoningUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const reasoningTokens = usage?.reasoningTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!reasoningTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reasoningCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { reasoningTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const reasoningCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(reasoningCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Reasoning</span>
|
||||
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextCacheUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextCacheUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextCacheUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const cacheTokens = usage?.cachedInputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!cacheTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { cacheReads: cacheTokens, input: 0, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const cacheCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(cacheCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Cache</span>
|
||||
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TokensWithCost = ({
|
||||
tokens,
|
||||
costText,
|
||||
}: {
|
||||
tokens?: number;
|
||||
costText?: string;
|
||||
}) => (
|
||||
<span>
|
||||
{tokens === undefined
|
||||
? "—"
|
||||
: new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(tokens)}
|
||||
{costText ? (
|
||||
<span className="ml-2 text-muted-foreground">• {costText}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
18
frontend/src/components/ai-elements/controls.tsx
Normal file
18
frontend/src/components/ai-elements/controls.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Controls as ControlsPrimitive } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type ControlsProps = ComponentProps<typeof ControlsPrimitive>;
|
||||
|
||||
export const Controls = ({ className, ...props }: ControlsProps) => (
|
||||
<ControlsPrimitive
|
||||
className={cn(
|
||||
"gap-px overflow-hidden rounded-md border bg-card p-1 shadow-none!",
|
||||
"[&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
100
frontend/src/components/ai-elements/conversation.tsx
Normal file
100
frontend/src/components/ai-elements/conversation.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-auto", className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>;
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn("flex flex-col gap-8 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title = "No messages yet",
|
||||
description = "Start a conversation to see messages here",
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
140
frontend/src/components/ai-elements/edge.tsx
Normal file
140
frontend/src/components/ai-elements/edge.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
BaseEdge,
|
||||
type EdgeProps,
|
||||
getBezierPath,
|
||||
getSimpleBezierPath,
|
||||
type InternalNode,
|
||||
type Node,
|
||||
Position,
|
||||
useInternalNode,
|
||||
} from "@xyflow/react";
|
||||
|
||||
const Temporary = ({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
}: EdgeProps) => {
|
||||
const [edgePath] = getSimpleBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
className="stroke-1 stroke-ring"
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={{
|
||||
strokeDasharray: "5, 5",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getHandleCoordsByPosition = (
|
||||
node: InternalNode<Node>,
|
||||
handlePosition: Position
|
||||
) => {
|
||||
// Choose the handle type based on position - Left is for target, Right is for source
|
||||
const handleType = handlePosition === Position.Left ? "target" : "source";
|
||||
|
||||
const handle = node.internals.handleBounds?.[handleType]?.find(
|
||||
(h) => h.position === handlePosition
|
||||
);
|
||||
|
||||
if (!handle) {
|
||||
return [0, 0] as const;
|
||||
}
|
||||
|
||||
let offsetX = handle.width / 2;
|
||||
let offsetY = handle.height / 2;
|
||||
|
||||
// this is a tiny detail to make the markerEnd of an edge visible.
|
||||
// The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset
|
||||
// when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position
|
||||
switch (handlePosition) {
|
||||
case Position.Left:
|
||||
offsetX = 0;
|
||||
break;
|
||||
case Position.Right:
|
||||
offsetX = handle.width;
|
||||
break;
|
||||
case Position.Top:
|
||||
offsetY = 0;
|
||||
break;
|
||||
case Position.Bottom:
|
||||
offsetY = handle.height;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid handle position: ${handlePosition}`);
|
||||
}
|
||||
|
||||
const x = node.internals.positionAbsolute.x + handle.x + offsetX;
|
||||
const y = node.internals.positionAbsolute.y + handle.y + offsetY;
|
||||
|
||||
return [x, y] as const;
|
||||
};
|
||||
|
||||
const getEdgeParams = (
|
||||
source: InternalNode<Node>,
|
||||
target: InternalNode<Node>
|
||||
) => {
|
||||
const sourcePos = Position.Right;
|
||||
const [sx, sy] = getHandleCoordsByPosition(source, sourcePos);
|
||||
const targetPos = Position.Left;
|
||||
const [tx, ty] = getHandleCoordsByPosition(target, targetPos);
|
||||
|
||||
return {
|
||||
sx,
|
||||
sy,
|
||||
tx,
|
||||
ty,
|
||||
sourcePos,
|
||||
targetPos,
|
||||
};
|
||||
};
|
||||
|
||||
const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => {
|
||||
const sourceNode = useInternalNode(source);
|
||||
const targetNode = useInternalNode(target);
|
||||
|
||||
if (!(sourceNode && targetNode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
|
||||
sourceNode,
|
||||
targetNode
|
||||
);
|
||||
|
||||
const [edgePath] = getBezierPath({
|
||||
sourceX: sx,
|
||||
sourceY: sy,
|
||||
sourcePosition: sourcePos,
|
||||
targetX: tx,
|
||||
targetY: ty,
|
||||
targetPosition: targetPos,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} markerEnd={markerEnd} path={edgePath} style={style} />
|
||||
<circle fill="var(--primary)" r="4">
|
||||
<animateMotion dur="2s" path={edgePath} repeatCount="indefinite" />
|
||||
</circle>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Edge = {
|
||||
Temporary,
|
||||
Animated,
|
||||
};
|
||||
24
frontend/src/components/ai-elements/image.tsx
Normal file
24
frontend/src/components/ai-elements/image.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Experimental_GeneratedImage } from "ai";
|
||||
|
||||
export type ImageProps = Experimental_GeneratedImage & {
|
||||
className?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export const Image = ({
|
||||
base64,
|
||||
uint8Array,
|
||||
mediaType,
|
||||
...props
|
||||
}: ImageProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={props.alt}
|
||||
className={cn(
|
||||
"h-auto max-w-full overflow-hidden rounded-md",
|
||||
props.className
|
||||
)}
|
||||
src={`data:${mediaType};base64,${base64}`}
|
||||
/>
|
||||
);
|
||||
287
frontend/src/components/ai-elements/inline-citation.tsx
Normal file
287
frontend/src/components/ai-elements/inline-citation.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Carousel,
|
||||
type CarouselApi,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export type InlineCitationProps = ComponentProps<"span">;
|
||||
|
||||
export const InlineCitation = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationProps) => (
|
||||
<span
|
||||
className={cn("group inline items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationTextProps = ComponentProps<"span">;
|
||||
|
||||
export const InlineCitationText = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationTextProps) => (
|
||||
<span
|
||||
className={cn("transition-colors group-hover:bg-accent", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
|
||||
|
||||
export const InlineCitationCard = (props: InlineCitationCardProps) => (
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
);
|
||||
|
||||
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
|
||||
sources: string[];
|
||||
};
|
||||
|
||||
export const InlineCitationCardTrigger = ({
|
||||
sources,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardTriggerProps) => (
|
||||
<HoverCardTrigger asChild>
|
||||
<Badge
|
||||
className={cn("ml-1 rounded-full", className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{sources[0] ? (
|
||||
<>
|
||||
{new URL(sources[0]).hostname}{" "}
|
||||
{sources.length > 1 && `+${sources.length - 1}`}
|
||||
</>
|
||||
) : (
|
||||
"unknown"
|
||||
)}
|
||||
</Badge>
|
||||
</HoverCardTrigger>
|
||||
);
|
||||
|
||||
export type InlineCitationCardBodyProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCardBody = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardBodyProps) => (
|
||||
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
|
||||
);
|
||||
|
||||
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
|
||||
|
||||
const useCarouselApi = () => {
|
||||
const context = useContext(CarouselApiContext);
|
||||
return context;
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
|
||||
|
||||
export const InlineCitationCarousel = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationCarouselProps) => {
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
|
||||
return (
|
||||
<CarouselApiContext.Provider value={api}>
|
||||
<Carousel className={cn("w-full", className)} setApi={setApi} {...props}>
|
||||
{children}
|
||||
</Carousel>
|
||||
</CarouselApiContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselContentProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselContent = (
|
||||
props: InlineCitationCarouselContentProps
|
||||
) => <CarouselContent {...props} />;
|
||||
|
||||
export type InlineCitationCarouselItemProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselItem = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselItemProps) => (
|
||||
<CarouselItem
|
||||
className={cn("w-full space-y-2 p-4 pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCarouselHeaderProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCarouselIndexProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselIndex = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselIndexProps) => {
|
||||
const api = useCarouselApi();
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCount(api.scrollSnapList().length);
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
|
||||
api.on("select", () => {
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? `${current}/${count}`}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselPrevProps = ComponentProps<"button">;
|
||||
|
||||
export const InlineCitationCarouselPrev = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselPrevProps) => {
|
||||
const api = useCarouselApi();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (api) {
|
||||
api.scrollPrev();
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Previous"
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselNextProps = ComponentProps<"button">;
|
||||
|
||||
export const InlineCitationCarouselNext = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselNextProps) => {
|
||||
const api = useCarouselApi();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (api) {
|
||||
api.scrollNext();
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Next"
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ArrowRightIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationSourceProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
url?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const InlineCitationSource = ({
|
||||
title,
|
||||
url,
|
||||
description,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationSourceProps) => (
|
||||
<div className={cn("space-y-1", className)} {...props}>
|
||||
{title && (
|
||||
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
|
||||
)}
|
||||
{url && (
|
||||
<p className="truncate break-all text-muted-foreground text-xs">{url}</p>
|
||||
)}
|
||||
{description && (
|
||||
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type InlineCitationQuoteProps = ComponentProps<"blockquote">;
|
||||
|
||||
export const InlineCitationQuote = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationQuoteProps) => (
|
||||
<blockquote
|
||||
className={cn(
|
||||
"border-muted border-l-2 pl-3 text-muted-foreground text-sm italic",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
96
frontend/src/components/ai-elements/loader.tsx
Normal file
96
frontend/src/components/ai-elements/loader.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
type LoaderIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "currentcolor" }}
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
>
|
||||
<title>Loader</title>
|
||||
<g clipPath="url(#clip0_2393_1490)">
|
||||
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path
|
||||
d="M8 16V12"
|
||||
opacity="0.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M3.29773 1.52783L5.64887 4.7639"
|
||||
opacity="0.9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12.7023 1.52783L10.3511 4.7639"
|
||||
opacity="0.1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12.7023 14.472L10.3511 11.236"
|
||||
opacity="0.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M3.29773 14.472L5.64887 11.236"
|
||||
opacity="0.6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M15.6085 5.52783L11.8043 6.7639"
|
||||
opacity="0.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M0.391602 10.472L4.19583 9.23598"
|
||||
opacity="0.7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M15.6085 10.4722L11.8043 9.2361"
|
||||
opacity="0.3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M0.391602 5.52783L4.19583 6.7639"
|
||||
opacity="0.8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2393_1490">
|
||||
<rect fill="white" height="16" width="16" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex animate-spin items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<LoaderIcon size={size} />
|
||||
</div>
|
||||
);
|
||||
335
frontend/src/components/ai-elements/message.tsx
Normal file
335
frontend/src/components/ai-elements/message.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ButtonGroup,
|
||||
ButtonGroupText,
|
||||
} from "@/components/ui/button-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "ai";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full max-w-[80%] gap-2",
|
||||
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"is-user:dark flex flex-col gap-2 overflow-hidden rounded-lg text-sm",
|
||||
"group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||
"group-[.is-assistant]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const MessageAction = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
variant = "ghost",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: MessageActionProps) => {
|
||||
const button = (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
type MessageBranchContextType = {
|
||||
currentBranch: number;
|
||||
totalBranches: number;
|
||||
goToPrevious: () => void;
|
||||
goToNext: () => void;
|
||||
branches: ReactElement[];
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
};
|
||||
|
||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useMessageBranch = () => {
|
||||
const context = useContext(MessageBranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"MessageBranch components must be used within MessageBranch"
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number;
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const MessageBranch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
const handleBranchChange = (newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const contextValue: MessageBranchContextType = {
|
||||
currentBranch,
|
||||
totalBranches: branches.length,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
branches,
|
||||
setBranches,
|
||||
};
|
||||
|
||||
return (
|
||||
<MessageBranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</MessageBranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageBranchContent = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchContentProps) => {
|
||||
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||
const childrenArray = Array.isArray(children) ? children : [children];
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray);
|
||||
}
|
||||
}, [childrenArray, branches, setBranches]);
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||
index === currentBranch ? "block" : "hidden"
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const MessageBranchSelector = ({
|
||||
className,
|
||||
from,
|
||||
...props
|
||||
}: MessageBranchSelectorProps) => {
|
||||
const { totalBranches } = useMessageBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup
|
||||
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
|
||||
orientation="horizontal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchPrevious = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchNext = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const MessageBranchPage = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<ButtonGroupText
|
||||
className={cn(
|
||||
"border-none bg-transparent text-muted-foreground shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</ButtonGroupText>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
|
||||
export type MessageToolbarProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageToolbar = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageToolbarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
200
frontend/src/components/ai-elements/model-selector.tsx
Normal file
200
frontend/src/components/ai-elements/model-selector.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
|
||||
|
||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||
<Dialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
|
||||
|
||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent>;
|
||||
|
||||
export const ModelSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ModelSelectorContentProps) => (
|
||||
<DialogContent className={cn("p-0", className)} {...props}>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
|
||||
|
||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
|
||||
|
||||
export const ModelSelectorInput = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
|
||||
|
||||
export const ModelSelectorList = (props: ModelSelectorListProps) => (
|
||||
<CommandList {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||
|
||||
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
||||
<CommandEmpty {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
|
||||
|
||||
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
||||
<CommandGroup {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
|
||||
|
||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||
<CommandItem {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
|
||||
|
||||
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
||||
<CommandShortcut {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorSeparatorProps = ComponentProps<
|
||||
typeof CommandSeparator
|
||||
>;
|
||||
|
||||
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
||||
<CommandSeparator {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoProps = Omit<
|
||||
ComponentProps<"img">,
|
||||
"src" | "alt"
|
||||
> & {
|
||||
provider:
|
||||
| "moonshotai-cn"
|
||||
| "lucidquery"
|
||||
| "moonshotai"
|
||||
| "zai-coding-plan"
|
||||
| "alibaba"
|
||||
| "xai"
|
||||
| "vultr"
|
||||
| "nvidia"
|
||||
| "upstage"
|
||||
| "groq"
|
||||
| "github-copilot"
|
||||
| "mistral"
|
||||
| "vercel"
|
||||
| "nebius"
|
||||
| "deepseek"
|
||||
| "alibaba-cn"
|
||||
| "google-vertex-anthropic"
|
||||
| "venice"
|
||||
| "chutes"
|
||||
| "cortecs"
|
||||
| "github-models"
|
||||
| "togetherai"
|
||||
| "azure"
|
||||
| "baseten"
|
||||
| "huggingface"
|
||||
| "opencode"
|
||||
| "fastrouter"
|
||||
| "google"
|
||||
| "google-vertex"
|
||||
| "cloudflare-workers-ai"
|
||||
| "inception"
|
||||
| "wandb"
|
||||
| "openai"
|
||||
| "zhipuai-coding-plan"
|
||||
| "perplexity"
|
||||
| "openrouter"
|
||||
| "zenmux"
|
||||
| "v0"
|
||||
| "iflowcn"
|
||||
| "synthetic"
|
||||
| "deepinfra"
|
||||
| "zhipuai"
|
||||
| "submodel"
|
||||
| "zai"
|
||||
| "inference"
|
||||
| "requesty"
|
||||
| "morph"
|
||||
| "lmstudio"
|
||||
| "anthropic"
|
||||
| "aihubmix"
|
||||
| "fireworks-ai"
|
||||
| "modelscope"
|
||||
| "llama"
|
||||
| "scaleway"
|
||||
| "amazon-bedrock"
|
||||
| "cerebras"
|
||||
| (string & {});
|
||||
};
|
||||
|
||||
export const ModelSelectorLogo = ({
|
||||
provider,
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={`${provider} logo`}
|
||||
className={cn("size-3", className)}
|
||||
height={12}
|
||||
src={`https://models.dev/logos/${provider}.svg`}
|
||||
width={12}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
|
||||
|
||||
export const ModelSelectorLogoGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoGroupProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 [&>img]:ring-border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorNameProps = ComponentProps<"span">;
|
||||
|
||||
export const ModelSelectorName = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorNameProps) => (
|
||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
||||
);
|
||||
71
frontend/src/components/ai-elements/node.tsx
Normal file
71
frontend/src/components/ai-elements/node.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type NodeProps = ComponentProps<typeof Card> & {
|
||||
handles: {
|
||||
target: boolean;
|
||||
source: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const Node = ({ handles, className, ...props }: NodeProps) => (
|
||||
<Card
|
||||
className={cn(
|
||||
"node-container relative size-full h-auto w-sm gap-0 rounded-md p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{handles.target && <Handle position={Position.Left} type="target" />}
|
||||
{handles.source && <Handle position={Position.Right} type="source" />}
|
||||
{props.children}
|
||||
</Card>
|
||||
);
|
||||
|
||||
export type NodeHeaderProps = ComponentProps<typeof CardHeader>;
|
||||
|
||||
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn("gap-0.5 rounded-t-md border-b bg-secondary p-3!", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type NodeTitleProps = ComponentProps<typeof CardTitle>;
|
||||
|
||||
export const NodeTitle = (props: NodeTitleProps) => <CardTitle {...props} />;
|
||||
|
||||
export type NodeDescriptionProps = ComponentProps<typeof CardDescription>;
|
||||
|
||||
export const NodeDescription = (props: NodeDescriptionProps) => (
|
||||
<CardDescription {...props} />
|
||||
);
|
||||
|
||||
export type NodeActionProps = ComponentProps<typeof CardAction>;
|
||||
|
||||
export const NodeAction = (props: NodeActionProps) => <CardAction {...props} />;
|
||||
|
||||
export type NodeContentProps = ComponentProps<typeof CardContent>;
|
||||
|
||||
export const NodeContent = ({ className, ...props }: NodeContentProps) => (
|
||||
<CardContent className={cn("p-3", className)} {...props} />
|
||||
);
|
||||
|
||||
export type NodeFooterProps = ComponentProps<typeof CardFooter>;
|
||||
|
||||
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
|
||||
<CardFooter
|
||||
className={cn("rounded-b-md border-t bg-secondary p-3!", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
365
frontend/src/components/ai-elements/open-in-chat.tsx
Normal file
365
frontend/src/components/ai-elements/open-in-chat.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ExternalLinkIcon,
|
||||
MessageCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { type ComponentProps, createContext, useContext } from "react";
|
||||
|
||||
const providers = {
|
||||
github: {
|
||||
title: "Open in GitHub",
|
||||
createUrl: (url: string) => url,
|
||||
icon: (
|
||||
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
scira: {
|
||||
title: "Open in Scira",
|
||||
createUrl: (q: string) =>
|
||||
`https://scira.ai/?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="none"
|
||||
height="934"
|
||||
viewBox="0 0 910 934"
|
||||
width="910"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Scira AI</title>
|
||||
<path
|
||||
d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="20"
|
||||
/>
|
||||
<path
|
||||
d="M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="20"
|
||||
/>
|
||||
<path
|
||||
d="M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="30"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
chatgpt: {
|
||||
title: "Open in ChatGPT",
|
||||
createUrl: (prompt: string) =>
|
||||
`https://chatgpt.com/?${new URLSearchParams({
|
||||
hints: "search",
|
||||
prompt,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>OpenAI</title>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
claude: {
|
||||
title: "Open in Claude",
|
||||
createUrl: (q: string) =>
|
||||
`https://claude.ai/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 12 12"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Claude</title>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M2.3545 7.9775L4.7145 6.654L4.7545 6.539L4.7145 6.475H4.6L4.205 6.451L2.856 6.4145L1.6865 6.366L0.5535 6.305L0.268 6.2445L0 5.892L0.0275 5.716L0.2675 5.5555L0.6105 5.5855L1.3705 5.637L2.5095 5.716L3.3355 5.7645L4.56 5.892H4.7545L4.782 5.8135L4.715 5.7645L4.6635 5.716L3.4845 4.918L2.2085 4.074L1.5405 3.588L1.1785 3.3425L0.9965 3.1115L0.9175 2.6075L1.2455 2.2465L1.686 2.2765L1.7985 2.307L2.245 2.65L3.199 3.388L4.4445 4.3045L4.627 4.4565L4.6995 4.405L4.709 4.3685L4.627 4.2315L3.9495 3.0085L3.2265 1.7635L2.9045 1.2475L2.8195 0.938C2.78711 0.819128 2.76965 0.696687 2.7675 0.5735L3.1415 0.067L3.348 0L3.846 0.067L4.056 0.249L4.366 0.956L4.867 2.0705L5.6445 3.5855L5.8725 4.0345L5.994 4.4505L6.0395 4.578H6.1185V4.505L6.1825 3.652L6.301 2.6045L6.416 1.257L6.456 0.877L6.644 0.422L7.0175 0.176L7.3095 0.316L7.5495 0.6585L7.516 0.8805L7.373 1.806L7.0935 3.2575L6.9115 4.2285H7.0175L7.139 4.1075L7.6315 3.4545L8.4575 2.4225L8.8225 2.0125L9.2475 1.5605L9.521 1.345H10.0375L10.4175 1.9095L10.2475 2.4925L9.7155 3.166L9.275 3.737L8.643 4.587L8.248 5.267L8.2845 5.322L8.3785 5.312L9.8065 5.009L10.578 4.869L11.4985 4.7115L11.915 4.9055L11.9605 5.103L11.7965 5.5065L10.812 5.7495L9.6575 5.9805L7.938 6.387L7.917 6.402L7.9415 6.4325L8.716 6.5055L9.047 6.5235H9.858L11.368 6.636L11.763 6.897L12 7.216L11.9605 7.4585L11.353 7.7685L10.533 7.574L8.6185 7.119L7.9625 6.9545H7.8715V7.0095L8.418 7.5435L9.421 8.4485L10.6755 9.6135L10.739 9.9025L10.578 10.13L10.408 10.1055L9.3055 9.277L8.88 8.9035L7.917 8.0935H7.853V8.1785L8.075 8.503L9.2475 10.2635L9.3085 10.8035L9.2235 10.98L8.9195 11.0865L8.5855 11.0255L7.8985 10.063L7.191 8.9795L6.6195 8.008L6.5495 8.048L6.2125 11.675L6.0545 11.86L5.69 12L5.3865 11.7695L5.2255 11.396L5.3865 10.658L5.581 9.696L5.7385 8.931L5.8815 7.981L5.9665 7.665L5.9605 7.644L5.8905 7.653L5.1735 8.6365L4.0835 10.109L3.2205 11.0315L3.0135 11.1135L2.655 10.9285L2.6885 10.5975L2.889 10.303L4.083 8.785L4.803 7.844L5.268 7.301L5.265 7.222H5.2375L2.066 9.28L1.501 9.353L1.2575 9.125L1.288 8.752L1.4035 8.6305L2.3575 7.9745L2.3545 7.9775Z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
t3: {
|
||||
title: "Open in T3 Chat",
|
||||
createUrl: (q: string) =>
|
||||
`https://t3.chat/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: <MessageCircleIcon />,
|
||||
},
|
||||
v0: {
|
||||
title: "Open in v0",
|
||||
createUrl: (q: string) =>
|
||||
`https://v0.app?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 147 70"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>v0</title>
|
||||
<path d="M56 50.2031V14H70V60.1562C70 65.5928 65.5928 70 60.1562 70C57.5605 70 54.9982 68.9992 53.1562 67.1573L0 14H19.7969L56 50.2031Z" />
|
||||
<path d="M147 56H133V23.9531L100.953 56H133V70H96.6875C85.8144 70 77 61.1856 77 50.3125V14H91V46.1562L123.156 14H91V0H127.312C138.186 0 147 8.81439 147 19.6875V56Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
cursor: {
|
||||
title: "Open in Cursor",
|
||||
createUrl: (text: string) => {
|
||||
const url = new URL("https://cursor.com/link/prompt");
|
||||
url.searchParams.set("text", text);
|
||||
return url.toString();
|
||||
},
|
||||
icon: (
|
||||
<svg
|
||||
version="1.1"
|
||||
viewBox="0 0 466.73 532.09"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Cursor</title>
|
||||
<path
|
||||
d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const OpenInContext = createContext<{ query: string } | undefined>(undefined);
|
||||
|
||||
const useOpenInContext = () => {
|
||||
const context = useContext(OpenInContext);
|
||||
if (!context) {
|
||||
throw new Error("OpenIn components must be used within an OpenIn provider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type OpenInProps = ComponentProps<typeof DropdownMenu> & {
|
||||
query: string;
|
||||
};
|
||||
|
||||
export const OpenIn = ({ query, ...props }: OpenInProps) => (
|
||||
<OpenInContext.Provider value={{ query }}>
|
||||
<DropdownMenu {...props} />
|
||||
</OpenInContext.Provider>
|
||||
);
|
||||
|
||||
export type OpenInContentProps = ComponentProps<typeof DropdownMenuContent>;
|
||||
|
||||
export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={cn("w-[240px]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type OpenInItemProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInItem = (props: OpenInItemProps) => (
|
||||
<DropdownMenuItem {...props} />
|
||||
);
|
||||
|
||||
export type OpenInLabelProps = ComponentProps<typeof DropdownMenuLabel>;
|
||||
|
||||
export const OpenInLabel = (props: OpenInLabelProps) => (
|
||||
<DropdownMenuLabel {...props} />
|
||||
);
|
||||
|
||||
export type OpenInSeparatorProps = ComponentProps<typeof DropdownMenuSeparator>;
|
||||
|
||||
export const OpenInSeparator = (props: OpenInSeparatorProps) => (
|
||||
<DropdownMenuSeparator {...props} />
|
||||
);
|
||||
|
||||
export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger>;
|
||||
|
||||
export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
|
||||
<DropdownMenuTrigger {...props} asChild>
|
||||
{children ?? (
|
||||
<Button type="button" variant="outline">
|
||||
Open in chat
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
|
||||
export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.chatgpt.createUrl(query)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.chatgpt.icon}</span>
|
||||
<span className="flex-1">{providers.chatgpt.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInClaude = (props: OpenInClaudeProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.claude.createUrl(query)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.claude.icon}</span>
|
||||
<span className="flex-1">{providers.claude.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInT3 = (props: OpenInT3Props) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.t3.createUrl(query)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.t3.icon}</span>
|
||||
<span className="flex-1">{providers.t3.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInScira = (props: OpenInSciraProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.scira.createUrl(query)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.scira.icon}</span>
|
||||
<span className="flex-1">{providers.scira.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInv0 = (props: OpenInv0Props) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.v0.createUrl(query)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.v0.icon}</span>
|
||||
<span className="flex-1">{providers.v0.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInCursor = (props: OpenInCursorProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.cursor.createUrl(query)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.cursor.icon}</span>
|
||||
<span className="flex-1">{providers.cursor.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
15
frontend/src/components/ai-elements/panel.tsx
Normal file
15
frontend/src/components/ai-elements/panel.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Panel as PanelPrimitive } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type PanelProps = ComponentProps<typeof PanelPrimitive>;
|
||||
|
||||
export const Panel = ({ className, ...props }: PanelProps) => (
|
||||
<PanelPrimitive
|
||||
className={cn(
|
||||
"m-4 overflow-hidden rounded-md border bg-card p-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
142
frontend/src/components/ai-elements/plan.tsx
Normal file
142
frontend/src/components/ai-elements/plan.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronsUpDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
type PlanContextValue = {
|
||||
isStreaming: boolean;
|
||||
};
|
||||
|
||||
const PlanContext = createContext<PlanContextValue | null>(null);
|
||||
|
||||
const usePlan = () => {
|
||||
const context = useContext(PlanContext);
|
||||
if (!context) {
|
||||
throw new Error("Plan components must be used within Plan");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type PlanProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
};
|
||||
|
||||
export const Plan = ({
|
||||
className,
|
||||
isStreaming = false,
|
||||
children,
|
||||
...props
|
||||
}: PlanProps) => (
|
||||
<PlanContext.Provider value={{ isStreaming }}>
|
||||
<Collapsible asChild data-slot="plan" {...props}>
|
||||
<Card className={cn("shadow-none", className)}>{children}</Card>
|
||||
</Collapsible>
|
||||
</PlanContext.Provider>
|
||||
);
|
||||
|
||||
export type PlanHeaderProps = ComponentProps<typeof CardHeader>;
|
||||
|
||||
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn("flex items-start justify-between", className)}
|
||||
data-slot="plan-header"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PlanTitleProps = Omit<
|
||||
ComponentProps<typeof CardTitle>,
|
||||
"children"
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
|
||||
const { isStreaming } = usePlan();
|
||||
|
||||
return (
|
||||
<CardTitle data-slot="plan-title" {...props}>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardTitle>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanDescriptionProps = Omit<
|
||||
ComponentProps<typeof CardDescription>,
|
||||
"children"
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const PlanDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PlanDescriptionProps) => {
|
||||
const { isStreaming } = usePlan();
|
||||
|
||||
return (
|
||||
<CardDescription
|
||||
className={cn("text-balance", className)}
|
||||
data-slot="plan-description"
|
||||
{...props}
|
||||
>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardDescription>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanActionProps = ComponentProps<typeof CardAction>;
|
||||
|
||||
export const PlanAction = (props: PlanActionProps) => (
|
||||
<CardAction data-slot="plan-action" {...props} />
|
||||
);
|
||||
|
||||
export type PlanContentProps = ComponentProps<typeof CardContent>;
|
||||
|
||||
export const PlanContent = (props: PlanContentProps) => (
|
||||
<CollapsibleContent asChild>
|
||||
<CardContent data-slot="plan-content" {...props} />
|
||||
</CollapsibleContent>
|
||||
);
|
||||
|
||||
export type PlanFooterProps = ComponentProps<"div">;
|
||||
|
||||
export const PlanFooter = (props: PlanFooterProps) => (
|
||||
<CardFooter data-slot="plan-footer" {...props} />
|
||||
);
|
||||
|
||||
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
className={cn("size-8", className)}
|
||||
data-slot="plan-trigger"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<ChevronsUpDownIcon className="size-4" />
|
||||
<span className="sr-only">Toggle plan</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
1377
frontend/src/components/ai-elements/prompt-input.tsx
Normal file
1377
frontend/src/components/ai-elements/prompt-input.tsx
Normal file
File diff suppressed because it is too large
Load Diff
274
frontend/src/components/ai-elements/queue.tsx
Normal file
274
frontend/src/components/ai-elements/queue.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type QueueMessagePart = {
|
||||
type: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
filename?: string;
|
||||
mediaType?: string;
|
||||
};
|
||||
|
||||
export type QueueMessage = {
|
||||
id: string;
|
||||
parts: QueueMessagePart[];
|
||||
};
|
||||
|
||||
export type QueueTodo = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: "pending" | "completed";
|
||||
};
|
||||
|
||||
export type QueueItemProps = ComponentProps<"li">;
|
||||
|
||||
export const QueueItem = ({ className, ...props }: QueueItemProps) => (
|
||||
<li
|
||||
className={cn(
|
||||
"group flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemIndicatorProps = ComponentProps<"span"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemIndicator = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemIndicatorProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 inline-block size-2.5 rounded-full border",
|
||||
completed
|
||||
? "border-muted-foreground/20 bg-muted-foreground/10"
|
||||
: "border-muted-foreground/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemContentProps = ComponentProps<"span"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemContent = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemContentProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 grow break-words",
|
||||
completed
|
||||
? "text-muted-foreground/50 line-through"
|
||||
: "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemDescriptionProps = ComponentProps<"div"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemDescription = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemDescriptionProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-6 text-xs",
|
||||
completed
|
||||
? "text-muted-foreground/40 line-through"
|
||||
: "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const QueueItemActions = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemActionsProps) => (
|
||||
<div className={cn("flex gap-1", className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueItemActionProps = Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
"variant" | "size"
|
||||
>;
|
||||
|
||||
export const QueueItemAction = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemActionProps) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-auto rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted-foreground/10 hover:text-foreground group-hover:opacity-100",
|
||||
className
|
||||
)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemAttachmentProps = ComponentProps<"div">;
|
||||
|
||||
export const QueueItemAttachment = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemAttachmentProps) => (
|
||||
<div className={cn("mt-1 flex flex-wrap gap-2", className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueItemImageProps = ComponentProps<"img">;
|
||||
|
||||
export const QueueItemImage = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemImageProps) => (
|
||||
<img
|
||||
alt=""
|
||||
className={cn("h-8 w-8 rounded border object-cover", className)}
|
||||
height={32}
|
||||
width={32}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemFileProps = ComponentProps<"span">;
|
||||
|
||||
export const QueueItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemFileProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded border bg-muted px-2 py-1 text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<PaperclipIcon size={12} />
|
||||
<span className="max-w-[100px] truncate">{children}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
export type QueueListProps = ComponentProps<typeof ScrollArea>;
|
||||
|
||||
export const QueueList = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueListProps) => (
|
||||
<ScrollArea className={cn("-mb-1 mt-2", className)} {...props}>
|
||||
<div className="max-h-40 pr-4">
|
||||
<ul>{children}</ul>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
// QueueSection - collapsible section container
|
||||
export type QueueSectionProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const QueueSection = ({
|
||||
className,
|
||||
defaultOpen = true,
|
||||
...props
|
||||
}: QueueSectionProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
);
|
||||
|
||||
// QueueSectionTrigger - section header/trigger
|
||||
export type QueueSectionTriggerProps = ComponentProps<"button">;
|
||||
|
||||
export const QueueSectionTrigger = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionTriggerProps) => (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"group flex w-full items-center justify-between rounded-md bg-muted/40 px-3 py-2 text-left font-medium text-muted-foreground text-sm transition-colors hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
// QueueSectionLabel - label content with icon and count
|
||||
export type QueueSectionLabelProps = ComponentProps<"span"> & {
|
||||
count?: number;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const QueueSectionLabel = ({
|
||||
count,
|
||||
label,
|
||||
icon,
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionLabelProps) => (
|
||||
<span className={cn("flex items-center gap-2", className)} {...props}>
|
||||
<ChevronDownIcon className="group-data-[state=closed]:-rotate-90 size-4 transition-transform" />
|
||||
{icon}
|
||||
<span>
|
||||
{count} {label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
// QueueSectionContent - collapsible content area
|
||||
export type QueueSectionContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>;
|
||||
|
||||
export const QueueSectionContent = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionContentProps) => (
|
||||
<CollapsibleContent className={cn(className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueProps = ComponentProps<"div">;
|
||||
|
||||
export const Queue = ({ className, ...props }: QueueProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 rounded-xl border border-border bg-background px-3 pt-2 pb-2 shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
178
frontend/src/components/ai-elements/reasoning.tsx
Normal file
178
frontend/src/components/ai-elements/reasoning.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Response } from "./response";
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
type ReasoningContextValue = {
|
||||
isStreaming: boolean;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000;
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = true,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
const [duration, setDuration] = useControllableState({
|
||||
prop: durationProp,
|
||||
defaultProp: 0,
|
||||
});
|
||||
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||
const [startTime, setStartTime] = useState<number | null>(null);
|
||||
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now());
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
|
||||
setStartTime(null);
|
||||
}
|
||||
}, [isStreaming, startTime, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
// Add a small delay before closing to allow user to see the content
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosed(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider
|
||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
||||
>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
const getThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <p>Thought for a few seconds</p>;
|
||||
}
|
||||
return <p>Thought for {duration} seconds</p>;
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({ className, children, ...props }: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-4 text-sm",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Response className="grid gap-2">{children}</Response>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
);
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||
ReasoningContent.displayName = "ReasoningContent";
|
||||
22
frontend/src/components/ai-elements/response.tsx
Normal file
22
frontend/src/components/ai-elements/response.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type ComponentProps, memo } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
type ResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
export const Response = memo(
|
||||
({ className, ...props }: ResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
||||
);
|
||||
|
||||
Response.displayName = "Response";
|
||||
64
frontend/src/components/ai-elements/shimmer.tsx
Normal file
64
frontend/src/components/ai-elements/shimmer.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ElementType,
|
||||
type JSX,
|
||||
memo,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
export type TextShimmerProps = {
|
||||
children: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
spread?: number;
|
||||
};
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = motion.create(
|
||||
Component as keyof JSX.IntrinsicElements
|
||||
);
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread]
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||
className
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent);
|
||||
77
frontend/src/components/ai-elements/sources.tsx
Normal file
77
frontend/src/components/ai-elements/sources.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BookIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type SourcesProps = ComponentProps<"div">;
|
||||
|
||||
export const Sources = ({ className, ...props }: SourcesProps) => (
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4 text-primary text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const SourcesTrigger = ({
|
||||
className,
|
||||
count,
|
||||
children,
|
||||
...props
|
||||
}: SourcesTriggerProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<p className="font-medium">Used {count} sources</p>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const SourcesContent = ({
|
||||
className,
|
||||
...props
|
||||
}: SourcesContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-3 flex w-fit flex-col gap-2",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourceProps = ComponentProps<"a">;
|
||||
|
||||
export const Source = ({ href, title, children, ...props }: SourceProps) => (
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BookIcon className="h-4 w-4" />
|
||||
<span className="block font-medium">{title}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
56
frontend/src/components/ai-elements/suggestion.tsx
Normal file
56
frontend/src/components/ai-elements/suggestion.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ScrollArea,
|
||||
ScrollBar,
|
||||
} from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
|
||||
|
||||
export const Suggestions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SuggestionsProps) => (
|
||||
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
|
||||
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
|
||||
{children}
|
||||
</div>
|
||||
<ScrollBar className="hidden" orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
|
||||
suggestion: string;
|
||||
onClick?: (suggestion: string) => void;
|
||||
};
|
||||
|
||||
export const Suggestion = ({
|
||||
suggestion,
|
||||
onClick,
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
children,
|
||||
...props
|
||||
}: SuggestionProps) => {
|
||||
const handleClick = () => {
|
||||
onClick?.(suggestion);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("cursor-pointer rounded-full px-4", className)}
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children || suggestion}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
87
frontend/src/components/ai-elements/task.tsx
Normal file
87
frontend/src/components/ai-elements/task.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, SearchIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type TaskItemFileProps = ComponentProps<"div">;
|
||||
|
||||
export const TaskItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskItemFileProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TaskItemProps = ComponentProps<"div">;
|
||||
|
||||
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
|
||||
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TaskProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Task = ({
|
||||
defaultOpen = true,
|
||||
className,
|
||||
...props
|
||||
}: TaskProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
);
|
||||
|
||||
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const TaskTrigger = ({
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: TaskTriggerProps) => (
|
||||
<CollapsibleTrigger asChild className={cn("group", className)} {...props}>
|
||||
{children ?? (
|
||||
<div className="flex w-full cursor-pointer items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
|
||||
<SearchIcon className="size-4" />
|
||||
<p className="text-sm">{title}</p>
|
||||
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const TaskContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mt-4 space-y-2 border-muted border-l-2 pl-4">
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
163
frontend/src/components/ai-elements/tool.tsx
Normal file
163
frontend/src/components/ai-elements/tool.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { isValidElement } from "react";
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4 w-full rounded-md border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
title?: string;
|
||||
type: ToolUIPart["type"];
|
||||
state: ToolUIPart["state"];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: ToolUIPart["state"]) => {
|
||||
const labels: Record<ToolUIPart["state"], string> = {
|
||||
"input-streaming": "Pending",
|
||||
"input-available": "Running",
|
||||
"approval-requested": "Awaiting Approval",
|
||||
"approval-responded": "Responded",
|
||||
"output-available": "Completed",
|
||||
"output-error": "Error",
|
||||
"output-denied": "Denied",
|
||||
};
|
||||
|
||||
const icons: Record<ToolUIPart["state"], ReactNode> = {
|
||||
"input-streaming": <CircleIcon className="size-4" />,
|
||||
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
||||
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
||||
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
||||
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
||||
{icons[status]}
|
||||
{labels[status]}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolHeader = ({
|
||||
className,
|
||||
title,
|
||||
type,
|
||||
state,
|
||||
...props
|
||||
}: ToolHeaderProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<WrenchIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">
|
||||
{title ?? type.split("-").slice(1).join("-")}
|
||||
</span>
|
||||
{getStatusBadge(state)}
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolInputProps = ComponentProps<"div"> & {
|
||||
input: ToolUIPart["input"];
|
||||
};
|
||||
|
||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
||||
<div className={cn("space-y-2 overflow-hidden p-4", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="rounded-md bg-muted/50">
|
||||
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ToolOutputProps = ComponentProps<"div"> & {
|
||||
output: ToolUIPart["output"];
|
||||
errorText: ToolUIPart["errorText"];
|
||||
};
|
||||
|
||||
export const ToolOutput = ({
|
||||
className,
|
||||
output,
|
||||
errorText,
|
||||
...props
|
||||
}: ToolOutputProps) => {
|
||||
if (!(output || errorText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let Output = <div>{output as ReactNode}</div>;
|
||||
|
||||
if (typeof output === "object" && !isValidElement(output)) {
|
||||
Output = (
|
||||
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
|
||||
);
|
||||
} else if (typeof output === "string") {
|
||||
Output = <CodeBlock code={output} language="json" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2 p-4", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{errorText ? "Error" : "Result"}
|
||||
</h4>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
|
||||
errorText
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted/50 text-foreground"
|
||||
)}
|
||||
>
|
||||
{errorText && <div>{errorText}</div>}
|
||||
{Output}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
16
frontend/src/components/ai-elements/toolbar.tsx
Normal file
16
frontend/src/components/ai-elements/toolbar.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NodeToolbar, Position } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type ToolbarProps = ComponentProps<typeof NodeToolbar>;
|
||||
|
||||
export const Toolbar = ({ className, ...props }: ToolbarProps) => (
|
||||
<NodeToolbar
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-sm border bg-background p-1.5",
|
||||
className
|
||||
)}
|
||||
position={Position.Bottom}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
263
frontend/src/components/ai-elements/web-preview.tsx
Normal file
263
frontend/src/components/ai-elements/web-preview.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
export type WebPreviewContextValue = {
|
||||
url: string;
|
||||
setUrl: (url: string) => void;
|
||||
consoleOpen: boolean;
|
||||
setConsoleOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
|
||||
|
||||
const useWebPreview = () => {
|
||||
const context = useContext(WebPreviewContext);
|
||||
if (!context) {
|
||||
throw new Error("WebPreview components must be used within a WebPreview");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type WebPreviewProps = ComponentProps<"div"> & {
|
||||
defaultUrl?: string;
|
||||
onUrlChange?: (url: string) => void;
|
||||
};
|
||||
|
||||
export const WebPreview = ({
|
||||
className,
|
||||
children,
|
||||
defaultUrl = "",
|
||||
onUrlChange,
|
||||
...props
|
||||
}: WebPreviewProps) => {
|
||||
const [url, setUrl] = useState(defaultUrl);
|
||||
const [consoleOpen, setConsoleOpen] = useState(false);
|
||||
|
||||
const handleUrlChange = (newUrl: string) => {
|
||||
setUrl(newUrl);
|
||||
onUrlChange?.(newUrl);
|
||||
};
|
||||
|
||||
const contextValue: WebPreviewContextValue = {
|
||||
url,
|
||||
setUrl: handleUrlChange,
|
||||
consoleOpen,
|
||||
setConsoleOpen,
|
||||
};
|
||||
|
||||
return (
|
||||
<WebPreviewContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col rounded-lg border bg-card",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</WebPreviewContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewNavigationProps = ComponentProps<"div">;
|
||||
|
||||
export const WebPreviewNavigation = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationProps) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-1 border-b p-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
export const WebPreviewNavigationButton = ({
|
||||
onClick,
|
||||
disabled,
|
||||
tooltip,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationButtonProps) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="h-8 w-8 p-0 hover:text-foreground"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
export type WebPreviewUrlProps = ComponentProps<typeof Input>;
|
||||
|
||||
export const WebPreviewUrl = ({
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: WebPreviewUrlProps) => {
|
||||
const { url, setUrl } = useWebPreview();
|
||||
const [inputValue, setInputValue] = useState(url);
|
||||
|
||||
// Sync input value with context URL when it changes externally
|
||||
useEffect(() => {
|
||||
setInputValue(url);
|
||||
}, [url]);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
onChange?.(event);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
const target = event.target as HTMLInputElement;
|
||||
setUrl(target.value);
|
||||
}
|
||||
onKeyDown?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="h-8 flex-1 text-sm"
|
||||
onChange={onChange ?? handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter URL..."
|
||||
value={value ?? inputValue}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewBodyProps = ComponentProps<"iframe"> & {
|
||||
loading?: ReactNode;
|
||||
};
|
||||
|
||||
export const WebPreviewBody = ({
|
||||
className,
|
||||
loading,
|
||||
src,
|
||||
...props
|
||||
}: WebPreviewBodyProps) => {
|
||||
const { url } = useWebPreview();
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<iframe
|
||||
className={cn("size-full", className)}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
|
||||
src={(src ?? url) || undefined}
|
||||
title="Preview"
|
||||
{...props}
|
||||
/>
|
||||
{loading}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewConsoleProps = ComponentProps<"div"> & {
|
||||
logs?: Array<{
|
||||
level: "log" | "warn" | "error";
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const WebPreviewConsole = ({
|
||||
className,
|
||||
logs = [],
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewConsoleProps) => {
|
||||
const { consoleOpen, setConsoleOpen } = useWebPreview();
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
className={cn("border-t bg-muted/50 font-mono text-sm", className)}
|
||||
onOpenChange={setConsoleOpen}
|
||||
open={consoleOpen}
|
||||
{...props}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
className="flex w-full items-center justify-between p-4 text-left font-medium hover:bg-muted/50"
|
||||
variant="ghost"
|
||||
>
|
||||
Console
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
consoleOpen && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"px-4 pb-4",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in"
|
||||
)}
|
||||
>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground">No console output</p>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs",
|
||||
log.level === "error" && "text-destructive",
|
||||
log.level === "warn" && "text-yellow-600",
|
||||
log.level === "log" && "text-foreground"
|
||||
)}
|
||||
key={`${log.timestamp.getTime()}-${index}`}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{log.timestamp.toLocaleTimeString()}
|
||||
</span>{" "}
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
66
frontend/src/components/ui/alert.tsx
Normal file
66
frontend/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
46
frontend/src/components/ui/badge.tsx
Normal file
46
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
83
frontend/src/components/ui/button-group.tsx
Normal file
83
frontend/src/components/ui/button-group.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
92
frontend/src/components/ui/card.tsx
Normal file
92
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
241
frontend/src/components/ui/carousel.tsx
Normal file
241
frontend/src/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
33
frontend/src/components/ui/collapsible.tsx
Normal file
33
frontend/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
182
frontend/src/components/ui/command.tsx
Normal file
182
frontend/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
255
frontend/src/components/ui/dropdown-menu.tsx
Normal file
255
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
42
frontend/src/components/ui/hover-card.tsx
Normal file
42
frontend/src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
170
frontend/src/components/ui/input-group.tsx
Normal file
170
frontend/src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
||||
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
||||
|
||||
// Error state.
|
||||
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"text-sm shadow-none flex gap-2 items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
31
frontend/src/components/ui/progress.tsx
Normal file
31
frontend/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
56
frontend/src/components/ui/scroll-area.tsx
Normal file
56
frontend/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
26
frontend/src/components/ui/separator.tsx
Normal file
26
frontend/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
30
frontend/src/components/ui/tooltip.tsx
Normal file
30
frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -1,191 +1,267 @@
|
||||
const API_BASE_URL = 'http://localhost:8000/api/v1'
|
||||
const API_BASE_URL = "/api/v1";
|
||||
|
||||
interface FileUploadResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
success: boolean;
|
||||
message: string;
|
||||
file?: {
|
||||
name: string
|
||||
full_path: string
|
||||
tema: string
|
||||
size: number
|
||||
last_modified: string
|
||||
url?: string
|
||||
}
|
||||
name: string;
|
||||
full_path: string;
|
||||
tema: string;
|
||||
size: number;
|
||||
last_modified: string;
|
||||
url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface FileListResponse {
|
||||
files: Array<{
|
||||
name: string
|
||||
full_path: string
|
||||
tema: string
|
||||
size: number
|
||||
last_modified: string
|
||||
content_type?: string
|
||||
}>
|
||||
total: number
|
||||
tema?: string
|
||||
name: string;
|
||||
full_path: string;
|
||||
tema: string;
|
||||
size: number;
|
||||
last_modified: string;
|
||||
content_type?: string;
|
||||
}>;
|
||||
total: number;
|
||||
tema?: string;
|
||||
}
|
||||
|
||||
interface TemasResponse {
|
||||
temas: string[]
|
||||
total: number
|
||||
temas: string[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface DataroomsResponse {
|
||||
datarooms: Array<{
|
||||
name: string;
|
||||
collection: string;
|
||||
storage: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface CreateDataroomRequest {
|
||||
name: string;
|
||||
collection?: string;
|
||||
storage?: string;
|
||||
}
|
||||
|
||||
// API calls
|
||||
export const api = {
|
||||
// Obtener todos los temas
|
||||
// Obtener todos los temas (legacy)
|
||||
getTemas: async (): Promise<TemasResponse> => {
|
||||
const response = await fetch(`${API_BASE_URL}/files/temas`)
|
||||
if (!response.ok) throw new Error('Error fetching temas')
|
||||
return response.json()
|
||||
const response = await fetch(`${API_BASE_URL}/files/temas`);
|
||||
if (!response.ok) throw new Error("Error fetching temas");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Obtener todos los datarooms
|
||||
getDatarooms: async (): Promise<DataroomsResponse> => {
|
||||
const response = await fetch(`${API_BASE_URL}/dataroom/`);
|
||||
if (!response.ok) throw new Error("Error fetching datarooms");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Crear un nuevo dataroom
|
||||
createDataroom: async (
|
||||
data: CreateDataroomRequest,
|
||||
): Promise<{
|
||||
message: string;
|
||||
dataroom: {
|
||||
name: string;
|
||||
collection: string;
|
||||
storage: string;
|
||||
};
|
||||
}> => {
|
||||
const response = await fetch(`${API_BASE_URL}/dataroom/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) throw new Error("Error creating dataroom");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Eliminar un dataroom
|
||||
deleteDataroom: async (
|
||||
dataroomName: string,
|
||||
): Promise<{
|
||||
message: string;
|
||||
dataroom_name: string;
|
||||
}> => {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/dataroom/${encodeURIComponent(dataroomName)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error("Error deleting dataroom");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Obtener archivos (todos o por tema)
|
||||
getFiles: async (tema?: string): Promise<FileListResponse> => {
|
||||
const url = tema
|
||||
const url = tema
|
||||
? `${API_BASE_URL}/files/?tema=${encodeURIComponent(tema)}`
|
||||
: `${API_BASE_URL}/files/`
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error('Error fetching files')
|
||||
return response.json()
|
||||
: `${API_BASE_URL}/files/`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error("Error fetching files");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Subir archivo
|
||||
uploadFile: async (file: File, tema?: string): Promise<FileUploadResponse> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (tema) formData.append('tema', tema)
|
||||
uploadFile: async (
|
||||
file: File,
|
||||
tema?: string,
|
||||
): Promise<FileUploadResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (tema) formData.append("tema", tema);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/files/upload`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Error uploading file')
|
||||
return response.json()
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Error uploading file");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Eliminar archivo
|
||||
deleteFile: async (filename: string, tema?: string): Promise<void> => {
|
||||
const url = tema
|
||||
const url = tema
|
||||
? `${API_BASE_URL}/files/${encodeURIComponent(filename)}?tema=${encodeURIComponent(tema)}`
|
||||
: `${API_BASE_URL}/files/${encodeURIComponent(filename)}`
|
||||
|
||||
const response = await fetch(url, { method: 'DELETE' })
|
||||
if (!response.ok) throw new Error('Error deleting file')
|
||||
: `${API_BASE_URL}/files/${encodeURIComponent(filename)}`;
|
||||
|
||||
const response = await fetch(url, { method: "DELETE" });
|
||||
if (!response.ok) throw new Error("Error deleting file");
|
||||
},
|
||||
|
||||
// Eliminar múltiples archivos
|
||||
deleteFiles: async (filenames: string[], tema?: string): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE_URL}/files/delete-batch`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
files: filenames,
|
||||
tema: tema
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Error deleting files')
|
||||
},
|
||||
|
||||
// Eliminar tema completo
|
||||
deleteTema: async (tema: string): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE_URL}/files/tema/${encodeURIComponent(tema)}/delete-all`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Error deleting tema')
|
||||
},
|
||||
|
||||
// Descargar archivo individual
|
||||
downloadFile: async (filename: string, tema?: string): Promise<void> => {
|
||||
const url = tema
|
||||
? `${API_BASE_URL}/files/${encodeURIComponent(filename)}/download?tema=${encodeURIComponent(tema)}`
|
||||
: `${API_BASE_URL}/files/${encodeURIComponent(filename)}/download`
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error('Error downloading file')
|
||||
|
||||
// Crear blob y descargar
|
||||
const blob = await response.blob()
|
||||
const downloadUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadUrl
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(downloadUrl)
|
||||
},
|
||||
|
||||
// Descargar múltiples archivos como ZIP
|
||||
downloadMultipleFiles: async (filenames: string[], tema?: string, zipName?: string): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE_URL}/files/download-batch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
files: filenames,
|
||||
tema: tema,
|
||||
zip_name: zipName || 'archivos'
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Error downloading files')
|
||||
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Error deleting files");
|
||||
},
|
||||
|
||||
// Eliminar tema completo
|
||||
deleteTema: async (tema: string): Promise<void> => {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/files/tema/${encodeURIComponent(tema)}/delete-all`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error("Error deleting tema");
|
||||
},
|
||||
|
||||
// Descargar archivo individual
|
||||
downloadFile: async (filename: string, tema?: string): Promise<void> => {
|
||||
const url = tema
|
||||
? `${API_BASE_URL}/files/${encodeURIComponent(filename)}/download?tema=${encodeURIComponent(tema)}`
|
||||
: `${API_BASE_URL}/files/${encodeURIComponent(filename)}/download`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error("Error downloading file");
|
||||
|
||||
// Crear blob y descargar
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
},
|
||||
|
||||
// Descargar múltiples archivos como ZIP
|
||||
downloadMultipleFiles: async (
|
||||
filenames: string[],
|
||||
tema?: string,
|
||||
zipName?: string,
|
||||
): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE_URL}/files/download-batch`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
files: filenames,
|
||||
tema: tema,
|
||||
zip_name: zipName || "archivos",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Error downloading files");
|
||||
|
||||
// Crear blob y descargar ZIP
|
||||
const blob = await response.blob()
|
||||
const downloadUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadUrl
|
||||
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
|
||||
// Obtener nombre del archivo del header Content-Disposition
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
const filename = contentDisposition?.split('filename=')[1]?.replace(/"/g, '') || 'archivos.zip'
|
||||
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(downloadUrl)
|
||||
const contentDisposition = response.headers.get("Content-Disposition");
|
||||
const filename =
|
||||
contentDisposition?.split("filename=")[1]?.replace(/"/g, "") ||
|
||||
"archivos.zip";
|
||||
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
},
|
||||
|
||||
// Descargar tema completo
|
||||
downloadTema: async (tema: string): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE_URL}/files/tema/${encodeURIComponent(tema)}/download-all`)
|
||||
if (!response.ok) throw new Error('Error downloading tema')
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/files/tema/${encodeURIComponent(tema)}/download-all`,
|
||||
);
|
||||
if (!response.ok) throw new Error("Error downloading tema");
|
||||
|
||||
const blob = await response.blob()
|
||||
const downloadUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadUrl
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
const filename = contentDisposition?.split('filename=')[1]?.replace(/"/g, '') || `${tema}.zip`
|
||||
const contentDisposition = response.headers.get("Content-Disposition");
|
||||
const filename =
|
||||
contentDisposition?.split("filename=")[1]?.replace(/"/g, "") ||
|
||||
`${tema}.zip`;
|
||||
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(downloadUrl)
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
},
|
||||
|
||||
// Obtener URL temporal para preview de archivos
|
||||
getPreviewUrl: async (filename: string, tema?: string): Promise<string> => {
|
||||
const url = tema
|
||||
? `${API_BASE_URL}/files/${encodeURIComponent(filename)}/preview-url?tema=${encodeURIComponent(tema)}`
|
||||
: `${API_BASE_URL}/files/${encodeURIComponent(filename)}/preview-url`
|
||||
: `${API_BASE_URL}/files/${encodeURIComponent(filename)}/preview-url`;
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error('Error getting preview URL')
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error("Error getting preview URL");
|
||||
|
||||
const data = await response.json()
|
||||
return data.url
|
||||
const data = await response.json();
|
||||
return data.url;
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
@@ -193,139 +269,178 @@ export const api = {
|
||||
// ============================================================================
|
||||
|
||||
// Health check de la base de datos vectorial
|
||||
vectorHealthCheck: async (): Promise<{ status: string; db_type: string; message: string }> => {
|
||||
const response = await fetch(`${API_BASE_URL}/vectors/health`)
|
||||
if (!response.ok) throw new Error('Error checking vector DB health')
|
||||
return response.json()
|
||||
vectorHealthCheck: async (): Promise<{
|
||||
status: string;
|
||||
db_type: string;
|
||||
message: string;
|
||||
}> => {
|
||||
const response = await fetch(`${API_BASE_URL}/vectors/health`);
|
||||
if (!response.ok) throw new Error("Error checking vector DB health");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Verificar si una colección existe
|
||||
checkCollectionExists: async (collectionName: string): Promise<{ exists: boolean; collection_name: string }> => {
|
||||
checkCollectionExists: async (
|
||||
collectionName: string,
|
||||
): Promise<{ exists: boolean; collection_name: string }> => {
|
||||
const response = await fetch(`${API_BASE_URL}/vectors/collections/exists`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ collection_name: collectionName }),
|
||||
})
|
||||
if (!response.ok) throw new Error('Error checking collection')
|
||||
return response.json()
|
||||
});
|
||||
if (!response.ok) throw new Error("Error checking collection");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Crear una nueva colección
|
||||
createCollection: async (
|
||||
collectionName: string,
|
||||
vectorSize: number = 3072,
|
||||
distance: string = 'Cosine'
|
||||
): Promise<{ success: boolean; collection_name: string; message: string }> => {
|
||||
distance: string = "Cosine",
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
collection_name: string;
|
||||
message: string;
|
||||
}> => {
|
||||
const response = await fetch(`${API_BASE_URL}/vectors/collections/create`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
collection_name: collectionName,
|
||||
vector_size: vectorSize,
|
||||
distance: distance,
|
||||
}),
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || 'Error creating collection')
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || "Error creating collection");
|
||||
}
|
||||
return response.json()
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Eliminar una colección
|
||||
deleteCollection: async (collectionName: string): Promise<{ success: boolean; collection_name: string; message: string }> => {
|
||||
const response = await fetch(`${API_BASE_URL}/vectors/collections/${encodeURIComponent(collectionName)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!response.ok) throw new Error('Error deleting collection')
|
||||
return response.json()
|
||||
deleteCollection: async (
|
||||
collectionName: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
collection_name: string;
|
||||
message: string;
|
||||
}> => {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/vectors/collections/${encodeURIComponent(collectionName)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error("Error deleting collection");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Obtener información de una colección
|
||||
getCollectionInfo: async (collectionName: string): Promise<{
|
||||
name: string
|
||||
vectors_count: number
|
||||
vectors_config: { size: number; distance: string }
|
||||
status: string
|
||||
getCollectionInfo: async (
|
||||
collectionName: string,
|
||||
): Promise<{
|
||||
name: string;
|
||||
vectors_count: number;
|
||||
vectors_config: { size: number; distance: string };
|
||||
status: string;
|
||||
}> => {
|
||||
const response = await fetch(`${API_BASE_URL}/vectors/collections/${encodeURIComponent(collectionName)}/info`)
|
||||
if (!response.ok) throw new Error('Error getting collection info')
|
||||
return response.json()
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/vectors/collections/${encodeURIComponent(collectionName)}/info`,
|
||||
);
|
||||
if (!response.ok) throw new Error("Error getting collection info");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Verificar si un archivo existe en una colección
|
||||
checkFileExistsInCollection: async (
|
||||
collectionName: string,
|
||||
fileName: string
|
||||
): Promise<{ exists: boolean; collection_name: string; file_name: string; chunk_count?: number }> => {
|
||||
fileName: string,
|
||||
): Promise<{
|
||||
exists: boolean;
|
||||
collection_name: string;
|
||||
file_name: string;
|
||||
chunk_count?: number;
|
||||
}> => {
|
||||
const response = await fetch(`${API_BASE_URL}/vectors/files/exists`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
collection_name: collectionName,
|
||||
file_name: fileName,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error('Error checking file in collection')
|
||||
return response.json()
|
||||
});
|
||||
if (!response.ok) throw new Error("Error checking file in collection");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Obtener chunks de un archivo
|
||||
getChunksByFile: async (
|
||||
collectionName: string,
|
||||
fileName: string,
|
||||
limit?: number
|
||||
limit?: number,
|
||||
): Promise<{
|
||||
collection_name: string
|
||||
file_name: string
|
||||
chunks: Array<{ id: string; payload: any; vector?: number[] }>
|
||||
total_chunks: number
|
||||
collection_name: string;
|
||||
file_name: string;
|
||||
chunks: Array<{ id: string; payload: any; vector?: number[] }>;
|
||||
total_chunks: number;
|
||||
}> => {
|
||||
const url = limit
|
||||
? `${API_BASE_URL}/vectors/collections/${encodeURIComponent(collectionName)}/files/${encodeURIComponent(fileName)}/chunks?limit=${limit}`
|
||||
: `${API_BASE_URL}/vectors/collections/${encodeURIComponent(collectionName)}/files/${encodeURIComponent(fileName)}/chunks`
|
||||
: `${API_BASE_URL}/vectors/collections/${encodeURIComponent(collectionName)}/files/${encodeURIComponent(fileName)}/chunks`;
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error('Error getting chunks')
|
||||
return response.json()
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error("Error getting chunks");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Eliminar archivo de colección
|
||||
deleteFileFromCollection: async (
|
||||
collectionName: string,
|
||||
fileName: string
|
||||
): Promise<{ success: boolean; collection_name: string; file_name: string; chunks_deleted: number; message: string }> => {
|
||||
fileName: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
collection_name: string;
|
||||
file_name: string;
|
||||
chunks_deleted: number;
|
||||
message: string;
|
||||
}> => {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/vectors/collections/${encodeURIComponent(collectionName)}/files/${encodeURIComponent(fileName)}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
if (!response.ok) throw new Error('Error deleting file from collection')
|
||||
return response.json()
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) throw new Error("Error deleting file from collection");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Agregar chunks a una colección
|
||||
addChunks: async (
|
||||
collectionName: string,
|
||||
chunks: Array<{ id: string; vector: number[]; payload: any }>
|
||||
): Promise<{ success: boolean; collection_name: string; chunks_added: number; message: string }> => {
|
||||
chunks: Array<{ id: string; vector: number[]; payload: any }>,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
collection_name: string;
|
||||
chunks_added: number;
|
||||
message: string;
|
||||
}> => {
|
||||
const response = await fetch(`${API_BASE_URL}/vectors/chunks/add`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
collection_name: collectionName,
|
||||
chunks: chunks,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error('Error adding chunks')
|
||||
return response.json()
|
||||
});
|
||||
if (!response.ok) throw new Error("Error adding chunks");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
@@ -335,89 +450,89 @@ export const api = {
|
||||
// Obtener perfiles de chunking predefinidos
|
||||
getChunkingProfiles: async (): Promise<{
|
||||
profiles: Array<{
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
max_tokens: number
|
||||
target_tokens: number
|
||||
chunk_size: number
|
||||
chunk_overlap: number
|
||||
use_llm: boolean
|
||||
}>
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
max_tokens: number;
|
||||
target_tokens: number;
|
||||
chunk_size: number;
|
||||
chunk_overlap: number;
|
||||
use_llm: boolean;
|
||||
}>;
|
||||
}> => {
|
||||
const response = await fetch(`${API_BASE_URL}/chunking/profiles`)
|
||||
if (!response.ok) throw new Error('Error fetching chunking profiles')
|
||||
return response.json()
|
||||
const response = await fetch(`${API_BASE_URL}/chunking/profiles`);
|
||||
if (!response.ok) throw new Error("Error fetching chunking profiles");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Generar preview de chunks (hasta 3 chunks)
|
||||
generateChunkPreview: async (config: {
|
||||
file_name: string
|
||||
tema: string
|
||||
max_tokens?: number
|
||||
target_tokens?: number
|
||||
chunk_size?: number
|
||||
chunk_overlap?: number
|
||||
use_llm?: boolean
|
||||
custom_instructions?: string
|
||||
file_name: string;
|
||||
tema: string;
|
||||
max_tokens?: number;
|
||||
target_tokens?: number;
|
||||
chunk_size?: number;
|
||||
chunk_overlap?: number;
|
||||
use_llm?: boolean;
|
||||
custom_instructions?: string;
|
||||
}): Promise<{
|
||||
success: boolean
|
||||
file_name: string
|
||||
tema: string
|
||||
success: boolean;
|
||||
file_name: string;
|
||||
tema: string;
|
||||
chunks: Array<{
|
||||
index: number
|
||||
text: string
|
||||
page: number
|
||||
file_name: string
|
||||
tokens: number
|
||||
}>
|
||||
message: string
|
||||
index: number;
|
||||
text: string;
|
||||
page: number;
|
||||
file_name: string;
|
||||
tokens: number;
|
||||
}>;
|
||||
message: string;
|
||||
}> => {
|
||||
const response = await fetch(`${API_BASE_URL}/chunking/preview`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || 'Error generating preview')
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || "Error generating preview");
|
||||
}
|
||||
return response.json()
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Procesar PDF completo
|
||||
processChunkingFull: async (config: {
|
||||
file_name: string
|
||||
tema: string
|
||||
collection_name: string
|
||||
max_tokens?: number
|
||||
target_tokens?: number
|
||||
chunk_size?: number
|
||||
chunk_overlap?: number
|
||||
use_llm?: boolean
|
||||
custom_instructions?: string
|
||||
file_name: string;
|
||||
tema: string;
|
||||
collection_name: string;
|
||||
max_tokens?: number;
|
||||
target_tokens?: number;
|
||||
chunk_size?: number;
|
||||
chunk_overlap?: number;
|
||||
use_llm?: boolean;
|
||||
custom_instructions?: string;
|
||||
}): Promise<{
|
||||
success: boolean
|
||||
collection_name: string
|
||||
file_name: string
|
||||
total_chunks: number
|
||||
chunks_added: number
|
||||
message: string
|
||||
success: boolean;
|
||||
collection_name: string;
|
||||
file_name: string;
|
||||
total_chunks: number;
|
||||
chunks_added: number;
|
||||
message: string;
|
||||
}> => {
|
||||
const response = await fetch(`${API_BASE_URL}/chunking/process`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || 'Error processing PDF')
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || "Error processing PDF");
|
||||
}
|
||||
return response.json()
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
@@ -427,62 +542,62 @@ export const api = {
|
||||
// Crear schema
|
||||
createSchema: async (schema: any): Promise<any> => {
|
||||
const response = await fetch(`${API_BASE_URL}/schemas/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(schema)
|
||||
})
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(schema),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail?.message || 'Error creando schema')
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail?.message || "Error creando schema");
|
||||
}
|
||||
return response.json()
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Listar schemas
|
||||
listSchemas: async (tema?: string): Promise<any[]> => {
|
||||
const url = tema
|
||||
? `${API_BASE_URL}/schemas/?tema=${encodeURIComponent(tema)}`
|
||||
: `${API_BASE_URL}/schemas/`
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error('Error listando schemas')
|
||||
return response.json()
|
||||
: `${API_BASE_URL}/schemas/`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error("Error listando schemas");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Obtener schema por ID
|
||||
getSchema: async (schema_id: string): Promise<any> => {
|
||||
const response = await fetch(`${API_BASE_URL}/schemas/${schema_id}`)
|
||||
if (!response.ok) throw new Error('Error obteniendo schema')
|
||||
return response.json()
|
||||
const response = await fetch(`${API_BASE_URL}/schemas/${schema_id}`);
|
||||
if (!response.ok) throw new Error("Error obteniendo schema");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Actualizar schema
|
||||
updateSchema: async (schema_id: string, schema: any): Promise<any> => {
|
||||
const response = await fetch(`${API_BASE_URL}/schemas/${schema_id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(schema)
|
||||
})
|
||||
if (!response.ok) throw new Error('Error actualizando schema')
|
||||
return response.json()
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(schema),
|
||||
});
|
||||
if (!response.ok) throw new Error("Error actualizando schema");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Eliminar schema
|
||||
deleteSchema: async (schema_id: string): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE_URL}/schemas/${schema_id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
if (!response.ok) throw new Error('Error eliminando schema')
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) throw new Error("Error eliminando schema");
|
||||
},
|
||||
|
||||
// Validar schema
|
||||
validateSchema: async (schema: any): Promise<any> => {
|
||||
const response = await fetch(`${API_BASE_URL}/schemas/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(schema)
|
||||
})
|
||||
if (!response.ok) throw new Error('Error validando schema')
|
||||
return response.json()
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(schema),
|
||||
});
|
||||
if (!response.ok) throw new Error("Error validando schema");
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
@@ -491,25 +606,24 @@ export const api = {
|
||||
|
||||
// Procesar con LandingAI
|
||||
processWithLandingAI: async (config: {
|
||||
file_name: string
|
||||
tema: string
|
||||
collection_name: string
|
||||
mode: 'quick' | 'extract'
|
||||
schema_id?: string
|
||||
include_chunk_types?: string[]
|
||||
max_tokens_per_chunk?: number
|
||||
merge_small_chunks?: boolean
|
||||
file_name: string;
|
||||
tema: string;
|
||||
collection_name: string;
|
||||
mode: "quick" | "extract";
|
||||
schema_id?: string;
|
||||
include_chunk_types?: string[];
|
||||
max_tokens_per_chunk?: number;
|
||||
merge_small_chunks?: boolean;
|
||||
}): Promise<any> => {
|
||||
const response = await fetch(`${API_BASE_URL}/chunking-landingai/process`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
})
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || 'Error procesando con LandingAI')
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || "Error procesando con LandingAI");
|
||||
}
|
||||
return response.json()
|
||||
return response.json();
|
||||
},
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://backend:8000",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user