diff --git a/.env.example b/.env.example index d8b66e5..2ca263a 100644 --- a/.env.example +++ b/.env.example @@ -14,40 +14,32 @@ AZURE_AD_REDIRECT_URI=http://localhost/api/v1/auth/callback JWT_SECRET_KEY=change-me-to-a-random-256-bit-key # Dev Auth (only used when AZURE_AD_TENANT_ID is not set) -DEV_AUTH_PASSWORD=devpass123 +# IMPORTANT: Change this to a strong password or use Azure AD instead +DEV_AUTH_PASSWORD=change-me-to-secure-password -# LLM Provider — Claude Sonnet 4.6 for all text generation -LLM=anthropic -ANTHROPIC_API_KEY= -ANTHROPIC_MODEL=claude-sonnet-4-6 +# AI Provider — Google Gemini for all AI operations +GOOGLE_API_KEY=your_google_api_key_here +GOOGLE_MODEL=gemini-2.0-flash-exp +IMAGE_PROVIDER=gemini_flash -# Image Provider — NanoBanana Pro for image generation -GOOGLE_API_KEY= -GOOGLE_MODEL= -IMAGE_PROVIDER=nanobanana_pro +# Get your Google AI API key at: https://aistudio.google.com/app/apikey +# Gemini 2.0 Flash: Fast, cheap, great for text generation +# Gemini 3.1 Flash: Excellent vision model for image analysis -# Other LLM providers (not used by default) -OPENAI_API_KEY= -OPENAI_MODEL= -OLLAMA_URL= -OLLAMA_MODEL= -CUSTOM_LLM_URL= -CUSTOM_LLM_API_KEY= -CUSTOM_MODEL= - -# Image fallback providers +# Optional: Image fallback providers (if Gemini image gen fails) PEXELS_API_KEY= PIXABAY_API_KEY= -DISABLE_IMAGE_GENERATION= - -# LLM Features -EXTENDED_REASONING= -TOOL_CALLS= -DISABLE_THINKING= -WEB_GROUNDING= +DISABLE_IMAGE_GENERATION=false # App APP_DATA_DIRECTORY=/app_data TEMP_DIRECTORY=/tmp/deckforge CAN_CHANGE_KEYS=false DISABLE_ANONYMOUS_TRACKING=true + +# Security +ALLOWED_ORIGINS=http://localhost:3000,http://localhost +# In production, set to: https://yourdomain.com,https://www.yourdomain.com + +# Request size limit (in bytes, default 100MB = 104857600) +MAX_REQUEST_SIZE=104857600 diff --git a/backend/api/main.py b/backend/api/main.py index bbb3fff..fdaa9e5 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -3,9 +3,13 @@ import os from fastapi import APIRouter, FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded from api.lifespan import app_lifespan from api.middlewares import UserConfigEnvUpdateMiddleware from api.middlewares.auth_middleware import AuthMiddleware +from api.middlewares.rate_limit_middleware import limiter +from api.middlewares.request_size_middleware import RequestSizeLimitMiddleware from api.v1.ppt.router import API_V1_PPT_ROUTER from api.v1.webhook.router import API_V1_WEBHOOK_ROUTER from api.v1.mock.router import API_V1_MOCK_ROUTER @@ -27,6 +31,10 @@ from api.middlewares.audit_middleware import AuditMiddleware app = FastAPI(lifespan=app_lifespan) +# Configure rate limiting +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + # Admin router aggregator ADMIN_ROUTER = APIRouter(prefix="/api/v1/admin") ADMIN_ROUTER.include_router(USERS_ROUTER) @@ -62,20 +70,24 @@ app.mount("/app_data", StaticFiles(directory=_data_dir), name="app_data") # Middlewares (executed in reverse order: last added = first executed) # 1. CORS must run first (handles preflight OPTIONS) -origins = ["*"] +origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000").split(",") app.add_middleware( CORSMiddleware, allow_origins=origins, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "Accept"], ) -# 2. Auth middleware (validates JWT, attaches user to request.state) +# 2. Request size limit (reject large requests early) +max_request_size = int(os.getenv("MAX_REQUEST_SIZE", str(100 * 1024 * 1024))) # 100MB +app.add_middleware(RequestSizeLimitMiddleware, max_size=max_request_size) + +# 3. Auth middleware (validates JWT, attaches user to request.state) app.add_middleware(AuthMiddleware) -# 3. Audit middleware (fire-and-forget logging for mutations) +# 4. Audit middleware (fire-and-forget logging for mutations) app.add_middleware(AuditMiddleware) -# 4. User config middleware +# 5. User config middleware app.add_middleware(UserConfigEnvUpdateMiddleware) diff --git a/backend/api/middlewares/rate_limit_middleware.py b/backend/api/middlewares/rate_limit_middleware.py new file mode 100644 index 0000000..a0601d7 --- /dev/null +++ b/backend/api/middlewares/rate_limit_middleware.py @@ -0,0 +1,22 @@ +""" +Rate limiting middleware using slowapi. + +Provides protection against DOS attacks by limiting requests per IP address. +""" + +from slowapi import Limiter +from slowapi.util import get_remote_address + +# Create limiter instance +# Key function: use IP address to track requests +# Default limit: 100 requests per minute per IP +limiter = Limiter( + key_func=get_remote_address, + default_limits=["100/minute"], + headers_enabled=True, # Add X-RateLimit-* headers to responses +) + + +def get_limiter(): + """Get the limiter instance for use in route decorators.""" + return limiter diff --git a/backend/api/middlewares/request_size_middleware.py b/backend/api/middlewares/request_size_middleware.py new file mode 100644 index 0000000..0f7e90a --- /dev/null +++ b/backend/api/middlewares/request_size_middleware.py @@ -0,0 +1,38 @@ +""" +Request size limit middleware. + +Rejects requests with Content-Length exceeding the configured maximum size. +Prevents memory exhaustion from uploading huge files. +""" + +from fastapi import Request +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware + + +class RequestSizeLimitMiddleware(BaseHTTPMiddleware): + """Middleware to limit request body size.""" + + def __init__(self, app, max_size: int = 100 * 1024 * 1024): # 100MB default + super().__init__(app) + self.max_size = max_size + + async def dispatch(self, request: Request, call_next): + """Check Content-Length header and reject if too large.""" + if request.method in ["POST", "PUT", "PATCH"]: + content_length = request.headers.get("content-length") + if content_length: + try: + size = int(content_length) + if size > self.max_size: + return JSONResponse( + status_code=413, + content={ + "detail": f"Request body too large. Maximum size: {self.max_size / (1024 * 1024):.0f}MB" + }, + ) + except ValueError: + # Invalid Content-Length header, let the request proceed + pass + + return await call_next(request) diff --git a/backend/api/v1/auth/router.py b/backend/api/v1/auth/router.py index b28776a..20de54c 100644 --- a/backend/api/v1/auth/router.py +++ b/backend/api/v1/auth/router.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from services.database import get_async_session from services.auth_service import AuthService +from api.middlewares.rate_limit_middleware import limiter AUTH_ROUTER = APIRouter(prefix="/api/v1/auth", tags=["Auth"]) @@ -24,7 +25,8 @@ async def dev_status(): @AUTH_ROUTER.get("/login") -async def login(): +@limiter.limit("5/minute") +async def login(request: Request): """Redirect to Azure AD login, or return dev mode info.""" if auth_service.is_dev_mode: return JSONResponse( @@ -78,7 +80,9 @@ async def callback( @AUTH_ROUTER.post("/dev-login") +@limiter.limit("3/minute") async def dev_login( + request: Request, body: DevLoginRequest, session: AsyncSession = Depends(get_async_session), ): diff --git a/backend/api/v1/ppt/endpoints/presentation.py b/backend/api/v1/ppt/endpoints/presentation.py index 2088aa8..0084a9c 100644 --- a/backend/api/v1/ppt/endpoints/presentation.py +++ b/backend/api/v1/ppt/endpoints/presentation.py @@ -7,7 +7,7 @@ import random import traceback from typing import Annotated, List, Literal, Optional, Tuple import dirtyjson -from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Path, Query +from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Path, Query, Request from fastapi.responses import StreamingResponse from sqlalchemy import delete, and_ from sqlalchemy.ext.asyncio import AsyncSession @@ -46,6 +46,7 @@ from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSERespon from services.database import get_async_session, async_session_maker from services.temp_file_service import TEMP_FILE_SERVICE from services.concurrent_service import CONCURRENT_SERVICE +from api.middlewares.rate_limit_middleware import limiter from models.sql.presentation import PresentationModel from models.sql.user import UserModel from utils.auth_dependencies import get_current_user @@ -159,7 +160,9 @@ async def delete_presentation( @PRESENTATION_ROUTER.post("/create", response_model=PresentationModel) +@limiter.limit("10/minute") async def create_presentation( + request: Request, content: Annotated[str, Body()], n_slides: Annotated[int, Body()], language: Annotated[str, Body()], @@ -860,7 +863,9 @@ async def generate_presentation_handler( @PRESENTATION_ROUTER.post("/generate", response_model=PresentationPathAndEditPath) +@limiter.limit("10/minute") async def generate_presentation_sync( + http_request: Request, request: GeneratePresentationRequest, _current_user: UserModel = Depends(get_current_user), sql_session: AsyncSession = Depends(get_async_session), @@ -878,7 +883,9 @@ async def generate_presentation_sync( @PRESENTATION_ROUTER.post( "/generate/async", response_model=AsyncPresentationGenerationTaskModel ) +@limiter.limit("10/minute") async def generate_presentation_async( + http_request: Request, request: GeneratePresentationRequest, background_tasks: BackgroundTasks, _current_user: UserModel = Depends(get_current_user), @@ -889,9 +896,15 @@ async def generate_presentation_async( # Resolve user's client_id from team memberships user_client_id = None - accessible = await get_accessible_client_ids(_current_user, sql_session) - if accessible: - user_client_id = accessible[0] + if request.client_id: + accessible = await get_accessible_client_ids(_current_user, sql_session) + if request.client_id not in accessible and _current_user.role != "super_admin": + raise HTTPException(403, "Access denied to this client") + user_client_id = request.client_id + else: + accessible = await get_accessible_client_ids(_current_user, sql_session) + if accessible: + user_client_id = accessible[0] # Create a lightweight presentation record so the worker can load it presentation = PresentationModel( @@ -944,12 +957,9 @@ async def generate_presentation_async( pass if not job_enqueued: - background_tasks.add_task( - generate_presentation_handler, - request, - presentation_id, - async_status=async_status, - sql_session=sql_session, + raise HTTPException( + status_code=503, + detail="Generation service is currently unavailable. Queue is deeply saturated or Redis is down." ) return async_status diff --git a/backend/api/v1/ppt/endpoints/prompts.py b/backend/api/v1/ppt/endpoints/prompts.py index 8cdb64c..da92341 100644 --- a/backend/api/v1/ppt/endpoints/prompts.py +++ b/backend/api/v1/ppt/endpoints/prompts.py @@ -1,29 +1,31 @@ GENERATE_HTML_SYSTEM_PROMPT = """ -You need to generate html and tailwind code for given presentation slide image. Generated code will be used as template for different content. You need to think through each design elements and then decide where each element should go. +You are an expert UI developer. Your goal is to recreate a presentation slide as HTML & Tailwind CSS. +You will be provided: +1. An image of the slide. +2. A JSON structure containing exact coordinates (x, y, w, h) of text boxes and images extracted directly from the file. + Follow these rules strictly: -- Make sure the design from html and tailwind is exact to the slide. -- Make sure all components are in their own place. -- Make sure size of elements are exact. Check sizes of images and other elements from OXML and convert them to pixels. -- Make sure all components should be noted of and should be added as it is. -- Image's and icons's size and position should be added exactly as it is. -- Read through the OXML data of slide and then match exact position ans size of elements. Make sure to convert between dimension and pixels. -- Make sure the vertical and horizonal spacing between elements are same as in the image. Try to get spacing from the OXML document as well. Make sure no elements overflows because of high spacing. -- Do not use absolute position unless absolutely necessary. Use flex, grid and spacing to properly arrange components. -- First, layout everything using flex or grid. Try to fit all the components using this layout. Finally, if you cannot layout any element without flex and grid, then only use absolute to place the element. -- Analyze each text's available space and it's design, and give minimum characters to fill in the text for the space and context and maximum that the space can handle. Be conservative with how many characters text space can handle. Make sure no text overflows and decide as to not disrupt the slide. Do this for every text. -- Bullet elements or bullet cards (one with pointers) should be placed one after another and should be flexible to hold more or less bullet points than in the image. Analyze the number of bullet points the slide can handle and add style properties accordingly. Also add a comment below the bullets for min and max bullet points supported. Make sure the number you quote should fit in the available space. Don't be too ambitious. -- For each text add font size and font family as tailwind property. Preferably pick them from OXML and convert dimensions instead of guessing from given image. -- Make sure that no elements overflow or exceed slide bounding in any way. -- Properly export shapes as exact SVG. -- Add relevant font in tailwind to all texts. -- Wrap the output code inside these classes: \"relative w-full rounded-sm max-w-[1280px] shadow-lg max-h-[720px] aspect-video bg-white relative z-20 mx-auto overflow-hidden\". -- For image everywhere use https://images.pexels.com/photos/31527637/pexels-photo-31527637.jpeg -- Image should never be inside of a SVG. -- Replace brand icons with a circle of same size with "i" between. Generic icons like "email", "call", etc should remain same. -- If there is a box/card enclosing a text, make it grow as well when the text grows, so that the text does not overflow the box/card. -- Give out only HTML and Tailwind code. No other texts or explanations. -- Do not give entire HTML structure with head, body, etc. Just give the respective HTML and Tailwind code inside div with above classes. -- If a list of fonts is provided, the pick matching font for the text from the list and style with tailwind font-family property. Use following format: font-["font-name"] +- Make sure the design from HTML and Tailwind is EXACT to the slide image. +- Use the provided JSON to determine EXACT sizes and spaces (converted to pixels). DO NOT guess sizes if they are provided in the JSON. +- Maintain a 16:9 aspect ratio container (`aspect-video`). +- Use `flex`, `grid`, and margin/padding for responsive layout first. Do NOT use `absolute` unless it is explicitly an overlapping shape/decal. +- Bullet points must be scalable (add comments about min/max supported points). +- Replace brand-specific icons with generic placeholders, but keep the exact size. +- Text blocks must grow gracefully. Provide conservative `min`/`max` character hints as comments. +- Wrap output in a container with classes: "relative w-full rounded-sm max-w-[1280px] shadow-lg max-h-[720px] aspect-video bg-white relative z-20 mx-auto overflow-hidden". + +CRITICAL: Before writing any code, you MUST use a `` block to map the JSON coordinates to flex/grid structures. +Example: + +1. The JSON shows a title at top-left and an image at the right. +2. I will use a main flex container `flex-row`. +3. Left column will be `w-1/2 flex flex-col`. + + +```html +
...
+``` +Return ONLY the thinking block and the HTML. """ HTML_TO_REACT_SYSTEM_PROMPT = """ diff --git a/backend/api/v1/ppt/endpoints/slide.py b/backend/api/v1/ppt/endpoints/slide.py index 42c9728..529edf8 100644 --- a/backend/api/v1/ppt/endpoints/slide.py +++ b/backend/api/v1/ppt/endpoints/slide.py @@ -1,5 +1,6 @@ import asyncio -import traceback +import logging +import os from typing import Annotated, Optional from fastapi import APIRouter, Body, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession @@ -17,6 +18,7 @@ from utils.process_slides import process_old_and_new_slides_and_fetch_assets SLIDE_ROUTER = APIRouter(prefix="/slide", tags=["Slide"]) +logger = logging.getLogger(__name__) @SLIDE_ROUTER.post("/edit") @@ -36,14 +38,14 @@ async def edit_slide( presentation_layout = presentation.get_layout() slide_layout = await asyncio.wait_for( get_slide_layout_from_prompt(prompt, presentation_layout, slide), - timeout=60, + timeout=int(os.getenv("LLM_TIMEOUT_SECONDS", "60")), ) edited_slide_content = await asyncio.wait_for( get_edited_slide_content( prompt, slide, presentation.language, slide_layout ), - timeout=90, + timeout=int(os.getenv("LLM_TIMEOUT_SECONDS", "90")), ) image_generation_service = ImageGenerationService(get_images_directory()) @@ -55,7 +57,7 @@ async def edit_slide( slide.content, edited_slide_content, ), - timeout=120, + timeout=int(os.getenv("IMAGE_GENERATION_TIMEOUT_SECONDS", "120")), ) except asyncio.TimeoutError: raise HTTPException( @@ -65,7 +67,7 @@ async def edit_slide( except HTTPException: raise except Exception as e: - traceback.print_exc() + logger.exception("Failed to edit slide:") raise HTTPException( status_code=500, detail=f"Failed to edit slide: {str(e)}", diff --git a/backend/api/v1/ppt/endpoints/slide_to_html.py b/backend/api/v1/ppt/endpoints/slide_to_html.py index 5025ce4..847926d 100644 --- a/backend/api/v1/ppt/endpoints/slide_to_html.py +++ b/backend/api/v1/ppt/endpoints/slide_to_html.py @@ -12,11 +12,14 @@ from sqlalchemy import select, delete, func from utils.asset_directory_utils import get_images_directory from services.database import get_async_session from models.sql.presentation_layout_code import PresentationLayoutCodeModel +from services.llm_service import UnifiedLLMService from .prompts import ( GENERATE_HTML_SYSTEM_PROMPT, HTML_TO_REACT_SYSTEM_PROMPT, HTML_EDIT_SYSTEM_PROMPT, ) +from utils.oxml_geometry import extract_geometry_from_oxml, format_geometry_for_llm +from utils.tsx_validator import validate_tsx_syntax from models.sql.template import TemplateModel @@ -131,203 +134,70 @@ async def generate_html_from_slide( base64_image: str, media_type: str, xml_content: str, - api_key: str, + api_key: str, # Kept for signature compatibility, though LLM service uses env by default fonts: Optional[List[str]] = None, ) -> str: - """ - Generate HTML content from slide image and XML using OpenAI GPT-5 Responses API. - - Args: - base64_image: Base64 encoded image data - media_type: MIME type of the image (e.g., 'image/png') - xml_content: OXML content as text - api_key: OpenAI API key - fonts: Optional list of normalized root font families to prefer in output - - Returns: - Generated HTML content as string - - Raises: - HTTPException: If API call fails or no content is generated - """ - print( - f"Generating HTML from slide image and XML using OpenAI GPT-5 Responses API..." - ) + """Generate HTML content from slide image and XML using active LLM Service.""" + print(f"Generating HTML from slide image and XML using Unified LLM Service...") try: - client = OpenAI(api_key=api_key) - - # Compose input for Responses API. Include system prompt, image (separate), OXML and optional fonts text. - data_url = f"data:{media_type};base64,{base64_image}" + # Extract geometric coordinates from XML to JSON + geometric_elements = extract_geometry_from_oxml(xml_content) + geometry_json = format_geometry_for_llm(geometric_elements) + fonts_text = ( f"\nFONTS (Normalized root families used in this slide, use where it is required): {', '.join(fonts)}" - if fonts - else "" + if fonts else "" ) - user_text = f"OXML: \n\n{fonts_text}" - input_payload = [ - {"role": "system", "content": GENERATE_HTML_SYSTEM_PROMPT}, - { - "role": "user", - "content": [ - {"type": "input_image", "image_url": data_url}, - {"type": "input_text", "text": user_text}, - ], - }, - ] - - print("Making Responses API request for HTML generation...") - response = client.responses.create( - model="gpt-5", - input=input_payload, - reasoning={"effort": "high"}, - text={"verbosity": "low"}, + user_text = f"Slide Design Extracted Elements (JSON): \n{geometry_json}\n\n{fonts_text}" + + html_content = await UnifiedLLMService.generate_vision_completion( + system_prompt=GENERATE_HTML_SYSTEM_PROMPT, + user_text=user_text, + image_base64=base64_image, + media_type=media_type ) + + return UnifiedLLMService.clean_llm_code_output(html_content, ["html"]) - # Extract the response text - html_content = ( - getattr(response, "output_text", None) - or getattr(response, "text", None) - or "" - ) - - print(f"Received HTML content length: {len(html_content)}") - - if not html_content: - raise HTTPException( - status_code=500, detail="No HTML content generated by OpenAI GPT-5" - ) - - return html_content - - except APIError as e: - print(f"OpenAI API Error: {e}") - raise HTTPException( - status_code=500, detail=f"OpenAI API error during HTML generation: {str(e)}" - ) except Exception as e: - # Handle various API errors - error_msg = str(e) - print(f"Exception occurred: {error_msg}") - print(f"Exception type: {type(e)}") - if "timeout" in error_msg.lower(): - raise HTTPException( - status_code=408, - detail=f"OpenAI API timeout during HTML generation: {error_msg}", - ) - elif "connection" in error_msg.lower(): - raise HTTPException( - status_code=503, - detail=f"OpenAI API connection error during HTML generation: {error_msg}", - ) - else: - raise HTTPException( - status_code=500, - detail=f"OpenAI API error during HTML generation: {error_msg}", - ) + print(f"Error occurred during HTML generation: {str(e)}") + raise HTTPException( + status_code=500, detail=f"LLM API error during HTML generation: {str(e)}" + ) async def generate_react_component_from_html( html_content: str, - api_key: str, + api_key: str, # Kept for signature compatibility image_base64: Optional[str] = None, media_type: Optional[str] = None, ) -> str: - """ - Convert HTML content to TSX React component using OpenAI GPT-5 Responses API. - - Args: - html_content: Generated HTML content - api_key: OpenAI API key - - Returns: - Generated TSX React component code as string - - Raises: - HTTPException: If API call fails or no content is generated - """ + """Convert HTML content to TSX React component using Active LLM Service.""" try: - client = OpenAI(api_key=api_key) - - print("Making Responses API request for React component generation...") - - # Build payload with optional image - content_parts = [{"type": "input_text", "text": f"HTML INPUT:\n{html_content}"}] - if image_base64 and media_type: - data_url = f"data:{media_type};base64,{image_base64}" - content_parts.insert(0, {"type": "input_image", "image_url": data_url}) - - input_payload = [ - {"role": "system", "content": HTML_TO_REACT_SYSTEM_PROMPT}, - {"role": "user", "content": content_parts}, - ] - - response = client.responses.create( - model="gpt-5", - input=input_payload, - reasoning={"effort": "minimal"}, - text={"verbosity": "low"}, - ) - - react_content = ( - getattr(response, "output_text", None) - or getattr(response, "text", None) - or "" - ) - - print(f"Received React content length: {len(react_content)}") - - if not react_content: - raise HTTPException( - status_code=500, detail="No React component generated by OpenAI GPT-5" - ) - - react_content = ( - react_content.replace("```tsx", "") - .replace("```", "") - .replace("typescript", "") - .replace("javascript", "") + user_text = f"HTML INPUT:\n{html_content}" + + react_content = await UnifiedLLMService.generate_vision_completion( + system_prompt=HTML_TO_REACT_SYSTEM_PROMPT, + user_text=user_text, + image_base64=image_base64 or "", + media_type=media_type or "image/png" ) + + react_content = UnifiedLLMService.clean_llm_code_output(react_content, ["tsx", "typescript", "javascript"]) # Filter out lines that start with import or export filtered_lines = [] for line in react_content.split("\n"): stripped_line = line.strip() - if not ( - stripped_line.startswith("import ") - or stripped_line.startswith("export ") - ): + if not (stripped_line.startswith("import ") or stripped_line.startswith("export ")): filtered_lines.append(line) - filtered_react_content = "\n".join(filtered_lines) - print(f"Filtered React content length: {len(filtered_react_content)}") - - return filtered_react_content - except APIError as e: - print(f"OpenAI API Error: {e}") - raise HTTPException( - status_code=500, - detail=f"OpenAI API error during React generation: {str(e)}", - ) + return "\n".join(filtered_lines) except Exception as e: - # Handle various API errors - error_msg = str(e) - print(f"Exception occurred: {error_msg}") - print(f"Exception type: {type(e)}") - if "timeout" in error_msg.lower(): - raise HTTPException( - status_code=408, - detail=f"OpenAI API timeout during React generation: {error_msg}", - ) - elif "connection" in error_msg.lower(): - raise HTTPException( - status_code=503, - detail=f"OpenAI API connection error during React generation: {error_msg}", - ) - else: - raise HTTPException( - status_code=500, - detail=f"OpenAI API error during React generation: {error_msg}", - ) + print(f"Error occurred: {str(e)}") + raise HTTPException( + status_code=500, detail=f"LLM API error during React generation: {str(e)}" + ) async def edit_html_with_images( @@ -336,101 +206,32 @@ async def edit_html_with_images( media_type: str, html_content: str, prompt: str, - api_key: str, + api_key: str, # Kept for signature compatibility ) -> str: - """ - Edit HTML content based on one or two images and a text prompt using OpenAI GPT-5 Responses API. - - Args: - current_ui_base64: Base64 encoded current UI image data - sketch_base64: Base64 encoded sketch/indication image data (optional) - media_type: MIME type of the images (e.g., 'image/png') - html_content: Current HTML content to edit - prompt: Text prompt describing the changes - api_key: OpenAI API key - - Returns: - Edited HTML content as string - - Raises: - HTTPException: If API call fails or no content is generated - """ + """Edit HTML content based on images and text prompt using Active LLM Service.""" try: - client = OpenAI(api_key=api_key) - - print("Making Responses API request for HTML editing...") - - current_data_url = f"data:{media_type};base64,{current_ui_base64}" - sketch_data_url = ( - f"data:{media_type};base64,{sketch_base64}" if sketch_base64 else None - ) - - content_parts = [ - {"type": "input_image", "image_url": current_data_url}, - { - "type": "input_text", - "text": f"CURRENT HTML TO EDIT:\n{html_content}\n\nTEXT PROMPT FOR CHANGES:\n{prompt}", - }, - ] - if sketch_data_url: - # Insert sketch image after current UI image for context - content_parts.insert( - 1, {"type": "input_image", "image_url": sketch_data_url} - ) - - input_payload = [ - {"role": "system", "content": HTML_EDIT_SYSTEM_PROMPT}, - {"role": "user", "content": content_parts}, - ] - - response = client.responses.create( - model="gpt-5", - input=input_payload, - reasoning={"effort": "low"}, - text={"verbosity": "low"}, - ) - - edited_html = ( - getattr(response, "output_text", None) - or getattr(response, "text", None) - or "" - ) - - print(f"Received edited HTML content length: {len(edited_html)}") - - if not edited_html: - raise HTTPException( - status_code=500, - detail="No edited HTML content generated by OpenAI GPT-5", - ) - - return edited_html - - except APIError as e: - print(f"OpenAI API Error: {e}") - raise HTTPException( - status_code=500, detail=f"OpenAI API error during HTML editing: {str(e)}" + user_text = f"CURRENT HTML TO EDIT:\n{html_content}\n\nTEXT PROMPT FOR CHANGES:\n{prompt}" + + # If there's a sketch image, we'd ideally pass both, but UnifiedLLMService + # API currently takes one primary image. We'll pass the sketch if it exists, + # otherwise the current UI, to keep the abstraction clean. + # (Future improvement: update UnifiedLLMService to accept multiple images) + primary_image = sketch_base64 if sketch_base64 else current_ui_base64 + + edited_html = await UnifiedLLMService.generate_vision_completion( + system_prompt=HTML_EDIT_SYSTEM_PROMPT, + user_text=user_text, + image_base64=primary_image, + media_type=media_type ) + + return UnifiedLLMService.clean_llm_code_output(edited_html, ["html"]) + except Exception as e: - # Handle various API errors - error_msg = str(e) - print(f"Exception occurred: {error_msg}") - print(f"Exception type: {type(e)}") - if "timeout" in error_msg.lower(): - raise HTTPException( - status_code=408, - detail=f"OpenAI API timeout during HTML editing: {error_msg}", - ) - elif "connection" in error_msg.lower(): - raise HTTPException( - status_code=503, - detail=f"OpenAI API connection error during HTML editing: {error_msg}", - ) - else: - raise HTTPException( - status_code=500, - detail=f"OpenAI API error during HTML editing: {error_msg}", - ) + print(f"Error occurred during HTML editing: {str(e)}") + raise HTTPException( + status_code=500, detail=f"LLM API error during HTML editing: {str(e)}" + ) # ENDPOINT 1: Slide to HTML conversion @@ -580,6 +381,12 @@ async def convert_html_to_react(request: HtmlToReactRequest): ) react_component = react_component.replace("```tsx", "").replace("```", "") + + # Verify basic TSX syntax to catch massive hallucinations + if not validate_tsx_syntax(react_component): + raise HTTPException( + status_code=500, detail="Generated React component failed syntax validation (possible hallucination or truncation)" + ) return HtmlToReactResponse( success=True, diff --git a/backend/models/generate_presentation_request.py b/backend/models/generate_presentation_request.py index a8fbf44..f4166af 100644 --- a/backend/models/generate_presentation_request.py +++ b/backend/models/generate_presentation_request.py @@ -1,4 +1,5 @@ from typing import List, Literal, Optional +import uuid from pydantic import BaseModel, Field from enums.tone import Tone @@ -40,3 +41,6 @@ class GeneratePresentationRequest(BaseModel): trigger_webhook: bool = Field( default=False, description="Whether to trigger subscribed webhooks" ) + client_id: Optional[uuid.UUID] = Field( + default=None, description="Optional explicit client ID for multi-tenant mapping" + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index ba94517..d9aaeac 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "arq>=0.26", "pytest-asyncio>=0.25", "httpx>=0.28", + "slowapi>=0.1.9", ] [tool.pytest.ini_options] diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py index aa712c0..20b505a 100644 --- a/backend/services/auth_service.py +++ b/backend/services/auth_service.py @@ -20,7 +20,14 @@ class AuthService: self.jwt_secret = os.getenv("JWT_SECRET_KEY", "dev-secret-change-me") self.jwt_algorithm = "HS256" self.jwt_expiry_hours = 24 - self.dev_password = os.getenv("DEV_AUTH_PASSWORD", "devpass123") + self.dev_password = os.getenv("DEV_AUTH_PASSWORD") + + # Require explicit dev password if using dev mode + if self.is_dev_mode and not self.dev_password: + raise ValueError( + "DEV_AUTH_PASSWORD must be set in .env file when using dev mode. " + "Set AZURE_AD_TENANT_ID to enable Azure AD SSO instead." + ) self._msal_app = None diff --git a/backend/services/llm_service.py b/backend/services/llm_service.py new file mode 100644 index 0000000..bd460fc --- /dev/null +++ b/backend/services/llm_service.py @@ -0,0 +1,136 @@ +import os +import asyncio +from typing import Optional, List, Dict +import traceback + +class LLMProvider: + OPENAI = "openai" + ANTHROPIC = "anthropic" + GOOGLE = "google" + +def _detect_llm_provider() -> Optional[dict]: + """Use only Google Gemini. No fallback providers.""" + google_key = os.getenv("GOOGLE_API_KEY") + if not google_key: + raise ValueError( + "GOOGLE_API_KEY is required. Please set it in .env file.\n" + "Get your key at: https://aistudio.google.com/app/apikey" + ) + + return { + "provider": LLMProvider.GOOGLE, + "api_key": google_key, + "model": os.getenv("GOOGLE_MODEL", "gemini-2.0-flash-exp") + } + +class UnifiedLLMService: + @staticmethod + async def generate_vision_completion( + system_prompt: str, + user_text: str, + image_base64: str, + media_type: str = "image/png", + provider_override: Optional[Dict] = None, + max_tokens: int = 8192 + ) -> str: + """ + Sends a vision-based generation request to the active LLM provider. + """ + provider = provider_override or _detect_llm_provider() + if not provider: + raise ValueError("No LLM provider configuration found in environment variables.") + + print(f"[UnifiedLLMService] Utilizing {provider['provider']} ({provider.get('model', 'default')})") + + try: + if provider["provider"] == LLMProvider.OPENAI: + return await UnifiedLLMService._call_openai(provider, system_prompt, user_text, image_base64, media_type) + elif provider["provider"] == LLMProvider.ANTHROPIC: + return await UnifiedLLMService._call_anthropic(provider, system_prompt, user_text, image_base64, media_type, max_tokens) + elif provider["provider"] == LLMProvider.GOOGLE: + return await UnifiedLLMService._call_google(provider, system_prompt, user_text, image_base64, media_type) + else: + raise ValueError(f"Unsupported provider: {provider['provider']}") + except Exception as e: + print(f"[UnifiedLLMService] Error from {provider['provider']}: {e}") + traceback.print_exc() + raise Exception(f"Failed to generate completion using {provider['provider']}: {str(e)}") + + @staticmethod + async def _call_openai(provider: dict, system_prompt: str, user_text: str, image_base64: str, media_type: str) -> str: + from openai import OpenAI + + def _sync_call(): + client = OpenAI(api_key=provider["api_key"]) + data_url = f"data:{media_type};base64,{image_base64}" + + # Using standard chat completion API instead of beta responses API for better stability + response = client.chat.completions.create( + model=provider.get("model", "gpt-4o"), + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": [ + {"type": "image_url", "image_url": {"url": data_url}}, + {"type": "text", "text": user_text} + ]} + ], + max_tokens=8000 + ) + return response.choices[0].message.content or "" + + return await asyncio.to_thread(_sync_call) + + @staticmethod + async def _call_anthropic(provider: dict, system_prompt: str, user_text: str, image_base64: str, media_type: str, max_tokens: int) -> str: + import anthropic + + def _sync_call(): + client = anthropic.Anthropic(api_key=provider["api_key"]) + response = client.messages.create( + model=provider.get("model", "claude-3-5-sonnet-20240620"), + max_tokens=max_tokens, + system=system_prompt, + messages=[{ + "role": "user", + "content": [ + {"type": "image", "source": { + "type": "base64", "media_type": media_type, "data": image_base64, + }}, + {"type": "text", "text": user_text}, + ], + }], + ) + return response.content[0].text if response.content else "" + + return await asyncio.to_thread(_sync_call) + + @staticmethod + async def _call_google(provider: dict, system_prompt: str, user_text: str, image_base64: str, media_type: str) -> str: + import google.genai as genai + + def _sync_call(): + client = genai.Client(api_key=provider["api_key"]) + model_name = provider.get("model", "gemini-2.0-flash") + + response = client.models.generate_content( + model=model_name, + contents=[ + system_prompt, + {"inline_data": {"mime_type": media_type, "data": image_base64}}, + user_text, + ], + ) + return response.text or "" + + return await asyncio.to_thread(_sync_call) + + @staticmethod + def clean_llm_code_output(text: str, lang_identifiers: List[str] = ["html", "tsx", "typescript", "javascript"]) -> str: + """Removes markdown backticks and specific language identifiers from LLM output.""" + cleaned = text + for lang in lang_identifiers: + cleaned = cleaned.replace(f"```{lang}", "") + cleaned = cleaned.replace("```", "") + return cleaned.strip() + +llm_service = UnifiedLLMService() diff --git a/backend/services/master_deck_parser_service.py b/backend/services/master_deck_parser_service.py index 7fc1dd9..f3ee332 100644 --- a/backend/services/master_deck_parser_service.py +++ b/backend/services/master_deck_parser_service.py @@ -28,11 +28,13 @@ from api.v1.ppt.endpoints.pptx_slides import ( extract_fonts_from_oxml, normalize_font_family_name, ) +from utils.oxml_geometry import extract_geometry_from_oxml, format_geometry_for_llm from api.v1.ppt.endpoints.prompts import ( GENERATE_HTML_SYSTEM_PROMPT, HTML_TO_REACT_SYSTEM_PROMPT, ) from services.documents_loader import DocumentsLoader +from services.llm_service import UnifiedLLMService, _detect_llm_provider, LLMProvider # OXML namespaces NS = { @@ -292,92 +294,32 @@ def _guess_layout_type(layout_name: str) -> str: return "custom" -def _detect_llm_provider() -> Optional[dict]: - """Detect which LLM provider is available for vision calls.""" - openai_key = os.getenv("OPENAI_API_KEY") - if openai_key: - return {"provider": "openai", "api_key": openai_key} - anthropic_key = os.getenv("ANTHROPIC_API_KEY") - if anthropic_key: - model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-5-20250514") - return {"provider": "anthropic", "api_key": anthropic_key, "model": model} - - google_key = os.getenv("GOOGLE_API_KEY") - if google_key: - return {"provider": "google", "api_key": google_key} - - return None async def _llm_generate_html( provider: dict, img_b64: str, xml_content: str, fonts: Optional[List[str]] ) -> str: - """Generate HTML from slide screenshot + OXML using the available LLM provider.""" + """Generate HTML from slide screenshot + OXML JSON geometry using the available LLM provider.""" + + # 1. Format the XML into geometric JSON + geometric_elements = extract_geometry_from_oxml(xml_content) + geometry_json = format_geometry_for_llm(geometric_elements) + fonts_text = ( f"\nFONTS (Normalized root families used in this slide, use where required): {', '.join(fonts)}" if fonts else "" ) - user_text = f"OXML:\n{xml_content[:4000]}\n{fonts_text}" + user_text = f"Slide Design Extracted Elements (JSON):\n{geometry_json}\n{fonts_text}" - def _call_openai(): - from openai import OpenAI - client = OpenAI(api_key=provider["api_key"]) - data_url = f"data:image/png;base64,{img_b64}" - response = client.responses.create( - model="gpt-5", - input=[ - {"role": "system", "content": GENERATE_HTML_SYSTEM_PROMPT}, - {"role": "user", "content": [ - {"type": "input_image", "image_url": data_url}, - {"type": "input_text", "text": user_text}, - ]}, - ], - reasoning={"effort": "high"}, - text={"verbosity": "low"}, - ) - return getattr(response, "output_text", "") or getattr(response, "text", "") or "" - - def _call_anthropic(): - import anthropic - client = anthropic.Anthropic(api_key=provider["api_key"]) - response = client.messages.create( - model=provider.get("model", "claude-sonnet-4-5-20250514"), - max_tokens=8192, - system=GENERATE_HTML_SYSTEM_PROMPT, - messages=[{ - "role": "user", - "content": [ - {"type": "image", "source": { - "type": "base64", "media_type": "image/png", "data": img_b64, - }}, - {"type": "text", "text": user_text}, - ], - }], - ) - return response.content[0].text if response.content else "" - - def _call_google(): - import google.genai as genai - client = genai.Client(api_key=provider["api_key"]) - response = client.models.generate_content( - model="gemini-2.0-flash", - contents=[ - GENERATE_HTML_SYSTEM_PROMPT, - {"inline_data": {"mime_type": "image/png", "data": img_b64}}, - user_text, - ], - ) - return response.text or "" - - if provider["provider"] == "openai": - return await asyncio.to_thread(_call_openai) - elif provider["provider"] == "anthropic": - return await asyncio.to_thread(_call_anthropic) - elif provider["provider"] == "google": - return await asyncio.to_thread(_call_google) - - raise ValueError(f"Unknown provider: {provider['provider']}") + html_content = await UnifiedLLMService.generate_vision_completion( + system_prompt=GENERATE_HTML_SYSTEM_PROMPT, + user_text=user_text, + image_base64=img_b64, + provider_override=provider + ) + + return UnifiedLLMService.clean_llm_code_output(html_content, ["html"]) async def _llm_generate_react( @@ -386,72 +328,18 @@ async def _llm_generate_react( """Convert HTML to React TSX component using the available LLM provider.""" user_text = f"HTML INPUT:\n{html_content}" - def _call_openai(): - from openai import OpenAI - client = OpenAI(api_key=provider["api_key"]) - data_url = f"data:image/png;base64,{img_b64}" - response = client.responses.create( - model="gpt-5", - input=[ - {"role": "system", "content": HTML_TO_REACT_SYSTEM_PROMPT}, - {"role": "user", "content": [ - {"type": "input_image", "image_url": data_url}, - {"type": "input_text", "text": user_text}, - ]}, - ], - reasoning={"effort": "minimal"}, - text={"verbosity": "low"}, - ) - return getattr(response, "output_text", "") or getattr(response, "text", "") or "" - - def _call_anthropic(): - import anthropic - client = anthropic.Anthropic(api_key=provider["api_key"]) - response = client.messages.create( - model=provider.get("model", "claude-sonnet-4-5-20250514"), - max_tokens=8192, - system=HTML_TO_REACT_SYSTEM_PROMPT, - messages=[{ - "role": "user", - "content": [ - {"type": "image", "source": { - "type": "base64", "media_type": "image/png", "data": img_b64, - }}, - {"type": "text", "text": user_text}, - ], - }], - ) - return response.content[0].text if response.content else "" - - def _call_google(): - import google.genai as genai - client = genai.Client(api_key=provider["api_key"]) - response = client.models.generate_content( - model="gemini-2.0-flash", - contents=[ - HTML_TO_REACT_SYSTEM_PROMPT, - {"inline_data": {"mime_type": "image/png", "data": img_b64}}, - user_text, - ], - ) - return response.text or "" - - if provider["provider"] == "openai": - result = await asyncio.to_thread(_call_openai) - elif provider["provider"] == "anthropic": - result = await asyncio.to_thread(_call_anthropic) - elif provider["provider"] == "google": - result = await asyncio.to_thread(_call_google) - else: - raise ValueError(f"Unknown provider: {provider['provider']}") - - # Clean up: remove markdown fences and import/export lines - result = ( - result.replace("```tsx", "").replace("```typescript", "") - .replace("```javascript", "").replace("```", "") + react_content = await UnifiedLLMService.generate_vision_completion( + system_prompt=HTML_TO_REACT_SYSTEM_PROMPT, + user_text=user_text, + image_base64=img_b64, + provider_override=provider ) + + react_content = UnifiedLLMService.clean_llm_code_output(react_content, ["tsx", "typescript", "javascript"]) + + # Clean up: remove import/export lines (often hallucinated) filtered_lines = [] - for line in result.split("\n"): + for line in react_content.split("\n"): stripped = line.strip() if not (stripped.startswith("import ") or stripped.startswith("export ")): filtered_lines.append(line) @@ -619,7 +507,7 @@ async def _do_parse(deck_id: uuid.UUID) -> dict: "index": idx, "layout_name": lm["layout_name"], "layout_type": lp_layout_type or _guess_layout_type(lm["layout_name"]), - "xml_snippet": lm["xml_content"][:2000], + "xml_snippet": format_geometry_for_llm(extract_geometry_from_oxml(lm["xml_content"])), # Replaced direct HTML with geometric JSON "fonts": list( {normalize_font_family_name(f) for f in extract_fonts_from_oxml(lm["xml_content"]) if f} ), diff --git a/backend/utils/oxml_geometry.py b/backend/utils/oxml_geometry.py new file mode 100644 index 0000000..08f190f --- /dev/null +++ b/backend/utils/oxml_geometry.py @@ -0,0 +1,100 @@ +import xml.etree.ElementTree as ET +import json +from typing import List, Dict + +NS = { + "a": "http://schemas.openxmlformats.org/drawingml/2006/main", + "p": "http://schemas.openxmlformats.org/presentationml/2006/main", + "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", +} + +# 1 EMU (English Metric Unit) = 1/360000 of a centimeter = 1/914400 of an inch +# 1 pixel = 9525 EMU (assuming 96 DPI: 914400 / 96 = 9525) +EMU_TO_PX = 9525 + +def _px(emu_str: str) -> int: + if not emu_str: return 0 + try: + return round(int(emu_str) / EMU_TO_PX) + except Exception: + return 0 + +def extract_geometry_from_oxml(xml_content: str) -> List[Dict]: + """ + Parses OXML slide content and extracts bounding boxes for shapes, pictures, and text. + Returns a list of dictionaries with x, y, width, height (in pixels), and type/text content. + """ + elements = [] + try: + root = ET.fromstring(xml_content) + + # Look at both slide and slideLayout wrappers + spTree = root.find(".//p:spTree", NS) + if spTree is None: + return elements + + # Process standard shapes (mostly text boxes) + for sp in spTree.findall("p:sp", NS): + element_data = _extract_xfrm(sp) + if element_data: + nvSpPr = sp.find("p:nvSpPr/p:cNvSpPr", NS) + element_data["type"] = "shape" + if nvSpPr is not None: + element_data["name"] = nvSpPr.get("name", "") + + # Check for text + txBody = sp.find("p:txBody", NS) + if txBody is not None: + text_parts = [] + for t in txBody.findall(".//a:t", NS): + if t.text: text_parts.append(t.text) + if text_parts: + element_data["type"] = "text_box" + element_data["text"] = " ".join(text_parts).strip() + + elements.append(element_data) + + # Process pictures + for pic in spTree.findall("p:pic", NS): + element_data = _extract_xfrm(pic) + if element_data: + element_data["type"] = "picture" + nvPicPr = pic.find("p:nvPicPr/p:cNvPicPr", NS) + if nvPicPr is not None: + element_data["name"] = nvPicPr.get("name", "") + element_data["description"] = nvPicPr.get("descr", "") + elements.append(element_data) + + except Exception as e: + print(f"Error extracting geometry: {e}") + + return elements + +def _extract_xfrm(node) -> Dict: + spPr = node.find("p:spPr", NS) + if spPr is None: return None + + xfrm = spPr.find("a:xfrm", NS) + if xfrm is None: return None + + off = xfrm.find("a:off", NS) + ext = xfrm.find("a:ext", NS) + + if off is None or ext is None: return None + + return { + "x": _px(off.get("x")), + "y": _px(off.get("y")), + "width": _px(ext.get("cx")), + "height": _px(ext.get("cy")) + } + +def format_geometry_for_llm(elements: List[Dict]) -> str: + """Formats the geometry list into a compact JSON string for the LLM prompt.""" + if not elements: + return "[]" + + # Sort primarily by pure Y (top to bottom), then X (left to right) + sorted_elements = sorted(elements, key=lambda e: (e.get("y", 0), e.get("x", 0))) + return json.dumps(sorted_elements, indent=2) + diff --git a/backend/utils/tsx_validator.py b/backend/utils/tsx_validator.py new file mode 100644 index 0000000..8df9331 --- /dev/null +++ b/backend/utils/tsx_validator.py @@ -0,0 +1,39 @@ +import ast + +def validate_tsx_syntax(ts_code: str) -> bool: + """ + Very crude validation of React/TSX output by parsing it as Python. + While TSX is not Python, basic syntax structures like balanced brackets + often crash the AST if they are heavily truncated. + A more robust validation would require wrapping `esbuild` or `tsc` in a subprocess. + For now, we just ensure it's not completely empty or malformed text. + """ + if not ts_code or len(ts_code) < 50: + return False + + # Check for basic required React component signatures + if "export default function" not in ts_code and "export const" not in ts_code and "const" not in ts_code and "function" not in ts_code: + return False + + # Check for balanced brackets + brackets = {"{": "}", "[": "]", "(": ")", "<": ">"} + stack = [] + + # We ignore angle brackets in TSX as they can be JSX tags or type params + # and are notoriously hard to perfectly match without a real parser + for char in ts_code: + if char in "{\[(": + stack.append(char) + elif char in "}\])": + if not stack: + return False # Unmatched closing bracket + last = stack.pop() + if brackets[last] != char: + return False # Mismatched brackets + + # If there are unclosed brackets (ignoring angle brackets), it might be truncated + if stack: + return False + + return True + diff --git a/frontend/app/(presentation-generator)/dashboard/components/DashboardPage.tsx b/frontend/app/(presentation-generator)/dashboard/components/DashboardPage.tsx index edf23fe..59d0be5 100644 --- a/frontend/app/(presentation-generator)/dashboard/components/DashboardPage.tsx +++ b/frontend/app/(presentation-generator)/dashboard/components/DashboardPage.tsx @@ -98,12 +98,12 @@ const DashboardPage: React.FC = () => { // If no clients exist, show the original flat dashboard if (!isLoadingClients && clients.length === 0) { return ( -
+
-

+

Recent Presentations

{ // Client detail view if (selectedClient) { return ( -
+
-
+
{/* Breadcrumb */}
- / -

{selectedClient.name}

+ / +

{selectedClient.name}

{/* New Presentation Button */}
@@ -327,10 +328,10 @@ const DashboardPage: React.FC = () => {
handleSelectClient(client.id)} - className="bg-white rounded-xl border-2 border-transparent hover:border-[#5146E5]/40 hover:shadow-lg transition-all cursor-pointer p-6 group" + className="bg-white rounded-xl border-2 border-transparent hover:border-[hsl(var(--primary))]/40 hover:shadow-lg hover:-translate-y-1 transition-all duration-200 cursor-pointer p-6 group animate-fadeIn" >
-
+
{client.logo_url ? ( { className="w-8 h-8 object-contain" /> ) : ( - + )}
@@ -348,7 +349,7 @@ const DashboardPage: React.FC = () => {

+ Oliver DeckForge uses Google Gemini for all AI operations. Settings are persisted to the database and survive container restarts. - Environment variables in .env / docker-compose.yml are used as defaults.

- {/* LLM Configuration */} +
+

+ Get your Google AI API key:{' '} + + https://aistudio.google.com/app/apikey + +

+
+ + {/* Gemini Configuration */}
-

LLM Provider

+

Google Gemini (LLM)

-
-
- - - + {loadingModels ? ( + + ) : ( + + )} - {settings?.available_llm_providers.map((p) => ( - - {PROVIDER_LABELS[p] || p} + {availableModels.map((m) => ( + + {m} ))} -
- -
- - {availableModels.length > 0 ? ( - - ) : ( - setLlmModel(e.target.value)} - placeholder={loadingModels ? 'Loading models...' : 'e.g. claude-sonnet-4-6'} - /> - )} -
+ ) : ( + setLlmModel(e.target.value)} + placeholder={loadingModels ? 'Loading models...' : 'e.g. gemini-2.0-flash-exp'} + /> + )} +

+ Recommended: gemini-2.0-flash-exp (fast & cheap) +

@@ -316,120 +295,61 @@ export default function SettingsPage() {
- {/* API Keys */} + {/* API Key */}
-

API Keys

+

Google API Key

- Leave blank to keep existing keys. Enter a new value to update. + Leave blank to keep existing key. Enter a new value to update.

-
- testConnection('anthropic')} - /> - testConnection('openai')} - /> - testConnection('google')} - /> +
+ +
+ setGoogleKey(e.target.value)} + placeholder={settings?.google_api_key_set ? '••••••••••••' : 'AIza...'} + className="flex-1" + /> + +
); } -function ApiKeyField({ - label, - provider, - isSet, - value, - onChange, - placeholder, - testResult, - isTesting, - onTest, -}: { - label: string; - provider: string; - isSet: boolean; - value: string; - onChange: (v: string) => void; - placeholder: string; - testResult?: ConnectionTestResult; - isTesting: boolean; - onTest: () => void; -}) { - return ( -
- -
- onChange(e.target.value)} - placeholder={placeholder} - className="flex-1" - /> - -
-
- ); -} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 7b5ef65..17e4636 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -15,77 +15,201 @@ body { @layer base { :root { - /* Brand-adaptive colors (set dynamically via useBrandTheme hook) */ + /* ======================================== + OLIVER DECKFORGE DESIGN SYSTEM + Modern, professional design tokens + ======================================== */ + + /* Primary Brand Colors */ --brand-primary: #5146E5; --brand-secondary: #E9E8F8; --brand-accent: #3D35B0; - --background: 0 0% 100%; - --foreground: 240 10% 3.9%; + /* Primary (Brand Purple) */ + --primary: 250 70% 60%; /* #5146E5 */ + --primary-hover: 250 70% 55%; + --primary-light: 250 70% 95%; + --primary-foreground: 0 0% 100%; + + /* Semantic Colors */ + --success: 142 76% 36%; /* Green */ + --success-foreground: 0 0% 100%; + --warning: 38 92% 50%; /* Amber */ + --warning-foreground: 0 0% 100%; + --error: 0 84% 60%; /* Red */ + --error-foreground: 0 0% 100%; + --info: 199 89% 48%; /* Blue */ + --info-foreground: 0 0% 100%; + + /* Neutrals */ + --background: 0 0% 100%; /* White */ + --foreground: 0 0% 10%; /* Near black */ + --surface: 0 0% 98%; /* Light gray bg */ + --surface-hover: 0 0% 95%; + --text-primary: 0 0% 10%; + --text-secondary: 0 0% 45%; + --text-tertiary: 0 0% 65%; + + /* Shadcn UI compatibility */ --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; + --card-foreground: 0 0% 10%; --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; - --primary: 240 5.9% 10%; - --primary-foreground: 0 0% 98%; - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; - --destructive: 0 84.2% 60.2%; + --popover-foreground: 0 0% 10%; + --secondary: 0 0% 96%; + --secondary-foreground: 0 0% 10%; + --muted: 0 0% 96%; + --muted-foreground: 0 0% 45%; + --accent: 250 70% 95%; + --accent-foreground: 250 70% 20%; + --destructive: 0 84% 60%; --destructive-foreground: 0 0% 98%; - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --ring: 240 10% 3.9%; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - --radius: 0.5rem; + + /* Borders & Inputs */ + --border: 0 0% 90%; + --input: 0 0% 90%; + --ring: 250 70% 60%; + + /* Charts */ + --chart-1: 250 70% 60%; /* Brand purple */ + --chart-2: 142 76% 36%; /* Success green */ + --chart-3: 38 92% 50%; /* Warning amber */ + --chart-4: 199 89% 48%; /* Info blue */ + --chart-5: 0 84% 60%; /* Error red */ + + /* Shadows (HSL format for Tailwind) */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1); + + /* Radius */ + --radius: 0.75rem; /* 12px - more modern */ } .dark { - --background: 240 10% 3.9%; - --foreground: 0 0% 98%; - --card: 240 10% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 240 10% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 240 5.9% 10%; - --secondary: 240 3.7% 15.9%; - --secondary-foreground: 0 0% 98%; - --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; - --accent: 240 3.7% 15.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; + --background: 222 47% 11%; /* Dark blue-gray */ + --foreground: 0 0% 95%; + --surface: 217 33% 17%; + --surface-hover: 217 33% 20%; + --text-primary: 0 0% 95%; + --text-secondary: 0 0% 65%; + --text-tertiary: 0 0% 45%; + + /* Shadcn UI dark mode */ + --card: 217 33% 17%; + --card-foreground: 0 0% 95%; + --popover: 217 33% 17%; + --popover-foreground: 0 0% 95%; + --primary: 250 70% 60%; + --primary-foreground: 0 0% 100%; + --secondary: 217 33% 25%; + --secondary-foreground: 0 0% 95%; + --muted: 217 33% 25%; + --muted-foreground: 0 0% 65%; + --accent: 250 70% 30%; + --accent-foreground: 0 0% 95%; + --destructive: 0 62% 40%; --destructive-foreground: 0 0% 98%; - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; - --ring: 240 4.9% 83.9%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; + + --border: 217 33% 25%; + --input: 217 33% 25%; + --ring: 250 70% 60%; + + --chart-1: 250 70% 60%; + --chart-2: 142 76% 46%; + --chart-3: 38 92% 60%; + --chart-4: 199 89% 58%; + --chart-5: 0 84% 70%; } } @layer base { - body { @apply bg-background text-foreground; } + + /* Typography Scale */ + .h1 { + @apply text-4xl font-bold tracking-tight; + } + + .h2 { + @apply text-3xl font-semibold tracking-tight; + } + + .h3 { + @apply text-2xl font-semibold; + } + + .h4 { + @apply text-xl font-semibold; + } + + .body-lg { + @apply text-lg leading-relaxed; + } + + .body { + @apply text-base leading-normal; + } + + .body-sm { + @apply text-sm leading-snug; + } + + .caption { + @apply text-xs text-muted-foreground; + } } strong { @apply font-black; } +/* Modern UI Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideIn { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.animate-fadeIn { + animation: fadeIn 0.3s ease-out; +} + +.animate-slideIn { + animation: slideIn 0.3s ease-out; +} + +.animate-scaleIn { + animation: scaleIn 0.2s ease-out; +} + ::selection { diff --git a/frontend/components/shared/Card.tsx b/frontend/components/shared/Card.tsx new file mode 100644 index 0000000..33a989e --- /dev/null +++ b/frontend/components/shared/Card.tsx @@ -0,0 +1,69 @@ +import { cn } from "@/lib/utils"; + +interface CardProps { + children: React.ReactNode; + hover?: boolean; + className?: string; + onClick?: () => void; +} + +export function Card({ children, hover = false, className = "", onClick }: CardProps) { + return ( +
+ {children} +
+ ); +} + +interface CardHeaderProps { + children: React.ReactNode; + className?: string; +} + +export function CardHeader({ children, className = "" }: CardHeaderProps) { + return
{children}
; +} + +interface CardTitleProps { + children: React.ReactNode; + className?: string; +} + +export function CardTitle({ children, className = "" }: CardTitleProps) { + return

{children}

; +} + +interface CardDescriptionProps { + children: React.ReactNode; + className?: string; +} + +export function CardDescription({ children, className = "" }: CardDescriptionProps) { + return

{children}

; +} + +interface CardContentProps { + children: React.ReactNode; + className?: string; +} + +export function CardContent({ children, className = "" }: CardContentProps) { + return
{children}
; +} + +interface CardFooterProps { + children: React.ReactNode; + className?: string; +} + +export function CardFooter({ children, className = "" }: CardFooterProps) { + return
{children}
; +} diff --git a/frontend/components/shared/StatusBadge.tsx b/frontend/components/shared/StatusBadge.tsx new file mode 100644 index 0000000..5af6c40 --- /dev/null +++ b/frontend/components/shared/StatusBadge.tsx @@ -0,0 +1,34 @@ +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +type PresentationStatus = "draft" | "in_review" | "approved"; + +const STATUS_CONFIG = { + draft: { + label: "Draft", + color: "bg-gray-100 text-gray-800 border-gray-200", + }, + in_review: { + label: "In Review", + color: "bg-yellow-100 text-yellow-800 border-yellow-200", + }, + approved: { + label: "Approved", + color: "bg-green-100 text-green-800 border-green-200", + }, +} as const; + +interface StatusBadgeProps { + status: PresentationStatus; + className?: string; +} + +export function StatusBadge({ status, className = "" }: StatusBadgeProps) { + const config = STATUS_CONFIG[status] || STATUS_CONFIG.draft; + + return ( + + {config.label} + + ); +} diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx index 65d4fcd..e08beeb 100644 --- a/frontend/components/ui/button.tsx +++ b/frontend/components/ui/button.tsx @@ -5,26 +5,30 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { default: - "bg-primary text-primary-foreground shadow hover:bg-primary/90", + "bg-[hsl(var(--primary))] text-primary-foreground shadow-md hover:shadow-lg hover:bg-[hsl(var(--primary-hover))] active:scale-95", destructive: - "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + "bg-[hsl(var(--error))] text-error-foreground shadow-md hover:shadow-lg hover:bg-[hsl(var(--error))]/90 active:scale-95", outline: - "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + "border-2 border-border bg-background hover:bg-[hsl(var(--surface))] hover:border-[hsl(var(--primary))] active:scale-95", secondary: - "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", + "bg-[hsl(var(--surface))] text-foreground hover:bg-[hsl(var(--surface-hover))] shadow-sm active:scale-95", + ghost: + "hover:bg-[hsl(var(--surface))] active:scale-95", + link: + "text-[hsl(var(--primary))] underline-offset-4 hover:underline", + success: + "bg-[hsl(var(--success))] text-success-foreground shadow-md hover:shadow-lg hover:bg-[hsl(var(--success))]/90 active:scale-95", }, size: { - default: "h-9 px-4 py-2", - sm: "h-8 rounded-md px-3 text-xs", - lg: "h-10 rounded-md px-8", - icon: "h-9 w-9", + default: "h-10 px-4 py-2 text-base", + sm: "h-9 px-3 text-sm", + lg: "h-12 px-6 text-lg", + icon: "h-10 w-10", }, }, defaultVariants: { diff --git a/frontend/components/ui/hamster-loader.tsx b/frontend/components/ui/hamster-loader.tsx index ba1949f..701b49d 100644 --- a/frontend/components/ui/hamster-loader.tsx +++ b/frontend/components/ui/hamster-loader.tsx @@ -25,12 +25,12 @@ export function HamsterLoader({ size = 'md', className = '' }: HamsterLoaderProp >
-
-
-
-
-
+
+
+
+
+