diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e146d2c --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# API Keys +GOOGLE_API_KEY=your_key_here + +# Application Settings +APP_SECRET=your_secret_here + +# URLs and Origins +CORS_ORIGINS=http://localhost:3000 +API_URL=http://localhost:8000 diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..5ed4b32 --- /dev/null +++ b/.env.production @@ -0,0 +1,39 @@ +# API Configuration +API_URL=https://chat-app-backend.fly.dev +API_PORT=8000 + +# Frontend Configuration +FRONTEND_URL=https://chat-app-frontend.fly.dev +FRONTEND_PORT=80 + +# Database Configuration +DATABASE_URL=postgresql://user:password@host:5432/dbname +DATABASE_SSL_MODE=require + +# Authentication +JWT_SECRET=your-jwt-secret-here +JWT_EXPIRATION=24h + +# Service API Keys +OPENAI_API_KEY=your-openai-api-key +ANTHROPIC_API_KEY=your-anthropic-api-key + +# Monitoring and Logging +SENTRY_DSN=your-sentry-dsn +LOG_LEVEL=info + +# Redis Configuration (if needed) +REDIS_URL=redis://user:password@host:6379 + +# Fly.io Specific +FLY_API_TOKEN=your-fly-api-token +FLY_REGION=your-preferred-region + +# Security +CORS_ORIGINS=https://chat-app-frontend.fly.dev +RATE_LIMIT_REQUESTS=100 +RATE_LIMIT_PERIOD=60 + +# Feature Flags +ENABLE_CACHE=true +ENABLE_RATE_LIMITING=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3461576..706b32f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI +name: CI/CD on: push: @@ -7,41 +7,39 @@ on: branches: [ main ] jobs: - build: - + test: runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [14.x, 16.x] - python-version: [3.8, 3.9, 3.10] - steps: - - uses: actions/checkout@v3 - - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Install Dependencies (Node.js) - run: | - npm install - - - name: Install Dependencies (Python) - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run Tests (Node.js) - run: | - npm test - - - name: Run Tests (Python) - run: | - pytest + - uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.9' + + - name: Install Poetry + run: pip install poetry + + - name: Install dependencies + run: | + cd backend + poetry install + + - name: Run tests + run: | + cd backend + poetry run pytest + + deploy: + needs: test + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - uses: superfly/flyctl-actions/setup-flyctl@master + + - name: Deploy to fly.io + run: flyctl deploy --remote-only + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..16e6485 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,71 @@ +# Base image for both dev and prod +FROM python:3.9-slim as python-base +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=off \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=100 \ + POETRY_VERSION=1.4.2 \ + POETRY_HOME="/opt/poetry" \ + POETRY_VIRTUALENVS_IN_PROJECT=true \ + POETRY_NO_INTERACTION=1 \ + PYSETUP_PATH="/opt/pysetup" \ + VENV_PATH="/opt/pysetup/.venv" + +ENV PATH="$POETRY_HOME/bin:$VENV_PATH/bin:$PATH" + +# Builder stage +FROM python-base as builder +RUN apt-get update \ + && apt-get install --no-install-recommends -y \ + curl \ + build-essential + +# Install poetry +RUN curl -sSL https://install.python-poetry.org | python3 - + +# Copy project dependency files +WORKDIR $PYSETUP_PATH +COPY poetry.lock pyproject.toml ./ + +# Install runtime deps +RUN poetry install --no-dev + +# Development image +FROM python-base as development +ENV FASTAPI_ENV=development + +WORKDIR $PYSETUP_PATH + +# Copy dependencies from builder +COPY --from=builder $POETRY_HOME $POETRY_HOME +COPY --from=builder $PYSETUP_PATH $PYSETUP_PATH + +# Copy application code +COPY . . + +EXPOSE 8000 + +CMD ["poetry", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + +# Production image +FROM python:3.9-slim as production +ENV FASTAPI_ENV=production + +WORKDIR /app + +# Copy only necessary files from builder +COPY --from=builder /opt/pysetup/.venv /app/.venv +COPY . . + +ENV PATH="/app/.venv/bin:$PATH" + +# Create non-root user +RUN useradd -m -u 1000 appuser && \ + chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..fdd84f7 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""FastAPI backend application package.""" diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..c74d9be --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,28 @@ +from pydantic_settings import BaseSettings +from typing import List +from datetime import timedelta + +class Settings(BaseSettings): + """Application settings.""" + + # API Configuration + API_V1_STR: str = "/api/v1" + PROJECT_NAME: str = "SPARC API" + + # CORS + CORS_ORIGINS: List[str] = ["http://localhost:3000"] + + # LLM Configuration + GOOGLE_API_KEY: str + DEFAULT_MODEL: str = "gemini-pro" + + # API Key Configuration + API_KEY_ENCRYPTION_KEY: str + API_KEY_EXPIRATION: timedelta = timedelta(days=90) + API_RATE_LIMIT_MINUTE: int = 60 + API_RATE_LIMIT_HOUR: int = 1000 + API_RATE_LIMIT_DAY: int = 10000 + + class Config: + env_file = ".env" + case_sensitive = True diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..9c5d8fb --- /dev/null +++ b/backend/app/dependencies.py @@ -0,0 +1,15 @@ +from fastapi import Depends, HTTPException, status +from .services.llm.factory import LLMFactory +from .config import Settings + +settings = Settings() + +def get_llm_service(): + """Dependency for LLM service.""" + try: + return LLMFactory.create("gemini", settings.GOOGLE_API_KEY) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"LLM service unavailable: {str(e)}" + ) diff --git a/backend/app/dependencies/auth.py b/backend/app/dependencies/auth.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/dependencies/backend/app/dependencies/auth.py b/backend/app/dependencies/backend/app/dependencies/auth.py new file mode 100644 index 0000000..8f891f9 --- /dev/null +++ b/backend/app/dependencies/backend/app/dependencies/auth.py @@ -0,0 +1,40 @@ +from fastapi import Depends, HTTPException, Security +from fastapi.security.api_key import APIKeyHeader +from starlette.status import HTTP_403_FORBIDDEN + +# Define API key header field name +API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False) + +# In a real application, this would be stored securely (e.g., in a database or environment variable) +# This is just for demonstration +VALID_API_KEYS = { + "test-api-key-1": "user1", + "test-api-key-2": "user2" +} + +async def get_current_user_id(api_key: str = Depends(API_KEY_HEADER)) -> str: + """ + Dependency to get the current user ID from the API key. + + Args: + api_key: The API key from the request header + + Returns: + str: The user ID associated with the API key + + Raises: + HTTPException: If the API key is invalid or missing + """ + if not api_key: + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, + detail="No API key provided" + ) + + if api_key not in VALID_API_KEYS: + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, + detail="Invalid API key" + ) + + return VALID_API_KEYS[api_key] diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..1c10699 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,27 @@ +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from .routers import chat, preview +from .config import Settings + +settings = Settings() + +app = FastAPI( + title="SPARC API", + description="SPARC Framework API with LLM integration", + version="1.0.0" +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(chat.router, prefix="/api/v1") +app.include_router(preview.router, prefix="/api/v1") + +@app.get("/health") +async def health_check(): + return {"status": "healthy"} diff --git a/backend/app/models/api_key.py b/backend/app/models/api_key.py new file mode 100644 index 0000000..4a98b4c --- /dev/null +++ b/backend/app/models/api_key.py @@ -0,0 +1,32 @@ +from sqlalchemy import Column, Integer, String, DateTime, Boolean, func +from sqlalchemy.orm import relationship +from datetime import datetime +from cryptography.fernet import Fernet +from ..database import Base +from ..config import Settings + +settings = Settings() +fernet = Fernet(settings.API_KEY_ENCRYPTION_KEY.encode()) + +class APIKey(Base): + """Model for storing API keys""" + __tablename__ = "api_keys" + + id = Column(Integer, primary_key=True, index=True) + key = Column(String, unique=True, index=True, nullable=False) + provider = Column(String, nullable=False) + user_id = Column(Integer, nullable=False) + created_at = Column(DateTime, default=func.now(), nullable=False) + last_used = Column(DateTime, nullable=True) + usage_count = Column(Integer, default=0) + is_active = Column(Boolean, default=True) + + @property + def decrypted_key(self) -> str: + """Decrypt and return the API key""" + return fernet.decrypt(self.key.encode()).decode() + + @classmethod + def encrypt_key(cls, key: str) -> str: + """Encrypt an API key""" + return fernet.encrypt(key.encode()).decode() diff --git a/backend/app/models/chat.py b/backend/app/models/chat.py new file mode 100644 index 0000000..01e6de1 --- /dev/null +++ b/backend/app/models/chat.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel + +class ChatRequest(BaseModel): + prompt: str + stream: bool = False + +class ChatResponse(BaseModel): + response: str diff --git a/backend/app/routers/api_keys.py b/backend/app/routers/api_keys.py new file mode 100644 index 0000000..8307aaa --- /dev/null +++ b/backend/app/routers/api_keys.py @@ -0,0 +1,95 @@ +from fastapi import APIRouter, Depends, HTTPException, Security +from fastapi.security import APIKeyHeader +from sqlalchemy.orm import Session +from typing import List, Optional +from ..dependencies.auth import get_current_user_id +from ..database import get_db +from ..services.api_key import APIKeyService +from ..models.api_key import APIKey +from pydantic import BaseModel +from datetime import datetime + +router = APIRouter(prefix="/api/v1/keys", tags=["api-keys"]) + +class APIKeyCreate(BaseModel): + provider: str + +class APIKeyResponse(BaseModel): + id: int + provider: str + created_at: datetime + last_used: Optional[datetime] + usage_count: int + is_active: bool + + class Config: + from_attributes = True + +@router.post("/", response_model=APIKeyResponse) +async def create_api_key( + key_create: APIKeyCreate, + db: Session = Depends(get_db), + current_user_id: int = Depends(get_current_user_id) +): + """Create a new API key""" + service = APIKeyService(db) + key = service.create_key(current_user_id, key_create.provider) + return key + +@router.get("/", response_model=List[APIKeyResponse]) +async def list_api_keys( + db: Session = Depends(get_db), + current_user_id: int = Depends(get_current_user_id) +): + """List all API keys for the current user""" + service = APIKeyService(db) + return service.list_keys(current_user_id) + +@router.delete("/{key_id}") +async def delete_api_key( + key_id: int, + db: Session = Depends(get_db), + current_user_id: int = Depends(get_current_user_id) +): + """Delete an API key""" + service = APIKeyService(db) + if not service.delete_key(key_id, current_user_id): + raise HTTPException(status_code=404, detail="API key not found") + return {"status": "success"} + +@router.post("/{key_id}/rotate") +async def rotate_api_key( + key_id: int, + db: Session = Depends(get_db), + current_user_id: int = Depends(get_current_user_id) +): + """Rotate an API key""" + service = APIKeyService(db) + key = service.rotate_key(key_id, current_user_id) + if not key: + raise HTTPException(status_code=404, detail="API key not found") + return {"status": "success"} + +# Middleware for API key validation +api_key_header = APIKeyHeader(name="X-API-Key") + +async def validate_api_key( + api_key: str = Security(api_key_header), + db: Session = Depends(get_db) +): + service = APIKeyService(db) + key = service.validate_key(api_key) + + if not key: + raise HTTPException( + status_code=401, + detail="Invalid API key" + ) + + if not service.check_rate_limit(key): + raise HTTPException( + status_code=429, + detail="Rate limit exceeded" + ) + + return key diff --git a/backend/app/routers/chat.py b/backend/app/routers/chat.py new file mode 100644 index 0000000..03c175b --- /dev/null +++ b/backend/app/routers/chat.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from typing import Optional +from ..dependencies import get_llm_service +from ..services.llm.base import BaseLLMService + +router = APIRouter(prefix="/chat", tags=["chat"]) + +class ChatRequest(BaseModel): + prompt: str + stream: bool = False + +class ChatResponse(BaseModel): + text: str + +@router.post("/complete", response_model=ChatResponse) +async def create_completion( + request: ChatRequest, + llm_service: BaseLLMService = Depends(get_llm_service) +): + """Create a chat completion.""" + try: + if request.stream: + return StreamingResponse( + llm_service.stream(request.prompt), + media_type="text/event-stream" + ) + + response = await llm_service.complete(request.prompt) + return ChatResponse(text=response) + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/routers/preview.py b/backend/app/routers/preview.py new file mode 100644 index 0000000..6c64dbf --- /dev/null +++ b/backend/app/routers/preview.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional +from ..services.preview.base import CodePreviewService + +router = APIRouter(prefix="/preview", tags=["preview"]) +preview_service = CodePreviewService() + +class PreviewRequest(BaseModel): + code: str + language: Optional[str] = None + +@router.post("/") +async def generate_preview(request: PreviewRequest): + try: + return preview_service.highlight_code( + request.code, + request.language + ) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..b7c77ed --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""Services package for the FastAPI backend.""" diff --git a/backend/app/services/api_key.py b/backend/app/services/api_key.py new file mode 100644 index 0000000..081cb4b --- /dev/null +++ b/backend/app/services/api_key.py @@ -0,0 +1,92 @@ +from datetime import datetime, timedelta +from typing import List, Optional +from sqlalchemy.orm import Session +from fastapi import HTTPException +from ..models.api_key import APIKey +from ..config import Settings +import secrets + +settings = Settings() + +class APIKeyService: + def __init__(self, db: Session): + self.db = db + + def create_key(self, user_id: int, provider: str) -> APIKey: + """Create a new API key""" + key = secrets.token_urlsafe(32) + encrypted_key = APIKey.encrypt_key(key) + + db_key = APIKey( + key=encrypted_key, + provider=provider, + user_id=user_id + ) + + self.db.add(db_key) + self.db.commit() + self.db.refresh(db_key) + return db_key + + def get_key(self, key_id: int, user_id: int) -> Optional[APIKey]: + """Retrieve an API key""" + return self.db.query(APIKey).filter( + APIKey.id == key_id, + APIKey.user_id == user_id + ).first() + + def list_keys(self, user_id: int) -> List[APIKey]: + """List all API keys for a user""" + return self.db.query(APIKey).filter(APIKey.user_id == user_id).all() + + def delete_key(self, key_id: int, user_id: int) -> bool: + """Delete an API key""" + key = self.get_key(key_id, user_id) + if not key: + return False + + self.db.delete(key) + self.db.commit() + return True + + def validate_key(self, key: str) -> Optional[APIKey]: + """Validate an API key and update usage metrics""" + db_key = self.db.query(APIKey).filter(APIKey.key == APIKey.encrypt_key(key)).first() + + if not db_key or not db_key.is_active: + return None + + # Update usage metrics + db_key.last_used = datetime.utcnow() + db_key.usage_count += 1 + self.db.commit() + + return db_key + + def check_rate_limit(self, key: APIKey) -> bool: + """Check if key has exceeded rate limits""" + now = datetime.utcnow() + + # Implement rate limiting logic here + minute_usage = self.db.query(APIKey).filter( + APIKey.id == key.id, + APIKey.last_used >= now - timedelta(minutes=1) + ).count() + + if minute_usage > settings.API_RATE_LIMIT_MINUTE: + return False + + return True + + def rotate_key(self, key_id: int, user_id: int) -> Optional[APIKey]: + """Rotate an API key""" + key = self.get_key(key_id, user_id) + if not key: + return None + + new_key = secrets.token_urlsafe(32) + key.key = APIKey.encrypt_key(new_key) + key.created_at = datetime.utcnow() + + self.db.commit() + return key diff --git a/backend/app/services/llm/base.py b/backend/app/services/llm/base.py new file mode 100644 index 0000000..e028f10 --- /dev/null +++ b/backend/app/services/llm/base.py @@ -0,0 +1,15 @@ +from abc import ABC, abstractmethod +from typing import AsyncIterator, Optional + +class BaseLLMService(ABC): + """Abstract base class for LLM services.""" + + @abstractmethod + async def complete(self, prompt: str, **kwargs) -> str: + """Generate a completion for the given prompt.""" + pass + + @abstractmethod + async def stream(self, prompt: str, **kwargs) -> AsyncIterator[str]: + """Stream completions for the given prompt.""" + pass diff --git a/backend/app/services/llm/factory.py b/backend/app/services/llm/factory.py new file mode 100644 index 0000000..d7ef56e --- /dev/null +++ b/backend/app/services/llm/factory.py @@ -0,0 +1,80 @@ +import logging +from typing import Dict, Type, List, Optional +from .base import BaseLLMService +from .gemini import GeminiService, GeminiServiceError + +logger = logging.getLogger(__name__) + +class LLMFactoryError(Exception): + """Base exception for LLM factory errors""" + pass + +class LLMFactory: + """Factory for creating LLM service instances with fallback support.""" + + _services: Dict[str, Type[BaseLLMService]] = { + "gemini": GeminiService + } + + @classmethod + def create( + cls, + provider: str, + api_key: str, + fallback_providers: Optional[List[str]] = None, + **kwargs + ) -> BaseLLMService: + """ + Create an LLM service instance with optional fallbacks. + + Args: + provider: Primary LLM provider name + api_key: API key for the provider + fallback_providers: Optional list of fallback providers + **kwargs: Additional provider-specific arguments + + Returns: + BaseLLMService: Configured LLM service instance + + Raises: + LLMFactoryError: If no working provider could be initialized + """ + providers_to_try = [provider] + if fallback_providers: + providers_to_try.extend(fallback_providers) + + last_error = None + for provider_name in providers_to_try: + if provider_name not in cls._services: + logger.warning(f"Unsupported LLM provider: {provider_name}") + continue + + try: + service_class = cls._services[provider_name] + return service_class(api_key=api_key, **kwargs) + except Exception as e: + last_error = e + logger.error( + f"Failed to initialize {provider_name} service: {str(e)}, " + f"trying next provider..." + ) + + raise LLMFactoryError( + f"Failed to initialize any LLM provider from {providers_to_try}" + ) from last_error + +def get_llm_service(api_key: str, provider: str = "gemini", **kwargs) -> BaseLLMService: + """Get an instance of an LLM service. + + Args: + api_key: Provider API key + provider: Provider name (default: gemini) + **kwargs: Additional provider-specific arguments + + Returns: + BaseLLMService: Configured LLM service + + Raises: + LLMFactoryError: If provider initialization fails + """ + return LLMFactory.create(provider, api_key, **kwargs) diff --git a/backend/app/services/llm/gemini.py b/backend/app/services/llm/gemini.py new file mode 100644 index 0000000..edf6212 --- /dev/null +++ b/backend/app/services/llm/gemini.py @@ -0,0 +1,51 @@ +import logging +import google.generativeai as genai +from typing import AsyncIterator +from google.api_core import exceptions as google_exceptions +from .base import BaseLLMService +from ...utils.retry import async_retry + +logger = logging.getLogger(__name__) + +class GeminiServiceError(Exception): + """Base exception for Gemini service errors""" + pass + +class GeminiService(BaseLLMService): + """Google Gemini LLM service implementation.""" + + def __init__(self, api_key: str, model: str = "gemini-pro"): + try: + genai.configure(api_key=api_key) + self.model = genai.GenerativeModel(model) + except Exception as e: + raise GeminiServiceError(f"Failed to initialize Gemini service: {str(e)}") + + @async_retry( + retries=3, + delay=1.0, + exceptions=( + google_exceptions.ResourceExhausted, + google_exceptions.ServiceUnavailable, + google_exceptions.DeadlineExceeded + ) + ) + async def complete(self, prompt: str, **kwargs) -> str: + try: + response = self.model.generate_content(prompt) + if not response.text: + raise GeminiServiceError("Empty response received") + return response.text + except Exception as e: + if isinstance(e, GeminiServiceError): + raise + raise GeminiServiceError(f"Completion failed: {str(e)}") from e + + async def stream(self, prompt: str, **kwargs) -> AsyncIterator[str]: + try: + response = self.model.generate_content(prompt, stream=True) + async for chunk in response: + if chunk.text: + yield chunk.text + except Exception as e: + raise GeminiServiceError(f"Stream generation failed: {str(e)}") from e diff --git a/backend/app/services/preview/__init__.py b/backend/app/services/preview/__init__.py new file mode 100644 index 0000000..2a9a8be --- /dev/null +++ b/backend/app/services/preview/__init__.py @@ -0,0 +1 @@ +"""Preview service package for handling preview-related functionality.""" diff --git a/backend/app/services/preview/base.py b/backend/app/services/preview/base.py new file mode 100644 index 0000000..14d4325 --- /dev/null +++ b/backend/app/services/preview/base.py @@ -0,0 +1,45 @@ +from typing import Dict, Optional +import pygments +from pygments import lexers, formatters +from pygments.util import ClassNotFound +from pygments.lexers.special import TextLexer +from pygments.lexers.javascript import JavascriptLexer + +class CodePreviewService: + def __init__(self): + self._cache = {} + + def highlight_code(self, code: str, language: Optional[str] = None) -> Dict: + cache_key = f"{code}-{language}" + if cache_key in self._cache: + return self._cache[cache_key] + + if language: + try: + lexer = lexers.get_lexer_by_name(language) + except ClassNotFound: + lexer = lexers.guess_lexer(code) + else: + # Try to detect if the code is JavaScript + if "function" in code and ("{" in code or "(" in code) and ";" in code: + lexer = JavascriptLexer() + else: + try: + lexer = lexers.guess_lexer(code) + except ClassNotFound: + lexer = TextLexer() + + formatter = formatters.HtmlFormatter( + linenos=True, + cssclass="highlight", + style="monokai" + ) + + result = { + "html": pygments.highlight(code, lexer, formatter), + "css": formatter.get_style_defs(".highlight"), + "language": lexer.name + } + + self._cache[cache_key] = result + return result diff --git a/backend/app/utils/retry.py b/backend/app/utils/retry.py new file mode 100644 index 0000000..35278c5 --- /dev/null +++ b/backend/app/utils/retry.py @@ -0,0 +1,57 @@ +import asyncio +from functools import wraps +from typing import Type, Tuple, Optional, Callable, Any +import logging + +logger = logging.getLogger(__name__) + +class RetryError(Exception): + """Raised when all retry attempts fail""" + pass + +def async_retry( + retries: int = 3, + delay: float = 1.0, + backoff: float = 2.0, + exceptions: Tuple[Type[Exception], ...] = (Exception,), + on_retry: Optional[Callable[[Exception, int], Any]] = None +): + """ + Async retry decorator with exponential backoff + + Args: + retries: Maximum number of retries + delay: Initial delay between retries in seconds + backoff: Multiplier for delay between retries + exceptions: Tuple of exceptions to catch + on_retry: Optional callback function called on each retry + """ + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + last_exception = None + current_delay = delay + + for attempt in range(retries + 1): + try: + return await func(*args, **kwargs) + except exceptions as e: + last_exception = e + if attempt == retries: + break + + if on_retry: + on_retry(e, attempt + 1) + + logger.warning( + f"Attempt {attempt + 1}/{retries} failed: {str(e)}. " + f"Retrying in {current_delay:.1f}s..." + ) + + await asyncio.sleep(current_delay) + current_delay *= backoff + + raise RetryError(f"Failed after {retries} retries") from last_exception + + return wrapper + return decorator diff --git a/backend/app/utils/streaming.py b/backend/app/utils/streaming.py new file mode 100644 index 0000000..cdba824 --- /dev/null +++ b/backend/app/utils/streaming.py @@ -0,0 +1,19 @@ +import json +from typing import AsyncIterator + +async def format_sse(data: str, event: str = None) -> str: + """Format Server-Sent Events message.""" + message = f"data: {json.dumps(data)}\n" + if event is not None: + message = f"event: {event}\n{message}" + return f"{message}\n" + +async def stream_generator(stream: AsyncIterator[str]) -> AsyncIterator[str]: + """Generate SSE stream from async iterator.""" + try: + async for chunk in stream: + yield await format_sse(chunk) + except Exception as e: + yield await format_sse(str(e), event="error") + finally: + yield await format_sse("[DONE]", event="done") diff --git a/backend/app/utils/validation.py b/backend/app/utils/validation.py new file mode 100644 index 0000000..9bc437e --- /dev/null +++ b/backend/app/utils/validation.py @@ -0,0 +1,41 @@ +from typing import Optional +from pydantic import BaseModel, validator +from fastapi import HTTPException +from ..services.llm.factory import LLMFactory + +class ValidationError(Exception): + """Custom validation error.""" + pass + +def validate_prompt(prompt: str) -> str: + """Validate chat prompt.""" + if not prompt or not prompt.strip(): + raise ValidationError("Prompt cannot be empty") + return prompt.strip() + +def validate_api_key(api_key: str, provider: str) -> bool: + """Validate API key for the given provider. + + Args: + api_key: API key to validate + provider: LLM provider name + + Returns: + bool: True if valid + + Raises: + HTTPException: If validation fails + """ + if not api_key: + raise HTTPException( + status_code=401, + detail="API key is required" + ) + + if provider not in LLMFactory._services: + raise HTTPException( + status_code=400, + detail=f"Invalid provider: {provider}" + ) + + return True diff --git a/backend/fly.toml b/backend/fly.toml new file mode 100644 index 0000000..c09e96f --- /dev/null +++ b/backend/fly.toml @@ -0,0 +1,40 @@ +app = "chat-app-backend" + +[build] + dockerfile = "Dockerfile" + +[env] + PORT = "8000" + PYTHON_ENV = "production" + +[[services]] + internal_port = 8000 + protocol = "tcp" + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 1 + + [[services.ports]] + handlers = ["http"] + port = "80" + + [[services.ports]] + handlers = ["tls", "http"] + port = "443" + + [[services.http_checks]] + interval = "10s" + timeout = "2s" + grace_period = "5s" + method = "get" + path = "/health" + protocol = "http" + +[metrics] + port = 8000 + path = "/metrics" + +[[vm]] + cpu_kind = "shared" + cpus = 1 + memory_mb = 1024 diff --git a/backend/poetry.lock b/backend/poetry.lock new file mode 100644 index 0000000..814af74 --- /dev/null +++ b/backend/poetry.lock @@ -0,0 +1,327 @@ +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.7.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.9" +files = [ + {file = "anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352"}, + {file = "anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] +trio = ["trio (>=0.26.1)"] + +[[package]] +name = "click" +version = "8.1.8" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "fastapi" +version = "0.109.2" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fastapi-0.109.2-py3-none-any.whl", hash = "sha256:2c9bab24667293b501cad8dd388c05240c850b58ec5876ee3283c47d6e1e3a4d"}, + {file = "fastapi-0.109.2.tar.gz", hash = "sha256:f3817eac96fe4f65a2ebb4baa000f394e55f5fccdaf7f75250804bc58f354f73"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.36.3,<0.37.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "pydantic" +version = "2.10.4" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.10.4-py3-none-any.whl", hash = "sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d"}, + {file = "pydantic-2.10.4.tar.gz", hash = "sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.27.2" +typing-extensions = ">=4.12.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata"] + +[[package]] +name = "pydantic-core" +version = "2.27.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"}, + {file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af"}, + {file = "pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4"}, + {file = "pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31"}, + {file = "pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc"}, + {file = "pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0"}, + {file = "pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b"}, + {file = "pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b"}, + {file = "pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b"}, + {file = "pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506"}, + {file = "pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da"}, + {file = "pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b"}, + {file = "pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad"}, + {file = "pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993"}, + {file = "pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96"}, + {file = "pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e"}, + {file = "pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35"}, + {file = "pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "starlette" +version = "0.36.3" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.8" +files = [ + {file = "starlette-0.36.3-py3-none-any.whl", hash = "sha256:13d429aa93a61dc40bf503e8c801db1f1bca3dc706b10ef2434a36123568f044"}, + {file = "starlette-0.36.3.tar.gz", hash = "sha256:90a671733cfb35771d8cc605e0b679d23b992f8dcfad48cc60b38cb29aeb7080"}, +] + +[package.dependencies] +anyio = ">=3.4.0,<5" +typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} + +[package.extras] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "uvicorn" +version = "0.25.0" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +files = [ + {file = "uvicorn-0.25.0-py3-none-any.whl", hash = "sha256:ce107f5d9bd02b4636001a77a4e74aab5e1e2b146868ebbad565237145af444c"}, + {file = "uvicorn-0.25.0.tar.gz", hash = "sha256:6dddbad1d7ee0f5140aba5ec138ddc9612c5109399903828b4874c9937f009c2"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" +typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.9" +content-hash = "294b8e2693958ca37c6372f8ab048e6a80ccd3c961e7f7542b41fd1c306908b6" diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..a821e78 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,17 @@ +[tool.poetry] +name = "sparc-backend" +version = "0.1.0" +description = "SPARC Framework Backend with LLM Integration" +authors = ["SPARC Team"] + +[tool.poetry.dependencies] +python = "^3.9" +pygments = "^2.17.2" +fastapi = "^0.109.0" +uvicorn = "^0.25.0" +google-cloud-aiplatform = "^1.38.1" +python-dotenv = "^1.0.0" +pytest = "^7.4.3" +httpx = "^0.25.2" +pydantic = "^2.5.2" +python-jose = {extras = ["cryptography"], version = "^3.3.0"} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3144d88 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1 @@ +google-generativeai==0.3.2 diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py new file mode 100644 index 0000000..f7ff548 --- /dev/null +++ b/backend/tests/test_chat.py @@ -0,0 +1,65 @@ +import pytest +from fastapi.testclient import TestClient +from fastapi import status +from app.main import app +from app.services.llm.base import BaseLLMService +from app.models.chat import ChatRequest, ChatResponse +from unittest.mock import AsyncMock, patch + +client = TestClient(app) + +@pytest.fixture +def mock_llm_service(): + with patch('app.services.llm.factory.get_llm_service') as mock: + service = AsyncMock(spec=BaseLLMService) + service.complete.return_value = "Mock response" + service.stream.return_value = AsyncMock(__aiter__=lambda _: iter(["Mock stream response"])) + mock.return_value = service + yield service + +def test_create_completion(mock_llm_service): + """Test chat completion endpoint.""" + request = ChatRequest(prompt="Test prompt", stream=False) + response = client.post( + "/api/v1/chat/complete", + json=request.dict() + ) + assert response.status_code == status.HTTP_200_OK + result = ChatResponse.parse_obj(response.json()) + assert result.response == "Mock response" + mock_llm_service.complete.assert_called_once_with(prompt="Test prompt") + +def test_create_completion_streaming(mock_llm_service): + """Test streaming chat completion.""" + request = ChatRequest(prompt="Test prompt", stream=True) + response = client.post( + "/api/v1/chat/complete", + json=request.dict() + ) + assert response.status_code == status.HTTP_200_OK + assert 'text/event-stream' in response.headers['content-type'] + mock_llm_service.stream.assert_called_once_with(prompt="Test prompt") + +def test_chat_validation_error(): + """Test chat endpoint with validation error.""" + response = client.post( + "/api/v1/chat/complete", + json={"prompt": "", "stream": False} + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + error = response.json() + assert "detail" in error + assert any("prompt" in e["loc"] for e in error["detail"]) + +@pytest.mark.asyncio +async def test_chat_streaming_error(mock_llm_service): + """Test streaming chat with error.""" + mock_llm_service.stream.side_effect = Exception("Test error") + request = ChatRequest(prompt="Test prompt", stream=True) + response = client.post( + "/api/v1/chat/complete", + json=request.dict() + ) + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + error = response.json() + assert "detail" in error diff --git a/backend/tests/test_llm.py b/backend/tests/test_llm.py new file mode 100644 index 0000000..a73ee69 --- /dev/null +++ b/backend/tests/test_llm.py @@ -0,0 +1,47 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from google.api_core import exceptions as google_exceptions + +from app.services.llm.factory import LLMFactory +from app.services.llm.gemini import GeminiService, GeminiServiceError + +def test_llm_factory_create(): + """Test LLM factory creation.""" + with patch('app.services.llm.factory.LLMFactory._services', {'invalid_provider': None}): + with pytest.raises(GeminiServiceError): + LLMFactory.create("invalid_provider", "fake_key") + + +@pytest.mark.asyncio +async def test_gemini_service_complete(): + """Test Gemini service completion.""" + service = GeminiService("fake_key") + + mock_response = MagicMock() + mock_response.text = "Test response" + service.model = MagicMock() + service.model.generate_content.return_value = mock_response + + response = await service.complete("Test prompt") + assert response == "Test response" + service.model.generate_content.assert_called_once_with("Test prompt") + +@pytest.mark.asyncio +async def test_gemini_service_stream(): + """Test Gemini service streaming.""" + service = GeminiService("fake_key") + + mock_chunk = MagicMock() + mock_chunk.text = "Test chunk" + mock_response = AsyncMock() + mock_response.__aiter__.return_value = [mock_chunk].__aiter__() + + service.model = MagicMock() + service.model.generate_content.return_value = mock_response + + chunks = [] + async for chunk in service.stream("Test prompt"): + chunks.append(chunk) + + assert chunks == ["Test chunk"] + service.model.generate_content.assert_called_once_with("Test prompt", stream=True) diff --git a/backend/tests/test_preview.py b/backend/tests/test_preview.py new file mode 100644 index 0000000..3f1bc58 --- /dev/null +++ b/backend/tests/test_preview.py @@ -0,0 +1,44 @@ +import pytest +from app.services.preview.base import CodePreviewService + +@pytest.fixture +def preview_service(): + return CodePreviewService() + +def test_code_preview_python(preview_service): + code = '''def hello(): + print("Hello, World!")''' + + result = preview_service.highlight_code(code, "python") + + assert "html" in result + assert "css" in result + assert result["language"].lower() == "python" + assert "highlight" in result["css"] + assert "print" in result["html"] + +def test_code_preview_auto_detect(preview_service): + code = '''function hello() { + console.log("Hello, World!"); +}''' + + result = preview_service.highlight_code(code) + + assert result["language"].lower() == "javascript" + assert "console" in result["html"] + +def test_code_preview_invalid_language(preview_service): + code = 'print("Hello")' + + result = preview_service.highlight_code(code, "invalid_lang") + + assert "html" in result + assert "print" in result["html"] + +def test_code_preview_caching(preview_service): + code = 'print("test")' + + result1 = preview_service.highlight_code(code, "python") + result2 = preview_service.highlight_code(code, "python") + + assert result1 == result2 diff --git a/backend/tests/test_validation.py b/backend/tests/test_validation.py new file mode 100644 index 0000000..693c27d --- /dev/null +++ b/backend/tests/test_validation.py @@ -0,0 +1,20 @@ +import pytest +from app.utils.validation import validate_api_key +from fastapi import HTTPException + +def test_valid_api_key(): + """Test valid API key validation.""" + result = validate_api_key("valid_key", "gemini") + assert result is True + +def test_invalid_api_key(): + """Test invalid API key validation.""" + with pytest.raises(HTTPException) as exc: + validate_api_key("", "gemini") + assert exc.value.status_code == 401 + +def test_invalid_provider(): + """Test invalid provider validation.""" + with pytest.raises(HTTPException) as exc: + validate_api_key("valid_key", "invalid_provider") + assert exc.value.status_code == 400 diff --git a/docker-compose.yml b/docker-compose.yml index ab11ad9..27f02db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,66 @@ version: '3.8' services: - app: - build: . + backend: + build: + context: ./backend + target: development ports: - - "3000:3000" + - "8000:8000" volumes: - - .:/app + - ./backend:/app + - /app/node_modules # Prevents overwriting node_modules + environment: + - ENVIRONMENT=development + env_file: + - .env + depends_on: + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + develop: + watch: + - path: ./backend + target: /app + action: sync + + frontend: + build: + context: ./frontend + target: development + ports: + - "3000:80" + volumes: + - ./frontend:/app + - /app/node_modules # Prevents overwriting node_modules environment: - NODE_ENV=development + - REACT_APP_API_URL=http://backend:8000 + env_file: + - .env + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000"] + interval: 30s + timeout: 10s + retries: 3 + develop: + watch: + - path: ./frontend + target: /app + action: sync + + redis: + image: redis:alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 3 diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..0b145fc --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,78 @@ +# Deployment Guide + +## Local Development + +1. Install prerequisites: + - Docker and Docker Compose + - Node.js 18+ + - Python 3.9+ + - Poetry + +2. Set up environment variables: + ```bash + cp .env.example .env + # Edit .env with your configuration + ``` + +3. Start the development environment: + ```bash + docker-compose up -d + ``` + +4. Access the services: + - Frontend: http://localhost:3000 + - Backend API: http://localhost:8000 + - API Documentation: http://localhost:8000/docs + +## Production Deployment (fly.io) + +1. Install flyctl: + ```bash + curl -L https://fly.io/install.sh | sh + ``` + +2. Login to fly.io: + ```bash + flyctl auth login + ``` + +3. Create the application: + ```bash + flyctl launch + ``` + +4. Set up secrets: + ```bash + flyctl secrets set LLM_API_KEY=your_api_key + # Add other required secrets + ``` + +5. Deploy the application: + ```bash + flyctl deploy + ``` + +## Configuration Reference + +### Environment Variables + +- `LLM_API_KEY`: API key for the LLM provider +- `LLM_PROVIDER`: LLM provider to use (default: "gemini") +- `NODE_ENV`: Environment mode (development/production) +- `PORT`: Port for the frontend service +- `API_URL`: Backend API URL + +### Docker Services + +- Frontend: React/Next.js application +- Backend: FastAPI service +- Redis: Session store and caching + +### Deployment Regions + +The application is configured to deploy to fly.io's automatically selected region. You can modify the region in fly.toml: + +```toml +[env] + PRIMARY_REGION = "iad" +``` diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..5cad414 --- /dev/null +++ b/fly.toml @@ -0,0 +1,28 @@ +app = "chat-app" + +[build] + [build.args] + NODE_VERSION = "18" + +[env] + PORT = "8080" + +[[services]] + internal_port = 8080 + protocol = "tcp" + + [[services.ports]] + handlers = ["http"] + port = "80" + + [[services.ports]] + handlers = ["tls", "http"] + port = "443" + + [[services.http_checks]] + interval = "10s" + timeout = "2s" + grace_period = "5s" + method = "get" + path = "/health" + protocol = "http" diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..1d13642 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,14 @@ +# Build stage +FROM node:18-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# Production stage +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/fly.toml b/frontend/fly.toml new file mode 100644 index 0000000..4567cd6 --- /dev/null +++ b/frontend/fly.toml @@ -0,0 +1,36 @@ +app = "chat-app-frontend" + +[build] + [build.args] + NODE_VERSION = "18" + +[env] + PORT = "80" + +[[services]] + internal_port = 80 + protocol = "tcp" + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 1 + + [[services.ports]] + handlers = ["http"] + port = "80" + + [[services.ports]] + handlers = ["tls", "http"] + port = "443" + + [[services.http_checks]] + interval = "10s" + timeout = "2s" + grace_period = "5s" + method = "get" + path = "/" + protocol = "http" + +[mounts] + source = "static" + destination = "/usr/share/nginx/html" diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..5ae2afb --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,15 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backend:8000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6687ec1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,62 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-aspect-ratio": "^1.0.3", + "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-collapsible": "^1.0.3", + "@radix-ui/react-context-menu": "^2.1.5", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-hover-card": "^1.0.7", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-menubar": "^1.0.4", + "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slider": "^1.1.2", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-toggle": "^1.0.3", + "@radix-ui/react-tooltip": "^1.0.7", + "axios": "^1.6.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.0.0", + "lucide-react": "^0.294.0", + "next": "14.0.3", + "prismjs": "^1.29.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-syntax-highlighter": "^15.5.0", + "tailwind-merge": "^2.0.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/react": "^18.2.39", + "@types/react-dom": "^18.2.17", + "@types/react-syntax-highlighter": "^15.5.10", + "autoprefixer": "^10.4.16", + "eslint": "^8.54.0", + "eslint-config-next": "14.0.3", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.5", + "typescript": "^5.3.2" + } +} diff --git a/frontend/src/components/ChatContainer.tsx b/frontend/src/components/ChatContainer.tsx new file mode 100644 index 0000000..32ca742 --- /dev/null +++ b/frontend/src/components/ChatContainer.tsx @@ -0,0 +1,60 @@ +import React, { useState, useEffect } from 'react'; +import MessageList from './MessageList'; +import MessageInput from './MessageInput'; +import SettingsPanel from './SettingsPanel'; +import { api } from '@/utils/api'; + +export interface Message { + id: string; + role: 'user' | 'assistant'; + content: string; + timestamp: number; +} + +export default function ChatContainer() { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + + const sendMessage = async (content: string) => { + if (!content.trim()) return; + + const newMessage: Message = { + id: Date.now().toString(), + role: 'user', + content, + timestamp: Date.now(), + }; + + setMessages(prev => [...prev, newMessage]); + setLoading(true); + + try { + const response = await api.post('/chat', { message: content }); + const assistantMessage: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: response.data.message, + timestamp: Date.now(), + }; + setMessages(prev => [...prev, assistantMessage]); + } catch (error) { + console.error('Error sending message:', error); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +
+
+ +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/CodePreview.tsx b/frontend/src/components/CodePreview.tsx new file mode 100644 index 0000000..63fe6b6 --- /dev/null +++ b/frontend/src/components/CodePreview.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; +import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { docco } from 'react-syntax-highlighter/dist/esm/styles/hljs'; + +interface CodePreviewProps { + code: string; + language?: string; + loading?: boolean; + className?: string; +} + +export const CodePreview: React.FC = ({ + code, + language = 'typescript', + loading = false, + className = '', +}) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + if (loading) { + return ( +
+
+
+
+ ); + } + + return ( +
+ + + {code} + +
+ ); +}; diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx new file mode 100644 index 0000000..d186710 --- /dev/null +++ b/frontend/src/components/MessageInput.tsx @@ -0,0 +1,68 @@ +import React, { useState, KeyboardEvent } from 'react'; + +interface MessageInputProps { + onSend: (message: string) => void; + loading?: boolean; + disabled?: boolean; +} + +export const MessageInput: React.FC = ({ + onSend, + loading = false, + disabled = false +}) => { + const [message, setMessage] = useState(''); + + const handleSend = () => { + if (message.trim() && !loading && !disabled) { + onSend(message.trim()); + setMessage(''); + } + }; + + const handleKeyPress = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
+