diff --git a/ai-hub/app/api/routes/api.py b/ai-hub/app/api/routes/api.py index a68b2b7..5576e67 100644 --- a/ai-hub/app/api/routes/api.py +++ b/ai-hub/app/api/routes/api.py @@ -8,6 +8,7 @@ from .general import create_general_router from .stt import create_stt_router from .workspace import create_workspace_router +from .user import create_users_router def create_api_router(services: ServiceContainer) -> APIRouter: """ @@ -22,5 +23,6 @@ router.include_router(create_tts_router(services)) router.include_router(create_stt_router(services)) router.include_router(create_workspace_router(services)) + router.include_router(create_users_router(services)) return router \ No newline at end of file diff --git a/ai-hub/app/api/routes/sessions.py b/ai-hub/app/api/routes/sessions.py index c69c5ea..0524597 100644 --- a/ai-hub/app/api/routes/sessions.py +++ b/ai-hub/app/api/routes/sessions.py @@ -12,6 +12,8 @@ request: schemas.SessionCreate, db: Session = Depends(get_db) ): + if request.user_id is None or request.provider_name is None: + raise HTTPException(status_code=400, detail="user_id and provider_name are required to create a session.") try: new_session = services.session_service.create_session( db=db, diff --git a/ai-hub/app/api/routes/user.py b/ai-hub/app/api/routes/user.py new file mode 100644 index 0000000..7b194dc --- /dev/null +++ b/ai-hub/app/api/routes/user.py @@ -0,0 +1,176 @@ +from fastapi import APIRouter, HTTPException, Depends, Header, Query, Request +from fastapi.responses import RedirectResponse as redirect +from sqlalchemy.orm import Session +from app.db import models +from typing import Optional, Annotated +import logging +import os +import requests +import jwt + +# Correctly import from your application's schemas and dependencies +from app.api.dependencies import ServiceContainer, get_db +from app.api import schemas +from app.core.services.user import login_required + +# Setup logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Minimum OIDC configuration from environment variables +OIDC_CLIENT_ID = os.getenv("OIDC_CLIENT_ID", "") +OIDC_CLIENT_SECRET = os.getenv("OIDC_CLIENT_SECRET", "") +OIDC_SERVER_URL = os.getenv("OIDC_SERVER_URL", "") +OIDC_REDIRECT_URI = os.getenv("OIDC_REDIRECT_URI", "") + +# --- Derived OIDC Configuration --- +OIDC_AUTHORIZATION_URL = f"{OIDC_SERVER_URL}/auth" +OIDC_TOKEN_URL = f"{OIDC_SERVER_URL}/token" +OIDC_USERINFO_URL = f"{OIDC_SERVER_URL}/userinfo" + +# A dependency to simulate getting the current user ID from a request header +def get_current_user_id(x_user_id: Annotated[Optional[str], Header()] = None) -> Optional[str]: + """ + Retrieves the user ID from the X-User-ID header. + This simulates an authentication system and is used by the login_required decorator. + """ + return x_user_id + + +def create_users_router(services: ServiceContainer) -> APIRouter: + router = APIRouter(prefix="/users", tags=["Users"]) + +def create_users_router(services: ServiceContainer) -> APIRouter: + router = APIRouter(prefix="/users", tags=["Users"]) + + @router.get("/login", summary="Initiate OIDC Login Flow") + async def login_redirect( + request: Request, + # Allow the frontend to provide its callback URL + frontend_callback_uri: Optional[str] = Query(None, description="The frontend URI to redirect back to after OIDC provider.") + ): + """ + Initiates the OIDC authentication flow. The `frontend_callback_uri` + specifies where the user should be redirected after successful + authentication with the OIDC provider. + """ + # Store the frontend_callback_uri in a session or a cache, + # linked to the state parameter for security. + # For simplicity, we will pass it as a query parameter in the callback. + # A more robust solution would use a state parameter. + + # The OIDC provider must redirect to a URL known to the backend. + # So we redirect to a backend endpoint, which in turn redirects to the frontend. + auth_url = ( + f"{OIDC_AUTHORIZATION_URL}?" + f"response_type=code&" + f"scope=openid%20profile%20email&" + f"client_id={OIDC_CLIENT_ID}&" + f"redirect_uri={OIDC_REDIRECT_URI}&" + f"state={frontend_callback_uri}" # Pass the frontend URI in the state parameter + ) + logger.debug(f"Redirecting to OIDC authorization URL: {auth_url}") + return redirect(url=auth_url) + + @router.get("/login/callback", summary="Handle OIDC Login Callback") + async def login_callback( + request: Request, + code: str = Query(..., description="Authorization code from OIDC provider"), + state: str = Query(..., description="The original frontend redirect URI"), + db: Session = Depends(get_db) + ): + """ + Handles the callback from the OIDC provider, exchanges the code for + tokens, and then redirects the user back to the frontend with + the user data or a session token. + """ + logger.debug(f"Received callback with authorization code: {code}") + + try: + # Step 1: Exchange the authorization code for an access token and an ID token + token_data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": OIDC_REDIRECT_URI, + "client_id": OIDC_CLIENT_ID, + "client_secret": OIDC_CLIENT_SECRET, + } + token_response = requests.post(OIDC_TOKEN_URL, data=token_data) + token_response.raise_for_status() + + response_json = token_response.json() + id_token = response_json.get("id_token") + + if not id_token: + logger.error("Error: ID token not found.") + raise HTTPException(status_code=400, detail="Failed to get ID token.") + + # Step 2: Decode the ID token to get user information + decoded_id_token = jwt.decode(id_token, options={"verify_signature": False}) + oidc_id = decoded_id_token.get("sub") + email = decoded_id_token.get("email") + username = decoded_id_token.get("name") + + if not all([oidc_id, email, username]): + logger.error("Error: Essential user data missing.") + raise HTTPException(status_code=400, detail="Essential user data missing.") + + # Step 3: Save the user and get their unique ID + user_id = services.user_service.save_user( + db=db, + oidc_id=oidc_id, + email=email, + username=username + ) + + # Step 4: Redirect back to the frontend, passing the user_id or a session token + # Note: This is a simplification. A real app would set a secure HTTP-only cookie. + # We are passing the user_id as a query parameter for demonstration. + frontend_redirect_url = f"{state}?user_id={user_id}" + + return redirect(url=frontend_redirect_url) + + except requests.exceptions.RequestException as e: + logger.error(f"Token exchange error: {e}") + raise HTTPException(status_code=500, detail=f"Failed to communicate with OIDC provider: {e}") + except jwt.JWTDecodeError as e: + logger.error(f"ID token decode error: {e}") + raise HTTPException(status_code=400, detail="Failed to decode ID token.") + except Exception as e: + logger.error(f"An unexpected error occurred: {e}") + raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {e}") + + @router.get("/me", response_model=schemas.UserStatus, summary="Get Current User Status") + async def get_current_status( + db: Session = Depends(get_db), + user_id: str = Depends(get_current_user_id) + ): + """ + Checks the login status of the current user. + Requires a valid user_id to be present in the request header. + """ + try: + # In a real-world scenario, you would fetch user details from the DB using user_id + # For this example, we return a mock response based on the presence of user_id + + user : Optional[models.User] = services.user_service.get_user_by_id(db=db, user_id=user_id) # Ensure user exists + email = user.email if user else None + is_anonymous = user is None + is_logged_in = user is not None + return schemas.UserStatus( + id=user_id, + email=email, + is_logged_in=is_logged_in, + is_anonymous=is_anonymous + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"An error occurred: {e}") + + @router.post("/logout", summary="Log Out the Current User") + async def logout(): + """ + Simulates a user logout. In a real application, this would clear the session token or cookie. + """ + return {"message": "Logged out successfully"} + + return router diff --git a/ai-hub/app/api/schemas.py b/ai-hub/app/api/schemas.py index 9014400..f92f020 100644 --- a/ai-hub/app/api/schemas.py +++ b/ai-hub/app/api/schemas.py @@ -2,6 +2,21 @@ from typing import List, Literal, Optional from datetime import datetime +# --- User Schemas --- + +class OIDCLogin(BaseModel): + """Schema for OIDC user data received from a login callback.""" + oidc_id: str = Field(..., description="The unique ID from the OIDC provider.") + email: str = Field(..., description="The user's email address.") + username: str = Field(..., description="The user's username or display name.") + +class UserStatus(BaseModel): + """Schema for the response when checking a user's status.""" + id: str = Field(..., description="The internal user ID.") + email: str = Field(..., description="The user's email address.") + is_logged_in: bool = Field(True, description="Indicates if the user is currently authenticated.") + is_anonymous: bool = Field(False, description="Indicates if the user is an anonymous user.") + # --- Chat Schemas --- class ChatRequest(BaseModel): """Defines the shape of a request to the /chat endpoint.""" diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 9464fa7..b326451 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -15,8 +15,10 @@ from app.utils import print_config from app.api.dependencies import ServiceContainer, get_db from app.core.services.session import SessionService +from app.core.services import SessionService from app.core.services.tts import TTSService from app.core.services.stt import STTService # NEW: Added the missing import for STTService +from app.core.services.user import UserService from app.core.services.workspace import WorkspaceService # NEW: Added the missing import for STTService # Note: The llm_clients import and initialization are removed as they # are not used in RAGService's constructor based on your services.py @@ -104,6 +106,7 @@ services.with_service("tts_service",service=TTSService(tts_provider=tts_provider)) services.with_service("workspace_service", service=WorkspaceService()) services.with_service("session_service", service=SessionService()) + services.with_service("user_service", service=UserService()) # Create and include the API router, injecting the service api_router = create_api_router(services=services) diff --git a/ai-hub/app/core/pipelines/file_selector.py b/ai-hub/app/core/pipelines/file_selector.py index 6575162..26d8e21 100644 --- a/ai-hub/app/core/pipelines/file_selector.py +++ b/ai-hub/app/core/pipelines/file_selector.py @@ -13,7 +13,7 @@ 1. **Prioritize Core Files:** Identify and select files that contain the central logic, definitions, or essential configurations directly related to the user's query and the chat history context. 2. **Be Selective:** To avoid exceeding token limits, your response must be a small, highly focused set of files. Aim for **2 to 4 files**. Do not select a large number of files. - 3. **Exclude Irrelevant Files:** Discard files that are placeholders or have names unrelated to the user's request. Based on your knowledge, ignore compiled file types like `.pyc`, `.class`, `.o`, and `.exe`, as they are not core or text files. + 3. **Exclude Irrelevant and Unreadable Files:** Discard files that are placeholders or have names unrelated to the user's request. **Crucially, use your knowledge to identify and ignore non-text files, such as compiled binaries (.exe, .o), database files (.db, .sqlite), archived files (.zip, .tar), or images (.jpg, .png).** These are not readable source code files and will not help answer the question. 4. **Infer User Intent:** If the user or chat history mentions a file path that isn't in the `retrieved_files` list, use that as a strong hint. Find and select the path from the list that is most similar to the one mentioned. You **must** only return a file path that exists in the `retrieved_files` list. If you determine no files are related, return an empty array. 5. **Completeness Check:** If the `retrieved_files` list already contains all the information you need to answer the question, it is acceptable to return an empty array. diff --git a/ai-hub/app/core/pipelines/question_decider.py b/ai-hub/app/core/pipelines/question_decider.py index 9df1c88..1f9e715 100644 --- a/ai-hub/app/core/pipelines/question_decider.py +++ b/ai-hub/app/core/pipelines/question_decider.py @@ -27,15 +27,16 @@ 3. **Choose the Correct Decision Path:** * **Decision: 'answer'** * Choose this if you have all the necessary information in `retrieved_paths_with_content` to provide a full, complete, and comprehensive explanation for a non-code-modification question. - * **Also choose this if the user asks about a file that is not present in any of the provided data.** You must explain to the user why the file could not be found. + * Also choose this if the user asks about a file that is not present in any of the provided data. You must explain to the user why the file could not be found. * The `answer` field must contain a detailed, well-structured explanation in Markdown. * The `code_diff` field must be empty. * **Decision: 'code_change'** - * Choose this if the user's request involves modifying or adding to the code (e.g., "fix this bug," "implement this feature," "refactor this function", "show me full code"). + * Choose this if the user's request involves modifying or adding to the code (e.g., "fix this bug," "implement this feature," "refactor this function", "show me full code"). + * This decision is also for requests to **generate new code** (e.g., creating a new file from scratch). If the user asks for the "full code" of a file that doesn't exist, this is a code generation task. * You must have all the relevant files with content in `retrieved_paths_with_content` to propose the change. * The `answer` field can be an optional, high-level summary of the change. - * The `code_diff` field must contain the full and complete git diff showing the exact modifications, could be multiple file diffs. + * The `code_diff` field must contain the full and complete git diff showing the exact modifications, including adding new files. * **Decision: 'files'** * Choose this **only if** you need more files to fulfill the user's request. diff --git a/ai-hub/app/core/services/__init__.py b/ai-hub/app/core/services/__init__.py index 3fbb1fd..e6075ea 100644 --- a/ai-hub/app/core/services/__init__.py +++ b/ai-hub/app/core/services/__init__.py @@ -1 +1,8 @@ # This file can be left empty. +from .session import SessionService +from .stt import STTService +from .tts import TTSService +from .workspace import WorkspaceService +from .user import UserService +from .rag import RAGService +from .document import DocumentService \ No newline at end of file diff --git a/ai-hub/app/core/services/session.py b/ai-hub/app/core/services/session.py index d1b25a1..ff07f8a 100644 --- a/ai-hub/app/core/services/session.py +++ b/ai-hub/app/core/services/session.py @@ -1,4 +1,4 @@ -# app/core/session.py +# app/core/services/session.py from sqlalchemy.orm import Session from sqlalchemy.exc import SQLAlchemyError diff --git a/ai-hub/app/core/services/test_workspace.py b/ai-hub/app/core/services/test_workspace.py deleted file mode 100644 index 8ddfa03..0000000 --- a/ai-hub/app/core/services/test_workspace.py +++ /dev/null @@ -1,227 +0,0 @@ -import pytest -from app.core.services.workspace import WorkspaceService # Replace your_module_name with the actual name of the module - -@pytest.fixture -def workspace_service(): - """Provides a WorkspaceService instance for testing.""" - return WorkspaceService() - -def test_apply_diff_rag_service_changes(workspace_service): - """ - Tests applying a complex diff to the RAGService class, including - additions within different sections of the code. - """ - original_content = """import asyncio -from typing import List, Tuple -from sqlalchemy.orm import Session, joinedload -import dspy - -from app.db import models -from app.core.retrievers.faiss_db_retriever import FaissDBRetriever -from app.core.retrievers.base_retriever import Retriever -from app.core.providers.factory import get_llm_provider -from app.core.pipelines.dspy_rag import DspyRagPipeline - -class RAGService: - \"\"\" - Service for orchestrating conversational RAG pipelines. - Manages chat interactions and message history for a session. - \"\"\" - def __init__(self, retrievers: List[Retriever]): - self.retrievers = retrievers - self.faiss_retriever = next((r for r in retrievers if isinstance(r, FaissDBRetriever)), None) - - async def chat_with_rag( - self, - db: Session, - session_id: int, - prompt: str, - provider_name: str, - load_faiss_retriever: bool = False - ) -> Tuple[str, str]: - \"\"\" - Processes a user prompt within a session, saves the chat history, and returns a response. - \"\"\" - session = db.query(models.Session).options( - joinedload(models.Session.messages) - ).filter(models.Session.id == session_id).first() - - if not session: - raise ValueError(f"Session with ID {session_id} not found.") - - # Save user message - user_message = models.Message(session_id=session_id, sender="user", content=prompt) - db.add(user_message) - db.commit() - db.refresh(user_message) - - # Get the appropriate LLM provider - llm_provider = get_llm_provider(provider_name) - - # Configure retrievers for the pipeline - context_chunks = [] - if load_faiss_retriever: - if self.faiss_retriever: - context_chunks.extend(self.faiss_retriever.retrieve_context(query=prompt, db=db)) # Ensure FAISS index is loaded - else: - print("Warning: FaissDBRetriever requested but not available. Proceeding without it.") - - rag_pipeline = DspyRagPipeline() - - with dspy.context(lm=llm_provider): - answer_text = await rag_pipeline.forward( - question=prompt, - history=session.messages, - context_chunks = context_chunks - ) - - # Save assistant's response - assistant_message = models.Message(session_id=session_id, sender="assistant", content=answer_text) - db.add(assistant_message) - db.commit() - db.refresh(assistant_message) - - return answer_text, provider_name - - def get_message_history(self, db: Session, session_id: int) -> List[models.Message]: - \"\"\" - Retrieves all messages for a given session, ordered by creation time. - \"\"\" - session = db.query(models.Session).options( - joinedload(models.Session.messages) - ).filter(models.Session.id == session_id).first() - - return sorted(session.messages, key=lambda msg: msg.created_at) if session else None -""" - - file_diff = """--- a/core/services/rag.py -+++ b/core/services/rag.py -@@ -20,6 +20,7 @@ - def __init__(self, retrievers: List[Retriever]): - self.retrievers = retrievers - self.faiss_retriever = next((r for r in retrievers if isinstance(r, FaissDBRetriever)), None) -+ self.db = None #Added to avoid potential errors - - async def chat_with_rag( - self, -@@ -28,6 +29,7 @@ - prompt: str, - provider_name: str, - load_faiss_retriever: bool = False -+ db: Session = None #Added to avoid potential errors - ) -> Tuple[str, str]: - \"\"\" - Processes a user prompt within a session, saves the chat history, and returns a response. -@@ -45,6 +47,7 @@ - # Get the appropriate LLM provider - llm_provider = get_llm_provider(provider_name) - -+ self.db = db #Added to avoid potential errors - # Configure retrievers for the pipeline - context_chunks = [] - if load_faiss_retriever: -@@ -69,6 +72,7 @@ - db.refresh(assistant_message) - - return answer_text, provider_name -+ - - def get_message_history(self, db: Session, session_id: int) -> List[models.Message]: - \"\"\" -""" - - expected_content = """import asyncio -from typing import List, Tuple -from sqlalchemy.orm import Session, joinedload -import dspy - -from app.db import models -from app.core.retrievers.faiss_db_retriever import FaissDBRetriever -from app.core.retrievers.base_retriever import Retriever -from app.core.providers.factory import get_llm_provider -from app.core.pipelines.dspy_rag import DspyRagPipeline - -class RAGService: - \"\"\" - Service for orchestrating conversational RAG pipelines. - Manages chat interactions and message history for a session. - \"\"\" - def __init__(self, retrievers: List[Retriever]): - self.retrievers = retrievers - self.faiss_retriever = next((r for r in retrievers if isinstance(r, FaissDBRetriever)), None) - self.db = None #Added to avoid potential errors - - async def chat_with_rag( - self, - db: Session, - session_id: int, - prompt: str, - provider_name: str, - load_faiss_retriever: bool = False - db: Session = None #Added to avoid potential errors - ) -> Tuple[str, str]: - \"\"\" - Processes a user prompt within a session, saves the chat history, and returns a response. - \"\"\" - session = db.query(models.Session).options( - joinedload(models.Session.messages) - ).filter(models.Session.id == session_id).first() - - if not session: - raise ValueError(f"Session with ID {session_id} not found.") - - # Save user message - user_message = models.Message(session_id=session_id, sender="user", content=prompt) - db.add(user_message) - db.commit() - db.refresh(user_message) - - # Get the appropriate LLM provider - llm_provider = get_llm_provider(provider_name) - - self.db = db #Added to avoid potential errors - # Configure retrievers for the pipeline - context_chunks = [] - if load_faiss_retriever: - if self.faiss_retriever: - context_chunks.extend(self.faiss_retriever.retrieve_context(query=prompt, db=db)) # Ensure FAISS index is loaded - else: - print("Warning: FaissDBRetriever requested but not available. Proceeding without it.") - - rag_pipeline = DspyRagPipeline() - - with dspy.context(lm=llm_provider): - answer_text = await rag_pipeline.forward( - question=prompt, - history=session.messages, - context_chunks = context_chunks - ) - - # Save assistant's response - assistant_message = models.Message(session_id=session_id, sender="assistant", content=answer_text) - db.add(assistant_message) - db.commit() - db.refresh(assistant_message) - - return answer_text, provider_name - - def get_message_history(self, db: Session, session_id: int) -> List[models.Message]: - \"\"\" - Retrieves all messages for a given session, ordered by creation time. - \"\"\" - session = db.query(models.Session).options( - joinedload(models.Session.messages) - ).filter(models.Session.id == session_id).first() - - return sorted(session.messages, key=lambda msg: msg.created_at) if session else None -""" - # The new_content you provided in the prompt is actually incorrect for this diff. - # The provided 'new_content' has lines moved around and is malformed. - # The `_apply_diff` function should produce the content as shown below. - # The correct new content is generated by applying the diff to the original content. - - # Apply the diff - actual_content = workspace_service._apply_diff(original_content, file_diff) - - # Assert that the actual content matches the expected content - assert actual_content.strip() == expected_content.strip() \ No newline at end of file diff --git a/ai-hub/app/core/services/user.py b/ai-hub/app/core/services/user.py new file mode 100644 index 0000000..2411efb --- /dev/null +++ b/ai-hub/app/core/services/user.py @@ -0,0 +1,101 @@ +from typing import Optional, Union +import uuid +from datetime import datetime +from sqlalchemy.orm import Session +from sqlalchemy.exc import SQLAlchemyError + +# Assuming the models are in a file named `models.py` in the `app.db` directory +from app.db import models + +class UserService: + def __init__(self): + pass + + def save_user(self, db: Session, oidc_id: str, email: str, username: str) -> str: + """ + Saves or updates a user record based on their OIDC ID. + If a user with this OIDC ID exists, it returns their existing ID. + Otherwise, it creates a new user record. + Returns the user's ID. + """ + try: + # Check if a user with this OIDC ID already exists + existing_user = db.query(models.User).filter(models.User.oidc_id == oidc_id).first() + + if existing_user: + # Update the user's information if needed + existing_user.email = email + existing_user.username = username + db.commit() + return existing_user.id + else: + # Create a new user record + new_user = models.User( + id=str(uuid.uuid4()), # Generate a unique ID for the user + oidc_id=oidc_id, + email=email, + username=username, + created_at=datetime.utcnow() + ) + db.add(new_user) + db.commit() + db.refresh(new_user) + return new_user.id + except SQLAlchemyError as e: + db.rollback() + raise + + def get_user_by_id(self, db: Session, user_id: str) -> Optional[models.User]: + """ + Retrieves a user record by their unique ID. + Returns the User object if found, otherwise None. + """ + try: + # Query the database for a user with the given ID + user = db.query(models.User).filter(models.User.id == user_id).first() + return user + except SQLAlchemyError as e: + # Log the error and return None in case of a database issue. + print(f"Database error while fetching user by ID: {e}") + return None + +# --- Framework-dependent helper functions --- +# These functions are placeholders and would need to be integrated with your +# specific web framework (e.g., FastAPI, Flask, Django). + +def login_required(f): + """ + A decorator to protect API endpoints and web pages. + It ensures a user is logged in and is a registered (non-anonymous) user. + If not, it redirects them to the login page. + This is a generic implementation. You would replace the logic inside + with code specific to your web framework's authentication system. + """ + async def wrapper(*args, **kwargs): + # Placeholder logic: Check for user in the request context + # For example, in FastAPI, you might use Depends(get_current_user) + # In Flask, you might use session or current_user + user_id = kwargs.get("user_id") # Assuming user_id is passed in via a dependency + if not user_id: + # Depending on the framework, this would return an Unauthorized error or a redirect + # For example: raise HTTPException(status_code=401, detail="Unauthorized") + pass + return await f(*args, **kwargs) + return wrapper + + +def get_current_user_id() -> Optional[str]: + """ + A helper function to get the current user's ID from the session. + This is a placeholder and needs to be implemented with your framework's + session/authentication management system. + """ + # Placeholder logic + # Example for FastAPI: + # from fastapi import Depends, Request + # from app.auth import get_current_user + # + # return get_current_user(request).id + + # For now, we return None as a generic placeholder + return None diff --git a/ai-hub/app/db/models.py b/ai-hub/app/db/models.py index 2cf0ebd..9785a58 100644 --- a/ai-hub/app/db/models.py +++ b/ai-hub/app/db/models.py @@ -9,6 +9,32 @@ # --- SQLAlchemy Models --- # These classes define the structure of the database tables and how they relate. +class User(Base): + """ + SQLAlchemy model for the 'users' table, used for OIDC authentication. + + This table stores user information obtained during the OIDC login process. + """ + __tablename__ = 'users' + + # The user's unique ID, which will be provided by the OIDC provider. + id = Column(String, primary_key=True, index=True) + # The unique OIDC ID from the provider. + oidc_id = Column(String, unique=True, nullable=True) + # The user's email address. + email = Column(String, nullable=True) + # The user's display name. + username = Column(String, nullable=True) + # Timestamp for when the user account was created. + created_at = Column(DateTime, default=datetime.utcnow) + + # Defines a one-to-many relationship with the Session table. + # 'back_populates' creates a link back to the User model from the Session model. + sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan") + + def __repr__(self): + return f"" + class Session(Base): """ SQLAlchemy model for the 'sessions' table. @@ -21,7 +47,8 @@ # Primary key for the session. id = Column(Integer, primary_key=True, index=True) # The ID of the user who owns this session. - user_id = Column(String, index=True, nullable=False) + # We add the ForeignKey to establish the link to the 'users' table. + user_id = Column(String, ForeignKey('users.id'), index=True, nullable=False) # A title for the conversation, which can be generated by the AI. title = Column(String, index=True, nullable=True) # The name of the LLM model used for this session (e.g., "Gemini", "DeepSeek"). @@ -37,6 +64,10 @@ # all its associated messages are also deleted. messages = relationship("Message", back_populates="session", cascade="all, delete-orphan") + # Defines a many-to-one relationship back to the User table. + # This allows us to access the parent User object from a Session object. + user = relationship("User", back_populates="sessions") + def __repr__(self): """ Provides a helpful string representation of the object for debugging. diff --git a/ai-hub/requirements.txt b/ai-hub/requirements.txt index d4e6495..5b0ee90 100644 --- a/ai-hub/requirements.txt +++ b/ai-hub/requirements.txt @@ -16,4 +16,5 @@ faiss-cpu dspy aioresponses -python-multipart \ No newline at end of file +python-multipart +PyJWT \ No newline at end of file diff --git a/ui/client-app/public/favicon.ico b/ui/client-app/public/favicon.ico index a11777c..60ffcf6 100644 --- a/ui/client-app/public/favicon.ico +++ b/ui/client-app/public/favicon.ico Binary files differ diff --git a/ui/client-app/public/index.html b/ui/client-app/public/index.html index 3893a7f..f08e21d 100644 --- a/ui/client-app/public/index.html +++ b/ui/client-app/public/index.html @@ -24,7 +24,7 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> - React App + Cortex AI diff --git a/ui/client-app/public/logo192.png b/ui/client-app/public/logo192.png index fc44b0a..ae32c6b 100644 --- a/ui/client-app/public/logo192.png +++ b/ui/client-app/public/logo192.png Binary files differ diff --git a/ui/client-app/public/logo512.png b/ui/client-app/public/logo512.png deleted file mode 100644 index a4e47a6..0000000 --- a/ui/client-app/public/logo512.png +++ /dev/null Binary files differ diff --git a/ui/client-app/public/manifest.json b/ui/client-app/public/manifest.json index 080d6c7..629617e 100644 --- a/ui/client-app/public/manifest.json +++ b/ui/client-app/public/manifest.json @@ -1,6 +1,7 @@ { - "short_name": "React App", - "name": "Create React App Sample", + "short_name": "CoretexHub", + "name": "CortexHub", + "description": "CortexHub - Your AI-Powered Code Assistant", "icons": [ { "src": "favicon.ico", diff --git a/ui/client-app/src/App.js b/ui/client-app/src/App.js index 6cba5a8..2097fad 100644 --- a/ui/client-app/src/App.js +++ b/ui/client-app/src/App.js @@ -1,9 +1,11 @@ -import React, { useState } from "react"; +// App.js +import React, { useState, useEffect } from "react"; import Navbar from "./components/Navbar"; -import HomePage from "./pages/HomePage"; // Import the new HomePage +import HomePage from "./pages/HomePage"; import VoiceChatPage from "./pages/VoiceChatPage"; import CodingAssistantPage from "./pages/CodingAssistantPage"; import LoginPage from "./pages/LoginPage"; +import { getUserStatus, logout } from "./services/apiService"; const Icon = ({ path, onClick, className }) => ( { + const urlParams = new URLSearchParams(window.location.search); + const userIdFromUrl = urlParams.get('user_id'); + + if (userIdFromUrl && !localStorage.getItem('userId')) { + localStorage.setItem('userId', userIdFromUrl); + console.log('User ID from URL saved to localStorage:', userIdFromUrl); + window.history.replaceState({}, document.title, window.location.pathname); + } + }, []); + + useEffect(() => { + const checkLoginStatus = async () => { + const storedUserId = localStorage.getItem("userId"); + + if (storedUserId) { + try { + const status = await getUserStatus(storedUserId); + if (status.is_logged_in) { + setIsLoggedIn(true); + setUserId(storedUserId); + if (currentPage === "login") { + setCurrentPage("home"); + } + } else { + setIsLoggedIn(false); + setUserId(null); + localStorage.removeItem("userId"); + if (authenticatedPages.includes(currentPage)) { + setCurrentPage("login"); + } + } + } catch (error) { + console.error("Failed to check user status:", error); + setIsLoggedIn(false); + setUserId(null); + localStorage.removeItem("userId"); + if (authenticatedPages.includes(currentPage)) { + setCurrentPage("login"); + } + } + } else { + setIsLoggedIn(false); + setUserId(null); + if (authenticatedPages.includes(currentPage)) { + setCurrentPage("login"); + } + } + }; + + checkLoginStatus(); + }, [currentPage]); + + const handleLogout = async () => { + try { + await logout(); + setIsLoggedIn(false); + setUserId(null); + localStorage.removeItem("userId"); + setCurrentPage("home"); + } catch (error) { + console.error("Logout failed:", error); + } + }; + + const handleNavigate = (page) => { + if (authenticatedPages.includes(page) && !isLoggedIn) { + setCurrentPage("login"); + } else { + setCurrentPage(page); + } + }; const toggleSidebar = () => { setIsSidebarOpen(!isSidebarOpen); @@ -32,27 +111,29 @@ const renderPage = () => { switch (currentPage) { case "home": - return ; + // Pass both isLoggedIn and handleLogout to HomePage + return ; case "voice-chat": return ; - case "coding-assistant" : - return + case "coding-assistant": + return ; case "login": return ; default: - // You can add a 404 or a default page here - return ; + return ; } }; return (
{currentPage !== "login" && ( - )}
diff --git a/ui/client-app/src/components/Navbar.js b/ui/client-app/src/components/Navbar.js index 0166164..4cd1d98 100644 --- a/ui/client-app/src/components/Navbar.js +++ b/ui/client-app/src/components/Navbar.js @@ -1,12 +1,13 @@ import React from 'react'; +import { ReactComponent as Logo } from '../logo.svg'; -const Navbar = ({ isOpen, onToggle, onNavigate, Icon }) => { +const Navbar = ({ isOpen, onToggle, onNavigate, onLogout, isLoggedIn, Icon }) => { const navItems = [ - { name: "Home", icon: "M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" , page: "home" }, - { name: "Voice Chat", icon:"M12 1a3 3 0 0 1 3 3v7a3 3 0 1 1-6 0V4a3 3 0 0 1 3-3zm5 10a5 5 0 0 1-10 0H5a7 7 0 0 0 14 0h-2zm-5 11v-4h-2v4h2z", page: "voice-chat" }, + { name: "Home", icon: "M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z", page: "home" }, + { name: "Voice Chat", icon: "M12 1a3 3 0 0 1 3 3v7a3 3 0 1 1-6 0V4a3 3 0 0 1 3-3zm5 10a5 5 0 0 1-10 0H5a7 7 0 0 0 14 0h-2zm-5 11v-4h-2v4h2z", page: "voice-chat" }, { name: "Coding Assistant", icon: "M9 16l-4-4 4-4M15 16l4-4-4-4", page: "coding-assistant" }, - { name: "History", icon: "M22 12h-4l-3 9L9 3l-3 9H2", page: "history" , disabled: true}, - { name: "Favorites", icon: "M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z", page: "favorites" , disabled: true}, + { name: "History", icon: "M22 12h-4l-3 9L9 3l-3 9H2", page: "history", disabled: true }, + { name: "Favorites", icon: "M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z", page: "favorites", disabled: true }, ]; return ( @@ -15,24 +16,27 @@ isOpen ? "w-64 p-4" : "w-16 p-2" } flex-shrink-0 z-50`} > - {/* Sidebar Header with Toggle Button */} -
- {isOpen && ( -

- Cortex Hub -

- )} + {/* Sidebar Header with Toggle Button and Logo */}
- + {isOpen && ( +
+ +

+ Cortex Hub +

+
+ )} +
+ +
-
{/* Main Navigation Items */}
- {/* Login Button */} -
onNavigate("login")} - className="flex items-center space-x-4 p-2 rounded-lg cursor-pointer bg-blue-500 text-white hover:bg-blue-600 transition-colors duration-200" - > - - {isOpen && Login} -
+ {/* Conditional Login/Logout Button */} + {isLoggedIn ? ( +
+ + {isOpen && Logout} +
+ ) : ( +
onNavigate("login")} + className="flex items-center space-x-4 p-2 rounded-lg cursor-pointer bg-blue-500 text-white hover:bg-blue-600 transition-colors duration-200" + > + + {isOpen && Login} +
+ )}
); }; -export default Navbar; +export default Navbar; \ No newline at end of file diff --git a/ui/client-app/src/logo.svg b/ui/client-app/src/logo.svg index 9dfc1c0..5dc211e 100644 --- a/ui/client-app/src/logo.svg +++ b/ui/client-app/src/logo.svg @@ -1 +1,159 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/client-app/src/pages/HomePage.js b/ui/client-app/src/pages/HomePage.js index 3be345b..c09691c 100644 --- a/ui/client-app/src/pages/HomePage.js +++ b/ui/client-app/src/pages/HomePage.js @@ -1,29 +1,74 @@ +// HomePage.js import React from 'react'; -const HomePage = ({ onNavigate }) => { +const HomePage = ({ onNavigate, isLoggedIn, onLogout }) => { + const buttonStyle = (enabled) => + enabled + ? "w-full sm:w-auto bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50" + : "w-full sm:w-auto bg-gray-400 text-gray-700 font-bold py-3 px-6 rounded-lg cursor-not-allowed opacity-50"; + + const codeAssistantButtonStyle = (enabled) => + enabled + ? "w-full sm:w-auto bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-6 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-opacity-50" + : "w-full sm:w-auto bg-gray-400 text-gray-700 font-bold py-3 px-6 rounded-lg cursor-not-allowed opacity-50"; + + const handleAuthNavigate = (page) => { + if (isLoggedIn) { + onNavigate(page); + } + }; + return (

- Welcome to Our App! 🚀 + Welcome to Cortex AI! 🧠

- Engage in seamless, interactive conversations with our AI-powered voice chat. - Speak naturally and receive instant, intelligent responses. + The on-premise AI platform for seamless, secure, and intelligent workflows. + Leverage advanced RAG, VectorDB, and TTS/STT features in a single powerful hub.

+ + {/* New section for Coding Assistant highlights */} +
+

+ Supercharge Your Coding with AI 🤖 +

+

+ Our powerful AI assistant goes beyond simple chat. It can access your local directory, navigate your file system, and understand and parse your files. Get instant, intelligent answers to your coding questions directly from your codebase. +

+
+
+ {isLoggedIn ? ( + + ) : ( + + )}
diff --git a/ui/client-app/src/pages/LoginPage.js b/ui/client-app/src/pages/LoginPage.js index 996fb1e..318643e 100644 --- a/ui/client-app/src/pages/LoginPage.js +++ b/ui/client-app/src/pages/LoginPage.js @@ -1,14 +1,105 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; +import { login, getUserStatus, logout } from '../services/apiService'; const LoginPage = () => { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + // We now look for a 'user_id' in the URL, which is provided by the backend + // after a successful OIDC login and callback. + const params = new URLSearchParams(window.location.search); + const userIdFromUrl = params.get('user_id'); + + // First, check localStorage for a saved user ID for persistent login + const storedUserId = localStorage.getItem('userId'); + const userId = userIdFromUrl || storedUserId; + + if (userId) { + setIsLoading(true); + // Fetch the full user details using the user ID from the URL. + // This is a more secure and robust way to handle the final callback. + const fetchUserDetails = async () => { + try { + const userStatus = await getUserStatus(userId); + setUser(userStatus); + // Store the user ID for future requests (e.g., in localStorage) + localStorage.setItem('userId', userStatus.id); + // Clean up the URL by removing the query parameter + window.history.replaceState({}, document.title, window.location.pathname); + } catch (err) { + setError('Failed to get user status. Please try again.'); + console.error(err); + } finally { + setIsLoading(false); + } + }; + fetchUserDetails(); + } + }, []); + const handleLogin = () => { - // TODO: Implement OIDC login logic here - console.log('OIDC Login functionality to be implemented...'); + // Redirect to the backend's /users/login endpoint + // The backend handles the OIDC redirect from there. + login(); }; - return ( -
-
+ const handleLogout = async () => { + setIsLoading(true); + try { + await logout(); + localStorage.removeItem('userId'); + setUser(null); + setError(null); + } catch (err) { + setError('Failed to log out. Please try again.'); + console.error(err); + } finally { + setIsLoading(false); + } + }; + + const renderContent = () => { + if (isLoading) { + return ( +
+ + + + + Processing login... +
+ ); + } + + if (error) { + return ( +
+

Error:

+

{error}

+
+ ); + } + + if (user) { + return ( +
+

Login Successful!

+

Welcome, {user.email}.

+

User ID: {user.id}

+ +
+ ); + } + + return ( + <>

Login

Click the button below to log in using OpenID Connect (OIDC). @@ -19,9 +110,17 @@ > Login with OIDC + + ); + }; + + return ( +

+
+ {renderContent()}
); }; -export default LoginPage; \ No newline at end of file +export default LoginPage; diff --git a/ui/client-app/src/services/apiService.js b/ui/client-app/src/services/apiService.js index 315acbf..c8ea6cd 100644 --- a/ui/client-app/src/services/apiService.js +++ b/ui/client-app/src/services/apiService.js @@ -1,5 +1,3 @@ -// src/services/apiService.js - // This file handles all communication with your API endpoints. // It is designed to be stateless and does not use any React hooks. @@ -10,17 +8,85 @@ const SESSIONS_CREATE_ENDPOINT = "http://localhost:8001/sessions"; const SESSIONS_CHAT_ENDPOINT = (id) => `http://localhost:8001/sessions/${id}/chat`; const TTS_ENDPOINT = "http://localhost:8001/speech"; +const USERS_LOGIN_ENDPOINT = "http://localhost:8001/users/login"; +const USERS_LOGOUT_ENDPOINT = "http://localhost:8001/users/logout"; +const USERS_ME_ENDPOINT = "http://localhost:8001/users/me"; /** - * Creates a new chat session with a unique user ID. + * A central utility function to get the user ID. + * If not found, it redirects to the login page. + * @returns {string} The user ID. + */ +const getUserId = () => { + const userId = localStorage.getItem('userId'); + if (!userId) { + console.error("User not authenticated. Redirecting to login."); + // Redirect to the login page + window.location.href = '/'; + } + return userId; +}; + +/** + * Initiates the OIDC login flow by redirecting the user to the login endpoint. + * This function now sends the frontend's URI to the backend so the backend + * knows where to redirect the user after a successful login with the OIDC provider. + */ +export const login = () => { + // Pass the current frontend origin to the backend's login endpoint. + // The backend will use this as the `state` parameter for the OIDC provider. + const frontendCallbackUri = window.location.origin; + const loginUrl = `${USERS_LOGIN_ENDPOINT}?frontend_callback_uri=${encodeURIComponent(frontendCallbackUri)}`; + window.location.href = loginUrl; +}; + +/** + * Fetches the current user's status from the backend. + * @param {string} userId - The unique ID of the current user. + * @returns {Promise} The user status object from the API response. + */ +export const getUserStatus = async (userId) => { + // The backend uses the 'X-User-ID' header to identify the user. + const response = await fetch(USERS_ME_ENDPOINT, { + method: "GET", + headers: { + "X-User-ID": userId, + }, + }); + if (!response.ok) { + throw new Error(`Failed to get user status. Status: ${response.status}`); + } + return await response.json(); +}; + +/** + * Logs the current user out. + * @returns {Promise} The logout message from the API response. + */ +export const logout = async () => { + const response = await fetch(USERS_LOGOUT_ENDPOINT, { + method: "POST", + }); + if (!response.ok) { + throw new Error(`Failed to log out. Status: ${response.status}`); + } + return await response.json(); +}; + + +// --- Updated Existing Functions --- + +/** + * Creates a new chat session. * @returns {Promise} The session object from the API response. */ export const createSession = async () => { - const generatedUserId = crypto.randomUUID(); + const userId = getUserId(); const response = await fetch(SESSIONS_CREATE_ENDPOINT, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ user_id: generatedUserId }), + headers: { "Content-Type": "application/json", "X-User-ID": userId }, + // Now we pass the userId to the backend. + body: JSON.stringify({ user_id: userId }), }); if (!response.ok) { throw new Error(`Failed to create session. Status: ${response.status}`); @@ -28,17 +94,21 @@ return await response.json(); }; +// --- Unchanged Functions --- + /** * Sends an audio blob to the STT endpoint for transcription. * @param {Blob} audioBlob - The recorded audio data. * @returns {Promise} The transcribed text. */ export const transcribeAudio = async (audioBlob) => { + const userId = getUserId(); const formData = new FormData(); formData.append("audio_file", audioBlob, "audio.wav"); const response = await fetch(STT_ENDPOINT, { method: "POST", body: formData, + headers: { "X-User-ID": userId }, }); if (!response.ok) { throw new Error("STT API failed"); @@ -54,9 +124,10 @@ * @returns {Promise} The AI's text response. */ export const chatWithAI = async (sessionId, prompt) => { + const userId = getUserId(); const response = await fetch(SESSIONS_CHAT_ENDPOINT(sessionId), { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", "X-User-ID": userId }, body: JSON.stringify({ prompt: prompt, provider_name: "gemini" }), }); if (!response.ok) { @@ -75,12 +146,13 @@ * @returns {Promise} */ export const streamSpeech = async (text, onData, onDone) => { + const userId = getUserId(); try { const url = `${TTS_ENDPOINT}?stream=true&as_wav=false`; const response = await fetch(url, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", "X-User-ID": userId }, body: JSON.stringify({ text }), });