Phase 1 Complete: Dual-bot architecture, knowledge base, access control
- Remove notebook mode, add RAG + Personal Assistant dual-bot setup - Add knowledge base management (upload, URL scraping, document processing) - Add user feature access control (allowed_features, features_override) - Update admin dashboard with knowledge base tab - Redesign login page, sidebar, and profile - Add Celery tasks for async document processing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9b7377352e
commit
44a512c41f
37 changed files with 1829 additions and 3623 deletions
|
|
@ -59,10 +59,9 @@ JWT_EXPIRATION_MINUTES=15
|
|||
REFRESH_TOKEN_EXPIRATION_DAYS=7
|
||||
|
||||
# ==========================================
|
||||
# NotebookLlama Integration
|
||||
# LlamaParse (Optional — cloud PDF parsing)
|
||||
# ==========================================
|
||||
# URL to your internal NotebookLlama instance
|
||||
NOTEBOOKLLAMA_URL=http://internal-notebook-server:8080
|
||||
LLAMAPARSE_API_KEY=
|
||||
|
||||
# ==========================================
|
||||
# File Upload Configuration
|
||||
|
|
|
|||
|
|
@ -52,16 +52,9 @@ GOOGLE_API_KEY=AIza...
|
|||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# =============================================================================
|
||||
# NOTEBOOKLLAMA INTEGRATION
|
||||
# LLAMAPARSE (Optional — cloud PDF parsing for better table extraction)
|
||||
# =============================================================================
|
||||
# URL of the NotebookLlama service (external API for isolated document analysis)
|
||||
NOTEBOOKLLAMA_URL=http://localhost:8000
|
||||
|
||||
# Service account credentials for authenticating with NotebookLlama API
|
||||
# These credentials are used by our backend to authenticate as a service account
|
||||
# Create this account in NotebookLlama: POST /api/auth/signup
|
||||
NOTEBOOKLLAMA_SERVICE_EMAIL=service@oliver.internal
|
||||
NOTEBOOKLLAMA_SERVICE_PASSWORD=your_notebookllama_service_password_here
|
||||
LLAMAPARSE_API_KEY=
|
||||
|
||||
# =============================================================================
|
||||
# FILE UPLOAD CONFIGURATION
|
||||
|
|
@ -72,6 +65,16 @@ MAX_UPLOAD_SIZE_MB=100
|
|||
# Directory for uploaded files (local filesystem)
|
||||
UPLOAD_DIR=/app/uploads
|
||||
|
||||
# =============================================================================
|
||||
# MICROSOFT GRAPH TOOL CALLING (Phase 2)
|
||||
# =============================================================================
|
||||
# Encryption key for storing Graph tokens (falls back to JWT_SECRET if not set)
|
||||
GRAPH_ENCRYPTION_KEY=
|
||||
# Delegated scopes requested during Graph consent
|
||||
GRAPH_DELEGATED_SCOPES=User.Read Calendars.ReadWrite Mail.ReadWrite Chat.ReadWrite Tasks.ReadWrite offline_access
|
||||
# Redirect URI for Graph consent flow
|
||||
GRAPH_CONSENT_REDIRECT_URI=http://localhost:3000/auth/graph-callback
|
||||
|
||||
# =============================================================================
|
||||
# CORS CONFIGURATION
|
||||
# =============================================================================
|
||||
|
|
|
|||
57
backend/alembic/versions/006_add_knowledge_documents.py
Normal file
57
backend/alembic/versions/006_add_knowledge_documents.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""Add knowledge_documents table for admin-uploaded RAG documents
|
||||
|
||||
Revision ID: 006_knowledge_docs
|
||||
Revises: 005_sharepoint
|
||||
Create Date: 2026-03-04
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '006_knowledge_docs'
|
||||
down_revision = '005_sharepoint'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create documentstatus enum
|
||||
documentstatus_enum = sa.Enum('pending', 'processing', 'completed', 'failed', name='documentstatus')
|
||||
documentstatus_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# Create knowledge_documents table
|
||||
op.create_table(
|
||||
'knowledge_documents',
|
||||
sa.Column('id', UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('file_name', sa.String(512), nullable=False),
|
||||
sa.Column('file_type', sa.String(50), nullable=False),
|
||||
sa.Column('file_size', sa.BigInteger(), nullable=False),
|
||||
sa.Column('document_key', sa.String(255), nullable=False, unique=True),
|
||||
sa.Column('status', documentstatus_enum, nullable=False, server_default='pending'),
|
||||
sa.Column('vector_count', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('error_message', sa.Text(), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('department_id', UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('region_code', sa.String(10), nullable=True),
|
||||
sa.Column('uploaded_by', UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('processed_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['department_id'], ['departments.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ondelete='SET NULL'),
|
||||
)
|
||||
|
||||
# Create indexes
|
||||
op.create_index('ix_knowledge_documents_document_key', 'knowledge_documents', ['document_key'], unique=True)
|
||||
op.create_index('ix_knowledge_documents_status', 'knowledge_documents', ['status'])
|
||||
op.create_index('ix_knowledge_documents_department_id', 'knowledge_documents', ['department_id'])
|
||||
op.create_index('ix_knowledge_documents_uploaded_by', 'knowledge_documents', ['uploaded_by'])
|
||||
op.create_index('ix_knowledge_documents_is_active', 'knowledge_documents', ['is_active'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('knowledge_documents')
|
||||
op.execute('DROP TYPE documentstatus')
|
||||
67
backend/alembic/versions/007_remove_notebook_add_features.py
Normal file
67
backend/alembic/versions/007_remove_notebook_add_features.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Remove notebook tables, add user features columns
|
||||
|
||||
Revision ID: 007_remove_notebook_features
|
||||
Revises: 006_knowledge_docs
|
||||
Create Date: 2026-03-04
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '007_remove_notebook_features'
|
||||
down_revision = '006_knowledge_docs'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Drop notebook-related tables
|
||||
op.drop_table('uploaded_files')
|
||||
op.drop_table('notebook_sessions')
|
||||
|
||||
# 2. Remove 'notebook' from conversationmode enum (if it exists as DB enum)
|
||||
# Since mode is stored as VARCHAR(20), no enum alteration needed.
|
||||
# Just delete any conversations with mode='notebook'
|
||||
op.execute("DELETE FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE mode = 'notebook')")
|
||||
op.execute("DELETE FROM conversations WHERE mode = 'notebook'")
|
||||
|
||||
# 3. Add access control columns to users
|
||||
op.add_column('users', sa.Column('allowed_features', JSONB, server_default='[]', nullable=False))
|
||||
op.add_column('users', sa.Column('features_override', sa.Boolean(), server_default='false', nullable=False))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove access control columns
|
||||
op.drop_column('users', 'features_override')
|
||||
op.drop_column('users', 'allowed_features')
|
||||
|
||||
# Recreate notebook tables
|
||||
op.create_table(
|
||||
'notebook_sessions',
|
||||
sa.Column('id', sa.UUID(), primary_key=True),
|
||||
sa.Column('user_id', sa.UUID(), sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('conversation_id', sa.UUID(), sa.ForeignKey('conversations.id'), nullable=False),
|
||||
sa.Column('title', sa.String(255), nullable=True),
|
||||
sa.Column('notebookllama_notebook_id', sa.Integer(), nullable=True, unique=True),
|
||||
sa.Column('is_pinned', sa.Boolean(), default=False),
|
||||
sa.Column('total_file_size', sa.BigInteger(), default=0),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'uploaded_files',
|
||||
sa.Column('id', sa.UUID(), primary_key=True),
|
||||
sa.Column('session_id', sa.UUID(), sa.ForeignKey('notebook_sessions.id'), nullable=False),
|
||||
sa.Column('file_name', sa.String(255), nullable=False),
|
||||
sa.Column('file_size', sa.BigInteger(), nullable=False),
|
||||
sa.Column('file_type', sa.String(50), nullable=False),
|
||||
sa.Column('storage_path', sa.String(500), nullable=False),
|
||||
sa.Column('notebookllama_task_id', sa.Integer(), nullable=True),
|
||||
sa.Column('processing_status', sa.String(50), default='queued'),
|
||||
sa.Column('processing_error', sa.String(500), nullable=True),
|
||||
sa.Column('uploaded_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('processed_at', sa.DateTime(), nullable=True),
|
||||
)
|
||||
|
|
@ -27,6 +27,8 @@ class UserResponse(BaseModel):
|
|||
is_active: bool
|
||||
created_at: str
|
||||
last_login_at: str | None
|
||||
allowed_features: List[str] = []
|
||||
features_override: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -37,6 +39,13 @@ class UsersListResponse(BaseModel):
|
|||
total: int
|
||||
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
allowed_features: List[str] | None = None
|
||||
features_override: bool | None = None
|
||||
role: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class LLMConfigRequest(BaseModel):
|
||||
openai_api_key: str | None = None
|
||||
azure_api_key: str | None = None
|
||||
|
|
@ -84,7 +93,9 @@ async def get_all_users(
|
|||
department_id=str(u.department_id) if u.department_id else None,
|
||||
is_active=u.is_active,
|
||||
created_at=u.created_at.isoformat(),
|
||||
last_login_at=u.last_login_at.isoformat() if u.last_login_at else None
|
||||
last_login_at=u.last_login_at.isoformat() if u.last_login_at else None,
|
||||
allowed_features=u.allowed_features or [],
|
||||
features_override=u.features_override or False,
|
||||
)
|
||||
for u in users
|
||||
],
|
||||
|
|
@ -92,6 +103,45 @@ async def get_all_users(
|
|||
)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
request: UserUpdateRequest,
|
||||
current_user: User = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update user features and settings (super_admin only)"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
target_user = result.scalar_one_or_none()
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if request.allowed_features is not None:
|
||||
target_user.allowed_features = request.allowed_features
|
||||
if request.features_override is not None:
|
||||
target_user.features_override = request.features_override
|
||||
if request.role is not None:
|
||||
target_user.role = request.role
|
||||
if request.is_active is not None:
|
||||
target_user.is_active = request.is_active
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(target_user)
|
||||
|
||||
return UserResponse(
|
||||
id=str(target_user.id),
|
||||
email=target_user.email,
|
||||
display_name=target_user.display_name or "",
|
||||
role=target_user.role,
|
||||
department_id=str(target_user.department_id) if target_user.department_id else None,
|
||||
is_active=target_user.is_active,
|
||||
created_at=target_user.created_at.isoformat(),
|
||||
last_login_at=target_user.last_login_at.isoformat() if target_user.last_login_at else None,
|
||||
allowed_features=target_user.allowed_features or [],
|
||||
features_override=target_user.features_override or False,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/config", response_model=LLMConfigResponse)
|
||||
async def update_llm_config(
|
||||
request: LLMConfigRequest,
|
||||
|
|
|
|||
281
backend/app/api/v1/endpoints/knowledge.py
Normal file
281
backend/app/api/v1/endpoints/knowledge.py
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
"""
|
||||
Knowledge Base Admin API Endpoints
|
||||
|
||||
Upload, list, get, and delete documents for RAG knowledge base.
|
||||
All endpoints require admin role (super_admin or content_manager).
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.core.dependencies import require_admin
|
||||
from app.core.document_processor import DocumentProcessor
|
||||
from app.models.user import User
|
||||
from app.models.knowledge_document import KnowledgeDocument, DocumentStatus
|
||||
from app.schemas.knowledge_document import (
|
||||
KnowledgeDocumentResponse,
|
||||
KnowledgeDocumentListResponse,
|
||||
KnowledgeDocumentUploadResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
KNOWLEDGE_UPLOAD_DIR = os.path.join(settings.UPLOAD_DIR, "knowledge")
|
||||
|
||||
|
||||
def _doc_to_response(doc: KnowledgeDocument) -> KnowledgeDocumentResponse:
|
||||
return KnowledgeDocumentResponse(
|
||||
id=str(doc.id),
|
||||
file_name=doc.file_name,
|
||||
file_type=doc.file_type,
|
||||
file_size=doc.file_size,
|
||||
document_key=doc.document_key,
|
||||
status=doc.status.value,
|
||||
vector_count=doc.vector_count,
|
||||
error_message=doc.error_message,
|
||||
description=doc.description,
|
||||
department_id=str(doc.department_id) if doc.department_id else None,
|
||||
region_code=doc.region_code,
|
||||
uploaded_by=str(doc.uploaded_by) if doc.uploaded_by else None,
|
||||
is_active=doc.is_active,
|
||||
created_at=doc.created_at,
|
||||
updated_at=doc.updated_at,
|
||||
processed_at=doc.processed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload", response_model=KnowledgeDocumentUploadResponse)
|
||||
async def upload_knowledge_document(
|
||||
file: UploadFile = File(...),
|
||||
description: Optional[str] = Form(None),
|
||||
department_id: Optional[str] = Form(None),
|
||||
region_code: Optional[str] = Form(None),
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Upload a document to the RAG knowledge base."""
|
||||
# Validate file type
|
||||
file_ext = file.filename.rsplit(".", 1)[-1].lower() if file.filename and "." in file.filename else ""
|
||||
if file_ext not in settings.SUPPORTED_FILE_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported file type: {file_ext}. Supported: {', '.join(settings.SUPPORTED_FILE_TYPES)}",
|
||||
)
|
||||
|
||||
# Read file to get size
|
||||
file_bytes = await file.read()
|
||||
file_size = len(file_bytes)
|
||||
|
||||
max_size = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
||||
if file_size > max_size:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File size exceeds {settings.MAX_UPLOAD_SIZE_MB}MB limit",
|
||||
)
|
||||
|
||||
# Generate unique document key
|
||||
doc_id = uuid.uuid4()
|
||||
document_key = f"kb-{doc_id}"
|
||||
|
||||
# Save file to disk for Celery task
|
||||
os.makedirs(KNOWLEDGE_UPLOAD_DIR, exist_ok=True)
|
||||
file_path = os.path.join(KNOWLEDGE_UPLOAD_DIR, f"{doc_id}.{file_ext}")
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(file_bytes)
|
||||
|
||||
# Create DB record
|
||||
doc = KnowledgeDocument(
|
||||
id=doc_id,
|
||||
file_name=file.filename or f"document.{file_ext}",
|
||||
file_type=file_ext,
|
||||
file_size=file_size,
|
||||
document_key=document_key,
|
||||
status=DocumentStatus.PENDING,
|
||||
description=description,
|
||||
department_id=uuid.UUID(department_id) if department_id else None,
|
||||
region_code=region_code,
|
||||
uploaded_by=current_user.id,
|
||||
)
|
||||
db.add(doc)
|
||||
await db.commit()
|
||||
await db.refresh(doc)
|
||||
|
||||
# Dispatch Celery task
|
||||
from app.tasks.knowledge_processing import process_knowledge_document
|
||||
process_knowledge_document.delay(str(doc_id), file_path)
|
||||
|
||||
logger.info("Knowledge document uploaded: %s (%s, %d bytes) by %s", file.filename, file_ext, file_size, current_user.email)
|
||||
|
||||
return KnowledgeDocumentUploadResponse(
|
||||
id=str(doc.id),
|
||||
file_name=doc.file_name,
|
||||
file_size=doc.file_size,
|
||||
status=doc.status.value,
|
||||
message="Document uploaded and queued for processing",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/documents", response_model=KnowledgeDocumentListResponse)
|
||||
async def list_knowledge_documents(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List knowledge base documents with pagination and optional status filter."""
|
||||
query = select(KnowledgeDocument).order_by(KnowledgeDocument.created_at.desc())
|
||||
count_query = select(func.count(KnowledgeDocument.id))
|
||||
|
||||
if status_filter:
|
||||
try:
|
||||
status_enum = DocumentStatus(status_filter)
|
||||
query = query.where(KnowledgeDocument.status == status_enum)
|
||||
count_query = count_query.where(KnowledgeDocument.status == status_enum)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid status: {status_filter}",
|
||||
)
|
||||
|
||||
# Get total count
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Get paginated results
|
||||
query = query.offset(skip).limit(limit)
|
||||
result = await db.execute(query)
|
||||
documents = result.scalars().all()
|
||||
|
||||
return KnowledgeDocumentListResponse(
|
||||
documents=[_doc_to_response(doc) for doc in documents],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/documents/{document_id}", response_model=KnowledgeDocumentResponse)
|
||||
async def get_knowledge_document(
|
||||
document_id: str,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a single knowledge base document by ID."""
|
||||
result = await db.execute(
|
||||
select(KnowledgeDocument).where(KnowledgeDocument.id == uuid.UUID(document_id))
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
|
||||
if not doc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
return _doc_to_response(doc)
|
||||
|
||||
|
||||
@router.delete("/documents/{document_id}")
|
||||
async def delete_knowledge_document(
|
||||
document_id: str,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a knowledge base document and its vectors from Qdrant."""
|
||||
result = await db.execute(
|
||||
select(KnowledgeDocument).where(KnowledgeDocument.id == uuid.UUID(document_id))
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
|
||||
if not doc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
# Delete vectors from Qdrant
|
||||
try:
|
||||
processor = DocumentProcessor()
|
||||
processor.delete_document(doc.document_key)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to delete vectors for %s: %s", doc.document_key, exc)
|
||||
|
||||
# Delete DB record
|
||||
await db.delete(doc)
|
||||
await db.commit()
|
||||
|
||||
logger.info("Knowledge document deleted: %s by %s", doc.file_name, current_user.email)
|
||||
|
||||
return {"status": "deleted", "document_id": document_id}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Web Scraping → Knowledge Base
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class ScrapeURLRequest(BaseModel):
|
||||
url: str
|
||||
description: Optional[str] = None
|
||||
department_id: Optional[str] = None
|
||||
region_code: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/scrape", response_model=KnowledgeDocumentUploadResponse)
|
||||
async def scrape_url_to_knowledge(
|
||||
request: ScrapeURLRequest,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Scrape a URL and add its content to the knowledge base."""
|
||||
from app.core.web_scraper import scrape_url, WebScraperError
|
||||
|
||||
# Scrape URL content
|
||||
try:
|
||||
content = scrape_url(request.url)
|
||||
except WebScraperError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
# Create document record
|
||||
doc_id = uuid.uuid4()
|
||||
document_key = f"kb-{doc_id}"
|
||||
file_name = request.url[:200]
|
||||
|
||||
# Save content as a text file
|
||||
os.makedirs(KNOWLEDGE_UPLOAD_DIR, exist_ok=True)
|
||||
file_path = os.path.join(KNOWLEDGE_UPLOAD_DIR, f"{doc_id}.txt")
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
doc = KnowledgeDocument(
|
||||
id=doc_id,
|
||||
file_name=file_name,
|
||||
file_type="txt",
|
||||
file_size=len(content.encode("utf-8")),
|
||||
document_key=document_key,
|
||||
status=DocumentStatus.PENDING,
|
||||
description=request.description or f"Scraped from {request.url}",
|
||||
department_id=uuid.UUID(request.department_id) if request.department_id else None,
|
||||
region_code=request.region_code,
|
||||
uploaded_by=current_user.id,
|
||||
)
|
||||
db.add(doc)
|
||||
await db.commit()
|
||||
await db.refresh(doc)
|
||||
|
||||
# Dispatch Celery task
|
||||
from app.tasks.knowledge_processing import process_knowledge_document
|
||||
process_knowledge_document.delay(str(doc_id), file_path)
|
||||
|
||||
logger.info("URL scraped and queued: %s by %s", request.url, current_user.email)
|
||||
|
||||
return KnowledgeDocumentUploadResponse(
|
||||
id=str(doc.id),
|
||||
file_name=doc.file_name,
|
||||
file_size=doc.file_size,
|
||||
status=doc.status.value,
|
||||
message="URL scraped and queued for processing",
|
||||
)
|
||||
|
|
@ -1,745 +0,0 @@
|
|||
"""
|
||||
Notebook Mode API Endpoints
|
||||
Proxy layer between our frontend and external NotebookLlama service
|
||||
|
||||
Flow:
|
||||
1. User creates session → We create Conversation + NotebookSession + External Notebook
|
||||
2. User uploads file → We save locally + Upload to NotebookLlama + Poll status
|
||||
3. User chats → WebSocket connection via client → SSE stream to frontend
|
||||
4. User deletes session → Delete external notebook + Local files + DB records
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional
|
||||
from uuid import UUID, uuid4
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
import shutil
|
||||
import logging
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.dependencies import get_current_user, get_current_user_flexible
|
||||
from app.models.user import User
|
||||
from app.models.conversation import Conversation, ConversationMode
|
||||
from app.models.notebook import NotebookSession, UploadedFile, ProcessingStatus
|
||||
from app.core.notebook_client import (
|
||||
NotebookLlamaClient,
|
||||
NotebookLlamaAuthError,
|
||||
NotebookLlamaAPIError
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# Constants
|
||||
MAX_FILE_SIZE = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
||||
UPLOAD_DIR = settings.UPLOAD_DIR
|
||||
|
||||
|
||||
# ==================== SESSION MANAGEMENT ====================
|
||||
|
||||
@router.get("/")
|
||||
async def list_notebook_sessions(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
List all notebook sessions for the current user
|
||||
|
||||
Returns sessions sorted by updated/created date (most recent first)
|
||||
|
||||
Returns:
|
||||
list: Array of session summaries
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(NotebookSession)
|
||||
.where(NotebookSession.user_id == current_user.id)
|
||||
.order_by(NotebookSession.created_at.desc())
|
||||
)
|
||||
sessions = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"session_id": str(session.id),
|
||||
"conversation_id": str(session.conversation_id),
|
||||
"title": session.title,
|
||||
"is_pinned": session.is_pinned,
|
||||
"total_file_size": session.total_file_size,
|
||||
"notebookllama_notebook_id": session.notebookllama_notebook_id,
|
||||
"expires_at": session.expires_at.isoformat() if session.expires_at else None,
|
||||
"created_at": session.created_at.isoformat()
|
||||
}
|
||||
for session in sessions
|
||||
]
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_notebook_session(
|
||||
title: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a new notebook session
|
||||
|
||||
This creates:
|
||||
1. A conversation record in our DB
|
||||
2. A notebook session record in our DB
|
||||
3. An external notebook on NotebookLlama service
|
||||
|
||||
Returns:
|
||||
dict: Session details including IDs and expiration
|
||||
"""
|
||||
try:
|
||||
# Generate title if not provided
|
||||
if not title:
|
||||
title = f"Notebook - {datetime.utcnow().strftime('%Y-%m-%d %H:%M')}"
|
||||
|
||||
# 1. Create conversation
|
||||
conversation = Conversation(
|
||||
id=uuid4(),
|
||||
user_id=current_user.id,
|
||||
mode="notebook", # Use string directly, not enum
|
||||
title=title
|
||||
)
|
||||
db.add(conversation)
|
||||
await db.flush()
|
||||
|
||||
# 2. Create external notebook via NotebookLlama
|
||||
client = NotebookLlamaClient()
|
||||
try:
|
||||
nb_response = await client.create_notebook(
|
||||
title=title,
|
||||
description=f"Created by {current_user.email}",
|
||||
model_type="openai"
|
||||
)
|
||||
except NotebookLlamaAuthError as e:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"NotebookLlama authentication failed: {str(e)}"
|
||||
)
|
||||
except NotebookLlamaAPIError as e:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"NotebookLlama API error: {str(e)}"
|
||||
)
|
||||
|
||||
# 3. Create our session record
|
||||
session = NotebookSession(
|
||||
id=uuid4(),
|
||||
user_id=current_user.id,
|
||||
conversation_id=conversation.id,
|
||||
title=title,
|
||||
notebookllama_notebook_id=nb_response["id"], # Store INTEGER id
|
||||
is_pinned=False,
|
||||
total_file_size=0,
|
||||
expires_at=datetime.utcnow() + timedelta(hours=24)
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
logger.info(
|
||||
f"Created notebook session: session_id={session.id}, "
|
||||
f"notebook_id={session.notebookllama_notebook_id}"
|
||||
)
|
||||
|
||||
return {
|
||||
"session_id": str(session.id),
|
||||
"conversation_id": str(conversation.id),
|
||||
"notebookllama_notebook_id": session.notebookllama_notebook_id,
|
||||
"title": session.title,
|
||||
"is_pinned": session.is_pinned,
|
||||
"expires_at": session.expires_at.isoformat() if session.expires_at else None,
|
||||
"created_at": session.created_at.isoformat()
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Failed to create notebook session: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create session: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# ==================== FILE UPLOAD ====================
|
||||
|
||||
@router.post("/{session_id}/upload")
|
||||
async def upload_file(
|
||||
session_id: UUID,
|
||||
file: UploadFile = File(...),
|
||||
wait_for_completion: bool = Query(
|
||||
default=False,
|
||||
description="If true, wait for processing to complete before returning"
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Upload a file to a notebook session
|
||||
|
||||
Flow:
|
||||
1. Validate session and file size
|
||||
2. Save file locally
|
||||
3. Upload to NotebookLlama (returns task_id)
|
||||
4. Save upload record to DB
|
||||
5. Optionally poll until processing completes
|
||||
|
||||
Args:
|
||||
session_id: Notebook session UUID
|
||||
file: File to upload
|
||||
wait_for_completion: If true, poll until processing completes
|
||||
|
||||
Returns:
|
||||
dict: Upload confirmation with task_id and status
|
||||
"""
|
||||
# Verify session
|
||||
result = await db.execute(
|
||||
select(NotebookSession).where(
|
||||
NotebookSession.id == session_id,
|
||||
NotebookSession.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
if not session.notebookllama_notebook_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Session has no associated notebook"
|
||||
)
|
||||
|
||||
# Check expiration
|
||||
if session.expires_at and session.expires_at < datetime.utcnow():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail="Session has expired"
|
||||
)
|
||||
|
||||
# Read and validate file
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"File exceeds {settings.MAX_UPLOAD_SIZE_MB}MB limit"
|
||||
)
|
||||
|
||||
if session.total_file_size + file_size > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail="Session storage quota exceeded"
|
||||
)
|
||||
|
||||
# Save file locally
|
||||
session_dir = os.path.join(UPLOAD_DIR, str(session_id))
|
||||
os.makedirs(session_dir, exist_ok=True)
|
||||
|
||||
file_id = str(uuid4())
|
||||
file_ext = os.path.splitext(file.filename)[1] if file.filename else ""
|
||||
storage_path = os.path.join(session_dir, f"{file_id}{file_ext}")
|
||||
|
||||
try:
|
||||
with open(storage_path, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
# Upload to NotebookLlama
|
||||
client = NotebookLlamaClient()
|
||||
upload_result = await client.upload_file(
|
||||
notebook_id=session.notebookllama_notebook_id,
|
||||
file_path=storage_path,
|
||||
file_name=file.filename or f"file{file_ext}"
|
||||
)
|
||||
|
||||
task_id = upload_result["task_id"]
|
||||
|
||||
# Create upload record
|
||||
uploaded_file = UploadedFile(
|
||||
id=uuid4(),
|
||||
session_id=session.id,
|
||||
file_name=file.filename or f"file{file_ext}",
|
||||
file_size=file_size,
|
||||
file_type=file_ext[1:] if file_ext else "unknown",
|
||||
storage_path=storage_path,
|
||||
notebookllama_task_id=task_id,
|
||||
processing_status=ProcessingStatus.QUEUED.value
|
||||
)
|
||||
db.add(uploaded_file)
|
||||
|
||||
# Update session file size
|
||||
session.total_file_size += file_size
|
||||
await db.commit()
|
||||
await db.refresh(uploaded_file)
|
||||
|
||||
logger.info(
|
||||
f"File uploaded: session={session_id}, file={file.filename}, task_id={task_id}"
|
||||
)
|
||||
|
||||
# Optionally wait for completion
|
||||
if wait_for_completion:
|
||||
try:
|
||||
final_status = await client.poll_until_complete(task_id, timeout=300)
|
||||
|
||||
# Update our record
|
||||
uploaded_file.processing_status = ProcessingStatus.COMPLETED.value
|
||||
uploaded_file.processed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"file_id": str(uploaded_file.id),
|
||||
"file_name": uploaded_file.file_name,
|
||||
"file_size": file_size,
|
||||
"task_id": task_id,
|
||||
"processing_status": "completed",
|
||||
"message": "File uploaded and processed successfully"
|
||||
}
|
||||
|
||||
except TimeoutError:
|
||||
logger.warning(f"Task {task_id} processing timeout")
|
||||
return {
|
||||
"file_id": str(uploaded_file.id),
|
||||
"file_name": uploaded_file.file_name,
|
||||
"file_size": file_size,
|
||||
"task_id": task_id,
|
||||
"processing_status": "processing",
|
||||
"message": "File uploaded, still processing (timeout waiting)"
|
||||
}
|
||||
except NotebookLlamaAPIError as e:
|
||||
# Update our record with error
|
||||
uploaded_file.processing_status = ProcessingStatus.FAILED.value
|
||||
uploaded_file.processing_error = str(e)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"file_id": str(uploaded_file.id),
|
||||
"file_name": uploaded_file.file_name,
|
||||
"file_size": file_size,
|
||||
"task_id": task_id,
|
||||
"processing_status": "failed",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
# Return immediately without waiting
|
||||
return {
|
||||
"file_id": str(uploaded_file.id),
|
||||
"file_name": uploaded_file.file_name,
|
||||
"file_size": file_size,
|
||||
"task_id": task_id,
|
||||
"processing_status": "queued",
|
||||
"message": "File uploaded, processing in background"
|
||||
}
|
||||
|
||||
except NotebookLlamaAPIError as e:
|
||||
# Clean up file
|
||||
if os.path.exists(storage_path):
|
||||
os.remove(storage_path)
|
||||
await db.rollback()
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"NotebookLlama upload failed: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
# Clean up file
|
||||
if os.path.exists(storage_path):
|
||||
os.remove(storage_path)
|
||||
await db.rollback()
|
||||
|
||||
logger.error(f"Upload failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Upload failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# ==================== TASK STATUS ====================
|
||||
|
||||
@router.get("/{session_id}/files/{file_id}/status")
|
||||
async def get_file_status(
|
||||
session_id: UUID,
|
||||
file_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get processing status of an uploaded file
|
||||
|
||||
Returns:
|
||||
dict: Processing status from NotebookLlama
|
||||
"""
|
||||
# Verify file belongs to user's session
|
||||
result = await db.execute(
|
||||
select(UploadedFile).where(
|
||||
UploadedFile.id == file_id,
|
||||
UploadedFile.session_id == session_id
|
||||
)
|
||||
)
|
||||
uploaded_file = result.scalar_one_or_none()
|
||||
|
||||
if not uploaded_file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
|
||||
# Verify session ownership
|
||||
result = await db.execute(
|
||||
select(NotebookSession).where(
|
||||
NotebookSession.id == session_id,
|
||||
NotebookSession.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
# Get status from NotebookLlama
|
||||
if uploaded_file.notebookllama_task_id:
|
||||
try:
|
||||
client = NotebookLlamaClient()
|
||||
task_status = await client.get_task_status(uploaded_file.notebookllama_task_id)
|
||||
|
||||
# Update our record if status changed
|
||||
if task_status["status"] == "completed" and uploaded_file.processing_status != ProcessingStatus.COMPLETED.value:
|
||||
uploaded_file.processing_status = ProcessingStatus.COMPLETED.value
|
||||
uploaded_file.processed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
elif task_status["status"] == "failed" and uploaded_file.processing_status != ProcessingStatus.FAILED.value:
|
||||
uploaded_file.processing_status = ProcessingStatus.FAILED.value
|
||||
uploaded_file.processing_error = task_status.get("error_message")
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"file_id": str(uploaded_file.id),
|
||||
"file_name": uploaded_file.file_name,
|
||||
"processing_status": task_status["status"],
|
||||
"task_id": uploaded_file.notebookllama_task_id,
|
||||
"created_at": task_status.get("created_at"),
|
||||
"completed_at": task_status.get("completed_at"),
|
||||
"error_message": task_status.get("error_message")
|
||||
}
|
||||
|
||||
except NotebookLlamaAPIError as e:
|
||||
logger.error(f"Failed to get task status: {str(e)}")
|
||||
# Return our local status as fallback
|
||||
return {
|
||||
"file_id": str(uploaded_file.id),
|
||||
"file_name": uploaded_file.file_name,
|
||||
"processing_status": uploaded_file.processing_status,
|
||||
"task_id": uploaded_file.notebookllama_task_id,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
# No task ID, return local status
|
||||
return {
|
||||
"file_id": str(uploaded_file.id),
|
||||
"file_name": uploaded_file.file_name,
|
||||
"processing_status": uploaded_file.processing_status,
|
||||
"error_message": uploaded_file.processing_error
|
||||
}
|
||||
|
||||
|
||||
# ==================== CHAT ====================
|
||||
|
||||
@router.get("/{session_id}/chat", response_class=StreamingResponse)
|
||||
async def chat(
|
||||
session_id: UUID,
|
||||
message: str = Query(..., description="User's chat message"),
|
||||
current_user: User = Depends(get_current_user_flexible),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Stream chat response from NotebookLlama (GET for EventSource compatibility)
|
||||
|
||||
Connects to NotebookLlama WebSocket and streams response as SSE events
|
||||
|
||||
Args:
|
||||
session_id: Notebook session UUID
|
||||
message: User's question (query parameter)
|
||||
|
||||
Returns:
|
||||
StreamingResponse: SSE events with tokens, sources, completion, errors
|
||||
"""
|
||||
# Verify session
|
||||
result = await db.execute(
|
||||
select(NotebookSession).where(
|
||||
NotebookSession.id == session_id,
|
||||
NotebookSession.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
if not session.notebookllama_notebook_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Session has no associated notebook"
|
||||
)
|
||||
|
||||
# Check expiration
|
||||
if session.expires_at and session.expires_at < datetime.utcnow():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail="Session has expired"
|
||||
)
|
||||
|
||||
# Stream response from NotebookLlama
|
||||
async def event_generator():
|
||||
"""SSE event generator"""
|
||||
try:
|
||||
client = NotebookLlamaClient()
|
||||
|
||||
# Create chat session in NotebookLlama
|
||||
chat_session = await client.create_chat_session(
|
||||
notebook_id=session.notebookllama_notebook_id,
|
||||
title=f"Chat - {datetime.utcnow().strftime('%Y-%m-%d %H:%M')}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created NotebookLlama chat session: id={chat_session['id']}, "
|
||||
f"notebook={session.notebookllama_notebook_id}"
|
||||
)
|
||||
|
||||
# Stream from WebSocket (client converts to SSE format)
|
||||
async for sse_event in client.chat_stream(
|
||||
notebook_id=session.notebookllama_notebook_id,
|
||||
message=message,
|
||||
chat_session_id=chat_session['id']
|
||||
):
|
||||
yield sse_event
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Chat streaming error: {str(e)}")
|
||||
import json
|
||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Access-Control-Allow-Origin": "http://localhost:3000", # CORS for EventSource
|
||||
"Access-Control-Allow-Credentials": "true"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ==================== SESSION DETAILS ====================
|
||||
|
||||
@router.get("/{session_id}")
|
||||
async def get_session(
|
||||
session_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get notebook session details including uploaded files
|
||||
|
||||
Returns:
|
||||
dict: Session information with files list
|
||||
"""
|
||||
# Verify session
|
||||
result = await db.execute(
|
||||
select(NotebookSession).where(
|
||||
NotebookSession.id == session_id,
|
||||
NotebookSession.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
# Get uploaded files
|
||||
result = await db.execute(
|
||||
select(UploadedFile).where(UploadedFile.session_id == session.id)
|
||||
)
|
||||
files = result.scalars().all()
|
||||
|
||||
return {
|
||||
"session_id": str(session.id),
|
||||
"conversation_id": str(session.conversation_id),
|
||||
"notebookllama_notebook_id": session.notebookllama_notebook_id,
|
||||
"title": session.title,
|
||||
"is_pinned": session.is_pinned,
|
||||
"total_file_size": session.total_file_size,
|
||||
"expires_at": session.expires_at.isoformat() if session.expires_at else None,
|
||||
"created_at": session.created_at.isoformat(),
|
||||
"files": [
|
||||
{
|
||||
"file_id": str(f.id),
|
||||
"file_name": f.file_name,
|
||||
"file_size": f.file_size,
|
||||
"file_type": f.file_type,
|
||||
"processing_status": f.processing_status,
|
||||
"processing_error": f.processing_error,
|
||||
"uploaded_at": f.uploaded_at.isoformat(),
|
||||
"processed_at": f.processed_at.isoformat() if f.processed_at else None
|
||||
}
|
||||
for f in files
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ==================== PIN/UNPIN ====================
|
||||
|
||||
@router.post("/{session_id}/pin")
|
||||
async def pin_session(
|
||||
session_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Pin session to prevent expiration"""
|
||||
result = await db.execute(
|
||||
select(NotebookSession).where(
|
||||
NotebookSession.id == session_id,
|
||||
NotebookSession.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
session.is_pinned = True
|
||||
session.expires_at = None
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"session_id": str(session.id),
|
||||
"is_pinned": True,
|
||||
"expires_at": None
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{session_id}/unpin")
|
||||
async def unpin_session(
|
||||
session_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Unpin session and set expiration"""
|
||||
result = await db.execute(
|
||||
select(NotebookSession).where(
|
||||
NotebookSession.id == session_id,
|
||||
NotebookSession.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
session.is_pinned = False
|
||||
session.expires_at = datetime.utcnow() + timedelta(hours=24)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"session_id": str(session.id),
|
||||
"is_pinned": False,
|
||||
"expires_at": session.expires_at.isoformat()
|
||||
}
|
||||
|
||||
|
||||
# ==================== DELETE SESSION ====================
|
||||
|
||||
@router.delete("/{session_id}")
|
||||
async def delete_session(
|
||||
session_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Delete notebook session
|
||||
|
||||
Deletes:
|
||||
1. External notebook from NotebookLlama
|
||||
2. Local files
|
||||
3. Database records (cascades to uploaded_files)
|
||||
|
||||
Returns:
|
||||
dict: Deletion confirmation
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(NotebookSession).where(
|
||||
NotebookSession.id == session_id,
|
||||
NotebookSession.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
try:
|
||||
# 1. Delete from NotebookLlama
|
||||
if session.notebookllama_notebook_id:
|
||||
try:
|
||||
client = NotebookLlamaClient()
|
||||
await client.delete_notebook(session.notebookllama_notebook_id)
|
||||
logger.info(f"Deleted external notebook: {session.notebookllama_notebook_id}")
|
||||
except NotebookLlamaAPIError as e:
|
||||
logger.warning(f"Failed to delete external notebook: {str(e)}")
|
||||
# Continue anyway to clean up local resources
|
||||
|
||||
# 2. Delete local files
|
||||
session_dir = os.path.join(UPLOAD_DIR, str(session_id))
|
||||
if os.path.exists(session_dir):
|
||||
shutil.rmtree(session_dir)
|
||||
logger.info(f"Deleted local files: {session_dir}")
|
||||
|
||||
# 3. Delete from database (cascade handles uploaded_files)
|
||||
await db.delete(session)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Deleted session: {session_id}")
|
||||
|
||||
return {
|
||||
"status": "deleted",
|
||||
"session_id": str(session_id)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Session deletion failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Deletion failed: {str(e)}"
|
||||
)
|
||||
|
|
@ -132,6 +132,19 @@ async def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
|||
return current_user
|
||||
|
||||
|
||||
def require_feature(feature: str):
|
||||
"""Dependency factory: require user to have a specific feature access."""
|
||||
async def checker(current_user: User = Depends(get_current_user)):
|
||||
allowed = current_user.allowed_features or []
|
||||
if allowed and feature not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Access to {feature} not granted"
|
||||
)
|
||||
return current_user
|
||||
return checker
|
||||
|
||||
|
||||
async def require_super_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""
|
||||
Dependency to require super admin role
|
||||
|
|
|
|||
|
|
@ -190,14 +190,17 @@ class DocumentProcessor:
|
|||
"""Dispatch to the appropriate extractor based on file type."""
|
||||
try:
|
||||
ft = file_type.lower().lstrip(".")
|
||||
if ft == "pdf":
|
||||
return self._extract_pdf(file_bytes)
|
||||
elif ft in ("docx", "doc"):
|
||||
return self._extract_docx(file_bytes)
|
||||
elif ft in ("xlsx", "xls"):
|
||||
return self._extract_xlsx(file_bytes)
|
||||
elif ft == "txt":
|
||||
if ft == "txt":
|
||||
return file_bytes.decode("utf-8", errors="replace")
|
||||
elif ft == "pdf" and settings.LLAMAPARSE_API_KEY:
|
||||
# Use LlamaParse for PDFs when API key is available
|
||||
try:
|
||||
return self._extract_with_llamaparse(file_bytes, file_name)
|
||||
except Exception as exc:
|
||||
logger.warning("LlamaParse failed for '%s', falling back to MarkItDown: %s", file_name, exc)
|
||||
return self._extract_with_markitdown(file_bytes, file_name, ft)
|
||||
elif ft in ("pdf", "docx", "doc", "xlsx", "xls", "pptx", "ppt", "csv"):
|
||||
return self._extract_with_markitdown(file_bytes, file_name, ft)
|
||||
else:
|
||||
raise DocumentProcessingError(
|
||||
f"Unsupported file type '{file_type}' for '{file_name}'"
|
||||
|
|
@ -209,38 +212,33 @@ class DocumentProcessor:
|
|||
f"Failed to extract text from '{file_name}': {exc}"
|
||||
) from exc
|
||||
|
||||
def _extract_pdf(self, file_bytes: bytes) -> str:
|
||||
"""Extract text from PDF bytes using PyPDF2."""
|
||||
from PyPDF2 import PdfReader
|
||||
def _extract_with_markitdown(self, file_bytes: bytes, file_name: str, file_type: str) -> str:
|
||||
"""Extract text from any supported format using MarkItDown."""
|
||||
import tempfile
|
||||
from markitdown import MarkItDown
|
||||
|
||||
reader = PdfReader(io.BytesIO(file_bytes))
|
||||
pages = []
|
||||
for page in reader.pages:
|
||||
page_text = page.extract_text()
|
||||
if page_text:
|
||||
pages.append(page_text)
|
||||
return "\n\n".join(pages)
|
||||
md = MarkItDown()
|
||||
suffix = f".{file_type}"
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp:
|
||||
tmp.write(file_bytes)
|
||||
tmp.flush()
|
||||
result = md.convert(tmp.name)
|
||||
return result.text_content or ""
|
||||
|
||||
def _extract_docx(self, file_bytes: bytes) -> str:
|
||||
"""Extract text from DOCX bytes using python-docx."""
|
||||
import docx
|
||||
def _extract_with_llamaparse(self, file_bytes: bytes, file_name: str) -> str:
|
||||
"""Extract text from PDF using LlamaParse (cloud API, better for tables)."""
|
||||
import tempfile
|
||||
from llama_parse import LlamaParse
|
||||
|
||||
doc = docx.Document(io.BytesIO(file_bytes))
|
||||
paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
|
||||
return "\n\n".join(paragraphs)
|
||||
|
||||
def _extract_xlsx(self, file_bytes: bytes) -> str:
|
||||
"""Extract text from XLSX bytes using openpyxl."""
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True)
|
||||
rows = []
|
||||
for sheet in wb.worksheets:
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
row_text = " | ".join(str(cell) for cell in row if cell is not None)
|
||||
if row_text.strip():
|
||||
rows.append(row_text)
|
||||
return "\n".join(rows)
|
||||
parser = LlamaParse(
|
||||
api_key=settings.LLAMAPARSE_API_KEY,
|
||||
result_type="markdown",
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=True) as tmp:
|
||||
tmp.write(file_bytes)
|
||||
tmp.flush()
|
||||
documents = parser.load_data(tmp.name)
|
||||
return "\n\n".join(doc.text for doc in documents)
|
||||
|
||||
# =========================================================================
|
||||
# Embeddings
|
||||
|
|
|
|||
|
|
@ -1,542 +0,0 @@
|
|||
"""
|
||||
NotebookLlama Client - Integration with External NotebookLlama API
|
||||
|
||||
This client handles all communication with the external NotebookLlama service,
|
||||
including authentication, notebook management, file uploads, and WebSocket chat.
|
||||
|
||||
Architecture:
|
||||
- JWT authentication with automatic token refresh
|
||||
- WebSocket → SSE conversion for chat streaming
|
||||
- Comprehensive error handling and logging
|
||||
- Task status polling for async document processing
|
||||
"""
|
||||
import httpx
|
||||
import websockets
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncGenerator, Optional, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotebookLlamaAuthError(Exception):
|
||||
"""Authentication failed with NotebookLlama"""
|
||||
pass
|
||||
|
||||
|
||||
class NotebookLlamaAPIError(Exception):
|
||||
"""Generic API error from NotebookLlama"""
|
||||
pass
|
||||
|
||||
|
||||
class NotebookLlamaClient:
|
||||
"""
|
||||
Client for NotebookLlama API with JWT authentication and WebSocket support
|
||||
|
||||
Manages:
|
||||
- Service account authentication
|
||||
- Notebook CRUD operations
|
||||
- File uploads with background processing
|
||||
- WebSocket chat with SSE conversion
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.NOTEBOOKLLAMA_URL
|
||||
self.service_email = settings.NOTEBOOKLLAMA_SERVICE_EMAIL
|
||||
self.service_password = settings.NOTEBOOKLLAMA_SERVICE_PASSWORD
|
||||
self.timeout = httpx.Timeout(60.0, connect=10.0)
|
||||
|
||||
# Token management
|
||||
self._token: Optional[str] = None
|
||||
self._token_expiry: Optional[datetime] = None
|
||||
|
||||
logger.info(f"NotebookLlamaClient initialized (base_url={self.base_url})")
|
||||
|
||||
# ==================== AUTHENTICATION ====================
|
||||
|
||||
async def _get_token(self) -> str:
|
||||
"""
|
||||
Get or refresh JWT token for NotebookLlama API
|
||||
|
||||
Returns:
|
||||
str: Valid JWT token
|
||||
|
||||
Raises:
|
||||
NotebookLlamaAuthError: If authentication fails
|
||||
"""
|
||||
# Return cached token if still valid
|
||||
if self._token and self._token_expiry and self._token_expiry > datetime.utcnow():
|
||||
return self._token
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/auth/login",
|
||||
json={
|
||||
"email": self.service_email,
|
||||
"password": self.service_password
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
self._token = data["access_token"]
|
||||
# Refresh token 10 minutes before expiry (assume 1 hour expiry)
|
||||
self._token_expiry = datetime.utcnow() + timedelta(minutes=50)
|
||||
|
||||
logger.info("Successfully authenticated with NotebookLlama")
|
||||
return self._token
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Authentication failed: {str(e)}")
|
||||
raise NotebookLlamaAuthError(f"Failed to authenticate: {str(e)}")
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
**kwargs
|
||||
) -> httpx.Response:
|
||||
"""
|
||||
Make authenticated HTTP request to NotebookLlama API
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, DELETE, etc.)
|
||||
path: API path (e.g., "/api/notebooks")
|
||||
**kwargs: Additional arguments for httpx request
|
||||
|
||||
Returns:
|
||||
httpx.Response: Response object
|
||||
|
||||
Raises:
|
||||
NotebookLlamaAPIError: If request fails
|
||||
"""
|
||||
try:
|
||||
# Get valid token
|
||||
token = await self._get_token()
|
||||
|
||||
# Add authorization header
|
||||
headers = kwargs.pop("headers", {})
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
# Make request
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
f"{self.base_url}{path}",
|
||||
headers=headers,
|
||||
**kwargs
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"API request failed: {method} {path} - Status {e.response.status_code}")
|
||||
raise NotebookLlamaAPIError(
|
||||
f"API request failed: {e.response.status_code} - {e.response.text}"
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"HTTP error: {method} {path} - {str(e)}")
|
||||
raise NotebookLlamaAPIError(f"HTTP error: {str(e)}")
|
||||
|
||||
# ==================== NOTEBOOK MANAGEMENT ====================
|
||||
|
||||
async def create_notebook(
|
||||
self,
|
||||
title: str,
|
||||
description: Optional[str] = None,
|
||||
model_type: str = "openai"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new notebook on NotebookLlama
|
||||
|
||||
Args:
|
||||
title: Notebook title
|
||||
description: Optional description
|
||||
model_type: LLM model to use ("openai", "gemini", "claude")
|
||||
|
||||
Returns:
|
||||
dict: Notebook data with integer 'id' field
|
||||
|
||||
Example response:
|
||||
{
|
||||
"id": 123,
|
||||
"name": "My Notebook",
|
||||
"description": null,
|
||||
"model_type": "openai",
|
||||
"pipeline_id": "llamaindex-abc123",
|
||||
"created_at": "2024-...",
|
||||
"updated_at": "2024-..."
|
||||
}
|
||||
"""
|
||||
try:
|
||||
response = await self._request(
|
||||
"POST",
|
||||
"/api/notebooks/",
|
||||
json={
|
||||
"name": title,
|
||||
"description": description,
|
||||
"model_type": model_type
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
logger.info(f"Created notebook: id={data['id']}, title={title}")
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create notebook: {str(e)}")
|
||||
raise
|
||||
|
||||
async def get_notebook(self, notebook_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Get notebook details from NotebookLlama
|
||||
|
||||
Args:
|
||||
notebook_id: Integer notebook ID
|
||||
|
||||
Returns:
|
||||
dict: Notebook details including documents
|
||||
"""
|
||||
try:
|
||||
response = await self._request("GET", f"/api/notebooks/{notebook_id}")
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get notebook {notebook_id}: {str(e)}")
|
||||
raise
|
||||
|
||||
async def delete_notebook(self, notebook_id: int) -> Dict[str, str]:
|
||||
"""
|
||||
Delete a notebook from NotebookLlama
|
||||
|
||||
Args:
|
||||
notebook_id: Integer notebook ID
|
||||
|
||||
Returns:
|
||||
dict: Deletion confirmation
|
||||
"""
|
||||
try:
|
||||
response = await self._request("DELETE", f"/api/notebooks/{notebook_id}")
|
||||
data = response.json()
|
||||
|
||||
logger.info(f"Deleted notebook: id={notebook_id}")
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete notebook {notebook_id}: {str(e)}")
|
||||
raise
|
||||
|
||||
# ==================== FILE UPLOAD & PROCESSING ====================
|
||||
|
||||
async def upload_file(
|
||||
self,
|
||||
notebook_id: int,
|
||||
file_path: str,
|
||||
file_name: str,
|
||||
user_id: int = 1
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Upload a file to a NotebookLlama notebook
|
||||
|
||||
Args:
|
||||
notebook_id: Target notebook ID
|
||||
file_path: Local path to file
|
||||
file_name: Original filename
|
||||
user_id: User ID for tracking (default=1)
|
||||
|
||||
Returns:
|
||||
dict: Upload response with task_id for background processing
|
||||
|
||||
Example response:
|
||||
{
|
||||
"task_id": 456,
|
||||
"filename": "document.pdf",
|
||||
"status": "queued",
|
||||
"message": "Document uploaded and queued for processing"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Get authenticated token
|
||||
token = await self._get_token()
|
||||
|
||||
# Prepare multipart upload
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {
|
||||
'file': (file_name, f, 'application/octet-stream')
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/documents/upload/{notebook_id}",
|
||||
files=files,
|
||||
params={"user_id": user_id},
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
logger.info(
|
||||
f"Uploaded file to notebook {notebook_id}: "
|
||||
f"file={file_name}, task_id={data['task_id']}"
|
||||
)
|
||||
return data
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"File upload failed: {str(e)}")
|
||||
raise NotebookLlamaAPIError(f"File upload failed: {str(e)}")
|
||||
except IOError as e:
|
||||
logger.error(f"Failed to read file {file_path}: {str(e)}")
|
||||
raise
|
||||
|
||||
async def get_task_status(self, task_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Check the status of a document processing task
|
||||
|
||||
Args:
|
||||
task_id: Task ID from upload response
|
||||
|
||||
Returns:
|
||||
dict: Task status information
|
||||
|
||||
Example response:
|
||||
{
|
||||
"id": 456,
|
||||
"status": "completed", // "queued", "processing", "failed"
|
||||
"filename": "document.pdf",
|
||||
"created_at": "2024-...",
|
||||
"completed_at": "2024-...",
|
||||
"error_message": null
|
||||
}
|
||||
"""
|
||||
try:
|
||||
response = await self._request("GET", f"/api/documents/task/{task_id}")
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get task status {task_id}: {str(e)}")
|
||||
raise
|
||||
|
||||
async def poll_until_complete(
|
||||
self,
|
||||
task_id: int,
|
||||
timeout: int = 300,
|
||||
poll_interval: int = 2
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Poll task status until completion or timeout
|
||||
|
||||
Args:
|
||||
task_id: Task ID to poll
|
||||
timeout: Maximum wait time in seconds (default: 5 minutes)
|
||||
poll_interval: Seconds between polls (default: 2)
|
||||
|
||||
Returns:
|
||||
dict: Final task status
|
||||
|
||||
Raises:
|
||||
TimeoutError: If task doesn't complete within timeout
|
||||
NotebookLlamaAPIError: If task fails
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
start_time = datetime.utcnow()
|
||||
max_time = start_time + timedelta(seconds=timeout)
|
||||
|
||||
while datetime.utcnow() < max_time:
|
||||
status = await self.get_task_status(task_id)
|
||||
|
||||
if status["status"] == "completed":
|
||||
logger.info(f"Task {task_id} completed successfully")
|
||||
return status
|
||||
elif status["status"] == "failed":
|
||||
error = status.get("error_message", "Unknown error")
|
||||
logger.error(f"Task {task_id} failed: {error}")
|
||||
raise NotebookLlamaAPIError(f"Task processing failed: {error}")
|
||||
|
||||
# Still processing, wait and retry
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
# Timeout reached
|
||||
raise TimeoutError(f"Task {task_id} did not complete within {timeout} seconds")
|
||||
|
||||
# ==================== CHAT SESSION MANAGEMENT ====================
|
||||
|
||||
async def create_chat_session(
|
||||
self,
|
||||
notebook_id: int,
|
||||
title: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new chat session for a notebook
|
||||
|
||||
Args:
|
||||
notebook_id: Notebook ID
|
||||
title: Optional session title
|
||||
|
||||
Returns:
|
||||
dict: Chat session data with id field
|
||||
|
||||
Example response:
|
||||
{
|
||||
"id": 123,
|
||||
"notebook_id": 456,
|
||||
"title": "Chat Session",
|
||||
"created_at": "2024-..."
|
||||
}
|
||||
"""
|
||||
try:
|
||||
params = {}
|
||||
if title:
|
||||
params["title"] = title
|
||||
|
||||
response = await self._request(
|
||||
"POST",
|
||||
f"/api/chat/{notebook_id}/sessions",
|
||||
params=params
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
logger.info(f"Created chat session: id={data['id']}, notebook={notebook_id}")
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create chat session for notebook {notebook_id}: {str(e)}")
|
||||
raise
|
||||
|
||||
# ==================== WEBSOCKET CHAT ====================
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
notebook_id: int,
|
||||
message: str,
|
||||
chat_session_id: Optional[int] = None
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
Stream chat response from NotebookLlama via WebSocket
|
||||
|
||||
Connects to NotebookLlama WebSocket, sends message, and yields
|
||||
responses in SSE format compatible with our frontend.
|
||||
|
||||
Args:
|
||||
notebook_id: Notebook ID to query
|
||||
message: User's question
|
||||
chat_session_id: Optional chat session ID for history
|
||||
|
||||
Yields:
|
||||
str: SSE-formatted events for frontend
|
||||
|
||||
SSE Event Format:
|
||||
data: {"token": "Hello"}
|
||||
data: {"sources": "## Sources\n..."}
|
||||
data: {"done": true}
|
||||
data: {"error": "Error message"}
|
||||
"""
|
||||
try:
|
||||
# Get JWT token
|
||||
token = await self._get_token()
|
||||
|
||||
# Construct WebSocket URL
|
||||
ws_url = self.base_url.replace('http', 'ws').replace('https', 'wss')
|
||||
ws_path = f"/api/chat/ws/{notebook_id}"
|
||||
|
||||
if chat_session_id:
|
||||
ws_path += f"?session_id={chat_session_id}"
|
||||
|
||||
full_ws_url = f"{ws_url}{ws_path}"
|
||||
|
||||
logger.info(f"Connecting to WebSocket: {full_ws_url}")
|
||||
|
||||
async with websockets.connect(
|
||||
full_ws_url,
|
||||
extra_headers={"Authorization": f"Bearer {token}"}
|
||||
) as websocket:
|
||||
# Wait for connection confirmation
|
||||
conn_msg = await websocket.recv()
|
||||
conn_data = json.loads(conn_msg)
|
||||
|
||||
if conn_data.get("type") != "connected":
|
||||
logger.error(f"Unexpected connection response: {conn_data}")
|
||||
yield f"data: {json.dumps({'error': 'Failed to connect to chat'})}\n\n"
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"WebSocket connected: notebook={conn_data.get('notebook_id')}, "
|
||||
f"session={conn_data.get('session_id')}"
|
||||
)
|
||||
|
||||
# Send question
|
||||
await websocket.send(json.dumps({"question": message}))
|
||||
|
||||
# Stream responses
|
||||
async for ws_msg in websocket:
|
||||
data = json.loads(ws_msg)
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "processing":
|
||||
# Acknowledge processing (optional)
|
||||
logger.debug("Processing message received")
|
||||
continue
|
||||
|
||||
elif msg_type == "response":
|
||||
# Stream the main response
|
||||
response_text = data.get("response", "")
|
||||
if response_text:
|
||||
yield f"data: {json.dumps({'token': response_text})}\n\n"
|
||||
|
||||
# Stream sources separately if present
|
||||
sources = data.get("sources")
|
||||
if sources:
|
||||
yield f"data: {json.dumps({'sources': sources})}\n\n"
|
||||
|
||||
# Send completion event
|
||||
yield f"data: {json.dumps({'done': True})}\n\n"
|
||||
break
|
||||
|
||||
elif msg_type == "error":
|
||||
error_msg = data.get("message", "Unknown error")
|
||||
logger.error(f"WebSocket error: {error_msg}")
|
||||
yield f"data: {json.dumps({'error': error_msg})}\n\n"
|
||||
break
|
||||
|
||||
else:
|
||||
logger.warning(f"Unknown message type: {msg_type}")
|
||||
|
||||
except websockets.exceptions.WebSocketException as e:
|
||||
logger.error(f"WebSocket connection failed: {str(e)}")
|
||||
yield f"data: {json.dumps({'error': f'Connection failed: {str(e)}'})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Chat stream error: {str(e)}")
|
||||
yield f"data: {json.dumps({'error': f'Stream error: {str(e)}'})}\n\n"
|
||||
|
||||
# ==================== CHAT HISTORY ====================
|
||||
|
||||
async def get_chat_history(
|
||||
self,
|
||||
notebook_id: int,
|
||||
limit: int = 50
|
||||
) -> list[Dict[str, Any]]:
|
||||
"""
|
||||
Get chat history for a notebook
|
||||
|
||||
Args:
|
||||
notebook_id: Notebook ID
|
||||
limit: Maximum number of messages to retrieve
|
||||
|
||||
Returns:
|
||||
list: Chat messages with role, content, sources, etc.
|
||||
"""
|
||||
try:
|
||||
response = await self._request(
|
||||
"GET",
|
||||
f"/api/chat/{notebook_id}/history",
|
||||
params={"limit": limit}
|
||||
)
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get chat history for notebook {notebook_id}: {str(e)}")
|
||||
raise
|
||||
67
backend/app/core/web_scraper.py
Normal file
67
backend/app/core/web_scraper.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""
|
||||
Web Scraper — extract clean text from URLs using trafilatura
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import trafilatura
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_CONTENT_LENGTH = 500_000 # ~500KB of text
|
||||
TIMEOUT = 30 # seconds
|
||||
|
||||
|
||||
class WebScraperError(Exception):
|
||||
"""Raised when URL scraping fails."""
|
||||
|
||||
|
||||
def validate_url(url: str) -> str:
|
||||
"""Validate and normalize URL."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise WebScraperError(f"Invalid URL scheme: {parsed.scheme}. Only http/https allowed.")
|
||||
if not parsed.netloc:
|
||||
raise WebScraperError("Invalid URL: no host found.")
|
||||
return url
|
||||
|
||||
|
||||
def scrape_url(url: str, output_format: str = "markdown") -> str:
|
||||
"""
|
||||
Fetch and extract main content from a URL.
|
||||
|
||||
Args:
|
||||
url: The URL to scrape
|
||||
output_format: "markdown" or "text"
|
||||
|
||||
Returns:
|
||||
Extracted content as markdown or plain text
|
||||
|
||||
Raises:
|
||||
WebScraperError: If fetching or extraction fails
|
||||
"""
|
||||
url = validate_url(url)
|
||||
logger.info("Scraping URL: %s", url)
|
||||
|
||||
downloaded = trafilatura.fetch_url(url)
|
||||
if not downloaded:
|
||||
raise WebScraperError(f"Failed to fetch URL: {url}")
|
||||
|
||||
result = trafilatura.extract(
|
||||
downloaded,
|
||||
output_format=output_format,
|
||||
include_links=True,
|
||||
include_tables=True,
|
||||
include_images=False,
|
||||
)
|
||||
|
||||
if not result:
|
||||
raise WebScraperError(f"No content extracted from URL: {url}")
|
||||
|
||||
if len(result) > MAX_CONTENT_LENGTH:
|
||||
logger.warning("Content truncated from %d to %d chars for URL: %s", len(result), MAX_CONTENT_LENGTH, url)
|
||||
result = result[:MAX_CONTENT_LENGTH]
|
||||
|
||||
logger.info("Extracted %d chars from %s", len(result), url)
|
||||
return result
|
||||
|
|
@ -14,7 +14,6 @@ class ConversationMode(enum.Enum):
|
|||
"""Conversation modes"""
|
||||
RAG = "rag"
|
||||
ASSISTANT = "assistant"
|
||||
NOTEBOOK = "notebook"
|
||||
|
||||
|
||||
class MessageRole(enum.Enum):
|
||||
|
|
@ -38,7 +37,6 @@ class Conversation(Base):
|
|||
# Relationships
|
||||
user = relationship("User", back_populates="conversations")
|
||||
messages = relationship("Message", back_populates="conversation", cascade="all, delete-orphan")
|
||||
notebook_session = relationship("NotebookSession", back_populates="conversation", uselist=False)
|
||||
|
||||
|
||||
class Message(Base):
|
||||
|
|
|
|||
57
backend/app/models/knowledge_document.py
Normal file
57
backend/app/models/knowledge_document.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""
|
||||
Knowledge Base document model for admin-uploaded RAG documents
|
||||
"""
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Integer, BigInteger, Text, ForeignKey, Enum as SQLEnum
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import enum
|
||||
|
||||
|
||||
class DocumentStatus(str, enum.Enum):
|
||||
"""Knowledge document processing status"""
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class KnowledgeDocument(Base):
|
||||
"""
|
||||
Tracks documents uploaded via admin panel for RAG knowledge base.
|
||||
document_key maps to sharepoint_id in Qdrant (DocumentProcessor reuse).
|
||||
"""
|
||||
__tablename__ = "knowledge_documents"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
file_name = Column(String(512), nullable=False)
|
||||
file_type = Column(String(50), nullable=False) # pdf, docx, xlsx, txt
|
||||
file_size = Column(BigInteger, nullable=False)
|
||||
|
||||
# Unique key for Qdrant mapping (maps to sharepoint_id in DocumentProcessor)
|
||||
document_key = Column(String(255), unique=True, nullable=False, index=True)
|
||||
|
||||
# Processing status
|
||||
status = Column(SQLEnum(DocumentStatus), default=DocumentStatus.PENDING, nullable=False, index=True)
|
||||
vector_count = Column(Integer, default=0, nullable=False)
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
# Metadata
|
||||
description = Column(Text, nullable=True)
|
||||
department_id = Column(UUID(as_uuid=True), ForeignKey("departments.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
region_code = Column(String(10), nullable=True)
|
||||
|
||||
# Ownership
|
||||
uploaded_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False, index=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
processed_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
uploader = relationship("User", foreign_keys=[uploaded_by])
|
||||
department = relationship("Department", foreign_keys=[department_id])
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
"""
|
||||
Notebook Mode Models for NotebookLlama Integration
|
||||
Handles file uploads and isolated analysis sessions
|
||||
"""
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, BigInteger, Integer, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import enum
|
||||
|
||||
|
||||
class ProcessingStatus(enum.Enum):
|
||||
"""Processing status for document uploads"""
|
||||
QUEUED = "queued"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class NotebookSession(Base):
|
||||
"""
|
||||
Notebook session table
|
||||
Maps our internal session to external NotebookLlama notebook
|
||||
"""
|
||||
__tablename__ = "notebook_sessions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
conversation_id = Column(UUID(as_uuid=True), ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
title = Column(String(255), nullable=True)
|
||||
|
||||
# External NotebookLlama notebook ID (INTEGER from their API)
|
||||
notebookllama_notebook_id = Column(Integer, nullable=True, unique=True)
|
||||
|
||||
# Pin feature: pinned sessions never expire
|
||||
is_pinned = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Track total file size for quota management
|
||||
total_file_size = Column(BigInteger, default=0, nullable=False)
|
||||
|
||||
# Expiration: NULL if pinned, NOW() + 24h otherwise
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="notebook_sessions")
|
||||
conversation = relationship("Conversation", back_populates="notebook_session")
|
||||
uploaded_files = relationship("UploadedFile", back_populates="session", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NotebookSession {self.id} (Notebook: {self.notebookllama_notebook_id})>"
|
||||
|
||||
|
||||
class UploadedFile(Base):
|
||||
"""
|
||||
Uploaded files table
|
||||
Tracks files uploaded to NotebookLlama sessions with processing status
|
||||
"""
|
||||
__tablename__ = "uploaded_files"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
session_id = Column(UUID(as_uuid=True), ForeignKey("notebook_sessions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
file_name = Column(String(255), nullable=False)
|
||||
file_size = Column(BigInteger, nullable=False)
|
||||
file_type = Column(String(50), nullable=True) # pdf, docx, xlsx, etc.
|
||||
|
||||
# Local storage path
|
||||
storage_path = Column(String(500), nullable=False)
|
||||
|
||||
# External NotebookLlama task ID (from background processing)
|
||||
notebookllama_task_id = Column(Integer, nullable=True)
|
||||
|
||||
# Processing status tracking
|
||||
processing_status = Column(
|
||||
String(50),
|
||||
default=ProcessingStatus.QUEUED.value,
|
||||
nullable=False
|
||||
)
|
||||
processing_error = Column(String(500), nullable=True)
|
||||
|
||||
uploaded_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
processed_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
session = relationship("NotebookSession", back_populates="uploaded_files")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UploadedFile {self.file_name} (Status: {self.processing_status})>"
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
User Model with Role-Based Access Control (RBAC)
|
||||
"""
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Enum, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
import uuid
|
||||
|
|
@ -28,16 +28,18 @@ class User(Base):
|
|||
entra_id = Column(String, unique=True, nullable=False, index=True) # Azure AD object ID
|
||||
email = Column(String, unique=True, nullable=False, index=True)
|
||||
display_name = Column(String)
|
||||
role = Column(String, default="user", nullable=False) # Changed from Enum to String for simplicity
|
||||
department_id = Column(UUID(as_uuid=True), nullable=True) # Temporarily removed FK
|
||||
role = Column(String, default="user", nullable=False)
|
||||
department_id = Column(UUID(as_uuid=True), nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
last_login_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships (Department temporarily disabled)
|
||||
# department = relationship("Department", back_populates="users")
|
||||
# Access Control — features granted via AD Groups or admin override
|
||||
allowed_features = Column(JSONB, default=list, nullable=False, server_default='[]')
|
||||
features_override = Column(Boolean, default=False, nullable=False, server_default='false')
|
||||
|
||||
# Relationships
|
||||
conversations = relationship("Conversation", back_populates="user", cascade="all, delete-orphan")
|
||||
notebook_sessions = relationship("NotebookSession", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User {self.email} ({self.role.value})>"
|
||||
return f"<User {self.email} ({self.role})>"
|
||||
|
|
|
|||
|
|
@ -174,10 +174,11 @@ class RAGRetriever:
|
|||
department_id: Optional[str] = None,
|
||||
region_code: Optional[str] = None,
|
||||
top_k: int = 5,
|
||||
system_prompt: Optional[str] = None
|
||||
system_prompt: Optional[str] = None,
|
||||
conversation_history: Optional[List[Dict]] = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
Complete RAG query pipeline with streaming response
|
||||
Complete RAG query pipeline with streaming response and multi-turn support.
|
||||
|
||||
Args:
|
||||
user_query: User's question
|
||||
|
|
@ -185,6 +186,7 @@ class RAGRetriever:
|
|||
region_code: Optional region filter
|
||||
top_k: Number of documents to retrieve
|
||||
system_prompt: Optional system prompt override
|
||||
conversation_history: Previous messages for multi-turn context
|
||||
|
||||
Yields:
|
||||
Response tokens as they're generated
|
||||
|
|
@ -210,17 +212,19 @@ If the context does not contain the answer, respond: "I don't have enough inform
|
|||
Always cite sources using numbered references [1], [2], etc. when you use information from the context."""
|
||||
|
||||
if not context:
|
||||
# No documents found
|
||||
yield "I don't have any relevant documents to answer this question. Please try rephrasing or check if documents have been uploaded to the system."
|
||||
return
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"**Context:**\n{context}\n\n**Question:** {user_query}"
|
||||
}
|
||||
]
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
|
||||
# Include conversation history for multi-turn
|
||||
if conversation_history:
|
||||
messages.extend(conversation_history)
|
||||
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": f"**Context:**\n{context}\n\n**Question:** {user_query}"
|
||||
})
|
||||
|
||||
# 5. Stream LLM response
|
||||
async for token in LLMFactory.stream_completion(mode="rag", messages=messages):
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@ class ChatRequest(BaseModel):
|
|||
"""Request schema for chat endpoint"""
|
||||
message: str = Field(..., min_length=1, max_length=10000, description="User's message")
|
||||
conversation_id: Optional[UUID] = Field(None, description="Existing conversation ID (creates new if not provided)")
|
||||
mode: Optional[str] = Field("rag", description="Chat mode: 'rag', 'assistant', or 'notebook'")
|
||||
mode: Optional[str] = Field("rag", description="Chat mode: 'rag', 'assistant', or 'assistant'")
|
||||
department_id: Optional[str] = Field(None, description="Department ID for filtering (optional)")
|
||||
region_code: Optional[str] = Field(None, description="Region code for filtering (optional)")
|
||||
|
||||
|
||||
class ConversationCreate(BaseModel):
|
||||
"""Schema for creating a new conversation"""
|
||||
mode: str = Field(..., description="Conversation mode: 'rag', 'assistant', or 'notebook'")
|
||||
mode: str = Field(..., description="Conversation mode: 'rag', 'assistant', or 'assistant'")
|
||||
title: Optional[str] = Field(None, description="Conversation title")
|
||||
|
||||
|
||||
|
|
|
|||
41
backend/app/schemas/knowledge_document.py
Normal file
41
backend/app/schemas/knowledge_document.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""
|
||||
Pydantic schemas for Knowledge Base documents
|
||||
"""
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class KnowledgeDocumentResponse(BaseModel):
|
||||
id: str
|
||||
file_name: str
|
||||
file_type: str
|
||||
file_size: int
|
||||
document_key: str
|
||||
status: str
|
||||
vector_count: int
|
||||
error_message: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
department_id: Optional[str] = None
|
||||
region_code: Optional[str] = None
|
||||
uploaded_by: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
processed_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class KnowledgeDocumentListResponse(BaseModel):
|
||||
documents: List[KnowledgeDocumentResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class KnowledgeDocumentUploadResponse(BaseModel):
|
||||
id: str
|
||||
file_name: str
|
||||
file_size: int
|
||||
status: str
|
||||
message: str
|
||||
109
backend/app/tasks/knowledge_processing.py
Normal file
109
backend/app/tasks/knowledge_processing.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""
|
||||
Celery Task for processing Knowledge Base documents uploaded via admin panel.
|
||||
|
||||
Reads the uploaded file from disk, runs it through DocumentProcessor
|
||||
(same pipeline as SharePoint sync), and updates the DB record.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.document_processor import DocumentProcessor, DocumentProcessingError
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.knowledge_document import KnowledgeDocument, DocumentStatus
|
||||
from celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="app.tasks.knowledge_processing.process_knowledge_document",
|
||||
bind=True,
|
||||
max_retries=2,
|
||||
default_retry_delay=30,
|
||||
)
|
||||
def process_knowledge_document(self, document_id: str, file_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Process an uploaded knowledge base document.
|
||||
|
||||
Args:
|
||||
document_id: UUID string of the KnowledgeDocument record
|
||||
file_path: Path to the uploaded temp file on disk
|
||||
|
||||
Returns:
|
||||
Dict with processing result
|
||||
"""
|
||||
return asyncio.run(_async_process_knowledge_document(document_id, file_path))
|
||||
|
||||
|
||||
async def _async_process_knowledge_document(document_id: str, file_path: str) -> Dict[str, Any]:
|
||||
"""Async implementation of process_knowledge_document."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Load document record
|
||||
result = await session.execute(
|
||||
select(KnowledgeDocument).where(KnowledgeDocument.id == UUID(document_id))
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
|
||||
if not doc:
|
||||
logger.error("KnowledgeDocument %s not found", document_id)
|
||||
return {"error": "document_not_found"}
|
||||
|
||||
# Update status to PROCESSING
|
||||
doc.status = DocumentStatus.PROCESSING
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
# Read file bytes
|
||||
with open(file_path, "rb") as f:
|
||||
file_bytes = f.read()
|
||||
|
||||
# Process through DocumentProcessor (same pipeline as SharePoint)
|
||||
processor = DocumentProcessor()
|
||||
vector_count = await processor.process_document(
|
||||
file_bytes=file_bytes,
|
||||
file_name=doc.file_name,
|
||||
file_type=doc.file_type,
|
||||
sharepoint_id=doc.document_key, # Maps to sharepoint_id in Qdrant
|
||||
file_url="", # No URL for uploaded files
|
||||
source_id="knowledge_base", # Distinguishes from SharePoint docs
|
||||
department_id=str(doc.department_id) if doc.department_id else None,
|
||||
region_code=doc.region_code,
|
||||
)
|
||||
|
||||
# Success: update record
|
||||
doc.status = DocumentStatus.COMPLETED
|
||||
doc.vector_count = vector_count
|
||||
doc.processed_at = datetime.utcnow()
|
||||
doc.error_message = None
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"Knowledge document %s processed: %d vectors",
|
||||
document_id, vector_count,
|
||||
)
|
||||
return {
|
||||
"document_id": document_id,
|
||||
"vector_count": vector_count,
|
||||
"status": "completed",
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to process knowledge document %s: %s", document_id, exc)
|
||||
doc.status = DocumentStatus.FAILED
|
||||
doc.error_message = str(exc)[:2000]
|
||||
await session.commit()
|
||||
return {"error": str(exc), "document_id": document_id}
|
||||
|
||||
finally:
|
||||
# Clean up temp file
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove temp file %s: %s", file_path, e)
|
||||
|
|
@ -24,7 +24,7 @@ celery_app = Celery(
|
|||
"nexus",
|
||||
broker=settings.CELERY_BROKER_URL,
|
||||
backend=settings.CELERY_RESULT_BACKEND,
|
||||
include=["app.tasks.sharepoint_sync"],
|
||||
include=["app.tasks.sharepoint_sync", "app.tasks.knowledge_processing"],
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
|
|
@ -40,6 +40,7 @@ celery_app.conf.update(
|
|||
# Task routing — all SharePoint tasks go to a dedicated queue
|
||||
task_routes={
|
||||
"app.tasks.sharepoint_sync.*": {"queue": "sharepoint"},
|
||||
"app.tasks.knowledge_processing.*": {"queue": "default"},
|
||||
},
|
||||
|
||||
# Result retention: keep results for 24 hours
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from datetime import datetime
|
|||
# Add parent directory to path to import app modules
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from PyPDF2 import PdfReader
|
||||
from markitdown import MarkItDown
|
||||
from langchain.text_splitter import RecursiveCharacterTextSplitter
|
||||
from langchain_openai import OpenAIEmbeddings
|
||||
from qdrant_client import QdrantClient
|
||||
|
|
@ -33,30 +33,24 @@ from dotenv import load_dotenv
|
|||
load_dotenv()
|
||||
|
||||
|
||||
def extract_text_from_pdf(file_path: str) -> str:
|
||||
def extract_text_from_file(file_path: str) -> str:
|
||||
"""
|
||||
Extract text from PDF file
|
||||
Extract text from any supported file using MarkItDown
|
||||
|
||||
Args:
|
||||
file_path: Path to PDF file
|
||||
file_path: Path to file
|
||||
|
||||
Returns:
|
||||
Extracted text
|
||||
Extracted text (markdown)
|
||||
"""
|
||||
print(f"📄 Extracting text from: {file_path}")
|
||||
text_parts = []
|
||||
|
||||
try:
|
||||
reader = PdfReader(file_path)
|
||||
for i, page in enumerate(reader.pages, start=1):
|
||||
page_text = page.extract_text()
|
||||
if page_text:
|
||||
text_parts.append(page_text)
|
||||
print(f" ✓ Extracted page {i}/{len(reader.pages)}")
|
||||
|
||||
full_text = "\n\n".join(text_parts)
|
||||
print(f" ✓ Total characters extracted: {len(full_text)}")
|
||||
return full_text
|
||||
md = MarkItDown()
|
||||
result = md.convert(file_path)
|
||||
text = result.text_content or ""
|
||||
print(f" ✓ Total characters extracted: {len(text)}")
|
||||
return text
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Error extracting text: {e}")
|
||||
|
|
@ -214,7 +208,7 @@ def upload_to_qdrant(
|
|||
"text": chunk,
|
||||
"department_id": department_id,
|
||||
"region_code": region_code,
|
||||
"file_type": "pdf",
|
||||
"file_type": file_type,
|
||||
"last_modified": datetime.utcnow().isoformat(),
|
||||
"is_active": True
|
||||
}
|
||||
|
|
@ -245,8 +239,8 @@ async def main():
|
|||
description="Ingest a local PDF file into Qdrant for RAG testing"
|
||||
)
|
||||
parser.add_argument(
|
||||
"pdf_path",
|
||||
help="Path to the PDF file to ingest"
|
||||
"file_path",
|
||||
help="Path to the file to ingest (PDF, DOCX, PPTX, XLSX, CSV, TXT)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--title",
|
||||
|
|
@ -279,8 +273,8 @@ async def main():
|
|||
args = parser.parse_args()
|
||||
|
||||
# Validate file exists
|
||||
if not os.path.exists(args.pdf_path):
|
||||
print(f"❌ Error: File not found: {args.pdf_path}")
|
||||
if not os.path.exists(args.file_path):
|
||||
print(f"❌ Error: File not found: {args.file_path}")
|
||||
sys.exit(1)
|
||||
|
||||
# Get environment variables
|
||||
|
|
@ -291,14 +285,17 @@ async def main():
|
|||
print("❌ Error: OPENAI_API_KEY environment variable not set")
|
||||
sys.exit(1)
|
||||
|
||||
# Extract file name
|
||||
file_name = args.title or Path(args.pdf_path).name
|
||||
# Extract file name and type
|
||||
file_path = Path(args.file_path)
|
||||
file_name = args.title or file_path.name
|
||||
file_type = file_path.suffix.lstrip(".").lower()
|
||||
|
||||
print("=" * 80)
|
||||
print("📚 PDF Ingestion Script")
|
||||
print("📚 Document Ingestion Script")
|
||||
print("=" * 80)
|
||||
print(f"File: {args.pdf_path}")
|
||||
print(f"File: {args.file_path}")
|
||||
print(f"Title: {file_name}")
|
||||
print(f"Type: {file_type}")
|
||||
print(f"Department ID: {args.department_id or 'None'}")
|
||||
print(f"Region: {args.region_code or 'None'}")
|
||||
print(f"Qdrant URL: {qdrant_url}")
|
||||
|
|
@ -307,7 +304,7 @@ async def main():
|
|||
|
||||
try:
|
||||
# 1. Extract text
|
||||
text = extract_text_from_pdf(args.pdf_path)
|
||||
text = extract_text_from_file(args.file_path)
|
||||
|
||||
# 2. Chunk text
|
||||
chunks = chunk_text(text, args.chunk_size, args.chunk_overlap)
|
||||
|
|
|
|||
|
|
@ -69,9 +69,7 @@ services:
|
|||
JWT_ALGORITHM: ${JWT_ALGORITHM:-HS256}
|
||||
JWT_EXPIRATION_MINUTES: ${JWT_EXPIRATION_MINUTES:-15}
|
||||
REFRESH_TOKEN_EXPIRATION_DAYS: ${REFRESH_TOKEN_EXPIRATION_DAYS:-7}
|
||||
NOTEBOOKLLAMA_URL: ${NOTEBOOKLLAMA_URL}
|
||||
NOTEBOOKLLAMA_SERVICE_EMAIL: ${NOTEBOOKLLAMA_SERVICE_EMAIL}
|
||||
NOTEBOOKLLAMA_SERVICE_PASSWORD: ${NOTEBOOKLLAMA_SERVICE_PASSWORD}
|
||||
LLAMAPARSE_API_KEY: ${LLAMAPARSE_API_KEY:-}
|
||||
MAX_UPLOAD_SIZE_MB: ${MAX_UPLOAD_SIZE_MB:-100}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
'use client';
|
||||
|
||||
// ============================================
|
||||
// Admin Dashboard Page
|
||||
// ============================================
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/store/useAuthStore';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
|
@ -14,8 +10,13 @@ import { Input } from '@/components/ui/input';
|
|||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import apiClient from '@/lib/api-client';
|
||||
import { Shield, Users, Settings, BarChart3, Loader2, Save } from 'lucide-react';
|
||||
import type { User, UserRole } from '@/types';
|
||||
import {
|
||||
Shield, Users, Settings, BarChart3, Loader2, Save, FileText, Trash2,
|
||||
Globe, BookOpenCheck, Sparkles, Check, X, Puzzle,
|
||||
} from 'lucide-react';
|
||||
import type { User, UserRole, KnowledgeDocument } from '@/types';
|
||||
import { KnowledgeUploader } from '@/components/admin/knowledge-uploader';
|
||||
import { IntegrationsTab } from '@/components/admin/integrations-tab';
|
||||
|
||||
export default function AdminPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -30,27 +31,30 @@ export default function AdminPage() {
|
|||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
// Role protection
|
||||
// Knowledge Base state
|
||||
const [knowledgeDocs, setKnowledgeDocs] = useState<KnowledgeDocument[]>([]);
|
||||
const [knowledgeTotal, setKnowledgeTotal] = useState(0);
|
||||
const [knowledgeLoading, setKnowledgeLoading] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// URL scraping state
|
||||
const [scrapeUrl, setScrapeUrl] = useState('');
|
||||
const [scrapeDescription, setScrapeDescription] = useState('');
|
||||
const [scraping, setScraping] = useState(false);
|
||||
|
||||
const isAdmin = user?.role === 'super_admin' || user?.role === 'content_manager';
|
||||
const isSuperAdmin = user?.role === 'super_admin';
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.role !== 'super_admin') {
|
||||
router.push('/chat');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) { router.push('/login'); return; }
|
||||
if (!isAdmin) { router.push('/chat'); return; }
|
||||
setLoading(false);
|
||||
}, [user, router]);
|
||||
}, [user, router, isAdmin]);
|
||||
|
||||
// Fetch users
|
||||
useEffect(() => {
|
||||
if (!loading && user?.role === 'super_admin') {
|
||||
fetchUsers();
|
||||
}
|
||||
}, [loading, user]);
|
||||
if (!loading && isSuperAdmin) fetchUsers();
|
||||
}, [loading, isSuperAdmin]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
|
|
@ -61,49 +65,117 @@ export default function AdminPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const fetchKnowledgeDocs = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await apiClient.get('/admin/knowledge/documents');
|
||||
setKnowledgeDocs(data.documents || []);
|
||||
setKnowledgeTotal(data.total || 0);
|
||||
return data.documents || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch knowledge documents:', error);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && isAdmin) {
|
||||
setKnowledgeLoading(true);
|
||||
fetchKnowledgeDocs().finally(() => setKnowledgeLoading(false));
|
||||
}
|
||||
}, [loading, isAdmin, fetchKnowledgeDocs]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasPending = knowledgeDocs.some((d) => d.status === 'pending' || d.status === 'processing');
|
||||
if (hasPending && !pollIntervalRef.current) {
|
||||
pollIntervalRef.current = setInterval(() => fetchKnowledgeDocs(), 5000);
|
||||
} else if (!hasPending && pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
return () => { if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; } };
|
||||
}, [knowledgeDocs, fetchKnowledgeDocs]);
|
||||
|
||||
const handleDeleteKnowledgeDoc = async (docId: string) => {
|
||||
if (!confirm('Delete this document and its vectors?')) return;
|
||||
setDeletingId(docId);
|
||||
try {
|
||||
await apiClient.delete(`/admin/knowledge/documents/${docId}`);
|
||||
await fetchKnowledgeDocs();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete document:', error);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScrapeUrl = async () => {
|
||||
if (!scrapeUrl.trim()) return;
|
||||
setScraping(true);
|
||||
try {
|
||||
await apiClient.post('/admin/knowledge/scrape', {
|
||||
url: scrapeUrl.trim(),
|
||||
description: scrapeDescription.trim() || undefined,
|
||||
});
|
||||
setScrapeUrl('');
|
||||
setScrapeDescription('');
|
||||
await fetchKnowledgeDocs();
|
||||
} catch (error: unknown) {
|
||||
console.error('Scrape error:', error);
|
||||
const msg = error instanceof Error ? error.message : 'Failed to scrape URL';
|
||||
setMessage({ type: 'error', text: msg });
|
||||
} finally {
|
||||
setScraping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFeature = async (userId: string, feature: string, currentFeatures: string[]) => {
|
||||
const newFeatures = currentFeatures.includes(feature)
|
||||
? currentFeatures.filter((f) => f !== feature)
|
||||
: [...currentFeatures, feature];
|
||||
try {
|
||||
await apiClient.patch(`/admin/users/${userId}`, {
|
||||
allowed_features: newFeatures,
|
||||
features_override: true,
|
||||
});
|
||||
await fetchUsers();
|
||||
} catch (error) {
|
||||
console.error('Failed to update user features:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveLLMConfig = async () => {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
await apiClient.post('/admin/config', llmConfig);
|
||||
setMessage({ type: 'success', text: 'LLM configuration saved successfully!' });
|
||||
setMessage({ type: 'success', text: 'LLM configuration saved!' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to save configuration';
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: errorMessage,
|
||||
});
|
||||
const msg = error instanceof Error ? error.message : 'Failed to save configuration';
|
||||
setMessage({ type: 'error', text: msg });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading or access denied
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (user?.role !== 'super_admin') {
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8">
|
||||
<Card className="max-w-md p-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
||||
<Shield className="h-8 w-8 text-red-600" />
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
|
||||
<Shield className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<h2 className="mb-2 text-xl font-semibold text-gray-900">Access Denied</h2>
|
||||
<p className="mb-6 text-gray-600">
|
||||
You need Super Admin privileges to access this page.
|
||||
</p>
|
||||
<Button onClick={() => router.push('/chat')}>Go to Dashboard</Button>
|
||||
<h2 className="mb-2 text-xl font-semibold text-foreground">Access Denied</h2>
|
||||
<p className="mb-6 text-muted-foreground">You need admin privileges to access this page.</p>
|
||||
<Button onClick={() => router.push('/chat')}>Go to Chat</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -111,234 +183,344 @@ export default function AdminPage() {
|
|||
|
||||
const getRoleBadge = (role: UserRole) => {
|
||||
const variants: Record<UserRole, { label: string; className: string }> = {
|
||||
super_admin: { label: 'Super Admin', className: 'bg-purple-100 text-purple-700' },
|
||||
content_manager: { label: 'Content Manager', className: 'bg-blue-100 text-blue-700' },
|
||||
user: { label: 'User', className: 'bg-gray-100 text-gray-700' },
|
||||
super_admin: { label: 'Super Admin', className: 'bg-primary/10 text-primary' },
|
||||
content_manager: { label: 'Content Manager', className: 'bg-accent/10 text-accent' },
|
||||
user: { label: 'User', className: 'bg-muted text-muted-foreground' },
|
||||
};
|
||||
const variant = variants[role];
|
||||
const v = variants[role];
|
||||
return <Badge className={v.className} variant="outline">{v.label}</Badge>;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: KnowledgeDocument['status']) => {
|
||||
const config = {
|
||||
pending: { label: 'Pending', className: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||
processing: { label: 'Processing', className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
completed: { label: 'Completed', className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' },
|
||||
failed: { label: 'Failed', className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' },
|
||||
};
|
||||
const c = config[status];
|
||||
return (
|
||||
<Badge className={variant.className} variant="outline">
|
||||
{variant.label}
|
||||
<Badge className={c.className} variant="outline">
|
||||
{status === 'processing' && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
|
||||
{c.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const defaultTab = isSuperAdmin ? 'users' : 'knowledge';
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-white px-8 py-6">
|
||||
<div className="border-b bg-card px-8 py-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-600">
|
||||
<Shield className="h-6 w-6 text-white" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
|
||||
<Shield className="h-6 w-6 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Admin Dashboard</h1>
|
||||
<p className="text-sm text-gray-600">Manage users, configuration, and analytics</p>
|
||||
<h1 className="text-2xl font-bold text-foreground">Admin Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isSuperAdmin ? 'Manage users, configuration, and knowledge base' : 'Manage knowledge base documents'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-8">
|
||||
<Tabs defaultValue="users" className="w-full">
|
||||
<Tabs defaultValue={defaultTab} className="w-full">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="users" className="gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Users
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="llm" className="gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
LLM Config
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analytics" className="gap-2">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
<TabsTrigger value="users" className="gap-2"><Users className="h-4 w-4" />Users</TabsTrigger>
|
||||
<TabsTrigger value="llm" className="gap-2"><Settings className="h-4 w-4" />LLM Config</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
<TabsTrigger value="knowledge" className="gap-2"><FileText className="h-4 w-4" />Knowledge Base</TabsTrigger>
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="integrations" className="gap-2"><Puzzle className="h-4 w-4" />Integrations</TabsTrigger>
|
||||
)}
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="analytics" className="gap-2"><BarChart3 className="h-4 w-4" />Analytics</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{/* Users Tab */}
|
||||
<TabsContent value="users">
|
||||
<Card className="p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">User Management</h3>
|
||||
<Badge variant="outline">{users.length} Total Users</Badge>
|
||||
</div>
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="users">
|
||||
<Card className="p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-foreground">User Management</h3>
|
||||
<Badge variant="outline">{users.length} Users</Badge>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-sm text-gray-600">
|
||||
<th className="pb-3 pr-4 font-medium">Name</th>
|
||||
<th className="pb-3 pr-4 font-medium">Email</th>
|
||||
<th className="pb-3 pr-4 font-medium">Role</th>
|
||||
<th className="pb-3 font-medium">Last Login</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-8 text-center text-gray-500">
|
||||
No users found
|
||||
</td>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-sm text-muted-foreground">
|
||||
<th className="pb-3 pr-4 font-medium">Name</th>
|
||||
<th className="pb-3 pr-4 font-medium">Email</th>
|
||||
<th className="pb-3 pr-4 font-medium">Role</th>
|
||||
<th className="pb-3 pr-4 font-medium">Features</th>
|
||||
<th className="pb-3 font-medium">Last Login</th>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<tr key={u.id} className="border-b last:border-0">
|
||||
<td className="py-4 pr-4 font-medium text-gray-900">
|
||||
{u.display_name}
|
||||
</td>
|
||||
<td className="py-4 pr-4 text-gray-600">{u.email}</td>
|
||||
<td className="py-4 pr-4">{getRoleBadge(u.role)}</td>
|
||||
<td className="py-4 text-gray-600">
|
||||
{u.last_login_at
|
||||
? new Date(u.last_login_at).toLocaleDateString()
|
||||
: 'Never'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.length === 0 ? (
|
||||
<tr><td colSpan={5} className="py-8 text-center text-muted-foreground">No users found</td></tr>
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<tr key={u.id} className="border-b last:border-0">
|
||||
<td className="py-3 pr-4 font-medium text-foreground">{u.display_name}</td>
|
||||
<td className="py-3 pr-4 text-muted-foreground">{u.email}</td>
|
||||
<td className="py-3 pr-4">{getRoleBadge(u.role)}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleToggleFeature(u.id, 'rag', u.allowed_features || [])}
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors ${
|
||||
!u.allowed_features?.length || u.allowed_features?.includes('rag')
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
title="Toggle RAG access"
|
||||
>
|
||||
<BookOpenCheck className="h-3 w-3" />
|
||||
RAG
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleFeature(u.id, 'assistant', u.allowed_features || [])}
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors ${
|
||||
!u.allowed_features?.length || u.allowed_features?.includes('assistant')
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
title="Toggle Assistant access"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
PA
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 text-muted-foreground">
|
||||
{u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* LLM Config Tab */}
|
||||
<TabsContent value="llm">
|
||||
<Card className="p-6">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
LLM Provider Configuration
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Update API keys for OpenAI, Azure, and Anthropic
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label htmlFor="openai-key" className="text-base font-medium">
|
||||
OpenAI API Key
|
||||
</Label>
|
||||
<Input
|
||||
id="openai-key"
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
value={llmConfig.openai_api_key}
|
||||
onChange={(e) =>
|
||||
setLlmConfig({ ...llmConfig, openai_api_key: e.target.value })
|
||||
}
|
||||
className="mt-2"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Used for RAG embeddings and chat completions
|
||||
</p>
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="llm">
|
||||
<Card className="p-6">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-foreground">LLM Provider Configuration</h3>
|
||||
<p className="text-sm text-muted-foreground">Update API keys for LLM providers</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="azure-key" className="text-base font-medium">
|
||||
Azure OpenAI API Key
|
||||
</Label>
|
||||
<Input
|
||||
id="azure-key"
|
||||
type="password"
|
||||
placeholder="..."
|
||||
value={llmConfig.azure_api_key}
|
||||
onChange={(e) =>
|
||||
setLlmConfig({ ...llmConfig, azure_api_key: e.target.value })
|
||||
}
|
||||
className="mt-2"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Enterprise Azure OpenAI endpoint
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="anthropic-key" className="text-base font-medium">
|
||||
Anthropic API Key
|
||||
</Label>
|
||||
<Input
|
||||
id="anthropic-key"
|
||||
type="password"
|
||||
placeholder="sk-ant-..."
|
||||
value={llmConfig.anthropic_api_key}
|
||||
onChange={(e) =>
|
||||
setLlmConfig({ ...llmConfig, anthropic_api_key: e.target.value })
|
||||
}
|
||||
className="mt-2"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Used for Claude models (optional)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`rounded-lg p-4 text-sm ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-50 text-green-700'
|
||||
: 'bg-red-50 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<Label htmlFor="openai-key">OpenAI API Key</Label>
|
||||
<Input
|
||||
id="openai-key" type="password" placeholder="sk-..."
|
||||
value={llmConfig.openai_api_key}
|
||||
onChange={(e) => setLlmConfig({ ...llmConfig, openai_api_key: e.target.value })}
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="azure-key">Azure OpenAI API Key</Label>
|
||||
<Input
|
||||
id="azure-key" type="password" placeholder="..."
|
||||
value={llmConfig.azure_api_key}
|
||||
onChange={(e) => setLlmConfig({ ...llmConfig, azure_api_key: e.target.value })}
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="anthropic-key">Anthropic API Key</Label>
|
||||
<Input
|
||||
id="anthropic-key" type="password" placeholder="sk-ant-..."
|
||||
value={llmConfig.anthropic_api_key}
|
||||
onChange={(e) => setLlmConfig({ ...llmConfig, anthropic_api_key: e.target.value })}
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSaveLLMConfig}
|
||||
disabled={saving}
|
||||
className="gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
Save Configuration
|
||||
</>
|
||||
)}
|
||||
{message && (
|
||||
<div className={`rounded-lg p-3 text-sm ${
|
||||
message.type === 'success' ? 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400' : 'bg-destructive/10 text-destructive'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveLLMConfig} disabled={saving} className="gap-2">
|
||||
{saving ? <><Loader2 className="h-4 w-4 animate-spin" />Saving...</> : <><Save className="h-4 w-4" />Save Configuration</>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Knowledge Base Tab */}
|
||||
<TabsContent value="knowledge">
|
||||
<div className="space-y-6">
|
||||
{/* Upload Section */}
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-foreground">Upload Document</h3>
|
||||
<KnowledgeUploader
|
||||
onUploadComplete={() => fetchKnowledgeDocs()}
|
||||
onUploadError={(err) => console.error('Upload error:', err)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* URL Scraping Section */}
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-primary" />
|
||||
Add URL to Knowledge Base
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="scrape-url">URL</Label>
|
||||
<Input
|
||||
id="scrape-url"
|
||||
placeholder="https://example.com/page"
|
||||
value={scrapeUrl}
|
||||
onChange={(e) => setScrapeUrl(e.target.value)}
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="scrape-desc">Description (optional)</Label>
|
||||
<Input
|
||||
id="scrape-desc"
|
||||
placeholder="Brief description of the page content"
|
||||
value={scrapeDescription}
|
||||
onChange={(e) => setScrapeDescription(e.target.value)}
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleScrapeUrl} disabled={scraping || !scrapeUrl.trim()} className="gap-2">
|
||||
{scraping ? <><Loader2 className="h-4 w-4 animate-spin" />Scraping...</> : <><Globe className="h-4 w-4" />Scrape & Index</>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
{/* Documents Table */}
|
||||
<Card className="p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-foreground">Documents</h3>
|
||||
<Badge variant="outline">{knowledgeTotal} Total</Badge>
|
||||
</div>
|
||||
|
||||
{knowledgeLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-sm text-muted-foreground">
|
||||
<th className="pb-3 pr-4 font-medium">File Name</th>
|
||||
<th className="pb-3 pr-4 font-medium">Type</th>
|
||||
<th className="pb-3 pr-4 font-medium">Size</th>
|
||||
<th className="pb-3 pr-4 font-medium">Status</th>
|
||||
<th className="pb-3 pr-4 font-medium">Chunks</th>
|
||||
<th className="pb-3 pr-4 font-medium">Date</th>
|
||||
<th className="pb-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{knowledgeDocs.length === 0 ? (
|
||||
<tr><td colSpan={7} className="py-8 text-center text-muted-foreground">No documents uploaded yet.</td></tr>
|
||||
) : (
|
||||
knowledgeDocs.map((doc) => (
|
||||
<tr key={doc.id} className="border-b last:border-0">
|
||||
<td className="max-w-[200px] truncate py-3 pr-4 font-medium text-foreground" title={doc.file_name}>
|
||||
{doc.file_name}
|
||||
</td>
|
||||
<td className="py-3 pr-4 uppercase text-muted-foreground text-xs">{doc.file_type}</td>
|
||||
<td className="py-3 pr-4 text-muted-foreground">{formatFileSize(doc.file_size)}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div>
|
||||
{getStatusBadge(doc.status)}
|
||||
{doc.status === 'failed' && doc.error_message && (
|
||||
<p className="mt-1 max-w-[200px] truncate text-xs text-destructive" title={doc.error_message}>
|
||||
{doc.error_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-muted-foreground">{doc.vector_count}</td>
|
||||
<td className="py-3 pr-4 text-muted-foreground">{new Date(doc.created_at).toLocaleDateString()}</td>
|
||||
<td className="py-3">
|
||||
<Button
|
||||
variant="ghost" size="sm"
|
||||
onClick={() => handleDeleteKnowledgeDoc(doc.id)}
|
||||
disabled={deletingId === doc.id}
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{deletingId === doc.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Integrations Tab */}
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="integrations">
|
||||
<IntegrationsTab />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Analytics Tab */}
|
||||
<TabsContent value="analytics">
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">Usage Analytics</h3>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-2 text-sm text-gray-600">Total Conversations</div>
|
||||
<div className="text-3xl font-bold text-gray-900">0</div>
|
||||
<div className="mt-1 text-xs text-gray-500">All modes</div>
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="analytics">
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-foreground">Usage Analytics</h3>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<div className="mb-1 text-sm text-muted-foreground">Total Conversations</div>
|
||||
<div className="text-3xl font-bold text-foreground">0</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<div className="mb-1 text-sm text-muted-foreground">Total Messages</div>
|
||||
<div className="text-3xl font-bold text-foreground">0</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<div className="mb-1 text-sm text-muted-foreground">Active Users</div>
|
||||
<div className="text-3xl font-bold text-foreground">{users.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-2 text-sm text-gray-600">Total Messages</div>
|
||||
<div className="text-3xl font-bold text-gray-900">0</div>
|
||||
<div className="mt-1 text-xs text-gray-500">User + Assistant</div>
|
||||
<div className="mt-6 rounded-lg bg-primary/5 p-4 text-sm text-primary">
|
||||
<strong>Coming Soon:</strong> Detailed analytics with charts, usage trends, and performance metrics.
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-2 text-sm text-gray-600">Active Users</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{users.length}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">Last 30 days</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-lg bg-blue-50 p-4 text-sm text-blue-700">
|
||||
<strong>Coming Soon:</strong> Detailed analytics with charts, usage trends, and
|
||||
performance metrics.
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,71 +8,128 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--background: 0 0% 98%;
|
||||
--foreground: 222 47% 11%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
--popover-foreground: 222 47% 11%;
|
||||
--primary: 221 83% 53%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222 47% 11%;
|
||||
--muted: 210 40% 96%;
|
||||
--muted-foreground: 215 16% 47%;
|
||||
--accent: 262 83% 58%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214 32% 91%;
|
||||
--input: 214 32% 91%;
|
||||
--ring: 221 83% 53%;
|
||||
--chart-1: 221 83% 53%;
|
||||
--chart-2: 262 83% 58%;
|
||||
--chart-3: 142 71% 45%;
|
||||
--chart-4: 43 96% 56%;
|
||||
--chart-5: 0 84% 60%;
|
||||
--radius: 0.75rem;
|
||||
|
||||
/* Custom brand tokens */
|
||||
--brand-rag: 221 83% 53%;
|
||||
--brand-assistant: 262 83% 58%;
|
||||
--brand-gradient-from: 221 83% 53%;
|
||||
--brand-gradient-to: 262 83% 58%;
|
||||
--sidebar-width: 280px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--background: 222 47% 5%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222 47% 8%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222 47% 8%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 222 47% 5%;
|
||||
--secondary: 217 33% 17%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217 33% 17%;
|
||||
--muted-foreground: 215 20% 65%;
|
||||
--accent: 263 70% 50%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217 33% 17%;
|
||||
--input: 217 33% 17%;
|
||||
--ring: 217 91% 60%;
|
||||
--chart-1: 217 91% 60%;
|
||||
--chart-2: 263 70% 50%;
|
||||
--chart-3: 142 71% 45%;
|
||||
--chart-4: 43 96% 56%;
|
||||
--chart-5: 0 63% 31%;
|
||||
|
||||
--brand-rag: 217 91% 60%;
|
||||
--brand-assistant: 263 70% 50%;
|
||||
--brand-gradient-from: 217 91% 60%;
|
||||
--brand-gradient-to: 263 70% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
@layer utilities {
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--muted-foreground));
|
||||
}
|
||||
}
|
||||
|
||||
/* Typing indicator animation */
|
||||
@keyframes typing-dot {
|
||||
0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
|
||||
30% { opacity: 1; transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
.typing-dot {
|
||||
animation: typing-dot 1.4s infinite ease-in-out;
|
||||
}
|
||||
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
/* Gradient text */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, hsl(var(--brand-gradient-from)), hsl(var(--brand-gradient-to)));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Smooth page transitions */
|
||||
.page-transition {
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
'use client';
|
||||
|
||||
// ============================================
|
||||
// Login Page with Dev Mode Support
|
||||
// ============================================
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LoginButton } from '@/components/auth/login-button';
|
||||
|
|
@ -11,6 +7,7 @@ import { useAuthStore } from '@/store/useAuthStore';
|
|||
import { loginDev } from '@/lib/auth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { BookOpenCheck, Sparkles, Shield } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -18,11 +15,8 @@ export default function LoginPage() {
|
|||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Redirect if already authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.push('/chat');
|
||||
}
|
||||
if (isAuthenticated) router.push('/chat');
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
const handleDevLogin = async (role: string) => {
|
||||
|
|
@ -30,13 +24,11 @@ export default function LoginPage() {
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
// Map role to email for consistency
|
||||
const emailMap: Record<string, string> = {
|
||||
super_admin: 'admin@nexus.dev',
|
||||
content_manager: 'manager@nexus.dev',
|
||||
user: 'user@nexus.dev',
|
||||
};
|
||||
|
||||
const displayNameMap: Record<string, string> = {
|
||||
super_admin: 'Admin User',
|
||||
content_manager: 'Content Manager',
|
||||
|
|
@ -45,264 +37,167 @@ export default function LoginPage() {
|
|||
|
||||
const email = emailMap[role];
|
||||
const displayName = displayNameMap[role];
|
||||
|
||||
// Call dev login endpoint
|
||||
const user = await loginDev(email, role, displayName);
|
||||
|
||||
// Get tokens from localStorage (set by loginDev)
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (!accessToken || !refreshToken) throw new Error('Failed to retrieve tokens');
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw new Error('Failed to retrieve tokens');
|
||||
}
|
||||
|
||||
// Update Zustand store
|
||||
login(user, accessToken, refreshToken);
|
||||
|
||||
// Redirect to chat
|
||||
router.push('/chat');
|
||||
} catch (err: unknown) {
|
||||
console.error('Dev login error:', err);
|
||||
let errorMessage = 'Failed to login';
|
||||
if (err instanceof Error) {
|
||||
errorMessage = err.message;
|
||||
} else if (typeof err === 'object' && err !== null && 'response' in err) {
|
||||
const errObj = err as Record<string, unknown>;
|
||||
if (errObj.response && typeof errObj.response === 'object') {
|
||||
const response = errObj.response as Record<string, unknown>;
|
||||
if (response.data && typeof response.data === 'object') {
|
||||
const data = response.data as Record<string, unknown>;
|
||||
if (typeof data.detail === 'string') {
|
||||
errorMessage = data.detail;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (err instanceof Error) errorMessage = err.message;
|
||||
setError(errorMessage);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50">
|
||||
<div className="w-full max-w-md px-6">
|
||||
{/* Card */}
|
||||
<div className="rounded-2xl bg-white p-8 shadow-2xl">
|
||||
{/* Logo/Icon */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-600 shadow-lg">
|
||||
<svg
|
||||
className="h-10 w-10 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
||||
/>
|
||||
<div className="flex min-h-screen">
|
||||
{/* Left Panel — Branding */}
|
||||
<div className="hidden lg:flex lg:w-1/2 flex-col justify-between bg-gradient-to-br from-primary via-primary/90 to-accent p-12 text-white">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/20 backdrop-blur-sm">
|
||||
<svg className="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="mb-2 text-3xl font-bold text-gray-900">
|
||||
Welcome to Nexus
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600">Enterprise AI Hub</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-8 rounded-lg bg-blue-50 p-4">
|
||||
<p className="text-sm text-gray-700">
|
||||
Access powerful AI capabilities:
|
||||
</p>
|
||||
<ul className="mt-3 space-y-2 text-sm text-gray-600">
|
||||
<li className="flex items-start">
|
||||
<svg
|
||||
className="mr-2 mt-0.5 h-4 w-4 flex-shrink-0 text-blue-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
<strong>RAG Chat</strong> - Query your corporate knowledge
|
||||
base
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<svg
|
||||
className="mr-2 mt-0.5 h-4 w-4 flex-shrink-0 text-blue-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
<strong>Notebooks</strong> - Analyze documents with AI
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<svg
|
||||
className="mr-2 mt-0.5 h-4 w-4 flex-shrink-0 text-blue-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
<strong>Assistant</strong> - Productivity tools & automation
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Production Login Section */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-700">
|
||||
Production Login
|
||||
</span>
|
||||
<span className="rounded-full bg-gray-200 px-2 py-0.5 text-xs text-gray-600">
|
||||
Not Configured
|
||||
</span>
|
||||
</div>
|
||||
<LoginButton
|
||||
className="w-full opacity-50 cursor-not-allowed"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
alert('Azure AD is not configured yet. Use Dev Login below.');
|
||||
}}
|
||||
/>
|
||||
<p className="mt-2 text-center text-xs text-gray-500">
|
||||
Microsoft Entra ID integration coming soon
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
{/* Dev Login Section */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-700">
|
||||
Development Login
|
||||
</span>
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-700">
|
||||
Dev Only
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dev Login Buttons */}
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
onClick={() => handleDevLogin('super_admin')}
|
||||
disabled={isLoading}
|
||||
className="w-full bg-purple-600 hover:bg-purple-700"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
|
||||
<span>Logging in...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Login as Super Admin</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleDevLogin('content_manager')}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Login as Content Manager</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleDevLogin('user')}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Login as Regular User</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-center text-xs text-gray-500">
|
||||
Choose a role to quickly access the app in development mode
|
||||
</p>
|
||||
<span className="text-2xl font-bold">Nexus AI</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
<p className="mt-6 text-center text-xs text-gray-600">
|
||||
By signing in, you agree to our Terms of Service and Privacy Policy
|
||||
</p>
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold leading-tight">
|
||||
Your enterprise<br />AI companion
|
||||
</h1>
|
||||
<p className="mt-4 text-lg text-white/80">
|
||||
Two powerful assistants working together to boost your productivity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4 rounded-xl bg-white/10 p-4 backdrop-blur-sm">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-white/20">
|
||||
<BookOpenCheck className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Oliver Process Helper</h3>
|
||||
<p className="mt-1 text-sm text-white/70">
|
||||
Query your corporate knowledge base with AI-powered search and citations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 rounded-xl bg-white/10 p-4 backdrop-blur-sm">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-white/20">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Personal Assistant</h3>
|
||||
<p className="mt-1 text-sm text-white/70">
|
||||
Your personal AI for writing, brainstorming, analysis, and general tasks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-white/50">Enterprise AI Hub by Oliver Solutions</p>
|
||||
</div>
|
||||
|
||||
{/* Right Panel — Login Form */}
|
||||
<div className="flex flex-1 items-center justify-center bg-background p-8">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Mobile Logo */}
|
||||
<div className="mb-8 text-center lg:hidden">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-accent shadow-lg">
|
||||
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Nexus AI</h1>
|
||||
<p className="text-sm text-muted-foreground">Enterprise AI Hub</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-8 shadow-sm">
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-semibold text-foreground">Welcome back</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Sign in to continue to Nexus</p>
|
||||
</div>
|
||||
|
||||
{/* Microsoft SSO */}
|
||||
<div className="mb-6">
|
||||
<LoginButton
|
||||
className="w-full"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
alert('Azure AD SSO not configured yet. Use Development Login below.');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
{/* Dev Login */}
|
||||
<div>
|
||||
<p className="mb-3 text-center text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Development Login
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
onClick={() => handleDevLogin('super_admin')}
|
||||
disabled={isLoading}
|
||||
className="w-full bg-primary hover:bg-primary/90"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
<span>Signing in...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>Super Admin</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleDevLogin('content_manager')}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Content Manager
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleDevLogin('user')}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Regular User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-muted-foreground">
|
||||
By signing in, you agree to our Terms of Service and Privacy Policy
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,313 +0,0 @@
|
|||
'use client';
|
||||
|
||||
// ============================================
|
||||
// Notebook Mode Page with Two-Column Layout
|
||||
// ============================================
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useNotebookStore } from '@/store/useNotebookStore';
|
||||
import { FileUploader } from '@/components/notebook/file-uploader';
|
||||
import { FileList } from '@/components/notebook/file-list';
|
||||
import { NotebookChat } from '@/components/notebook/notebook-chat';
|
||||
import { SessionSelector } from '@/components/notebook/session-selector';
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Pin, PinOff, Trash2, Clock } from 'lucide-react';
|
||||
import apiClient from '@/lib/api-client';
|
||||
import type { FileUploadResponse } from '@/types';
|
||||
|
||||
export default function NotebooksPage() {
|
||||
const {
|
||||
sessionId,
|
||||
title,
|
||||
isPinned,
|
||||
expiresAt,
|
||||
files,
|
||||
setSession,
|
||||
updateSessionPin,
|
||||
addFile,
|
||||
updateFile,
|
||||
setFiles,
|
||||
clearSession,
|
||||
} = useNotebookStore();
|
||||
|
||||
// Load session data when sessionId changes
|
||||
useEffect(() => {
|
||||
const loadSession = async () => {
|
||||
if (sessionId) {
|
||||
try {
|
||||
const { data } = await apiClient.get(`/notebook/${sessionId}`);
|
||||
|
||||
setSession(
|
||||
data.session_id,
|
||||
data.conversation_id,
|
||||
data.title,
|
||||
data.is_pinned,
|
||||
data.expires_at
|
||||
);
|
||||
|
||||
// Load files
|
||||
if (data.files && data.files.length > 0) {
|
||||
setFiles(data.files);
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load notebook session:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSession();
|
||||
}, [sessionId, setSession, setFiles]);
|
||||
|
||||
const handleSelectSession = async (selectedSessionId: string) => {
|
||||
try {
|
||||
const { data } = await apiClient.get(`/notebook/${selectedSessionId}`);
|
||||
|
||||
setSession(
|
||||
data.session_id,
|
||||
data.conversation_id,
|
||||
data.title,
|
||||
data.is_pinned,
|
||||
data.expires_at
|
||||
);
|
||||
|
||||
if (data.files && data.files.length > 0) {
|
||||
setFiles(data.files);
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load session:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewSession = async () => {
|
||||
try {
|
||||
const { data } = await apiClient.post('/notebook/create');
|
||||
|
||||
setSession(
|
||||
data.session_id,
|
||||
data.conversation_id,
|
||||
data.title,
|
||||
data.is_pinned,
|
||||
data.expires_at
|
||||
);
|
||||
|
||||
setFiles([]);
|
||||
} catch (error) {
|
||||
console.error('Failed to create notebook session:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadComplete = (uploadResponse: FileUploadResponse) => {
|
||||
// Add file to state
|
||||
addFile({
|
||||
file_id: uploadResponse.file_id,
|
||||
file_name: uploadResponse.file_name,
|
||||
file_size: uploadResponse.file_size,
|
||||
file_type: uploadResponse.file_name.split('.').pop() || 'unknown',
|
||||
processing_status: uploadResponse.processing_status,
|
||||
processing_error: null,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
processed_at: null,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePinToggle = async () => {
|
||||
if (!sessionId) return;
|
||||
|
||||
try {
|
||||
const endpoint = isPinned
|
||||
? `/notebook/${sessionId}/unpin`
|
||||
: `/notebook/${sessionId}/pin`;
|
||||
|
||||
const { data } = await apiClient.post(endpoint);
|
||||
|
||||
updateSessionPin(data.is_pinned, data.expires_at);
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle pin:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSession = async () => {
|
||||
if (!sessionId) return;
|
||||
|
||||
if (
|
||||
!confirm('Are you sure you want to delete this notebook session? All files and chat history will be lost.')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/notebook/${sessionId}`);
|
||||
clearSession();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete session:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getTimeUntilExpiry = (): string | null => {
|
||||
if (!expiresAt || isPinned) return null;
|
||||
|
||||
const now = new Date();
|
||||
const expiry = new Date(expiresAt);
|
||||
const diffMs = expiry.getTime() - now.getTime();
|
||||
|
||||
if (diffMs < 0) return 'Expired';
|
||||
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-orange-100">
|
||||
<svg
|
||||
className="h-8 w-8 text-orange-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="mb-2 text-lg font-semibold text-gray-900">
|
||||
No Session Selected
|
||||
</h2>
|
||||
<p className="mb-6 text-sm text-gray-600">
|
||||
Select an existing notebook session or create a new one
|
||||
</p>
|
||||
<div className="w-64 mx-auto">
|
||||
<SessionSelector
|
||||
currentSessionId={null}
|
||||
onSessionSelect={handleSelectSession}
|
||||
onNewSession={handleNewSession}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Left Column: File Management (30%) */}
|
||||
<div className="w-[30%] border-r bg-gray-50 p-4 space-y-4 overflow-y-auto">
|
||||
{/* Session Selector */}
|
||||
<SessionSelector
|
||||
currentSessionId={sessionId}
|
||||
onSessionSelect={handleSelectSession}
|
||||
onNewSession={handleNewSession}
|
||||
/>
|
||||
|
||||
{/* Session Header */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-sm">{title}</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">
|
||||
{isPinned ? (
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<Pin className="h-3 w-3" />
|
||||
Pinned
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Expires in {getTimeUntilExpiry()}
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={handlePinToggle}
|
||||
title={isPinned ? 'Unpin session' : 'Pin session'}
|
||||
>
|
||||
{isPinned ? (
|
||||
<PinOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Pin className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={handleDeleteSession}
|
||||
title="Delete session"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* File Uploader */}
|
||||
<FileUploader
|
||||
sessionId={sessionId}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
/>
|
||||
|
||||
{/* File List */}
|
||||
<FileList
|
||||
sessionId={sessionId}
|
||||
files={files}
|
||||
onFileUpdate={updateFile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Chat Interface (70%) */}
|
||||
<div className="flex-1 flex flex-col bg-white">
|
||||
{/* Header */}
|
||||
<div className="border-b px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">
|
||||
Document Analysis
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
Ask questions about your uploaded documents
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-orange-50 text-orange-700 border-orange-200"
|
||||
>
|
||||
{files.filter((f) => f.processing_status === 'completed').length} /{' '}
|
||||
{files.length} Ready
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat */}
|
||||
<NotebookChat sessionId={sessionId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +1,10 @@
|
|||
'use client';
|
||||
|
||||
// ============================================
|
||||
// Profile Page
|
||||
// ============================================
|
||||
|
||||
import { useAuthStore } from '@/store/useAuthStore';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { User as UserIcon, Mail, Shield, Calendar } from 'lucide-react';
|
||||
import { User as UserIcon, Mail, Shield, Calendar, BookOpenCheck, Sparkles, Wrench } from 'lucide-react';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user } = useAuthStore();
|
||||
|
|
@ -16,80 +12,64 @@ export default function ProfilePage() {
|
|||
const getUserInitials = () => {
|
||||
if (!user?.display_name) return 'U';
|
||||
const names = user.display_name.split(' ');
|
||||
if (names.length >= 2) {
|
||||
return `${names[0][0]}${names[1][0]}`.toUpperCase();
|
||||
}
|
||||
if (names.length >= 2) return `${names[0][0]}${names[1][0]}`.toUpperCase();
|
||||
return user.display_name[0].toUpperCase();
|
||||
};
|
||||
|
||||
const getRoleBadge = () => {
|
||||
const roleMap: Record<string, { label: string; className: string }> = {
|
||||
super_admin: { label: 'Super Admin', className: 'bg-purple-100 text-purple-700' },
|
||||
content_manager: { label: 'Content Manager', className: 'bg-blue-100 text-blue-700' },
|
||||
user: { label: 'User', className: 'bg-gray-100 text-gray-700' },
|
||||
super_admin: { label: 'Super Admin', className: 'bg-primary/10 text-primary border-primary/20' },
|
||||
content_manager: { label: 'Content Manager', className: 'bg-accent/10 text-accent border-accent/20' },
|
||||
user: { label: 'User', className: 'bg-muted text-muted-foreground' },
|
||||
};
|
||||
const role = roleMap[user?.role || 'user'];
|
||||
return (
|
||||
<Badge className={role.className} variant="outline">
|
||||
{role.label}
|
||||
</Badge>
|
||||
);
|
||||
return <Badge className={role.className} variant="outline">{role.label}</Badge>;
|
||||
};
|
||||
|
||||
const features = user?.allowed_features || [];
|
||||
const hasAllAccess = !features.length;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-white px-8 py-6">
|
||||
<div className="border-b bg-card px-8 py-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-600">
|
||||
<UserIcon className="h-6 w-6 text-white" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
|
||||
<UserIcon className="h-6 w-6 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">My Profile</h1>
|
||||
<p className="text-sm text-gray-600">View your account information</p>
|
||||
<h1 className="text-2xl font-bold text-foreground">My Profile</h1>
|
||||
<p className="text-sm text-muted-foreground">View your account information</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-8">
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
{/* Profile Card */}
|
||||
<Card className="p-8">
|
||||
<div className="flex items-start gap-6">
|
||||
<Avatar className="h-24 w-24">
|
||||
<AvatarFallback className="bg-gradient-to-br from-blue-600 to-indigo-600 text-2xl font-semibold text-white">
|
||||
<Avatar className="h-20 w-20">
|
||||
<AvatarFallback className="bg-gradient-to-br from-primary to-accent text-xl font-semibold text-white">
|
||||
{getUserInitials()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{user?.display_name || 'User'}
|
||||
</h2>
|
||||
<h2 className="text-2xl font-bold text-foreground">{user?.display_name || 'User'}</h2>
|
||||
{getRoleBadge()}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Mail className="h-4 w-4" />
|
||||
<span className="text-sm">{user?.email || 'No email'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span className="text-sm">
|
||||
Role: {user?.role.replace('_', ' ').toUpperCase() || 'USER'}
|
||||
</span>
|
||||
<span className="text-sm">Role: {(user?.role || 'user').replace('_', ' ').toUpperCase()}</span>
|
||||
</div>
|
||||
|
||||
{user?.last_login_at && (
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="text-sm">
|
||||
Last login: {new Date(user.last_login_at).toLocaleString()}
|
||||
</span>
|
||||
<span className="text-sm">Last login: {new Date(user.last_login_at).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -97,76 +77,47 @@ export default function ProfilePage() {
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Additional Info */}
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">Account Details</h3>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">User ID</span>
|
||||
<span className="font-mono text-gray-900">{user?.id.slice(0, 8)}...</span>
|
||||
<h3 className="mb-4 text-lg font-semibold text-foreground">Feature Access</h3>
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`h-2 w-2 rounded-full ${hasAllAccess || features.includes('rag') ? 'bg-green-500' : 'bg-muted'}`} />
|
||||
<BookOpenCheck className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm text-foreground">Oliver Process Helper (RAG)</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Account Created</span>
|
||||
<span className="text-gray-900">
|
||||
{user?.created_at
|
||||
? new Date(user.created_at).toLocaleDateString()
|
||||
: 'Unknown'}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`h-2 w-2 rounded-full ${hasAllAccess || features.includes('assistant') ? 'bg-green-500' : 'bg-muted'}`} />
|
||||
<Sparkles className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm text-foreground">Personal Assistant</span>
|
||||
</div>
|
||||
|
||||
{user?.department_id && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Department</span>
|
||||
<span className="text-gray-900">
|
||||
{user.department_id.slice(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user?.country_id && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Country</span>
|
||||
<span className="text-gray-900">{user.country_id.slice(0, 8)}...</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<Wrench className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-foreground">Productivity Tools</span>
|
||||
</div>
|
||||
{(user?.role === 'super_admin' || user?.role === 'content_manager') && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
<Shield className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium text-primary">Admin Dashboard</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Permissions */}
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">Permissions</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span className="text-sm text-gray-700">Access RAG Chat</span>
|
||||
<h3 className="mb-4 text-lg font-semibold text-foreground">Account Details</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">User ID</span>
|
||||
<span className="font-mono text-foreground">{user?.id?.slice(0, 8)}...</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span className="text-sm text-gray-700">Access Notebooks</span>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Account Created</span>
|
||||
<span className="text-foreground">
|
||||
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span className="text-sm text-gray-700">Access Assistant Tools</span>
|
||||
</div>
|
||||
{user?.role === 'super_admin' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-purple-500" />
|
||||
<span className="text-sm font-medium text-purple-700">
|
||||
Admin Dashboard Access
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{user?.role === 'content_manager' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<span className="text-sm font-medium text-blue-700">
|
||||
Content Management Access
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,21 @@
|
|||
'use client';
|
||||
|
||||
// ============================================
|
||||
// File Uploader Component with Dropzone
|
||||
// ============================================
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { Upload, FileText, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import type { FileUploadResponse } from '@/types';
|
||||
import type { KnowledgeDocumentUploadResponse } from '@/types';
|
||||
|
||||
interface FileUploaderProps {
|
||||
sessionId: string;
|
||||
onUploadComplete: (file: FileUploadResponse) => void;
|
||||
interface KnowledgeUploaderProps {
|
||||
onUploadComplete: (doc: KnowledgeDocumentUploadResponse) => void;
|
||||
onUploadError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export function FileUploader({
|
||||
sessionId,
|
||||
export function KnowledgeUploader({
|
||||
onUploadComplete,
|
||||
onUploadError,
|
||||
}: FileUploaderProps) {
|
||||
}: KnowledgeUploaderProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [currentFileName, setCurrentFileName] = useState<string | null>(null);
|
||||
|
|
@ -31,38 +25,32 @@ export function FileUploader({
|
|||
async (acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length === 0) return;
|
||||
|
||||
const file = acceptedFiles[0]; // Only handle one file at a time
|
||||
const file = acceptedFiles[0];
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
setCurrentFileName(file.name);
|
||||
setError(null);
|
||||
|
||||
// Validate file size (100MB)
|
||||
const maxSize = 100 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
setError('File size exceeds 100MB limit');
|
||||
setUploading(false);
|
||||
setCurrentFileName(null);
|
||||
if (onUploadError) {
|
||||
onUploadError('File size exceeds 100MB limit');
|
||||
}
|
||||
onUploadError?.('File size exceeds 100MB limit');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
setUploadProgress(30);
|
||||
|
||||
// Upload file with progress tracking
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/notebook/${sessionId}/upload`,
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/admin/knowledge/upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
|
@ -72,33 +60,25 @@ export function FileUploader({
|
|||
throw new Error(typeof errorData.detail === 'string' ? errorData.detail : 'Upload failed');
|
||||
}
|
||||
|
||||
const data: FileUploadResponse = await response.json();
|
||||
|
||||
// Simulate upload progress (since we can't track real progress with fetch)
|
||||
const data: KnowledgeDocumentUploadResponse = await response.json();
|
||||
setUploadProgress(100);
|
||||
|
||||
// Call success callback
|
||||
onUploadComplete(data);
|
||||
|
||||
// Reset state
|
||||
setTimeout(() => {
|
||||
setUploading(false);
|
||||
setCurrentFileName(null);
|
||||
setUploadProgress(0);
|
||||
}, 500);
|
||||
} catch (err: unknown) {
|
||||
console.error('Upload error:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to upload file';
|
||||
setError(errorMessage);
|
||||
if (onUploadError) {
|
||||
onUploadError(errorMessage);
|
||||
}
|
||||
onUploadError?.(errorMessage);
|
||||
setUploading(false);
|
||||
setCurrentFileName(null);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
},
|
||||
[sessionId, onUploadComplete, onUploadError]
|
||||
[onUploadComplete, onUploadError]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
|
|
@ -107,91 +87,69 @@ export function FileUploader({
|
|||
disabled: uploading,
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
|
||||
['.docx'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||
'application/msword': ['.doc'],
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation':
|
||||
['.pptx'],
|
||||
'application/vnd.ms-powerpoint': ['.ppt'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
|
||||
['.xlsx'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||
'application/vnd.ms-excel': ['.xls'],
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
|
||||
'application/vnd.ms-powerpoint': ['.ppt'],
|
||||
'text/plain': ['.txt'],
|
||||
'text/csv': ['.csv'],
|
||||
'text/markdown': ['.md'],
|
||||
'image/*': ['.jpg', '.jpeg', '.png', '.gif'],
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Dropzone */}
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'relative rounded-lg border-2 border-dashed p-8 text-center transition-all cursor-pointer',
|
||||
'relative cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition-all',
|
||||
isDragActive
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-300 bg-white hover:border-gray-400 hover:bg-gray-50',
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border bg-background hover:border-muted-foreground/30 hover:bg-muted/50',
|
||||
uploading && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-12 w-12 items-center justify-center rounded-full transition-colors',
|
||||
isDragActive
|
||||
? 'bg-blue-100 text-blue-600'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex h-12 w-12 items-center justify-center rounded-full transition-colors',
|
||||
isDragActive ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
|
||||
)}>
|
||||
<Upload className="h-6 w-6" />
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{isDragActive ? 'Drop file here' : 'Upload a document'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Drag & drop or click to browse
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Drag & drop or click to browse</p>
|
||||
</div>
|
||||
|
||||
{/* Supported formats */}
|
||||
<p className="text-xs text-gray-400">
|
||||
PDF, DOCX, XLSX, TXT, CSV, Images (Max 100MB)
|
||||
<p className="text-xs text-muted-foreground">
|
||||
PDF, DOCX, DOC, XLSX, XLS, PPTX, PPT, CSV, TXT (Max 100MB)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Progress */}
|
||||
{uploading && currentFileName && (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="h-5 w-5 flex-shrink-0 text-blue-600" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{currentFileName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Uploading...</p>
|
||||
<FileText className="h-5 w-5 flex-shrink-0 text-primary" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">{currentFileName}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Uploading...</p>
|
||||
<Progress value={uploadProgress} className="mt-2 h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && !uploading && (
|
||||
<div className="rounded-lg bg-red-50 p-4">
|
||||
<div className="rounded-lg bg-destructive/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<X className="h-5 w-5 flex-shrink-0 text-red-600" />
|
||||
<X className="h-5 w-5 flex-shrink-0 text-destructive" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-red-900">Upload Failed</p>
|
||||
<p className="text-sm text-red-700 mt-1">{error}</p>
|
||||
<p className="text-sm font-medium text-destructive">Upload Failed</p>
|
||||
<p className="mt-1 text-sm text-destructive/80">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,9 +1,5 @@
|
|||
'use client';
|
||||
|
||||
// ============================================
|
||||
// Chat Input Component with Auto-Resize
|
||||
// ============================================
|
||||
|
||||
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Send, StopCircle } from 'lucide-react';
|
||||
|
|
@ -15,6 +11,7 @@ interface ChatInputProps {
|
|||
disabled?: boolean;
|
||||
isStreaming?: boolean;
|
||||
placeholder?: string;
|
||||
mode?: 'rag' | 'assistant';
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
|
|
@ -22,12 +19,12 @@ export function ChatInput({
|
|||
onStop,
|
||||
disabled = false,
|
||||
isStreaming = false,
|
||||
placeholder = 'Ask about your knowledge base...',
|
||||
placeholder = 'Type a message...',
|
||||
mode = 'rag',
|
||||
}: ChatInputProps) {
|
||||
const [message, setMessage] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
|
|
@ -41,16 +38,11 @@ export function ChatInput({
|
|||
if (trimmedMessage && !disabled && !isStreaming) {
|
||||
onSend(trimmedMessage);
|
||||
setMessage('');
|
||||
|
||||
// Reset textarea height
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Send on Enter (without Shift)
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
|
|
@ -58,16 +50,17 @@ export function ChatInput({
|
|||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (onStop && isStreaming) {
|
||||
onStop();
|
||||
}
|
||||
if (onStop && isStreaming) onStop();
|
||||
};
|
||||
|
||||
const sendBtnColor = mode === 'rag'
|
||||
? 'bg-primary hover:bg-primary/90'
|
||||
: 'bg-accent hover:bg-accent/90';
|
||||
|
||||
return (
|
||||
<div className="border-t bg-white">
|
||||
<div className="max-w-3xl mx-auto px-4 py-4">
|
||||
<div className="border-t bg-card">
|
||||
<div className="mx-auto max-w-3xl px-4 py-3">
|
||||
<div className="relative flex items-end gap-2">
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
|
|
@ -77,38 +70,37 @@ export function ChatInput({
|
|||
placeholder={placeholder}
|
||||
rows={1}
|
||||
className={cn(
|
||||
'flex-1 resize-none rounded-xl border border-gray-300 bg-white px-4 py-3 text-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'min-h-[52px] max-h-[200px]'
|
||||
'flex-1 resize-none rounded-xl border border-input bg-background px-4 py-3 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/20',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'min-h-[48px] max-h-[200px]'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Send/Stop Button */}
|
||||
{isStreaming ? (
|
||||
<Button
|
||||
onClick={handleStop}
|
||||
size="icon"
|
||||
className="flex-shrink-0 h-[52px] w-[52px] rounded-xl bg-red-600 hover:bg-red-700"
|
||||
className="h-[48px] w-[48px] flex-shrink-0 rounded-xl bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
<StopCircle className="h-5 w-5" />
|
||||
<span className="sr-only">Stop generating</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={disabled || !message.trim() || isStreaming}
|
||||
size="icon"
|
||||
className="flex-shrink-0 h-[52px] w-[52px] rounded-xl bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
|
||||
className={cn('h-[48px] w-[48px] flex-shrink-0 rounded-xl disabled:opacity-50', sendBtnColor)}
|
||||
>
|
||||
<Send className="h-5 w-5" />
|
||||
<span className="sr-only">Send message</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Helper Text */}
|
||||
<p className="mt-2 text-xs text-center text-gray-500">
|
||||
Press <kbd className="px-1.5 py-0.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded">Enter</kbd> to send,{' '}
|
||||
<kbd className="px-1.5 py-0.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded">Shift + Enter</kbd> for new line
|
||||
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">
|
||||
<kbd className="rounded border border-border bg-muted px-1 py-0.5 text-[10px] font-semibold">Enter</kbd> to send
|
||||
{' '}·{' '}
|
||||
<kbd className="rounded border border-border bg-muted px-1 py-0.5 text-[10px] font-semibold">Shift+Enter</kbd> new line
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
'use client';
|
||||
|
||||
// ============================================
|
||||
// Citation Card Component
|
||||
// ============================================
|
||||
|
||||
import { ExternalLink, FileText } from 'lucide-react';
|
||||
|
||||
interface CitationCardProps {
|
||||
|
|
@ -19,36 +15,26 @@ export function CitationCard({ number, title, url, score }: CitationCardProps) {
|
|||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-colors group"
|
||||
className="flex items-center gap-3 rounded-lg border border-border bg-card p-3 transition-colors hover:border-primary/30 hover:bg-primary/5 group"
|
||||
>
|
||||
{/* Citation Number */}
|
||||
<div className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-purple-100 text-purple-700 text-xs font-semibold">
|
||||
<div className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
|
||||
{number}
|
||||
</div>
|
||||
|
||||
{/* Document Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded bg-gray-100 text-gray-600 group-hover:bg-blue-100 group-hover:text-blue-600 transition-colors">
|
||||
<FileText className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded bg-muted text-muted-foreground transition-colors group-hover:bg-primary/10 group-hover:text-primary">
|
||||
<FileText className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate group-hover:text-blue-700">
|
||||
{title}
|
||||
</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground group-hover:text-primary">{title}</p>
|
||||
{score !== undefined && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Relevance: {(score * 100).toFixed(0)}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* External Link Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
<ExternalLink className="h-4 w-4 text-gray-400 group-hover:text-blue-600" />
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 flex-shrink-0 text-muted-foreground group-hover:text-primary" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
'use client';
|
||||
|
||||
// ============================================
|
||||
// App Shell Component (Layout with Sidebar)
|
||||
// ============================================
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Sidebar } from './sidebar';
|
||||
import { MobileSidebar } from './mobile-sidebar';
|
||||
|
|
@ -15,7 +11,6 @@ interface AppShellProps {
|
|||
export function AppShell({ children }: AppShellProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Routes that should not show the sidebar
|
||||
const noSidebarRoutes = ['/login', '/auth/callback'];
|
||||
const shouldShowSidebar = !noSidebarRoutes.some((route) =>
|
||||
pathname?.startsWith(route)
|
||||
|
|
@ -26,39 +21,29 @@ export function AppShell({ children }: AppShellProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-gray-50">
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="hidden md:block">
|
||||
<aside className="hidden lg:block">
|
||||
<Sidebar />
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Mobile Header */}
|
||||
<header className="flex h-16 items-center border-b bg-white px-4 md:hidden">
|
||||
<header className="flex h-14 items-center border-b bg-card px-4 lg:hidden">
|
||||
<MobileSidebar />
|
||||
<div className="ml-3 flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-600 to-indigo-600">
|
||||
<svg
|
||||
className="h-5 w-5 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
||||
/>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-primary to-accent">
|
||||
<svg className="h-4 w-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-gray-900">Nexus</span>
|
||||
<span className="text-base font-bold text-foreground">Nexus</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 overflow-y-auto">{children}</main>
|
||||
<main className="flex-1 overflow-y-auto page-transition">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -30,13 +30,13 @@ export function MobileSidebar() {
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
className="lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-64 p-0">
|
||||
<SheetContent side="left" className="w-[280px] p-0">
|
||||
<Sidebar />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
'use client';
|
||||
|
||||
// ============================================
|
||||
// Sidebar Component
|
||||
// ============================================
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/store/useAuthStore';
|
||||
import { useChatStore } from '@/store/useChatStore';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -20,51 +17,56 @@ import {
|
|||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
MessageSquare,
|
||||
BookOpen,
|
||||
BookOpenCheck,
|
||||
Sparkles,
|
||||
LogOut,
|
||||
User,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Wrench,
|
||||
Moon,
|
||||
Sun,
|
||||
} from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface NavItem {
|
||||
interface BotConfig {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
description: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
mode: 'rag' | 'assistant';
|
||||
colorClass: string;
|
||||
bgClass: string;
|
||||
activeClass: string;
|
||||
feature: string;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
const bots: BotConfig[] = [
|
||||
{
|
||||
title: 'Oliver support hub',
|
||||
href: '/chat',
|
||||
icon: MessageSquare,
|
||||
description: 'Ask me anything about Oliver',
|
||||
id: 'rag',
|
||||
title: 'Oliver Process Helper',
|
||||
description: 'Corporate knowledge base',
|
||||
icon: BookOpenCheck,
|
||||
mode: 'rag',
|
||||
colorClass: 'text-primary',
|
||||
bgClass: 'bg-primary/10',
|
||||
activeClass: 'bg-primary/10 border-primary/30',
|
||||
feature: 'rag',
|
||||
},
|
||||
{
|
||||
title: 'Notebooks',
|
||||
href: '/notebooks',
|
||||
icon: BookOpen,
|
||||
description: 'Document analysis',
|
||||
},
|
||||
{
|
||||
title: 'Assistant',
|
||||
href: '/assistant',
|
||||
id: 'assistant',
|
||||
title: 'Personal Assistant',
|
||||
description: 'General AI chat',
|
||||
icon: Sparkles,
|
||||
description: 'Productivity tools',
|
||||
mode: 'assistant',
|
||||
colorClass: 'text-accent',
|
||||
bgClass: 'bg-accent/10',
|
||||
activeClass: 'bg-accent/10 border-accent/30',
|
||||
feature: 'assistant',
|
||||
},
|
||||
];
|
||||
|
||||
// Admin nav item (conditionally shown)
|
||||
const adminNavItem: NavItem = {
|
||||
title: 'Admin',
|
||||
href: '/admin',
|
||||
icon: Settings,
|
||||
description: 'System administration',
|
||||
};
|
||||
|
||||
interface SidebarProps {
|
||||
className?: string;
|
||||
}
|
||||
|
|
@ -73,162 +75,213 @@ export function Sidebar({ className }: SidebarProps) {
|
|||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { clearMessages, setConversationId } = useChatStore();
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
setIsDark(true);
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const next = !isDark;
|
||||
setIsDark(next);
|
||||
if (next) {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
// Get user initials for avatar
|
||||
const getUserInitials = () => {
|
||||
if (!user?.display_name) return 'U';
|
||||
const names = user.display_name.split(' ');
|
||||
if (names.length >= 2) {
|
||||
return `${names[0][0]}${names[1][0]}`.toUpperCase();
|
||||
}
|
||||
if (names.length >= 2) return `${names[0][0]}${names[1][0]}`.toUpperCase();
|
||||
return user.display_name[0].toUpperCase();
|
||||
};
|
||||
|
||||
const handleBotClick = (mode: 'rag' | 'assistant') => {
|
||||
clearMessages();
|
||||
setConversationId(null as unknown as string);
|
||||
router.push(`/chat?mode=${mode}`);
|
||||
};
|
||||
|
||||
const handleNewChat = (mode: 'rag' | 'assistant') => {
|
||||
clearMessages();
|
||||
setConversationId(null as unknown as string);
|
||||
router.push(`/chat?mode=${mode}`);
|
||||
};
|
||||
|
||||
// Determine which features user has access to
|
||||
const allowedFeatures = user?.allowed_features || [];
|
||||
const hasFeatureAccess = (feature: string) => {
|
||||
if (!allowedFeatures || allowedFeatures.length === 0) return true; // No restrictions
|
||||
return allowedFeatures.includes(feature);
|
||||
};
|
||||
|
||||
const isAdmin = user?.role === 'super_admin' || user?.role === 'content_manager';
|
||||
|
||||
// Determine active mode from pathname/query
|
||||
const isOnChat = pathname?.startsWith('/chat');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full w-64 flex-col border-r bg-white',
|
||||
'flex h-full w-[280px] flex-col border-r bg-card',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Logo/Header */}
|
||||
<div className="flex h-16 items-center border-b px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-600 to-indigo-600">
|
||||
<svg
|
||||
className="h-5 w-5 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
||||
/>
|
||||
<div className="flex h-16 items-center border-b px-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-accent shadow-sm">
|
||||
<svg className="h-5 w-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-gray-900">Nexus</span>
|
||||
<div>
|
||||
<span className="text-lg font-bold text-foreground">Nexus</span>
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">AI</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<ScrollArea className="flex-1 px-3 py-4">
|
||||
<nav className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname?.startsWith(item.href);
|
||||
{/* Bot Selection */}
|
||||
<div className="px-3 pt-4 pb-2">
|
||||
<p className="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Assistants
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{bots.map((bot) => {
|
||||
if (!hasFeatureAccess(bot.feature)) return null;
|
||||
const Icon = bot.icon;
|
||||
const isActive = isOnChat; // Could be refined with query params
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-5 w-5', isActive && 'text-blue-600')} />
|
||||
<div className="flex flex-col">
|
||||
<span>{item.title}</span>
|
||||
{!isActive && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{item.description}
|
||||
</span>
|
||||
<div key={bot.id} className="group">
|
||||
<button
|
||||
onClick={() => handleBotClick(bot.mode)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-xl border border-transparent px-3 py-2.5 text-left transition-all',
|
||||
'hover:bg-muted/80',
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
>
|
||||
<div className={cn('flex h-9 w-9 items-center justify-center rounded-lg', bot.bgClass)}>
|
||||
<Icon className={cn('h-5 w-5', bot.colorClass)} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">{bot.title}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{bot.description}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleNewChat(bot.mode); }}
|
||||
className="hidden h-7 w-7 items-center justify-center rounded-lg text-muted-foreground hover:bg-background hover:text-foreground group-hover:flex"
|
||||
title="New chat"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin link (Super Admin only) */}
|
||||
{user?.role === 'super_admin' && (
|
||||
<>
|
||||
<div className="my-2 border-t" />
|
||||
<Link
|
||||
href={adminNavItem.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
pathname?.startsWith(adminNavItem.href)
|
||||
? 'bg-purple-50 text-purple-700'
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
|
||||
)}
|
||||
>
|
||||
<Settings
|
||||
className={cn(
|
||||
'h-5 w-5',
|
||||
pathname?.startsWith(adminNavItem.href) && 'text-purple-600'
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{adminNavItem.title}</span>
|
||||
{!pathname?.startsWith(adminNavItem.href) && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{adminNavItem.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
<Separator className="mx-3" />
|
||||
|
||||
{/* Tools & Navigation */}
|
||||
<div className="px-3 pt-3 pb-2">
|
||||
<p className="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Tools
|
||||
</p>
|
||||
<nav className="space-y-0.5">
|
||||
<Link
|
||||
href="/assistant"
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-xl px-3 py-2 text-sm font-medium transition-colors',
|
||||
pathname?.startsWith('/assistant')
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted/80 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
Productivity Tools
|
||||
</Link>
|
||||
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-xl px-3 py-2 text-sm font-medium transition-colors',
|
||||
pathname?.startsWith('/admin')
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted/80 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Admin Panel
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
{/* Spacer */}
|
||||
<ScrollArea className="flex-1" />
|
||||
|
||||
{/* User Profile */}
|
||||
<div className="p-4">
|
||||
<Separator className="mx-3" />
|
||||
|
||||
{/* Footer: Theme toggle + User */}
|
||||
<div className="p-3 space-y-2">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground"
|
||||
>
|
||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
{isDark ? 'Light Mode' : 'Dark Mode'}
|
||||
</button>
|
||||
|
||||
{/* User Profile */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex w-full items-center gap-3 rounded-lg p-2 text-left transition-colors hover:bg-gray-100">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarFallback className="bg-gradient-to-br from-blue-600 to-indigo-600 text-sm font-semibold text-white">
|
||||
<button className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-muted/80">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="bg-gradient-to-br from-primary to-accent text-xs font-semibold text-white">
|
||||
{getUserInitials()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<p className="truncate text-sm font-medium text-gray-900">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{user?.display_name || 'User'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-gray-500">
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{user?.email || ''}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 text-gray-500" />
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end">
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push('/profile')}
|
||||
>
|
||||
<DropdownMenuItem className="cursor-pointer" onClick={() => router.push('/profile')}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push('/settings')}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-red-600 focus:text-red-700"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<DropdownMenuItem className="cursor-pointer text-destructive focus:text-destructive" onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Logout</span>
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -1,227 +0,0 @@
|
|||
'use client';
|
||||
|
||||
// ============================================
|
||||
// File List Component with Status Polling
|
||||
// ============================================
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
FileText,
|
||||
FileSpreadsheet,
|
||||
Image as ImageIcon,
|
||||
File,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UploadedFile, ProcessingStatus } from '@/types';
|
||||
import apiClient from '@/lib/api-client';
|
||||
|
||||
interface FileListProps {
|
||||
sessionId: string;
|
||||
files: UploadedFile[];
|
||||
onFileUpdate: (fileId: string, updates: Partial<UploadedFile>) => void;
|
||||
}
|
||||
|
||||
export function FileList({ sessionId, files, onFileUpdate }: FileListProps) {
|
||||
// Poll processing status for files that are queued or processing
|
||||
useEffect(() => {
|
||||
const pollInterval = setInterval(async () => {
|
||||
const processingFiles = files.filter(
|
||||
(file) =>
|
||||
file.processing_status === 'queued' ||
|
||||
file.processing_status === 'processing'
|
||||
);
|
||||
|
||||
for (const file of processingFiles) {
|
||||
try {
|
||||
const { data } = await apiClient.get(
|
||||
`/notebook/${sessionId}/files/${file.file_id}/status`
|
||||
);
|
||||
|
||||
// Update file status if changed
|
||||
if (data.processing_status !== file.processing_status) {
|
||||
onFileUpdate(file.file_id, {
|
||||
processing_status: data.processing_status,
|
||||
processing_error: data.error_message,
|
||||
processed_at: data.completed_at,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to poll file status:', error);
|
||||
}
|
||||
}
|
||||
}, 2000); // Poll every 2 seconds
|
||||
|
||||
return () => clearInterval(pollInterval);
|
||||
}, [files, sessionId, onFileUpdate]);
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
|
||||
<File className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">No files yet</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Upload documents to start analysis
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">
|
||||
Uploaded Files ({files.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{files.map((file) => (
|
||||
<FileItem key={file.file_id} file={file} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// File Item Component
|
||||
// ============================================
|
||||
|
||||
function FileItem({ file }: { file: UploadedFile }) {
|
||||
const getFileIcon = (fileType: string) => {
|
||||
if (fileType.match(/pdf/i)) return FileText;
|
||||
if (fileType.match(/xlsx?|csv/i)) return FileSpreadsheet;
|
||||
if (fileType.match(/jpe?g|png|gif|bmp/i)) return ImageIcon;
|
||||
return File;
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: ProcessingStatus) => {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return <Clock className="h-4 w-4 text-gray-500" />;
|
||||
case 'processing':
|
||||
return <Loader2 className="h-4 w-4 text-blue-600 animate-spin" />;
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-600" />;
|
||||
case 'failed':
|
||||
return <AlertCircle className="h-4 w-4 text-red-600" />;
|
||||
default:
|
||||
return <Clock className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: ProcessingStatus) => {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Queued
|
||||
</Badge>
|
||||
);
|
||||
case 'processing':
|
||||
return (
|
||||
<Badge className="text-xs bg-blue-100 text-blue-700 hover:bg-blue-100">
|
||||
Processing
|
||||
</Badge>
|
||||
);
|
||||
case 'completed':
|
||||
return (
|
||||
<Badge className="text-xs bg-green-100 text-green-700 hover:bg-green-100">
|
||||
Ready
|
||||
</Badge>
|
||||
);
|
||||
case 'failed':
|
||||
return (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Failed
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const Icon = getFileIcon(file.file_type);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-3 transition-colors',
|
||||
file.processing_status === 'completed'
|
||||
? 'border-green-200 bg-green-50'
|
||||
: file.processing_status === 'failed'
|
||||
? 'border-red-200 bg-red-50'
|
||||
: 'border-gray-200 bg-white'
|
||||
)}
|
||||
>
|
||||
{/* File Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-lg',
|
||||
file.processing_status === 'completed'
|
||||
? 'bg-green-100 text-green-600'
|
||||
: file.processing_status === 'failed'
|
||||
? 'bg-red-100 text-red-600'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{file.file_name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{formatFileSize(file.file_size)}
|
||||
</p>
|
||||
</div>
|
||||
{getStatusBadge(file.processing_status)}
|
||||
</div>
|
||||
|
||||
{/* Processing Progress */}
|
||||
{file.processing_status === 'processing' && (
|
||||
<div className="mt-2">
|
||||
<Progress value={undefined} className="h-1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{file.processing_status === 'failed' && file.processing_error && (
|
||||
<p className="text-xs text-red-600 mt-2">{file.processing_error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Icon */}
|
||||
<div className="flex-shrink-0">{getStatusIcon(file.processing_status)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,333 +0,0 @@
|
|||
'use client';
|
||||
|
||||
// ============================================
|
||||
// Notebook Chat Component with SSE Streaming
|
||||
// ============================================
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useNotebookStore } from '@/store/useNotebookStore';
|
||||
import { ChatInput } from '@/components/chat/chat-input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Bot, User } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface NotebookChatProps {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export function NotebookChat({ sessionId }: NotebookChatProps) {
|
||||
const {
|
||||
messages,
|
||||
isStreaming,
|
||||
addMessage,
|
||||
updateMessage,
|
||||
updateMessageSources,
|
||||
setIsStreaming,
|
||||
} = useNotebookStore();
|
||||
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const currentAssistantMessageIdRef = useRef<string | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = async (message: string) => {
|
||||
if (isStreaming) return;
|
||||
|
||||
// Add user message optimistically
|
||||
const userMessageId = `user-${Date.now()}`;
|
||||
addMessage({
|
||||
id: userMessageId,
|
||||
role: 'user',
|
||||
content: message,
|
||||
});
|
||||
|
||||
// Add placeholder assistant message
|
||||
const assistantMessageId = `assistant-${Date.now()}`;
|
||||
currentAssistantMessageIdRef.current = assistantMessageId;
|
||||
addMessage({
|
||||
id: assistantMessageId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
try {
|
||||
// Set up SSE connection
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/notebook/${sessionId}/chat`;
|
||||
|
||||
// Get access token from localStorage
|
||||
const accessToken = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
|
||||
|
||||
// EventSource doesn't support custom headers, so pass token via query param
|
||||
const eventSource = new EventSource(
|
||||
`${url}?message=${encodeURIComponent(message)}&token=${accessToken || ''}`
|
||||
);
|
||||
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
let accumulatedContent = '';
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.token) {
|
||||
// Append token to accumulated content
|
||||
accumulatedContent += data.token;
|
||||
updateMessage(assistantMessageId, accumulatedContent);
|
||||
} else if (data.sources) {
|
||||
// Handle sources (markdown string for notebook mode)
|
||||
updateMessageSources(assistantMessageId, data.sources);
|
||||
} else if (data.done) {
|
||||
// Stream complete
|
||||
setIsStreaming(assistantMessageId, false);
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
currentAssistantMessageIdRef.current = null;
|
||||
} else if (data.error) {
|
||||
// Handle error
|
||||
console.error('Stream error:', data.error);
|
||||
updateMessage(assistantMessageId, `Error: ${data.error}`);
|
||||
setIsStreaming(assistantMessageId, false);
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
currentAssistantMessageIdRef.current = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse SSE event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE connection error:', error);
|
||||
updateMessage(
|
||||
assistantMessageId,
|
||||
accumulatedContent ||
|
||||
'Sorry, there was an error connecting to the server. Please try again.'
|
||||
);
|
||||
setIsStreaming(assistantMessageId, false);
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
currentAssistantMessageIdRef.current = null;
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to send message:', error);
|
||||
updateMessage(
|
||||
assistantMessageId,
|
||||
'Sorry, I encountered an error. Please try again.'
|
||||
);
|
||||
setIsStreaming(assistantMessageId, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
|
||||
if (currentAssistantMessageIdRef.current) {
|
||||
setIsStreaming(currentAssistantMessageIdRef.current, false);
|
||||
currentAssistantMessageIdRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1 px-4 py-6">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-orange-100">
|
||||
<svg
|
||||
className="h-8 w-8 text-orange-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="mb-2 text-lg font-semibold text-gray-900">
|
||||
Start Analyzing
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Upload documents and ask questions to get AI-powered insights
|
||||
</p>
|
||||
<div className="mt-4 space-y-1.5 text-left text-xs text-gray-600">
|
||||
<p>Try asking:</p>
|
||||
<ul className="space-y-1 pl-4">
|
||||
<li>• Summarize this document</li>
|
||||
<li>• What are the key findings?</li>
|
||||
<li>• Extract all dates mentioned</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="flex gap-3">
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0">
|
||||
<Avatar
|
||||
className={cn(
|
||||
'h-8 w-8',
|
||||
message.role === 'user' ? 'bg-blue-600' : 'bg-orange-600'
|
||||
)}
|
||||
>
|
||||
<AvatarFallback className="text-white">
|
||||
{message.role === 'user' ? (
|
||||
<User className="h-5 w-5" />
|
||||
) : (
|
||||
<Bot className="h-5 w-5" />
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 space-y-2">
|
||||
{message.role === 'user' ? (
|
||||
<p className="text-sm text-gray-900 whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ ...props }) => (
|
||||
<a
|
||||
{...props}
|
||||
className="text-blue-600 hover:text-blue-700 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
),
|
||||
code: ({ className, children, ...props }) => {
|
||||
const isInline = !className;
|
||||
return isInline ? (
|
||||
<code
|
||||
{...props}
|
||||
className="bg-gray-100 text-gray-900 px-1.5 py-0.5 rounded text-sm font-mono"
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code
|
||||
{...props}
|
||||
className="block bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto font-mono text-sm"
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
p: ({ ...props }) => (
|
||||
<p
|
||||
{...props}
|
||||
className="text-gray-900 leading-relaxed mb-3"
|
||||
/>
|
||||
),
|
||||
ul: ({ ...props }) => (
|
||||
<ul {...props} className="list-disc pl-5 mb-3 space-y-1" />
|
||||
),
|
||||
ol: ({ ...props }) => (
|
||||
<ol {...props} className="list-decimal pl-5 mb-3 space-y-1" />
|
||||
),
|
||||
li: ({ ...props }) => (
|
||||
<li {...props} className="text-gray-900" />
|
||||
),
|
||||
h1: ({ ...props }) => (
|
||||
<h1 {...props} className="text-xl font-bold mb-3 mt-4" />
|
||||
),
|
||||
h2: ({ ...props }) => (
|
||||
<h2 {...props} className="text-lg font-semibold mb-2 mt-3" />
|
||||
),
|
||||
h3: ({ ...props }) => (
|
||||
<h3 {...props} className="text-base font-semibold mb-2 mt-2" />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
{message.isStreaming && (
|
||||
<span className="inline-block w-2 h-4 ml-1 bg-orange-400 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sources (Markdown format for notebook) */}
|
||||
{message.sources && (
|
||||
<details className="mt-4 pt-4 border-t border-gray-200">
|
||||
<summary className="cursor-pointer text-xs font-medium text-gray-700 hover:text-gray-900 mb-2">
|
||||
📚 View Sources
|
||||
</summary>
|
||||
<div className="mt-2 pl-4 text-xs text-gray-600 bg-gray-50 rounded-lg p-3 max-h-60 overflow-y-auto">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h2: ({ ...props }) => (
|
||||
<h2 {...props} className="font-semibold text-gray-800 mb-2" />
|
||||
),
|
||||
p: ({ ...props }) => (
|
||||
<p {...props} className="text-gray-600 mb-1 leading-relaxed" />
|
||||
),
|
||||
ul: ({ ...props }) => (
|
||||
<ul {...props} className="list-none space-y-1" />
|
||||
),
|
||||
li: ({ ...props }) => (
|
||||
<li {...props} className="text-gray-600" />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{message.sources}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t bg-white p-4">
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
disabled={isStreaming}
|
||||
isStreaming={isStreaming}
|
||||
placeholder="Ask questions about your documents..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ChevronDown, Plus, Trash2, Clock, Pin } from 'lucide-react';
|
||||
import apiClient from '@/lib/api-client';
|
||||
|
||||
interface NotebookSession {
|
||||
session_id: string;
|
||||
title: string;
|
||||
is_pinned: boolean;
|
||||
total_file_size: number;
|
||||
expires_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface SessionSelectorProps {
|
||||
currentSessionId: string | null;
|
||||
onSessionSelect: (sessionId: string) => void;
|
||||
onNewSession: () => void;
|
||||
}
|
||||
|
||||
export function SessionSelector({
|
||||
currentSessionId,
|
||||
onSessionSelect,
|
||||
onNewSession,
|
||||
}: SessionSelectorProps) {
|
||||
const [sessions, setSessions] = useState<NotebookSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchSessions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data } = await apiClient.get('/notebook/');
|
||||
setSessions(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch sessions:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions();
|
||||
}, []);
|
||||
|
||||
const handleDeleteSession = async (sessionId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!confirm('Delete this notebook session?')) return;
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/notebook/${sessionId}`);
|
||||
setSessions(sessions.filter((s) => s.session_id !== sessionId));
|
||||
|
||||
// If deleted current session, trigger new session creation
|
||||
if (sessionId === currentSessionId) {
|
||||
onNewSession();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete session:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
return `${diffDays}d ago`;
|
||||
};
|
||||
|
||||
const currentSession = sessions.find((s) => s.session_id === currentSessionId);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-between">
|
||||
<span className="truncate">
|
||||
{currentSession?.title || 'Select Session'}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-80">
|
||||
<DropdownMenuLabel>Notebook Sessions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{loading ? (
|
||||
<div className="py-4 text-center text-sm text-gray-500">
|
||||
Loading sessions...
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="py-4 text-center text-sm text-gray-500">
|
||||
No sessions found
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{sessions.map((session) => (
|
||||
<DropdownMenuItem
|
||||
key={session.session_id}
|
||||
className="flex items-center justify-between p-3 cursor-pointer"
|
||||
onClick={() => onSessionSelect(session.session_id)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{session.title}
|
||||
</p>
|
||||
{session.is_pinned && (
|
||||
<Pin className="h-3 w-3 text-green-600 shrink-0" />
|
||||
)}
|
||||
{session.session_id === currentSessionId && (
|
||||
<span className="text-xs bg-orange-100 text-orange-700 px-2 py-0.5 rounded">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDate(session.created_at)}
|
||||
</span>
|
||||
{session.total_file_size > 0 && (
|
||||
<span>
|
||||
{(session.total_file_size / 1024 / 1024).toFixed(1)} MB
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={(e) => handleDeleteSession(session.session_id, e)}
|
||||
title="Delete session"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 cursor-pointer bg-orange-50 text-orange-700 font-medium"
|
||||
onClick={onNewSession}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Session
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
// ============================================
|
||||
// Notebook Store (Zustand)
|
||||
// ============================================
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { UploadedFile, Source } from '@/types';
|
||||
|
||||
interface NotebookMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
sources?: string; // Markdown string for notebook mode
|
||||
isStreaming?: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface NotebookState {
|
||||
// Session State
|
||||
sessionId: string | null;
|
||||
conversationId: string | null;
|
||||
title: string;
|
||||
isPinned: boolean;
|
||||
expiresAt: string | null;
|
||||
|
||||
// Files State
|
||||
files: UploadedFile[];
|
||||
uploadingFiles: Map<string, number>; // fileName -> progress (0-100)
|
||||
|
||||
// Messages State
|
||||
messages: NotebookMessage[];
|
||||
isStreaming: boolean;
|
||||
|
||||
// Session Actions
|
||||
setSession: (
|
||||
sessionId: string,
|
||||
conversationId: string,
|
||||
title: string,
|
||||
isPinned: boolean,
|
||||
expiresAt: string | null
|
||||
) => void;
|
||||
updateSessionPin: (isPinned: boolean, expiresAt: string | null) => void;
|
||||
clearSession: () => void;
|
||||
|
||||
// File Actions
|
||||
addFile: (file: UploadedFile) => void;
|
||||
updateFile: (fileId: string, updates: Partial<UploadedFile>) => void;
|
||||
removeFile: (fileId: string) => void;
|
||||
setFiles: (files: UploadedFile[]) => void;
|
||||
setUploadProgress: (fileName: string, progress: number) => void;
|
||||
clearUploadProgress: (fileName: string) => void;
|
||||
|
||||
// Message Actions
|
||||
addMessage: (message: NotebookMessage) => void;
|
||||
updateMessage: (id: string, content: string) => void;
|
||||
updateMessageSources: (id: string, sources: string) => void;
|
||||
setIsStreaming: (id: string, isStreaming: boolean) => void;
|
||||
clearMessages: () => void;
|
||||
}
|
||||
|
||||
export const useNotebookStore = create<NotebookState>((set) => ({
|
||||
// Initial session state
|
||||
sessionId: null,
|
||||
conversationId: null,
|
||||
title: 'New Notebook',
|
||||
isPinned: false,
|
||||
expiresAt: null,
|
||||
|
||||
// Initial files state
|
||||
files: [],
|
||||
uploadingFiles: new Map(),
|
||||
|
||||
// Initial messages state
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
|
||||
// ============================================
|
||||
// SESSION ACTIONS
|
||||
// ============================================
|
||||
|
||||
setSession: (sessionId, conversationId, title, isPinned, expiresAt) =>
|
||||
set({
|
||||
sessionId,
|
||||
conversationId,
|
||||
title,
|
||||
isPinned,
|
||||
expiresAt,
|
||||
}),
|
||||
|
||||
updateSessionPin: (isPinned, expiresAt) =>
|
||||
set({ isPinned, expiresAt }),
|
||||
|
||||
clearSession: () =>
|
||||
set({
|
||||
sessionId: null,
|
||||
conversationId: null,
|
||||
title: 'New Notebook',
|
||||
isPinned: false,
|
||||
expiresAt: null,
|
||||
files: [],
|
||||
messages: [],
|
||||
}),
|
||||
|
||||
// ============================================
|
||||
// FILE ACTIONS
|
||||
// ============================================
|
||||
|
||||
addFile: (file) =>
|
||||
set((state) => ({
|
||||
files: [...state.files, file],
|
||||
})),
|
||||
|
||||
updateFile: (fileId, updates) =>
|
||||
set((state) => ({
|
||||
files: state.files.map((file) =>
|
||||
file.file_id === fileId ? { ...file, ...updates } : file
|
||||
),
|
||||
})),
|
||||
|
||||
removeFile: (fileId) =>
|
||||
set((state) => ({
|
||||
files: state.files.filter((file) => file.file_id !== fileId),
|
||||
})),
|
||||
|
||||
setFiles: (files) =>
|
||||
set({ files }),
|
||||
|
||||
setUploadProgress: (fileName, progress) =>
|
||||
set((state) => {
|
||||
const newMap = new Map(state.uploadingFiles);
|
||||
newMap.set(fileName, progress);
|
||||
return { uploadingFiles: newMap };
|
||||
}),
|
||||
|
||||
clearUploadProgress: (fileName) =>
|
||||
set((state) => {
|
||||
const newMap = new Map(state.uploadingFiles);
|
||||
newMap.delete(fileName);
|
||||
return { uploadingFiles: newMap };
|
||||
}),
|
||||
|
||||
// ============================================
|
||||
// MESSAGE ACTIONS
|
||||
// ============================================
|
||||
|
||||
addMessage: (message) =>
|
||||
set((state) => ({
|
||||
messages: [...state.messages, message],
|
||||
})),
|
||||
|
||||
updateMessage: (id, content) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === id ? { ...msg, content } : msg
|
||||
),
|
||||
})),
|
||||
|
||||
updateMessageSources: (id, sources) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === id ? { ...msg, sources } : msg
|
||||
),
|
||||
})),
|
||||
|
||||
setIsStreaming: (id, isStreaming) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === id ? { ...msg, isStreaming } : msg
|
||||
),
|
||||
isStreaming,
|
||||
})),
|
||||
|
||||
clearMessages: () =>
|
||||
set({ messages: [] }),
|
||||
}));
|
||||
Loading…
Add table
Reference in a new issue