diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 96e61d1..ecd27cf 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -30,4 +30,4 @@ jobs: env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: - model: "opencode/MiniMax M2.5 Free" + model: "opencode/MiniMax-M2.5-Free" diff --git a/.gitignore b/.gitignore index 5a747ca..e5933de 100644 --- a/.gitignore +++ b/.gitignore @@ -88,4 +88,8 @@ jspm_packages/ .doc/ .claude -firebaseconfig.ts \ No newline at end of file +firebaseconfig.ts + +# Firebase service account keys +backend/privateKey.json +backend/firebase-service-account.json diff --git a/backend/api/routers/__init__.py b/backend/api/routers/__init__.py index 0bff5ad..0162644 100644 --- a/backend/api/routers/__init__.py +++ b/backend/api/routers/__init__.py @@ -3,9 +3,11 @@ from .databases import router as databases_router from .queries import router as queries_router from .schema import router as schema_router +from .chat import router as chat_router __all__ = [ "databases_router", "queries_router", "schema_router", + "chat_router", ] diff --git a/backend/api/routers/chat.py b/backend/api/routers/chat.py new file mode 100644 index 0000000..348ad69 --- /dev/null +++ b/backend/api/routers/chat.py @@ -0,0 +1,306 @@ +"""Chat history and bookmarks API endpoints.""" + +from datetime import datetime, UTC +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel + +from api.middleware.auth import get_current_user +from database.chat_models import ChatSession, ChatMessage, Bookmark +from database.session import get_db, set_current_user_context + + +router = APIRouter(prefix="/api/v1", tags=["chat"]) + + +class CreateSessionRequest(BaseModel): + title: Optional[str] = None + + +class CreateMessageRequest(BaseModel): + role: str + content: str + + +class CreateBookmarkRequest(BaseModel): + session_id: Optional[int] = None + message_id: Optional[int] = None + note: Optional[str] = None + + +def _auto_title(content: str) -> str: + text = (content or "").strip() + if not text: + return "New Chat" + return text[:60] + + +@router.get("/chat/sessions") +async def list_chat_sessions(user: dict = Depends(get_current_user)): + user_id = user.get("uid") + with get_db() as db: + set_current_user_context(db, user_id) + sessions = ( + db.query(ChatSession) + .filter(ChatSession.user_id == user_id) + .order_by(ChatSession.updated_at.desc()) + .all() + ) + return [ + { + "id": s.id, + "title": s.title, + "created_at": s.created_at, + "updated_at": s.updated_at, + } + for s in sessions + ] + + +@router.post("/chat/sessions") +async def create_chat_session( + request: CreateSessionRequest, user: dict = Depends(get_current_user) +): + user_id = user.get("uid") + with get_db() as db: + set_current_user_context(db, user_id) + session = ChatSession(user_id=user_id, title=request.title or "New Chat") + db.add(session) + db.flush() + return { + "id": session.id, + "title": session.title, + "created_at": session.created_at, + "updated_at": session.updated_at, + } + + +@router.get("/chat/sessions/{session_id}") +async def get_chat_session(session_id: int, user: dict = Depends(get_current_user)): + user_id = user.get("uid") + with get_db() as db: + set_current_user_context(db, user_id) + session = ( + db.query(ChatSession) + .filter(ChatSession.id == session_id) + .filter(ChatSession.user_id == user_id) + .first() + ) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + messages = ( + db.query(ChatMessage) + .filter(ChatMessage.session_id == session_id) + .filter(ChatMessage.user_id == user_id) + .order_by(ChatMessage.created_at.asc()) + .all() + ) + return { + "id": session.id, + "title": session.title, + "created_at": session.created_at, + "updated_at": session.updated_at, + "messages": [ + { + "id": m.id, + "role": m.role, + "content": m.content, + "created_at": m.created_at, + } + for m in messages + ], + } + + +@router.delete("/chat/sessions/{session_id}") +async def delete_chat_session(session_id: int, user: dict = Depends(get_current_user)): + user_id = user.get("uid") + with get_db() as db: + set_current_user_context(db, user_id) + session = ( + db.query(ChatSession) + .filter(ChatSession.id == session_id) + .filter(ChatSession.user_id == user_id) + .first() + ) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + db.delete(session) + return {"success": True} + + +@router.post("/chat/sessions/{session_id}/messages") +async def add_chat_message( + session_id: int, + request: CreateMessageRequest, + user: dict = Depends(get_current_user), +): + if request.role not in {"user", "assistant"}: + raise HTTPException( + status_code=400, detail="Role must be 'user' or 'assistant'" + ) + + user_id = user.get("uid") + with get_db() as db: + set_current_user_context(db, user_id) + session = ( + db.query(ChatSession) + .filter(ChatSession.id == session_id) + .filter(ChatSession.user_id == user_id) + .first() + ) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + message = ChatMessage( + session_id=session_id, + user_id=user_id, + role=request.role, + content=request.content, + ) + db.add(message) + + if request.role == "user" and ( + not session.title or session.title == "New Chat" + ): + session.title = _auto_title(request.content) + + session.updated_at = datetime.now(UTC) + db.flush() + + return { + "id": message.id, + "session_id": session_id, + "role": message.role, + "content": message.content, + "created_at": message.created_at, + } + + +@router.post("/bookmarks") +async def create_bookmark( + request: CreateBookmarkRequest, user: dict = Depends(get_current_user) +): + if not request.session_id and not request.message_id: + raise HTTPException( + status_code=400, detail="session_id or message_id is required" + ) + + user_id = user.get("uid") + with get_db() as db: + set_current_user_context(db, user_id) + + if request.session_id: + session = ( + db.query(ChatSession) + .filter(ChatSession.id == request.session_id) + .filter(ChatSession.user_id == user_id) + .first() + ) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + if request.message_id: + message = ( + db.query(ChatMessage) + .filter(ChatMessage.id == request.message_id) + .filter(ChatMessage.user_id == user_id) + .first() + ) + if not message: + raise HTTPException(status_code=404, detail="Message not found") + + bookmark = Bookmark( + user_id=user_id, + session_id=request.session_id, + message_id=request.message_id, + note=request.note, + ) + db.add(bookmark) + db.flush() + + return { + "id": bookmark.id, + "session_id": bookmark.session_id, + "message_id": bookmark.message_id, + "note": bookmark.note, + "bookmarked_at": bookmark.bookmarked_at, + } + + +@router.get("/bookmarks") +async def list_bookmarks(user: dict = Depends(get_current_user)): + user_id = user.get("uid") + with get_db() as db: + set_current_user_context(db, user_id) + bookmarks = ( + db.query(Bookmark) + .filter(Bookmark.user_id == user_id) + .order_by(Bookmark.bookmarked_at.desc()) + .all() + ) + + grouped = {} + for b in bookmarks: + day = b.bookmarked_at.date().isoformat() + grouped.setdefault(day, []).append( + { + "id": b.id, + "session_id": b.session_id, + "message_id": b.message_id, + "note": b.note, + "bookmarked_at": b.bookmarked_at, + } + ) + return grouped + + +@router.delete("/bookmarks/{bookmark_id}") +async def delete_bookmark(bookmark_id: int, user: dict = Depends(get_current_user)): + user_id = user.get("uid") + with get_db() as db: + set_current_user_context(db, user_id) + bookmark = ( + db.query(Bookmark) + .filter(Bookmark.id == bookmark_id) + .filter(Bookmark.user_id == user_id) + .first() + ) + if not bookmark: + raise HTTPException(status_code=404, detail="Bookmark not found") + + db.delete(bookmark) + return {"success": True} + + +@router.get("/chat/search") +async def search_chat( + q: str = Query(..., min_length=1), user: dict = Depends(get_current_user) +): + user_id = user.get("uid") + with get_db() as db: + set_current_user_context(db, user_id) + messages = ( + db.query(ChatMessage) + .join(ChatSession, ChatSession.id == ChatMessage.session_id) + .filter(ChatMessage.user_id == user_id) + .filter(ChatMessage.content.ilike(f"%{q}%")) + .order_by(ChatMessage.created_at.desc()) + .limit(50) + .all() + ) + + return [ + { + "message_id": m.id, + "session_id": m.session_id, + "session_title": m.session.title if m.session else "Untitled", + "role": m.role, + "snippet": m.content[:200], + "created_at": m.created_at, + } + for m in messages + ] diff --git a/backend/api/routers/databases.py b/backend/api/routers/databases.py index 235cff1..a83873d 100644 --- a/backend/api/routers/databases.py +++ b/backend/api/routers/databases.py @@ -1,17 +1,17 @@ """Database management API endpoints""" from fastapi import APIRouter, HTTPException, UploadFile, File, Form, Depends -from typing import List, Optional +from typing import List from datetime import datetime, UTC import os import logging -from api.schemas import DatabaseCreate, DatabaseResponse +from api.schemas import DatabaseResponse from database.models import Database as DatabaseModel -from database.session import get_db +from database.session import get_db, set_current_user_context from database.manager import DatabaseConnectionManager from api.services.upload_handler import DatabaseUploadHandler -from api.middleware.auth import get_optional_user +from api.middleware.auth import get_current_user logger = logging.getLogger(__name__) @@ -19,14 +19,16 @@ @router.get("", response_model=List[DatabaseResponse]) -async def list_databases(user: Optional[dict] = Depends(get_optional_user)): +async def list_databases(user: dict = Depends(get_current_user)): """Get list of all databases""" - if user: - logger.info(f"User {user.get('sub')} fetching database list") + user_id = user.get("uid") + logger.info(f"User {user_id} fetching database list") with get_db() as db: + set_current_user_context(db, user_id) databases = ( db.query(DatabaseModel) + .filter(DatabaseModel.user_id == user_id) .filter(DatabaseModel.is_active == True) .order_by(DatabaseModel.last_accessed.desc()) .all() @@ -52,16 +54,18 @@ async def list_databases(user: Optional[dict] = Depends(get_optional_user)): @router.get("/{database_id}", response_model=DatabaseResponse) -async def get_database( - database_id: int, user: Optional[dict] = Depends(get_optional_user) -): +async def get_database(database_id: int, user: dict = Depends(get_current_user)): """Get specific database details""" - if user: - logger.info(f"User {user.get('sub')} fetching database {database_id}") + user_id = user.get("uid") + logger.info(f"User {user_id} fetching database {database_id}") with get_db() as db: + set_current_user_context(db, user_id) database = ( - db.query(DatabaseModel).filter(DatabaseModel.id == database_id).first() + db.query(DatabaseModel) + .filter(DatabaseModel.id == database_id) + .filter(DatabaseModel.user_id == user_id) + .first() ) if not database: raise HTTPException(status_code=404, detail="Database not found") @@ -91,11 +95,11 @@ async def upload_database( file: UploadFile = File(...), display_name: str = Form(...), description: str = Form(None), - user: Optional[dict] = Depends(get_optional_user), + user: dict = Depends(get_current_user), ): """Upload a new database""" - if user: - logger.info(f"User {user.get('sub')} uploading database: {display_name}") + user_id = user.get("uid") + logger.info(f"User {user_id} uploading database: {display_name}") temp_file_path = None try: @@ -181,6 +185,7 @@ async def upload_database( # Save to PostgreSQL with get_db() as db: + set_current_user_context(db, user_id) new_db = DatabaseModel( name=db_name, display_name=display_name, @@ -191,6 +196,7 @@ async def upload_database( table_count=stats["table_count"], row_count=stats["row_count"], size_mb=stats["size_mb"], + user_id=user_id, ) db.add(new_db) db.commit() @@ -222,16 +228,18 @@ async def upload_database( @router.delete("/{database_id}") -async def delete_database( - database_id: int, user: Optional[dict] = Depends(get_optional_user) -): +async def delete_database(database_id: int, user: dict = Depends(get_current_user)): """Delete a database""" - if user: - logger.info(f"User {user.get('sub')} deleting database {database_id}") + user_id = user.get("uid") + logger.info(f"User {user_id} deleting database {database_id}") with get_db() as db: + set_current_user_context(db, user_id) database = ( - db.query(DatabaseModel).filter(DatabaseModel.id == database_id).first() + db.query(DatabaseModel) + .filter(DatabaseModel.id == database_id) + .filter(DatabaseModel.user_id == user_id) + .first() ) if not database: raise HTTPException(status_code=404, detail="Database not found") @@ -248,18 +256,18 @@ async def delete_database( @router.get("/{database_id}/schema") -async def get_database_schema( - database_id: int, user: Optional[dict] = Depends(get_optional_user) -): +async def get_database_schema(database_id: int, user: dict = Depends(get_current_user)): """Get schema for specific database""" - if user: - logger.info( - f"User {user.get('sub')} fetching schema for database {database_id}" - ) + user_id = user.get("uid") + logger.info(f"User {user_id} fetching schema for database {database_id}") with get_db() as db: + set_current_user_context(db, user_id) database = ( - db.query(DatabaseModel).filter(DatabaseModel.id == database_id).first() + db.query(DatabaseModel) + .filter(DatabaseModel.id == database_id) + .filter(DatabaseModel.user_id == user_id) + .first() ) if not database: raise HTTPException(status_code=404, detail="Database not found") @@ -291,18 +299,18 @@ async def get_database_schema( @router.get("/{database_id}/tables") -async def get_database_tables( - database_id: int, user: Optional[dict] = Depends(get_optional_user) -): +async def get_database_tables(database_id: int, user: dict = Depends(get_current_user)): """Get list of tables in a specific database (for debugging)""" - if user: - logger.info( - f"User {user.get('sub')} fetching tables for database {database_id}" - ) + user_id = user.get("uid") + logger.info(f"User {user_id} fetching tables for database {database_id}") with get_db() as db: + set_current_user_context(db, user_id) database = ( - db.query(DatabaseModel).filter(DatabaseModel.id == database_id).first() + db.query(DatabaseModel) + .filter(DatabaseModel.id == database_id) + .filter(DatabaseModel.user_id == user_id) + .first() ) if not database: raise HTTPException(status_code=404, detail="Database not found") diff --git a/backend/api/routers/queries.py b/backend/api/routers/queries.py index 86f4270..1a3f1de 100644 --- a/backend/api/routers/queries.py +++ b/backend/api/routers/queries.py @@ -1,7 +1,6 @@ """Query processing API endpoints""" from fastapi import APIRouter, HTTPException, Depends -from typing import Optional import time import logging import hashlib @@ -9,10 +8,10 @@ from api.schemas import QueryRequest, QueryResponse from database.models import Database as DatabaseModel, QueryHistory -from database.session import get_db +from database.session import get_db, set_current_user_context from database.manager import DatabaseConnectionManager from api.services import determine_query_complexity, generate_query_explanation -from api.middleware.auth import get_optional_user +from api.middleware.auth import get_current_user # Configure logging logger = logging.getLogger(__name__) @@ -34,18 +33,22 @@ async def query_database( database_id: int, request: QueryRequest, - user: Optional[dict] = Depends(get_optional_user), + user: dict = Depends(get_current_user), ): """Query a specific database with natural language""" - if user: - logger.info( - f"User {user.get('sub')} querying database {database_id}: {request.question[:50]}" - ) + user_id = user.get("uid") + logger.info( + f"User {user_id} querying database {database_id}: {request.question[:50]}" + ) with get_db() as db: + set_current_user_context(db, user_id) # Get database database = ( - db.query(DatabaseModel).filter(DatabaseModel.id == database_id).first() + db.query(DatabaseModel) + .filter(DatabaseModel.id == database_id) + .filter(DatabaseModel.user_id == user_id) + .first() ) if not database: raise HTTPException(status_code=404, detail="Database not found") @@ -231,6 +234,7 @@ async def query_database( # Save to query history history = QueryHistory( database_id=database_id, + user_id=user_id, question=request.question, sql_query=sql_query, execution_time_ms=execution_time_ms, @@ -252,6 +256,7 @@ async def query_database( history = QueryHistory( database_id=database_id, + user_id=user_id, question=request.question, sql_query="", success=False, @@ -286,10 +291,9 @@ async def process_query(request: QueryRequest): @router.get("/cache/stats") -async def get_cache_stats(user: Optional[dict] = Depends(get_optional_user)): +async def get_cache_stats(user: dict = Depends(get_current_user)): """Get query cache statistics""" - if user: - logger.info(f"User {user.get('sub')} fetching cache stats") + logger.info(f"User {user.get('uid')} fetching cache stats") if not CACHE_ENABLED: return {"error": "Cache not enabled"} @@ -304,10 +308,9 @@ async def get_cache_stats(user: Optional[dict] = Depends(get_optional_user)): @router.post("/cache/clear") -async def clear_cache(user: Optional[dict] = Depends(get_optional_user)): +async def clear_cache(user: dict = Depends(get_current_user)): """Clear all cached queries""" - if user: - logger.info(f"User {user.get('sub')} clearing query cache") + logger.info(f"User {user.get('uid')} clearing query cache") if not CACHE_ENABLED: return {"error": "Cache not enabled"} @@ -318,20 +321,22 @@ async def clear_cache(user: Optional[dict] = Depends(get_optional_user)): @router.get("/databases/{database_id}/tables") async def get_database_tables_info( - database_id: int, user: Optional[dict] = Depends(get_optional_user) + database_id: int, user: dict = Depends(get_current_user) ): """ Get detailed table information for a specific database Useful for debugging schema issues """ - if user: - logger.info( - f"User {user.get('sub')} fetching table info for database {database_id}" - ) + user_id = user.get("uid") + logger.info(f"User {user_id} fetching table info for database {database_id}") with get_db() as db: + set_current_user_context(db, user_id) database = ( - db.query(DatabaseModel).filter(DatabaseModel.id == database_id).first() + db.query(DatabaseModel) + .filter(DatabaseModel.id == database_id) + .filter(DatabaseModel.user_id == user_id) + .first() ) if not database: raise HTTPException(status_code=404, detail="Database not found") diff --git a/backend/core/llm/gemini_client.py b/backend/core/llm/gemini_client.py index 8bb0f2d..9118282 100644 --- a/backend/core/llm/gemini_client.py +++ b/backend/core/llm/gemini_client.py @@ -4,7 +4,7 @@ """ import logging from typing import Dict, Any, Optional -import google.generativeai as genai +from google import genai from .config import LLMConfig @@ -25,16 +25,13 @@ def __init__(self, model_name: Optional[str] = None, timeout: Optional[int] = No # Validate configuration LLMConfig.validate() - # Configure Gemini API - genai.configure(api_key=LLMConfig.GEMINI_API_KEY) + # Initialize Gemini API client + self.client = genai.Client(api_key=LLMConfig.GEMINI_API_KEY) # Set model and timeout self.model_name = LLMConfig.get_model_name(model_name) self.timeout = LLMConfig.get_timeout(timeout) - # Initialize model - self.model = genai.GenerativeModel(self.model_name) - logger.info(f"Initialized Gemini client with model: {self.model_name}") def generate_content(self, prompt: str) -> Dict[str, Any]: @@ -56,7 +53,10 @@ def generate_content(self, prompt: str) -> Dict[str, Any]: logger.info(f"⚡ SENDING REQUEST TO GEMINI...") # Call Gemini API - response = self.model.generate_content(prompt) + response = self.client.models.generate_content( + model=self.model_name, + contents=prompt + ) logger.info(f"✅ RECEIVED RESPONSE FROM GEMINI") diff --git a/backend/database/chat_models.py b/backend/database/chat_models.py new file mode 100644 index 0000000..c175342 --- /dev/null +++ b/backend/database/chat_models.py @@ -0,0 +1,98 @@ +"""Chat history models for sessions, messages, and bookmarks.""" + +from sqlalchemy import ( + Column, + Integer, + String, + Text, + DateTime, + ForeignKey, + CheckConstraint, + Index, +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from .models import Base + + +class ChatSession(Base): + __tablename__ = "chat_sessions" + __table_args__ = (Index("idx_chat_sessions_user", "user_id", "updated_at"),) + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(String(128), nullable=False, index=True) + title = Column(String(255), nullable=True) + created_at = Column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + messages = relationship( + "ChatMessage", + back_populates="session", + cascade="all, delete-orphan", + lazy="selectin", + ) + bookmarks = relationship( + "Bookmark", + back_populates="session", + cascade="all, delete-orphan", + lazy="selectin", + ) + + +class ChatMessage(Base): + __tablename__ = "chat_messages" + __table_args__ = ( + CheckConstraint( + "role IN ('user', 'assistant')", name="check_chat_messages_role" + ), + Index("idx_chat_messages_session", "session_id", "created_at"), + Index("idx_chat_messages_user", "user_id"), + ) + + id = Column(Integer, primary_key=True, index=True) + session_id = Column( + Integer, ForeignKey("chat_sessions.id", ondelete="CASCADE"), nullable=False + ) + user_id = Column(String(128), nullable=False, index=True) + role = Column(String(20), nullable=False) + content = Column(Text, nullable=False) + created_at = Column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + session = relationship("ChatSession", back_populates="messages", lazy="joined") + bookmarks = relationship( + "Bookmark", + back_populates="message", + cascade="all, delete-orphan", + lazy="selectin", + ) + + +class Bookmark(Base): + __tablename__ = "bookmarks" + __table_args__ = (Index("idx_bookmarks_user", "user_id", "bookmarked_at"),) + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(String(128), nullable=False, index=True) + session_id = Column( + Integer, ForeignKey("chat_sessions.id", ondelete="CASCADE"), nullable=True + ) + message_id = Column( + Integer, ForeignKey("chat_messages.id", ondelete="CASCADE"), nullable=True + ) + note = Column(Text, nullable=True) + bookmarked_at = Column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + session = relationship("ChatSession", back_populates="bookmarks", lazy="joined") + message = relationship("ChatMessage", back_populates="bookmarks", lazy="joined") diff --git a/backend/migrations/001_add_user_id.sql b/backend/migrations/001_add_user_id.sql new file mode 100644 index 0000000..9ba10b6 --- /dev/null +++ b/backend/migrations/001_add_user_id.sql @@ -0,0 +1,16 @@ +ALTER TABLE databases ADD COLUMN IF NOT EXISTS user_id VARCHAR(128); +CREATE INDEX IF NOT EXISTS idx_databases_user_id ON databases(user_id); + +ALTER TABLE query_history ADD COLUMN IF NOT EXISTS user_id VARCHAR(128); +CREATE INDEX IF NOT EXISTS idx_query_history_user_id ON query_history(user_id); + +ALTER TABLE databases ENABLE ROW LEVEL SECURITY; +ALTER TABLE query_history ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS databases_user_isolation ON databases; +CREATE POLICY databases_user_isolation ON databases +USING (user_id = current_setting('app.current_user_id', true)); + +DROP POLICY IF EXISTS query_history_user_isolation ON query_history; +CREATE POLICY query_history_user_isolation ON query_history +USING (user_id = current_setting('app.current_user_id', true)); diff --git a/backend/migrations/002_chat_history.sql b/backend/migrations/002_chat_history.sql new file mode 100644 index 0000000..fbca07d --- /dev/null +++ b/backend/migrations/002_chat_history.sql @@ -0,0 +1,50 @@ +CREATE TABLE IF NOT EXISTS chat_sessions ( + id SERIAL PRIMARY KEY, + user_id VARCHAR(128) NOT NULL, + title VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS chat_messages ( + id SERIAL PRIMARY KEY, + session_id INTEGER REFERENCES chat_sessions(id) ON DELETE CASCADE, + user_id VARCHAR(128) NOT NULL, + role VARCHAR(20) NOT NULL CHECK (role IN ('user', 'assistant')), + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS bookmarks ( + id SERIAL PRIMARY KEY, + user_id VARCHAR(128) NOT NULL, + session_id INTEGER REFERENCES chat_sessions(id) ON DELETE CASCADE, + message_id INTEGER REFERENCES chat_messages(id) ON DELETE CASCADE, + note TEXT, + bookmarked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_chat_sessions_user ON chat_sessions(user_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_chat_messages_session ON chat_messages(session_id, created_at); +CREATE INDEX IF NOT EXISTS idx_chat_messages_user ON chat_messages(user_id); +CREATE INDEX IF NOT EXISTS idx_bookmarks_user ON bookmarks(user_id, bookmarked_at DESC); + +ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS content_tsv tsvector +GENERATED ALWAYS AS (to_tsvector('english', content)) STORED; +CREATE INDEX IF NOT EXISTS idx_chat_messages_fts ON chat_messages USING GIN(content_tsv); + +ALTER TABLE chat_sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE chat_messages ENABLE ROW LEVEL SECURITY; +ALTER TABLE bookmarks ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS chat_sessions_isolation ON chat_sessions; +CREATE POLICY chat_sessions_isolation ON chat_sessions +USING (user_id = current_setting('app.current_user_id', true)); + +DROP POLICY IF EXISTS chat_messages_isolation ON chat_messages; +CREATE POLICY chat_messages_isolation ON chat_messages +USING (user_id = current_setting('app.current_user_id', true)); + +DROP POLICY IF EXISTS bookmarks_isolation ON bookmarks; +CREATE POLICY bookmarks_isolation ON bookmarks +USING (user_id = current_setting('app.current_user_id', true)); diff --git a/frontend/src/app/dashboard/bookmarks/page.tsx b/frontend/src/app/dashboard/bookmarks/page.tsx new file mode 100644 index 0000000..5148e01 --- /dev/null +++ b/frontend/src/app/dashboard/bookmarks/page.tsx @@ -0,0 +1,5 @@ +import BookmarksPage from "@/components/chat/BookmarksPage"; + +export default function DashboardBookmarksPage() { + return ; +} diff --git a/frontend/src/app/dashboard/chat/page.tsx b/frontend/src/app/dashboard/chat/page.tsx index 3d6f050..0ca3051 100644 --- a/frontend/src/app/dashboard/chat/page.tsx +++ b/frontend/src/app/dashboard/chat/page.tsx @@ -1,17 +1,40 @@ +"use client"; + +import { useEffect, useState } from "react"; import ChatPage from "@/modules/dashboard/ChatPage"; -import { getDatabases } from "@/lib/api"; +import { useApi } from "@/hooks/use-api"; import type { DatabaseResponse } from "@/types/api"; -export default async function MainChatPage() { - let databases: DatabaseResponse[] = []; - let error: string | undefined; +export default function MainChatPage() { + const [databases, setDatabases] = useState([]); + const [error, setError] = useState(); + const [loading, setLoading] = useState(true); + const api = useApi(); + + + useEffect(() => { + async function fetchDatabases() { + try { + const data = await api.getDatabases(); + setDatabases(data); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : "Failed to load databases"; + setError(errorMessage); + console.error("Error fetching databases:", e); + } finally { + setLoading(false); + } + } + + fetchDatabases(); + }, [api]); - try { - databases = await getDatabases(); - } catch (e) { - error = e instanceof Error ? e.message : "Failed to load databases"; - console.error("Error fetching databases:", e); - databases = []; + if (loading) { + return ( +
+
Loading...
+
+ ); } return ; diff --git a/frontend/src/app/dashboard/databases/[dbId]/chat/page.tsx b/frontend/src/app/dashboard/databases/[dbId]/chat/page.tsx index c32058c..b55714d 100644 --- a/frontend/src/app/dashboard/databases/[dbId]/chat/page.tsx +++ b/frontend/src/app/dashboard/databases/[dbId]/chat/page.tsx @@ -1,22 +1,45 @@ -import { getDatabase } from "@/lib/api"; +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { useApi } from "@/hooks/use-api"; import QueryInterface from "@/components/query/QueryInterface"; +import type { DatabaseResponse } from "@/types/api"; -export default async function DatabaseChatPage({ - params, -}: { - params: Promise<{ dbId: string }>; -}) { - const { dbId } = await params; +export default function DatabaseChatPage() { + const params = useParams(); + const dbId = params.dbId as string; const id = parseInt(dbId, 10); - let database; - let error; + const [database, setDatabase] = useState(null); + const [error, setError] = useState(); + const [loading, setLoading] = useState(true); + const api = useApi(); + + + useEffect(() => { + async function fetchDatabase() { + try { + const data = await api.getDatabase(id); + setDatabase(data); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : "Failed to load database"; + setError(errorMessage); + console.error("Error fetching database:", e); + } finally { + setLoading(false); + } + } + + fetchDatabase(); + }, [id, api]); - try { - database = await getDatabase(id); - } catch (e) { - error = e instanceof Error ? e.message : "Failed to load database"; - console.error("Error fetching database:", e); + if (loading) { + return ( +
+
Loading database...
+
+ ); } if (error || !database) { diff --git a/frontend/src/app/dashboard/databases/[dbId]/erd/page.tsx b/frontend/src/app/dashboard/databases/[dbId]/erd/page.tsx index efc4148..f50893d 100644 --- a/frontend/src/app/dashboard/databases/[dbId]/erd/page.tsx +++ b/frontend/src/app/dashboard/databases/[dbId]/erd/page.tsx @@ -1,4 +1,9 @@ -import { getDatabaseSchema } from "@/lib/api"; +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { useApi } from "@/hooks/use-api"; +import type { SchemaDataResponse } from "@/types/api"; type SchemaColumn = { name: string; @@ -10,21 +15,39 @@ type SchemaColumn = { }; }; -export default async function ERDPage({ - params, -}: { - params: Promise<{ dbId: string }>; -}) { - const { dbId } = await params; +export default function ERDPage() { + const params = useParams(); + const dbId = params.dbId as string; const id = parseInt(dbId, 10); - let schema; - let error; + const [schema, setSchema] = useState(null); + const [error, setError] = useState(); + const [loading, setLoading] = useState(true); + const api = useApi(); + + + useEffect(() => { + async function fetchSchema() { + try { + const data = await api.getDatabaseSchema(id); + setSchema(data); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : "Failed to load ERD data"; + setError(errorMessage); + } finally { + setLoading(false); + } + } + + fetchSchema(); + }, [id, api]); - try { - schema = await getDatabaseSchema(id); - } catch (e) { - error = e instanceof Error ? e.message : "Failed to load ERD data"; + if (loading) { + return ( +
+
Loading ERD...
+
+ ); } if (error || !schema) { diff --git a/frontend/src/app/dashboard/databases/[dbId]/layout.tsx b/frontend/src/app/dashboard/databases/[dbId]/layout.tsx index 58a167a..de6dc0e 100644 --- a/frontend/src/app/dashboard/databases/[dbId]/layout.tsx +++ b/frontend/src/app/dashboard/databases/[dbId]/layout.tsx @@ -1,6 +1,11 @@ +"use client"; + +import { useEffect, useState } from "react"; import Link from "next/link"; -import { getDatabase } from "@/lib/api"; +import { useParams } from "next/navigation"; +import { useApi } from "@/hooks/use-api"; import DetailNav from "./_components/DetailNav"; +import type { DatabaseResponse } from "@/types/api"; const tabs = [ { to: "overview", label: "Overview" }, @@ -10,26 +15,37 @@ const tabs = [ { to: "settings", label: "Settings" }, ]; -export default async function DatabaseDetailLayout({ +export default function DatabaseDetailLayout({ children, - params, }: { children: React.ReactNode; - params: Promise<{ dbId: string }>; }) { - const { dbId } = await params; + const params = useParams(); + const dbId = params.dbId as string; const id = parseInt(dbId, 10); + + const [database, setDatabase] = useState(null); + const [loading, setLoading] = useState(true); + const api = useApi(); + - let databaseName = `Database ${dbId}`; - let status = "Unknown"; + useEffect(() => { + async function fetchDatabase() { + try { + const db = await api.getDatabase(id); + setDatabase(db); + } catch (error) { + console.error("Error fetching database:", error); + } finally { + setLoading(false); + } + } - try { - const db = await getDatabase(id); - databaseName = db.display_name; - status = db.is_active ? "Active" : "Inactive"; - } catch { - // Keep fallback label if database lookup fails - } + fetchDatabase(); + }, [id, api]); + + const databaseName = database?.display_name || `Database ${dbId}`; + const status = database ? (database.is_active ? "Active" : "Inactive") : "Unknown"; return (
@@ -38,7 +54,9 @@ export default async function DatabaseDetailLayout({ Back to Databases -
{databaseName}
+
+ {loading ? "Loading..." : databaseName} +
{status}
diff --git a/frontend/src/app/dashboard/databases/[dbId]/overview/page.tsx b/frontend/src/app/dashboard/databases/[dbId]/overview/page.tsx index 24f63f3..e10237c 100644 --- a/frontend/src/app/dashboard/databases/[dbId]/overview/page.tsx +++ b/frontend/src/app/dashboard/databases/[dbId]/overview/page.tsx @@ -1,23 +1,50 @@ -import { getDatabase, getDatabaseSchema } from "@/lib/api"; +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { useApi } from "@/hooks/use-api"; import { Database, Table2 } from "lucide-react"; +import type { DatabaseResponse, SchemaDataResponse } from "@/types/api"; -export default async function DatabaseOverviewPage({ - params, -}: { - params: Promise<{ dbId: string }>; -}) { - const { dbId } = await params; +export default function DatabaseOverviewPage() { + const params = useParams(); + const dbId = params.dbId as string; const id = parseInt(dbId, 10); - let database; - let schema; - let error; + const [database, setDatabase] = useState(null); + const [schema, setSchema] = useState(null); + const [error, setError] = useState(); + const [loading, setLoading] = useState(true); + const api = useApi(); + + + useEffect(() => { + async function fetchData() { + try { + const [dbData, schemaData] = await Promise.all([ + api.getDatabase(id), + api.getDatabaseSchema(id), + ]); + setDatabase(dbData); + setSchema(schemaData); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : "Failed to load database"; + setError(errorMessage); + console.error("Error fetching database:", e); + } finally { + setLoading(false); + } + } + + fetchData(); + }, [id, api]); - try { - [database, schema] = await Promise.all([getDatabase(id), getDatabaseSchema(id)]); - } catch (e) { - error = e instanceof Error ? e.message : "Failed to load database"; - console.error("Error fetching database:", e); + if (loading) { + return ( +
+
Loading database overview...
+
+ ); } if (error || !database) { diff --git a/frontend/src/app/dashboard/databases/[dbId]/schema/page.tsx b/frontend/src/app/dashboard/databases/[dbId]/schema/page.tsx index 1ded2ee..0902644 100644 --- a/frontend/src/app/dashboard/databases/[dbId]/schema/page.tsx +++ b/frontend/src/app/dashboard/databases/[dbId]/schema/page.tsx @@ -1,21 +1,44 @@ -import { getDatabaseSchema } from "@/lib/api"; +"use client"; -export default async function DatabaseSchemaPage({ - params, -}: { - params: Promise<{ dbId: string }>; -}) { - const { dbId } = await params; +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { useApi } from "@/hooks/use-api"; +import type { SchemaDataResponse } from "@/types/api"; + +export default function DatabaseSchemaPage() { + const params = useParams(); + const dbId = params.dbId as string; const id = parseInt(dbId, 10); - let schema; - let error; + const [schema, setSchema] = useState(null); + const [error, setError] = useState(); + const [loading, setLoading] = useState(true); + const api = useApi(); + + + useEffect(() => { + async function fetchSchema() { + try { + const data = await api.getDatabaseSchema(id); + setSchema(data); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : "Failed to load schema"; + setError(errorMessage); + console.error("Error fetching schema:", e); + } finally { + setLoading(false); + } + } + + fetchSchema(); + }, [id, api]); - try { - schema = await getDatabaseSchema(id); - } catch (e) { - error = e instanceof Error ? e.message : "Failed to load schema"; - console.error("Error fetching schema:", e); + if (loading) { + return ( +
+
Loading schema...
+
+ ); } if (error || !schema) { diff --git a/frontend/src/app/dashboard/databases/[dbId]/settings/page.tsx b/frontend/src/app/dashboard/databases/[dbId]/settings/page.tsx index e7ad7de..af8bb53 100644 --- a/frontend/src/app/dashboard/databases/[dbId]/settings/page.tsx +++ b/frontend/src/app/dashboard/databases/[dbId]/settings/page.tsx @@ -1,20 +1,43 @@ -import { getDatabase } from "@/lib/api"; +"use client"; -export default async function SettingsPage({ - params, -}: { - params: Promise<{ dbId: string }>; -}) { - const { dbId } = await params; +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { useApi } from "@/hooks/use-api"; +import type { DatabaseResponse } from "@/types/api"; + +export default function SettingsPage() { + const params = useParams(); + const dbId = params.dbId as string; const id = parseInt(dbId, 10); - let database; - let error; + const [database, setDatabase] = useState(null); + const [error, setError] = useState(); + const [loading, setLoading] = useState(true); + const api = useApi(); + + + useEffect(() => { + async function fetchDatabase() { + try { + const data = await api.getDatabase(id); + setDatabase(data); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : "Failed to load database settings"; + setError(errorMessage); + } finally { + setLoading(false); + } + } + + fetchDatabase(); + }, [id, api]); - try { - database = await getDatabase(id); - } catch (e) { - error = e instanceof Error ? e.message : "Failed to load database settings"; + if (loading) { + return ( +
+
Loading settings...
+
+ ); } if (error || !database) { diff --git a/frontend/src/app/dashboard/databases/page.tsx b/frontend/src/app/dashboard/databases/page.tsx index d8c5a4d..8879f1d 100644 --- a/frontend/src/app/dashboard/databases/page.tsx +++ b/frontend/src/app/dashboard/databases/page.tsx @@ -1,17 +1,51 @@ +"use client"; + +import { useEffect, useState } from "react"; import DatabasesView from "@/modules/dashboard/Databases"; -import { getDatabases } from "@/lib/api"; +import { useApi } from "@/hooks/use-api"; +import { useAuth } from "@/hooks/use-auth"; import type { DatabaseResponse } from "@/types/api"; -export default async function DatabasesPage() { - let databases: DatabaseResponse[] = []; - let error: string | undefined; +export default function DatabasesPage() { + const [databases, setDatabases] = useState([]); + const [error, setError] = useState(); + const [loading, setLoading] = useState(true); + const api = useApi(); + const { isLoading: authLoading, isAuthenticated } = useAuth(); + + useEffect(() => { + if (authLoading) { + return; + } + + if (!isAuthenticated) { + setError("Missing authentication token"); + setLoading(false); + return; + } + + async function fetchDatabases() { + try { + const data = await api.getDatabases(); + setDatabases(data); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : "Failed to load databases"; + setError(errorMessage); + console.error("Error fetching databases:", e); + } finally { + setLoading(false); + } + } + + fetchDatabases(); + }, [api, authLoading, isAuthenticated]); - try { - databases = await getDatabases(); - } catch (e) { - error = e instanceof Error ? e.message : "Failed to load databases"; - console.error("Error fetching databases:", e); - databases = []; + if (loading) { + return ( +
+
Loading databases...
+
+ ); } return ; diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index be2e6b9..c65e654 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -1,17 +1,51 @@ +"use client"; + +import { useEffect, useState } from "react"; import DashboardHome from "@/modules/dashboard/index"; -import { getDatabases } from "@/lib/api"; +import { useApi } from "@/hooks/use-api"; +import { useAuth } from "@/hooks/use-auth"; import type { DatabaseResponse } from "@/types/api"; -export default async function DashboardPage() { - let databases: DatabaseResponse[] = []; - let error: string | undefined; +export default function DashboardPage() { + const [databases, setDatabases] = useState([]); + const [error, setError] = useState(); + const [loading, setLoading] = useState(true); + const api = useApi(); + const { isLoading: authLoading, isAuthenticated } = useAuth(); + + useEffect(() => { + if (authLoading) { + return; + } + + if (!isAuthenticated) { + setError("Missing authentication token"); + setLoading(false); + return; + } + + async function fetchDatabases() { + try { + const data = await api.getDatabases(); + setDatabases(data); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : "Failed to load databases"; + setError(errorMessage); + console.error("Error fetching databases:", e); + } finally { + setLoading(false); + } + } + + fetchDatabases(); + }, [api, authLoading, isAuthenticated]); - try { - databases = await getDatabases(); - } catch (e) { - error = e instanceof Error ? e.message : "Failed to load databases"; - console.error("Error fetching databases:", e); - databases = []; + if (loading) { + return ( +
+
Loading...
+
+ ); } return ; diff --git a/frontend/src/app/dashboard/search/page.tsx b/frontend/src/app/dashboard/search/page.tsx new file mode 100644 index 0000000..a9fe945 --- /dev/null +++ b/frontend/src/app/dashboard/search/page.tsx @@ -0,0 +1,5 @@ +import ChatSearch from "@/components/chat/ChatSearch"; + +export default function DashboardSearchPage() { + return ; +} diff --git a/frontend/src/components/chat/BookmarksPage.tsx b/frontend/src/components/chat/BookmarksPage.tsx new file mode 100644 index 0000000..01e1254 --- /dev/null +++ b/frontend/src/components/chat/BookmarksPage.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Trash2 } from "lucide-react"; +import { useChat } from "@/hooks/use-chat"; + +export default function BookmarksPage() { + const router = useRouter(); + const { listBookmarks, deleteBookmark } = useChat(); + const [bookmarks, setBookmarks] = useState>>({}); + + useEffect(() => { + async function load() { + const data = await listBookmarks(); + setBookmarks(data); + } + load(); + }, [listBookmarks]); + + async function remove(id: number) { + await deleteBookmark(id); + const data = await listBookmarks(); + setBookmarks(data); + } + + const days = Object.keys(bookmarks); + + return ( +
+
+

Bookmarks

+

Saved sessions and messages grouped by date

+
+ + {days.length === 0 ? ( +
No bookmarks yet.
+ ) : ( + days.map((day) => ( +
+

{day}

+
+ {bookmarks[day].map((item) => ( +
+ + +
+ ))} +
+
+ )) + )} +
+ ); +} diff --git a/frontend/src/components/chat/ChatSearch.tsx b/frontend/src/components/chat/ChatSearch.tsx new file mode 100644 index 0000000..b25948c --- /dev/null +++ b/frontend/src/components/chat/ChatSearch.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Search } from "lucide-react"; +import { useChat } from "@/hooks/use-chat"; + +export default function ChatSearch() { + const router = useRouter(); + const { search } = useChat(); + const [q, setQ] = useState(""); + const [results, setResults] = useState>([]); + + async function runSearch() { + if (!q.trim()) { + setResults([]); + return; + } + const data = await search(q.trim()); + setResults(data); + } + + return ( +
+
+

Search Chats

+

Search across your chat history

+
+ +
+
+ + setQ(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && runSearch()} + placeholder="Search for SQL, schema, or analysis..." + className="w-full bg-transparent outline-none text-sm text-[#f0f0f0] placeholder:text-[#666666]" + /> +
+ +
+ +
+ {results.map((result) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/database/DatabaseUploadModal.tsx b/frontend/src/components/database/DatabaseUploadModal.tsx index f5b8837..132eab2 100644 --- a/frontend/src/components/database/DatabaseUploadModal.tsx +++ b/frontend/src/components/database/DatabaseUploadModal.tsx @@ -1,10 +1,16 @@ "use client"; import { useState } from "react"; -import { X, Upload, Loader2 } from "lucide-react"; +import { Upload, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useApi } from "@/hooks/use-api"; import type { DatabaseUploadResponse } from "@/types/api"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; interface DatabaseUploadModalProps { isOpen: boolean; @@ -24,8 +30,6 @@ export default function DatabaseUploadModal({ const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); - if (!isOpen) return null; - const handleFileChange = (e: React.ChangeEvent) => { const selectedFile = e.target.files?.[0]; if (selectedFile) { @@ -85,21 +89,13 @@ export default function DatabaseUploadModal({ }; return ( -
-
- {/* Header */} -
-

+ !open && onClose()}> + + + Upload Database -

- -
+ +
{/* File Input */} @@ -193,7 +189,7 @@ export default function DatabaseUploadModal({
-
-
+ + ); } diff --git a/frontend/src/components/query/QueryInterface.tsx b/frontend/src/components/query/QueryInterface.tsx index c9b9cbc..f262ff9 100644 --- a/frontend/src/components/query/QueryInterface.tsx +++ b/frontend/src/components/query/QueryInterface.tsx @@ -144,6 +144,16 @@ export default function QueryInterface({ databases, preselectedDatabaseId }: Que )} + + {!selectedDatabaseId && availableDatabases.length === 0 && ( +
+ +

No database connected

+

+ Upload a database to start Querying +

+
+ )}
@@ -192,7 +202,7 @@ export default function QueryInterface({ databases, preselectedDatabaseId }: Que void handleQuery(); } }} - placeholder="Ask a question about your data..." + placeholder={selectedDatabaseId ? "Ask a question about your data..." : "Please upload or select a database..."} disabled={loading || !selectedDatabaseId} rows={1} onInput={(e) => { diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx new file mode 100644 index 0000000..d260623 --- /dev/null +++ b/frontend/src/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client"; + +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..995409e --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,200 @@ +"use client"; + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/frontend/src/hooks/use-chat.ts b/frontend/src/hooks/use-chat.ts new file mode 100644 index 0000000..1acd342 --- /dev/null +++ b/frontend/src/hooks/use-chat.ts @@ -0,0 +1,143 @@ +"use client"; + +import { useCallback } from "react"; +import { useAuthContext } from "@/components/providers/auth-provider"; + +export interface ChatSession { + id: number; + title: string; + created_at: string; + updated_at: string; +} + +export interface ChatMessage { + id: number; + session_id: number; + role: "user" | "assistant"; + content: string; + created_at: string; +} + +interface Bookmark { + id: number; + session_id: number | null; + message_id: number | null; + note: string | null; + bookmarked_at: string; +} + +async function apiFetch( + endpoint: string, + token: string | null, + options?: RequestInit +): Promise { + if (!token) { + throw new Error("Missing authentication token"); + } + + const response = await fetch(endpoint, { + ...options, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(options?.headers || {}), + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: response.statusText })); + throw new Error(error.detail || `API error: ${response.status}`); + } + + return response.json(); +} + +export function useChat() { + const { getToken } = useAuthContext(); + + const withToken = useCallback(async () => { + return getToken(); + }, [getToken]); + + const listSessions = useCallback(async (): Promise => { + const token = await withToken(); + return apiFetch("/api/v1/chat/sessions", token); + }, [withToken]); + + const createSession = useCallback(async (title?: string): Promise => { + const token = await withToken(); + return apiFetch("/api/v1/chat/sessions", token, { + method: "POST", + body: JSON.stringify({ title }), + }); + }, [withToken]); + + const getSession = useCallback(async (sessionId: number) => { + const token = await withToken(); + return apiFetch<{ id: number; title: string; messages: ChatMessage[] }>( + `/api/v1/chat/sessions/${sessionId}`, + token + ); + }, [withToken]); + + const deleteSession = useCallback(async (sessionId: number) => { + const token = await withToken(); + return apiFetch<{ success: boolean }>(`/api/v1/chat/sessions/${sessionId}`, token, { + method: "DELETE", + }); + }, [withToken]); + + const addMessage = useCallback( + async (sessionId: number, role: "user" | "assistant", content: string): Promise => { + const token = await withToken(); + return apiFetch(`/api/v1/chat/sessions/${sessionId}/messages`, token, { + method: "POST", + body: JSON.stringify({ role, content }), + }); + }, + [withToken] + ); + + const search = useCallback(async (q: string) => { + const token = await withToken(); + return apiFetch>( + `/api/v1/chat/search?q=${encodeURIComponent(q)}`, + token + ); + }, [withToken]); + + const listBookmarks = useCallback(async () => { + const token = await withToken(); + return apiFetch>("/api/v1/bookmarks", token); + }, [withToken]); + + const createBookmark = useCallback( + async (sessionId?: number, messageId?: number, note?: string) => { + const token = await withToken(); + return apiFetch("/api/v1/bookmarks", token, { + method: "POST", + body: JSON.stringify({ session_id: sessionId, message_id: messageId, note }), + }); + }, + [withToken] + ); + + const deleteBookmark = useCallback(async (bookmarkId: number) => { + const token = await withToken(); + return apiFetch<{ success: boolean }>(`/api/v1/bookmarks/${bookmarkId}`, token, { + method: "DELETE", + }); + }, [withToken]); + + return { + listSessions, + createSession, + getSession, + deleteSession, + addMessage, + search, + listBookmarks, + createBookmark, + deleteBookmark, + }; +}